redesign: playful pastel theme with solid colors, new landing page

- Rewrite all components to use Tailwind utility classes (Tailwind v4 broke custom CSS classes)
- New landing page at / with hero, features bento grid, how-it-works, privacy section
- Converter tool moved to /convert route
- Fonts: Fraunces (serif headlines) + Plus Jakarta Sans (body) + JetBrains Mono (labels)
- Warm cream palette with pastel category colors (pink, blue, purple, orange, mint)
- Remove all gradients — solid colors only throughout
- Remove floating badge bounce animation
- Responsive action bar and file grid for mobile
- Register design tokens via @theme inline for Tailwind v4 compatibility
This commit is contained in:
noah
2026-03-09 18:38:35 +01:00
parent 221a72b2bf
commit 38f58327c4
8 changed files with 690 additions and 911 deletions
+171
View File
@@ -0,0 +1,171 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import Link from 'next/link';
import { DropZone } from '@/components/DropZone';
import { FileCard } from '@/components/FileCard';
import { useFileUpload } from '@/hooks/useFileUpload';
import { useConversion } from '@/hooks/useConversion';
import { formatFileSize } from '@/lib/utils';
export default function ConvertPage() {
const {
files,
isDragging,
inputRef,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handleFileInput,
openFilePicker,
removeFile,
updateFile,
setTargetFormat,
clearAll,
} = useFileUpload();
const {
isConverting,
convertAll,
downloadFile,
downloadAllAsZip,
} = useConversion(updateFile);
const hasFiles = files.length > 0;
const convertableCount = files.filter(
(f) => f.targetFormat && f.status !== 'done' && f.availableFormats.length > 0
).length;
const completedCount = files.filter((f) => f.status === 'done').length;
const totalSize = files.reduce((acc, f) => acc + f.size, 0);
return (
<div className="min-h-screen relative bg-bg-cream">
{/* Atmospheric backgrounds */}
<div className="fixed inset-0 pointer-events-none z-0 bg-pastel-mesh" />
<div className="fixed inset-0 pointer-events-none z-0 opacity-30 bg-dots" />
{/* Header */}
<header className="sticky top-0 z-50 flex items-center justify-between px-6 py-4 bg-bg-cream/80 backdrop-blur-xl border-b border-border-soft">
<Link href="/" className="flex items-center gap-2.5 no-underline">
<div className="w-8 h-8 rounded-[10px] bg-pink flex items-center justify-center text-white font-serif font-black text-sm">
T
</div>
<span className="font-serif font-extrabold text-xl tracking-tight text-text-dark">Transmute</span>
</Link>
<span className="font-mono text-[11px] text-text-light px-2.5 py-1 border border-border-soft rounded-full tracking-wide">
v1.0 / client-side
</span>
</header>
{/* Drop Zone */}
<DropZone
isDragging={isDragging}
hasFiles={hasFiles}
inputRef={inputRef}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onFileInput={handleFileInput}
onBrowse={openFilePicker}
/>
{/* 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"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<AnimatePresence mode="popLayout">
{files.map((file, index) => (
<FileCard
key={file.id}
file={file}
index={index}
onSetFormat={setTargetFormat}
onRemove={removeFile}
onDownload={downloadFile}
/>
))}
</AnimatePresence>
</motion.div>
)}
{/* Action Bar */}
{hasFiles && (
<motion.div
className="fixed bottom-0 left-0 right-0 z-40 flex flex-col sm:flex-row items-center justify-between gap-3 px-4 sm:px-6 py-3 sm:py-4 bg-bg-cream/85 backdrop-blur-xl border-t border-border-soft shadow-[0_-4px_20px_rgba(160,120,80,0.06)]"
initial={{ y: 80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] as const }}
>
<div className="flex items-center gap-4">
<span className="font-mono text-xs text-text-mid flex items-center gap-1.5">
<strong className="text-text-dark font-semibold">{files.length}</strong> file{files.length !== 1 ? 's' : ''}
</span>
<div className="w-px h-4 bg-border-soft" />
<span className="font-mono text-xs text-text-mid">
<strong className="text-text-dark font-semibold">{formatFileSize(totalSize)}</strong>
</span>
{completedCount > 0 && (
<>
<div className="w-px h-4 bg-border-soft" />
<span className="font-mono text-xs text-text-mid">
<strong className="text-mint font-semibold">{completedCount}</strong> converted
</span>
</>
)}
</div>
<div className="flex items-center gap-2 sm:gap-2.5 flex-wrap justify-center sm:justify-end">
<button
className="inline-flex items-center gap-1.5 px-4 py-2 text-[13px] font-semibold text-text-mid bg-white border border-border-soft rounded-xl cursor-pointer hover:text-text-dark hover:border-border-med hover:shadow-[0_1px_3px_rgba(160,120,80,0.06)] transition-all"
onClick={clearAll}
>
Clear all
</button>
{completedCount > 0 && (
<motion.button
className="inline-flex items-center gap-1.5 px-5 py-2.5 text-[13px] 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={() => downloadAllAsZip(files)}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
>
<svg width="14" height="14" 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>
Download ZIP
</motion.button>
)}
<motion.button
className={`inline-flex items-center gap-2 px-7 py-2.5 text-sm font-bold text-white bg-pink border-none rounded-xl cursor-pointer shadow-[0_4px_20px_rgba(244,114,182,0.25)] hover:-translate-y-0.5 hover:shadow-[0_6px_28px_rgba(244,114,182,0.35)] transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none disabled:transform-none ${isConverting ? 'animate-pulse-soft opacity-85' : ''}`}
onClick={() => convertAll(files)}
disabled={isConverting || convertableCount === 0}
>
{isConverting ? (
<>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
</svg>
Converting...
</>
) : (
<>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Transmute {convertableCount > 0 ? `(${convertableCount})` : ''}
</>
)}
</motion.button>
</div>
</motion.div>
)}
</div>
);
}
+61 -686
View File
@@ -1,730 +1,105 @@
@import "tailwindcss"; @import "tailwindcss";
/* ============================================ /* ============================================
TRANSMUTE — Dark Industrial Theme TRANSMUTE — Playful Pastel Theme
Force light color-scheme so dark mode OS
doesn't override our cream backgrounds.
============================================ */ ============================================ */
:root {
--bg-primary: #09090b;
--bg-secondary: #111114;
--bg-tertiary: #18181c;
--bg-card: rgba(255, 255, 255, 0.025);
--bg-card-hover: rgba(255, 255, 255, 0.05);
--border: rgba(255, 255, 255, 0.07);
--border-hover: rgba(255, 255, 255, 0.14);
--text-primary: #f0f0f2;
--text-secondary: #7a7a85;
--text-muted: #45454d;
--accent: #00f0ff;
--accent-glow: rgba(0, 240, 255, 0.15);
--accent-dim: rgba(0, 240, 255, 0.5);
--cat-image: #10b981;
--cat-document: #0ea5e9;
--cat-audio: #8b5cf6;
--cat-video: #f43f5e;
--cat-data: #f59e0b;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 20px;
}
@theme inline { @theme inline {
--color-background: var(--bg-primary); --color-bg-cream: #fef7f0;
--color-foreground: var(--text-primary); --color-bg-warm: #fff5eb;
--font-sans: var(--font-space-grotesk); --color-bg-peach: #ffecd9;
--color-text-dark: #2d1f14;
--color-text-mid: #7a6552;
--color-text-light: #b8a08a;
--color-border-soft: rgba(180, 140, 100, 0.12);
--color-border-med: rgba(180, 140, 100, 0.25);
--color-pink: #f472b6;
--color-purple: #a78bfa;
--color-blue: #60a5fa;
--color-mint: #34d399;
--color-orange: #fb923c;
--color-teal: #2dd4bf;
--font-sans: var(--font-plus-jakarta-sans);
--font-serif: var(--font-fraunces);
--font-mono: var(--font-jetbrains-mono); --font-mono: var(--font-jetbrains-mono);
} }
/* ---- Base ---- */ html {
* { color-scheme: light;
box-sizing: border-box;
} }
body { body {
background: var(--bg-primary); background: var(--color-bg-cream);
color: var(--text-primary); color: var(--color-text-dark);
font-family: var(--font-space-grotesk), system-ui, sans-serif; font-family: var(--font-plus-jakarta-sans), system-ui, sans-serif;
overflow-x: hidden; overflow-x: hidden;
} }
/* Noise texture overlay */
.noise-overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
/* Gradient mesh background */
.bg-mesh {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(ellipse 60% 50% at 20% 0%, rgba(0, 240, 255, 0.04) 0%, transparent 60%),
radial-gradient(ellipse 50% 40% at 80% 100%, rgba(139, 92, 246, 0.03) 0%, transparent 60%);
}
/* ---- Scrollbar ---- */ /* ---- Scrollbar ---- */
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1); background: rgba(180, 140, 100, 0.15);
border-radius: 3px; border-radius: 4px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.18); background: rgba(180, 140, 100, 0.25);
} }
/* ============================================ /* ---- Gradient text utility ---- */
Header /* Removed — using solid text colors instead */
============================================ */
.app-header { /* ---- Animations ---- */
position: sticky; @keyframes badge-pulse {
top: 0; 0%, 100% { opacity: 1; transform: scale(1); }
z-index: 50; 50% { opacity: 0.6; transform: scale(0.85); }
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: rgba(9, 9, 11, 0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
} }
.logo { @keyframes pulse-soft {
display: flex; 0%, 100% { box-shadow: 0 4px 20px rgba(244, 114, 182, 0.25); }
align-items: center; 50% { box-shadow: 0 4px 32px rgba(167, 139, 250, 0.35); }
gap: 10px;
font-family: var(--font-space-grotesk), sans-serif;
font-weight: 700;
font-size: 18px;
letter-spacing: -0.02em;
color: var(--text-primary);
} }
.logo-icon { .animate-badge-pulse {
width: 28px; animation: badge-pulse 2s ease-in-out infinite;
height: 28px;
border-radius: 7px;
background: linear-gradient(135deg, var(--accent), #0090ff);
display: flex;
align-items: center;
justify-content: center;
color: #000;
font-weight: 800;
font-size: 14px;
} }
.header-tag { .animate-pulse-soft {
font-family: var(--font-jetbrains-mono), monospace; animation: pulse-soft 2s ease-in-out infinite;
font-size: 11px;
color: var(--text-muted);
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 20px;
letter-spacing: 0.03em;
} }
/* ============================================ /* ---- Dot pattern background ---- */
Drop Zone — Hero (no files) .bg-dots {
============================================ */ background-image: radial-gradient(circle, rgba(180, 140, 100, 0.12) 1px, transparent 1px);
.drop-zone-hero { background-size: 24px 24px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 57px);
padding: 40px 24px;
} }
.drop-zone-inner { /* ---- Pastel mesh background ---- */
position: relative; .bg-pastel-mesh {
width: 100%; background-color: rgba(244, 114, 182, 0.03);
max-width: 640px;
padding: 64px 40px;
border: 1.5px dashed var(--border-hover);
border-radius: var(--radius-xl);
background: var(--bg-card);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
cursor: pointer;
} }
.drop-zone-inner.dragging { /* ---- Top-border accent for privacy card ---- */
border-color: var(--accent); .border-t-rainbow::before {
background: var(--accent-glow); content: '';
box-shadow:
0 0 40px rgba(0, 240, 255, 0.08),
inset 0 0 40px rgba(0, 240, 255, 0.03);
}
/* Corner accents */
.corner-accent {
position: absolute;
width: 16px;
height: 16px;
border-color: var(--accent-dim);
transition: border-color 0.3s;
}
.drop-zone-inner.dragging .corner-accent {
border-color: var(--accent);
}
.corner-accent.top-left {
top: -1px; left: -1px;
border-top: 2px solid;
border-left: 2px solid;
border-top-left-radius: var(--radius-xl);
}
.corner-accent.top-right {
top: -1px; right: -1px;
border-top: 2px solid;
border-right: 2px solid;
border-top-right-radius: var(--radius-xl);
}
.corner-accent.bottom-left {
bottom: -1px; left: -1px;
border-bottom: 2px solid;
border-left: 2px solid;
border-bottom-left-radius: var(--radius-xl);
}
.corner-accent.bottom-right {
bottom: -1px; right: -1px;
border-bottom: 2px solid;
border-right: 2px solid;
border-bottom-right-radius: var(--radius-xl);
}
.drop-zone-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 16px;
}
.upload-icon {
color: var(--accent);
margin-bottom: 8px;
filter: drop-shadow(0 0 12px rgba(0, 240, 255, 0.3));
}
.drop-zone-title {
font-family: var(--font-space-grotesk), sans-serif;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.03em;
color: var(--text-primary);
line-height: 1.2;
}
.drop-zone-subtitle {
font-size: 15px;
color: var(--text-secondary);
max-width: 380px;
line-height: 1.5;
}
.browse-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
margin-top: 8px;
font-family: var(--font-space-grotesk), sans-serif;
font-size: 14px;
font-weight: 600;
color: #000;
background: var(--accent);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 0 20px rgba(0, 240, 255, 0.2);
}
.browse-button:hover {
box-shadow: 0 0 30px rgba(0, 240, 255, 0.3);
}
.drop-zone-hint {
font-family: var(--font-jetbrains-mono), monospace;
font-size: 11px;
color: var(--text-muted);
margin-top: 8px;
letter-spacing: 0.02em;
}
/* ============================================
Drop Zone — Compact (has files)
============================================ */
.drop-zone-compact {
padding: 16px 24px;
position: relative;
z-index: 1;
}
.compact-drop-area {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px;
border: 1.5px dashed var(--border);
border-radius: var(--radius-md);
background: var(--bg-card);
cursor: pointer;
transition: all 0.3s;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
}
.compact-drop-area:hover {
border-color: var(--border-hover);
background: var(--bg-card-hover);
color: var(--text-primary);
}
.compact-drop-area.dragging {
border-color: var(--accent);
background: var(--accent-glow);
color: var(--accent);
}
/* ============================================
File Cards
============================================ */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
padding: 0 24px 120px;
position: relative;
z-index: 1;
}
.file-card {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
transition: all 0.25s ease;
overflow: hidden;
}
.file-card:hover {
background: var(--bg-card-hover);
border-color: var(--border-hover);
}
.card-accent-line {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 2px; height: 3px;
opacity: 0.6; background: var(--color-pink);
border-radius: inherit;
} }
.card-header { /* ---- Format select dropdown arrow ---- */
display: flex; .select-arrow-warm {
align-items: center;
justify-content: space-between;
}
.category-badge {
font-family: var(--font-jetbrains-mono), monospace;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 3px 8px;
border-radius: 20px;
border: 1px solid;
}
.remove-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.remove-btn:hover {
color: #f43f5e;
background: rgba(244, 63, 94, 0.1);
}
/* Preview area */
.card-preview {
position: relative;
width: 100%;
height: 100px;
border-radius: var(--radius-sm);
background: var(--bg-secondary);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-icon {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.file-ext {
font-family: var(--font-jetbrains-mono), monospace;
font-size: 18px;
font-weight: 700;
opacity: 0.5;
}
.progress-overlay,
.done-overlay,
.error-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: rgba(9, 9, 11, 0.75);
backdrop-filter: blur(4px);
}
.progress-text {
font-family: var(--font-jetbrains-mono), monospace;
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
}
/* File info */
.card-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.file-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
word-break: break-word;
}
.file-size {
font-family: var(--font-jetbrains-mono), monospace;
font-size: 11px;
color: var(--text-muted);
}
.error-message {
font-size: 11px;
color: #f43f5e;
background: rgba(244, 63, 94, 0.08);
padding: 6px 8px;
border-radius: var(--radius-sm);
line-height: 1.4;
}
/* Format selector */
.format-selector {
display: flex;
align-items: center;
gap: 8px;
}
.format-from {
font-family: var(--font-jetbrains-mono), monospace;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
padding: 4px 8px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
.format-arrow {
color: var(--text-muted);
flex-shrink: 0;
}
.format-select {
flex: 1;
font-family: var(--font-jetbrains-mono), monospace;
font-size: 12px;
font-weight: 600;
color: var(--accent);
background: var(--bg-secondary);
border: 1px solid;
border-radius: var(--radius-sm);
padding: 4px 8px;
cursor: pointer;
outline: none;
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%237a7a85' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%23b8a08a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right 8px center; background-position: right 10px center;
padding-right: 24px; padding-right: 28px;
}
.format-select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
.download-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 8px;
font-family: var(--font-space-grotesk), sans-serif;
font-size: 12px;
font-weight: 600;
color: #000;
background: #10b981;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s;
}
.download-btn:hover {
background: #34d399;
box-shadow: 0 0 16px rgba(16, 185, 129, 0.3);
}
.unsupported-msg {
font-size: 11px;
color: var(--text-muted);
text-align: center;
padding: 6px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
}
/* ============================================
Action Bar
============================================ */
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: rgba(9, 9, 11, 0.85);
backdrop-filter: blur(16px);
border-top: 1px solid var(--border);
}
.action-bar-info {
display: flex;
align-items: center;
gap: 16px;
}
.action-bar-stat {
font-family: var(--font-jetbrains-mono), monospace;
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.action-bar-stat strong {
color: var(--text-primary);
font-weight: 600;
}
.stat-divider {
width: 1px;
height: 16px;
background: var(--border);
}
.action-bar-buttons {
display: flex;
align-items: center;
gap: 10px;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
font-family: var(--font-space-grotesk), sans-serif;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
color: var(--text-primary);
border-color: var(--border-hover);
background: var(--bg-card);
}
.btn-convert {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 28px;
font-family: var(--font-space-grotesk), sans-serif;
font-size: 14px;
font-weight: 700;
color: #000;
background: var(--accent);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 0 24px rgba(0, 240, 255, 0.2);
letter-spacing: -0.01em;
}
.btn-convert:hover:not(:disabled) {
box-shadow: 0 0 36px rgba(0, 240, 255, 0.35);
transform: translateY(-1px);
}
.btn-convert:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
.btn-convert.converting {
background: var(--accent-dim);
animation: pulse-glow 2s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 24px rgba(0, 240, 255, 0.2); }
50% { box-shadow: 0 0 40px rgba(0, 240, 255, 0.4); }
}
.btn-download-all {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
font-family: var(--font-space-grotesk), sans-serif;
font-size: 13px;
font-weight: 600;
color: #000;
background: #10b981;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 0 16px rgba(16, 185, 129, 0.2);
}
.btn-download-all:hover {
background: #34d399;
box-shadow: 0 0 24px rgba(16, 185, 129, 0.35);
}
/* ============================================
Responsive
============================================ */
@media (max-width: 640px) {
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
padding: 0 16px 120px;
}
.drop-zone-hero {
padding: 24px 16px;
min-height: calc(100vh - 57px);
}
.drop-zone-inner {
padding: 48px 24px;
}
.drop-zone-title {
font-size: 22px;
}
.action-bar {
flex-direction: column;
gap: 12px;
padding: 12px 16px;
}
.action-bar-buttons {
width: 100%;
}
.btn-convert {
flex: 1;
}
.app-header {
padding: 12px 16px;
}
}
/* ============================================
Utility animations
============================================ */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-fade-in {
animation: fade-in 0.5s ease both;
} }
+11 -5
View File
@@ -1,11 +1,17 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Space_Grotesk, JetBrains_Mono } from "next/font/google"; import { Fraunces, Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
const spaceGrotesk = Space_Grotesk({ const fraunces = Fraunces({
variable: "--font-space-grotesk", variable: "--font-fraunces",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "600", "700"], weight: ["400", "500", "600", "700", "800", "900"],
});
const plusJakartaSans = Plus_Jakarta_Sans({
variable: "--font-plus-jakarta-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
}); });
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
@@ -40,7 +46,7 @@ export default function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body <body
className={`${spaceGrotesk.variable} ${jetbrainsMono.variable} antialiased`} className={`${fraunces.variable} ${plusJakartaSans.variable} ${jetbrainsMono.variable} antialiased`}
> >
{children} {children}
</body> </body>
+339 -142
View File
@@ -1,167 +1,364 @@
'use client'; 'use client';
import { AnimatePresence, motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { DropZone } from '@/components/DropZone'; import Link from 'next/link';
import { FileCard } from '@/components/FileCard';
import { useFileUpload } from '@/hooks/useFileUpload';
import { useConversion } from '@/hooks/useConversion';
import { formatFileSize } from '@/lib/utils';
export default function Home() { const floatingBadges = [
const { { label: 'PNG', color: 'bg-pink-200', dot: 'bg-pink-400', top: '18%', left: '8%', delay: 0 },
files, { label: 'MP4', color: 'bg-orange-100', dot: 'bg-orange-400', top: '22%', right: '10%', delay: 0.5 },
isDragging, { label: 'CSV', color: 'bg-emerald-100', dot: 'bg-emerald-400', bottom: '32%', left: '6%', delay: 1.0 },
inputRef, { label: 'PDF', color: 'bg-blue-100', dot: 'bg-blue-400', bottom: '28%', right: '8%', delay: 0.3 },
handleDragEnter, { label: 'WAV', color: 'bg-purple-100', dot: 'bg-purple-400', top: '42%', left: '3%', delay: 0.7 },
handleDragLeave, { label: 'WEBP', color: 'bg-pink-100', dot: 'bg-pink-400', top: '35%', right: '4%', delay: 1.2 },
handleDragOver, ];
handleDrop,
handleFileInput,
openFilePicker,
removeFile,
updateFile,
setTargetFormat,
clearAll,
} = useFileUpload();
const { const features = [
isConverting, {
convertAll, icon: '\u{1F5BC}',
downloadFile, title: 'Images',
downloadAllAsZip, desc: 'PNG, JPG, WebP, GIF, BMP, AVIF, SVG \u2014 convert between any format using Canvas API.',
} = useConversion(updateFile); bg: 'bg-pink-50',
iconBg: 'bg-pink-100',
formats: ['PNG', 'JPG', 'WebP', 'GIF', 'AVIF', 'SVG'],
wide: true,
},
{
icon: '\u{1F4C4}',
title: 'Documents',
desc: 'DOCX, PDF, Markdown, HTML, TXT \u2014 preserves formatting with styled rendering.',
bg: 'bg-blue-50',
iconBg: 'bg-blue-100',
formats: ['DOCX', 'PDF', 'MD', 'HTML', 'TXT'],
wide: false,
},
{
icon: '\u{1F3B5}',
title: 'Audio',
desc: 'MP3, WAV, OGG, AAC, FLAC, M4A \u2014 powered by FFmpeg WebAssembly.',
bg: 'bg-purple-50',
iconBg: 'bg-purple-100',
formats: ['MP3', 'WAV', 'OGG', 'FLAC'],
wide: false,
},
{
icon: '\u{1F3AC}',
title: 'Video',
desc: 'MP4, WebM, AVI, MOV, MKV \u2014 full video transcoding in your browser.',
bg: 'bg-orange-50',
iconBg: 'bg-orange-100',
formats: ['MP4', 'WebM', 'AVI', 'MOV'],
wide: false,
},
{
icon: '\u{1F4CA}',
title: 'Data',
desc: 'CSV, JSON, XML, YAML, TSV \u2014 smart parsing with structure preservation.',
bg: 'bg-emerald-50',
iconBg: 'bg-emerald-100',
formats: ['CSV', 'JSON', 'XML', 'YAML', 'TSV'],
wide: true,
},
];
const hasFiles = files.length > 0; const stagger = {
const convertableCount = files.filter( hidden: {},
(f) => f.targetFormat && f.status !== 'done' && f.availableFormats.length > 0 visible: { transition: { staggerChildren: 0.08 } },
).length; };
const completedCount = files.filter((f) => f.status === 'done').length;
const totalSize = files.reduce((acc, f) => acc + f.size, 0);
const fadeUp = {
hidden: { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: [0.16, 1, 0.3, 1] as const },
},
};
export default function LandingPage() {
return ( return (
<div className="min-h-screen relative"> <div className="min-h-screen relative bg-bg-cream">
{/* Atmospheric backgrounds */} {/* Atmospheric backgrounds */}
<div className="bg-mesh" /> <div className="fixed inset-0 pointer-events-none z-0 bg-pastel-mesh" />
<div className="noise-overlay" /> <div className="fixed inset-0 pointer-events-none z-0 opacity-30 bg-dots" />
{/* Header */} {/* ──── NAV ──── */}
<header className="app-header"> <header className="sticky top-0 z-50 flex items-center justify-between px-6 py-4 bg-bg-cream/80 backdrop-blur-xl border-b border-border-soft">
<div className="logo"> <Link href="/" className="flex items-center gap-2.5 no-underline">
<div className="logo-icon">T</div> <div className="w-8 h-8 rounded-[10px] bg-pink flex items-center justify-center text-white font-serif font-black text-sm">
<span>Transmute</span> T
</div> </div>
<span className="header-tag">v1.0 / client-side</span> <span className="font-serif font-extrabold text-xl tracking-tight text-text-dark">Transmute</span>
</Link>
<Link
href="/convert"
className="inline-flex items-center gap-2 px-5 py-2 text-sm font-bold text-white bg-pink rounded-2xl no-underline shadow-[0_4px_16px_rgba(244,114,182,0.3)] hover:shadow-[0_6px_24px_rgba(244,114,182,0.4)] hover:-translate-y-0.5 transition-all"
>
Open Converter
</Link>
</header> </header>
{/* Drop Zone */} {/* ──── HERO ──── */}
<DropZone <section className="relative flex flex-col items-center justify-center min-h-screen px-6 pt-32 pb-20 text-center overflow-hidden">
isDragging={isDragging} {/* Floating format badges */}
hasFiles={hasFiles} {floatingBadges.map((badge) => (
inputRef={inputRef}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onFileInput={handleFileInput}
onBrowse={openFilePicker}
/>
{/* File Grid */}
{hasFiles && (
<motion.div <motion.div
className="file-grid" key={badge.label}
initial={{ opacity: 0 }} className="absolute hidden md:flex items-center gap-1.5 px-3.5 py-2 bg-white border border-border-soft rounded-2xl font-mono text-xs font-semibold text-text-mid shadow-[0_4px_12px_rgba(160,120,80,0.08)] pointer-events-none select-none"
animate={{ opacity: 1 }} style={{
transition={{ duration: 0.3 }} top: badge.top,
> bottom: badge.bottom,
<AnimatePresence mode="popLayout"> left: badge.left,
{files.map((file, index) => ( right: badge.right,
<FileCard } as React.CSSProperties}
key={file.id} initial={{ opacity: 0, scale: 0.8 }}
file={file}
index={index}
onSetFormat={setTargetFormat}
onRemove={removeFile}
onDownload={downloadFile}
/>
))}
</AnimatePresence>
</motion.div>
)}
{/* Action Bar */}
{hasFiles && (
<motion.div
className="action-bar"
initial={{ y: 80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
>
<div className="action-bar-info">
<span className="action-bar-stat">
<strong>{files.length}</strong> file{files.length !== 1 ? 's' : ''}
</span>
<div className="stat-divider" />
<span className="action-bar-stat">
<strong>{formatFileSize(totalSize)}</strong>
</span>
{completedCount > 0 && (
<>
<div className="stat-divider" />
<span className="action-bar-stat">
<strong style={{ color: '#10b981' }}>{completedCount}</strong> converted
</span>
</>
)}
</div>
<div className="action-bar-buttons">
<button className="btn-secondary" onClick={clearAll}>
Clear all
</button>
{completedCount > 0 && (
<motion.button
className="btn-download-all"
onClick={() => downloadAllAsZip(files)}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
whileHover={{ scale: 1.03 }} transition={{ delay: 0.8 + badge.delay, duration: 0.5, ease: 'easeOut' }}
whileTap={{ scale: 0.97 }}
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <div className="flex items-center gap-1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" /> <span className={`w-2 h-2 rounded-sm ${badge.dot}`} />
</svg> .{badge.label}
Download ZIP </div>
</motion.button> </motion.div>
)} ))}
<motion.button <motion.div
className={`btn-convert ${isConverting ? 'converting' : ''}`} variants={stagger}
onClick={() => convertAll(files)} initial="hidden"
disabled={isConverting || convertableCount === 0} animate="visible"
whileHover={!isConverting && convertableCount > 0 ? { scale: 1.03 } : {}} className="flex flex-col items-center gap-0 z-10"
whileTap={!isConverting && convertableCount > 0 ? { scale: 0.97 } : {}}
> >
{isConverting ? ( {/* Badge */}
<> <motion.div
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin"> className="inline-flex items-center gap-2 px-4 py-1.5 pl-2.5 bg-white border border-border-soft rounded-full text-sm font-medium text-text-mid shadow-[0_1px_3px_rgba(160,120,80,0.06)]"
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" /> variants={fadeUp}
</svg> >
Converting... <span className="w-2 h-2 rounded-full bg-mint animate-badge-pulse" />
</> 100% client-side, forever free
) : ( </motion.div>
<>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> {/* Title */}
<motion.h1
className="font-serif font-black text-[clamp(48px,8vw,88px)] leading-[1.05] tracking-[-0.03em] text-text-dark max-w-[800px] mt-6"
variants={fadeUp}
>
Convert <span className="text-pink">anything</span> to{' '}
<span className="text-purple">everything</span>
</motion.h1>
{/* Subtitle */}
<motion.p
className="text-[clamp(16px,2.5vw,20px)] text-text-mid max-w-[520px] leading-relaxed mt-5"
variants={fadeUp}
>
Drop images, docs, audio, video, or data files &mdash; get instant conversions
right in your browser. No uploads. No accounts. No limits.
</motion.p>
{/* CTAs */}
<motion.div className="flex items-center gap-4 mt-10 flex-wrap justify-center" variants={fadeUp}>
<Link
href="/convert"
className="inline-flex items-center gap-2.5 px-8 py-3.5 text-base font-bold text-white bg-pink rounded-2xl no-underline shadow-[0_4px_24px_rgba(244,114,182,0.3)] hover:shadow-[0_8px_36px_rgba(244,114,182,0.4)] hover:-translate-y-0.5 transition-all"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" strokeLinecap="round" strokeLinejoin="round" /> <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
Transmute {convertableCount > 0 ? `(${convertableCount})` : ''} Start Converting
</> </Link>
)} <a
</motion.button> href="#features"
className="inline-flex items-center gap-2 px-7 py-3.5 text-base font-semibold text-text-mid bg-transparent border-[1.5px] border-border-med rounded-2xl no-underline hover:text-text-dark hover:border-pink hover:bg-pink/10 transition-all"
>
See what&apos;s possible
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 9l6 6 6-6" />
</svg>
</a>
</motion.div>
</motion.div>
</section>
{/* ──── FEATURES ──── */}
<section
id="features"
className="relative z-10 flex flex-col items-center gap-10 px-6 py-20"
>
<motion.div
className="text-center flex flex-col items-center gap-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.6 }}
>
<span className="inline-flex items-center gap-2 px-3.5 py-1.5 bg-pink/10 rounded-full font-mono text-[11px] font-semibold uppercase tracking-wider text-pink">
Formats
</span>
<h2 className="font-serif font-extrabold text-[clamp(32px,5vw,48px)] leading-[1.1] tracking-tight text-text-dark">
Every format you need
</h2>
<p className="text-[17px] text-text-mid leading-relaxed max-w-[520px]">
40+ file formats across 5 categories, all converted instantly with zero quality loss.
</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' }}
>
{features.map((feat) => (
<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}
>
<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"
>
.{f}
</span>
))}
</div> </div>
</motion.div> </motion.div>
))}
</motion.div>
</section>
{/* ──── HOW IT WORKS ──── */}
<section className="relative z-10 flex flex-col items-center gap-10 px-6 py-10 pb-24">
<motion.div
className="text-center flex flex-col items-center gap-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.6 }}
>
<span className="inline-flex items-center gap-2 px-3.5 py-1.5 bg-pink/10 rounded-full font-mono text-[11px] font-semibold uppercase tracking-wider text-pink">
How it works
</span>
<h2 className="font-serif font-extrabold text-[clamp(32px,5vw,48px)] leading-[1.1] tracking-tight text-text-dark">
Three steps. That&apos;s it.
</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', title: 'Drop your files', desc: 'Drag and drop any file \u2014 or click to browse. We accept everything.' },
{ num: '2', title: 'Pick a format', desc: 'Choose your target format from smart suggestions based on file type.' },
{ num: '3', 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="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>
</div>
)} )}
</motion.div>
))}
</motion.div>
</section>
{/* ──── PRIVACY ──── */}
<section className="relative z-10 flex flex-col items-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 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] as const }}
>
<div className="w-16 h-16 mx-auto mb-5 rounded-full bg-emerald-50 flex items-center justify-center">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#2d1f14" strokeWidth="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</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">
{[
{ label: 'No uploads', color: 'bg-emerald-50', stroke: '#34d399' },
{ label: 'No servers', color: 'bg-blue-50', stroke: '#60a5fa' },
{ label: 'No tracking', color: 'bg-purple-50', stroke: '#a78bfa' },
{ label: 'No limits', color: 'bg-orange-50', stroke: '#fb923c' },
].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}`}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={b.stroke} strokeWidth="2.5">
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{b.label}
</div>
))}
</div>
</motion.div>
</section>
{/* ──── FOOTER CTA ──── */}
<section className="relative z-10 flex flex-col items-center gap-6 px-6 pt-10 pb-6 text-center">
<motion.h2
className="font-serif font-extrabold text-[clamp(32px,5vw,48px)] leading-[1.1] tracking-tight text-text-dark"
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
Ready to transmute?
</motion.h2>
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<Link
href="/convert"
className="inline-flex items-center gap-2.5 px-8 py-3.5 text-base font-bold text-white bg-pink rounded-2xl no-underline shadow-[0_4px_24px_rgba(244,114,182,0.3)] hover:shadow-[0_8px_36px_rgba(244,114,182,0.4)] hover:-translate-y-0.5 transition-all"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Open Converter
</Link>
</motion.div>
</section>
{/* ──── FOOTER ──── */}
<footer className="text-center px-6 py-12 text-sm text-text-light">
<p>
Built with love, runs on your machine.
</p>
</footer>
</div> </div>
); );
} }
+38 -21
View File
@@ -26,10 +26,11 @@ export function DropZone({
onFileInput, onFileInput,
onBrowse, onBrowse,
}: DropZoneProps) { }: DropZoneProps) {
// Compact mode — thin bar when files are present
if (hasFiles) { if (hasFiles) {
return ( return (
<div <div
className="drop-zone-compact" className="px-6 py-3 relative z-10"
onDragEnter={onDragEnter} onDragEnter={onDragEnter}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
onDragOver={onDragOver} onDragOver={onDragOver}
@@ -43,21 +44,28 @@ export function DropZone({
className="hidden" className="hidden"
/> />
<div <div
className={`compact-drop-area ${isDragging ? 'dragging' : ''}`} className={`flex items-center justify-center gap-2.5 px-5 py-3 rounded-2xl border-2 border-dashed cursor-pointer transition-all duration-200 select-none ${
isDragging
? 'border-pink bg-pink/5 text-pink scale-[1.01]'
: 'border-border-soft bg-white/60 text-text-light hover:border-pink/40 hover:text-pink/70 hover:bg-white/80'
}`}
onClick={onBrowse} onClick={onBrowse}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<path d="M12 5v14M5 12h14" /> <path d="M12 5v14M5 12h14" />
</svg> </svg>
<span>Drop more files or click to browse</span> <span className="text-sm font-semibold">
{isDragging ? 'Release to add files' : 'Drop more files or click to browse'}
</span>
</div> </div>
</div> </div>
); );
} }
// Hero mode — full drop zone when no files
return ( return (
<div <div
className="drop-zone-hero" className="flex items-center justify-center min-h-[70vh] px-6 py-16 relative z-10"
onDragEnter={onDragEnter} onDragEnter={onDragEnter}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
onDragOver={onDragOver} onDragOver={onDragOver}
@@ -72,36 +80,45 @@ export function DropZone({
/> />
<motion.div <motion.div
className={`drop-zone-inner ${isDragging ? 'dragging' : ''}`} className={`relative w-full max-w-xl rounded-3xl border-2 border-dashed p-12 text-center transition-colors duration-300 ${
isDragging
? 'border-pink bg-pink/[0.04] shadow-[0_8px_40px_rgba(244,114,182,0.12)]'
: 'border-border-med bg-white/50 shadow-[0_4px_24px_rgba(180,140,100,0.06)]'
}`}
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ animate={{
opacity: 1, opacity: 1,
scale: isDragging ? 1.02 : 1, scale: isDragging ? 1.02 : 1,
}} }}
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] as const }}
> >
{/* Animated corner accents */} {/* Corner accents */}
<div className="corner-accent top-left" /> <div className="absolute top-3 left-3 w-5 h-5 border-t-2 border-l-2 border-pink/30 rounded-tl-lg" />
<div className="corner-accent top-right" /> <div className="absolute top-3 right-3 w-5 h-5 border-t-2 border-r-2 border-purple/30 rounded-tr-lg" />
<div className="corner-accent bottom-left" /> <div className="absolute bottom-3 left-3 w-5 h-5 border-b-2 border-l-2 border-blue/30 rounded-bl-lg" />
<div className="corner-accent bottom-right" /> <div className="absolute bottom-3 right-3 w-5 h-5 border-b-2 border-r-2 border-mint/30 rounded-br-lg" />
<motion.div <motion.div
className="drop-zone-content" className="flex flex-col items-center gap-5"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.6 }} transition={{ delay: 0.2, duration: 0.6 }}
> >
{/* Upload icon */} {/* Upload icon */}
<motion.div <motion.div
className="upload-icon" className={`flex items-center justify-center w-20 h-20 rounded-2xl transition-colors duration-300 ${
isDragging
? 'bg-pink/15 text-pink'
: 'bg-pink/8 text-pink/70'
}`}
animate={{ animate={{
y: isDragging ? -8 : 0, y: isDragging ? -10 : 0,
scale: isDragging ? 1.15 : 1, scale: isDragging ? 1.15 : 1,
rotate: isDragging ? -5 : 0,
}} }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }} transition={{ type: 'spring', stiffness: 300, damping: 20 }}
> >
<svg width="48" height="48" viewBox="0 0 48 48" fill="none"> <svg width="40" height="40" viewBox="0 0 48 48" fill="none">
<path <path
d="M24 32V8M24 8L16 16M24 8L32 16" d="M24 32V8M24 8L16 16M24 8L32 16"
stroke="currentColor" stroke="currentColor"
@@ -119,17 +136,17 @@ export function DropZone({
</svg> </svg>
</motion.div> </motion.div>
<h2 className="drop-zone-title"> <h2 className="font-serif text-3xl font-extrabold text-text-dark tracking-tight">
{isDragging ? 'Release to transmute' : 'Drop anything.'} {isDragging ? 'Release to transmute' : 'Drop anything.'}
</h2> </h2>
<p className="drop-zone-subtitle"> <p className="text-text-mid text-[15px] max-w-xs leading-relaxed">
{isDragging {isDragging
? 'Your files are ready for transformation' ? 'Your files are ready for transformation'
: 'Images, documents, audio, video, data \u2014 all formats welcome'} : 'Images, documents, audio, video, data \u2014 all formats welcome'}
</p> </p>
<motion.button <motion.button
className="browse-button" className="inline-flex items-center gap-2 mt-2 px-7 py-3 text-sm font-bold text-white bg-pink rounded-2xl cursor-pointer shadow-[0_4px_20px_rgba(244,114,182,0.25)] hover:-translate-y-0.5 hover:shadow-[0_6px_28px_rgba(244,114,182,0.35)] transition-all border-none"
onClick={onBrowse} onClick={onBrowse}
whileHover={{ scale: 1.04 }} whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.97 }} whileTap={{ scale: 0.97 }}
@@ -140,8 +157,8 @@ export function DropZone({
Browse files Browse files
</motion.button> </motion.button>
<p className="drop-zone-hint"> <p className="font-mono text-[11px] text-text-light tracking-wide mt-1">
100% client-side \u2014 your files never leave your browser 100% client-side &mdash; your files never leave your browser
</p> </p>
</motion.div> </motion.div>
</motion.div> </motion.div>
+42 -29
View File
@@ -25,30 +25,27 @@ export function FileCard({
return ( return (
<motion.div <motion.div
className="file-card" 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 }} initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }} exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ transition={{
duration: 0.4, duration: 0.4,
delay: index * 0.05, delay: index * 0.05,
ease: [0.16, 1, 0.3, 1], ease: [0.16, 1, 0.3, 1] as const,
}} }}
layout layout
style={{
'--card-accent': categoryColor,
} as React.CSSProperties}
> >
{/* Top accent line */} {/* Top accent line */}
<div <div
className="card-accent-line" className="h-[3px] w-full"
style={{ background: categoryColor }} style={{ background: categoryColor }}
/> />
{/* Header: category badge + remove */} {/* Header: category badge + remove */}
<div className="card-header"> <div className="flex items-center justify-between px-4 pt-3 pb-1">
<span <span
className="category-badge" className="inline-flex items-center px-2.5 py-0.5 text-[11px] font-bold font-mono tracking-wider uppercase rounded-full border"
style={{ style={{
background: `${categoryColor}18`, background: `${categoryColor}18`,
color: categoryColor, color: categoryColor,
@@ -60,7 +57,7 @@ export function FileCard({
{file.status !== 'converting' && ( {file.status !== 'converting' && (
<button <button
className="remove-btn" 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)} onClick={() => onRemove(file.id)}
aria-label="Remove file" aria-label="Remove file"
> >
@@ -72,80 +69,94 @@ export function FileCard({
</div> </div>
{/* File preview / icon */} {/* File preview / icon */}
<div className="card-preview"> <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 ? ( {file.preview ? (
/* eslint-disable-next-line @next/next/no-img-element */ /* eslint-disable-next-line @next/next/no-img-element */
<img <img
src={file.preview} src={file.preview}
alt={file.name} alt={file.name}
className="preview-image" className="w-full h-full object-cover"
/> />
) : ( ) : (
<div <div className="flex items-center justify-center w-full h-full">
className="preview-icon" <span
className="font-mono text-2xl font-black tracking-wider opacity-60"
style={{ color: categoryColor }} style={{ color: categoryColor }}
> >
<span className="file-ext">.{file.extension}</span> .{file.extension}
</span>
</div> </div>
)} )}
{/* Progress overlay */} {/* Progress overlay */}
{file.status === 'converting' && ( {file.status === 'converting' && (
<div className="progress-overlay"> <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} /> <ProgressRing progress={file.progress} color={categoryColor} />
<span className="progress-text">{Math.round(file.progress)}%</span> <span className="font-mono text-xs font-bold text-text-dark">
{Math.round(file.progress)}%
</span>
</div> </div>
)} )}
{/* Done overlay */} {/* Done overlay */}
{file.status === 'done' && ( {file.status === 'done' && (
<motion.div <motion.div
className="done-overlay" className="absolute inset-0 flex items-center justify-center bg-mint/10 backdrop-blur-sm rounded-xl"
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 }}
> >
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#10b981" strokeWidth="2.5"> <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" /> <path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</div>
</motion.div> </motion.div>
)} )}
{/* Error overlay */} {/* Error overlay */}
{file.status === 'error' && ( {file.status === 'error' && (
<div className="error-overlay"> <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"> <svg width="24" height="24" 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>
</div> </div>
</div>
)} )}
</div> </div>
{/* File info */} {/* File info */}
<div className="card-info"> <div className="px-4 pb-1">
<p className="file-name" title={file.name}> <p className="text-sm font-semibold text-text-dark truncate leading-snug" title={file.name}>
{truncateFilename(file.name)} {truncateFilename(file.name)}
</p> </p>
<p className="file-size">{formatFileSize(file.size)}</p> <p className="font-mono text-[11px] text-text-light mt-0.5">
{formatFileSize(file.size)}
</p>
</div> </div>
{/* Error message */} {/* Error message */}
{file.status === 'error' && file.error && ( {file.status === 'error' && file.error && (
<p className="error-message">{file.error}</p> <p className="px-4 pb-2 text-[12px] text-red-400 leading-snug">
{file.error}
</p>
)} )}
{/* Format selector */} {/* Format selector */}
{file.availableFormats.length > 0 && file.status !== 'done' && ( {file.availableFormats.length > 0 && file.status !== 'done' && (
<div className="format-selector"> <div className="flex items-center gap-2 px-4 pb-4 pt-1.5">
<span className="format-from">.{file.extension}</span> <span className="font-mono text-xs font-bold text-text-mid bg-bg-warm px-2 py-1 rounded-lg">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="format-arrow"> .{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" /> <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="format-select" 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` }} style={{ borderColor: `${categoryColor}40` }}
> >
{file.availableFormats.map((fmt) => ( {file.availableFormats.map((fmt) => (
@@ -159,8 +170,9 @@ export function FileCard({
{/* Download button */} {/* Download button */}
{file.status === 'done' && ( {file.status === 'done' && (
<div className="px-4 pb-4 pt-1">
<motion.button <motion.button
className="download-btn" className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 text-[13px] 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)} onClick={() => onDownload(file)}
initial={{ opacity: 0, y: 5 }} initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@@ -172,11 +184,12 @@ export function FileCard({
</svg> </svg>
Download .{file.targetFormat} Download .{file.targetFormat}
</motion.button> </motion.button>
</div>
)} )}
{/* Unsupported message */} {/* Unsupported message */}
{file.availableFormats.length === 0 && ( {file.availableFormats.length === 0 && (
<p className="unsupported-msg"> <p className="px-4 pb-4 pt-1 text-[12px] text-text-light italic text-center">
Format not supported for conversion Format not supported for conversion
</p> </p>
)} )}
+2 -2
View File
@@ -11,7 +11,7 @@ export function ProgressRing({
progress, progress,
size = 36, size = 36,
strokeWidth = 3, strokeWidth = 3,
color = '#00F0FF', color = '#f472b6',
}: ProgressRingProps) { }: ProgressRingProps) {
const radius = (size - strokeWidth) / 2; const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
@@ -30,7 +30,7 @@ export function ProgressRing({
cy={size / 2} cy={size / 2}
r={radius} r={radius}
fill="none" fill="none"
stroke="rgba(255,255,255,0.08)" stroke="rgba(0,0,0,0.06)"
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
/> />
{/* Progress ring */} {/* Progress ring */}
+6 -6
View File
@@ -26,12 +26,12 @@ export interface ConversionResult {
} }
export const CATEGORY_COLORS: Record<FileCategory, string> = { export const CATEGORY_COLORS: Record<FileCategory, string> = {
image: '#10b981', image: '#f472b6', // soft pink
document: '#0ea5e9', document: '#60a5fa', // soft blue
audio: '#8b5cf6', audio: '#a78bfa', // soft purple
video: '#f43f5e', video: '#fb923c', // soft orange
data: '#f59e0b', data: '#34d399', // soft mint
unknown: '#6b7280', unknown: '#94a3b8', // soft slate
}; };
export const CATEGORY_ICONS: Record<FileCategory, string> = { export const CATEGORY_ICONS: Record<FileCategory, string> = {