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