Compare commits

..

2 commits

33 changed files with 1595 additions and 53 deletions

View file

View file

@ -0,0 +1,15 @@
from dataclasses import dataclass
@dataclass
class TranslatedArticle:
id: str
source_lang: str
source_title: str
source_text: str
target_lang: str
target_title: str
target_text: str

View file

@ -0,0 +1,30 @@
import re
from ..models.summarise_job import SummariseJob
from ..models.translated_article import TranslatedArticle
def first_heading(md: str) -> str | None:
m = re.search(r'^#{1,2}\s+(.+)', md, re.MULTILINE)
return m.group(1).strip() if m else None
class ArticleService:
def __init__(self, summarise_job_repository):
self.summarise_job_repository = summarise_job_repository
async def get_all_articles(self) -> list[TranslatedArticle]:
summarise_jobs = await self.summarise_job_repository.list_all()
return summarise_jobs.map(self.summarise_job_to_translated_article)
def summarise_job_to_translated_article(
self,
summarise_job: SummariseJob,
) -> TranslatedArticle:
return TranslatedArticle(
id=summarise_job.id,
source_lang=summarise_job.target_language, # The source language for the article is the target language of the job
source_title=first_heading(summarise_job.translated_text) or "",
source_text=summarise_job.translated_text,
target_lang=summarise_job.source_language, # The target language for the article is the source language of the job
target_title=first_heading(summarise_job.generated_text) or "",
target_text=summarise_job.generated_text,
)

View file

@ -8,6 +8,7 @@ from .routers.api import jobs
from .routers import auth as auth_router
from .routers import media as media_router
from .routers.api.main import api_router
from .routers.bff.main import bff_router
from .storage import ensure_bucket_exists
from . import worker
@ -27,6 +28,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Language Learning API", lifespan=lifespan)
app.include_router(api_router)
app.include_router(bff_router)
app.include_router(auth_router.router)
app.include_router(media_router.router)

View file

@ -11,6 +11,6 @@ class Base(DeclarativeBase):
pass
async def get_db():
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session

View file

