frontend: create pages to list jobs
This commit is contained in:
parent
271519204c
commit
6a08da1ff6
22 changed files with 1463 additions and 35 deletions
109
frontend/design.md
Normal file
109
frontend/design.md
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# Design System Document: Language Learning App
|
||||||
|
|
||||||
|
## 1. Overview & Creative North Star
|
||||||
|
|
||||||
|
**Creative North Star: The Digital Archivist**
|
||||||
|
|
||||||
|
This design system rejects the frantic, "attention-economy" aesthetic of modern web apps. Instead, it draws inspiration from high-end printed journals and architectural minimalism. The goal is to create a "Digital Paper" experience that honours the act of reading.
|
||||||
|
|
||||||
|
We break the standard "SaaS dashboard" template by using intentional asymmetry and high-contrast typographic scales.
|
||||||
|
|
||||||
|
Layouts with multiple sources of information should feel like a well-composed magazine spread: large, sweeping areas of `surface` punctuated by tight, authoritative `label` groupings. We do not fill space; we curate it.
|
||||||
|
|
||||||
|
Layouts which are focused on content, e.g. reading or listening, should feel focused and intentional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Colors: The Palette of Focus
|
||||||
|
|
||||||
|
Our palette is rooted in organic, desaturated tones that reduce eye strain and promote deep work.
|
||||||
|
|
||||||
|
- **Primary (`#516356`)**: A muted Forest Green. This is our singular "Action" voice. Use it sparingly to guide the eye, not to shout.
|
||||||
|
|
||||||
|
- **The "No-Line" Rule**: You are strictly prohibited from using 1px solid borders to define sections. Layout boundaries are created exclusively through background shifts. For example, a `surface-container-low` (`#f4f4ef`) sidebar sitting against a `surface` (`#faf9f5`) main body.
|
||||||
|
|
||||||
|
- **Nesting & Layers**: Treat the UI as stacked sheets of fine vellum. Use `surface-container-lowest` (`#ffffff`) for the most elevated elements (like an active reading pane) and `surface-dim` (`#d6dcd2`) for recessed utility areas.
|
||||||
|
|
||||||
|
- **The "Glass & Gradient" Rule**: To prevent the UI from feeling "dead," use subtle radial gradients on hero backgrounds (transitioning from `surface` to `surface-container-low`). For floating navigation, apply `surface` colors at 80% opacity with a `24px` backdrop-blur to create a "frosted glass" effect that lets the content bleed through softly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Typography: The Editorial Engine
|
||||||
|
|
||||||
|
Typography is the primary visual asset. We use a sophisticated pairing of **Archivo** (Sans-serif) for functional UI and **Newsreader** (Serif) for the reading experience.
|
||||||
|
|
||||||
|
- **Display & Headline (Archivo)**: These are your "Wayfinders." Use `display-lg` (3.5rem) with tight letter-spacing for article titles to create an authoritative, architectural feel.
|
||||||
|
|
||||||
|
- **Body (Newsreader)**: This is the soul of the system. `body-lg` (1rem) is the standard for long-form reading. It must have a line-height of at least 1.6 to ensure the "Digital Paper" feel.
|
||||||
|
|
||||||
|
- **Labels (Inter)**: Use `label-md` in all-caps with a `0.05rem` letter-spacing for metadata (e.g., "READING TIME," "DATE SAVED"). This creates a stark, functional contrast to the fluid Serif body text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Elevation & Depth: Tonal Layering
|
||||||
|
|
||||||
|
We do not use shadows to mimic light; we use tone to mimic physical presence.
|
||||||
|
|
||||||
|
- **The Layering Principle**: If a card needs to stand out, do not add a shadow. Instead, change its background to `surface-container-lowest` and place it on a `surface-container` background. The 2-step tonal shift provides all the clarity needed.
|
||||||
|
|
||||||
|
- **Ambient Shadows**: If a component _must_ float (like a mobile action button), use a "Tonal Shadow": `color: on-surface` at 5% opacity, with a `32px` blur and `8px` Y-offset. It should look like a soft smudge of graphite, not a digital drop shadow.
|
||||||
|
|
||||||
|
- **The "Ghost Border"**: If a button needs a stroke for accessibility, use `outline-variant` (`#afb3ac`) at 20% opacity. If you can see the line clearly, 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.
|
||||||
394
frontend/src/app.css
Normal file
394
frontend/src/app.css
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Design System: The Editorial Stillness
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Fonts
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: archivo; /* set name */
|
||||||
|
src: url('/fonts/Archivo-Medium.woff2'); /* url of the font */
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: archivo; /* set name */
|
||||||
|
src: url('/fonts/Archivo-Regular.woff2'); /* url of the font */
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Design Tokens
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
|
||||||
|
/* --- Color: Primary --- */
|
||||||
|
--color-primary: #516356;
|
||||||
|
--color-on-primary: #e9fded;
|
||||||
|
--color-primary-container: #c8d8c8;
|
||||||
|
--color-on-primary-container: #2f342e;
|
||||||
|
|
||||||
|
/* --- Color: Secondary --- */
|
||||||
|
--color-secondary-container: #dde4de;
|
||||||
|
--color-on-secondary-container: #2f342e;
|
||||||
|
|
||||||
|
/* --- Color: Surface Hierarchy (light → dark = elevated → recessed) --- */
|
||||||
|
--color-surface-container-lowest: #ffffff; /* most elevated */
|
||||||
|
--color-surface-container-low: #f4f4ef;
|
||||||
|
--color-surface: #faf9f5; /* base */
|
||||||
|
--color-surface-container: #eeede9;
|
||||||
|
--color-surface-container-high: #e8e8e3;
|
||||||
|
--color-surface-container-highest:#e2e1dd;
|
||||||
|
--color-surface-dim: #d6dcd2; /* recessed utility */
|
||||||
|
|
||||||
|
/* --- Color: On-Surface --- */
|
||||||
|
--color-on-surface: #2f342e; /* replaces pure black */
|
||||||
|
--color-on-surface-variant: #5c605b;
|
||||||
|
|
||||||
|
/* --- Color: Outline --- */
|
||||||
|
--color-outline: #8c908b;
|
||||||
|
--color-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */
|
||||||
|
|
||||||
|
/* --- Typography: Font Families --- */
|
||||||
|
--font-display: 'archivo', sans-serif;
|
||||||
|
--font-body: 'Newsreader', serif;
|
||||||
|
--font-label: 'Inter', sans-serif;
|
||||||
|
|
||||||
|
/* --- Typography: Scale --- */
|
||||||
|
--text-display-lg: 3.5rem; /* article titles */
|
||||||
|
--text-display-md: 2.75rem;
|
||||||
|
--text-display-sm: 2.25rem;
|
||||||
|
--text-headline-lg: 1.875rem;
|
||||||
|
--text-headline-md: 1.5rem;
|
||||||
|
--text-headline-sm: 1.25rem;
|
||||||
|
--text-title-lg: 1.125rem;
|
||||||
|
--text-title-md: 1rem;
|
||||||
|
--text-body-lg: 1rem; /* long-form reading standard */
|
||||||
|
--text-body-md: 0.9375rem;
|
||||||
|
--text-body-sm: 0.875rem;
|
||||||
|
--text-label-lg: 0.875rem;
|
||||||
|
--text-label-md: 0.75rem; /* metadata, all-caps */
|
||||||
|
--text-label-sm: 0.6875rem;
|
||||||
|
|
||||||
|
/* --- Typography: Weights --- */
|
||||||
|
--weight-light: 300;
|
||||||
|
--weight-regular: 400;
|
||||||
|
--weight-medium: 500;
|
||||||
|
--weight-semibold: 600;
|
||||||
|
--weight-bold: 700;
|
||||||
|
|
||||||
|
/* --- Typography: Line Height --- */
|
||||||
|
--leading-tight: 1.2;
|
||||||
|
--leading-snug: 1.375;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.6; /* "Digital Paper" body text minimum */
|
||||||
|
--leading-loose: 1.8;
|
||||||
|
|
||||||
|
/* --- Typography: Letter Spacing --- */
|
||||||
|
--tracking-tight: -0.025em;
|
||||||
|
--tracking-normal: 0em;
|
||||||
|
--tracking-wide: 0.05rem; /* label-md metadata */
|
||||||
|
--tracking-wider: 0.08rem;
|
||||||
|
|
||||||
|
/* --- Spacing Scale (base-4) --- */
|
||||||
|
--space-1: 0.25rem; /* 4px */
|
||||||
|
--space-2: 0.5rem; /* 8px */
|
||||||
|
--space-3: 1rem; /* 16px — list item separation */
|
||||||
|
--space-4: 1.4rem; /* ~22px — list item group separation */
|
||||||
|
--space-5: 1.75rem; /* 28px */
|
||||||
|
--space-6: 2rem; /* 32px */
|
||||||
|
--space-8: 3rem; /* 48px */
|
||||||
|
--space-10: 4rem; /* 64px */
|
||||||
|
--space-12: 4.5rem; /* 72px */
|
||||||
|
--space-16: 5.5rem; /* 88px — top-of-page breathability */
|
||||||
|
|
||||||
|
/* --- Border Radius --- */
|
||||||
|
--radius-xs: 0.125rem;
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.375rem; /* primary button */
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
--radius-xl: 1.25rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* --- Elevation: Ambient "Tonal Shadow" --- */
|
||||||
|
--shadow-tonal-sm: 0 4px 16px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent);
|
||||||
|
--shadow-tonal-md: 0 8px 32px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent);
|
||||||
|
|
||||||
|
/* --- Motion --- */
|
||||||
|
--duration-fast: 100ms;
|
||||||
|
--duration-normal: 200ms;
|
||||||
|
--duration-slow: 400ms;
|
||||||
|
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
|
||||||
|
/* --- Glass / Frosted Effect --- */
|
||||||
|
--glass-bg: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
||||||
|
--glass-blur: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Reset & Base
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
font-weight: var(--weight-regular);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Typography Utilities
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.display-lg {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-display-lg);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-md {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-headline-md);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-md {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Component: Form
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-header {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-eyebrow {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-headline-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Component: Button
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-label-lg);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
opacity var(--duration-normal) var(--ease-standard),
|
||||||
|
background-color var(--duration-normal) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--color-secondary-container);
|
||||||
|
color: var(--color-on-secondary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding-inline: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Component: Input
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
background-color: var(--color-surface-container-high);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
|
||||||
|
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input::placeholder {
|
||||||
|
color: var(--color-outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input:focus {
|
||||||
|
border-bottom: 2px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-select {
|
||||||
|
background-color: var(--color-surface-container-high);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
|
||||||
|
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||||
|
padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%235c605b' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right var(--space-3) center;
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-select:focus {
|
||||||
|
border-bottom: 2px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-textarea {
|
||||||
|
background-color: var(--color-surface-container-high);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
|
||||||
|
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 14rem;
|
||||||
|
resize: vertical;
|
||||||
|
transition: border-color var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-textarea::placeholder {
|
||||||
|
color: var(--color-outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-textarea:focus {
|
||||||
|
border-bottom: 2px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Component: Alert
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background-color: color-mix(in srgb, #b3261e 10%, var(--color-surface));
|
||||||
|
color: #b3261e;
|
||||||
|
border-left: 3px solid #b3261e;
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,42 @@
|
||||||
import type { Handle } from '@sveltejs/kit';
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
import { redirect, type Handle } from '@sveltejs/kit';
|
||||||
|
import { PRIVATE_JWT_SECRET } from '$env/static/private';
|
||||||
import { client } from './lib/apiClient.ts';
|
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 }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.apiClient = client;
|
event.locals.apiClient = client;
|
||||||
|
|
||||||
const authToken = event.cookies.get('auth_token');
|
const rawToken = event.cookies.get('auth_token');
|
||||||
event.locals.authToken = authToken || null;
|
const isValid = rawToken ? verifyJwt(rawToken, PRIVATE_JWT_SECRET) : false;
|
||||||
|
event.locals.authToken = isValid ? rawToken! : null;
|
||||||
|
|
||||||
const response = resolve(event);
|
if (event.url.pathname.startsWith('/app') && !isValid) {
|
||||||
|
return redirect(307, '/login');
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return resolve(event);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
import '../app.css';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
6
frontend/src/routes/app/+page.svelte
Normal file
6
frontend/src/routes/app/+page.svelte
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<h1>App</h1>
|
||||||
|
|
||||||
|
<menu>
|
||||||
|
<li><a href="/app/generate/summary">Generate Summary Job</a></li>
|
||||||
|
<li><a href="/app/jobs">Jobs</a></li>
|
||||||
|
</menu>
|
||||||
39
frontend/src/routes/app/generate/summary/+page.server.ts
Normal file
39
frontend/src/routes/app/generate/summary/+page.server.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||||
|
import { createGenerationJobApiGeneratePost } from '../../../../client/sdk.gen.ts';
|
||||||
|
import type { HttpValidationError } from '../../../../client/types.gen.ts';
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const target_language = formData.get('target_language') as string;
|
||||||
|
const source_language = formData.get('source_language') as string;
|
||||||
|
const complexity_level = formData.get('complexity_level') as string;
|
||||||
|
const input_texts_raw = formData.get('input_texts') as string;
|
||||||
|
|
||||||
|
const input_texts = input_texts_raw
|
||||||
|
.split(/\n?---\n?/)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const values = { target_language, source_language, complexity_level, input_texts_raw };
|
||||||
|
|
||||||
|
const { response, data } = await createGenerationJobApiGeneratePost({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken}` },
|
||||||
|
body: { target_language, source_language, complexity_level, input_texts }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 202 && data) {
|
||||||
|
return redirect(303, `/app/jobs/${data.job_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = 'Something went wrong. Please try again.';
|
||||||
|
if (data && typeof data === 'object' && 'detail' in data) {
|
||||||
|
const detail = (data as HttpValidationError).detail;
|
||||||
|
if (Array.isArray(detail) && detail.length > 0) {
|
||||||
|
error = detail[0].msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fail(response.status, { error, values });
|
||||||
|
}
|
||||||
|
} satisfies Actions;
|
||||||
152
frontend/src/routes/app/generate/summary/+page.svelte
Normal file
152
frontend/src/routes/app/generate/summary/+page.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { form }: PageProps = $props();
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'fr', label: 'French' },
|
||||||
|
{ value: 'es', label: 'Spanish' },
|
||||||
|
{ value: 'it', label: 'Italian' },
|
||||||
|
{ value: 'de', label: 'German' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const complexityLevels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<header class="page-header">
|
||||||
|
<p class="form-eyebrow">Generation</p>
|
||||||
|
<h1 class="form-title">New Summary</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="alert alert-error" role="alert">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="POST" class="form">
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="source_language" class="field-label">Source Language</label>
|
||||||
|
<select
|
||||||
|
id="source_language"
|
||||||
|
name="source_language"
|
||||||
|
class="field-select"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{#each languages as lang}
|
||||||
|
<option
|
||||||
|
value={lang.value}
|
||||||
|
selected={form?.values?.source_language === lang.value || (!form && lang.value === 'en')}
|
||||||
|
>{lang.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="target_language" class="field-label">Target Language</label>
|
||||||
|
<select
|
||||||
|
id="target_language"
|
||||||
|
name="target_language"
|
||||||
|
class="field-select"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{#each languages as lang}
|
||||||
|
<option
|
||||||
|
value={lang.value}
|
||||||
|
selected={form?.values?.target_language === lang.value || (!form && lang.value === 'fr')}
|
||||||
|
>{lang.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="complexity_level" class="field-label">Complexity Level</label>
|
||||||
|
<select
|
||||||
|
id="complexity_level"
|
||||||
|
name="complexity_level"
|
||||||
|
class="field-select"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{#each complexityLevels as level}
|
||||||
|
<option
|
||||||
|
value={level}
|
||||||
|
selected={form?.values?.complexity_level === level || (!form && level === 'B1')}
|
||||||
|
>{level}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="input_texts" class="field-label">Input Texts</label>
|
||||||
|
<textarea
|
||||||
|
id="input_texts"
|
||||||
|
name="input_texts"
|
||||||
|
class="field-textarea"
|
||||||
|
placeholder="Paste your source text here…"
|
||||||
|
required
|
||||||
|
>{form?.values?.input_texts_raw ?? ''}</textarea>
|
||||||
|
<p class="field-hint">Separate multiple texts with a line containing only <code>---</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Generate Summary</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 52rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-8) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions .btn {
|
||||||
|
padding-block: var(--space-3);
|
||||||
|
padding-inline: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
background-color: var(--color-surface-container-highest);
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.page {
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
28
frontend/src/routes/app/jobs/+page.server.ts
Normal file
28
frontend/src/routes/app/jobs/+page.server.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { client } from '$lib/apiClient';
|
||||||
|
import { getJobsApiJobsGet } from '../../../client/sdk.gen.ts';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
const authToken = locals.authToken;
|
||||||
|
console.log({ authToken });
|
||||||
|
client.setConfig({
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${authToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, error, response } = await getJobsApiJobsGet({
|
||||||
|
client
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error !== undefined || data === undefined) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobs: data.jobs
|
||||||
|
};
|
||||||
|
};
|
||||||
92
frontend/src/routes/app/jobs/+page.svelte
Normal file
92
frontend/src/routes/app/jobs/+page.svelte
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import JobsList from './JobsList.svelte';
|
||||||
|
|
||||||
|
const { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="header-row">
|
||||||
|
<div>
|
||||||
|
<p class="form-eyebrow">History</p>
|
||||||
|
<h1 class="form-title">Jobs</h1>
|
||||||
|
</div>
|
||||||
|
<a href="/app/generate/summary" class="btn btn-primary">New Summary</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if data.jobs && data.jobs.length > 0}
|
||||||
|
<JobsList jobs={data.jobs} />
|
||||||
|
{:else if data.jobs}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p class="empty-heading">No jobs yet</p>
|
||||||
|
<p class="empty-body">Generate your first summary to see it here.</p>
|
||||||
|
<a href="/app/generate/summary" class="btn btn-secondary">Get started</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="alert alert-error" role="alert">Failed to load jobs. Please try refreshing.</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 52rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-8) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row .btn {
|
||||||
|
padding-block: var(--space-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Empty state --- */
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-10) var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
background-color: var(--color-surface-container-low);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-heading {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-title-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-body {
|
||||||
|
font-size: var(--text-body-md);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
max-width: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .btn {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding-block: var(--space-2);
|
||||||
|
padding-inline: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.page {
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
146
frontend/src/routes/app/jobs/JobsList.svelte
Normal file
146
frontend/src/routes/app/jobs/JobsList.svelte
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { JobSummary } from '../../../client/types.gen.ts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
jobs: JobSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { jobs }: Props = $props();
|
||||||
|
|
||||||
|
const fmt = (iso: string) =>
|
||||||
|
new Intl.DateTimeFormat('en-GB', {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit'
|
||||||
|
}).format(new Date(iso));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul class="jobs-list" role="list">
|
||||||
|
{#each jobs as job (job.id)}
|
||||||
|
<li class="job-row">
|
||||||
|
<a href={`/app/jobs/${job.id}`} class="job-link">
|
||||||
|
<span class="status-badge" data-status={job.status}>{job.status}</span>
|
||||||
|
<span class="job-date">{fmt(job.created_at)}</span>
|
||||||
|
<span class="job-id">{job.id}</span>
|
||||||
|
<span class="job-arrow" aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.jobs-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-row + .job-row {
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-link {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
background-color: var(--color-surface-container-lowest);
|
||||||
|
transition: background-color var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-link:hover {
|
||||||
|
background-color: var(--color-surface-container-low);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Status badge --- */
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2em 0.75em;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background-color: var(--color-secondary-container);
|
||||||
|
color: var(--color-on-secondary-container);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-status="completed"] {
|
||||||
|
background-color: var(--color-primary-container);
|
||||||
|
color: var(--color-on-primary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-status="failed"] {
|
||||||
|
background-color: color-mix(in srgb, #b3261e 12%, var(--color-surface));
|
||||||
|
color: #b3261e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Row text --- */
|
||||||
|
|
||||||
|
.job-date {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-id {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-arrow {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-lg);
|
||||||
|
color: var(--color-outline);
|
||||||
|
transition: color var(--duration-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-link:hover .job-arrow {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Responsive --- */
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.job-link {
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: var(--space-2) var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-date {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-id {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-arrow {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
17
frontend/src/routes/app/jobs/[job_id]/+page.server.ts
Normal file
17
frontend/src/routes/app/jobs/[job_id]/+page.server.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { error, type ServerLoad } from '@sveltejs/kit';
|
||||||
|
import { getJobApiJobsJobIdGet } from '../../../../client/sdk.gen.ts';
|
||||||
|
import { PUBLIC_API_BASE_URL } from '$env/static/public';
|
||||||
|
|
||||||
|
export const load: ServerLoad = async ({ params, locals }) => {
|
||||||
|
const { data, response } = await getJobApiJobsJobIdGet({
|
||||||
|
headers: { Authorization: `Bearer ${locals.authToken ?? ''}` },
|
||||||
|
path: { job_id: params.job_id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data || response.status !== 200) {
|
||||||
|
error(response.status === 404 ? 404 : 500, 'Job not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullAudioUrl = `${PUBLIC_API_BASE_URL}/media/${data.audio_url}`;
|
||||||
|
return { job: data, fullAudioUrl };
|
||||||
|
};
|
||||||
298
frontend/src/routes/app/jobs/[job_id]/+page.svelte
Normal file
298
frontend/src/routes/app/jobs/[job_id]/+page.svelte
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
const { data }: PageProps = $props();
|
||||||
|
const { job } = data;
|
||||||
|
|
||||||
|
const languageNames: Record<string, string> = {
|
||||||
|
en: 'English',
|
||||||
|
fr: 'French',
|
||||||
|
es: 'Spanish',
|
||||||
|
it: 'Italian',
|
||||||
|
de: 'German'
|
||||||
|
};
|
||||||
|
|
||||||
|
const lang = (code: string) => languageNames[code] ?? code.toUpperCase();
|
||||||
|
|
||||||
|
const fmt = (iso: string | null | undefined) => {
|
||||||
|
if (!iso) return null;
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(new Date(iso));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTerminal = job.status === 'succeeded' || job.status === 'failed';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/app/jobs" class="link">← Jobs</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="header-top">
|
||||||
|
<h1 class="form-title">Job Details</h1>
|
||||||
|
<span class="status-badge" data-status={job.status}>{job.status}</span>
|
||||||
|
</div>
|
||||||
|
<p class="job-id">{job.id}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<dl class="meta-grid">
|
||||||
|
<div class="meta-item">
|
||||||
|
<dt class="field-label">Language Pair</dt>
|
||||||
|
<dd class="meta-value">{lang(job.source_language)} → {lang(job.target_language)}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<dt class="field-label">Complexity</dt>
|
||||||
|
<dd class="meta-value">{job.complexity_level}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<dt class="field-label">Created</dt>
|
||||||
|
<dd class="meta-value">{fmt(job.created_at)}</dd>
|
||||||
|
</div>
|
||||||
|
{#if job.started_at}
|
||||||
|
<div class="meta-item">
|
||||||
|
<dt class="field-label">Started</dt>
|
||||||
|
<dd class="meta-value">{fmt(job.started_at)}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if job.completed_at}
|
||||||
|
<div class="meta-item">
|
||||||
|
<dt class="field-label">Completed</dt>
|
||||||
|
<dd class="meta-value">{fmt(job.completed_at)}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{#if job.status === 'failed' && job.error_message}
|
||||||
|
<div class="alert alert-error" role="alert">
|
||||||
|
<strong>Job failed:</strong>
|
||||||
|
{job.error_message}
|
||||||
|
</div>
|
||||||
|
{:else if !isTerminal}
|
||||||
|
<div class="pending-notice">
|
||||||
|
<div class="spinner" aria-hidden="true"></div>
|
||||||
|
<p>This job is <strong>{job.status}</strong>. Refresh to check for updates.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if job.input_summary}
|
||||||
|
<section class="content-section">
|
||||||
|
<h2 class="section-title">Input Summary</h2>
|
||||||
|
<div class="prose">{job.input_summary}</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if job.generated_text}
|
||||||
|
<section class="content-section">
|
||||||
|
<h2 class="section-title">
|
||||||
|
Generated Text
|
||||||
|
<span class="section-lang">{lang(job.target_language)}</span>
|
||||||
|
</h2>
|
||||||
|
{#if job.audio_url}
|
||||||
|
<section class="content-section">
|
||||||
|
<h2 class="section-title">Audio</h2>
|
||||||
|
<audio class="audio-player" controls src={data.fullAudioUrl}>
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
<div class="prose prose-target">{job.generated_text}</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if job.translated_text}
|
||||||
|
<section class="content-section">
|
||||||
|
<h2 class="section-title">
|
||||||
|
Translation
|
||||||
|
<span class="section-lang">{lang(job.source_language)}</span>
|
||||||
|
</h2>
|
||||||
|
<div class="prose prose-translated">{job.translated_text}</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 52rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-8) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Breadcrumb --- */
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Header --- */
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-id {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Status badge --- */
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2em 0.75em;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
background-color: var(--color-secondary-container);
|
||||||
|
color: var(--color-on-secondary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-status='completed'] {
|
||||||
|
background-color: var(--color-primary-container);
|
||||||
|
color: var(--color-on-primary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-status='failed'] {
|
||||||
|
background-color: color-mix(in srgb, #b3261e 12%, var(--color-surface));
|
||||||
|
color: #b3261e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Metadata grid --- */
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||||
|
gap: var(--space-3) var(--space-5);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background-color: var(--color-surface-container-low);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-body-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Pending notice --- */
|
||||||
|
|
||||||
|
.pending-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background-color: var(--color-secondary-container);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-on-secondary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border: 2px solid var(--color-outline-variant);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Content sections --- */
|
||||||
|
|
||||||
|
.content-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--color-outline-variant) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--text-title-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-lang {
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: var(--text-label-md);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Prose --- */
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-lg);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-target {
|
||||||
|
padding: var(--space-5) var(--space-6);
|
||||||
|
background-color: var(--color-surface-container-low);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-size: calc(var(--text-body-lg) * 1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-translated {
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
font-size: var(--text-body-md);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Audio --- */
|
||||||
|
|
||||||
|
.audio-player {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Responsive --- */
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.page {
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,34 +1,144 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
const { data }: PageProps = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if data.isLoggedIn}
|
<div class="page">
|
||||||
<p>You are logged in</p>
|
<aside class="brand-panel" aria-hidden="true">
|
||||||
{/if}
|
<div class="brand-content">
|
||||||
<form method="POST">
|
<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">
|
<div class="field">
|
||||||
<label for="email" class="label">Email</label>
|
<label for="email" class="field-label">Email</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
class="input"
|
class="field-input"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="e.g john@gmail.com"
|
placeholder="you@example.com"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="password" class="label">Password</label>
|
<label for="password" class="field-label">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
class="input"
|
class="field-input"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="********"
|
placeholder="••••••••"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input type="submit" class="button is-primary" value="Login" />
|
|
||||||
|
<div class="field">
|
||||||
|
<input type="submit" value="Log In" />
|
||||||
|
</div>
|
||||||
</form>
|
</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>
|
||||||
|
|
|
||||||
7
frontend/src/routes/logout/+page.server.ts
Normal file
7
frontend/src/routes/logout/+page.server.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, cookies }) => {
|
||||||
|
cookies.delete(`auth_token`, { path: '/' });
|
||||||
|
return redirect(307, '/');
|
||||||
|
};
|
||||||
BIN
frontend/static/fonts/Archivo-Bold.woff2
Normal file
BIN
frontend/static/fonts/Archivo-Bold.woff2
Normal file
Binary file not shown.
BIN
frontend/static/fonts/Archivo-BoldItalic.woff2
Normal file
BIN
frontend/static/fonts/Archivo-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/static/fonts/Archivo-Italic.woff2
Normal file
BIN
frontend/static/fonts/Archivo-Italic.woff2
Normal file
Binary file not shown.
BIN
frontend/static/fonts/Archivo-Medium.woff2
Normal file
BIN
frontend/static/fonts/Archivo-Medium.woff2
Normal file
Binary file not shown.
BIN
frontend/static/fonts/Archivo-MediumItalic.woff2
Normal file
BIN
frontend/static/fonts/Archivo-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/static/fonts/Archivo-Regular.woff2
Normal file
BIN
frontend/static/fonts/Archivo-Regular.woff2
Normal file
Binary file not shown.
BIN
frontend/static/fonts/Archivo-SemiBold.woff2
Normal file
BIN
frontend/static/fonts/Archivo-SemiBold.woff2
Normal file
Binary file not shown.
BIN
frontend/static/fonts/Archivo-SemiBoldItalic.woff2
Normal file
BIN
frontend/static/fonts/Archivo-SemiBoldItalic.woff2
Normal file
Binary file not shown.
Loading…
Reference in a new issue