docs: add i18n implementation plan (next-intl, 6 tasks)
This commit is contained in:
@@ -0,0 +1,749 @@
|
||||
# i18n (next-intl, FR/EN/ES/IT) 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:** Add multilingual support (FR, EN, ES, IT) using next-intl, with URL-based locale routing (`/fr`, `/en`, `/es`, `/it`), auto-detection via `Accept-Language`, and a flag emoji switcher on all pages.
|
||||
|
||||
**Architecture:** A Next.js middleware intercepts all non-API requests and redirects to the locale-prefixed URL detected from the browser's `Accept-Language` header (defaulting to `en`). All UI pages move under a `[locale]` dynamic segment. Components access translations via next-intl's `useTranslations`/`getTranslations` hooks backed by per-locale JSON files in `messages/`.
|
||||
|
||||
**Tech Stack:** next-intl (latest), Next.js 16 App Router, React 19
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Default locale (fallback): `en`
|
||||
- Supported locales: `en`, `fr`, `es`, `it`
|
||||
- Flags: 🇺🇸 (en), 🇫🇷 (fr), 🇪🇸 (es), 🇮🇹 (it)
|
||||
- next-intl is pure JS — no native binaries, compatible with o2switch shared hosting
|
||||
- API routes (`/api/*`) must NOT be locale-prefixed — middleware must exclude them
|
||||
- No changes to `src/lib/`, `src/app/api/`, `worker/`, Prisma schema, or existing tests
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Install next-intl and create i18n configuration
|
||||
|
||||
**Files:**
|
||||
- Create: `src/i18n/routing.ts`
|
||||
- Create: `src/i18n/request.ts`
|
||||
- Create: `src/navigation.ts`
|
||||
- Modify: `next.config.ts`
|
||||
- Modify: `package.json` + `package-lock.json` (via npm install)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `routing` exported from `src/i18n/routing.ts` — used by middleware, layout, and LanguageSwitcher
|
||||
- Produces: `useRouter`, `usePathname`, `Link` locale-aware hooks from `src/navigation.ts` — used by SubmitForm and LanguageSwitcher
|
||||
|
||||
- [ ] **Step 1: Install next-intl**
|
||||
|
||||
```bash
|
||||
npm install next-intl
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `src/i18n/routing.ts`**
|
||||
|
||||
```typescript
|
||||
import { defineRouting } from 'next-intl/routing'
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['en', 'fr', 'es', 'it'],
|
||||
defaultLocale: 'en',
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create `src/i18n/request.ts`**
|
||||
|
||||
```typescript
|
||||
import { getRequestConfig } from 'next-intl/server'
|
||||
import { routing } from './routing'
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let locale = await requestLocale
|
||||
if (!locale || !(routing.locales as readonly string[]).includes(locale)) {
|
||||
locale = routing.defaultLocale
|
||||
}
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `src/navigation.ts`**
|
||||
|
||||
```typescript
|
||||
import { createNavigation } from 'next-intl/navigation'
|
||||
import { routing } from './i18n/routing'
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Replace `next.config.ts`**
|
||||
|
||||
```typescript
|
||||
import type { NextConfig } from 'next'
|
||||
import createNextIntlPlugin from 'next-intl/plugin'
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
|
||||
|
||||
const nextConfig: NextConfig = {}
|
||||
|
||||
export default withNextIntl(nextConfig)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/i18n/routing.ts src/i18n/request.ts src/navigation.ts next.config.ts package.json package-lock.json
|
||||
git commit -m "feat: install next-intl and set up i18n routing config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add middleware for locale detection and redirect
|
||||
|
||||
**Files:**
|
||||
- Create: `src/middleware.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `routing` from `src/i18n/routing.ts`
|
||||
- Produces: middleware that reads `Accept-Language`, redirects `/` → `/en` (or detected locale), passes `/api/*` and static assets through unchanged
|
||||
|
||||
- [ ] **Step 1: Create `src/middleware.ts`**
|
||||
|
||||
```typescript
|
||||
import createMiddleware from 'next-intl/middleware'
|
||||
import { routing } from './i18n/routing'
|
||||
|
||||
export default createMiddleware(routing)
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!api|_next|_vercel|.*\\..*).*)',
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/middleware.ts
|
||||
git commit -m "feat: add locale-detection middleware"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Create translation message files
|
||||
|
||||
**Files:**
|
||||
- Create: `messages/en.json`
|
||||
- Create: `messages/fr.json`
|
||||
- Create: `messages/es.json`
|
||||
- Create: `messages/it.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: namespaces `meta`, `home`, `status` consumed by `getTranslations`/`useTranslations` in Tasks 4 and 5
|
||||
- Key names must match exactly: `meta.title`, `home.heading`, `home.urlLabel`, `home.format`, `home.quality`, `home.subtitles`, `home.advanced`, `home.submit`, `home.submitting`, `home.errorRateLimit`, `home.errorGeneric`, `status.heading`, `status.notFound`, `status.loading`, `status.pending`, `status.processing`, `status.polling`, `status.ready`, `status.download`, `status.linkExpires`, `status.failed`, `status.deleted`, `status.unknown`
|
||||
|
||||
- [ ] **Step 1: Create `messages/en.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"title": "Ombrora — Video downloader"
|
||||
},
|
||||
"home": {
|
||||
"heading": "Ombrora",
|
||||
"urlLabel": "Video URL",
|
||||
"format": "Format",
|
||||
"quality": "Quality",
|
||||
"subtitles": "Download subtitles (fr, en)",
|
||||
"advanced": "Advanced options (optional, JSON)",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `messages/fr.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"title": "Ombrora — Téléchargeur de vidéos"
|
||||
},
|
||||
"home": {
|
||||
"heading": "Ombrora",
|
||||
"urlLabel": "URL de la vidéo",
|
||||
"format": "Format",
|
||||
"quality": "Qualité",
|
||||
"subtitles": "Télécharger les sous-titres (fr, en)",
|
||||
"advanced": "Options avancées (optionnel, JSON)",
|
||||
"submit": "Télécharger",
|
||||
"submitting": "Envoi...",
|
||||
"errorRateLimit": "Trop de soumissions. Réessayez dans une heure.",
|
||||
"errorGeneric": "Erreur lors de la soumission."
|
||||
},
|
||||
"status": {
|
||||
"heading": "Statut du téléchargement",
|
||||
"notFound": "Téléchargement introuvable.",
|
||||
"loading": "Chargement...",
|
||||
"pending": "En attente",
|
||||
"processing": "En cours...",
|
||||
"polling": "Cette page se rafraîchit automatiquement toutes les 5 secondes.",
|
||||
"ready": "Téléchargement prêt",
|
||||
"download": "Télécharger",
|
||||
"linkExpires": "Lien valable jusqu'au",
|
||||
"failed": "Échec du téléchargement.",
|
||||
"deleted": "Le fichier a expiré et a été supprimé. Le téléchargement n'est plus disponible.",
|
||||
"unknown": "Statut inconnu"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create `messages/es.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"title": "Ombrora — Descargador de vídeos"
|
||||
},
|
||||
"home": {
|
||||
"heading": "Ombrora",
|
||||
"urlLabel": "URL del vídeo",
|
||||
"format": "Formato",
|
||||
"quality": "Calidad",
|
||||
"subtitles": "Descargar subtítulos (fr, en)",
|
||||
"advanced": "Opciones avanzadas (opcional, JSON)",
|
||||
"submit": "Descargar",
|
||||
"submitting": "Enviando...",
|
||||
"errorRateLimit": "Demasiadas peticiones. Inténtalo de nuevo en una hora.",
|
||||
"errorGeneric": "Error al enviar."
|
||||
},
|
||||
"status": {
|
||||
"heading": "Estado de la descarga",
|
||||
"notFound": "Descarga no encontrada.",
|
||||
"loading": "Cargando...",
|
||||
"pending": "Pendiente",
|
||||
"processing": "En proceso...",
|
||||
"polling": "Esta página se actualiza automáticamente cada 5 segundos.",
|
||||
"ready": "Descarga lista",
|
||||
"download": "Descargar",
|
||||
"linkExpires": "Enlace válido hasta",
|
||||
"failed": "Error en la descarga.",
|
||||
"deleted": "El archivo ha expirado y fue eliminado. La descarga ya no está disponible.",
|
||||
"unknown": "Estado desconocido"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `messages/it.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"title": "Ombrora — Scaricatore di video"
|
||||
},
|
||||
"home": {
|
||||
"heading": "Ombrora",
|
||||
"urlLabel": "URL del video",
|
||||
"format": "Formato",
|
||||
"quality": "Qualità",
|
||||
"subtitles": "Scarica sottotitoli (fr, en)",
|
||||
"advanced": "Opzioni avanzate (opzionale, JSON)",
|
||||
"submit": "Scarica",
|
||||
"submitting": "Invio...",
|
||||
"errorRateLimit": "Troppe richieste. Riprova tra un'ora.",
|
||||
"errorGeneric": "Errore durante l'invio."
|
||||
},
|
||||
"status": {
|
||||
"heading": "Stato del download",
|
||||
"notFound": "Download non trovato.",
|
||||
"loading": "Caricamento...",
|
||||
"pending": "In attesa",
|
||||
"processing": "In corso...",
|
||||
"polling": "Questa pagina si aggiorna automaticamente ogni 5 secondi.",
|
||||
"ready": "Download pronto",
|
||||
"download": "Scarica",
|
||||
"linkExpires": "Link valido fino al",
|
||||
"failed": "Errore nel download.",
|
||||
"deleted": "Il file è scaduto ed è stato eliminato. Il download non è più disponibile.",
|
||||
"unknown": "Stato sconosciuto"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add messages/
|
||||
git commit -m "feat: add translation messages for en, fr, es, it"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Restructure app routes under [locale] + create LanguageSwitcher
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/layout.tsx` (replace with minimal root pass-through)
|
||||
- Create: `src/components/LanguageSwitcher.tsx`
|
||||
- Create: `src/app/[locale]/layout.tsx`
|
||||
- Create: `src/app/[locale]/page.tsx`
|
||||
- Create: `src/app/[locale]/status/[uuid]/page.tsx`
|
||||
- Delete: `src/app/page.tsx`
|
||||
- Delete: `src/app/status/[uuid]/page.tsx` (whole `src/app/status/` tree)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `routing` from `@/i18n/routing`
|
||||
- Consumes: `getTranslations`, `getMessages` from `next-intl/server`
|
||||
- Consumes: `NextIntlClientProvider` from `next-intl`
|
||||
- Consumes: `useRouter`, `usePathname` from `@/navigation`
|
||||
- Consumes: message keys `meta.title`, `home.heading`, `status.heading`
|
||||
|
||||
- [ ] **Step 1: Replace `src/app/layout.tsx` with a minimal root pass-through**
|
||||
|
||||
The root layout must exist but should not render `<html>`/`<body>` — those come from `[locale]/layout.tsx` so the `lang` attribute can be set dynamically. Overwrite the file with:
|
||||
|
||||
```tsx
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `src/components/LanguageSwitcher.tsx`**
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useRouter, usePathname } from '@/navigation'
|
||||
import { routing } from '@/i18n/routing'
|
||||
|
||||
const FLAGS: Record<string, string> = {
|
||||
en: '🇺🇸',
|
||||
fr: '🇫🇷',
|
||||
es: '🇪🇸',
|
||||
it: '🇮🇹',
|
||||
}
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const locale = useLocale()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', padding: '0.5rem 2rem' }}>
|
||||
{routing.locales.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
onClick={() => router.replace(pathname, { locale: loc })}
|
||||
aria-label={loc}
|
||||
style={{
|
||||
fontSize: '1.5rem',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
opacity: loc === locale ? 1 : 0.4,
|
||||
}}
|
||||
>
|
||||
{FLAGS[loc]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create `src/app/[locale]/layout.tsx`**
|
||||
|
||||
```tsx
|
||||
import type { Metadata } from 'next'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { getTranslations, getMessages } from 'next-intl/server'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { routing } from '@/i18n/routing'
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher'
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: 'meta' })
|
||||
return { title: t('title') }
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<LanguageSwitcher />
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `src/app/[locale]/page.tsx`**
|
||||
|
||||
```tsx
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { SubmitForm } from '@/components/SubmitForm'
|
||||
|
||||
export default async function Home({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: 'home' })
|
||||
return (
|
||||
<main style={{ padding: '2rem' }}>
|
||||
<h1>{t('heading')}</h1>
|
||||
<SubmitForm />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create `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 style={{ padding: '2rem' }}>
|
||||
<h1>{t('heading')}</h1>
|
||||
<StatusView uuid={uuid} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Remove the old route files**
|
||||
|
||||
```bash
|
||||
git rm src/app/page.tsx
|
||||
git rm -r src/app/status
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/layout.tsx src/app/[locale]/ src/components/LanguageSwitcher.tsx
|
||||
git commit -m "feat: restructure routes under [locale], add LanguageSwitcher"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update SubmitForm and StatusView with translations
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/SubmitForm.tsx`
|
||||
- Modify: `src/components/StatusView.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useTranslations` from `next-intl` (available in client components because `NextIntlClientProvider` wraps the app in `[locale]/layout.tsx`)
|
||||
- Consumes: `useLocale` from `next-intl`
|
||||
- Consumes: `useRouter` from `@/navigation` (locale-aware — `router.push('/status/uuid')` auto-prepends the current locale)
|
||||
- Consumes: message keys `home.*` and `status.*` defined in Task 3
|
||||
|
||||
- [ ] **Step 1: Replace `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']
|
||||
|
||||
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} style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxWidth: 480 }}>
|
||||
<label>
|
||||
{t('urlLabel')}
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
required
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
style={{ display: 'block', width: '100%' }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t('format')}
|
||||
<select value={format} onChange={(e) => setFormat(e.target.value)} style={{ display: 'block' }}>
|
||||
{FORMATS.map((f) => <option key={f}>{f}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t('quality')}
|
||||
<select value={quality} onChange={(e) => setQuality(e.target.value)} style={{ display: 'block' }}>
|
||||
{QUALITIES.map((q) => <option key={q}>{q}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subtitles}
|
||||
onChange={(e) => setSubtitles(e.target.checked)}
|
||||
/>{' '}
|
||||
{t('subtitles')}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t('advanced')}
|
||||
<input
|
||||
type="text"
|
||||
value={extraArgs}
|
||||
onChange={(e) => setExtraArgs(e.target.value)}
|
||||
placeholder='["--sponsorblock-remove","all"]'
|
||||
style={{ display: 'block', width: '100%' }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `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
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
if (notFound) return <p>{t('notFound')}</p>
|
||||
if (!data) return <p>{t('loading')}</p>
|
||||
|
||||
if (data.status === 'PENDING' || data.status === 'PROCESSING') {
|
||||
return (
|
||||
<div>
|
||||
<p>{data.status === 'PENDING' ? t('pending') : t('processing')}</p>
|
||||
<p>{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>
|
||||
<p>{t('ready')} {sizeMb}</p>
|
||||
<a
|
||||
href={`/api/download/${data.downloadToken}`}
|
||||
download={data.fileName ?? undefined}
|
||||
>
|
||||
{t('download')} {data.fileName}
|
||||
</a>
|
||||
{data.tokenExpiresAt && (
|
||||
<p style={{ fontSize: '0.85rem', color: '#666' }}>
|
||||
{t('linkExpires')}{' '}
|
||||
{new Date(data.tokenExpiresAt).toLocaleString(locale)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.status === 'FAILED') {
|
||||
return (
|
||||
<div>
|
||||
<p>{t('failed')}</p>
|
||||
<pre style={{ background: '#fee', padding: '0.5rem', overflowX: 'auto' }}>
|
||||
{data.errorMsg}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.status === 'FILE_DELETED') {
|
||||
return <p>{t('deleted')}</p>
|
||||
}
|
||||
|
||||
return <p>{t('unknown')}: {data.status}</p>
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/SubmitForm.tsx src/components/StatusView.tsx
|
||||
git commit -m "feat: translate SubmitForm and StatusView with next-intl"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Smoke test
|
||||
|
||||
No automated tests exist for UI components in this project. Verify manually.
|
||||
|
||||
- [ ] **Step 1: Start the dev server**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify locale redirect**
|
||||
|
||||
Open `http://localhost:3000` in a browser. Confirm it redirects to `/en` (or your browser's locale if it is one of `fr`, `es`, `it`).
|
||||
|
||||
- [ ] **Step 3: Verify flag switcher**
|
||||
|
||||
Confirm four flag buttons appear at the top of the page: 🇺🇸 🇫🇷 🇪🇸 🇮🇹. Click each and confirm:
|
||||
- The URL changes to `/fr`, `/en`, `/es`, `/it`
|
||||
- The page text changes language
|
||||
- The active flag is fully opaque; others are at 40% opacity
|
||||
|
||||
- [ ] **Step 4: Verify form submission redirect**
|
||||
|
||||
Submit a valid YouTube URL. Confirm the browser redirects to `/[current-locale]/status/[uuid]` (not `/status/[uuid]`).
|
||||
|
||||
- [ ] **Step 5: Verify status page**
|
||||
|
||||
Open a status URL directly (`/fr/status/some-uuid`). Confirm the heading and all status strings are in French.
|
||||
|
||||
- [ ] **Step 6: Verify API routes are unaffected**
|
||||
|
||||
Confirm `http://localhost:3000/api/downloads` still returns a JSON response (not a 404 or locale redirect).
|
||||
Reference in New Issue
Block a user