Compare commits
7 commits
45336277df
...
26ce12f69f
| Author | SHA1 | Date | |
|---|---|---|---|
| 26ce12f69f | |||
| 33da2faa0d | |||
| 678ada3031 | |||
| 612c33ba93 | |||
| c9dd9d0b4c | |||
| fd96396c30 | |||
| ae9e50721b |
17 changed files with 1054 additions and 158 deletions
2
Makefile
2
Makefile
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
5
content/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Content
|
||||
|
||||
This module contains information about the written foreign-language content for Language Learning App.
|
||||
|
||||
For simplicity it contains
|
||||
5
content/evergreen/README.md
Normal file
5
content/evergreen/README.md
Normal 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.
|
||||
3
content/evergreen/zodiac/00_intro.md
Normal file
3
content/evergreen/zodiac/00_intro.md
Normal 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
3
content/fr/README.md
Normal 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
|
|
@ -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;
|
||||
|
|
|
|||
7
frontend/src/routes/+page.server.ts
Normal file
7
frontend/src/routes/+page.server.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
|
||||
export const load: ServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
isLoggedIn: !!locals.authToken
|
||||
};
|
||||
};
|
||||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</div>
|
||||
<!-- 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>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Center body -->
|
||||
<main class="body">
|
||||
<p class="eyebrow">Your reading library</p>
|
||||
|
||||
<h1 class="headline">
|
||||
Articles & <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>
|
||||
|
|
|
|||
|
|
@ -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 { data } = await searchWordformsApiDictionaryWordformsGet({
|
||||
headers: { Authorization: `Bearer ${cookies.get(COOKIE_NAME_AUTH_TOKEN)}` },
|
||||
query: { lang_code: langCode, text }
|
||||
});
|
||||
const trimmed = text.trim();
|
||||
|
||||
return data;
|
||||
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;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue