Added local storage upgraded looks
This commit is contained in:
+276
-110
@@ -4,13 +4,16 @@ import { useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { DropZone } from '@/components/DropZone';
|
||||
import { FileCard } from '@/components/FileCard';
|
||||
import { FileRow } from '@/components/FileRow';
|
||||
import { PreviewModal } from '@/components/PreviewModal';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useConversion } from '@/hooks/useConversion';
|
||||
import { formatFileSize } from '@/lib/utils';
|
||||
import { UploadedFile } from '@/types';
|
||||
|
||||
/* Number of ghost rows to show below real files */
|
||||
const MIN_VISIBLE_ROWS = 8;
|
||||
|
||||
export default function ConvertPage() {
|
||||
const {
|
||||
files,
|
||||
@@ -43,6 +46,7 @@ export default function ConvertPage() {
|
||||
).length;
|
||||
const completedCount = files.filter((f) => f.status === 'done').length;
|
||||
const totalSize = files.reduce((acc, f) => acc + f.size, 0);
|
||||
const ghostRowCount = Math.max(0, MIN_VISIBLE_ROWS - files.length);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative bg-bg-cream">
|
||||
@@ -50,126 +54,288 @@ export default function ConvertPage() {
|
||||
<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">
|
||||
<img src="/logo.png" alt="Transmute" className="w-8 h-8 rounded-[10px]" />
|
||||
<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}
|
||||
{/* Hidden file input — lives at top level so the ref is always stable */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* File Grid */}
|
||||
{hasFiles && (
|
||||
{/* Finder Window — the entire converter lives inside this */}
|
||||
<div className="relative z-10 max-w-[960px] mx-auto px-4 sm:px-6 py-6 sm:py-10">
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-6 px-4 sm:px-6 pt-2 pb-44 sm:pb-36 relative z-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex flex-col max-h-[calc(100vh-80px)] rounded-xl overflow-hidden shadow-[0_12px_60px_rgba(45,31,20,0.12)] border border-border-soft bg-white"
|
||||
initial={{ opacity: 0, y: 16, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{files.map((file, index) => (
|
||||
<FileCard
|
||||
key={file.id}
|
||||
file={file}
|
||||
index={index}
|
||||
onSetFormat={setTargetFormat}
|
||||
onRemove={removeFile}
|
||||
onDownload={downloadFile}
|
||||
onPreview={setPreviewFile}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
{/* ─ Title bar ─ */}
|
||||
<div className="flex-shrink-0 flex items-center gap-3 px-4 py-2.5 bg-[#f6f6f6] border-b border-border-soft select-none">
|
||||
{/* Traffic lights */}
|
||||
<Link href="/" className="flex items-center gap-[6px] no-underline group/dots">
|
||||
<div className="w-[11px] h-[11px] rounded-full bg-[#ff5f57] border border-[#e0443e]/40 group-hover/dots:bg-[#ff3b30] transition-colors" />
|
||||
<div className="w-[11px] h-[11px] rounded-full bg-[#febc2e] border border-[#dea123]/40 group-hover/dots:bg-[#ff9500] transition-colors" />
|
||||
<div className="w-[11px] h-[11px] rounded-full bg-[#28c840] border border-[#1aab29]/40 group-hover/dots:bg-[#28cd41] transition-colors" />
|
||||
</Link>
|
||||
|
||||
{/* 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>
|
||||
{/* Navigation arrows */}
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<Link href="/" className="text-text-light/40 hover:text-text-mid transition-colors no-underline">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M15 18l-6-6 6-6" /></svg>
|
||||
</Link>
|
||||
<div className="text-text-light/25">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 18l6-6-6-6" /></svg>
|
||||
</div>
|
||||
</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>
|
||||
{/* Breadcrumb — centered */}
|
||||
<div className="flex-1 flex items-center justify-center gap-1.5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/logo.png" alt="" className="w-4 h-4 rounded-[4px]" />
|
||||
<span className="text-[12px] font-medium text-text-mid">Transmute</span>
|
||||
<span className="text-[12px] text-text-light/40">{'\u203A'}</span>
|
||||
<span className="text-[12px] font-medium text-text-dark">Converter</span>
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
{/* Right side — version + add files button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[10px] text-text-light/50 tracking-wide hidden sm:block">
|
||||
v1.0
|
||||
</span>
|
||||
{hasFiles && (
|
||||
<button
|
||||
className="flex items-center justify-center w-6 h-6 rounded-md bg-transparent border border-border-soft/60 cursor-pointer text-text-light hover:text-pink hover:border-pink/30 transition-all"
|
||||
onClick={openFilePicker}
|
||||
title="Add files"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</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})` : ''}
|
||||
</>
|
||||
</button>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─ Toolbar (only when files present) ─ */}
|
||||
{hasFiles && (
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-4 py-2 bg-[#fafafa] border-b border-border-soft">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-[11px] text-text-mid">
|
||||
<strong className="text-text-dark">{files.length}</strong> file{files.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<div className="w-px h-3.5 bg-border-soft" />
|
||||
<span className="font-mono text-[11px] text-text-mid">
|
||||
{formatFileSize(totalSize)}
|
||||
</span>
|
||||
{completedCount > 0 && (
|
||||
<>
|
||||
<div className="w-px h-3.5 bg-border-soft" />
|
||||
<span className="font-mono text-[11px] text-text-mid flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-mint" />
|
||||
<strong className="text-mint">{completedCount}</strong> converted
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="font-mono text-[11px] text-text-light hover:text-text-dark cursor-pointer bg-transparent border-none transition-colors"
|
||||
onClick={clearAll}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─ Column headers (only when files present) ─ */}
|
||||
{hasFiles && (
|
||||
<div className="flex-shrink-0 flex items-center px-4 py-1.5 bg-[#fafafa] border-b border-border-soft text-[11px] font-medium text-text-light tracking-wide uppercase select-none">
|
||||
<div className="flex-1 pl-10">Name</div>
|
||||
<div className="w-[72px] text-right hidden sm:block">Size</div>
|
||||
<div className="w-[140px] text-center hidden sm:block">Convert to</div>
|
||||
<div className="w-[130px] text-right pr-1">Status</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─ Content area (scrollable) ─ */}
|
||||
<div
|
||||
className="relative flex-1 overflow-y-auto min-h-0"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
style={{ minHeight: hasFiles ? undefined : '60vh' }}
|
||||
>
|
||||
{/* Drop zone (empty state) */}
|
||||
{!hasFiles && (
|
||||
<DropZone
|
||||
isDragging={isDragging}
|
||||
hasFiles={false}
|
||||
inputRef={inputRef}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onFileInput={handleFileInput}
|
||||
onBrowse={openFilePicker}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drag overlay when files present */}
|
||||
{hasFiles && isDragging && (
|
||||
<motion.div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center bg-pink/[0.04] border-2 border-dashed border-pink/30 rounded-b-xl"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-pink font-semibold text-sm">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
Drop to add files
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* File rows */}
|
||||
{hasFiles && (
|
||||
<div className="divide-y divide-border-soft/50">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{files.map((file, index) => (
|
||||
<FileRow
|
||||
key={file.id}
|
||||
file={file}
|
||||
index={index}
|
||||
onSetFormat={setTargetFormat}
|
||||
onRemove={removeFile}
|
||||
onDownload={downloadFile}
|
||||
onPreview={setPreviewFile}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Ghost / placeholder rows */}
|
||||
{ghostRowCount > 0 && (
|
||||
<>
|
||||
{Array.from({ length: ghostRowCount }).map((_, i) => (
|
||||
<div
|
||||
key={`ghost-${i}`}
|
||||
className={`flex items-center px-4 py-2.5 ${i === 0 ? 'cursor-pointer hover:bg-[#fafafa] transition-colors' : ''}`}
|
||||
onClick={i === 0 ? openFilePicker : undefined}
|
||||
>
|
||||
{/* Ghost icon */}
|
||||
<div className="w-8 h-8 rounded-lg bg-border-soft/20 flex-shrink-0" />
|
||||
{/* Ghost name bar */}
|
||||
<div className="flex-1 ml-3">
|
||||
{i === 0 ? (
|
||||
<span className="text-[12px] text-text-light/40 flex items-center gap-1.5">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40">
|
||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
Drop files here or click to browse
|
||||
</span>
|
||||
) : (
|
||||
<div className="h-2.5 w-24 rounded bg-border-soft/15" style={{ width: `${60 + ((i * 37) % 60)}px` }} />
|
||||
)}
|
||||
</div>
|
||||
{/* Ghost size */}
|
||||
<div className="w-[72px] hidden sm:block">
|
||||
{i < 2 && <div className="h-2 w-10 rounded bg-border-soft/10 ml-auto" />}
|
||||
</div>
|
||||
{/* Ghost convert to */}
|
||||
<div className="w-[140px] hidden sm:block">
|
||||
{i < 2 && <div className="h-2 w-12 rounded bg-border-soft/10 mx-auto" />}
|
||||
</div>
|
||||
{/* Ghost status */}
|
||||
<div className="w-[130px] pr-1">
|
||||
{i < 1 && <div className="h-2 w-8 rounded bg-border-soft/10 ml-auto" />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─ Bottom action bar (always visible) ─ */}
|
||||
{hasFiles && (
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 bg-[#fafafa] border-t border-border-soft">
|
||||
{/* Left — status */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isConverting ? (
|
||||
<>
|
||||
<motion.div
|
||||
className="w-3 h-3 rounded-full border-[1.5px] border-pink border-t-transparent"
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 0.6, repeat: Infinity, ease: 'linear' }}
|
||||
/>
|
||||
<span className="text-[11px] font-mono text-pink font-medium">Converting...</span>
|
||||
</>
|
||||
) : completedCount === files.length && completedCount > 0 ? (
|
||||
<>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-mint" />
|
||||
<span className="text-[11px] font-mono text-mint font-medium">All converted</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[11px] text-text-light font-mono">
|
||||
{convertableCount > 0 ? `${convertableCount} ready to convert` : 'Select target formats'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right — action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{completedCount > 0 && (
|
||||
<motion.button
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-[12px] font-bold text-white bg-mint border-none rounded-lg cursor-pointer shadow-[0_2px_8px_rgba(52,211,153,0.2)] hover:-translate-y-0.5 hover:shadow-[0_3px_14px_rgba(52,211,153,0.3)] transition-all"
|
||||
onClick={() => downloadAllAsZip(files)}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
ZIP
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`inline-flex items-center gap-1.5 px-5 py-1.5 text-[12px] font-bold text-white bg-pink border-none rounded-lg cursor-pointer shadow-[0_2px_12px_rgba(244,114,182,0.25)] hover:-translate-y-0.5 hover:shadow-[0_4px_18px_rgba(244,114,182,0.35)] transition-all disabled:opacity-40 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="12" height="12" 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="12" height="12" 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})` : ''}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Footer outside window */}
|
||||
<div className="text-center mt-4">
|
||||
<p className="font-mono text-[10px] text-text-light/50 tracking-wide">
|
||||
100% client-side — files never leave your browser
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Modal */}
|
||||
<PreviewModal
|
||||
file={previewFile}
|
||||
|
||||
+241
-132
@@ -51,19 +51,42 @@ const flowSteps = [
|
||||
{ inputIcon: '\u{1F3B5}', inputLabel: '.FLAC', outputIcon: '\u{1F3A7}', outputLabel: '.MP3' },
|
||||
];
|
||||
|
||||
/* ─── Terminal Simulation Data ─── */
|
||||
/* ─── Finder Window Data ─── */
|
||||
|
||||
const terminalCommands = [
|
||||
{ cmd: 'transmute photo.heic --to webp', output: ' \u2713 photo.webp (2.4 MB \u2192 680 KB)', color: '#f472b6', time: 1800 },
|
||||
{ cmd: 'transmute report.docx --to pdf', output: ' \u2713 report.pdf (formatting preserved)', color: '#60a5fa', time: 2200 },
|
||||
{ cmd: 'transmute song.flac --to mp3 --quality 320k', output: ' \u2713 song.mp3 (48 MB \u2192 9.2 MB)', color: '#a78bfa', time: 2800 },
|
||||
{ cmd: 'transmute data.csv --to json', output: ' \u2713 data.json (2,847 rows parsed)', color: '#34d399', time: 1400 },
|
||||
{ cmd: 'transmute clip.mov --to mp4', output: ' \u2713 clip.mp4 (H.264, browser-native)', color: '#fb923c', time: 3200 },
|
||||
{ cmd: 'transmute design.psd --to png', output: ' \u2713 design.png (composite layer)', color: '#f472b6', time: 1600 },
|
||||
{ cmd: 'transmute book.epub --to pdf', output: ' \u2713 book.pdf (chapters preserved)', color: '#60a5fa', time: 2000 },
|
||||
{ cmd: 'transmute font.ttf --to woff2', output: ' \u2713 font.woff2 (compressed 62%)', color: '#34d399', time: 1200 },
|
||||
{ cmd: 'transmute slides.pptx --to html', output: ' \u2713 slides.html (12 slides)', color: '#a78bfa', time: 2400 },
|
||||
{ cmd: 'transmute sheet.xlsx --to csv', output: ' \u2713 sheet.csv (3 sheets merged)', color: '#34d399', time: 1500 },
|
||||
interface FinderFile {
|
||||
icon: string;
|
||||
name: string;
|
||||
size: string;
|
||||
targetFormat: string;
|
||||
category: 'image' | 'document' | 'media' | 'data';
|
||||
color: string;
|
||||
}
|
||||
|
||||
const finderBatches: FinderFile[][] = [
|
||||
[
|
||||
{ icon: '\u{1F5BC}', name: 'vacation-photo.heic', size: '2.4 MB', targetFormat: 'WebP', category: 'image', color: '#f472b6' },
|
||||
{ icon: '\u{1F4C4}', name: 'quarterly-report.docx', size: '1.8 MB', targetFormat: 'PDF', category: 'document', color: '#60a5fa' },
|
||||
{ icon: '\u{1F3B5}', name: 'podcast-episode.flac', size: '48 MB', targetFormat: 'MP3', category: 'media', color: '#a78bfa' },
|
||||
{ icon: '\u{1F4CA}', name: 'user-analytics.csv', size: '340 KB', targetFormat: 'JSON', category: 'data', color: '#34d399' },
|
||||
{ icon: '\u{1F3AC}', name: 'screen-recording.mov', size: '126 MB', targetFormat: 'MP4', category: 'media', color: '#fb923c' },
|
||||
{ icon: '\u{1F3A8}', name: 'hero-banner.psd', size: '18.4 MB', targetFormat: 'PNG', category: 'image', color: '#f472b6' },
|
||||
],
|
||||
[
|
||||
{ icon: '\u{1F4D6}', name: 'sci-fi-novel.epub', size: '3.2 MB', targetFormat: 'PDF', category: 'document', color: '#60a5fa' },
|
||||
{ icon: '\u{1F524}', name: 'brand-font.ttf', size: '420 KB', targetFormat: 'WOFF2', category: 'data', color: '#34d399' },
|
||||
{ icon: '\u{1F4CA}', name: 'sales-figures.xlsx', size: '2.1 MB', targetFormat: 'CSV', category: 'data', color: '#34d399' },
|
||||
{ icon: '\u{1F39E}', name: 'product-demo.mkv', size: '340 MB', targetFormat: 'MP4', category: 'media', color: '#fb923c' },
|
||||
{ icon: '\u{1F4DD}', name: 'meeting-notes.md', size: '12 KB', targetFormat: 'HTML', category: 'document', color: '#60a5fa' },
|
||||
{ icon: '\u{1F5BC}', name: 'app-icon.svg', size: '8 KB', targetFormat: 'PNG', category: 'image', color: '#f472b6' },
|
||||
],
|
||||
[
|
||||
{ icon: '\u{2699}', name: 'api-config.yaml', size: '24 KB', targetFormat: 'JSON', category: 'data', color: '#34d399' },
|
||||
{ icon: '\u{1F3B5}', name: 'voice-memo.wav', size: '32 MB', targetFormat: 'OGG', category: 'media', color: '#a78bfa' },
|
||||
{ icon: '\u{1F4C4}', name: 'thesis-draft.pdf', size: '5.6 MB', targetFormat: 'DOCX', category: 'document', color: '#60a5fa' },
|
||||
{ icon: '\u{1F5BC}', name: 'team-photo.png', size: '4.8 MB', targetFormat: 'WebP', category: 'image', color: '#f472b6' },
|
||||
{ icon: '\u{1F4CA}', name: 'inventory.json', size: '180 KB', targetFormat: 'CSV', category: 'data', color: '#34d399' },
|
||||
{ icon: '\u{1F3AC}', name: 'tutorial-clip.webm', size: '68 MB', targetFormat: 'GIF', category: 'media', color: '#fb923c' },
|
||||
],
|
||||
];
|
||||
|
||||
/* ─── Animation Variants ─── */
|
||||
@@ -310,153 +333,239 @@ function ConversionFlow() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Terminal Simulation Component ─── */
|
||||
/* ─── Finder Window Component ─── */
|
||||
|
||||
interface TerminalLine {
|
||||
type: 'prompt' | 'output' | 'blank';
|
||||
text: string;
|
||||
color?: string;
|
||||
}
|
||||
type FileStatus = 'idle' | 'converting' | 'done';
|
||||
|
||||
function TerminalSimulation() {
|
||||
const [lines, setLines] = useState<TerminalLine[]>([]);
|
||||
const [currentTyping, setCurrentTyping] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const termRef = useRef<HTMLDivElement>(null);
|
||||
const cmdIndexRef = useRef(0);
|
||||
function FinderWindow() {
|
||||
const [batchIndex, setBatchIndex] = useState(0);
|
||||
const [fileStatuses, setFileStatuses] = useState<FileStatus[]>(
|
||||
Array(6).fill('idle')
|
||||
);
|
||||
const runningRef = useRef(true);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (termRef.current) {
|
||||
termRef.current.scrollTop = termRef.current.scrollHeight;
|
||||
}
|
||||
const clearTimeouts = useCallback(() => {
|
||||
timeoutRef.current.forEach(clearTimeout);
|
||||
timeoutRef.current = [];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
runningRef.current = true;
|
||||
|
||||
const typeCommand = async (cmdObj: typeof terminalCommands[0]) => {
|
||||
const runBatch = () => {
|
||||
if (!runningRef.current) return;
|
||||
|
||||
// Type the command character by character
|
||||
setIsTyping(true);
|
||||
for (let i = 0; i <= cmdObj.cmd.length; i++) {
|
||||
if (!runningRef.current) return;
|
||||
setCurrentTyping(cmdObj.cmd.slice(0, i));
|
||||
await new Promise((r) => setTimeout(r, 30 + Math.random() * 40));
|
||||
}
|
||||
// Reset all to idle
|
||||
setFileStatuses(Array(6).fill('idle'));
|
||||
|
||||
// Brief pause after typing
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
if (!runningRef.current) return;
|
||||
const batch = finderBatches[batchIndex % finderBatches.length];
|
||||
|
||||
// "Execute" — move command to lines, show output
|
||||
setIsTyping(false);
|
||||
setCurrentTyping('');
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{ type: 'prompt', text: cmdObj.cmd },
|
||||
{ type: 'output', text: cmdObj.output, color: cmdObj.color },
|
||||
]);
|
||||
scrollToBottom();
|
||||
|
||||
// Pause before next command
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
};
|
||||
|
||||
const runLoop = async () => {
|
||||
// Small initial delay
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
|
||||
while (runningRef.current) {
|
||||
const cmd = terminalCommands[cmdIndexRef.current % terminalCommands.length];
|
||||
await typeCommand(cmd);
|
||||
cmdIndexRef.current++;
|
||||
|
||||
// After showing 6 commands, clear and start fresh to prevent infinite growth
|
||||
if (cmdIndexRef.current % 6 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
// Stagger: each file starts converting 600ms apart
|
||||
batch.forEach((_, i) => {
|
||||
// Start converting
|
||||
const convertTimer = setTimeout(() => {
|
||||
if (!runningRef.current) return;
|
||||
setLines([]);
|
||||
}
|
||||
}
|
||||
setFileStatuses((prev) => {
|
||||
const next = [...prev];
|
||||
next[i] = 'converting';
|
||||
return next;
|
||||
});
|
||||
}, 800 + i * 600);
|
||||
timeoutRef.current.push(convertTimer);
|
||||
|
||||
// Finish converting (700-1200ms after start)
|
||||
const doneTimer = setTimeout(() => {
|
||||
if (!runningRef.current) return;
|
||||
setFileStatuses((prev) => {
|
||||
const next = [...prev];
|
||||
next[i] = 'done';
|
||||
return next;
|
||||
});
|
||||
}, 800 + i * 600 + 700 + Math.random() * 500);
|
||||
timeoutRef.current.push(doneTimer);
|
||||
});
|
||||
|
||||
// After all done, wait then move to next batch
|
||||
const nextBatchTimer = setTimeout(() => {
|
||||
if (!runningRef.current) return;
|
||||
setBatchIndex((prev) => prev + 1);
|
||||
}, 800 + batch.length * 600 + 1800);
|
||||
timeoutRef.current.push(nextBatchTimer);
|
||||
};
|
||||
|
||||
runLoop();
|
||||
runBatch();
|
||||
|
||||
return () => {
|
||||
runningRef.current = false;
|
||||
clearTimeouts();
|
||||
};
|
||||
}, [scrollToBottom]);
|
||||
}, [batchIndex, clearTimeouts]);
|
||||
|
||||
// Auto-scroll on new lines
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [lines, currentTyping, scrollToBottom]);
|
||||
const batch = finderBatches[batchIndex % finderBatches.length];
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[700px] mx-auto">
|
||||
{/* Terminal window */}
|
||||
<div className="rounded-xl overflow-hidden shadow-[0_8px_60px_rgba(0,0,0,0.25)] border border-white/[0.06]">
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-[#1a1a2e]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-[#ff5f57]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#febc2e]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#28c840]" />
|
||||
<div className="w-full max-w-[720px] mx-auto">
|
||||
{/* Finder window */}
|
||||
<div className="rounded-xl overflow-hidden shadow-[0_8px_48px_rgba(45,31,20,0.1)] border border-border-soft bg-white">
|
||||
|
||||
{/* ─ Title bar ─ */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 bg-[#f6f6f6] border-b border-border-soft">
|
||||
{/* Traffic lights */}
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<div className="w-[11px] h-[11px] rounded-full bg-[#ff5f57] border border-[#e0443e]/40" />
|
||||
<div className="w-[11px] h-[11px] rounded-full bg-[#febc2e] border border-[#dea123]/40" />
|
||||
<div className="w-[11px] h-[11px] rounded-full bg-[#28c840] border border-[#1aab29]/40" />
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<div className="text-text-light/40">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M15 18l-6-6 6-6" /></svg>
|
||||
</div>
|
||||
<div className="text-text-light/40">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 18l6-6-6-6" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex-1 flex items-center justify-center gap-1.5">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-text-light/50">
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
<span className="text-[12px] font-medium text-text-mid">Transmute</span>
|
||||
<span className="text-[12px] text-text-light/40">{'\u203A'}</span>
|
||||
<span className="text-[12px] font-medium text-text-dark">Converting</span>
|
||||
</div>
|
||||
|
||||
{/* View/search icons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-text-light/40">
|
||||
<path d="M3 6h18M3 12h18M3 18h18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-text-light/40">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M21 21l-4.35-4.35" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="flex-1 text-center font-mono text-[11px] text-white/30 tracking-wider">
|
||||
transmute
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Terminal body */}
|
||||
<div
|
||||
ref={termRef}
|
||||
className="bg-[#0f0f1a] px-5 py-4 h-[320px] overflow-y-auto font-mono text-[13px] leading-[1.8] scroll-smooth"
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
{/* Welcome message */}
|
||||
<div className="text-white/20 mb-2 select-none">
|
||||
Transmute v1.0 {'\u2014'} 70+ formats, zero uploads
|
||||
{/* ─ Column headers ─ */}
|
||||
<div className="flex items-center px-4 py-1.5 bg-[#fafafa] border-b border-border-soft text-[11px] font-medium text-text-light tracking-wide uppercase select-none">
|
||||
<div className="flex-1 pl-9">Name</div>
|
||||
<div className="w-[70px] text-right">Size</div>
|
||||
<div className="w-[120px] text-right pr-1">Status</div>
|
||||
</div>
|
||||
|
||||
{/* ─ File rows ─ */}
|
||||
<div className="divide-y divide-border-soft/50">
|
||||
{batch.map((file, i) => {
|
||||
const status = fileStatuses[i];
|
||||
return (
|
||||
<motion.div
|
||||
key={`${batchIndex}-${i}`}
|
||||
className="flex items-center px-4 py-2.5 hover:bg-[#fafafa] transition-colors"
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.05, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
>
|
||||
{/* Icon + filename */}
|
||||
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-[14px] flex-shrink-0"
|
||||
style={{ background: `${file.color}12` }}
|
||||
>
|
||||
{file.icon}
|
||||
</div>
|
||||
<span className="text-[13px] font-medium text-text-dark truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Size */}
|
||||
<div className="w-[70px] text-right font-mono text-[11px] text-text-light flex-shrink-0">
|
||||
{file.size}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="w-[120px] flex items-center justify-end gap-2 flex-shrink-0 pr-1">
|
||||
<AnimatePresence mode="wait">
|
||||
{status === 'idle' && (
|
||||
<motion.span
|
||||
key="idle"
|
||||
className="text-[11px] text-text-light/50 font-mono"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
queued
|
||||
</motion.span>
|
||||
)}
|
||||
{status === 'converting' && (
|
||||
<motion.div
|
||||
key="converting"
|
||||
className="flex items-center gap-1.5"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<motion.div
|
||||
className="w-3.5 h-3.5 rounded-full border-[1.5px] border-t-transparent"
|
||||
style={{ borderColor: file.color, borderTopColor: 'transparent' }}
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 0.6, repeat: Infinity, ease: 'linear' }}
|
||||
/>
|
||||
<span className="text-[11px] font-mono font-medium" style={{ color: file.color }}>
|
||||
converting
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{status === 'done' && (
|
||||
<motion.div
|
||||
key="done"
|
||||
className="flex items-center gap-1.5"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
>
|
||||
{/* Checkmark */}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-mint flex-shrink-0">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(52,211,153,0.12)" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M8 12l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
{/* Target format badge */}
|
||||
<span
|
||||
className="text-[10px] font-mono font-bold px-1.5 py-0.5 rounded-md"
|
||||
style={{ background: `${file.color}12`, color: file.color }}
|
||||
>
|
||||
.{file.targetFormat.toLowerCase()}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ─ Bottom bar ─ */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-[#fafafa] border-t border-border-soft">
|
||||
<span className="text-[11px] text-text-light">
|
||||
{batch.length} items
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-mint" />
|
||||
<span className="text-[11px] text-text-light">
|
||||
{fileStatuses.filter((s) => s === 'done').length} of {batch.length} converted
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Completed lines */}
|
||||
{lines.map((line, i) => (
|
||||
<div key={i}>
|
||||
{line.type === 'prompt' ? (
|
||||
<div className="flex items-start gap-0">
|
||||
<span className="text-[#34d399] select-none">{'>'}</span>
|
||||
<span className="text-white/80 ml-2">{line.text}</span>
|
||||
</div>
|
||||
) : line.type === 'output' ? (
|
||||
<div style={{ color: line.color }} className="opacity-90">
|
||||
{line.text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Currently typing line */}
|
||||
{(isTyping || currentTyping) && (
|
||||
<div className="flex items-start gap-0">
|
||||
<span className="text-[#34d399] select-none">{'>'}</span>
|
||||
<span className="text-white/80 ml-2">{currentTyping}</span>
|
||||
<span className="inline-block w-[2px] h-[16px] bg-[#34d399] ml-[1px] translate-y-[2px] animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Idle cursor */}
|
||||
{!isTyping && !currentTyping && (
|
||||
<div className="flex items-start gap-0">
|
||||
<span className="text-[#34d399] select-none">{'>'}</span>
|
||||
<span className="inline-block w-[2px] h-[16px] bg-[#34d399] ml-2 translate-y-[2px] animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format count below terminal */}
|
||||
{/* Category format counts below window */}
|
||||
<div className="flex items-center justify-center gap-4 mt-6 flex-wrap">
|
||||
{[
|
||||
{ label: 'Images', count: 11, color: '#f472b6' },
|
||||
@@ -584,7 +693,7 @@ export default function LandingPage() {
|
||||
<ConversionFlow />
|
||||
</section>
|
||||
|
||||
{/* ──── FEATURES — TERMINAL SIMULATION ──── */}
|
||||
{/* ──── FEATURES — FINDER WINDOW ──── */}
|
||||
<section
|
||||
id="features"
|
||||
className="relative z-10 flex flex-col items-center gap-10 px-6 py-20"
|
||||
@@ -608,13 +717,13 @@ export default function LandingPage() {
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="w-full max-w-[700px]"
|
||||
className="w-full max-w-[720px]"
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-60px' }}
|
||||
transition={{ duration: 0.6, delay: 0.15, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
>
|
||||
<TerminalSimulation />
|
||||
<FinderWindow />
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
|
||||
+56
-121
@@ -6,161 +6,96 @@ import React from 'react';
|
||||
interface DropZoneProps {
|
||||
isDragging: boolean;
|
||||
hasFiles: boolean;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>;
|
||||
onDragEnter: (e: React.DragEvent) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
onFileInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFileInput?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onBrowse: () => void;
|
||||
}
|
||||
|
||||
export function DropZone({
|
||||
isDragging,
|
||||
hasFiles,
|
||||
inputRef,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onFileInput,
|
||||
onBrowse,
|
||||
}: DropZoneProps) {
|
||||
// Compact mode — thin bar when files are present
|
||||
if (hasFiles) {
|
||||
return (
|
||||
<div
|
||||
className="px-6 py-3 relative z-10"
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={onFileInput}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
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}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold">
|
||||
{isDragging ? 'Release to add files' : 'Drop more files or click to browse'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hero mode — full drop zone when no files
|
||||
// Empty state inside Finder window
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-[70vh] px-6 py-16 relative z-10"
|
||||
className="flex items-center justify-center px-6 py-16"
|
||||
style={{ minHeight: '60vh' }}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={onFileInput}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
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 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: isDragging ? 1.02 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
className="flex flex-col items-center gap-4 text-center"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
>
|
||||
{/* Corner accents */}
|
||||
<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="absolute top-3 right-3 w-5 h-5 border-t-2 border-r-2 border-purple/30 rounded-tr-lg" />
|
||||
<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="absolute bottom-3 right-3 w-5 h-5 border-b-2 border-r-2 border-mint/30 rounded-br-lg" />
|
||||
|
||||
{/* Upload icon */}
|
||||
<motion.div
|
||||
className="flex flex-col items-center gap-5"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.6 }}
|
||||
className={`flex items-center justify-center w-20 h-20 rounded-2xl transition-all duration-300 ${
|
||||
isDragging
|
||||
? 'bg-pink/12 text-pink scale-110'
|
||||
: 'bg-[#f6f6f6] text-text-light'
|
||||
}`}
|
||||
animate={{
|
||||
y: isDragging ? -8 : 0,
|
||||
rotate: isDragging ? -3 : 0,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
>
|
||||
{/* Upload icon */}
|
||||
<motion.div
|
||||
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={{
|
||||
y: isDragging ? -10 : 0,
|
||||
scale: isDragging ? 1.15 : 1,
|
||||
rotate: isDragging ? -5 : 0,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
>
|
||||
<svg width="40" height="40" viewBox="0 0 48 48" fill="none">
|
||||
<path
|
||||
d="M24 32V8M24 8L16 16M24 8L32 16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 28v8a4 4 0 004 4h24a4 4 0 004-4v-8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
<svg width="36" height="36" viewBox="0 0 48 48" fill="none">
|
||||
<path
|
||||
d="M24 32V12M24 12L16 20M24 12L32 20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 28v8a4 4 0 004 4h24a4 4 0 004-4v-8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
|
||||
<h2 className="font-serif text-3xl font-extrabold text-text-dark tracking-tight">
|
||||
{isDragging ? 'Release to transmute' : 'Drop anything.'}
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-extrabold text-text-dark tracking-tight mb-1">
|
||||
{isDragging ? 'Release to add' : 'Drop files here'}
|
||||
</h2>
|
||||
<p className="text-text-mid text-[15px] max-w-xs leading-relaxed">
|
||||
<p className="text-text-mid text-[14px] max-w-xs leading-relaxed">
|
||||
{isDragging
|
||||
? 'Your files are ready for transformation'
|
||||
: 'Images, documents, audio, video, data \u2014 all formats welcome'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.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}
|
||||
whileHover={{ scale: 1.04 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<svg width="16" height="16" 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>
|
||||
Browse files
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className="inline-flex items-center gap-2 mt-1 px-6 py-2.5 text-[13px] font-bold text-white bg-pink rounded-xl cursor-pointer shadow-[0_3px_16px_rgba(244,114,182,0.25)] hover:-translate-y-0.5 hover:shadow-[0_5px_22px_rgba(244,114,182,0.35)] transition-all border-none"
|
||||
onClick={onBrowse}
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
Browse files
|
||||
</motion.button>
|
||||
|
||||
<p className="font-mono text-[11px] text-text-light tracking-wide mt-1">
|
||||
100% client-side — your files never leave your browser
|
||||
</p>
|
||||
</motion.div>
|
||||
<p className="font-mono text-[10px] text-text-light/60 tracking-wide mt-1">
|
||||
70+ formats — 100% client-side
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { UploadedFile, CATEGORY_COLORS, CATEGORY_LABELS } from '@/types';
|
||||
import { formatFileSize, truncateFilename } from '@/lib/utils';
|
||||
import { ProgressRing } from './ProgressRing';
|
||||
|
||||
interface FileCardProps {
|
||||
file: UploadedFile;
|
||||
index: number;
|
||||
onSetFormat: (id: string, format: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onDownload: (file: UploadedFile) => void;
|
||||
onPreview: (file: UploadedFile) => void;
|
||||
}
|
||||
|
||||
/* Seeded random for consistent per-card rotation */
|
||||
function seededRandom(seed: string) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = Math.imul(31, h) + seed.charCodeAt(i) | 0;
|
||||
}
|
||||
return ((h >>> 0) % 1000) / 1000;
|
||||
}
|
||||
|
||||
export function FileCard({
|
||||
file,
|
||||
index,
|
||||
onSetFormat,
|
||||
onRemove,
|
||||
onDownload,
|
||||
onPreview,
|
||||
}: FileCardProps) {
|
||||
const categoryColor = CATEGORY_COLORS[file.category];
|
||||
const categoryLabel = CATEGORY_LABELS[file.category];
|
||||
|
||||
// Stable random rotation per card (-2.5 to 2.5 degrees)
|
||||
const rotation = useMemo(() => {
|
||||
const r = seededRandom(file.id);
|
||||
return (r - 0.5) * 5;
|
||||
}, [file.id]);
|
||||
|
||||
// Slight random tape offset
|
||||
const tapeOffset = useMemo(() => {
|
||||
const r = seededRandom(file.id + 'tape');
|
||||
return (r - 0.5) * 20; // -10 to 10px
|
||||
}, [file.id]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative group"
|
||||
style={{
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
}}
|
||||
initial={{ opacity: 0, y: 24, rotate: rotation }}
|
||||
animate={{ opacity: 1, y: 0, rotate: rotation }}
|
||||
exit={{ opacity: 0, scale: 0.9, rotate: rotation + 5 }}
|
||||
transition={{
|
||||
duration: 0.45,
|
||||
delay: index * 0.04,
|
||||
ease: [0.16, 1, 0.3, 1] as const,
|
||||
}}
|
||||
whileHover={{
|
||||
rotate: 0,
|
||||
scale: 1.03,
|
||||
y: -4,
|
||||
transition: { duration: 0.25, ease: [0.16, 1, 0.3, 1] as const },
|
||||
}}
|
||||
layout
|
||||
>
|
||||
{/* Paper shadow — slightly offset for depth */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-sm bg-text-dark/[0.03] translate-y-1 translate-x-0.5"
|
||||
style={{ filter: 'blur(4px)' }}
|
||||
/>
|
||||
|
||||
{/* Main paper */}
|
||||
<div className="relative bg-[#fffef9] rounded-sm overflow-visible shadow-[0_1px_2px_rgba(120,100,70,0.08)]">
|
||||
{/* Tape strip across top */}
|
||||
<div
|
||||
className="absolute -top-2.5 z-10 w-16 h-6 rounded-[2px] opacity-70"
|
||||
style={{
|
||||
left: `calc(50% + ${tapeOffset}px - 32px)`,
|
||||
background: `${categoryColor}40`,
|
||||
transform: `rotate(${-rotation * 0.5}deg)`,
|
||||
boxShadow: `0 1px 3px ${categoryColor}15`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Faint ruled lines */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.04]"
|
||||
style={{
|
||||
backgroundImage: 'repeating-linear-gradient(to bottom, transparent, transparent 27px, #8b7355 27px, #8b7355 28px)',
|
||||
backgroundPosition: '0 16px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Left margin line */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-10 w-px opacity-[0.06]"
|
||||
style={{ background: '#e8766a' }}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative p-4 pt-5">
|
||||
{/* Header: category + remove */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span
|
||||
className="font-mono text-[10px] font-bold uppercase tracking-[0.08em] px-2 py-0.5 rounded-sm"
|
||||
style={{
|
||||
color: categoryColor,
|
||||
background: `${categoryColor}10`,
|
||||
}}
|
||||
>
|
||||
{categoryLabel}
|
||||
</span>
|
||||
|
||||
{file.status !== 'converting' && (
|
||||
<button
|
||||
className="flex items-center justify-center w-6 h-6 rounded-sm bg-transparent border-none cursor-pointer text-text-light/50 hover:text-text-dark hover:bg-text-dark/5 transition-all opacity-0 group-hover:opacity-100"
|
||||
onClick={() => onRemove(file.id)}
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extension — big typewriter style */}
|
||||
<div className="relative flex items-center justify-center py-5 mb-3">
|
||||
{file.preview ? (
|
||||
<div className="relative w-full h-28 rounded-sm overflow-hidden border border-border-soft/50">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="font-mono text-[32px] font-black tracking-tight leading-none select-none"
|
||||
style={{ color: `${categoryColor}90` }}
|
||||
>
|
||||
.{file.extension}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Progress overlay */}
|
||||
{file.status === 'converting' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-[#fffef9]/85 backdrop-blur-[2px] rounded-sm">
|
||||
<ProgressRing progress={file.progress} color={categoryColor} />
|
||||
<span className="font-mono text-[11px] font-bold text-text-dark">
|
||||
{Math.round(file.progress)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done overlay */}
|
||||
{file.status === 'done' && (
|
||||
<motion.div
|
||||
className="absolute inset-0 flex items-center justify-center bg-mint/[0.07] rounded-sm"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 15 }}
|
||||
>
|
||||
<div className="flex items-center justify-center w-11 h-11 rounded-full bg-[#fffef9] shadow-[0_2px_10px_rgba(52,211,153,0.15)] border border-mint/20">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2.5">
|
||||
<path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Error overlay */}
|
||||
{file.status === 'error' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-red-50/70 backdrop-blur-[2px] rounded-sm">
|
||||
<div className="flex items-center justify-center w-11 h-11 rounded-full bg-[#fffef9] shadow-[0_2px_10px_rgba(244,63,94,0.12)] border border-red-200/40">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f43f5e" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filename + size — handwritten feel area */}
|
||||
<div className="mb-2.5">
|
||||
<p className="text-[13px] font-semibold text-text-dark truncate leading-snug" title={file.name}>
|
||||
{truncateFilename(file.name)}
|
||||
</p>
|
||||
<p className="font-mono text-[10px] text-text-light mt-0.5 tracking-wide">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{file.status === 'error' && file.error && (
|
||||
<p className="pb-1 text-[11px] text-red-400 leading-snug">
|
||||
{file.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Format selector — styled like a form field on paper */}
|
||||
{file.availableFormats.length > 0 && file.status !== 'done' && (
|
||||
<div className="flex items-center gap-2 pt-2.5 mt-1 border-t border-dashed border-text-dark/[0.06]">
|
||||
<span className="font-mono text-[11px] font-bold text-text-mid">
|
||||
.{file.extension}
|
||||
</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-text-light/50 flex-shrink-0">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<select
|
||||
value={file.targetFormat || ''}
|
||||
onChange={(e) => onSetFormat(file.id, e.target.value)}
|
||||
className="flex-1 min-w-0 font-mono text-[11px] font-bold text-text-dark bg-transparent px-2 py-1 rounded-sm border border-dashed cursor-pointer hover:border-text-dark/20 focus:outline-none focus:border-pink/40 transition-all appearance-none"
|
||||
style={{ borderColor: `${categoryColor}30` }}
|
||||
>
|
||||
{file.availableFormats.map((fmt) => (
|
||||
<option key={fmt} value={fmt}>
|
||||
.{fmt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons — done state */}
|
||||
{file.status === 'done' && (
|
||||
<div className="flex items-center gap-2 pt-2.5 mt-1 border-t border-dashed border-text-dark/[0.06]">
|
||||
<motion.button
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-2.5 py-2 text-[11px] font-bold text-text-dark bg-transparent border border-dashed border-text-dark/10 rounded-sm cursor-pointer hover:bg-text-dark/[0.03] hover:border-text-dark/20 transition-all"
|
||||
onClick={() => onPreview(file)}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
Preview
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className="flex-1 inline-flex items-center justify-center gap-1.5 px-2.5 py-2 text-[11px] font-bold text-white bg-mint border-none rounded-sm cursor-pointer shadow-[0_1px_6px_rgba(52,211,153,0.2)] hover:shadow-[0_2px_12px_rgba(52,211,153,0.3)] transition-all"
|
||||
onClick={() => onDownload(file)}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
.{file.targetFormat}
|
||||
</motion.button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unsupported message */}
|
||||
{file.availableFormats.length === 0 && (
|
||||
<p className="pt-2.5 mt-1 text-[11px] text-text-light italic text-center border-t border-dashed border-text-dark/[0.06]">
|
||||
Format not supported for conversion
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { UploadedFile, CATEGORY_COLORS, CATEGORY_ICONS } from '@/types';
|
||||
import { formatFileSize, truncateFilename } from '@/lib/utils';
|
||||
import { ProgressRing } from './ProgressRing';
|
||||
|
||||
interface FileRowProps {
|
||||
file: UploadedFile;
|
||||
index: number;
|
||||
onSetFormat: (id: string, format: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onDownload: (file: UploadedFile) => void;
|
||||
onPreview: (file: UploadedFile) => void;
|
||||
}
|
||||
|
||||
export function FileRow({
|
||||
file,
|
||||
index,
|
||||
onSetFormat,
|
||||
onRemove,
|
||||
onDownload,
|
||||
onPreview,
|
||||
}: FileRowProps) {
|
||||
const color = CATEGORY_COLORS[file.category];
|
||||
const icon = CATEGORY_ICONS[file.category];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="group flex items-center px-4 py-2.5 hover:bg-[#fafafa] transition-colors"
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -8, height: 0, paddingTop: 0, paddingBottom: 0, overflow: 'hidden' }}
|
||||
transition={{ duration: 0.3, delay: index * 0.03, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
layout
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[15px] flex-shrink-0"
|
||||
style={{ background: `${color}12` }}
|
||||
>
|
||||
{file.preview ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={file.preview}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + extension */}
|
||||
<div className="flex-1 min-w-0 ml-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-text-dark truncate" title={file.name}>
|
||||
{truncateFilename(file.name, 36)}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-[10px] font-bold px-1.5 py-0.5 rounded flex-shrink-0"
|
||||
style={{ background: `${color}10`, color }}
|
||||
>
|
||||
.{file.extension}
|
||||
</span>
|
||||
</div>
|
||||
{/* Error message inline */}
|
||||
{file.status === 'error' && file.error && (
|
||||
<p className="text-[10px] text-red-400 truncate mt-0.5">{file.error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Size — hidden on small screens */}
|
||||
<div className="w-[72px] text-right font-mono text-[11px] text-text-light flex-shrink-0 hidden sm:block">
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
|
||||
{/* Format selector — hidden on small screens, shown inline */}
|
||||
<div className="w-[140px] flex items-center justify-center flex-shrink-0 hidden sm:flex">
|
||||
{file.availableFormats.length > 0 && file.status !== 'done' ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-text-light/40 flex-shrink-0">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
<select
|
||||
value={file.targetFormat || ''}
|
||||
onChange={(e) => onSetFormat(file.id, e.target.value)}
|
||||
className="font-mono text-[11px] font-bold text-text-dark bg-transparent px-1.5 py-1 rounded-md border border-border-soft/60 cursor-pointer hover:border-pink/30 focus:outline-none focus:border-pink/40 transition-all appearance-none select-arrow-warm w-[72px]"
|
||||
>
|
||||
{file.availableFormats.map((fmt) => (
|
||||
<option key={fmt} value={fmt}>
|
||||
.{fmt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : file.status === 'done' ? (
|
||||
<span
|
||||
className="font-mono text-[11px] font-bold px-2 py-0.5 rounded-md"
|
||||
style={{ background: `${color}10`, color }}
|
||||
>
|
||||
.{file.targetFormat}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-text-light/40 italic">unsupported</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status / actions column */}
|
||||
<div className="w-[130px] flex items-center justify-end gap-1.5 flex-shrink-0 pr-1">
|
||||
<AnimatePresence mode="wait">
|
||||
{/* Idle — just format selector on mobile, or nothing */}
|
||||
{file.status === 'idle' && (
|
||||
<motion.div
|
||||
key="idle"
|
||||
className="flex items-center gap-1.5"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{/* Mobile format selector */}
|
||||
{file.availableFormats.length > 0 && (
|
||||
<select
|
||||
value={file.targetFormat || ''}
|
||||
onChange={(e) => onSetFormat(file.id, e.target.value)}
|
||||
className="sm:hidden font-mono text-[10px] font-bold text-text-dark bg-transparent px-1 py-0.5 rounded border border-border-soft/60 cursor-pointer appearance-none select-arrow-warm"
|
||||
style={{ paddingRight: '18px', maxWidth: '70px' }}
|
||||
>
|
||||
{file.availableFormats.map((fmt) => (
|
||||
<option key={fmt} value={fmt}>
|
||||
.{fmt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* Remove button */}
|
||||
<button
|
||||
className="flex items-center justify-center w-6 h-6 rounded-md bg-transparent border-none cursor-pointer text-text-light/30 hover:text-red-400 hover:bg-red-50 transition-all opacity-0 group-hover:opacity-100"
|
||||
onClick={() => onRemove(file.id)}
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Converting — spinner + progress */}
|
||||
{file.status === 'converting' && (
|
||||
<motion.div
|
||||
key="converting"
|
||||
className="flex items-center gap-2"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<ProgressRing progress={file.progress} size={20} strokeWidth={2} color={color} />
|
||||
<span className="font-mono text-[11px] font-medium" style={{ color }}>
|
||||
{Math.round(file.progress)}%
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Done — checkmark + preview + download */}
|
||||
{file.status === 'done' && (
|
||||
<motion.div
|
||||
key="done"
|
||||
className="flex items-center gap-1"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] as const }}
|
||||
>
|
||||
{/* Checkmark */}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-mint flex-shrink-0">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(52,211,153,0.12)" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M8 12l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
|
||||
{/* Preview button */}
|
||||
<button
|
||||
className="flex items-center justify-center w-6 h-6 rounded-md bg-transparent border-none cursor-pointer text-text-light/50 hover:text-purple hover:bg-purple/8 transition-all"
|
||||
onClick={() => onPreview(file)}
|
||||
title="Preview"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Download button */}
|
||||
<button
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-bold text-white bg-mint border-none rounded-md cursor-pointer shadow-[0_1px_4px_rgba(52,211,153,0.2)] hover:shadow-[0_2px_8px_rgba(52,211,153,0.3)] transition-all"
|
||||
onClick={() => onDownload(file)}
|
||||
title="Download"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
.{file.targetFormat}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Error — icon + remove */}
|
||||
{file.status === 'error' && (
|
||||
<motion.div
|
||||
key="error"
|
||||
className="flex items-center gap-1.5"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-red-400 flex-shrink-0">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.5" fill="rgba(244,63,94,0.08)" />
|
||||
<path d="M12 8v4M12 16h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="text-[10px] text-red-400 font-mono font-medium">Failed</span>
|
||||
<button
|
||||
className="flex items-center justify-center w-5 h-5 rounded bg-transparent border-none cursor-pointer text-text-light/40 hover:text-red-400 transition-all"
|
||||
onClick={() => onRemove(file.id)}
|
||||
aria-label="Remove"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { UploadedFile } from '@/types';
|
||||
import { detectCategory, getExtension, generateId } from '@/lib/fileDetector';
|
||||
import { getAvailableFormats, getDefaultTarget } from '@/lib/conversionMap';
|
||||
import {
|
||||
persistFile,
|
||||
updatePersistedMeta,
|
||||
removePersistedFile,
|
||||
clearAllPersistedFiles,
|
||||
loadPersistedFiles,
|
||||
} from '@/lib/filePersistence';
|
||||
|
||||
export function useFileUpload() {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isLoadingPersisted, setIsLoadingPersisted] = useState(true);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dragCountRef = useRef(0);
|
||||
|
||||
// ─── Restore persisted files on mount ─────────────────────
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const persisted = await loadPersistedFiles();
|
||||
if (cancelled) return;
|
||||
if (persisted.length > 0) {
|
||||
const restored: UploadedFile[] = persisted.map((p) => {
|
||||
let preview: string | undefined;
|
||||
if (p.category === 'image') {
|
||||
preview = URL.createObjectURL(p.file);
|
||||
}
|
||||
return {
|
||||
id: p.id,
|
||||
file: p.file,
|
||||
name: p.name,
|
||||
size: p.size,
|
||||
type: p.mimeType,
|
||||
category: p.category,
|
||||
extension: p.extension,
|
||||
preview,
|
||||
targetFormat: p.targetFormat,
|
||||
availableFormats: p.availableFormats,
|
||||
// Reset status to idle on reload (don't carry over 'converting' or 'done')
|
||||
status: 'idle' as const,
|
||||
progress: 0,
|
||||
};
|
||||
});
|
||||
setFiles(restored);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — user just gets a fresh start
|
||||
} finally {
|
||||
if (!cancelled) setIsLoadingPersisted(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const processFiles = useCallback((fileList: FileList | File[]) => {
|
||||
const newFiles: UploadedFile[] = Array.from(fileList).map((file) => {
|
||||
const category = detectCategory(file);
|
||||
@@ -24,8 +72,22 @@ export function useFileUpload() {
|
||||
preview = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
|
||||
// Persist to IndexedDB (fire-and-forget)
|
||||
persistFile(id, file, {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
category,
|
||||
extension,
|
||||
targetFormat,
|
||||
availableFormats,
|
||||
status: 'idle',
|
||||
}).catch(() => {});
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
id,
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
@@ -97,18 +159,26 @@ export function useFileUpload() {
|
||||
if (file?.preview) URL.revokeObjectURL(file.preview);
|
||||
return prev.filter((f) => f.id !== id);
|
||||
});
|
||||
// Remove from persistence
|
||||
removePersistedFile(id).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const updateFile = useCallback((id: string, updates: Partial<UploadedFile>) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.id === id ? { ...f, ...updates } : f))
|
||||
);
|
||||
// Sync status changes to persistence
|
||||
if (updates.status) {
|
||||
updatePersistedMeta(id, { status: updates.status });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setTargetFormat = useCallback((id: string, format: string) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.id === id ? { ...f, targetFormat: format } : f))
|
||||
);
|
||||
// Sync to persistence
|
||||
updatePersistedMeta(id, { targetFormat: format });
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
@@ -116,12 +186,16 @@ export function useFileUpload() {
|
||||
if (f.preview) URL.revokeObjectURL(f.preview);
|
||||
});
|
||||
setFiles([]);
|
||||
// Clear all persisted data
|
||||
clearAllPersistedFiles().catch(() => {});
|
||||
}, [files]);
|
||||
|
||||
const clearCompleted = useCallback(() => {
|
||||
setFiles((prev) => {
|
||||
prev.forEach((f) => {
|
||||
if (f.status === 'done' && f.preview) URL.revokeObjectURL(f.preview);
|
||||
const completed = prev.filter((f) => f.status === 'done');
|
||||
completed.forEach((f) => {
|
||||
if (f.preview) URL.revokeObjectURL(f.preview);
|
||||
removePersistedFile(f.id).catch(() => {});
|
||||
});
|
||||
return prev.filter((f) => f.status !== 'done');
|
||||
});
|
||||
@@ -130,6 +204,7 @@ export function useFileUpload() {
|
||||
return {
|
||||
files,
|
||||
isDragging,
|
||||
isLoadingPersisted,
|
||||
inputRef,
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* File persistence using IndexedDB for blob storage + localStorage for metadata.
|
||||
* Files survive page reloads and auto-expire after 24 hours.
|
||||
*/
|
||||
|
||||
import { FileCategory, ConversionStatus } from '@/types';
|
||||
|
||||
const DB_NAME = 'transmute-files';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'files';
|
||||
const META_KEY = 'transmute-file-meta';
|
||||
const EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
/** Serializable metadata stored in localStorage */
|
||||
export interface PersistedFileMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
category: FileCategory;
|
||||
extension: string;
|
||||
targetFormat: string | null;
|
||||
availableFormats: string[];
|
||||
status: ConversionStatus;
|
||||
persistedAt: number; // timestamp
|
||||
}
|
||||
|
||||
// ─── IndexedDB helpers ────────────────────────────────────────
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function putBlob(id: string, blob: Blob): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).put(blob, id);
|
||||
tx.oncomplete = () => { db.close(); resolve(); };
|
||||
tx.onerror = () => { db.close(); reject(tx.error); };
|
||||
});
|
||||
}
|
||||
|
||||
async function getBlob(id: string): Promise<Blob | undefined> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const req = tx.objectStore(STORE_NAME).get(id);
|
||||
req.onsuccess = () => { db.close(); resolve(req.result as Blob | undefined); };
|
||||
req.onerror = () => { db.close(); reject(req.error); };
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteBlob(id: string): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).delete(id);
|
||||
tx.oncomplete = () => { db.close(); resolve(); };
|
||||
tx.onerror = () => { db.close(); reject(tx.error); };
|
||||
});
|
||||
}
|
||||
|
||||
async function clearAllBlobs(): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).clear();
|
||||
tx.oncomplete = () => { db.close(); resolve(); };
|
||||
tx.onerror = () => { db.close(); reject(tx.error); };
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Metadata helpers (localStorage) ──────────────────────────
|
||||
|
||||
function getMetaList(): PersistedFileMeta[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(META_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as PersistedFileMeta[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function setMetaList(metas: PersistedFileMeta[]): void {
|
||||
localStorage.setItem(META_KEY, JSON.stringify(metas));
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────
|
||||
|
||||
/** Persist a single file (blob + metadata) */
|
||||
export async function persistFile(
|
||||
id: string,
|
||||
file: File,
|
||||
meta: Omit<PersistedFileMeta, 'id' | 'persistedAt'>
|
||||
): Promise<void> {
|
||||
await putBlob(id, file);
|
||||
const metas = getMetaList();
|
||||
// Remove existing entry with same id (shouldn't happen but be safe)
|
||||
const filtered = metas.filter((m) => m.id !== id);
|
||||
filtered.push({ ...meta, id, persistedAt: Date.now() });
|
||||
setMetaList(filtered);
|
||||
}
|
||||
|
||||
/** Update persisted metadata for a file (e.g. when targetFormat changes) */
|
||||
export function updatePersistedMeta(
|
||||
id: string,
|
||||
updates: Partial<Pick<PersistedFileMeta, 'targetFormat' | 'status'>>
|
||||
): void {
|
||||
const metas = getMetaList();
|
||||
const idx = metas.findIndex((m) => m.id === id);
|
||||
if (idx !== -1) {
|
||||
metas[idx] = { ...metas[idx], ...updates };
|
||||
setMetaList(metas);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a single persisted file */
|
||||
export async function removePersistedFile(id: string): Promise<void> {
|
||||
await deleteBlob(id);
|
||||
const metas = getMetaList().filter((m) => m.id !== id);
|
||||
setMetaList(metas);
|
||||
}
|
||||
|
||||
/** Clear ALL persisted files */
|
||||
export async function clearAllPersistedFiles(): Promise<void> {
|
||||
await clearAllBlobs();
|
||||
localStorage.removeItem(META_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted files, pruning any that have expired (>24h).
|
||||
* Returns metadata + the reconstructed File objects.
|
||||
*/
|
||||
export async function loadPersistedFiles(): Promise<
|
||||
Array<PersistedFileMeta & { file: File }>
|
||||
> {
|
||||
const metas = getMetaList();
|
||||
const now = Date.now();
|
||||
const valid: PersistedFileMeta[] = [];
|
||||
const expired: string[] = [];
|
||||
|
||||
for (const meta of metas) {
|
||||
if (now - meta.persistedAt > EXPIRY_MS) {
|
||||
expired.push(meta.id);
|
||||
} else {
|
||||
valid.push(meta);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired blobs
|
||||
for (const id of expired) {
|
||||
await deleteBlob(id).catch(() => {});
|
||||
}
|
||||
|
||||
// Update metadata list (remove expired)
|
||||
if (expired.length > 0) {
|
||||
setMetaList(valid);
|
||||
}
|
||||
|
||||
// Load blobs for valid entries
|
||||
const results: Array<PersistedFileMeta & { file: File }> = [];
|
||||
for (const meta of valid) {
|
||||
try {
|
||||
const blob = await getBlob(meta.id);
|
||||
if (blob) {
|
||||
// Reconstruct a File object from the blob
|
||||
const file = new File([blob], meta.name, { type: meta.mimeType });
|
||||
results.push({ ...meta, file });
|
||||
}
|
||||
} catch {
|
||||
// If blob retrieval fails, skip this entry
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
Reference in New Issue
Block a user