11 tasks covering Tailwind v4 setup, translations, flag SVGs, ThemeToggle, LanguageSwitcher, Header, layout SEO/JSON-LD, SubmitForm, ReassuranceSection, home page hero, and StatusView restyle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1292 lines
38 KiB
Markdown
1292 lines
38 KiB
Markdown
# Ombrora Minimalist UI -- Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Restyle the Ombrora frontend with Tailwind CSS v4, light/dark mode, flag-image language switcher, SEO-optimized markup, and a hero + reassurance layout.
|
|
|
|
**Architecture:** `src/app/[locale]/layout.tsx` is the real shell (renders `<html>`/`<body>`); the root `src/app/layout.tsx` just passes children through. All styling goes through a single `src/app/globals.css` imported in the locale layout. next-themes wraps the tree and toggles a `.dark` class on `<html>`.
|
|
|
|
**Tech Stack:** Next.js 16 App Router, Tailwind CSS v4 (`@tailwindcss/postcss`), next-themes, `next/font/google` (Inter), next-intl v4.
|
|
|
|
## Global Constraints
|
|
|
|
- Tailwind v4 only -- no `tailwind.config.ts`, config lives in CSS (`@custom-variant dark`)
|
|
- `npm install -D` for tailwindcss + @tailwindcss/postcss (build-time, not runtime)
|
|
- `next-themes` pure JS -- fine for o2switch
|
|
- Flag SVGs stored in `/public/flags/` -- no CDN runtime calls
|
|
- Inter font via `next/font/google` with `subsets: ['latin', 'latin-ext']` -- covers FR/ES/IT accents
|
|
- `NEXT_PUBLIC_BASE_URL` env var for canonical URLs -- set in `.env.local`
|
|
- No external icon library -- SVG paths are inlined
|
|
- Never touch files in `src/app/api/`, `src/lib/`, `worker/`, or any test file
|
|
- `src/app/layout.tsx` (root) -- do not modify, it just returns children
|
|
- `src/app/page.tsx` (non-locale) -- do not modify
|
|
|
|
## File Map
|
|
|
|
```
|
|
NEW
|
|
postcss.config.mjs
|
|
src/app/globals.css
|
|
src/components/ThemeToggle.tsx
|
|
src/components/Header.tsx
|
|
src/components/ReassuranceSection.tsx
|
|
public/flags/en.svg
|
|
public/flags/fr.svg
|
|
public/flags/es.svg
|
|
public/flags/it.svg
|
|
|
|
MODIFIED
|
|
messages/en.json
|
|
messages/fr.json
|
|
messages/es.json
|
|
messages/it.json
|
|
src/app/[locale]/layout.tsx
|
|
src/app/[locale]/page.tsx
|
|
src/app/[locale]/status/[uuid]/page.tsx
|
|
src/components/SubmitForm.tsx
|
|
src/components/StatusView.tsx
|
|
src/components/LanguageSwitcher.tsx
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1: Tailwind CSS v4 setup
|
|
|
|
**Files:**
|
|
- Create: `postcss.config.mjs`
|
|
- Create: `src/app/globals.css`
|
|
- Modify: `.env.local` (create if absent)
|
|
- DevDependencies: `tailwindcss`, `@tailwindcss/postcss`
|
|
|
|
**Interfaces:**
|
|
- Produces: Tailwind utility classes available in all TSX files; `dark:` variant active when `<html>` has class `dark`
|
|
|
|
- [ ] **Step 1: Install Tailwind**
|
|
|
|
```bash
|
|
npm install -D tailwindcss @tailwindcss/postcss
|
|
```
|
|
|
|
Expected: `tailwindcss` and `@tailwindcss/postcss` appear in `devDependencies` in `package.json`.
|
|
|
|
- [ ] **Step 2: Create PostCSS config**
|
|
|
|
Create `postcss.config.mjs` at the repo root:
|
|
|
|
```js
|
|
export default {
|
|
plugins: {
|
|
'@tailwindcss/postcss': {},
|
|
},
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create globals.css**
|
|
|
|
Create `src/app/globals.css`:
|
|
|
|
```css
|
|
@import 'tailwindcss';
|
|
|
|
/* Dark mode: activated when any ancestor has the .dark class (set by next-themes) */
|
|
@custom-variant dark (&:where(.dark, .dark *));
|
|
```
|
|
|
|
- [ ] **Step 4: Add NEXT_PUBLIC_BASE_URL to .env.local**
|
|
|
|
Add to `.env.local` (create the file if it does not exist):
|
|
|
|
```
|
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
|
```
|
|
|
|
(Set the production value before deploying to o2switch.)
|
|
|
|
- [ ] **Step 5: Smoke-test**
|
|
|
|
```bash
|
|
npm run dev
|
|
```
|
|
|
|
Open `http://localhost:3000/fr`. Add `className="text-violet-600"` temporarily to any element in `src/app/[locale]/page.tsx` and verify the element turns violet in the browser. Remove the temporary class.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add postcss.config.mjs src/app/globals.css .env.local package.json package-lock.json
|
|
git commit -m "feat: install Tailwind CSS v4 with PostCSS"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Update translation files (all 4 locales)
|
|
|
|
**Files:**
|
|
- Modify: `messages/en.json`, `messages/fr.json`, `messages/es.json`, `messages/it.json`
|
|
|
|
**Interfaces:**
|
|
- Produces: new keys `meta.description`, `home.subheading`, `home.formats`, `reassurance.*`, `footer.copyright`
|
|
- Consumed by: Tasks 7, 8, 9, 10 (layout, SubmitForm, ReassuranceSection, home page)
|
|
|
|
- [ ] **Step 1: Replace messages/en.json**
|
|
|
|
```json
|
|
{
|
|
"meta": {
|
|
"title": "Ombrora -- Free Video Downloader",
|
|
"description": "Download YouTube, Vimeo, and more in mp4, mp3, webm -- free, fast, no account required."
|
|
},
|
|
"home": {
|
|
"heading": "Download your videos for free",
|
|
"subheading": "Free, fast, no account required",
|
|
"urlLabel": "Video URL",
|
|
"format": "Format",
|
|
"quality": "Quality",
|
|
"subtitles": "Subtitles (fr, en)",
|
|
"advanced": "Advanced options",
|
|
"formats": "mp4 · mp3 · webm · mkv",
|
|
"submit": "Download",
|
|
"submitting": "Sending...",
|
|
"errorRateLimit": "Too many submissions. Try again in an hour.",
|
|
"errorGeneric": "Submission error."
|
|
},
|
|
"status": {
|
|
"heading": "Download status",
|
|
"notFound": "Download not found.",
|
|
"loading": "Loading...",
|
|
"pending": "Pending",
|
|
"processing": "Processing...",
|
|
"polling": "This page refreshes automatically every 5 seconds.",
|
|
"ready": "Download ready",
|
|
"download": "Download",
|
|
"linkExpires": "Link valid until",
|
|
"failed": "Download failed.",
|
|
"deleted": "The file has expired and was deleted. The download is no longer available.",
|
|
"unknown": "Unknown status"
|
|
},
|
|
"reassurance": {
|
|
"free": "Free",
|
|
"freeDesc": "No account, no sign-up",
|
|
"fast": "Fast",
|
|
"fastDesc": "Ready in seconds",
|
|
"private": "Private",
|
|
"privateDesc": "No data retained",
|
|
"formats": "Multi-format",
|
|
"formatsDesc": "mp4, mp3, webm, mkv -- up to 1080p"
|
|
},
|
|
"footer": {
|
|
"copyright": "© 2026 Ombrora"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Replace messages/fr.json**
|
|
|
|
```json
|
|
{
|
|
"meta": {
|
|
"title": "Ombrora -- Telechargeur de videos gratuit",
|
|
"description": "Telechargez vos videos YouTube, Vimeo et plus en mp4, mp3, webm -- gratuit, rapide, sans compte."
|
|
},
|
|
"home": {
|
|
"heading": "Telechargez vos videos gratuitement",
|
|
"subheading": "Gratuit, rapide, sans compte",
|
|
"urlLabel": "URL de la video",
|
|
"format": "Format",
|
|
"quality": "Qualite",
|
|
"subtitles": "Sous-titres (fr, en)",
|
|
"advanced": "Options avancees",
|
|
"formats": "mp4 · mp3 · webm · mkv",
|
|
"submit": "Telecharger",
|
|
"submitting": "Envoi en cours...",
|
|
"errorRateLimit": "Trop de soumissions. Reessayez dans une heure.",
|
|
"errorGeneric": "Erreur de soumission."
|
|
},
|
|
"status": {
|
|
"heading": "Statut du telechargement",
|
|
"notFound": "Telechargement introuvable.",
|
|
"loading": "Chargement...",
|
|
"pending": "En attente",
|
|
"processing": "En cours de traitement...",
|
|
"polling": "Cette page se rafraichit automatiquement toutes les 5 secondes.",
|
|
"ready": "Telechargement pret",
|
|
"download": "Telecharger",
|
|
"linkExpires": "Lien valide jusqu'au",
|
|
"failed": "Echec du telechargement.",
|
|
"deleted": "Le fichier a expire et a ete supprime. Le telechargement n'est plus disponible.",
|
|
"unknown": "Statut inconnu"
|
|
},
|
|
"reassurance": {
|
|
"free": "Gratuit",
|
|
"freeDesc": "Sans compte, sans inscription",
|
|
"fast": "Rapide",
|
|
"fastDesc": "Pret en quelques secondes",
|
|
"private": "Prive",
|
|
"privateDesc": "Aucune donnee conservee",
|
|
"formats": "Multi-format",
|
|
"formatsDesc": "mp4, mp3, webm, mkv -- jusqu'en 1080p"
|
|
},
|
|
"footer": {
|
|
"copyright": "© 2026 Ombrora"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Replace messages/es.json**
|
|
|
|
```json
|
|
{
|
|
"meta": {
|
|
"title": "Ombrora -- Descargador de videos gratuito",
|
|
"description": "Descarga videos de YouTube, Vimeo y mas en mp4, mp3, webm -- gratis, rapido, sin cuenta."
|
|
},
|
|
"home": {
|
|
"heading": "Descarga tus videos gratis",
|
|
"subheading": "Gratis, rapido, sin cuenta",
|
|
"urlLabel": "URL del video",
|
|
"format": "Formato",
|
|
"quality": "Calidad",
|
|
"subtitles": "Subtitulos (fr, en)",
|
|
"advanced": "Opciones avanzadas",
|
|
"formats": "mp4 · mp3 · webm · mkv",
|
|
"submit": "Descargar",
|
|
"submitting": "Enviando...",
|
|
"errorRateLimit": "Demasiadas solicitudes. Intentalo de nuevo en una hora.",
|
|
"errorGeneric": "Error de envio."
|
|
},
|
|
"status": {
|
|
"heading": "Estado de la descarga",
|
|
"notFound": "Descarga no encontrada.",
|
|
"loading": "Cargando...",
|
|
"pending": "Pendiente",
|
|
"processing": "Procesando...",
|
|
"polling": "Esta pagina se actualiza automaticamente cada 5 segundos.",
|
|
"ready": "Descarga lista",
|
|
"download": "Descargar",
|
|
"linkExpires": "Enlace valido hasta",
|
|
"failed": "Error de descarga.",
|
|
"deleted": "El archivo ha expirado y fue eliminado. La descarga ya no esta disponible.",
|
|
"unknown": "Estado desconocido"
|
|
},
|
|
"reassurance": {
|
|
"free": "Gratuito",
|
|
"freeDesc": "Sin cuenta, sin registro",
|
|
"fast": "Rapido",
|
|
"fastDesc": "Listo en segundos",
|
|
"private": "Privado",
|
|
"privateDesc": "Sin datos retenidos",
|
|
"formats": "Multi-formato",
|
|
"formatsDesc": "mp4, mp3, webm, mkv -- hasta 1080p"
|
|
},
|
|
"footer": {
|
|
"copyright": "© 2026 Ombrora"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Replace messages/it.json**
|
|
|
|
```json
|
|
{
|
|
"meta": {
|
|
"title": "Ombrora -- Scaricatore di video gratuito",
|
|
"description": "Scarica video da YouTube, Vimeo e altro in mp4, mp3, webm -- gratis, veloce, senza account."
|
|
},
|
|
"home": {
|
|
"heading": "Scarica i tuoi video gratuitamente",
|
|
"subheading": "Gratuito, veloce, senza account",
|
|
"urlLabel": "URL del video",
|
|
"format": "Formato",
|
|
"quality": "Qualita",
|
|
"subtitles": "Sottotitoli (fr, en)",
|
|
"advanced": "Opzioni avanzate",
|
|
"formats": "mp4 · mp3 · webm · mkv",
|
|
"submit": "Scarica",
|
|
"submitting": "Invio in corso...",
|
|
"errorRateLimit": "Troppe richieste. Riprova tra un'ora.",
|
|
"errorGeneric": "Errore di invio."
|
|
},
|
|
"status": {
|
|
"heading": "Stato del download",
|
|
"notFound": "Download non trovato.",
|
|
"loading": "Caricamento...",
|
|
"pending": "In attesa",
|
|
"processing": "In elaborazione...",
|
|
"polling": "Questa pagina si aggiorna automaticamente ogni 5 secondi.",
|
|
"ready": "Download pronto",
|
|
"download": "Scarica",
|
|
"linkExpires": "Link valido fino al",
|
|
"failed": "Download fallito.",
|
|
"deleted": "Il file e scaduto ed e stato eliminato. Il download non e piu disponibile.",
|
|
"unknown": "Stato sconosciuto"
|
|
},
|
|
"reassurance": {
|
|
"free": "Gratuito",
|
|
"freeDesc": "Senza account, senza registrazione",
|
|
"fast": "Veloce",
|
|
"fastDesc": "Pronto in pochi secondi",
|
|
"private": "Privato",
|
|
"privateDesc": "Nessun dato conservato",
|
|
"formats": "Multi-formato",
|
|
"formatsDesc": "mp4, mp3, webm, mkv -- fino a 1080p"
|
|
},
|
|
"footer": {
|
|
"copyright": "© 2026 Ombrora"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add messages/
|
|
git commit -m "feat: add SEO, reassurance, and subheading translation keys"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Download flag SVGs
|
|
|
|
**Files:**
|
|
- Create: `public/flags/en.svg`, `public/flags/fr.svg`, `public/flags/es.svg`, `public/flags/it.svg`
|
|
|
|
**Interfaces:**
|
|
- Produces: static SVG files served at `/flags/{locale}.svg`
|
|
- Consumed by: Task 5 (LanguageSwitcher)
|
|
|
|
- [ ] **Step 1: Create the flags directory and download SVGs**
|
|
|
|
Run in PowerShell from the repo root:
|
|
|
|
```powershell
|
|
New-Item -ItemType Directory -Force -Path public/flags
|
|
Invoke-WebRequest -Uri "https://flagcdn.com/us.svg" -OutFile "public/flags/en.svg"
|
|
Invoke-WebRequest -Uri "https://flagcdn.com/fr.svg" -OutFile "public/flags/fr.svg"
|
|
Invoke-WebRequest -Uri "https://flagcdn.com/es.svg" -OutFile "public/flags/es.svg"
|
|
Invoke-WebRequest -Uri "https://flagcdn.com/it.svg" -OutFile "public/flags/it.svg"
|
|
```
|
|
|
|
Expected: 4 SVG files appear under `public/flags/`. Each file starts with `<svg` or `<?xml`.
|
|
|
|
- [ ] **Step 2: Verify files are served**
|
|
|
|
Start `npm run dev` if not already running. Open `http://localhost:3000/flags/fr.svg` in the browser. You should see the French flag SVG.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add public/flags/
|
|
git commit -m "feat: add local flag SVGs for language switcher"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: ThemeToggle component + next-themes
|
|
|
|
**Files:**
|
|
- Create: `src/components/ThemeToggle.tsx`
|
|
- DevDependencies: `next-themes`
|
|
|
|
**Interfaces:**
|
|
- Produces: `export function ThemeToggle()` -- button that toggles `dark`/`light` via next-themes
|
|
- Consumed by: Task 6 (Header)
|
|
|
|
- [ ] **Step 1: Install next-themes**
|
|
|
|
```bash
|
|
npm install next-themes
|
|
```
|
|
|
|
- [ ] **Step 2: Create ThemeToggle.tsx**
|
|
|
|
```tsx
|
|
'use client'
|
|
|
|
import { useTheme } from 'next-themes'
|
|
import { useEffect, useState } from 'react'
|
|
|
|
function SunIcon() {
|
|
return (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<circle cx="12" cy="12" r="5"/>
|
|
<line x1="12" y1="1" x2="12" y2="3"/>
|
|
<line x1="12" y1="21" x2="12" y2="23"/>
|
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
|
<line x1="1" y1="12" x2="3" y2="12"/>
|
|
<line x1="21" y1="12" x2="23" y2="12"/>
|
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function MoonIcon() {
|
|
return (
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function ThemeToggle() {
|
|
const { theme, setTheme } = useTheme()
|
|
const [mounted, setMounted] = useState(false)
|
|
|
|
useEffect(() => { setMounted(true) }, [])
|
|
|
|
if (!mounted) {
|
|
// Prevents hydration mismatch -- renders a same-size placeholder
|
|
return <div className="w-9 h-9" />
|
|
}
|
|
|
|
return (
|
|
<button
|
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
|
aria-label="Basculer le theme"
|
|
className="rounded-lg p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
|
|
>
|
|
{theme === 'dark' ? <SunIcon /> : <MoonIcon />}
|
|
</button>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify the component compiles**
|
|
|
|
```bash
|
|
npx tsc --noEmit
|
|
```
|
|
|
|
Expected: no errors in `ThemeToggle.tsx`.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/components/ThemeToggle.tsx package.json package-lock.json
|
|
git commit -m "feat: add ThemeToggle component with next-themes"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Restyle LanguageSwitcher
|
|
|
|
**Files:**
|
|
- Modify: `src/components/LanguageSwitcher.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `/public/flags/{locale}.svg` (from Task 3)
|
|
- Produces: `export function LanguageSwitcher()` -- same signature as before, now uses `<img>` flags
|
|
|
|
- [ ] **Step 1: Rewrite LanguageSwitcher.tsx**
|
|
|
|
```tsx
|
|
'use client'
|
|
|
|
import { useLocale } from 'next-intl'
|
|
import { useRouter, usePathname } from '@/navigation'
|
|
import { routing } from '@/i18n/routing'
|
|
|
|
const FLAG_LABELS: Record<string, string> = {
|
|
en: 'English',
|
|
fr: 'Francais',
|
|
es: 'Espanol',
|
|
it: 'Italiano',
|
|
}
|
|
|
|
export function LanguageSwitcher() {
|
|
const locale = useLocale()
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
{routing.locales.map((loc) => (
|
|
<button
|
|
key={loc}
|
|
onClick={() => router.replace(pathname, { locale: loc })}
|
|
aria-label={FLAG_LABELS[loc]}
|
|
className={[
|
|
'rounded transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500',
|
|
loc === locale
|
|
? 'opacity-100 ring-2 ring-violet-500 ring-offset-1 ring-offset-white dark:ring-offset-slate-950'
|
|
: 'opacity-40 hover:opacity-70',
|
|
].join(' ')}
|
|
>
|
|
<img
|
|
src={`/flags/${loc}.svg`}
|
|
alt={FLAG_LABELS[loc]}
|
|
width={28}
|
|
height={20}
|
|
className="rounded-sm block"
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify**
|
|
|
|
```bash
|
|
npx tsc --noEmit
|
|
```
|
|
|
|
Open `http://localhost:3000/fr`. The header should show 4 flag images. The active flag has a violet ring; others are dimmed.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/LanguageSwitcher.tsx
|
|
git commit -m "feat: replace emoji flags with SVG flag images in LanguageSwitcher"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Header component
|
|
|
|
**Files:**
|
|
- Create: `src/components/Header.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `ThemeToggle` (Task 4), `LanguageSwitcher` (Task 5)
|
|
- Produces: `export function Header()` -- sticky header with logo, theme toggle, language switcher
|
|
|
|
- [ ] **Step 1: Create Header.tsx**
|
|
|
|
```tsx
|
|
import { ThemeToggle } from '@/components/ThemeToggle'
|
|
import { LanguageSwitcher } from '@/components/LanguageSwitcher'
|
|
|
|
export function Header() {
|
|
return (
|
|
<header className="sticky top-0 z-50 border-b border-gray-100 dark:border-slate-800 bg-white/80 dark:bg-slate-950/80 backdrop-blur-sm">
|
|
<div className="max-w-5xl mx-auto px-6 py-3 flex items-center justify-between">
|
|
<span className="text-xl font-bold tracking-tight select-none">
|
|
<span className="text-violet-600 dark:text-violet-400">O</span>mbrora
|
|
</span>
|
|
<div className="flex items-center gap-3">
|
|
<ThemeToggle />
|
|
<LanguageSwitcher />
|
|
</div>
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify**
|
|
|
|
```bash
|
|
npx tsc --noEmit
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/Header.tsx
|
|
git commit -m "feat: add Header component with logo, theme toggle, and language switcher"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Locale layout update
|
|
|
|
**Files:**
|
|
- Modify: `src/app/[locale]/layout.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `Header` (Task 6), `globals.css` (Task 1), translation keys `meta.*` and `footer.*` (Task 2)
|
|
- Produces: full page shell with Inter font, ThemeProvider, SEO metadata, JSON-LD, Header, footer
|
|
|
|
- [ ] **Step 1: Rewrite src/app/[locale]/layout.tsx**
|
|
|
|
```tsx
|
|
import type { Metadata } from 'next'
|
|
import { Inter } from 'next/font/google'
|
|
import { NextIntlClientProvider } from 'next-intl'
|
|
import { getTranslations, getMessages } from 'next-intl/server'
|
|
import { notFound } from 'next/navigation'
|
|
import { ThemeProvider } from 'next-themes'
|
|
import { routing } from '@/i18n/routing'
|
|
import { Header } from '@/components/Header'
|
|
import '../globals.css'
|
|
|
|
const inter = Inter({ subsets: ['latin', 'latin-ext'] })
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: Promise<{ locale: string }>
|
|
}): Promise<Metadata> {
|
|
const { locale } = await params
|
|
const t = await getTranslations({ locale, namespace: 'meta' })
|
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? ''
|
|
|
|
return {
|
|
title: t('title'),
|
|
description: t('description'),
|
|
robots: { index: true, follow: true },
|
|
openGraph: {
|
|
title: t('title'),
|
|
description: t('description'),
|
|
url: `${baseUrl}/${locale}`,
|
|
siteName: 'Ombrora',
|
|
locale,
|
|
},
|
|
alternates: {
|
|
canonical: `${baseUrl}/${locale}`,
|
|
languages: Object.fromEntries(
|
|
routing.locales.map((l) => [l, `/${l}`])
|
|
),
|
|
},
|
|
}
|
|
}
|
|
|
|
export default async function LocaleLayout({
|
|
children,
|
|
params,
|
|
}: {
|
|
children: React.ReactNode
|
|
params: Promise<{ locale: string }>
|
|
}) {
|
|
const { locale } = await params
|
|
|
|
if (!(routing.locales as readonly string[]).includes(locale)) {
|
|
notFound()
|
|
}
|
|
|
|
const messages = await getMessages()
|
|
const t = await getTranslations({ locale, namespace: 'footer' })
|
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? ''
|
|
|
|
const jsonLd = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'WebApplication',
|
|
name: 'Ombrora',
|
|
url: baseUrl,
|
|
description: 'Free, fast, private video downloader.',
|
|
applicationCategory: 'MultimediaApplication',
|
|
operatingSystem: 'Web',
|
|
offers: {
|
|
'@type': 'Offer',
|
|
price: '0',
|
|
priceCurrency: 'EUR',
|
|
},
|
|
}
|
|
|
|
return (
|
|
<html lang={locale} suppressHydrationWarning>
|
|
<head>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
/>
|
|
</head>
|
|
<body className={`${inter.className} bg-white dark:bg-slate-950 text-gray-900 dark:text-gray-50 antialiased min-h-screen flex flex-col`}>
|
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
|
<NextIntlClientProvider messages={messages}>
|
|
<Header />
|
|
<div className="flex-1">
|
|
{children}
|
|
</div>
|
|
<footer className="border-t border-gray-100 dark:border-slate-800 py-6 text-center text-sm text-gray-400 dark:text-slate-500">
|
|
{t('copyright')}
|
|
</footer>
|
|
</NextIntlClientProvider>
|
|
</ThemeProvider>
|
|
</body>
|
|
</html>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify build**
|
|
|
|
```bash
|
|
npx tsc --noEmit
|
|
npm run dev
|
|
```
|
|
|
|
Open `http://localhost:3000/fr`. You should see:
|
|
- Inter font applied
|
|
- Header with "Ombrora" logo + toggle + flags
|
|
- A footer with "© 2026 Ombrora"
|
|
- Dark mode toggle works (background switches between white and slate-950)
|
|
- Page `<title>` in the browser tab matches the French translation
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/app/[locale]/layout.tsx
|
|
git commit -m "feat: update locale layout with Inter font, ThemeProvider, SEO metadata, JSON-LD, Header, and footer"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Restyle SubmitForm
|
|
|
|
**Files:**
|
|
- Modify: `src/components/SubmitForm.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: translation keys `home.*` (Task 2)
|
|
- Produces: `export function SubmitForm()` -- same API/behavior as before, Tailwind-styled
|
|
|
|
- [ ] **Step 1: Rewrite src/components/SubmitForm.tsx**
|
|
|
|
```tsx
|
|
'use client'
|
|
|
|
import { useState, FormEvent } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { useRouter } from '@/navigation'
|
|
|
|
const FORMATS = ['mp4', 'mp3', 'webm', 'mkv']
|
|
const QUALITIES = ['best', '1080p', '720p', '480p', '360p']
|
|
|
|
function Spinner() {
|
|
return (
|
|
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
const selectClass =
|
|
'rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-gray-900 dark:text-gray-50 text-sm outline-none focus:ring-2 focus:ring-violet-500'
|
|
|
|
export function SubmitForm() {
|
|
const t = useTranslations('home')
|
|
const router = useRouter()
|
|
const [url, setUrl] = useState('')
|
|
const [format, setFormat] = useState('mp4')
|
|
const [quality, setQuality] = useState('best')
|
|
const [subtitles, setSubtitles] = useState(false)
|
|
const [extraArgs, setExtraArgs] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
async function handleSubmit(e: FormEvent) {
|
|
e.preventDefault()
|
|
setError(null)
|
|
setLoading(true)
|
|
|
|
const res = await fetch('/api/downloads', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
url,
|
|
format,
|
|
quality,
|
|
subtitles,
|
|
extraArgs: extraArgs.trim() || null,
|
|
}),
|
|
})
|
|
|
|
setLoading(false)
|
|
|
|
if (res.status === 429) {
|
|
setError(t('errorRateLimit'))
|
|
return
|
|
}
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}))
|
|
setError((body as { error?: string }).error ?? t('errorGeneric'))
|
|
return
|
|
}
|
|
|
|
const { uuid } = (await res.json()) as { uuid: string }
|
|
router.push(`/status/${uuid}`)
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="w-full max-w-2xl mx-auto">
|
|
{/* URL input row */}
|
|
<div className="relative">
|
|
{/* Violet glow behind the input */}
|
|
<div className="absolute inset-0 -z-10 rounded-xl bg-violet-500 blur-3xl opacity-15 dark:opacity-20" aria-hidden />
|
|
<div className="flex rounded-xl border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 overflow-hidden shadow-sm focus-within:ring-2 focus-within:ring-violet-500">
|
|
<input
|
|
type="url"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
required
|
|
placeholder="https://www.youtube.com/watch?v=..."
|
|
className="flex-1 px-4 py-3 bg-transparent text-sm outline-none placeholder:text-gray-400 dark:placeholder:text-slate-500"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="flex items-center gap-2 px-5 py-3 bg-violet-600 hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-600 text-white text-sm font-medium transition-colors disabled:opacity-60"
|
|
>
|
|
{loading && <Spinner />}
|
|
{loading ? t('submitting') : t('submit')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Options row */}
|
|
<div className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-gray-600 dark:text-gray-400">
|
|
<label className="flex items-center gap-2">
|
|
<span>{t('format')}</span>
|
|
<select
|
|
value={format}
|
|
onChange={(e) => setFormat(e.target.value)}
|
|
className={selectClass}
|
|
>
|
|
{FORMATS.map((f) => <option key={f}>{f}</option>)}
|
|
</select>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-2">
|
|
<span>{t('quality')}</span>
|
|
<select
|
|
value={quality}
|
|
onChange={(e) => setQuality(e.target.value)}
|
|
className={selectClass}
|
|
>
|
|
{QUALITIES.map((q) => <option key={q}>{q}</option>)}
|
|
</select>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={subtitles}
|
|
onChange={(e) => setSubtitles(e.target.checked)}
|
|
className="accent-violet-600 w-4 h-4"
|
|
/>
|
|
<span>{t('subtitles')}</span>
|
|
</label>
|
|
|
|
<details className="w-full mt-1">
|
|
<summary className="cursor-pointer select-none text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
|
|
{t('advanced')}
|
|
</summary>
|
|
<input
|
|
type="text"
|
|
value={extraArgs}
|
|
onChange={(e) => setExtraArgs(e.target.value)}
|
|
placeholder='["--sponsorblock-remove","all"]'
|
|
className="mt-2 w-full rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-50 outline-none focus:ring-2 focus:ring-violet-500"
|
|
/>
|
|
</details>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="mt-3 text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
)}
|
|
</form>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify**
|
|
|
|
```bash
|
|
npx tsc --noEmit
|
|
```
|
|
|
|
Open `http://localhost:3000/fr`. The form should show: URL input with violet button inline, format/quality/subtitles options below. Hover on the button -- it darkens. Toggle dark mode -- everything inverts correctly. The glow is visible behind the input.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/SubmitForm.tsx
|
|
git commit -m "feat: restyle SubmitForm with Tailwind, inline button, and violet glow"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: ReassuranceSection component
|
|
|
|
**Files:**
|
|
- Create: `src/components/ReassuranceSection.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: translation namespace `reassurance.*` (Task 2)
|
|
- Produces: `export function ReassuranceSection()` -- 4-card grid, server component
|
|
|
|
- [ ] **Step 1: Create src/components/ReassuranceSection.tsx**
|
|
|
|
```tsx
|
|
import { getTranslations } from 'next-intl/server'
|
|
|
|
function GiftIcon() {
|
|
return (
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="M20 12v10H4V12"/>
|
|
<path d="M2 7h20v5H2z"/>
|
|
<path d="M12 22V7"/>
|
|
<path d="M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7z"/>
|
|
<path d="M12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z"/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function BoltIcon() {
|
|
return (
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function LockIcon() {
|
|
return (
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
<path d="M7 11V7a5 5 0 0110 0v4"/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function FilmIcon() {
|
|
return (
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
|
|
<line x1="7" y1="2" x2="7" y2="22"/>
|
|
<line x1="17" y1="2" x2="17" y2="22"/>
|
|
<line x1="2" y1="12" x2="22" y2="12"/>
|
|
<line x1="2" y1="7" x2="7" y2="7"/>
|
|
<line x1="2" y1="17" x2="7" y2="17"/>
|
|
<line x1="17" y1="17" x2="22" y2="17"/>
|
|
<line x1="17" y1="7" x2="22" y2="7"/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export async function ReassuranceSection({ locale }: { locale: string }) {
|
|
const t = await getTranslations({ locale, namespace: 'reassurance' })
|
|
|
|
const items = [
|
|
{ title: t('free'), desc: t('freeDesc'), Icon: GiftIcon },
|
|
{ title: t('fast'), desc: t('fastDesc'), Icon: BoltIcon },
|
|
{ title: t('private'), desc: t('privateDesc'), Icon: LockIcon },
|
|
{ title: t('formats'), desc: t('formatsDesc'), Icon: FilmIcon },
|
|
]
|
|
|
|
return (
|
|
<section className="py-16 px-6" aria-label="Avantages">
|
|
<div className="max-w-5xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{items.map(({ title, desc, Icon }) => (
|
|
<div
|
|
key={title}
|
|
className="rounded-xl border border-gray-100 dark:border-slate-800 bg-gray-50 dark:bg-slate-900 p-5 text-center"
|
|
>
|
|
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400 mb-3">
|
|
<Icon />
|
|
</div>
|
|
<p className="font-semibold text-sm text-gray-900 dark:text-gray-50">{title}</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{desc}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify**
|
|
|
|
```bash
|
|
npx tsc --noEmit
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/ReassuranceSection.tsx
|
|
git commit -m "feat: add ReassuranceSection with 4 benefit cards"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Home page update
|
|
|
|
**Files:**
|
|
- Modify: `src/app/[locale]/page.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `SubmitForm` (Task 8), `ReassuranceSection` (Task 9), translation keys `home.heading`, `home.subheading`, `home.formats` (Task 2)
|
|
- Produces: full home page with hero section + form + reassurance
|
|
|
|
- [ ] **Step 1: Rewrite src/app/[locale]/page.tsx**
|
|
|
|
```tsx
|
|
import { getTranslations } from 'next-intl/server'
|
|
import { SubmitForm } from '@/components/SubmitForm'
|
|
import { ReassuranceSection } from '@/components/ReassuranceSection'
|
|
|
|
export default async function Home({
|
|
params,
|
|
}: {
|
|
params: Promise<{ locale: string }>
|
|
}) {
|
|
const { locale } = await params
|
|
const t = await getTranslations({ locale, namespace: 'home' })
|
|
|
|
return (
|
|
<main>
|
|
<section
|
|
className="flex flex-col items-center justify-center px-6 pt-20 pb-16 text-center"
|
|
aria-labelledby="hero-heading"
|
|
>
|
|
<h1
|
|
id="hero-heading"
|
|
className="text-4xl md:text-5xl font-bold tracking-tight text-gray-900 dark:text-gray-50 mb-4 max-w-2xl"
|
|
>
|
|
{t('heading')}
|
|
</h1>
|
|
<p className="text-lg text-gray-500 dark:text-gray-400 mb-10">
|
|
{t('subheading')}
|
|
</p>
|
|
<SubmitForm />
|
|
<p className="mt-5 text-xs text-gray-400 dark:text-slate-500 tracking-wide uppercase">
|
|
{t('formats')}
|
|
</p>
|
|
</section>
|
|
|
|
<ReassuranceSection locale={locale} />
|
|
</main>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify visually**
|
|
|
|
Open `http://localhost:3000/fr`. You should see:
|
|
- Centered `<h1>` "Telechargez vos videos gratuitement"
|
|
- Sub-heading "Gratuit, rapide, sans compte"
|
|
- URL input with violet button and glow
|
|
- Format/quality/subtitle options below
|
|
- "mp4 · mp3 · webm · mkv" label below options
|
|
- 4 reassurance cards in a 2x2 (mobile) or 4x1 (desktop) grid
|
|
- Footer "© 2026 Ombrora"
|
|
|
|
Switch to dark mode. Switch locales. Confirm all translations update.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/app/[locale]/page.tsx
|
|
git commit -m "feat: add hero section and reassurance grid to home page"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: StatusView + status page
|
|
|
|
**Files:**
|
|
- Modify: `src/components/StatusView.tsx`
|
|
- Modify: `src/app/[locale]/status/[uuid]/page.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: translation keys `status.*` (Task 2); same polling API as before
|
|
- Produces: styled status card with spinner, download button, error block
|
|
|
|
- [ ] **Step 1: Rewrite src/components/StatusView.tsx**
|
|
|
|
```tsx
|
|
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useTranslations, useLocale } from 'next-intl'
|
|
|
|
type DownloadData = {
|
|
uuid: string
|
|
status: 'PENDING' | 'PROCESSING' | 'DONE' | 'FAILED' | 'FILE_DELETED'
|
|
format: string
|
|
quality: string
|
|
fileName: string | null
|
|
fileSize: string | null
|
|
errorMsg: string | null
|
|
downloadToken: string | null
|
|
tokenExpiresAt: string | null
|
|
}
|
|
|
|
function Spinner() {
|
|
return (
|
|
<svg className="animate-spin w-8 h-8 text-violet-600 dark:text-violet-400" viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function StatusView({ uuid }: { uuid: string }) {
|
|
const t = useTranslations('status')
|
|
const locale = useLocale()
|
|
const [data, setData] = useState<DownloadData | null>(null)
|
|
const [notFound, setNotFound] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
|
|
async function poll() {
|
|
const res = await fetch(`/api/downloads/${uuid}`)
|
|
if (!active) return
|
|
|
|
if (res.status === 404) {
|
|
setNotFound(true)
|
|
return
|
|
}
|
|
|
|
const body = (await res.json()) as DownloadData
|
|
setData(body)
|
|
|
|
if (body.status === 'PENDING' || body.status === 'PROCESSING') {
|
|
setTimeout(poll, 5_000)
|
|
}
|
|
}
|
|
|
|
poll()
|
|
return () => { active = false }
|
|
}, [uuid])
|
|
|
|
const cardClass = 'max-w-md w-full mx-auto rounded-2xl border border-gray-100 dark:border-slate-800 bg-gray-50 dark:bg-slate-900 p-8'
|
|
|
|
if (notFound) {
|
|
return (
|
|
<div className={cardClass}>
|
|
<p className="text-gray-600 dark:text-gray-400 text-center">{t('notFound')}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!data) {
|
|
return (
|
|
<div className={`${cardClass} flex flex-col items-center gap-4`}>
|
|
<Spinner />
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm">{t('loading')}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (data.status === 'PENDING' || data.status === 'PROCESSING') {
|
|
return (
|
|
<div className={`${cardClass} flex flex-col items-center gap-4`}>
|
|
<Spinner />
|
|
<p className="font-medium text-gray-900 dark:text-gray-50">
|
|
{data.status === 'PENDING' ? t('pending') : t('processing')}
|
|
</p>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 text-center">{t('polling')}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (data.status === 'DONE' && data.downloadToken) {
|
|
const sizeMb = data.fileSize
|
|
? `(${(Number(data.fileSize) / 1_048_576).toFixed(1)} MB)`
|
|
: ''
|
|
return (
|
|
<div className={`${cardClass} flex flex-col items-center gap-5`}>
|
|
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600 dark:text-green-400" aria-hidden>
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
</div>
|
|
<p className="font-medium text-gray-900 dark:text-gray-50">
|
|
{t('ready')} {sizeMb}
|
|
</p>
|
|
<a
|
|
href={`/api/download/${data.downloadToken}`}
|
|
download={data.fileName ?? undefined}
|
|
className="w-full text-center rounded-xl bg-violet-600 hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-600 text-white font-medium py-3 transition-colors"
|
|
>
|
|
{t('download')} {data.fileName}
|
|
</a>
|
|
{data.tokenExpiresAt && (
|
|
<p className="text-xs text-gray-400 dark:text-slate-500">
|
|
{t('linkExpires')}{' '}
|
|
{new Date(data.tokenExpiresAt).toLocaleString(locale)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (data.status === 'FAILED') {
|
|
return (
|
|
<div className={cardClass}>
|
|
<p className="font-medium text-red-600 dark:text-red-400 mb-3">{t('failed')}</p>
|
|
<pre className="text-xs bg-red-50 dark:bg-red-950/40 border border-red-100 dark:border-red-900/50 rounded-lg p-4 overflow-x-auto text-red-700 dark:text-red-300 whitespace-pre-wrap">
|
|
{data.errorMsg}
|
|
</pre>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (data.status === 'FILE_DELETED') {
|
|
return (
|
|
<div className={cardClass}>
|
|
<p className="text-gray-600 dark:text-gray-400 text-center">{t('deleted')}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={cardClass}>
|
|
<p className="text-gray-500 dark:text-gray-400 text-center">{t('unknown')}: {data.status}</p>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Rewrite src/app/[locale]/status/[uuid]/page.tsx**
|
|
|
|
```tsx
|
|
import { getTranslations } from 'next-intl/server'
|
|
import { StatusView } from '@/components/StatusView'
|
|
|
|
export default async function StatusPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ locale: string; uuid: string }>
|
|
}) {
|
|
const { locale, uuid } = await params
|
|
const t = await getTranslations({ locale, namespace: 'status' })
|
|
|
|
return (
|
|
<main className="flex flex-col items-center px-6 py-16">
|
|
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-50 mb-10">
|
|
{t('heading')}
|
|
</h1>
|
|
<StatusView uuid={uuid} />
|
|
</main>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify visually**
|
|
|
|
Submit a URL via the home form. On the status page, you should see:
|
|
- A centred card with spinner while PENDING/PROCESSING
|
|
- A green check + download button when DONE
|
|
- A red block with error text when FAILED
|
|
|
|
Test dark mode on the status page.
|
|
|
|
- [ ] **Step 4: Verify type-check and existing tests still pass**
|
|
|
|
```bash
|
|
npx tsc --noEmit
|
|
npm test
|
|
```
|
|
|
|
Expected: TypeScript clean, all existing backend tests pass (no UI tests exist, none added).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/components/StatusView.tsx src/app/[locale]/status/[uuid]/page.tsx
|
|
git commit -m "feat: restyle StatusView and status page with Tailwind"
|
|
```
|