From e6574ce0ec5a58a96a752c64f9a26a1e07a52903 Mon Sep 17 00:00:00 2001 From: Harshit Singh Date: Fri, 2 Oct 2026 03:07:26 +0000 Subject: [PATCH 1/2] Fix dropdown spacing, keep metadata across formats, split the queue - Dropdowns: hide the native arrow and draw a chevron with padding, via a shared Select component used by every dropdown. - Photo metadata: "Keep photo metadata" only worked for JPEG to JPEG. EXIF is now read from JPEG, PNG, WebP and HEIC and written back into JPEG, PNG or WebP output (orientation reset to upright). AVIF says it can't store it. When the original file is returned (already optimized), EXIF/XMP is stripped losslessly unless metadata should be kept. The "removed" note only shows when the photo actually had metadata. - Queue: files added while others are processing are listed under "Newly added", with "Compress new files" and "Re-compress all" buttons. All batches share one concurrency limit; files waiting for a slot show "Waiting" and can be cancelled (previously their Cancel did nothing). Claude-Session: https://claude.ai/code/session_011UUBS4XF57trCnZcGwBu35 --- src/App.tsx | 166 +++++++++---- src/components/FileRow.tsx | 29 ++- src/components/Select.tsx | 32 +++ src/components/SettingsPanel.tsx | 28 ++- src/lib/compress.ts | 25 +- src/lib/image.ts | 63 +++-- src/lib/metadata.test.ts | 110 +++++++++ src/lib/metadata.ts | 401 +++++++++++++++++++++++++++++++ tests/e2e/app.spec.ts | 46 +++- 9 files changed, 796 insertions(+), 104 deletions(-) create mode 100644 src/components/Select.tsx create mode 100644 src/lib/metadata.test.ts create mode 100644 src/lib/metadata.ts diff --git a/src/App.tsx b/src/App.tsx index 6f9ffe1..4f0b0a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,7 +34,7 @@ import { takeSharedFiles } from "./lib/share-target"; /** Files compressed in parallel. FFmpeg jobs are still serialized internally. */ const CONCURRENCY = 3; -type CompressionScope = "all" | "new" | "old"; +type CompressionScope = "all" | "new"; function createId(): string { return typeof crypto !== "undefined" && "randomUUID" in crypto @@ -45,23 +45,33 @@ function createId(): string { function isInScope(f: FileItem, scope: CompressionScope): boolean { if (f.status === "compressing" || f.status === "unsupported") return false; if (scope === "new") return f.status === "idle"; - if (scope === "old") return f.status === "done"; return true; } -async function runPool( - items: T[], - limit: number, - worker: (item: T) => Promise, -) { - let next = 0; - await Promise.all( - Array.from({ length: Math.min(limit, items.length) }, async () => { - while (next < items.length) await worker(items[next++]); - }), - ); +/** Files not compressed yet (shown under "Newly added" once others have run). */ +const isPending = (f: FileItem) => f.status === "idle" || f.status === "unsupported"; + +/** + * Runs at most `limit` tasks at once. Shared by all batches, so starting a + * new batch while another runs doesn't multiply the work in parallel. + */ +function createLimiter(limit: number) { + let active = 0; + const waiting: (() => void)[] = []; + return async function run(task: () => Promise): Promise { + if (active >= limit) await new Promise((resolve) => waiting.push(resolve)); + active++; + try { + return await task(); + } finally { + active--; + waiting.shift()?.(); + } + }; } +const limit = createLimiter(CONCURRENCY); + function outputFile(item: FileItem): File { const blob = item.compressedBlob || item.file; return new File([blob], getOutputName(item.file.name, blob.type), { type: blob.type }); @@ -307,6 +317,15 @@ export default function App() { const cancelFile = useCallback((id: string) => { controllers.current.get(id)?.abort(); + // A file still waiting for a slot has no running job to wind down, so + // return it to the queue right away. + setFiles((prev) => + prev.map((f) => + f.id === id && f.status === "compressing" && f.waiting + ? { ...f, status: "idle", waiting: false, progress: 0 } + : f, + ), + ); }, []); const removeFile = useCallback((id: string) => { @@ -373,6 +392,9 @@ export default function App() { if (selected.length === 0) return; const ids = new Set(selected.map((f) => f.id)); + // Controllers exist from the start so files still waiting for a free slot + // can be cancelled too. + for (const id of ids) controllers.current.set(id, new AbortController()); setSummary(null); setFiles((prev) => prev.map((f) => @@ -380,11 +402,12 @@ export default function App() { ? { ...f, status: "compressing", + waiting: true, progress: 0, compressedSize: undefined, compressedBlob: undefined, keptOriginal: false, - metadataRemoved: false, + metadata: undefined, } : f, ), @@ -397,11 +420,14 @@ export default function App() { let originalBytes = 0; let failed = 0; - await runPool(selected, CONCURRENCY, async (fileItem: FileItem) => { - const controller = new AbortController(); - controllers.current.set(fileItem.id, controller); + const processFile = async (fileItem: FileItem) => { + const controller = controllers.current.get(fileItem.id)!; try { - const { blob, keptOriginal, metadataRemoved } = await compressFile( + if (controller.signal.aborted) throw new DOMException("", "AbortError"); + setFiles((prev) => + prev.map((f) => (f.id === fileItem.id ? { ...f, waiting: false } : f)), + ); + const { blob, keptOriginal, metadata } = await compressFile( fileItem.file, fileItem.id, applyOverrides(settings, fileItem.overrides), @@ -421,7 +447,7 @@ export default function App() { compressedSize: blob.size, compressedBlob: blob, keptOriginal, - metadataRemoved, + metadata, } : f, ), @@ -442,14 +468,16 @@ export default function App() { setFiles((prev) => prev.map((f) => f.id === fileItem.id - ? { ...f, status: cancelled ? "idle" : "error", progress: 0 } + ? { ...f, status: cancelled ? "idle" : "error", waiting: false, progress: 0 } : f, ), ); } finally { controllers.current.delete(fileItem.id); } - }); + }; + + await Promise.all(selected.map((fileItem) => limit(() => processFile(fileItem)))); setProcessedBytes((prev) => prev + originalBytes); if (processed + failed > 0) { @@ -492,6 +520,8 @@ export default function App() { }; const compressible = files.filter((f) => f.status !== "unsupported"); + const newFiles = files.filter((f) => f.status === "idle"); + const hasStarted = files.some((f) => !isPending(f)); const isBusy = compressible.length === 0 || compressible.every((f) => f.status === "compressing") || @@ -522,30 +552,29 @@ export default function App() { else window.scrollTo({ top: 0, behavior: "smooth" }); }; + const primaryButton = + "w-full sm:w-auto h-12 px-8 rounded-full bg-primary text-on-accent hover:bg-primary-hover hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-medium whitespace-nowrap"; + const secondaryButton = + "w-full sm:w-auto h-12 px-8 rounded-full border-2 border-accent text-accent hover:bg-accent hover:text-on-accent transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-medium whitespace-nowrap"; + + // New files added after a batch started: compress just those, or everything. const actionButtons = - files.some((f) => f.status === "idle") && files.some((f) => f.status === "done") ? ( -
+ newFiles.length > 0 && hasStarted ? ( +
-
) : ( - ); + const renderRows = (items: FileItem[]) => + items.map((file) => ( + + )); + + // Once some files have been compressed (or are compressing), files added + // afterwards are listed separately under "Newly added". + const processedFiles = files.filter((f) => !isPending(f)); + const pendingFiles = files.filter(isPending); + const showSections = processedFiles.length > 0 && pendingFiles.length > 0; + const processedTitle = processedFiles.some((f) => f.status === "compressing") + ? "Processing" + : "Compressed"; + return (
{/* Decorative Background Elements (gradients instead of costly blur filters) */} @@ -688,21 +741,30 @@ export default function App() {
- - {files.map((file) => ( - - ))} - + {showSections ? ( + <> +
+
+ {processedTitle} ({processedFiles.length}) +
+ {renderRows(processedFiles)} +
+
+
+ Newly added ({pendingFiles.length}) +
+ {renderRows(pendingFiles)} +
+ + ) : ( + {renderRows(files)} + )} )} diff --git a/src/components/FileRow.tsx b/src/components/FileRow.tsx index acc47bc..587cf01 100644 --- a/src/components/FileRow.tsx +++ b/src/components/FileRow.tsx @@ -1,4 +1,5 @@ import { memo, useState } from "react"; +import { Select } from "./Select"; import { motion } from "motion/react"; import { Columns2, @@ -6,6 +7,7 @@ import { File, FileText, Image as ImageIcon, + MapPin, MapPinOff, Music, Share2, @@ -15,6 +17,7 @@ import { X, } from "lucide-react"; import type { FileKind } from "../lib/compress"; +import type { MetadataOutcome } from "../lib/image"; import { type FileOverrides, PRESET_LEVELS, formatSize } from "../lib/settings"; export interface FileItem { @@ -23,10 +26,12 @@ export interface FileItem { kind: FileKind | null; status: "idle" | "compressing" | "done" | "error" | "unsupported"; progress: number; + /** Compressing, but still waiting for a free slot. */ + waiting?: boolean; originalSize: number; compressedSize?: number; keptOriginal?: boolean; - metadataRemoved?: boolean; + metadata?: MetadataOutcome; previewUrl?: string; compressedBlob?: Blob; overrides?: FileOverrides; @@ -136,12 +141,24 @@ export const FileRow = memo(function FileRow({ )} - {file.status === "done" && file.metadataRemoved && ( + {file.status === "done" && file.metadata === "removed" && ( )} + {file.status === "done" && file.metadata === "kept" && ( + + + )} + {file.status === "done" && file.metadata === "unsupported" && ( + + + )} {file.status === "unsupported" && ( File type not supported )} @@ -156,7 +173,7 @@ export const FileRow = memo(function FileRow({ {busy && ( <> - {Math.round(file.progress)}% + {file.waiting ? "Waiting" : `${Math.round(file.progress)}%`}