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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user