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:
@@ -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 }}
|
||||
|
||||
+265
-87
@@ -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 }}
|
||||
>
|
||||
{/* 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}
|
||||
</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">
|
||||
<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-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}
|
||||
</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 — 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,71 +574,196 @@ export default function LandingPage() {
|
||||
</h2>
|
||||
</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
|
||||
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' }}
|
||||
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 }}
|
||||
>
|
||||
{[
|
||||
{ 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) => (
|
||||
<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}
|
||||
>
|
||||
<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" />
|
||||
{/* 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>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
{b.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
|
||||
+116
-56
@@ -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,33 +35,83 @@ 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={{
|
||||
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={{
|
||||
background: `${categoryColor}18`,
|
||||
color: categoryColor,
|
||||
borderColor: `${categoryColor}30`,
|
||||
background: `${categoryColor}10`,
|
||||
}}
|
||||
>
|
||||
{categoryLabel}
|
||||
@@ -59,42 +119,42 @@ export function FileCard({
|
||||
|
||||
{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"
|
||||
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="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" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* Extension — big typewriter style */}
|
||||
<div className="relative flex items-center justify-center py-5 mb-3">
|
||||
{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
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<span
|
||||
className="font-mono text-2xl font-black tracking-wider opacity-60"
|
||||
style={{ color: categoryColor }}
|
||||
className="font-mono text-[32px] font-black tracking-tight leading-none select-none"
|
||||
style={{ color: `${categoryColor}90` }}
|
||||
>
|
||||
.{file.extension}
|
||||
</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">
|
||||
<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-xs font-bold text-text-dark">
|
||||
<span className="font-mono text-[11px] font-bold text-text-dark">
|
||||
{Math.round(file.progress)}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -103,13 +163,13 @@ export function FileCard({
|
||||
{/* Done overlay */}
|
||||
{file.status === 'done' && (
|
||||
<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 }}
|
||||
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">
|
||||
<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>
|
||||
@@ -118,9 +178,9 @@ export function FileCard({
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
@@ -129,37 +189,37 @@ export function FileCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="px-4 pb-1">
|
||||
<p className="text-sm font-semibold text-text-dark truncate leading-snug" title={file.name}>
|
||||
{/* 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-[11px] text-text-light mt-0.5">
|
||||
<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="px-4 pb-2 text-[12px] text-red-400 leading-snug">
|
||||
<p className="pb-1 text-[11px] text-red-400 leading-snug">
|
||||
{file.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Format selector */}
|
||||
{/* Format selector — styled like a form field on paper */}
|
||||
{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">
|
||||
<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="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" />
|
||||
</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` }}
|
||||
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}>
|
||||
@@ -170,32 +230,30 @@ export function FileCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{/* Action buttons — done state */}
|
||||
{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
|
||||
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)}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<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" />
|
||||
<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"
|
||||
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: 5 }}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
.{file.targetFormat}
|
||||
@@ -205,10 +263,12 @@ export function FileCard({
|
||||
|
||||
{/* Unsupported message */}
|
||||
{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
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user