Added mobile support
This commit is contained in:
+48
-45
@@ -64,15 +64,15 @@ export default function ConvertPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Finder Window — the entire converter lives inside this */}
|
{/* 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">
|
<div className="relative z-10 max-w-[960px] mx-auto px-2 sm:px-6 py-3 sm:py-10">
|
||||||
<motion.div
|
<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"
|
className="flex flex-col max-h-[calc(100vh-24px)] sm: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 }}
|
initial={{ opacity: 0, y: 16, scale: 0.98 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
|
||||||
>
|
>
|
||||||
{/* ─ Title bar ─ */}
|
{/* ─ 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">
|
<div className="flex-shrink-0 flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-2 sm:py-2.5 bg-[#f6f6f6] border-b border-border-soft select-none">
|
||||||
{/* Traffic lights */}
|
{/* Traffic lights */}
|
||||||
<Link href="/" className="flex items-center gap-[6px] no-underline group/dots">
|
<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-[#ff5f57] border border-[#e0443e]/40 group-hover/dots:bg-[#ff3b30] transition-colors" />
|
||||||
@@ -80,8 +80,8 @@ export default function ConvertPage() {
|
|||||||
<div className="w-[11px] h-[11px] rounded-full bg-[#28c840] border border-[#1aab29]/40 group-hover/dots:bg-[#28cd41] transition-colors" />
|
<div className="w-[11px] h-[11px] rounded-full bg-[#28c840] border border-[#1aab29]/40 group-hover/dots:bg-[#28cd41] transition-colors" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation arrows */}
|
{/* Navigation arrows — hidden on mobile */}
|
||||||
<div className="flex items-center gap-1 ml-1">
|
<div className="hidden sm:flex items-center gap-1 ml-1">
|
||||||
<Link href="/" className="text-text-light/40 hover:text-text-mid transition-colors no-underline">
|
<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>
|
<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>
|
</Link>
|
||||||
@@ -94,8 +94,8 @@ export default function ConvertPage() {
|
|||||||
<div className="flex-1 flex items-center justify-center gap-1.5">
|
<div className="flex-1 flex items-center justify-center gap-1.5">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src="/logo.png" alt="" className="w-4 h-4 rounded-[4px]" />
|
<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] font-medium text-text-mid hidden sm:inline">Transmute</span>
|
||||||
<span className="text-[12px] text-text-light/40">{'\u203A'}</span>
|
<span className="text-[12px] text-text-light/40 hidden sm:inline">{'\u203A'}</span>
|
||||||
<span className="text-[12px] font-medium text-text-dark">Converter</span>
|
<span className="text-[12px] font-medium text-text-dark">Converter</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,8 +120,8 @@ export default function ConvertPage() {
|
|||||||
|
|
||||||
{/* ─ Toolbar (only when files present) ─ */}
|
{/* ─ Toolbar (only when files present) ─ */}
|
||||||
{hasFiles && (
|
{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-shrink-0 flex items-center justify-between px-3 sm:px-4 py-1.5 sm:py-2 bg-[#fafafa] border-b border-border-soft">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<span className="font-mono text-[11px] text-text-mid">
|
<span className="font-mono text-[11px] text-text-mid">
|
||||||
<strong className="text-text-dark">{files.length}</strong> file{files.length !== 1 ? 's' : ''}
|
<strong className="text-text-dark">{files.length}</strong> file{files.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
@@ -130,13 +130,11 @@ export default function ConvertPage() {
|
|||||||
{formatFileSize(totalSize)}
|
{formatFileSize(totalSize)}
|
||||||
</span>
|
</span>
|
||||||
{completedCount > 0 && (
|
{completedCount > 0 && (
|
||||||
<>
|
<span className="hidden sm:flex items-center gap-1 font-mono text-[11px] text-text-mid">
|
||||||
<div className="w-px h-3.5 bg-border-soft" />
|
<div className="w-px h-3.5 bg-border-soft mr-1" />
|
||||||
<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" />
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-mint" />
|
<strong className="text-mint">{completedCount}</strong> converted
|
||||||
<strong className="text-mint">{completedCount}</strong> converted
|
</span>
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -148,12 +146,12 @@ export default function ConvertPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─ Column headers (only when files present) ─ */}
|
{/* ─ Column headers (desktop only — mobile uses stacked rows) ─ */}
|
||||||
{hasFiles && (
|
{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-shrink-0 hidden sm: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="flex-1 pl-10">Name</div>
|
||||||
<div className="w-[72px] text-right hidden sm:block">Size</div>
|
<div className="w-[72px] text-right">Size</div>
|
||||||
<div className="w-[140px] text-center hidden sm:block">Convert to</div>
|
<div className="w-[140px] text-center">Convert to</div>
|
||||||
<div className="w-[130px] text-right pr-1">Status</div>
|
<div className="w-[130px] text-right pr-1">Status</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -165,7 +163,7 @@ export default function ConvertPage() {
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
style={{ minHeight: hasFiles ? undefined : '60vh' }}
|
style={{ minHeight: hasFiles ? undefined : '50vh' }}
|
||||||
>
|
>
|
||||||
{/* Drop zone (empty state) */}
|
{/* Drop zone (empty state) */}
|
||||||
{!hasFiles && (
|
{!hasFiles && (
|
||||||
@@ -222,34 +220,33 @@ export default function ConvertPage() {
|
|||||||
{Array.from({ length: ghostRowCount }).map((_, i) => (
|
{Array.from({ length: ghostRowCount }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={`ghost-${i}`}
|
key={`ghost-${i}`}
|
||||||
className={`flex items-center px-4 py-2.5 ${i === 0 ? 'cursor-pointer hover:bg-[#fafafa] transition-colors' : ''}`}
|
className={`flex items-center px-3 sm:px-4 py-2.5 ${i === 0 ? 'cursor-pointer hover:bg-[#fafafa] active:bg-[#fafafa] transition-colors' : ''} ${i >= 4 ? 'hidden sm:flex' : ''}`}
|
||||||
onClick={i === 0 ? openFilePicker : undefined}
|
onClick={i === 0 ? openFilePicker : undefined}
|
||||||
>
|
>
|
||||||
{/* Ghost icon */}
|
{/* Ghost icon */}
|
||||||
<div className="w-8 h-8 rounded-lg bg-border-soft/20 flex-shrink-0" />
|
<div className="w-8 sm:w-8 h-8 sm:h-8 rounded-lg bg-border-soft/20 flex-shrink-0" />
|
||||||
{/* Ghost name bar */}
|
{/* Ghost name bar */}
|
||||||
<div className="flex-1 ml-3">
|
<div className="flex-1 ml-2.5 sm:ml-3">
|
||||||
{i === 0 ? (
|
{i === 0 ? (
|
||||||
<span className="text-[12px] text-text-light/40 flex items-center gap-1.5">
|
<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">
|
<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" />
|
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
Drop files here or click to browse
|
<span className="hidden sm:inline">Drop files here or click to browse</span>
|
||||||
|
<span className="sm:hidden">Tap to add files</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-2.5 w-24 rounded bg-border-soft/15" style={{ width: `${60 + ((i * 37) % 60)}px` }} />
|
<div className="h-2.5 rounded bg-border-soft/15" style={{ width: `${60 + ((i * 37) % 60)}px` }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Ghost size */}
|
{/* Ghost columns — desktop only */}
|
||||||
<div className="w-[72px] hidden sm:block">
|
<div className="w-[72px] hidden sm:block">
|
||||||
{i < 2 && <div className="h-2 w-10 rounded bg-border-soft/10 ml-auto" />}
|
{i < 2 && <div className="h-2 w-10 rounded bg-border-soft/10 ml-auto" />}
|
||||||
</div>
|
</div>
|
||||||
{/* Ghost convert to */}
|
|
||||||
<div className="w-[140px] hidden sm:block">
|
<div className="w-[140px] hidden sm:block">
|
||||||
{i < 2 && <div className="h-2 w-12 rounded bg-border-soft/10 mx-auto" />}
|
{i < 2 && <div className="h-2 w-12 rounded bg-border-soft/10 mx-auto" />}
|
||||||
</div>
|
</div>
|
||||||
{/* Ghost status */}
|
<div className="w-[130px] pr-1 hidden sm:block">
|
||||||
<div className="w-[130px] pr-1">
|
|
||||||
{i < 1 && <div className="h-2 w-8 rounded bg-border-soft/10 ml-auto" />}
|
{i < 1 && <div className="h-2 w-8 rounded bg-border-soft/10 ml-auto" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,40 +259,45 @@ export default function ConvertPage() {
|
|||||||
|
|
||||||
{/* ─ Bottom action bar (always visible) ─ */}
|
{/* ─ Bottom action bar (always visible) ─ */}
|
||||||
{hasFiles && (
|
{hasFiles && (
|
||||||
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 bg-[#fafafa] border-t border-border-soft">
|
<div className="flex-shrink-0 flex items-center justify-between px-3 sm:px-4 py-2.5 sm:py-3 bg-[#fafafa] border-t border-border-soft gap-2">
|
||||||
{/* Left — status */}
|
{/* Left — status */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
{isConverting ? (
|
{isConverting ? (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="w-3 h-3 rounded-full border-[1.5px] border-pink border-t-transparent"
|
className="w-3 h-3 rounded-full border-[1.5px] border-pink border-t-transparent flex-shrink-0"
|
||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
transition={{ duration: 0.6, repeat: Infinity, ease: 'linear' }}
|
transition={{ duration: 0.6, repeat: Infinity, ease: 'linear' }}
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] font-mono text-pink font-medium">Converting...</span>
|
<span className="text-[11px] font-mono text-pink font-medium truncate">Converting...</span>
|
||||||
</>
|
</>
|
||||||
) : completedCount === files.length && completedCount > 0 ? (
|
) : completedCount === files.length && completedCount > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-mint" />
|
<div className="w-1.5 h-1.5 rounded-full bg-mint flex-shrink-0" />
|
||||||
<span className="text-[11px] font-mono text-mint font-medium">All converted</span>
|
<span className="text-[11px] font-mono text-mint font-medium truncate">All done</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[11px] text-text-light font-mono">
|
<span className="text-[11px] text-text-light font-mono truncate">
|
||||||
{convertableCount > 0 ? `${convertableCount} ready to convert` : 'Select target formats'}
|
{convertableCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="hidden sm:inline">{convertableCount} ready to convert</span>
|
||||||
|
<span className="sm:hidden">{convertableCount} ready</span>
|
||||||
|
</>
|
||||||
|
) : 'Select formats'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right — action buttons */}
|
{/* Right — action buttons */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5 sm:gap-2 flex-shrink-0">
|
||||||
{completedCount > 0 && (
|
{completedCount > 0 && (
|
||||||
<motion.button
|
<motion.button
|
||||||
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-[12px] font-bold text-white bg-mint border-none rounded-lg cursor-pointer shadow-[0_2px_8px_rgba(52,211,153,0.2)] hover:-translate-y-0.5 hover:shadow-[0_3px_14px_rgba(52,211,153,0.3)] transition-all"
|
className="inline-flex items-center gap-1 sm:gap-1.5 px-2.5 sm:px-3.5 py-1.5 text-[11px] sm: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="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="11" height="11" 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>
|
||||||
ZIP
|
ZIP
|
||||||
@@ -303,20 +305,21 @@ export default function ConvertPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`inline-flex items-center gap-1.5 px-5 py-1.5 text-[12px] font-bold text-white bg-pink border-none rounded-lg cursor-pointer shadow-[0_2px_12px_rgba(244,114,182,0.25)] hover:-translate-y-0.5 hover:shadow-[0_4px_18px_rgba(244,114,182,0.35)] transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none disabled:transform-none ${isConverting ? 'animate-pulse-soft opacity-85' : ''}`}
|
className={`inline-flex items-center gap-1 sm:gap-1.5 px-3.5 sm:px-5 py-1.5 text-[11px] sm: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="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin">
|
<svg width="11" height="11" 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...
|
<span className="hidden sm:inline">Converting...</span>
|
||||||
|
<span className="sm:hidden">Wait...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<svg width="11" height="11" 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})` : ''}
|
||||||
|
|||||||
@@ -26,22 +26,22 @@ export function DropZone({
|
|||||||
// Empty state inside Finder window
|
// Empty state inside Finder window
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center px-6 py-16"
|
className="flex items-center justify-center px-4 sm:px-6 py-10 sm:py-16"
|
||||||
style={{ minHeight: '60vh' }}
|
style={{ minHeight: '50vh' }}
|
||||||
onDragEnter={onDragEnter}
|
onDragEnter={onDragEnter}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex flex-col items-center gap-4 text-center"
|
className="flex flex-col items-center gap-3 sm:gap-4 text-center"
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] as const }}
|
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-all duration-300 ${
|
className={`flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 rounded-2xl transition-all duration-300 ${
|
||||||
isDragging
|
isDragging
|
||||||
? 'bg-pink/12 text-pink scale-110'
|
? 'bg-pink/12 text-pink scale-110'
|
||||||
: 'bg-[#f6f6f6] text-text-light'
|
: 'bg-[#f6f6f6] text-text-light'
|
||||||
@@ -52,7 +52,7 @@ export function DropZone({
|
|||||||
}}
|
}}
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||||
>
|
>
|
||||||
<svg width="36" height="36" viewBox="0 0 48 48" fill="none">
|
<svg width="32" height="32" viewBox="0 0 48 48" fill="none" className="sm:w-9 sm:h-9">
|
||||||
<path
|
<path
|
||||||
d="M24 32V12M24 12L16 20M24 12L32 20"
|
d="M24 32V12M24 12L16 20M24 12L32 20"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -71,10 +71,10 @@ export function DropZone({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-serif text-2xl font-extrabold text-text-dark tracking-tight mb-1">
|
<h2 className="font-serif text-xl sm:text-2xl font-extrabold text-text-dark tracking-tight mb-1">
|
||||||
{isDragging ? 'Release to add' : 'Drop files here'}
|
{isDragging ? 'Release to add' : 'Drop files here'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-text-mid text-[14px] max-w-xs leading-relaxed">
|
<p className="text-text-mid text-[13px] sm: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'}
|
||||||
@@ -82,7 +82,7 @@ export function DropZone({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
className="inline-flex items-center gap-2 mt-1 px-6 py-2.5 text-[13px] font-bold text-white bg-pink rounded-xl cursor-pointer shadow-[0_3px_16px_rgba(244,114,182,0.25)] hover:-translate-y-0.5 hover:shadow-[0_5px_22px_rgba(244,114,182,0.35)] transition-all border-none"
|
className="inline-flex items-center gap-2 mt-1 px-5 sm:px-6 py-2 sm: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)] active:scale-[0.97] transition-all border-none"
|
||||||
onClick={onBrowse}
|
onClick={onBrowse}
|
||||||
whileHover={{ scale: 1.03 }}
|
whileHover={{ scale: 1.03 }}
|
||||||
whileTap={{ scale: 0.97 }}
|
whileTap={{ scale: 0.97 }}
|
||||||
|
|||||||
+333
-184
@@ -27,212 +27,361 @@ export function FileRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="group flex items-center px-4 py-2.5 hover:bg-[#fafafa] transition-colors"
|
className="group relative px-3 sm:px-4 py-2.5 hover:bg-[#fafafa] transition-colors"
|
||||||
initial={{ opacity: 0, x: -8 }}
|
initial={{ opacity: 0, x: -8 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: -8, height: 0, paddingTop: 0, paddingBottom: 0, overflow: 'hidden' }}
|
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 }}
|
transition={{ duration: 0.3, delay: index * 0.03, ease: [0.16, 1, 0.3, 1] as const }}
|
||||||
layout
|
layout
|
||||||
>
|
>
|
||||||
{/* Icon */}
|
{/* ─── Desktop layout (sm+) ─── */}
|
||||||
<div
|
<div className="hidden sm:flex items-center">
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[15px] flex-shrink-0"
|
{/* Icon */}
|
||||||
style={{ background: `${color}12` }}
|
<div
|
||||||
>
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[15px] flex-shrink-0"
|
||||||
{file.preview ? (
|
style={{ background: `${color}12` }}
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
>
|
||||||
<img
|
{file.preview ? (
|
||||||
src={file.preview}
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
alt=""
|
<img src={file.preview} alt="" className="w-8 h-8 rounded-lg object-cover" />
|
||||||
className="w-8 h-8 rounded-lg object-cover"
|
) : (
|
||||||
/>
|
icon
|
||||||
) : (
|
)}
|
||||||
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>
|
</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 */}
|
{/* Name + extension */}
|
||||||
<div className="w-[72px] text-right font-mono text-[11px] text-text-light flex-shrink-0 hidden sm:block">
|
<div className="flex-1 min-w-0 ml-3">
|
||||||
{formatFileSize(file.size)}
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<span className="text-[13px] font-medium text-text-dark truncate" title={file.name}>
|
||||||
|
{truncateFilename(file.name, 36)}
|
||||||
{/* Format selector — hidden on small screens, shown inline */}
|
</span>
|
||||||
<div className="w-[140px] flex items-center justify-center flex-shrink-0 hidden sm:flex">
|
<span
|
||||||
{file.availableFormats.length > 0 && file.status !== 'done' ? (
|
className="font-mono text-[10px] font-bold px-1.5 py-0.5 rounded flex-shrink-0"
|
||||||
<div className="flex items-center gap-1.5">
|
style={{ background: `${color}10`, color }}
|
||||||
<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) => (
|
.{file.extension}
|
||||||
<option key={fmt} value={fmt}>
|
</span>
|
||||||
.{fmt}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
) : file.status === 'done' ? (
|
{file.status === 'error' && file.error && (
|
||||||
<span
|
<p className="text-[10px] text-red-400 truncate mt-0.5">{file.error}</p>
|
||||||
className="font-mono text-[11px] font-bold px-2 py-0.5 rounded-md"
|
)}
|
||||||
style={{ background: `${color}10`, color }}
|
</div>
|
||||||
>
|
|
||||||
.{file.targetFormat}
|
{/* Size */}
|
||||||
</span>
|
<div className="w-[72px] text-right font-mono text-[11px] text-text-light flex-shrink-0">
|
||||||
) : (
|
{formatFileSize(file.size)}
|
||||||
<span className="text-[10px] text-text-light/40 italic">unsupported</span>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Format selector */}
|
||||||
|
<div className="w-[140px] flex items-center justify-center flex-shrink-0">
|
||||||
|
{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 */}
|
||||||
|
<div className="w-[130px] flex items-center justify-end gap-1.5 flex-shrink-0 pr-1">
|
||||||
|
<DesktopStatus file={file} color={color} onRemove={onRemove} onDownload={onDownload} onPreview={onPreview} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status / actions column */}
|
{/* ─── Mobile layout (< sm) ─── */}
|
||||||
<div className="w-[130px] flex items-center justify-end gap-1.5 flex-shrink-0 pr-1">
|
<div className="flex sm:hidden items-start gap-2.5">
|
||||||
<AnimatePresence mode="wait">
|
{/* Icon */}
|
||||||
{/* Idle — just format selector on mobile, or nothing */}
|
<div
|
||||||
{file.status === 'idle' && (
|
className="w-9 h-9 rounded-lg flex items-center justify-center text-[15px] flex-shrink-0 mt-0.5"
|
||||||
<motion.div
|
style={{ background: `${color}12` }}
|
||||||
key="idle"
|
>
|
||||||
className="flex items-center gap-1.5"
|
{file.preview ? (
|
||||||
initial={{ opacity: 0 }}
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
animate={{ opacity: 1 }}
|
<img src={file.preview} alt="" className="w-9 h-9 rounded-lg object-cover" />
|
||||||
exit={{ opacity: 0 }}
|
) : (
|
||||||
transition={{ duration: 0.15 }}
|
icon
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Row 1: Name + remove button */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[13px] font-medium text-text-dark truncate flex-1" title={file.name}>
|
||||||
|
{truncateFilename(file.name, 24)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[9px] font-bold px-1 py-0.5 rounded flex-shrink-0"
|
||||||
|
style={{ background: `${color}10`, color }}
|
||||||
>
|
>
|
||||||
{/* Mobile format selector */}
|
.{file.extension}
|
||||||
{file.availableFormats.length > 0 && (
|
</span>
|
||||||
<select
|
{/* Remove — always visible on mobile */}
|
||||||
value={file.targetFormat || ''}
|
{file.status !== 'converting' && (
|
||||||
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
|
<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"
|
className="flex items-center justify-center w-6 h-6 rounded-md bg-transparent border-none cursor-pointer text-text-light/40 active:text-red-400 active:bg-red-50 transition-all flex-shrink-0 -mr-1"
|
||||||
onClick={() => onRemove(file.id)}
|
onClick={() => onRemove(file.id)}
|
||||||
aria-label="Remove file"
|
aria-label="Remove file"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Size + format selector + status */}
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<span className="font-mono text-[10px] text-text-light flex-shrink-0">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Format selector or status */}
|
||||||
|
<MobileStatus
|
||||||
|
file={file}
|
||||||
|
color={color}
|
||||||
|
onSetFormat={onSetFormat}
|
||||||
|
onDownload={onDownload}
|
||||||
|
onPreview={onPreview}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{file.status === 'error' && file.error && (
|
||||||
|
<p className="text-[10px] text-red-400 truncate mt-1">{file.error}</p>
|
||||||
)}
|
)}
|
||||||
|
</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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Desktop status (sm+) ─────────────────────────────── */
|
||||||
|
function DesktopStatus({
|
||||||
|
file,
|
||||||
|
color,
|
||||||
|
onRemove,
|
||||||
|
onDownload,
|
||||||
|
onPreview,
|
||||||
|
}: {
|
||||||
|
file: UploadedFile;
|
||||||
|
color: string;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onDownload: (file: UploadedFile) => void;
|
||||||
|
onPreview: (file: UploadedFile) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{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 }}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 }}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile status (< sm) ─────────────────────────────── */
|
||||||
|
function MobileStatus({
|
||||||
|
file,
|
||||||
|
color,
|
||||||
|
onSetFormat,
|
||||||
|
onDownload,
|
||||||
|
onPreview,
|
||||||
|
}: {
|
||||||
|
file: UploadedFile;
|
||||||
|
color: string;
|
||||||
|
onSetFormat: (id: string, format: string) => void;
|
||||||
|
onDownload: (file: UploadedFile) => void;
|
||||||
|
onPreview: (file: UploadedFile) => void;
|
||||||
|
}) {
|
||||||
|
if (file.status === 'idle' && file.availableFormats.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
|
<svg width="9" height="9" 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-0.5 rounded-md border border-border-soft/60 cursor-pointer focus:outline-none focus:border-pink/40 transition-all appearance-none select-arrow-warm"
|
||||||
|
style={{ maxWidth: '80px' }}
|
||||||
|
>
|
||||||
|
{file.availableFormats.map((fmt) => (
|
||||||
|
<option key={fmt} value={fmt}>.{fmt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.status === 'converting') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ProgressRing progress={file.progress} size={16} strokeWidth={2} color={color} />
|
||||||
|
<span className="font-mono text-[10px] font-medium" style={{ color }}>
|
||||||
|
{Math.round(file.progress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.status === 'done') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg width="12" height="12" 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>
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center w-6 h-6 rounded-md bg-transparent border-none cursor-pointer text-text-light/50 active:text-purple transition-all"
|
||||||
|
onClick={() => onPreview(file)}
|
||||||
|
title="Preview"
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" 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>
|
||||||
|
<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)] transition-all"
|
||||||
|
onClick={() => onDownload(file)}
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<svg width="9" height="9" 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.status === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<svg width="12" height="12" 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No formats available
|
||||||
|
return (
|
||||||
|
<span className="text-[10px] text-text-light/40 italic">unsupported</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user