mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add progress bar to collage processing
Uses XMLHttpRequest upload progress to track image upload (0-80%), then shows "Processing collage..." for the server-side compositing phase (80-100%). Progress bar with percentage shown in the preview area.
This commit is contained in:
@@ -61,12 +61,7 @@ export function CollagePreview() {
|
||||
}
|
||||
|
||||
if (phase === "processing") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">Creating your collage...</p>
|
||||
</div>
|
||||
);
|
||||
return <ProcessingView />;
|
||||
}
|
||||
|
||||
if (phase === "result" && resultUrl) {
|
||||
@@ -150,6 +145,29 @@ function UploadArea() {
|
||||
);
|
||||
}
|
||||
|
||||
function ProcessingView() {
|
||||
const progress = useCollageStore((s) => s.progress);
|
||||
const label = progress < 80 ? "Uploading images..." : "Processing collage...";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-8">
|
||||
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
||||
<div className="w-full max-w-xs space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span className="font-mono">{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The live CSS Grid collage canvas. */
|
||||
function CollageCanvas({ template }: { template: CollageTemplate }) {
|
||||
const store = useCollageStore();
|
||||
|
||||
@@ -63,11 +63,11 @@ export function CollageSettings() {
|
||||
if (!hasImages || !template) return;
|
||||
|
||||
store.setPhase("processing");
|
||||
store.setProgress(0);
|
||||
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]) {
|
||||
@@ -94,18 +94,50 @@ export function CollageSettings() {
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await fetch("/api/v1/tools/collage", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
const result = await new Promise<{
|
||||
downloadUrl: string;
|
||||
processedSize: number;
|
||||
originalSize: number;
|
||||
jobId: string;
|
||||
}>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/v1/tools/collage");
|
||||
|
||||
const headers = formatHeaders();
|
||||
headers.forEach((value, key) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
store.setProgress(Math.round((e.loaded / e.total) * 80));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
store.setProgress(100);
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} catch {
|
||||
reject(new Error("Invalid response"));
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
reject(new Error(body.error || `Failed: ${xhr.status}`));
|
||||
} catch {
|
||||
reject(new Error(`Failed: ${xhr.status}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error("Network error"));
|
||||
xhr.send(formData);
|
||||
|
||||
store.setProgress(5);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -50,6 +50,7 @@ interface CollageState {
|
||||
// UI state
|
||||
selectedCell: number | null;
|
||||
phase: Phase;
|
||||
progress: number;
|
||||
resultUrl: string | null;
|
||||
resultSize: number | null;
|
||||
originalSize: number | null;
|
||||
@@ -74,6 +75,7 @@ interface CollageState {
|
||||
setQuality: (v: number) => void;
|
||||
setSelectedCell: (v: number | null) => void;
|
||||
setPhase: (v: Phase) => void;
|
||||
setProgress: (v: number) => void;
|
||||
setResult: (url: string, size: number, originalSize: number, jobId: string) => void;
|
||||
setError: (e: string | null) => void;
|
||||
reset: () => void;
|
||||
@@ -105,6 +107,7 @@ export const useCollageStore = create<CollageState>((set, get) => ({
|
||||
quality: 90,
|
||||
selectedCell: null,
|
||||
phase: "upload",
|
||||
progress: 0,
|
||||
resultUrl: null,
|
||||
resultSize: null,
|
||||
originalSize: null,
|
||||
@@ -263,6 +266,7 @@ export const useCollageStore = create<CollageState>((set, get) => ({
|
||||
setQuality: (v) => set({ quality: v, resultUrl: null }),
|
||||
setSelectedCell: (v) => set({ selectedCell: v }),
|
||||
setPhase: (v) => set({ phase: v }),
|
||||
setProgress: (v) => set({ progress: v }),
|
||||
setResult: (url, size, originalSize, jobId) =>
|
||||
set({ resultUrl: url, resultSize: size, originalSize, jobId, phase: "result", error: null }),
|
||||
setError: (e) => set({ error: e, phase: "editing" }),
|
||||
@@ -287,6 +291,7 @@ export const useCollageStore = create<CollageState>((set, get) => ({
|
||||
quality: 90,
|
||||
selectedCell: null,
|
||||
phase: "upload",
|
||||
progress: 0,
|
||||
resultUrl: null,
|
||||
resultSize: null,
|
||||
originalSize: null,
|
||||
|
||||
Reference in New Issue
Block a user