添加动词拼接学习页

整合动词拼接沙盒,并扩充常见复合动词词库,方便在歌词学习之外进行动词组合练习。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
TU
2026-07-20 11:45:01 +08:00
parent bf127782f4
commit 550b5743f6
9 changed files with 2292 additions and 120 deletions
+24 -21
View File
@@ -10,6 +10,7 @@ import { LineItem } from './components/LineItem';
import { Sidebar, SavedDocumentNote } from './components/Sidebar';
import { AppNav, AppView } from './components/AppNav';
import { VocabCards } from './components/VocabCards';
import { CompoundVerbLab } from './components/CompoundVerbLab';
import { adjustFuriganaItemsForTextChange, normalizeLine, normalizeLines } from './utils';
import { apiUrl } from './api';
import { Check, Copy, Plus, X } from 'lucide-react';
@@ -980,7 +981,7 @@ export default function App() {
{saveNotice && (
<div className="fixed top-4 left-1/2 z-50 -translate-x-1/2 pointer-events-none">
<div
className={`rounded-full border px-4 py-1.5 text-xs font-semibold shadow-sm ${
className={`border px-4 py-1.5 text-xs font-semibold ${
saveNotice.type === 'success'
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-rose-200 bg-rose-50 text-rose-700'
@@ -1007,8 +1008,8 @@ export default function App() {
<AppNav activeView={activeView} onChange={setActiveView} />
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{activeView === 'lyrics' && showSavedArea && (
<div className="fixed left-1/2 top-20 z-40 w-[calc(100vw-1rem)] max-w-2xl -translate-x-1/2 text-xs select-none">
<div className="bg-[#FDFCFB] border border-[#E8E4DE] rounded-xl p-2 shadow-xl animate-in fade-in duration-200">
<div className="fixed left-1/2 top-16 z-40 w-[calc(100vw-1rem)] max-w-2xl -translate-x-1/2 text-xs select-none">
<div className="bg-[#FDFCFB] border border-[#E8E4DE] p-1 animate-in fade-in duration-200">
{serverSaveFiles.length > 0 ? (
<div className="space-y-1.5 max-h-[min(62vh,420px)] overflow-y-auto">
{serverSaveFiles.map((file) => (
@@ -1016,7 +1017,7 @@ export default function App() {
key={file.name}
type="button"
onClick={() => handleLoadServerSave(file)}
className="w-full text-left bg-white hover:bg-[#FAF9F6] active:bg-[#E8E4DE]/30 border border-[#E8E4DE]/70 hover:border-[#8B7E74]/40 rounded-lg px-3 py-2.5 transition cursor-pointer"
className="w-full text-left bg-white hover:bg-[#FAF9F6] active:bg-[#E8E4DE]/30 border-b border-[#E8E4DE]/70 px-3 py-2 transition cursor-pointer last:border-b-0"
>
<div className="min-w-0">
<div className="font-semibold text-[#2D2926] truncate">{file.title}</div>
@@ -1025,7 +1026,7 @@ export default function App() {
))}
</div>
) : (
<div className="py-6 text-center text-[#A69F92] bg-white border border-dashed border-[#E8E4DE] rounded-lg">
<div className="py-6 text-center text-[#A69F92] bg-white border border-dashed border-[#E8E4DE]">
</div>
)}
@@ -1036,9 +1037,9 @@ export default function App() {
{activeView === 'lyrics' ? (
<div className="flex-1 flex overflow-hidden">
{/* LEFT COMPARTMENT: The writing sheet */}
<div className="flex-1 overflow-y-auto px-1 py-1 md:px-4 md:py-4 flex flex-col items-center bg-[#FAF9F6]">
<div className="flex-1 overflow-y-auto px-0 py-0 md:px-2 md:py-2 flex flex-col items-center bg-[#FAF9F6]">
<div
className="w-full bg-[#FDFCFB] premium-shadow min-h-[85vh] h-auto flex-shrink-0 rounded-xl border border-[#E8E4DE] px-1 py-2 sm:p-5 md:p-8 relative flex flex-col animate-fadeIn overflow-x-hidden md:overflow-x-auto"
className="w-full bg-[#FDFCFB] min-h-[85vh] h-auto flex-shrink-0 px-1 py-2 sm:p-4 md:p-6 relative flex flex-col animate-fadeIn overflow-x-hidden md:overflow-x-auto"
>
<div className="mt-1 mb-3 md:mt-2 md:mb-4 flex justify-center">
<input
@@ -1057,10 +1058,10 @@ export default function App() {
</div>
{showImportArea && (
<div className="mt-1 mb-3 text-xs select-none relative z-20 bg-[#FDFCFB] border border-[#E8E4DE] rounded-xl p-4 shadow-sm animate-in fade-in duration-200">
<div className="mt-1 mb-3 text-xs select-none relative z-20 bg-[#FDFCFB] border-y border-[#E8E4DE] p-3 animate-in fade-in duration-200">
<p className="text-[11px] text-[#A69F92] mb-3 leading-relaxed">
<code className="bg-[#FAF9F6] px-1 py-0.5 mx-1 text-[#8B7E74] font-mono rounded">
<code className="bg-[#FAF9F6] px-1 py-0.5 mx-1 text-[#8B7E74] font-mono">
&#123;&#125;
</code>
@@ -1070,7 +1071,7 @@ export default function App() {
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder="在此粘贴整段文本,每一行会变成写字板的一行。"
className="w-full text-xs font-mono bg-[#FAF9F6] border border-[#E8E4DE] rounded-lg p-3 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] text-[#2D2926] placeholder-[#A69F92]/60 resize-y"
className="w-full text-xs font-mono bg-[#FAF9F6] border border-[#E8E4DE] p-2.5 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] text-[#2D2926] placeholder-[#A69F92]/60 resize-y"
/>
<div className="mt-3.5 flex flex-wrap items-center justify-between gap-3 border-t border-[#E8E4DE]/40 pt-3">
<div className="flex flex-wrap items-center gap-4 text-[11px]">
@@ -1102,13 +1103,13 @@ export default function App() {
setImportText('');
setShowImportArea(false);
}}
className="px-3 py-1.5 bg-white border border-[#E8E4DE] hover:bg-[#FAF9F6] text-[#A69F92] hover:text-[#2D2926] font-semibold rounded-lg transition"
className="px-3 py-1.5 bg-white border border-[#E8E4DE] hover:bg-[#FAF9F6] text-[#A69F92] hover:text-[#2D2926] font-semibold transition"
>
</button>
<button
onClick={handleBulkImport}
className="px-4 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-bold rounded-lg transition shadow-2xs hover:shadow-xs disabled:opacity-40 disabled:cursor-not-allowed"
className="px-4 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-bold transition disabled:opacity-40 disabled:cursor-not-allowed"
disabled={!importText.trim()}
>
@@ -1128,7 +1129,7 @@ export default function App() {
}
}}>
<div
className="mx-auto w-full max-w-none flex-1 flex flex-col divide-y divide-[#E8E4DE]/30 animate-fadeIn rounded-xl border-x border-[#8B7E74]/10 bg-[#FDFCFB]/70 shadow-[inset_4px_0_10px_-10px_rgba(139,126,116,0.45),inset_-4px_0_10px_-10px_rgba(139,126,116,0.45)] md:border-x-0 md:bg-transparent md:shadow-none"
className="mx-auto w-full max-w-none flex-1 flex flex-col divide-y divide-[#E8E4DE]/30 animate-fadeIn bg-[#FDFCFB]/70 md:bg-transparent"
style={paperBackgroundStyle}
>
{lines.map((line, idx) => (
@@ -1216,22 +1217,24 @@ export default function App() {
/>
</div>
</div>
) : (
) : activeView === 'vocab' ? (
<VocabCards />
) : (
<CompoundVerbLab />
)}
</main>
</div>
{/* Floating Sticky Drawer for Mobile / Tablet screens which normally hides the Sidebar */}
{activeView === 'lyrics' && currentSelection && (
<div id="mobile-annotations-drawer" className="lg:hidden fixed bottom-0 left-0 right-0 max-h-[70vh] bg-[#FDFCFB] border-t border-[#E8E4DE] shadow-2xl z-50 flex flex-col px-3 pt-2 pb-3 overflow-y-auto animate-in slide-in-from-bottom">
<div id="mobile-annotations-drawer" className="lg:hidden fixed bottom-0 left-0 right-0 max-h-[70vh] bg-[#FDFCFB] border-t border-[#E8E4DE] z-50 flex flex-col px-3 pt-2 pb-3 overflow-y-auto animate-in slide-in-from-bottom">
<button
onClick={() => {
window.getSelection()?.removeAllRanges();
setCurrentSelection(null);
setFocusedNoteId(null);
}}
className="absolute right-2 top-1.5 text-[#A69F92] hover:text-[#2D2926] p-1 rounded-full hover:bg-[#FAF9F6] cursor-pointer"
className="absolute right-2 top-1.5 text-[#A69F92] hover:text-[#2D2926] p-1 hover:bg-[#FAF9F6] cursor-pointer"
>
<X className="w-5 h-5" />
</button>
@@ -1251,7 +1254,7 @@ export default function App() {
showTransientSaveNotice('复制失败', 'error');
}
}}
className="shrink-0 flex items-center gap-1 rounded-md border border-[#E8E4DE] bg-white px-2 py-1 text-[11px] font-semibold text-[#8B7E74] hover:bg-[#FAF9F6] active:bg-[#E8E4DE]/40"
className="shrink-0 flex items-center gap-1 border border-[#E8E4DE] bg-white px-2 py-1 text-[11px] font-semibold text-[#8B7E74] hover:bg-[#FAF9F6] active:bg-[#E8E4DE]/40"
title="复制选中文本"
>
<Copy className="w-3.5 h-3.5" />
@@ -1289,7 +1292,7 @@ export default function App() {
rows={4}
id="comment-textarea-mobile-existing"
defaultValue={mobileSelectedNote.comment}
className="w-full text-sm bg-white border border-[#E8E4DE] rounded p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] text-[#2D2926] leading-relaxed"
className="w-full text-sm bg-white border border-[#E8E4DE] p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] text-[#2D2926] leading-relaxed"
/>
<button
onClick={() => {
@@ -1308,7 +1311,7 @@ export default function App() {
setFocusedNoteId(null);
}
}}
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs rounded text-center hover:bg-[#786C63] transition cursor-pointer"
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs text-center hover:bg-[#786C63] transition cursor-pointer"
>
</button>
@@ -1341,7 +1344,7 @@ export default function App() {
rows={3}
id="comment-textarea-mobile"
placeholder="在此为选中字词记录您的日语随笔、语法释义..."
className="w-full text-xs bg-white border border-[#E8E4DE] rounded p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] mb-2 text-[#2D2926]"
className="w-full text-xs bg-white border border-[#E8E4DE] p-2 focus:outline-none focus:ring-1 focus:ring-[#8B7E74] mb-2 text-[#2D2926]"
/>
<button
onClick={() => {
@@ -1353,7 +1356,7 @@ export default function App() {
setCurrentSelection(null);
}
}}
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs rounded text-center hover:bg-[#786C63] transition cursor-pointer"
className="w-full py-2 bg-[#8B7E74] text-white font-semibold text-xs text-center hover:bg-[#786C63] transition cursor-pointer"
>
</button>
+13 -12
View File
@@ -4,9 +4,9 @@
*/
import React from 'react';
import { BookOpen, Layers3 } from 'lucide-react';
import { BookOpen, GitMerge, Layers3 } from 'lucide-react';
export type AppView = 'lyrics' | 'vocab';
export type AppView = 'lyrics' | 'vocab' | 'compound';
interface AppNavProps {
activeView: AppView;
@@ -16,12 +16,13 @@ interface AppNavProps {
const NAV_ITEMS = [
{ id: 'lyrics' as const, label: '歌词', icon: BookOpen },
{ id: 'vocab' as const, label: '单词卡片', icon: Layers3 },
{ id: 'compound' as const, label: '动词拼接', icon: GitMerge },
];
export const AppNav: React.FC<AppNavProps> = ({ activeView, onChange }) => {
return (
<>
<nav className="hidden lg:flex w-28 shrink-0 flex-col gap-2 border-r border-[#E8E4DE] bg-[#FDFCFB] p-2 shadow-[8px_0_24px_-24px_rgba(45,41,38,0.45)]">
<nav className="hidden lg:flex w-28 shrink-0 flex-col gap-1 border-r border-[#E8E4DE] bg-[#F5F1EA] p-2">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeView === item.id;
@@ -30,20 +31,20 @@ export const AppNav: React.FC<AppNavProps> = ({ activeView, onChange }) => {
key={item.id}
type="button"
onClick={() => onChange(item.id)}
className={`flex w-full flex-col items-center gap-1 rounded-lg px-2 py-2 text-xs font-semibold transition ${
className={`flex w-full flex-col items-center gap-1 rounded-xl px-2 py-3 text-xs font-semibold transition-colors ${
isActive
? 'bg-[#8B7E74] text-white'
: 'bg-white text-[#8B7E74] hover:bg-[#FAF9F6] hover:text-[#2D2926]'
? 'bg-[#FDFCFB] text-[#2D2926]'
: 'text-[#8B7E74] hover:bg-[#EFECE7] hover:text-[#2D2926]'
}`}
>
<Icon className={`h-4 w-4 ${isActive ? 'text-white' : 'text-[#8B7E74]'}`} />
<Icon className={`h-4 w-4 ${isActive ? 'text-[#2D2926]' : 'text-[#8B7E74]'}`} />
{item.label}
</button>
);
})}
</nav>
<nav className="order-last grid shrink-0 grid-cols-2 border-t border-[#E8E4DE] bg-[#FDFCFB] px-3 py-2 shadow-[0_-8px_24px_-18px_rgba(45,41,38,0.4)] lg:hidden">
<nav className="order-last grid shrink-0 grid-cols-3 gap-2 border-t border-[#E8E4DE] bg-[#F5F1EA] p-2 lg:hidden">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = activeView === item.id;
@@ -52,13 +53,13 @@ export const AppNav: React.FC<AppNavProps> = ({ activeView, onChange }) => {
key={item.id}
type="button"
onClick={() => onChange(item.id)}
className={`mx-1 flex items-center justify-center gap-2 rounded-lg py-2 text-xs font-semibold transition ${
className={`flex items-center justify-center gap-2 rounded-xl py-2 text-xs font-semibold transition-colors ${
isActive
? 'bg-[#8B7E74] text-white'
: 'bg-white text-[#8B7E74] border border-[#E8E4DE]'
? 'bg-[#FDFCFB] text-[#2D2926]'
: 'text-[#8B7E74]'
}`}
>
<Icon className={`h-4 w-4 ${isActive ? 'text-white' : 'text-[#8B7E74]'}`} />
<Icon className={`h-4 w-4 ${isActive ? 'text-[#2D2926]' : 'text-[#8B7E74]'}`} />
{item.label}
</button>
);
+450
View File
@@ -0,0 +1,450 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useMemo, useState } from 'react';
import { Bookmark, BookmarkCheck, CheckCircle, HelpCircle, RotateCcw, Volume2 } from 'lucide-react';
import { CompoundVerb, SuffixVerb, Verb } from '../features/compoundVerbs/types';
import {
COMPOUND_VERBS,
PREFIX_VERBS,
SUFFIX_VERBS,
VALID_COMBINATIONS_MAP,
} from '../features/compoundVerbs/verbsData';
type FusionState = 'idle' | 'success' | 'failed';
const SAVED_COMPOUND_VERBS_KEY = 'kotonoha_compound_verbs_saved_ids';
const getDanRow = (char: string) => {
const rows: Record<string, string> = {
: 'う',
: 'く',
: 'ぐ',
: 'す',
: 'つ',
: 'ぬ',
: 'ぶ',
: 'む',
: 'う',
};
return rows[char] || 'う';
};
const conjugationRule = (verb: Verb) => {
const lastChar = verb.dictionaryForm.slice(-1);
if (verb.dictionaryForm === 'する') {
return '不规则动词:する 变为连用形「し」。';
}
if (verb.dictionaryForm === '来る') {
return '不规则动词:来る(くる)变为连用形「来」(き)。';
}
if (
verb.dictionaryForm.endsWith('る') &&
['落ちる', '食べる', '信じる', '疲れる', '逃げる', '生きる', '見る', '考える', '助ける', '分ける', '鍛える', '燃える'].includes(
verb.dictionaryForm
)
) {
return '二类动词(一段动词):去掉词尾「る」,直接拼接。';
}
return `一类动词(五段动词):词尾「${lastChar}」属于「${getDanRow(lastChar)}段」,变为对应い段后拼接。`;
};
const speakJapanese = (text: string) => {
if (!('speechSynthesis' in window)) return;
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'ja-JP';
utterance.rate = 0.86;
const voices = window.speechSynthesis.getVoices();
const jaVoice = voices.find((voice) => voice.lang.includes('ja') || voice.lang.includes('JP'));
if (jaVoice) utterance.voice = jaVoice;
window.speechSynthesis.speak(utterance);
};
export const CompoundVerbLab: React.FC = () => {
const [selectedPrefix, setSelectedPrefix] = useState<Verb | null>(null);
const [selectedSuffix, setSelectedSuffix] = useState<SuffixVerb | null>(null);
const [fusionState, setFusionState] = useState<FusionState>('idle');
const [foundVerb, setFoundVerb] = useState<CompoundVerb | null>(null);
const [savedVerbIds, setSavedVerbIds] = useState<string[]>([]);
useEffect(() => {
try {
const saved = localStorage.getItem(SAVED_COMPOUND_VERBS_KEY);
if (saved) setSavedVerbIds(JSON.parse(saved));
} catch (error) {
console.error('Error loading saved compound verbs', error);
}
}, []);
const recommendedSuffixes = useMemo(() => {
if (!selectedPrefix) return [];
return SUFFIX_VERBS.filter((suffix) => {
const key = `${selectedPrefix.dictionaryForm}-${suffix.id}`;
return VALID_COMBINATIONS_MAP[key]?.length > 0;
});
}, [selectedPrefix]);
const recommendedPrefixes = useMemo(() => {
if (!selectedSuffix) return [];
return PREFIX_VERBS.filter((prefix) => {
const key = `${prefix.dictionaryForm}-${selectedSuffix.id}`;
return VALID_COMBINATIONS_MAP[key]?.length > 0;
});
}, [selectedSuffix]);
const toggleSaveVerb = (id: string) => {
const next = savedVerbIds.includes(id)
? savedVerbIds.filter((verbId) => verbId !== id)
: [...savedVerbIds, id];
setSavedVerbIds(next);
localStorage.setItem(SAVED_COMPOUND_VERBS_KEY, JSON.stringify(next));
};
const resolveFusion = (prefix: Verb | null, suffix: SuffixVerb | null) => {
if (!prefix || !suffix) {
setFusionState('idle');
setFoundVerb(null);
return;
}
const comboKey = `${prefix.dictionaryForm}-${suffix.id}`;
const foundIds = VALID_COMBINATIONS_MAP[comboKey] || [];
const verb = COMPOUND_VERBS.find((item) => item.id === foundIds[0]) || null;
setFoundVerb(verb);
setFusionState(verb ? 'success' : 'failed');
if (verb) speakJapanese(verb.kanji);
};
const selectPrefix = (prefix: Verb) => {
setSelectedPrefix(prefix);
resolveFusion(prefix, selectedSuffix);
};
const selectSuffix = (suffix: SuffixVerb) => {
setSelectedSuffix(suffix);
resolveFusion(selectedPrefix, suffix);
};
const reset = () => {
setSelectedPrefix(null);
setSelectedSuffix(null);
setFusionState('idle');
setFoundVerb(null);
};
return (
<div className="flex-1 overflow-y-auto bg-[#F5F1EA] px-3 py-4 lg:px-8 lg:py-6">
<div className="mx-auto flex max-w-6xl flex-col gap-4">
<header className="flex flex-col gap-1 px-1">
<h2 className="font-serif text-[clamp(1.7rem,3vw,2.4rem)] font-bold leading-tight tracking-wide text-[#2D2926]">
</h2>
<p className="text-sm leading-6 text-[#8B7E74]">
</p>
</header>
<section className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="grid gap-3 md:grid-cols-2">
<VerbColumn
title="前项动词"
caption="变为连用形后拼接"
items={PREFIX_VERBS}
selectedKey={selectedPrefix?.dictionaryForm}
getKey={(verb) => verb.dictionaryForm}
getTitle={(verb) => verb.dictionaryForm}
getMeta={(verb) => `${verb.reading} / 连用形 ${verb.masuStemForm}`}
getMeaning={(verb) => verb.meaning}
recommendedKeys={recommendedPrefixes.map((verb) => verb.dictionaryForm)}
onSelect={selectPrefix}
/>
<VerbColumn
title="后项动词"
caption="决定复合动词的方向和语义"
items={SUFFIX_VERBS}
selectedKey={selectedSuffix?.id}
getKey={(suffix) => suffix.id}
getTitle={(suffix) => `${suffix.kanji}`}
getMeta={(suffix) => `${suffix.reading} / ${suffix.exampleInEnglish}`}
getMeaning={(suffix) => suffix.meaning}
recommendedKeys={recommendedSuffixes.map((suffix) => suffix.id)}
onSelect={selectSuffix}
/>
</div>
<aside className="flex flex-col gap-3">
<div className="rounded-2xl bg-[#FDFCFB] p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-xs font-semibold text-[#8B7E74]"></div>
{(selectedPrefix || selectedSuffix) && (
<button
type="button"
onClick={reset}
className="flex items-center gap-1 rounded-full bg-[#E8E1D8] px-2.5 py-1 text-xs font-semibold text-[#6F6258] active:bg-[#DCD2C7]"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-center">
<FusionPart label="前项" value={selectedPrefix?.masuStemForm || '选择前项'} subValue={selectedPrefix?.dictionaryForm} />
<div className="text-lg font-bold text-[#8B7E74]">+</div>
<FusionPart label="后项" value={selectedSuffix?.kanji ? `${selectedSuffix.kanji}` : '选择后项'} subValue={selectedSuffix?.meaning} />
</div>
{selectedPrefix && (
<div className="mt-4 border-l-4 border-[#8B7E74]/40 pl-3 text-xs leading-5 text-[#6F6258]">
{conjugationRule(selectedPrefix)}
</div>
)}
</div>
<ResultPanel
fusionState={fusionState}
foundVerb={foundVerb}
selectedPrefix={selectedPrefix}
selectedSuffix={selectedSuffix}
savedVerbIds={savedVerbIds}
recommendedPrefixes={recommendedPrefixes}
recommendedSuffixes={recommendedSuffixes}
onSelectPrefix={selectPrefix}
onSelectSuffix={selectSuffix}
onToggleSave={toggleSaveVerb}
/>
</aside>
</section>
</div>
</div>
);
};
interface VerbColumnProps<T> {
title: string;
caption: string;
items: T[];
selectedKey?: string;
recommendedKeys: string[];
getKey: (item: T) => string;
getTitle: (item: T) => string;
getMeta: (item: T) => string;
getMeaning: (item: T) => string;
onSelect: (item: T) => void;
}
const VerbColumn = <T,>({
title,
caption,
items,
selectedKey,
recommendedKeys,
getKey,
getTitle,
getMeta,
getMeaning,
onSelect,
}: VerbColumnProps<T>) => (
<section className="flex min-h-[460px] flex-col rounded-2xl bg-[#FDFCFB]">
<div className="border-b border-[#E8E4DE] px-4 py-3">
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-[#2D2926]">{title}</div>
<div className="text-xs text-[#8B7E74]">{items.length} </div>
</div>
<div className="mt-0.5 text-xs text-[#8B7E74]">{caption}</div>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{items.map((item) => {
const key = getKey(item);
const isSelected = selectedKey === key;
const isRecommended = recommendedKeys.includes(key);
return (
<button
key={key}
type="button"
onClick={() => onSelect(item)}
className={`w-full rounded-xl px-3 py-2 text-left transition-colors ${
isSelected
? 'bg-[#E8E1D8] text-[#2D2926]'
: isRecommended
? 'bg-[#F5F1EA] text-[#2D2926]'
: 'text-[#2D2926] hover:bg-[#F5F1EA]'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-serif text-lg font-semibold leading-tight">{getTitle(item)}</div>
<div className="mt-0.5 truncate text-[11px] text-[#8B7E74]">{getMeta(item)}</div>
</div>
{isRecommended && !isSelected && (
<span className="shrink-0 rounded-full bg-[#8B7E74]/10 px-2 py-0.5 text-[10px] font-semibold text-[#8B7E74]">
</span>
)}
</div>
<div className="mt-1 text-xs leading-5 text-[#6F6258]">{getMeaning(item)}</div>
</button>
);
})}
</div>
</section>
);
const FusionPart: React.FC<{ label: string; value: string; subValue?: string }> = ({ label, value, subValue }) => (
<div className="rounded-xl bg-[#F5F1EA] px-2 py-3">
<div className="text-[10px] font-semibold text-[#8B7E74]">{label}</div>
<div className="mt-1 font-serif text-lg font-bold text-[#2D2926]">{value}</div>
{subValue && <div className="mt-0.5 truncate text-[10px] text-[#8B7E74]">{subValue}</div>}
</div>
);
interface ResultPanelProps {
fusionState: FusionState;
foundVerb: CompoundVerb | null;
selectedPrefix: Verb | null;
selectedSuffix: SuffixVerb | null;
savedVerbIds: string[];
recommendedPrefixes: Verb[];
recommendedSuffixes: SuffixVerb[];
onSelectPrefix: (prefix: Verb) => void;
onSelectSuffix: (suffix: SuffixVerb) => void;
onToggleSave: (id: string) => void;
}
const ResultPanel: React.FC<ResultPanelProps> = ({
fusionState,
foundVerb,
selectedPrefix,
selectedSuffix,
savedVerbIds,
recommendedPrefixes,
recommendedSuffixes,
onSelectPrefix,
onSelectSuffix,
onToggleSave,
}) => {
if (fusionState === 'success' && foundVerb) {
const isSaved = savedVerbIds.includes(foundVerb.id);
return (
<section className="rounded-2xl bg-[#FDFCFB] p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-1.5 text-xs font-semibold text-emerald-700">
<CheckCircle className="h-4 w-4" />
</div>
<h3 className="mt-2 font-serif text-3xl font-bold text-[#2D2926]">{foundVerb.kanji}</h3>
<div className="mt-1 text-xs text-[#8B7E74]">
{foundVerb.reading} / {foundVerb.romaji} / JLPT {foundVerb.jlpt}
</div>
</div>
<div className="flex shrink-0 gap-1">
<button
type="button"
onClick={() => speakJapanese(foundVerb.kanji)}
className="rounded-full bg-[#E8E1D8] p-2 text-[#6F6258] active:bg-[#DCD2C7]"
title="朗读"
>
<Volume2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => onToggleSave(foundVerb.id)}
className="rounded-full bg-[#E8E1D8] p-2 text-[#6F6258] active:bg-[#DCD2C7]"
title={isSaved ? '取消收藏' : '收藏'}
>
{isSaved ? <BookmarkCheck className="h-4 w-4" /> : <Bookmark className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-3">
<div>
<div className="text-xs font-semibold text-[#8B7E74]"></div>
<div className="mt-1 text-sm font-semibold leading-6 text-[#2D2926]">{foundVerb.meaning}</div>
</div>
<div>
<div className="text-xs font-semibold text-[#8B7E74]"></div>
<div className="mt-1 font-serif text-sm leading-6 text-[#2D2926]">{foundVerb.exampleSentence.japanese}</div>
<div className="mt-1 text-xs leading-5 text-[#8B7E74]">{foundVerb.exampleSentence.translation}</div>
</div>
{foundVerb.notes && (
<div className="border-l-4 border-[#8B7E74]/40 pl-3 text-xs leading-5 text-[#6F6258]">{foundVerb.notes}</div>
)}
</div>
</section>
);
}
if (fusionState === 'failed' && selectedPrefix && selectedSuffix) {
return (
<section className="rounded-2xl bg-[#FDFCFB] p-4">
<div className="flex items-center gap-1.5 text-xs font-semibold text-rose-700">
<HelpCircle className="h-4 w-4" />
</div>
<p className="mt-2 text-sm leading-6 text-[#2D2926]">
{selectedPrefix.masuStemForm} + {selectedSuffix.kanji}
</p>
{(recommendedSuffixes.length > 0 || recommendedPrefixes.length > 0) && (
<div className="mt-4 space-y-3">
{recommendedSuffixes.length > 0 && (
<RecommendationGroup title={`可搭配「${selectedPrefix.dictionaryForm}」的后项`}>
{recommendedSuffixes.slice(0, 6).map((suffix) => (
<button
key={suffix.id}
type="button"
onClick={() => onSelectSuffix(suffix)}
className="rounded-full bg-[#F5F1EA] px-2.5 py-1 text-xs font-semibold text-[#6F6258]"
>
{suffix.kanji}
</button>
))}
</RecommendationGroup>
)}
{recommendedPrefixes.length > 0 && (
<RecommendationGroup title={`可搭配「〜${selectedSuffix.kanji}」的前项`}>
{recommendedPrefixes.slice(0, 6).map((prefix) => (
<button
key={prefix.dictionaryForm}
type="button"
onClick={() => onSelectPrefix(prefix)}
className="rounded-full bg-[#F5F1EA] px-2.5 py-1 text-xs font-semibold text-[#6F6258]"
>
{prefix.dictionaryForm}
</button>
))}
</RecommendationGroup>
)}
</div>
)}
</section>
);
}
return (
<section className="rounded-2xl bg-[#FDFCFB] p-4 text-sm leading-6 text-[#8B7E74]">
</section>
);
};
const RecommendationGroup: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
<div>
<div className="mb-1.5 text-xs font-semibold text-[#8B7E74]">{title}</div>
<div className="flex flex-wrap gap-1.5">{children}</div>
</div>
);
+2 -2
View File
@@ -350,7 +350,7 @@ export const LineItem: React.FC<LineItemProps> = ({
e.stopPropagation();
onNoteClick(earliestNote!);
}}
className={`${colorClass} px-1 rounded border-b-2 font-medium cursor-pointer transition-all duration-200 inline-block active:scale-95`}
className={`${colorClass} px-1 border-b-2 font-medium cursor-pointer transition-all duration-200 inline-block active:scale-95`}
title={`点击查看注释: ${earliestNote.comment}`}
>
{matchText}
@@ -456,7 +456,7 @@ export const LineItem: React.FC<LineItemProps> = ({
onDoubleClick(line.id);
}
}}
className={`flex flex-col w-full min-w-0 select-text overflow-hidden pr-0.5 font-mono hover:bg-[#FAF9F6]/80 rounded px-0.5 md:-mx-1 md:px-1 md:pr-1 md:overflow-visible ${
className={`flex flex-col w-full min-w-0 select-text overflow-hidden pr-0.5 font-mono hover:bg-[#FAF9F6]/80 px-0.5 md:-mx-1 md:px-1 md:pr-1 md:overflow-visible ${
canEdit ? 'cursor-text' : 'cursor-default'
}`}
>
+20 -20
View File
@@ -197,20 +197,20 @@ export const Sidebar: React.FC<SidebarProps> = ({
{selectedFurigana && selectedFuriganaLine && selectedFuriganaItem ? (
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-xs font-semibold text-[#2D2926] bg-[#8B7E74]/10 border border-[#8B7E74]/20 px-2 py-0.5 rounded flex items-center gap-1 leading-none">
<span className="text-xs font-semibold text-[#2D2926] bg-[#8B7E74]/10 border-l-4 border-[#8B7E74] px-2 py-0.5 flex items-center gap-1 leading-none">
<Tag className="w-3 h-3" />
</span>
<button
type="button"
onClick={onClearSelection}
className="text-[#A69F92] hover:text-[#2D2926] rounded-full hover:bg-[#FAF9F6] p-0.5 transition-colors"
className="text-[#A69F92] hover:text-[#2D2926] hover:bg-[#FAF9F6] p-0.5 transition-colors"
title="关闭假名操作区"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="bg-white border border-[#E8E4DE] rounded p-2.5 shadow-xs">
<div className="bg-white border-l-4 border-[#E8E4DE] p-2.5">
<div className="text-[10px] text-[#8B7E74] font-bold mb-1 uppercase font-mono tracking-wider">
:
</div>
@@ -221,14 +221,14 @@ export const Sidebar: React.FC<SidebarProps> = ({
onUpdateFurigana(selectedFurigana.lineId, selectedFurigana.itemId, e.target.value)
}
placeholder="在此修改假名"
className="w-full bg-[#FAF9F6] border border-[#E8E4DE] rounded px-2 py-1.5 text-[#2D2926] text-base font-serif focus:outline-none focus:ring-1 focus:ring-[#8B7E74] placeholder-[#A69F92]/60"
className="w-full bg-[#FAF9F6] border border-[#E8E4DE] px-2 py-1.5 text-[#2D2926] text-base font-serif focus:outline-none focus:ring-1 focus:ring-[#8B7E74] placeholder-[#A69F92]/60"
/>
<div className="mt-1 text-[11px] text-[#A69F92] font-mono">
{(selectedFuriganaLineIndex + 1).toString().padStart(2, '0')}
</div>
</div>
<div className="bg-white border border-[#E8E4DE] rounded p-2.5 shadow-xs">
<div className="bg-white border-l-4 border-[#E8E4DE] p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-[10px] text-[#8B7E74] font-bold uppercase font-mono tracking-wider">
X
@@ -262,14 +262,14 @@ export const Sidebar: React.FC<SidebarProps> = ({
<button
type="button"
onClick={() => onJumpToLine(selectedFurigana.lineId)}
className="flex-1 border border-[#E8E4DE] bg-white hover:bg-[#FAF9F6] text-[#8B7E74] hover:text-[#2D2926] font-semibold text-xs py-2 rounded shadow-xs transition-all cursor-pointer"
className="flex-1 border border-[#E8E4DE] bg-white hover:bg-[#FAF9F6] text-[#8B7E74] hover:text-[#2D2926] font-semibold text-xs py-2 transition-colors cursor-pointer"
>
</button>
<button
type="button"
onClick={() => onDeleteFurigana(selectedFurigana.lineId, selectedFurigana.itemId)}
className="flex-1 border border-rose-200 hover:border-rose-400 bg-rose-50/55 hover:bg-rose-50 text-rose-700 hover:text-rose-800 font-semibold text-xs py-2 rounded shadow-xs transition-all flex items-center justify-center gap-1 cursor-pointer"
className="flex-1 border border-rose-200 hover:border-rose-400 bg-rose-50/55 hover:bg-rose-50 text-rose-700 hover:text-rose-800 font-semibold text-xs py-2 transition-colors flex items-center justify-center gap-1 cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
@@ -280,13 +280,13 @@ export const Sidebar: React.FC<SidebarProps> = ({
/* ACTIVE NEW NOTE FORM */
<form onSubmit={handleSaveNote} className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-xs font-semibold text-[#2D2926] bg-[#8B7E74]/10 border border-[#8B7E74]/20 px-2 py-0.5 rounded flex items-center gap-1 leading-none">
<span className="text-xs font-semibold text-[#2D2926] bg-[#8B7E74]/10 border-l-4 border-[#8B7E74] px-2 py-0.5 flex items-center gap-1 leading-none">
<Tag className="w-3 h-3" /> {isEditing ? '修改注释' : '新建注释'}
</span>
<button
type="button"
onClick={onClearSelection}
className="text-[#A69F92] hover:text-[#2D2926] rounded-full hover:bg-[#FAF9F6] p-0.5 transition-colors"
className="text-[#A69F92] hover:text-[#2D2926] hover:bg-[#FAF9F6] p-0.5 transition-colors"
title="取消选择"
>
<X className="w-4 h-4" />
@@ -294,7 +294,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
</div>
{/* Selected Text Showcase */}
<div className="bg-white border border-[#E8E4DE] rounded p-2.5 shadow-xs">
<div className="bg-white border-l-4 border-[#E8E4DE] p-2.5">
<div className="text-[10px] text-[#8B7E74] font-bold mb-1 uppercase font-mono tracking-wider">
:
</div>
@@ -317,7 +317,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
title={c.label}
className={`w-6 h-6 rounded-full border-2 transition-all flex items-center justify-center ${c.bg} ${
selectedColor === c.value
? 'border-[#8B7E74] scale-110 shadow-xs'
? 'border-[#8B7E74] scale-110'
: 'border-transparent hover:scale-105'
}`}
>
@@ -340,7 +340,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="在此录入释义、语法要点、生词短语等注释..."
className="w-full text-sm md:text-base bg-white border border-[#E8E4DE] rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-[#8B7E74]/40 focus:border-[#8B7E74] placeholder-neutral-400 text-[#2D2926] leading-relaxed shadow-inner"
className="w-full text-sm md:text-base bg-white border border-[#E8E4DE] p-2.5 focus:outline-none focus:ring-2 focus:ring-[#8B7E74]/40 focus:border-[#8B7E74] placeholder-neutral-400 text-[#2D2926] leading-relaxed"
required
/>
</div>
@@ -355,7 +355,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
onClearSelection();
}
}}
className="flex-1 border border-rose-200 hover:border-rose-400 bg-rose-50/55 hover:bg-rose-50 text-rose-700 hover:text-rose-800 font-semibold text-xs py-2 rounded shadow-xs transition-all flex items-center justify-center gap-1 cursor-pointer"
className="flex-1 border border-rose-200 hover:border-rose-400 bg-rose-50/55 hover:bg-rose-50 text-rose-700 hover:text-rose-800 font-semibold text-xs py-2 transition-colors flex items-center justify-center gap-1 cursor-pointer"
title="删除此条注释"
>
<Trash2 className="w-3.5 h-3.5" />
@@ -364,7 +364,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
)}
<button
type="submit"
className={`${editingNoteId ? 'flex-[2]' : 'w-full'} bg-[#8B7E74] text-white font-semibold text-xs py-2 rounded shadow-xs hover:bg-[#786C63] active:bg-[#665B53] transition-all font-sans cursor-pointer text-center`}
className={`${editingNoteId ? 'flex-[2]' : 'w-full'} bg-[#8B7E74] text-white font-semibold text-xs py-2 hover:bg-[#786C63] active:bg-[#665B53] transition-colors font-sans cursor-pointer text-center`}
>
{isEditing ? '保存修改' : '保存此条注释'}
</button>
@@ -387,7 +387,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
<>
{exactMatchedNotes.length > 0 && (
<div className="p-3 bg-[#FAF9F6] border-b border-[#E8E4DE]">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 shadow-xs">
<div className="bg-amber-50 border-l-4 border-amber-300 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm text-amber-900 font-bold font-sans tracking-wide">
@@ -401,7 +401,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
{exactMatchedNotes.map((note) => (
<div
key={`${note.lineId}-${note.id}`}
className="rounded border border-amber-200 bg-white p-2.5 shadow-xs"
className="border-l-4 border-amber-200 bg-white p-2.5"
>
<div className="mb-1.5 flex items-center justify-between gap-2">
{note.source === 'current' ? (
@@ -464,7 +464,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
onFocusNote(note.id);
}
}}
className={`group border rounded-lg p-3 bg-white hover:shadow-xs transition-all flex flex-col gap-2 relative cursor-pointer ${
className={`group border-b p-3 bg-white transition-colors flex flex-col gap-2 relative cursor-pointer ${
isFocused
? 'border-[#8B7E74] ring-1 ring-[#8B7E74]/30'
: 'border-[#E8E4DE]/60 hover:border-[#8B7E74]/60'
@@ -474,7 +474,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
<div className="flex items-center justify-between">
{/* Highlight text indicator */}
<span
className={`text-xs font-semibold px-2 py-0.5 rounded border ${badgeColorClass} font-serif max-w-[70%] truncate`}
className={`text-xs font-semibold px-2 py-0.5 border ${badgeColorClass} font-serif max-w-[70%] truncate`}
title={note.text}
>
{note.text}
@@ -486,7 +486,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
e.stopPropagation();
onJumpToLine(note.lineId);
}}
className="text-[#A69F92] hover:text-[#2D2926] border border-transparent hover:border-[#E8E4DE] hover:bg-[#FAF9F6] px-1.5 py-0.5 rounded font-mono text-[10px] flex items-center gap-0.5 transition-all cursor-pointer"
className="text-[#A69F92] hover:text-[#2D2926] border border-transparent hover:border-[#E8E4DE] hover:bg-[#FAF9F6] px-1.5 py-0.5 font-mono text-[10px] flex items-center gap-0.5 transition-colors cursor-pointer"
title="点击跳转并定位此行"
>
{note.lineNumber.toString().padStart(2, '0')}
@@ -506,7 +506,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
onDeleteNote(note.lineId, note.id);
}}
title="删除此注释"
className="absolute bottom-2.5 right-2.5 p-1 bg-[#FAF9F6] hover:bg-rose-50 border border-[#E8E4DE]/60 hover:border-rose-200 text-[#A69F92] hover:text-rose-700 rounded transition-all md:opacity-0 md:group-hover:opacity-100 cursor-pointer"
className="absolute bottom-2.5 right-2.5 p-1 bg-[#FAF9F6] hover:bg-rose-50 border border-[#E8E4DE]/60 hover:border-rose-200 text-[#A69F92] hover:text-rose-700 transition-colors md:opacity-0 md:group-hover:opacity-100 cursor-pointer"
>
<Trash2 className="w-3 h-3" />
</button>
+11 -11
View File
@@ -34,10 +34,10 @@ export const Toolbar: React.FC<ToolbarProps> = ({
canEditDocument,
}) => {
return (
<div id="main-toolbar" className="bg-[#FDFCFB] border-b border-[#E8E4DE] px-3 py-2 sm:px-4 flex flex-col sm:flex-row sm:items-center justify-between gap-2 select-none">
<div id="main-toolbar" className="bg-[#FDFCFB] border-b border-[#E8E4DE] px-2 py-1.5 sm:px-3 flex flex-col sm:flex-row sm:items-center justify-between gap-1.5 select-none">
{/* Brand / Logo */}
<div className="flex items-center gap-2 min-w-0">
<div className="bg-[#8B7E74] text-white rounded-lg p-2 shadow-sm">
<div className="bg-[#8B7E74] text-white p-1.5">
<BookOpen className="w-4 h-4" />
</div>
<div>
@@ -48,10 +48,10 @@ export const Toolbar: React.FC<ToolbarProps> = ({
</div>
{/* Document actions */}
<div className="flex flex-wrap items-center gap-1.5 text-xs select-none font-sans">
<div className="flex flex-wrap items-center gap-px text-xs select-none font-sans">
<button
onClick={onSaveDocument}
className="flex items-center gap-1 px-3 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-medium rounded-lg border border-[#8B7E74] shadow-2xs transition-all cursor-pointer"
className="flex items-center gap-1 px-3 py-1.5 bg-[#8B7E74] hover:bg-[#786C63] text-white font-medium transition-colors cursor-pointer"
title="按当前文档标题保存到项目 saves 目录"
>
<Save className="w-3.5 h-3.5 text-white" />
@@ -61,10 +61,10 @@ export const Toolbar: React.FC<ToolbarProps> = ({
{canEditDocument && (
<button
onClick={onToggleBulkImport}
className={`flex items-center gap-1 px-3 py-1.5 font-medium rounded-lg border shadow-2xs transition-all cursor-pointer ${
className={`flex items-center gap-1 px-3 py-1.5 font-medium transition-colors cursor-pointer ${
showImportArea
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
? 'bg-[#8B7E74] text-white'
: 'bg-white hover:bg-[#EFECE7] text-[#2D2926]'
}`}
title="粘贴整段文本并按行导入"
>
@@ -76,7 +76,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
{canEditDocument && (
<button
onClick={onNewDocument}
className="flex items-center gap-1 px-3 py-1.5 bg-white hover:bg-[#FAF9F6] text-[#2D2926] font-medium rounded-lg border border-[#E8E4DE] shadow-2xs transition-all cursor-pointer"
className="flex items-center gap-1 px-3 py-1.5 bg-white hover:bg-[#EFECE7] text-[#2D2926] font-medium transition-colors cursor-pointer"
title="新建空白文档"
>
<Plus className="w-3.5 h-3.5 text-[#8B7E74]" />
@@ -86,10 +86,10 @@ export const Toolbar: React.FC<ToolbarProps> = ({
<button
onClick={onViewSavedDocuments}
className={`flex items-center gap-1 px-3 py-1.5 font-medium rounded-lg border shadow-2xs transition-all cursor-pointer ${
className={`flex items-center gap-1 px-3 py-1.5 font-medium transition-colors cursor-pointer ${
showSavedArea
? 'bg-[#8B7E74] text-white border-[#8B7E74]'
: 'bg-white hover:bg-[#FAF9F6] text-[#2D2926] border-[#E8E4DE]'
? 'bg-[#8B7E74] text-white'
: 'bg-white hover:bg-[#EFECE7] text-[#2D2926]'
}`}
title="打开已保存歌词"
>
+46 -38
View File
@@ -4,7 +4,7 @@
*/
import React, { useEffect, useState } from 'react';
import { ArrowLeft, BookOpen, Layers3 } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
import { apiUrl } from '../api';
interface VocabEntry {
@@ -29,11 +29,11 @@ interface VocabCard {
}
const colorClassFor = (color: string) => {
if (color === 'rose') return 'bg-rose-100 text-rose-950 border-rose-200';
if (color === 'sky') return 'bg-sky-100 text-sky-950 border-sky-200';
if (color === 'emerald') return 'bg-emerald-100 text-emerald-950 border-emerald-200';
if (color === 'violet') return 'bg-purple-100 text-purple-950 border-purple-200';
return 'bg-[#E8E4DE] text-[#2D2926] border-[#8B7E74]/30';
if (color === 'rose') return 'border-rose-300 text-rose-950';
if (color === 'sky') return 'border-sky-300 text-sky-950';
if (color === 'emerald') return 'border-emerald-300 text-emerald-950';
if (color === 'violet') return 'border-purple-300 text-purple-950';
return 'border-[#8B7E74]/40 text-[#2D2926]';
};
export const VocabCards: React.FC = () => {
@@ -67,17 +67,16 @@ export const VocabCards: React.FC = () => {
}, []);
return (
<div className="flex-1 overflow-y-auto bg-[#FAF9F6] px-2 py-3 lg:px-6 lg:pb-6">
<div className="flex-1 overflow-y-auto bg-[#F5F1EA] px-3 py-4 lg:px-8 lg:py-6">
<div className="mx-auto max-w-5xl">
<div className="mb-3 flex items-center justify-between gap-3 rounded-xl border border-[#E8E4DE] bg-[#FDFCFB] px-4 py-3 shadow-sm">
<div className="mb-4 flex items-start justify-between gap-4 px-1">
<div>
<div className="flex items-center gap-2 font-serif text-lg font-bold text-[#2D2926]">
<Layers3 className="h-5 w-5 text-[#8B7E74]" />
<div className="font-serif text-[clamp(1.7rem,3vw,2.3rem)] font-bold leading-tight tracking-wide text-[#2D2926]">
{selectedCard ? selectedCard.text : '单词卡片'}
</div>
{!selectedCard && (
<div className="mt-1 text-xs text-[#A69F92]">
<div className="mt-1.5 text-sm text-[#8B7E74]">
{cards.length}
</div>
)}
</div>
@@ -85,77 +84,86 @@ export const VocabCards: React.FC = () => {
<button
type="button"
onClick={() => setSelectedCard(null)}
className="flex items-center gap-1 rounded-full border border-[#E8E4DE] bg-white px-3 py-1.5 text-xs font-semibold text-[#8B7E74] hover:bg-[#FAF9F6]"
className="flex items-center gap-1 rounded-full bg-[#E8E1D8] px-3 py-1.5 text-sm font-semibold text-[#6F6258] active:bg-[#DCD2C7]"
>
<ArrowLeft className="h-3.5 w-3.5" />
<ArrowLeft className="h-4 w-4" />
</button>
) : (
<div className="rounded-full bg-[#8B7E74]/10 px-3 py-1 text-xs font-semibold text-[#8B7E74]">
{cards.length}
</div>
)}
) : null}
</div>
{status === 'loading' && (
<div className="rounded-xl border border-dashed border-[#E8E4DE] bg-[#FDFCFB] py-10 text-center text-sm text-[#A69F92]">
<div className="py-16 text-center text-sm text-[#8B7E74]">
...
</div>
)}
{status === 'error' && (
<div className="rounded-xl border border-rose-200 bg-rose-50 py-10 text-center text-sm text-rose-700">
<div className="py-16 text-center text-sm text-rose-700">
</div>
)}
{status === 'ready' && cards.length === 0 && (
<div className="rounded-xl border border-dashed border-[#E8E4DE] bg-[#FDFCFB] py-10 text-center text-sm text-[#A69F92]">
<div className="rounded-2xl bg-[#FDFCFB] px-4 py-12 text-center text-sm text-[#8B7E74]">
</div>
)}
{status === 'ready' && selectedCard && (
<div className="space-y-3">
<div className={`rounded-xl border p-3 text-sm leading-relaxed ${colorClassFor(selectedCard.latestColor)}`}>
<div className="space-y-4">
<section className="rounded-2xl bg-[#FDFCFB] px-4 py-4">
<div className="mb-2 text-xs font-semibold text-[#8B7E74]"></div>
<div className={`border-l-4 pl-3 text-base leading-7 ${colorClassFor(selectedCard.latestColor)}`}>
{selectedCard.latestComment || '无释义内容'}
</div>
<div className="space-y-2">
{selectedCard.entries.map((entry, index) => (
<div
key={`${entry.fileName}-${entry.lineNumber}-${index}`}
className="rounded-xl border border-[#E8E4DE]/70 bg-white p-3 shadow-sm"
>
<div className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-[#8B7E74]">
<BookOpen className="h-3.5 w-3.5" />
{entry.documentTitle} · {entry.lineNumber}
<div className="mt-3 text-xs leading-6 text-[#8B7E74]">
{selectedCard.noteCount} / {selectedCard.documentTitles.length}
</div>
<div className="text-sm leading-relaxed text-[#2D2926]">
</section>
<section className="overflow-hidden rounded-2xl bg-[#FDFCFB]">
<div className="px-4 py-3 text-xs font-semibold text-[#8B7E74]"></div>
<div className="divide-y divide-[#E8E4DE]">
{selectedCard.entries.map((entry, index) => (
<section
key={`${entry.fileName}-${entry.lineNumber}-${index}`}
className="grid gap-2 px-4 py-3 md:grid-cols-[150px_minmax(0,1fr)] md:gap-5"
>
<div className="text-xs leading-5 text-[#8B7E74]">
<div className="font-semibold text-[#2D2926]">{entry.documentTitle}</div>
<div> {entry.lineNumber}</div>
</div>
<div>
<div className="font-serif text-base leading-7 text-[#2D2926]">
{entry.lineText}
</div>
<div className={`mt-2 rounded-lg border px-2 py-1.5 text-sm ${colorClassFor(entry.color)}`}>
<div className={`mt-2 border-l-4 pl-3 text-sm leading-6 ${colorClassFor(entry.color)}`}>
{entry.comment}
</div>
</div>
</section>
))}
</div>
</section>
</div>
)}
{status === 'ready' && !selectedCard && (
<div className="grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
<div className="rounded-2xl bg-[#FDFCFB] p-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{cards.map((card) => (
<button
key={card.normalizedText}
type="button"
onClick={() => setSelectedCard(card)}
className="min-h-14 rounded-xl border border-[#E8E4DE] bg-[#FDFCFB] px-2 py-2 text-center font-serif text-base font-bold leading-snug text-[#2D2926] shadow-sm transition hover:border-[#8B7E74]/50 hover:bg-white active:bg-[#E8E4DE]/30 sm:text-lg"
className="min-h-12 rounded-xl bg-[#F5F1EA] px-3 py-2 text-center font-serif text-[clamp(1rem,2vw,1.18rem)] font-semibold leading-tight tracking-wide text-[#2D2926] transition-colors hover:bg-[#EFE7DD] active:bg-[#E8E1D8]"
>
{card.text}
</button>
))}
</div>
</div>
)}
</div>
</div>
+48
View File
@@ -0,0 +1,48 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
export interface Verb {
dictionaryForm: string; // e.g. 思う
masuStemForm: string; // e.g. 思い
kanji: string; // e.g. 思う (if different, otherwise same)
reading: string; // e.g. おもう
romaji: string; // e.g. omou
meaning: string; // e.g. 想、认为
}
export interface SuffixVerb {
id: string; // e.g. "dasu"
kanji: string; // e.g. 出す
reading: string; // e.g. だす
meaning: string; // e.g. 动作开始、突然发生、向外
description: string; // Comprehensive explanation of what this suffix adds to a verb
exampleInEnglish: string;
}
export interface CompoundVerb {
id: string; // Unique identifier, e.g., "omoidasu"
kanji: string; // e.g. 思い出す
reading: string; // e.g. おもいだす
romaji: string; // e.g. omoidasu
prefix: Verb; // The first verb (zenkou doushi)
suffix: SuffixVerb; // The second verb (koukou doushi)
meaning: string; // e.g. 想起、回忆起
jlpt: 'N3' | 'N2' | 'N1'; // JLPT Level
exampleSentence: {
japanese: string; // e.g. 昔の友人の名前を思い出した。
reading: string; // e.g. むかしのゆうじんのなまえをおもいだした。
translation: string; // e.g. 我想起了昔日好友的名字。
};
notes?: string; // Nuance or usage notes
}
export interface QuizQuestion {
id: string;
type: 'match' | 'fill_stem' | 'select_meaning';
compoundVerb: CompoundVerb;
options: string[];
correctAnswer: string;
prompt: string;
}
File diff suppressed because it is too large Load Diff