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

50
README.md Normal file
View File

@@ -0,0 +1,50 @@
# IG Japanese Quizzer
A full-stack web app for drilling Japanese grammar, vocabulary, and phrases from locally stored Instagram posts. The server indexes every `*.mp4` + matching `*.json` pair under `data/` and exposes them to a React/Vite frontend with a quiz wizard.
## Quick start
1. Install dependencies (root workspace):
```bash
npm install
```
2. Run both servers (frontend on 5173, backend on 5174):
```bash
npm run dev
```
- Vite proxies `/api` and `/data` to the Express server, so the client can use relative URLs.
3. Open the app at http://localhost:5173.
### Scripts
- `npm run dev` concurrently runs `server` (Express + ts-node-dev) and `client` (Vite dev server).
- `npm run build` builds the server TypeScript and Vite client.
- `npm run dev --workspace server` / `npm run dev --workspace client` run either side individually.
## Data layout
Files live under `data/` (scanned recursively):
```
data/<POST_ID>/<FILENAME>.mp4
\_ <FILENAME>.json # quiz payload for that video
```
- Base filenames must match. Extra sidecars (`.raw.txt`, `.mp4.json`, images) are ignored.
- The JSON structure is tolerant but expects keys: `meta`, `items`, `quiz`, `ui_hints` (additional fields are ignored).
- `entry_id` is the path from `data/` to the mp4 **without** the `.mp4` extension (e.g., `C1abc/12345`). It is URL-encoded in routes/query params.
- Videos are served statically at `/data/...` by the backend; JSON is only accessible through the API.
## API
- `GET /api/entries` → list of entries `{ id, title, mode, type, counts, video_url }`, sorted by title.
- `GET /api/entry?id=<entry_id>` → full entry JSON plus derived fields `{ id, title, video_url, counts }`.
## Frontend features
- Overview grid of all entries with counts and metadata.
- Entry detail page with embedded video and learning panels.
- Quiz Wizard with three modes:
- All entries (random 10 questions)
- Selected entries (checkbox picker)
- Single entry (linked from detail page)
- Quiz types: cloze input, multiple-choice variants, match pairs, and best reply. Wrong answers reveal explanations and the source video.
## Notes
- The server prevents path traversal by validating resolved paths against the data root and only serving scanned entries.
- Update or add new posts by dropping files into `data/` and restarting the server to rescan.

24
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
client/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
client/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

31
client/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

1
client/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

45
client/src/App.css Normal file
View File

@@ -0,0 +1,45 @@
.app-shell {
min-height: 100vh;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 0;
position: sticky;
top: 0;
z-index: 10;
backdrop-filter: blur(12px);
}
.brand {
font-weight: 800;
letter-spacing: -0.01em;
font-size: 1.1rem;
text-decoration: none;
}
nav {
display: flex;
gap: 0.5rem;
}
.link {
padding: 0.55rem 0.85rem;
border-radius: 12px;
text-decoration: none;
color: inherit;
font-weight: 600;
}
.link.active {
background: rgba(0, 0, 0, 0.06);
color: var(--accent);
}
.main {
max-width: 1080px;
margin: 0 auto;
padding: 0 1rem 3rem;
}

33
client/src/App.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { NavLink, Route, Routes } from 'react-router-dom';
import OverviewPage from './pages/OverviewPage';
import EntryPage from './pages/EntryPage';
import QuizPage from './pages/QuizPage';
import './App.css';
export default function App() {
return (
<div className="app-shell">
<header className="topbar">
<NavLink to="/" className="brand">
IG Japanese Quizzer
</NavLink>
<nav>
<NavLink to="/" end className={({ isActive }) => (isActive ? 'link active' : 'link')}>
Overview
</NavLink>
<NavLink to="/quiz" className={({ isActive }) => (isActive ? 'link active' : 'link')}>
Quiz Wizard
</NavLink>
</nav>
</header>
<main className="main">
<Routes>
<Route path="/" element={<OverviewPage />} />
<Route path="/entry/:idEncoded" element={<EntryPage />} />
<Route path="/quiz" element={<QuizPage />} />
</Routes>
</main>
</div>
);
}

