350 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|