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