Added local storage upgraded looks
This commit is contained in:
+222
-56
@@ -4,13 +4,16 @@ import { useState } from 'react';
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { DropZone } from '@/components/DropZone';
|
import { DropZone } from '@/components/DropZone';
|
||||||
import { FileCard } from '@/components/FileCard';
|
import { FileRow } from '@/components/FileRow';
|
||||||
import { PreviewModal } from '@/components/PreviewModal';
|
import { PreviewModal } from '@/components/PreviewModal';
|
||||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||||
import { useConversion } from '@/hooks/useConversion';
|
import { useConversion } from '@/hooks/useConversion';
|
||||||
import { formatFileSize } from '@/lib/utils';
|
import { formatFileSize } from '@/lib/utils';
|
||||||
import { UploadedFile } from '@/types';
|
import { UploadedFile } from '@/types';
|
||||||
|
|
||||||
|
/* Number of ghost rows to show below real files */
|
||||||
|
const MIN_VISIBLE_ROWS = 8;
|
||||||
|
|
||||||
export default function ConvertPage() {
|
export default function ConvertPage() {
|
||||||
const {
|
const {
|
||||||
files,
|
files,
|
||||||
@@ -43,6 +46,7 @@ export default function ConvertPage() {
|
|||||||
).length;
|
).length;
|
||||||
const completedCount = files.filter((f) => f.status === 'done').length;
|
const completedCount = files.filter((f) => f.status === 'done').length;
|
||||||
const totalSize = files.reduce((acc, f) => acc + f.size, 0);
|
const totalSize = files.reduce((acc, f) => acc + f.size, 0);
|
||||||
|
const ghostRowCount = Math.max(0, MIN_VISIBLE_ROWS - files.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen relative bg-bg-cream">
|
<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 bg-pastel-mesh" />
|
||||||
<div className="fixed inset-0 pointer-events-none z-0 opacity-30 bg-dots" />
|
<div className="fixed inset-0 pointer-events-none z-0 opacity-30 bg-dots" />
|
||||||
|
|
||||||
{/* Header */}
|
{/* Hidden file input — lives at top level so the ref is always stable */}
|
||||||
<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">
|
<input
|
||||||
<Link href="/" className="flex items-center gap-2.5 no-underline">
|
ref={inputRef}
|
||||||
<img src="/logo.png" alt="Transmute" className="w-8 h-8 rounded-[10px]" />
|
type="file"
|
||||||
<span className="font-serif font-extrabold text-xl tracking-tight text-text-dark">Transmute</span>
|
multiple
|
||||||
</Link>
|
onChange={handleFileInput}
|
||||||
<span className="font-mono text-[11px] text-text-light px-2.5 py-1 border border-border-soft rounded-full tracking-wide">
|
className="hidden"
|
||||||
v1.0 / client-side
|
/>
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* 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
|
<DropZone
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
hasFiles={hasFiles}
|
hasFiles={false}
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@@ -73,18 +180,31 @@ export default function ConvertPage() {
|
|||||||
onFileInput={handleFileInput}
|
onFileInput={handleFileInput}
|
||||||
onBrowse={openFilePicker}
|
onBrowse={openFilePicker}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* File Grid */}
|
{/* Drag overlay when files present */}
|
||||||
{hasFiles && (
|
{hasFiles && isDragging && (
|
||||||
<motion.div
|
<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 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
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">
|
<AnimatePresence mode="popLayout">
|
||||||
{files.map((file, index) => (
|
{files.map((file, index) => (
|
||||||
<FileCard
|
<FileRow
|
||||||
key={file.id}
|
key={file.id}
|
||||||
file={file}
|
file={file}
|
||||||
index={index}
|
index={index}
|
||||||
@@ -95,81 +215,127 @@ export default function ConvertPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action Bar */}
|
{/* Ghost / placeholder rows */}
|
||||||
{hasFiles && (
|
{ghostRowCount > 0 && (
|
||||||
<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" />
|
{Array.from({ length: ghostRowCount }).map((_, i) => (
|
||||||
<span className="font-mono text-xs text-text-mid">
|
<div
|
||||||
<strong className="text-mint font-semibold">{completedCount}</strong> converted
|
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>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 sm:gap-2.5 flex-wrap justify-center sm:justify-end">
|
{/* ─ Bottom action bar (always visible) ─ */}
|
||||||
<button
|
{hasFiles && (
|
||||||
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"
|
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 bg-[#fafafa] border-t border-border-soft">
|
||||||
onClick={clearAll}
|
{/* Left — status */}
|
||||||
>
|
<div className="flex items-center gap-1.5">
|
||||||
Clear all
|
{isConverting ? (
|
||||||
</button>
|
<>
|
||||||
|
<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 && (
|
{completedCount > 0 && (
|
||||||
<motion.button
|
<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)}
|
onClick={() => downloadAllAsZip(files)}
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
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" />
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||||
</svg>
|
</svg>
|
||||||
Download ZIP
|
ZIP
|
||||||
</motion.button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<motion.button
|
<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' : ''}`}
|
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)}
|
onClick={() => convertAll(files)}
|
||||||
disabled={isConverting || convertableCount === 0}
|
disabled={isConverting || convertableCount === 0}
|
||||||
>
|
>
|
||||||
{isConverting ? (
|
{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" />
|
<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>
|
</svg>
|
||||||
Converting...
|
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" />
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
Transmute{convertableCount > 0 ? ` (${convertableCount})` : ''}
|
Transmute{convertableCount > 0 ? ` (${convertableCount})` : ''}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</button>
|
||||||
|
</div>
|
||||||
</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 — files never leave your browser
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Preview Modal */}
|
{/* Preview Modal */}
|
||||||
<PreviewModal
|
<PreviewModal
|
||||||
file={previewFile}
|
file={previewFile}
|
||||||
|
|||||||
+237
-128
@@ -51,19 +51,42 @@ const flowSteps = [
|
|||||||
{ inputIcon: '\u{1F3B5}', inputLabel: '.FLAC', outputIcon: '\u{1F3A7}', outputLabel: '.MP3' },
|
{ inputIcon: '\u{1F3B5}', inputLabel: '.FLAC', outputIcon: '\u{1F3A7}', outputLabel: '.MP3' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ─── Terminal Simulation Data ─── */
|
/* ─── Finder Window Data ─── */
|
||||||
|
|
||||||
const terminalCommands = [
|
interface FinderFile {
|
||||||
{ cmd: 'transmute photo.heic --to webp', output: ' \u2713 photo.webp (2.4 MB \u2192 680 KB)', color: '#f472b6', time: 1800 },
|
icon: string;
|
||||||
{ cmd: 'transmute report.docx --to pdf', output: ' \u2713 report.pdf (formatting preserved)', color: '#60a5fa', time: 2200 },
|
name: string;
|
||||||
{ cmd: 'transmute song.flac --to mp3 --quality 320k', output: ' \u2713 song.mp3 (48 MB \u2192 9.2 MB)', color: '#a78bfa', time: 2800 },
|
size: string;
|
||||||
{ cmd: 'transmute data.csv --to json', output: ' \u2713 data.json (2,847 rows parsed)', color: '#34d399', time: 1400 },
|
targetFormat: string;
|
||||||
{ cmd: 'transmute clip.mov --to mp4', output: ' \u2713 clip.mp4 (H.264, browser-native)', color: '#fb923c', time: 3200 },
|
category: 'image' | 'document' | 'media' | 'data';
|
||||||
{ cmd: 'transmute design.psd --to png', output: ' \u2713 design.png (composite layer)', color: '#f472b6', time: 1600 },
|
color: string;
|
||||||
{ 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 },
|
const finderBatches: FinderFile[][] = [
|
||||||
{ cmd: 'transmute sheet.xlsx --to csv', output: ' \u2713 sheet.csv (3 sheets merged)', color: '#34d399', time: 1500 },
|
[
|
||||||
|
{ 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 ─── */
|
/* ─── Animation Variants ─── */
|
||||||
@@ -310,153 +333,239 @@ function ConversionFlow() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Terminal Simulation Component ─── */
|
/* ─── Finder Window Component ─── */
|
||||||
|
|
||||||
interface TerminalLine {
|
type FileStatus = 'idle' | 'converting' | 'done';
|
||||||
type: 'prompt' | 'output' | 'blank';
|
|
||||||
text: string;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TerminalSimulation() {
|
function FinderWindow() {
|
||||||
const [lines, setLines] = useState<TerminalLine[]>([]);
|
const [batchIndex, setBatchIndex] = useState(0);
|
||||||
const [currentTyping, setCurrentTyping] = useState('');
|
const [fileStatuses, setFileStatuses] = useState<FileStatus[]>(
|
||||||
const [isTyping, setIsTyping] = useState(false);
|
Array(6).fill('idle')
|
||||||
const termRef = useRef<HTMLDivElement>(null);
|
);
|
||||||
const cmdIndexRef = useRef(0);
|
|
||||||
const runningRef = useRef(true);
|
const runningRef = useRef(true);
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const clearTimeouts = useCallback(() => {
|
||||||
if (termRef.current) {
|
timeoutRef.current.forEach(clearTimeout);
|
||||||
termRef.current.scrollTop = termRef.current.scrollHeight;
|
timeoutRef.current = [];
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
runningRef.current = true;
|
runningRef.current = true;
|
||||||
|
|
||||||
const typeCommand = async (cmdObj: typeof terminalCommands[0]) => {
|
const runBatch = () => {
|
||||||
if (!runningRef.current) return;
|
if (!runningRef.current) return;
|
||||||
|
|
||||||
// Type the command character by character
|
// Reset all to idle
|
||||||
setIsTyping(true);
|
setFileStatuses(Array(6).fill('idle'));
|
||||||
for (let i = 0; i <= cmdObj.cmd.length; i++) {
|
|
||||||
|
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;
|
if (!runningRef.current) return;
|
||||||
setCurrentTyping(cmdObj.cmd.slice(0, i));
|
setFileStatuses((prev) => {
|
||||||
await new Promise((r) => setTimeout(r, 30 + Math.random() * 40));
|
const next = [...prev];
|
||||||
}
|
next[i] = 'converting';
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 800 + i * 600);
|
||||||
|
timeoutRef.current.push(convertTimer);
|
||||||
|
|
||||||
// Brief pause after typing
|
// Finish converting (700-1200ms after start)
|
||||||
await new Promise((r) => setTimeout(r, 300));
|
const doneTimer = setTimeout(() => {
|
||||||
if (!runningRef.current) return;
|
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
|
// After all done, wait then move to next batch
|
||||||
setIsTyping(false);
|
const nextBatchTimer = setTimeout(() => {
|
||||||
setCurrentTyping('');
|
if (!runningRef.current) return;
|
||||||
setLines((prev) => [
|
setBatchIndex((prev) => prev + 1);
|
||||||
...prev,
|
}, 800 + batch.length * 600 + 1800);
|
||||||
{ type: 'prompt', text: cmdObj.cmd },
|
timeoutRef.current.push(nextBatchTimer);
|
||||||
{ type: 'output', text: cmdObj.output, color: cmdObj.color },
|
|
||||||
]);
|
|
||||||
scrollToBottom();
|
|
||||||
|
|
||||||
// Pause before next command
|
|
||||||
await new Promise((r) => setTimeout(r, 1200));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const runLoop = async () => {
|
runBatch();
|
||||||
// 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();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
runningRef.current = false;
|
runningRef.current = false;
|
||||||
|
clearTimeouts();
|
||||||
};
|
};
|
||||||
}, [scrollToBottom]);
|
}, [batchIndex, clearTimeouts]);
|
||||||
|
|
||||||
// Auto-scroll on new lines
|
const batch = finderBatches[batchIndex % finderBatches.length];
|
||||||
useEffect(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
}, [lines, currentTyping, scrollToBottom]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-[700px] mx-auto">
|
<div className="w-full max-w-[720px] mx-auto">
|
||||||
{/* Terminal window */}
|
{/* Finder window */}
|
||||||
<div className="rounded-xl overflow-hidden shadow-[0_8px_60px_rgba(0,0,0,0.25)] border border-white/[0.06]">
|
<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-2 px-4 py-3 bg-[#1a1a2e]">
|
{/* ─ Title bar ─ */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-3 px-4 py-2.5 bg-[#f6f6f6] border-b border-border-soft">
|
||||||
<div className="w-3 h-3 rounded-full bg-[#ff5f57]" />
|
{/* Traffic lights */}
|
||||||
<div className="w-3 h-3 rounded-full bg-[#febc2e]" />
|
<div className="flex items-center gap-[6px]">
|
||||||
<div className="w-3 h-3 rounded-full bg-[#28c840]" />
|
<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>
|
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terminal body */}
|
{/* Size */}
|
||||||
<div
|
<div className="w-[70px] text-right font-mono text-[11px] text-text-light flex-shrink-0">
|
||||||
ref={termRef}
|
{file.size}
|
||||||
className="bg-[#0f0f1a] px-5 py-4 h-[320px] overflow-y-auto font-mono text-[13px] leading-[1.8] scroll-smooth"
|
</div>
|
||||||
style={{ scrollbarWidth: 'none' }}
|
|
||||||
|
{/* 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 */}
|
queued
|
||||||
<div className="text-white/20 mb-2 select-none">
|
</motion.span>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
{status === 'converting' && (
|
||||||
{/* Idle cursor */}
|
<motion.div
|
||||||
{!isTyping && !currentTyping && (
|
key="converting"
|
||||||
<div className="flex items-start gap-0">
|
className="flex items-center gap-1.5"
|
||||||
<span className="text-[#34d399] select-none">{'>'}</span>
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
<span className="inline-block w-[2px] h-[16px] bg-[#34d399] ml-2 translate-y-[2px] animate-pulse" />
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Format count below terminal */}
|
{/* Category format counts below window */}
|
||||||
<div className="flex items-center justify-center gap-4 mt-6 flex-wrap">
|
<div className="flex items-center justify-center gap-4 mt-6 flex-wrap">
|
||||||
{[
|
{[
|
||||||
{ label: 'Images', count: 11, color: '#f472b6' },
|
{ label: 'Images', count: 11, color: '#f472b6' },
|
||||||
@@ -584,7 +693,7 @@ export default function LandingPage() {
|
|||||||
<ConversionFlow />
|
<ConversionFlow />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ──── FEATURES — TERMINAL SIMULATION ──── */}
|
{/* ──── FEATURES — FINDER WINDOW ──── */}
|
||||||
<section
|
<section
|
||||||
id="features"
|
id="features"
|
||||||
className="relative z-10 flex flex-col items-center gap-10 px-6 py-20"
|
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>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="w-full max-w-[700px]"
|
className="w-full max-w-[720px]"
|
||||||
initial={{ opacity: 0, y: 24 }}
|
initial={{ opacity: 0, y: 24 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: '-60px' }}
|
viewport={{ once: true, margin: '-60px' }}
|
||||||
transition={{ duration: 0.6, delay: 0.15, ease: [0.16, 1, 0.3, 1] as const }}
|
transition={{ duration: 0.6, delay: 0.15, ease: [0.16, 1, 0.3, 1] as const }}
|
||||||
>
|
>
|
||||||
<TerminalSimulation />
|
<FinderWindow />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.p
|
<motion.p
|
||||||
|
|||||||
+26
-91
@@ -6,121 +6,55 @@ import React from 'react';
|
|||||||
interface DropZoneProps {
|
interface DropZoneProps {
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
hasFiles: boolean;
|
hasFiles: boolean;
|
||||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
inputRef?: React.RefObject<HTMLInputElement | null>;
|
||||||
onDragEnter: (e: React.DragEvent) => void;
|
onDragEnter: (e: React.DragEvent) => void;
|
||||||
onDragLeave: (e: React.DragEvent) => void;
|
onDragLeave: (e: React.DragEvent) => void;
|
||||||
onDragOver: (e: React.DragEvent) => void;
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
onDrop: (e: React.DragEvent) => void;
|
onDrop: (e: React.DragEvent) => void;
|
||||||
onFileInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
onFileInput?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
onBrowse: () => void;
|
onBrowse: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DropZone({
|
export function DropZone({
|
||||||
isDragging,
|
isDragging,
|
||||||
hasFiles,
|
|
||||||
inputRef,
|
|
||||||
onDragEnter,
|
onDragEnter,
|
||||||
onDragLeave,
|
onDragLeave,
|
||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
onFileInput,
|
|
||||||
onBrowse,
|
onBrowse,
|
||||||
}: DropZoneProps) {
|
}: DropZoneProps) {
|
||||||
// Compact mode — thin bar when files are present
|
// Empty state inside Finder window
|
||||||
if (hasFiles) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="px-6 py-3 relative z-10"
|
className="flex items-center justify-center px-6 py-16"
|
||||||
|
style={{ minHeight: '60vh' }}
|
||||||
onDragEnter={onDragEnter}
|
onDragEnter={onDragEnter}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDrop={onDrop}
|
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
|
<motion.div
|
||||||
className={`relative w-full max-w-xl rounded-3xl border-2 border-dashed p-12 text-center transition-colors duration-300 ${
|
className="flex flex-col items-center gap-4 text-center"
|
||||||
isDragging
|
initial={{ opacity: 0, y: 16 }}
|
||||||
? '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 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
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 */}
|
{/* Upload icon */}
|
||||||
<motion.div
|
<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
|
isDragging
|
||||||
? 'bg-pink/15 text-pink'
|
? 'bg-pink/12 text-pink scale-110'
|
||||||
: 'bg-pink/8 text-pink/70'
|
: 'bg-[#f6f6f6] text-text-light'
|
||||||
}`}
|
}`}
|
||||||
animate={{
|
animate={{
|
||||||
y: isDragging ? -10 : 0,
|
y: isDragging ? -8 : 0,
|
||||||
scale: isDragging ? 1.15 : 1,
|
rotate: isDragging ? -3 : 0,
|
||||||
rotate: isDragging ? -5 : 0,
|
|
||||||
}}
|
}}
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
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
|
<path
|
||||||
d="M24 32V8M24 8L16 16M24 8L32 16"
|
d="M24 32V12M24 12L16 20M24 12L32 20"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2.5"
|
strokeWidth="2.5"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -136,32 +70,33 @@ export function DropZone({
|
|||||||
</svg>
|
</svg>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<h2 className="font-serif text-3xl font-extrabold text-text-dark tracking-tight">
|
<div>
|
||||||
{isDragging ? 'Release to transmute' : 'Drop anything.'}
|
<h2 className="font-serif text-2xl font-extrabold text-text-dark tracking-tight mb-1">
|
||||||
|
{isDragging ? 'Release to add' : 'Drop files here'}
|
||||||
</h2>
|
</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
|
{isDragging
|
||||||
? 'Your files are ready for transformation'
|
? 'Your files are ready for transformation'
|
||||||
: 'Images, documents, audio, video, data \u2014 all formats welcome'}
|
: 'Images, documents, audio, video, data \u2014 all formats welcome'}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<motion.button
|
<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}
|
onClick={onBrowse}
|
||||||
whileHover={{ scale: 1.04 }}
|
whileHover={{ scale: 1.03 }}
|
||||||
whileTap={{ scale: 0.97 }}
|
whileTap={{ scale: 0.97 }}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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" />
|
<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>
|
</svg>
|
||||||
Browse files
|
Browse files
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<p className="font-mono text-[11px] text-text-light tracking-wide mt-1">
|
<p className="font-mono text-[10px] text-text-light/60 tracking-wide mt-1">
|
||||||
100% client-side — your files never leave your browser
|
70+ formats — 100% client-side
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
|
||||||
</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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { UploadedFile } from '@/types';
|
import { UploadedFile } from '@/types';
|
||||||
import { detectCategory, getExtension, generateId } from '@/lib/fileDetector';
|
import { detectCategory, getExtension, generateId } from '@/lib/fileDetector';
|
||||||
import { getAvailableFormats, getDefaultTarget } from '@/lib/conversionMap';
|
import { getAvailableFormats, getDefaultTarget } from '@/lib/conversionMap';
|
||||||
|
import {
|
||||||
|
persistFile,
|
||||||
|
updatePersistedMeta,
|
||||||
|
removePersistedFile,
|
||||||
|
clearAllPersistedFiles,
|
||||||
|
loadPersistedFiles,
|
||||||
|
} from '@/lib/filePersistence';
|
||||||
|
|
||||||
export function useFileUpload() {
|
export function useFileUpload() {
|
||||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isLoadingPersisted, setIsLoadingPersisted] = useState(true);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const dragCountRef = useRef(0);
|
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 processFiles = useCallback((fileList: FileList | File[]) => {
|
||||||
const newFiles: UploadedFile[] = Array.from(fileList).map((file) => {
|
const newFiles: UploadedFile[] = Array.from(fileList).map((file) => {
|
||||||
const category = detectCategory(file);
|
const category = detectCategory(file);
|
||||||
@@ -24,8 +72,22 @@ export function useFileUpload() {
|
|||||||
preview = URL.createObjectURL(file);
|
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 {
|
return {
|
||||||
id: generateId(),
|
id,
|
||||||
file,
|
file,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
@@ -97,18 +159,26 @@ export function useFileUpload() {
|
|||||||
if (file?.preview) URL.revokeObjectURL(file.preview);
|
if (file?.preview) URL.revokeObjectURL(file.preview);
|
||||||
return prev.filter((f) => f.id !== id);
|
return prev.filter((f) => f.id !== id);
|
||||||
});
|
});
|
||||||
|
// Remove from persistence
|
||||||
|
removePersistedFile(id).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateFile = useCallback((id: string, updates: Partial<UploadedFile>) => {
|
const updateFile = useCallback((id: string, updates: Partial<UploadedFile>) => {
|
||||||
setFiles((prev) =>
|
setFiles((prev) =>
|
||||||
prev.map((f) => (f.id === id ? { ...f, ...updates } : f))
|
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) => {
|
const setTargetFormat = useCallback((id: string, format: string) => {
|
||||||
setFiles((prev) =>
|
setFiles((prev) =>
|
||||||
prev.map((f) => (f.id === id ? { ...f, targetFormat: format } : f))
|
prev.map((f) => (f.id === id ? { ...f, targetFormat: format } : f))
|
||||||
);
|
);
|
||||||
|
// Sync to persistence
|
||||||
|
updatePersistedMeta(id, { targetFormat: format });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearAll = useCallback(() => {
|
const clearAll = useCallback(() => {
|
||||||
@@ -116,12 +186,16 @@ export function useFileUpload() {
|
|||||||
if (f.preview) URL.revokeObjectURL(f.preview);
|
if (f.preview) URL.revokeObjectURL(f.preview);
|
||||||
});
|
});
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
|
// Clear all persisted data
|
||||||
|
clearAllPersistedFiles().catch(() => {});
|
||||||
}, [files]);
|
}, [files]);
|
||||||
|
|
||||||
const clearCompleted = useCallback(() => {
|
const clearCompleted = useCallback(() => {
|
||||||
setFiles((prev) => {
|
setFiles((prev) => {
|
||||||
prev.forEach((f) => {
|
const completed = prev.filter((f) => f.status === 'done');
|
||||||
if (f.status === 'done' && f.preview) URL.revokeObjectURL(f.preview);
|
completed.forEach((f) => {
|
||||||
|
if (f.preview) URL.revokeObjectURL(f.preview);
|
||||||
|
removePersistedFile(f.id).catch(() => {});
|
||||||
});
|
});
|
||||||
return prev.filter((f) => f.status !== 'done');
|
return prev.filter((f) => f.status !== 'done');
|
||||||
});
|
});
|
||||||
@@ -130,6 +204,7 @@ export function useFileUpload() {
|
|||||||
return {
|
return {
|
||||||
files,
|
files,
|
||||||
isDragging,
|
isDragging,
|
||||||
|
isLoadingPersisted,
|
||||||
inputRef,
|
inputRef,
|
||||||
handleDragEnter,
|
handleDragEnter,
|
||||||
handleDragLeave,
|
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