From bc7a3b0691237460d3f377bafce23fc02327484a Mon Sep 17 00:00:00 2001
From: Anthony GAEREMYNCK <1@anthony.sh>
Date: Fri, 31 Jul 2026 14:55:16 +0200
Subject: [PATCH] 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).
---
frontend/src/App.css | 21 --
frontend/src/App.jsx | 205 +++----------------
frontend/src/FileCard.jsx | 13 +-
frontend/src/components/Dropzone.jsx | 43 ++++
frontend/src/components/FileConfigCard.jsx | 93 +++++++++
frontend/src/components/Footer.jsx | 10 +
frontend/src/components/Header.jsx | 25 +++
frontend/src/components/LanguageSwitcher.jsx | 15 ++
frontend/src/components/Layout.jsx | 18 ++
frontend/src/main.jsx | 18 +-
frontend/src/pages/HomePage.jsx | 119 +++++++++++
frontend/src/pages/NotFound.jsx | 12 ++
frontend/src/styles/home.css | 94 +++++++++
frontend/src/styles/layout.css | 50 +++++
14 files changed, 528 insertions(+), 208 deletions(-)
delete mode 100644 frontend/src/App.css
create mode 100644 frontend/src/components/Dropzone.jsx
create mode 100644 frontend/src/components/FileConfigCard.jsx
create mode 100644 frontend/src/components/Footer.jsx
create mode 100644 frontend/src/components/Header.jsx
create mode 100644 frontend/src/components/LanguageSwitcher.jsx
create mode 100644 frontend/src/components/Layout.jsx
create mode 100644 frontend/src/pages/HomePage.jsx
create mode 100644 frontend/src/pages/NotFound.jsx
create mode 100644 frontend/src/styles/home.css
create mode 100644 frontend/src/styles/layout.css
diff --git a/frontend/src/App.css b/frontend/src/App.css
deleted file mode 100644
index e3e3871..0000000
--- a/frontend/src/App.css
+++ /dev/null
@@ -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;
-}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 427ad60..1fa1a95 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,187 +1,38 @@
-import { useState } from 'react';
-import { fetchFormats, uploadFiles } from './api.js';
-import { FileCard } from './FileCard.jsx';
-import './App.css';
+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 DEFAULT_QUALITY = {
- jpg: 80,
- jpeg: 80,
- webp: 80,
- avif: 50,
- tiff: 80,
- png: 6,
-};
+const SUPPORTED_LANGS = ['fr', 'en'];
-const QUALITY_FORMATS = ['jpg', 'jpeg', 'webp', 'avif', 'tiff'];
-const ICON_SIZES = [16, 32, 48, 256, 512];
-const DEFAULT_ICON_SIZE = 256;
-
-function extensionOf(fileName) {
- return fileName.split('.').pop().toLowerCase();
+function RootRedirect() {
+ const preferred = navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr';
+ return ;
}
-function defaultQualityFor(targetFormat) {
- return DEFAULT_QUALITY[targetFormat] ?? null;
+function LangShell() {
+ const { lang } = useParams();
+
+ useEffect(() => {
+ if (SUPPORTED_LANGS.includes(lang) && i18n.language !== lang) {
+ i18n.changeLanguage(lang);
+ }
+ }, [lang]);
+
+ if (!SUPPORTED_LANGS.includes(lang)) return ;
+ return ;
}
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 (
-
- Convertisseur de fichiers
-
- handleFilesSelected(event.target.files)} />
-
- {pendingFiles.length > 0 && (
-
- )}
-
-
- {submittedJobs.map((job, index) =>
- job.id ? (
-
- ) : (
-
- )
- )}
-
-
+
+ } />
+ }>
+ } />
+
+ } />
+
);
}
diff --git a/frontend/src/FileCard.jsx b/frontend/src/FileCard.jsx
index 9aea9b4..0d4ff54 100644
--- a/frontend/src/FileCard.jsx
+++ b/frontend/src/FileCard.jsx
@@ -1,7 +1,10 @@
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);
@@ -25,9 +28,13 @@ export function FileCard({ fileName, jobId, initialError }) {
return (
- {fileName}
- {(status === 'pending' || status === 'processing') && Conversion en cours...}
- {status === 'done' && Télécharger}
+ {fileName}
+ {(status === 'pending' || status === 'processing') && {t('job.converting')}}
+ {status === 'done' && (
+
+ {t('job.download')}
+
+ )}
{status === 'failed' && {errorMessage}}
);
diff --git a/frontend/src/components/Dropzone.jsx b/frontend/src/components/Dropzone.jsx
new file mode 100644
index 0000000..3f3c46a
--- /dev/null
+++ b/frontend/src/components/Dropzone.jsx
@@ -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 (
+ {
+ 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();
+ }}
+ >
+
+ {label}
+ onFilesSelected(event.target.files)}
+ />
+
+ );
+}
diff --git a/frontend/src/components/FileConfigCard.jsx b/frontend/src/components/FileConfigCard.jsx
new file mode 100644
index 0000000..96e90cf
--- /dev/null
+++ b/frontend/src/components/FileConfigCard.jsx
@@ -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 (
+
+
+ {item.file.name}
+
+ {item.targets.length > 0 ? (
+
+
+
+ {QUALITY_FORMATS.includes(item.targetFormat) && (
+
+ )}
+
+ {item.targetFormat === 'png' && (
+
+ )}
+
+ {item.targetFormat === 'ico' && (
+
+ )}
+
+ {item.targetFormat === 'pdf' && (
+
+ )}
+
+ ) : (
+ {t('hero.unsupportedFormat')}
+ )}
+
+ );
+}
diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx
new file mode 100644
index 0000000..00fdc8f
--- /dev/null
+++ b/frontend/src/components/Footer.jsx
@@ -0,0 +1,10 @@
+import { useTranslation } from 'react-i18next';
+
+export function Footer() {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx
new file mode 100644
index 0000000..1bb2d68
--- /dev/null
+++ b/frontend/src/components/Header.jsx
@@ -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 (
+
+
+ {t('brand')}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/LanguageSwitcher.jsx b/frontend/src/components/LanguageSwitcher.jsx
new file mode 100644
index 0000000..cb31381
--- /dev/null
+++ b/frontend/src/components/LanguageSwitcher.jsx
@@ -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 (
+
+ {label}
+
+ );
+}
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
new file mode 100644
index 0000000..c856a71
--- /dev/null
+++ b/frontend/src/components/Layout.jsx
@@ -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 (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index b9a1a6d..d786e96 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -1,10 +1,14 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.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(
-
- ,
-)
+
+
+
+
+);
diff --git a/frontend/src/pages/HomePage.jsx b/frontend/src/pages/HomePage.jsx
new file mode 100644
index 0000000..f935dfd
--- /dev/null
+++ b/frontend/src/pages/HomePage.jsx
@@ -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 (
+
+ {t('hero.title')}
+ {t('hero.subtitle')}
+
+
+ {pendingFiles.length > 0 && (
+
+
+ {pendingFiles.map((item, index) => (
+
+ ))}
+
+
+
+ )}
+
+
+ {submittedJobs.map((job, index) =>
+ job.id ? (
+
+ ) : (
+
+ )
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/NotFound.jsx b/frontend/src/pages/NotFound.jsx
new file mode 100644
index 0000000..62dbcdb
--- /dev/null
+++ b/frontend/src/pages/NotFound.jsx
@@ -0,0 +1,12 @@
+import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+
+export function NotFound() {
+ const { t } = useTranslation();
+ return (
+
+
{t('notFound.title')}
+ {t('notFound.backHome')}
+
+ );
+}
diff --git a/frontend/src/styles/home.css b/frontend/src/styles/home.css
new file mode 100644
index 0000000..5a2c3b3
--- /dev/null
+++ b/frontend/src/styles/home.css
@@ -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;
+ }
+}
diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css
new file mode 100644
index 0000000..738a491
--- /dev/null
+++ b/frontend/src/styles/layout.css
@@ -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;
+}