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);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
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
|
||||
-------------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
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),
|
||||
// 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
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
const { data, children }: LayoutProps = $props();
|
||||
</script>
|
||||
|
||||
<TopNav />
|
||||
<TopNav isAdmin={data.isAdmin} />
|
||||
|
||||
{#if data.emailUnverified}
|
||||
<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) {
|
||||
redirect(303, '/app');
|
||||
redirect(303, '/app/packs?onboarding=1');
|
||||
}
|
||||
|
||||
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 { 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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, '/');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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",
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue