380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import BulkImportProgress from '@/components/BulkImportProgress';
|
|
import ImportLayout from '@/components/layout/ImportLayout';
|
|
import Button from '@/components/ui/Button';
|
|
import { Textarea } from '@/components/ui/Input';
|
|
|
|
interface ImportResult {
|
|
url: string;
|
|
status: 'imported' | 'skipped' | 'error';
|
|
reason?: string;
|
|
title?: string;
|
|
author?: string;
|
|
error?: string;
|
|
storyId?: string;
|
|
}
|
|
|
|
interface BulkImportResponse {
|
|
results: ImportResult[];
|
|
summary: {
|
|
total: number;
|
|
imported: number;
|
|
skipped: number;
|
|
errors: number;
|
|
};
|
|
combinedStory?: {
|
|
title: string;
|
|
author: string;
|
|
content: string;
|
|
summary?: string;
|
|
sourceUrl: string;
|
|
tags?: string[];
|
|
};
|
|
}
|
|
|
|
export default function BulkImportPage() {
|
|
const router = useRouter();
|
|
const [urls, setUrls] = useState('');
|
|
const [combineIntoOne, setCombineIntoOne] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [results, setResults] = useState<BulkImportResponse | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
const [showProgress, setShowProgress] = useState(false);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!urls.trim()) {
|
|
setError('Please enter at least one URL');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
setResults(null);
|
|
|
|
try {
|
|
// Parse URLs from textarea (one per line)
|
|
const urlList = urls
|
|
.split('\n')
|
|
.map(url => url.trim())
|
|
.filter(url => url.length > 0);
|
|
|
|
if (urlList.length === 0) {
|
|
setError('Please enter at least one valid URL');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (urlList.length > 200) {
|
|
setError('Maximum 200 URLs allowed per bulk import');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Generate session ID for progress tracking
|
|
const newSessionId = `bulk-import-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
setSessionId(newSessionId);
|
|
setShowProgress(true);
|
|
|
|
// Get auth token for server-side API calls
|
|
const token = localStorage.getItem('auth-token');
|
|
|
|
const response = await fetch('/scrape/bulk', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': token ? `Bearer ${token}` : '',
|
|
},
|
|
body: JSON.stringify({ urls: urlList, combineIntoOne, sessionId: newSessionId }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Failed to start bulk import');
|
|
}
|
|
|
|
const startData = await response.json();
|
|
console.log('Bulk import started:', startData);
|
|
|
|
// The progress component will handle the rest via SSE
|
|
|
|
} catch (err) {
|
|
console.error('Bulk import error:', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to import stories');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setUrls('');
|
|
setCombineIntoOne(false);
|
|
setResults(null);
|
|
setError(null);
|
|
setSessionId(null);
|
|
setShowProgress(false);
|
|
};
|
|
|
|
const handleProgressComplete = (data?: any) => {
|
|
// Progress component will handle this when the operation completes
|
|
setShowProgress(false);
|
|
setIsLoading(false);
|
|
|
|
// Handle completion data
|
|
if (data) {
|
|
if (data.combinedStory && combineIntoOne) {
|
|
// For combine mode, redirect to import page with the combined content
|
|
localStorage.setItem('pendingStory', JSON.stringify(data.combinedStory));
|
|
router.push('/import?from=bulk-combine');
|
|
return;
|
|
} else if (data.results && data.summary) {
|
|
// For individual mode, show the results
|
|
setResults({
|
|
results: data.results,
|
|
summary: data.summary
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fallback: just hide progress and let user know it completed
|
|
console.log('Import completed successfully');
|
|
};
|
|
|
|
const handleProgressError = (errorMessage: string) => {
|
|
setError(errorMessage);
|
|
setIsLoading(false);
|
|
setShowProgress(false);
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'imported': return 'text-green-700 bg-green-50 border-green-200';
|
|
case 'skipped': return 'text-yellow-700 bg-yellow-50 border-yellow-200';
|
|
case 'error': return 'text-red-700 bg-red-50 border-red-200';
|
|
default: return 'text-gray-700 bg-gray-50 border-gray-200';
|
|
}
|
|
};
|
|
|
|
const getStatusIcon = (status: string) => {
|
|
switch (status) {
|
|
case 'imported': return '✓';
|
|
case 'skipped': return '⚠';
|
|
case 'error': return '✗';
|
|
default: return '';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ImportLayout
|
|
title="Bulk Import Stories"
|
|
description="Import multiple stories at once by providing a list of URLs"
|
|
>
|
|
|
|
{!results ? (
|
|
// Import Form
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div>
|
|
<label htmlFor="urls" className="block text-sm font-medium theme-header mb-2">
|
|
Story URLs
|
|
</label>
|
|
<p className="text-sm theme-text mb-3">
|
|
Enter one URL per line. Maximum 200 URLs per import.
|
|
</p>
|
|
<Textarea
|
|
id="urls"
|
|
value={urls}
|
|
onChange={(e) => setUrls(e.target.value)}
|
|
placeholder="https://example.com/story1
|
|
https://example.com/story2
|
|
https://example.com/story3"
|
|
rows={12}
|
|
disabled={isLoading}
|
|
/>
|
|
<p className="mt-2 text-sm theme-text">
|
|
URLs: {urls.split('\n').filter(url => url.trim().length > 0).length}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center">
|
|
<input
|
|
id="combine-into-one"
|
|
type="checkbox"
|
|
checked={combineIntoOne}
|
|
onChange={(e) => setCombineIntoOne(e.target.checked)}
|
|
className="h-4 w-4 theme-accent focus:ring-theme-accent theme-border rounded"
|
|
disabled={isLoading}
|
|
/>
|
|
<label htmlFor="combine-into-one" className="ml-2 block text-sm theme-text">
|
|
Combine all URL content into a single story
|
|
</label>
|
|
</div>
|
|
|
|
{combineIntoOne && (
|
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
|
<div className="text-sm text-blue-800 dark:text-blue-200">
|
|
<p className="font-medium mb-2">Combined Story Mode:</p>
|
|
<ul className="list-disc list-inside space-y-1 text-blue-700 dark:text-blue-300">
|
|
<li>All URLs will be scraped and their content combined into one story</li>
|
|
<li>Story title and author will be taken from the first URL</li>
|
|
<li>Import will fail if any URL has no content (title/author can be empty)</li>
|
|
<li>You'll be redirected to the story creation page to review and edit</li>
|
|
{urls.split('\n').filter(url => url.trim().length > 0).length > 50 && (
|
|
<li className="text-yellow-700 dark:text-yellow-300 font-medium">⚠️ Large imports (50+ URLs) may take several minutes and could be truncated if too large</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
<div className="flex">
|
|
<div className="ml-3">
|
|
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error</h3>
|
|
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
|
{error}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-4">
|
|
<Button
|
|
type="submit"
|
|
disabled={isLoading || !urls.trim()}
|
|
loading={isLoading}
|
|
>
|
|
{isLoading ? 'Importing...' : 'Start Import'}
|
|
</Button>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={handleReset}
|
|
disabled={isLoading}
|
|
>
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Progress Component */}
|
|
{showProgress && sessionId && (
|
|
<BulkImportProgress
|
|
sessionId={sessionId}
|
|
onComplete={handleProgressComplete}
|
|
onError={handleProgressError}
|
|
combineMode={combineIntoOne}
|
|
/>
|
|
)}
|
|
|
|
{/* Fallback loading indicator if progress isn't shown yet */}
|
|
{isLoading && !showProgress && (
|
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
|
<div className="flex items-center">
|
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-theme-accent mr-3"></div>
|
|
<div>
|
|
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">Starting import...</p>
|
|
<p className="text-sm text-blue-600 dark:text-blue-300">
|
|
Preparing to process {urls.split('\n').filter(url => url.trim().length > 0).length} URLs.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</form>
|
|
) : (
|
|
// Results
|
|
<div className="space-y-6">
|
|
{/* Summary */}
|
|
<div className="theme-card theme-shadow rounded-lg p-6">
|
|
<h2 className="text-xl font-semibold theme-header mb-4">Import Summary</h2>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold theme-header">{results.summary.total}</div>
|
|
<div className="text-sm theme-text">Total URLs</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-green-600 dark:text-green-400">{results.summary.imported}</div>
|
|
<div className="text-sm theme-text">Imported</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">{results.summary.skipped}</div>
|
|
<div className="text-sm theme-text">Skipped</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-red-600 dark:text-red-400">{results.summary.errors}</div>
|
|
<div className="text-sm theme-text">Errors</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detailed Results */}
|
|
<div className="theme-card theme-shadow rounded-lg">
|
|
<div className="px-6 py-4 border-b theme-border">
|
|
<h3 className="text-lg font-medium theme-header">Detailed Results</h3>
|
|
</div>
|
|
<div className="divide-y theme-border">
|
|
{results.results.map((result, index) => (
|
|
<div key={index} className="p-6">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${getStatusColor(result.status)}`}>
|
|
{getStatusIcon(result.status)} {result.status.charAt(0).toUpperCase() + result.status.slice(1)}
|
|
</span>
|
|
</div>
|
|
|
|
<p className="text-sm theme-header font-medium truncate mb-1">
|
|
{result.url}
|
|
</p>
|
|
|
|
{result.title && result.author && (
|
|
<p className="text-sm theme-text mb-1">
|
|
"{result.title}" by {result.author}
|
|
</p>
|
|
)}
|
|
|
|
{result.reason && (
|
|
<p className="text-sm theme-text">
|
|
{result.reason}
|
|
</p>
|
|
)}
|
|
|
|
{result.error && (
|
|
<p className="text-sm text-red-600 dark:text-red-400">
|
|
Error: {result.error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-4">
|
|
<Button onClick={handleReset}>
|
|
Import More URLs
|
|
</Button>
|
|
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => router.push('/library')}
|
|
>
|
|
View Stories
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</ImportLayout>
|
|
);
|
|
} |