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:
@@ -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
@@ -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
@@ -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
@@ -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 — 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'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'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
@@ -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 — your files never leave your browser
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
+42
-29
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user