Compare commits

..

No commits in common. "407d423a4c13cbb4005f87c1a1accb88da2bbb14" and "271519204ce513d2ac4308987bcbdb3112b4af74" have entirely different histories.

33 changed files with 53 additions and 1595 deletions

View file

@ -1,15 +0,0 @@
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

@ -1,30 +0,0 @@
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,7 +8,6 @@ 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
@ -28,7 +27,6 @@ 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() -> AsyncSession:
async def get_db():
async with AsyncSessionLocal() as session:
yield session

View file

@ -5,51 +5,7 @@ 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()
@ -85,6 +41,18 @@ 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,6 +23,7 @@ class GenerationRequest(BaseModel):
target_language: str
complexity_level: str
input_texts: list[str]
topic: str | None = None
source_language: str = "en"

View file

@ -1,30 +0,0 @@
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

@ -1,7 +0,0 @@
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.summarise_job_repository import PostgresSummariseJobRepository
from ..outbound.postgres.repositories import summarise_job_repository
from ..storage import download_audio
router = APIRouter(prefix="/media", tags=["media"])
@ -17,10 +17,11 @@ 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:
repository = PostgresSummariseJobRepository(db)
job = await repository.get_by_audio_url(filename)
user_id = uuid.UUID(token_data["sub"])
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")

View file

@ -1,109 +0,0 @@
# 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.

View file

@ -1,394 +0,0 @@
/* ==========================================================================
Design System: The Editorial Stillness
========================================================================== */
/* --------------------------------------------------------------------------
Fonts
-------------------------------------------------------------------------- */
@font-face {
font-family: archivo; /* set name */
src: url('/fonts/Archivo-Medium.woff2'); /* url of the font */
font-weight: 500;
}
@font-face {
font-family: archivo; /* set name */
src: url('/fonts/Archivo-Regular.woff2'); /* url of the font */
font-weight: normal;
}
/* --------------------------------------------------------------------------
Design Tokens
-------------------------------------------------------------------------- */
:root {
/* --- Color: Primary --- */
--color-primary: #516356;
--color-on-primary: #e9fded;
--color-primary-container: #c8d8c8;
--color-on-primary-container: #2f342e;
/* --- Color: Secondary --- */
--color-secondary-container: #dde4de;
--color-on-secondary-container: #2f342e;
/* --- Color: Surface Hierarchy (light → dark = elevated → recessed) --- */
--color-surface-container-lowest: #ffffff; /* most elevated */
--color-surface-container-low: #f4f4ef;
--color-surface: #faf9f5; /* base */
--color-surface-container: #eeede9;
--color-surface-container-high: #e8e8e3;
--color-surface-container-highest:#e2e1dd;
--color-surface-dim: #d6dcd2; /* recessed utility */
/* --- Color: On-Surface --- */
--color-on-surface: #2f342e; /* replaces pure black */
--color-on-surface-variant: #5c605b;
/* --- Color: Outline --- */
--color-outline: #8c908b;
--color-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */
/* --- Typography: Font Families --- */
--font-display: 'archivo', sans-serif;
--font-body: 'Newsreader', serif;
--font-label: 'Inter', sans-serif;
/* --- Typography: Scale --- */
--text-display-lg: 3.5rem; /* article titles */
--text-display-md: 2.75rem;
--text-display-sm: 2.25rem;
--text-headline-lg: 1.875rem;
--text-headline-md: 1.5rem;
--text-headline-sm: 1.25rem;
--text-title-lg: 1.125rem;
--text-title-md: 1rem;
--text-body-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,42 +1,13 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { redirect, type Handle } from '@sveltejs/kit';
import { PRIVATE_JWT_SECRET } from '$env/static/private';
import type { Handle } from '@sveltejs/kit';
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 rawToken = event.cookies.get('auth_token');
const isValid = rawToken ? verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false;
event.locals.authToken = isValid ? rawToken! : null;
const authToken = event.cookies.get('auth_token');
event.locals.authToken = authToken || null;
if (event.url.pathname.startsWith('/app') && !isValid) {
return redirect(307, '/login');
}
const response = resolve(event);
return resolve(event);
return response;
};

View file

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

View file

@ -1,6 +0,0 @@
<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

@ -1,39 +0,0 @@
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

@ -1,152 +0,0 @@
<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

@ -1,28 +0,0 @@
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

@ -1,92 +0,0 @@
<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

@ -1,146 +0,0 @@
<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

@ -1,17 +0,0 @@
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

@ -1,298 +0,0 @@
<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,144 +1,34 @@
<script lang="ts">
import type { PageProps } from './$types';
const { data }: PageProps = $props();
</script>
<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">
{#if data.isLoggedIn}
<p>You are logged in</p>
{/if}
<form method="POST">
<div class="field">
<label for="email" class="field-label">Email</label>
<label for="email" class="label">Email</label>
<input
id="email"
name="email"
class="field-input"
class="input"
type="email"
placeholder="you@example.com"
placeholder="e.g john@gmail.com"
autocomplete="email"
required
/>
</div>
<div class="field">
<label for="password" class="field-label">Password</label>
<label for="password" class="label">Password</label>
<input
id="password"
name="password"
class="field-input"
class="input"
type="password"
placeholder="••••••••"
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>
<input type="submit" class="button is-primary" value="Login" />
</form>

View file

@ -1,7 +0,0 @@
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, '/');
};