57
client/src/api.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { EntryDetail, EntryItems, EntrySummary } from './types';
const entryCache = new Map<string, EntryDetail>();
function normalizeItems(items: Partial<EntryItems> | undefined): EntryItems {
return {
grammar: Array.isArray(items?.grammar) ? items.grammar : [],
vocab: Array.isArray(items?.vocab) ? items.vocab : [],
conversation: Array.isArray(items?.conversation) ? items.conversation : [],
key_phrases: Array.isArray(items?.key_phrases) ? items.key_phrases : [],
};
}
function normalizeDetail(payload: any): EntryDetail {
const items = normalizeItems(payload?.items);
const counts = payload?.counts || {
grammar: items.grammar.length,
vocab: items.vocab.length,
key_phrases: items.key_phrases.length,
conversation: items.conversation.length,
quiz: Array.isArray(payload?.quiz) ? payload.quiz.length : 0,
};
return {
id: String(payload?.id ?? ''),
title: payload?.title || payload?.meta?.title_en || String(payload?.id ?? 'Untitled'),
meta: payload?.meta || {},
items,
quiz: Array.isArray(payload?.quiz) ? payload.quiz : [],
ui_hints: payload?.ui_hints || {},
video_url: payload?.video_url || '',
counts,
};
}
export async function fetchEntries(): Promise<EntrySummary[]> {
const response = await fetch('/api/entries');
if (!response.ok) {
throw new Error('Failed to load entries');
}
return response.json();
}
export async function fetchEntry(id: string): Promise<EntryDetail> {
if (entryCache.has(id)) {
return entryCache.get(id)!;
}
const response = await fetch(`/api/entry?id=${encodeURIComponent(id)}`);
if (!response.ok) {
throw new Error('Failed to load entry');
}
const payload = await response.json();
const detail = normalizeDetail(payload);
entryCache.set(id, detail);
return detail;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,26 @@
import { Link } from 'react-router-dom';
import type { EntrySummary } from '../types';
interface Props {
entry: EntrySummary;
}
export default function EntryCard({ entry }: Props) {
const { counts } = entry;
return (
<Link to={`/entry/${encodeURIComponent(entry.id)}`} className="entry-card">
<div className="entry-card__title">{entry.title}</div>
<div className="entry-card__meta">
<span className="pill">{entry.mode || 'mixed'}</span>
{entry.type && <span className="pill pill--ghost">{entry.type}</span>}
</div>
<div className="entry-card__counts">
<span>Grammar {counts.grammar}</span>
<span>Vocab {counts.vocab}</span>
<span>Phrases {counts.key_phrases}</span>
<span>Conversation {counts.conversation}</span>
<span>Quiz {counts.quiz}</span>
</div>
</Link>
);
}

View File

@@ -0,0 +1,146 @@
import type { ConversationItem, GrammarItem, KeyPhraseItem, VocabItem } from '../types';
interface ItemProps<T> {
items?: T[];
}
const renderExample = (item: any) => {
const example = item.example || {};
const hasExample = example.jp || example.en || example.kana || item.example_jp || item.example_en || item.example_kana;
if (!hasExample) return null;
return (
<div className="item-row">
<span className="label">Example</span>
<div className="muted">
{example.jp || item.example_jp}
{example.kana || item.example_kana ? <div className="subline">{example.kana || item.example_kana}</div> : null}
{example.en || item.example_en ? <div className="subline">{example.en || item.example_en}</div> : null}
</div>
</div>
);
};
const renderNote = (item: any) => {
const note = item.use_note_en || item.note_en || item.when_to_use_en;
if (!note) return null;
return (
<div className="item-row">
<span className="label">Usage</span>
<div className="muted">{note}</div>
</div>
);
};
const renderRegister = (item: any) => {
if (!item.register) return null;
return (
<div className="item-row">
<span className="label">Register</span>
<div className="pill pill--ghost">{item.register}</div>
</div>
);
};
export function GrammarPanel({ items }: ItemProps<GrammarItem>) {
if (!items?.length) return null;
return (
<section className="panel">
<header>
<h3>Grammar</h3>
</header>
<div className="panel-grid">
{items.map((item) => (
<div key={item.id || item.pattern} className="panel-card">
<div className="panel-card__title">{item.pattern || item.jp || item.id}</div>
<div className="panel-card__body">
{(item.meaning_en || item.meaning) && <div className="muted">{item.meaning_en || item.meaning}</div>}
{renderRegister(item)}
{renderNote(item)}
{renderExample(item)}
</div>
</div>
))}
</div>
</section>
);
}
export function VocabPanel({ items }: ItemProps<VocabItem>) {
if (!items?.length) return null;
return (
<section className="panel">
<header>
<h3>Vocabulary</h3>
</header>
<div className="panel-grid">
{items.map((item) => (
<div key={item.id || item.jp} className="panel-card">
<div className="panel-card__title">{item.jp || item.id}</div>
{(item.kana || item.meaning_en || item.meaning) && (
<div className="muted">
{item.kana && <div className="subline">{item.kana}</div>}
{(item.meaning_en || item.meaning) && <div className="subline">{item.meaning_en || item.meaning}</div>}
</div>
)}
{renderRegister(item)}
{renderNote(item)}
{renderExample(item)}
</div>
))}
</div>
</section>
);
}
export function KeyPhrasePanel({ items }: ItemProps<KeyPhraseItem>) {
if (!items?.length) return null;
return (
<section className="panel">
<header>
<h3>Key Phrases</h3>
</header>
<div className="panel-grid">
{items.map((item) => (
<div key={item.id || item.jp} className="panel-card">
<div className="panel-card__title">{item.jp || item.id}</div>
{(item.kana || item.meaning_en || item.meaning) && (
<div className="muted">
{item.kana && <div className="subline">{item.kana}</div>}
{(item.meaning_en || item.meaning) && <div className="subline">{item.meaning_en || item.meaning}</div>}
</div>
)}
{renderRegister(item)}
{renderNote(item)}
{renderExample(item)}
</div>
))}
</div>
</section>
);
}
export function ConversationPanel({ items }: ItemProps<ConversationItem>) {
if (!items?.length) return null;
return (
<section className="panel">
<header>
<h3>Conversation</h3>
</header>
<div className="panel-grid">
{items.map((item) => (
<div key={item.id || item.jp} className="panel-card">
<div className="panel-card__title">{item.jp || item.id}</div>
{(item.kana || item.en) && (
<div className="muted">
{item.kana && <div className="subline">{item.kana}</div>}
{item.en && <div className="subline">{item.en}</div>}
</div>
)}
{renderRegister(item)}
{renderNote(item)}
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,463 @@
import { useEffect, useMemo, useState } from 'react';
import { fetchEntries, fetchEntry } from '../api';
import type { EntryItems, EntrySummary, QuizQuestionWithEntry } from '../types';
import VideoPlayer from './VideoPlayer';
type Mode = 'all' | 'selected' | 'single';
const TOTAL_QUESTIONS = 10;
interface QuizRunnerProps {
defaultMode?: Mode;
defaultEntryId?: string;
}
interface TargetHit {
group: string;
item: Record<string, any>;
}
function shuffle<T>(list: T[]) {
const copy = [...list];
for (let i = copy.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
const normalize = (val: any) => (val === undefined || val === null ? '' : String(val).trim());
function resolveTargets(question: QuizQuestionWithEntry): TargetHit[] {
const targetIds = new Set((question.targets || []).map((t) => normalize(t)));
const groups: { label: string; items: any[] }[] = [
{ label: 'Grammar', items: question.items?.grammar || [] },
{ label: 'Vocabulary', items: question.items?.vocab || [] },
{ label: 'Key Phrases', items: question.items?.key_phrases || [] },
{ label: 'Conversation', items: question.items?.conversation || [] },
];
const found: TargetHit[] = [];
groups.forEach(({ label, items }) => {
items.forEach((item) => {
if (item?.id && targetIds.has(normalize(item.id))) {
found.push({ group: label, item });
}
});
});
return found;
}
function deriveCorrectText(question: QuizQuestionWithEntry) {
const options: any[] = Array.isArray(question.payload?.options) ? question.payload?.options : [];
if (typeof question.answer?.correct_index === 'number' && options[question.answer.correct_index]) {
return options[question.answer.correct_index];
}
if (question.answer?.correct_text) return question.answer.correct_text;
if (question.payload?.blanked) return question.payload.blanked;
const pairs = Array.isArray(question.payload?.pairs) ? question.payload.pairs : [];
if (pairs.length) {
return pairs.map((p: any) => `${p.left}${p.right}`).join(' | ');
}
return '';
}
function checkClozeAnswer(question: QuizQuestionWithEntry, response: string) {
if (!response) return false;
const expected = [question.answer?.correct_text, question.answer?.correct, question.payload?.blanked].filter(Boolean).map(normalize);
const answer = normalize(response);
return expected.some((val) => val === answer || val.toLowerCase() === answer.toLowerCase());
}
function checkMatchAnswer(question: QuizQuestionWithEntry, response: Record<number, string> | null) {
const pairs: any[] = Array.isArray(question.payload?.pairs) ? question.payload.pairs : [];
if (!pairs.length) return false;
return pairs.every((pair, idx) => {
const expected = normalize(pair.right);
const user = normalize(response?.[idx]);
return expected === user;
});
}
function checkMcAnswer(question: QuizQuestionWithEntry, response: number | null) {
if (typeof response !== 'number') return false;
if (typeof question.answer?.correct_index !== 'number') return false;
return response === question.answer.correct_index;
}
function QuestionRenderer({
question,
response,
onChange,
}: {
question: QuizQuestionWithEntry;
response: any;
onChange: (val: any) => void;
}) {
const payload = question.payload || {};
const type = question.type || '';
if (type === 'cloze') {
const sentence = payload.sentence_jp || payload.sentence || '';
return (
<div className="question-block">
{sentence && <div className="muted">{sentence.replace(payload.blanked || '', '____')}</div>}
<input
className="input"
type="text"
placeholder="Type the missing text"
value={response || ''}
onChange={(e) => onChange(e.target.value)}
/>
{Array.isArray(payload.options) && payload.options.length > 0 && (
<div className="option-hints">Hints: {payload.options.join(' • ')}</div>
)}
</div>
);
}
if (type === 'match') {
const pairs: any[] = Array.isArray(payload.pairs) ? payload.pairs : [];
const rightOptions = useMemo(
() => shuffle(pairs.map((p) => p.right).filter(Boolean)),
[question.id, question.entryId]
);
return (
<div className="question-block matches">
{pairs.map((pair, idx) => (
<div key={idx} className="match-row">
<div className="match-left">{pair.left}</div>
<select
className="input"
value={response?.[idx] || ''}
onChange={(e) => {
const current = response && typeof response === 'object' ? response : {};
onChange({ ...current, [idx]: e.target.value });
}}
>
<option value="">Match</option>
{rightOptions.map((opt, optionIdx) => (
<option key={optionIdx} value={opt}>
{opt}
</option>
))}
</select>
</div>
))}
</div>
);
}
const options: any[] = Array.isArray(payload.options) ? payload.options : [];
if (!options.length) {
return <div className="muted">No options provided for this question.</div>;
}
return (
<div className="question-block">
{options.map((option, idx) => (
<label key={idx} className="option">
<input
type="radio"
checked={response === idx}
onChange={() => onChange(idx)}
name={`q-${question.id}`}
/>
<span>{option}</span>
</label>
))}
</div>
);
}
function ExplanationPanel({ question, targets }: { question: QuizQuestionWithEntry; targets: TargetHit[] }) {
return (
<div className="explanation">
<h4>Explanation</h4>
{targets.length ? (
<div className="explanation-grid">
{targets.map(({ group, item }) => (
<div key={`${group}-${item.id || item.jp}`} className="panel-card">
<div className="panel-card__title">{item.jp || item.pattern || item.id}</div>
<div className="muted">{item.meaning_en || item.en || item.when_to_use_en}</div>
{item.use_note_en && <div className="subline">{item.use_note_en}</div>}
{item.register && <span className="pill pill--ghost">{item.register}</span>}
<div className="tag">{group}</div>
</div>
))}
</div>
) : (
<p className="muted">No linked study items were found for this question.</p>
)}
<VideoPlayer src={question.video_url} />
</div>
);
}
export default function QuizRunner({ defaultMode = 'all', defaultEntryId }: QuizRunnerProps) {
const [entries, setEntries] = useState<EntrySummary[]>([]);
const [loadingEntries, setLoadingEntries] = useState(true);
const [mode, setMode] = useState<Mode>(defaultMode);
const [selectedIds, setSelectedIds] = useState<string[]>(defaultEntryId ? [defaultEntryId] : []);
const [questions, setQuestions] = useState<QuizQuestionWithEntry[]>([]);
const [status, setStatus] = useState<'setup' | 'loading' | 'running' | 'finished'>('setup');
const [currentIndex, setCurrentIndex] = useState(0);
const [score, setScore] = useState(0);
const [response, setResponse] = useState<any>(null);
const [showResult, setShowResult] = useState(false);
const [lastCorrect, setLastCorrect] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoadingEntries(true);
fetchEntries()
.then((data) => setEntries(data))
.catch(() => setError('Could not load entries.'))
.finally(() => setLoadingEntries(false));
}, []);
useEffect(() => {
if (defaultEntryId) {
setMode('single');
setSelectedIds([defaultEntryId]);
}
}, [defaultEntryId]);
const currentQuestion = useMemo(() => questions[currentIndex], [questions, currentIndex]);
const resetQuestionState = () => {
setResponse(null);
setShowResult(false);
setLastCorrect(false);
};
const startQuiz = async () => {
const ids: string[] =
mode === 'all'
? entries.map((e) => e.id)
: mode === 'selected'
? selectedIds
: selectedIds.slice(0, 1);
if (!ids.length) {
setError('Pick at least one entry to quiz on.');
return;
}
setError(null);
setStatus('loading');
try {
const uniqueIds = Array.from(new Set(ids));
const details = await Promise.all(uniqueIds.map((id) => fetchEntry(id)));
const pool: QuizQuestionWithEntry[] = details.flatMap((entry) => {
const safeItems: EntryItems = entry.items || { grammar: [], vocab: [], conversation: [], key_phrases: [] };
return (entry.quiz || []).map((q) => ({
...q,
entryId: entry.id,
entryTitle: entry.title,
items: safeItems,
video_url: entry.video_url,
targets: q.targets || [],
type: q.type || 'unknown',
payload: q.payload || {},
answer: q.answer || {},
}));
});
if (!pool.length) {
setError('No quiz questions found in the selected entries.');
setStatus('setup');
return;
}
const chosen = shuffle(pool).slice(0, Math.min(TOTAL_QUESTIONS, pool.length));
setQuestions(chosen);
setCurrentIndex(0);
setScore(0);
resetQuestionState();
setStatus('running');
} catch (err: any) {
setError(err?.message || 'Could not start quiz.');
setStatus('setup');
}
};
const handleSubmit = (skip = false) => {
if (!currentQuestion || showResult) return;
let correct = false;
if (!skip) {
if ((currentQuestion.type || '').startsWith('mc') || currentQuestion.type === 'choose_best_reply') {
correct = checkMcAnswer(currentQuestion, response);
} else if (currentQuestion.type === 'cloze') {
correct = checkClozeAnswer(currentQuestion, response);
} else if (currentQuestion.type === 'match') {
correct = checkMatchAnswer(currentQuestion, response);
} else if (typeof currentQuestion.answer?.correct_index === 'number') {
correct = checkMcAnswer(currentQuestion, response);
}
}
if (correct) {
setScore((s) => s + 1);
}
setLastCorrect(correct);
setShowResult(true);
};
const goNext = () => {
if (currentIndex + 1 >= questions.length) {
setStatus('finished');
} else {
setCurrentIndex((idx) => idx + 1);
resetQuestionState();
}
};
if (loadingEntries) {
return <div className="loading">Loading quiz setup</div>;
}
if (status === 'setup') {
return (
<div className="quiz-setup">
<div className="page-header">
<div>
<p className="eyebrow">Quiz Wizard</p>
<h1>Pick a mode</h1>
<p className="muted">Build a 10-question run from all entries, a custom set, or a single reel.</p>
</div>
</div>
<div className="mode-switch">
<button className={mode === 'all' ? 'button button--solid' : 'button'} onClick={() => setMode('all')}>
Mode A · All entries
</button>
<button className={mode === 'selected' ? 'button button--solid' : 'button'} onClick={() => setMode('selected')}>
Mode B · Select entries
</button>
<button className={mode === 'single' ? 'button button--solid' : 'button'} onClick={() => setMode('single')}>
Mode C · Single entry
</button>
</div>
{mode === 'selected' && (
<div className="selector">
<p className="muted">Check the entries you want in the pool.</p>
<div className="selector-grid">
{entries.map((entry) => (
<label key={entry.id} className="selector-row">
<input
type="checkbox"
checked={selectedIds.includes(entry.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedIds((prev) => [...prev, entry.id]);
} else {
setSelectedIds((prev) => prev.filter((id) => id !== entry.id));
}
}}
/>
<span>{entry.title}</span>
</label>
))}
</div>
</div>
)}
{mode === 'single' && (
<div className="selector">
<p className="muted">Pick the entry to drill.</p>
<select
className="input"
value={selectedIds[0] || ''}
onChange={(e) => setSelectedIds(e.target.value ? [e.target.value] : [])}
>
<option value="">Select an entry</option>
{entries.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.title}
</option>
))}
</select>
</div>
)}
{error && <div className="error">{error}</div>}
<button className="button button--primary" onClick={startQuiz}>
Start quiz
</button>
</div>
);
}
if (status === 'loading') {
return <div className="loading">Building your quiz</div>;
}
if (status === 'finished') {
return (
<div className="quiz-finished">
<h2>Nice work!</h2>
<p className="muted">You scored {score} out of {questions.length}.</p>
<div className="actions">
<button className="button" onClick={() => setStatus('setup')}>
Play again
</button>
</div>
</div>
);
}
if (!currentQuestion) {
return <div className="error">No questions available.</div>;
}
const targets = resolveTargets(currentQuestion);
const correctText = deriveCorrectText(currentQuestion);
return (
<div className="quiz-runner">
<div className="quiz-top">
<div>
<p className="eyebrow">{currentQuestion.entryTitle}</p>
<h2>{currentQuestion.prompt_en || 'Answer the prompt'}</h2>
</div>
<div className="score-box">
<div className="muted">{currentIndex + 1} / {questions.length}</div>
<div className="score">Score: {score}</div>
</div>
</div>
<QuestionRenderer question={currentQuestion} response={response} onChange={setResponse} />
{showResult && (
<div className={lastCorrect ? 'callout success' : 'callout'}>
{lastCorrect ? 'Correct!' : 'Not quite.'}
{!lastCorrect && correctText && <div className="subline">Answer: {correctText}</div>}
</div>
)}
<div className="quiz-actions">
{!showResult && (
<>
<button className="button" onClick={() => handleSubmit(false)}>
Submit
</button>
<button className="button button--ghost" onClick={() => handleSubmit(true)}>
Dont know
</button>
</>
)}
{showResult && (
<button className="button button--primary" onClick={goNext}>
{currentIndex + 1 === questions.length ? 'Finish' : 'Next'}
</button>
)}
</div>
{showResult && !lastCorrect && <ExplanationPanel question={currentQuestion} targets={targets} />}
</div>
);
}

View File

@@ -0,0 +1,12 @@
interface Props {
src: string;
}
export default function VideoPlayer({ src }: Props) {
if (!src) return null;
return (
<div className="video-shell">
<video controls src={src} preload="metadata" />
</div>
);
}

429
client/src/index.css Normal file
View File

@@ -0,0 +1,429 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&family=DM+Sans:wght@400;500;700&display=swap');
:root {
font-family: 'DM Sans', 'Space Grotesk', sans-serif;
line-height: 1.6;
font-weight: 400;
color: #0f172a;
background: radial-gradient(circle at 10% 20%, rgba(255, 192, 203, 0.25), transparent 35%),
radial-gradient(circle at 80% 0%, rgba(109, 195, 255, 0.2), transparent 32%),
#f6f7fb;
min-height: 100vh;
--accent: #ff5a3c;
--card: #ffffff;
--border: #e5e7eb;
--muted: #4b5563;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
color: inherit;
background: inherit;
}
a {
color: inherit;
text-decoration: none;
}
p {
margin: 0;
}
h1,
h2,
h3,
h4 {
margin: 0;
letter-spacing: -0.01em;
}
.main {
padding-top: 0.5rem;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin: 1rem 0 1.5rem;
}
.eyebrow {
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.25em;
color: var(--muted);
margin-bottom: 0.35rem;
}
.muted {
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.entry-card {
display: block;
background: var(--card);
padding: 1rem;
border-radius: 16px;
border: 1px solid var(--border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
transition: transform 150ms ease, box-shadow 150ms ease;
}
.entry-card:hover {
transform: translateY(-4px);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.08);
}
.entry-card__title {
font-weight: 700;
margin-bottom: 0.35rem;
}
.entry-card__meta {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.entry-card__counts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem;
font-size: 0.9rem;
color: var(--muted);
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.06);
font-size: 0.85rem;
font-weight: 600;
}
.pill--ghost {
background: rgba(0, 0, 0, 0.04);
color: var(--muted);
}
.pill--accent {
background: rgba(255, 90, 60, 0.1);
color: #d9482b;
}
.button {
border: 1px solid var(--border);
border-radius: 14px;
padding: 0.65rem 1rem;
background: #fff;
cursor: pointer;
font-weight: 700;
transition: all 120ms ease;
}
.button:hover {
transform: translateY(-1px);
}
.button--primary,
.button--solid {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.button--ghost {
background: transparent;
color: inherit;
}
.actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.video-shell {
margin: 1rem 0;
background: #000;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.2);
}
.video-shell video {
width: 100%;
height: auto;
display: block;
}
.meta-box {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin: 1rem 0 2rem;
padding: 1rem;
background: var(--card);
border-radius: 16px;
border: 1px solid var(--border);
}
.label {
font-size: 0.85rem;
color: var(--muted);
}
.code {
word-break: break-all;
font-family: 'Space Grotesk', monospace;
}
.crumbs {
margin: 0.5rem 0;
}
.link {
background: transparent;
}
.panel {
margin: 1.5rem 0;
}
.panel-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
}
.panel-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 0.85rem;
position: relative;
}
.panel-card__title {
font-weight: 700;
margin-bottom: 0.25rem;
}
.panel-card__body {
color: var(--muted);
font-size: 0.95rem;
}
.tag {
position: absolute;
top: 0.65rem;
right: 0.65rem;
font-size: 0.7rem;
color: var(--muted);
}
.subline {
color: var(--muted);
font-size: 0.9rem;
}
.item-row {
margin-top: 0.35rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
}
.loading,
.error {
padding: 1rem;
background: var(--card);
border-radius: 12px;
border: 1px solid var(--border);
color: #111827;
}
.error {
border-color: #ef4444;
}
.input {
width: 100%;
padding: 0.65rem 0.75rem;
border-radius: 12px;
border: 1px solid var(--border);
background: #fff;
font-size: 1rem;
}
.selector {
background: var(--card);
padding: 1rem;
border-radius: 12px;
border: 1px solid var(--border);
margin: 1rem 0;
}
.selector-grid {
max-height: 260px;
overflow: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.35rem;
}
.selector-row {
display: flex;
gap: 0.5rem;
align-items: center;
background: #f9fafb;
padding: 0.45rem 0.6rem;
border-radius: 10px;
}
.mode-switch {
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
margin: 1rem 0;
}
.quiz-setup {
background: var(--card);
padding: 1.25rem;
border-radius: 14px;
border: 1px solid var(--border);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.05);
}
.quiz-runner {
background: var(--card);
padding: 1.25rem;
border-radius: 16px;
border: 1px solid var(--border);
box-shadow: 0 14px 35px rgba(0, 0, 0, 0.05);
}
.quiz-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.score-box {
text-align: right;
}
.score {
font-weight: 800;
}
.question-block {
margin: 1rem 0;
}
.option {
display: flex;
gap: 0.5rem;
align-items: center;
background: #f9fafb;
border-radius: 10px;
padding: 0.5rem 0.75rem;
margin-bottom: 0.35rem;
}
.matches {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.match-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
align-items: center;
}
.match-left {
font-weight: 700;
}
.option-hints {
font-size: 0.9rem;
color: var(--muted);
}
.quiz-actions {
display: flex;
gap: 0.5rem;
margin: 0.5rem 0 1rem;
}
.callout {
padding: 0.85rem 1rem;
border-radius: 12px;
border: 1px solid var(--border);
background: #fff6f4;
color: #d9482b;
}
.callout.success {
background: #e5fbef;
color: #0f9d58;
}
.explanation {
margin-top: 1rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
}
.explanation-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.quiz-finished {
background: var(--card);
padding: 1.5rem;
border-radius: 14px;
border: 1px solid var(--border);
text-align: center;
}
@media (max-width: 720px) {
.page-header,
.quiz-top {
flex-direction: column;
align-items: flex-start;
}
.match-row {
grid-template-columns: 1fr;
}
}

