diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index ba9800c4..fc032eb2 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -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), diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts index 2f2bd86b..0df3e00a 100644 --- a/apps/api/src/lib/file-validation.ts +++ b/apps/api/src/lib/file-validation.ts @@ -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" }; } } diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index 170aaf89..af0676f0 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -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 { diff --git a/apps/web/src/components/common/file-library-modal.tsx b/apps/web/src/components/common/file-library-modal.tsx new file mode 100644 index 00000000..1002856f --- /dev/null +++ b/apps/web/src/components/common/file-library-modal.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [importing, setImporting] = useState(false); + const [checkedIds, setCheckedIds] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(""); + const debounceRef = useRef | 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) { + 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 ( +
+
- {/* Open File button */} -
+ {/* Action buttons */} +
+
); diff --git a/apps/web/src/components/files/file-list.tsx b/apps/web/src/components/files/file-list.tsx index f2f8f96a..08378bb1 100644 --- a/apps/web/src/components/files/file-list.tsx +++ b/apps/web/src/components/files/file-list.tsx @@ -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 | 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() { Delete + )} @@ -257,6 +315,13 @@ export function AutomatePage() { > + Add + + ) : ( - No files loaded +
+ No files loaded + +
)} @@ -722,12 +810,22 @@ export function AutomatePage() { )} {!hasFile && ( - +
+ + +
)} {hasFile && !hasProcessed && currentEntry?.status === "failed" && ( @@ -772,6 +870,11 @@ export function AutomatePage() { + setLibraryModalOpen(false)} + onImport={handleLibraryImport} + /> ); }