restructuring

This commit is contained in:
Stefan Hardegger
2025-08-11 14:40:56 +02:00
parent 51e3d20c24
commit 3b22d155db
12 changed files with 1518 additions and 1365 deletions

View File

@@ -1,395 +1,20 @@
'use client';
import { useState } from 'react';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import BulkImportProgress from '@/components/BulkImportProgress';
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() {
export default function BulkImportRedirectPage() {
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 add story page with the combined content
localStorage.setItem('pendingStory', JSON.stringify(data.combinedStory));
router.push('/add-story?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 '';
}
};
useEffect(() => {
router.replace('/import/bulk');
}, [router]);
return (
<div className="container mx-auto px-4 py-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-4">
<Link
href="/library"
className="inline-flex items-center text-blue-600 hover:text-blue-800"
>
<ArrowLeftIcon className="h-4 w-4 mr-1" />
Back to Library
</Link>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Bulk Import Stories</h1>
<p className="text-gray-600">
Import multiple stories at once by providing a list of URLs. Each URL will be scraped
and automatically added to your story collection.
</p>
</div>
{!results ? (
// Import Form
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="urls" className="block text-sm font-medium text-gray-700 mb-2">
Story URLs
</label>
<p className="text-sm text-gray-500 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&#10;https://example.com/story2&#10;https://example.com/story3"
className="w-full h-64 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isLoading}
/>
<p className="mt-2 text-sm text-gray-500">
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 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
disabled={isLoading}
/>
<label htmlFor="combine-into-one" className="ml-2 block text-sm text-gray-700">
Combine all URL content into a single story
</label>
</div>
{combineIntoOne && (
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<div className="text-sm text-blue-800">
<p className="font-medium mb-2">Combined Story Mode:</p>
<ul className="list-disc list-inside space-y-1 text-blue-700">
<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 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 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error</h3>
<div className="mt-2 text-sm text-red-700">
{error}
</div>
</div>
</div>
</div>
)}
<div className="flex gap-4">
<button
type="submit"
disabled={isLoading || !urls.trim()}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Importing...' : 'Start Import'}
</button>
<button
type="button"
onClick={handleReset}
disabled={isLoading}
className="px-6 py-2 bg-gray-600 text-white font-medium rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
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 border border-blue-200 rounded-md p-4">
<div className="flex items-center">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600 mr-3"></div>
<div>
<p className="text-sm font-medium text-blue-800">Starting import...</p>
<p className="text-sm text-blue-600">
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="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-900 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 text-gray-900">{results.summary.total}</div>
<div className="text-sm text-gray-600">Total URLs</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{results.summary.imported}</div>
<div className="text-sm text-gray-600">Imported</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-600">{results.summary.skipped}</div>
<div className="text-sm text-gray-600">Skipped</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">{results.summary.errors}</div>
<div className="text-sm text-gray-600">Errors</div>
</div>
</div>
</div>
{/* Detailed Results */}
<div className="bg-white border border-gray-200 rounded-lg">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Detailed Results</h3>
</div>
<div className="divide-y divide-gray-200">
{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 text-gray-900 font-medium truncate mb-1">
{result.url}
</p>
{result.title && result.author && (
<p className="text-sm text-gray-600 mb-1">
"{result.title}" by {result.author}
</p>
)}
{result.reason && (
<p className="text-sm text-gray-500">
{result.reason}
</p>
)}
{result.error && (
<p className="text-sm text-red-600">
Error: {result.error}
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Actions */}
<div className="flex gap-4">
<button
onClick={handleReset}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Import More URLs
</button>
<Link
href="/library"
className="px-6 py-2 bg-gray-600 text-white font-medium rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
View Stories
</Link>
</div>
</div>
)}
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Redirecting...</p>
</div>
</div>
);

View File

@@ -1,432 +1,21 @@
'use client';
import { useState } from 'react';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon, DocumentArrowUpIcon } from '@heroicons/react/24/outline';
import Button from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
interface EPUBImportResponse {
success: boolean;
message: string;
storyId?: string;
storyTitle?: string;
totalChapters?: number;
wordCount?: number;
readingPosition?: any;
warnings?: string[];
errors?: string[];
}
export default function EPUBImportPage() {
export default function EpubImportRedirectPage() {
const router = useRouter();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [validationResult, setValidationResult] = useState<any>(null);
const [importResult, setImportResult] = useState<EPUBImportResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Import options
const [authorName, setAuthorName] = useState<string>('');
const [seriesName, setSeriesName] = useState<string>('');
const [seriesVolume, setSeriesVolume] = useState<string>('');
const [tags, setTags] = useState<string>('');
const [preserveReadingPosition, setPreserveReadingPosition] = useState(true);
const [overwriteExisting, setOverwriteExisting] = useState(false);
const [createMissingAuthor, setCreateMissingAuthor] = useState(true);
const [createMissingSeries, setCreateMissingSeries] = useState(true);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
setValidationResult(null);
setImportResult(null);
setError(null);
if (file.name.toLowerCase().endsWith('.epub')) {
await validateFile(file);
} else {
setError('Please select a valid EPUB file (.epub extension)');
}
}
};
const validateFile = async (file: File) => {
setIsValidating(true);
try {
const token = localStorage.getItem('auth-token');
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/stories/epub/validate', {
method: 'POST',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
},
body: formData,
});
if (response.ok) {
const result = await response.json();
setValidationResult(result);
if (!result.valid) {
setError('EPUB file validation failed: ' + result.errors.join(', '));
}
} else if (response.status === 401 || response.status === 403) {
setError('Authentication required. Please log in.');
} else {
setError('Failed to validate EPUB file');
}
} catch (err) {
setError('Error validating EPUB file: ' + (err as Error).message);
} finally {
setIsValidating(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedFile) {
setError('Please select an EPUB file');
return;
}
if (validationResult && !validationResult.valid) {
setError('Cannot import invalid EPUB file');
return;
}
setIsLoading(true);
setError(null);
try {
const token = localStorage.getItem('auth-token');
const formData = new FormData();
formData.append('file', selectedFile);
if (authorName) formData.append('authorName', authorName);
if (seriesName) formData.append('seriesName', seriesName);
if (seriesVolume) formData.append('seriesVolume', seriesVolume);
if (tags) {
const tagList = tags.split(',').map(t => t.trim()).filter(t => t.length > 0);
tagList.forEach(tag => formData.append('tags', tag));
}
formData.append('preserveReadingPosition', preserveReadingPosition.toString());
formData.append('overwriteExisting', overwriteExisting.toString());
formData.append('createMissingAuthor', createMissingAuthor.toString());
formData.append('createMissingSeries', createMissingSeries.toString());
const response = await fetch('/api/stories/epub/import', {
method: 'POST',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
},
body: formData,
});
const result = await response.json();
if (response.ok && result.success) {
setImportResult(result);
} else if (response.status === 401 || response.status === 403) {
setError('Authentication required. Please log in.');
} else {
setError(result.message || 'Failed to import EPUB');
}
} catch (err) {
setError('Error importing EPUB: ' + (err as Error).message);
} finally {
setIsLoading(false);
}
};
const resetForm = () => {
setSelectedFile(null);
setValidationResult(null);
setImportResult(null);
setError(null);
setAuthorName('');
setSeriesName('');
setSeriesVolume('');
setTags('');
};
if (importResult?.success) {
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<Link
href="/library"
className="inline-flex items-center text-blue-600 hover:text-blue-800 mb-4"
>
<ArrowLeftIcon className="h-4 w-4 mr-2" />
Back to Library
</Link>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
EPUB Import Successful
</h1>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-green-600 mb-2">Import Completed</h2>
<p className="text-gray-600 dark:text-gray-300">
Your EPUB has been successfully imported into StoryCove.
</p>
</div>
<div>
<div className="space-y-4">
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Story Title:</span>
<p className="text-gray-900 dark:text-white">{importResult.storyTitle}</p>
</div>
{importResult.wordCount && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Word Count:</span>
<p className="text-gray-900 dark:text-white">{importResult.wordCount.toLocaleString()} words</p>
</div>
)}
{importResult.totalChapters && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Chapters:</span>
<p className="text-gray-900 dark:text-white">{importResult.totalChapters}</p>
</div>
)}
{importResult.warnings && importResult.warnings.length > 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<strong className="text-yellow-800">Warnings:</strong>
<ul className="list-disc list-inside mt-2 text-yellow-700">
{importResult.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
<div className="flex gap-4 mt-6">
<Button
onClick={() => router.push(`/stories/${importResult.storyId}`)}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
View Story
</Button>
<Button
onClick={resetForm}
variant="secondary"
>
Import Another EPUB
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
useEffect(() => {
router.replace('/import/epub');
}, [router]);
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<Link
href="/library"
className="inline-flex items-center text-blue-600 hover:text-blue-800 mb-4"
>
<ArrowLeftIcon className="h-4 w-4 mr-2" />
Back to Library
</Link>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Import EPUB
</h1>
<p className="text-gray-600 dark:text-gray-300 mt-2">
Upload an EPUB file to import it as a story into your library.
</p>
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Redirecting...</p>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<p className="text-red-700">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* File Upload */}
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<div className="mb-4">
<h3 className="text-lg font-semibold mb-2">Select EPUB File</h3>
<p className="text-gray-600 dark:text-gray-300">
Choose an EPUB file from your device to import.
</p>
</div>
<div className="space-y-4">
<div>
<label htmlFor="epub-file" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">EPUB File</label>
<Input
id="epub-file"
type="file"
accept=".epub,application/epub+zip"
onChange={handleFileChange}
disabled={isLoading || isValidating}
/>
</div>
{selectedFile && (
<div className="flex items-center gap-2">
<DocumentArrowUpIcon className="h-5 w-5" />
<span className="text-sm text-gray-600">
{selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
</span>
</div>
)}
{isValidating && (
<div className="text-sm text-blue-600">
Validating EPUB file...
</div>
)}
{validationResult && (
<div className="text-sm">
{validationResult.valid ? (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800">
Valid EPUB
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800">
Invalid EPUB
</span>
)}
</div>
)}
</div>
</div>
{/* Import Options */}
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<div className="mb-4">
<h3 className="text-lg font-semibold mb-2">Import Options</h3>
<p className="text-gray-600 dark:text-gray-300">
Configure how the EPUB should be imported.
</p>
</div>
<div className="space-y-4">
<div>
<label htmlFor="author-name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Author Name (Override)</label>
<Input
id="author-name"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
placeholder="Leave empty to use EPUB metadata"
/>
</div>
<div>
<label htmlFor="series-name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Series Name</label>
<Input
id="series-name"
value={seriesName}
onChange={(e) => setSeriesName(e.target.value)}
placeholder="Optional: Add to a series"
/>
</div>
{seriesName && (
<div>
<label htmlFor="series-volume" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Series Volume</label>
<Input
id="series-volume"
type="number"
value={seriesVolume}
onChange={(e) => setSeriesVolume(e.target.value)}
placeholder="Volume number in series"
/>
</div>
)}
<div>
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tags</label>
<Input
id="tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="Comma-separated tags (e.g., fantasy, adventure, romance)"
/>
</div>
<div className="space-y-3">
<div className="flex items-center">
<input
type="checkbox"
id="preserve-reading-position"
checked={preserveReadingPosition}
onChange={(e) => setPreserveReadingPosition(e.target.checked)}
className="mr-2"
/>
<label htmlFor="preserve-reading-position" className="text-sm text-gray-700 dark:text-gray-300">
Preserve reading position from EPUB metadata
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="create-missing-author"
checked={createMissingAuthor}
onChange={(e) => setCreateMissingAuthor(e.target.checked)}
className="mr-2"
/>
<label htmlFor="create-missing-author" className="text-sm text-gray-700 dark:text-gray-300">
Create author if not found
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="create-missing-series"
checked={createMissingSeries}
onChange={(e) => setCreateMissingSeries(e.target.checked)}
className="mr-2"
/>
<label htmlFor="create-missing-series" className="text-sm text-gray-700 dark:text-gray-300">
Create series if not found
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="overwrite-existing"
checked={overwriteExisting}
onChange={(e) => setOverwriteExisting(e.target.checked)}
className="mr-2"
/>
<label htmlFor="overwrite-existing" className="text-sm text-gray-700 dark:text-gray-300">
Overwrite existing story with same title and author
</label>
</div>
</div>
</div>
</div>
{/* Submit Button */}
<div className="flex justify-end">
<Button
type="submit"
disabled={!selectedFile || isLoading || isValidating || (validationResult && !validationResult.valid)}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{isLoading ? 'Importing...' : 'Import EPUB'}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,21 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function ImportRedirectPage() {
const router = useRouter();
useEffect(() => {
router.replace('/import');
}, [router]);
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Redirecting...</p>
</div>
</div>
);
}