Optimize admin performance and split render surface
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user