Compare commits
No commits in common. "45336277df59fcb4d584bee7f3779b35db0fe6ce" and "c6fab5fdbbb9b2af2726b9dfea938411a82415c7" have entirely different histories.
45336277df
...
c6fab5fdbb
54 changed files with 196 additions and 4106 deletions
3
Makefile
3
Makefile
|
|
@ -17,9 +17,6 @@ shell:
|
||||||
|
|
||||||
# Run pending migrations against the running db container
|
# Run pending migrations against the running db container
|
||||||
migrate:
|
migrate:
|
||||||
docker compose build api --no-cache && docker compose up -d && docker compose exec api alembic upgrade head
|
|
||||||
|
|
||||||
migrate-no-build:
|
|
||||||
docker compose exec api alembic upgrade head
|
docker compose exec api alembic upgrade head
|
||||||
|
|
||||||
# Generate a new migration: make migration NAME="add foo table"
|
# Generate a new migration: make migration NAME="add foo table"
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
"""drop card_direction from flashcard and word_bank_pack_flashcard_template
|
|
||||||
|
|
||||||
Revision ID: 0014
|
|
||||||
Revises: 0013
|
|
||||||
Create Date: 2026-04-17
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "0014"
|
|
||||||
down_revision: Union[str, None] = "0013"
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.drop_column("flashcard", "card_direction")
|
|
||||||
op.drop_column("word_bank_pack_flashcard_template", "card_direction")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.add_column("word_bank_pack_flashcard_template", sa.Column("card_direction", sa.Text(), nullable=False, server_default="both"))
|
|
||||||
op.add_column("flashcard", sa.Column("card_direction", sa.Text(), nullable=False, server_default="both"))
|
|
||||||
|
|
@ -13,6 +13,7 @@ class Flashcard:
|
||||||
answer_text: str
|
answer_text: str
|
||||||
prompt_context_text: str | None
|
prompt_context_text: str | None
|
||||||
answer_context_text: str | None
|
answer_context_text: str | None
|
||||||
|
card_direction: str
|
||||||
prompt_modality: str
|
prompt_modality: str
|
||||||
source_pack_flashcard_template_id: str | None
|
source_pack_flashcard_template_id: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class WordBankPackEntry:
|
||||||
class WordBankPackFlashcardTemplate:
|
class WordBankPackFlashcardTemplate:
|
||||||
id: str
|
id: str
|
||||||
pack_entry_id: str
|
pack_entry_id: str
|
||||||
|
card_direction: str
|
||||||
prompt_text: str
|
prompt_text: str
|
||||||
answer_text: str
|
answer_text: str
|
||||||
prompt_context_text: str | None
|
prompt_context_text: str | None
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ class FlashcardService:
|
||||||
target_lang=pair.target_lang,
|
target_lang=pair.target_lang,
|
||||||
prompt_text=prompt,
|
prompt_text=prompt,
|
||||||
answer_text=answer,
|
answer_text=answer,
|
||||||
|
card_direction=d,
|
||||||
)
|
)
|
||||||
flashcards.append(card)
|
flashcards.append(card)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ class PackService:
|
||||||
async def add_flashcard_template_to_entry(
|
async def add_flashcard_template_to_entry(
|
||||||
self,
|
self,
|
||||||
pack_entry_id: uuid.UUID,
|
pack_entry_id: uuid.UUID,
|
||||||
|
card_direction: str,
|
||||||
prompt_text: str,
|
prompt_text: str,
|
||||||
answer_text: str,
|
answer_text: str,
|
||||||
prompt_context_text: str | None = None,
|
prompt_context_text: str | None = None,
|
||||||
|
|
@ -114,6 +115,7 @@ class PackService:
|
||||||
) -> WordBankPackFlashcardTemplate:
|
) -> WordBankPackFlashcardTemplate:
|
||||||
return await self.pack_repo.add_flashcard_template(
|
return await self.pack_repo.add_flashcard_template(
|
||||||
pack_entry_id=pack_entry_id,
|
pack_entry_id=pack_entry_id,
|
||||||
|
card_direction=card_direction,
|
||||||
prompt_text=prompt_text,
|
prompt_text=prompt_text,
|
||||||
answer_text=answer_text,
|
answer_text=answer_text,
|
||||||
prompt_context_text=prompt_context_text,
|
prompt_context_text=prompt_context_text,
|
||||||
|
|
@ -182,6 +184,7 @@ class PackService:
|
||||||
target_lang=pair.target_lang,
|
target_lang=pair.target_lang,
|
||||||
prompt_text=template.prompt_text,
|
prompt_text=template.prompt_text,
|
||||||
answer_text=template.answer_text,
|
answer_text=template.answer_text,
|
||||||
|
card_direction=template.card_direction,
|
||||||
prompt_context_text=template.prompt_context_text,
|
prompt_context_text=template.prompt_context_text,
|
||||||
answer_context_text=template.answer_context_text,
|
answer_context_text=template.answer_context_text,
|
||||||
source_pack_flashcard_template_id=uuid.UUID(template.id),
|
source_pack_flashcard_template_id=uuid.UUID(template.id),
|
||||||
|
|
@ -214,10 +217,11 @@ class PackService:
|
||||||
lemma = await self.dict_repo.get_lemma(uuid.UUID(sense.lemma_id))
|
lemma = await self.dict_repo.get_lemma(uuid.UUID(sense.lemma_id))
|
||||||
if lemma is None:
|
if lemma is None:
|
||||||
return
|
return
|
||||||
for prompt, answer in [
|
for direction in ("target_to_source", "source_to_target"):
|
||||||
(lemma.headword, sense.gloss),
|
if direction == "target_to_source":
|
||||||
(sense.gloss, lemma.headword),
|
prompt, answer = lemma.headword, sense.gloss
|
||||||
]:
|
else:
|
||||||
|
prompt, answer = sense.gloss, lemma.headword
|
||||||
await self.flashcard_repo.create_flashcard(
|
await self.flashcard_repo.create_flashcard(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
bank_entry_id=bank_entry_id,
|
bank_entry_id=bank_entry_id,
|
||||||
|
|
@ -225,4 +229,5 @@ class PackService:
|
||||||
target_lang=target_lang,
|
target_lang=target_lang,
|
||||||
prompt_text=prompt,
|
prompt_text=prompt,
|
||||||
answer_text=answer,
|
answer_text=answer,
|
||||||
|
card_direction=direction,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ class FlashcardEntity(Base):
|
||||||
answer_text: Mapped[str] = mapped_column(Text, nullable=False)
|
answer_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
prompt_context_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
prompt_context_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
answer_context_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
answer_context_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
card_direction: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
prompt_modality: Mapped[str] = mapped_column(Text, nullable=False, default="text")
|
prompt_modality: Mapped[str] = mapped_column(Text, nullable=False, default="text")
|
||||||
source_pack_flashcard_template_id: Mapped[uuid.UUID | None] = mapped_column(
|
source_pack_flashcard_template_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True),
|
UUID(as_uuid=True),
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ class WordBankPackFlashcardTemplateEntity(Base):
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
card_direction: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
prompt_text: Mapped[str] = mapped_column(Text, nullable=False)
|
prompt_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
answer_text: Mapped[str] = mapped_column(Text, nullable=False)
|
answer_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
prompt_context_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
prompt_context_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class FlashcardRepository(Protocol):
|
||||||
target_lang: str,
|
target_lang: str,
|
||||||
prompt_text: str,
|
prompt_text: str,
|
||||||
answer_text: str,
|
answer_text: str,
|
||||||
|
card_direction: str,
|
||||||
prompt_modality: str = "text",
|
prompt_modality: str = "text",
|
||||||
prompt_context_text: str | None = None,
|
prompt_context_text: str | None = None,
|
||||||
answer_context_text: str | None = None,
|
answer_context_text: str | None = None,
|
||||||
|
|
@ -48,6 +49,7 @@ def _flashcard_to_model(entity: FlashcardEntity) -> Flashcard:
|
||||||
answer_text=entity.answer_text,
|
answer_text=entity.answer_text,
|
||||||
prompt_context_text=entity.prompt_context_text,
|
prompt_context_text=entity.prompt_context_text,
|
||||||
answer_context_text=entity.answer_context_text,
|
answer_context_text=entity.answer_context_text,
|
||||||
|
card_direction=entity.card_direction,
|
||||||
prompt_modality=entity.prompt_modality,
|
prompt_modality=entity.prompt_modality,
|
||||||
source_pack_flashcard_template_id=(
|
source_pack_flashcard_template_id=(
|
||||||
str(entity.source_pack_flashcard_template_id)
|
str(entity.source_pack_flashcard_template_id)
|
||||||
|
|
@ -81,6 +83,7 @@ class PostgresFlashcardRepository:
|
||||||
target_lang: str,
|
target_lang: str,
|
||||||
prompt_text: str,
|
prompt_text: str,
|
||||||
answer_text: str,
|
answer_text: str,
|
||||||
|
card_direction: str,
|
||||||
prompt_modality: str = "text",
|
prompt_modality: str = "text",
|
||||||
prompt_context_text: str | None = None,
|
prompt_context_text: str | None = None,
|
||||||
answer_context_text: str | None = None,
|
answer_context_text: str | None = None,
|
||||||
|
|
@ -95,6 +98,7 @@ class PostgresFlashcardRepository:
|
||||||
answer_text=answer_text,
|
answer_text=answer_text,
|
||||||
prompt_context_text=prompt_context_text,
|
prompt_context_text=prompt_context_text,
|
||||||
answer_context_text=answer_context_text,
|
answer_context_text=answer_context_text,
|
||||||
|
card_direction=card_direction,
|
||||||
prompt_modality=prompt_modality,
|
prompt_modality=prompt_modality,
|
||||||
source_pack_flashcard_template_id=source_pack_flashcard_template_id,
|
source_pack_flashcard_template_id=source_pack_flashcard_template_id,
|
||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ class PackRepository(Protocol):
|
||||||
async def add_flashcard_template(
|
async def add_flashcard_template(
|
||||||
self,
|
self,
|
||||||
pack_entry_id: uuid.UUID,
|
pack_entry_id: uuid.UUID,
|
||||||
|
card_direction: str,
|
||||||
prompt_text: str,
|
prompt_text: str,
|
||||||
answer_text: str,
|
answer_text: str,
|
||||||
prompt_context_text: str | None = None,
|
prompt_context_text: str | None = None,
|
||||||
|
|
@ -113,6 +114,7 @@ def _template_to_model(entity: WordBankPackFlashcardTemplateEntity) -> WordBankP
|
||||||
return WordBankPackFlashcardTemplate(
|
return WordBankPackFlashcardTemplate(
|
||||||
id=str(entity.id),
|
id=str(entity.id),
|
||||||
pack_entry_id=str(entity.pack_entry_id),
|
pack_entry_id=str(entity.pack_entry_id),
|
||||||
|
card_direction=entity.card_direction,
|
||||||
prompt_text=entity.prompt_text,
|
prompt_text=entity.prompt_text,
|
||||||
answer_text=entity.answer_text,
|
answer_text=entity.answer_text,
|
||||||
prompt_context_text=entity.prompt_context_text,
|
prompt_context_text=entity.prompt_context_text,
|
||||||
|
|
@ -248,6 +250,7 @@ class PostgresPackRepository:
|
||||||
async def add_flashcard_template(
|
async def add_flashcard_template(
|
||||||
self,
|
self,
|
||||||
pack_entry_id: uuid.UUID,
|
pack_entry_id: uuid.UUID,
|
||||||
|
card_direction: str,
|
||||||
prompt_text: str,
|
prompt_text: str,
|
||||||
answer_text: str,
|
answer_text: str,
|
||||||
prompt_context_text: str | None = None,
|
prompt_context_text: str | None = None,
|
||||||
|
|
@ -255,6 +258,7 @@ class PostgresPackRepository:
|
||||||
) -> WordBankPackFlashcardTemplate:
|
) -> WordBankPackFlashcardTemplate:
|
||||||
entity = WordBankPackFlashcardTemplateEntity(
|
entity = WordBankPackFlashcardTemplateEntity(
|
||||||
pack_entry_id=pack_entry_id,
|
pack_entry_id=pack_entry_id,
|
||||||
|
card_direction=card_direction,
|
||||||
prompt_text=prompt_text,
|
prompt_text=prompt_text,
|
||||||
answer_text=answer_text,
|
answer_text=answer_text,
|
||||||
prompt_context_text=prompt_context_text,
|
prompt_context_text=prompt_context_text,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ class AddEntryRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class AddFlashcardTemplateRequest(BaseModel):
|
class AddFlashcardTemplateRequest(BaseModel):
|
||||||
|
card_direction: str
|
||||||
prompt_text: str
|
prompt_text: str
|
||||||
answer_text: str
|
answer_text: str
|
||||||
prompt_context_text: str | None = None
|
prompt_context_text: str | None = None
|
||||||
|
|
@ -50,6 +51,7 @@ class AddFlashcardTemplateRequest(BaseModel):
|
||||||
class FlashcardTemplateResponse(BaseModel):
|
class FlashcardTemplateResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
pack_entry_id: str
|
pack_entry_id: str
|
||||||
|
card_direction: str
|
||||||
prompt_text: str
|
prompt_text: str
|
||||||
answer_text: str
|
answer_text: str
|
||||||
prompt_context_text: str | None
|
prompt_context_text: str | None
|
||||||
|
|
@ -244,6 +246,7 @@ async def add_flashcard_template(
|
||||||
) -> FlashcardTemplateResponse:
|
) -> FlashcardTemplateResponse:
|
||||||
template = await _service(db).add_flashcard_template_to_entry(
|
template = await _service(db).add_flashcard_template_to_entry(
|
||||||
pack_entry_id=_parse_uuid(entry_id),
|
pack_entry_id=_parse_uuid(entry_id),
|
||||||
|
card_direction=request.card_direction,
|
||||||
prompt_text=request.prompt_text,
|
prompt_text=request.prompt_text,
|
||||||
answer_text=request.answer_text,
|
answer_text=request.answer_text,
|
||||||
prompt_context_text=request.prompt_context_text,
|
prompt_context_text=request.prompt_context_text,
|
||||||
|
|
@ -291,6 +294,7 @@ def _to_template_response(template) -> FlashcardTemplateResponse:
|
||||||
return FlashcardTemplateResponse(
|
return FlashcardTemplateResponse(
|
||||||
id=template.id,
|
id=template.id,
|
||||||
pack_entry_id=template.pack_entry_id,
|
pack_entry_id=template.pack_entry_id,
|
||||||
|
card_direction=template.card_direction,
|
||||||
prompt_text=template.prompt_text,
|
prompt_text=template.prompt_text,
|
||||||
answer_text=template.answer_text,
|
answer_text=template.answer_text,
|
||||||
prompt_context_text=template.prompt_context_text,
|
prompt_context_text=template.prompt_context_text,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class FlashcardResponse(BaseModel):
|
||||||
answer_text: str
|
answer_text: str
|
||||||
prompt_context_text: str | None
|
prompt_context_text: str | None
|
||||||
answer_context_text: str | None
|
answer_context_text: str | None
|
||||||
|
card_direction: str
|
||||||
prompt_modality: str
|
prompt_modality: str
|
||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
|
|
@ -136,6 +137,7 @@ def _flashcard_response(card) -> FlashcardResponse:
|
||||||
answer_text=card.answer_text,
|
answer_text=card.answer_text,
|
||||||
prompt_context_text=card.prompt_context_text,
|
prompt_context_text=card.prompt_context_text,
|
||||||
answer_context_text=card.answer_context_text,
|
answer_context_text=card.answer_context_text,
|
||||||
|
card_direction=card.card_direction,
|
||||||
prompt_modality=card.prompt_modality,
|
prompt_modality=card.prompt_modality,
|
||||||
created_at=card.created_at.isoformat(),
|
created_at=card.created_at.isoformat(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
"""
|
|
||||||
Clear all rows from dictionary tables and re-import from source JSONL files.
|
|
||||||
|
|
||||||
Usage (from api/ directory):
|
|
||||||
uv run ./scripts/clear_dictionary.py
|
|
||||||
|
|
||||||
# Dry-run: clear only, no re-import
|
|
||||||
uv run ./scripts/clear_dictionary.py --no-import
|
|
||||||
|
|
||||||
DATABASE_URL defaults to postgresql+asyncpg://langlearn:langlearn@localhost:5432/langlearn
|
|
||||||
which matches the docker-compose dev credentials when the DB port is exposed on the host.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
# Re-use table definitions and run_import from the sibling script so there is
|
|
||||||
# no duplication of schema knowledge.
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
|
||||||
from import_dictionary import ( # noqa: E402
|
|
||||||
_LANG_FILE_MAP,
|
|
||||||
_lemma_table,
|
|
||||||
_raw_table,
|
|
||||||
_sense_link_table,
|
|
||||||
_sense_table,
|
|
||||||
_wordform_table,
|
|
||||||
run_import,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete order respects foreign-key dependencies:
|
|
||||||
# sense_link → sense
|
|
||||||
# sense → lemma
|
|
||||||
# wordform → lemma
|
|
||||||
# raw → lemma
|
|
||||||
# lemma (parent)
|
|
||||||
_DELETE_ORDER = [
|
|
||||||
_sense_link_table,
|
|
||||||
_sense_table,
|
|
||||||
_wordform_table,
|
|
||||||
_raw_table,
|
|
||||||
_lemma_table,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def clear_all(database_url: str) -> None:
|
|
||||||
engine = create_async_engine(database_url, echo=False)
|
|
||||||
try:
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
print("Clearing all dictionary tables...")
|
|
||||||
for table in _DELETE_ORDER:
|
|
||||||
result = await conn.execute(sa.delete(table))
|
|
||||||
print(f" Deleted {result.rowcount} rows from {table.name}")
|
|
||||||
await conn.commit()
|
|
||||||
print("All dictionary tables cleared.")
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def main(run_reimport: bool, batch_size: int) -> None:
|
|
||||||
database_url = os.environ.get(
|
|
||||||
"DATABASE_URL",
|
|
||||||
"postgresql+asyncpg://langlearn:changeme@localhost:5432/langlearn",
|
|
||||||
)
|
|
||||||
|
|
||||||
await clear_all(database_url)
|
|
||||||
|
|
||||||
if not run_reimport:
|
|
||||||
return
|
|
||||||
|
|
||||||
for lang_code in _LANG_FILE_MAP:
|
|
||||||
print(f"\nRe-importing language={lang_code!r}...")
|
|
||||||
await run_import(lang_code, batch_size)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Clear all dictionary tables and optionally re-import."
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-import",
|
|
||||||
action="store_true",
|
|
||||||
help="Clear tables only; skip re-import.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--batch-size",
|
|
||||||
type=int,
|
|
||||||
default=1000,
|
|
||||||
help="Rows per commit during re-import (default: 1000)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
asyncio.run(main(run_reimport=not args.no_import, batch_size=args.batch_size))
|
|
||||||
|
|
@ -23,8 +23,7 @@ from pathlib import Path
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
_API_DIR = Path(__file__).parent.parent
|
_API_DIR = Path(__file__).parent.parent
|
||||||
_REPO_ROOT = _API_DIR.parent
|
_REPO_ROOT = _API_DIR.parent
|
||||||
|
|
@ -70,39 +69,6 @@ _GENDER_MAP: dict[str, str] = {
|
||||||
"common": "common",
|
"common": "common",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Deterministic UUID namespace
|
|
||||||
#
|
|
||||||
# All dictionary entity IDs are derived via uuid5(namespace, natural_key) so
|
|
||||||
# that re-importing the same kaikki data always produces the same UUIDs. This
|
|
||||||
# means:
|
|
||||||
# • Re-imports update rows in place (upsert) without changing PKs, so
|
|
||||||
# learnable_word_bank_entry / word_bank_pack_entry FK references are never
|
|
||||||
# nullified by a re-import.
|
|
||||||
# • WordPacks developed in one environment can be transferred to another
|
|
||||||
# environment that imported from the same kaikki dataset, because sense UUIDs
|
|
||||||
# will be identical in both.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_KAIKKI_UUID_NS = uuid.UUID("c7d8e9f0-1234-5678-abcd-ef0123456789")
|
|
||||||
|
|
||||||
|
|
||||||
def _lemma_uuid(lang_code: str, word: str, pos: str, etymology_number: int, sense_ids: list[str]) -> uuid.UUID:
|
|
||||||
# Include sorted sense IDs so that two kaikki entries with the same
|
|
||||||
# (word, pos, etymology_number) but different senses get distinct UUIDs.
|
|
||||||
sense_key = ":".join(sorted(sense_ids))
|
|
||||||
return uuid.uuid5(_KAIKKI_UUID_NS, f"kaikki:lemma:{lang_code}:{word}:{pos}:{etymology_number}:{sense_key}")
|
|
||||||
|
|
||||||
|
|
||||||
def _sense_uuid(kaikki_sense_id: str) -> uuid.UUID:
|
|
||||||
return uuid.uuid5(_KAIKKI_UUID_NS, f"kaikki:sense:{kaikki_sense_id}")
|
|
||||||
|
|
||||||
|
|
||||||
def _wordform_uuid(lemma_id: uuid.UUID, form: str, tags: list[str]) -> uuid.UUID:
|
|
||||||
tags_key = ",".join(sorted(tags))
|
|
||||||
return uuid.uuid5(_KAIKKI_UUID_NS, f"kaikki:wordform:{lemma_id}:{form}:{tags_key}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Standalone table definitions — no app imports, no Settings() call
|
# Standalone table definitions — no app imports, no Settings() call
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -192,34 +158,16 @@ def _parse_entry(record: dict, lang_code: str) -> dict | None:
|
||||||
if not word:
|
if not word:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Skip entries that are inflected forms of another lemma (e.g. conjugations,
|
|
||||||
# plurals). These appear as top-level JSONL records but are already captured
|
|
||||||
# as wordforms via the parent lemma's `forms` array.
|
|
||||||
for sense in record.get("senses") or []:
|
|
||||||
if sense.get("form_of"):
|
|
||||||
return None
|
|
||||||
|
|
||||||
pos_raw = (record.get("pos") or "").strip()
|
pos_raw = (record.get("pos") or "").strip()
|
||||||
etymology_number = record.get("etymology_number", 0)
|
|
||||||
raw_senses = record.get("senses") or []
|
|
||||||
|
|
||||||
# Collect kaikki sense IDs up front so the lemma UUID can incorporate them.
|
lemma_id = uuid.uuid4()
|
||||||
# This disambiguates entries that share (word, pos, etymology_number) but
|
|
||||||
# have genuinely different senses — kaikki has ~349 such cases in French.
|
|
||||||
kaikki_sense_ids = [
|
|
||||||
s.get("id") or f"{lang_code}:{word}:{pos_raw}:{etymology_number}:{i}"
|
|
||||||
for i, s in enumerate(raw_senses)
|
|
||||||
]
|
|
||||||
|
|
||||||
lemma_id = _lemma_uuid(lang_code, word, pos_raw, etymology_number, kaikki_sense_ids)
|
|
||||||
|
|
||||||
_GENDER_TAGS = {"masculine", "feminine", "neuter"}
|
_GENDER_TAGS = {"masculine", "feminine", "neuter"}
|
||||||
gender: str | None = None
|
gender: str | None = None
|
||||||
senses = []
|
senses = []
|
||||||
sense_links = []
|
sense_links = []
|
||||||
for i, sense_record in enumerate(raw_senses):
|
for i, sense_record in enumerate(record.get("senses") or []):
|
||||||
kaikki_sense_id = kaikki_sense_ids[i]
|
sense_id = uuid.uuid4()
|
||||||
sense_id = _sense_uuid(kaikki_sense_id)
|
|
||||||
glosses = sense_record.get("glosses") or []
|
glosses = sense_record.get("glosses") or []
|
||||||
gloss = glosses[0] if glosses else ""
|
gloss = glosses[0] if glosses else ""
|
||||||
topics = sense_record.get("topics") or []
|
topics = sense_record.get("topics") or []
|
||||||
|
|
@ -244,34 +192,25 @@ def _parse_entry(record: dict, lang_code: str) -> dict | None:
|
||||||
|
|
||||||
for link_pair in (sense_record.get("links") or []):
|
for link_pair in (sense_record.get("links") or []):
|
||||||
if isinstance(link_pair, list) and len(link_pair) == 2:
|
if isinstance(link_pair, list) and len(link_pair) == 2:
|
||||||
link_text, link_target = link_pair[0], link_pair[1]
|
|
||||||
link_id = uuid.uuid5(
|
|
||||||
_KAIKKI_UUID_NS,
|
|
||||||
f"kaikki:link:{sense_id}:{link_text}:{link_target}",
|
|
||||||
)
|
|
||||||
sense_links.append(
|
sense_links.append(
|
||||||
{
|
{
|
||||||
"id": link_id,
|
"id": uuid.uuid4(),
|
||||||
"sense_id": sense_id,
|
"sense_id": sense_id,
|
||||||
"link_text": link_text,
|
"link_text": link_pair[0],
|
||||||
"link_target": link_target,
|
"link_target": link_pair[1],
|
||||||
"target_lemma_id": None,
|
"target_lemma_id": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
_METADATA_FORM_TAGS = {"table-tags", "inflection-template"}
|
|
||||||
|
|
||||||
wordforms = []
|
wordforms = []
|
||||||
for f in record.get("forms") or []:
|
for f in record.get("forms") or []:
|
||||||
form_text = (f.get("form") or "").strip()
|
form_text = (f.get("form") or "").strip()
|
||||||
if not form_text or form_text == word:
|
if not form_text or form_text == word:
|
||||||
continue
|
continue
|
||||||
form_tags = f.get("tags") or []
|
form_tags = f.get("tags") or []
|
||||||
if _METADATA_FORM_TAGS.intersection(form_tags):
|
|
||||||
continue
|
|
||||||
wordforms.append(
|
wordforms.append(
|
||||||
{
|
{
|
||||||
"id": _wordform_uuid(lemma_id, form_text, form_tags),
|
"id": uuid.uuid4(),
|
||||||
"lemma_id": lemma_id,
|
"lemma_id": lemma_id,
|
||||||
"form": form_text,
|
"form": form_text,
|
||||||
"tags": form_tags,
|
"tags": form_tags,
|
||||||
|
|
@ -292,7 +231,7 @@ def _parse_entry(record: dict, lang_code: str) -> dict | None:
|
||||||
"sense_links": sense_links,
|
"sense_links": sense_links,
|
||||||
"wordforms": wordforms,
|
"wordforms": wordforms,
|
||||||
"raw": {
|
"raw": {
|
||||||
"id": uuid.uuid5(_KAIKKI_UUID_NS, f"kaikki:raw:{lemma_id}"),
|
"id": uuid.uuid4(),
|
||||||
"lemma_id": lemma_id,
|
"lemma_id": lemma_id,
|
||||||
"language": lang_code,
|
"language": lang_code,
|
||||||
"raw": record,
|
"raw": record,
|
||||||
|
|
@ -312,69 +251,16 @@ async def _flush_batch(conn: sa.ext.asyncio.AsyncConnection, batch: list[dict])
|
||||||
wordform_rows = [w for e in batch for w in e["wordforms"]]
|
wordform_rows = [w for e in batch for w in e["wordforms"]]
|
||||||
raw_rows = [e["raw"] for e in batch]
|
raw_rows = [e["raw"] for e in batch]
|
||||||
|
|
||||||
# asyncpg caps query parameters at 32767. Split each row list into chunks
|
if lemma_rows:
|
||||||
# sized so that rows × columns stays comfortably under that limit.
|
await conn.execute(_lemma_table.insert(), lemma_rows)
|
||||||
def _chunks(rows: list[dict], n_cols: int) -> list[list[dict]]:
|
if sense_rows:
|
||||||
size = max(1, 32767 // n_cols)
|
await conn.execute(_sense_table.insert(), sense_rows)
|
||||||
return [rows[i : i + size] for i in range(0, len(rows), size)]
|
if sense_link_rows:
|
||||||
|
await conn.execute(_sense_link_table.insert(), sense_link_rows)
|
||||||
# Deduplicate by id: safety net for truly identical rows (should be rare
|
if wordform_rows:
|
||||||
# now that the lemma UUID incorporates sense IDs).
|
await conn.execute(_wordform_table.insert(), wordform_rows)
|
||||||
def _dedup(rows: list[dict]) -> list[dict]:
|
if raw_rows:
|
||||||
seen: dict = {}
|
await conn.execute(_raw_table.insert(), raw_rows)
|
||||||
for row in rows:
|
|
||||||
seen[row["id"]] = row
|
|
||||||
return list(seen.values())
|
|
||||||
|
|
||||||
lemma_rows = _dedup(lemma_rows)
|
|
||||||
sense_rows = _dedup(sense_rows)
|
|
||||||
wordform_rows = _dedup(wordform_rows)
|
|
||||||
raw_rows = _dedup(raw_rows)
|
|
||||||
sense_link_rows = _dedup(sense_link_rows)
|
|
||||||
|
|
||||||
for chunk in _chunks(lemma_rows, len(_lemma_table.columns)):
|
|
||||||
stmt = pg_insert(_lemma_table).values(chunk)
|
|
||||||
await conn.execute(stmt.on_conflict_do_update(
|
|
||||||
index_elements=["id"],
|
|
||||||
set_={
|
|
||||||
"headword": stmt.excluded.headword,
|
|
||||||
"pos_raw": stmt.excluded.pos_raw,
|
|
||||||
"pos_normalised": stmt.excluded.pos_normalised,
|
|
||||||
"gender": stmt.excluded.gender,
|
|
||||||
"tags": stmt.excluded.tags,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
for chunk in _chunks(sense_rows, len(_sense_table.columns)):
|
|
||||||
stmt = pg_insert(_sense_table).values(chunk)
|
|
||||||
await conn.execute(stmt.on_conflict_do_update(
|
|
||||||
index_elements=["id"],
|
|
||||||
set_={
|
|
||||||
"sense_index": stmt.excluded.sense_index,
|
|
||||||
"gloss": stmt.excluded.gloss,
|
|
||||||
"topics": stmt.excluded.topics,
|
|
||||||
"tags": stmt.excluded.tags,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
for chunk in _chunks(wordform_rows, len(_wordform_table.columns)):
|
|
||||||
stmt = pg_insert(_wordform_table).values(chunk)
|
|
||||||
await conn.execute(stmt.on_conflict_do_update(
|
|
||||||
index_elements=["id"],
|
|
||||||
set_={"tags": stmt.excluded.tags},
|
|
||||||
))
|
|
||||||
|
|
||||||
for chunk in _chunks(raw_rows, len(_raw_table.columns)):
|
|
||||||
stmt = pg_insert(_raw_table).values(chunk)
|
|
||||||
await conn.execute(stmt.on_conflict_do_update(
|
|
||||||
index_elements=["id"],
|
|
||||||
set_={"raw": stmt.excluded.raw},
|
|
||||||
))
|
|
||||||
|
|
||||||
for chunk in _chunks(sense_link_rows, len(_sense_link_table.columns)):
|
|
||||||
await conn.execute(
|
|
||||||
pg_insert(_sense_link_table).values(chunk).on_conflict_do_nothing()
|
|
||||||
)
|
|
||||||
|
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
|
||||||
|
|
@ -464,9 +350,12 @@ async def run_import(lang_code: str, batch_size: int = 1000) -> None:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
# No upfront delete — rows are upserted so existing FK references
|
print(f"Deleting existing entries for language={lang_code!r}...")
|
||||||
# (word bank entries, pack entries) are preserved across re-imports.
|
await conn.execute(
|
||||||
# To fully wipe and start fresh, run clear_dictionary.py first.
|
_lemma_table.delete().where(_lemma_table.c.language == lang_code)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
print(f"Importing {jsonl_path} ...")
|
print(f"Importing {jsonl_path} ...")
|
||||||
batch: list[dict] = []
|
batch: list[dict] = []
|
||||||
total_lemmas = 0
|
total_lemmas = 0
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
|
|
||||||
This document describes the software architecture and aptterns for the web application for language learning application.
|
This document describes the software architecture and aptterns for the web application for language learning application.
|
||||||
|
|
||||||
This is a web application built using Svelte Kit v5, running on the NodeJS adapter. It is written in TypeScript.
|
This is a web application built using Svelte Kit v5, running on the NodeJS adapter.
|
||||||
|
|
||||||
Package management is with pnpm.
|
|
||||||
|
|
||||||
Follow the svelte kit conventions where possible, e.g. in placing routes, authentication, code.
|
Follow the svelte kit conventions where possible, e.g. in placing routes, authentication, code.
|
||||||
|
|
||||||
|
|
@ -26,8 +24,6 @@ Token and role checking is centralised into the `src/hooks.server.ts` file, whic
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
For "boring" screens (settings pages, admin pages, cms-like pages) use shadcn-svelte components to create sensible defaults and uninteresting User Interfaces.
|
|
||||||
|
|
||||||
It is bad practice to simply have a `+page.svelte` component contain all aspects of a page. When convenient, code should be split into smaller component files.
|
It is bad practice to simply have a `+page.svelte` component contain all aspects of a page. When convenient, code should be split into smaller component files.
|
||||||
|
|
||||||
Where components aren't shared outside of a single page, they live as siblings to the `+page.svelte` file.
|
Where components aren't shared outside of a single page, they live as siblings to the `+page.svelte` file.
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,14 +1,14 @@
|
||||||
import { defineConfig } from '@hey-api/openapi-ts';
|
import { defineConfig } from '@hey-api/openapi-ts';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
input: './docs/openapi.json', // sign up at app.heyapi.dev
|
input: 'src/lib/openapi.json', // sign up at app.heyapi.dev
|
||||||
output: {
|
output: {
|
||||||
path: 'src/client'
|
path: 'src/client'
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
{
|
{
|
||||||
name: '@hey-api/client-fetch',
|
name: '@hey-api/client-fetch',
|
||||||
runtimeConfigPath: '../hey-api.ts'
|
runtimeConfigPath: '../hey-api.ts',
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,14 @@
|
||||||
"test": "npm run test:unit -- --run"
|
"test": "npm run test:unit -- --run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "latest",
|
"@eslint/compat": "^2.0.2",
|
||||||
"@eslint/js": "latest",
|
"@eslint/js": "^9.39.2",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
|
||||||
"@hey-api/openapi-ts": "0.94.4",
|
"@hey-api/openapi-ts": "0.94.4",
|
||||||
"@lucide/svelte": "^1.8.0",
|
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/kit": "^2.57.1",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@vitest/browser-playwright": "^4.1.0",
|
"@vitest/browser-playwright": "^4.1.0",
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.14.0",
|
"eslint-plugin-svelte": "^3.14.0",
|
||||||
|
|
@ -37,16 +34,14 @@
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"svelte": "^5.51.0",
|
"svelte": "^5.51.0",
|
||||||
"svelte-check": "^4.4.2",
|
"svelte-check": "^4.4.2",
|
||||||
"tw-animate-css": "^1.4.0",
|
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript-eslint": "^8.54.0",
|
||||||
"vite": "^7.3.2",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.1.0",
|
"vitest": "^4.1.0",
|
||||||
"vitest-browser-svelte": "^2.0.2"
|
"vitest-browser-svelte": "^2.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deepl-node": "^1.24.0",
|
"deepl-node": "^1.24.0",
|
||||||
"jose": "^6.2.2",
|
|
||||||
"valibot": "^1.3.1"
|
"valibot": "^1.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,46 +11,34 @@ importers:
|
||||||
deepl-node:
|
deepl-node:
|
||||||
specifier: ^1.24.0
|
specifier: ^1.24.0
|
||||||
version: 1.24.0
|
version: 1.24.0
|
||||||
jose:
|
|
||||||
specifier: ^6.2.2
|
|
||||||
version: 6.2.2
|
|
||||||
valibot:
|
valibot:
|
||||||
specifier: ^1.3.1
|
specifier: ^1.3.1
|
||||||
version: 1.3.1(typescript@5.9.3)
|
version: 1.3.1(typescript@5.9.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/compat':
|
'@eslint/compat':
|
||||||
specifier: latest
|
specifier: ^2.0.2
|
||||||
version: 2.0.3(eslint@9.39.4(jiti@2.6.1))
|
version: 2.0.3(eslint@9.39.4(jiti@2.6.1))
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: latest
|
specifier: ^9.39.2
|
||||||
version: 9.39.4
|
version: 9.39.4
|
||||||
'@fontsource-variable/inter':
|
|
||||||
specifier: ^5.2.8
|
|
||||||
version: 5.2.8
|
|
||||||
'@hey-api/openapi-ts':
|
'@hey-api/openapi-ts':
|
||||||
specifier: 0.94.4
|
specifier: 0.94.4
|
||||||
version: 0.94.4(typescript@5.9.3)
|
version: 0.94.4(typescript@5.9.3)
|
||||||
'@lucide/svelte':
|
|
||||||
specifier: ^1.8.0
|
|
||||||
version: 1.8.0(svelte@5.54.1)
|
|
||||||
'@sveltejs/adapter-node':
|
'@sveltejs/adapter-node':
|
||||||
specifier: ^5.5.4
|
specifier: ^5.5.4
|
||||||
version: 5.5.4(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)))
|
version: 5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)))
|
||||||
'@sveltejs/kit':
|
'@sveltejs/kit':
|
||||||
specifier: ^2.57.1
|
specifier: ^2.50.2
|
||||||
version: 2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
version: 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
'@sveltejs/vite-plugin-svelte':
|
'@sveltejs/vite-plugin-svelte':
|
||||||
specifier: ^6.2.4
|
specifier: ^6.2.4
|
||||||
version: 6.2.4(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
version: 6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22
|
specifier: ^22
|
||||||
version: 22.19.15
|
version: 22.19.15
|
||||||
'@vitest/browser-playwright':
|
'@vitest/browser-playwright':
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0(playwright@1.58.2)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))(vitest@4.1.0)
|
version: 4.1.0(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))(vitest@4.1.0)
|
||||||
clsx:
|
|
||||||
specifier: ^2.1.1
|
|
||||||
version: 2.1.1
|
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.39.2
|
specifier: ^9.39.2
|
||||||
version: 9.39.4(jiti@2.6.1)
|
version: 9.39.4(jiti@2.6.1)
|
||||||
|
|
@ -77,10 +65,7 @@ importers:
|
||||||
version: 5.54.1
|
version: 5.54.1
|
||||||
svelte-check:
|
svelte-check:
|
||||||
specifier: ^4.4.2
|
specifier: ^4.4.2
|
||||||
version: 4.4.5(picomatch@4.0.4)(svelte@5.54.1)(typescript@5.9.3)
|
version: 4.4.5(picomatch@4.0.3)(svelte@5.54.1)(typescript@5.9.3)
|
||||||
tw-animate-css:
|
|
||||||
specifier: ^1.4.0
|
|
||||||
version: 1.4.0
|
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
@ -88,11 +73,11 @@ importers:
|
||||||
specifier: ^8.54.0
|
specifier: ^8.54.0
|
||||||
version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
|
||||||
vite:
|
vite:
|
||||||
specifier: ^7.3.2
|
specifier: ^7.3.1
|
||||||
version: 7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)
|
version: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0(@types/node@22.19.15)(@vitest/browser-playwright@4.1.0)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
version: 4.1.0(@types/node@22.19.15)(@vitest/browser-playwright@4.1.0)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
vitest-browser-svelte:
|
vitest-browser-svelte:
|
||||||
specifier: ^2.0.2
|
specifier: ^2.0.2
|
||||||
version: 2.1.0(svelte@5.54.1)(vitest@4.1.0)
|
version: 2.1.0(svelte@5.54.1)(vitest@4.1.0)
|
||||||
|
|
@ -309,9 +294,6 @@ packages:
|
||||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@fontsource-variable/inter@5.2.8':
|
|
||||||
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
|
|
||||||
|
|
||||||
'@hey-api/codegen-core@0.7.4':
|
'@hey-api/codegen-core@0.7.4':
|
||||||
resolution: {integrity: sha512-DGd9yeSQzflOWO3Y5mt1GRXkXH9O/yIMgbxPjwLI3jwu/3nAjoXXD26lEeFb6tclYlg0JAqTIs5d930G/qxHeA==}
|
resolution: {integrity: sha512-DGd9yeSQzflOWO3Y5mt1GRXkXH9O/yIMgbxPjwLI3jwu/3nAjoXXD26lEeFb6tclYlg0JAqTIs5d930G/qxHeA==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
@ -369,11 +351,6 @@ packages:
|
||||||
'@jsdevtools/ono@7.1.3':
|
'@jsdevtools/ono@7.1.3':
|
||||||
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
|
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
|
||||||
|
|
||||||
'@lucide/svelte@1.8.0':
|
|
||||||
resolution: {integrity: sha512-+zYQUKqEOVP5lxbGmxL1OVgGMQtRK91eIJ0bR+3Cr1ts4oQEsQfxyzzd5X47psJlblAuGFrl2xm4YuATjR9oaA==}
|
|
||||||
peerDependencies:
|
|
||||||
svelte: ^5
|
|
||||||
|
|
||||||
'@playwright/test@1.58.2':
|
'@playwright/test@1.58.2':
|
||||||
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -569,15 +546,15 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@sveltejs/kit': ^2.4.0
|
'@sveltejs/kit': ^2.4.0
|
||||||
|
|
||||||
'@sveltejs/kit@2.57.1':
|
'@sveltejs/kit@2.55.0':
|
||||||
resolution: {integrity: sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==}
|
resolution: {integrity: sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==}
|
||||||
engines: {node: '>=18.13'}
|
engines: {node: '>=18.13'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.0.0
|
'@opentelemetry/api': ^1.0.0
|
||||||
'@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0
|
'@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0
|
||||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||||
typescript: ^5.3.3 || ^6.0.0
|
typescript: ^5.3.3
|
||||||
vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0
|
vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@opentelemetry/api':
|
'@opentelemetry/api':
|
||||||
|
|
@ -925,10 +902,6 @@ packages:
|
||||||
destr@2.0.5:
|
destr@2.0.5:
|
||||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
|
||||||
engines: {node: '>=8'}
|
|
||||||
|
|
||||||
devalue@5.6.4:
|
devalue@5.6.4:
|
||||||
resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==}
|
resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==}
|
||||||
|
|
||||||
|
|
@ -1225,9 +1198,6 @@ packages:
|
||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jose@6.2.2:
|
|
||||||
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
@ -1255,80 +1225,6 @@ packages:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
|
||||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [android]
|
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.32.0:
|
|
||||||
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [darwin]
|
|
||||||
|
|
||||||
lightningcss-darwin-x64@1.32.0:
|
|
||||||
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [darwin]
|
|
||||||
|
|
||||||
lightningcss-freebsd-x64@1.32.0:
|
|
||||||
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [freebsd]
|
|
||||||
|
|
||||||
lightningcss-linux-arm-gnueabihf@1.32.0:
|
|
||||||
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
cpu: [arm]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
lightningcss-linux-arm64-gnu@1.32.0:
|
|
||||||
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [linux]
|
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.32.0:
|
|
||||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [linux]
|
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.32.0:
|
|
||||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [linux]
|
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.32.0:
|
|
||||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [linux]
|
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.32.0:
|
|
||||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [win32]
|
|
||||||
|
|
||||||
lightningcss-win32-x64-msvc@1.32.0:
|
|
||||||
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [win32]
|
|
||||||
|
|
||||||
lightningcss@1.32.0:
|
|
||||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
|
||||||
engines: {node: '>= 12.0.0'}
|
|
||||||
|
|
||||||
lilconfig@2.1.0:
|
lilconfig@2.1.0:
|
||||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -1446,10 +1342,6 @@ packages:
|
||||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
picomatch@4.0.4:
|
|
||||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
|
|
||||||
pkg-types@2.3.0:
|
pkg-types@2.3.0:
|
||||||
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||||
|
|
||||||
|
|
@ -1658,9 +1550,6 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4'
|
typescript: '>=4.8.4'
|
||||||
|
|
||||||
tw-animate-css@1.4.0:
|
|
||||||
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
@ -1698,8 +1587,8 @@ packages:
|
||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
vite@7.3.2:
|
vite@7.3.1:
|
||||||
resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==}
|
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -1966,8 +1855,6 @@ snapshots:
|
||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 0.17.0
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
'@fontsource-variable/inter@5.2.8': {}
|
|
||||||
|
|
||||||
'@hey-api/codegen-core@0.7.4':
|
'@hey-api/codegen-core@0.7.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hey-api/types': 0.1.4
|
'@hey-api/types': 0.1.4
|
||||||
|
|
@ -2043,10 +1930,6 @@ snapshots:
|
||||||
|
|
||||||
'@jsdevtools/ono@7.1.3': {}
|
'@jsdevtools/ono@7.1.3': {}
|
||||||
|
|
||||||
'@lucide/svelte@1.8.0(svelte@5.54.1)':
|
|
||||||
dependencies:
|
|
||||||
svelte: 5.54.1
|
|
||||||
|
|
||||||
'@playwright/test@1.58.2':
|
'@playwright/test@1.58.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.58.2
|
playwright: 1.58.2
|
||||||
|
|
@ -2170,19 +2053,19 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
|
|
||||||
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)))':
|
'@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rollup/plugin-commonjs': 29.0.2(rollup@4.60.0)
|
'@rollup/plugin-commonjs': 29.0.2(rollup@4.60.0)
|
||||||
'@rollup/plugin-json': 6.1.0(rollup@4.60.0)
|
'@rollup/plugin-json': 6.1.0(rollup@4.60.0)
|
||||||
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.0)
|
'@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.0)
|
||||||
'@sveltejs/kit': 2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
'@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
rollup: 4.60.0
|
rollup: 4.60.0
|
||||||
|
|
||||||
'@sveltejs/kit@2.57.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))':
|
'@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
|
'@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0)
|
||||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
'@types/cookie': 0.6.0
|
'@types/cookie': 0.6.0
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
cookie: 0.6.0
|
cookie: 0.6.0
|
||||||
|
|
@ -2194,26 +2077,26 @@ snapshots:
|
||||||
set-cookie-parser: 3.1.0
|
set-cookie-parser: 3.1.0
|
||||||
sirv: 3.0.2
|
sirv: 3.0.2
|
||||||
svelte: 5.54.1
|
svelte: 5.54.1
|
||||||
vite: 7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)
|
vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
'@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))':
|
'@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)))(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
obug: 2.1.1
|
obug: 2.1.1
|
||||||
svelte: 5.54.1
|
svelte: 5.54.1
|
||||||
vite: 7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)
|
vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)
|
||||||
|
|
||||||
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))':
|
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.54.1)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
'@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)))(svelte@5.54.1)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
deepmerge: 4.3.1
|
deepmerge: 4.3.1
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
obug: 2.1.1
|
obug: 2.1.1
|
||||||
svelte: 5.54.1
|
svelte: 5.54.1
|
||||||
vite: 7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)
|
vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)
|
||||||
vitefu: 1.1.2(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
vitefu: 1.1.2(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
|
|
||||||
'@testing-library/svelte-core@1.0.0(svelte@5.54.1)':
|
'@testing-library/svelte-core@1.0.0(svelte@5.54.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -2331,29 +2214,29 @@ snapshots:
|
||||||
'@typescript-eslint/types': 8.57.1
|
'@typescript-eslint/types': 8.57.1
|
||||||
eslint-visitor-keys: 5.0.1
|
eslint-visitor-keys: 5.0.1
|
||||||
|
|
||||||
'@vitest/browser-playwright@4.1.0(playwright@1.58.2)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))(vitest@4.1.0)':
|
'@vitest/browser-playwright@4.1.0(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))(vitest@4.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/browser': 4.1.0(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))(vitest@4.1.0)
|
'@vitest/browser': 4.1.0(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))(vitest@4.1.0)
|
||||||
'@vitest/mocker': 4.1.0(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
'@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
playwright: 1.58.2
|
playwright: 1.58.2
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vitest: 4.1.0(@types/node@22.19.15)(@vitest/browser-playwright@4.1.0)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
vitest: 4.1.0(@types/node@22.19.15)(@vitest/browser-playwright@4.1.0)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- msw
|
- msw
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
- vite
|
- vite
|
||||||
|
|
||||||
'@vitest/browser@4.1.0(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))(vitest@4.1.0)':
|
'@vitest/browser@4.1.0(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))(vitest@4.1.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@blazediff/core': 1.9.1
|
'@blazediff/core': 1.9.1
|
||||||
'@vitest/mocker': 4.1.0(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
'@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
'@vitest/utils': 4.1.0
|
'@vitest/utils': 4.1.0
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
pngjs: 7.0.0
|
pngjs: 7.0.0
|
||||||
sirv: 3.0.2
|
sirv: 3.0.2
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vitest: 4.1.0(@types/node@22.19.15)(@vitest/browser-playwright@4.1.0)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
vitest: 4.1.0(@types/node@22.19.15)(@vitest/browser-playwright@4.1.0)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
ws: 8.20.0
|
ws: 8.20.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
|
|
@ -2370,13 +2253,13 @@ snapshots:
|
||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
'@vitest/mocker@4.1.0(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))':
|
'@vitest/mocker@4.1.0(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.0
|
'@vitest/spy': 4.1.0
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)
|
vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)
|
||||||
|
|
||||||
'@vitest/pretty-format@4.1.0':
|
'@vitest/pretty-format@4.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -2571,9 +2454,6 @@ snapshots:
|
||||||
|
|
||||||
destr@2.0.5: {}
|
destr@2.0.5: {}
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
devalue@5.6.4: {}
|
devalue@5.6.4: {}
|
||||||
|
|
||||||
dotenv@17.3.1: {}
|
dotenv@17.3.1: {}
|
||||||
|
|
@ -2751,10 +2631,6 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.4):
|
|
||||||
optionalDependencies:
|
|
||||||
picomatch: 4.0.4
|
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 4.0.1
|
flat-cache: 4.0.1
|
||||||
|
|
@ -2899,8 +2775,6 @@ snapshots:
|
||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
jose@6.2.2: {}
|
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
|
|
@ -2924,56 +2798,6 @@ snapshots:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.32.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
lightningcss-darwin-x64@1.32.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
lightningcss-freebsd-x64@1.32.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
lightningcss-linux-arm-gnueabihf@1.32.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
lightningcss-linux-arm64-gnu@1.32.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
lightningcss-linux-arm64-musl@1.32.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.32.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
lightningcss-linux-x64-musl@1.32.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
lightningcss-win32-arm64-msvc@1.32.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
lightningcss-win32-x64-msvc@1.32.0:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
lightningcss@1.32.0:
|
|
||||||
dependencies:
|
|
||||||
detect-libc: 2.1.2
|
|
||||||
optionalDependencies:
|
|
||||||
lightningcss-android-arm64: 1.32.0
|
|
||||||
lightningcss-darwin-arm64: 1.32.0
|
|
||||||
lightningcss-darwin-x64: 1.32.0
|
|
||||||
lightningcss-freebsd-x64: 1.32.0
|
|
||||||
lightningcss-linux-arm-gnueabihf: 1.32.0
|
|
||||||
lightningcss-linux-arm64-gnu: 1.32.0
|
|
||||||
lightningcss-linux-arm64-musl: 1.32.0
|
|
||||||
lightningcss-linux-x64-gnu: 1.32.0
|
|
||||||
lightningcss-linux-x64-musl: 1.32.0
|
|
||||||
lightningcss-win32-arm64-msvc: 1.32.0
|
|
||||||
lightningcss-win32-x64-msvc: 1.32.0
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
lilconfig@2.1.0: {}
|
lilconfig@2.1.0: {}
|
||||||
|
|
||||||
locate-character@3.0.0: {}
|
locate-character@3.0.0: {}
|
||||||
|
|
@ -3072,8 +2896,6 @@ snapshots:
|
||||||
|
|
||||||
picomatch@4.0.3: {}
|
picomatch@4.0.3: {}
|
||||||
|
|
||||||
picomatch@4.0.4: {}
|
|
||||||
|
|
||||||
pkg-types@2.3.0:
|
pkg-types@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
confbox: 0.2.4
|
confbox: 0.2.4
|
||||||
|
|
@ -3221,11 +3043,11 @@ snapshots:
|
||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
svelte-check@4.4.5(picomatch@4.0.4)(svelte@5.54.1)(typescript@5.9.3):
|
svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.54.1)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
chokidar: 4.0.3
|
chokidar: 4.0.3
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
sade: 1.8.1
|
sade: 1.8.1
|
||||||
svelte: 5.54.1
|
svelte: 5.54.1
|
||||||
|
|
@ -3281,8 +3103,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
tw-animate-css@1.4.0: {}
|
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|
@ -3314,11 +3134,11 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0):
|
vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.4
|
esbuild: 0.27.4
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.3
|
||||||
postcss: 8.5.8
|
postcss: 8.5.8
|
||||||
rollup: 4.60.0
|
rollup: 4.60.0
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
|
|
@ -3326,23 +3146,22 @@ snapshots:
|
||||||
'@types/node': 22.19.15
|
'@types/node': 22.19.15
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
lightningcss: 1.32.0
|
|
||||||
|
|
||||||
vitefu@1.1.2(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)):
|
vitefu@1.1.2(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)
|
vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)
|
||||||
|
|
||||||
vitest-browser-svelte@2.1.0(svelte@5.54.1)(vitest@4.1.0):
|
vitest-browser-svelte@2.1.0(svelte@5.54.1)(vitest@4.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@playwright/test': 1.58.2
|
'@playwright/test': 1.58.2
|
||||||
'@testing-library/svelte-core': 1.0.0(svelte@5.54.1)
|
'@testing-library/svelte-core': 1.0.0(svelte@5.54.1)
|
||||||
svelte: 5.54.1
|
svelte: 5.54.1
|
||||||
vitest: 4.1.0(@types/node@22.19.15)(@vitest/browser-playwright@4.1.0)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
vitest: 4.1.0(@types/node@22.19.15)(@vitest/browser-playwright@4.1.0)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
|
|
||||||
vitest@4.1.0(@types/node@22.19.15)(@vitest/browser-playwright@4.1.0)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)):
|
vitest@4.1.0(@types/node@22.19.15)(@vitest/browser-playwright@4.1.0)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.0
|
'@vitest/expect': 4.1.0
|
||||||
'@vitest/mocker': 4.1.0(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))
|
'@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))
|
||||||
'@vitest/pretty-format': 4.1.0
|
'@vitest/pretty-format': 4.1.0
|
||||||
'@vitest/runner': 4.1.0
|
'@vitest/runner': 4.1.0
|
||||||
'@vitest/snapshot': 4.1.0
|
'@vitest/snapshot': 4.1.0
|
||||||
|
|
@ -3359,11 +3178,11 @@ snapshots:
|
||||||
tinyexec: 1.0.4
|
tinyexec: 1.0.4
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vite: 7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)
|
vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.19.15
|
'@types/node': 22.19.15
|
||||||
'@vitest/browser-playwright': 4.1.0(playwright@1.58.2)(vite@7.3.2(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0))(vitest@4.1.0)
|
'@vitest/browser-playwright': 4.1.0(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1))(vitest@4.1.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- msw
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# Makes a request to localhost:8000/openapi.json and saves the result in ./src/lib/openapi.json
|
# Makes a request to localhost:8000/openapi.json and saves the result in ./src/lib/openapi.json
|
||||||
curl -o ./docs/openapi.json http://localhost:8000/openapi.json
|
curl -o ./src/lib/openapi.json http://localhost:8000/openapi.json
|
||||||
pnpm openapi-ts:gen
|
pnpm openapi-ts:gen
|
||||||
|
|
@ -387,105 +387,6 @@ body {
|
||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Component: Button (modifiers)
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
font-size: var(--text-label-md);
|
|
||||||
padding: 0.125rem var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
color: #b3261e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background-color: color-mix(in srgb, #b3261e 8%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Component: Badge
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-label-md);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
padding: 0.125rem var(--space-2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-primary {
|
|
||||||
color: var(--color-primary);
|
|
||||||
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-secondary {
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
background-color: var(--color-surface-container);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-warning {
|
|
||||||
color: #b3261e;
|
|
||||||
background-color: color-mix(in srgb, #b3261e 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Component: Section card
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.section-card {
|
|
||||||
background-color: var(--color-surface-container-low);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Component: Admin table
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
.admin-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table thead tr {
|
|
||||||
background-color: var(--color-surface-container);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table th {
|
|
||||||
text-align: left;
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-label-md);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
letter-spacing: var(--tracking-wide);
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table td {
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-body-sm);
|
|
||||||
color: var(--color-on-surface);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table tbody tr {
|
|
||||||
border-top: 1px solid var(--color-surface-container);
|
|
||||||
transition: background-color var(--duration-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-table tbody tr:hover {
|
|
||||||
background-color: var(--color-surface-container);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Component: Alert
|
Component: Alert
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
|
|
|
||||||
1
frontend/src/app.d.ts
vendored
1
frontend/src/app.d.ts
vendored
|
|
@ -8,7 +8,6 @@ declare global {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
apiClient?: ApiClient;
|
apiClient?: ApiClient;
|
||||||
authToken: string | null;
|
authToken: string | null;
|
||||||
isAdmin: boolean;
|
|
||||||
}
|
}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -1,43 +1,40 @@
|
||||||
import { decodeJwt, jwtVerify } from 'jose';
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
import { redirect, type Handle } from '@sveltejs/kit';
|
import { redirect, type Handle } from '@sveltejs/kit';
|
||||||
import { PRIVATE_JWT_SECRET } from '$env/static/private';
|
import { PRIVATE_JWT_SECRET } from '$env/static/private';
|
||||||
import { client } from './lib/apiClient.ts';
|
import { client } from './lib/apiClient.ts';
|
||||||
import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth/index.ts';
|
|
||||||
|
|
||||||
async function verifyJwt(token: string, secret: string): Promise<boolean> {
|
function verifyJwt(token: string, secret: string): boolean {
|
||||||
const encodedSecret = new TextEncoder().encode(secret);
|
try {
|
||||||
return await jwtVerify(token, encodedSecret)
|
const parts = token.split('.');
|
||||||
.then(() => true)
|
if (parts.length !== 3) return false;
|
||||||
.catch((e) => {
|
const [header, payload, sig] = parts;
|
||||||
console.log(`Caught error while validating JWT: ${e}`);
|
|
||||||
|
const expected = createHmac('sha256', secret)
|
||||||
|
.update(`${header}.${payload}`)
|
||||||
|
.digest('base64url');
|
||||||
|
|
||||||
|
const expectedBuf = Buffer.from(expected);
|
||||||
|
const sigBuf = Buffer.from(sig);
|
||||||
|
if (expectedBuf.length !== sigBuf.length) return false;
|
||||||
|
if (!timingSafeEqual(expectedBuf, sigBuf)) return false;
|
||||||
|
|
||||||
|
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||||
|
if (claims.exp && claims.exp < Date.now() / 1000) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function getJwtPayload(jwt: string): { isAdmin: boolean } {
|
|
||||||
const decodeResult = decodeJwt<{ is_admin: boolean }>(jwt);
|
|
||||||
return { isAdmin: decodeResult.is_admin };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.apiClient = client;
|
event.locals.apiClient = client;
|
||||||
|
|
||||||
const rawToken = event.cookies.get(COOKIE_NAME_AUTH_TOKEN);
|
const rawToken = event.cookies.get('auth_token');
|
||||||
const isValid = rawToken ? await verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false;
|
const isValid = rawToken ? verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false;
|
||||||
console.log({ isValid });
|
|
||||||
event.locals.authToken = isValid ? rawToken! : null;
|
event.locals.authToken = isValid ? rawToken! : null;
|
||||||
|
|
||||||
if (isValid && rawToken) {
|
if (event.url.pathname.startsWith('/app') && !isValid) {
|
||||||
const payload = getJwtPayload(rawToken);
|
|
||||||
event.locals.isAdmin = payload.isAdmin;
|
|
||||||
} else {
|
|
||||||
event.locals.isAdmin = false;
|
|
||||||
console.log(`Not valid and no token`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pathname } = event.url;
|
|
||||||
if ((pathname.startsWith('/app') || pathname.startsWith('/admin')) && !isValid) {
|
|
||||||
console.log(`Redirecting to login`);
|
|
||||||
return redirect(307, '/login');
|
return redirect(307, '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export const COOKIE_NAME_AUTH_TOKEN = 'auth_token';
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isAdmin?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isAdmin = false }: Props = $props();
|
|
||||||
|
|
||||||
const isActive = (prefix: string) => page.url.pathname.startsWith(prefix);
|
const isActive = (prefix: string) => page.url.pathname.startsWith(prefix);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -28,16 +22,6 @@
|
||||||
Articles
|
Articles
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/app/packs"
|
|
||||||
class="nav-link"
|
|
||||||
class:is-active={isActive('/app/packs')}
|
|
||||||
aria-current={isActive('/app/packs') ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
Packs
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/app/profile"
|
href="/app/profile"
|
||||||
|
|
@ -48,18 +32,6 @@
|
||||||
Profile
|
Profile
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{#if isAdmin}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/app/admin/packs"
|
|
||||||
class="nav-link"
|
|
||||||
class:is-active={isActive('/app/admin')}
|
|
||||||
aria-current={isActive('/app/admin') ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
Admin
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -31,7 +31,6 @@ export const load: ServerLoad = async ({ locals, url, cookies }) => {
|
||||||
// Don't hard-block on unverified email yet (login gate is not active),
|
// Don't hard-block on unverified email yet (login gate is not active),
|
||||||
// but surface the flag so the layout can show a warning banner.
|
// but surface the flag so the layout can show a warning banner.
|
||||||
return {
|
return {
|
||||||
emailUnverified: problemFlags.includes('unvalidated_email'),
|
emailUnverified: problemFlags.includes('unvalidated_email')
|
||||||
isAdmin: locals.isAdmin
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
const { data, children }: LayoutProps = $props();
|
const { data, children }: LayoutProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<TopNav isAdmin={data.isAdmin} />
|
<TopNav />
|
||||||
|
|
||||||
{#if data.emailUnverified}
|
{#if data.emailUnverified}
|
||||||
<div class="email-warning" role="alert">
|
<div class="email-warning" role="alert">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { error, type ServerLoad } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const load: ServerLoad = async ({ locals }) => {
|
|
||||||
if (!locals.isAdmin) error(403, 'Access denied');
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { LayoutProps } from './$types';
|
|
||||||
|
|
||||||
const { children }: LayoutProps = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{@render children()}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,430 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { PageProps } from './$types';
|
|
||||||
|
|
||||||
const { form }: PageProps = $props();
|
|
||||||
|
|
||||||
const LANGUAGES = [
|
|
||||||
{ code: 'en', label: 'English' },
|
|
||||||
{ code: 'fr', label: 'French' },
|
|
||||||
{ code: 'es', label: 'Spanish' },
|
|
||||||
{ code: 'it', label: 'Italian' },
|
|
||||||
{ code: 'de', label: 'German' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const PROFICIENCY_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<nav class="breadcrumb">
|
|
||||||
<a href="/app/admin/packs" class="link">Packs</a>
|
|
||||||
<span>/</span>
|
|
||||||
<span>New pack</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p class="form-eyebrow">Admin</p>
|
|
||||||
<h1 class="page-title">Create new pack</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if form?.error}
|
|
||||||
<div class="alert alert-error">{form.error}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="section-card">
|
|
||||||
<form method="POST" class="form">
|
|
||||||
<div class="grid-2">
|
|
||||||
<div class="field">
|
|
||||||
<label for="name" class="field-label">Source name</label>
|
|
||||||
<p class="field-hint">e.g. Food & Drink</p>
|
|
||||||
<input id="name" name="name" class="field-input" placeholder="Food & Drink" required />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="name_target" class="field-label">Target name</label>
|
|
||||||
<p class="field-hint">e.g. La Nourriture et les Boissons</p>
|
|
||||||
<input
|
|
||||||
id="name_target"
|
|
||||||
name="name_target"
|
|
||||||
class="field-input"
|
|
||||||
placeholder="La Nourriture et les Boissons"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid-2">
|
|
||||||
<div class="field">
|
|
||||||
<label for="description" class="field-label">Description (source)</label>
|
|
||||||
<textarea
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
class="field-textarea"
|
|
||||||
rows={3}
|
|
||||||
placeholder="A collection of essential food and drink vocabulary."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="description_target" class="field-label">Description (target)</label>
|
|
||||||
<textarea
|
|
||||||
id="description_target"
|
|
||||||
name="description_target"
|
|
||||||
class="field-textarea"
|
|
||||||
rows={3}
|
|
||||||
placeholder="Une collection de vocabulaire essentiel sur la nourriture."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid-2">
|
|
||||||
<div class="field">
|
|
||||||
<label for="source_lang" class="field-label">Source language</label>
|
|
||||||
<select id="source_lang" name="source_lang" class="field-select">
|
|
||||||
<option value="">Select…</option>
|
|
||||||
{#each LANGUAGES as lang}
|
|
||||||
<option value={lang.code}>{lang.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="target_lang" class="field-label">Target language</label>
|
|
||||||
<select id="target_lang" name="target_lang" class="field-select">
|
|
||||||
<option value="">Select…</option>
|
|
||||||
{#each LANGUAGES as lang}
|
|
||||||
<option value={lang.code}>{lang.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="field-label">Proficiency levels</span>
|
|
||||||
<div class="checkboxes">
|
|
||||||
{#each PROFICIENCY_LEVELS as level}
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" name="proficiency" value={level} />
|
|
||||||
<span>{level}</span>
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">Create pack</button>
|
|
||||||
<a href="/app/admin/packs" class="btn btn-ghost">Cancel</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
max-width: 42rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: var(--space-10) var(--space-6);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-body-sm);
|
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--text-headline-lg);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-2 {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-textarea {
|
|
||||||
min-height: 6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkboxes {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: var(--text-body-sm);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding-top: var(--space-2);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -43,7 +43,7 @@ export const actions = {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
redirect(303, '/app/packs?onboarding=1');
|
redirect(303, '/app');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: 'Something went wrong. Please try again.' };
|
return { error: 'Something went wrong. Please try again.' };
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,314 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
<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,4 +1,3 @@
|
||||||
import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth/index.ts';
|
|
||||||
import { loginApiAuthLoginPost } from '../../client/sdk.gen.ts';
|
import { loginApiAuthLoginPost } from '../../client/sdk.gen.ts';
|
||||||
import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
|
import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
|
@ -27,7 +26,7 @@ export const actions = {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 200 && data) {
|
if (response.status === 200 && data) {
|
||||||
cookies.set(COOKIE_NAME_AUTH_TOKEN, data.access_token, {
|
cookies.set('auth_token', data.access_token, {
|
||||||
path: '/',
|
path: '/',
|
||||||
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
|
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies }) => {
|
export const load: PageServerLoad = async ({ locals, cookies }) => {
|
||||||
cookies.delete(COOKIE_NAME_AUTH_TOKEN, { path: '/' });
|
cookies.delete(`auth_token`, { path: '/' });
|
||||||
return redirect(307, '/');
|
return redirect(307, '/');
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,7 @@ def test_admin_adds_flashcard_template_to_entry(admin_client: httpx.Client):
|
||||||
resp = admin_client.post(
|
resp = admin_client.post(
|
||||||
f"/api/admin/packs/{pack_id}/entries/{entry_id}/flashcards",
|
f"/api/admin/packs/{pack_id}/entries/{entry_id}/flashcards",
|
||||||
json={
|
json={
|
||||||
|
"card_direction": "target_to_source",
|
||||||
"prompt_text": "aller",
|
"prompt_text": "aller",
|
||||||
"answer_text": "to go",
|
"answer_text": "to go",
|
||||||
"prompt_context_text": "il veut [aller] au cinéma",
|
"prompt_context_text": "il veut [aller] au cinéma",
|
||||||
|
|
@ -196,6 +197,7 @@ def test_admin_adds_flashcard_template_to_entry(admin_client: httpx.Client):
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
|
assert body["card_direction"] == "target_to_source"
|
||||||
assert body["prompt_context_text"] == "il veut [aller] au cinéma"
|
assert body["prompt_context_text"] == "il veut [aller] au cinéma"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -209,6 +211,7 @@ def test_admin_get_pack_detail_includes_entries_and_templates(admin_client: http
|
||||||
admin_client.post(
|
admin_client.post(
|
||||||
f"/api/admin/packs/{pack_id}/entries/{entry_id}/flashcards",
|
f"/api/admin/packs/{pack_id}/entries/{entry_id}/flashcards",
|
||||||
json={
|
json={
|
||||||
|
"card_direction": "source_to_target",
|
||||||
"prompt_text": "house",
|
"prompt_text": "house",
|
||||||
"answer_text": "maison",
|
"answer_text": "maison",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue