Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3ec3a8ba3 | ||
|
|
ca4f896256 | ||
|
|
7483379bcd | ||
|
|
6fc3386965 | ||
|
|
74c676df9f | ||
|
|
3a9836d58b | ||
|
|
04b792c454 | ||
|
|
0e19f92cda | ||
|
|
e6ab78de29 | ||
|
|
3b53d82b3d |
@@ -28,6 +28,7 @@
|
||||
"enabledPlugins": {
|
||||
"superpowers@claude-plugins-official": true,
|
||||
"claude-seo@agricidaniel-claude-seo": true,
|
||||
"ui-ux-pro-max@ui-ux-pro-max-skill": true
|
||||
"ui-ux-pro-max@ui-ux-pro-max-skill": true,
|
||||
"frontend-design@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
# File Card / Language Switcher Polish Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Give the file-conversion flow (`frontend/`) flag-icon language switching, a restyled format/quality picker, a consistent icon+name+size header across the choose/convert/download states, and human-readable file sizes — all frontend-only, no backend changes.
|
||||
|
||||
**Architecture:** A new `formatBytes()` util plus a shared `.file-tile-header` CSS shape used by both `FileConfigCard` (choose step) and `FileCard` (convert/download step), which stay separate components. File size is read from the browser `File` object client-side and threaded through `HomePage` state into both components — no API change. The language switcher swaps its text label for a `flag-icons` icon.
|
||||
|
||||
**Tech Stack:** React 19, `react-i18next`, `@phosphor-icons/react` (already installed), `flag-icons` (new), plain CSS with the existing `--color-*` custom-property design tokens.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `flag-icons` must be added to **both** `frontend/package.json` and root `package.json` dependencies, with `npm install` run in both locations (o2switch shared-hosting single-`node_modules` constraint — see `CLAUDE.md`).
|
||||
- No backend/API changes. File size is derived entirely from the client-side `File.size` already available at selection time.
|
||||
- No new automated frontend test harness — this project has none today; verification is manual (`npm run dev`) plus plain `node` sanity checks for pure functions.
|
||||
- `FileConfigCard` and `FileCard` remain separate components (different responsibilities) — share CSS classes, not a merged component.
|
||||
- The target-format picker stays a native `<select>` (not a custom chip grid).
|
||||
- Use only the existing `var(--color-*)` design tokens from `frontend/src/index.css` — no new hardcoded colors.
|
||||
- The spinner animation must respect `prefers-reduced-motion` — already globally enforced by the `@media (prefers-reduced-motion: reduce)` block in `frontend/src/index.css:68-72`, so no per-component handling is needed.
|
||||
- Package versions: let `npm install <pkg>` resolve and write the version itself; do not hand-write a version number into `package.json`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add the `flag-icons` dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json`
|
||||
- Modify: `package.json`
|
||||
- Modify: `frontend/src/main.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: global CSS classes `.fi` (base) and `.fi-fr` / `.fi-gb` (per-flag), available anywhere in the app once imported once in `main.jsx`. Verified against the published `flag-icons@7.5.0` package contents: it ships `css/flag-icons.min.css`, and that file defines `.fi-fr` / `.fi-gb` (not `.fi-en` — flag-icons keys are ISO 3166-1 country codes, so "English" maps to the `gb` flag).
|
||||
|
||||
- [ ] **Step 1: Install in `frontend/` (local dev)**
|
||||
|
||||
Run from the `frontend/` directory:
|
||||
```bash
|
||||
cd frontend && npm install flag-icons
|
||||
```
|
||||
Expected: `frontend/package.json` gains a `flag-icons` entry under `dependencies`, `frontend/package-lock.json` updates, and `frontend/node_modules/flag-icons/` is created.
|
||||
|
||||
- [ ] **Step 2: Install at the repo root (o2switch build)**
|
||||
|
||||
Run from the repo root:
|
||||
```bash
|
||||
npm install flag-icons
|
||||
```
|
||||
Expected: root `package.json` gains the same `flag-icons` entry under `dependencies`, root `package-lock.json` updates. This mirrors the existing pattern already used for `react-router-dom`, `react-i18next`, `i18next`, `@phosphor-icons/react` (see `CLAUDE.md`'s o2switch deployment notes) — packages `frontend/src/**` imports must also resolve from the root `node_modules` since the o2switch server never creates a `frontend/node_modules`.
|
||||
|
||||
- [ ] **Step 3: Verify both `package.json` files list it**
|
||||
|
||||
```bash
|
||||
grep -n '"flag-icons"' package.json frontend/package.json
|
||||
```
|
||||
Expected: one matching line per file.
|
||||
|
||||
- [ ] **Step 4: Import the stylesheet once, globally**
|
||||
|
||||
Edit `frontend/src/main.jsx`:
|
||||
|
||||
```jsx
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import 'flag-icons/css/flag-icons.min.css';
|
||||
import './i18n.js';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json frontend/package.json frontend/package-lock.json frontend/src/main.jsx
|
||||
git commit -m "feat(frontend): add flag-icons dependency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `formatBytes` utility
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/utils/formatBytes.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `formatBytes(bytes: number | null | undefined): string` — used by Task 4 (`FileConfigCard`) and Task 5 (`FileCard`).
|
||||
|
||||
- [ ] **Step 1: Write the implementation**
|
||||
|
||||
```js
|
||||
const UNITS = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
export function formatBytes(bytes) {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1);
|
||||
const value = bytes / 1024 ** exponent;
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
const formatted = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
||||
return `${formatted} ${UNITS[exponent]}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Sanity-check it with plain `node` (no test harness exists for `frontend/`, so this is a manual verification run, not a committed test file)**
|
||||
|
||||
Run from the repo root:
|
||||
```bash
|
||||
node --input-type=module -e "
|
||||
import { formatBytes } from './frontend/src/utils/formatBytes.js';
|
||||
const cases = [
|
||||
[0, '0 B'],
|
||||
[undefined, '0 B'],
|
||||
[500, '500 B'],
|
||||
[524288, '512 KB'],
|
||||
[2516582, '2.4 MB'],
|
||||
[1073741824, '1 GB'],
|
||||
];
|
||||
for (const [input, expected] of cases) {
|
||||
const actual = formatBytes(input);
|
||||
if (actual !== expected) throw new Error('formatBytes(' + input + ') = ' + actual + ', expected ' + expected);
|
||||
}
|
||||
console.log('all formatBytes cases passed');
|
||||
"
|
||||
```
|
||||
Expected output: `all formatBytes cases passed`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/utils/formatBytes.js
|
||||
git commit -m "feat(frontend): add formatBytes util for human-readable file sizes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Flag icons in the language switcher
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/LanguageSwitcher.jsx`
|
||||
- Modify: `frontend/src/styles/layout.css:22-29`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `flag-icons` CSS classes from Task 1 (`.fi`, `.fi-fr`, `.fi-gb`).
|
||||
- Consumes: existing i18n keys `nav.switchToEnglish` / `nav.switchToFrench` (`frontend/src/locales/en.json:4-5`, `fr.json:4-5`) — unchanged, reused as the accessible name instead of visible text.
|
||||
|
||||
- [ ] **Step 1: Replace the text label with a flag icon**
|
||||
|
||||
Replace the full contents of `frontend/src/components/LanguageSwitcher.jsx`:
|
||||
|
||||
```jsx
|
||||
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');
|
||||
const flagCode = otherLang === 'en' ? 'gb' : 'fr';
|
||||
|
||||
return (
|
||||
<Link to={`/${otherLang}/`} className="language-switcher" aria-label={label} title={label}>
|
||||
<span className={`fi fi-${flagCode}`} aria-hidden="true" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Size the flag and keep the 44px touch target**
|
||||
|
||||
In `frontend/src/styles/layout.css`, the existing `.language-switcher` rule (lines 22-29) already gives the link `min-height: 44px` and centers its content — that still applies to an icon child. Add a new rule directly after it:
|
||||
|
||||
```css
|
||||
.language-switcher .fi {
|
||||
font-size: 1.35rem;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 0 1px var(--color-border);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Manual check**
|
||||
|
||||
Run `npm run dev` inside `frontend/`, open `/fr/` and `/en/`. Confirm: the FR page shows a GB flag (link target `/en/`), the EN page shows an FR flag (link target `/fr/`), and hovering/focusing shows the tooltip text from `title`. Confirm with a screen reader or the browser accessibility inspector that the link's accessible name is "English" / "Français" (not blank).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/LanguageSwitcher.jsx frontend/src/styles/layout.css
|
||||
git commit -m "feat(frontend): use flag icons in the language switcher"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Shared file-tile header + `FileConfigCard` restyle
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/styles/home.css`
|
||||
- Modify: `frontend/src/components/FileConfigCard.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `formatBytes` from Task 2 (`../utils/formatBytes.js` relative to `frontend/src/components/`).
|
||||
- Produces: CSS classes `.file-tile-header`, `.file-tile-name`, `.file-tile-size`, `.format-select-group`, `.format-select`, `.format-badge` — consumed by Task 5 (`FileCard` reuses `.file-tile-header`/`.file-tile-name`/`.file-tile-size`).
|
||||
|
||||
- [ ] **Step 1: Replace `.file-config-name` with the shared tile-header classes**
|
||||
|
||||
In `frontend/src/styles/home.css`, replace this block (lines 62-65):
|
||||
|
||||
```css
|
||||
.file-config-name {
|
||||
font-weight: 500;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```css
|
||||
.file-tile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.file-tile-name {
|
||||
font-weight: 500;
|
||||
flex: 1 1 auto;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.file-tile-size {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-foreground);
|
||||
opacity: 0.65;
|
||||
white-space: nowrap;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add format-picker and control-spacing styles**
|
||||
|
||||
In `frontend/src/styles/home.css`, immediately after the `.file-config-controls` block (originally lines 67-72, now shifted by the edit above — locate it by its content, not the line number), add:
|
||||
|
||||
```css
|
||||
.file-config-controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.format-select-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.format-select {
|
||||
appearance: none;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||
font-weight: 500;
|
||||
min-height: 44px;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--color-foreground) 50%),
|
||||
linear-gradient(135deg, var(--color-foreground) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 17px) calc(1.15em),
|
||||
calc(100% - 12px) calc(1.15em);
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.format-select:focus-visible {
|
||||
border-color: var(--color-ring);
|
||||
}
|
||||
|
||||
.format-badge {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-primary);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `FileConfigCard.jsx` to use the new markup**
|
||||
|
||||
Replace the full contents of `frontend/src/components/FileConfigCard.jsx`:
|
||||
|
||||
```jsx
|
||||
import { File as FileIcon } from '@phosphor-icons/react';
|
||||
import { formatBytes } from '../utils/formatBytes.js';
|
||||
|
||||
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">
|
||||
<div className="file-tile-header">
|
||||
<FileIcon size={24} weight="regular" />
|
||||
<span className="file-tile-name">{item.file.name}</span>
|
||||
<span className="file-tile-size">{formatBytes(item.file.size)}</span>
|
||||
</div>
|
||||
|
||||
{item.targets.length > 0 ? (
|
||||
<div className="file-config-controls">
|
||||
<div className="format-select-group">
|
||||
<select
|
||||
className="format-select"
|
||||
value={item.targetFormat ?? ''}
|
||||
onChange={(event) => onTargetFormatChange(index, event.target.value)}
|
||||
>
|
||||
{item.targets.map((target) => (
|
||||
<option key={target} value={target}>
|
||||
{target}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{item.targetFormat && <span className="format-badge">{item.targetFormat.toUpperCase()}</span>}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Manual check**
|
||||
|
||||
Run `npm run dev` inside `frontend/`. Select an image file, confirm: the file icon, name, and a size like "1.2 MB" appear on one line; the format `<select>` shows the custom border/arrow and a badge with the selected extension (e.g. "WEBP") next to it; changing the quality slider still updates the displayed value; switching the target format to `ico` still shows the icon-size select, and to `pdf` still shows the compress checkbox — no control lost.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/styles/home.css frontend/src/components/FileConfigCard.jsx
|
||||
git commit -m "feat(frontend): restyle format picker and add file size to the config card"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `FileCard` restyle (icon, size, spinner, download button)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/styles/home.css`
|
||||
- Modify: `frontend/src/FileCard.jsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `.file-tile-header` / `.file-tile-name` / `.file-tile-size` from Task 4. `formatBytes` from Task 2 (`./utils/formatBytes.js` relative to `frontend/src/`, since `FileCard.jsx` lives directly in `frontend/src/`, not in `components/`).
|
||||
- Consumes: new `fileSize` prop, wired up by Task 6.
|
||||
- Produces: `FileCard({ fileName, fileSize, jobId, initialError })` — the added `fileSize` prop is optional (`undefined` renders "0 B" via `formatBytes`), so existing callers that don't pass it don't break; Task 6 updates the one real caller (`HomePage.jsx`) anyway.
|
||||
|
||||
- [ ] **Step 1: Add spinner and download-button CSS**
|
||||
|
||||
In `frontend/src/styles/home.css`, add after the `.convert-button` block:
|
||||
|
||||
```css
|
||||
.job-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-foreground);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.download-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 1.25rem;
|
||||
font-weight: 600;
|
||||
min-height: 44px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
```
|
||||
|
||||
(No `prefers-reduced-motion` override is needed here — `frontend/src/index.css:68-72` already forces `animation: none !important` globally under that media query, which covers `.spin`.)
|
||||
|
||||
- [ ] **Step 2: Update `FileCard.jsx`**
|
||||
|
||||
Replace the full contents of `frontend/src/FileCard.jsx`:
|
||||
|
||||
```jsx
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { File as FileIcon, CircleNotch, DownloadSimple } from '@phosphor-icons/react';
|
||||
import { fetchJobStatus, downloadUrl } from './api.js';
|
||||
import { formatBytes } from './utils/formatBytes.js';
|
||||
|
||||
export function FileCard({ fileName, fileSize, jobId, initialError }) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState(initialError ? 'failed' : 'pending');
|
||||
const [errorMessage, setErrorMessage] = useState(initialError ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId || initialError) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
const interval = setInterval(async () => {
|
||||
const job = await fetchJobStatus(jobId);
|
||||
if (cancelled) return;
|
||||
setStatus(job.status);
|
||||
if (job.status === 'failed') setErrorMessage(job.errorMessage);
|
||||
if (job.status === 'done' || job.status === 'failed') clearInterval(interval);
|
||||
}, 1500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [jobId, initialError]);
|
||||
|
||||
return (
|
||||
<li className="file-card">
|
||||
<div className="file-tile-header">
|
||||
<FileIcon size={24} weight="regular" />
|
||||
<span className="file-tile-name">{fileName}</span>
|
||||
<span className="file-tile-size">{formatBytes(fileSize)}</span>
|
||||
</div>
|
||||
{(status === 'pending' || status === 'processing') && (
|
||||
<span className="job-status">
|
||||
<CircleNotch size={18} weight="bold" className="spin" />
|
||||
{t('job.converting')}
|
||||
</span>
|
||||
)}
|
||||
{status === 'done' && (
|
||||
<a className="download-button" href={downloadUrl(jobId)}>
|
||||
<DownloadSimple size={20} weight="regular" /> {t('job.download')}
|
||||
</a>
|
||||
)}
|
||||
{status === 'failed' && <span className="error">{errorMessage}</span>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/styles/home.css frontend/src/FileCard.jsx
|
||||
git commit -m "feat(frontend): add icon/size header, spinner, and styled download button to FileCard"
|
||||
```
|
||||
|
||||
(Manual verification of this component happens in Task 6, once `HomePage` actually supplies `fileSize` and a real job to poll.)
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire file size through `HomePage`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/HomePage.jsx:80-85` (`handleConvert`), `:116-124` (job-list render)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `FileCard`'s new `fileSize` prop (Task 5).
|
||||
- Consumes: verified backend contract — `POST /api/jobs` (`src/app.js:136-159`) builds its `results` array with exactly one entry per uploaded file, in the same order `req.files` was received, for both success and failure cases (confirmed by reading the handler's single indexed `for` loop). Since `uploadFiles()` (`frontend/src/api.js:12-30`) appends files to `FormData` in the same order as its `items` argument, `uploadFiles(validItems)`'s resolved `jobs` array is guaranteed index-aligned with `validItems`.
|
||||
|
||||
- [ ] **Step 1: Attach the original file size to each returned job**
|
||||
|
||||
In `frontend/src/pages/HomePage.jsx`, replace `handleConvert` (lines 80-85):
|
||||
|
||||
```js
|
||||
async function handleConvert() {
|
||||
const validItems = pendingFiles.filter((item) => item.targetFormat);
|
||||
const jobs = await uploadFiles(validItems);
|
||||
setSubmittedJobs((current) => [...current, ...jobs]);
|
||||
setPendingFiles([]);
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
async function handleConvert() {
|
||||
const validItems = pendingFiles.filter((item) => item.targetFormat);
|
||||
const jobs = await uploadFiles(validItems);
|
||||
const jobsWithSize = jobs.map((job, i) => ({ ...job, size: validItems[i].file.size }));
|
||||
setSubmittedJobs((current) => [...current, ...jobsWithSize]);
|
||||
setPendingFiles([]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Pass the size down to `FileCard`**
|
||||
|
||||
Replace the job-list render block (lines 116-124):
|
||||
|
||||
```jsx
|
||||
<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>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```jsx
|
||||
<ul className="job-list">
|
||||
{submittedJobs.map((job, index) =>
|
||||
job.id ? (
|
||||
<FileCard key={job.id} fileName={job.file} fileSize={job.size} jobId={job.id} />
|
||||
) : (
|
||||
<FileCard key={`${job.file}-${index}`} fileName={job.file} fileSize={job.size} initialError={job.error} />
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Manual end-to-end check**
|
||||
|
||||
This requires a running backend. Per `CLAUDE.md`, first check whether a `node src/server.js` / `node src/worker.js` pair is already running (`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Select-Object ProcessId,CommandLine"`). If not, and you start your own for this check, only stop processes you started yourself afterward — never a pre-existing server/worker.
|
||||
|
||||
With the backend reachable and `npm run dev` running in `frontend/`:
|
||||
1. Upload a file, confirm its size shows in the choose step (Task 4).
|
||||
2. Click "Convertir"/"Convert". Confirm the download-step card shows the same file icon + name + size, with the spinning icon next to "Converting..." while the job is pending/processing.
|
||||
3. Once done, confirm a filled, styled download button appears with the same size still shown, and clicking it downloads the converted file.
|
||||
4. Upload an unsupported/mismatched file to trigger the error path; confirm the error message still renders (with icon + name + size still shown in the header).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/HomePage.jsx
|
||||
git commit -m "feat(frontend): thread file size from upload through to the download card"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Cross-cutting manual verification pass
|
||||
|
||||
No new files — this is a checklist-only task confirming the spec's full "Testing" section, since no automated frontend suite exists to run it for you.
|
||||
|
||||
- [ ] **Step 1: Both languages**
|
||||
|
||||
`npm run dev` in `frontend/`. Visit `/fr/` and `/en/`. Confirm all new UI (flags, format badge, download button, spinner) renders correctly and every visible string is translated (no raw i18n keys, no English leaking onto the FR page or vice versa).
|
||||
|
||||
- [ ] **Step 2: Both themes**
|
||||
|
||||
Toggle dark mode. Confirm the flag's border (`box-shadow` against `--color-border`), the format badge, and the download button all keep sufficient contrast in dark mode (all colors come from existing `--color-*` tokens, which already have dark variants in `frontend/src/index.css:21-32`, so this should hold without extra CSS — verify visually).
|
||||
|
||||
- [ ] **Step 3: Responsive widths**
|
||||
|
||||
Resize the browser (or use devtools device toolbar) to roughly 375 / 768 / 1024 / 1440px. Confirm the `.file-tile-header` row (icon + name + size) wraps sensibly on narrow widths (name should be allowed to shrink/wrap via `flex: 1 1 auto` + `overflow-wrap: anywhere`; size stays on one line via `white-space: nowrap`) and the format-select/badge row doesn't overflow its card.
|
||||
|
||||
- [ ] **Step 4: Multi-file batch**
|
||||
|
||||
Upload 3+ files of different formats (e.g. one image, one document, one unsupported type) in one go. Confirm each config card and each resulting job card keeps its own correct name/size/format independent of the others (no index mixups) — this specifically exercises the index-based zip from Task 6.
|
||||
|
||||
- [ ] **Step 5: Final commit (if step 1-4 surfaced any fixups)**
|
||||
|
||||
If any of the above steps required a fix, commit it separately with a message describing what was wrong, e.g.:
|
||||
```bash
|
||||
git add <fixed files>
|
||||
git commit -m "fix(frontend): <describe the specific issue found during manual verification>"
|
||||
```
|
||||
If no fixes were needed, no commit is required for this task.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
# Archive conversion (zip, tar, tar.gz, tar.bz2, 7z, tar.7z, rar)
|
||||
|
||||
Date: 2026-08-01
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new `archive` family. Requested formats: `zip`, `tar`, `tar.gz`, `7z`, `rar`, `tar.bz2` (user wrote `tar.bz`, meaning bzip2), `tar.7z`. All-pairs, **except**:
|
||||
|
||||
- `rar` is source-only. Confirmed with the user: the `unrar` library's license explicitly bars using it to re-implement the RAR compression algorithm, so no npm package (or anything else) can legally create `.rar` files. Every existing RAR-handling package (`node-rar`, `node-unrar`, `node-unrar-js`, `rarfile`) is extraction-only for this reason. `rar` is therefore accepted as an input format and never offered as a target.
|
||||
- `tar.7z` (not a standard extension) is, per the user, confirmed to mean: a `.tar` stream compressed with the 7z/LZMA algorithm — the same relationship `tar.gz` has to gzip and `tar.bz2` has to bzip2.
|
||||
- Unlike every other family in this codebase (image, document, font, ebook — all reject `sourceFormat === targetFormat`), the user explicitly asked for same-format pairs to be allowed here (`zip -> zip`, `7z -> 7z`, etc.), since recompressing at a different level is a real use case specific to this family.
|
||||
|
||||
Net registration: 7 source formats (`zip`, `tar`, `tar.gz`, `tar.bz2`, `7z`, `tar.7z`, `rar`) × 6 target formats (same list minus `rar`) = 42 pairs, family `'archive'`.
|
||||
|
||||
## Architecture: extract-then-rebuild
|
||||
|
||||
One generic pipeline in `src/converters/archive.js`, `sourceFormat`/`targetFormat` bound via closures at registration time (same pattern `image.js` uses for `sharpFormatName`):
|
||||
|
||||
```js
|
||||
async function convert(inputPath, outputPath, { quality } = {}, sourceFormat, targetFormat) {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'archive-convert-'));
|
||||
try {
|
||||
const extractDir = path.join(tmpDir, 'extracted');
|
||||
await fs.mkdir(extractDir, { recursive: true });
|
||||
await EXTRACTORS[sourceFormat](inputPath, extractDir);
|
||||
await assertNoPathEscape(extractDir); // see Security
|
||||
await CREATORS[targetFormat](extractDir, outputPath, quality);
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function registerArchiveConverters() {
|
||||
for (const sourceFormat of Object.keys(EXTRACTORS)) {
|
||||
for (const targetFormat of Object.keys(CREATORS)) {
|
||||
register({
|
||||
family: 'archive',
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
convert: (inputPath, outputPath, options) => convert(inputPath, outputPath, options, sourceFormat, targetFormat),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No `config`/storage-dir threading needed — `os.tmpdir()` + `fs.mkdtemp` is the standard Node scratch-space pattern and keeps the `convert(inputPath, outputPath, options)` signature identical to every other family.
|
||||
|
||||
### Libraries (all verified: prebuilt binary or pure JS, nothing requires compilation)
|
||||
|
||||
| Format | Extract | Create | Compression level |
|
||||
|---|---|---|---|
|
||||
| `zip` | `adm-zip` (pure JS) | `archiver` (pure JS, `zlib: { level }`) | 0 (store) – 9 (max deflate) |
|
||||
| `tar` | `tar` (pure JS) | `tar` | none (like `gif`/`ico` today) |
|
||||
| `tar.gz` | `tar` (`gzip` option decompresses) | `tar` (`{ gzip: { level } }`) | 0–9, zlib gzip level |
|
||||
| `tar.bz2` | `7zip-min` (`unpack`) decompresses the bzip2 layer to an intermediate `.tar`, then `tar` extracts that (7za reads bzip2 natively — no separate bzip2 library needed) | `tar` builds a plain `.tar`, then `7zip-min` recompresses it (`cmd(['a', '-tbzip2', '-mx=' + level, outputPath, tarPath])`) | 1–9 (bzip2 has no level 0; a requested `0` is clamped to `1` inside the converter, not at the validation layer — same layering `image.js`'s `buildFormatOptions` already uses for PNG vs. other formats) |
|
||||
| `7z` | `7zip-min` (`unpack`) | `7zip-min` (`cmd(['a', '-mx=' + level, outputPath, extractDir + '/*'])`) | 0–9, mapped straight to `-mx` |
|
||||
| `tar.7z` | `7zip-min` unpacks the `.7z` to get an intermediate `.tar`, then `tar` extracts that | `tar` builds a plain `.tar`, then `7zip-min` packs it into a `.7z` (`-mx` level) | 0–9, same as `7z` |
|
||||
| `rar` (extract only) | `node-unrar-js` (WASM, pure JS — this is *why* it's extraction-only: the same license restriction that blocks every other package blocks this one too) | — | n/a |
|
||||
|
||||
`7zip-min` wraps a **precompiled** `7za` binary (via its `7zip-bin` dependency) invoked through `child_process` — no compilation, same category of dependency as `sharp` (bundles libvips) and `puppeteer` (bundles Chromium, and already proves child-process spawning works on the o2switch deployment target). Verified API: `pack(src, dest)`, `unpack(archive, destDir)`, and the low-level `cmd(argsArray)` for the `-mx=N` level flag and the `-tbzip2` format switch, all promise-returning.
|
||||
|
||||
New dependencies to add to `package.json` (root only — these are backend-only Node deps with no frontend import, so the o2switch single-`node_modules` mirroring rule for frontend runtime deps doesn't apply): `adm-zip`, `archiver`, `tar`, `7zip-min`, `node-unrar-js`.
|
||||
|
||||
## Compression level: reuse `quality`, uniform 0–9 scale
|
||||
|
||||
No schema change — `ConversionJob.quality` (`Int?`) already exists and already flows `app.js` → `createJob` → `worker.js` → `entry.convert(..., { quality })` unchanged.
|
||||
|
||||
`isValidQuality(targetFormat, quality)` in `src/app.js` gains:
|
||||
```js
|
||||
if (['zip', 'tar.gz', 'tar.bz2', '7z', 'tar.7z'].includes(targetFormat)) return quality >= 0 && quality <= 9;
|
||||
if (targetFormat === 'tar') return false; // no compression knob, same rule as gif/ico today
|
||||
```
|
||||
placed alongside the existing `gif`/`png` branches (order matters: `tar` must be checked before any fallthrough).
|
||||
|
||||
## Double extensions: a real gap in today's extension parsing
|
||||
|
||||
`app.js` currently derives `sourceFormat` via `path.extname(...).slice(1)` in two places (the multer `filename` callback, and the route handler reading `file.filename`), and the download route derives the base filename via `path.parse(job.originalFilename).name`. `path.extname`/`path.parse` only ever see the *last* dot segment — for `backup.tar.gz`, `path.extname` returns `.gz`, not `.tar.gz`, and `path.parse(...).name` returns `backup.tar`, not `backup`. This is a pre-existing gap that becomes load-bearing now that `tar.gz`/`tar.bz2`/`tar.7z` are real formats.
|
||||
|
||||
Fix: new small module `src/archiveExtensions.js`:
|
||||
```js
|
||||
const DOUBLE_EXTENSIONS = ['tar.gz', 'tar.bz2', 'tar.7z'];
|
||||
|
||||
export function extractExtension(filename) {
|
||||
const lower = filename.toLowerCase();
|
||||
const match = DOUBLE_EXTENSIONS.find((ext) => lower.endsWith(`.${ext}`));
|
||||
return match ?? path.extname(filename).slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
export function stripExtension(filename) {
|
||||
const ext = extractExtension(filename);
|
||||
return filename.slice(0, filename.length - ext.length - 1);
|
||||
}
|
||||
```
|
||||
Used in place of the raw `path.extname`/`path.parse(...).name` calls at all three call sites (multer `filename`, `sourceFormat` computation, download `downloadFilename` base). This is a mechanical fix, not a product decision — flagging it here because it's easy to miss and the feature silently mis-files every double-extension upload without it.
|
||||
|
||||
## `src/mime.js`
|
||||
|
||||
`OUTPUT_MIME_TYPES` additions: `zip: 'application/zip'`, `tar: 'application/x-tar'`, `'tar.gz': 'application/gzip'` (IANA-registered per RFC 6713), `'tar.bz2': 'application/x-bzip2'`, `'7z': 'application/x-7z-compressed'`, `'tar.7z': 'application/x-7z-compressed'`.
|
||||
|
||||
`resolveInputFormat`/`normalizeFormat`: confirmed by reading the installed `file-type` v22's `source/supported.js` — it detects `zip`, `tar`, `rar`, `gz`, `bz2`, `7z` by magic bytes (including `tar`, via the `ustar` marker at offset 257). It sniffs the *outer compression layer only* — a `.tar.gz` sniffs as `gz`, a `.tar.bz2` as `bz2`, a `.tar.7z` as `7z`, with no way to confirm the decompressed payload is actually a tar stream (same class of limitation the ebook design accepted for `fb2`/`lrf`/etc. — not a security gap, since a mismatched payload fails cleanly inside `convert()` with the existing generic `failed` status, just a weaker input check). `normalizeFormat` gains:
|
||||
```js
|
||||
if (format === 'tar.gz') return 'gz';
|
||||
if (format === 'tar.bz2') return 'bz2';
|
||||
if (format === 'tar.7z') return '7z';
|
||||
```
|
||||
(existing identity fallback already makes the comparison symmetric — `normalizeFormat('gz') === 'gz'`).
|
||||
|
||||
## Security: path-escape defense, applied uniformly post-extraction
|
||||
|
||||
Rather than trusting each of the five extraction libraries individually to guard against zip-slip/path-traversal (some — `tar`'s default extraction — are documented to strip `..`/absolute paths already; others — `adm-zip`, `node-unrar-js`, `7zip-min` shelling to `7za` — make no such guarantee, and `7za` itself has had real historical path-escape CVEs), one shared check runs after every extraction, before the rebuild step:
|
||||
|
||||
```js
|
||||
async function assertNoPathEscape(extractDir) {
|
||||
const resolvedRoot = await fs.realpath(extractDir);
|
||||
for (const entry of await fs.readdir(extractDir, { recursive: true, withFileTypes: true })) {
|
||||
const fullPath = path.join(entry.parentPath, entry.name);
|
||||
const real = entry.isSymbolicLink() ? await fs.realpath(fullPath) : fullPath;
|
||||
if (!real.startsWith(resolvedRoot + path.sep) && real !== resolvedRoot) {
|
||||
throw new Error('Archive entry escapes extraction directory');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
This throws into the existing generic `catch` in `worker.js`'s `processJob` unchanged (job marked `failed`, generic user-facing message, real error in `errorLog`).
|
||||
|
||||
Also: a cap on total decompressed bytes during extraction (zip-bomb defense) — accumulate size as each extractor writes files, abort past `config.maxFileSizeMb * 20`. Since converters don't currently receive `config`, this multiplier is a constant defined in `archive.js` itself (e.g. `MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024`, 2 GiB flat) rather than threading `config` through — simpler, and consistent with `archive.js` having no other config dependency.
|
||||
|
||||
## Registration wiring
|
||||
|
||||
`src/app.js` and `src/worker.js`: add `import { registerArchiveConverters } from './converters/archive.js'` and one call inside `registerAllConverters()` / `main()`, alongside the existing seven.
|
||||
|
||||
## Frontend
|
||||
|
||||
- `frontend/src/data/formats.js`: new entry `{ key: 'archives', formats: ['zip', 'tar', 'tar.gz', 'tar.bz2', '7z', 'tar.7z', 'rar'] }` (rar included here only so upload/icon detection recognizes it as an archive; it never appears as a selectable target, which falls out naturally from `GET /api/formats` reflecting the registry — no special-case frontend code needed).
|
||||
- `frontend/src/utils/fileFamily.js`: `FAMILY_ICONS.archives` — `Archive` icon from `@phosphor-icons/react` (already a dependency).
|
||||
- `frontend/src/components/FileConfigCard.jsx`: new conditional block, same `RangeField` pattern as the existing PNG control, min 0 max 9, shown when `item.targetFormat` is one of `zip`, `tar.gz`, `tar.bz2`, `7z`, `tar.7z` (not `tar`, matching the `gif`/`ico` precedent of no control at all when there's nothing to tune).
|
||||
|
||||
No DB/schema changes anywhere in this feature.
|
||||
|
||||
## Testing
|
||||
|
||||
`test/converters/archive.test.js`, same convention as `document.test.js`/`image.test.js`: small fixture archives (2–3 files + one subdirectory) for each of the 7 source formats. Not all 42 pairs — one representative test per extractor and per creator (covers every library once), plus explicitly: `zip -> zip` (the same-format recompression case), and a hand-built malicious zip (a `../../evil` entry) asserting `convert` rejects it via `assertNoPathEscape` rather than writing outside the temp dir.
|
||||
|
||||
`test/mime.test.js`: cases for the `tar.gz`/`tar.bz2`/`tar.7z` → `gz`/`bz2`/`7z` alias mapping.
|
||||
|
||||
`test/archiveExtensions.test.js` (new, small): `extractExtension`/`stripExtension` against plain and double-extension filenames.
|
||||
|
||||
Frontend: manual browser check (upload a `.zip`, confirm target chips include the other 5 formats and the compression slider appears/disappears correctly per target).
|
||||
@@ -4,10 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ombrora Convert</title>
|
||||
<meta name="description" content="Convertissez vos fichiers en ligne gratuitement : images, documents, polices et ebooks." />
|
||||
<meta property="og:site_name" content="Ombrora Convert" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+1171
-49
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "vite-react-ssg dev",
|
||||
"build": "vite-react-ssg build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
@@ -16,7 +16,8 @@
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-router-dom": "^6.30.4"
|
||||
"react-router-dom": "^6.30.4",
|
||||
"vite-react-ssg": "^0.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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 SUPPORTED_LANGS = ['fr', 'en'];
|
||||
|
||||
function RootRedirect() {
|
||||
const preferred = navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr';
|
||||
return <Navigate to={`/${preferred}/`} replace />;
|
||||
}
|
||||
|
||||
function LangShell() {
|
||||
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() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/:lang" element={<LangShell />}>
|
||||
<Route index element={<HomePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
export function RootRedirect() {
|
||||
const preferred = navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr';
|
||||
return <Navigate to={`/${preferred}/`} replace />;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Link } 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();
|
||||
export function Header({ lang, theme, onToggleTheme }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -13,7 +12,7 @@ export function Header({ theme, onToggleTheme }) {
|
||||
{t('brand')}
|
||||
</Link>
|
||||
<div className="site-header-controls">
|
||||
<LanguageSwitcher />
|
||||
<LanguageSwitcher lang={lang} />
|
||||
<ThemeToggle
|
||||
theme={theme}
|
||||
onToggle={onToggleTheme}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../i18n.js';
|
||||
import { Header } from './Header.jsx';
|
||||
import { Footer } from './Footer.jsx';
|
||||
import { useTheme } from '../hooks/useTheme.js';
|
||||
import '../styles/layout.css';
|
||||
|
||||
export function LangLayout({ lang }) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
// A per-page cloned instance, not a mutation of the shared `i18n` singleton:
|
||||
// the SSG build renders many pages concurrently in the same process
|
||||
// (ssgOptions.concurrency), so changing the shared instance's language
|
||||
// during render would leak between pages rendering at the same time.
|
||||
const scopedI18n = useMemo(() => i18n.cloneInstance({ lng: lang }), [lang]);
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={scopedI18n}>
|
||||
<Header lang={lang} theme={theme} onToggleTheme={toggleTheme} />
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { lang } = useParams();
|
||||
export function LanguageSwitcher({ lang }) {
|
||||
const { t } = useTranslation();
|
||||
const otherLang = lang === 'fr' ? 'en' : 'fr';
|
||||
const label = otherLang === 'en' ? t('nav.switchToEnglish') : t('nav.switchToFrench');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Head } from 'vite-react-ssg';
|
||||
|
||||
const SITE_URL = 'https://convert.ombrora.com';
|
||||
|
||||
const META = {
|
||||
const DEFAULT_META = {
|
||||
fr: {
|
||||
title: 'Ombrora Convert — Conversion de fichiers en ligne',
|
||||
description:
|
||||
@@ -15,74 +15,30 @@ const META = {
|
||||
},
|
||||
};
|
||||
|
||||
function setNameMeta(name, content) {
|
||||
let el = document.head.querySelector(`meta[name="${name}"]`);
|
||||
if (!el) {
|
||||
el = document.createElement('meta');
|
||||
el.setAttribute('name', name);
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.setAttribute('content', content);
|
||||
}
|
||||
export function SeoHead({ lang, path = '', title, description, jsonLd, noindex = false }) {
|
||||
const resolvedTitle = title ?? DEFAULT_META[lang].title;
|
||||
const resolvedDescription = description ?? DEFAULT_META[lang].description;
|
||||
const canonical = `${SITE_URL}/${lang}${path}`;
|
||||
|
||||
function setPropertyMeta(property, content) {
|
||||
let el = document.head.querySelector(`meta[property="${property}"]`);
|
||||
if (!el) {
|
||||
el = document.createElement('meta');
|
||||
el.setAttribute('property', property);
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.setAttribute('content', content);
|
||||
}
|
||||
|
||||
function setAlternateLink(hreflang, href) {
|
||||
const selector = `link[rel="alternate"][hreflang="${hreflang}"]`;
|
||||
let el = document.head.querySelector(selector);
|
||||
if (!el) {
|
||||
el = document.createElement('link');
|
||||
el.setAttribute('rel', 'alternate');
|
||||
el.setAttribute('hreflang', hreflang);
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
el.setAttribute('href', href);
|
||||
}
|
||||
|
||||
function setJsonLd(lang) {
|
||||
const id = 'seo-jsonld';
|
||||
let script = document.getElementById(id);
|
||||
if (!script) {
|
||||
script = document.createElement('script');
|
||||
script.id = id;
|
||||
script.type = 'application/ld+json';
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
script.textContent = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Ombrora Convert',
|
||||
applicationCategory: 'UtilitiesApplication',
|
||||
operatingSystem: 'Any',
|
||||
offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
|
||||
inLanguage: lang,
|
||||
url: `${SITE_URL}/${lang}/`,
|
||||
});
|
||||
}
|
||||
|
||||
export function SeoHead({ lang }) {
|
||||
useEffect(() => {
|
||||
const meta = META[lang] ?? META.fr;
|
||||
document.title = meta.title;
|
||||
document.documentElement.lang = lang;
|
||||
setNameMeta('description', meta.description);
|
||||
setPropertyMeta('og:title', meta.title);
|
||||
setPropertyMeta('og:description', meta.description);
|
||||
setPropertyMeta('og:type', 'website');
|
||||
setPropertyMeta('og:locale', lang === 'fr' ? 'fr_FR' : 'en_US');
|
||||
setAlternateLink('fr', `${SITE_URL}/fr/`);
|
||||
setAlternateLink('en', `${SITE_URL}/en/`);
|
||||
setAlternateLink('x-default', `${SITE_URL}/fr/`);
|
||||
setJsonLd(lang);
|
||||
}, [lang]);
|
||||
|
||||
return null;
|
||||
return (
|
||||
<Head>
|
||||
<title>{resolvedTitle}</title>
|
||||
<meta name="description" content={resolvedDescription} />
|
||||
{noindex && <meta name="robots" content="noindex" />}
|
||||
<link rel="canonical" href={canonical} />
|
||||
<meta property="og:site_name" content="Ombrora Convert" />
|
||||
<meta property="og:title" content={resolvedTitle} />
|
||||
<meta property="og:description" content={resolvedDescription} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:locale" content={lang === 'fr' ? 'fr_FR' : 'en_US'} />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content={resolvedTitle} />
|
||||
<meta name="twitter:description" content={resolvedDescription} />
|
||||
<link rel="alternate" hrefLang="fr" href={`${SITE_URL}/fr${path}`} />
|
||||
<link rel="alternate" hrefLang="en" href={`${SITE_URL}/en${path}`} />
|
||||
<link rel="alternate" hrefLang="x-default" href={`${SITE_URL}/fr${path}`} />
|
||||
{jsonLd && <script type="application/ld+json">{JSON.stringify(jsonLd)}</script>}
|
||||
</Head>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,933 @@
|
||||
export const conversionPages = [
|
||||
{
|
||||
pairId: 'jpg-to-png',
|
||||
sourceFormat: 'jpg',
|
||||
targetFormat: 'png',
|
||||
family: 'image',
|
||||
slugs: { fr: 'convertir-jpg-en-png', en: 'convert-jpg-to-png' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir JPG en PNG en ligne',
|
||||
description:
|
||||
'Convertissez vos JPG en PNG gratuitement, sans perte de qualité et avec support de la transparence.',
|
||||
intro:
|
||||
"Le format PNG conserve une compression sans perte et prend en charge la transparence, contrairement au JPG qui compresse avec perte. Idéal pour les logos, captures d'écran et visuels nécessitant un fond transparent.",
|
||||
benefits: [
|
||||
'Compression sans perte, aucune dégradation visuelle',
|
||||
'Support natif de la transparence (canal alpha)',
|
||||
'Traitement local, fichiers supprimés automatiquement après conversion',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert JPG to PNG Online',
|
||||
description: 'Convert JPG to PNG for free, with lossless quality and transparency support.',
|
||||
intro:
|
||||
"PNG uses lossless compression and supports transparency, unlike JPG's lossy compression. Ideal for logos, screenshots, and visuals that need a transparent background.",
|
||||
benefits: [
|
||||
'Lossless compression, no visual degradation',
|
||||
'Native transparency support (alpha channel)',
|
||||
'Files processed and deleted automatically, no account needed',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'pdf-to-docx',
|
||||
sourceFormat: 'pdf',
|
||||
targetFormat: 'docx',
|
||||
family: 'document',
|
||||
slugs: { fr: 'convertir-pdf-en-word', en: 'convert-pdf-to-word' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir PDF en Word (DOCX) en ligne',
|
||||
description: 'Transformez un PDF en document Word modifiable, avec mise en page conservée.',
|
||||
intro:
|
||||
'Recevoir un contrat ou un rapport en PDF alors qu’il faut le modifier est une situation courante. Cette conversion reconstruit un document Word (.docx) éditable à partir du contenu du PDF.',
|
||||
benefits: [
|
||||
'Texte réellement modifiable, pas une image scannée',
|
||||
'Mise en page approximativement conservée',
|
||||
'Évite de ressaisir un document entier à la main',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert PDF to Word (DOCX) Online',
|
||||
description: 'Turn a PDF into an editable Word document, with the layout preserved.',
|
||||
intro:
|
||||
'Receiving a contract or report as a PDF when you need to edit it is a common situation. This conversion rebuilds an editable Word (.docx) document from the PDF’s content.',
|
||||
benefits: [
|
||||
'Genuinely editable text, not a scanned image',
|
||||
'Layout approximately preserved',
|
||||
'Avoids retyping an entire document by hand',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'png-to-jpg',
|
||||
sourceFormat: 'png',
|
||||
targetFormat: 'jpg',
|
||||
family: 'image',
|
||||
slugs: { fr: 'convertir-png-en-jpg', en: 'convert-png-to-jpg' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir PNG en JPG en ligne',
|
||||
description: 'Réduisez la taille de vos images PNG en les convertissant en JPG, avec contrôle du niveau de qualité.',
|
||||
intro:
|
||||
'Le JPG produit des fichiers nettement plus légers que le PNG grâce à sa compression avec perte, au prix d’un fond blanc à la place de la transparence. Utile pour l’envoi par email ou l’hébergement web où le poids du fichier compte.',
|
||||
benefits: [
|
||||
'Fichiers jusqu’à 5 à 10 fois plus légers',
|
||||
'Curseur de qualité pour équilibrer poids et netteté',
|
||||
'Format universellement supporté par tous les navigateurs et appareils',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert PNG to JPG Online',
|
||||
description: 'Shrink your PNG images by converting them to JPG, with adjustable quality.',
|
||||
intro:
|
||||
'JPG produces significantly smaller files than PNG thanks to lossy compression, at the cost of transparency (replaced with a white background). Useful for email attachments or web hosting where file size matters.',
|
||||
benefits: [
|
||||
'Files up to 5-10x smaller',
|
||||
'Quality slider to balance size and sharpness',
|
||||
'Universally supported format across browsers and devices',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'png-to-webp',
|
||||
sourceFormat: 'png',
|
||||
targetFormat: 'webp',
|
||||
family: 'image',
|
||||
slugs: { fr: 'convertir-png-en-webp', en: 'convert-png-to-webp' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir PNG en WebP en ligne',
|
||||
description: 'Passez vos PNG au format WebP pour des pages web plus rapides, sans perdre la transparence.',
|
||||
intro:
|
||||
'WebP combine une compression supérieure au PNG et au JPG tout en conservant la transparence, ce qui en fait le choix recommandé par Google pour accélérer le chargement des sites.',
|
||||
benefits: [
|
||||
'Jusqu’à 30% plus léger qu’un PNG équivalent',
|
||||
'Transparence conservée, contrairement au JPG',
|
||||
'Recommandé pour améliorer les Core Web Vitals (LCP)',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert PNG to WebP Online',
|
||||
description: 'Turn your PNGs into WebP for faster web pages, without losing transparency.',
|
||||
intro:
|
||||
'WebP combines better compression than both PNG and JPG while keeping transparency, making it Google’s recommended format for faster-loading sites.',
|
||||
benefits: [
|
||||
'Up to 30% smaller than an equivalent PNG',
|
||||
'Transparency preserved, unlike JPG',
|
||||
'Recommended to improve Core Web Vitals (LCP)',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'webp-to-jpg',
|
||||
sourceFormat: 'webp',
|
||||
targetFormat: 'jpg',
|
||||
family: 'image',
|
||||
slugs: { fr: 'convertir-webp-en-jpg', en: 'convert-webp-to-jpg' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir WebP en JPG en ligne',
|
||||
description: 'Convertissez vos images WebP en JPG pour une compatibilité maximale avec anciens logiciels et appareils.',
|
||||
intro:
|
||||
'Bien que le WebP soit efficace, certains outils, imprimantes ou logiciels plus anciens ne le lisent pas encore. Le JPG reste le format le plus universellement compatible.',
|
||||
benefits: [
|
||||
'Compatible avec tous les logiciels, y compris les plus anciens',
|
||||
'Idéal avant import dans un logiciel de retouche ou d’impression',
|
||||
'Conversion rapide, sans installation',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert WebP to JPG Online',
|
||||
description: 'Convert WebP images to JPG for maximum compatibility with older software and devices.',
|
||||
intro:
|
||||
'WebP is efficient, but some tools, printers, or older software still can’t read it. JPG remains the most universally compatible image format.',
|
||||
benefits: [
|
||||
'Compatible with all software, including older tools',
|
||||
'Ideal before importing into editing or printing software',
|
||||
'Fast conversion, no installation required',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'jpg-to-webp',
|
||||
sourceFormat: 'jpg',
|
||||
targetFormat: 'webp',
|
||||
family: 'image',
|
||||
slugs: { fr: 'convertir-jpg-en-webp', en: 'convert-jpg-to-webp' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir JPG en WebP en ligne',
|
||||
description: 'Allégez vos photos JPG au format WebP pour accélérer votre site web sans sacrifier la qualité.',
|
||||
intro:
|
||||
'WebP réduit le poids des photos JPG de 25 à 35% en moyenne à qualité visuelle équivalente, un gain direct sur la vitesse de chargement de votre site.',
|
||||
benefits: [
|
||||
'25 à 35% de poids en moins à qualité équivalente',
|
||||
'Meilleur temps de chargement pour votre site ou boutique en ligne',
|
||||
'Curseur de qualité ajustable selon vos besoins',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert JPG to WebP Online',
|
||||
description: 'Shrink your JPG photos to WebP to speed up your website without sacrificing quality.',
|
||||
intro:
|
||||
'WebP cuts JPG file size by 25-35% on average at equivalent visual quality, a direct win for your site’s load speed.',
|
||||
benefits: [
|
||||
'25-35% smaller at equivalent quality',
|
||||
'Faster load times for your site or online store',
|
||||
'Adjustable quality slider to fit your needs',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'gif-to-png',
|
||||
sourceFormat: 'gif',
|
||||
targetFormat: 'png',
|
||||
family: 'image',
|
||||
slugs: { fr: 'convertir-gif-en-png', en: 'convert-gif-to-png' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir GIF en PNG en ligne',
|
||||
description: 'Extrayez une image PNG nette et sans compression à partir d’un GIF, avec transparence conservée.',
|
||||
intro:
|
||||
'Le GIF limite les images à 256 couleurs, ce qui crée des dégradés visibles. Convertir en PNG permet de récupérer une image fixe nette, avec un espace colorimétrique bien plus large.',
|
||||
benefits: [
|
||||
'Sort de la limite des 256 couleurs du GIF',
|
||||
'Netteté préservée sur les dégradés et aplats de couleur',
|
||||
'Transparence conservée si présente dans le GIF source',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert GIF to PNG Online',
|
||||
description: 'Extract a sharp, uncompressed PNG image from a GIF, with transparency preserved.',
|
||||
intro:
|
||||
'GIF limits images to 256 colors, which creates visible banding on gradients. Converting to PNG recovers a sharp still image with a much wider color range.',
|
||||
benefits: [
|
||||
'Breaks free of GIF’s 256-color limit',
|
||||
'Sharper gradients and flat color areas',
|
||||
'Transparency preserved if present in the source GIF',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'png-to-pdf',
|
||||
sourceFormat: 'png',
|
||||
targetFormat: 'pdf',
|
||||
family: 'image',
|
||||
slugs: { fr: 'convertir-png-en-pdf', en: 'convert-png-to-pdf' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir PNG en PDF en ligne',
|
||||
description: 'Regroupez ou transformez vos images PNG en document PDF prêt à partager ou imprimer.',
|
||||
intro:
|
||||
'Le PDF est le format de référence pour partager ou imprimer un document sans dépendre du logiciel du destinataire. Convertir un PNG en PDF permet de l’insérer facilement dans un dossier, un contrat ou une archive.',
|
||||
benefits: [
|
||||
'Format universellement lisible et imprimable',
|
||||
'Idéal pour joindre une image à un dossier administratif',
|
||||
'Aucune perte de qualité lors de la conversion',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert PNG to PDF Online',
|
||||
description: 'Turn your PNG images into a ready-to-share, ready-to-print PDF document.',
|
||||
intro:
|
||||
'PDF is the standard format for sharing or printing a document without depending on the recipient’s software. Converting a PNG to PDF makes it easy to include in a folder, contract, or archive.',
|
||||
benefits: [
|
||||
'Universally readable and printable format',
|
||||
'Ideal for attaching an image to an administrative file',
|
||||
'No quality loss during conversion',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'jpg-to-pdf',
|
||||
sourceFormat: 'jpg',
|
||||
targetFormat: 'pdf',
|
||||
family: 'image',
|
||||
slugs: { fr: 'convertir-jpg-en-pdf', en: 'convert-jpg-to-pdf' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir JPG en PDF en ligne',
|
||||
description: 'Transformez vos photos JPG en document PDF, pratique pour l’envoi de justificatifs ou de scans.',
|
||||
intro:
|
||||
'De nombreuses démarches administratives demandent un PDF plutôt qu’une photo. Cette conversion transforme instantanément votre JPG (photo de document, reçu, scan) en PDF prêt à être envoyé.',
|
||||
benefits: [
|
||||
'Format attendu par la plupart des démarches administratives en ligne',
|
||||
'Pratique pour convertir une photo de reçu ou de justificatif',
|
||||
'Traitement instantané, sans regroupement complexe de fichiers',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert JPG to PDF Online',
|
||||
description: 'Turn your JPG photos into a PDF document, handy for sending receipts or scanned documents.',
|
||||
intro:
|
||||
'Many administrative processes require a PDF rather than a photo. This conversion instantly turns your JPG (a document photo, receipt, or scan) into a PDF ready to send.',
|
||||
benefits: [
|
||||
'The format most online administrative processes expect',
|
||||
'Handy for converting a photo of a receipt or document',
|
||||
'Instant processing, no complex file bundling',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'heic-to-jpg',
|
||||
sourceFormat: 'heic',
|
||||
targetFormat: 'jpg',
|
||||
family: 'image',
|
||||
slugs: { fr: 'convertir-heic-en-jpg', en: 'convert-heic-to-jpg' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir HEIC en JPG en ligne',
|
||||
description: 'Convertissez vos photos iPhone au format HEIC en JPG, lisibles partout sans perte visible.',
|
||||
intro:
|
||||
'Les iPhone enregistrent les photos en HEIC par défaut, un format peu supporté en dehors de l’écosystème Apple. Le JPG reste lisible par tous les appareils, réseaux sociaux et logiciels.',
|
||||
benefits: [
|
||||
'Résout les photos HEIC illisibles sur Windows ou Android',
|
||||
'Compatible avec tous les réseaux sociaux et messageries',
|
||||
'Qualité visuelle quasiment identique à l’original',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert HEIC to JPG Online',
|
||||
description: 'Convert your iPhone HEIC photos to JPG, viewable everywhere with no visible quality loss.',
|
||||
intro:
|
||||
'iPhones save photos as HEIC by default, a format poorly supported outside Apple’s ecosystem. JPG remains readable by every device, social network, and piece of software.',
|
||||
benefits: [
|
||||
'Fixes HEIC photos that won’t open on Windows or Android',
|
||||
'Compatible with every social network and messaging app',
|
||||
'Visual quality nearly identical to the original',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'heic-to-png',
|
||||
sourceFormat: 'heic',
|
||||
targetFormat: 'png',
|
||||
family: 'image',
|
||||
slugs: { fr: 'convertir-heic-en-png', en: 'convert-heic-to-png' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir HEIC en PNG en ligne',
|
||||
description: 'Convertissez vos photos HEIC d’iPhone en PNG, sans compression avec perte.',
|
||||
intro:
|
||||
'Pour une retouche photo ou un usage graphique nécessitant une qualité maximale sans artefacts de compression, le PNG est préférable au JPG lors de la conversion d’un HEIC.',
|
||||
benefits: [
|
||||
'Pas de compression avec perte, contrairement au JPG',
|
||||
'Adapté à la retouche photo ou au montage graphique',
|
||||
'Compatible avec tous les logiciels d’édition d’image',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert HEIC to PNG Online',
|
||||
description: 'Convert your iPhone HEIC photos to PNG, with no lossy compression.',
|
||||
intro:
|
||||
'For photo editing or graphic work that needs maximum quality without compression artifacts, PNG is the better choice over JPG when converting a HEIC file.',
|
||||
benefits: [
|
||||
'No lossy compression, unlike JPG',
|
||||
'Suited for photo retouching or graphic design work',
|
||||
'Compatible with every image editing tool',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'png-to-ico',
|
||||
sourceFormat: 'png',
|
||||
targetFormat: 'ico',
|
||||
family: 'image',
|
||||
slugs: { fr: 'convertir-png-en-ico-favicon', en: 'convert-png-to-ico-favicon' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir PNG en ICO en ligne (favicon)',
|
||||
description: 'Créez un favicon .ico à partir d’une image PNG, pour votre site ou application.',
|
||||
intro:
|
||||
'Le format ICO est requis pour les favicons de site web et les icônes d’application Windows. Cette conversion génère un fichier .ico prêt à l’emploi à partir de votre logo ou icône PNG.',
|
||||
benefits: [
|
||||
'Génère un favicon compatible avec tous les navigateurs',
|
||||
'Tailles d’icône standard disponibles (16, 32, 48, 256, 512px)',
|
||||
'Aucune installation de logiciel de conception requise',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert PNG to ICO Online (favicon)',
|
||||
description: 'Create an .ico favicon from a PNG image, for your website or application.',
|
||||
intro:
|
||||
'The ICO format is required for website favicons and Windows application icons. This conversion generates a ready-to-use .ico file from your PNG logo or icon.',
|
||||
benefits: [
|
||||
'Generates a favicon compatible with every browser',
|
||||
'Standard icon sizes available (16, 32, 48, 256, 512px)',
|
||||
'No design software installation required',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'ico-to-png',
|
||||
sourceFormat: 'ico',
|
||||
targetFormat: 'png',
|
||||
family: 'image',
|
||||
slugs: { fr: 'convertir-ico-en-png', en: 'convert-ico-to-png' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir ICO en PNG en ligne',
|
||||
description: 'Extrayez l’image PNG d’un fichier ICO pour la modifier ou la réutiliser ailleurs.',
|
||||
intro:
|
||||
'Besoin de récupérer l’icône d’un ancien favicon ou d’un exécutable Windows pour la retoucher ? Cette conversion extrait l’image la plus grande contenue dans le fichier ICO au format PNG.',
|
||||
benefits: [
|
||||
'Extrait automatiquement l’image de plus haute résolution du fichier ICO',
|
||||
'Format PNG modifiable dans n’importe quel logiciel de retouche',
|
||||
'Pratique pour recycler une icône existante dans un nouveau design',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert ICO to PNG Online',
|
||||
description: 'Extract the PNG image from an ICO file to edit or reuse it elsewhere.',
|
||||
intro:
|
||||
'Need to recover the icon from an old favicon or a Windows executable to edit it? This conversion extracts the highest-resolution image contained in the ICO file as a PNG.',
|
||||
benefits: [
|
||||
'Automatically extracts the highest-resolution image from the ICO file',
|
||||
'PNG output editable in any image editing software',
|
||||
'Handy for repurposing an existing icon into a new design',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'docx-to-pdf',
|
||||
sourceFormat: 'docx',
|
||||
targetFormat: 'pdf',
|
||||
family: 'document',
|
||||
slugs: { fr: 'convertir-word-en-pdf', en: 'convert-word-to-pdf' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir Word (DOCX) en PDF en ligne',
|
||||
description: 'Transformez votre document Word en PDF, prêt à envoyer sans risque de mise en page cassée.',
|
||||
intro:
|
||||
'Envoyer un .docx expose votre mise en page aux variations de logiciel du destinataire. Le PDF fige la mise en forme et se lit à l’identique sur tous les appareils.',
|
||||
benefits: [
|
||||
'Mise en page figée, identique quel que soit le lecteur',
|
||||
'Format standard pour les candidatures, contrats et rapports',
|
||||
'Empêche la modification accidentelle du contenu',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert Word (DOCX) to PDF Online',
|
||||
description: 'Turn your Word document into a PDF, ready to send without risking broken formatting.',
|
||||
intro:
|
||||
'Sending a .docx exposes your layout to the recipient’s software quirks. PDF locks in the formatting and reads identically on every device.',
|
||||
benefits: [
|
||||
'Formatting locked in, identical on every viewer',
|
||||
'The standard format for applications, contracts, and reports',
|
||||
'Prevents accidental edits to the content',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'pdf-to-txt',
|
||||
sourceFormat: 'pdf',
|
||||
targetFormat: 'txt',
|
||||
family: 'document',
|
||||
slugs: { fr: 'convertir-pdf-en-texte', en: 'convert-pdf-to-text' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir PDF en texte (TXT) en ligne',
|
||||
description: 'Extrayez le texte brut d’un PDF pour le réutiliser dans un autre document ou script.',
|
||||
intro:
|
||||
'Besoin de récupérer uniquement le contenu textuel d’un PDF, sans mise en forme, pour l’analyser, le copier dans un script ou l’indexer ? Cette conversion extrait le texte brut du document.',
|
||||
benefits: [
|
||||
'Texte brut, sans mise en forme parasite',
|
||||
'Pratique pour l’analyse de données ou l’indexation',
|
||||
'Fonctionne même sur des PDF multi-pages',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert PDF to Text (TXT) Online',
|
||||
description: 'Extract the raw text from a PDF to reuse it in another document or script.',
|
||||
intro:
|
||||
'Need to pull just the text content out of a PDF, with no formatting, to analyze, script against, or index it? This conversion extracts the document’s raw text.',
|
||||
benefits: [
|
||||
'Plain text, free of formatting clutter',
|
||||
'Handy for data analysis or indexing',
|
||||
'Works even on multi-page PDFs',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'pdf-to-html',
|
||||
sourceFormat: 'pdf',
|
||||
targetFormat: 'html',
|
||||
family: 'document',
|
||||
slugs: { fr: 'convertir-pdf-en-html', en: 'convert-pdf-to-html' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir PDF en HTML en ligne',
|
||||
description: 'Transformez un PDF en page HTML pour le publier ou l’intégrer sur un site web.',
|
||||
intro:
|
||||
'Publier le contenu d’un PDF directement sur une page web, plutôt que de forcer un téléchargement, améliore l’expérience utilisateur et le référencement du contenu. Cette conversion produit un fichier HTML à partir du PDF.',
|
||||
benefits: [
|
||||
'Contenu directement indexable par les moteurs de recherche',
|
||||
'Évite de forcer un téléchargement pour consulter le contenu',
|
||||
'Structure HTML réutilisable dans un site existant',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert PDF to HTML Online',
|
||||
description: 'Turn a PDF into an HTML page to publish or embed on a website.',
|
||||
intro:
|
||||
'Publishing a PDF’s content directly on a web page, instead of forcing a download, improves user experience and the content’s search visibility. This conversion produces an HTML file from the PDF.',
|
||||
benefits: [
|
||||
'Content directly indexable by search engines',
|
||||
'Avoids forcing a download just to view the content',
|
||||
'HTML structure reusable within an existing site',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'csv-to-xlsx',
|
||||
sourceFormat: 'csv',
|
||||
targetFormat: 'xlsx',
|
||||
family: 'document',
|
||||
slugs: { fr: 'convertir-csv-en-excel', en: 'convert-csv-to-excel' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir CSV en Excel (XLSX) en ligne',
|
||||
description: 'Transformez un fichier CSV en classeur Excel, avec colonnes et feuilles prêtes à l’emploi.',
|
||||
intro:
|
||||
'Un CSV n’a ni mise en forme ni formules. Le convertir en XLSX permet de l’ouvrir directement dans Excel avec des colonnes correctement dimensionnées, prêt pour vos calculs et graphiques.',
|
||||
benefits: [
|
||||
'Ouverture directe dans Excel, Google Sheets ou LibreOffice',
|
||||
'Base prête pour ajouter formules, filtres et graphiques',
|
||||
'Conserve l’intégralité des lignes et colonnes du CSV source',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert CSV to Excel (XLSX) Online',
|
||||
description: 'Turn a CSV file into an Excel workbook, with columns and sheets ready to use.',
|
||||
intro:
|
||||
'A CSV has no formatting or formulas. Converting it to XLSX lets you open it directly in Excel with properly sized columns, ready for your calculations and charts.',
|
||||
benefits: [
|
||||
'Opens directly in Excel, Google Sheets, or LibreOffice',
|
||||
'A ready base for adding formulas, filters, and charts',
|
||||
'Preserves every row and column from the source CSV',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'xlsx-to-csv',
|
||||
sourceFormat: 'xlsx',
|
||||
targetFormat: 'csv',
|
||||
family: 'document',
|
||||
slugs: { fr: 'convertir-excel-en-csv', en: 'convert-excel-to-csv' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir Excel (XLSX) en CSV en ligne',
|
||||
description: 'Exportez votre classeur Excel en CSV, un format universel pour l’import dans d’autres outils.',
|
||||
intro:
|
||||
'De nombreux outils (bases de données, scripts, imports en masse) n’acceptent que le CSV, pas le format XLSX propriétaire d’Excel. Cette conversion extrait les données brutes de la première feuille en CSV.',
|
||||
benefits: [
|
||||
'Format universellement accepté en import par la plupart des logiciels',
|
||||
'Fichier plus léger et plus simple qu’un classeur Excel',
|
||||
'Idéal avant import dans une base de données ou un script',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert Excel (XLSX) to CSV Online',
|
||||
description: 'Export your Excel workbook to CSV, a universal format for importing into other tools.',
|
||||
intro:
|
||||
'Many tools (databases, scripts, bulk imports) only accept CSV, not Excel’s proprietary XLSX format. This conversion extracts the raw data from the first sheet as CSV.',
|
||||
benefits: [
|
||||
'A format universally accepted for import by most software',
|
||||
'A lighter, simpler file than an Excel workbook',
|
||||
'Ideal before importing into a database or script',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'csv-to-pdf',
|
||||
sourceFormat: 'csv',
|
||||
targetFormat: 'pdf',
|
||||
family: 'document',
|
||||
slugs: { fr: 'convertir-csv-en-pdf', en: 'convert-csv-to-pdf' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir CSV en PDF en ligne',
|
||||
description: 'Transformez un fichier CSV en tableau PDF lisible et imprimable.',
|
||||
intro:
|
||||
'Un CSV brut n’est pas fait pour être lu tel quel ou imprimé. Cette conversion met en forme les données en un tableau PDF propre, prêt à être partagé ou archivé.',
|
||||
benefits: [
|
||||
'Tableau lisible et mis en forme, contrairement au CSV brut',
|
||||
'Prêt à imprimer ou archiver tel quel',
|
||||
'Pratique pour partager des données sans donner accès au fichier source',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert CSV to PDF Online',
|
||||
description: 'Turn a CSV file into a readable, printable PDF table.',
|
||||
intro:
|
||||
'A raw CSV isn’t meant to be read as-is or printed. This conversion formats the data into a clean PDF table, ready to share or archive.',
|
||||
benefits: [
|
||||
'A readable, formatted table, unlike raw CSV',
|
||||
'Ready to print or archive as-is',
|
||||
'Handy for sharing data without giving access to the source file',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'xlsx-to-pdf',
|
||||
sourceFormat: 'xlsx',
|
||||
targetFormat: 'pdf',
|
||||
family: 'document',
|
||||
slugs: { fr: 'convertir-excel-en-pdf', en: 'convert-excel-to-pdf' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir Excel (XLSX) en PDF en ligne',
|
||||
description: 'Transformez votre classeur Excel en PDF, figé et prêt à partager sans risque de modification.',
|
||||
intro:
|
||||
'Partager un fichier Excel expose vos formules et données brutes à la modification. Le PDF fige le contenu affiché, parfait pour un rapport ou un devis final.',
|
||||
benefits: [
|
||||
'Contenu figé, sans formules ni données modifiables',
|
||||
'Mise en page identique quel que soit l’appareil du destinataire',
|
||||
'Format standard pour les rapports et devis finaux',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert Excel (XLSX) to PDF Online',
|
||||
description: 'Turn your Excel workbook into a PDF, locked and ready to share without risk of edits.',
|
||||
intro:
|
||||
'Sharing an Excel file exposes your formulas and raw data to changes. PDF locks in the displayed content, perfect for a final report or quote.',
|
||||
benefits: [
|
||||
'Locked content, no editable formulas or data',
|
||||
'Identical layout regardless of the recipient’s device',
|
||||
'The standard format for final reports and quotes',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'mov-to-mp4',
|
||||
sourceFormat: 'mov',
|
||||
targetFormat: 'mp4',
|
||||
family: 'video',
|
||||
slugs: { fr: 'convertir-mov-en-mp4', en: 'convert-mov-to-mp4' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir MOV en MP4 en ligne',
|
||||
description: 'Convertissez vos vidéos MOV (iPhone, QuickTime) en MP4, lisibles sur tous les appareils.',
|
||||
intro:
|
||||
'Le format MOV, utilisé par défaut par les iPhone et QuickTime, est mal supporté sur Windows, Android et de nombreux lecteurs vidéo. Le MP4 est le format vidéo le plus universellement compatible.',
|
||||
benefits: [
|
||||
'Lecture garantie sur Windows, Android et tous les navigateurs',
|
||||
'Compatible avec les principales plateformes de partage vidéo',
|
||||
'Poids de fichier optimisé grâce au codec H.264',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert MOV to MP4 Online',
|
||||
description: 'Convert your MOV videos (iPhone, QuickTime) to MP4, playable on every device.',
|
||||
intro:
|
||||
'MOV, the default format for iPhone and QuickTime, is poorly supported on Windows, Android, and many video players. MP4 is the most universally compatible video format.',
|
||||
benefits: [
|
||||
'Guaranteed playback on Windows, Android, and every browser',
|
||||
'Compatible with the major video-sharing platforms',
|
||||
'Optimized file size thanks to the H.264 codec',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'mkv-to-mp4',
|
||||
sourceFormat: 'mkv',
|
||||
targetFormat: 'mp4',
|
||||
family: 'video',
|
||||
slugs: { fr: 'convertir-mkv-en-mp4', en: 'convert-mkv-to-mp4' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir MKV en MP4 en ligne',
|
||||
description: 'Convertissez vos fichiers MKV en MP4 pour une lecture sans souci sur mobile, TV et navigateurs.',
|
||||
intro:
|
||||
'Le MKV est un conteneur riche en fonctionnalités mais mal supporté nativement par les smartphones, smart TV et navigateurs web. Le MP4 se lit partout sans logiciel additionnel.',
|
||||
benefits: [
|
||||
'Lecture native sur smartphones, smart TV et navigateurs',
|
||||
'Pas besoin d’installer un lecteur compatible MKV (type VLC)',
|
||||
'Format accepté par toutes les plateformes de streaming personnel',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert MKV to MP4 Online',
|
||||
description: 'Convert your MKV files to MP4 for hassle-free playback on mobile, TV, and browsers.',
|
||||
intro:
|
||||
'MKV is a feature-rich container but poorly supported natively by smartphones, smart TVs, and web browsers. MP4 plays everywhere with no extra software.',
|
||||
benefits: [
|
||||
'Native playback on smartphones, smart TVs, and browsers',
|
||||
'No need to install an MKV-compatible player (like VLC)',
|
||||
'Accepted by every personal streaming platform',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'avi-to-mp4',
|
||||
sourceFormat: 'avi',
|
||||
targetFormat: 'mp4',
|
||||
family: 'video',
|
||||
slugs: { fr: 'convertir-avi-en-mp4', en: 'convert-avi-to-mp4' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir AVI en MP4 en ligne',
|
||||
description: 'Modernisez vos anciennes vidéos AVI en MP4, plus légères et compatibles partout.',
|
||||
intro:
|
||||
'L’AVI est un format ancien, souvent volumineux et peu efficace en compression par rapport aux standards actuels. Le convertir en MP4 réduit le poids du fichier tout en assurant une compatibilité moderne.',
|
||||
benefits: [
|
||||
'Fichier généralement plus léger qu’un AVI équivalent',
|
||||
'Compatible avec les appareils et logiciels récents',
|
||||
'Idéal pour numériser une ancienne bibliothèque de vidéos',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert AVI to MP4 Online',
|
||||
description: 'Modernize your old AVI videos to MP4, smaller and compatible everywhere.',
|
||||
intro:
|
||||
'AVI is an old format, often bulky and inefficient by today’s compression standards. Converting it to MP4 reduces file size while ensuring modern compatibility.',
|
||||
benefits: [
|
||||
'Generally smaller file size than an equivalent AVI',
|
||||
'Compatible with modern devices and software',
|
||||
'Ideal for digitizing an old video library',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'webm-to-mp4',
|
||||
sourceFormat: 'webm',
|
||||
targetFormat: 'mp4',
|
||||
family: 'video',
|
||||
slugs: { fr: 'convertir-webm-en-mp4', en: 'convert-webm-to-mp4' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir WebM en MP4 en ligne',
|
||||
description: 'Convertissez vos vidéos WebM en MP4 pour les monter, partager ou lire sur davantage d’appareils.',
|
||||
intro:
|
||||
'Le WebM, courant sur le web (YouTube, enregistrements d’écran), n’est pas toujours lisible dans les logiciels de montage ou certains lecteurs. Le MP4 offre une compatibilité plus large en dehors du navigateur.',
|
||||
benefits: [
|
||||
'Compatible avec la majorité des logiciels de montage vidéo',
|
||||
'Lecture garantie hors navigateur, y compris sur ancien matériel',
|
||||
'Pratique pour réutiliser un enregistrement d’écran ou une vidéo téléchargée',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert WebM to MP4 Online',
|
||||
description: 'Convert your WebM videos to MP4 to edit, share, or play them on more devices.',
|
||||
intro:
|
||||
'WebM, common on the web (YouTube, screen recordings), isn’t always readable in editing software or certain players. MP4 offers broader compatibility outside the browser.',
|
||||
benefits: [
|
||||
'Compatible with most video editing software',
|
||||
'Guaranteed playback outside the browser, even on older hardware',
|
||||
'Handy for reusing a screen recording or a downloaded video',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'mp4-to-webm',
|
||||
sourceFormat: 'mp4',
|
||||
targetFormat: 'webm',
|
||||
family: 'video',
|
||||
slugs: { fr: 'convertir-mp4-en-webm', en: 'convert-mp4-to-webm' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir MP4 en WebM en ligne',
|
||||
description: 'Convertissez vos vidéos MP4 en WebM, un format optimisé pour le web et les pages plus rapides.',
|
||||
intro:
|
||||
'Le WebM offre souvent une meilleure compression que le MP4 à qualité comparable et est nativement supporté par tous les navigateurs modernes, un atout pour héberger des vidéos sur un site web.',
|
||||
benefits: [
|
||||
'Compression souvent plus efficace que le MP4 à qualité égale',
|
||||
'Lecture native dans tous les navigateurs modernes, sans plugin',
|
||||
'Format libre de droits, sans licence de codec à payer',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert MP4 to WebM Online',
|
||||
description: 'Convert your MP4 videos to WebM, a format optimized for the web and faster pages.',
|
||||
intro:
|
||||
'WebM often offers better compression than MP4 at comparable quality and is natively supported by every modern browser, a real asset for hosting videos on a website.',
|
||||
benefits: [
|
||||
'Often more efficient compression than MP4 at equal quality',
|
||||
'Native playback in every modern browser, no plugin needed',
|
||||
'A royalty-free format, no codec license fees',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'wav-to-mp3',
|
||||
sourceFormat: 'wav',
|
||||
targetFormat: 'mp3',
|
||||
family: 'audio',
|
||||
slugs: { fr: 'convertir-wav-en-mp3', en: 'convert-wav-to-mp3' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir WAV en MP3 en ligne',
|
||||
description: 'Compressez vos fichiers audio WAV en MP3, jusqu’à 10 fois plus légers.',
|
||||
intro:
|
||||
'Le WAV est un format non compressé, donc volumineux — plusieurs dizaines de Mo par minute. Le MP3 compresse intelligemment le son pour des fichiers bien plus légers, à qualité d’écoute quasi identique.',
|
||||
benefits: [
|
||||
'Fichiers jusqu’à 10 fois plus légers qu’en WAV',
|
||||
'Format le plus universellement lisible (lecteurs, téléphones, voitures)',
|
||||
'Débit binaire ajustable (128 à 320 kbps) selon vos besoins',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert WAV to MP3 Online',
|
||||
description: 'Compress your WAV audio files to MP3, up to 10 times smaller.',
|
||||
intro:
|
||||
'WAV is an uncompressed format, so it’s bulky — tens of megabytes per minute. MP3 intelligently compresses sound for much smaller files, at nearly identical listening quality.',
|
||||
benefits: [
|
||||
'Files up to 10x smaller than WAV',
|
||||
'The most universally playable format (players, phones, cars)',
|
||||
'Adjustable bitrate (128 to 320 kbps) to fit your needs',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'mp3-to-wav',
|
||||
sourceFormat: 'mp3',
|
||||
targetFormat: 'wav',
|
||||
family: 'audio',
|
||||
slugs: { fr: 'convertir-mp3-en-wav', en: 'convert-mp3-to-wav' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir MP3 en WAV en ligne',
|
||||
description: 'Décompressez un MP3 en WAV pour le montage audio ou l’import dans un logiciel professionnel.',
|
||||
intro:
|
||||
'Certains logiciels de montage audio ou de production musicale préfèrent ou exigent un format non compressé comme le WAV pour éviter d’accumuler des pertes de qualité lors des traitements successifs.',
|
||||
benefits: [
|
||||
'Format non compressé, adapté au montage audio professionnel',
|
||||
'Évite d’accumuler les pertes de qualité lors de traitements successifs',
|
||||
'Compatible avec la quasi-totalité des logiciels audio (DAW)',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert MP3 to WAV Online',
|
||||
description: 'Decompress an MP3 to WAV for audio editing or import into professional software.',
|
||||
intro:
|
||||
'Some audio editing or music production software prefers or requires an uncompressed format like WAV, to avoid stacking quality losses across successive processing steps.',
|
||||
benefits: [
|
||||
'Uncompressed format, suited to professional audio editing',
|
||||
'Avoids stacking quality losses across successive processing',
|
||||
'Compatible with virtually every audio software (DAW)',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'm4a-to-mp3',
|
||||
sourceFormat: 'm4a',
|
||||
targetFormat: 'mp3',
|
||||
family: 'audio',
|
||||
slugs: { fr: 'convertir-m4a-en-mp3', en: 'convert-m4a-to-mp3' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir M4A en MP3 en ligne',
|
||||
description: 'Convertissez vos fichiers M4A (Apple Music, mémos vocaux iPhone) en MP3, lisibles partout.',
|
||||
intro:
|
||||
'Le M4A, utilisé par iTunes et les mémos vocaux iPhone, n’est pas toujours reconnu par les autoradios, lecteurs MP3 ou logiciels plus anciens. Le MP3 reste le format audio le plus universellement compatible.',
|
||||
benefits: [
|
||||
'Compatible avec les autoradios, lecteurs MP3 et logiciels anciens',
|
||||
'Idéal pour exporter un mémo vocal ou un morceau iTunes',
|
||||
'Débit binaire ajustable pour maîtriser le poids du fichier',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert M4A to MP3 Online',
|
||||
description: 'Convert your M4A files (Apple Music, iPhone voice memos) to MP3, playable everywhere.',
|
||||
intro:
|
||||
'M4A, used by iTunes and iPhone voice memos, isn’t always recognized by car stereos, MP3 players, or older software. MP3 remains the most universally compatible audio format.',
|
||||
benefits: [
|
||||
'Compatible with car stereos, MP3 players, and older software',
|
||||
'Ideal for exporting a voice memo or an iTunes track',
|
||||
'Adjustable bitrate to control file size',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'flac-to-mp3',
|
||||
sourceFormat: 'flac',
|
||||
targetFormat: 'mp3',
|
||||
family: 'audio',
|
||||
slugs: { fr: 'convertir-flac-en-mp3', en: 'convert-flac-to-mp3' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir FLAC en MP3 en ligne',
|
||||
description: 'Réduisez le poids de vos fichiers FLAC haute résolution en MP3, pour l’écoute mobile au quotidien.',
|
||||
intro:
|
||||
'Le FLAC préserve une qualité audio sans perte mais produit des fichiers volumineux, peu adaptés au stockage limité d’un téléphone. Le MP3 offre un compromis poids/qualité idéal pour l’écoute nomade.',
|
||||
benefits: [
|
||||
'Fichiers nettement plus légers qu’en FLAC',
|
||||
'Adapté au stockage limité d’un smartphone ou baladeur',
|
||||
'Compatible avec la quasi-totalité des lecteurs audio du marché',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert FLAC to MP3 Online',
|
||||
description: 'Shrink your high-resolution FLAC files to MP3, for everyday mobile listening.',
|
||||
intro:
|
||||
'FLAC preserves lossless audio quality but produces large files, ill-suited to a phone’s limited storage. MP3 offers an ideal size/quality trade-off for listening on the go.',
|
||||
benefits: [
|
||||
'Noticeably smaller files than FLAC',
|
||||
'Suited to the limited storage of a smartphone or music player',
|
||||
'Compatible with virtually every audio player on the market',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pairId: 'ogg-to-mp3',
|
||||
sourceFormat: 'ogg',
|
||||
targetFormat: 'mp3',
|
||||
family: 'audio',
|
||||
slugs: { fr: 'convertir-ogg-en-mp3', en: 'convert-ogg-to-mp3' },
|
||||
content: {
|
||||
fr: {
|
||||
title: 'Convertir OGG en MP3 en ligne',
|
||||
description: 'Convertissez vos fichiers audio OGG en MP3 pour une compatibilité maximale sur tous vos appareils.',
|
||||
intro:
|
||||
'L’OGG, utilisé par certains jeux vidéo et logiciels open-source, n’est pas toujours reconnu par les téléphones, autoradios ou lecteurs multimédia grand public. Le MP3 assure une lecture sans souci partout.',
|
||||
benefits: [
|
||||
'Lecture garantie sur téléphones, autoradios et lecteurs grand public',
|
||||
'Format le plus répandu pour le partage et le stockage audio',
|
||||
'Débit binaire ajustable de 128 à 320 kbps',
|
||||
],
|
||||
},
|
||||
en: {
|
||||
title: 'Convert OGG to MP3 Online',
|
||||
description: 'Convert your OGG audio files to MP3 for maximum compatibility across all your devices.',
|
||||
intro:
|
||||
'OGG, used by some video games and open-source software, isn’t always recognized by phones, car stereos, or mainstream media players. MP3 ensures hassle-free playback everywhere.',
|
||||
benefits: [
|
||||
'Guaranteed playback on phones, car stereos, and mainstream players',
|
||||
'The most widespread format for audio sharing and storage',
|
||||
'Adjustable bitrate from 128 to 320 kbps',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,64 @@
|
||||
export const STEP_TEMPLATES = {
|
||||
image: {
|
||||
fr: [
|
||||
'Déposez votre fichier {source} dans la zone prévue, ou cliquez pour le sélectionner.',
|
||||
'Le format {target} est déjà présélectionné comme format de sortie.',
|
||||
"Ajustez la qualité si l'option est disponible pour ce format.",
|
||||
'Cliquez sur Convertir, puis téléchargez votre fichier {target}.',
|
||||
],
|
||||
en: [
|
||||
'Drop your {source} file into the upload area, or click to select it.',
|
||||
'{target} is already pre-selected as the output format.',
|
||||
'Adjust the quality if that option is available for this format.',
|
||||
'Click Convert, then download your {target} file.',
|
||||
],
|
||||
},
|
||||
document: {
|
||||
fr: [
|
||||
'Déposez votre fichier {source} dans la zone prévue, ou cliquez pour le sélectionner.',
|
||||
'Le format {target} est déjà présélectionné comme format de sortie.',
|
||||
'Cliquez sur Convertir pour lancer le traitement.',
|
||||
'Téléchargez votre fichier {target} dès qu’il est prêt.',
|
||||
],
|
||||
en: [
|
||||
'Drop your {source} file into the upload area, or click to select it.',
|
||||
'{target} is already pre-selected as the output format.',
|
||||
'Click Convert to start processing.',
|
||||
'Download your {target} file as soon as it’s ready.',
|
||||
],
|
||||
},
|
||||
video: {
|
||||
fr: [
|
||||
'Déposez votre fichier vidéo {source}, ou cliquez pour le sélectionner.',
|
||||
'Le format {target} est déjà présélectionné comme format de sortie.',
|
||||
'Choisissez la résolution de sortie si besoin (480p, 720p, 1080p).',
|
||||
'Cliquez sur Convertir, puis téléchargez votre vidéo {target}.',
|
||||
],
|
||||
en: [
|
||||
'Drop your {source} video file, or click to select it.',
|
||||
'{target} is already pre-selected as the output format.',
|
||||
'Choose the output resolution if needed (480p, 720p, 1080p).',
|
||||
'Click Convert, then download your {target} video.',
|
||||
],
|
||||
},
|
||||
audio: {
|
||||
fr: [
|
||||
'Déposez votre fichier audio {source}, ou cliquez pour le sélectionner.',
|
||||
'Le format {target} est déjà présélectionné comme format de sortie.',
|
||||
'Choisissez le débit binaire si besoin (128 à 320 kbps).',
|
||||
'Cliquez sur Convertir, puis téléchargez votre fichier {target}.',
|
||||
],
|
||||
en: [
|
||||
'Drop your {source} audio file, or click to select it.',
|
||||
'{target} is already pre-selected as the output format.',
|
||||
'Choose the bitrate if needed (128 to 320 kbps).',
|
||||
'Click Convert, then download your {target} file.',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveSteps(family, lang, sourceFormat, targetFormat) {
|
||||
return STEP_TEMPLATES[family][lang].map((step) =>
|
||||
step.replace('{source}', sourceFormat.toUpperCase()).replace('{target}', targetFormat.toUpperCase())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { conversionPages } from './conversionPages.js';
|
||||
|
||||
export const SUPPORTED_LANGS = ['fr', 'en'];
|
||||
|
||||
export function listAllRoutePaths() {
|
||||
const paths = [];
|
||||
for (const lang of SUPPORTED_LANGS) {
|
||||
paths.push(`/${lang}`);
|
||||
for (const page of conversionPages) {
|
||||
paths.push(`/${lang}/${page.slugs[lang]}`);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { fetchFormats, uploadFiles } from '../api.js';
|
||||
import { extensionOf } from '../utils/archiveExtensions.js';
|
||||
|
||||
const DEFAULT_QUALITY = {
|
||||
jpg: 80,
|
||||
jpeg: 80,
|
||||
webp: 80,
|
||||
avif: 50,
|
||||
tiff: 80,
|
||||
png: 6,
|
||||
zip: 6,
|
||||
'tar.gz': 6,
|
||||
'tar.bz2': 9,
|
||||
'7z': 5,
|
||||
'tar.7z': 5,
|
||||
mp3: 192,
|
||||
ogg: 192,
|
||||
aac: 192,
|
||||
m4a: 192,
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_SIZE = 256;
|
||||
|
||||
function defaultQualityFor(targetFormat) {
|
||||
return DEFAULT_QUALITY[targetFormat] ?? null;
|
||||
}
|
||||
|
||||
export function useConverterWorkflow({ preferredTargetFormat } = {}) {
|
||||
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 =
|
||||
preferredTargetFormat && targets.includes(preferredTargetFormat)
|
||||
? preferredTargetFormat
|
||||
: targets[0] ?? null;
|
||||
return {
|
||||
file,
|
||||
targets,
|
||||
targetFormat,
|
||||
quality: defaultQualityFor(targetFormat),
|
||||
iconSize: targetFormat === 'ico' ? DEFAULT_ICON_SIZE : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
setPendingFiles((current) => [...current, ...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)));
|
||||
}
|
||||
|
||||
function removePendingFile(index) {
|
||||
setPendingFiles((current) => current.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function removeSubmittedJob(index) {
|
||||
setSubmittedJobs((current) => current.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
async function handleConvert() {
|
||||
const validItems = pendingFiles.filter((item) => item.targetFormat);
|
||||
const jobs = await uploadFiles(validItems);
|
||||
const jobsWithSize = jobs.map((job, i) => ({
|
||||
...job,
|
||||
size: validItems[i].file.size,
|
||||
targetFormat: validItems[i].targetFormat,
|
||||
}));
|
||||
setSubmittedJobs((current) => [...current, ...jobsWithSize]);
|
||||
setPendingFiles([]);
|
||||
}
|
||||
|
||||
return {
|
||||
pendingFiles,
|
||||
submittedJobs,
|
||||
handleFilesSelected,
|
||||
updateTargetFormat,
|
||||
updateQuality,
|
||||
updateIconSize,
|
||||
removePendingFile,
|
||||
removeSubmittedJob,
|
||||
handleConvert,
|
||||
};
|
||||
}
|
||||
@@ -2,14 +2,22 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'theme';
|
||||
|
||||
function getInitialTheme() {
|
||||
function readStoredOrPreferredTheme() {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState(getInitialTheme);
|
||||
const [theme, setTheme] = useState('light');
|
||||
|
||||
useEffect(() => {
|
||||
const preferred = readStoredOrPreferredTheme();
|
||||
if (preferred !== theme) {
|
||||
setTheme(preferred);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
+3
-11
@@ -1,15 +1,7 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ViteReactSSG } from 'vite-react-ssg';
|
||||
import 'flag-icons/css/flag-icons.min.css';
|
||||
import './i18n.js';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
import { routes } from './routes.jsx';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
export const createRoot = ViteReactSSG({ routes });
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileCard } from '../FileCard.jsx';
|
||||
import { Dropzone } from '../components/Dropzone.jsx';
|
||||
import { FileConfigCard } from '../components/FileConfigCard.jsx';
|
||||
import { ReassuranceStrip } from '../components/ReassuranceStrip.jsx';
|
||||
import { SeoHead } from '../components/SeoHead.jsx';
|
||||
import { useConverterWorkflow } from '../hooks/useConverterWorkflow.js';
|
||||
import { resolveSteps } from '../data/conversionStepTemplates.js';
|
||||
import '../styles/home.css';
|
||||
import '../styles/sections.css';
|
||||
|
||||
export function ConversionPage({ lang, page }) {
|
||||
const { t } = useTranslation();
|
||||
const copy = page.content[lang];
|
||||
const steps = resolveSteps(page.family, lang, page.sourceFormat, page.targetFormat);
|
||||
const {
|
||||
pendingFiles,
|
||||
submittedJobs,
|
||||
handleFilesSelected,
|
||||
updateTargetFormat,
|
||||
updateQuality,
|
||||
updateIconSize,
|
||||
removePendingFile,
|
||||
removeSubmittedJob,
|
||||
handleConvert,
|
||||
} = useConverterWorkflow({ preferredTargetFormat: page.targetFormat });
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: copy.title,
|
||||
step: steps.map((text, index) => ({ '@type': 'HowToStep', position: index + 1, text })),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoHead
|
||||
lang={lang}
|
||||
path={`/${page.slugs[lang]}`}
|
||||
title={copy.title}
|
||||
description={copy.description}
|
||||
jsonLd={jsonLd}
|
||||
/>
|
||||
<section className="hero">
|
||||
<div className="hero-aurora" aria-hidden="true" />
|
||||
<div className="hero-content">
|
||||
<h1 className="hero-title">{copy.title}</h1>
|
||||
<p>{copy.intro}</p>
|
||||
<ul>
|
||||
{copy.benefits.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
<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}
|
||||
onRemove={removePendingFile}
|
||||
/>
|
||||
))}
|
||||
</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}
|
||||
fileSize={job.size}
|
||||
targetFormat={job.targetFormat}
|
||||
jobId={job.id}
|
||||
onRemove={() => removeSubmittedJob(index)}
|
||||
/>
|
||||
) : (
|
||||
<FileCard
|
||||
key={`${job.file}-${index}`}
|
||||
fileName={job.file}
|
||||
fileSize={job.size}
|
||||
targetFormat={job.targetFormat}
|
||||
initialError={job.error}
|
||||
onRemove={() => removeSubmittedJob(index)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<ol>
|
||||
{steps.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ReassuranceStrip />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
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';
|
||||
@@ -9,104 +6,27 @@ import { ReassuranceStrip } from '../components/ReassuranceStrip.jsx';
|
||||
import { FormatsGrid } from '../components/FormatsGrid.jsx';
|
||||
import { FormatMarquee } from '../components/FormatMarquee.jsx';
|
||||
import { SeoHead } from '../components/SeoHead.jsx';
|
||||
import { extensionOf } from '../utils/archiveExtensions.js';
|
||||
import { useConverterWorkflow } from '../hooks/useConverterWorkflow.js';
|
||||
import '../styles/home.css';
|
||||
import '../styles/sections.css';
|
||||
|
||||
const DEFAULT_QUALITY = {
|
||||
jpg: 80,
|
||||
jpeg: 80,
|
||||
webp: 80,
|
||||
avif: 50,
|
||||
tiff: 80,
|
||||
png: 6,
|
||||
zip: 6,
|
||||
'tar.gz': 6,
|
||||
'tar.bz2': 9,
|
||||
'7z': 5,
|
||||
'tar.7z': 5,
|
||||
mp3: 192,
|
||||
ogg: 192,
|
||||
aac: 192,
|
||||
m4a: 192,
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_SIZE = 256;
|
||||
|
||||
function defaultQualityFor(targetFormat) {
|
||||
return DEFAULT_QUALITY[targetFormat] ?? null;
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { lang } = useParams();
|
||||
export function HomePage({ lang }) {
|
||||
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((current) => [...current, ...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)));
|
||||
}
|
||||
|
||||
function removePendingFile(index) {
|
||||
setPendingFiles((current) => current.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function removeSubmittedJob(index) {
|
||||
setSubmittedJobs((current) => current.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
async function handleConvert() {
|
||||
const validItems = pendingFiles.filter((item) => item.targetFormat);
|
||||
const jobs = await uploadFiles(validItems);
|
||||
const jobsWithSize = jobs.map((job, i) => ({
|
||||
...job,
|
||||
size: validItems[i].file.size,
|
||||
targetFormat: validItems[i].targetFormat,
|
||||
}));
|
||||
setSubmittedJobs((current) => [...current, ...jobsWithSize]);
|
||||
setPendingFiles([]);
|
||||
}
|
||||
const {
|
||||
pendingFiles,
|
||||
submittedJobs,
|
||||
handleFilesSelected,
|
||||
updateTargetFormat,
|
||||
updateQuality,
|
||||
updateIconSize,
|
||||
removePendingFile,
|
||||
removeSubmittedJob,
|
||||
handleConvert,
|
||||
} = useConverterWorkflow();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeoHead lang={lang} />
|
||||
<SeoHead lang={lang} path="" />
|
||||
<section className="hero">
|
||||
<div className="hero-aurora" aria-hidden="true" />
|
||||
<div className="hero-content">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SeoHead } from '../components/SeoHead.jsx';
|
||||
|
||||
export function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="not-found">
|
||||
<SeoHead lang="fr" path="/404" noindex title={t('notFound.title')} description={t('notFound.title')} />
|
||||
<h1>{t('notFound.title')}</h1>
|
||||
<Link to="/fr/">{t('notFound.backHome')}</Link>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { RootRedirect } from './RootRedirect.jsx';
|
||||
import { LangLayout } from './components/LangLayout.jsx';
|
||||
import { HomePage } from './pages/HomePage.jsx';
|
||||
import { NotFound } from './pages/NotFound.jsx';
|
||||
import { ConversionPage } from './pages/ConversionPage.jsx';
|
||||
import { conversionPages } from './data/conversionPages.js';
|
||||
import { SUPPORTED_LANGS } from './data/routePaths.js';
|
||||
|
||||
function buildLangRoute(lang) {
|
||||
return {
|
||||
path: `/${lang}`,
|
||||
Component: () => <LangLayout lang={lang} />,
|
||||
children: [
|
||||
{ index: true, Component: () => <HomePage lang={lang} /> },
|
||||
...conversionPages.map((page) => ({
|
||||
path: page.slugs[lang],
|
||||
Component: () => <ConversionPage lang={lang} page={page} />,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export const routes = [
|
||||
{ path: '/', Component: RootRedirect },
|
||||
...SUPPORTED_LANGS.map(buildLangRoute),
|
||||
{ path: '/404', Component: NotFound },
|
||||
{ path: '*', Component: NotFound },
|
||||
];
|
||||
@@ -1,5 +1,20 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { listAllRoutePaths } from './src/data/routePaths.js';
|
||||
|
||||
const SITE_URL = 'https://convert.ombrora.com';
|
||||
|
||||
function writeSitemap(outDir) {
|
||||
const paths = listAllRoutePaths();
|
||||
const lastmod = new Date().toISOString().slice(0, 10);
|
||||
const urls = paths
|
||||
.map((p) => ` <url>\n <loc>${SITE_URL}${p}/</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`)
|
||||
.join('\n');
|
||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
|
||||
fs.writeFileSync(path.join(outDir, 'sitemap.xml'), sitemap);
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -8,4 +23,16 @@ export default defineConfig({
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
ssgOptions: {
|
||||
dirStyle: 'nested',
|
||||
// 'defer' (not 'async'): the entry module reads window.__VITE_REACT_SSG_HASH__,
|
||||
// which is set by a plain inline <script> placed later in the document body.
|
||||
// An async module can start executing as soon as it's fetched, racing ahead
|
||||
// of the parser reaching that later inline script — intermittently reading
|
||||
// it as undefined depending on network/cache timing. defer guarantees the
|
||||
// module only runs after the full document (including that inline script)
|
||||
// has been parsed.
|
||||
script: 'defer',
|
||||
onFinished: writeSitemap,
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+827
-91
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -49,7 +49,8 @@
|
||||
"sharp": "^0.35.3",
|
||||
"tar": "^7.5.22",
|
||||
"turndown": "^7.2.4",
|
||||
"uuid": "^14.0.1"
|
||||
"uuid": "^14.0.1",
|
||||
"vite-react-ssg": "^0.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
|
||||
Reference in New Issue
Block a user