import { Download, Loader2 } from "lucide-react"; import { useCallback } from "react"; import { CollapsibleSection } from "@/components/common/collapsible-section"; import { formatHeaders } from "@/lib/api"; import { COLLAGE_TEMPLATES, type CollageTemplate, getTemplateById, getTemplatesForCount, } from "@/lib/collage-templates"; import { cn } from "@/lib/utils"; import { type AspectRatio, type OutputFormat, useCollageStore } from "@/stores/collage-store"; const ASPECT_RATIOS: { value: AspectRatio; label: string }[] = [ { value: "free", label: "Free" }, { value: "1:1", label: "1:1" }, { value: "4:3", label: "4:3" }, { value: "3:2", label: "3:2" }, { value: "16:9", label: "16:9" }, { value: "9:16", label: "9:16" }, { value: "4:5", label: "4:5" }, ]; const OUTPUT_FORMATS: { value: OutputFormat; label: string }[] = [ { value: "png", label: "PNG" }, { value: "jpeg", label: "JPEG" }, { value: "webp", label: "WebP" }, ]; const BG_PRESETS = [ { id: "white" as const, label: "White", color: "#FFFFFF", border: true }, { id: "black" as const, label: "Black", color: "#000000", border: false }, { id: "transparent" as const, label: "None", color: "transparent", border: true }, { id: "custom" as const, label: "Custom", color: null, border: true }, ]; export function CollageSettings() { const store = useCollageStore(); const { images, templateId, cellAssignments, cellTransforms, gap, cornerRadius, backgroundColor, bgPreset, aspectRatio, outputFormat, quality, phase, resultUrl, resultSize, originalSize, error, } = store; const template = getTemplateById(templateId); const imageCount = images.length; const hasImages = imageCount > 0; const handleProcess = useCallback(async () => { if (!hasImages || !template) return; store.setPhase("processing"); store.setError(null); try { const formData = new FormData(); // Send images in cell-assignment order for (let i = 0; i < template.cells.length; i++) { const imgIdx = cellAssignments[i] ?? -1; if (imgIdx >= 0 && images[imgIdx]) { formData.append("file", images[imgIdx].file); } } const cells = template.cells.map((_, i) => { const t = cellTransforms[i] ?? { panX: 0, panY: 0, zoom: 1 }; return { imageIndex: i, panX: t.panX, panY: t.panY, zoom: t.zoom }; }); formData.append( "settings", JSON.stringify({ templateId, cells, gap, cornerRadius, backgroundColor, aspectRatio, outputFormat, quality, }), ); const res = await fetch("/api/v1/tools/collage", { method: "POST", headers: formatHeaders(), body: formData, }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Failed: ${res.status}`); } const result = await res.json(); store.setResult(result.downloadUrl, result.processedSize, result.originalSize, result.jobId); } catch (err) { store.setError(err instanceof Error ? err.message : "Collage failed"); } }, [ hasImages, template, store, cellAssignments, cellTransforms, images, templateId, gap, cornerRadius, backgroundColor, aspectRatio, outputFormat, quality, ]); // Group templates by image count, prioritizing current count const matchingTemplates = imageCount > 0 ? getTemplatesForCount(imageCount) : []; const allTemplates = COLLAGE_TEMPLATES; return (
{/* Image count info */} {hasImages && (
{imageCount} image{imageCount !== 1 ? "s" : ""} loaded
)} {/* Layout templates */}
{matchingTemplates.length > 0 && (

Best for {imageCount} images

{matchingTemplates.map((t) => ( store.setTemplateId(t.id)} /> ))}
)}
{matchingTemplates.length > 0 && (

All layouts

)}
{allTemplates.map((t) => ( store.setTemplateId(t.id)} /> ))}
{/* Spacing & Style */}
Gap {gap}px
store.setGap(Number(e.target.value))} className="w-full mt-1" />
Corner Radius {cornerRadius}px
store.setCornerRadius(Number(e.target.value))} className="w-full mt-1" />
Background
{BG_PRESETS.map((p) => (
{/* Canvas */}
Aspect Ratio
{ASPECT_RATIOS.map((ar) => ( ))}
{/* Output */}
Format
{OUTPUT_FORMATS.map((f) => ( ))}
{outputFormat !== "png" && (
Quality {quality}%
store.setQuality(Number(e.target.value))} className="w-full mt-1" />
)}
{/* Error */} {error &&

{error}

} {/* Actions */} {resultUrl && ( Download Collage )} {/* Size info */} {originalSize != null && resultSize != null && (

Input total: {(originalSize / 1024).toFixed(1)} KB

Collage: {(resultSize / 1024).toFixed(1)} KB

)}
); } /** Mini template thumbnail as an SVG diagram. */ function TemplateButton({ template, isSelected, onClick, }: { template: CollageTemplate; isSelected: boolean; onClick: () => void; }) { return ( ); } /** Renders a mini SVG preview of a template layout. */ function TemplateDiagram({ template, size }: { template: CollageTemplate; size: number }) { const padding = 2; const gap = 1.5; const inner = size - padding * 2; // Parse the CSS grid template to compute cell rects const rects = computeCellRects(template, inner, gap); return ( {rects.map((r, i) => ( ))} ); } /** Parse CSS grid definitions into pixel rects for the SVG diagram. */ function computeCellRects( template: CollageTemplate, size: number, gap: number, ): Array<{ x: number; y: number; w: number; h: number }> { const cols = parseFrValues(template.gridTemplateColumns); const rows = parseFrValues(template.gridTemplateRows); const totalColGaps = (cols.length - 1) * gap; const totalRowGaps = (rows.length - 1) * gap; const availW = size - totalColGaps; const availH = size - totalRowGaps; const colFrTotal = cols.reduce((s, v) => s + v, 0); const rowFrTotal = rows.reduce((s, v) => s + v, 0); const colWidths = cols.map((fr) => (fr / colFrTotal) * availW); const rowHeights = rows.map((fr) => (fr / rowFrTotal) * availH); // Compute cumulative positions const colStarts: number[] = [0]; for (let i = 1; i < cols.length; i++) { colStarts.push(colStarts[i - 1] + colWidths[i - 1] + gap); } const rowStarts: number[] = [0]; for (let i = 1; i < rows.length; i++) { rowStarts.push(rowStarts[i - 1] + rowHeights[i - 1] + gap); } return template.cells.map((cell) => { const [colStart, colEnd] = parseGridRange(cell.gridColumn, cols.length); const [rowStart, rowEnd] = parseGridRange(cell.gridRow, rows.length); const x = colStarts[colStart]; const y = rowStarts[rowStart]; const w = colStarts[colEnd - 1] + colWidths[colEnd - 1] - colStarts[colStart]; const h = rowStarts[rowEnd - 1] + rowHeights[rowEnd - 1] - rowStarts[rowStart]; return { x, y, w, h }; }); } /** Parse "1fr 2fr 1fr" into [1, 2, 1]. */ function parseFrValues(template: string): number[] { return template .trim() .split(/\s+/) .map((s) => { const match = s.match(/^(\d+(?:\.\d+)?)fr$/); return match ? Number(match[1]) : 1; }); } /** Parse CSS grid-column/grid-row value like "1 / 3" or "2" into [startIndex, endIndex]. */ function parseGridRange(value: string, trackCount: number): [number, number] { const parts = value.split("/").map((s) => s.trim()); const start = Number(parts[0]) - 1; // CSS grid lines are 1-based const end = parts.length > 1 ? Number(parts[1]) - 1 : start + 1; return [Math.max(0, start), Math.min(trackCount, end)]; }