Compare commits

...

7 commits

Author SHA1 Message Date
26ce12f69f feat: [content] Initialise the content module
Some checks failed
/ test (push) Has been cancelled
2026-04-18 20:07:13 +01:00
33da2faa0d fixup: [frontend] Send through the root path server file 2026-04-18 20:06:06 +01:00
678ada3031 feat: [frontend] Update the styles of the root page, and the application
dashboard, both from claude design tool
2026-04-18 17:28:59 +01:00
612c33ba93 feat: [script] Fix how non-verb words are imported, as they were missing
a wordform entry for the "default" lemma (i.e. masculine, singular form)
2026-04-18 17:27:32 +01:00
c9dd9d0b4c feat: [api] Create better "search" functionality for the dictionary 2026-04-18 17:26:09 +01:00
fd96396c30 chore: [frontend] Generate API types 2026-04-18 17:25:31 +01:00
ae9e50721b Chore: fix the import-dictionary makefile line 2026-04-18 16:28:54 +01:00
17 changed files with 1054 additions and 158 deletions

View file

@ -37,4 +37,4 @@ rebuild: down build up
# DATABASE_URL defaults to the docker-compose dev credentials.
# Usage: make import-dictionary lang=fr
import-dictionary:
cd api && python scripts/import_dictionary.py --lang $(lang)
cd api && uv run ./scripts/import_dictionary.py --lang $(lang)

View file