@ -5,7 +5,51 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..entities.summarise_job_entity import SummariseJobEntity
from ....domain.models.summarise_job import SummariseJob
class PostgresSummariseJobRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def list_all(self) -> list[SummariseJob]:
result = self.db.execute(
select(SummariseJobEntity).order_by(SummariseJobEntity.created_at.desc())
)
return list(result.scalars().all()).map(self.entity_to_model)
async def get_by_audio_url(
self,
audio_url: str
) -> SummariseJob | None:
result = await self.db.execute(
select(SummariseJobEntity).where(
SummariseJobEntity.audio_url == audio_url
)
)
return self.entity_to_model(result.scalar_one_or_none())
def entity_to_model(self, entity: SummariseJobEntity | None) -> SummariseJob:
if entity is None:
return None
return SummariseJob(
id=str(entity.id),
user_id=str(entity.user_id),
status=entity.status,
source_language=entity.source_language,
target_language=entity.target_language,
complexity_level=entity.complexity_level,
input_summary=entity.input_summary,
generated_text=entity.generated_text,
translated_text=entity.translated_text,
error_message=entity.error_message,
audio_url=entity.audio_url,
created_at=entity.created_at,
started_at=entity.started_at,
completed_at=entity.completed_at,
)
async def update(db: AsyncSession, job: SummariseJobEntity) -> None:
await db.commit()
@ -41,18 +85,6 @@ async def list_all(db: AsyncSession) -> list[SummariseJobEntity]:
return list(result.scalars().all())
async def get_by_audio_url_and_user(
db: AsyncSession, audio_url: str, user_id: uuid.UUID
) -> SummariseJobEntity | None:
result = await db.execute(
select(SummariseJobEntity).where(
SummariseJobEntity.audio_url == audio_url,
SummariseJobEntity.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def mark_processing(db: AsyncSession, job: SummariseJobEntity) -> None:
job.status = "processing"
job.started_at = datetime.now(timezone.utc)

View file

@ -23,7 +23,6 @@ class GenerationRequest(BaseModel):
target_language: str
complexity_level: str
input_texts: list[str]
topic: str | None = None
source_language: str = "en"

View file

View file

@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ...domain.services.article_service import ArticleService
from ...outbound.postgres.database import get_db, AsyncSessionLocal
from ...outbound.postgres.repositories.summarise_job_repository import PostgresSummariseJobRepository
router = APIRouter(prefix="/articles", tags=["articles"])
class ArticleResponse(BaseModel):
target_language: str
complexity_level: str
input_texts: list[str]
class ArticlesResponse(BaseModel):
articles: list[ArticleResponse]
@router.get("", response_model=ArticlesResponse, status_code=200)
async def get_articles(
db = Depends(get_db),
) -> ArticlesResponse:
service = ArticleService(PostgresSummariseJobRepository(db))
try:
articles = await service.get_all_articles()
return ArticlesResponse(articles=articles)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,7 @@
from .articles import router as article_router
from fastapi import APIRouter
bff_router = APIRouter(prefix="/bff", tags=["bff"])
bff_router.include_router(article_router)

View file

@ -7,7 +7,7 @@ from botocore.exceptions import ClientError
from ..auth import verify_token
from ..outbound.postgres.database import get_db
from ..outbound.postgres.repositories import summarise_job_repository
from ..outbound.postgres.repositories.summarise_job_repository import PostgresSummariseJobRepository
from ..storage import download_audio
router = APIRouter(prefix="/media", tags=["media"])
@ -17,11 +17,10 @@ router = APIRouter(prefix="/media", tags=["media"])
async def get_media_file(
filename: str,
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> Response:
user_id = uuid.UUID(token_data["sub"])
repository = PostgresSummariseJobRepository(db)
job = await repository.get_by_audio_url(filename)
job = await summarise_job_repository.get_by_audio_url_and_user(db, filename, user_id)
if job is None:
raise HTTPException(status_code=404, detail="File not found")

109
frontend/design.md Normal file
View file

@ -0,0 +1,109 @@
# Design System Document: Language Learning App
## 1. Overview & Creative North Star
**Creative North Star: The Digital Archivist**
This design system rejects the frantic, "attention-economy" aesthetic of modern web apps. Instead, it draws inspiration from high-end printed journals and architectural minimalism. The goal is to create a "Digital Paper" experience that honours the act of reading.
We break the standard "SaaS dashboard" template by using intentional asymmetry and high-contrast typographic scales.
Layouts with multiple sources of information should feel like a well-composed magazine spread: large, sweeping areas of `surface` punctuated by tight, authoritative `label` groupings. We do not fill space; we curate it.
Layouts which are focused on content, e.g. reading or listening, should feel focused and intentional.
---
## 2. Colors: The Palette of Focus
Our palette is rooted in organic, desaturated tones that reduce eye strain and promote deep work.
- **Primary (`#516356`)**: A muted Forest Green. This is our singular "Action" voice. Use it sparingly to guide the eye, not to shout.
- **The "No-Line" Rule**: You are strictly prohibited from using 1px solid borders to define sections. Layout boundaries are created exclusively through background shifts. For example, a `surface-container-low` (`#f4f4ef`) sidebar sitting against a `surface` (`#faf9f5`) main body.
- **Nesting & Layers**: Treat the UI as stacked sheets of fine vellum. Use `surface-container-lowest` (`#ffffff`) for the most elevated elements (like an active reading pane) and `surface-dim` (`#d6dcd2`) for recessed utility areas.
- **The "Glass & Gradient" Rule**: To prevent the UI from feeling "dead," use subtle radial gradients on hero backgrounds (transitioning from `surface` to `surface-container-low`). For floating navigation, apply `surface` colors at 80% opacity with a `24px` backdrop-blur to create a "frosted glass" effect that lets the content bleed through softly.
---
## 3. Typography: The Editorial Engine
Typography is the primary visual asset. We use a sophisticated pairing of **Archivo** (Sans-serif) for functional UI and **Newsreader** (Serif) for the reading experience.
- **Display & Headline (Archivo)**: These are your "Wayfinders." Use `display-lg` (3.5rem) with tight letter-spacing for article titles to create an authoritative, architectural feel.
- **Body (Newsreader)**: This is the soul of the system. `body-lg` (1rem) is the standard for long-form reading. It must have a line-height of at least 1.6 to ensure the "Digital Paper" feel.
- **Labels (Inter)**: Use `label-md` in all-caps with a `0.05rem` letter-spacing for metadata (e.g., "READING TIME," "DATE SAVED"). This creates a stark, functional contrast to the fluid Serif body text.
---
## 4. Elevation & Depth: Tonal Layering
We do not use shadows to mimic light; we use tone to mimic physical presence.
- **The Layering Principle**: If a card needs to stand out, do not add a shadow. Instead, change its background to `surface-container-lowest` and place it on a `surface-container` background. The 2-step tonal shift provides all the clarity needed.
- **Ambient Shadows**: If a component _must_ float (like a mobile action button), use a "Tonal Shadow": `color: on-surface` at 5% opacity, with a `32px` blur and `8px` Y-offset. It should look like a soft smudge of graphite, not a digital drop shadow.
- **The "Ghost Border"**: If a button needs a stroke for accessibility, use `outline-variant` (`#afb3ac`) at 20% opacity. If you can see the line clearly, its too dark.
---
## 5. Components: Functional Minimalism
### Buttons
- **Primary**: Background `primary` (`#516356`), text `on_primary` (`#e9fded`). Corner radius `md` (`0.375rem`). No border.
- **Secondary**: Background `secondary_container` (`#dde4de`), text `on_secondary_container`.
- **Tertiary/Ghost**: No background. Text `primary`. Use only `label-md` typography.
### Input Fields
- **Style**: Forgo the "box" look. Use a `surface-container-high` background with a bottom-only "Ghost Border."
- **States**: On focus, the bottom border transitions to `primary` (`#516356`) with a width of 2px.
### Cards & Lists
- **The "No-Divider" Rule**: Forbid the use of horizontal rules (`
`). Separate list items using the`3`(1rem) or`4` (1.4rem) spacing tokens.
- **Structure**: Group items using subtle background shifts. An active list item should move from `surface` to `surface-container-lowest`.
### The "Reading Progress" Component (System Specific)
- A thin, 2px bar using `primary` that sits at the very top of the viewport. As the user scrolls, it grows. No container or background for the bar—it should look like a thread laying on the paper.
---
## 6. Dos and Donts
### Do
- **Do** use asymmetrical margins. For example, give the main text column a 20% left margin and a 30% right margin to create a sophisticated, editorial layout.
- **Do** use design tokens and CSS Custom Properties (variables)
- **Do** extract re-usable components into `app.css` so that styles can be used in multiple page.
- **Do** prioritize the `16` (5.5rem) spacing token for top-of-page "breathability."
- **Do** use `primary_container` for subtle highlighting of text within an article.
- **Do** create responsive layouts, preferably using @container queries.
### Dont
- **Dont** use pure black `#000000`. Use `on_surface` (`#2f342e`) for all high-contrast text.
- **Dont** use icons unless absolutely necessary. Prefer text labels (`label-md`) to ensure the "Digital Paper" aesthetic remains unbroken.
- **Dont** use "Pop-up" modals that cover the whole screen. Use "Slide-over" panels that use the `surface-container-highest` tone to maintain the sense of a physical stack.
- **Don't** Over-use utility classes.

394
frontend/src/app.css Normal file
View file

@ -0,0 +1,394 @@
/* ==========================================================================
Design System: The Editorial Stillness
========================================================================== */
/* --------------------------------------------------------------------------
Fonts
-------------------------------------------------------------------------- */
@font-face {
font-family: archivo; /* set name */
src: url('/fonts/Archivo-Medium.woff2'); /* url of the font */
font-weight: 500;
}
@font-face {
font-family: archivo; /* set name */
src: url('/fonts/Archivo-Regular.woff2'); /* url of the font */
font-weight: normal;
}
/* --------------------------------------------------------------------------
Design Tokens
-------------------------------------------------------------------------- */
:root {
/* --- Color: Primary --- */
--color-primary: #516356;
--color-on-primary: #e9fded;
--color-primary-container: #c8d8c8;
--color-on-primary-container: #2f342e;
/* --- Color: Secondary --- */
--color-secondary-container: #dde4de;
--color-on-secondary-container: #2f342e;
/* --- Color: Surface Hierarchy (light → dark = elevated → recessed) --- */
--color-surface-container-lowest: #ffffff; /* most elevated */
--color-surface-container-low: #f4f4ef;
--color-surface: #faf9f5; /* base */
--color-surface-container: #eeede9;
--color-surface-container-high: #e8e8e3;
--color-surface-container-highest:#e2e1dd;
--color-surface-dim: #d6dcd2; /* recessed utility */
/* --- Color: On-Surface --- */
--color-on-surface: #2f342e; /* replaces pure black */
--color-on-surface-variant: #5c605b;
/* --- Color: Outline --- */
--color-outline: #8c908b;
--color-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */
/* --- Typography: Font Families --- */
--font-display: 'archivo', sans-serif;
--font-body: 'Newsreader', serif;
--font-label: 'Inter', sans-serif;
/* --- Typography: Scale --- */
--text-display-lg: 3.5rem; /* article titles */
--text-display-md: 2.75rem;
--text-display-sm: 2.25rem;
--text-headline-lg: 1.875rem;
--text-headline-md: 1.5rem;
--text-headline-sm: 1.25rem;
--text-title-lg: 1.125rem;
--text-title-md: 1rem;
--text-body-lg: 1rem; /* long-form reading standard */
--text-body-md: 0.9375rem;
--text-body-sm: 0.875rem;
--text-label-lg: 0.875rem;
--text-label-md: 0.75rem; /* metadata, all-caps */
--text-label-sm: 0.6875rem;
/* --- Typography: Weights --- */
--weight-light: 300;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* --- Typography: Line Height --- */
--leading-tight: 1.2;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.6; /* "Digital Paper" body text minimum */
--leading-loose: 1.8;
/* --- Typography: Letter Spacing --- */
--tracking-tight: -0.025em;
--tracking-normal: 0em;
--tracking-wide: 0.05rem; /* label-md metadata */
--tracking-wider: 0.08rem;
/* --- Spacing Scale (base-4) --- */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 1rem; /* 16px — list item separation */
--space-4: 1.4rem; /* ~22px — list item group separation */
--space-5: 1.75rem; /* 28px */
--space-6: 2rem; /* 32px */
--space-8: 3rem; /* 48px */
--space-10: 4rem; /* 64px */
--space-12: 4.5rem; /* 72px */
--space-16: 5.5rem; /* 88px — top-of-page breathability */
/* --- Border Radius --- */
--radius-xs: 0.125rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem; /* primary button */
--radius-lg: 0.75rem;
--radius-xl: 1.25rem;
--radius-full: 9999px;
/* --- Elevation: Ambient "Tonal Shadow" --- */
--shadow-tonal-sm: 0 4px 16px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent);
--shadow-tonal-md: 0 8px 32px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent);
/* --- Motion --- */
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 400ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
/* --- Glass / Frosted Effect --- */
--glass-bg: color-mix(in srgb, var(--color-surface) 80%, transparent);
--glass-blur: 24px;
}
/* --------------------------------------------------------------------------
Reset & Base
-------------------------------------------------------------------------- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
font-size: var(--text-body-lg);
font-weight: var(--weight-regular);
line-height: var(--leading-relaxed);
color: var(--color-on-surface);
background-color: var(--color-surface);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* --------------------------------------------------------------------------
Typography Utilities
-------------------------------------------------------------------------- */
.display-lg {
font-family: var(--font-display);
font-size: var(--text-display-lg);
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
}
.headline-md {
font-family: var(--font-display);
font-size: var(--text-headline-md);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
}
.label-md {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.link {
color: var(--color-primary);
text-decoration: none;
font-weight: var(--weight-medium);
transition: opacity var(--duration-fast) var(--ease-standard);
}
.link:hover {
opacity: 0.7;
}
/* --------------------------------------------------------------------------
Component: Form
-------------------------------------------------------------------------- */
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.form-header {
margin-bottom: var(--space-6);
}
.form-eyebrow {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-on-surface-variant);
margin-bottom: var(--space-1);
}
.form-title {
font-family: var(--font-display);
font-size: var(--text-headline-lg);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--color-on-surface);
}
.form-footer {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-6);
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface-variant);
}
/* --------------------------------------------------------------------------
Component: Button
-------------------------------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-display);
font-size: var(--text-label-lg);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
cursor: pointer;
text-decoration: none;
transition:
opacity var(--duration-normal) var(--ease-standard),
background-color var(--duration-normal) var(--ease-standard);
}
.btn-primary {
background-color: var(--color-primary);
color: var(--color-on-primary);
}
.btn-primary:hover {
opacity: 0.88;
}
.btn-secondary {
background-color: var(--color-secondary-container);
color: var(--color-on-secondary-container);
}
.btn-ghost {
background: none;
color: var(--color-primary);
padding-inline: var(--space-2);
}
/* --------------------------------------------------------------------------
Component: Input
-------------------------------------------------------------------------- */
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.field-label {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-on-surface-variant);
}
.field-input {
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-normal);
outline: none;
width: 100%;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.field-input::placeholder {
color: var(--color-outline-variant);
}
.field-input:focus {
border-bottom: 2px solid var(--color-primary);
}
.field-select {
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-normal);
outline: none;
width: 100%;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%235c605b' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--space-3) center;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.field-select:focus {
border-bottom: 2px solid var(--color-primary);
}
.field-textarea {
background-color: var(--color-surface-container-high);
color: var(--color-on-surface);
border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-relaxed);
outline: none;
width: 100%;
min-height: 14rem;
resize: vertical;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.field-textarea::placeholder {
color: var(--color-outline-variant);
}
.field-textarea:focus {
border-bottom: 2px solid var(--color-primary);
}
.field-hint {
font-family: var(--font-label);
font-size: var(--text-label-md);
color: var(--color-on-surface-variant);
margin-top: var(--space-1);
}
/* --------------------------------------------------------------------------
Component: Alert
-------------------------------------------------------------------------- */
.alert {
padding: var(--space-3);
border-radius: var(--radius-md);
font-family: var(--font-label);
font-size: var(--text-body-sm);
}
.alert-error {
background-color: color-mix(in srgb, #b3261e 10%, var(--color-surface));
color: #b3261e;
border-left: 3px solid #b3261e;
}

View file

@ -1,13 +1,42 @@
import type { Handle } from '@sveltejs/kit';
import { createHmac, timingSafeEqual } from 'crypto';
import { redirect, type Handle } from '@sveltejs/kit';
import { PRIVATE_JWT_SECRET } from '$env/static/private';
import { client } from './lib/apiClient.ts';
function verifyJwt(token: string, secret: string): boolean {
try {
const parts = token.split('.');
if (parts.length !== 3) return false;
const [header, payload, sig] = parts;
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;
}
}
export const handle: Handle = async ({ event, resolve }) => {
event.locals.apiClient = client;
const authToken = event.cookies.get('auth_token');
event.locals.authToken = authToken || null;
const rawToken = event.cookies.get('auth_token');
const isValid = rawToken ? verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false;
event.locals.authToken = isValid ? rawToken! : null;
const response = resolve(event);
if (event.url.pathname.startsWith('/app') && !isValid) {
return redirect(307, '/login');
}
return response;
return resolve(event);
};

View file

@ -1,5 +1,6 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
let { children } = $props();
</script>

View file

@ -0,0 +1,6 @@
<h1>App</h1>
<menu>
<li><a href="/app/generate/summary">Generate Summary Job</a></li>
<li><a href="/app/jobs">Jobs</a></li>
</menu>

View file

@ -0,0 +1,39 @@
import { fail, redirect, type Actions } from '@sveltejs/kit';
import { createGenerationJobApiGeneratePost } from '../../../../client/sdk.gen.ts';
import type { HttpValidationError } from '../../../../client/types.gen.ts';
export const actions = {
default: async ({ request, locals }) => {
const formData = await request.formData();
const target_language = formData.get('target_language') as string;
const source_language = formData.get('source_language') as string;
const complexity_level = formData.get('complexity_level') as string;
const input_texts_raw = formData.get('input_texts') as string;
const input_texts = input_texts_raw
.split(/\n?---\n?/)
.map((t) => t.trim())
.filter(Boolean);
const values = { target_language, source_language, complexity_level, input_texts_raw };
const { response, data } = await createGenerationJobApiGeneratePost({
headers: { Authorization: `Bearer ${locals.authToken}` },
body: { target_language, source_language, complexity_level, input_texts }
});
if (response.status === 202 && data) {
return redirect(303, `/app/jobs/${data.job_id}`);
}
let error = 'Something went wrong. Please try again.';
if (data && typeof data === 'object' && 'detail' in data) {
const detail = (data as HttpValidationError).detail;
if (Array.isArray(detail) && detail.length > 0) {
error = detail[0].msg;
}
}
return fail(response.status, { error, values });
}
} satisfies Actions;

View file

@ -0,0 +1,152 @@
<script lang="ts">
import type { PageProps } from './$types';
let { form }: PageProps = $props();
const languages = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'French' },
{ value: 'es', label: 'Spanish' },
{ value: 'it', label: 'Italian' },
{ value: 'de', label: 'German' }
];
const complexityLevels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'];
</script>
<div class="page">
<header class="page-header">
<p class="form-eyebrow">Generation</p>
<h1 class="form-title">New Summary</h1>
</header>
<main class="content">
{#if form?.error}
<div class="alert alert-error" role="alert">{form.error}</div>
{/if}
<form method="POST" class="form">
<div class="field-row">
<div class="field">
<label for="source_language" class="field-label">Source Language</label>
<select
id="source_language"
name="source_language"
class="field-select"
required
>
{#each languages as lang}
<option
value={lang.value}
selected={form?.values?.source_language === lang.value || (!form && lang.value === 'en')}
>{lang.label}</option>
{/each}
</select>
</div>
<div class="field">
<label for="target_language" class="field-label">Target Language</label>
<select
id="target_language"
name="target_language"
class="field-select"
required
>
{#each languages as lang}
<option
value={lang.value}
selected={form?.values?.target_language === lang.value || (!form && lang.value === 'fr')}
>{lang.label}</option>
{/each}
</select>
</div>
<div class="field">
<label for="complexity_level" class="field-label">Complexity Level</label>
<select
id="complexity_level"
name="complexity_level"
class="field-select"
required
>
{#each complexityLevels as level}
<option
value={level}
selected={form?.values?.complexity_level === level || (!form && level === 'B1')}
>{level}</option>
{/each}
</select>
</div>
</div>
<div class="field">
<label for="input_texts" class="field-label">Input Texts</label>
<textarea
id="input_texts"
name="input_texts"
class="field-textarea"
placeholder="Paste your source text here…"
required
>{form?.values?.input_texts_raw ?? ''}</textarea>
<p class="field-hint">Separate multiple texts with a line containing only <code>---</code></p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Generate Summary</button>
</div>
</form>
</main>
</div>
<style>
.page {
max-width: 52rem;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
}
.page-header {
margin-bottom: var(--space-8);
}
.content {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.field-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
.form-actions {
display: flex;
justify-content: flex-end;
padding-top: var(--space-2);
}
.form-actions .btn {
padding-block: var(--space-3);
padding-inline: var(--space-6);
}
code {
font-family: var(--font-label);
font-size: var(--text-label-md);
background-color: var(--color-surface-container-highest);
padding: 0.1em 0.4em;
border-radius: var(--radius-xs);
}
@media (max-width: 640px) {
.page {
padding: var(--space-6) var(--space-4);
}
.field-row {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,28 @@
import type { PageServerLoad } from './$types';
import { client } from '$lib/apiClient';
import { getJobsApiJobsGet } from '../../../client/sdk.gen.ts';
export const load: PageServerLoad = async ({ locals }) => {
const authToken = locals.authToken;
console.log({ authToken });
client.setConfig({
headers: {
Authorization: `Bearer ${authToken}`
}
});
const { data, error, response } = await getJobsApiJobsGet({
client
});
if (error !== undefined || data === undefined) {
return {
success: false,
error: error
};
}
return {
jobs: data.jobs
};
};

View file

@ -0,0 +1,92 @@
<script lang="ts">
import type { PageProps } from './$types';
import JobsList from './JobsList.svelte';
const { data }: PageProps = $props();
</script>
<div class="page">
<header class="page-header">
<div class="header-row">
<div>
<p class="form-eyebrow">History</p>
<h1 class="form-title">Jobs</h1>
</div>
<a href="/app/generate/summary" class="btn btn-primary">New Summary</a>
</div>
</header>
{#if data.jobs && data.jobs.length > 0}
<JobsList jobs={data.jobs} />
{:else if data.jobs}
<div class="empty-state">
<p class="empty-heading">No jobs yet</p>
<p class="empty-body">Generate your first summary to see it here.</p>
<a href="/app/generate/summary" class="btn btn-secondary">Get started</a>
</div>
{:else}
<div class="alert alert-error" role="alert">Failed to load jobs. Please try refreshing.</div>
{/if}
</div>
<style>
.page {
max-width: 52rem;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.header-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.header-row .btn {
padding-block: var(--space-2);
white-space: nowrap;
}
/* --- Empty state --- */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-10) var(--space-6);
text-align: center;
background-color: var(--color-surface-container-low);
border-radius: var(--radius-xl);
}
.empty-heading {
font-family: var(--font-display);
font-size: var(--text-title-lg);
font-weight: var(--weight-semibold);
color: var(--color-on-surface);
}
.empty-body {
font-size: var(--text-body-md);
color: var(--color-on-surface-variant);
max-width: 20rem;
}
.empty-state .btn {
margin-top: var(--space-2);
padding-block: var(--space-2);
padding-inline: var(--space-5);
}
@media (max-width: 640px) {
.page {
padding: var(--space-6) var(--space-4);
}
}
</style>

View file

@ -0,0 +1,146 @@
<script lang="ts">
import type { JobSummary } from '../../../client/types.gen.ts';
interface Props {
jobs: JobSummary[];
}
const { jobs }: Props = $props();
const fmt = (iso: string) =>
new Intl.DateTimeFormat('en-GB', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
}).format(new Date(iso));
</script>
<ul class="jobs-list" role="list">
{#each jobs as job (job.id)}
<li class="job-row">
<a href={`/app/jobs/${job.id}`} class="job-link">
<span class="status-badge" data-status={job.status}>{job.status}</span>
<span class="job-date">{fmt(job.created_at)}</span>
<span class="job-id">{job.id}</span>
<span class="job-arrow" aria-hidden="true"></span>
</a>
</li>
{/each}
</ul>
<style>
.jobs-list {
list-style: none;
display: flex;
flex-direction: column;
border: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent);
border-radius: var(--radius-lg);
overflow: hidden;
}
.job-row + .job-row {
border-top: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent);
}
.job-link {
display: grid;
grid-template-columns: auto 1fr 1fr auto;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-5);
text-decoration: none;
color: inherit;
background-color: var(--color-surface-container-lowest);
transition: background-color var(--duration-fast) var(--ease-standard);
}
.job-link:hover {
background-color: var(--color-surface-container-low);
}
/* --- Status badge --- */
.status-badge {
display: inline-block;
padding: 0.2em 0.75em;
border-radius: var(--radius-full);
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
background-color: var(--color-secondary-container);
color: var(--color-on-secondary-container);
white-space: nowrap;
}
.status-badge[data-status="completed"] {
background-color: var(--color-primary-container);
color: var(--color-on-primary-container);
}
.status-badge[data-status="failed"] {
background-color: color-mix(in srgb, #b3261e 12%, var(--color-surface));
color: #b3261e;
}
/* --- Row text --- */
.job-date {
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-surface);
}
.job-id {
font-family: var(--font-label);
font-size: var(--text-label-md);
color: var(--color-on-surface-variant);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-arrow {
font-family: var(--font-label);
font-size: var(--text-label-lg);
color: var(--color-outline);
transition: color var(--duration-fast) var(--ease-standard);
}
.job-link:hover .job-arrow {
color: var(--color-primary);
}
/* --- Responsive --- */
@media (max-width: 640px) {
.job-link {
grid-template-columns: auto 1fr auto;
grid-template-rows: auto auto;
gap: var(--space-2) var(--space-3);
padding: var(--space-3) var(--space-4);
}
.job-date {
grid-column: 2;
grid-row: 1;
}
.status-badge {
grid-column: 1;
grid-row: 1 / 3;
align-self: center;
}
.job-id {
grid-column: 2;
grid-row: 2;
}
.job-arrow {
grid-column: 3;
grid-row: 1 / 3;
align-self: center;
}
}
</style>

View file

@ -0,0 +1,17 @@
import { error, type ServerLoad } from '@sveltejs/kit';
import { getJobApiJobsJobIdGet } from '../../../../client/sdk.gen.ts';
import { PUBLIC_API_BASE_URL } from '$env/static/public';
export const load: ServerLoad = async ({ params, locals }) => {
const { data, response } = await getJobApiJobsJobIdGet({
headers: { Authorization: `Bearer ${locals.authToken ?? ''}` },
path: { job_id: params.job_id }
});
if (!data || response.status !== 200) {
error(response.status === 404 ? 404 : 500, 'Job not found');
}
const fullAudioUrl = `${PUBLIC_API_BASE_URL}/media/${data.audio_url}`;
return { job: data, fullAudioUrl };
};

View file

@ -0,0 +1,298 @@
<script lang="ts">
import type { PageProps } from './$types';
const { data }: PageProps = $props();
const { job } = data;
const languageNames: Record<string, string> = {
en: 'English',
fr: 'French',
es: 'Spanish',
it: 'Italian',
de: 'German'
};
const lang = (code: string) => languageNames[code] ?? code.toUpperCase();
const fmt = (iso: string | null | undefined) => {
if (!iso) return null;
return new Intl.DateTimeFormat('en-GB', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(iso));
};
const isTerminal = job.status === 'succeeded' || job.status === 'failed';
</script>
<div class="page">
<nav class="breadcrumb">
<a href="/app/jobs" class="link">← Jobs</a>
</nav>
<header class="page-header">
<div class="header-top">
<h1 class="form-title">Job Details</h1>
<span class="status-badge" data-status={job.status}>{job.status}</span>
</div>
<p class="job-id">{job.id}</p>
</header>
<dl class="meta-grid">
<div class="meta-item">
<dt class="field-label">Language Pair</dt>
<dd class="meta-value">{lang(job.source_language)}{lang(job.target_language)}</dd>
</div>
<div class="meta-item">
<dt class="field-label">Complexity</dt>
<dd class="meta-value">{job.complexity_level}</dd>
</div>
<div class="meta-item">
<dt class="field-label">Created</dt>
<dd class="meta-value">{fmt(job.created_at)}</dd>
</div>
{#if job.started_at}
<div class="meta-item">
<dt class="field-label">Started</dt>
<dd class="meta-value">{fmt(job.started_at)}</dd>
</div>
{/if}
{#if job.completed_at}
<div class="meta-item">
<dt class="field-label">Completed</dt>
<dd class="meta-value">{fmt(job.completed_at)}</dd>
</div>
{/if}
</dl>
{#if job.status === 'failed' && job.error_message}
<div class="alert alert-error" role="alert">
<strong>Job failed:</strong>
{job.error_message}
</div>
{:else if !isTerminal}
<div class="pending-notice">
<div class="spinner" aria-hidden="true"></div>
<p>This job is <strong>{job.status}</strong>. Refresh to check for updates.</p>
</div>
{/if}
{#if job.input_summary}
<section class="content-section">
<h2 class="section-title">Input Summary</h2>
<div class="prose">{job.input_summary}</div>
</section>
{/if}
{#if job.generated_text}
<section class="content-section">
<h2 class="section-title">
Generated Text
<span class="section-lang">{lang(job.target_language)}</span>
</h2>
{#if job.audio_url}
<section class="content-section">
<h2 class="section-title">Audio</h2>
<audio class="audio-player" controls src={data.fullAudioUrl}>
Your browser does not support the audio element.
</audio>
</section>
{/if}
<div class="prose prose-target">{job.generated_text}</div>
</section>
{/if}
{#if job.translated_text}
<section class="content-section">
<h2 class="section-title">
Translation
<span class="section-lang">{lang(job.source_language)}</span>
</h2>
<div class="prose prose-translated">{job.translated_text}</div>
</section>
{/if}
</div>
<style>
.page {
max-width: 52rem;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* --- Breadcrumb --- */
.breadcrumb {
font-family: var(--font-label);
font-size: var(--text-label-lg);
}
/* --- Header --- */
.header-top {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.job-id {
font-family: var(--font-label);
font-size: var(--text-label-md);
color: var(--color-on-surface-variant);
margin-top: var(--space-1);
word-break: break-all;
}
/* --- Status badge --- */
.status-badge {
display: inline-block;
padding: 0.2em 0.75em;
border-radius: var(--radius-full);
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
background-color: var(--color-secondary-container);
color: var(--color-on-secondary-container);
}
.status-badge[data-status='completed'] {
background-color: var(--color-primary-container);
color: var(--color-on-primary-container);
}
.status-badge[data-status='failed'] {
background-color: color-mix(in srgb, #b3261e 12%, var(--color-surface));
color: #b3261e;
}
/* --- Metadata grid --- */
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: var(--space-3) var(--space-5);
padding: var(--space-4);
background-color: var(--color-surface-container-low);
border-radius: var(--radius-lg);
}
.meta-item {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.meta-value {
font-family: var(--font-display);
font-size: var(--text-body-md);
font-weight: var(--weight-medium);
color: var(--color-on-surface);
}
/* --- Pending notice --- */
.pending-notice {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
background-color: var(--color-secondary-container);
border-radius: var(--radius-lg);
font-family: var(--font-label);
font-size: var(--text-body-sm);
color: var(--color-on-secondary-container);
}
.spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--color-outline-variant);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* --- Content sections --- */
.content-section {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent);
}
.section-title {
font-family: var(--font-display);
font-size: var(--text-title-lg);
font-weight: var(--weight-semibold);
color: var(--color-on-surface);
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.section-lang {
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);
}
/* --- Prose --- */
.prose {
font-family: var(--font-body);
font-size: var(--text-body-lg);
line-height: var(--leading-relaxed);
color: var(--color-on-surface);
white-space: pre-wrap;
}
.prose-target {
padding: var(--space-5) var(--space-6);
background-color: var(--color-surface-container-low);
border-radius: var(--radius-lg);
font-size: calc(var(--text-body-lg) * 1.05);
}
.prose-translated {
color: var(--color-on-surface-variant);
font-size: var(--text-body-md);
font-style: italic;
}
/* --- Audio --- */
.audio-player {
width: 100%;
accent-color: var(--color-primary);
}
/* --- Responsive --- */
@media (max-width: 640px) {
.page {
padding: var(--space-6) var(--space-4);
}
}
</style>

View file

@ -1,34 +1,144 @@
<script lang="ts">
import type { PageProps } from './$types';
const { data }: PageProps = $props();
</script>
{#if data.isLoggedIn}
<p>You are logged in</p>
{/if}
<form method="POST">
<div class="field">
<label for="email" class="label">Email</label>
<input
id="email"
name="email"
class="input"
type="email"
placeholder="e.g john@gmail.com"
autocomplete="email"
/>
</div>
<div class="field">
<label for="password" class="label">Password</label>
<input
id="password"
name="password"
class="input"
type="password"
placeholder="********"
autocomplete="current-password"
/>
</div>
<input type="submit" class="button is-primary" value="Login" />
</form>
<div class="page">
<aside class="brand-panel" aria-hidden="true">
<div class="brand-content">
<p class="brand-eyebrow">Language Learning App</p>
<h1 class="brand-headline">Read.<br />Listen.<br />Remember.</h1>
</div>
</aside>
<main class="form-panel">
<div class="form-inner">
<header class="form-header">
<p class="form-eyebrow">Language Learnnig App</p>
<h2 class="form-title">Sign in</h2>
</header>
<form method="POST" class="form">
<div class="field">
<label for="email" class="field-label">Email</label>
<input
id="email"
name="email"
class="field-input"
type="email"
placeholder="you@example.com"
autocomplete="email"
required
/>
</div>
<div class="field">
<label for="password" class="field-label">Password</label>
<input
id="password"
name="password"
class="field-input"
type="password"
placeholder="••••••••"
autocomplete="current-password"
required
/>
</div>
<div class="field">
<input type="submit" value="Log In" />
</div>
</form>
<footer class="form-footer">
<span>Don't have an account?</span>
<a href="/register" class="link">Create one</a>
</footer>
</div>
</main>
</div>
<style>
/* -----------------------------------------------------------------------
Page layout: two-panel split
----------------------------------------------------------------------- */
.page {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 100svh;
}
/* -----------------------------------------------------------------------
Left: brand panel
----------------------------------------------------------------------- */
.brand-panel {
display: flex;
align-items: flex-end;
padding: var(--space-8);
background: radial-gradient(
ellipse at 30% 60%,
var(--color-surface-container-low) 0%,
var(--color-surface) 70%
);
border-right: none; /* no-line rule — tonal shift does the work */
}
.brand-content {
max-width: 28rem;
}
.brand-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);
margin-bottom: var(--space-3);
}
.brand-headline {
font-family: var(--font-display);
font-size: var(--text-display-lg);
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--color-on-surface);
}
/* -----------------------------------------------------------------------
Right: form panel
----------------------------------------------------------------------- */
.form-panel {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-8) var(--space-6);
background-color: var(--color-surface-container-lowest);
}
.form-inner {
width: 100%;
max-width: 22rem;
}
/* -----------------------------------------------------------------------
Responsive: collapse to single column on mobile
----------------------------------------------------------------------- */
@media (max-width: 640px) {
.page {
grid-template-columns: 1fr;
}
.brand-panel {
display: none;
}
.form-panel {
padding: var(--space-10) var(--space-5);
}
}
</style>

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.