import { useCallback, useEffect, useState } from "react";
import { OpenPressRuntime, type OpenPressRuntimeMode } from "./OpenPressRuntime";
import { WorkspaceGalleryPage } from "./WorkspaceGalleryPage";
import { isLocalWorkspaceHost, ToastProvider } from "../shared";
import type {
DeploymentInfo,
DocumentRefreshOptions,
ReaderDocument,
WorkspaceManifest,
WorkspaceManifestPress,
} from "../document-model";
import { findManifestPress, manifestHasMultiplePresses } from "../document-model";
import {
buildWorkspaceDestination,
normalizeWorkspaceSlug,
parseWorkspaceDestination,
type WorkspaceDestination,
} from "./workspaceRoute";
type LoadState =
| { status: "loading" }
| {
status: "workspace";
view: "documents" | "settings";
manifest: WorkspaceManifest;
deploymentInfo: DeploymentInfo;
}
| {
status: "ready";
document: ReaderDocument;
deploymentInfo: DeploymentInfo;
manifest: WorkspaceManifest | null;
// Active Press slug, used by refresh/back/forward to re-resolve.
activeSlug: string;
runtimeMode: OpenPressRuntimeMode;
}
| { status: "error"; message: string };
interface DeployConfig {
pdf?: string;
deployed_at?: string;
public_url?: string;
dirty?: boolean;
deploy_configured?: boolean;
deploy_adapter?: string;
deploy_source?: string;
deploy_project_name?: string | null;
deploy_setup_message?: string;
}
const offlineDeploymentInfo: DeploymentInfo = { online: false };
const LOADING_SCREEN_CLASS = "openpress-loading-screen fixed inset-0 flex items-center justify-center bg-[#141414]";
const LOADING_SCREEN_INNER_CLASS = "openpress-loading-screen__inner flex flex-col items-center gap-5";
const LOADING_DOTS_CLASS = "openpress-loading-dots flex gap-2";
const LOADING_DOT_CLASS = "h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--openpress-accent,#e5c97a)]";
const LOADING_LABEL_CLASS = "openpress-loading-screen__label text-xs uppercase tracking-[0.12em] text-[rgb(200_200_200_/_0.40)]";
const LOAD_STATE_CLASS = [
"openpress-load-state openpress-load-state--error fixed left-1/2 top-4 z-20 -translate-x-1/2",
"border border-white/15 bg-[#141414]/85 px-3 py-2 text-[13px] text-[#d8dadd]",
].join(" ");
const REFRESH_RENDER_TIMEOUT_MS = 15_000;
const REFRESH_RENDER_POLL_MS = 160;
function LoadingScreen() {
return (
);
}
export function OpenPressApp() {
const [state, setState] = useState({ status: "loading" });
// Single resolution function — same code path for "boot from URL",
// "click gallery card", and "browser back button". Given a manifest
// + route, decides whether to render gallery or load a press.
const resolveFromRoute = useCallback(async (
manifest: WorkspaceManifest | null,
destination: WorkspaceDestination,
deploymentInfo: DeploymentInfo,
) => {
if (!manifest || manifest.presses.length === 0) {
setState({
status: "error",
message: "OpenPress workspace manifest is missing or empty. Run open-press render to generate /openpress/workspace.json.",
});
return;
}
if (destination.kind === "settings") {
setState({ status: "workspace", view: "settings", manifest, deploymentInfo });
return;
}
if (destination.kind === "documents" && manifestHasMultiplePresses(manifest)) {
setState({ status: "workspace", view: "documents", manifest, deploymentInfo });
return;
}
const normalizedSlug = destination.kind === "press"
? normalizeWorkspaceSlug(destination.slug)
: "";
const press = normalizedSlug
? findManifestPress(manifest, normalizedSlug)
: manifest.presses[0];
if (!press) {
setState({
status: "error",
message: `Unknown document slug "/${normalizedSlug}". Known: ${manifest.presses.map((p) => `/${p.slug}`).join(", ")}.`,
});
return;
}
const document = await loadReaderDocument(press.documentUrl);
setState({
status: "ready",
document,
deploymentInfo,
manifest,
activeSlug: press.slug,
runtimeMode: resolveRuntimeMode(document, destination.kind === "press" ? destination.mode : "preview"),
});
}, []);
const refreshDocument = useCallback(async (options?: DocumentRefreshOptions) => {
if (state.status !== "ready") return;
const press = state.manifest
? findManifestPress(state.manifest, state.activeSlug)
: null;
if (!press) return;
const nextDocument = await loadReaderDocumentWhenReady(press.documentUrl, options);
setState((latest) => {
if (latest.status !== "ready") return latest;
// Preserve block object references when html is unchanged so that
// PageHtmlContent (memoized by html string) can bail out and avoid
// resetting innerHTML — which would destroy running CSS animation state.
const prevBlocks = latest.document.blocks;
const stableBlocks = nextDocument.blocks.map((block) => {
if (block.kind !== "htmlPage") return block;
const prev = prevBlocks.find((b) => b.kind === "htmlPage" && b.id === block.id);
if (prev && prev.kind === "htmlPage" && prev.html === block.html) return prev;
return block;
});
const document = stableBlocks === nextDocument.blocks
? nextDocument
: { ...nextDocument, blocks: stableBlocks };
return { ...latest, document };
});
}, [state]);
// Gallery click → pushState + load. Bypasses resolveFromRoute's
// "empty slug + multi-Press → gallery" branch.
const enterPress = useCallback(async (press: WorkspaceManifestPress) => {
if (state.status !== "workspace") return;
pushPressRoute(press.slug, "preview");
setState({ status: "loading" });
try {
const document = await loadReaderDocument(press.documentUrl);
setState({
status: "ready",
document,
deploymentInfo: state.deploymentInfo,
manifest: state.manifest,
activeSlug: press.slug,
runtimeMode: "preview",
});
} catch (error) {
setState({
status: "error",
message: error instanceof Error ? error.message : "Unable to load OpenPress document.",
});
}
}, [state]);
const switchPress = useCallback(async (press: WorkspaceManifestPress) => {
if (state.status !== "ready" || !state.manifest) return;
if (press.slug === state.activeSlug) return;
pushPressRoute(press.slug, "preview");
setState({ status: "loading" });
try {
const document = await loadReaderDocument(press.documentUrl);
setState({
status: "ready",
document,
deploymentInfo: state.deploymentInfo,
manifest: state.manifest,
activeSlug: press.slug,
runtimeMode: "preview",
});
} catch (error) {
setState({
status: "error",
message: error instanceof Error ? error.message : "Unable to load OpenPress document.",
});
}
}, [state]);
const openWorkspaceView = useCallback(async (view: "documents" | "settings") => {
const workspaceState = state.status === "workspace" || state.status === "ready" ? state : null;
if (!workspaceState?.manifest) return;
const destination: WorkspaceDestination = { kind: view };
pushWorkspaceRoute(destination);
if (view === "settings" || manifestHasMultiplePresses(workspaceState.manifest)) {
setState({
status: "workspace",
view,
manifest: workspaceState.manifest,
deploymentInfo: workspaceState.deploymentInfo,
});
return;
}
setState({ status: "loading" });
await resolveFromRoute(workspaceState.manifest, destination, workspaceState.deploymentInfo);
}, [resolveFromRoute, state]);
// Bootstrap: read URL → load manifest + deploy info → resolve.
useEffect(() => {
let cancelled = false;
async function bootstrap() {
try {
const [manifest, deploymentInfo] = await Promise.all([
loadWorkspaceManifest(),
loadDeploymentInfo(),
]);
if (cancelled) return;
await resolveFromRoute(manifest, currentRouteFromLocation(), deploymentInfo);
} catch (error) {
if (!cancelled) {
setState({
status: "error",
message: error instanceof Error ? error.message : "Unable to load OpenPress document.",
});
}
}
}
void bootstrap();
return () => {
cancelled = true;
};
}, [resolveFromRoute]);
// Back / forward button — re-resolve from the new URL.
useEffect(() => {
function onPopState() {
if (state.status === "loading") return;
const manifest = state.status === "workspace"
? state.manifest
: state.status === "ready"
? state.manifest
: null;
const deploymentInfo = state.status === "workspace" || state.status === "ready"
? state.deploymentInfo
: offlineDeploymentInfo;
void resolveFromRoute(manifest, currentRouteFromLocation(), deploymentInfo);
}
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, [state, resolveFromRoute]);
if (state.status === "loading") return ;
if (state.status === "error") {
return {state.message}
;
}
if (state.status === "workspace") {
return (
void openWorkspaceView("documents")}
onOpenSettings={() => void openWorkspaceView("settings")}
/>
);
}
// Only multi-Press workspaces have a gallery to go back to. Single-Press
// workspaces don't render the button (no destination exists).
const backToWorkspace = state.manifest && manifestHasMultiplePresses(state.manifest)
? () => {
if (state.status !== "ready" || !state.manifest) return;
void openWorkspaceView("documents");
}
: undefined;
const currentDestination = currentRouteFromLocation();
const routeSlug = currentDestination.kind === "press" ? currentDestination.slug : "";
const presentationSlug = state.activeSlug || routeSlug;
const openPresentation = state.document.meta.type === "slides" && presentationSlug
? (pageIndex: number) => {
const slug = normalizeWorkspaceSlug(presentationSlug);
pushPressRoute(slug, "present", pageIndex);
setState((latest) => latest.status === "ready"
? { ...latest, runtimeMode: "present" }
: latest);
}
: undefined;
const exitPresentation = state.document.meta.type === "slides"
? (pageIndex: number) => {
if (state.status !== "ready") return;
// Exit fullscreen before returning to the workbench.
const activeDoc = globalThis.document;
if (activeDoc?.fullscreenElement && activeDoc?.exitFullscreen) {
void activeDoc.exitFullscreen().catch(() => {});
}
const destination = currentRouteFromLocation();
const slug = state.activeSlug || (destination.kind === "press" ? destination.slug : "");
if (slug) pushPressRoute(slug, "preview", pageIndex);
setState((latest) => latest.status === "ready"
? { ...latest, runtimeMode: "preview" }
: latest);
}
: undefined;
return (
void openWorkspaceView("settings")}
/>
);
}
function currentRouteFromLocation(): WorkspaceDestination {
if (typeof window === "undefined") return { kind: "documents" };
return parseWorkspaceDestination(window.location.pathname);
}
function pushWorkspaceRoute(destination: WorkspaceDestination) {
if (typeof window === "undefined") return;
const target = buildWorkspaceDestination(destination);
if (`${window.location.pathname}${window.location.search}${window.location.hash}` === target) return;
window.history.pushState({}, "", target);
}
function pushPressRoute(slug: string, mode: OpenPressRuntimeMode, pageIndex?: number) {
if (typeof window === "undefined") return;
const target = buildPressRoute(slug, mode, pageIndex);
if (`${window.location.pathname}${window.location.search}${window.location.hash}` === target) return;
window.history.pushState({}, "", target);
}
function buildPressRoute(
slug: string,
mode: OpenPressRuntimeMode,
pageIndex?: number,
options: { fullscreen?: boolean } = {},
) {
const normalizedSlug = normalizeWorkspaceSlug(slug);
const pathname = normalizedSlug
? buildWorkspaceDestination({ kind: "press", slug: normalizedSlug, mode })
: buildWorkspaceDestination({ kind: "documents" });
const search = mode === "present" && options.fullscreen ? "?fullscreen=1" : "";
const pageHash = typeof pageIndex === "number"
? `#page-${String(pageIndex + 1).padStart(2, "0")}`
: "";
return `${pathname}${search}${pageHash}`;
}
function resolveRuntimeMode(document: ReaderDocument, requestedMode: OpenPressRuntimeMode): OpenPressRuntimeMode {
if (requestedMode === "present" && document.meta.type === "slides") return "present";
return "preview";
}
async function loadWorkspaceManifest(): Promise {
try {
const response = await fetch("/openpress/workspace.json", { cache: "no-store" });
if (!response.ok) return null;
return (await response.json()) as WorkspaceManifest;
} catch {
return null;
}
}
async function loadReaderDocument(url: string): Promise {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Unable to load ${url} (${response.status})`);
}
return (await response.json()) as ReaderDocument;
}
async function loadReaderDocumentWhenReady(
url: string,
options?: DocumentRefreshOptions,
): Promise {
const expectedRenderId = options?.expectedRenderId?.trim();
if (!expectedRenderId) return loadReaderDocument(url);
const startedAt = Date.now();
let attempt = 0;
let lastRenderId = "";
let lastError = "";
while (Date.now() - startedAt <= REFRESH_RENDER_TIMEOUT_MS) {
try {
const document = await loadReaderDocument(cacheBustedDocumentUrl(url, `${expectedRenderId}-${attempt}`));
lastRenderId = document.meta.renderId ?? "";
lastError = "";
if (lastRenderId === expectedRenderId) return document;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
attempt += 1;
await wait(REFRESH_RENDER_POLL_MS);
}
const lastObserved = lastRenderId || (lastError ? `error: ${lastError}` : "none");
throw new Error(
`Rendered document did not reach ${expectedRenderId}. Last observed: ${lastObserved}.`,
);
}
function cacheBustedDocumentUrl(url: string, token: string) {
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}openpress_render_wait=${encodeURIComponent(token)}`;
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function loadDeploymentInfo(): Promise {
if (typeof window !== "undefined" && isLocalWorkspaceHost(window.location.hostname)) {
const localInfo = await loadDeploymentInfoFrom("/__openpress/status");
if (localInfo) return localInfo;
}
return (await loadDeploymentInfoFrom("/openpress/deploy.json")) ?? offlineDeploymentInfo;
}
async function loadDeploymentInfoFrom(path: string): Promise {
try {
const response = await fetch(path, { cache: "no-store" });
if (!response.ok) {
return null;
}
const config = (await response.json()) as DeployConfig;
return deploymentConfigToInfo(config);
} catch {
return null;
}
}
function deploymentConfigToInfo(config: DeployConfig): DeploymentInfo {
const configured = config.deploy_configured !== false;
return {
online: configured && Boolean(config.deployed_at || config.public_url),
deployedAt: config.deployed_at,
pdf: typeof config.pdf === "string" ? config.pdf : undefined,
publicUrl: typeof config.public_url === "string" ? config.public_url : undefined,
dirty: config.dirty === true,
configured,
adapter: typeof config.deploy_adapter === "string" ? config.deploy_adapter : undefined,
source: typeof config.deploy_source === "string" ? config.deploy_source : undefined,
projectName: typeof config.deploy_project_name === "string" ? config.deploy_project_name : undefined,
setupMessage: typeof config.deploy_setup_message === "string" ? config.deploy_setup_message : undefined,
};
}