13
client/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);

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} />;
}

92
client/src/types.ts Normal file
View File

@@ -0,0 +1,92 @@
export interface ExampleBlock {
jp?: string;
kana?: string;
en?: string;
}
export interface BaseItem {
id?: string;
jp?: string;
kana?: string;
meaning_en?: string;
meaning?: string;
use_note_en?: string;
when_to_use_en?: string;
register?: string;
note_en?: string;
example?: ExampleBlock;
example_jp?: string;
example_kana?: string;
example_en?: string;
}
export interface GrammarItem extends BaseItem {
pattern?: string;
}
export interface VocabItem extends BaseItem {}
export interface ConversationItem extends BaseItem {
en?: string;
}
export interface KeyPhraseItem extends BaseItem {}
export interface EntryItems {
grammar: GrammarItem[];
vocab: VocabItem[];
conversation: ConversationItem[];
key_phrases: KeyPhraseItem[];
}
export interface EntryCounts {
grammar: number;
vocab: number;
key_phrases: number;
conversation: number;
quiz: number;
}
export interface EntrySummary {
id: string;
title: string;
mode?: string;
type?: string;
counts: EntryCounts;
video_url: string;
}
export interface EntryDetail {
id: string;
title: string;
meta?: {
mode?: string;
type?: string;
title_en?: string;
};
items: EntryItems;
quiz: QuizQuestion[];
ui_hints?: {
recommended_order?: (string | number)[];
show_first?: string;
explain_on_fail?: boolean;
};
video_url: string;
counts: EntryCounts;
}
export interface QuizQuestion {
id?: string | number;
targets?: (string | number)[];
type?: string;
prompt_en?: string;
payload?: Record<string, any>;
answer?: Record<string, any>;
}
export interface QuizQuestionWithEntry extends QuizQuestion {
entryId: string;
entryTitle: string;
items: EntryItems;
video_url: string;
}

