feat: redesign landing page sections and file cards

Redesign 'How it works' as illustrated timeline with unique scene
per step (pink/purple/mint), connecting line, and staggered animations.
Redesign 'Features' as asymmetric bento grid and 'Privacy' as
side-by-side layout with animated shield. Redesign FileCard as paper
note style with random rotation, washi tape strip, ruled lines, and
typewriter typography.
This commit is contained in:
noah
2026-03-09 21:02:41 +01:00
parent 5b1182a818
commit dee839964b
3 changed files with 496 additions and 258 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ export default function ConvertPage() {
{/* File Grid */}
{hasFiles && (
<motion.div
className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-4 px-4 sm:px-6 pb-44 sm:pb-36 relative z-10"
className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-6 px-4 sm:px-6 pt-2 pb-44 sm:pb-36 relative z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
+277 -99
View File
@@ -51,48 +51,54 @@ const flowSteps = [
{ inputIcon: '\u{1F3B5}', inputLabel: '.FLAC', outputIcon: '\u{1F3A7}', outputLabel: '.MP3' },
];
/* ─── Features ─── */
/* ─── Bento Features ─── */
const features = [
const bentoFeatures = [
{
icon: '\u{1F5BC}',
title: 'Images',
desc: 'PNG, JPG, WebP, GIF, BMP, AVIF, SVG, PSD, HEIC \u2014 convert between any format.',
iconBg: 'bg-pink-100',
formats: ['PNG', 'JPG', 'WebP', 'GIF', 'AVIF', 'SVG', 'PSD', 'HEIC'],
wide: true,
desc: 'Convert between any image format with zero quality loss.',
accent: '#f472b6',
accentLight: 'rgba(244,114,182,0.08)',
formats: ['PNG', 'JPG', 'WebP', 'GIF', 'AVIF', 'SVG', 'PSD', 'HEIC', 'BMP', 'TIFF', 'ICO'],
// Bento: tall left column
gridArea: 'images',
},
{
icon: '\u{1F4C4}',
title: 'Documents',
desc: 'DOCX, PDF, Markdown, HTML, TXT, PPTX, EPUB \u2014 preserves formatting.',
iconBg: 'bg-blue-100',
formats: ['DOCX', 'PDF', 'MD', 'HTML', 'TXT', 'PPTX', 'EPUB'],
wide: false,
desc: 'Full formatting preservation across office and web formats.',
accent: '#60a5fa',
accentLight: 'rgba(96,165,250,0.08)',
formats: ['DOCX', 'PDF', 'MD', 'HTML', 'TXT', 'RTF', 'PPTX', 'EPUB'],
gridArea: 'docs',
},
{
icon: '\u{1F3B5}',
title: 'Audio',
desc: 'MP3, WAV, OGG, AAC, FLAC, M4A \u2014 powered by FFmpeg WebAssembly.',
iconBg: 'bg-purple-100',
formats: ['MP3', 'WAV', 'OGG', 'FLAC', 'AAC'],
wide: false,
desc: 'FFmpeg WebAssembly powered.',
accent: '#a78bfa',
accentLight: 'rgba(167,139,250,0.08)',
formats: ['MP3', 'WAV', 'OGG', 'FLAC', 'AAC', 'M4A'],
gridArea: 'audio',
},
{
icon: '\u{1F3AC}',
title: 'Video',
desc: 'MP4, WebM, AVI, MOV, MKV \u2014 full video transcoding in your browser.',
iconBg: 'bg-orange-100',
desc: 'Full transcoding in your browser.',
accent: '#fb923c',
accentLight: 'rgba(251,146,60,0.08)',
formats: ['MP4', 'WebM', 'AVI', 'MOV', 'MKV'],
wide: false,
gridArea: 'video',
},
{
icon: '\u{1F4CA}',
title: 'Data & Fonts',
desc: 'CSV, JSON, XML, YAML, XLSX, TTF, OTF, WOFF2 \u2014 smart structure preservation.',
iconBg: 'bg-emerald-100',
formats: ['CSV', 'JSON', 'XML', 'YAML', 'XLSX', 'TTF', 'WOFF2'],
wide: true,
desc: 'Smart structure preservation for structured data and typography.',
accent: '#34d399',
accentLight: 'rgba(52,211,153,0.08)',
formats: ['CSV', 'JSON', 'XML', 'YAML', 'XLSX', 'TSV', 'TOML', 'TTF', 'OTF', 'WOFF2'],
gridArea: 'data',
},
];
@@ -448,7 +454,7 @@ export default function LandingPage() {
<ConversionFlow />
</section>
{/* ──── FEATURES ──── */}
{/* ──── FEATURES — BENTO GRID ──── */}
<section
id="features"
className="relative z-10 flex flex-col items-center gap-10 px-6 py-20"
@@ -471,43 +477,90 @@ export default function LandingPage() {
</p>
</motion.div>
<motion.div
className="grid grid-cols-1 md:grid-cols-3 gap-5 max-w-[960px] w-full"
variants={stagger}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-60px' }}
{/* Bento Grid — asymmetric masonry layout */}
<div
className="w-full max-w-[1060px] grid gap-4 md:gap-5"
style={{
gridTemplateColumns: 'repeat(12, 1fr)',
gridTemplateRows: 'auto auto auto',
gridTemplateAreas: `
"images images images images images docs docs docs docs docs docs docs"
"audio audio audio audio video video video video data data data data"
"audio audio audio audio video video video video data data data data"
`,
}}
>
{features.map((feat) => (
{bentoFeatures.map((feat, i) => (
<motion.div
key={feat.title}
className={`relative bg-white border border-border-soft rounded-3xl p-7 overflow-hidden shadow-[0_1px_3px_rgba(160,120,80,0.06)] hover:shadow-[0_12px_32px_rgba(160,120,80,0.1)] hover:-translate-y-1 hover:border-border-med transition-all duration-300 ${feat.wide ? 'md:col-span-2' : ''}`}
variants={fadeUp}
className="relative bg-white rounded-[20px] overflow-hidden shadow-[0_1px_3px_rgba(160,120,80,0.06)] hover:shadow-[0_12px_40px_rgba(160,120,80,0.12)] hover:-translate-y-0.5 transition-all duration-300 group"
style={{
gridArea: feat.gridArea,
borderLeft: `3px solid ${feat.accent}`,
}}
initial={{ opacity: 0, y: 28 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-40px' }}
transition={{ duration: 0.5, delay: i * 0.08, ease: [0.16, 1, 0.3, 1] as const }}
>
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-[22px] mb-4 ${feat.iconBg}`}>
{feat.icon}
</div>
<h3 className="font-serif font-bold text-[19px] text-text-dark mb-2">{feat.title}</h3>
<p className="text-sm text-text-mid leading-relaxed">{feat.desc}</p>
<div className="flex flex-wrap gap-1.5 mt-4">
{feat.formats.map((f) => (
<span
key={f}
className="px-2.5 py-1 font-mono text-[11px] font-semibold rounded-full bg-bg-peach text-text-mid border border-border-soft"
{/* Top accent bar */}
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ background: feat.accent, opacity: 0.4 }}
/>
<div className="p-6 md:p-7 h-full flex flex-col">
{/* Header row */}
<div className="flex items-start gap-3.5 mb-3">
<div
className="w-11 h-11 rounded-xl flex items-center justify-center text-[20px] flex-shrink-0"
style={{ background: feat.accentLight }}
>
.{f}
</span>
))}
{feat.icon}
</div>
<div>
<h3 className="font-serif font-bold text-[18px] text-text-dark leading-tight">{feat.title}</h3>
<p className="text-[13px] text-text-mid leading-relaxed mt-0.5">{feat.desc}</p>
</div>
</div>
{/* Format badges — grow to fill space */}
<div className="flex flex-wrap gap-1.5 mt-auto pt-3">
{feat.formats.map((f) => (
<span
key={f}
className="px-2.5 py-[5px] font-mono text-[10px] font-bold rounded-lg border transition-colors duration-200"
style={{
borderColor: `${feat.accent}25`,
color: feat.accent,
background: feat.accentLight,
}}
>
.{f}
</span>
))}
</div>
</div>
</motion.div>
))}
</motion.div>
</div>
{/* Total count callout */}
<motion.p
className="text-sm text-text-light font-mono tracking-wide"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.5 }}
>
70+ formats supported &mdash; and counting
</motion.p>
</section>
{/* ──── HOW IT WORKS ──── */}
<section className="relative z-10 flex flex-col items-center gap-10 px-6 py-10 pb-24">
<section className="relative z-10 flex flex-col items-center px-6 py-10 pb-24">
<motion.div
className="text-center flex flex-col items-center gap-3"
className="text-center flex flex-col items-center gap-3 mb-14"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
@@ -521,70 +574,195 @@ export default function LandingPage() {
</h2>
</motion.div>
<motion.div
className="flex flex-col md:flex-row gap-6 md:gap-8 max-w-[880px] w-full"
variants={stagger}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-60px' }}
>
{[
{ num: '1', icon: '\u{1F4E5}', title: 'Drop your files', desc: 'Drag and drop any file \u2014 or click to browse. We accept everything.' },
{ num: '2', icon: '\u{2699}', title: 'Pick a format', desc: 'Choose your target format from smart suggestions based on file type.' },
{ num: '3', icon: '\u{2B07}', title: 'Download', desc: 'Hit convert and download instantly. Files never leave your browser.' },
].map((step, i) => (
{/* Timeline layout */}
<div className="relative max-w-[960px] w-full">
{/* Connecting line — desktop only */}
<div className="absolute top-[52px] left-[calc(8.33%+24px)] right-[calc(8.33%+24px)] h-[2px] bg-border-soft hidden md:block" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-0">
{/* Step 1 — Drop */}
<motion.div
key={step.num}
className="flex-1 relative bg-white border border-border-soft rounded-3xl p-8 text-center shadow-[0_1px_3px_rgba(160,120,80,0.06)] hover:shadow-[0_12px_32px_rgba(160,120,80,0.1)] hover:-translate-y-1 transition-all duration-300"
variants={fadeUp}
className="flex flex-col items-center text-center px-4"
initial={{ opacity: 0, y: 28 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-40px' }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
>
<div className="text-3xl mb-3">{step.icon}</div>
<div className="w-10 h-10 rounded-full inline-flex items-center justify-center font-serif font-extrabold text-lg text-white bg-pink mb-4">
{step.num}
</div>
<h3 className="font-serif font-bold text-lg text-text-dark mb-2">{step.title}</h3>
<p className="text-sm text-text-mid leading-relaxed">{step.desc}</p>
{i < 2 && (
<div className="absolute top-1/2 -right-5 -translate-y-1/2 text-text-light hidden md:block">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
{/* Visual scene */}
<div className="relative w-[104px] h-[104px] mb-5">
{/* Background shape */}
<div className="absolute inset-0 rounded-[28px] bg-pink/8 rotate-3" />
<div className="relative w-full h-full rounded-[28px] bg-white border-2 border-pink/20 shadow-[0_4px_20px_rgba(244,114,182,0.1)] flex items-center justify-center -rotate-1">
{/* File stack */}
<div className="relative">
<div className="absolute -top-1 -left-1 w-10 h-12 rounded-lg bg-pink/10 border border-pink/15 rotate-[-6deg]" />
<div className="absolute -top-0.5 left-0 w-10 h-12 rounded-lg bg-pink/8 border border-pink/12 rotate-[-3deg]" />
<div className="relative w-10 h-12 rounded-lg bg-white border-[1.5px] border-pink/25 flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" className="text-pink">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
</div>
)}
</div>
{/* Number + text */}
<div className="w-8 h-8 rounded-full bg-pink flex items-center justify-center font-serif font-extrabold text-sm text-white mb-3 shadow-[0_2px_8px_rgba(244,114,182,0.3)]">
1
</div>
<h3 className="font-serif font-bold text-[17px] text-text-dark mb-1.5">Drop your files</h3>
<p className="text-[13px] text-text-mid leading-relaxed max-w-[220px]">
Drag and drop anything {'\u2014'} images, docs, audio, video, data. We handle 70+ formats.
</p>
</motion.div>
))}
</motion.div>
{/* Step 2 — Pick */}
<motion.div
className="flex flex-col items-center text-center px-4"
initial={{ opacity: 0, y: 28 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-40px' }}
transition={{ duration: 0.5, delay: 0.1, ease: [0.16, 1, 0.3, 1] as const }}
>
{/* Visual scene */}
<div className="relative w-[104px] h-[104px] mb-5">
<div className="absolute inset-0 rounded-[28px] bg-purple/8 -rotate-2" />
<div className="relative w-full h-full rounded-[28px] bg-white border-2 border-purple/20 shadow-[0_4px_20px_rgba(167,139,250,0.1)] flex items-center justify-center rotate-1">
{/* Format picker mini-UI */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<div className="w-[42px] h-[14px] rounded-md bg-purple/15 border border-purple/20" />
<div className="w-3 h-3 rounded-full bg-purple/30 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-purple" />
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="w-[42px] h-[14px] rounded-md bg-purple/8 border border-purple/10" />
<div className="w-3 h-3 rounded-full border border-purple/20" />
</div>
<div className="flex items-center gap-1.5">
<div className="w-[42px] h-[14px] rounded-md bg-purple/8 border border-purple/10" />
<div className="w-3 h-3 rounded-full border border-purple/20" />
</div>
</div>
</div>
</div>
{/* Number + text */}
<div className="w-8 h-8 rounded-full bg-purple flex items-center justify-center font-serif font-extrabold text-sm text-white mb-3 shadow-[0_2px_8px_rgba(167,139,250,0.3)]">
2
</div>
<h3 className="font-serif font-bold text-[17px] text-text-dark mb-1.5">Pick a format</h3>
<p className="text-[13px] text-text-mid leading-relaxed max-w-[220px]">
Smart suggestions based on your file type. Or choose any compatible output format.
</p>
</motion.div>
{/* Step 3 — Download */}
<motion.div
className="flex flex-col items-center text-center px-4"
initial={{ opacity: 0, y: 28 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-40px' }}
transition={{ duration: 0.5, delay: 0.2, ease: [0.16, 1, 0.3, 1] as const }}
>
{/* Visual scene */}
<div className="relative w-[104px] h-[104px] mb-5">
<div className="absolute inset-0 rounded-[28px] bg-mint/8 rotate-2" />
<div className="relative w-full h-full rounded-[28px] bg-white border-2 border-mint/20 shadow-[0_4px_20px_rgba(52,211,153,0.1)] flex items-center justify-center -rotate-1">
{/* Checkmark + download visual */}
<div className="relative">
<div className="w-12 h-12 rounded-2xl bg-mint/10 border-[1.5px] border-mint/25 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="text-mint">
<path d="M20 6L9 17l-5-5" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{/* Tiny sparkles */}
<div className="absolute -top-1.5 -right-1.5 w-2.5 h-2.5 rounded-full bg-mint/30" />
<div className="absolute -bottom-1 -left-2 w-2 h-2 rounded-full bg-mint/20" />
</div>
</div>
</div>
{/* Number + text */}
<div className="w-8 h-8 rounded-full bg-mint flex items-center justify-center font-serif font-extrabold text-sm text-white mb-3 shadow-[0_2px_8px_rgba(52,211,153,0.3)]">
3
</div>
<h3 className="font-serif font-bold text-[17px] text-text-dark mb-1.5">Download</h3>
<p className="text-[13px] text-text-mid leading-relaxed max-w-[220px]">
Converted instantly in your browser. Hit download {'\u2014'} done. Files never leave your machine.
</p>
</motion.div>
</div>
</div>
</section>
{/* ──── PRIVACY ──── */}
<section className="relative z-10 flex flex-col items-center px-6 pb-20">
<section className="relative z-10 flex justify-center px-6 pb-20">
<motion.div
className="relative max-w-[720px] w-full bg-white border-[1.5px] border-border-soft rounded-[32px] p-12 text-center shadow-[0_4px_12px_rgba(160,120,80,0.08)] overflow-hidden border-t-rainbow"
initial={{ opacity: 0, y: 20, scale: 0.97 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
className="relative max-w-[960px] w-full flex flex-col md:flex-row items-center gap-10 md:gap-16"
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] as const }}
>
<div className="text-4xl mb-4">{'\u{1F6E1}'}</div>
<h2 className="font-serif font-extrabold text-[28px] text-text-dark mb-3">Your files stay yours</h2>
<p className="text-base text-text-mid leading-[1.7] max-w-[500px] mx-auto">
Every conversion happens entirely in your browser using WebAssembly and Canvas APIs.
No file ever touches a server. No data is collected. No account needed. Ever.
</p>
<div className="flex justify-center gap-6 mt-7 flex-wrap">
{[
{ icon: '\u{1F6AB}', label: 'No uploads', color: 'bg-emerald-50' },
{ icon: '\u{1F4BB}', label: 'No servers', color: 'bg-blue-50' },
{ icon: '\u{1F440}', label: 'No tracking', color: 'bg-purple-50' },
{ icon: '\u{267E}', label: 'No limits', color: 'bg-orange-50' },
].map((b) => (
<div key={b.label} className="flex items-center gap-2 text-sm font-semibold text-text-mid">
<div className={`w-8 h-8 rounded-[10px] flex items-center justify-center ${b.color}`}>
<span className="text-base">{b.icon}</span>
{/* Left — Shield visual */}
<div className="relative flex-shrink-0 w-[200px] h-[200px] md:w-[260px] md:h-[260px] flex items-center justify-center">
{/* Pulsing rings */}
<motion.div
className="absolute inset-0 rounded-full border-2 border-mint/20"
animate={{ scale: [1, 1.15, 1], opacity: [0.4, 0.1, 0.4] }}
transition={{ duration: 3, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="absolute inset-4 rounded-full border-2 border-mint/15"
animate={{ scale: [1, 1.1, 1], opacity: [0.3, 0.08, 0.3] }}
transition={{ duration: 3, repeat: Infinity, ease: 'easeInOut', delay: 0.5 }}
/>
{/* Shield body */}
<div className="relative w-28 h-28 md:w-36 md:h-36 bg-white rounded-[28px] border-2 border-mint/25 shadow-[0_8px_40px_rgba(52,211,153,0.12)] flex items-center justify-center">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" className="text-mint md:w-[64px] md:h-[64px]">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="rgba(52,211,153,0.08)" />
<path d="M9 12l2 2 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
{/* Right — Text + privacy points */}
<div className="flex flex-col text-center md:text-left">
<span className="inline-flex self-center md:self-start items-center gap-2 px-3.5 py-1.5 bg-mint/10 rounded-full font-mono text-[11px] font-semibold uppercase tracking-wider text-mint mb-4">
Privacy First
</span>
<h2 className="font-serif font-extrabold text-[clamp(28px,4vw,40px)] leading-[1.1] tracking-tight text-text-dark mb-3">
Your files stay yours
</h2>
<p className="text-[15px] text-text-mid leading-[1.7] max-w-[440px]">
Every conversion happens entirely in your browser using WebAssembly and Canvas APIs.
No file ever touches a server. No data is collected. No account needed.
</p>
{/* Privacy points — stacked vertically */}
<div className="grid grid-cols-2 gap-3 mt-7">
{[
{ icon: '\u{1F512}', label: 'No uploads', desc: 'Files stay on your device', accent: '#34d399' },
{ icon: '\u{1F6AB}', label: 'No servers', desc: 'Zero network requests', accent: '#60a5fa' },
{ icon: '\u{1F440}', label: 'No tracking', desc: 'No analytics or cookies', accent: '#a78bfa' },
{ icon: '\u{267E}\uFE0F', label: 'No limits', desc: 'Unlimited file size & count', accent: '#fb923c' },
].map((point) => (
<div
key={point.label}
className="flex items-start gap-3 bg-white rounded-2xl p-3.5 border border-border-soft shadow-[0_1px_3px_rgba(160,120,80,0.04)]"
>
<div
className="w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 text-[16px]"
style={{ background: `${point.accent}12` }}
>
{point.icon}
</div>
<div>
<div className="text-[13px] font-bold text-text-dark">{point.label}</div>
<div className="text-[11px] text-text-light leading-snug">{point.desc}</div>
</div>
</div>
{b.label}
</div>
))}
))}
</div>
</div>
</motion.div>
</section>
+218 -158
View File
@@ -1,5 +1,6 @@
'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion';
import { UploadedFile, CATEGORY_COLORS, CATEGORY_LABELS } from '@/types';
import { formatFileSize, truncateFilename } from '@/lib/utils';
@@ -14,6 +15,15 @@ interface FileCardProps {
onPreview: (file: UploadedFile) => void;
}
/* Seeded random for consistent per-card rotation */
function seededRandom(seed: string) {
let h = 0;
for (let i = 0; i < seed.length; i++) {
h = Math.imul(31, h) + seed.charCodeAt(i) | 0;
}
return ((h >>> 0) % 1000) / 1000;
}
export function FileCard({
file,
index,
@@ -25,190 +35,240 @@ export function FileCard({
const categoryColor = CATEGORY_COLORS[file.category];
const categoryLabel = CATEGORY_LABELS[file.category];
// Stable random rotation per card (-2.5 to 2.5 degrees)
const rotation = useMemo(() => {
const r = seededRandom(file.id);
return (r - 0.5) * 5;
}, [file.id]);
// Slight random tape offset
const tapeOffset = useMemo(() => {
const r = seededRandom(file.id + 'tape');
return (r - 0.5) * 20; // -10 to 10px
}, [file.id]);
return (
<motion.div
className="relative bg-white rounded-2xl overflow-hidden border border-border-soft shadow-[0_2px_12px_rgba(180,140,100,0.06)] hover:shadow-[0_4px_24px_rgba(180,140,100,0.1)] hover:-translate-y-0.5 transition-all duration-200"
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
className="relative group"
style={{
transform: `rotate(${rotation}deg)`,
}}
initial={{ opacity: 0, y: 24, rotate: rotation }}
animate={{ opacity: 1, y: 0, rotate: rotation }}
exit={{ opacity: 0, scale: 0.9, rotate: rotation + 5 }}
transition={{
duration: 0.4,
delay: index * 0.05,
duration: 0.45,
delay: index * 0.04,
ease: [0.16, 1, 0.3, 1] as const,
}}
whileHover={{
rotate: 0,
scale: 1.03,
y: -4,
transition: { duration: 0.25, ease: [0.16, 1, 0.3, 1] as const },
}}
layout
>
{/* Top accent line */}
{/* Paper shadow — slightly offset for depth */}
<div
className="h-[3px] w-full"
style={{ background: categoryColor }}
className="absolute inset-0 rounded-sm bg-text-dark/[0.03] translate-y-1 translate-x-0.5"
style={{ filter: 'blur(4px)' }}
/>
{/* Header: category badge + remove */}
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<span
className="inline-flex items-center px-2.5 py-0.5 text-[11px] font-bold font-mono tracking-wider uppercase rounded-full border"
{/* Main paper */}
<div className="relative bg-[#fffef9] rounded-sm overflow-visible shadow-[0_1px_2px_rgba(120,100,70,0.08)]">
{/* Tape strip across top */}
<div
className="absolute -top-2.5 z-10 w-16 h-6 rounded-[2px] opacity-70"
style={{
background: `${categoryColor}18`,
color: categoryColor,
borderColor: `${categoryColor}30`,
left: `calc(50% + ${tapeOffset}px - 32px)`,
background: `${categoryColor}40`,
transform: `rotate(${-rotation * 0.5}deg)`,
boxShadow: `0 1px 3px ${categoryColor}15`,
}}
>
{categoryLabel}
</span>
/>
{file.status !== 'converting' && (
<button
className="flex items-center justify-center w-7 h-7 rounded-lg bg-transparent border-none cursor-pointer text-text-light hover:text-text-dark hover:bg-bg-warm transition-all"
onClick={() => onRemove(file.id)}
aria-label="Remove file"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Faint ruled lines */}
<div
className="absolute inset-0 pointer-events-none opacity-[0.04]"
style={{
backgroundImage: 'repeating-linear-gradient(to bottom, transparent, transparent 27px, #8b7355 27px, #8b7355 28px)',
backgroundPosition: '0 16px',
}}
/>
{/* File preview / icon */}
<div className="relative flex items-center justify-center h-32 mx-4 mt-1 mb-2 rounded-xl bg-bg-warm/60 overflow-hidden">
{file.preview ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={file.preview}
alt={file.name}
className="w-full h-full object-cover"
/>
) : (
<div className="flex items-center justify-center w-full h-full">
{/* Left margin line */}
<div
className="absolute top-0 bottom-0 left-10 w-px opacity-[0.06]"
style={{ background: '#e8766a' }}
/>
{/* Content */}
<div className="relative p-4 pt-5">
{/* Header: category + remove */}
<div className="flex items-center justify-between mb-3">
<span
className="font-mono text-2xl font-black tracking-wider opacity-60"
style={{ color: categoryColor }}
className="font-mono text-[10px] font-bold uppercase tracking-[0.08em] px-2 py-0.5 rounded-sm"
style={{
color: categoryColor,
background: `${categoryColor}10`,
}}
>
.{file.extension}
{categoryLabel}
</span>
</div>
)}
{/* Progress overlay */}
{file.status === 'converting' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-white/80 backdrop-blur-sm rounded-xl">
<ProgressRing progress={file.progress} color={categoryColor} />
<span className="font-mono text-xs font-bold text-text-dark">
{Math.round(file.progress)}%
</span>
{file.status !== 'converting' && (
<button
className="flex items-center justify-center w-6 h-6 rounded-sm bg-transparent border-none cursor-pointer text-text-light/50 hover:text-text-dark hover:bg-text-dark/5 transition-all opacity-0 group-hover:opacity-100"
onClick={() => onRemove(file.id)}
aria-label="Remove file"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
)}
{/* Done overlay */}
{file.status === 'done' && (
<motion.div
className="absolute inset-0 flex items-center justify-center bg-mint/10 backdrop-blur-sm rounded-xl"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 400, damping: 15 }}
>
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-white shadow-[0_2px_12px_rgba(52,211,153,0.2)]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2.5">
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
{/* Extension — big typewriter style */}
<div className="relative flex items-center justify-center py-5 mb-3">
{file.preview ? (
<div className="relative w-full h-28 rounded-sm overflow-hidden border border-border-soft/50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={file.preview}
alt={file.name}
className="w-full h-full object-cover"
/>
</div>
) : (
<span
className="font-mono text-[32px] font-black tracking-tight leading-none select-none"
style={{ color: `${categoryColor}90` }}
>
.{file.extension}
</span>
)}
{/* Progress overlay */}
{file.status === 'converting' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-[#fffef9]/85 backdrop-blur-[2px] rounded-sm">
<ProgressRing progress={file.progress} color={categoryColor} />
<span className="font-mono text-[11px] font-bold text-text-dark">
{Math.round(file.progress)}%
</span>
</div>
)}
{/* Done overlay */}
{file.status === 'done' && (
<motion.div
className="absolute inset-0 flex items-center justify-center bg-mint/[0.07] rounded-sm"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 400, damping: 15 }}
>
<div className="flex items-center justify-center w-11 h-11 rounded-full bg-[#fffef9] shadow-[0_2px_10px_rgba(52,211,153,0.15)] border border-mint/20">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2.5">
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</motion.div>
)}
{/* Error overlay */}
{file.status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center bg-red-50/70 backdrop-blur-[2px] rounded-sm">
<div className="flex items-center justify-center w-11 h-11 rounded-full bg-[#fffef9] shadow-[0_2px_10px_rgba(244,63,94,0.12)] border border-red-200/40">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f43f5e" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
</div>
</div>
)}
</div>
{/* Filename + size — handwritten feel area */}
<div className="mb-2.5">
<p className="text-[13px] font-semibold text-text-dark truncate leading-snug" title={file.name}>
{truncateFilename(file.name)}
</p>
<p className="font-mono text-[10px] text-text-light mt-0.5 tracking-wide">
{formatFileSize(file.size)}
</p>
</div>
{/* Error message */}
{file.status === 'error' && file.error && (
<p className="pb-1 text-[11px] text-red-400 leading-snug">
{file.error}
</p>
)}
{/* Format selector — styled like a form field on paper */}
{file.availableFormats.length > 0 && file.status !== 'done' && (
<div className="flex items-center gap-2 pt-2.5 mt-1 border-t border-dashed border-text-dark/[0.06]">
<span className="font-mono text-[11px] font-bold text-text-mid">
.{file.extension}
</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-text-light/50 flex-shrink-0">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
<select
value={file.targetFormat || ''}
onChange={(e) => onSetFormat(file.id, e.target.value)}
className="flex-1 min-w-0 font-mono text-[11px] font-bold text-text-dark bg-transparent px-2 py-1 rounded-sm border border-dashed cursor-pointer hover:border-text-dark/20 focus:outline-none focus:border-pink/40 transition-all appearance-none"
style={{ borderColor: `${categoryColor}30` }}
>
{file.availableFormats.map((fmt) => (
<option key={fmt} value={fmt}>
.{fmt}
</option>
))}
</select>
</div>
</motion.div>
)}
)}
{/* Error overlay */}
{file.status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center bg-red-50/80 backdrop-blur-sm rounded-xl">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-white shadow-[0_2px_12px_rgba(244,63,94,0.15)]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f43f5e" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
{/* Action buttons — done state */}
{file.status === 'done' && (
<div className="flex items-center gap-2 pt-2.5 mt-1 border-t border-dashed border-text-dark/[0.06]">
<motion.button
className="flex-1 inline-flex items-center justify-center gap-1.5 px-2.5 py-2 text-[11px] font-bold text-text-dark bg-transparent border border-dashed border-text-dark/10 rounded-sm cursor-pointer hover:bg-text-dark/[0.03] hover:border-text-dark/20 transition-all"
onClick={() => onPreview(file)}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
whileTap={{ scale: 0.97 }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
Preview
</motion.button>
<motion.button
className="flex-1 inline-flex items-center justify-center gap-1.5 px-2.5 py-2 text-[11px] font-bold text-white bg-mint border-none rounded-sm cursor-pointer shadow-[0_1px_6px_rgba(52,211,153,0.2)] hover:shadow-[0_2px_12px_rgba(52,211,153,0.3)] transition-all"
onClick={() => onDownload(file)}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
whileTap={{ scale: 0.97 }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
.{file.targetFormat}
</motion.button>
</div>
</div>
)}
</div>
)}
{/* File info */}
<div className="px-4 pb-1">
<p className="text-sm font-semibold text-text-dark truncate leading-snug" title={file.name}>
{truncateFilename(file.name)}
</p>
<p className="font-mono text-[11px] text-text-light mt-0.5">
{formatFileSize(file.size)}
</p>
</div>
{/* Error message */}
{file.status === 'error' && file.error && (
<p className="px-4 pb-2 text-[12px] text-red-400 leading-snug">
{file.error}
</p>
)}
{/* Format selector */}
{file.availableFormats.length > 0 && file.status !== 'done' && (
<div className="flex items-center gap-2 px-4 pb-4 pt-1.5">
<span className="font-mono text-xs font-bold text-text-mid bg-bg-warm px-2 py-1 rounded-lg">
.{file.extension}
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-text-light flex-shrink-0">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
<select
value={file.targetFormat || ''}
onChange={(e) => onSetFormat(file.id, e.target.value)}
className="select-arrow-warm flex-1 min-w-0 font-mono text-xs font-bold text-text-dark bg-white px-3 py-1.5 rounded-xl border border-border-soft cursor-pointer hover:border-border-med focus:outline-none focus:ring-2 focus:ring-pink/20 focus:border-pink/40 transition-all"
style={{ borderColor: `${categoryColor}40` }}
>
{file.availableFormats.map((fmt) => (
<option key={fmt} value={fmt}>
.{fmt}
</option>
))}
</select>
{/* Unsupported message */}
{file.availableFormats.length === 0 && (
<p className="pt-2.5 mt-1 text-[11px] text-text-light italic text-center border-t border-dashed border-text-dark/[0.06]">
Format not supported for conversion
</p>
)}
</div>
)}
{/* Action buttons */}
{file.status === 'done' && (
<div className="flex items-center gap-2 px-4 pb-4 pt-1">
<motion.button
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2.5 text-[12px] font-bold text-text-dark bg-bg-warm border border-border-soft rounded-xl cursor-pointer hover:-translate-y-0.5 hover:shadow-[0_2px_12px_rgba(180,140,100,0.1)] hover:border-border-med transition-all"
onClick={() => onPreview(file)}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
Preview
</motion.button>
<motion.button
className="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2.5 text-[12px] font-bold text-white bg-mint border-none rounded-xl cursor-pointer shadow-[0_2px_12px_rgba(52,211,153,0.2)] hover:-translate-y-0.5 hover:shadow-[0_4px_20px_rgba(52,211,153,0.3)] transition-all"
onClick={() => onDownload(file)}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
.{file.targetFormat}
</motion.button>
</div>
)}
{/* Unsupported message */}
{file.availableFormats.length === 0 && (
<p className="px-4 pb-4 pt-1 text-[12px] text-text-light italic text-center">
Format not supported for conversion
</p>
)}
</div>
</motion.div>
);
}