Files
video-downloader/docs/superpowers/plans/2026-08-10-minimalist-ui.md
anthonyandClaude Sonnet 4.6 717b251941 docs: add minimalist UI implementation plan
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>
2026-08-10 16:01:38 +02:00

38 KiB

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

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:

export default {
  plugins: {
    '@tailwindcss/postcss': {},
  },
}
  • Step 3: Create globals.css

Create src/app/globals.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
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
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

{
  "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
{
  "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
{
  "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
{
  "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
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:

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
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

npm install next-themes
  • Step 2: Create ThemeToggle.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
npx tsc --noEmit

Expected: no errors in ThemeToggle.tsx.

  • Step 4: Commit
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

'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
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
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

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
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
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

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
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

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

'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
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
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

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
npx tsc --noEmit

Expected: no errors.

  • Step 3: Commit
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

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
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

'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
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
npx tsc --noEmit
npm test

Expected: TypeScript clean, all existing backend tests pass (no UI tests exist, none added).

  • Step 5: Commit
git add src/components/StatusView.tsx src/app/[locale]/status/[uuid]/page.tsx
git commit -m "feat: restyle StatusView and status page with Tailwind"