Files
french-vocab/src/components/ImportModal.tsx

259 lines
8.8 KiB
TypeScript

import { useState } from 'react';
import type { ChangeEvent } from 'react';
import type { Word } from '../types/vocabulary';
export type ImportPayload = Word[] | { words: Word[]; progress?: unknown[]; stats?: unknown };
interface ImportModalProps {
isOpen: boolean;
onClose: () => void;
onImport: (payload: ImportPayload) => Promise<void> | void;
}
const normalizeWord = (input: Partial<Word>, index: number): Word | null => {
const french = input.french?.trim();
const english = input.english?.trim();
if (!french || !english) {
return null;
}
return {
id: input.id || `imported-${Date.now()}-${index}`,
french,
english,
pronunciation: input.pronunciation?.trim() || '',
ttsText: input.ttsText?.trim() || french,
chinese: input.chinese?.trim() || '',
partOfSpeech: input.partOfSpeech?.trim() || '',
example: input.example?.trim() || '',
exampleTranslation: input.exampleTranslation?.trim() || '',
examplePronunciation: input.examplePronunciation?.trim() || '',
notes: input.notes?.trim() || '',
audioUrl: input.audioUrl?.trim() || '',
category: input.category?.trim() || 'General',
difficulty: input.difficulty || 'beginner',
tags: input.tags || [],
};
};
const parseCsvLine = (line: string) => line.split(',').map((part) => part.trim().replace(/^"|"$/g, ''));
const parseTags = (value: string | undefined) =>
value
?.split(/[|;]+/)
.map((item) => item.trim())
.filter(Boolean) || [];
export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
const [content, setContent] = useState('');
const [error, setError] = useState('');
const [isImporting, setIsImporting] = useState(false);
if (!isOpen) return null;
const finishImport = async (payload: ImportPayload) => {
setError('');
setIsImporting(true);
try {
await onImport(payload);
setContent('');
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed. Please check the file format.');
} finally {
setIsImporting(false);
}
};
const parseContent = async (text: string) => {
try {
const json = JSON.parse(text);
if (Array.isArray(json)) {
const words = json
.map((item, index) => normalizeWord(item as Partial<Word>, index))
.filter(Boolean) as Word[];
if (words.length === 0) {
throw new Error('The JSON word list is empty.');
}
await finishImport(words);
return;
}
if (json && typeof json === 'object' && Array.isArray((json as { words?: unknown[] }).words)) {
const backup = json as { words: Partial<Word>[]; progress?: unknown[]; stats?: unknown };
const words = backup.words
.map((item, index) => normalizeWord(item, index))
.filter(Boolean) as Word[];
if (words.length === 0) {
throw new Error('The backup does not contain any valid words.');
}
await finishImport({
words,
progress: Array.isArray(backup.progress) ? backup.progress : undefined,
stats: backup.stats,
});
return;
}
throw new Error('Unsupported JSON structure.');
} catch (jsonError) {
const lines = text
.trim()
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
throw jsonError instanceof Error ? jsonError : new Error('There is no importable content.');
}
const header = parseCsvLine(lines[0]).map((value) => value.toLowerCase());
const hasHeader = header.includes('french') || header.includes('english');
const dataLines = hasHeader ? lines.slice(1) : lines;
const words = dataLines
.map((line, index) => {
const parts = parseCsvLine(line);
if (hasHeader) {
return normalizeWord(
{
french: parts[header.indexOf('french')] || '',
english: parts[header.indexOf('english')] || '',
chinese: parts[header.indexOf('chinese')] || '',
pronunciation: parts[header.indexOf('pronunciation')] || '',
ttsText: parts[header.indexOf('ttstext')] || '',
partOfSpeech: parts[header.indexOf('partofspeech')] || '',
category: parts[header.indexOf('category')] || 'General',
example: parts[header.indexOf('example')] || '',
exampleTranslation: parts[header.indexOf('exampletranslation')] || '',
examplePronunciation: parts[header.indexOf('examplepronunciation')] || '',
notes: parts[header.indexOf('notes')] || '',
audioUrl: parts[header.indexOf('audiourl')] || '',
difficulty: (parts[header.indexOf('difficulty')] as Word['difficulty']) || 'beginner',
tags: parseTags(parts[header.indexOf('tags')]),
},
index,
);
}
return normalizeWord(
{
french: parts[0] || '',
english: parts[1] || '',
pronunciation: parts[2] || '',
category: parts[3] || 'General',
difficulty: (parts[4] as Word['difficulty']) || 'beginner',
chinese: parts[5] || '',
example: parts[6] || '',
exampleTranslation: parts[7] || '',
tags: parseTags(parts[8]),
},
index,
);
})
.filter(Boolean) as Word[];
if (words.length === 0) {
throw jsonError instanceof Error ? jsonError : new Error('Unable to parse the provided content.');
}
await finishImport(words);
}
};
const handleFileUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (loadEvent) => {
const text = String(loadEvent.target?.result || '');
setContent(text);
await parseContent(text);
};
reader.readAsText(file);
};
const handleSubmit = async () => {
if (!content.trim()) {
setError('Please paste content or choose a file first.');
return;
}
await parseContent(content);
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl shadow-2xl max-w-2xl w-full p-6">
<h2 className="text-2xl font-bold text-gray-800 mb-2">Import vocabulary</h2>
<p className="text-sm text-gray-500 mb-4">
Supports CSV, JSON word lists, and full backup files with words, progress, and stats.
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-600 mb-2">Upload a CSV or JSON file</label>
<input
type="file"
accept=".csv,.json"
onChange={handleFileUpload}
className="w-full px-4 py-2 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">or paste content</span>
</div>
</div>
<textarea
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder={`CSV header example:
french,english,chinese,pronunciation,partOfSpeech,category,example,exampleTranslation,difficulty,tags
bonjour,hello,hello,bɔ̃ʒuʁ,interjection,Greetings,Bonjour tout le monde.,Hello everyone.,beginner,daily|greeting
JSON word list:
[{"french":"bonjour","english":"hello","category":"Greetings"}]
Full backup:
{"words":[...],"progress":[...],"stats":{...}}`}
className="w-full h-56 px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
/>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex gap-3 pt-2">
<button
onClick={onClose}
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl text-gray-600 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isImporting}
className="flex-1 px-4 py-3 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl hover:shadow-lg transition-all disabled:opacity-60"
>
{isImporting ? 'Importing...' : 'Import'}
</button>
</div>
</div>
</div>
</div>
);
}