28
client/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
client/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

13
client/vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:5174',
'/data': 'http://localhost:5174',
},
},
});

358
gemini_replicate_batch.py Normal file
View File

@@ -0,0 +1,358 @@
#!/usr/bin/env python3
"""
gemini_replicate_batch.py
Batch-generate <ID>.json files for Instagram reels using Replicate's
google/gemini-2.5-flash model with dynamic_thinking enabled.
Input: data/**/<ID>.mp4 (any subfolder under data)
Output: data/**/<ID>.json (parsed JSON, next to video)
data/**/<ID>.raw.txt (raw model output, next to video)
Usage:
python3 gemini_replicate_batch.py --data data
python3 gemini_replicate_batch.py --data data --only-missing
python3 gemini_replicate_batch.py --data data --prompt-file prompt.txt
python3 gemini_replicate_batch.py --data data --max-output-tokens 12000
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
from pathlib import Path
from typing import Any, Dict, Optional
import replicate
DEFAULT_PROMPT = r"""
You analyze an Instagram-style Japanese language video.
The video is either Japanese-only or English+Japanese.
Goal: Create a compact learning JSON for a custom quiz website.
The website will ask the user questions; if the user is wrong or taps "Don't know",
we will show the explanation from this JSON and the original IG media.
TOKEN BUDGET:
Be concise. Do not duplicate explanations inside questions.
STRICT RULES:
1) Do not invent. Only include words/phrases/grammar that clearly appear in the video.
2) For every Japanese string containing kanji, provide a full hiragana reading in a separate field "kana".
3) Keep it small:
- grammar: max 2
- vocab: max 10
- conversation lines: max 10
- key_phrases: max 10
4) NO timestamps. NO evidence_quote. NO source fields. NO confidence fields.
5) Questions MUST reference items by id (do not repeat long explanations in questions).
6) Provide 13 questions per item (depending on usefulness). Prefer: cloze, multiple choice, match, register-choice.
OUTPUT:
Return ONLY valid JSON (no markdown). UTF-8 Japanese.
SCHEMA:
{
"meta": {
"mode": "ja_only|en+ja",
"type": "grammar|vocab|conversation|mixed|unknown",
"title_en": "short title (5-8 words max)"
},
"items": {
"grammar": [
{
"id": "g1",
"pattern": "string",
"meaning_en": "one line",
"use_note_en": "1-2 lines max",
"register": "polite|neutral|casual|slang|formal|unknown",
"example": { "jp": "string", "kana": "string", "en": "string" }
}
],
"vocab": [
{
"id": "v1",
"jp": "surface form",
"kana": "hiragana reading",
"meaning_en": "short",
"register": "polite|neutral|casual|slang|formal|unknown",
"note_en": "optional, 1 line max",
"example": { "jp": "optional", "kana": "optional", "en": "optional" }
}
],
"conversation": [
{
"id": "c1",
"jp": "exact line",
"kana": "hiragana reading",
"en": "translation",
"register": "polite|neutral|casual|slang|mixed|unknown"
}
],
"key_phrases": [
{
"id": "k1",
"jp": "phrase",
"kana": "reading",
"meaning_en": "short",
"when_to_use_en": "1-2 lines max",
"register": "polite|neutral|casual|slang|formal|unknown"
}
]
},
"quiz": [
{
"id": "q1",
"targets": ["k1"],
"type": "mc_meaning|mc_register|cloze|match|choose_best_reply",
"prompt_en": "string",
"payload": {
"sentence_jp": "optional",
"sentence_kana": "optional",
"blanked": "optional",
"options": ["A","B","C","D"],
"pairs": [{"left":"","right":""}]
},
"answer": {
"correct_index": 0,
"correct_text": "optional"
}
}
],
"ui_hints": {
"recommended_order": ["g1","k1","v1"],
"show_first": "quiz",
"explain_on_fail": true
}
}
QUESTION GUIDELINES:
- For each grammar item: at least 1 cloze question + 1 meaning/usage question.
- For vocab/key_phrases: at least 1 meaning MC and optionally 1 register/situation question.
- For conversation lines: optionally “what does this mean” or “best reply”.
- Keep prompts short. Do not restate long explanations (shown after fail).
Return ONLY the JSON object. No markdown fences.
""".strip()
def load_env_files(paths: list[Path]) -> None:
"""
Load simple KEY=VALUE pairs from one or more .env files without
overriding existing environment variables.
"""
seen = set()
for path in paths:
try:
resolved = path.resolve()
except FileNotFoundError:
continue
if resolved in seen or not resolved.exists():
continue
seen.add(resolved)
for line in resolved.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip().removeprefix("export ").strip()
value = value.strip()
if value and len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
value = value[1:-1]
if key:
os.environ.setdefault(key, value)
def strip_code_fences(text: str) -> str:
# Remove ```json ... ``` or ``` ... ```
text = text.strip()
text = re.sub(r"^\s*```(?:json)?\s*", "", text, flags=re.IGNORECASE)
text = re.sub(r"\s*```\s*$", "", text)
return text.strip()
def extract_json_object(text: str) -> Dict[str, Any]:
"""
Try to recover JSON if the model wrapped it with text or fences.
"""
cleaned = strip_code_fences(text)
# If it's already pure JSON:
try:
return json.loads(cleaned)
except Exception:
pass
# Otherwise, take substring from first { to last }
start = cleaned.find("{")
end = cleaned.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError("Could not locate JSON object in model output.")
snippet = cleaned[start : end + 1].strip()
return json.loads(snippet)
def validate_minimal_schema(obj: Dict[str, Any]) -> None:
# Minimal checks only (Gemini can be slightly variable)
if not isinstance(obj, dict):
raise ValueError("Top-level JSON is not an object.")
for key in ("meta", "items", "quiz"):
if key not in obj:
raise ValueError(f"Missing required top-level key: {key}")
if "title_en" not in obj["meta"]:
raise ValueError("meta.title_en missing")
if not isinstance(obj["quiz"], list):
raise ValueError("quiz must be an array")
def run_gemini_on_video(
video_path: Path,
video_url: str,
prompt: str,
*,
top_p: float,
temperature: float,
dynamic_thinking: bool,
max_output_tokens: int,
client: replicate.Client,
prefer_wait_seconds: Optional[int] = None,
) -> str:
"""
Calls Replicate model and returns raw text output.
"""
inp = {
"top_p": top_p,
"temperature": temperature,
"dynamic_thinking": dynamic_thinking,
"max_output_tokens": max_output_tokens,
"prompt": prompt,
"images": [],
"videos": [video_url],
}
try:
out = client.run("google/gemini-2.5-flash", input=inp)
if isinstance(out, str):
return out
if isinstance(out, list):
return "".join(str(x) for x in out)
return str(out)
except Exception as e:
raise RuntimeError(f"Replicate call failed for {video_path.name}: {e}") from e
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--data", default="data", help="Data directory containing .mp4 files (default: data)")
ap.add_argument("--prompt-file", default=None, help="Optional prompt.txt to override the default prompt")
ap.add_argument("--only-missing", action="store_true", help="Only process videos without an existing .json")
ap.add_argument("--overwrite", action="store_true", help="Overwrite existing .json outputs")
ap.add_argument("--sleep", type=float, default=0.0, help="Sleep seconds between requests (default: 0)")
ap.add_argument("--top-p", type=float, default=0.95)
ap.add_argument("--temperature", type=float, default=0.7)
ap.add_argument("--dynamic-thinking", action="store_true", default=True,
help="Enable dynamic_thinking (default: ON)")
ap.add_argument("--max-output-tokens", type=int, default=12000,
help="Max output tokens (default: 12000; raise if you need bigger JSON)")
ap.add_argument("--remote-base-url", default=None,
help="Base URL where the --data tree is mirrored (e.g., https://example.com/data)")
args = ap.parse_args()
script_dir = Path(__file__).resolve().parent
load_env_files([Path.cwd() / ".env", script_dir / ".env"])
token = os.environ.get("REPLICATE_API_TOKEN") or os.environ.get("REPLICATE_API_KEY")
if token and not os.environ.get("REPLICATE_API_TOKEN"):
os.environ["REPLICATE_API_TOKEN"] = token # replicate library expects this name
if not token:
print("ERROR: REPLICATE_API_TOKEN not set.", file=sys.stderr)
sys.exit(2)
base_url = args.remote_base_url or os.environ.get("REMOTE_BASE_URL")
if not base_url:
print("ERROR: --remote-base-url or REMOTE_BASE_URL env var is required (public URL of mirrored data)", file=sys.stderr)
sys.exit(2)
base_url = base_url.rstrip("/")
client = replicate.Client()
data_dir = Path(args.data).expanduser().resolve()
if not data_dir.exists():
print(f"ERROR: data dir not found: {data_dir}", file=sys.stderr)
sys.exit(2)
prompt = DEFAULT_PROMPT
if args.prompt_file:
prompt_path = Path(args.prompt_file).expanduser().resolve()
prompt = prompt_path.read_text(encoding="utf-8").strip()
mp4s = sorted(data_dir.rglob("*.mp4"))
if not mp4s:
print(f"No .mp4 files found under {data_dir}")
return
print(f"Found {len(mp4s)} videos under {data_dir}")
for video_path in mp4s:
stem = video_path.stem
out_json = video_path.with_suffix(".json")
out_raw = video_path.with_suffix(".raw.txt")
rel_video = video_path.relative_to(data_dir)
video_url = f"{base_url}/{rel_video.as_posix()}"
if out_json.exists() and args.only_missing:
print(f"SKIP (exists): {rel_video}")
continue
if out_json.exists() and (not args.overwrite) and (not args.only_missing):
print(f"SKIP (use --overwrite to replace): {rel_video}")
continue
# Quick size warning for local uploads
size_mb = video_path.stat().st_size / (1024 * 1024)
if size_mb > 150:
print(f"WARNING: {video_path.name} is {size_mb:.1f}MB (>150MB). "
f"Downloads from the remote server may be slow.")
print(f"RUN: {rel_video}")
try:
raw = run_gemini_on_video(
video_path,
video_url,
prompt,
top_p=args.top_p,
temperature=args.temperature,
dynamic_thinking=True, # you asked for this explicitly
max_output_tokens=args.max_output_tokens,
client=client,
)
out_raw.write_text(raw, encoding="utf-8")
obj = extract_json_object(raw)
validate_minimal_schema(obj)
out_json.write_text(json.dumps(obj, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"OK -> {out_json.relative_to(data_dir)}")
except Exception as e:
print(f"FAIL: {video_path.name}: {e}", file=sys.stderr)
# keep raw if we got it
if out_raw.exists():
print(f" Raw output saved: {out_raw.name}", file=sys.stderr)
if args.sleep > 0:
time.sleep(args.sleep)
if __name__ == "__main__":
main()

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "ig-japanese-quizzer",
"version": "1.0.0",
"private": true,
"workspaces": [
"server",
"client"
],
"scripts": {
"dev": "concurrently \"npm run dev --workspace server\" \"npm run dev --workspace client\"",
"build": "npm run build --workspace server && npm run build --workspace client"
},
"devDependencies": {
"concurrently": "^8.2.2"
}
}

116
prompt.txt Normal file
View File

@@ -0,0 +1,116 @@
You analyze an Instagram-style Japanese language video.
The video is either Japanese-only or English+Japanese.
Goal: Create a compact learning JSON for a custom quiz website.
The website will ask the user questions; if the user is wrong or taps "Don't know",
we will show the explanation from this JSON and the original IG media.
TOKEN BUDGET:
Be concise. Do not duplicate explanations inside questions.
STRICT RULES:
1) Do not invent. Only include words/phrases/grammar that clearly appear in the video.
2) For every Japanese string containing kanji, provide a full hiragana reading in a separate field "kana".
3) Keep it small:
- grammar: max 2
- vocab: max 10
- conversation lines: max 10
- key_phrases: max 10
4) NO timestamps. NO evidence_quote. NO source fields. NO confidence fields.
5) Questions MUST reference items by id (do not repeat long explanations in questions).
6) Provide 13 questions per item (depending on usefulness). Prefer: cloze, multiple choice, match, register-choice.
OUTPUT:
Return ONLY valid JSON (no markdown). UTF-8 Japanese.
SCHEMA:
{
"meta": {
"mode": "ja_only|en+ja",
"type": "grammar|vocab|conversation|mixed|unknown",
"title_en": "short title (5-8 words max)"
},
"items": {
"grammar": [
{
"id": "g1",
"pattern": "string",
"meaning_en": "one line",
"use_note_en": "1-2 lines max",
"register": "polite|neutral|casual|slang|formal|unknown",
"example": {
"jp": "string",
"kana": "string (hiragana reading; required if jp has kanji)",
"en": "string"
}
}
],
"vocab": [
{
"id": "v1",
"jp": "surface form",
"kana": "hiragana reading (required if jp has kanji; for kana-only words keep as-is)",
"meaning_en": "short",
"register": "polite|neutral|casual|slang|formal|unknown",
"note_en": "optional, 1 line max",
"example": {
"jp": "optional",
"kana": "optional",
"en": "optional"
}
}
],
"conversation": [
{
"id": "c1",
"jp": "exact line",
"kana": "hiragana reading (required if jp has kanji)",
"en": "translation",
"register": "polite|neutral|casual|slang|mixed|unknown"
}
],
"key_phrases": [
{
"id": "k1",
"jp": "phrase",
"kana": "reading (hiragana if needed)",
"meaning_en": "short",
"when_to_use_en": "1-2 lines max",
"register": "polite|neutral|casual|slang|formal|unknown"
}
]
},
"quiz": [
{
"id": "q1",
"targets": ["k1"],
"type": "mc_meaning|mc_register|cloze|match|choose_best_reply",
"prompt_en": "string",
"payload": {
"sentence_jp": "optional",
"sentence_kana": "optional",
"blanked": "optional (use ____ for blank)",
"options": ["A", "B", "C", "D"],
"pairs": [{"left":"", "right":""}]
},
"answer": {
"correct_index": 0,
"correct_text": "optional (for non-mc types)"
}
}
],
"ui_hints": {
"recommended_order": ["g1","k1","k2","v1"],
"show_first": "quiz",
"explain_on_fail": true
}
}
QUESTION GUIDELINES:
- For each grammar item: make at least 1 cloze question from the example, and 1 meaning or usage question.
- For vocab/key_phrases: make at least 1 meaning multiple-choice and optionally 1 register or “best situation” question.
- For conversation lines: optionally make “choose the best reply” or “what does this line mean” questions.
- Keep prompts short. Do not restate long explanations. The website will show explanations after.
Now analyze the video and output ONLY the JSON.

23
server/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "ig-japanese-quizzer-server",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc --project tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^4.19.2",
"glob": "^10.3.12",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.4.1",
"ts-node-dev": "^2.0.0",
"typescript": "^5.5.4"
}
}

272
server/src/index.ts Normal file
View File

@@ -0,0 +1,272 @@
import express from 'express';
import path from 'path';
import fs from 'fs/promises';
import { glob } from 'glob';
import { z } from 'zod';
const DEFAULT_DATA_ROOT = path.resolve(__dirname, '..', '..', 'data');
const DATA_ROOT = process.env.DATA_ROOT ? path.resolve(process.env.DATA_ROOT) : DEFAULT_DATA_ROOT;
const metaSchema = z
.object({
mode: z.string().optional(),
type: z.string().optional(),
title_en: z.string().optional(),
})
.partial()
.default({});
const itemsSchema = z
.object({
grammar: z.array(z.record(z.any())).default([]),
vocab: z.array(z.record(z.any())).default([]),
conversation: z.array(z.record(z.any())).default([]),
key_phrases: z.array(z.record(z.any())).default([]),
})
.partial()
.default({
grammar: [],
vocab: [],
conversation: [],
key_phrases: [],
});
const quizSchema = z
.array(
z
.object({
id: z.union([z.string(), z.number()]).optional(),
targets: z.array(z.union([z.string(), z.number()])).default([]),
type: z.string().default(''),
prompt_en: z.string().optional(),
payload: z.record(z.any()).default({}),
answer: z.record(z.any()).default({}),
})
.partial()
)
.default([]);
const uiHintsSchema = z
.object({
recommended_order: z.array(z.union([z.string(), z.number()])).default([]),
show_first: z.string().optional(),
explain_on_fail: z.boolean().optional(),
})
.partial()
.default({ recommended_order: [] });
const entrySchema = z
.object({
meta: metaSchema,
items: itemsSchema,
quiz: quizSchema,
ui_hints: uiHintsSchema,
})
.partial()
.passthrough()
.default({
meta: {},
items: { grammar: [], vocab: [], conversation: [], key_phrases: [] },
quiz: [],
ui_hints: { recommended_order: [] },
});
type EntryData = z.infer<typeof entrySchema>;
interface EntryRecord {
id: string;
title: string;
meta: EntryData['meta'];
items: EntryData['items'];
quiz: EntryData['quiz'];
ui_hints: EntryData['ui_hints'];
videoPath: string;
jsonPath: string;
video_url: string;
counts: {
grammar: number;
vocab: number;
key_phrases: number;
conversation: number;
quiz: number;
};
}
const entryIndex = new Map<string, EntryRecord>();
function ensureWithinDataRoot(targetPath: string) {
const resolved = path.resolve(targetPath);
return resolved === DATA_ROOT || resolved.startsWith(DATA_ROOT + path.sep);
}
async function fileExists(targetPath: string) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
function toPosixId(relativePath: string) {
return relativePath.split(path.sep).join('/');
}
function buildVideoUrl(id: string) {
const encoded = id
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
return `/data/${encoded}.mp4`;
}
function computeCounts(items: EntryData['items'], quiz: EntryData['quiz']) {
return {
grammar: items?.grammar?.length || 0,
vocab: items?.vocab?.length || 0,
key_phrases: items?.key_phrases?.length || 0,
conversation: items?.conversation?.length || 0,
quiz: quiz?.length || 0,
};
}
async function loadEntries() {
entryIndex.clear();
const dataExists = await fileExists(DATA_ROOT);
if (!dataExists) {
console.warn(`Data root not found at ${DATA_ROOT}`);
return;
}
const mp4Paths = await glob('**/*.mp4', { cwd: DATA_ROOT, absolute: true });
for (const mp4Path of mp4Paths) {
const resolvedMp4 = path.resolve(mp4Path);
if (!ensureWithinDataRoot(resolvedMp4)) {
continue;
}
const dir = path.dirname(resolvedMp4);
const baseName = path.basename(resolvedMp4, '.mp4');
const jsonPath = path.join(dir, `${baseName}.json`);
if (!(await fileExists(jsonPath))) {
continue;
}
const resolvedJson = path.resolve(jsonPath);
if (!ensureWithinDataRoot(resolvedJson)) {
continue;
}
let parsed: EntryData | null = null;
try {
const raw = await fs.readFile(resolvedJson, 'utf-8');
const json = JSON.parse(raw);
const safe = entrySchema.safeParse(json);
parsed = safe.success ? safe.data : entrySchema.parse({});
if (!safe.success) {
console.warn(`Entry at ${resolvedJson} parsed with defaults due to validation issues.`);
}
} catch (err) {
console.warn(`Failed to parse ${resolvedJson}:`, err);
parsed = entrySchema.parse({});
}
const relative = path.relative(DATA_ROOT, resolvedMp4);
const id = toPosixId(relative.replace(/\.mp4$/i, ''));
const title = parsed.meta?.title_en?.trim() || baseName;
const video_url = buildVideoUrl(id);
const counts = computeCounts(parsed.items || { grammar: [], vocab: [], conversation: [], key_phrases: [] }, parsed.quiz || []);
entryIndex.set(id, {
id,
title,
meta: parsed.meta || {},
items: parsed.items || { grammar: [], vocab: [], conversation: [], key_phrases: [] },
quiz: parsed.quiz || [],
ui_hints: parsed.ui_hints || { recommended_order: [] },
videoPath: resolvedMp4,
jsonPath: resolvedJson,
video_url,
counts,
});
}
console.log(`Loaded ${entryIndex.size} entries from data directory.`);
}
function sanitizeEntryResponse(entry: EntryRecord) {
return {
id: entry.id,
title: entry.title,
meta: entry.meta || {},
items: entry.items || { grammar: [], vocab: [], conversation: [], key_phrases: [] },
quiz: entry.quiz || [],
ui_hints: entry.ui_hints || { recommended_order: [] },
video_url: entry.video_url,
counts: entry.counts,
};
}
async function main() {
await loadEntries();
const app = express();
const port = process.env.PORT || 5174;
app.disable('x-powered-by');
app.use('/data', express.static(DATA_ROOT));
app.get('/', (_req, res) => {
res.type('text/plain').send('IG Japanese Quizzer backend is running. See /api/entries.');
});
app.get('/api/entries', (_req, res) => {
const entries = Array.from(entryIndex.values())
.map((entry) => ({
id: entry.id,
title: entry.title,
mode: entry.meta?.mode,
type: entry.meta?.type,
counts: entry.counts,
video_url: entry.video_url,
}))
.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }));
res.json(entries);
});
app.get('/api/entry', (req, res) => {
const idParam = req.query.id;
if (!idParam || typeof idParam !== 'string') {
res.status(400).json({ error: 'Missing id query param' });
return;
}
const entry = entryIndex.get(idParam);
if (!entry) {
res.status(404).json({ error: 'Entry not found' });
return;
}
res.json(sanitizeEntryResponse(entry));
});
app.get('/api/health', (_req, res) => {
res.json({ ok: true, entries: entryIndex.size });
});
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
console.log(`Data root: ${DATA_ROOT}`);
console.log(`Entries loaded: ${entryIndex.size}`);
});
}
main().catch((err) => {
console.error('Failed to start server', err);
process.exit(1);
});

17
server/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"rootDir": "src",
"outDir": "dist",
"esModuleInterop": true,
"declaration": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"strict": false,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src/**/*"]
}