Added local storage upgraded looks

This commit is contained in:
noah
2026-03-09 21:58:37 +01:00
parent 338cb07ba2
commit 76f02c3b1a
7 changed files with 1078 additions and 641 deletions
+222 -56
View File
@@ -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,21 +54,124 @@ 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>
{/* Hidden file input — lives at top level so the ref is always stable */}
<input
ref={inputRef}
type="file"
multiple
onChange={handleFileInput}
className="hidden"
/>
{/* Drop Zone */}
{/* 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="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 }}
>
{/* ─ 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>
{/* 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>
{/* 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>
{/* 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>
</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={hasFiles}
hasFiles={false}
inputRef={inputRef}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -73,18 +180,31 @@ export default function ConvertPage() {
onFileInput={handleFileInput}
onBrowse={openFilePicker}
/>
)}
{/* File Grid */}
{hasFiles && (
{/* Drag overlay when files present */}
{hasFiles && isDragging && (
<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"
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 }}
transition={{ duration: 0.3 }}
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) => (
<FileCard
<FileRow
key={file.id}
file={file}
index={index}
@@ -95,81 +215,127 @@ export default function ConvertPage() {
/>
))}
</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 && (
{/* Ghost / placeholder rows */}
{ghostRowCount > 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
{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>
<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>
{/* ─ 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-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"
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="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
Download ZIP
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' : ''}`}
<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="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin">
<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="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<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})` : ''}
</>
)}
</motion.button>
</button>
</div>
</div>
</motion.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 &mdash; files never leave your browser
</p>
</div>
</div>
{/* Preview Modal */}
<PreviewModal
file={previewFile}
+237 -128
View File
@@ -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++) {
// Reset all to idle
setFileStatuses(Array(6).fill('idle'));
const batch = finderBatches[batchIndex % finderBatches.length];
// Stagger: each file starts converting 600ms apart
batch.forEach((_, i) => {
// Start converting
const convertTimer = setTimeout(() => {
if (!runningRef.current) return;
setCurrentTyping(cmdObj.cmd.slice(0, i));
await new Promise((r) => setTimeout(r, 30 + Math.random() * 40));
}
setFileStatuses((prev) => {
const next = [...prev];
next[i] = 'converting';
return next;
});
}, 800 + i * 600);
timeoutRef.current.push(convertTimer);
// Brief pause after typing
await new Promise((r) => setTimeout(r, 300));
// 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);
});
// "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));
// 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);
};
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));
if (!runningRef.current) return;
setLines([]);
}
}
};
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>
<span className="flex-1 text-center font-mono text-[11px] text-white/30 tracking-wider">
transmute
{/* 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>
</div>
{/* ─ 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>
{/* 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' }}
{/* 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 }}
>
{/* Welcome message */}
<div className="text-white/20 mb-2 select-none">
Transmute v1.0 {'\u2014'} 70+ formats, zero uploads
</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>
queued
</motion.span>
)}
{/* 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>
{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>
</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
+26 -91
View File
@@ -6,121 +6,55 @@ 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) {
// Empty state inside Finder window
return (
<div
className="px-6 py-3 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"
/>
<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
return (
<div
className="flex items-center justify-center min-h-[70vh] px-6 py-16 relative z-10"
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 }}
>
{/* 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" />
<motion.div
className="flex flex-col items-center gap-5"
initial={{ opacity: 0, y: 20 }}
className="flex flex-col items-center gap-4 text-center"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.6 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
>
{/* Upload icon */}
<motion.div
className={`flex items-center justify-center w-20 h-20 rounded-2xl transition-colors duration-300 ${
className={`flex items-center justify-center w-20 h-20 rounded-2xl transition-all duration-300 ${
isDragging
? 'bg-pink/15 text-pink'
: 'bg-pink/8 text-pink/70'
? 'bg-pink/12 text-pink scale-110'
: 'bg-[#f6f6f6] text-text-light'
}`}
animate={{
y: isDragging ? -10 : 0,
scale: isDragging ? 1.15 : 1,
rotate: isDragging ? -5 : 0,
y: isDragging ? -8 : 0,
rotate: isDragging ? -3 : 0,
}}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<svg width="40" height="40" viewBox="0 0 48 48" fill="none">
<svg width="36" height="36" viewBox="0 0 48 48" fill="none">
<path
d="M24 32V8M24 8L16 16M24 8L32 16"
d="M24 32V12M24 12L16 20M24 12L32 20"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
@@ -136,32 +70,33 @@ export function DropZone({
</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"
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.04 }}
whileHover={{ scale: 1.03 }}
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 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 &mdash; your files never leave your browser
<p className="font-mono text-[10px] text-text-light/60 tracking-wide mt-1">
70+ formats &mdash; 100% client-side
</p>
</motion.div>
</motion.div>
</div>
);
}
-274
View File
@@ -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>
);
}
+238
View File
@@ -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>
);
}
+79 -4
View File
@@ -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,
+188
View File
@@ -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;
}