forked from admin/french-vocab
feat: stabilize study flow and import experience
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import { useState, useRef } from 'react';
|
||||
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: (words: Word[]) => void;
|
||||
onImport: (payload: ImportPayload) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const normalizeWord = (input: Partial<Word>, index: number): Word | null => {
|
||||
@@ -34,134 +37,172 @@ const normalizeWord = (input: Partial<Word>, index: number): Word | null => {
|
||||
};
|
||||
};
|
||||
|
||||
const parseCsvLine = (line: string) =>
|
||||
line.split(',').map(part => part.trim().replace(/^"|"$/g, ''));
|
||||
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
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 = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
reader.onload = async (loadEvent) => {
|
||||
const text = String(loadEvent.target?.result || '');
|
||||
setContent(text);
|
||||
parseContent(text);
|
||||
await parseContent(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const parseContent = (text: string) => {
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (Array.isArray(json)) {
|
||||
const words = json.map((item, index) => normalizeWord(item, index)).filter(Boolean) as Word[];
|
||||
if (words.length > 0) {
|
||||
onImport(words);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (json.words && Array.isArray(json.words)) {
|
||||
const words = json.words.map((item: Partial<Word>, index: number) => normalizeWord(item, index)).filter(Boolean) as Word[];
|
||||
if (words.length > 0) {
|
||||
onImport(words);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallthrough to CSV
|
||||
}
|
||||
|
||||
const lines = text
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (lines.length === 0) {
|
||||
setError('没有可导入的内容');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstLine = parseCsvLine(lines[0]).map(v => v.toLowerCase());
|
||||
const hasHeader = firstLine.includes('french') || firstLine.includes('english');
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||
const headerIndex = hasHeader ? firstLine : [];
|
||||
|
||||
const words = dataLines
|
||||
.map((line, index) => {
|
||||
const parts = parseCsvLine(line);
|
||||
|
||||
if (hasHeader) {
|
||||
const item: Partial<Word> = {
|
||||
french: parts[headerIndex.indexOf('french')] || '',
|
||||
english: parts[headerIndex.indexOf('english')] || '',
|
||||
pronunciation: parts[headerIndex.indexOf('pronunciation')] || '',
|
||||
chinese: parts[headerIndex.indexOf('chinese')] || '',
|
||||
partOfSpeech: parts[headerIndex.indexOf('partofspeech')] || '',
|
||||
example: parts[headerIndex.indexOf('example')] || '',
|
||||
exampleTranslation: parts[headerIndex.indexOf('exampletranslation')] || '',
|
||||
category: parts[headerIndex.indexOf('category')] || 'General',
|
||||
difficulty: (parts[headerIndex.indexOf('difficulty')] as Word['difficulty']) || 'beginner',
|
||||
};
|
||||
return normalizeWord(item, index);
|
||||
}
|
||||
|
||||
return normalizeWord(
|
||||
{
|
||||
french: parts[0] || '',
|
||||
english: parts[1] || '',
|
||||
pronunciation: parts[2] || '',
|
||||
category: parts[3] || 'General',
|
||||
example: parts[4] || '',
|
||||
exampleTranslation: parts[5] || '',
|
||||
},
|
||||
index
|
||||
);
|
||||
})
|
||||
.filter(Boolean) as Word[];
|
||||
|
||||
if (words.length > 0) {
|
||||
onImport(words);
|
||||
onClose();
|
||||
} else {
|
||||
setError('无法解析文件格式,请检查 french 和 english 字段是否存在');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!content.trim()) {
|
||||
setError('请输入内容');
|
||||
setError('Please paste content or choose a file first.');
|
||||
return;
|
||||
}
|
||||
parseContent(content);
|
||||
|
||||
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">导入词库</h2>
|
||||
<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">
|
||||
上传文件 (CSV 或 JSON)
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">Upload a CSV or JSON file</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.json"
|
||||
onChange={handleFileUpload}
|
||||
@@ -171,22 +212,25 @@ export function ImportModal({ isOpen, onClose, onImport }: ImportModalProps) {
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200"></div>
|
||||
<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">或粘贴内容</span>
|
||||
<span className="px-2 bg-white text-gray-500">or paste content</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={`CSV 表头示例:
|
||||
french,english,chinese,pronunciation,partOfSpeech,category,example,exampleTranslation,difficulty
|
||||
bonjour,hello,你好,bɔ̃ʒuʁ,interjection,Greetings,Bonjour tout le monde.,大家好。,beginner
|
||||
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 示例:
|
||||
[{"french":"bonjour","english":"hello","chinese":"你好","pronunciation":"bɔ̃ʒuʁ","partOfSpeech":"interjection","example":"Bonjour, comment ça va ?","exampleTranslation":"你好,最近怎么样?"}]`}
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -197,13 +241,14 @@ JSON 示例:
|
||||
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}
|
||||
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={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>
|
||||
|
||||
Reference in New Issue
Block a user