feat: [frontend] Refactor the packs admin interface
Some checks failed
/ test (push) Has been cancelled

This commit is contained in:
wilson 2026-04-17 09:39:42 +01:00
parent eba8a1b4cd
commit 45336277df
29 changed files with 2422 additions and 452 deletions

185
api/frontend-todo.md Normal file
View 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

View file

@ -387,6 +387,105 @@ body {
margin-top: var(--space-1);
}
/* --------------------------------------------------------------------------
Component: Button (modifiers)
-------------------------------------------------------------------------- */
.btn-sm {
font-size: var(--text-label-md);
padding: 0.125rem var(--space-2);
}
.btn-danger {
color: #b3261e;
}
.btn-danger:hover {
background-color: color-mix(in srgb, #b3261e 8%, transparent);
}
/* --------------------------------------------------------------------------
Component: Badge
-------------------------------------------------------------------------- */
.badge {
display: inline-block;
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 0.125rem var(--space-2);
border-radius: var(--radius-sm);
white-space: nowrap;
}
.badge-primary {
color: var(--color-primary);
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.badge-secondary {
color: var(--color-on-surface-variant);
background-color: var(--color-surface-container);
}
.badge-warning {
color: #b3261e;
background-color: color-mix(in srgb, #b3261e 10%, transparent);
}
/* --------------------------------------------------------------------------
Component: Section card
-------------------------------------------------------------------------- */
.section-card {
background-color: var(--color-surface-container-low);
border-radius: var(--radius-lg);
padding: var(--space-6);
}
/* --------------------------------------------------------------------------
Component: Admin table
-------------------------------------------------------------------------- */
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table thead tr {
background-color: var(--color-surface-container);
}
.admin-table th {
text-align: left;
padding: var(--space-2) var(--space-3);
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-on-surface-variant);
}
.admin-table td {
padding: var(--space-2) var(--space-3);
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface);
vertical-align: middle;
}
.admin-table tbody tr {
border-top: 1px solid var(--color-surface-container);
transition: background-color var(--duration-fast) var(--ease-standard);
}
.admin-table tbody tr:hover {
background-color: var(--color-surface-container);
}
/* --------------------------------------------------------------------------
Component: Alert
-------------------------------------------------------------------------- */

View file

@ -1,40 +1,43 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { decodeJwt, jwtVerify } from 'jose';
import { redirect, type Handle } from '@sveltejs/kit';
import { PRIVATE_JWT_SECRET } from '$env/static/private';
import { client } from './lib/apiClient.ts';
import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth/index.ts';
function verifyJwt(token: string, secret: string): boolean {
try {
const parts = token.split('.');
if (parts.length !== 3) return false;
const [header, payload, sig] = parts;
async function verifyJwt(token: string, secret: string): Promise<boolean> {
const encodedSecret = new TextEncoder().encode(secret);
return await jwtVerify(token, encodedSecret)
.then(() => true)
.catch((e) => {
console.log(`Caught error while validating JWT: ${e}`);
return false;
});
}
const expected = createHmac('sha256', secret)
.update(`${header}.${payload}`)
.digest('base64url');
const expectedBuf = Buffer.from(expected);
const sigBuf = Buffer.from(sig);
if (expectedBuf.length !== sigBuf.length) return false;
if (!timingSafeEqual(expectedBuf, sigBuf)) return false;
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
if (claims.exp && claims.exp < Date.now() / 1000) return false;
return true;
} catch {
return false;
}
function getJwtPayload(jwt: string): { isAdmin: boolean } {
const decodeResult = decodeJwt<{ is_admin: boolean }>(jwt);
return { isAdmin: decodeResult.is_admin };
}
export const handle: Handle = async ({ event, resolve }) => {
event.locals.apiClient = client;
const rawToken = event.cookies.get('auth_token');
const isValid = rawToken ? verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false;
const rawToken = event.cookies.get(COOKIE_NAME_AUTH_TOKEN);
const isValid = rawToken ? await verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false;
console.log({ isValid });
event.locals.authToken = isValid ? rawToken! : null;
if (event.url.pathname.startsWith('/app') && !isValid) {
if (isValid && rawToken) {
const payload = getJwtPayload(rawToken);
event.locals.isAdmin = payload.isAdmin;
} else {
event.locals.isAdmin = false;
console.log(`Not valid and no token`);
}
const { pathname } = event.url;
if ((pathname.startsWith('/app') || pathname.startsWith('/admin')) && !isValid) {
console.log(`Redirecting to login`);
return redirect(307, '/login');
}

View file

@ -0,0 +1 @@
export const COOKIE_NAME_AUTH_TOKEN = 'auth_token';

View file

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

View file

@ -31,6 +31,7 @@ export const load: ServerLoad = async ({ locals, url, cookies }) => {
// Don't hard-block on unverified email yet (login gate is not active),
// but surface the flag so the layout can show a warning banner.
return {
emailUnverified: problemFlags.includes('unvalidated_email')
emailUnverified: problemFlags.includes('unvalidated_email'),
isAdmin: locals.isAdmin
};
};

View file

@ -5,7 +5,7 @@
const { data, children }: LayoutProps = $props();
</script>
<TopNav />
<TopNav isAdmin={data.isAdmin} />
{#if data.emailUnverified}
<div class="email-warning" role="alert">

View 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 {};
};

View file

@ -0,0 +1,7 @@
<script lang="ts">
import type { LayoutProps } from './$types';
const { children }: LayoutProps = $props();
</script>
{@render children()}

View file

@ -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;
}
);

View 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;

View 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>

View file

@ -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;

View 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>

View file

@ -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;

View 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>

View file

@ -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;

View file

@ -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>

View 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;

View 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 &amp; 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>

View file

@ -43,7 +43,7 @@ export const actions = {
});
if (response.status === 200) {
redirect(303, '/app');
redirect(303, '/app/packs?onboarding=1');
}
return { error: 'Something went wrong. Please try again.' };

View 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;

View 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>

View 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;

View 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>

View file

@ -1,3 +1,4 @@
import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth/index.ts';
import { loginApiAuthLoginPost } from '../../client/sdk.gen.ts';
import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
@ -26,7 +27,7 @@ export const actions = {
});
if (response.status === 200 && data) {
cookies.set('auth_token', data.access_token, {
cookies.set(COOKIE_NAME_AUTH_TOKEN, data.access_token, {
path: '/',
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
});

View file

@ -1,7 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth';
export const load: PageServerLoad = async ({ locals, cookies }) => {
cookies.delete(`auth_token`, { path: '/' });
export const load: PageServerLoad = async ({ cookies }) => {
cookies.delete(COOKIE_NAME_AUTH_TOKEN, { path: '/' });
return redirect(307, '/');
};

View file

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

View file

@ -188,7 +188,6 @@ def test_admin_adds_flashcard_template_to_entry(admin_client: httpx.Client):
resp = admin_client.post(
f"/api/admin/packs/{pack_id}/entries/{entry_id}/flashcards",
json={
"card_direction": "target_to_source",
"prompt_text": "aller",
"answer_text": "to go",
"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
body = resp.json()
assert body["card_direction"] == "target_to_source"
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(
f"/api/admin/packs/{pack_id}/entries/{entry_id}/flashcards",
json={
"card_direction": "source_to_target",
"prompt_text": "house",
"answer_text": "maison",
},