auto-git:

[add] README.md
 [add] client/.gitignore
 [add] client/README.md
 [add] client/eslint.config.js
 [add] client/index.html
 [add] client/package.json
 [add] client/public/vite.svg
 [add] client/src/App.css
 [add] client/src/App.tsx
 [add] client/src/api.ts
 [add] client/src/assets/react.svg
 [add] client/src/components/EntryCard.tsx
 [add] client/src/components/ItemPanels.tsx
 [add] client/src/components/QuizRunner.tsx
 [add] client/src/components/VideoPlayer.tsx
 [add] client/src/index.css
 [add] client/src/main.tsx
 [add] client/src/pages/EntryPage.tsx
 [add] client/src/pages/OverviewPage.tsx
 [add] client/src/pages/QuizPage.tsx
 [add] client/src/types.ts
 [add] client/tsconfig.app.json
 [add] client/tsconfig.json
 [add] client/tsconfig.node.json
 [add] client/vite.config.ts
 [add] gemini_replicate_batch.py
 [add] package.json
 [add] prompt.txt
 [add] server/package.json
 [add] server/src/index.ts
 [add] server/tsconfig.json
This commit is contained in:
2026-01-07 18:35:20 +01:00
parent f80da70512
commit 1d9e7cbcbb
31 changed files with 2564 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { fetchEntry } from '../api';
import VideoPlayer from '../components/VideoPlayer';
import { ConversationPanel, GrammarPanel, KeyPhrasePanel, VocabPanel } from '../components/ItemPanels';
import type { EntryDetail } from '../types';
export default function EntryPage() {
const { idEncoded } = useParams();
const navigate = useNavigate();
const [entry, setEntry] = useState<EntryDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const entryId = useMemo(() => {
try {
return decodeURIComponent(idEncoded || '');
} catch {
return idEncoded || '';
}
}, [idEncoded]);
useEffect(() => {
if (!entryId) return;
setLoading(true);
fetchEntry(entryId)
.then((data) => setEntry(data))
.catch(() => setError('Entry not found'))
.finally(() => setLoading(false));
}, [entryId]);
if (!entryId) {
return <div className="error">No entry id provided.</div>;
}
if (loading) return <div className="loading">Loading entry</div>;
if (error || !entry) return <div className="error">{error || 'Entry not found.'}</div>;
const counts = entry.counts || { grammar: 0, vocab: 0, key_phrases: 0, conversation: 0, quiz: 0 };
const quizLink = `/quiz?mode=entry&id=${encodeURIComponent(entry.id)}`;
return (
<div className="entry-page">
<div className="crumbs">
<button className="button button--ghost" onClick={() => navigate(-1)}> Back</button>
</div>
<div className="page-header">
<div>
<p className="eyebrow">{entry.meta?.mode || 'mode not set'}</p>
<h1>{entry.title}</h1>
<p className="muted">{entry.meta?.type}</p>
<div className="chips">
<span className="pill">Grammar {counts.grammar}</span>
<span className="pill">Vocab {counts.vocab}</span>
<span className="pill">Phrases {counts.key_phrases}</span>
<span className="pill">Conversation {counts.conversation}</span>
<span className="pill pill--accent">Quiz {counts.quiz}</span>
</div>
</div>
<div className="actions">
<Link className="button" to={quizLink}>Start quiz (this entry)</Link>
</div>
</div>
<VideoPlayer src={entry.video_url} />
<div className="meta-box">
<div>
<div className="label">Mode</div>
<div>{entry.meta?.mode || 'n/a'}</div>
</div>
<div>
<div className="label">Type</div>
<div>{entry.meta?.type || 'n/a'}</div>
</div>
<div>
<div className="label">Entry ID</div>
<div className="muted code">{entry.id}</div>
</div>
</div>
<GrammarPanel items={entry.items?.grammar} />
<VocabPanel items={entry.items?.vocab} />
<KeyPhrasePanel items={entry.items?.key_phrases} />
<ConversationPanel items={entry.items?.conversation} />
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import EntryCard from '../components/EntryCard';
import { fetchEntries } from '../api';
import type { EntrySummary } from '../types';
export default function OverviewPage() {
const [entries, setEntries] = useState<EntrySummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchEntries()
.then((data) => setEntries(data))
.catch(() => setError('Could not load entries'))
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="loading">Loading entries</div>;
if (error) return <div className="error">{error}</div>;
return (
<div>
<div className="page-header">
<div>
<p className="eyebrow">IG Japanese Quizzer</p>
<h1>Choose a reel to study</h1>
<p className="muted">Each card bundles grammar, vocab, phrases, and quizzes pulled from your local data folder.</p>
</div>
<Link className="button" to="/quiz">Jump to Quiz Wizard</Link>
</div>
{entries.length === 0 ? (
<div className="error">No entries detected in data/. Add mp4 + json pairs and restart the server.</div>
) : (
<div className="grid">
{entries.map((entry) => (
<EntryCard key={entry.id} entry={entry} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import QuizRunner from '../components/QuizRunner';
export default function QuizPage() {
const [params] = useSearchParams();
const { mode, entryId } = useMemo(() => {
const modeParam = params.get('mode');
const idParam = params.get('id');
let decodedId: string | undefined;
if (idParam) {
try {
decodedId = decodeURIComponent(idParam);
} catch {
decodedId = idParam;
}
}
return { mode: modeParam, entryId: decodedId };
}, [params]);
const defaultMode = mode === 'entry' ? 'single' : mode === 'selected' ? 'selected' : 'all';
return <QuizRunner defaultMode={defaultMode} defaultEntryId={entryId} />;
}