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:
50
README.md
Normal file
50
README.md
Normal 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
24
client/.gitignore
vendored
Normal 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
73
client/README.md
Normal 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
23
client/eslint.config.js
Normal 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
13
client/index.html
Normal 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
31
client/package.json
Normal 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
1
client/public/vite.svg
Normal 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
45
client/src/App.css
Normal 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
33
client/src/App.tsx
Normal 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
57
client/src/api.ts
Normal 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;
|
||||
}
|
||||
1
client/src/assets/react.svg
Normal file
1
client/src/assets/react.svg
Normal 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 |
26
client/src/components/EntryCard.tsx
Normal file
26
client/src/components/EntryCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
client/src/components/ItemPanels.tsx
Normal file
146
client/src/components/ItemPanels.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
463
client/src/components/QuizRunner.tsx
Normal file
463
client/src/components/QuizRunner.tsx
Normal 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)}>
|
||||
Don’t 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>
|
||||
);
|
||||
}
|
||||
12
client/src/components/VideoPlayer.tsx
Normal file
12
client/src/components/VideoPlayer.tsx
Normal 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
429
client/src/index.css
Normal 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
13
client/src/main.tsx
Normal 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>
|
||||
);
|
||||
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} />;
|
||||
}
|
||||
92
client/src/types.ts
Normal file
92
client/src/types.ts
Normal 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
28
client/tsconfig.app.json
Normal 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
7
client/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
client/tsconfig.node.json
Normal file
26
client/tsconfig.node.json
Normal 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
13
client/vite.config.ts
Normal 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
358
gemini_replicate_batch.py
Normal 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 1–3 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
16
package.json
Normal 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
116
prompt.txt
Normal 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 1–3 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
23
server/package.json
Normal 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
272
server/src/index.ts
Normal 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
17
server/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user