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 */}
{/* Canvas */}
Aspect Ratio
{ASPECT_RATIOS.map((ar) => (
))}
{/* Output */}
Format
{OUTPUT_FORMATS.map((f) => (
))}
{outputFormat !== "png" && (
)}
{/* 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 (
);
}
/** 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)];
}