Optimize admin performance and split render surface

This commit is contained in:
2026-04-11 13:59:58 -07:00
parent 4c6982bf68
commit e9aa82e1e1
28 changed files with 3396 additions and 2526 deletions
+1
View File
@@ -14,6 +14,7 @@
"@goodgrief/effects": "file:../../packages/effects",
"@goodgrief/render-engine": "file:../../packages/render-engine",
"@goodgrief/shared-types": "file:../../packages/shared-types",
"@tanstack/react-virtual": "^3.13.23",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
+148 -71
View File
@@ -49,6 +49,8 @@ import {
type ProgramOutputState
} from "../features/live/output-sync";
import { SceneViewport } from "../features/live/SceneViewport";
import { VirtualizedGrid } from "../features/live/VirtualizedGrid";
import { VirtualizedList } from "../features/live/VirtualizedList";
import {
adminReducer,
createCueDraft,
@@ -477,6 +479,12 @@ export const App = () => {
const deferredMediaSearch = useDeferredValue(mediaSearch);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const mediaSearchInputRef = useRef<HTMLInputElement | null>(null);
const liveSnapshotRef = useRef({
programRevision: null as string | null,
loadedLibraryRevision: null as string | null,
pendingCount: -1,
approvedCount: -1
});
const publishProgramOutput = (
presentation: SurfacePresentation | null,
@@ -490,7 +498,20 @@ export const App = () => {
}
};
const hydrate = (payload: RepositoryState, initialize: boolean) => {
const hydrate = (
payload: RepositoryState,
initialize: boolean,
revisions?: { libraryRevision?: string; programRevision?: string }
) => {
if (revisions?.programRevision) {
liveSnapshotRef.current.programRevision = revisions.programRevision;
}
if (revisions?.libraryRevision) {
liveSnapshotRef.current.loadedLibraryRevision = revisions.libraryRevision;
}
liveSnapshotRef.current.pendingCount = getPendingModerationAssets(payload.photoAssets, payload.submissions).length;
liveSnapshotRef.current.approvedCount = getApprovedAssets(payload).length;
startTransition(() => {
if (initialize) {
const initial = createInitialLiveState(payload);
@@ -519,23 +540,15 @@ export const App = () => {
const refreshBootstrap = async (initialize = false) => {
const payload = await loadAdminBootstrap();
hydrate(payload, initialize);
};
const refreshLiveState = async () => {
const payload = await loadAdminLive();
startTransition(() => {
dispatchAdmin({
type: "liveLoaded",
cues: payload.cues,
pendingCount: payload.pendingCount,
approvedCount: payload.approvedCount
});
hydrate(payload, initialize, {
libraryRevision: payload.libraryRevision,
programRevision: payload.programRevision
});
};
const refreshLibraryState = async () => {
const payload = await loadAdminLibrary();
liveSnapshotRef.current.loadedLibraryRevision = payload.revision;
startTransition(() => {
dispatchAdmin({
type: "libraryLoaded",
@@ -546,6 +559,39 @@ export const App = () => {
});
};
const refreshLiveState = async (allowLibraryRefresh: boolean) => {
const payload = await loadAdminLive();
const currentSnapshot = liveSnapshotRef.current;
const shouldDispatchLive =
payload.programRevision !== currentSnapshot.programRevision ||
payload.pendingCount !== currentSnapshot.pendingCount ||
payload.approvedCount !== currentSnapshot.approvedCount;
const shouldRefreshLibrary =
allowLibraryRefresh && payload.libraryRevision !== currentSnapshot.loadedLibraryRevision;
liveSnapshotRef.current = {
programRevision: payload.programRevision,
loadedLibraryRevision: currentSnapshot.loadedLibraryRevision,
pendingCount: payload.pendingCount,
approvedCount: payload.approvedCount
};
if (shouldDispatchLive) {
startTransition(() => {
dispatchAdmin({
type: "liveLoaded",
cues: payload.cues,
pendingCount: payload.pendingCount,
approvedCount: payload.approvedCount
});
});
}
if (shouldRefreshLibrary) {
await refreshLibraryState();
}
};
useEffect(() => {
void refreshBootstrap(true).catch((error) => {
setStatus(error instanceof Error ? error.message : "Could not load state.");
@@ -557,15 +603,11 @@ export const App = () => {
return;
}
const shouldWatchLibrary = workspaceMode === "build" || showUtilityTab === "media" || showUtilityTab === "moderation";
const interval = window.setInterval(() => {
void refreshLiveState().catch(() => {
void refreshLiveState(shouldWatchLibrary).catch(() => {
setStatus("Refresh failed. Local state may be stale.");
});
if (workspaceMode === "build" || showUtilityTab === "media" || showUtilityTab === "moderation") {
void refreshLibraryState().catch(() => {
setStatus("Library refresh failed. Media state may be stale.");
});
}
}, 4000);
return () => window.clearInterval(interval);
@@ -861,7 +903,10 @@ export const App = () => {
const handleRescanLibrary = async () => {
try {
const payload = await rescanLibrary();
hydrate(payload, false);
hydrate(payload, false, {
libraryRevision: payload.libraryRevision,
programRevision: payload.programRevision
});
setStatus(
`Library rescanned. ${getApprovedAssets(payload).length} approved assets ready / ${payload.collections.find((collection) => collection.id === "collection-curated-library")?.assetIds.length ?? 0} curated.`
);
@@ -1148,7 +1193,7 @@ export const App = () => {
const savedCue = cueDraft.id ? await updateCue(cueId, payload) : await createCue(payload);
dispatchAdmin({ type: "cueUpsertSucceeded", cue: savedCue, scene: selectedScene });
syncPreviewFromCue(savedCue);
await refreshLiveState();
await refreshLiveState(false);
setStatus(cueDraft.id ? `Cue updated: ${savedCue.notes ?? savedCue.id}` : `Cue created: ${savedCue.notes ?? savedCue.id}`);
} catch (error) {
dispatchAdmin({ type: "cueMutationFinished" });
@@ -1179,7 +1224,7 @@ export const App = () => {
const createdCue = await createCue(payload);
dispatchAdmin({ type: "cueUpsertSucceeded", cue: createdCue, scene: selectedScene });
syncPreviewFromCue(createdCue);
await refreshLiveState();
await refreshLiveState(false);
setStatus(`Cue inserted: ${createdCue.notes ?? createdCue.id}`);
} catch (error) {
dispatchAdmin({ type: "cueMutationFinished" });
@@ -1208,7 +1253,7 @@ export const App = () => {
const duplicatedCue = await createCue(payload);
dispatchAdmin({ type: "cueUpsertSucceeded", cue: duplicatedCue, scene: selectedScene });
syncPreviewFromCue(duplicatedCue);
await refreshLiveState();
await refreshLiveState(false);
setStatus(`Cue duplicated: ${duplicatedCue.notes ?? duplicatedCue.id}`);
} catch (error) {
dispatchAdmin({ type: "cueMutationFinished" });
@@ -1232,7 +1277,7 @@ export const App = () => {
if (nextCue) {
syncPreviewFromCue(nextCue);
}
await refreshLiveState();
await refreshLiveState(false);
setStatus("Cue deleted.");
} catch (error) {
dispatchAdmin({ type: "cueMutationFinished" });
@@ -1260,7 +1305,7 @@ export const App = () => {
setStatus(`Cue moved ${direction}.`);
} catch (error) {
dispatchAdmin({ type: "cueMoveFailed" });
void refreshLiveState();
void refreshLiveState(false);
setStatus(error instanceof Error ? error.message : `Could not move cue ${direction}.`);
}
};
@@ -1474,7 +1519,7 @@ export const App = () => {
const createdCue = await createCue(payload);
dispatchAdmin({ type: "cueUpsertSucceeded", cue: createdCue, scene: selectedScene });
syncPreviewFromCue(createdCue);
await refreshLiveState();
await refreshLiveState(false);
setStatus(`New cue created: ${createdCue.notes ?? createdCue.id}`);
} catch (error) {
dispatchAdmin({ type: "cueMutationFinished" });
@@ -1576,38 +1621,57 @@ export const App = () => {
return cue.assetIds?.length && cue.assetIds.length > 0 ? cue.assetIds.length : findCollectionAssets(state, cue.collectionId).length;
};
const renderCueRows = (variant: "show" | "build") => (
<div className={`cue-list cue-list--${variant}`}>
{cueStack.map((cue) => {
const definition = state ? findSceneById(state, cue.sceneDefinitionId) : undefined;
const preset = definition
? matchPresetForScene(definition, availablePresets, cue.effectPresetId)
: undefined;
const cueAssetCount = getCueAssetCount(cue);
const renderCueRows = (variant: "show" | "build") => {
const renderCueRow = (cue: Cue) => {
const definition = state ? findSceneById(state, cue.sceneDefinitionId) : undefined;
const preset = definition
? matchPresetForScene(definition, availablePresets, cue.effectPresetId)
: undefined;
const cueAssetCount = getCueAssetCount(cue);
return (
<button
key={cue.id}
className={`cue-row ${cue.id === cueState.previewCueId ? "cue-row--armed" : ""} ${cue.id === cueState.currentCueId ? "cue-row--live" : ""}`}
onClick={() => syncPreviewFromCue(cue)}
title={`${cue.notes ?? cue.id} · ${definition?.name ?? "Unknown scene"} · ${preset?.name ?? "Default mode"} · ${cue.triggerMode} · ${cue.transitionIn.style} · ${cueAssetCount} assets`}
>
<span>{cue.orderIndex}</span>
<div className="cue-row__body">
<strong>{cue.notes ?? cue.id}</strong>
<small>
{definition?.name ?? "Unknown scene"} / {preset?.name ?? "Default mode"}
</small>
</div>
return (
<button
key={cue.id}
className={`cue-row ${cue.id === cueState.previewCueId ? "cue-row--armed" : ""} ${cue.id === cueState.currentCueId ? "cue-row--live" : ""}`}
onClick={() => syncPreviewFromCue(cue)}
title={`${cue.notes ?? cue.id} · ${definition?.name ?? "Unknown scene"} · ${preset?.name ?? "Default mode"} · ${cue.triggerMode} · ${cue.transitionIn.style} · ${cueAssetCount} assets`}
>
<span>{cue.orderIndex}</span>
<div className="cue-row__body">
<strong>{cue.notes ?? cue.id}</strong>
<small>
{cue.triggerMode} / {cue.transitionIn.style} / {cueAssetCount}
{definition?.name ?? "Unknown scene"} / {preset?.name ?? "Default mode"}
</small>
</button>
);
})}
{cueStack.length === 0 ? <p className="empty-state">No cues have been created yet.</p> : null}
</div>
);
</div>
<small>
{cue.triggerMode} / {cue.transitionIn.style} / {cueAssetCount}
</small>
</button>
);
};
if (variant === "build") {
return (
<div className={`cue-list cue-list--${variant}`}>
{cueStack.map((cue) => renderCueRow(cue))}
{cueStack.length === 0 ? <p className="empty-state">No cues have been created yet.</p> : null}
</div>
);
}
return (
<VirtualizedList
items={cueStack}
className={`cue-list cue-list--${variant}`}
estimateSize={58}
overscan={8}
gap={6}
itemKey={(cue) => cue.id}
empty={<p className="empty-state">No cues have been created yet.</p>}
renderItem={(cue) => renderCueRow(cue)}
/>
);
};
const renderSceneModeChooser = (variant: "show" | "build") => (
<div className={`browser-stack browser-stack--${variant}`}>
@@ -1812,7 +1876,7 @@ export const App = () => {
onClick={() => focusMetadataAsset(asset)}
>
<div className="selected-asset__thumb">
{asset.thumbKey ? <img src={asset.thumbKey} alt="" /> : <div className="asset-card__placeholder" />}
{asset.thumbKey ? <img src={asset.thumbKey} alt="" loading="lazy" decoding="async" /> : <div className="asset-card__placeholder" />}
</div>
<div className="selected-asset__body">
<div className="asset-meta">
@@ -1874,7 +1938,7 @@ export const App = () => {
<div className="metadata-inspector__preview">
<div className="metadata-inspector__thumb">
{metadataAsset.previewKey || metadataAsset.thumbKey ? (
<img src={metadataAsset.previewKey ?? metadataAsset.thumbKey} alt="" />
<img src={metadataAsset.previewKey ?? metadataAsset.thumbKey} alt="" loading="lazy" decoding="async" />
) : (
<div className="asset-card__placeholder" />
)}
@@ -1972,8 +2036,16 @@ export const App = () => {
);
const renderApprovedBank = (variant: "show" | "build") => (
<div className={`bank-list bank-list--${variant}`}>
{filteredApprovedAssets.map((asset) => {
<VirtualizedGrid
items={filteredApprovedAssets}
className={`bank-list bank-list--${variant}`}
minColumnWidth={variant === "build" ? 92 : 100}
maxColumnWidth={variant === "build" ? 112 : 118}
gap={8}
overscan={4}
itemKey={(asset) => asset.id}
empty={<p className="empty-state">Approved images will appear here after import or moderation.</p>}
renderItem={(asset) => {
const submission = submissionMap.get(asset.submissionId);
const assetLabel = getAssetPrimaryLabel(asset, submission);
const assetDetail = getAssetSecondaryLabel(submission);
@@ -1992,7 +2064,7 @@ export const App = () => {
title={`${assetLabel}\n${submission?.caption ?? submission?.promptAnswer ?? ""}`}
>
<div className="bank-item__thumb">
{asset.thumbKey ? <img src={asset.thumbKey} alt="" /> : <div className="asset-card__placeholder" />}
{asset.thumbKey ? <img src={asset.thumbKey} alt="" loading="lazy" decoding="async" /> : <div className="asset-card__placeholder" />}
</div>
<div className="bank-item__overlay">
<div className="bank-item__flags">
@@ -2020,25 +2092,29 @@ export const App = () => {
</button>
</article>
);
})}
{filteredApprovedAssets.length === 0 ? <p className="empty-state">Approved images will appear here after import or moderation.</p> : null}
</div>
}}
/>
);
const renderPendingList = (variant: "show" | "build") => (
<div className={`asset-list asset-list--${variant}`}>
{filteredPendingAssets.length === 0 ? <p className="empty-state">No pending submissions right now.</p> : null}
{filteredPendingAssets.map((asset) => {
<VirtualizedList
items={filteredPendingAssets}
className={`asset-list asset-list--${variant}`}
estimateSize={146}
overscan={6}
gap={8}
itemKey={(asset) => asset.id}
empty={<p className="empty-state">No pending submissions right now.</p>}
renderItem={(asset) => {
const submission = submissionMap.get(asset.submissionId);
const assetLabel = getAssetPrimaryLabel(asset, submission);
return (
<article
key={asset.id}
className={`asset-card ${metadataAssetId === asset.id ? "asset-card--editing" : ""}`}
onClick={() => focusMetadataAsset(asset)}
>
<div className="asset-card__media">
{asset.thumbKey ? <img src={asset.thumbKey} alt="" /> : <div className="asset-card__placeholder" />}
{asset.thumbKey ? <img src={asset.thumbKey} alt="" loading="lazy" decoding="async" /> : <div className="asset-card__placeholder" />}
</div>
<div className="asset-card__body">
<div className="asset-meta">
@@ -2065,8 +2141,8 @@ export const App = () => {
</div>
</article>
);
})}
</div>
}}
/>
);
const renderUploadTools = () => (
@@ -2322,7 +2398,8 @@ export const App = () => {
blackout={cueState.blackout}
transition={programOutputState?.transition ?? null}
activationKey={programActivationKey}
qualityProfile="program"
qualityProfile="program-monitor"
busy={workspaceMode === "build"}
/>
</div>
</div>
+2 -6
View File
@@ -20,7 +20,7 @@ const enterFullscreen = async () => {
export const ProgramOutputApp = () => {
const [outputState, setOutputState] = useState<ProgramOutputState | null>(() => readProgramOutputState());
const [overlayVisible, setOverlayVisible] = useState(true);
const [overlayVisible, setOverlayVisible] = useState(false);
const [overlayDismissed, setOverlayDismissed] = useState(false);
const hideTimeoutRef = useRef<number | null>(null);
@@ -74,11 +74,6 @@ export const ProgramOutputApp = () => {
[]
);
useEffect(() => {
setOverlayDismissed(false);
showOverlay(2800);
}, [outputState?.updatedAt, showOverlay]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "f") {
@@ -132,6 +127,7 @@ export const ProgramOutputApp = () => {
blackout={outputState?.blackout ?? false}
transition={transition}
activationKey={createPresentationStructureHash(outputState?.presentation ?? null)}
qualityProfile="program-output"
/>
<div className={`output-overlay ${overlayVisible ? "output-overlay--visible" : ""}`}>
<div>
+60 -13
View File
@@ -214,7 +214,6 @@ select:focus-visible {
background: var(--panel);
border: 1px solid var(--panel-border);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.show-layout {
@@ -418,7 +417,6 @@ select:focus-visible {
}
.utility-tabpanel,
.cue-list,
.browser-stack,
.build-sidebar-scroll,
.build-media-browser,
@@ -426,11 +424,17 @@ select:focus-visible {
.show-media-pane,
.show-moderation-pane {
min-height: 0;
overflow: auto;
overflow: hidden;
scrollbar-gutter: stable;
overscroll-behavior: contain;
}
.utility-tabpanel,
.build-sidebar-scroll,
.build-media-inspector {
overflow: auto;
}
.utility-tabpanel,
.build-sidebar-scroll,
.show-media-pane,
@@ -439,6 +443,14 @@ select:focus-visible {
gap: var(--space-3);
}
.show-media-pane {
grid-template-rows: auto auto minmax(0, 1fr);
}
.show-moderation-pane {
grid-template-rows: auto minmax(0, 1fr);
}
.browser-stack {
display: grid;
gap: var(--space-3);
@@ -846,6 +858,10 @@ select:focus-visible {
gap: var(--space-2);
}
.build-media-stack {
grid-template-rows: auto minmax(0, 1fr);
}
.build-media-workarea {
grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.92fr);
}
@@ -855,23 +871,45 @@ select:focus-visible {
min-height: 0;
}
.bank-list {
.build-media-browser {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-2);
}
.bank-list--build {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
.build-media-browser > .bank-list,
.build-media-browser > .asset-list {
min-height: 0;
}
.build-media-browser > .bank-list:only-child,
.build-media-browser > .asset-list:only-child {
height: 100%;
}
.bank-list,
.asset-list,
.cue-list {
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
overscroll-behavior: contain;
}
.bank-list {
display: block;
}
.bank-list--build,
.bank-list--show {
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
padding-right: 2px;
}
.bank-item {
display: grid;
gap: 0;
padding: 0;
width: 100%;
aspect-ratio: 0.86;
position: relative;
overflow: hidden;
@@ -972,8 +1010,7 @@ select:focus-visible {
}
.asset-list {
display: grid;
gap: var(--space-2);
display: block;
}
.asset-card {
@@ -1038,8 +1075,7 @@ select:focus-visible {
}
.cue-list {
display: grid;
gap: var(--space-2);
display: block;
}
.cue-row {
@@ -1047,6 +1083,7 @@ select:focus-visible {
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: start;
gap: var(--space-2);
width: 100%;
text-align: left;
padding: 6px 7px;
}
@@ -1102,13 +1139,23 @@ select:focus-visible {
padding-right: 2px;
}
.build-sidebar-panel .cue-list {
overflow: visible;
}
.show-cue-panel .cue-list {
height: 100%;
}
.show-cue-panel .cue-list {
max-height: 100%;
}
.show-media-pane .bank-list,
.show-moderation-pane .asset-list {
max-height: 100%;
.show-moderation-pane .asset-list,
.build-media-browser .bank-list,
.build-media-browser .asset-list {
height: 100%;
}
.danger {
+1 -2
View File
@@ -55,10 +55,9 @@ body.mode-output .output-overlay {
align-items: end;
padding: 16px 18px;
border-radius: 18px;
background: rgba(8, 12, 16, 0.78);
background: rgba(8, 12, 16, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #f5f2ea;
backdrop-filter: blur(18px);
opacity: 0;
transform: translateY(12px);
pointer-events: none;
+41 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { memo, useEffect, useRef } from "react";
import type {
RenderSurface as RenderSurfaceType,
SurfacePresentation,
@@ -21,12 +21,12 @@ const defaultTransition: CueTransition = {
durationMs: 0
};
export const SceneViewport = ({
const SceneViewportInner = ({
presentation,
blackout = false,
transition,
activationKey,
qualityProfile = "program",
qualityProfile = "program-monitor",
busy = false
}: SceneViewportProps) => {
const frameRef = useRef<HTMLDivElement | null>(null);
@@ -55,14 +55,28 @@ export const SceneViewport = ({
let cancelled = false;
let observer: ResizeObserver | null = null;
let intersectionObserver: IntersectionObserver | null = null;
void import("@goodgrief/render-engine").then(({ RenderSurface, defaultScenePlugins }) => {
const syncPausedState = (intersects = true) => {
const hidden = typeof document !== "undefined" && document.visibilityState === "hidden";
surfaceRef.current?.setPaused(hidden || !intersects);
};
const handleVisibility = () => {
if (!frame.isConnected) {
return;
}
const rect = frame.getBoundingClientRect();
const visible = rect.width > 0 && rect.height > 0;
syncPausedState(visible);
};
void import("@goodgrief/render-engine").then(({ RenderSurface }) => {
if (cancelled) {
return;
}
const surface = new RenderSurface(canvas);
surface.registerMany(defaultScenePlugins);
surface.setQualityProfile(qualityProfileRef.current);
surface.setBusy(busyRef.current);
surface.setBlackout(blackoutRef.current, null, true);
@@ -77,8 +91,22 @@ export const SceneViewport = ({
observer = new ResizeObserver(() => resize());
observer.observe(frame);
if (typeof IntersectionObserver !== "undefined") {
intersectionObserver = new IntersectionObserver((entries) => {
const next = entries[0]?.isIntersecting ?? true;
syncPausedState(next);
}, { threshold: 0.05 });
intersectionObserver.observe(frame);
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", handleVisibility);
}
handleVisibility();
const initialPresentation = presentationRef.current;
if (initialPresentation) {
surface.preloadPresentation(initialPresentation);
void surface.activate(initialPresentation, defaultTransition, activationRef.current);
}
});
@@ -86,6 +114,10 @@ export const SceneViewport = ({
return () => {
cancelled = true;
observer?.disconnect();
intersectionObserver?.disconnect();
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", handleVisibility);
}
surfaceRef.current?.dispose();
surfaceRef.current = null;
};
@@ -109,10 +141,12 @@ export const SceneViewport = ({
return;
}
surface.preloadPresentation(presentationRef.current);
void surface.activate(presentationRef.current, transitionRef.current ?? defaultTransition, activationRef.current);
}, [activationKey]);
useEffect(() => {
surfaceRef.current?.preloadPresentation(presentation);
surfaceRef.current?.updatePresentation(presentation, activationKey);
}, [activationKey, presentation]);
@@ -122,3 +156,5 @@ export const SceneViewport = ({
</div>
);
};
export const SceneViewport = memo(SceneViewportInner);
@@ -0,0 +1,104 @@
import { useEffect, useMemo, useRef, useState, type Key, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
interface VirtualizedGridProps<T> {
items: T[];
className?: string;
minColumnWidth: number;
maxColumnWidth?: number;
gap?: number;
overscan?: number;
itemAspectRatio?: number;
empty?: ReactNode;
itemKey?: (item: T, index: number) => Key;
renderItem: (item: T, index: number) => ReactNode;
}
const fallbackWidth = 720;
export const VirtualizedGrid = <T,>({
items,
className,
minColumnWidth,
maxColumnWidth,
gap = 8,
overscan = 3,
itemAspectRatio = 0.86,
empty = null,
itemKey,
renderItem
}: VirtualizedGridProps<T>) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [width, setWidth] = useState(fallbackWidth);
useEffect(() => {
const element = scrollRef.current;
if (!element || typeof ResizeObserver === "undefined") {
return;
}
const measure = () => setWidth(Math.max(element.clientWidth, minColumnWidth));
measure();
const observer = new ResizeObserver(() => measure());
observer.observe(element);
return () => observer.disconnect();
}, [minColumnWidth]);
const columns = Math.max(1, Math.floor((width + gap) / (minColumnWidth + gap)));
const rowCount = Math.ceil(items.length / columns);
const computedCellWidth = Math.max(minColumnWidth, (width - gap * Math.max(columns - 1, 0)) / columns);
const cellWidth = maxColumnWidth ? Math.min(computedCellWidth, maxColumnWidth) : computedCellWidth;
const rowHeight = Math.max(1, cellWidth / itemAspectRatio);
const rows = useMemo(() => {
const grouped: T[][] = [];
for (let index = 0; index < items.length; index += columns) {
grouped.push(items.slice(index, index + columns));
}
return grouped;
}, [columns, items]);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollRef.current,
estimateSize: () => rowHeight + gap,
overscan
});
if (items.length === 0) {
return <div className={className}>{empty}</div>;
}
return (
<div ref={scrollRef} className={className}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index] ?? [];
const startIndex = virtualRow.index * columns;
return (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
display: "grid",
gap: `${gap}px`,
justifyContent: "start",
gridTemplateColumns: `repeat(${columns}, ${cellWidth}px)`,
transform: `translateY(${virtualRow.start}px)`
}}
>
{row.map((item, offset) => (
<div key={itemKey ? itemKey(item, startIndex + offset) : `${virtualRow.index}-${offset}`}>
{renderItem(item, startIndex + offset)}
</div>
))}
</div>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,61 @@
import { useRef, type Key, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
interface VirtualizedListProps<T> {
items: T[];
className?: string;
estimateSize: number;
overscan?: number;
gap?: number;
empty?: ReactNode;
itemKey?: (item: T, index: number) => Key;
renderItem: (item: T, index: number) => ReactNode;
}
export const VirtualizedList = <T,>({
items,
className,
estimateSize,
overscan = 6,
gap = 0,
empty = null,
itemKey,
renderItem
}: VirtualizedListProps<T>) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => estimateSize + gap,
overscan
});
if (items.length === 0) {
return <div className={className}>{empty}</div>;
}
return (
<div ref={scrollRef} className={className}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index];
return (
<div
key={itemKey ? itemKey(item, virtualItem.index) : virtualItem.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
paddingBottom: `${gap}px`,
transform: `translateY(${virtualItem.start}px)`
}}
>
{renderItem(item, virtualItem.index)}
</div>
);
})}
</div>
</div>
);
};
+12 -4
View File
@@ -11,16 +11,24 @@ import type {
SubmissionUpdatePayload
} from "@goodgrief/shared-types";
export interface AdminBootstrapPayload extends RepositoryState {
libraryRevision: string;
programRevision: string;
}
export interface AdminLivePayload {
cues: Cue[];
pendingCount: number;
approvedCount: number;
libraryRevision: string;
programRevision: string;
}
export interface AdminLibraryPayload {
photoAssets: PhotoAsset[];
submissions: Submission[];
collections: Collection[];
revision: string;
}
const postVoid = async (url: string, body?: unknown) => {
@@ -56,8 +64,8 @@ const requestJson = async <T>(url: string, init?: RequestInit) => {
return (await response.json()) as T;
};
export const loadAdminBootstrap = async (): Promise<RepositoryState> =>
requestJson<RepositoryState>("/api/admin/bootstrap");
export const loadAdminBootstrap = async (): Promise<AdminBootstrapPayload> =>
requestJson<AdminBootstrapPayload>("/api/admin/bootstrap");
export const loadAdminLive = async (): Promise<AdminLivePayload> =>
requestJson<AdminLivePayload>("/api/admin/live");
@@ -65,8 +73,8 @@ export const loadAdminLive = async (): Promise<AdminLivePayload> =>
export const loadAdminLibrary = async (): Promise<AdminLibraryPayload> =>
requestJson<AdminLibraryPayload>("/api/admin/library");
export const rescanLibrary = async (): Promise<RepositoryState> =>
requestJson<RepositoryState>("/api/library/rescan", {
export const rescanLibrary = async (): Promise<AdminBootstrapPayload> =>
requestJson<AdminBootstrapPayload>("/api/library/rescan", {
method: "POST"
});
+39
View File
@@ -1,3 +1,4 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
@@ -11,5 +12,43 @@ export default defineConfig({
"/api": apiProxyTarget,
"/uploads": apiProxyTarget
}
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (
id.includes("packages/render-engine/src/render-surface") ||
id.includes("packages/render-engine/src/index") ||
id.includes("packages/render-engine/src/types") ||
id.includes("packages/render-engine/src/scene-loader")
) {
return "render-core";
}
if (id.includes("packages/render-engine/src/scene-helpers")) {
return "render-scene-support";
}
if (id.includes("packages/render-engine/src/text-overlay")) {
return "render-text";
}
if (id.includes("packages/render-engine/src/scenes/")) {
return `scene-${path.basename(id, path.extname(id))}`;
}
if (id.includes("node_modules/react") || id.includes("node_modules/react-dom")) {
return "react-vendor";
}
if (id.includes("node_modules/@tanstack/react-virtual")) {
return "admin-virtual";
}
return undefined;
}
}
}
}
});