Added mobile support

This commit is contained in:
noah
2026-03-09 22:06:58 +01:00
parent 76f02c3b1a
commit 18912547a8
3 changed files with 389 additions and 237 deletions
+45 -42
View File
@@ -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})` : ''}
+8 -8
View File
@@ -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 }}
+191 -42
View File
@@ -27,13 +27,15 @@ 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
> >
{/* ─── Desktop layout (sm+) ─── */}
<div className="hidden sm:flex items-center">
{/* Icon */} {/* Icon */}
<div <div
className="w-8 h-8 rounded-lg flex items-center justify-center text-[15px] flex-shrink-0" className="w-8 h-8 rounded-lg flex items-center justify-center text-[15px] flex-shrink-0"
@@ -41,11 +43,7 @@ export function FileRow({
> >
{file.preview ? ( {file.preview ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img src={file.preview} alt="" className="w-8 h-8 rounded-lg object-cover" />
src={file.preview}
alt=""
className="w-8 h-8 rounded-lg object-cover"
/>
) : ( ) : (
icon icon
)} )}
@@ -64,19 +62,18 @@ export function FileRow({
.{file.extension} .{file.extension}
</span> </span>
</div> </div>
{/* Error message inline */}
{file.status === 'error' && file.error && ( {file.status === 'error' && file.error && (
<p className="text-[10px] text-red-400 truncate mt-0.5">{file.error}</p> <p className="text-[10px] text-red-400 truncate mt-0.5">{file.error}</p>
)} )}
</div> </div>
{/* Size — hidden on small screens */} {/* Size */}
<div className="w-[72px] text-right font-mono text-[11px] text-text-light flex-shrink-0 hidden sm:block"> <div className="w-[72px] text-right font-mono text-[11px] text-text-light flex-shrink-0">
{formatFileSize(file.size)} {formatFileSize(file.size)}
</div> </div>
{/* Format selector — hidden on small screens, shown inline */} {/* Format selector */}
<div className="w-[140px] flex items-center justify-center flex-shrink-0 hidden sm:flex"> <div className="w-[140px] flex items-center justify-center flex-shrink-0">
{file.availableFormats.length > 0 && file.status !== 'done' ? ( {file.availableFormats.length > 0 && file.status !== 'done' ? (
<div className="flex items-center gap-1.5"> <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"> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-text-light/40 flex-shrink-0">
@@ -88,9 +85,7 @@ export function FileRow({
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]" 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.availableFormats.map((fmt) => (
<option key={fmt} value={fmt}> <option key={fmt} value={fmt}>.{fmt}</option>
.{fmt}
</option>
))} ))}
</select> </select>
</div> </div>
@@ -106,10 +101,96 @@ export function FileRow({
)} )}
</div> </div>
{/* Status / actions column */} {/* Status / actions */}
<div className="w-[130px] flex items-center justify-end gap-1.5 flex-shrink-0 pr-1"> <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>
{/* ─── Mobile layout (< sm) ─── */}
<div className="flex sm:hidden items-start gap-2.5">
{/* Icon */}
<div
className="w-9 h-9 rounded-lg flex items-center justify-center text-[15px] flex-shrink-0 mt-0.5"
style={{ background: `${color}12` }}
>
{file.preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={file.preview} alt="" className="w-9 h-9 rounded-lg object-cover" />
) : (
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 }}
>
.{file.extension}
</span>
{/* Remove — always visible on mobile */}
{file.status !== 'converting' && (
<button
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)}
aria-label="Remove file"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
)}
</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>
</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"> <AnimatePresence mode="wait">
{/* Idle — just format selector on mobile, or nothing */}
{file.status === 'idle' && ( {file.status === 'idle' && (
<motion.div <motion.div
key="idle" key="idle"
@@ -119,22 +200,6 @@ export function FileRow({
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.15 }} 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 <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/30 hover:text-red-400 hover:bg-red-50 transition-all opacity-0 group-hover:opacity-100"
onClick={() => onRemove(file.id)} onClick={() => onRemove(file.id)}
@@ -147,7 +212,6 @@ export function FileRow({
</motion.div> </motion.div>
)} )}
{/* Converting — spinner + progress */}
{file.status === 'converting' && ( {file.status === 'converting' && (
<motion.div <motion.div
key="converting" key="converting"
@@ -164,7 +228,6 @@ export function FileRow({
</motion.div> </motion.div>
)} )}
{/* Done — checkmark + preview + download */}
{file.status === 'done' && ( {file.status === 'done' && (
<motion.div <motion.div
key="done" key="done"
@@ -173,13 +236,10 @@ export function FileRow({
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] as const }} 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"> <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" /> <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" /> <path d="M8 12l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
{/* Preview button */}
<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" 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)} onClick={() => onPreview(file)}
@@ -190,8 +250,6 @@ export function FileRow({
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
</button> </button>
{/* Download button */}
<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" 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)} onClick={() => onDownload(file)}
@@ -205,7 +263,6 @@ export function FileRow({
</motion.div> </motion.div>
)} )}
{/* Error — icon + remove */}
{file.status === 'error' && ( {file.status === 'error' && (
<motion.div <motion.div
key="error" key="error"
@@ -232,7 +289,99 @@ export function FileRow({
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</div> );
</motion.div> }
/* ── 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>
); );
} }