Compare commits
No commits in common. "407d423a4c13cbb4005f87c1a1accb88da2bbb14" and "271519204ce513d2ac4308987bcbdb3112b4af74" have entirely different histories.
407d423a4c
...
271519204c
33 changed files with 53 additions and 1595 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@ class Base(DeclarativeBase):
|
|||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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, it’s 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. Do’s and Don’ts
|
||||
|
||||
### 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.
|
||||
|
||||
### Don’t
|
||||
|
||||
- **Don’t** use pure black `#000000`. Use `on_surface` (`#2f342e`) for all high-contrast text.
|
||||
|
||||
- **Don’t** use icons unless absolutely necessary. Prefer text labels (`label-md`) to ensure the "Digital Paper" aesthetic remains unbroken.
|
||||
|
||||
- **Don’t** 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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
<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>
|
||||
{#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>
|
||||
|
|
|
|||
|
|
@ -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, '/');
|
||||
};
|
||||
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.
Loading…
Reference in a new issue