diff --git a/api/frontend-todo.md b/api/frontend-todo.md new file mode 100644 index 0000000..03ccac4 --- /dev/null +++ b/api/frontend-todo.md @@ -0,0 +1,185 @@ +# Frontend TODO + +--- + +## Screen 1 — User: Manual Flashcard Creator + +**Route:** `/flashcards/new` (and `/flashcards/:id/edit` for editing) + +**Purpose:** Allow a user to create a flashcard for a word they want to learn, with optional dictionary linking to anchor it to a specific sense. + +### Layout + +A single-page form divided into two sections: + +**Section A — Word lookup** + +- Text input: "Word or phrase" (`surface_text`). As the user types (debounced ~400ms), call `GET /api/dictionary/wordforms?lang_code={target_lang}&text={input}` and display results inline. +- Dictionary search results display as a list of candidate cards. Each card shows: + - Lemma headword (bold), POS label (e.g. "verb", "noun"), gender if present (e.g. "m." / "f.") + - Indented list of senses, each showing: sense index, gloss (= English translation), topics/tags as small chips +- User clicks a sense to select it. Selected state: the sense is highlighted, the sense `id` is stored, and Section B is pre-populated. +- If no results are found, show a "No dictionary match — you can still create a card manually" message. The form remains usable without a sense link. +- "Clear" button to deselect and start over. + +**Section B — Card content** + +Four text inputs, pre-populated from the selected sense but always editable: + +| Field | Pre-populated from | Label shown to user | +|---|---|---| +| `prompt_text` | `lemma.headword` (if sense selected, else blank) | Prompt (target language) | +| `answer_text` | `sense.gloss` (if sense selected, else blank) | Answer (English) | +| `prompt_context_text` | blank | Context for prompt (optional) | +| `answer_context_text` | blank | Context for answer (optional) | + +Card direction selector: two toggle options — **Recognition** (target → English) and **Production** (English → target). Defaults to both selected (generates two cards). User can deselect one. + +**Save action:** + +1. `POST /api/vocab` with `{ surface_text, language_pair_id, entry_pathway: "manual" }` → returns a `WordBankEntry` with a `bank_entry_id` and `disambiguation_status`. +2. If a sense was selected and `disambiguation_status != "auto_resolved"`: `PATCH /api/vocab/{entry_id}/sense` with `{ sense_id }`. +3. `POST /api/vocab/{entry_id}/flashcards` with `{ direction }` for each selected direction. +4. On success: navigate to the flashcard list or show a confirmation with a "Study now" shortcut. + +**Edit mode (`/flashcards/:id/edit`):** + +- Pre-populate all fields from the existing flashcard record. +- Sense search is pre-filled with the existing `surface_text` and the linked sense highlighted (if present). +- Save updates the flashcard. *(Note: a `PATCH /api/flashcards/:id` endpoint does not yet exist — this needs to be added to the API.)* + +### State notes + +- `language_pair_id` must be known before this screen renders. Resolve it from the user's active language pair (stored in app state / from `GET /api/learnable-languages`). +- A user may have no dictionary match but still create a valid card manually. Do not block submission if the sense search returns nothing. + +--- + +## Screen 2 — Admin: WordBankPack List + +**Route:** `/admin/packs` + +**Auth:** Admin token required. All calls go to `/api/admin/packs/*`. + +**Purpose:** Entry point to the pack CMS. Shows all packs (published and draft) and allows creation of new ones. + +### Layout + +- Page header: "Word Packs" + "New Pack" button (opens Screen 3). +- Table with columns: Name (source lang), Name (target lang), Language pair, Proficiencies, Entries, Status (Published / Draft), Actions (Edit, Publish). +- "Publish" action: `POST /api/admin/packs/{id}/publish`. Disabled if pack is already published. Show a confirmation modal before calling — publishing is not reversible via the API. +- Row click → navigate to Screen 3 (edit mode). + +**Fetch:** `GET /api/admin/packs?source_lang={}&target_lang={}` (filter controls optional). + +--- + +## Screen 3 — Admin: WordBankPack Detail / Editor + +**Route:** `/admin/packs/new` and `/admin/packs/:id` + +**Purpose:** Create or edit a pack, manage its entries, and add flashcard templates to each entry. + +### Layout + +Three vertical sections on the same page: + +--- + +**Section A — Pack metadata** + +Fields mapping directly to `CreatePackRequest` / `UpdatePackRequest`: + +| Field | Input type | Notes | +|---|---|---| +| `name` | text | Pack name in source language (English) | +| `name_target` | text | Pack name in target language (e.g. French) | +| `description` | textarea | Description in source language | +| `description_target` | textarea | Description in target language | +| `source_lang` | select (ISO 639-1) | Disabled after creation | +| `target_lang` | select (ISO 639-1) | Disabled after creation | +| `proficiencies` | multi-select | CEFR values: A1, A2, B1, B2, C1, C2 | + +Save: `POST /api/admin/packs` (new) or `PATCH /api/admin/packs/{id}` (edit). After creation, the page transitions to edit mode with the new pack ID in the URL. + +--- + +**Section B — Pack entries** + +A table/list of the pack's word entries. Each row expands to show flashcard templates (Screen 3B). + +**Adding an entry:** + +Inline form above the list (always visible): + +- Text input: "Word or phrase" (`surface_text`). As the user types, call `GET /api/dictionary/wordforms?lang_code={target_lang}&text={input}` and display results in a dropdown. +- Dropdown shows: headword + POS + gender + each sense's gloss. User selects a specific sense. +- Selected state shows a summary pill: e.g. "aller (verb) — to go". Clear button to deselect. +- "Add entry" button → `POST /api/admin/packs/{id}/entries` with `{ surface_text, sense_id }`. + - `sense_id` is included if a sense was selected; omitted otherwise (entry is created without a sense link — this is valid but means no flashcards can be generated from it until a sense is linked). + +**Entry row (collapsed):** + +- Surface text (bold) +- Sense gloss if linked, or a "⚠ No sense linked" warning badge +- Template count (e.g. "2 templates") +- Delete button → `DELETE /api/admin/packs/{id}/entries/{entry_id}` with confirmation. +- Expand toggle. + +**Entry row (expanded) — Section 3B:** + +Shows the flashcard template sub-list for this entry. See Section C below. + +--- + +**Section C — Flashcard templates (per entry)** + +Rendered inside the expanded entry row. + +A flashcard template defines the canonical prompt/answer for this word when a user adopts the pack. Fields map to `AddFlashcardTemplateRequest`: + +| Field | Input type | Notes | +|---|---|---| +| `card_direction` | select | `target_to_source` (Recognition) / `source_to_target` (Production) | +| `prompt_text` | text | Pre-populated from sense: headword for `target_to_source`, gloss for `source_to_target` | +| `answer_text` | text | Opposite of prompt | +| `prompt_context_text` | text | Optional — example sentence or grammatical cue | +| `answer_context_text` | text | Optional — corresponding target-language context | + +"Add template" button → `POST /api/admin/packs/{id}/entries/{entry_id}/flashcards`. + +Existing templates are listed below the form, each showing all four fields read-only, with a delete button → `DELETE /api/admin/packs/{id}/entries/{entry_id}/flashcards/{template_id}`. + +*(Note: there is no `PATCH` endpoint for templates — delete and re-create to edit.)* + +**Pre-population hint for admins:** When a sense is linked to the entry, the "Add template" form should auto-fill `prompt_text` and `answer_text` based on the selected `card_direction`: +- `target_to_source`: prompt = `lemma.headword`, answer = `sense.gloss` +- `source_to_target`: prompt = `sense.gloss`, answer = `lemma.headword` + +These are editable before submitting. + +--- + +## API reference summary + +| Endpoint | Used by | +|---|---| +| `GET /api/dictionary/wordforms?lang_code=&text=` | Screen 1 (live search), Screen 3 (entry add) | +| `POST /api/vocab` | Screen 1 (save) | +| `PATCH /api/vocab/{id}/sense` | Screen 1 (save, when sense selected) | +| `POST /api/vocab/{id}/flashcards` | Screen 1 (save) | +| `GET /api/admin/packs` | Screen 2 | +| `POST /api/admin/packs` | Screen 3 (new pack) | +| `GET /api/admin/packs/{id}` | Screen 3 (edit pack) | +| `PATCH /api/admin/packs/{id}` | Screen 3 (update metadata) | +| `POST /api/admin/packs/{id}/publish` | Screen 2 | +| `POST /api/admin/packs/{id}/entries` | Screen 3 (add entry) | +| `DELETE /api/admin/packs/{id}/entries/{entry_id}` | Screen 3 (remove entry) | +| `POST /api/admin/packs/{id}/entries/{entry_id}/flashcards` | Screen 3 (add template) | +| `DELETE /api/admin/packs/{id}/entries/{entry_id}/flashcards/{template_id}` | Screen 3 (remove template) | + +## API gaps (need to be added before frontend is complete) + +- `PATCH /api/flashcards/{id}` — update prompt/answer/context text on an existing user flashcard (needed for Screen 1 edit mode) +- `GET /api/flashcards/{id}` — fetch a single flashcard by ID (needed to pre-populate Screen 1 edit mode) +- `GET /api/dictionary/lemmas?lang_code=&headword=` or similar — a headword-level search returning all senses for a lemma directly, useful as a fallback when the wordform search returns no results but the user typed a known headword diff --git a/frontend/src/app.css b/frontend/src/app.css index 38214a2..38b6c2f 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -387,6 +387,105 @@ body { margin-top: var(--space-1); } +/* -------------------------------------------------------------------------- + Component: Button (modifiers) + -------------------------------------------------------------------------- */ + +.btn-sm { + font-size: var(--text-label-md); + padding: 0.125rem var(--space-2); +} + +.btn-danger { + color: #b3261e; +} + +.btn-danger:hover { + background-color: color-mix(in srgb, #b3261e 8%, transparent); +} + +/* -------------------------------------------------------------------------- + Component: Badge + -------------------------------------------------------------------------- */ + +.badge { + display: inline-block; + font-family: var(--font-label); + font-size: var(--text-label-md); + font-weight: var(--weight-medium); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + padding: 0.125rem var(--space-2); + border-radius: var(--radius-sm); + white-space: nowrap; +} + +.badge-primary { + color: var(--color-primary); + background-color: color-mix(in srgb, var(--color-primary) 12%, transparent); +} + +.badge-secondary { + color: var(--color-on-surface-variant); + background-color: var(--color-surface-container); +} + +.badge-warning { + color: #b3261e; + background-color: color-mix(in srgb, #b3261e 10%, transparent); +} + +/* -------------------------------------------------------------------------- + Component: Section card + -------------------------------------------------------------------------- */ + +.section-card { + background-color: var(--color-surface-container-low); + border-radius: var(--radius-lg); + padding: var(--space-6); +} + +/* -------------------------------------------------------------------------- + Component: Admin table + -------------------------------------------------------------------------- */ + +.admin-table { + width: 100%; + border-collapse: collapse; +} + +.admin-table thead tr { + background-color: var(--color-surface-container); +} + +.admin-table th { + text-align: left; + padding: var(--space-2) var(--space-3); + font-family: var(--font-label); + font-size: var(--text-label-md); + font-weight: var(--weight-semibold); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + color: var(--color-on-surface-variant); +} + +.admin-table td { + padding: var(--space-2) var(--space-3); + font-family: var(--font-label); + font-size: var(--text-body-sm); + color: var(--color-on-surface); + vertical-align: middle; +} + +.admin-table tbody tr { + border-top: 1px solid var(--color-surface-container); + transition: background-color var(--duration-fast) var(--ease-standard); +} + +.admin-table tbody tr:hover { + background-color: var(--color-surface-container); +} + /* -------------------------------------------------------------------------- Component: Alert -------------------------------------------------------------------------- */ diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index c1c9fde..833a1a2 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,40 +1,43 @@ -import { createHmac, timingSafeEqual } from 'crypto'; +import { decodeJwt, jwtVerify } from 'jose'; import { redirect, type Handle } from '@sveltejs/kit'; import { PRIVATE_JWT_SECRET } from '$env/static/private'; import { client } from './lib/apiClient.ts'; +import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth/index.ts'; -function verifyJwt(token: string, secret: string): boolean { - try { - const parts = token.split('.'); - if (parts.length !== 3) return false; - const [header, payload, sig] = parts; +async function verifyJwt(token: string, secret: string): Promise { + const encodedSecret = new TextEncoder().encode(secret); + return await jwtVerify(token, encodedSecret) + .then(() => true) + .catch((e) => { + console.log(`Caught error while validating JWT: ${e}`); + return false; + }); +} - const expected = createHmac('sha256', secret) - .update(`${header}.${payload}`) - .digest('base64url'); - - const expectedBuf = Buffer.from(expected); - const sigBuf = Buffer.from(sig); - if (expectedBuf.length !== sigBuf.length) return false; - if (!timingSafeEqual(expectedBuf, sigBuf)) return false; - - const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); - if (claims.exp && claims.exp < Date.now() / 1000) return false; - - return true; - } catch { - return false; - } +function getJwtPayload(jwt: string): { isAdmin: boolean } { + const decodeResult = decodeJwt<{ is_admin: boolean }>(jwt); + return { isAdmin: decodeResult.is_admin }; } export const handle: Handle = async ({ event, resolve }) => { event.locals.apiClient = client; - const rawToken = event.cookies.get('auth_token'); - const isValid = rawToken ? verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false; + const rawToken = event.cookies.get(COOKIE_NAME_AUTH_TOKEN); + const isValid = rawToken ? await verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false; + console.log({ isValid }); event.locals.authToken = isValid ? rawToken! : null; - if (event.url.pathname.startsWith('/app') && !isValid) { + if (isValid && rawToken) { + const payload = getJwtPayload(rawToken); + event.locals.isAdmin = payload.isAdmin; + } else { + event.locals.isAdmin = false; + console.log(`Not valid and no token`); + } + + const { pathname } = event.url; + if ((pathname.startsWith('/app') || pathname.startsWith('/admin')) && !isValid) { + console.log(`Redirecting to login`); return redirect(307, '/login'); } diff --git a/frontend/src/lib/auth/index.ts b/frontend/src/lib/auth/index.ts new file mode 100644 index 0000000..2c15d36 --- /dev/null +++ b/frontend/src/lib/auth/index.ts @@ -0,0 +1 @@ +export const COOKIE_NAME_AUTH_TOKEN = 'auth_token'; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts deleted file mode 100644 index 55b3a91..0000000 --- a/frontend/src/lib/utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type WithoutChild = T extends { child?: any } ? Omit : T; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type WithoutChildren = T extends { children?: any } ? Omit : T; -export type WithoutChildrenOrChild = WithoutChildren>; -export type WithElementRef = T & { ref?: U | null }; diff --git a/frontend/src/routes/app/+layout.server.ts b/frontend/src/routes/app/+layout.server.ts index b698d8e..c8ddc9a 100644 --- a/frontend/src/routes/app/+layout.server.ts +++ b/frontend/src/routes/app/+layout.server.ts @@ -31,6 +31,7 @@ export const load: ServerLoad = async ({ locals, url, cookies }) => { // Don't hard-block on unverified email yet (login gate is not active), // but surface the flag so the layout can show a warning banner. return { - emailUnverified: problemFlags.includes('unvalidated_email') + emailUnverified: problemFlags.includes('unvalidated_email'), + isAdmin: locals.isAdmin }; }; diff --git a/frontend/src/routes/app/+layout.svelte b/frontend/src/routes/app/+layout.svelte index 50cd343..1a72e48 100644 --- a/frontend/src/routes/app/+layout.svelte +++ b/frontend/src/routes/app/+layout.svelte @@ -5,7 +5,7 @@ const { data, children }: LayoutProps = $props(); - + {#if data.emailUnverified}