feat(seo): add conversion-page scaffolding with 2 seed pairs (jpg-to-png, pdf-to-docx)

This commit is contained in:
2026-08-02 10:32:53 +02:00
parent 3b53d82b3d
commit e6ab78de29
5 changed files with 361 additions and 93 deletions
+65 -1
View File
@@ -1 +1,65 @@
export const conversionPages = [];
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 quil 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 PDFs content.',
benefits: [
'Genuinely editable text, not a scanned image',
'Layout approximately preserved',
'Avoids retyping an entire document by hand',
],
},
},
},
];
@@ -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 quil 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 its 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())
);
}
+108
View File
@@ -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,
};
}
+111 -1
View File
@@ -1,3 +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 }) {
return null;
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 />
</>
);
}
+13 -91
View File
@@ -1,6 +1,4 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { fetchFormats, uploadFiles } from '../api.js';
import { FileCard } from '../FileCard.jsx';
import { Dropzone } from '../components/Dropzone.jsx';
import { FileConfigCard } from '../components/FileConfigCard.jsx';
@@ -8,103 +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({ 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">