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 */} {/* File Grid */}
{hasFiles && ( {hasFiles && (
<motion.div <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 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
+265 -87
View File
@@ -51,48 +51,54 @@ const flowSteps = [
{ inputIcon: '\u{1F3B5}', inputLabel: '.FLAC', outputIcon: '\u{1F3A7}', outputLabel: '.MP3' }, { inputIcon: '\u{1F3B5}', inputLabel: '.FLAC', outputIcon: '\u{1F3A7}', outputLabel: '.MP3' },
]; ];
/* ─── Features ─── */ /* ─── Bento Features ─── */
const features = [ const bentoFeatures = [
{ {
icon: '\u{1F5BC}', icon: '\u{1F5BC}',
title: 'Images', title: 'Images',
desc: 'PNG, JPG, WebP, GIF, BMP, AVIF, SVG, PSD, HEIC \u2014 convert between any format.', desc: 'Convert between any image format with zero quality loss.',
iconBg: 'bg-pink-100', accent: '#f472b6',
formats: ['PNG', 'JPG', 'WebP', 'GIF', 'AVIF', 'SVG', 'PSD', 'HEIC'], accentLight: 'rgba(244,114,182,0.08)',
wide: true, formats: ['PNG', 'JPG', 'WebP', 'GIF', 'AVIF', 'SVG', 'PSD', 'HEIC', 'BMP', 'TIFF', 'ICO'],
// Bento: tall left column
gridArea: 'images',
}, },
{ {
icon: '\u{1F4C4}', icon: '\u{1F4C4}',
title: 'Documents', title: 'Documents',
desc: 'DOCX, PDF, Markdown, HTML, TXT, PPTX, EPUB \u2014 preserves formatting.', desc: 'Full formatting preservation across office and web formats.',
iconBg: 'bg-blue-100', accent: '#60a5fa',
formats: ['DOCX', 'PDF', 'MD', 'HTML', 'TXT', 'PPTX', 'EPUB'], accentLight: 'rgba(96,165,250,0.08)',
wide: false, formats: ['DOCX', 'PDF', 'MD', 'HTML', 'TXT', 'RTF', 'PPTX', 'EPUB'],
gridArea: 'docs',
}, },
{ {
icon: '\u{1F3B5}', icon: '\u{1F3B5}',
title: 'Audio', title: 'Audio',
desc: 'MP3, WAV, OGG, AAC, FLAC, M4A \u2014 powered by FFmpeg WebAssembly.', desc: 'FFmpeg WebAssembly powered.',
iconBg: 'bg-purple-100', accent: '#a78bfa',
formats: ['MP3', 'WAV', 'OGG', 'FLAC', 'AAC'], accentLight: 'rgba(167,139,250,0.08)',
wide: false, formats: ['MP3', 'WAV', 'OGG', 'FLAC', 'AAC', 'M4A'],
gridArea: 'audio',
}, },
{ {
icon: '\u{1F3AC}', icon: '\u{1F3AC}',
title: 'Video', title: 'Video',
desc: 'MP4, WebM, AVI, MOV, MKV \u2014 full video transcoding in your browser.', desc: 'Full transcoding in your browser.',
iconBg: 'bg-orange-100', accent: '#fb923c',
accentLight: 'rgba(251,146,60,0.08)',
formats: ['MP4', 'WebM', 'AVI', 'MOV', 'MKV'], formats: ['MP4', 'WebM', 'AVI', 'MOV', 'MKV'],
wide: false, gridArea: 'video',
}, },
{ {
icon: '\u{1F4CA}', icon: '\u{1F4CA}',
title: 'Data & Fonts', title: 'Data & Fonts',
desc: 'CSV, JSON, XML, YAML, XLSX, TTF, OTF, WOFF2 \u2014 smart structure preservation.', desc: 'Smart structure preservation for structured data and typography.',
iconBg: 'bg-emerald-100', accent: '#34d399',
formats: ['CSV', 'JSON', 'XML', 'YAML', 'XLSX', 'TTF', 'WOFF2'], accentLight: 'rgba(52,211,153,0.08)',
wide: true, formats: ['CSV', 'JSON', 'XML', 'YAML', 'XLSX', 'TSV', 'TOML', 'TTF', 'OTF', 'WOFF2'],
gridArea: 'data',
}, },
]; ];
@@ -448,7 +454,7 @@ export default function LandingPage() {
<ConversionFlow /> <ConversionFlow />
</section> </section>
{/* ──── FEATURES ──── */} {/* ──── FEATURES — BENTO GRID ──── */}
<section <section
id="features" id="features"
className="relative z-10 flex flex-col items-center gap-10 px-6 py-20" className="relative z-10 flex flex-col items-center gap-10 px-6 py-20"
@@ -471,43 +477,90 @@ export default function LandingPage() {
</p> </p>
</motion.div> </motion.div>
<motion.div {/* Bento Grid — asymmetric masonry layout */}
className="grid grid-cols-1 md:grid-cols-3 gap-5 max-w-[960px] w-full" <div
variants={stagger} className="w-full max-w-[1060px] grid gap-4 md:gap-5"
initial="hidden" style={{
whileInView="visible" gridTemplateColumns: 'repeat(12, 1fr)',
viewport={{ once: true, margin: '-60px' }} 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 <motion.div
key={feat.title} 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' : ''}`} 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"
variants={fadeUp} 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 }}
>
{/* 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 }}
> >
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-[22px] mb-4 ${feat.iconBg}`}>
{feat.icon} {feat.icon}
</div> </div>
<h3 className="font-serif font-bold text-[19px] text-text-dark mb-2">{feat.title}</h3> <div>
<p className="text-sm text-text-mid leading-relaxed">{feat.desc}</p> <h3 className="font-serif font-bold text-[18px] text-text-dark leading-tight">{feat.title}</h3>
<div className="flex flex-wrap gap-1.5 mt-4"> <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) => ( {feat.formats.map((f) => (
<span <span
key={f} 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" 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} .{f}
</span> </span>
))} ))}
</div> </div>
</div>
</motion.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> </section>
{/* ──── HOW IT WORKS ──── */} {/* ──── 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 <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 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
@@ -521,71 +574,196 @@ export default function LandingPage() {
</h2> </h2>
</motion.div> </motion.div>
{/* 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 <motion.div
className="flex flex-col md:flex-row gap-6 md:gap-8 max-w-[880px] w-full" className="flex flex-col items-center text-center px-4"
variants={stagger} initial={{ opacity: 0, y: 28 }}
initial="hidden" whileInView={{ opacity: 1, y: 0 }}
whileInView="visible" viewport={{ once: true, margin: '-40px' }}
viewport={{ once: true, margin: '-60px' }} transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
> >
{[ {/* Visual scene */}
{ num: '1', icon: '\u{1F4E5}', title: 'Drop your files', desc: 'Drag and drop any file \u2014 or click to browse. We accept everything.' }, <div className="relative w-[104px] h-[104px] mb-5">
{ num: '2', icon: '\u{2699}', title: 'Pick a format', desc: 'Choose your target format from smart suggestions based on file type.' }, {/* Background shape */}
{ num: '3', icon: '\u{2B07}', title: 'Download', desc: 'Hit convert and download instantly. Files never leave your browser.' }, <div className="absolute inset-0 rounded-[28px] bg-pink/8 rotate-3" />
].map((step, i) => ( <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">
<motion.div {/* File stack */}
key={step.num} <div className="relative">
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" <div className="absolute -top-1 -left-1 w-10 h-12 rounded-lg bg-pink/10 border border-pink/15 rotate-[-6deg]" />
variants={fadeUp} <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">
<div className="text-3xl mb-3">{step.icon}</div> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" className="text-pink">
<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"> <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" />
{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> </svg>
</div> </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> </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> </section>
{/* ──── PRIVACY ──── */} {/* ──── 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 <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" className="relative max-w-[960px] w-full flex flex-col md:flex-row items-center gap-10 md:gap-16"
initial={{ opacity: 0, y: 20, scale: 0.97 }} initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }} viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] as const }} transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] as const }}
> >
<div className="text-4xl mb-4">{'\u{1F6E1}'}</div> {/* Left — Shield visual */}
<h2 className="font-serif font-extrabold text-[28px] text-text-dark mb-3">Your files stay yours</h2> <div className="relative flex-shrink-0 w-[200px] h-[200px] md:w-[260px] md:h-[260px] flex items-center justify-center">
<p className="text-base text-text-mid leading-[1.7] max-w-[500px] mx-auto"> {/* Pulsing rings */}
Every conversion happens entirely in your browser using WebAssembly and Canvas APIs. <motion.div
No file ever touches a server. No data is collected. No account needed. Ever. className="absolute inset-0 rounded-full border-2 border-mint/20"
</p> animate={{ scale: [1, 1.15, 1], opacity: [0.4, 0.1, 0.4] }}
<div className="flex justify-center gap-6 mt-7 flex-wrap"> transition={{ duration: 3, repeat: Infinity, ease: 'easeInOut' }}
{[ />
{ icon: '\u{1F6AB}', label: 'No uploads', color: 'bg-emerald-50' }, <motion.div
{ icon: '\u{1F4BB}', label: 'No servers', color: 'bg-blue-50' }, className="absolute inset-4 rounded-full border-2 border-mint/15"
{ icon: '\u{1F440}', label: 'No tracking', color: 'bg-purple-50' }, animate={{ scale: [1, 1.1, 1], opacity: [0.3, 0.08, 0.3] }}
{ icon: '\u{267E}', label: 'No limits', color: 'bg-orange-50' }, transition={{ duration: 3, repeat: Infinity, ease: 'easeInOut', delay: 0.5 }}
].map((b) => ( />
<div key={b.label} className="flex items-center gap-2 text-sm font-semibold text-text-mid"> {/* Shield body */}
<div className={`w-8 h-8 rounded-[10px] flex items-center justify-center ${b.color}`}> <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">
<span className="text-base">{b.icon}</span> <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> </div>
</div>
</motion.div> </motion.div>
</section> </section>
+116 -56
View File
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useMemo } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { UploadedFile, CATEGORY_COLORS, CATEGORY_LABELS } from '@/types'; import { UploadedFile, CATEGORY_COLORS, CATEGORY_LABELS } from '@/types';
import { formatFileSize, truncateFilename } from '@/lib/utils'; import { formatFileSize, truncateFilename } from '@/lib/utils';
@@ -14,6 +15,15 @@ interface FileCardProps {
onPreview: (file: UploadedFile) => void; 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({ export function FileCard({
file, file,
index, index,
@@ -25,33 +35,83 @@ export function FileCard({
const categoryColor = CATEGORY_COLORS[file.category]; const categoryColor = CATEGORY_COLORS[file.category];
const categoryLabel = CATEGORY_LABELS[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 ( return (
<motion.div <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" className="relative group"
initial={{ opacity: 0, y: 20, scale: 0.95 }} style={{
animate={{ opacity: 1, y: 0, scale: 1 }} transform: `rotate(${rotation}deg)`,
exit={{ opacity: 0, y: -10, scale: 0.95 }} }}
initial={{ opacity: 0, y: 24, rotate: rotation }}
animate={{ opacity: 1, y: 0, rotate: rotation }}
exit={{ opacity: 0, scale: 0.9, rotate: rotation + 5 }}
transition={{ transition={{
duration: 0.4, duration: 0.45,
delay: index * 0.05, delay: index * 0.04,
ease: [0.16, 1, 0.3, 1] as const, 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 layout
> >
{/* Top accent line */} {/* Paper shadow — slightly offset for depth */}
<div <div
className="h-[3px] w-full" className="absolute inset-0 rounded-sm bg-text-dark/[0.03] translate-y-1 translate-x-0.5"
style={{ background: categoryColor }} style={{ filter: 'blur(4px)' }}
/> />
{/* Header: category badge + remove */} {/* Main paper */}
<div className="flex items-center justify-between px-4 pt-3 pb-1"> <div className="relative bg-[#fffef9] rounded-sm overflow-visible shadow-[0_1px_2px_rgba(120,100,70,0.08)]">
<span {/* Tape strip across top */}
className="inline-flex items-center px-2.5 py-0.5 text-[11px] font-bold font-mono tracking-wider uppercase rounded-full border" <div
className="absolute -top-2.5 z-10 w-16 h-6 rounded-[2px] opacity-70"
style={{
left: `calc(50% + ${tapeOffset}px - 32px)`,
background: `${categoryColor}40`,
transform: `rotate(${-rotation * 0.5}deg)`,
boxShadow: `0 1px 3px ${categoryColor}15`,
}}
/>
{/* 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',
}}
/>
{/* 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-[10px] font-bold uppercase tracking-[0.08em] px-2 py-0.5 rounded-sm"
style={{ style={{
background: `${categoryColor}18`,
color: categoryColor, color: categoryColor,
borderColor: `${categoryColor}30`, background: `${categoryColor}10`,
}} }}
> >
{categoryLabel} {categoryLabel}
@@ -59,42 +119,42 @@ export function FileCard({
{file.status !== 'converting' && ( {file.status !== 'converting' && (
<button <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" 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)} onClick={() => onRemove(file.id)}
aria-label="Remove file" aria-label="Remove file"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" /> <path d="M18 6L6 18M6 6l12 12" />
</svg> </svg>
</button> </button>
)} )}
</div> </div>
{/* File preview / icon */} {/* Extension — big typewriter style */}
<div className="relative flex items-center justify-center h-32 mx-4 mt-1 mb-2 rounded-xl bg-bg-warm/60 overflow-hidden"> <div className="relative flex items-center justify-center py-5 mb-3">
{file.preview ? ( {file.preview ? (
/* eslint-disable-next-line @next/next/no-img-element */ <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 <img
src={file.preview} src={file.preview}
alt={file.name} alt={file.name}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
</div>
) : ( ) : (
<div className="flex items-center justify-center w-full h-full">
<span <span
className="font-mono text-2xl font-black tracking-wider opacity-60" className="font-mono text-[32px] font-black tracking-tight leading-none select-none"
style={{ color: categoryColor }} style={{ color: `${categoryColor}90` }}
> >
.{file.extension} .{file.extension}
</span> </span>
</div>
)} )}
{/* Progress overlay */} {/* Progress overlay */}
{file.status === 'converting' && ( {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"> <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} /> <ProgressRing progress={file.progress} color={categoryColor} />
<span className="font-mono text-xs font-bold text-text-dark"> <span className="font-mono text-[11px] font-bold text-text-dark">
{Math.round(file.progress)}% {Math.round(file.progress)}%
</span> </span>
</div> </div>
@@ -103,13 +163,13 @@ export function FileCard({
{/* Done overlay */} {/* Done overlay */}
{file.status === 'done' && ( {file.status === 'done' && (
<motion.div <motion.div
className="absolute inset-0 flex items-center justify-center bg-mint/10 backdrop-blur-sm rounded-xl" className="absolute inset-0 flex items-center justify-center bg-mint/[0.07] rounded-sm"
initial={{ opacity: 0, scale: 0.5 }} initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 400, damping: 15 }} 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)]"> <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="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2.5"> <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" /> <path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</div> </div>
@@ -118,9 +178,9 @@ export function FileCard({
{/* Error overlay */} {/* Error overlay */}
{file.status === 'error' && ( {file.status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center bg-red-50/80 backdrop-blur-sm rounded-xl"> <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-12 h-12 rounded-full bg-white shadow-[0_2px_12px_rgba(244,63,94,0.15)]"> <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="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f43f5e" strokeWidth="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f43f5e" strokeWidth="2">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" /> <path d="M12 8v4M12 16h.01" />
</svg> </svg>
@@ -129,37 +189,37 @@ export function FileCard({
)} )}
</div> </div>
{/* File info */} {/* Filename + size — handwritten feel area */}
<div className="px-4 pb-1"> <div className="mb-2.5">
<p className="text-sm font-semibold text-text-dark truncate leading-snug" title={file.name}> <p className="text-[13px] font-semibold text-text-dark truncate leading-snug" title={file.name}>
{truncateFilename(file.name)} {truncateFilename(file.name)}
</p> </p>
<p className="font-mono text-[11px] text-text-light mt-0.5"> <p className="font-mono text-[10px] text-text-light mt-0.5 tracking-wide">
{formatFileSize(file.size)} {formatFileSize(file.size)}
</p> </p>
</div> </div>
{/* Error message */} {/* Error message */}
{file.status === 'error' && file.error && ( {file.status === 'error' && file.error && (
<p className="px-4 pb-2 text-[12px] text-red-400 leading-snug"> <p className="pb-1 text-[11px] text-red-400 leading-snug">
{file.error} {file.error}
</p> </p>
)} )}
{/* Format selector */} {/* Format selector — styled like a form field on paper */}
{file.availableFormats.length > 0 && file.status !== 'done' && ( {file.availableFormats.length > 0 && file.status !== 'done' && (
<div className="flex items-center gap-2 px-4 pb-4 pt-1.5"> <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-xs font-bold text-text-mid bg-bg-warm px-2 py-1 rounded-lg"> <span className="font-mono text-[11px] font-bold text-text-mid">
.{file.extension} .{file.extension}
</span> </span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-text-light flex-shrink-0"> <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" /> <path d="M5 12h14M12 5l7 7-7 7" />
</svg> </svg>
<select <select
value={file.targetFormat || ''} value={file.targetFormat || ''}
onChange={(e) => onSetFormat(file.id, e.target.value)} 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" 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}40` }} style={{ borderColor: `${categoryColor}30` }}
> >
{file.availableFormats.map((fmt) => ( {file.availableFormats.map((fmt) => (
<option key={fmt} value={fmt}> <option key={fmt} value={fmt}>
@@ -170,32 +230,30 @@ export function FileCard({
</div> </div>
)} )}
{/* Action buttons */} {/* Action buttons — done state */}
{file.status === 'done' && ( {file.status === 'done' && (
<div className="flex items-center gap-2 px-4 pb-4 pt-1"> <div className="flex items-center gap-2 pt-2.5 mt-1 border-t border-dashed border-text-dark/[0.06]">
<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-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" 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)} onClick={() => onPreview(file)}
initial={{ opacity: 0, y: 5 }} initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.97 }}
whileTap={{ scale: 0.98 }}
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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" /> <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" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
Preview Preview
</motion.button> </motion.button>
<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" 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)} onClick={() => onDownload(file)}
initial={{ opacity: 0, y: 5 }} initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.97 }}
whileTap={{ scale: 0.98 }}
> >
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <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" /> <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg> </svg>
.{file.targetFormat} .{file.targetFormat}
@@ -205,10 +263,12 @@ export function FileCard({
{/* Unsupported message */} {/* Unsupported message */}
{file.availableFormats.length === 0 && ( {file.availableFormats.length === 0 && (
<p className="px-4 pb-4 pt-1 text-[12px] text-text-light italic text-center"> <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 Format not supported for conversion
</p> </p>
)} )}
</div>
</div>
</motion.div> </motion.div>
); );
} }