feat(frontend): redesign homepage and file config form with aurora palette

Rebuild the visual identity from the actual Ombrora mark (violet/cyan aurora
blobs) instead of the generic blue/amber scheme, add Bricolage Grotesque and
IBM Plex Mono for display and code-like text, and give the hero an animated
aurora backdrop, gradient headline, and a format-pair marquee driven by the
real registered converters.

Rework the file config form: target format and icon size are both chip
groups now instead of a native select, and quality/compression sliders get a
gradient-filled track, custom thumb, and a mono value pill to match. File
type icons are color-coded by family (images/documents/fonts/ebooks) and
reused across FileConfigCard, FileCard, and FormatsGrid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 16:34:13 +02:00
co-authored by Claude Sonnet 5
parent 023a96e5d8
commit 987af80286
14 changed files with 770 additions and 206 deletions
+91 -56
View File
@@ -1,96 +1,131 @@
import { File as FileIcon } from '@phosphor-icons/react';
import { formatBytes } from '../utils/formatBytes.js';
import { fileTypeMeta } from '../utils/fileFamily.js';
const QUALITY_FORMATS = ['jpg', 'jpeg', 'webp', 'avif', 'tiff'];
const ICON_SIZES = [16, 32, 48, 256, 512];
const DEFAULT_ICON_SIZE = 256;
function extensionOf(fileName) {
return fileName.split('.').pop();
}
function rangeProgress(value, min, max) {
return `${((value - min) / (max - min)) * 100}%`;
}
function RangeField({ label, value, min, max, onChange }) {
return (
<div className="range-field">
<div className="range-field-header">
<span>{label}</span>
<span className="value-pill">{value}</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
style={{ '--range-progress': rangeProgress(value, min, max) }}
onChange={(event) => onChange(Number(event.target.value))}
/>
</div>
);
}
export function FileConfigCard({ item, index, t, onTargetFormatChange, onQualityChange, onIconSizeChange }) {
const { Icon, colorVar } = fileTypeMeta(extensionOf(item.file.name));
return (
<li className="file-config-card">
<div className="file-tile-header">
<FileIcon size={24} weight="regular" />
<span className="file-tile-icon" style={{ '--tile-color': `var(${colorVar})` }}>
<Icon size={20} weight="regular" />
</span>
<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)}
>
<div className="chip-field">
<span>{t('quality.targetFormat')}</span>
<div className="chip-group" role="group" aria-label={t('quality.targetFormat')}>
{item.targets.map((target) => (
<option key={target} value={target}>
{target}
</option>
<button
key={target}
type="button"
className={`chip${item.targetFormat === target ? ' chip-active' : ''}`}
aria-pressed={item.targetFormat === target}
onClick={() => onTargetFormatChange(index, target)}
>
{target.toUpperCase()}
</button>
))}
</select>
{item.targetFormat && <span className="format-badge">{item.targetFormat.toUpperCase()}</span>}
</div>
</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>
<RangeField
label={t('quality.label')}
value={item.quality}
min={1}
max={100}
onChange={(value) => onQualityChange(index, value)}
/>
)}
{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>
<RangeField
label={t('quality.compression')}
value={item.quality}
min={0}
max={9}
onChange={(value) => onQualityChange(index, value)}
/>
)}
{item.targetFormat === 'ico' && (
<label>
{t('quality.iconSize')}
<select
value={item.iconSize ?? DEFAULT_ICON_SIZE}
onChange={(event) => onIconSizeChange(index, Number(event.target.value))}
>
<div className="chip-field">
<span>{t('quality.iconSize')}</span>
<div className="chip-group" role="group" aria-label={t('quality.iconSize')}>
{ICON_SIZES.map((size) => (
<option key={size} value={size}>
<button
key={size}
type="button"
className={`chip${(item.iconSize ?? DEFAULT_ICON_SIZE) === size ? ' chip-active' : ''}`}
aria-pressed={(item.iconSize ?? DEFAULT_ICON_SIZE) === size}
onClick={() => onIconSizeChange(index, size)}
>
{size}px
</option>
</button>
))}
</select>
</label>
</div>
</div>
)}
{item.targetFormat === 'pdf' && (
<label>
<input
type="checkbox"
checked={item.quality !== null}
onChange={(event) => onQualityChange(index, event.target.checked ? 90 : null)}
/>
{t('quality.compressPdf')}
<div className="pdf-field">
<label className="switch-row">
<span className="switch">
<input
type="checkbox"
checked={item.quality !== null}
onChange={(event) => onQualityChange(index, event.target.checked ? 90 : null)}
/>
<span className="switch-track" aria-hidden="true" />
</span>
<span>{t('quality.compressPdf')}</span>
</label>
{item.quality !== null && (
<input
type="range"
min="1"
max="100"
<RangeField
label={t('quality.label')}
value={item.quality}
onChange={(event) => onQualityChange(index, Number(event.target.value))}
min={1}
max={100}
onChange={(value) => onQualityChange(index, value)}
/>
)}
</label>
</div>
)}
</div>
) : (
+31
View File
@@ -0,0 +1,31 @@
import { ArrowRight } from '@phosphor-icons/react';
const PAIRS = [
['heic', 'jpg'],
['png', 'webp'],
['docx', 'pdf'],
['ttf', 'woff'],
['epub', 'mobi'],
];
function Pill({ from, to }) {
return (
<span className="format-pill">
<span>{from.toUpperCase()}</span>
<ArrowRight size={12} weight="bold" />
<span>{to.toUpperCase()}</span>
</span>
);
}
export function FormatMarquee() {
return (
<div className="format-marquee" aria-hidden="true">
<div className="format-marquee-track">
{[...PAIRS, ...PAIRS].map(([from, to], index) => (
<Pill key={`${from}-${to}-${index}`} from={from} to={to} />
))}
</div>
</div>
);
}
+20 -6
View File
@@ -1,18 +1,32 @@
import { useTranslation } from 'react-i18next';
import { Image, FileText, TextAa, BookOpen } from '@phosphor-icons/react';
import { FORMAT_FAMILIES } from '../data/formats.js';
const FAMILY_ICONS = {
images: Image,
documents: FileText,
fonts: TextAa,
ebooks: BookOpen,
};
export function FormatsGrid() {
const { t } = useTranslation();
return (
<section className="formats-grid-section">
<h2>{t('formats.title')}</h2>
<div className="formats-grid">
{FORMAT_FAMILIES.map(({ key, formats }) => (
<div key={key} className="formats-family">
<h3>{t(`formats.${key}`)}</h3>
<p>{formats.join(', ').toUpperCase()}</p>
</div>
))}
{FORMAT_FAMILIES.map(({ key, formats }) => {
const Icon = FAMILY_ICONS[key];
return (
<div key={key} className="formats-family" style={{ '--tile-color': `var(--color-family-${key})` }}>
<span className="formats-family-icon">
<Icon size={22} weight="regular" />
</span>
<h3>{t(`formats.${key}`)}</h3>
<p>{formats.join(', ').toUpperCase()}</p>
</div>
);
})}
</div>
</section>
);
+19 -2
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Clock, LockKey, CloudArrowUp, HardDrives } from '@phosphor-icons/react';
import { fetchConfig } from '../api.js';
const ITEMS = [
{ key: 'autoDelete', Icon: Clock },
@@ -10,14 +12,29 @@ const ITEMS = [
export function ReassuranceStrip() {
const { t } = useTranslation();
const [config, setConfig] = useState(null);
useEffect(() => {
fetchConfig().then(setConfig).catch(() => {});
}, []);
if (!config) return null;
const values = {
autoDelete: config.retentionHours,
maxSize: config.maxFileSizeMb,
};
return (
<section className="reassurance">
<h2>{t('reassurance.title')}</h2>
<ul className="reassurance-list">
{ITEMS.map(({ key, Icon }) => (
<li key={key}>
<Icon size={24} weight="regular" />
<span>{t(`reassurance.${key}`)}</span>
<span className="reassurance-icon">
<Icon size={22} weight="regular" />
</span>
<span>{t(`reassurance.${key}`, { value: values[key] })}</span>
</li>
))}
</ul>