@ -0,0 +1,23 @@
"""enable unaccent extension
Revision ID: 0015
Revises: 0014
Create Date: 2026-04-17
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0015"
down_revision: Union[str, None] = "0014"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS unaccent")
def downgrade() -> None:
op.execute("DROP EXTENSION IF EXISTS unaccent")

View file

@ -1,25 +1,37 @@
import uuid
from dataclasses import dataclass
from typing import Protocol
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ....domain.models.dictionary import Lemma, Sense, Wordform
from ..entities.dictionary_entities import (
DictionaryLemmaEntity,
DictionarySenseEntity,
DictionaryWordformEntity,
)
from ....domain.models.dictionary import Lemma, Sense, Wordform
class DictionaryRepository(Protocol):
async def get_senses_for_headword(self, headword: str, language: str) -> list[Sense]: ...
async def get_senses_for_headword_and_pos(self, headword: str, language: str, pos_normalised: str) -> list[Sense]: ...
async def get_senses_for_headword(
self, headword: str, language: str
) -> list[Sense]: ...
async def get_senses_for_headword_and_pos(
self, headword: str, language: str, pos_normalised: str
) -> list[Sense]: ...
async def get_senses_for_lemma(self, lemma_id: uuid.UUID) -> list[Sense]: ...
async def find_senses_by_english_gloss(self, text: str, target_lang: str) -> list[Sense]: ...
async def find_senses_by_english_gloss(
self, text: str, target_lang: str
) -> list[Sense]: ...
async def get_sense(self, sense_id: uuid.UUID) -> Sense | None: ...
async def get_lemma(self, lemma_id: uuid.UUID) -> Lemma | None: ...
async def get_wordforms_by_form(self, form: str, language: str) -> list[Wordform]: ...
async def get_wordforms_by_form(
self, form: str, language: str
) -> list[Wordform]: ...
async def search_wordforms_by_prefix(
self, prefix: str, language: str
) -> list[Wordform]: ...
async def get_wordforms_for_lemma(self, lemma_id: uuid.UUID) -> list[Wordform]: ...
@ -59,10 +71,15 @@ class PostgresDictionaryRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_senses_for_headword(self, headword: str, language: str) -> list[Sense]:
async def get_senses_for_headword(
self, headword: str, language: str
) -> list[Sense]:
result = await self.db.execute(
select(DictionarySenseEntity)
.join(DictionaryLemmaEntity, DictionarySenseEntity.lemma_id == DictionaryLemmaEntity.id)
.join(
DictionaryLemmaEntity,
DictionarySenseEntity.lemma_id == DictionaryLemmaEntity.id,
)
.where(
DictionaryLemmaEntity.headword == headword,
DictionaryLemmaEntity.language == language,
@ -71,7 +88,9 @@ class PostgresDictionaryRepository:
)
return [_sense_to_model(e) for e in result.scalars().all()]
async def find_senses_by_english_gloss(self, text: str, target_lang: str) -> list[Sense]:
async def find_senses_by_english_gloss(
self, text: str, target_lang: str
) -> list[Sense]:
"""EN→target direction: find senses whose gloss matches the given English text.
Uses a case-insensitive exact match on the gloss column, filtered to the
@ -79,7 +98,10 @@ class PostgresDictionaryRepository:
"""
result = await self.db.execute(
select(DictionarySenseEntity)
.join(DictionaryLemmaEntity, DictionarySenseEntity.lemma_id == DictionaryLemmaEntity.id)
.join(
DictionaryLemmaEntity,
DictionarySenseEntity.lemma_id == DictionaryLemmaEntity.id,
)
.where(
DictionarySenseEntity.gloss.ilike(text),
DictionaryLemmaEntity.language == target_lang,
@ -107,7 +129,10 @@ class PostgresDictionaryRepository:
) -> list[Sense]:
result = await self.db.execute(
select(DictionarySenseEntity)
.join(DictionaryLemmaEntity, DictionarySenseEntity.lemma_id == DictionaryLemmaEntity.id)
.join(
DictionaryLemmaEntity,
DictionarySenseEntity.lemma_id == DictionaryLemmaEntity.id,
)
.where(
DictionaryLemmaEntity.headword == headword,
DictionaryLemmaEntity.language == language,
@ -128,7 +153,10 @@ class PostgresDictionaryRepository:
async def get_wordforms_by_form(self, form: str, language: str) -> list[Wordform]:
result = await self.db.execute(
select(DictionaryWordformEntity)
.join(DictionaryLemmaEntity, DictionaryWordformEntity.lemma_id == DictionaryLemmaEntity.id)
.join(
DictionaryLemmaEntity,
DictionaryWordformEntity.lemma_id == DictionaryLemmaEntity.id,
)
.where(
DictionaryWordformEntity.form == form,
DictionaryLemmaEntity.language == language,
@ -136,6 +164,47 @@ class PostgresDictionaryRepository:
)
return [_wordform_to_model(e) for e in result.scalars().all()]
async def search_wordforms_by_prefix(
self, prefix: str, language: str
) -> list[Wordform]:
result = await self.db.execute(
select(DictionaryWordformEntity)
.join(
DictionaryLemmaEntity,
DictionaryWordformEntity.lemma_id == DictionaryLemmaEntity.id,
)
.where(
func.unaccent(DictionaryWordformEntity.form).ilike(
func.unaccent(prefix) + "%"
),
DictionaryLemmaEntity.language == language,
)
)
return [_wordform_to_model(e) for e in result.scalars().all()]
async def search_senses_by_prefix(
self, prefix: str, lang: str
) -> list[tuple[Sense, Lemma]]:
result = await self.db.execute(
select(DictionarySenseEntity, DictionaryLemmaEntity)
.join(
DictionaryLemmaEntity,
DictionarySenseEntity.lemma_id == DictionaryLemmaEntity.id,
)
.where(
DictionarySenseEntity.gloss.ilike(prefix),
DictionaryLemmaEntity.language == lang,
)
)
results: list[tuple[Sense, Lemma]] = []
for sense_with_lemma in result.all():
sense, lemma = sense_with_lemma.tuple()
results.append((_sense_to_model(sense), _lemma_to_model(lemma)))
return results
async def get_wordforms_for_lemma(self, lemma_id: uuid.UUID) -> list[Wordform]:
result = await self.db.execute(
select(DictionaryWordformEntity).where(

View file

@ -4,15 +4,20 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models.dictionary import Lemma, Sense
from ...auth import verify_token
from ...outbound.postgres.database import get_db
from ...outbound.postgres.repositories.dictionary_repository import PostgresDictionaryRepository
from ...outbound.postgres.repositories.dictionary_repository import (
PostgresDictionaryRepository,
)
router = APIRouter(prefix="/dictionary", tags=["dictionary"])
# ── Response models ───────────────────────────────────────────────────────────
class SenseResponse(BaseModel):
id: str
sense_index: int
@ -31,33 +36,61 @@ class LemmaResponse(BaseModel):
tags: list[str]
def _sense_to_response(s: Sense) -> SenseResponse:
return SenseResponse(
id=s.id,
sense_index=s.sense_index,
gloss=s.gloss,
topics=s.topics,
tags=s.tags,
)
def _lemma_to_response(lemma: Lemma) -> LemmaResponse:
return LemmaResponse(
id=lemma.id,
headword=lemma.headword,
language=lemma.language,
pos_raw=lemma.pos_raw,
pos_normalised=lemma.pos_normalised,
gender=lemma.gender,
tags=lemma.tags,
)
class WordformMatch(BaseModel):
lemma: LemmaResponse
senses: list[SenseResponse]
class SenseMatch(BaseModel):
sense: SenseResponse
lemma: LemmaResponse
# ── Endpoint ──────────────────────────────────────────────────────────────────
@router.get("/wordforms", response_model=list[WordformMatch])
async def search_wordforms(
@router.get("/search", response_model=list[WordformMatch])
async def search_wordforms_prefix(
lang_code: str,
text: str,
db: AsyncSession = Depends(get_db),
_: dict = Depends(verify_token),
) -> list[WordformMatch]:
"""
Search for a wordform by surface text within a language.
Search for wordforms whose surface text starts with the given prefix.
Returns one entry per matching lemma, each with the lemma's senses. A single
form (e.g. "allons") may resolve to more than one lemma when homographs exist.
Uses accent-insensitive, case-insensitive prefix matching so that e.g.
"chatea" returns both "château" and "châteaux", and "lent" returns all
four forms of the adjective. Returns one entry per matching lemma.
"""
repo = PostgresDictionaryRepository(db)
wordforms = await repo.get_wordforms_by_form(text, lang_code)
wordforms = await repo.search_wordforms_by_prefix(text, lang_code)
if not wordforms:
return []
# Deduplicate lemma IDs — multiple wordform rows may point to the same lemma
seen_lemma_ids: set[str] = set()
results: list[WordformMatch] = []
@ -97,3 +130,71 @@ async def search_wordforms(
)
return results
@router.get("/senses", response_model=list[SenseMatch])
async def search_senses(
lang_code: str,
text: str,
db: AsyncSession = Depends(get_db),
_: dict = Depends(verify_token),
) -> list[SenseMatch]:
"""
Search for a Sense by (English) definition
Returns one entry per matching senses,each with its Sense.
"""
repo = PostgresDictionaryRepository(db)
senses = await repo.search_senses_by_prefix(text, lang_code)
if not senses:
return []
return [
SenseMatch(lemma=_lemma_to_response(lemma), sense=_sense_to_response(sense))
for (sense, lemma) in senses
]
@router.get("/wordforms", response_model=list[WordformMatch])
async def search_wordforms(
lang_code: str,
text: str,
db: AsyncSession = Depends(get_db),
_: dict = Depends(verify_token),
) -> list[WordformMatch]:
"""
Search for a wordform by surface text within a language.
Returns one entry per matching lemma, each with the lemma's senses. A single
form (e.g. "allons") may resolve to more than one lemma when homographs exist.
"""
repo = PostgresDictionaryRepository(db)
wordforms = await repo.get_wordforms_by_form(text, lang_code)
if not wordforms:
return []
# Deduplicate lemma IDs — multiple wordform rows may point to the same lemma
seen_lemma_ids: set[str] = set()
results: list[WordformMatch] = []
for wf in wordforms:
if wf.lemma_id in seen_lemma_ids:
continue
seen_lemma_ids.add(wf.lemma_id)
lemma = await repo.get_lemma(uuid.UUID(wf.lemma_id))
if lemma is None:
continue
senses = await repo.get_senses_for_lemma(uuid.UUID(wf.lemma_id))
results.append(
WordformMatch(
lemma=_lemma_to_response(lemma),
senses=[_sense_to_response(s) for s in senses],
)
)
return results

View file

@ -278,6 +278,20 @@ def _parse_entry(record: dict, lang_code: str) -> dict | None:
}
)
# Verbs have a dedicated kaikki entry for each conjugated form (including
# the infinitive itself), so the headword is already covered. For all other
# POS (nouns, adjectives, …) no such entry exists, so we add the headword
# form explicitly here.
if pos_raw != "verb":
wordforms.append(
{
"id": _wordform_uuid(lemma_id, word, []),
"lemma_id": lemma_id,
"form": word,
"tags": [],
}
)
return {
"lemma": {
"id": lemma_id,

5
content/README.md Normal file
View file

@ -0,0 +1,5 @@
# Content
This module contains information about the written foreign-language content for Language Learning App.
For simplicity it contains

View file

@ -0,0 +1,5 @@
# Evergreen Content
This directory contains evergreen content, which are not notably specific towards any culture or country.
Any kind of content is going to have some degree of connection with the context (culture, language, time) that it was created or changed in. Nothing can be truly apolitical or universal.

View file

@ -0,0 +1,3 @@
# Zodiac Signs
This set of content describes the twelve signs of the zodiac.

3
content/fr/README.md Normal file
View file

@ -0,0 +1,3 @@
# French Content
This directory contains French-specific content.

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 one or more lines are too long

View file

@ -1522,6 +1522,42 @@ export type RemoveLearnableLanguageApiAccountLearnableLanguagesLanguageIdDeleteR
export type RemoveLearnableLanguageApiAccountLearnableLanguagesLanguageIdDeleteResponse = RemoveLearnableLanguageApiAccountLearnableLanguagesLanguageIdDeleteResponses[keyof RemoveLearnableLanguageApiAccountLearnableLanguagesLanguageIdDeleteResponses];
export type SearchWordformsPrefixApiDictionarySearchGetData = {
body?: never;
path?: never;
query: {
/**
* Lang Code
*/
lang_code: string;
/**
* Text
*/
text: string;
};
url: '/api/dictionary/search';
};
export type SearchWordformsPrefixApiDictionarySearchGetErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type SearchWordformsPrefixApiDictionarySearchGetError = SearchWordformsPrefixApiDictionarySearchGetErrors[keyof SearchWordformsPrefixApiDictionarySearchGetErrors];
export type SearchWordformsPrefixApiDictionarySearchGetResponses = {
/**
* Response Search Wordforms Prefix Api Dictionary Search Get
*
* Successful Response
*/
200: Array<WordformMatch>;
};
export type SearchWordformsPrefixApiDictionarySearchGetResponse = SearchWordformsPrefixApiDictionarySearchGetResponses[keyof SearchWordformsPrefixApiDictionarySearchGetResponses];
export type SearchWordformsApiDictionaryWordformsGetData = {
body?: never;
path?: never;

View file

@ -0,0 +1,7 @@
import type { ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = async ({ locals }) => {
return {
isLoggedIn: !!locals.authToken
};
};

View file

@ -1,7 +1,428 @@
<h1>Language Learning App</h1>
<script lang="ts">
import type { PageProps } from './$types';
<p>This is a language learning application.</p>
const { data }: PageProps = $props();
<p>
You probably want to <a href="/login">Login</a> to get started.
</p>
const now = new Date();
const verticalDate = now.toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
</script>
<!-- Top nav -->
<header class="sitenav">
<div class="sitenav-inner">
<a href="/" class="wordmark">Language Learning App</a>
<nav>
{#if data.isLoggedIn}
<a href="/app" class="btn btn-primary btn-sm">Go to app</a>
{:else}
<div class="auth-links">
<a href="/login" class="nav-link">Log in</a>
<a href="/register" class="btn btn-primary btn-sm">Create account</a>
</div>
{/if}
</nav>
</div>
</header>
<!-- Page body -->
<div class="page">
<!-- Left margin -->
<aside class="left-margin">
<div class="margin-copy">
<span class="meta-label">French · A2 → B1</span>
<span class="margin-sub">Read. Save. Review.</span>
</div>
<hr class="divider" />
<ul class="feature-list" role="list">
<li class="feature-item">
<span class="feature-mark">·</span>
<span>Articles &amp; reading</span>
</li>
<li class="feature-item">
<span class="feature-mark">·</span>
<span>Vocabulary lists</span>
</li>
<li class="feature-item">
<span class="feature-mark">·</span>
<span>Spaced repetition</span>
</li>
<li class="feature-item">
<span class="feature-mark">·</span>
<span>Word packs</span>
</li>
</ul>
</aside>
<!-- Center body -->
<main class="body">
<p class="eyebrow">A language learning application</p>
<h1 class="headline">
Read French.<br /><em>Learn naturally.</em>
</h1>
<p class="description">
Immerse yourself in curated and AI-generated French articles. Tap any word for a definition,
save vocabulary as you read, and review with spaced repetition — no gamification, just the
language.
</p>
<div class="actions">
{#if data.isLoggedIn}
<a href="/app" class="btn btn-primary">
Go to app <span class="arr"></span>
</a>
{:else}
<a href="/register" class="btn btn-primary">
Get started <span class="arr"></span>
</a>
<a href="/login" class="btn btn-secondary">Log in</a>
{/if}
</div>
<hr class="divider secondary" />
<div class="pillars">
<div class="pillar">
<span class="pillar-kicker meta-label">Exposure</span>
<p class="pillar-body">
Read articles written for your level. Bespoke content generated from topics you choose, or
browse an evergreen library.
</p>
</div>
<div class="pillar">
<span class="pillar-kicker meta-label">Vocabulary</span>
<p class="pillar-body">
Save words as you encounter them. Import packs around themes — cuisine, travel, culture.
Build a deck that reflects your reading.
</p>
</div>
<div class="pillar">
<span class="pillar-kicker meta-label">Review</span>
<p class="pillar-body">
Typed-recall flashcards powered by spaced repetition. No multiple choice, no confetti —
just the words, when they're due.
</p>
</div>
</div>
</main>
<!-- Right rail -->
<aside class="right-rail" aria-hidden="true">
<span class="vertical-date">{verticalDate}</span>
</aside>
</div>
<style>
/* ---------- Site nav ---------- */
.sitenav {
position: sticky;
top: 0;
z-index: 100;
background-color: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
box-shadow: 0 1px 0 color-mix(in srgb, var(--color-outline-variant) 35%, transparent);
}
.sitenav-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-6);
max-width: 82rem;
margin: 0 auto;
padding: 0 var(--space-6);
height: 3.25rem;
}
.wordmark {
font-family: var(--font-display);
font-size: var(--text-body-md);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-wide);
color: var(--color-on-surface);
text-decoration: none;
flex-shrink: 0;
}
.auth-links {
display: flex;
align-items: center;
gap: var(--space-2);
}
.nav-link {
font-family: var(--font-label);
font-size: var(--text-label-lg);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
color: var(--color-on-surface-variant);
text-decoration: none;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-md);
transition: color var(--duration-fast) var(--ease-standard);
}
.nav-link:hover {
color: var(--color-on-surface);
}
/* ---------- Layout ---------- */
.page {
display: grid;
grid-template-columns: 260px 1fr 100px;
min-height: calc(100vh - 3.25rem);
}
/* ---------- Left margin ---------- */
.left-margin {
border-right: 1px solid var(--color-outline-variant);
padding: var(--space-12) var(--space-5) var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.margin-copy {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.margin-sub {
font-family: var(--font-body);
font-size: var(--text-headline-sm);
font-style: italic;
color: var(--color-primary);
line-height: var(--leading-snug);
}
.feature-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0;
margin-top: var(--space-1);
}
.feature-item {
display: flex;
gap: var(--space-2);
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);
line-height: var(--leading-loose);
}
.feature-mark {
color: var(--color-primary);
}
/* ---------- Center body ---------- */
.body {
padding: var(--space-12) var(--space-12) var(--space-10);
display: flex;
flex-direction: column;
gap: var(--space-5);
max-width: 52rem;
}
.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: 0;
}
.headline {
font-family: var(--font-body);
font-size: clamp(3rem, 6vw, 5rem);
font-weight: var(--weight-regular);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--color-on-surface);
margin: 0;
}
.headline em {
color: var(--color-primary);
}
.description {
font-family: var(--font-body);
font-size: var(--text-body-xl);
line-height: var(--leading-relaxed);
color: var(--color-on-surface-variant);
max-width: 38rem;
margin: 0;
}
.actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.arr {
font-family: var(--font-body);
}
.divider {
border: none;
border-top: 1px solid var(--color-outline-variant);
margin: 0;
}
.divider.secondary {
margin-top: var(--space-2);
}
/* ---------- Pillars ---------- */
.pillars {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-6);
}
.pillar {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.pillar-kicker {
color: var(--color-on-surface-variant);
}
.pillar-body {
font-family: var(--font-body);
font-size: var(--text-body-md);
line-height: var(--leading-relaxed);
color: var(--color-on-surface-variant);
margin: 0;
}
/* ---------- Right rail ---------- */
.right-rail {
border-left: 1px dashed var(--color-outline-variant);
padding: var(--space-12) var(--space-4);
display: flex;
align-items: flex-start;
justify-content: center;
}
.vertical-date {
font-family: var(--font-label);
font-size: var(--text-label-sm);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--color-outline);
writing-mode: vertical-rl;
transform: rotate(180deg);
white-space: nowrap;
}
/* ---------- Shared utility ---------- */
.meta-label {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-on-surface-variant);
}
/* ---------- Responsive ---------- */
@media (max-width: 960px) {
.page {
grid-template-columns: 1fr;
}
.left-margin {
border-right: none;
border-bottom: 1px solid var(--color-outline-variant);
padding: var(--space-6) var(--space-6) var(--space-5);
flex-direction: row;
flex-wrap: wrap;
gap: var(--space-5);
}
.divider:first-of-type {
display: none;
}
.feature-list {
flex-direction: row;
gap: var(--space-4);
}
.right-rail {
display: none;
}
.body {
padding: var(--space-8) var(--space-6) var(--space-10);
}
.pillars {
grid-template-columns: 1fr;
gap: var(--space-5);
}
}
@media (max-width: 640px) {
.sitenav-inner {
padding: 0 var(--space-4);
}
.wordmark {
font-size: var(--text-label-lg);
}
.body {
padding: var(--space-6) var(--space-4) var(--space-8);
}
.headline {
font-size: var(--text-display-sm);
}
.description {
font-size: var(--text-body-lg);
}
.actions {
flex-direction: column;
align-items: flex-start;
}
.feature-list {
flex-direction: column;
gap: 0;
}
}
</style>

View file

@ -1,172 +1,348 @@
<script lang="ts">
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : hour < 17 ? 'Good afternoon' : 'Good evening';
const now = new Date();
const dayName = now.toLocaleDateString('en-US', { weekday: 'long' });
const dateShort = now.toLocaleDateString('en-US', { day: 'numeric', month: 'long' });
const dateFull = now.toLocaleDateString('en-US', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
const verticalDate = `${dayName} · ${dateFull}`;
</script>
<div class="page">
<div class="hero">
<p class="eyebrow label-md">Dashboard</p>
<h1 class="hero-heading">{greeting}.</h1>
<p class="hero-sub">What will you learn today?</p>
<!-- Left margin -->
<aside class="left-margin">
<div class="margin-date">
<span class="meta-label">{dayName}</span>
<span class="margin-day">{dateShort}</span>
</div>
<div class="card-grid">
<a href="/app/articles" class="card card--primary">
<div class="card-kicker label-md">Read</div>
<h2 class="card-title">Articles</h2>
<p class="card-body">Browse your reading library and practice with word-by-word translations.</p>
<span class="card-cta" aria-hidden="true">Open library →</span>
</a>
<hr class="divider" />
<a href="/app/generate/summary" class="card">
<div class="card-kicker label-md">Create</div>
<h2 class="card-title">New article</h2>
<p class="card-body">Generate a new reading from any text in the language you're learning.</p>
<span class="card-cta" aria-hidden="true">Get started →</span>
</a>
<span class="meta-label">Today</span>
<ul class="today-list" role="list">
<li><a href="/app/articles" class="today-item">· Read</a></li>
<li><a href="/app/generate/summary" class="today-item">· Create</a></li>
<li><a href="/app/packs" class="today-item">· Packs</a></li>
</ul>
</aside>
<a href="/app/jobs" class="card">
<div class="card-kicker label-md">History</div>
<h2 class="card-title">Jobs</h2>
<p class="card-body">Review the status of your generation jobs and access completed content.</p>
<span class="card-cta" aria-hidden="true">View jobs →</span>
<!-- Center body -->
<main class="body">
<p class="eyebrow">Your reading library</p>
<h1 class="headline">
Articles &amp; <em>reading</em>
</h1>
<p class="description">
Browse your library of French articles and generated readings. Tap any word for a definition
and save vocabulary as you go.
</p>
<div class="actions">
<a href="/app/articles" class="btn btn-primary">
Open library <span class="arr"></span>
</a>
<span class="meta-label">or generate a new article below</span>
</div>
<hr class="divider secondary" />
<div class="secondary-items">
<a href="/app/generate/summary" class="secondary-item">
<span class="secondary-kicker meta-label">Create</span>
<span class="secondary-title">New article</span>
<span class="secondary-arrow"></span>
</a>
<a href="/app/packs" class="secondary-item">
<span class="secondary-kicker meta-label">Browse</span>
<span class="secondary-title">Word packs</span>
<span class="secondary-arrow"></span>
</a>
<a href="/app/jobs" class="secondary-item">
<span class="secondary-kicker meta-label">History</span>
<span class="secondary-title">Jobs</span>
<span class="secondary-arrow"></span>
</a>
</div>
</main>
<!-- Right rail -->
<aside class="right-rail" aria-hidden="true">
<span class="vertical-date">{verticalDate}</span>
</aside>
</div>
<style>
/* ---------- Layout ---------- */
.page {
max-width: 60rem;
margin: 0 auto;
padding: var(--space-12) var(--space-6) var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-10);
}
/* --- Hero --- */
.hero {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.eyebrow {
color: var(--color-on-surface-variant);
}
.hero-heading {
font-family: var(--font-display);
font-size: var(--text-display-md);
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--color-on-surface);
}
.hero-sub {
font-family: var(--font-body);
font-size: var(--text-body-lg);
color: var(--color-on-surface-variant);
margin-top: var(--space-1);
}
/* --- Card grid --- */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
grid-template-columns: 260px 1fr 100px;
min-height: calc(100vh - 3.25rem); /* below TopNav */
}
/* ---------- Left margin ---------- */
.left-margin {
border-right: 1px solid var(--color-outline-variant);
padding: var(--space-12) var(--space-5) var(--space-8);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* --- Card --- */
.card {
.margin-date {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-6);
background-color: var(--color-surface-container-low);
border-radius: var(--radius-xl);
gap: var(--space-1);
}
.margin-day {
font-family: var(--font-body);
font-size: var(--text-headline-sm);
font-style: italic;
color: var(--color-primary);
line-height: var(--leading-snug);
}
.today-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0;
margin-top: var(--space-1);
}
.today-item {
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);
text-decoration: none;
line-height: var(--leading-loose);
transition: color var(--duration-fast) var(--ease-standard);
}
.today-item:hover {
color: var(--color-on-surface);
}
/* ---------- Center body ---------- */
.body {
padding: var(--space-12) var(--space-12) var(--space-10);
display: flex;
flex-direction: column;
gap: var(--space-5);
max-width: 48rem;
}
.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: 0;
}
.headline {
font-family: var(--font-body);
font-size: clamp(3rem, 6vw, 5rem);
font-weight: var(--weight-regular);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--color-on-surface);
margin: 0;
}
.headline em {
color: var(--color-primary);
}
.description {
font-family: var(--font-body);
font-size: var(--text-body-xl);
line-height: var(--leading-relaxed);
color: var(--color-on-surface-variant);
max-width: 36rem;
margin: 0;
}
.actions {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.arr {
font-family: var(--font-body);
}
.divider {
border: none;
border-top: 1px solid var(--color-outline-variant);
margin: 0;
}
.divider.secondary {
margin-top: var(--space-2);
}
/* ---------- Secondary items ---------- */
.secondary-items {
display: flex;
flex-direction: column;
}
.secondary-item {
display: grid;
grid-template-columns: 5rem 1fr auto;
align-items: baseline;
gap: var(--space-4);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-surface-container);
text-decoration: none;
color: inherit;
transition: background-color var(--duration-fast) var(--ease-standard);
}
.card:hover {
background-color: var(--color-surface-container);
.secondary-item:last-child {
border-bottom: none;
}
.card--primary {
background-color: var(--color-primary-container);
}
.card--primary:hover {
background-color: color-mix(in srgb, var(--color-primary-container) 80%, var(--color-primary));
}
.card-kicker {
.secondary-kicker {
color: var(--color-on-surface-variant);
margin-bottom: var(--space-1);
}
.card--primary .card-kicker {
color: var(--color-on-primary-container);
opacity: 0.75;
}
.card-title {
font-family: var(--font-display);
font-size: var(--text-headline-md);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
.secondary-title {
font-family: var(--font-body);
font-size: var(--text-title-lg);
font-style: italic;
color: var(--color-on-surface);
}
.card--primary .card-title {
color: var(--color-on-primary-container);
}
.card-body {
.secondary-arrow {
font-family: var(--font-body);
font-size: var(--text-body-sm);
font-size: var(--text-body-md);
color: var(--color-on-surface-variant);
line-height: var(--leading-relaxed);
flex: 1;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.card--primary .card-body {
color: var(--color-on-primary-container);
opacity: 0.8;
.secondary-item:hover .secondary-arrow {
opacity: 1;
}
.card-cta {
font-family: var(--font-label);
font-size: var(--text-label-lg);
font-weight: var(--weight-medium);
.secondary-item:hover .secondary-title {
color: var(--color-primary);
margin-top: var(--space-2);
}
.card--primary .card-cta {
color: var(--color-on-primary-container);
/* ---------- Right rail ---------- */
.right-rail {
border-left: 1px dashed var(--color-outline-variant);
padding: var(--space-12) var(--space-4);
display: flex;
align-items: flex-start;
justify-content: center;
}
/* --- Responsive --- */
.vertical-date {
font-family: var(--font-label);
font-size: var(--text-label-sm);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--color-outline);
writing-mode: vertical-rl;
transform: rotate(180deg);
white-space: nowrap;
}
/* ---------- Shared utility ---------- */
.meta-label {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-on-surface-variant);
}
/* ---------- Responsive ---------- */
@media (max-width: 960px) {
.page {
grid-template-columns: 1fr;
}
.left-margin {
border-right: none;
border-bottom: 1px solid var(--color-outline-variant);
padding: var(--space-6) var(--space-6) var(--space-5);
flex-direction: row;
flex-wrap: wrap;
gap: var(--space-5);
}
.margin-date {
flex-direction: row;
align-items: baseline;
gap: var(--space-3);
}
.margin-day {
font-size: var(--text-body-lg);
}
.divider:first-of-type {
display: none;
}
.today-list {
flex-direction: row;
gap: var(--space-4);
}
.right-rail {
display: none;
}
.body {
padding: var(--space-8) var(--space-6) var(--space-10);
}
.headline {
font-size: var(--text-display-md);
}
}
@media (max-width: 640px) {
.page {
padding: var(--space-8) var(--space-4) var(--space-6);
gap: var(--space-8);
.body {
padding: var(--space-6) var(--space-4) var(--space-8);
}
.hero-heading {
font-size: var(--text-headline-lg);
.headline {
font-size: var(--text-display-sm);
}
.card-grid {
grid-template-columns: 1fr;
.description {
font-size: var(--text-body-lg);
}
.actions {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View file

@ -1,6 +1,9 @@
import { query, getRequestEvent } from '$app/server';
import * as v from 'valibot';
import { searchWordformsApiDictionaryWordformsGet } from '../../../../client';
import {
searchWordformsApiDictionarySearchGet,
searchWordformsApiDictionaryWordformsGet
} from '../../../../client';
import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth';
export const dictionarySearch = query(
@ -10,11 +13,26 @@ export const dictionarySearch = query(
}),
async ({ langCode, text }) => {
const { cookies } = getRequestEvent();
const trimmed = text.trim();
if (trimmed.length === 0) {
return [];
}
if (trimmed.length < 5) {
const { data } = await searchWordformsApiDictionaryWordformsGet({
headers: { Authorization: `Bearer ${cookies.get(COOKIE_NAME_AUTH_TOKEN)}` },
query: { lang_code: langCode, text }
});
return data;
} else {
const { data } = await searchWordformsApiDictionarySearchGet({
headers: { Authorization: `Bearer ${cookies.get(COOKIE_NAME_AUTH_TOKEN)}` },
query: { lang_code: langCode, text }
});
return data;
}
}
);