specs
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user