106 lines
3.5 KiB
TypeScript
106 lines
3.5 KiB
TypeScript
"use client";
|
|
import { useEffect } from "react";
|
|
import { useCanvasStore } from "@/store/canvas";
|
|
import {
|
|
collectImageArchiveSources,
|
|
type ArchivedImageUrlMap,
|
|
} from "@/lib/storage/image-archive-urls";
|
|
|
|
function preload(url: string, signal: AbortSignal): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const image = new Image();
|
|
let done = false;
|
|
const finish = (loaded: boolean) => {
|
|
if (done) return;
|
|
done = true;
|
|
clearTimeout(timer);
|
|
signal.removeEventListener("abort", cancel);
|
|
image.onload = image.onerror = null;
|
|
resolve(loaded);
|
|
};
|
|
const cancel = () => finish(false);
|
|
const timer = setTimeout(cancel, 15_000);
|
|
signal.addEventListener("abort", cancel, { once: true });
|
|
image.onload = () => finish(true);
|
|
image.onerror = cancel;
|
|
if (signal.aborted) cancel();
|
|
else image.src = url;
|
|
});
|
|
}
|
|
|
|
export function useCanvasImageArchives(projectId: string) {
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
const checked = new Set<string>();
|
|
const unknownChecks = new Map<string, number>();
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let polling = false;
|
|
const poll = async () => {
|
|
if (polling || controller.signal.aborted || document.visibilityState === "hidden") return;
|
|
const state = useCanvasStore.getState();
|
|
if (state.projectId !== projectId) return;
|
|
const urls = collectImageArchiveSources(state.nodes)
|
|
.filter((url) => !checked.has(url))
|
|
.slice(0, 100);
|
|
if (!urls.length) return;
|
|
polling = true;
|
|
try {
|
|
const response = await fetch(
|
|
`/api/canvas/${encodeURIComponent(projectId)}/image-archives`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ urls }),
|
|
signal: controller.signal,
|
|
},
|
|
);
|
|
if (!response.ok) return;
|
|
const result = (await response.json()) as {
|
|
replacements: ArchivedImageUrlMap;
|
|
pending: string[];
|
|
};
|
|
const pending = new Set(result.pending);
|
|
const ready: ArchivedImageUrlMap = {};
|
|
await Promise.all(
|
|
urls.map(async (url) => {
|
|
const target = result.replacements[url];
|
|
if (target) {
|
|
if (await preload(target, controller.signal)) {
|
|
ready[url] = target;
|
|
checked.add(url);
|
|
checked.add(target);
|
|
}
|
|
} else if (!pending.has(url)) {
|
|
// A direct generation can reach the browser before its first autosave.
|
|
// Allow ownership records to catch up before classifying a URL as untracked.
|
|
const checks = (unknownChecks.get(url) || 0) + 1;
|
|
unknownChecks.set(url, checks);
|
|
if (checks >= 10) checked.add(url);
|
|
}
|
|
}),
|
|
);
|
|
if (
|
|
!controller.signal.aborted &&
|
|
useCanvasStore.getState().projectId === projectId &&
|
|
Object.keys(ready).length
|
|
) {
|
|
useCanvasStore.getState().replaceArchivedImageUrls(ready);
|
|
}
|
|
} catch {
|
|
// Temporary network errors keep provider previews and are retried on the next poll.
|
|
} finally {
|
|
polling = false;
|
|
}
|
|
};
|
|
const tick = () => {
|
|
void poll();
|
|
timer = setTimeout(tick, 3_000);
|
|
};
|
|
timer = setTimeout(tick, 1_000);
|
|
return () => {
|
|
controller.abort();
|
|
if (timer) clearTimeout(timer);
|
|
};
|
|
}, [projectId]);
|
|
}
|