AirShelf/core/frontend/src/routes/exact-document.tsx

350 lines
12 KiB
TypeScript

import { useEffect, useMemo, useRef } from "react";
import type { FormEvent } from "react";
import { exactHtmlDocuments } from "./exact-html";
import type { ExactHtmlKey } from "./exact-html";
import type { AuthMode, NavigateFn, Page } from "./route-config";
const fileToPage: Record<string, Page | "login" | "register" | null> = {
"account.html": "account",
"asset-factory.html": "assetFactory",
"image-optimize.html": "imageOptimize",
"index.html": "dashboard",
"library.html": "library",
"login.html": "login",
"messages.html": "messages",
"model-photo.html": "modelPhoto",
"model-photo-demo-a.html": "modelPhotoDemoA",
"model-photo-demo-b.html": "modelPhotoDemoB",
"pipeline.html": "pipeline",
"platform-cover.html": "platformCover",
"product-create.html": "productCreateUpload",
"product-create-upload.html": "productCreateUpload",
"product-detail.html": "productDetail",
"products.html": "products",
"projects-new.html": "projectWizard",
"projects.html": "projects",
"register.html": "register",
"settings.html": "settings",
"team.html": "team"
};
const pageToExactKey: Record<Page, ExactHtmlKey> = {
dashboard: "dashboard",
products: "products",
productDetail: "productDetail",
productCreateUpload: "productCreateUpload",
projects: "projects",
projectWizard: "projectWizard",
pipeline: "pipeline",
library: "library",
account: "account",
team: "team",
messages: "messages",
assetFactory: "assetFactory",
imageOptimize: "imageOptimize",
modelPhoto: "modelPhoto",
modelPhotoDemoA: "modelPhotoDemoA",
modelPhotoDemoB: "modelPhotoDemoB",
platformCover: "platformCover",
settings: "settings",
settingsNotify: "settings"
};
const exactKeyToPage: Partial<Record<ExactHtmlKey, Page | "login" | "register">> = {
account: "account",
assetFactory: "assetFactory",
dashboard: "dashboard",
imageOptimize: "imageOptimize",
library: "library",
login: "login",
messages: "messages",
modelPhoto: "modelPhoto",
modelPhotoDemoA: "modelPhotoDemoA",
modelPhotoDemoB: "modelPhotoDemoB",
pipeline: "pipeline",
platformCover: "platformCover",
productCreate: "productCreateUpload",
productCreateUpload: "productCreateUpload",
productDetail: "productDetail",
products: "products",
projectWizard: "projectWizard",
projects: "projects",
register: "register",
settings: "settings",
team: "team"
};
const exactKeyToFile: Record<ExactHtmlKey, string> = {
account: "account.html",
assetFactory: "asset-factory.html",
dashboard: "index.html",
imageOptimize: "image-optimize.html",
library: "library.html",
login: "login.html",
messages: "messages.html",
modelPhoto: "model-photo.html",
modelPhotoDemoA: "model-photo-demo-a.html",
modelPhotoDemoB: "model-photo-demo-b.html",
pipeline: "pipeline.html",
platformCover: "platform-cover.html",
productCreate: "product-create.html",
productCreateUpload: "product-create-upload.html",
productDetail: "product-detail.html",
products: "products.html",
projectWizard: "projects-new.html",
projects: "projects.html",
register: "register.html",
settings: "settings.html",
team: "team.html"
};
const liveHydratePages = new Set<ExactHtmlKey>([
"dashboard",
"products",
"productDetail",
"projectWizard",
"projects",
"pipeline",
"library",
"account",
"settings",
"team"
]);
function routeFromHref(rawHref: string | null) {
if (!rawHref || rawHref === "#" || rawHref.startsWith("javascript:")) return null;
const url = new URL(rawHref, "https://airshelf.local/exact/");
const fileName = url.pathname.split("/").filter(Boolean).pop() || "index.html";
const page = fileToPage[fileName];
if (!page) return null;
const params = new URLSearchParams(url.search);
return {
page,
hash: url.hash || "",
search: url.search || "",
productId: params.get("product_id") || undefined,
projectId: params.get("project_id") || undefined
};
}
function routeFromInlineAction(action: string | null) {
if (!action) return null;
const hrefMatch = action.match(/location\.href\s*=\s*['"]([^'"]+)/);
if (hrefMatch) return routeFromHref(hrefMatch[1]);
const hashMatch = action.match(/location\.hash\s*=\s*['"]([^'"]+)/);
if (hashMatch) return { page: null, hash: hashMatch[1], search: "" };
return null;
}
function setFrameHeight(frame: HTMLIFrameElement) {
frame.style.height = `${Math.max(window.innerHeight, 720)}px`;
}
export type ExactDocumentPageProps = {
pageKey: ExactHtmlKey;
hash?: string;
productId?: string;
projectId?: string;
navigate?: NavigateFn;
onAuthModeChange?: (mode: AuthMode) => void;
onAuthSubmit?: (mode: AuthMode, event: FormEvent<HTMLFormElement>, form: HTMLFormElement) => void;
};
export function exactKeyForPage(page: Page): ExactHtmlKey {
return pageToExactKey[page] || "dashboard";
}
function contextSearch(pageKey: ExactHtmlKey, productId?: string, projectId?: string) {
const params = new URLSearchParams();
if (pageKey === "productDetail" && productId) params.set("product_id", productId);
if (pageKey === "pipeline" && projectId) params.set("project_id", projectId);
const text = params.toString();
return text ? `?${text}` : "";
}
export function ExactDocumentPage({
pageKey,
hash,
productId,
projectId,
navigate,
onAuthModeChange,
onAuthSubmit
}: ExactDocumentPageProps) {
const frameRef = useRef<HTMLIFrameElement | null>(null);
const html = useMemo(() => {
const context = {
page: exactKeyToFile[pageKey],
search: contextSearch(pageKey, productId, projectId),
hash: hash ? `#${hash.replace(/^#/, "")}` : "",
liveHydrate: liveHydratePages.has(pageKey)
};
return exactHtmlDocuments[pageKey].replace(
"</head>",
`<script>window.__AIR_SHELF_EXACT_CONTEXT__=${JSON.stringify(context)};</script></head>`
);
}, [hash, pageKey, productId, projectId]);
useEffect(() => {
const frame = frameRef.current;
if (!frame) return;
const currentFrame: HTMLIFrameElement = frame;
function onLoad() {
const doc = currentFrame.contentDocument;
const win = currentFrame.contentWindow;
if (!doc || !win) return;
(win as Window & { __AIR_SHELF_HOST_NAVIGATE__?: (href: string) => void }).__AIR_SHELF_HOST_NAVIGATE__ = (
href: string
) => {
const hostRoute = routeFromHref(href);
if (!hostRoute?.page) return;
if (hostRoute.page === "login" || hostRoute.page === "register") {
onAuthModeChange?.(hostRoute.page);
return;
}
navigate?.(hostRoute.page, {
hash: hostRoute.hash || undefined,
productId: hostRoute.productId || (hostRoute.page === "productDetail" ? productId : undefined),
projectId: hostRoute.projectId || (hostRoute.page === "pipeline" ? projectId : undefined)
});
};
const applyFrameHash = (nextHash: string) => {
const cleanHash = nextHash.replace(/^#/, "");
const stageMatch = cleanHash.match(/^stage-(\d+)$/);
const pipelineWindow = win as Window & { activateStage?: (stage: number) => void };
if (pageKey === "pipeline" && stageMatch && typeof pipelineWindow.activateStage === "function") {
pipelineWindow.activateStage(Number(stageMatch[1]));
return;
}
const settingsWindow = win as Window & { showSection?: (sectionId: string) => void };
if (pageKey === "settings" && cleanHash.startsWith("sec-") && typeof settingsWindow.showSection === "function") {
settingsWindow.showSection(cleanHash);
return;
}
doc.getElementById(cleanHash)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
if (hash) {
setTimeout(() => {
applyFrameHash(hash);
}, 0);
}
const clickHandler = (event: MouseEvent) => {
const target = event.target as Element | null;
if (!target) return;
const syncHashOnlyRoute = (nextHash: string) => {
const cleanHash = nextHash.replace(/^#/, "");
applyFrameHash(cleanHash);
if (exactKeyToPage[pageKey]) {
const nextUrl = `${window.location.pathname}${window.location.search}#${cleanHash}`;
window.history.replaceState(null, "", nextUrl);
}
};
const actionNode = target.closest("[onclick]") as HTMLElement | null;
const actionRoute = routeFromInlineAction(actionNode?.getAttribute("onclick") || null);
if (actionRoute?.hash && actionRoute.page === null) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
syncHashOnlyRoute(actionRoute.hash);
return;
}
if (actionRoute?.page) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (actionRoute.page === "login" || actionRoute.page === "register") {
onAuthModeChange?.(actionRoute.page);
return;
}
navigate?.(actionRoute.page, {
hash: actionRoute.hash || undefined,
productId: actionRoute.productId || (actionRoute.page === "productDetail" ? productId || "exact" : undefined),
projectId: actionRoute.projectId
});
return;
}
const anchor = target.closest("a[href]") as HTMLAnchorElement | null;
const rawAnchorHref = anchor?.getAttribute("href") || null;
if (rawAnchorHref?.startsWith("#")) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
syncHashOnlyRoute(rawAnchorHref);
return;
}
const anchorRoute = routeFromHref(rawAnchorHref);
if (!anchorRoute) return;
event.preventDefault();
event.stopPropagation();
if (anchorRoute.page === "login" || anchorRoute.page === "register") {
onAuthModeChange?.(anchorRoute.page);
return;
}
navigate?.(anchorRoute.page, {
hash: anchorRoute.hash || undefined,
productId: anchorRoute.productId || (anchorRoute.page === "productDetail" ? productId || "exact" : undefined),
projectId: anchorRoute.projectId
});
};
const submitHandler = (event: SubmitEvent) => {
const form = event.target as HTMLFormElement | null;
if (!form) return;
const isLogin = pageKey === "login" && form.id === "login-form";
const isRegister = pageKey === "register" && form.id === "register-form";
if (!isLogin && !isRegister) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
onAuthSubmit?.(isLogin ? "login" : "register", event as unknown as FormEvent<HTMLFormElement>, form);
};
doc.addEventListener("click", clickHandler, true);
doc.addEventListener("submit", submitHandler, true);
setFrameHeight(currentFrame);
const resizeFrame = () => setFrameHeight(currentFrame);
window.addEventListener("resize", resizeFrame);
const observer = new ResizeObserver(() => setFrameHeight(currentFrame));
observer.observe(doc.documentElement);
observer.observe(doc.body);
const cleanup = () => {
doc.removeEventListener("click", clickHandler, true);
doc.removeEventListener("submit", submitHandler, true);
window.removeEventListener("resize", resizeFrame);
observer.disconnect();
};
currentFrame.dataset.cleanupKey = String(Date.now());
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup?.();
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup = cleanup;
}
currentFrame.addEventListener("load", onLoad);
if (currentFrame.contentDocument?.readyState !== "loading") onLoad();
return () => {
currentFrame.removeEventListener("load", onLoad);
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup?.();
(currentFrame as HTMLIFrameElement & { __airshelfCleanup?: () => void }).__airshelfCleanup = undefined;
};
}, [hash, navigate, onAuthModeChange, onAuthSubmit, pageKey]);
return (
<div className="exact-document-route" data-exact-page={pageKey}>
<iframe
ref={frameRef}
title={`Airshelf ${pageKey}`}
className="exact-document-frame"
srcDoc={html}
/>
</div>
);
}