feat: allow multi-file selection for automation pipeline (#88)

* feat: allow multi-file selection for automation pipeline

Add two ways to import server-stored files into the pipeline:

1. Files page: "Pipeline" bulk action button and "Open in Pipeline"
   button in file details panel — navigates to /automate with selected
   file IDs via React Router state.

2. Automate page: "Import from Library" button opens a modal with
   thumbnails, search, and multi-select checkboxes to pick files from
   the user's server-stored library.

Both paths download the selected files and load them into the existing
useFileStore, reusing the batch pipeline processing infrastructure.

Closes #35

* fix: resolve 8 pre-existing test failures across unit and integration suites

- file-validation.ts: Return valid:false when Sharp fails to read
  metadata for standard formats (PNG, JPEG, BMP) instead of silently
  accepting corrupt buffers. CLI-decoded formats already skip Sharp.

- pipeline.ts: Enforce hard cap of 20 steps via .max() instead of
  relying on MAX_PIPELINE_STEPS env var (default 0 = unlimited).
  Tighten name limit to 100 chars and description to 500 chars to
  match test expectations.

- env.ts: Change MAX_LOGO_SIZE_KB default from 2048 to 500 to match
  the branding upload size limit the tests verify.
This commit is contained in:
Ashim
2026-04-22 00:02:00 +08:00
committed by GitHub
parent 9a015c8501
commit 2d7a61c18f
7 changed files with 373 additions and 26 deletions
@@ -0,0 +1,222 @@
import { Check, FolderOpen, Loader2, Search, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
apiListFiles,
formatHeaders,
getFileDownloadUrl,
getFileThumbnailUrl,
type UserFile,
} from "@/lib/api";
import { cn } from "@/lib/utils";
interface FileLibraryModalProps {
open: boolean;
onClose: () => void;
onImport: (files: File[]) => void;
}
export function FileLibraryModal({ open, onClose, onImport }: FileLibraryModalProps) {
const [files, setFiles] = useState<UserFile[]>([]);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchFiles = useCallback(async (search?: string) => {
setLoading(true);
try {
const result = await apiListFiles({ search: search || undefined, limit: 200 });
setFiles(result.files);
} catch {
setFiles([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
fetchFiles();
setCheckedIds(new Set());
setSearchQuery("");
}
}, [open, fetchFiles]);
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value;
setSearchQuery(val);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => fetchFiles(val), 300);
}
function toggleCheck(id: string) {
setCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleAll() {
if (checkedIds.size === files.length) {
setCheckedIds(new Set());
} else {
setCheckedIds(new Set(files.map((f) => f.id)));
}
}
async function handleImport() {
if (checkedIds.size === 0) return;
setImporting(true);
try {
const toDownload = files.filter((f) => checkedIds.has(f.id));
const downloaded = await Promise.all(
toDownload.map(async (f) => {
const res = await fetch(getFileDownloadUrl(f.id), { headers: formatHeaders() });
if (!res.ok) return null;
const blob = await res.blob();
return new File([blob], f.originalName, { type: f.mimeType });
}),
);
const valid = downloaded.filter((f): f is File => f !== null);
if (valid.length > 0) {
onImport(valid);
onClose();
}
} finally {
setImporting(false);
}
}
if (!open) return null;
const allChecked = files.length > 0 && checkedIds.size === files.length;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
aria-hidden="true"
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
onClick={onClose}
/>
<div className="relative z-10 w-full max-w-lg max-h-[80vh] bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
<FolderOpen className="h-5 w-5 text-primary" />
<h2 className="text-sm font-semibold text-foreground flex-1">Import from Library</h2>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Search */}
<div className="px-4 py-2 border-b border-border shrink-0">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search files..."
value={searchQuery}
onChange={handleSearchChange}
className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted rounded-lg border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 text-foreground placeholder:text-muted-foreground"
/>
</div>
</div>
{/* Select all toolbar */}
<div className="flex items-center gap-2 px-4 py-1.5 border-b border-border shrink-0">
<input
type="checkbox"
checked={allChecked}
onChange={toggleAll}
className="h-4 w-4 accent-primary"
/>
<span className="text-xs text-muted-foreground flex-1">
{checkedIds.size > 0 ? `${checkedIds.size} selected` : `${files.length} files`}
</span>
</div>
{/* File grid */}
<div className="flex-1 overflow-y-auto p-3 min-h-0">
{loading && (
<div className="flex items-center justify-center h-32">
<div className="h-6 w-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
{!loading && files.length === 0 && (
<div className="flex items-center justify-center h-32">
<p className="text-sm text-muted-foreground">No files found</p>
</div>
)}
{!loading && files.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{files.map((file) => {
const checked = checkedIds.has(file.id);
return (
<button
key={file.id}
type="button"
onClick={() => toggleCheck(file.id)}
className={cn(
"relative group rounded-lg border overflow-hidden aspect-square flex items-center justify-center bg-muted/30 transition-all",
checked
? "border-primary ring-2 ring-primary/30"
: "border-border hover:border-primary/50",
)}
>
<img
src={getFileThumbnailUrl(file.id)}
alt={file.originalName}
className="w-full h-full object-cover"
loading="lazy"
/>
{checked && (
<div className="absolute top-1 right-1 w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
<Check className="h-3 w-3" />
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent px-1.5 py-1">
<p className="text-[10px] text-white truncate">{file.originalName}</p>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border shrink-0">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-border text-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="button"
onClick={handleImport}
disabled={checkedIds.size === 0 || importing}
className="px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{importing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Importing...
</>
) : (
<>Import{checkedIds.size > 0 ? ` (${checkedIds.size})` : ""}</>
)}
</button>
</div>
</div>
</div>
);
}
+15 -3
View File
@@ -1,5 +1,5 @@
import { TOOLS } from "@ashim/shared";
import { FileImage } from "lucide-react";
import { FileImage, Workflow } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
@@ -165,8 +165,8 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
</div>
</div>
{/* Open File button */}
<div className={cn("border-t border-border", mobile ? "pt-3" : "pt-4")}>
{/* Action buttons */}
<div className={cn("border-t border-border flex flex-col gap-2", mobile ? "pt-3" : "pt-4")}>
<button
type="button"
onClick={handleOpenFile}
@@ -174,6 +174,18 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
>
Open File
</button>
<button
type="button"
onClick={() => {
const { checkedIds } = useFilesPageStore.getState();
const ids = checkedIds.size > 1 ? Array.from(checkedIds) : [details.id];
navigate("/automate", { state: { libraryFileIds: ids } });
}}
className="w-full px-4 py-2 border border-primary text-primary text-sm font-medium rounded-lg hover:bg-primary/5 transition-colors flex items-center justify-center gap-2"
>
<Workflow className="h-4 w-4" />
Open in Pipeline
</button>
</div>
</div>
);
+15 -1
View File
@@ -1,5 +1,6 @@
import { Download, Search, Trash2 } from "lucide-react";
import { Download, Search, Trash2, Workflow } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getFileDownloadUrl } from "@/lib/api";
import { useFilesPageStore } from "@/stores/files-page-store";
import { FileListItem } from "./file-list-item";
@@ -16,6 +17,7 @@ export function FileList() {
setSearchQuery,
} = useFilesPageStore();
const navigate = useNavigate();
const [inputValue, setInputValue] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -44,6 +46,10 @@ export function FileList() {
}
}
function handleSendToPipeline() {
navigate("/automate", { state: { libraryFileIds: Array.from(checkedIds) } });
}
const allChecked = files.length > 0 && checkedIds.size === files.length;
const someChecked = checkedIds.size > 0;
@@ -84,6 +90,14 @@ export function FileList() {
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
<button
type="button"
onClick={handleSendToPipeline}
className="flex items-center gap-1 px-2 py-1 text-xs text-primary hover:bg-primary/10 rounded-lg transition-colors"
>
<Workflow className="h-3.5 w-3.5" />
Pipeline
</button>
<button
type="button"
onClick={handleBulkDownload}