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