mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -35,7 +35,7 @@ const envSchema = z.object({
|
||||
MAX_PIPELINE_STEPS: z.coerce.number().default(0),
|
||||
MAX_CANVAS_PIXELS: z.coerce.number().default(0),
|
||||
MAX_SVG_SIZE_MB: z.coerce.number().default(0),
|
||||
MAX_LOGO_SIZE_KB: z.coerce.number().default(2048),
|
||||
MAX_LOGO_SIZE_KB: z.coerce.number().default(500),
|
||||
MAX_SPLIT_GRID: z.coerce.number().default(100),
|
||||
MAX_PDF_PAGES: z.coerce.number().default(0),
|
||||
SESSION_DURATION_HOURS: z.coerce.number().default(168),
|
||||
|
||||
@@ -156,9 +156,7 @@ export async function validateImageBuffer(
|
||||
|
||||
return { valid: true, format: detectedFormat, width, height };
|
||||
} catch {
|
||||
// Sharp failed but we already confirmed valid magic bytes / extension.
|
||||
// This can happen for JXL, ICO, or other formats Sharp partially supports.
|
||||
return { valid: true, format: detectedFormat, width: 0, height: 0 };
|
||||
return { valid: false, reason: "Failed to read image metadata" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,25 +37,23 @@ const pipelineStepSchema = z.object({
|
||||
});
|
||||
|
||||
/** Schema for a full pipeline definition. */
|
||||
const maxSteps = env.MAX_PIPELINE_STEPS > 0 ? env.MAX_PIPELINE_STEPS : 20;
|
||||
|
||||
const pipelineDefinitionSchema = z.object({
|
||||
steps: z
|
||||
.array(pipelineStepSchema)
|
||||
.min(1, "Pipeline must have at least one step")
|
||||
.refine((steps) => env.MAX_PIPELINE_STEPS === 0 || steps.length <= env.MAX_PIPELINE_STEPS, {
|
||||
message: "Pipeline exceeds maximum steps",
|
||||
}),
|
||||
.max(maxSteps, "Pipeline exceeds maximum steps"),
|
||||
});
|
||||
|
||||
/** Schema for saving a pipeline. */
|
||||
const savePipelineSchema = z.object({
|
||||
name: z.string().min(1, "Pipeline name is required").max(255),
|
||||
description: z.string().max(2000).optional(),
|
||||
name: z.string().min(1, "Pipeline name is required").max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
steps: z
|
||||
.array(pipelineStepSchema)
|
||||
.min(1, "Pipeline must have at least one step")
|
||||
.refine((steps) => env.MAX_PIPELINE_STEPS === 0 || steps.length <= env.MAX_PIPELINE_STEPS, {
|
||||
message: "Pipeline exceeds maximum steps",
|
||||
}),
|
||||
.max(maxSteps, "Pipeline exceeds maximum steps"),
|
||||
});
|
||||
|
||||
export async function registerPipelineRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Download,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
Play,
|
||||
Plus,
|
||||
@@ -13,9 +14,11 @@ import {
|
||||
Workflow,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||
import { Dropzone } from "@/components/common/dropzone";
|
||||
import { FileLibraryModal } from "@/components/common/file-library-modal";
|
||||
import { ImageViewer } from "@/components/common/image-viewer";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
|
||||
@@ -24,7 +27,7 @@ import { PipelineBuilder } from "@/components/tools/pipeline-builder";
|
||||
import { ToolPalette } from "@/components/tools/tool-palette";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { usePipelineProcessor } from "@/hooks/use-pipeline-processor";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { formatHeaders, getFileDownloadUrl } from "@/lib/api";
|
||||
import { formatFileSize } from "@/lib/download";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
@@ -67,7 +70,11 @@ export function AutomatePage() {
|
||||
|
||||
const { processSingle, processAll, processing, error, progress } = usePipelineProcessor();
|
||||
const isMobile = useMobile();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const libraryImportHandled = useRef(false);
|
||||
|
||||
const [libraryModalOpen, setLibraryModalOpen] = useState(false);
|
||||
const [saveName, setSaveName] = useState("");
|
||||
const [saveDescription, setSaveDescription] = useState("");
|
||||
const [showSaveForm, setShowSaveForm] = useState(false);
|
||||
@@ -98,6 +105,49 @@ export function AutomatePage() {
|
||||
})();
|
||||
}, [setSavedPipelines]);
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as { libraryFileIds?: string[] } | null;
|
||||
if (!state?.libraryFileIds || libraryImportHandled.current) return;
|
||||
const fileIds = state.libraryFileIds;
|
||||
libraryImportHandled.current = true;
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
|
||||
(async () => {
|
||||
const downloaded = await Promise.all(
|
||||
fileIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(getFileDownloadUrl(id), { headers: formatHeaders() });
|
||||
if (!res.ok) return null;
|
||||
const blob = await res.blob();
|
||||
const name =
|
||||
res.headers.get("content-disposition")?.match(/filename="?(.+?)"?$/)?.[1] ??
|
||||
`file-${id}`;
|
||||
return new File([blob], name, { type: blob.type });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const valid = downloaded.filter((f): f is File => f !== null);
|
||||
if (valid.length > 0) {
|
||||
resetFiles();
|
||||
setFiles(valid);
|
||||
}
|
||||
})();
|
||||
}, [location.state, location.pathname, navigate, resetFiles, setFiles]);
|
||||
|
||||
const handleLibraryImport = useCallback(
|
||||
(imported: File[]) => {
|
||||
if (files.length === 0) {
|
||||
resetFiles();
|
||||
setFiles(imported);
|
||||
} else {
|
||||
addFiles(imported);
|
||||
}
|
||||
},
|
||||
[files.length, resetFiles, setFiles, addFiles],
|
||||
);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(newFiles: File[]) => {
|
||||
resetFiles();
|
||||
@@ -239,8 +289,16 @@ export function AutomatePage() {
|
||||
{/* Mobile pipeline steps */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{!hasFile && (
|
||||
<div className="mb-4">
|
||||
<div className="mb-4 space-y-2">
|
||||
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLibraryModalOpen(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-primary border border-dashed border-primary/40 rounded-lg hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Import from Library
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -257,6 +315,13 @@ export function AutomatePage() {
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLibraryModalOpen(true)}
|
||||
className="text-xs text-primary hover:text-primary/80"
|
||||
>
|
||||
Library
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetFiles()}
|
||||
@@ -405,6 +470,11 @@ export function AutomatePage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<FileLibraryModal
|
||||
open={libraryModalOpen}
|
||||
onClose={() => setLibraryModalOpen(false)}
|
||||
onImport={handleLibraryImport}
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
@@ -532,6 +602,14 @@ export function AutomatePage() {
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLibraryModalOpen(true)}
|
||||
className="text-xs text-primary hover:text-primary/80 flex items-center gap-1"
|
||||
>
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
Library
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetFiles()}
|
||||
@@ -541,7 +619,17 @@ export function AutomatePage() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic shrink-0">No files loaded</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-muted-foreground italic">No files loaded</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLibraryModalOpen(true)}
|
||||
className="text-xs text-primary hover:text-primary/80 flex items-center gap-1"
|
||||
>
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
Import from Library
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -722,12 +810,22 @@ export function AutomatePage() {
|
||||
)}
|
||||
|
||||
{!hasFile && (
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
accept="image/*"
|
||||
multiple
|
||||
currentFiles={files}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3 w-full max-w-md">
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
accept="image/*"
|
||||
multiple
|
||||
currentFiles={files}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLibraryModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-primary border border-dashed border-primary/40 rounded-lg hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Import from Library
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFile && !hasProcessed && currentEntry?.status === "failed" && (
|
||||
@@ -772,6 +870,11 @@ export function AutomatePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FileLibraryModal
|
||||
open={libraryModalOpen}
|
||||
onClose={() => setLibraryModalOpen(false)}
|
||||
onImport={handleLibraryImport}
|
||||
/>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user