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:
88
client/src/pages/EntryPage.tsx
Normal file
88
client/src/pages/EntryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
client/src/pages/OverviewPage.tsx
Normal file
43
client/src/pages/OverviewPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
client/src/pages/QuizPage.tsx
Normal file
25
client/src/pages/QuizPage.tsx
Normal 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user