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 && ( -
-
    - {pendingFiles.map((item, index) => ( -
  • - {item.file.name} - {item.targets.length > 0 ? ( - <> - - - {QUALITY_FORMATS.includes(item.targetFormat) && ( - - )} - - {item.targetFormat === 'png' && ( - - )} - - {item.targetFormat === 'ico' && ( - - )} - - {item.targetFormat === 'pdf' && ( - - )} - - ) : ( - Format non supporté - )} -
  • - ))} -
- -
- )} - -
    - {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 ( +
    +

    {t('footer.rights')}

    +
    + ); +} 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 ( + <> +
    +
    + +
    +