feat: [frontend] Refactor the packs admin interface
Some checks failed
/ test (push) Has been cancelled
Some checks failed
/ test (push) Has been cancelled
This commit is contained in:
parent
eba8a1b4cd
commit
45336277df
29 changed files with 2422 additions and 452 deletions
185
api/frontend-todo.md
Normal file
185
api/frontend-todo.md
Normal file
|
|
@ -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
|
||||||
|
|
@ -387,6 +387,105 @@ body {
|
||||||
margin-top: var(--space-1);
|
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
|
Component: Alert
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,43 @@
|
||||||
import { createHmac, timingSafeEqual } from 'crypto';
|
import { decodeJwt, jwtVerify } from 'jose';
|
||||||
import { redirect, type Handle } from '@sveltejs/kit';
|
import { redirect, type Handle } from '@sveltejs/kit';
|
||||||
import { PRIVATE_JWT_SECRET } from '$env/static/private';
|
import { PRIVATE_JWT_SECRET } from '$env/static/private';
|
||||||
import { client } from './lib/apiClient.ts';
|
import { client } from './lib/apiClient.ts';
|
||||||
|
import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth/index.ts';
|
||||||
|
|
||||||
function verifyJwt(token: string, secret: string): boolean {
|
async function verifyJwt(token: string, secret: string): Promise<boolean> {
|
||||||
try {
|
const encodedSecret = new TextEncoder().encode(secret);
|
||||||
const parts = token.split('.');
|
return await jwtVerify(token, encodedSecret)
|
||||||
if (parts.length !== 3) return false;
|
.then(() => true)
|
||||||
const [header, payload, sig] = parts;
|
.catch((e) => {
|
||||||
|
console.log(`Caught error while validating JWT: ${e}`);
|
||||||
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;
|
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 }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.apiClient = client;
|
event.locals.apiClient = client;
|
||||||
|
|
||||||
const rawToken = event.cookies.get('auth_token');
|
const rawToken = event.cookies.get(COOKIE_NAME_AUTH_TOKEN);
|
||||||
const isValid = rawToken ? verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false;
|
const isValid = rawToken ? await verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false;
|
||||||
|
console.log({ isValid });
|
||||||
event.locals.authToken = isValid ? rawToken! : null;
|
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');
|
return redirect(307, '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
1
frontend/src/lib/auth/index.ts
Normal file
1
frontend/src/lib/auth/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const COOKIE_NAME_AUTH_TOKEN = 'auth_token';
|
||||||
|
|
@ -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> = T extends { child?: any } ? Omit<T, "child"> : T;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
|
||||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
|
||||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
|
||||||
|
|
@ -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),
|
// 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.
|
// but surface the flag so the layout can show a warning banner.
|
||||||
return {
|
return {
|
||||||
emailUnverified: problemFlags.includes('unvalidated_email')
|
emailUnverified: problemFlags.includes('unvalidated_email'),
|
||||||
|
isAdmin: locals.isAdmin
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
const { data, children }: LayoutProps = $props();
|
const { data, children }: LayoutProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<TopNav />
|
<TopNav isAdmin={data.isAdmin} />
|
||||||
|
|
||||||
{#if data.emailUnverified}
|
{#if data.emailUnverified}
|
||||||
<div class="email-warning" role="alert">
|
<div class="email-warning" role="alert">
|
||||||
|
|
|
||||||
6
frontend/src/routes/app/admin/+layout.server.ts
Normal file
6
frontend/src/routes/app/admin/+layout.server.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { error, type ServerLoad } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load: ServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.isAdmin) error(403, 'Access denied');
|
||||||
|
return {};
|
||||||
|
};
|
||||||
7
frontend/src/routes/app/admin/+layout.svelte
Normal file
7
frontend/src/routes/app/admin/+layout.svelte
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
|
const { children }: LayoutProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { query, getRequestEvent } from '$app/server';
|
||||||
|
import * as v from 'valibot';
|
||||||
|
import { searchWordformsApiDictionaryWordformsGet } from '../../../../client';
|
||||||
|
import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth';
|
||||||
|
|
||||||
|
export const dictionarySearch = query(
|
||||||
|
v.object({
|
||||||
|
text: v.string(),
|
||||||
|
langCode: v.string()
|
||||||
|
}),
|
||||||
|
async ({ langCode, text }) => {
|
||||||
|
const { cookies } = getRequestEvent();
|
||||||
|
const { data } = await searchWordformsApiDictionaryWordformsGet({
|
||||||
|
headers: { Authorization: `Bearer ${cookies.get(COOKIE_NAME_AUTH_TOKEN)}` },
|
||||||
|
query: { lang_code: langCode, text }
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
);
|
||||||
25
frontend/src/routes/app/admin/packs/+page.server.ts
Normal file
25
frontend/src/routes/app/admin/packs/+page.server.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { type Actions, type ServerLoad } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
listPacksApiAdminPacksGet,
|
||||||
|
publishPackApiAdminPacksPackIdPublishPost
|
||||||
|
} from '../../../../client/sdk.gen.ts';
|
||||||
|
|
||||||
|
export const load: ServerLoad = async ({ locals }) => {
|
||||||
|
const { data } = await listPacksApiAdminPacksGet({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` }
|
||||||
|
});
|
||||||
|
return { packs: data ?? [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
publish: async ({ request, locals }) => {
|
||||||
|
const fd = await request.formData();
|
||||||
|
const packId = fd.get('pack_id') as string;
|
||||||
|
const { response } = await publishPackApiAdminPacksPackIdPublishPost({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId }
|
||||||
|
});
|
||||||
|
if (response.status === 200) return { publishSuccess: true };
|
||||||
|
return { publishError: 'Failed to publish pack.' };
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
133
frontend/src/routes/app/admin/packs/+page.svelte
Normal file
133
frontend/src/routes/app/admin/packs/+page.svelte
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import { formatLanguage } from '$lib/formatters/index';
|
||||||
|
|
||||||
|
const { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
function confirmPublish(event: SubmitEvent) {
|
||||||
|
if (!confirm('Publish this pack? Publishing cannot be undone via the API.')) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="form-eyebrow">Admin</p>
|
||||||
|
<h1 class="page-title">Word Packs</h1>
|
||||||
|
</div>
|
||||||
|
<a href="/app/admin/packs/new" class="btn btn-primary">New pack</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.publishError}
|
||||||
|
<div class="alert alert-error">{form.publishError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.packs.length === 0}
|
||||||
|
<p style="color: var(--color-on-surface-variant)">No packs yet. Create one to get started.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Language pair</th>
|
||||||
|
<th>Levels</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th style="width: 8rem;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.packs as pack}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="/app/admin/packs/{pack.id}" class="link entry-link">{pack.name}</a>
|
||||||
|
<span class="entry-subtitle">{pack.name_target}</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatLanguage(pack.source_lang)} → {formatLanguage(pack.target_lang)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="chips">
|
||||||
|
{#each pack.proficiencies as level}
|
||||||
|
<span class="badge badge-secondary">{level}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if pack.is_published}
|
||||||
|
<span class="badge badge-primary">Published</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-secondary">Draft</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="row-actions">
|
||||||
|
<a href="/app/admin/packs/{pack.id}/edit" class="btn btn-ghost btn-sm">Edit</a>
|
||||||
|
{#if !pack.is_published}
|
||||||
|
<form method="POST" action="?/publish" onsubmit={confirmPublish}>
|
||||||
|
<input type="hidden" name="pack_id" value={pack.id} />
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">Publish</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 56rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-10) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-headline-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-surface-container-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-link {
|
||||||
|
display: block;
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-subtitle {
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { error, redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
getPackApiAdminPacksPackIdGet,
|
||||||
|
publishPackApiAdminPacksPackIdPublishPost,
|
||||||
|
addEntryApiAdminPacksPackIdEntriesPost,
|
||||||
|
removeEntryApiAdminPacksPackIdEntriesEntryIdDelete
|
||||||
|
} from '../../../../../client/sdk.gen.ts';
|
||||||
|
|
||||||
|
export const load: ServerLoad = async ({ locals, params }) => {
|
||||||
|
const { pack_id: packId } = params as { pack_id: string };
|
||||||
|
const { data } = await getPackApiAdminPacksPackIdGet({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId }
|
||||||
|
});
|
||||||
|
if (!data) error(404, 'Pack not found');
|
||||||
|
return { pack: data };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
publish: async ({ locals, params }) => {
|
||||||
|
const { pack_id: packId } = params as { pack_id: string };
|
||||||
|
const { response } = await publishPackApiAdminPacksPackIdPublishPost({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId }
|
||||||
|
});
|
||||||
|
if (response.status === 200) return { publishSuccess: true };
|
||||||
|
return { publishError: 'Failed to publish pack.' };
|
||||||
|
},
|
||||||
|
|
||||||
|
addEntry: async ({ request, locals, params }) => {
|
||||||
|
const { pack_id: packId } = params as { pack_id: string };
|
||||||
|
const fd = await request.formData();
|
||||||
|
const surfaceText = fd.get('surface_text') as string;
|
||||||
|
const senseId = (fd.get('sense_id') as string) || null;
|
||||||
|
|
||||||
|
if (!surfaceText?.trim()) return { addEntryError: 'Surface text is required.' };
|
||||||
|
|
||||||
|
const { response } = await addEntryApiAdminPacksPackIdEntriesPost({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId },
|
||||||
|
body: { surface_text: surfaceText.trim(), sense_id: senseId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 201) return { addEntrySuccess: true };
|
||||||
|
return { addEntryError: 'Failed to add entry.' };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteEntry: async ({ request, locals, params }) => {
|
||||||
|
const { pack_id: packId } = params as { pack_id: string };
|
||||||
|
const fd = await request.formData();
|
||||||
|
const entryId = fd.get('entry_id') as string;
|
||||||
|
|
||||||
|
await removeEntryApiAdminPacksPackIdEntriesEntryIdDelete({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId, entry_id: entryId }
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(303, `/app/admin/packs/${packId}`);
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
430
frontend/src/routes/app/admin/packs/[pack_id]/+page.svelte
Normal file
430
frontend/src/routes/app/admin/packs/[pack_id]/+page.svelte
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import type { WordformMatch } from '../../../../../client/types.gen.ts';
|
||||||
|
import { dictionarySearch } from '../../dictionary-search/dictionarySearch.remote.ts';
|
||||||
|
|
||||||
|
const { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
const pack = $derived(data.pack);
|
||||||
|
|
||||||
|
let searchText = $state('');
|
||||||
|
let searchResults = $derived(
|
||||||
|
(await dictionarySearch({ langCode: pack.target_lang, text: searchText })) ?? []
|
||||||
|
);
|
||||||
|
let selectedSenseId = $state('');
|
||||||
|
let selectedSenseLabel = $state('');
|
||||||
|
|
||||||
|
function selectSense(senseId: string, label: string) {
|
||||||
|
selectedSenseId = senseId;
|
||||||
|
selectedSenseLabel = label;
|
||||||
|
searchResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSense() {
|
||||||
|
selectedSenseId = '';
|
||||||
|
selectedSenseLabel = '';
|
||||||
|
searchResults = [];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/app/admin/packs" class="link">Packs</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span>{pack.name}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="pack-header">
|
||||||
|
<div class="pack-header-info">
|
||||||
|
<p class="form-eyebrow">Admin</p>
|
||||||
|
<h1 class="page-title">
|
||||||
|
{pack.name}
|
||||||
|
{#if pack.is_published}
|
||||||
|
<span class="badge badge-primary">Published</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-secondary">Draft</span>
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
<p class="pack-subtitle">{pack.name_target}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pack-header-actions">
|
||||||
|
<a href="/app/admin/packs/{pack.id}/edit" class="btn btn-secondary">Edit pack</a>
|
||||||
|
{#if !pack.is_published}
|
||||||
|
<form method="POST" action="?/publish">
|
||||||
|
{#if form?.publishError}
|
||||||
|
<p class="alert alert-error" style="margin-bottom: var(--space-2)">
|
||||||
|
{form.publishError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={(e: MouseEvent) => {
|
||||||
|
if (!confirm('Publish this pack? This cannot be undone via the API.'))
|
||||||
|
e.preventDefault();
|
||||||
|
}}>Publish</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pack summary -->
|
||||||
|
<div class="section-card summary-grid">
|
||||||
|
<div>
|
||||||
|
<p class="summary-label">Languages</p>
|
||||||
|
<p class="summary-value">
|
||||||
|
{pack.source_lang.toUpperCase()} → {pack.target_lang.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="summary-label">Proficiency levels</p>
|
||||||
|
<div class="chips">
|
||||||
|
{#each pack.proficiencies as level}
|
||||||
|
<span class="badge badge-secondary">{level}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if pack.description}
|
||||||
|
<div class="summary-full">
|
||||||
|
<p class="summary-label">Description</p>
|
||||||
|
<p>{pack.description}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entries section -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">
|
||||||
|
Entries <span class="section-count">({pack.entries?.length ?? 0})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add entry form -->
|
||||||
|
<div class="add-entry-panel">
|
||||||
|
<p class="form-eyebrow">Add word</p>
|
||||||
|
|
||||||
|
{#if form?.addEntryError}
|
||||||
|
<div class="alert alert-error">{form.addEntryError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="POST" action="?/addEntry" class="form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="surface_text" class="field-label">Word or phrase</label>
|
||||||
|
<input
|
||||||
|
id="surface_text"
|
||||||
|
name="surface_text"
|
||||||
|
class="field-input"
|
||||||
|
placeholder="e.g. manger"
|
||||||
|
bind:value={searchText}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Dictionary sense <span class="optional">(optional)</span></span>
|
||||||
|
<p class="field-hint">Link to a dictionary sense to enable template pre-population.</p>
|
||||||
|
<div class="sense-row">
|
||||||
|
{#if selectedSenseId}
|
||||||
|
<span class="sense-chip">
|
||||||
|
{selectedSenseLabel}
|
||||||
|
<button type="button" onclick={clearSense} class="sense-clear">×</button>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if searchResults.length > 0}
|
||||||
|
<div class="search-results">
|
||||||
|
{#each searchResults as match}
|
||||||
|
<div class="search-result-group">
|
||||||
|
<p class="search-result-meta">
|
||||||
|
{match.lemma.headword} · {match.lemma.pos_raw}{match.lemma.gender
|
||||||
|
? ` · ${match.lemma.gender}.`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
{#each match.senses as sense}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sense-option"
|
||||||
|
onclick={() =>
|
||||||
|
selectSense(sense.id, `${match.lemma.headword}: ${sense.gloss}`)}
|
||||||
|
>
|
||||||
|
<span class="sense-index">{sense.sense_index + 1}.</span>
|
||||||
|
<span>{sense.gloss}</span>
|
||||||
|
{#if sense.topics.length > 0}
|
||||||
|
<span class="sense-topics">{sense.topics.join(', ')}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<input type="hidden" name="sense_id" value={selectedSenseId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add word</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entry list -->
|
||||||
|
{#if (pack.entries?.length ?? 0) === 0}
|
||||||
|
<p style="color: var(--color-on-surface-variant); font-size: var(--text-body-sm)">
|
||||||
|
No entries yet. Add one above.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Word</th>
|
||||||
|
<th>Sense</th>
|
||||||
|
<th>Templates</th>
|
||||||
|
<th style="width: 6rem;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each pack.entries ?? [] as entry}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="/app/admin/packs/{pack.id}/entries/{entry.id}"
|
||||||
|
class="link"
|
||||||
|
style="font-weight: var(--weight-medium)"
|
||||||
|
>
|
||||||
|
{entry.surface_text}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if entry.sense_id}
|
||||||
|
<span class="badge badge-secondary">Sense linked</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-warning">No sense</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--color-on-surface-variant)">
|
||||||
|
{entry.flashcard_templates?.length ?? 0}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="?/deleteEntry">
|
||||||
|
<input type="hidden" name="entry_id" value={entry.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-ghost btn-sm btn-danger"
|
||||||
|
onclick={(e: MouseEvent) => {
|
||||||
|
if (!confirm(`Delete "${entry.surface_text}"?`)) e.preventDefault();
|
||||||
|
}}>Delete</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 56rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-10) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-headline-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-subtitle {
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-title-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-count {
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
font-weight: var(--weight-regular);
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-entry-panel {
|
||||||
|
background-color: var(--color-surface-container);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-4);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional {
|
||||||
|
font-weight: var(--weight-regular);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||||
|
padding: 0.25rem var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-clear {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-clear:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
border: 1px solid var(--color-surface-container-high);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-group {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-surface-container-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-group:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-meta {
|
||||||
|
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);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.375rem var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
transition: background-color var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-option:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-index {
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-topics {
|
||||||
|
font-size: var(--text-label-sm);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
background-color: var(--color-surface-container);
|
||||||
|
padding: 0.125rem var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { error, redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
getPackApiAdminPacksPackIdGet,
|
||||||
|
updatePackApiAdminPacksPackIdPatch
|
||||||
|
} from '../../../../../../client/sdk.gen.ts';
|
||||||
|
|
||||||
|
export const load: ServerLoad = async ({ locals, params }) => {
|
||||||
|
const { pack_id: packId } = params as { pack_id: string };
|
||||||
|
const { data } = await getPackApiAdminPacksPackIdGet({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId }
|
||||||
|
});
|
||||||
|
if (!data) error(404, 'Pack not found');
|
||||||
|
return { pack: data };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, locals, params }) => {
|
||||||
|
const { pack_id: packId } = params as { pack_id: string };
|
||||||
|
const fd = await request.formData();
|
||||||
|
const body = {
|
||||||
|
name: fd.get('name') as string,
|
||||||
|
name_target: fd.get('name_target') as string,
|
||||||
|
description: fd.get('description') as string,
|
||||||
|
description_target: fd.get('description_target') as string,
|
||||||
|
source_lang: fd.get('source_lang') as string,
|
||||||
|
target_lang: fd.get('target_lang') as string,
|
||||||
|
proficiencies: fd.getAll('proficiency') as string[]
|
||||||
|
};
|
||||||
|
|
||||||
|
const { response } = await updatePackApiAdminPacksPackIdPatch({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId },
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200) redirect(303, `/app/admin/packs/${packId}`);
|
||||||
|
return { error: 'Failed to save changes.' };
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
163
frontend/src/routes/app/admin/packs/[pack_id]/edit/+page.svelte
Normal file
163
frontend/src/routes/app/admin/packs/[pack_id]/edit/+page.svelte
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
const { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
const pack = $derived(data.pack);
|
||||||
|
|
||||||
|
const LANGUAGES = [
|
||||||
|
{ code: 'en', label: 'English' },
|
||||||
|
{ code: 'fr', label: 'French' },
|
||||||
|
{ code: 'es', label: 'Spanish' },
|
||||||
|
{ code: 'it', label: 'Italian' },
|
||||||
|
{ code: 'de', label: 'German' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROFICIENCY_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/app/admin/packs" class="link">Packs</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/app/admin/packs/{pack.id}" class="link">{pack.name}</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span>Edit</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="form-eyebrow">Admin</p>
|
||||||
|
<h1 class="page-title">Edit pack</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="alert alert-error">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<form method="POST" class="form">
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="name" class="field-label">Source name</label>
|
||||||
|
<input id="name" name="name" class="field-input" value={pack.name} required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="name_target" class="field-label">Target name</label>
|
||||||
|
<input id="name_target" name="name_target" class="field-input" value={pack.name_target} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="description" class="field-label">Description (source)</label>
|
||||||
|
<textarea id="description" name="description" class="field-textarea" rows={3}>{pack.description}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="description_target" class="field-label">Description (target)</label>
|
||||||
|
<textarea id="description_target" name="description_target" class="field-textarea" rows={3}>{pack.description_target}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="source_lang" class="field-label">Source language</label>
|
||||||
|
<select id="source_lang" name="source_lang" class="field-select">
|
||||||
|
{#each LANGUAGES as lang}
|
||||||
|
<option value={lang.code} selected={pack.source_lang === lang.code}>{lang.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="target_lang" class="field-label">Target language</label>
|
||||||
|
<select id="target_lang" name="target_lang" class="field-select">
|
||||||
|
{#each LANGUAGES as lang}
|
||||||
|
<option value={lang.code} selected={pack.target_lang === lang.code}>{lang.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Proficiency levels</span>
|
||||||
|
<div class="checkboxes">
|
||||||
|
{#each PROFICIENCY_LEVELS as level}
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="proficiency"
|
||||||
|
value={level}
|
||||||
|
checked={pack.proficiencies.includes(level)}
|
||||||
|
/>
|
||||||
|
<span>{level}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||||
|
<a href="/app/admin/packs/{pack.id}" class="btn btn-ghost">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-10) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-headline-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-textarea {
|
||||||
|
min-height: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { error, redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
getPackApiAdminPacksPackIdGet,
|
||||||
|
addFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsPost,
|
||||||
|
removeFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTemplateIdDelete
|
||||||
|
} from '../../../../../../../client/sdk.gen.ts';
|
||||||
|
|
||||||
|
export const load: ServerLoad = async ({ locals, params }) => {
|
||||||
|
const { pack_id: packId, entry_id: entryId } = params as { pack_id: string; entry_id: string };
|
||||||
|
const { data } = await getPackApiAdminPacksPackIdGet({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId }
|
||||||
|
});
|
||||||
|
if (!data) error(404, 'Pack not found');
|
||||||
|
const entry = data.entries?.find((e) => e.id === entryId);
|
||||||
|
if (!entry) error(404, 'Entry not found');
|
||||||
|
return { pack: data, entry };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
addTemplate: async ({ request, locals, params }) => {
|
||||||
|
const { pack_id: packId, entry_id: entryId } = params as {
|
||||||
|
pack_id: string;
|
||||||
|
entry_id: string;
|
||||||
|
};
|
||||||
|
const fd = await request.formData();
|
||||||
|
const body = {
|
||||||
|
prompt_text: fd.get('prompt_text') as string,
|
||||||
|
answer_text: fd.get('answer_text') as string,
|
||||||
|
prompt_context_text: (fd.get('prompt_context_text') as string) || null,
|
||||||
|
answer_context_text: (fd.get('answer_context_text') as string) || null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!body.prompt_text || !body.answer_text) {
|
||||||
|
return { addTemplateError: 'Prompt and answer are required.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { response } = await addFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsPost({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId, entry_id: entryId },
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 201) return { addTemplateSuccess: true };
|
||||||
|
return { addTemplateError: 'Failed to add template.' };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTemplate: async ({ request, locals, params }) => {
|
||||||
|
const { pack_id: packId, entry_id: entryId } = params as {
|
||||||
|
pack_id: string;
|
||||||
|
entry_id: string;
|
||||||
|
};
|
||||||
|
const fd = await request.formData();
|
||||||
|
const templateId = fd.get('template_id') as string;
|
||||||
|
|
||||||
|
await removeFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTemplateIdDelete({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId, entry_id: entryId, template_id: templateId }
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(303, `/app/admin/packs/${packId}/entries/${entryId}`);
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import type { WordformMatch } from '../../../../../../../client/types.gen.ts';
|
||||||
|
import { dictionarySearch } from '../../../../dictionary-search/dictionarySearch.remote.ts';
|
||||||
|
|
||||||
|
const { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
const pack = $derived(data.pack);
|
||||||
|
const entry = $derived(data.entry);
|
||||||
|
const src = $derived(pack.source_lang.toUpperCase());
|
||||||
|
const tgt = $derived(pack.target_lang.toUpperCase());
|
||||||
|
|
||||||
|
let senseData = $state<{ headword: string; gloss: string } | null>(null);
|
||||||
|
|
||||||
|
let promptText = $state('');
|
||||||
|
let answerText = $state('');
|
||||||
|
let promptContextText = $state('');
|
||||||
|
let answerContextText = $state('');
|
||||||
|
|
||||||
|
async function fetchSenseData() {
|
||||||
|
if (!entry.sense_id) return;
|
||||||
|
try {
|
||||||
|
const matches =
|
||||||
|
(await dictionarySearch({ langCode: pack.target_lang, text: entry.surface_text })) ?? [];
|
||||||
|
for (const match of matches) {
|
||||||
|
for (const sense of match.senses) {
|
||||||
|
if (sense.id === entry.sense_id) {
|
||||||
|
senseData = { headword: match.lemma.headword, gloss: sense.gloss };
|
||||||
|
promptText = match.lemma.headword;
|
||||||
|
answerText = sense.gloss;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
fetchSenseData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/app/admin/packs" class="link">Packs</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a href="/app/admin/packs/{pack.id}" class="link">{pack.name}</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span>{entry.surface_text}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="form-eyebrow">Admin</p>
|
||||||
|
<h1 class="page-title">{entry.surface_text}</h1>
|
||||||
|
<p class="pack-subtitle">{pack.name} · {src} → {tgt}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entry info -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="sense-row">
|
||||||
|
<span class="sense-key">Sense</span>
|
||||||
|
{#if entry.sense_id}
|
||||||
|
{#if senseData}
|
||||||
|
<span style="font-weight: var(--weight-medium)">{senseData.headword}</span>
|
||||||
|
<span style="color: var(--color-on-surface-variant)">— {senseData.gloss}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-secondary">Sense linked</span>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-warning">No sense linked</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flashcard templates -->
|
||||||
|
<div class="section-card">
|
||||||
|
<h2 class="section-title">
|
||||||
|
Flashcard templates <span class="section-count"
|
||||||
|
>({entry.flashcard_templates?.length ?? 0})</span
|
||||||
|
>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if (entry.flashcard_templates?.length ?? 0) > 0}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Prompt ({tgt})</th>
|
||||||
|
<th>Answer ({src})</th>
|
||||||
|
<th>Prompt context</th>
|
||||||
|
<th>Answer context</th>
|
||||||
|
<th style="width: 5rem;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each entry.flashcard_templates ?? [] as tmpl}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight: var(--weight-medium)">{tmpl.prompt_text}</td>
|
||||||
|
<td>{tmpl.answer_text}</td>
|
||||||
|
<td style="color: var(--color-on-surface-variant); font-style: italic"
|
||||||
|
>{tmpl.prompt_context_text ?? '—'}</td
|
||||||
|
>
|
||||||
|
<td style="color: var(--color-on-surface-variant); font-style: italic"
|
||||||
|
>{tmpl.answer_context_text ?? '—'}</td
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="?/deleteTemplate">
|
||||||
|
<input type="hidden" name="template_id" value={tmpl.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-ghost btn-sm btn-danger"
|
||||||
|
onclick={(e: MouseEvent) => {
|
||||||
|
if (!confirm('Delete this template?')) e.preventDefault();
|
||||||
|
}}>Delete</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<hr class="divider" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add template form -->
|
||||||
|
<div class="add-template-section">
|
||||||
|
<p class="form-eyebrow">Add template</p>
|
||||||
|
|
||||||
|
{#if form?.addTemplateError}
|
||||||
|
<div class="alert alert-error">{form.addTemplateError}</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.addTemplateSuccess}
|
||||||
|
<p class="success-msg">Template added.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="POST" action="?/addTemplate" class="form">
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="prompt_text" class="field-label">Prompt ({tgt})</label>
|
||||||
|
<input
|
||||||
|
id="prompt_text"
|
||||||
|
name="prompt_text"
|
||||||
|
class="field-input"
|
||||||
|
placeholder="e.g. {entry.surface_text}"
|
||||||
|
bind:value={promptText}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="answer_text" class="field-label">Answer ({src})</label>
|
||||||
|
<input
|
||||||
|
id="answer_text"
|
||||||
|
name="answer_text"
|
||||||
|
class="field-input"
|
||||||
|
placeholder="e.g. translation"
|
||||||
|
bind:value={answerText}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="prompt_context_text" class="field-label">
|
||||||
|
Prompt context <span class="optional">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="prompt_context_text"
|
||||||
|
name="prompt_context_text"
|
||||||
|
class="field-input"
|
||||||
|
placeholder="e.g. il veut [aller] au cinéma"
|
||||||
|
bind:value={promptContextText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="answer_context_text" class="field-label">
|
||||||
|
Answer context <span class="optional">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="answer_context_text"
|
||||||
|
name="answer_context_text"
|
||||||
|
class="field-input"
|
||||||
|
placeholder="e.g. he wants [to go] to the cinema"
|
||||||
|
bind:value={answerContextText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add template</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 48rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-10) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-headline-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-subtitle {
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sense-key {
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-title-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-count {
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
font-weight: var(--weight-regular);
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-surface-container-high);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-surface-container-high);
|
||||||
|
margin: var(--space-5) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-template-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional {
|
||||||
|
font-weight: var(--weight-regular);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-msg {
|
||||||
|
color: #2d6a4f;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
frontend/src/routes/app/admin/packs/new/+page.server.ts
Normal file
36
frontend/src/routes/app/admin/packs/new/+page.server.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
|
||||||
|
import { createPackApiAdminPacksPost } from '../../../../../client/sdk.gen.ts';
|
||||||
|
|
||||||
|
export const load: ServerLoad = async () => {
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, locals }) => {
|
||||||
|
const fd = await request.formData();
|
||||||
|
const body = {
|
||||||
|
name: fd.get('name') as string,
|
||||||
|
name_target: fd.get('name_target') as string,
|
||||||
|
description: fd.get('description') as string,
|
||||||
|
description_target: fd.get('description_target') as string,
|
||||||
|
source_lang: fd.get('source_lang') as string,
|
||||||
|
target_lang: fd.get('target_lang') as string,
|
||||||
|
proficiencies: fd.getAll('proficiency') as string[]
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!body.name || !body.name_target || !body.source_lang || !body.target_lang) {
|
||||||
|
return { error: 'Name and language pair are required.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, response } = await createPackApiAdminPacksPost({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 201 && data?.id) {
|
||||||
|
redirect(303, `/app/admin/packs/${data.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: 'Failed to create pack.' };
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
176
frontend/src/routes/app/admin/packs/new/+page.svelte
Normal file
176
frontend/src/routes/app/admin/packs/new/+page.svelte
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
const { form }: PageProps = $props();
|
||||||
|
|
||||||
|
const LANGUAGES = [
|
||||||
|
{ code: 'en', label: 'English' },
|
||||||
|
{ code: 'fr', label: 'French' },
|
||||||
|
{ code: 'es', label: 'Spanish' },
|
||||||
|
{ code: 'it', label: 'Italian' },
|
||||||
|
{ code: 'de', label: 'German' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROFICIENCY_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/app/admin/packs" class="link">Packs</a>
|
||||||
|
<span>/</span>
|
||||||
|
<span>New pack</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="form-eyebrow">Admin</p>
|
||||||
|
<h1 class="page-title">Create new pack</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="alert alert-error">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<form method="POST" class="form">
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="name" class="field-label">Source name</label>
|
||||||
|
<p class="field-hint">e.g. Food & Drink</p>
|
||||||
|
<input id="name" name="name" class="field-input" placeholder="Food & Drink" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="name_target" class="field-label">Target name</label>
|
||||||
|
<p class="field-hint">e.g. La Nourriture et les Boissons</p>
|
||||||
|
<input
|
||||||
|
id="name_target"
|
||||||
|
name="name_target"
|
||||||
|
class="field-input"
|
||||||
|
placeholder="La Nourriture et les Boissons"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="description" class="field-label">Description (source)</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
class="field-textarea"
|
||||||
|
rows={3}
|
||||||
|
placeholder="A collection of essential food and drink vocabulary."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="description_target" class="field-label">Description (target)</label>
|
||||||
|
<textarea
|
||||||
|
id="description_target"
|
||||||
|
name="description_target"
|
||||||
|
class="field-textarea"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Une collection de vocabulaire essentiel sur la nourriture."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="source_lang" class="field-label">Source language</label>
|
||||||
|
<select id="source_lang" name="source_lang" class="field-select">
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{#each LANGUAGES as lang}
|
||||||
|
<option value={lang.code}>{lang.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="target_lang" class="field-label">Target language</label>
|
||||||
|
<select id="target_lang" name="target_lang" class="field-select">
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{#each LANGUAGES as lang}
|
||||||
|
<option value={lang.code}>{lang.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Proficiency levels</span>
|
||||||
|
<div class="checkboxes">
|
||||||
|
{#each PROFICIENCY_LEVELS as level}
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" name="proficiency" value={level} />
|
||||||
|
<span>{level}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Create pack</button>
|
||||||
|
<a href="/app/admin/packs" class="btn btn-ghost">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-10) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-headline-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-textarea {
|
||||||
|
min-height: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -43,7 +43,7 @@ export const actions = {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
redirect(303, '/app');
|
redirect(303, '/app/packs?onboarding=1');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: 'Something went wrong. Please try again.' };
|
return { error: 'Something went wrong. Please try again.' };
|
||||||
|
|
|
||||||
53
frontend/src/routes/app/packs/+page.server.ts
Normal file
53
frontend/src/routes/app/packs/+page.server.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { Actions, ServerLoad } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
getAccountBffAccountGet,
|
||||||
|
listPacksForSelectionBffPacksGet,
|
||||||
|
addPackToBankApiPacksPackIdAddToBankPost
|
||||||
|
} from '../../../client/sdk.gen.ts';
|
||||||
|
|
||||||
|
export const load: ServerLoad = async ({ locals }) => {
|
||||||
|
const { data: account } = await getAccountBffAccountGet({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstPair = account?.language_pairs?.[0];
|
||||||
|
if (!firstPair) return { packs: [], noPair: true };
|
||||||
|
|
||||||
|
const sourceLang = firstPair.source_language;
|
||||||
|
const targetLang = firstPair.target_language;
|
||||||
|
|
||||||
|
const { data } = await listPacksForSelectionBffPacksGet({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
query: { source_lang: sourceLang, target_lang: targetLang }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
packs: data ?? [],
|
||||||
|
sourceLang,
|
||||||
|
targetLang,
|
||||||
|
noPair: false
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
addToBank: async ({ request, locals }) => {
|
||||||
|
const fd = await request.formData();
|
||||||
|
const packId = fd.get('pack_id') as string;
|
||||||
|
const sourceLang = fd.get('source_lang') as string;
|
||||||
|
const targetLang = fd.get('target_lang') as string;
|
||||||
|
|
||||||
|
const { response, data } = await addPackToBankApiPacksPackIdAddToBankPost({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId },
|
||||||
|
body: { source_lang: sourceLang, target_lang: targetLang }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 201) {
|
||||||
|
return { success: true, packId, added: data?.added ?? [] };
|
||||||
|
}
|
||||||
|
if (response.status === 409) {
|
||||||
|
return { error: (data as { detail?: string })?.detail ?? 'Some words already exist in your bank.', packId };
|
||||||
|
}
|
||||||
|
return { error: 'Something went wrong.', packId };
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
314
frontend/src/routes/app/packs/+page.svelte
Normal file
314
frontend/src/routes/app/packs/+page.svelte
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
const { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
const isOnboarding = $derived(page.url.searchParams.get('onboarding') === '1');
|
||||||
|
|
||||||
|
// Track per-card success state
|
||||||
|
const addedPacks = $derived.by(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
if (form?.success && form?.packId) set.add(form.packId as string);
|
||||||
|
return set;
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorPackId = $derived(form?.success ? null : (form?.packId ?? null));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
{#if isOnboarding}
|
||||||
|
<div class="onboarding-banner">
|
||||||
|
<p class="banner-eyebrow">Getting started</p>
|
||||||
|
<h2 class="banner-heading">Start by adding some words</h2>
|
||||||
|
<p class="banner-body">Pick a pack below to populate your word bank.</p>
|
||||||
|
<a href="/app" class="skip-link">Skip for now →</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Vocabulary</p>
|
||||||
|
<h1 class="page-title">Word Bank Packs</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if data.noPair}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p class="empty-message">Set up a language pair first.</p>
|
||||||
|
<a href="/app/onboarding" class="btn btn-primary">Go to setup</a>
|
||||||
|
</div>
|
||||||
|
{:else if data.packs.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p class="empty-message">No packs available yet for your language pair.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="pack-grid">
|
||||||
|
{#each data.packs as pack}
|
||||||
|
{@const isAdded = pack.already_added || addedPacks.has(pack.id)}
|
||||||
|
{@const justAdded = addedPacks.has(pack.id)}
|
||||||
|
{@const hasError = errorPackId === pack.id && !form?.success}
|
||||||
|
|
||||||
|
<div class="pack-card">
|
||||||
|
<a href="/app/packs/{pack.id}" class="pack-card-link">
|
||||||
|
<div class="pack-card-names">
|
||||||
|
<span class="pack-name">{pack.name}</span>
|
||||||
|
<span class="pack-name-target">{pack.name_target}</span>
|
||||||
|
</div>
|
||||||
|
<p class="pack-description">{pack.description}</p>
|
||||||
|
<div class="pack-meta">
|
||||||
|
<span class="entry-count">{pack.entry_count} words</span>
|
||||||
|
<div class="badge-row">
|
||||||
|
{#each pack.proficiencies as level}
|
||||||
|
<span class="badge">{level}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="pack-card-footer">
|
||||||
|
{#if isAdded}
|
||||||
|
<button type="button" class="btn btn-secondary" disabled>
|
||||||
|
Added ✓
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<form method="POST" action="?/addToBank">
|
||||||
|
<input type="hidden" name="pack_id" value={pack.id} />
|
||||||
|
<input type="hidden" name="source_lang" value={data.sourceLang} />
|
||||||
|
<input type="hidden" name="target_lang" value={data.targetLang} />
|
||||||
|
<button type="submit" class="btn btn-primary">Add to my bank</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if justAdded && form?.added?.length}
|
||||||
|
<p class="add-success">Added {form.added.length} words</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hasError}
|
||||||
|
<p class="add-error">{form?.error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 64rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-16) var(--space-6) var(--space-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------
|
||||||
|
Onboarding banner
|
||||||
|
----------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.onboarding-banner {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-6) var(--space-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-eyebrow {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-heading {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-title-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-md);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------
|
||||||
|
Header
|
||||||
|
----------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-display-sm);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------
|
||||||
|
Pack grid
|
||||||
|
----------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.pack-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-card {
|
||||||
|
background-color: var(--color-surface-container-low);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-card-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-5);
|
||||||
|
text-decoration: none;
|
||||||
|
flex: 1;
|
||||||
|
transition: background-color var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-card-link:hover {
|
||||||
|
background-color: var(--color-surface-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-card-names {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-title-md);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-name-target {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-description {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-count {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--color-on-secondary-container);
|
||||||
|
background-color: var(--color-secondary-container);
|
||||||
|
padding: 0.125rem var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-card-footer {
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
background-color: var(--color-surface-container);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-success {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-error {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
color: var(--color-error, #b00020);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------
|
||||||
|
Empty state
|
||||||
|
----------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
background-color: var(--color-surface-container-low);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-16) var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
55
frontend/src/routes/app/packs/[pack_id]/+page.server.ts
Normal file
55
frontend/src/routes/app/packs/[pack_id]/+page.server.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { error, type Actions, type ServerLoad } from '@sveltejs/kit';
|
||||||
|
import {
|
||||||
|
getPackApiPacksPackIdGet,
|
||||||
|
addPackToBankApiPacksPackIdAddToBankPost,
|
||||||
|
getAccountBffAccountGet
|
||||||
|
} from '../../../../client/sdk.gen.ts';
|
||||||
|
|
||||||
|
export const load: ServerLoad = async ({ locals, params }) => {
|
||||||
|
const packId = params['pack_id'] as string;
|
||||||
|
|
||||||
|
const [packResult, accountResult] = await Promise.all([
|
||||||
|
getPackApiPacksPackIdGet({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId }
|
||||||
|
}),
|
||||||
|
getAccountBffAccountGet({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!packResult.data || packResult.response.status === 404) {
|
||||||
|
error(404, 'Pack not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPair = accountResult.data?.language_pairs?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
pack: packResult.data,
|
||||||
|
sourceLang: firstPair?.source_language ?? '',
|
||||||
|
targetLang: firstPair?.target_language ?? ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
addToBank: async ({ request, locals }) => {
|
||||||
|
const fd = await request.formData();
|
||||||
|
const packId = fd.get('pack_id') as string;
|
||||||
|
const sourceLang = fd.get('source_lang') as string;
|
||||||
|
const targetLang = fd.get('target_lang') as string;
|
||||||
|
|
||||||
|
const { response, data } = await addPackToBankApiPacksPackIdAddToBankPost({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
path: { pack_id: packId },
|
||||||
|
body: { source_lang: sourceLang, target_lang: targetLang }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 201) {
|
||||||
|
return { success: true, added: data?.added ?? [] };
|
||||||
|
}
|
||||||
|
if (response.status === 409) {
|
||||||
|
return { error: (data as { detail?: string })?.detail ?? 'Some words already exist in your bank.' };
|
||||||
|
}
|
||||||
|
return { error: 'Something went wrong.' };
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
229
frontend/src/routes/app/packs/[pack_id]/+page.svelte
Normal file
229
frontend/src/routes/app/packs/[pack_id]/+page.svelte
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
const { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
const isAdded = $derived(form?.success === true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/app/packs">Packs</a>
|
||||||
|
<span>{data.pack.name}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="pack-header">
|
||||||
|
<div class="pack-names">
|
||||||
|
<h1 class="pack-name">{data.pack.name}</h1>
|
||||||
|
<p class="pack-name-target">{data.pack.name_target}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pack-meta">
|
||||||
|
<p class="pack-description">{data.pack.description}</p>
|
||||||
|
<div class="badge-row">
|
||||||
|
{#each data.pack.proficiencies as level}
|
||||||
|
<span class="badge">{level}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="words-section">
|
||||||
|
<p class="word-count">This pack contains {data.pack.surface_texts?.length ?? 0} words</p>
|
||||||
|
|
||||||
|
{#if data.pack.surface_texts?.length}
|
||||||
|
<div class="word-grid">
|
||||||
|
{#each data.pack.surface_texts as word}
|
||||||
|
<span class="word-pill">{word}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="cta-section">
|
||||||
|
{#if isAdded}
|
||||||
|
<div class="success-block">
|
||||||
|
<p class="success-message">Added {form?.added?.length ?? 0} words to your bank.</p>
|
||||||
|
<button type="button" class="btn btn-secondary" disabled>Already added ✓</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form method="POST" action="?/addToBank" class="add-form">
|
||||||
|
<input type="hidden" name="pack_id" value={data.pack.id} />
|
||||||
|
<input type="hidden" name="source_lang" value={data.sourceLang} />
|
||||||
|
<input type="hidden" name="target_lang" value={data.targetLang} />
|
||||||
|
{#if form?.error}
|
||||||
|
<p class="error-message">{form.error}</p>
|
||||||
|
{/if}
|
||||||
|
<button type="submit" class="btn btn-primary">Add all to my bank</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 56rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-16) var(--space-6) var(--space-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb span::before {
|
||||||
|
content: '/';
|
||||||
|
margin-right: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------
|
||||||
|
Header
|
||||||
|
----------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.pack-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-names {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-display-sm);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-name-target {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-description {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-md);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--color-on-secondary-container);
|
||||||
|
background-color: var(--color-secondary-container);
|
||||||
|
padding: 0.125rem var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------
|
||||||
|
Words section
|
||||||
|
----------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.words-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-count {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-pill {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
background-color: var(--color-surface-container-low);
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------
|
||||||
|
CTA section
|
||||||
|
----------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.cta-section {
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-error, #b00020);
|
||||||
|
background-color: color-mix(in srgb, var(--color-error, #b00020) 10%, var(--color-surface));
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border-left: 3px solid var(--color-error, #b00020);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth/index.ts';
|
||||||
import { loginApiAuthLoginPost } from '../../client/sdk.gen.ts';
|
import { loginApiAuthLoginPost } from '../../client/sdk.gen.ts';
|
||||||
import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
|
import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ export const actions = {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 200 && data) {
|
if (response.status === 200 && data) {
|
||||||
cookies.set('auth_token', data.access_token, {
|
cookies.set(COOKIE_NAME_AUTH_TOKEN, data.access_token, {
|
||||||
path: '/',
|
path: '/',
|
||||||
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
|
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, cookies }) => {
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
cookies.delete(`auth_token`, { path: '/' });
|
cookies.delete(COOKIE_NAME_AUTH_TOKEN, { path: '/' });
|
||||||
return redirect(307, '/');
|
return redirect(307, '/');
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,405 +0,0 @@
|
||||||
/* ==========================================================================
|
|
||||||
Design System: The Editorial Stillness
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Fonts
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: archivo; /* set name */
|
|
||||||
src: url('/fonts/Archivo-Medium.woff2'); /* url of the font */
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: archivo; /* set name */
|
|
||||||
src: url('/fonts/Archivo-Regular.woff2'); /* url of the font */
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Design Tokens
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* --- Color: Primary --- */
|
|
||||||
--color-primary: #516356;
|
|
||||||
--color-on-primary: #e9fded;
|
|
||||||
--color-primary-container: #c8d8c8;
|
|
||||||
--color-on-primary-container: #2f342e;
|
|
||||||
|
|
||||||
/* --- Color: Secondary --- */
|
|
||||||
--color-secondary-container: #dde4de;
|
|
||||||
--color-on-secondary-container: #2f342e;
|
|
||||||
|
|
||||||
/* --- Color: Surface Hierarchy (light → dark = elevated → recessed) --- */
|
|
||||||
--color-surface-container-lowest: #ffffff; /* most elevated */
|
|
||||||
--color-surface-container-low: #f4f4ef;
|
|
||||||
--color-surface: #faf9f5; /* base */
|
|
||||||
--color-surface-container: #eeede9;
|
|
||||||
--color-surface-container-high: #e8e8e3;
|
|
||||||
--color-surface-container-highest: #e2e1dd;
|
|
||||||
--color-surface-dim: #d6dcd2; /* recessed utility */
|
|
||||||
|
|
||||||
/* --- Color: On-Surface --- */
|
|
||||||
--color-on-surface: #2f342e; /* replaces pure black */
|
|
||||||
--color-on-surface-variant: #5c605b;
|
|
||||||
|
|
||||||
/* --- Color: Outline --- */
|
|
||||||
--color-outline: #8c908b;
|
|
||||||
--color-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */
|
|
||||||
|
|
||||||
/* --- Typography: Font Families --- */
|
|
||||||
--font-display: 'archivo', sans-serif;
|
|
||||||
--font-body: 'Newsreader', serif;
|
|
||||||
--font-label: 'Inter', sans-serif;
|
|
||||||
|
|
||||||
/* --- Typography: Scale --- */
|
|
||||||
--text-display-lg: 3.5rem; /* article titles */
|
|
||||||
--text-display-md: 2.75rem;
|
|
||||||
--text-display-sm: 2.25rem;
|
|
||||||
--text-headline-lg: 1.875rem;
|
|
||||||
--text-headline-md: 1.5rem;
|
|
||||||
--text-headline-sm: 1.25rem;
|
|
||||||
--text-title-lg: 1.125rem;
|
|
||||||
--text-title-md: 1rem;
|
|
||||||
--text-body-xl: 1.25rem; /* long-form reading standard */
|
|
||||||
--text-body-lg: 1rem;
|
|
||||||
--text-body-md: 0.9375rem;
|
|
||||||
--text-body-sm: 0.875rem;
|
|
||||||
--text-label-lg: 0.875rem;
|
|
||||||
--text-label-md: 0.75rem; /* metadata, all-caps */
|
|
||||||
--text-label-sm: 0.6875rem;
|
|
||||||
|
|
||||||
/* --- Typography: Weights --- */
|
|
||||||
--weight-light: 300;
|
|
||||||
--weight-regular: 400;
|
|
||||||
--weight-medium: 500;
|
|
||||||
--weight-semibold: 600;
|
|
||||||
--weight-bold: 700;
|
|
||||||
|
|
||||||
/* --- Typography: Line Height --- */
|
|
||||||
--leading-tight: 1.2;
|
|
||||||
--leading-snug: 1.375;
|
|
||||||
--leading-normal: 1.5;
|
|
||||||
--leading-relaxed: 1.6; /* "Digital Paper" body text minimum */
|
|
||||||
--leading-loose: 1.8;
|
|
||||||
|
|
||||||
/* --- Typography: Letter Spacing --- */
|
|
||||||
--tracking-tight: -0.025em;
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--tracking-wide: 0.05rem; /* label-md metadata */
|
|
||||||
--tracking-wider: 0.08rem;
|
|
||||||
|
|
||||||
/* --- Spacing Scale (base-4) --- */
|
|
||||||
--space-1: 0.25rem; /* 4px */
|
|
||||||
--space-2: 0.5rem; /* 8px */
|
|
||||||
--space-3: 1rem; /* 16px — list item separation */
|
|
||||||
--space-4: 1.4rem; /* ~22px — list item group separation */
|
|
||||||
--space-5: 1.75rem; /* 28px */
|
|
||||||
--space-6: 2rem; /* 32px */
|
|
||||||
--space-8: 3rem; /* 48px */
|
|
||||||
--space-10: 4rem; /* 64px */
|
|
||||||
--space-12: 4.5rem; /* 72px */
|
|
||||||
--space-16: 5.5rem; /* 88px — top-of-page breathability */
|
|
||||||
|
|
||||||
/* --- Border Radius --- */
|
|
||||||
--radius-xs: 0.125rem;
|
|
||||||
--radius-sm: 0.25rem;
|
|
||||||
--radius-md: 0.375rem; /* primary button */
|
|
||||||
--radius-lg: 0.75rem;
|
|
||||||
--radius-xl: 1.25rem;
|
|
||||||
--radius-full: 9999px;
|
|
||||||
|
|
||||||
/* --- Elevation: Ambient "Tonal Shadow" --- */
|
|
||||||
--shadow-tonal-sm: 0 4px 16px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent);
|
|
||||||
--shadow-tonal-md: 0 8px 32px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent);
|
|
||||||
|
|
||||||
/* --- Motion --- */
|
|
||||||
--duration-fast: 100ms;
|
|
||||||
--duration-normal: 200ms;
|
|
||||||
--duration-slow: 400ms;
|
|
||||||
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
|
||||||
|
|
||||||
/* --- Glass / Frosted Effect --- */
|
|
||||||
--glass-bg: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
|
||||||
--glass-blur: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Reset & Base
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-size: 16px;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-body-lg);
|
|
||||||
font-weight: var(--weight-regular);
|
|
||||||
line-height: var(--leading-relaxed);
|
|
||||||
color: var(--color-on-surface);
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Typography Utilities
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.display-lg {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--text-display-lg);
|
|
||||||
font-weight: var(--weight-bold);
|
|
||||||
line-height: var(--leading-tight);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.headline-md {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--text-headline-md);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-md {
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-label-md);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
color: var(--color-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.link:hover {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Component: Form
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-header {
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-eyebrow {
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-label-md);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-title {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--text-headline-lg);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
line-height: var(--leading-snug);
|
|
||||||
color: var(--color-on-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin-top: var(--space-6);
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-body-sm);
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Component: Button
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--text-label-lg);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
transition:
|
|
||||||
opacity var(--duration-normal) var(--ease-standard),
|
|
||||||
background-color var(--duration-normal) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
color: var(--color-on-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: var(--color-secondary-container);
|
|
||||||
color: var(--color-on-secondary-container);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
padding-inline: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Component: Input
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-label {
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-label-md);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-input {
|
|
||||||
background-color: var(--color-surface-container-high);
|
|
||||||
color: var(--color-on-surface);
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
|
|
||||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-body-lg);
|
|
||||||
line-height: var(--leading-normal);
|
|
||||||
outline: none;
|
|
||||||
width: 100%;
|
|
||||||
transition: border-color var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-disabled {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-body-lg);
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
background-color: var(--color-surface-container);
|
|
||||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-input::placeholder {
|
|
||||||
color: var(--color-outline-variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-input:focus {
|
|
||||||
border-bottom: 2px solid var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-select {
|
|
||||||
background-color: var(--color-surface-container-high);
|
|
||||||
color: var(--color-on-surface);
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
|
|
||||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
|
||||||
padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-body-lg);
|
|
||||||
line-height: var(--leading-normal);
|
|
||||||
outline: none;
|
|
||||||
width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%235c605b' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right var(--space-3) center;
|
|
||||||
transition: border-color var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-select:focus {
|
|
||||||
border-bottom: 2px solid var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-textarea {
|
|
||||||
background-color: var(--color-surface-container-high);
|
|
||||||
color: var(--color-on-surface);
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
|
|
||||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-body-lg);
|
|
||||||
line-height: var(--leading-relaxed);
|
|
||||||
outline: none;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 14rem;
|
|
||||||
resize: vertical;
|
|
||||||
transition: border-color var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-textarea::placeholder {
|
|
||||||
color: var(--color-outline-variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-textarea:focus {
|
|
||||||
border-bottom: 2px solid var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-hint {
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-label-md);
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Component: Alert
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
padding: var(--space-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-body-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-error {
|
|
||||||
background-color: color-mix(in srgb, #b3261e 10%, var(--color-surface));
|
|
||||||
color: #b3261e;
|
|
||||||
border-left: 3px solid #b3261e;
|
|
||||||
}
|
|
||||||
|
|
@ -188,7 +188,6 @@ def test_admin_adds_flashcard_template_to_entry(admin_client: httpx.Client):
|
||||||
resp = admin_client.post(
|
resp = admin_client.post(
|
||||||
f"/api/admin/packs/{pack_id}/entries/{entry_id}/flashcards",
|
f"/api/admin/packs/{pack_id}/entries/{entry_id}/flashcards",
|
||||||
json={
|
json={
|
||||||
"card_direction": "target_to_source",
|
|
||||||
"prompt_text": "aller",
|
"prompt_text": "aller",
|
||||||
"answer_text": "to go",
|
"answer_text": "to go",
|
||||||
"prompt_context_text": "il veut [aller] au cinéma",
|
"prompt_context_text": "il veut [aller] au cinéma",
|
||||||
|
|
@ -197,7 +196,6 @@ def test_admin_adds_flashcard_template_to_entry(admin_client: httpx.Client):
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["card_direction"] == "target_to_source"
|
|
||||||
assert body["prompt_context_text"] == "il veut [aller] au cinéma"
|
assert body["prompt_context_text"] == "il veut [aller] au cinéma"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -211,7 +209,6 @@ def test_admin_get_pack_detail_includes_entries_and_templates(admin_client: http
|
||||||
admin_client.post(
|
admin_client.post(
|
||||||
f"/api/admin/packs/{pack_id}/entries/{entry_id}/flashcards",
|
f"/api/admin/packs/{pack_id}/entries/{entry_id}/flashcards",
|
||||||
json={
|
json={
|
||||||
"card_direction": "source_to_target",
|
|
||||||
"prompt_text": "house",
|
"prompt_text": "house",
|
||||||
"answer_text": "maison",
|
"answer_text": "maison",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue