fix: harden Docker image and async job responses

Harden Docker runtime packaging, preserve async job response semantics, fix Redis subscriber startup connections, clear lint warnings, and harden enterprise S3 object body handling.
This commit is contained in:
SnapOtter
2026-07-01 12:32:33 +08:00
committed by GitHub
parent 6683ee8c30
commit f3342a1e57
89 changed files with 635 additions and 159 deletions
+14 -3
View File
@@ -1,5 +1,5 @@
import { AlertCircle, FileImage, FileUp, Upload } from "lucide-react";
import { type DragEvent, useCallback, useEffect, useState } from "react";
import { type DragEvent, type KeyboardEvent, useCallback, useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useUrlImport } from "@/hooks/use-url-import";
import { cn } from "@/lib/utils";
@@ -177,7 +177,7 @@ export function Dropzone({
[onFiles, checkFile, acceptDescription, accept],
);
const handleClick = () => {
const handleClick = useCallback(() => {
setError(null);
const input = document.createElement("input");
input.type = "file";
@@ -193,7 +193,17 @@ export function Dropzone({
}
};
input.click();
};
}, [multiple, resolvedAccept, checkFile, onFiles, acceptDescription, accept]);
const handleDropzoneKeyDown = useCallback(
(e: KeyboardEvent<HTMLElement>) => {
if (e.target !== e.currentTarget) return;
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
handleClick();
},
[handleClick],
);
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
@@ -234,6 +244,7 @@ export function Dropzone({
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={handleClick}
onKeyDown={handleDropzoneKeyDown}
className={cn(
"group flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-all duration-200 mx-auto max-w-2xl w-full cursor-pointer",
compact ? "min-h-0 h-full" : "min-h-[400px]",
@@ -94,10 +94,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
}, [handleClose]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Overlay */}
<div
aria-hidden="true"
@@ -420,7 +420,7 @@ function GeneralSection() {
setSaving(false);
setTimeout(() => setSaveMsg(null), 3000);
}
}, [defaultToolView]);
}, [defaultToolView, t.settings.general.saveSuccess, t.settings.general.saveFailed]);
const username = user?.username || "admin";
const role = user?.role || "unknown";
@@ -940,7 +940,16 @@ function SecuritySection() {
setSubmitting(false);
}
},
[currentPassword, newPassword, confirmPassword],
[
currentPassword,
newPassword,
confirmPassword,
t.settings.security.changeFailed,
t.settings.security.currentPasswordIncorrect,
t.settings.security.changeSuccess,
t.settings.security.passwordsMismatch,
t.settings.security.passwordTooShort,
],
);
return (
@@ -1468,7 +1477,17 @@ function PeopleSection() {
setTimeout(() => setActionMsg(null), 3000);
}
},
[newUsername, newPassword, newRole, newTeam, maxUsers, loadUsers],
[
newUsername,
newPassword,
newRole,
newTeam,
maxUsers,
loadUsers,
t.settings.people.createFailed,
t.settings.people.createSuccess,
t.settings.people.userLimitReached,
],
);
const handleDeleteUser = useCallback(
@@ -1487,7 +1506,12 @@ function PeopleSection() {
setOpenMenuId(null);
setTimeout(() => setActionMsg(null), 3000);
},
[loadUsers],
[
loadUsers,
t.settings.people.deleteSuccess,
t.settings.people.deleteFailed,
t.settings.people.deleteConfirm,
],
);
const handleUpdateUser = useCallback(
@@ -1511,7 +1535,14 @@ function PeopleSection() {
}
setTimeout(() => setActionMsg(null), 3000);
},
[editingUser, editRole, editTeam, loadUsers],
[
editingUser,
editRole,
editTeam,
loadUsers,
t.settings.people.cannotRemoveOwnAdmin,
t.settings.people.updateSuccess,
],
);
const handleResetPassword = useCallback(
@@ -1531,7 +1562,7 @@ function PeopleSection() {
}
setTimeout(() => setActionMsg(null), 3000);
},
[resetPasswordUser, resetPassword],
[resetPasswordUser, resetPassword, t.settings.people.resetSuccess],
);
if (loading) {
@@ -2357,7 +2388,7 @@ function TeamsSection() {
setTimeout(() => setActionMsg(null), 3000);
}
},
[newTeamName, loadTeams],
[newTeamName, loadTeams, t.settings.teams.duplicateName, t.settings.teams.createSuccess],
);
const handleRename = useCallback(
@@ -2375,7 +2406,7 @@ function TeamsSection() {
}
setTimeout(() => setActionMsg(null), 3000);
},
[editingTeamName, loadTeams],
[editingTeamName, loadTeams, t.settings.teams.renameSuccess],
);
const handleDelete = useCallback(
@@ -2395,7 +2426,7 @@ function TeamsSection() {
setOpenMenuId(null);
setTimeout(() => setActionMsg(null), 3000);
},
[loadTeams],
[loadTeams, t.settings.teams.deleteConfirm, t.settings.teams.cannotDeleteDefault],
);
const handleExpandTeam = useCallback(
@@ -2432,7 +2463,7 @@ function TeamsSection() {
setTimeout(() => setActionMsg(null), 3000);
}
},
[quotaMb, retention, loadTeams],
[quotaMb, retention, loadTeams, t.settings.teams.quotaSaved],
);
if (loading) {
@@ -2787,7 +2818,14 @@ function RolesSection() {
}
setTimeout(() => setActionMsg(null), 3000);
},
[newName, newDescription, newPermissions, loadRoles],
[
newName,
newDescription,
newPermissions,
loadRoles,
t.settings.roles.duplicateRoleError,
t.settings.roles.createSuccess,
],
);
const handleUpdate = useCallback(
@@ -2809,7 +2847,14 @@ function RolesSection() {
}
setTimeout(() => setActionMsg(null), 3000);
},
[editingRole, editName, editDescription, editPermissions, loadRoles],
[
editingRole,
editName,
editDescription,
editPermissions,
loadRoles,
t.settings.roles.updateSuccess,
],
);
const handleDelete = useCallback(
@@ -3213,8 +3258,11 @@ function AuditLogSection() {
<div className="divide-y divide-border">
{entries.map((entry) => (
<Fragment key={entry.id}>
<div
className="px-3 py-2.5 hover:bg-muted/20 cursor-pointer transition-colors"
<button
type="button"
aria-expanded={expandedId === entry.id}
aria-controls={`audit-details-${entry.id}`}
className="w-full px-3 py-2.5 hover:bg-muted/20 cursor-pointer transition-colors text-start"
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
>
<div className="flex items-center justify-between gap-2">
@@ -3239,9 +3287,9 @@ function AuditLogSection() {
</span>
)}
</div>
</div>
</button>
{expandedId === entry.id && entry.details && (
<div className="px-3 py-2 bg-muted/10">
<div id={`audit-details-${entry.id}`} className="px-3 py-2 bg-muted/10">
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono overflow-x-auto">
{JSON.stringify(entry.details, null, 2)}
</pre>
@@ -42,14 +42,15 @@ export function DocumentView({ inputOnly = false }: { inputOnly?: boolean } = {}
// F22: when processedUrl is available, use URL-based loading instead of
// entry.file (which is the original non-PDF input and would fail pdf.js)
const file = hasProcessedUrl ? undefined : entry?.file;
if (!file && !src) return;
const url = src;
if (!file && !url) return;
let cancelled = false;
let doc: pdfjs.PDFDocumentProxy | undefined;
let renderTask: pdfjs.RenderTask | undefined;
(async () => {
try {
const source = file ? { data: new Uint8Array(await file.arrayBuffer()) } : { url: src! };
const source = file ? { data: new Uint8Array(await file.arrayBuffer()) } : { url };
doc = await pdfjs.getDocument(source).promise;
if (cancelled) return;
setPageCount(doc.numPages);
@@ -1,5 +1,4 @@
import { ArrowLeft, ChevronLeft, ChevronRight, Crown, Search } from "lucide-react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatFileSize } from "@/lib/download";
import type { DuplicateFileInfo } from "@/stores/duplicate-store";
import { useDuplicateStore } from "@/stores/duplicate-store";
@@ -251,7 +250,6 @@ function DetailComparison() {
}
export function FindDuplicatesResults() {
const { t } = useTranslation();
const { results, scanning, viewMode } = useDuplicateStore();
if (scanning) {
@@ -1,6 +1,5 @@
import { Download, FolderArchive, Loader2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { formatFileSize } from "@/lib/download";
import type { DuplicateResult } from "@/stores/duplicate-store";
@@ -16,7 +15,6 @@ const PRESET_DESCRIPTIONS: Record<Preset, string> = {
};
export function FindDuplicatesSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const {
results,
@@ -217,7 +215,7 @@ export function FindDuplicatesSettings() {
URL.revokeObjectURL(url);
}, [files, results]);
const handleDownloadAll = useCallback(async () => {
const _handleDownloadAll = useCallback(async () => {
const { zipSync } = await import("fflate");
const zipData: Record<string, Uint8Array> = {};
@@ -27,7 +27,6 @@ export interface GifToolsControlsProps {
}
export function GifToolsControls({ settings: initialSettings, onChange }: GifToolsControlsProps) {
const { t } = useTranslation();
const { info, loading: infoLoading } = useGifInfo();
const isAnimated = (info?.pages ?? 0) > 1;
@@ -24,9 +24,11 @@ export function HtmlToImageResults() {
);
}
const resultUrl = store.resultUrl;
const handleDownload = () => {
const a = document.createElement("a");
a.href = store.resultUrl!;
a.href = resultUrl;
a.download = `screenshot.${store.format}`;
a.click();
};
@@ -35,7 +37,7 @@ export function HtmlToImageResults() {
<div className="flex h-full flex-col">
<div className="flex-1 overflow-auto p-4">
<img
src={store.resultUrl}
src={resultUrl}
alt="Captured screenshot"
className="mx-auto max-w-full rounded-lg border border-border shadow-sm"
/>
@@ -49,6 +51,7 @@ export function HtmlToImageResults() {
: ""}
</span>
<button
type="button"
onClick={handleDownload}
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
>
@@ -45,8 +45,11 @@ export function HtmlToImageSettings() {
{store.mode === "url" && (
<div>
<label className="mb-1 block text-sm font-medium">{ts.url}</label>
<label htmlFor="html-to-image-url" className="mb-1 block text-sm font-medium">
{ts.url}
</label>
<input
id="html-to-image-url"
type="url"
value={store.url}
onChange={(e) => store.setUrl(e.target.value)}
@@ -58,8 +61,11 @@ export function HtmlToImageSettings() {
{store.mode === "html" && (
<div>
<label className="mb-1 block text-sm font-medium">{ts.htmlFile}</label>
<label htmlFor="html-to-image-file" className="mb-1 block text-sm font-medium">
{ts.htmlFile}
</label>
<input
id="html-to-image-file"
type="file"
accept=".html,.htm"
onChange={(e) => {
@@ -74,8 +80,11 @@ export function HtmlToImageSettings() {
)}
<div>
<label className="mb-1 block text-sm font-medium">{ts.format}</label>
<label htmlFor="html-to-image-format" className="mb-1 block text-sm font-medium">
{ts.format}
</label>
<select
id="html-to-image-format"
value={store.format}
onChange={(e) => store.setFormat(e.target.value as "jpg" | "png" | "webp")}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
@@ -88,10 +97,11 @@ export function HtmlToImageSettings() {
{store.format !== "png" && (
<div>
<label className="mb-1 block text-sm font-medium">
<label htmlFor="html-to-image-quality" className="mb-1 block text-sm font-medium">
{ts.quality}: {store.quality}%
</label>
<input
id="html-to-image-quality"
type="range"
min={1}
max={100}
@@ -103,8 +113,11 @@ export function HtmlToImageSettings() {
)}
<div>
<label className="mb-1 block text-sm font-medium">{ts.devicePreset}</label>
<label htmlFor="html-to-image-device-preset" className="mb-1 block text-sm font-medium">
{ts.devicePreset}
</label>
<select
id="html-to-image-device-preset"
value={store.devicePreset}
onChange={(e) =>
store.setDevicePreset(e.target.value as "desktop" | "tablet" | "mobile" | "custom")
@@ -121,8 +134,14 @@ export function HtmlToImageSettings() {
{store.devicePreset === "custom" && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium">{ts.viewportWidth}</label>
<label
htmlFor="html-to-image-viewport-width"
className="mb-1 block text-sm font-medium"
>
{ts.viewportWidth}
</label>
<input
id="html-to-image-viewport-width"
type="number"
min={320}
max={3840}
@@ -132,8 +151,14 @@ export function HtmlToImageSettings() {
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium">{ts.viewportHeight}</label>
<label
htmlFor="html-to-image-viewport-height"
className="mb-1 block text-sm font-medium"
>
{ts.viewportHeight}
</label>
<input
id="html-to-image-viewport-height"
type="number"
min={320}
max={2160}
@@ -146,8 +171,11 @@ export function HtmlToImageSettings() {
)}
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{ts.fullPage}</label>
<label htmlFor="html-to-image-full-page" className="text-sm font-medium">
{ts.fullPage}
</label>
<button
id="html-to-image-full-page"
type="button"
role="switch"
aria-checked={store.fullPage}
@@ -13,7 +13,6 @@ import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type EnhancementMode = "auto" | "portrait" | "landscape" | "low-light" | "food" | "document";
@@ -1,6 +1,5 @@
import { Check, ClipboardCopy, Download, FileJson, FileText, Loader2 } from "lucide-react";
import { useCallback, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import type { Base64Result } from "@/stores/base64-store";
import { useBase64Store } from "@/stores/base64-store";
import { useFileStore } from "@/stores/file-store";
@@ -169,7 +168,6 @@ function FileResult({ result }: { result: Base64Result }) {
// -- Main ResultsPanel ------------------------------------------------------
export function ImageToBase64Results() {
const { t } = useTranslation();
const { results, errors, processing, progress } = useBase64Store();
const { entries, selectedIndex, originalBlobUrl, selectedFileName } = useFileStore();
@@ -1,8 +1,6 @@
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { useBase64Store } from "@/stores/base64-store";
import { useFileStore } from "@/stores/file-store";
@@ -16,7 +14,6 @@ const OUTPUT_FORMATS = [
] as const;
export function ImageToBase64Settings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processing, setProcessing, setProgress, addResult, addError, reset } = useBase64Store();
@@ -94,7 +94,7 @@ export function InfoSettings() {
cacheRef.current.clear();
autoFetchRef.current = false;
setInfo(null);
}, [files.length]);
}, []);
useEffect(() => {
if (!autoFetchRef.current || files.length === 0) return;
@@ -8,7 +8,6 @@ import {
Sparkles,
} from "lucide-react";
import { useCallback } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import {
FONT_OPTIONS,
@@ -328,7 +327,6 @@ function ResultSettings() {
// ── Main Settings Component ─────────────────────────────────────────
export function MemeGeneratorSettings() {
const { t } = useTranslation();
const phase = useMemeStore((s) => s.phase);
if (phase === "gallery") return <GallerySettings />;
@@ -20,7 +20,6 @@ import {
import { useCallback, useEffect, useRef, useState } from "react";
import { create } from "zustand";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
@@ -337,7 +336,6 @@ function CountryOption({
// ── Settings panel (left side) ─────────────────────────────────────
export function PassportPhotoSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { error } = useToolProcessor("passport-photo");
@@ -896,7 +894,6 @@ export function PassportPhotoSettings() {
// ── Preview panel (right side) ────────────────────────────────────
export function PassportPhotoPreview() {
const { t } = useTranslation();
const {
analyzeResult,
countryCode,
@@ -13,7 +13,6 @@ import {
import QRCodeStyling from "qr-code-styling";
import { useCallback, useRef } from "react";
import { CollapsibleSection } from "@/components/common/collapsible-section";
import { useTranslation } from "@/contexts/i18n-context";
import {
type ContentType,
type CornerDotType,
@@ -346,7 +345,6 @@ function PillButton({
// ── Main settings component ──────────────────────────────────────────
export function QrGenerateSettings() {
const { t } = useTranslation();
const store = useQrStore();
const logoInputRef = useRef<HTMLInputElement>(null);
@@ -104,6 +104,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
blurRadius,
sobelThreshold,
squareMode,
contentAware,
]);
const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
@@ -15,7 +15,6 @@ export function RestorePhotoControls({
settings: initialSettings,
onChange,
}: RestorePhotoControlsProps) {
const { t } = useTranslation();
const [scratchRemoval, setScratchRemoval] = useState(true);
const [faceEnhancement, setFaceEnhancement] = useState(true);
const [fidelity, setFidelity] = useState(70);
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type CropMode = "subject" | "face" | "trim";
@@ -551,7 +550,6 @@ export function SmartCropControls({ settings: initialSettings, onChange }: Smart
}
export function SmartCropSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, progress } =
useToolProcessor("smart-crop");
@@ -1,7 +1,6 @@
import { Download, Loader2, PackageOpen } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { CollapsibleSection } from "@/components/common/collapsible-section";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
import type { SplitMode } from "@/stores/split-store";
@@ -36,7 +35,6 @@ const OUTPUT_FORMATS = [
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif", "jxl"]);
export function SplitSettings() {
const { t } = useTranslation();
const { files, processing: fileStoreProcessing } = useFileStore();
const {
mode,
@@ -1,6 +1,5 @@
import { Download, Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
@@ -10,7 +9,6 @@ type Alignment = "start" | "center" | "end";
type OutputFormat = "png" | "jpeg" | "webp" | "avif" | "jxl";
export function StitchSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } =
useFileStore();
@@ -19,7 +19,6 @@ export function TransparencyFixerControls({
settings: _settings,
onChange,
}: TransparencyFixerControlsProps) {
const { t } = useTranslation();
const [defringe, setDefringe] = useState(30);
const [outputFormat, setOutputFormat] = useState<OutputFormat>("png");
const [removeWatermark, setRemoveWatermark] = useState(false);
@@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "tiled";
+1 -1
View File
@@ -67,7 +67,7 @@ export function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>,
return () => {
container.removeEventListener("keydown", handleKeyDown);
observer.disconnect();
if (returnFocusRef.current && returnFocusRef.current.isConnected) {
if (returnFocusRef.current?.isConnected) {
returnFocusRef.current.focus();
}
};
+16 -6
View File
@@ -363,7 +363,14 @@ export function AutomatePage() {
}
};
input.click();
}, [setSavedPipelines]);
}, [
setSavedPipelines,
t.automate.invalidPipelineFile,
t.automate.noSteps,
t.automate.newerVersion,
t.automate.missingName,
t.automate.couldNotRead,
]);
useEffect(() => {
if (!importError) return;
@@ -418,13 +425,16 @@ export function AutomatePage() {
*/
function renderPipelinePreview(mode: "result" | "original") {
const kind = currentEntry?.previewKind ?? "image";
const sourceUrl = originalBlobUrl;
if (!sourceUrl) return null;
if (mode === "result") {
if (!processedUrl) return null;
if (kind === "image") {
return (
<BeforeAfterSlider
beforeSrc={originalBlobUrl!}
afterSrc={processedUrl as string}
beforeSrc={sourceUrl}
afterSrc={processedUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
/>
@@ -440,7 +450,7 @@ export function AutomatePage() {
if (kind === "audio") {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
<WaveformPlayer src={processedUrl as string} />
<WaveformPlayer src={processedUrl} />
</Suspense>
);
}
@@ -467,7 +477,7 @@ export function AutomatePage() {
if (kind === "image") {
return (
<ImageViewer
src={originalBlobUrl!}
src={sourceUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
/>
@@ -483,7 +493,7 @@ export function AutomatePage() {
if (kind === "audio") {
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
<WaveformPlayer src={originalBlobUrl!} />
<WaveformPlayer src={sourceUrl} />
</Suspense>
);
}
+1 -1
View File
@@ -168,7 +168,7 @@ export function LoginPage() {
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const _data = await res.json().catch(() => ({}));
setError(t.auth.invalidCredentials);
return;
}
+1 -1
View File
@@ -173,7 +173,7 @@ function FileSelectionInfo({
const entry = fileEntries[i];
return (
<button
key={`${file.name}-${i}`}
key={entry?.blobUrl ?? `${file.name}-${file.size}-${file.lastModified}`}
type="button"
onClick={() => onSelect(i)}
className={`w-full flex items-center gap-1.5 text-xs rounded px-2 py-1.5 text-start transition-colors ${isSelected ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-muted"}`}