feat(frontend): add routing, layout, i18n wiring, and restyle the conversion tool

Migrates the existing upload/format-select/quality/convert logic from
the old single-file App.jsx into a router shell (/ -> /fr//en/) with a
shared Layout (header, language switcher, dark-mode toggle, footer).
Tool logic itself is unchanged, only restyled into cards.

Part of the Ombrora Convert redesign (Task 2/5).
This commit is contained in:
2026-07-31 14:55:16 +02:00
parent ee18cc2d90
commit bc7a3b0691
14 changed files with 528 additions and 208 deletions
-21
View File
@@ -1,21 +0,0 @@
main {
max-width: 640px;
margin: 2rem auto;
font-family: system-ui, sans-serif;
}
ul {
list-style: none;
padding: 0;
}
li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
}
.error {
color: #b00020;
}
+28 -177
View File
@@ -1,187 +1,38 @@
import { useState } from 'react'; import { useEffect } from 'react';
import { fetchFormats, uploadFiles } from './api.js'; import { Routes, Route, Navigate, useParams } from 'react-router-dom';
import { FileCard } from './FileCard.jsx'; import i18n from './i18n.js';
import './App.css'; import { Layout } from './components/Layout.jsx';
import { HomePage } from './pages/HomePage.jsx';
import { NotFound } from './pages/NotFound.jsx';
const DEFAULT_QUALITY = { const SUPPORTED_LANGS = ['fr', 'en'];
jpg: 80,
jpeg: 80,
webp: 80,
avif: 50,
tiff: 80,
png: 6,
};
const QUALITY_FORMATS = ['jpg', 'jpeg', 'webp', 'avif', 'tiff']; function RootRedirect() {
const ICON_SIZES = [16, 32, 48, 256, 512]; const preferred = navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr';
const DEFAULT_ICON_SIZE = 256; return <Navigate to={`/${preferred}/`} replace />;
function extensionOf(fileName) {
return fileName.split('.').pop().toLowerCase();
} }
function defaultQualityFor(targetFormat) { function LangShell() {
return DEFAULT_QUALITY[targetFormat] ?? null; 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() { export default function App() {
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 ( return (
<main> <Routes>
<h1>Convertisseur de fichiers</h1> <Route path="/" element={<RootRedirect />} />
<Route path="/:lang" element={<LangShell />}>
<input type="file" multiple onChange={(event) => handleFilesSelected(event.target.files)} /> <Route index element={<HomePage />} />
</Route>
{pendingFiles.length > 0 && ( <Route path="*" element={<NotFound />} />
<div> </Routes>
<ul>
{pendingFiles.map((item, index) => (
<li key={`${item.file.name}-${index}`}>
{item.file.name}
{item.targets.length > 0 ? (
<>
<select
value={item.targetFormat ?? ''}
onChange={(event) => updateTargetFormat(index, event.target.value)}
>
{item.targets.map((target) => (
<option key={target} value={target}>
{target}
</option>
))}
</select>
{QUALITY_FORMATS.includes(item.targetFormat) && (
<label>
Qualité ({item.quality})
<input
type="range"
min="1"
max="100"
value={item.quality}
onChange={(event) => updateQuality(index, Number(event.target.value))}
/>
</label>
)}
{item.targetFormat === 'png' && (
<label>
Compression ({item.quality})
<input
type="range"
min="0"
max="9"
value={item.quality}
onChange={(event) => updateQuality(index, Number(event.target.value))}
/>
</label>
)}
{item.targetFormat === 'ico' && (
<label>
Taille de l'icône
<select
value={item.iconSize ?? DEFAULT_ICON_SIZE}
onChange={(event) => updateIconSize(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) => updateQuality(index, event.target.checked ? 90 : null)}
/>
Compresser en JPEG
{item.quality !== null && (
<input
type="range"
min="1"
max="100"
value={item.quality}
onChange={(event) => updateQuality(index, Number(event.target.value))}
/>
)}
</label>
)}
</>
) : (
<span className="error">Format non supporté</span>
)}
</li>
))}
</ul>
<button onClick={handleConvert}>Convertir</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>
</main>
); );
} }
+10 -3
View File
@@ -1,7 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DownloadSimple } from '@phosphor-icons/react';
import { fetchJobStatus, downloadUrl } from './api.js'; import { fetchJobStatus, downloadUrl } from './api.js';
export function FileCard({ fileName, jobId, initialError }) { export function FileCard({ fileName, jobId, initialError }) {
const { t } = useTranslation();
const [status, setStatus] = useState(initialError ? 'failed' : 'pending'); const [status, setStatus] = useState(initialError ? 'failed' : 'pending');
const [errorMessage, setErrorMessage] = useState(initialError ?? null); const [errorMessage, setErrorMessage] = useState(initialError ?? null);
@@ -25,9 +28,13 @@ export function FileCard({ fileName, jobId, initialError }) {
return ( return (
<li className="file-card"> <li className="file-card">
<span className="file-name">{fileName}</span> <span className="file-config-name">{fileName}</span>
{(status === 'pending' || status === 'processing') && <span>Conversion en cours...</span>} {(status === 'pending' || status === 'processing') && <span>{t('job.converting')}</span>}
{status === 'done' && <a href={downloadUrl(jobId)}>Télécharger</a>} {status === 'done' && (
<a href={downloadUrl(jobId)}>
<DownloadSimple size={20} weight="regular" /> {t('job.download')}
</a>
)}
{status === 'failed' && <span className="error">{errorMessage}</span>} {status === 'failed' && <span className="error">{errorMessage}</span>}
</li> </li>
); );
+43
View File
@@ -0,0 +1,43 @@
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>
);
}
@@ -0,0 +1,93 @@
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>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { useTranslation } from 'react-i18next';
export function Footer() {
const { t } = useTranslation();
return (
<footer className="site-footer">
<p>{t('footer.rights')}</p>
</footer>
);
}
+25
View File
@@ -0,0 +1,25 @@
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>
);
}
@@ -0,0 +1,15 @@
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>
);
}
+18
View File
@@ -0,0 +1,18 @@
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 />
</>
);
}
+10 -6
View File
@@ -1,10 +1,14 @@
import { StrictMode } from 'react' import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client';
import './index.css' import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx' import './i18n.js';
import './index.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<BrowserRouter>
<App /> <App />
</StrictMode>, </BrowserRouter>
) </StrictMode>
);
+119
View File
@@ -0,0 +1,119 @@
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>
);
}
+12
View File
@@ -0,0 +1,12 @@
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>
);
}
+94
View File
@@ -0,0 +1,94 @@
.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;
}
}
+50
View File
@@ -0,0 +1,50 @@
.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;
}