Files
convert/docs/superpowers/plans/2026-07-31-ombrora-frontend-redesign.md
anthony f499196dae docs: add Ombrora Convert frontend redesign implementation plan
5-task plan: design tokens/dark mode/i18n scaffold, routing+layout+
migrated tool, reassurance+formats sections, SEO metadata, responsive
polish.
2026-07-31 14:37:05 +02:00

1561 lines
49 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ombrora Convert Frontend Redesign 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:** Rebuild `frontend/` into a modern, minimalist, dark-mode-capable, bilingual (FR/EN) single-page site named "Ombrora Convert" that embeds the existing file-conversion tool directly in the homepage, with reassurance messaging backed by real backend facts and baseline SEO.
**Architecture:** A `react-router-dom` shell (`/` redirects to `/fr/` or `/en/`; `/:lang/` renders the whole app) wraps a shared `Layout` (header with language switcher + dark-mode toggle, footer) around one `HomePage` that contains the hero, the drag-and-drop upload/convert tool (migrated unchanged in logic from the current `App.jsx`/`FileCard.jsx`/`api.js`), a reassurance strip, and a supported-formats grid. `react-i18next` drives all copy from `locales/fr.json`/`en.json`, keyed off the `:lang` route param.
**Tech Stack:** React 19, Vite, `react-router-dom`, `react-i18next` + `i18next`, `@phosphor-icons/react` (icons), plain CSS (no CSS framework — matches the project's existing plain-CSS convention).
## Global Constraints
- Every new dependency must be pure JS with no native/binary bindings (o2switch shared hosting has no compiler toolchain) — `react-router-dom`, `react-i18next`, `i18next`, `@phosphor-icons/react` all qualify.
- Brand name is **"Ombrora Convert"** everywhere (title, header, footer, meta tags, JSON-LD) — not "file-converter" or "Convertisseur de fichiers".
- Only two locales: `fr` and `en`, addressed via URL prefix (`/fr/`, `/en/`). No other language codes are valid routes.
- Dark mode default is **light**, not dark — `prefers-color-scheme: dark` is honored only as the *initial* value before any manual toggle; an explicit toggle is persisted in `localStorage` under key `theme` and always wins afterward.
- Reassurance copy must only state what the backend actually guarantees today: auto-delete after `RETENTION_HOURS` (default 1h, `src/config.js`), max file size `MAX_FILE_SIZE_MB` (default 100MB, `src/config.js`), no account required, 100% online/nothing to install. No RGPD/hosting-location claims (not requested).
- No new automated frontend test harness. Verification per task is `npm run build` (in `frontend/`), `npm run lint` (oxlint, already configured), and manual browser checks via `npm run dev`. This mirrors the approved design spec's explicit decision, not an oversight.
- Out of scope entirely (do not implement): legal pages (mentions légales/confidentialité/CGU), programmatic per-format-pair SEO pages, SSR/prerendering, any backend/`src/` changes. `src/app.js`'s existing catch-all route (`app.get(/^\/(?!api\/).*/, ...)` serving `frontend/dist/index.html`) already supports client-side routes like `/fr/` on direct navigation/hard refresh — confirmed by reading `src/app.js:235-238` — so no backend change is needed for routing to work.
---
## Task 1: Dependencies, design tokens, dark mode, i18n scaffold
**Files:**
- Modify: `frontend/package.json`
- Modify: `frontend/src/index.css`
- Create: `frontend/src/hooks/useTheme.js`
- Create: `frontend/src/components/ThemeToggle.jsx`
- Create: `frontend/src/i18n.js`
- Create: `frontend/src/locales/fr.json`
- Create: `frontend/src/locales/en.json`
**Interfaces:**
- Produces: `useTheme()` hook returning `{ theme: 'light'|'dark', toggleTheme: () => void }`.
- Produces: `ThemeToggle({ theme, onToggle, label })` component (no logic, pure presentation).
- Produces: `frontend/src/i18n.js` default-exports an initialized `i18next` instance; importing it for its side effect (calling `i18n.init(...)`) is required before any component calls `useTranslation()`.
- Produces: translation keys consumed by later tasks — `brand`, `nav.switchToEnglish`, `nav.switchToFrench`, `nav.themeToLight`, `nav.themeToDark`, `hero.title`, `hero.subtitle`, `hero.dropzoneLabel`, `hero.convert`, `hero.unsupportedFormat`, `quality.label`, `quality.compression`, `quality.iconSize`, `quality.compressPdf`, `job.converting`, `job.download`, `reassurance.title`, `reassurance.autoDelete`, `reassurance.noAccount`, `reassurance.online`, `reassurance.maxSize`, `formats.title`, `formats.images`, `formats.documents`, `formats.fonts`, `formats.ebooks`, `footer.rights`, `notFound.title`, `notFound.backHome`.
- [ ] **Step 1: Install new dependencies**
Run (from `frontend/`):
```bash
npm install react-router-dom react-i18next i18next
npm install @phosphor-icons/react
```
Do not pin exact versions manually in `package.json` — let `npm install` resolve the current compatible range and write it, same as every existing entry in the file.
`i18next-browser-languagedetector` is intentionally **not** installed: the spec listed it, but language detection is fully covered by (a) a plain `navigator.language` check in the `/``/fr/`/`/en/` redirect (Task 2) and (b) the `:lang` route param driving `i18n.changeLanguage()` directly — the extra package would have no job to do. Flagging this as a deliberate deviation from the written spec, not an oversight.
- [ ] **Step 2: Replace `frontend/src/index.css` with the new design tokens**
Replace the entire file content with:
```css
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap');
:root {
--color-primary: #2563eb;
--color-on-primary: #ffffff;
--color-secondary: #3b82f6;
--color-accent: #d97706;
--color-background: #f8fafc;
--color-foreground: #0f172a;
--color-muted: #f1f5fd;
--color-border: #e4ecfc;
--color-destructive: #dc2626;
--color-ring: #2563eb;
--font-sans: 'Plus Jakarta Sans', system-ui, 'Segoe UI', Roboto, sans-serif;
color-scheme: light dark;
font: 16px/1.5 var(--font-sans);
}
:root[data-theme='dark'] {
--color-primary: #3b82f6;
--color-on-primary: #0f172a;
--color-secondary: #60a5fa;
--color-accent: #f59e0b;
--color-background: #0f172a;
--color-foreground: #f1f5f9;
--color-muted: #1e293b;
--color-border: #334155;
--color-destructive: #f87171;
--color-ring: #3b82f6;
}
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--color-background);
color: var(--color-foreground);
transition: background-color 200ms ease, color 200ms ease;
}
#root {
min-height: 100svh;
display: flex;
flex-direction: column;
}
a {
color: var(--color-primary);
}
button, input, select {
font-family: inherit;
}
button {
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition: none !important;
animation: none !important;
}
}
```
This replaces the prior Vite-template leftover token set (purple accent, `prefers-color-scheme`-only dark mode) entirely. The new dark mode is driven by the `data-theme` attribute (Step 3), not the media query alone, so a manual toggle can override system preference.
- [ ] **Step 3: Write `frontend/src/hooks/useTheme.js`**
```js
import { useEffect, useState } from 'react';
const STORAGE_KEY = 'theme';
function getInitialTheme() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function useTheme() {
const [theme, setTheme] = useState(getInitialTheme);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
function toggleTheme() {
setTheme((current) => {
const next = current === 'dark' ? 'light' : 'dark';
localStorage.setItem(STORAGE_KEY, next);
return next;
});
}
return { theme, toggleTheme };
}
```
- [ ] **Step 4: Write `frontend/src/components/ThemeToggle.jsx`**
```jsx
import { Moon, Sun } from '@phosphor-icons/react';
export function ThemeToggle({ theme, onToggle, label }) {
const isDark = theme === 'dark';
return (
<button type="button" className="theme-toggle" onClick={onToggle} aria-label={label} aria-pressed={isDark}>
{isDark ? <Sun size={20} weight="regular" /> : <Moon size={20} weight="regular" />}
</button>
);
}
```
`Moon`/`Sun` are confirmed exports of `@phosphor-icons/react` (verified against the package's published type declarations before writing this task).
- [ ] **Step 5: Write `frontend/src/locales/fr.json`**
```json
{
"brand": "Ombrora Convert",
"nav": {
"switchToEnglish": "English",
"switchToFrench": "Français",
"themeToLight": "Activer le mode clair",
"themeToDark": "Activer le mode sombre"
},
"hero": {
"title": "Convertissez vos fichiers en ligne, gratuitement",
"subtitle": "Glissez-déposez vos fichiers, choisissez le format de sortie, téléchargez le résultat. Aucune installation, aucun compte.",
"dropzoneLabel": "Glissez vos fichiers ici ou cliquez pour parcourir",
"convert": "Convertir",
"unsupportedFormat": "Format non supporté"
},
"quality": {
"label": "Qualité ({{value}})",
"compression": "Compression ({{value}})",
"iconSize": "Taille de l'icône",
"compressPdf": "Compresser en JPEG"
},
"job": {
"converting": "Conversion en cours...",
"download": "Télécharger"
},
"reassurance": {
"title": "Pourquoi nous faire confiance",
"autoDelete": "Fichiers supprimés automatiquement après 1h",
"noAccount": "Aucun compte requis",
"online": "100% en ligne — rien à installer",
"maxSize": "Jusqu'à 100 Mo par fichier"
},
"formats": {
"title": "Formats pris en charge",
"images": "Images",
"documents": "Documents",
"fonts": "Polices",
"ebooks": "Ebooks"
},
"footer": {
"rights": "Ombrora Convert — Conversion de fichiers en ligne"
},
"notFound": {
"title": "Page introuvable",
"backHome": "Retour à l'accueil"
}
}
```
- [ ] **Step 6: Write `frontend/src/locales/en.json`**
```json
{
"brand": "Ombrora Convert",
"nav": {
"switchToEnglish": "English",
"switchToFrench": "Français",
"themeToLight": "Switch to light mode",
"themeToDark": "Switch to dark mode"
},
"hero": {
"title": "Convert your files online, for free",
"subtitle": "Drag and drop your files, pick the output format, download the result. No install, no account.",
"dropzoneLabel": "Drag your files here or click to browse",
"convert": "Convert",
"unsupportedFormat": "Unsupported format"
},
"quality": {
"label": "Quality ({{value}})",
"compression": "Compression ({{value}})",
"iconSize": "Icon size",
"compressPdf": "Compress as JPEG"
},
"job": {
"converting": "Converting...",
"download": "Download"
},
"reassurance": {
"title": "Why you can trust us",
"autoDelete": "Files deleted automatically after 1h",
"noAccount": "No account required",
"online": "100% online — nothing to install",
"maxSize": "Up to 100MB per file"
},
"formats": {
"title": "Supported formats",
"images": "Images",
"documents": "Documents",
"fonts": "Fonts",
"ebooks": "Ebooks"
},
"footer": {
"rights": "Ombrora Convert — Online file conversion"
},
"notFound": {
"title": "Page not found",
"backHome": "Back to home"
}
}
```
- [ ] **Step 7: Write `frontend/src/i18n.js`**
```js
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import fr from './locales/fr.json';
import en from './locales/en.json';
i18n.use(initReactI18next).init({
resources: {
fr: { translation: fr },
en: { translation: en },
},
lng: 'fr',
fallbackLng: 'fr',
interpolation: { escapeValue: false },
});
export default i18n;
```
- [ ] **Step 8: Verify the build and lint still pass**
Run (from `frontend/`):
```bash
npm run build
npm run lint
```
Expected: both succeed with no errors. Nothing imports these new files into the app yet (that starts in Task 2), so this only confirms the new files themselves are syntactically valid and importable.
- [ ] **Step 9: Commit**
```bash
git add frontend/package.json frontend/package-lock.json frontend/src/index.css frontend/src/hooks/useTheme.js frontend/src/components/ThemeToggle.jsx frontend/src/i18n.js frontend/src/locales/fr.json frontend/src/locales/en.json
git commit -m "feat(frontend): add design tokens, dark mode hook, and i18n scaffold"
```
---
## Task 2: Routing shell, layout, and the migrated conversion tool
**Files:**
- Modify: `frontend/src/main.jsx`
- Rewrite: `frontend/src/App.jsx`
- Delete: `frontend/src/App.css` (superseded by `frontend/src/styles/layout.css` and `frontend/src/styles/home.css`)
- Create: `frontend/src/components/LanguageSwitcher.jsx`
- Create: `frontend/src/components/Header.jsx`
- Create: `frontend/src/components/Footer.jsx`
- Create: `frontend/src/components/Layout.jsx`
- Create: `frontend/src/components/Dropzone.jsx`
- Create: `frontend/src/components/FileConfigCard.jsx`
- Create: `frontend/src/pages/HomePage.jsx`
- Create: `frontend/src/pages/NotFound.jsx`
- Create: `frontend/src/styles/layout.css`
- Create: `frontend/src/styles/home.css`
- Modify: `frontend/src/FileCard.jsx`
**Interfaces:**
- Consumes (from Task 1): `useTheme()` from `../hooks/useTheme.js`; `ThemeToggle` from `../components/ThemeToggle.jsx`; the initialized `i18n` default export from `../i18n.js`; all translation keys listed in Task 1.
- Consumes (unchanged, pre-existing): `fetchFormats(source)`, `uploadFiles(items)` from `./api.js`; `FileCard({ fileName, jobId, initialError })` from `./FileCard.jsx`.
- Produces: `Layout` — renders `<Header>` + `<main><Outlet/></main>` + `<Footer>`, used as the element for the `/:lang` route.
- Produces: `HomePage` — no props, reads `lang` via `useParams()`, renders the hero/dropzone/tool. Later tasks (3, 4) add sections to this file.
- Produces: `Dropzone({ label, onFilesSelected })``onFilesSelected` is called with a `FileList`-like object (works for both drag-drop's `DataTransfer.files` and a native file input's `.files`).
- Produces: `FileConfigCard({ item, index, t, onTargetFormatChange, onQualityChange, onIconSizeChange })``item` shape: `{ file: File, targets: string[], targetFormat: string|null, quality: number|null, iconSize: number|null }`.
- [ ] **Step 1: Delete the old `frontend/src/App.css`**
```bash
rm frontend/src/App.css
```
- [ ] **Step 2: Write `frontend/src/styles/layout.css`**
```css
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.brand {
font-weight: 700;
font-size: 1.25rem;
text-decoration: none;
color: var(--color-foreground);
}
.site-header-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.language-switcher {
text-decoration: none;
font-weight: 500;
min-height: 44px;
display: inline-flex;
align-items: center;
padding: 0 0.5rem;
}
.theme-toggle {
background: none;
border: 1px solid var(--color-border);
border-radius: 8px;
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-foreground);
}
.site-footer {
margin-top: auto;
padding: 1.5rem;
text-align: center;
color: var(--color-foreground);
border-top: 1px solid var(--color-border);
font-size: 0.875rem;
}
```
- [ ] **Step 3: Write `frontend/src/components/LanguageSwitcher.jsx`**
```jsx
import { Link, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export function LanguageSwitcher() {
const { lang } = useParams();
const { t } = useTranslation();
const otherLang = lang === 'fr' ? 'en' : 'fr';
const label = otherLang === 'en' ? t('nav.switchToEnglish') : t('nav.switchToFrench');
return (
<Link to={`/${otherLang}/`} className="language-switcher">
{label}
</Link>
);
}
```
- [ ] **Step 4: Write `frontend/src/components/Header.jsx`**
```jsx
import { Link, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LanguageSwitcher } from './LanguageSwitcher.jsx';
import { ThemeToggle } from './ThemeToggle.jsx';
export function Header({ theme, onToggleTheme }) {
const { lang } = useParams();
const { t } = useTranslation();
return (
<header className="site-header">
<Link to={`/${lang}/`} className="brand">
{t('brand')}
</Link>
<div className="site-header-controls">
<LanguageSwitcher />
<ThemeToggle
theme={theme}
onToggle={onToggleTheme}
label={theme === 'dark' ? t('nav.themeToLight') : t('nav.themeToDark')}
/>
</div>
</header>
);
}
```
- [ ] **Step 5: Write `frontend/src/components/Footer.jsx`**
```jsx
import { useTranslation } from 'react-i18next';
export function Footer() {
const { t } = useTranslation();
return (
<footer className="site-footer">
<p>{t('footer.rights')}</p>
</footer>
);
}
```
- [ ] **Step 6: Write `frontend/src/components/Layout.jsx`**
```jsx
import { Outlet } from 'react-router-dom';
import { Header } from './Header.jsx';
import { Footer } from './Footer.jsx';
import { useTheme } from '../hooks/useTheme.js';
import '../styles/layout.css';
export function Layout() {
const { theme, toggleTheme } = useTheme();
return (
<>
<Header theme={theme} onToggleTheme={toggleTheme} />
<main>
<Outlet />
</main>
<Footer />
</>
);
}
```
- [ ] **Step 7: Write `frontend/src/pages/NotFound.jsx`**
```jsx
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export function NotFound() {
const { t } = useTranslation();
return (
<div className="not-found">
<h1>{t('notFound.title')}</h1>
<Link to="/fr/">{t('notFound.backHome')}</Link>
</div>
);
}
```
- [ ] **Step 8: Write `frontend/src/components/Dropzone.jsx`**
```jsx
import { useRef, useState } from 'react';
import { UploadSimple } from '@phosphor-icons/react';
export function Dropzone({ label, onFilesSelected }) {
const inputRef = useRef(null);
const [isDragActive, setIsDragActive] = useState(false);
function handleDrop(event) {
event.preventDefault();
setIsDragActive(false);
if (event.dataTransfer.files.length > 0) {
onFilesSelected(event.dataTransfer.files);
}
}
return (
<div
className={`dropzone${isDragActive ? ' dropzone-active' : ''}`}
onDragOver={(event) => {
event.preventDefault();
setIsDragActive(true);
}}
onDragLeave={() => setIsDragActive(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') inputRef.current?.click();
}}
>
<UploadSimple size={32} weight="regular" />
<span>{label}</span>
<input
ref={inputRef}
type="file"
multiple
className="dropzone-input"
onChange={(event) => onFilesSelected(event.target.files)}
/>
</div>
);
}
```
`UploadSimple` is a confirmed export (already used identically in the ui-ux-pro-max icon database entry for this exact use case).
- [ ] **Step 9: Write `frontend/src/components/FileConfigCard.jsx`**
This is the restyled, card-based replacement for the inline `<li>` row in the old `App.jsx` — same fields, same conditional controls per target format, unchanged logic.
```jsx
import { File as FileIcon } from '@phosphor-icons/react';
const QUALITY_FORMATS = ['jpg', 'jpeg', 'webp', 'avif', 'tiff'];
const ICON_SIZES = [16, 32, 48, 256, 512];
const DEFAULT_ICON_SIZE = 256;
export function FileConfigCard({ item, index, t, onTargetFormatChange, onQualityChange, onIconSizeChange }) {
return (
<li className="file-config-card">
<FileIcon size={24} weight="regular" />
<span className="file-config-name">{item.file.name}</span>
{item.targets.length > 0 ? (
<div className="file-config-controls">
<select
value={item.targetFormat ?? ''}
onChange={(event) => onTargetFormatChange(index, event.target.value)}
>
{item.targets.map((target) => (
<option key={target} value={target}>
{target}
</option>
))}
</select>
{QUALITY_FORMATS.includes(item.targetFormat) && (
<label>
{t('quality.label', { value: item.quality })}
<input
type="range"
min="1"
max="100"
value={item.quality}
onChange={(event) => onQualityChange(index, Number(event.target.value))}
/>
</label>
)}
{item.targetFormat === 'png' && (
<label>
{t('quality.compression', { value: item.quality })}
<input
type="range"
min="0"
max="9"
value={item.quality}
onChange={(event) => onQualityChange(index, Number(event.target.value))}
/>
</label>
)}
{item.targetFormat === 'ico' && (
<label>
{t('quality.iconSize')}
<select
value={item.iconSize ?? DEFAULT_ICON_SIZE}
onChange={(event) => onIconSizeChange(index, Number(event.target.value))}
>
{ICON_SIZES.map((size) => (
<option key={size} value={size}>
{size}px
</option>
))}
</select>
</label>
)}
{item.targetFormat === 'pdf' && (
<label>
<input
type="checkbox"
checked={item.quality !== null}
onChange={(event) => onQualityChange(index, event.target.checked ? 90 : null)}
/>
{t('quality.compressPdf')}
{item.quality !== null && (
<input
type="range"
min="1"
max="100"
value={item.quality}
onChange={(event) => onQualityChange(index, Number(event.target.value))}
/>
)}
</label>
)}
</div>
) : (
<span className="error">{t('hero.unsupportedFormat')}</span>
)}
</li>
);
}
```
- [ ] **Step 10: Write `frontend/src/styles/home.css`**
```css
.hero {
max-width: 720px;
margin: 0 auto;
padding: 3rem 1.5rem;
text-align: center;
}
.hero h1 {
font-size: 2.5rem;
margin: 0 0 1rem;
}
.hero p {
color: var(--color-foreground);
opacity: 0.8;
margin: 0 0 2rem;
}
.dropzone {
border: 2px dashed var(--color-border);
border-radius: 12px;
padding: 2.5rem 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
cursor: pointer;
min-height: 44px;
}
.dropzone-active {
border-color: var(--color-primary);
background: var(--color-muted);
}
.dropzone-input {
display: none;
}
.pending-files ul,
.job-list {
list-style: none;
padding: 0;
margin: 1.5rem 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
text-align: left;
}
.file-config-card,
.file-card {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
}
.file-config-name {
font-weight: 500;
flex: 1 1 100%;
}
.file-config-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1 1 100%;
}
.convert-button {
background: var(--color-accent);
color: var(--color-on-primary);
border: none;
border-radius: 8px;
padding: 0.75rem 1.5rem;
font-weight: 600;
min-height: 44px;
}
.error {
color: var(--color-destructive);
}
@media (min-width: 768px) {
.file-config-controls {
flex-direction: row;
align-items: center;
flex: 1 1 auto;
}
}
```
- [ ] **Step 11: Update `frontend/src/FileCard.jsx` to use translated strings and the new class name**
```jsx
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DownloadSimple } from '@phosphor-icons/react';
import { fetchJobStatus, downloadUrl } from './api.js';
export function FileCard({ fileName, jobId, initialError }) {
const { t } = useTranslation();
const [status, setStatus] = useState(initialError ? 'failed' : 'pending');
const [errorMessage, setErrorMessage] = useState(initialError ?? null);
useEffect(() => {
if (!jobId || initialError) return undefined;
let cancelled = false;
const interval = setInterval(async () => {
const job = await fetchJobStatus(jobId);
if (cancelled) return;
setStatus(job.status);
if (job.status === 'failed') setErrorMessage(job.errorMessage);
if (job.status === 'done' || job.status === 'failed') clearInterval(interval);
}, 1500);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [jobId, initialError]);
return (
<li className="file-card">
<span className="file-config-name">{fileName}</span>
{(status === 'pending' || status === 'processing') && <span>{t('job.converting')}</span>}
{status === 'done' && (
<a href={downloadUrl(jobId)}>
<DownloadSimple size={20} weight="regular" /> {t('job.download')}
</a>
)}
{status === 'failed' && <span className="error">{errorMessage}</span>}
</li>
);
}
```
Only the rendered strings/markup/class name changed — the polling/status logic is untouched.
- [ ] **Step 12: Write `frontend/src/pages/HomePage.jsx`**
Migrates the state and handlers from the old `App.jsx` unchanged; only the markup and styling around it are new.
```jsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { fetchFormats, uploadFiles } from '../api.js';
import { FileCard } from '../FileCard.jsx';
import { Dropzone } from '../components/Dropzone.jsx';
import { FileConfigCard } from '../components/FileConfigCard.jsx';
import '../styles/home.css';
const DEFAULT_QUALITY = {
jpg: 80,
jpeg: 80,
webp: 80,
avif: 50,
tiff: 80,
png: 6,
};
const DEFAULT_ICON_SIZE = 256;
function extensionOf(fileName) {
return fileName.split('.').pop().toLowerCase();
}
function defaultQualityFor(targetFormat) {
return DEFAULT_QUALITY[targetFormat] ?? null;
}
export function HomePage() {
const { t } = useTranslation();
const [pendingFiles, setPendingFiles] = useState([]);
const [submittedJobs, setSubmittedJobs] = useState([]);
async function handleFilesSelected(fileList) {
const files = Array.from(fileList);
const withTargets = await Promise.all(
files.map(async (file) => {
const targets = await fetchFormats(extensionOf(file.name));
const targetFormat = targets[0] ?? null;
return {
file,
targets,
targetFormat,
quality: defaultQualityFor(targetFormat),
iconSize: targetFormat === 'ico' ? DEFAULT_ICON_SIZE : null,
};
})
);
setPendingFiles(withTargets);
}
function updateTargetFormat(index, targetFormat) {
setPendingFiles((current) =>
current.map((item, i) =>
i === index
? {
...item,
targetFormat,
quality: defaultQualityFor(targetFormat),
iconSize: targetFormat === 'ico' ? DEFAULT_ICON_SIZE : null,
}
: item
)
);
}
function updateQuality(index, quality) {
setPendingFiles((current) => current.map((item, i) => (i === index ? { ...item, quality } : item)));
}
function updateIconSize(index, iconSize) {
setPendingFiles((current) => current.map((item, i) => (i === index ? { ...item, iconSize } : item)));
}
async function handleConvert() {
const validItems = pendingFiles.filter((item) => item.targetFormat);
const jobs = await uploadFiles(validItems);
setSubmittedJobs((current) => [...current, ...jobs]);
setPendingFiles([]);
}
return (
<section className="hero">
<h1>{t('hero.title')}</h1>
<p>{t('hero.subtitle')}</p>
<Dropzone label={t('hero.dropzoneLabel')} onFilesSelected={handleFilesSelected} />
{pendingFiles.length > 0 && (
<div className="pending-files">
<ul>
{pendingFiles.map((item, index) => (
<FileConfigCard
key={`${item.file.name}-${index}`}
item={item}
index={index}
t={t}
onTargetFormatChange={updateTargetFormat}
onQualityChange={updateQuality}
onIconSizeChange={updateIconSize}
/>
))}
</ul>
<button className="convert-button" onClick={handleConvert}>
{t('hero.convert')}
</button>
</div>
)}
<ul className="job-list">
{submittedJobs.map((job, index) =>
job.id ? (
<FileCard key={job.id} fileName={job.file} jobId={job.id} />
) : (
<FileCard key={`${job.file}-${index}`} fileName={job.file} initialError={job.error} />
)
)}
</ul>
</section>
);
}
```
- [ ] **Step 13: Rewrite `frontend/src/App.jsx` as the router shell**
```jsx
import { useEffect } from 'react';
import { Routes, Route, Navigate, useParams } from 'react-router-dom';
import i18n from './i18n.js';
import { Layout } from './components/Layout.jsx';
import { HomePage } from './pages/HomePage.jsx';
import { NotFound } from './pages/NotFound.jsx';
const SUPPORTED_LANGS = ['fr', 'en'];
function RootRedirect() {
const preferred = navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr';
return <Navigate to={`/${preferred}/`} replace />;
}
function LangShell() {
const { lang } = useParams();
useEffect(() => {
if (SUPPORTED_LANGS.includes(lang) && i18n.language !== lang) {
i18n.changeLanguage(lang);
}
}, [lang]);
if (!SUPPORTED_LANGS.includes(lang)) return <NotFound />;
return <Layout />;
}
export default function App() {
return (
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/:lang" element={<LangShell />}>
<Route index element={<HomePage />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
);
}
```
`path="/:lang"` matches both `/fr` and `/fr/` (React Router v6 ignores trailing slashes in path matching by default — confirmed against the v6 docs before writing this task), so the `Navigate to="/fr/"` target and this route definition agree.
- [ ] **Step 14: Wrap the app in `BrowserRouter` in `frontend/src/main.jsx`**
```jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import './i18n.js';
import './index.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
```
- [ ] **Step 15: Manual verification**
Start the backend (existing `src/server.js` + `src/worker.js`, per this project's own local dev instructions — check first whether they're already running before starting new ones) and the frontend dev server:
```bash
npm run dev
```
(from `frontend/`, proxies `/api` to `http://localhost:3000` per the existing `vite.config.js`).
In a browser, verify:
- Navigating to `/` redirects to `/fr/` (or `/en/` if the browser's language is English).
- `/fr/` and `/en/` both render the hero, dropzone, header, and footer with correctly translated text.
- The language switcher in the header navigates between `/fr/` and `/en/` and the displayed language changes.
- The dark-mode toggle switches themes and persists across a page reload (check `localStorage.theme`).
- Selecting a file via the dropzone (click or drag) shows a `FileConfigCard` with format/quality controls; clicking "Convertir"/"Convert" submits the job and a `FileCard` appears and eventually shows a working download link (requires the backend running).
- An invalid language path (e.g. `/de/`) renders the "page not found" content.
- [ ] **Step 16: Run build and lint**
```bash
npm run build
npm run lint
```
Expected: both succeed.
- [ ] **Step 17: Commit**
```bash
git add frontend/src frontend/src/App.css
git commit -m "feat(frontend): add routing, layout, i18n wiring, and restyle the conversion tool"
```
---
## Task 3: Reassurance strip and supported-formats section
**Files:**
- Create: `frontend/src/data/formats.js`
- Create: `frontend/src/components/ReassuranceStrip.jsx`
- Create: `frontend/src/components/FormatsGrid.jsx`
- Create: `frontend/src/styles/sections.css`
- Modify: `frontend/src/pages/HomePage.jsx`
**Interfaces:**
- Consumes (from Task 1): `reassurance.*` and `formats.*` translation keys.
- Produces: `FORMAT_FAMILIES` array exported from `frontend/src/data/formats.js`, shape `{ key: string, formats: string[] }[]`, consumed by `FormatsGrid`.
- Produces: `ReassuranceStrip` and `FormatsGrid` — no props, self-contained sections appended after the hero in `HomePage`.
- [ ] **Step 1: Write `frontend/src/data/formats.js`**
Hand-maintained list matching the backend's registered conversion families (`src/converters/image.js`, `heic.js`, `ico.js`, `imageToPdf.js`, `document.js`, `font.js`, `dfont.js`, `ebook.js` — verified by reading each file's format constants before writing this list). Deliberately not fetched live from `GET /api/formats`, since that endpoint answers "targets for one given source format," not "everything supported" — see the design spec's discussion of this trade-off.
```js
export const FORMAT_FAMILIES = [
{
key: 'images',
formats: ['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif', 'heic', 'heif', 'ico'],
},
{
key: 'documents',
formats: ['docx', 'txt', 'html', 'md', 'pdf', 'csv', 'xlsx'],
},
{
key: 'fonts',
formats: ['ttf', 'otf', 'woff', 'dfont'],
},
{
key: 'ebooks',
formats: ['epub', 'fb2', 'lrf', 'mobi', 'pdb', 'rb', 'snb', 'tcr', 'azw3', 'pdf'],
},
];
```
- [ ] **Step 2: Write `frontend/src/components/ReassuranceStrip.jsx`**
```jsx
import { useTranslation } from 'react-i18next';
import { Clock, LockKey, CloudArrowUp, HardDrives } from '@phosphor-icons/react';
const ITEMS = [
{ key: 'autoDelete', Icon: Clock },
{ key: 'noAccount', Icon: LockKey },
{ key: 'online', Icon: CloudArrowUp },
{ key: 'maxSize', Icon: HardDrives },
];
export function ReassuranceStrip() {
const { t } = useTranslation();
return (
<section className="reassurance">
<h2>{t('reassurance.title')}</h2>
<ul className="reassurance-list">
{ITEMS.map(({ key, Icon }) => (
<li key={key}>
<Icon size={24} weight="regular" />
<span>{t(`reassurance.${key}`)}</span>
</li>
))}
</ul>
</section>
);
}
```
`Clock`, `LockKey`, `CloudArrowUp`, `HardDrives` are all confirmed exports of `@phosphor-icons/react` (verified against the package's published type declarations before writing this task).
- [ ] **Step 3: Write `frontend/src/components/FormatsGrid.jsx`**
```jsx
import { useTranslation } from 'react-i18next';
import { FORMAT_FAMILIES } from '../data/formats.js';
export function FormatsGrid() {
const { t } = useTranslation();
return (
<section className="formats-grid-section">
<h2>{t('formats.title')}</h2>
<div className="formats-grid">
{FORMAT_FAMILIES.map(({ key, formats }) => (
<div key={key} className="formats-family">
<h3>{t(`formats.${key}`)}</h3>
<p>{formats.join(', ').toUpperCase()}</p>
</div>
))}
</div>
</section>
);
}
```
- [ ] **Step 4: Write `frontend/src/styles/sections.css`**
```css
.reassurance,
.formats-grid-section {
max-width: 960px;
margin: 0 auto;
padding: 2.5rem 1.5rem;
text-align: center;
}
.reassurance-list {
list-style: none;
padding: 0;
margin: 1.5rem 0 0;
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.reassurance-list li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
text-align: left;
}
.formats-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
margin-top: 1.5rem;
}
.formats-family {
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: 8px;
text-align: left;
}
.formats-family h3 {
margin: 0 0 0.5rem;
}
.formats-family p {
margin: 0;
opacity: 0.8;
font-size: 0.875rem;
}
@media (min-width: 768px) {
.reassurance-list {
grid-template-columns: repeat(2, 1fr);
}
.formats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.formats-grid {
grid-template-columns: repeat(4, 1fr);
}
}
```
- [ ] **Step 5: Add both sections to `frontend/src/pages/HomePage.jsx`**
Add the imports:
```jsx
import { ReassuranceStrip } from '../components/ReassuranceStrip.jsx';
import { FormatsGrid } from '../components/FormatsGrid.jsx';
import '../styles/sections.css';
```
Change the component's return statement from a single `<section className="hero">...</section>` to a fragment wrapping the existing hero section plus the two new sections:
```jsx
return (
<>
<section className="hero">
<h1>{t('hero.title')}</h1>
<p>{t('hero.subtitle')}</p>
<Dropzone label={t('hero.dropzoneLabel')} onFilesSelected={handleFilesSelected} />
{pendingFiles.length > 0 && (
<div className="pending-files">
<ul>
{pendingFiles.map((item, index) => (
<FileConfigCard
key={`${item.file.name}-${index}`}
item={item}
index={index}
t={t}
onTargetFormatChange={updateTargetFormat}
onQualityChange={updateQuality}
onIconSizeChange={updateIconSize}
/>
))}
</ul>
<button className="convert-button" onClick={handleConvert}>
{t('hero.convert')}
</button>
</div>
)}
<ul className="job-list">
{submittedJobs.map((job, index) =>
job.id ? (
<FileCard key={job.id} fileName={job.file} jobId={job.id} />
) : (
<FileCard key={`${job.file}-${index}`} fileName={job.file} initialError={job.error} />
)
)}
</ul>
</section>
<ReassuranceStrip />
<FormatsGrid />
</>
);
```
- [ ] **Step 6: Manual verification**
```bash
npm run dev
```
Verify on `/fr/` and `/en/`: the reassurance strip renders 4 items with icons and correct translated copy; the formats grid renders 4 families (Images/Documents/Polices/Ebooks or Images/Documents/Fonts/Ebooks) each listing the exact formats from `formats.js`. Resize the browser to confirm the reassurance list and formats grid go from 1 column (narrow) to 2 columns (tablet width) to 4 columns for the formats grid (desktop width).
- [ ] **Step 7: Run build and lint**
```bash
npm run build
npm run lint
```
- [ ] **Step 8: Commit**
```bash
git add frontend/src/data frontend/src/components/ReassuranceStrip.jsx frontend/src/components/FormatsGrid.jsx frontend/src/styles/sections.css frontend/src/pages/HomePage.jsx
git commit -m "feat(frontend): add reassurance strip and supported-formats section"
```
---
## Task 4: SEO — meta tags, hreflang, JSON-LD, robots.txt, sitemap.xml
**Files:**
- Create: `frontend/src/components/SeoHead.jsx`
- Modify: `frontend/src/pages/HomePage.jsx`
- Modify: `frontend/index.html`
- Create: `frontend/public/robots.txt`
- Create: `frontend/public/sitemap.xml`
**Interfaces:**
- Produces: `SeoHead({ lang })` — side-effect-only component (renders `null`), updates `document.title`, `<html lang>`, meta description, OpenGraph tags, hreflang `<link>` tags, and a JSON-LD `<script>` tag whenever `lang` changes.
- [ ] **Step 1: Write `frontend/src/components/SeoHead.jsx`**
```jsx
import { useEffect } from 'react';
const SITE_URL = 'https://convert.ombrora.com';
const META = {
fr: {
title: 'Ombrora Convert — Conversion de fichiers en ligne',
description:
"Convertissez vos fichiers en ligne gratuitement : images, documents, polices et ebooks. Aucun compte requis, fichiers supprimés automatiquement.",
},
en: {
title: 'Ombrora Convert — Online File Conversion',
description:
'Convert your files online for free: images, documents, fonts, and ebooks. No account required, files deleted automatically.',
},
};
function setNameMeta(name, content) {
let el = document.head.querySelector(`meta[name="${name}"]`);
if (!el) {
el = document.createElement('meta');
el.setAttribute('name', name);
document.head.appendChild(el);
}
el.setAttribute('content', content);
}
function setPropertyMeta(property, content) {
let el = document.head.querySelector(`meta[property="${property}"]`);
if (!el) {
el = document.createElement('meta');
el.setAttribute('property', property);
document.head.appendChild(el);
}
el.setAttribute('content', content);
}
function setAlternateLink(hreflang, href) {
const selector = `link[rel="alternate"][hreflang="${hreflang}"]`;
let el = document.head.querySelector(selector);
if (!el) {
el = document.createElement('link');
el.setAttribute('rel', 'alternate');
el.setAttribute('hreflang', hreflang);
document.head.appendChild(el);
}
el.setAttribute('href', href);
}
function setJsonLd(lang) {
const id = 'seo-jsonld';
let script = document.getElementById(id);
if (!script) {
script = document.createElement('script');
script.id = id;
script.type = 'application/ld+json';
document.head.appendChild(script);
}
script.textContent = JSON.stringify({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'Ombrora Convert',
applicationCategory: 'UtilitiesApplication',
operatingSystem: 'Any',
offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
inLanguage: lang,
url: `${SITE_URL}/${lang}/`,
});
}
export function SeoHead({ lang }) {
useEffect(() => {
const meta = META[lang] ?? META.fr;
document.title = meta.title;
document.documentElement.lang = lang;
setNameMeta('description', meta.description);
setPropertyMeta('og:title', meta.title);
setPropertyMeta('og:description', meta.description);
setPropertyMeta('og:type', 'website');
setPropertyMeta('og:locale', lang === 'fr' ? 'fr_FR' : 'en_US');
setAlternateLink('fr', `${SITE_URL}/fr/`);
setAlternateLink('en', `${SITE_URL}/en/`);
setAlternateLink('x-default', `${SITE_URL}/fr/`);
setJsonLd(lang);
}, [lang]);
return null;
}
```
- [ ] **Step 2: Render it from `HomePage`**
Add the import to `frontend/src/pages/HomePage.jsx`:
```jsx
import { useParams } from 'react-router-dom';
import { SeoHead } from '../components/SeoHead.jsx';
```
Add `const { lang } = useParams();` at the top of the `HomePage` function body, and render `<SeoHead lang={lang} />` as the first child inside the returned fragment:
```jsx
return (
<>
<SeoHead lang={lang} />
<section className="hero">
...
```
- [ ] **Step 3: Update `frontend/index.html`'s base tags**
Replace the `<head>` section's title and add base meta tags (keep the existing favicon link and PostHog script untouched):
```html
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ombrora Convert</title>
<meta name="description" content="Convertissez vos fichiers en ligne gratuitement : images, documents, polices et ebooks." />
<meta property="og:site_name" content="Ombrora Convert" />
<meta name="twitter:card" content="summary" />
```
(followed by the existing `</head><body>...` content, unchanged). `SeoHead` overwrites the title/description/og tags per-language at runtime once React mounts; these static ones are just the pre-hydration fallback a crawler or link-preview bot sees before JS runs.
- [ ] **Step 4: Write `frontend/public/robots.txt`**
```
User-agent: *
Allow: /
Sitemap: https://convert.ombrora.com/sitemap.xml
```
- [ ] **Step 5: Write `frontend/public/sitemap.xml`**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://convert.ombrora.com/fr/</loc>
</url>
<url>
<loc>https://convert.ombrora.com/en/</loc>
</url>
</urlset>
```
- [ ] **Step 6: Manual verification**
```bash
npm run dev
```
In the browser devtools, inspect `<head>` on `/fr/` then `/en/`: confirm `document.title`, the `description`/`og:*` meta tags, and the three `hreflang` `<link>` tags update correctly, and that a `<script type="application/ld+json" id="seo-jsonld">` exists with the right `inLanguage`/`url`.
```bash
npm run build
```
Then serve the build (`npm run preview`, or via the backend's static serving) and confirm `GET /robots.txt` and `GET /sitemap.xml` return the files verbatim (not the SPA's `index.html` fallback).
- [ ] **Step 7: Run lint**
```bash
npm run lint
```
- [ ] **Step 8: Commit**
```bash
git add frontend/src/components/SeoHead.jsx frontend/src/pages/HomePage.jsx frontend/index.html frontend/public/robots.txt frontend/public/sitemap.xml
git commit -m "feat(frontend): add per-language SEO metadata, JSON-LD, robots.txt, and sitemap"
```
---
## Task 5: Responsive polish and end-to-end production build verification
**Files:**
- Modify: `frontend/src/styles/layout.css`
- Modify: `frontend/src/styles/home.css`
**Interfaces:** None new — this task only adjusts CSS in files created in Tasks 23.
- [ ] **Step 1: Add a narrow-viewport rule for the header to `frontend/src/styles/layout.css`**
Append:
```css
@media (max-width: 480px) {
.site-header {
padding: 0.75rem 1rem;
}
.brand {
font-size: 1.05rem;
}
.site-header-controls {
gap: 0.5rem;
}
}
```
- [ ] **Step 2: Tighten hero/dropzone spacing on narrow viewports in `frontend/src/styles/home.css`**
Append:
```css
@media (max-width: 480px) {
.hero {
padding: 2rem 1rem;
}
.hero h1 {
font-size: 1.75rem;
}
.dropzone {
padding: 1.75rem 1rem;
}
}
```
- [ ] **Step 3: Manual responsive verification**
```bash
npm run dev
```
Using browser devtools' responsive mode, check the full page at 375px, 768px, 1024px, and 1440px widths:
- Header stays a single row with no overlap or wrapping at all four widths.
- Dropzone and file-config cards are fully readable and usable (no horizontal scroll) at 375px.
- Reassurance list and formats grid reflow from 1 → 2 → 4 columns as documented in Task 3.
- No horizontal scrollbar appears at any of the four widths.
- [ ] **Step 4: Full production build and static-serve check**
```bash
npm run build
```
(from `frontend/` — or `npm run build` from the repo root, which per the root `package.json`'s existing `build` script runs `npm install --prefix frontend --include=dev && npm run build --prefix frontend`).
Start the backend (`node src/server.js`, only if not already running per this project's own guidance on checking for a pre-existing dev server first) and confirm in a browser:
- `http://localhost:3000/` redirects to `/fr/` or `/en/`.
- Directly navigating to `http://localhost:3000/fr/` (typed URL, not a client-side navigation) renders correctly — this confirms `src/app.js`'s existing catch-all static-file fallback correctly serves the SPA's `index.html` for the client-side route.
- `http://localhost:3000/robots.txt` and `http://localhost:3000/sitemap.xml` return the static files from Task 4, not the SPA shell.
- [ ] **Step 5: Run lint one final time**
```bash
npm run lint
```
- [ ] **Step 6: Commit**
```bash
git add frontend/src/styles/layout.css frontend/src/styles/home.css
git commit -m "style(frontend): responsive polish pass for narrow viewports"
```
---
## Plan self-review notes
- **Spec coverage:** merged home+tool page (Task 2), i18n URL-prefix routing + root redirect (Task 2), dark mode with light default + persisted toggle (Task 1), reassurance strip with only verified backend facts (Task 3), supported-formats grid (Task 3), responsive behavior (Tasks 2, 3, 5), SEO meta/hreflang/JSON-LD/robots/sitemap (Task 4), brand name "Ombrora Convert" applied throughout (Tasks 14), legal pages/programmatic SEO/SSR explicitly excluded (Global Constraints). All spec sections are covered.
- **Deviation flagged:** `i18next-browser-languagedetector` from the spec's dependency list is dropped as redundant (Task 1, Step 1) — a plain `navigator.language` check and route-param-driven `i18n.changeLanguage()` cover the same need without an unused package.
- **Icon names verified, not guessed:** `Moon`, `Sun`, `CloudArrowUp`, `HardDrives`, `LockKey` were confirmed against `@phosphor-icons/react`'s published type declarations before being written into Tasks 1 and 3; `UploadSimple`, `File`, `DownloadSimple` were confirmed via the project's own icon-recommendation database.
- **Routing behavior verified, not guessed:** React Router v6's trailing-slash-insensitive matching (needed for `path="/:lang"` to match both `/fr` and `/fr/`) was confirmed against the official v6 docs before relying on it in Task 2.