5-task plan: design tokens/dark mode/i18n scaffold, routing+layout+ migrated tool, reassurance+formats sections, SEO metadata, responsive polish.
49 KiB
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/reactall qualify. - Brand name is "Ombrora Convert" everywhere (title, header, footer, meta tags, JSON-LD) — not "file-converter" or "Convertisseur de fichiers".
- Only two locales:
franden, addressed via URL prefix (/fr/,/en/). No other language codes are valid routes. - Dark mode default is light, not dark —
prefers-color-scheme: darkis honored only as the initial value before any manual toggle; an explicit toggle is persisted inlocalStorageunder keythemeand 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 sizeMAX_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(infrontend/),npm run lint(oxlint, already configured), and manual browser checks vianpm 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\/).*/, ...)servingfrontend/dist/index.html) already supports client-side routes like/fr/on direct navigation/hard refresh — confirmed by readingsrc/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.jsdefault-exports an initializedi18nextinstance; importing it for its side effect (callingi18n.init(...)) is required before any component callsuseTranslation(). -
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/):
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.csswith the new design tokens
Replace the entire file content with:
@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
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
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
{
"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
{
"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
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/):
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
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 byfrontend/src/styles/layout.cssandfrontend/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;ThemeTogglefrom../components/ThemeToggle.jsx; the initializedi18ndefault 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/:langroute. -
Produces:
HomePage— no props, readslangviauseParams(), renders the hero/dropzone/tool. Later tasks (3, 4) add sections to this file. -
Produces:
Dropzone({ label, onFilesSelected })—onFilesSelectedis called with aFileList-like object (works for both drag-drop'sDataTransfer.filesand a native file input's.files). -
Produces:
FileConfigCard({ item, index, t, onTargetFormatChange, onQualityChange, onIconSizeChange })—itemshape:{ file: File, targets: string[], targetFormat: string|null, quality: number|null, iconSize: number|null }. -
Step 1: Delete the old
frontend/src/App.css
rm frontend/src/App.css
- Step 2: Write
frontend/src/styles/layout.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
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
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
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
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
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
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.
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
.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.jsxto use translated strings and the new class name
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.
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.jsxas the router shell
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
BrowserRouterinfrontend/src/main.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:
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
FileConfigCardwith format/quality controls; clicking "Convertir"/"Convert" submits the job and aFileCardappears 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
npm run build
npm run lint
Expected: both succeed.
- Step 17: Commit
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.*andformats.*translation keys. -
Produces:
FORMAT_FAMILIESarray exported fromfrontend/src/data/formats.js, shape{ key: string, formats: string[] }[], consumed byFormatsGrid. -
Produces:
ReassuranceStripandFormatsGrid— no props, self-contained sections appended after the hero inHomePage. -
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.
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
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
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
.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:
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:
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
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
npm run build
npm run lint
- Step 8: Commit
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 (rendersnull), updatesdocument.title,<html lang>, meta description, OpenGraph tags, hreflang<link>tags, and a JSON-LD<script>tag wheneverlangchanges. -
Step 1: Write
frontend/src/components/SeoHead.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:
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:
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):
<!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 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
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.
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
npm run lint
- Step 8: Commit
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 2–3.
- Step 1: Add a narrow-viewport rule for the header to
frontend/src/styles/layout.css
Append:
@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:
@media (max-width: 480px) {
.hero {
padding: 2rem 1rem;
}
.hero h1 {
font-size: 1.75rem;
}
.dropzone {
padding: 1.75rem 1rem;
}
}
- Step 3: Manual responsive verification
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
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 confirmssrc/app.js's existing catch-all static-file fallback correctly serves the SPA'sindex.htmlfor the client-side route. -
http://localhost:3000/robots.txtandhttp://localhost:3000/sitemap.xmlreturn the static files from Task 4, not the SPA shell. -
Step 5: Run lint one final time
npm run lint
- Step 6: Commit
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 1–4), legal pages/programmatic SEO/SSR explicitly excluded (Global Constraints). All spec sections are covered.
- Deviation flagged:
i18next-browser-languagedetectorfrom the spec's dependency list is dropped as redundant (Task 1, Step 1) — a plainnavigator.languagecheck and route-param-driveni18n.changeLanguage()cover the same need without an unused package. - Icon names verified, not guessed:
Moon,Sun,CloudArrowUp,HardDrives,LockKeywere confirmed against@phosphor-icons/react's published type declarations before being written into Tasks 1 and 3;UploadSimple,File,DownloadSimplewere 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/frand/fr/) was confirmed against the official v6 docs before relying on it in Task 2.