From bd2087ba5f609f787e69d97be3cbe79c98e82761 Mon Sep 17 00:00:00 2001 From: vance Date: Fri, 10 Apr 2026 15:48:32 -0700 Subject: [PATCH] Stabilize admin state and submission metadata --- apps/admin/package.json | 6 +- apps/admin/src/app/App.tsx | 853 ++++++++-------- apps/admin/src/app/admin-state.test.ts | 339 +++++++ apps/admin/src/app/admin-state.ts | 931 ++++++++++++++++++ apps/admin/src/features/live/api.ts | 11 +- .../submission/src/features/submission/api.ts | 7 +- .../features/submission/useSubmissionForm.ts | 9 +- .../submission/src/routes/SubmissionRoute.tsx | 20 +- package-lock.json | 371 ++++++- packages/shared-types/src/cue-order.ts | 38 + packages/shared-types/src/entities.ts | 7 + packages/shared-types/src/index.ts | 1 + services/api/src/server.ts | 6 +- services/api/src/state-store.ts | 35 +- 14 files changed, 2171 insertions(+), 463 deletions(-) create mode 100644 apps/admin/src/app/admin-state.test.ts create mode 100644 apps/admin/src/app/admin-state.ts create mode 100644 packages/shared-types/src/cue-order.ts diff --git a/apps/admin/package.json b/apps/admin/package.json index b35fbc3..d6e3db8 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "check": "tsc --noEmit" + "check": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@goodgrief/cue-engine": "file:../../packages/cue-engine", @@ -21,6 +22,7 @@ "@types/react-dom": "^19.1.3", "@vitejs/plugin-react": "^4.4.1", "typescript": "^5.8.3", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^4.1.4" } } diff --git a/apps/admin/src/app/App.tsx b/apps/admin/src/app/App.tsx index 5e85a56..4451590 100644 --- a/apps/admin/src/app/App.tsx +++ b/apps/admin/src/app/App.tsx @@ -1,10 +1,7 @@ -import { startTransition, useDeferredValue, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { startTransition, useDeferredValue, useEffect, useMemo, useReducer, useRef, type CSSProperties } from "react"; import { armCue, - createCueRuntimeState, - skipToCue, - takeCue, - triggerSafeScene + createCueRuntimeState } from "@goodgrief/cue-engine"; import type { SurfacePresentation } from "@goodgrief/render-engine"; import type { @@ -25,8 +22,7 @@ import type { } from "@goodgrief/shared-types"; import { getSceneParamValue, - mergeSceneParams, - setSceneParamValue + mergeSceneParams } from "@goodgrief/shared-types"; import { effectPresetLibrary } from "@goodgrief/effects"; import { @@ -53,52 +49,24 @@ import { type ProgramOutputState } from "../features/live/output-sync"; import { SceneViewport } from "../features/live/SceneViewport"; +import { + adminReducer, + createCueDraft, + defaultCueTransition, + filterAvailableAssetIds, + getApprovedAssets, + getPendingModerationAssets, + initialAdminUiState, + sortCues, + type AdminFieldUpdater, + type AdminUiState, + type BuildLibraryTab, + type MetadataDraftState, + type SceneBrowserFilter, + type ShowUtilityTab +} from "./admin-state"; import "./app.css"; -interface CueDraftState { - id: string | null; - notes: string; - triggerMode: Cue["triggerMode"]; - transitionInStyle: CueTransition["style"]; - transitionInDurationMs: number; - transitionOutStyle: CueTransition["style"]; - transitionOutDurationMs: number; -} - -interface MetadataDraftState { - displayName: string; - caption: string; - promptAnswer: string; - notes: string; -} - -type SceneBrowserFilter = "all" | SceneDefinition["sceneFamily"]; -type WorkspaceMode = "show" | "build"; -type ShowUtilityTab = "controls" | "notes" | "media" | "moderation"; -type BuildLibraryTab = "approved" | "pending" | "upload"; - -const defaultCueTransition: CueTransition = { - style: "dissolve", - durationMs: 4000 -}; - -const createCueDraft = (cue?: Cue | null, scene?: SceneDefinition): CueDraftState => ({ - id: cue?.id ?? null, - notes: cue?.notes ?? scene?.name ?? "", - triggerMode: cue?.triggerMode ?? "manual", - transitionInStyle: cue?.transitionIn.style ?? defaultCueTransition.style, - transitionInDurationMs: cue?.transitionIn.durationMs ?? defaultCueTransition.durationMs, - transitionOutStyle: cue?.transitionOut.style ?? "mist_reveal", - transitionOutDurationMs: cue?.transitionOut.durationMs ?? 4000 -}); - -const createMetadataDraft = (submission?: Submission | null): MetadataDraftState => ({ - displayName: submission?.displayName ?? "", - caption: submission?.caption ?? "", - promptAnswer: submission?.promptAnswer ?? "", - notes: submission?.notes ?? "" -}); - const scenePaletteMap: Record = { "scene-witness-float": { accent: "#ffcb8a", accentSoft: "#8cc8ff", ink: "#151218" }, "scene-portal-frame": { accent: "#f2c2a2", accentSoft: "#9db8ff", ink: "#141821" }, @@ -130,23 +98,6 @@ const formatParamLabel = (path: string) => { return key.replace(/([A-Z])/g, " $1").replace(/^./, (char) => char.toUpperCase()); }; -const moveItem = (items: T[], fromIndex: number, toIndex: number) => { - if ( - fromIndex < 0 || - toIndex < 0 || - fromIndex >= items.length || - toIndex >= items.length || - fromIndex === toIndex - ) { - return items; - } - - const next = [...items]; - const [item] = next.splice(fromIndex, 1); - next.splice(toIndex, 0, item); - return next; -}; - const sharedLookControlPaths = [ "scenicTreatment.hue", "scenicTreatment.saturation", @@ -199,27 +150,6 @@ const matchPresetForScene = ( ); }; -const getApprovedAssets = (payload: RepositoryState) => - payload.photoAssets.filter((asset) => asset.moderationStatus === "approved"); - -const getPendingModerationAssets = (photoAssets: PhotoAsset[], submissions: Submission[]) => - photoAssets.filter((asset) => { - if (asset.moderationStatus !== "pending") { - return false; - } - - const submission = submissions.find((entry) => entry.id === asset.submissionId); - return submission?.source !== "admin_upload"; - }); - -const getPendingModerationCount = (photoAssets: PhotoAsset[], submissions: Submission[]) => - getPendingModerationAssets(photoAssets, submissions).length; - -const filterAvailableAssetIds = (payload: RepositoryState, assetIds: string[]) => { - const available = new Set(getApprovedAssets(payload).map((asset) => asset.id)); - return assetIds.filter((assetId) => available.has(assetId)); -}; - const getSubmissionByAsset = (payload: RepositoryState, asset: PhotoAsset) => payload.submissions.find((submission) => submission.id === asset.submissionId); @@ -229,7 +159,7 @@ const getRenderableSubmissionTextFragments = (submission: Submission | undefined .filter((value): value is string => Boolean(value)); const getSubmissionTextFragments = (submission: Submission | undefined) => - [submission?.displayName, submission?.caption, submission?.promptAnswer] + [submission?.contributorName, submission?.lovedOneName, submission?.displayName, submission?.caption, submission?.promptAnswer] .map((value) => value?.trim()) .filter((value): value is string => Boolean(value)); @@ -276,6 +206,8 @@ const getAssetSearchText = (asset: PhotoAsset, submission: Submission | undefine [ asset.id, asset.orientation, + submission?.contributorName, + submission?.lovedOneName, submission?.displayName, submission?.caption, submission?.promptAnswer, @@ -287,10 +219,19 @@ const getAssetSearchText = (asset: PhotoAsset, submission: Submission | undefine .toLowerCase(); const getAssetPrimaryLabel = (asset: PhotoAsset, submission: Submission | undefined) => - submission?.caption?.trim() || submission?.promptAnswer?.trim() || submission?.displayName?.trim() || asset.id; + submission?.caption?.trim() || + submission?.promptAnswer?.trim() || + submission?.lovedOneName?.trim() || + submission?.displayName?.trim() || + asset.id; const getAssetSecondaryLabel = (submission: Submission | undefined) => - submission?.caption?.trim() || submission?.promptAnswer?.trim() || submission?.notes?.trim() || ""; + submission?.lovedOneName?.trim() || + submission?.contributorName?.trim() || + submission?.caption?.trim() || + submission?.promptAnswer?.trim() || + submission?.notes?.trim() || + ""; const getDefaultAssetIds = (payload: RepositoryState) => { const approvedIds = new Set(getApprovedAssets(payload).map((asset) => asset.id)); @@ -490,81 +431,91 @@ const createInitialLiveState = (payload: RepositoryState) => { }; export const App = () => { - const [state, setState] = useState(null); - const [workspaceMode, setWorkspaceMode] = useState("show"); - const [showUtilityTab, setShowUtilityTab] = useState("controls"); - const [buildLibraryTab, setBuildLibraryTab] = useState("approved"); - const [cueState, setCueState] = useState(createCueRuntimeState([])); - const [programOutputState, setProgramOutputState] = useState(null); - const [selectedSceneId, setSelectedSceneId] = useState(""); - const [sceneBrowserFilter, setSceneBrowserFilter] = useState("all"); - const [selectedAssetIds, setSelectedAssetIds] = useState([]); - const [metadataAssetId, setMetadataAssetId] = useState(null); - const [metadataDraft, setMetadataDraft] = useState(createMetadataDraft()); - const [metadataDirty, setMetadataDirty] = useState(false); - const [metadataSaving, setMetadataSaving] = useState(false); - const [previewParams, setPreviewParams] = useState(null); - const [activePresetId, setActivePresetId] = useState(effectPresetLibrary[0]?.id ?? ""); - const [cueDraft, setCueDraft] = useState(createCueDraft()); - const [mediaSearch, setMediaSearch] = useState(""); + const [adminState, dispatchAdmin] = useReducer(adminReducer, initialAdminUiState); + const setAdminField = (field: K, value: AdminFieldUpdater) => + dispatchAdmin({ type: "setField", field, value }); + const { + repository: state, + workspaceMode, + showUtilityTab, + buildLibraryTab, + cueState, + programOutputState, + selectedSceneId, + sceneBrowserFilter, + selectedAssetIds, + metadataAssetId, + metadataDraft, + metadataDirty, + metadataSaving, + previewParams, + activePresetId, + cueDraft, + cueDraftDirty, + mediaSearch, + uploadName, + uploadCaption, + uploadPromptAnswer, + uploadFile, + uploadAddToSelection, + status, + pendingCount, + approvedCount, + cueMoveInFlight, + cueMutationInFlight + } = adminState; + const setWorkspaceMode = (value: AdminFieldUpdater<"workspaceMode">) => setAdminField("workspaceMode", value); + const setShowUtilityTab = (value: AdminFieldUpdater<"showUtilityTab">) => setAdminField("showUtilityTab", value); + const setBuildLibraryTab = (value: AdminFieldUpdater<"buildLibraryTab">) => setAdminField("buildLibraryTab", value); + const setProgramOutputState = (value: AdminFieldUpdater<"programOutputState">) => setAdminField("programOutputState", value); + const setSceneBrowserFilter = (value: AdminFieldUpdater<"sceneBrowserFilter">) => setAdminField("sceneBrowserFilter", value); + const setMediaSearch = (value: AdminFieldUpdater<"mediaSearch">) => setAdminField("mediaSearch", value); + const setUploadName = (value: AdminFieldUpdater<"uploadName">) => setAdminField("uploadName", value); + const setUploadCaption = (value: AdminFieldUpdater<"uploadCaption">) => setAdminField("uploadCaption", value); + const setUploadPromptAnswer = (value: AdminFieldUpdater<"uploadPromptAnswer">) => setAdminField("uploadPromptAnswer", value); + const setUploadFile = (value: AdminFieldUpdater<"uploadFile">) => setAdminField("uploadFile", value); + const setUploadAddToSelection = (value: AdminFieldUpdater<"uploadAddToSelection">) => setAdminField("uploadAddToSelection", value); + const setStatus = (status: string) => dispatchAdmin({ type: "statusChanged", status }); const deferredMediaSearch = useDeferredValue(mediaSearch); - const [uploadName, setUploadName] = useState(""); - const [uploadCaption, setUploadCaption] = useState(""); - const [uploadPromptAnswer, setUploadPromptAnswer] = useState(""); - const [uploadFile, setUploadFile] = useState(null); - const [uploadAddToSelection, setUploadAddToSelection] = useState(true); - const [status, setStatus] = useState("Connecting to local show state..."); - const [pendingCount, setPendingCount] = useState(0); - const [approvedCount, setApprovedCount] = useState(0); const uploadInputRef = useRef(null); const mediaSearchInputRef = useRef(null); - const metadataHydrationKeyRef = useRef(null); const publishProgramOutput = ( presentation: SurfacePresentation | null, blackout: boolean, transition: CueTransition | null ) => { - setProgramOutputState((current) => { - const next = createProgramState(current, presentation, blackout, transition); - if (next !== current) { - writeProgramOutputState(next); - } - return next; - }); + const next = createProgramState(programOutputState, presentation, blackout, transition); + if (next !== programOutputState) { + writeProgramOutputState(next); + setProgramOutputState(next); + } }; const hydrate = (payload: RepositoryState, initialize: boolean) => { - const nextPendingCount = getPendingModerationCount(payload.photoAssets, payload.submissions); - const nextApprovedCount = getApprovedAssets(payload).length; - startTransition(() => { - setState(payload); - setPendingCount(nextPendingCount); - setApprovedCount(nextApprovedCount); - if (initialize) { const initial = createInitialLiveState(payload); - setCueState(initial.cueState); - setSelectedSceneId(initial.selectedSceneId); - setSceneBrowserFilter("all"); - setSelectedAssetIds(initial.selectedAssetIds); - setMetadataAssetId(initial.selectedAssetIds[0] ?? null); - setMetadataDraft(createMetadataDraft()); - setMetadataDirty(false); - setPreviewParams(initial.previewParams ?? null); - setActivePresetId(initial.activePresetId); - setCueDraft(initial.cueDraft); + dispatchAdmin({ + type: "bootstrapLoaded", + payload, + initial: { + cueState: initial.cueState, + selectedSceneId: initial.selectedSceneId, + selectedAssetIds: initial.selectedAssetIds, + previewParams: initial.previewParams, + activePresetId: initial.activePresetId, + cueDraft: initial.cueDraft + } + }); publishProgramOutput(initial.programPresentation, false, initial.programTransition); - setStatus("Ready. Local show state loaded."); return; } - setCueState((current) => ({ - ...current, - cueStack: payload.cues - })); - setSelectedAssetIds((current) => filterAvailableAssetIds(payload, current)); + dispatchAdmin({ + type: "repositoryHydrated", + payload + }); }); }; @@ -576,42 +527,24 @@ export const App = () => { const refreshLiveState = async () => { const payload = await loadAdminLive(); startTransition(() => { - setPendingCount(payload.pendingCount); - setApprovedCount(payload.approvedCount); - setState((current) => (current ? { ...current, cues: payload.cues } : current)); - setCueState((current) => ({ - ...current, - cueStack: payload.cues - })); + dispatchAdmin({ + type: "liveLoaded", + cues: payload.cues, + pendingCount: payload.pendingCount, + approvedCount: payload.approvedCount + }); }); }; const refreshLibraryState = async () => { const payload = await loadAdminLibrary(); - const nextPendingCount = getPendingModerationCount(payload.photoAssets, payload.submissions); - const nextApprovedCount = payload.photoAssets.filter((asset) => asset.moderationStatus === "approved").length; - const availableIds = new Set( - payload.photoAssets - .filter((asset) => asset.moderationStatus === "approved") - .map((asset) => asset.id) - ); - const knownIds = new Set(payload.photoAssets.map((asset) => asset.id)); - startTransition(() => { - setPendingCount(nextPendingCount); - setApprovedCount(nextApprovedCount); - setState((current) => - current - ? { - ...current, - photoAssets: payload.photoAssets, - submissions: payload.submissions, - collections: payload.collections - } - : current - ); - setSelectedAssetIds((current) => current.filter((assetId) => availableIds.has(assetId))); - setMetadataAssetId((current) => (current && knownIds.has(current) ? current : null)); + dispatchAdmin({ + type: "libraryLoaded", + photoAssets: payload.photoAssets, + submissions: payload.submissions, + collections: payload.collections + }); }); }; @@ -691,7 +624,12 @@ export const App = () => { () => state?.cues.find((cue) => cue.id === state.showConfig.safeSceneCueId), [state?.cues, state?.showConfig.safeSceneCueId] ); - const cueStack = state?.cues ?? []; + const cueStack = useMemo(() => sortCues(state?.cues ?? []), [state?.cues]); + const selectedCueId = cueDraft.id; + const selectedCueIndex = selectedCueId ? cueStack.findIndex((cue) => cue.id === selectedCueId) : -1; + const canMoveSelectedCueUp = selectedCueIndex > 0 && !cueMoveInFlight; + const canMoveSelectedCueDown = selectedCueIndex >= 0 && selectedCueIndex < cueStack.length - 1 && !cueMoveInFlight; + const canSaveCue = Boolean(selectedScene && previewParams && !cueMutationInFlight && (!cueDraft.id || cueDraftDirty)); const favoriteCollection: Collection | undefined = useMemo( () => state?.collections.find((collection) => collection.kind === "favorites"), [state?.collections] @@ -704,25 +642,27 @@ export const App = () => { () => new Map((state?.submissions ?? []).map((submission) => [submission.id, submission] as const)), [state?.submissions] ); + const approvedAssetMap = useMemo( + () => new Map(approvedAssets.map((asset) => [asset.id, asset] as const)), + [approvedAssets] + ); + const allAssetMap = useMemo( + () => new Map((state?.photoAssets ?? []).map((asset) => [asset.id, asset] as const)), + [state?.photoAssets] + ); const selectedAssets = useMemo(() => { - const assetMap = new Map(approvedAssets.map((asset) => [asset.id, asset] as const)); return selectedAssetIds - .map((assetId) => assetMap.get(assetId)) + .map((assetId) => approvedAssetMap.get(assetId)) .filter((asset): asset is PhotoAsset => Boolean(asset)); - }, [approvedAssets, selectedAssetIds]); + }, [approvedAssetMap, selectedAssetIds]); const metadataAsset = useMemo(() => { - if (!state) { - return undefined; - } - - const allAssetMap = new Map(state.photoAssets.map((asset) => [asset.id, asset] as const)); return ( (metadataAssetId ? allAssetMap.get(metadataAssetId) : undefined) ?? selectedAssets[0] ?? pendingAssets[0] ?? approvedAssets[0] ); - }, [approvedAssets, metadataAssetId, pendingAssets, selectedAssets, state]); + }, [allAssetMap, approvedAssets, metadataAssetId, pendingAssets, selectedAssets]); const metadataSubmission = useMemo( () => (metadataAsset ? submissionMap.get(metadataAsset.submissionId) : undefined), [metadataAsset, submissionMap] @@ -839,33 +779,12 @@ export const App = () => { const programActivationKey = `${programOutputState?.outputRevision ?? 0}:${createPresentationStructureHash(programPresentation)}`; useEffect(() => { - if (metadataAsset?.id && metadataAssetId !== metadataAsset.id) { - setMetadataAssetId(metadataAsset.id); - return; - } - - if (!metadataAsset && metadataAssetId !== null) { - setMetadataAssetId(null); - } - }, [metadataAsset, metadataAssetId]); - - useEffect(() => { - const nextKey = metadataAsset && metadataSubmission ? `${metadataAsset.id}:${metadataSubmission.id}` : null; - if (!nextKey) { - if (!metadataDirty) { - setMetadataDraft(createMetadataDraft()); - } - metadataHydrationKeyRef.current = null; - return; - } - - const targetChanged = metadataHydrationKeyRef.current !== nextKey; - if (targetChanged || !metadataDirty) { - setMetadataDraft(createMetadataDraft(metadataSubmission)); - setMetadataDirty(false); - metadataHydrationKeyRef.current = nextKey; - } - }, [metadataAsset, metadataDirty, metadataSubmission]); + dispatchAdmin({ + type: "metadataTargetSelected", + assetId: metadataAsset?.id ?? null, + submission: metadataSubmission + }); + }, [metadataAsset?.id, metadataSubmission]); const selectScene = (scene: SceneDefinition) => { if (!state) { @@ -874,16 +793,16 @@ export const App = () => { const nextPreset = matchPresetForScene(scene, availablePresets); const preservedAssetIds = filterAvailableAssetIds(state, selectedAssetIds); - setSelectedSceneId(scene.id); - setSceneBrowserFilter(scene.sceneFamily); - setActivePresetId(nextPreset?.id ?? availablePresets[0]?.id ?? effectPresetLibrary[0]?.id ?? ""); - setPreviewParams(buildParamsForScene(scene, undefined, nextPreset)); - setSelectedAssetIds( - preservedAssetIds.length > 0 - ? preservedAssetIds - : getSuggestedAssetsForScene(state, scene.id, getDefaultAssetIds(state)) - ); - setCueDraft(createCueDraft(undefined, scene)); + dispatchAdmin({ + type: "previewSceneSelected", + scene, + presetId: nextPreset?.id ?? availablePresets[0]?.id ?? effectPresetLibrary[0]?.id ?? "", + params: buildParamsForScene(scene, undefined, nextPreset), + assetIds: + preservedAssetIds.length > 0 + ? preservedAssetIds + : getSuggestedAssetsForScene(state, scene.id, getDefaultAssetIds(state)) + }); }; const syncPreviewFromCue = (cue: Cue, options: { armPreview?: boolean } = {}) => { @@ -902,18 +821,15 @@ export const App = () => { ? filterAvailableAssetIds(state, cue.assetIds) : findCollectionAssets(state, cue.collectionId); - if (options.armPreview ?? true) { - setCueState((current) => armCue(current, cue.id)); - } - setSelectedSceneId(scene.id); - setSceneBrowserFilter(scene.sceneFamily); - setActivePresetId(matchedPreset?.id ?? availablePresets[0]?.id ?? effectPresetLibrary[0]?.id ?? ""); - setPreviewParams(buildParamsForScene(scene, cue.parameterOverrides, matchedPreset)); - setCueDraft(createCueDraft(cue, scene)); - setMetadataAssetId(cueAssetIds[0] ?? null); - setSelectedAssetIds( - cueAssetIds.length > 0 ? cueAssetIds : getSuggestedAssetsForScene(state, scene.id, getDefaultAssetIds(state)) - ); + dispatchAdmin({ + type: "previewCueSelected", + cue, + scene, + presetId: matchedPreset?.id ?? availablePresets[0]?.id ?? effectPresetLibrary[0]?.id ?? "", + params: buildParamsForScene(scene, cue.parameterOverrides, matchedPreset), + assetIds: cueAssetIds.length > 0 ? cueAssetIds : getSuggestedAssetsForScene(state, scene.id, getDefaultAssetIds(state)), + armPreview: options.armPreview + }); }; const openOutputWindow = () => { @@ -940,20 +856,13 @@ export const App = () => { ? filterAvailableAssetIds(state, draft.assetIds) : getSuggestedAssetsForScene(state, scene.id, getDefaultAssetIds(state)); - setSelectedSceneId(scene.id); - setSceneBrowserFilter(scene.sceneFamily); - setActivePresetId(preset?.id ?? availablePresets[0]?.id ?? effectPresetLibrary[0]?.id ?? ""); - setPreviewParams(buildParamsForScene(scene, draft.parameterOverrides, preset)); - setMetadataAssetId(nextAssetIds[0] ?? null); - setSelectedAssetIds(nextAssetIds); - setCueDraft({ - id: null, - notes: draft.notes ?? scene.name, - triggerMode: draft.triggerMode, - transitionInStyle: draft.transitionIn.style, - transitionInDurationMs: draft.transitionIn.durationMs, - transitionOutStyle: draft.transitionOut.style, - transitionOutDurationMs: draft.transitionOut.durationMs + dispatchAdmin({ + type: "generatedCueDraftLoaded", + draft, + scene, + presetId: preset?.id ?? availablePresets[0]?.id ?? effectPresetLibrary[0]?.id ?? "", + params: buildParamsForScene(scene, draft.parameterOverrides, preset), + assetIds: nextAssetIds }); }; @@ -1003,25 +912,15 @@ export const App = () => { try { const result = await createAdminUpload(form); - await refreshBootstrap(false); - setMetadataAssetId(result.assetId); - setMetadataDirty(false); - - if (uploadAddToSelection && result.assetId) { - setSelectedAssetIds((current) => { - const next = [result.assetId, ...current.filter((assetId) => assetId !== result.assetId)]; - return next.slice(0, 12); - }); - } - - setUploadFile(null); + await refreshLibraryState(); + dispatchAdmin({ + type: "adminUploadSucceeded", + assetId: result.assetId, + addToSelection: uploadAddToSelection + }); if (uploadInputRef.current) { uploadInputRef.current.value = ""; } - setUploadName(""); - setUploadCaption(""); - setUploadPromptAnswer(""); - setUploadAddToSelection(true); setStatus("Admin upload queued. It will appear in the approved bank as soon as processing completes."); } catch (error) { setStatus(error instanceof Error ? error.message : "Admin upload failed."); @@ -1029,17 +928,15 @@ export const App = () => { }; const handleMetadataDraftChange = (field: keyof MetadataDraftState, value: string) => { - setMetadataDraft((current) => ({ - ...current, - [field]: value - })); - setMetadataDirty(true); + dispatchAdmin({ type: "metadataDraftChanged", field, value }); }; const handleResetMetadataDraft = () => { - setMetadataDraft(createMetadataDraft(metadataSubmission)); - setMetadataDirty(false); - setStatus("Metadata draft reset to the saved values."); + dispatchAdmin({ + type: "metadataDraftReset", + submission: metadataSubmission, + status: "Metadata draft reset to the saved values." + }); }; const handleSaveMetadata = async () => { @@ -1049,6 +946,8 @@ export const App = () => { } const payload: SubmissionUpdatePayload = { + contributorName: metadataDraft.contributorName, + lovedOneName: metadataDraft.lovedOneName, displayName: metadataDraft.displayName, caption: metadataDraft.caption, promptAnswer: metadataDraft.promptAnswer, @@ -1056,15 +955,15 @@ export const App = () => { }; try { - setMetadataSaving(true); - await updateSubmissionMetadata(metadataSubmission.id, payload); - setMetadataDirty(false); - await refreshBootstrap(false); + dispatchAdmin({ type: "metadataSaveStarted" }); + const savedSubmission = await updateSubmissionMetadata(metadataSubmission.id, payload); + dispatchAdmin({ type: "metadataSaved", submission: savedSubmission }); + await refreshLibraryState(); setStatus(`Metadata saved for ${metadataTitle}.`); } catch (error) { setStatus(error instanceof Error ? error.message : "Could not save image metadata."); } finally { - setMetadataSaving(false); + dispatchAdmin({ type: "metadataSaveFinished" }); } }; @@ -1089,9 +988,8 @@ export const App = () => { } else { await deleteAsset(asset.id); } - setSelectedAssetIds((current) => current.filter((assetId) => assetId !== asset.id)); - setMetadataAssetId((current) => (current === asset.id ? null : current)); - await refreshBootstrap(false); + dispatchAdmin({ type: "approvedAssetRemoved", assetId: asset.id }); + await refreshLibraryState(); setStatus( submission?.source === "library_import" ? `Removed ${assetLabel} from the approved bank.` @@ -1103,10 +1001,7 @@ export const App = () => { }; const setBlackout = (blackout: boolean) => { - setCueState((current) => ({ - ...current, - blackout - })); + dispatchAdmin({ type: "blackoutSet", blackout }); publishProgramOutput(programOutputState?.presentation ?? null, blackout, programOutputState?.transition ?? null); }; @@ -1150,7 +1045,6 @@ export const App = () => { const currentIndex = cueStack.findIndex((cue) => cue.id === cueState.previewCueId); const previousCue = cueStack[Math.max(0, currentIndex - 1)]; if (previousCue) { - setCueState((current) => skipToCue(current, previousCue.id)); syncPreviewFromCue(previousCue); } return; @@ -1228,6 +1122,15 @@ export const App = () => { }; const handleSaveCue = async () => { + if (cueMutationInFlight) { + return; + } + + if (cueDraft.id && !cueDraftDirty) { + setStatus("Selected cue is already saved."); + return; + } + const targetIndex = cueDraft.id ? cueStack.find((cue) => cue.id === cueDraft.id)?.orderIndex ?? cueStack.length : cueStack.length; @@ -1240,14 +1143,24 @@ export const App = () => { return; } - const savedCue = cueDraft.id ? await updateCue(cueId, payload) : await createCue(payload); - syncPreviewFromCue(savedCue); - await refreshBootstrap(false); - setCueDraft(createCueDraft(savedCue, selectedScene)); - setStatus(cueDraft.id ? `Cue updated: ${savedCue.notes ?? savedCue.id}` : `Cue created: ${savedCue.notes ?? savedCue.id}`); + dispatchAdmin({ type: "cueMutationStarted" }); + try { + const savedCue = cueDraft.id ? await updateCue(cueId, payload) : await createCue(payload); + dispatchAdmin({ type: "cueUpsertSucceeded", cue: savedCue, scene: selectedScene }); + syncPreviewFromCue(savedCue); + await refreshLiveState(); + setStatus(cueDraft.id ? `Cue updated: ${savedCue.notes ?? savedCue.id}` : `Cue created: ${savedCue.notes ?? savedCue.id}`); + } catch (error) { + dispatchAdmin({ type: "cueMutationFinished" }); + setStatus(error instanceof Error ? error.message : "Could not save cue."); + } }; const handleCreateCueAfterCurrent = async () => { + if (cueMutationInFlight) { + return; + } + const targetIndex = cueDraft.id !== null ? (cueStack.find((cue) => cue.id === cueDraft.id)?.orderIndex ?? cueStack.length - 1) + 1 @@ -1261,14 +1174,24 @@ export const App = () => { return; } - const createdCue = await createCue(payload); - syncPreviewFromCue(createdCue); - await refreshBootstrap(false); - setCueDraft(createCueDraft(createdCue, selectedScene)); - setStatus(`Cue inserted: ${createdCue.notes ?? createdCue.id}`); + dispatchAdmin({ type: "cueMutationStarted" }); + try { + const createdCue = await createCue(payload); + dispatchAdmin({ type: "cueUpsertSucceeded", cue: createdCue, scene: selectedScene }); + syncPreviewFromCue(createdCue); + await refreshLiveState(); + setStatus(`Cue inserted: ${createdCue.notes ?? createdCue.id}`); + } catch (error) { + dispatchAdmin({ type: "cueMutationFinished" }); + setStatus(error instanceof Error ? error.message : "Could not insert cue."); + } }; const handleDuplicateCue = async () => { + if (cueMutationInFlight) { + return; + } + const cueId = `cue-${crypto.randomUUID()}`; const sourceCue = cueDraft.id ? cueStack.find((cue) => cue.id === cueDraft.id) : null; const payload = buildCuePayload({ @@ -1280,32 +1203,66 @@ export const App = () => { } payload.notes = `${cueDraft.notes || selectedScene?.name || "Cue"} copy`; - const duplicatedCue = await createCue(payload); - syncPreviewFromCue(duplicatedCue); - await refreshBootstrap(false); - setCueDraft(createCueDraft(duplicatedCue, selectedScene)); - setStatus(`Cue duplicated: ${duplicatedCue.notes ?? duplicatedCue.id}`); + dispatchAdmin({ type: "cueMutationStarted" }); + try { + const duplicatedCue = await createCue(payload); + dispatchAdmin({ type: "cueUpsertSucceeded", cue: duplicatedCue, scene: selectedScene }); + syncPreviewFromCue(duplicatedCue); + await refreshLiveState(); + setStatus(`Cue duplicated: ${duplicatedCue.notes ?? duplicatedCue.id}`); + } catch (error) { + dispatchAdmin({ type: "cueMutationFinished" }); + setStatus(error instanceof Error ? error.message : "Could not duplicate cue."); + } }; const handleDeleteCue = async () => { - if (!cueDraft.id) { + if (!cueDraft.id || cueMutationInFlight) { return; } - await deleteCue(cueDraft.id); - await refreshBootstrap(false); - setCueDraft(createCueDraft(undefined, selectedScene)); - setStatus("Cue deleted."); + const deletedCueId = cueDraft.id; + const deletedIndex = cueStack.findIndex((cue) => cue.id === deletedCueId); + const remainingCues = cueStack.filter((cue) => cue.id !== deletedCueId); + const nextCue = remainingCues[Math.min(Math.max(deletedIndex, 0), Math.max(remainingCues.length - 1, 0))]; + dispatchAdmin({ type: "cueMutationStarted" }); + try { + await deleteCue(deletedCueId); + dispatchAdmin({ type: "cueDeleted", cueId: deletedCueId }); + if (nextCue) { + syncPreviewFromCue(nextCue); + } + await refreshLiveState(); + setStatus("Cue deleted."); + } catch (error) { + dispatchAdmin({ type: "cueMutationFinished" }); + setStatus(error instanceof Error ? error.message : "Could not delete cue."); + } }; const handleMoveCue = async (direction: "up" | "down") => { - if (!cueDraft.id) { + const moveCueId = selectedCueId; + if (!moveCueId || cueMoveInFlight) { return; } - await moveCue(cueDraft.id, { direction }); - await refreshBootstrap(false); - setStatus(`Cue moved ${direction}.`); + if (direction === "up" && !canMoveSelectedCueUp) { + return; + } + if (direction === "down" && !canMoveSelectedCueDown) { + return; + } + + dispatchAdmin({ type: "cueMoveOptimistic", cueId: moveCueId, direction }); + try { + const nextCues = await moveCue(moveCueId, { direction }); + dispatchAdmin({ type: "cueMoveSucceeded", cues: nextCues, cueId: moveCueId }); + setStatus(`Cue moved ${direction}.`); + } catch (error) { + dispatchAdmin({ type: "cueMoveFailed" }); + void refreshLiveState(); + setStatus(error instanceof Error ? error.message : `Could not move cue ${direction}.`); + } }; const handleModeration = async (asset: PhotoAsset, decision: ModerationActionPayload["decision"]) => { @@ -1313,7 +1270,7 @@ export const App = () => { decision, reasonCode: decision === "rejected" ? "operator_review" : undefined }); - await refreshBootstrap(false); + await refreshLibraryState(); }; const handleTakeCue = async () => { @@ -1326,14 +1283,7 @@ export const App = () => { const nextCue = currentPreviewIndex >= 0 ? cueStack[currentPreviewIndex + 1] : undefined; publishProgramOutput(previewPresentation, false, nextTransition); - setCueState((current) => { - if (!previewUsesArmedCue) { - return { ...current, blackout: false, safeSceneActive: false }; - } - - const taken = takeCue(current); - return nextCue ? armCue(taken, nextCue.id) : taken; - }); + dispatchAdmin({ type: "programCueTaken", previewUsesArmedCue }); if (nextCue) { syncPreviewFromCue(nextCue, { armPreview: false }); @@ -1375,7 +1325,7 @@ export const App = () => { }; publishProgramOutput(presentation, false, safeCue.transitionIn); - setCueState((current) => triggerSafeScene(current, safeCue.id)); + dispatchAdmin({ type: "safeCueTriggered", cueId: safeCue.id }); setStatus("Safe scene on program."); try { @@ -1391,15 +1341,8 @@ export const App = () => { } const initial = createInitialLiveState(state); - setCueState(initial.cueState); - setSelectedSceneId(initial.selectedSceneId); - setSceneBrowserFilter("all"); - setSelectedAssetIds(initial.selectedAssetIds); - setPreviewParams(initial.previewParams ?? null); - setActivePresetId(initial.activePresetId); - setCueDraft(initial.cueDraft); + dispatchAdmin({ type: "previewReset", initial }); publishProgramOutput(initial.programPresentation, false, initial.programTransition); - setStatus("Reset to safe hold on program and opening cue in preview."); }; const handleNextCue = () => { @@ -1413,15 +1356,11 @@ export const App = () => { return; } - setCueState((current) => skipToCue(current, nextCue.id)); syncPreviewFromCue(nextCue); }; const toggleAssetSelection = (assetId: string) => { - setMetadataAssetId(assetId); - setSelectedAssetIds((current) => - current.includes(assetId) ? current.filter((candidate) => candidate !== assetId) : [...current, assetId].slice(-12) - ); + dispatchAdmin({ type: "selectedAssetToggled", assetId }); }; const handleLoadSuggestedAssets = () => { @@ -1430,9 +1369,12 @@ export const App = () => { } const nextAssetIds = getSuggestedAssetsForScene(state, selectedScene.id, getDefaultAssetIds(state)); - setSelectedAssetIds(nextAssetIds); - setMetadataAssetId(nextAssetIds[0] ?? null); - setStatus(`Suggested media loaded for ${selectedScene.name}.`); + dispatchAdmin({ + type: "selectedAssetsReplaced", + assetIds: nextAssetIds, + metadataAssetId: nextAssetIds[0] ?? null, + status: `Suggested media loaded for ${selectedScene.name}.` + }); }; const handleLoadFavorites = () => { @@ -1441,37 +1383,36 @@ export const App = () => { } const nextAssetIds = getDefaultAssetIds(state); - setSelectedAssetIds(nextAssetIds); - setMetadataAssetId(nextAssetIds[0] ?? null); - setStatus("Favorites bank loaded into preview."); + dispatchAdmin({ + type: "selectedAssetsReplaced", + assetIds: nextAssetIds, + metadataAssetId: nextAssetIds[0] ?? null, + status: "Favorites bank loaded into preview." + }); }; const handleClearSelectedAssets = () => { - setSelectedAssetIds([]); - setStatus("Preview asset bank cleared."); + dispatchAdmin({ type: "selectedAssetsCleared", status: "Preview asset bank cleared." }); }; const handlePromoteAsset = (assetId: string) => { - setSelectedAssetIds((current) => { - const index = current.indexOf(assetId); - return index <= 0 ? current : moveItem(current, index, 0); - }); - setStatus("Anchor image updated."); + dispatchAdmin({ type: "selectedAssetPromoted", assetId, status: "Anchor image updated." }); }; const handleReorderAsset = (assetId: string, direction: "earlier" | "later") => { - setSelectedAssetIds((current) => { - const index = current.indexOf(assetId); - if (index === -1) { - return current; - } - - return moveItem(current, index, direction === "earlier" ? index - 1 : index + 1); - }); + dispatchAdmin({ type: "selectedAssetReordered", assetId, direction }); }; const handleRemoveSelectedAsset = (assetId: string) => { - setSelectedAssetIds((current) => current.filter((candidate) => candidate !== assetId)); + dispatchAdmin({ type: "selectedAssetRemoved", assetId }); + }; + + const focusMetadataAsset = (asset: PhotoAsset | null | undefined) => { + dispatchAdmin({ + type: "metadataTargetSelected", + assetId: asset?.id ?? null, + submission: asset ? submissionMap.get(asset.submissionId) : undefined + }); }; const handleResetModeDefaults = () => { @@ -1480,8 +1421,11 @@ export const App = () => { } const preset = matchPresetForScene(selectedScene, availablePresets, activePresetId); - setPreviewParams(buildParamsForScene(selectedScene, undefined, preset)); - setStatus(`Preview reset to ${preset?.name ?? selectedScene.name} defaults.`); + dispatchAdmin({ + type: "previewParamsReplaced", + params: buildParamsForScene(selectedScene, undefined, preset), + status: `Preview reset to ${preset?.name ?? selectedScene.name} defaults.` + }); }; const handleResetFillColor = () => { @@ -1491,25 +1435,46 @@ export const App = () => { const preset = matchPresetForScene(selectedScene, availablePresets, activePresetId); const base = buildParamsForScene(selectedScene, undefined, preset); - setPreviewParams({ - ...previewParams, - scenicTreatment: { - ...previewParams.scenicTreatment, - hue: base.scenicTreatment.hue, - saturation: base.scenicTreatment.saturation, - lightness: base.scenicTreatment.lightness - } + dispatchAdmin({ + type: "previewParamsReplaced", + params: { + ...previewParams, + scenicTreatment: { + ...previewParams.scenicTreatment, + hue: base.scenicTreatment.hue, + saturation: base.scenicTreatment.saturation, + lightness: base.scenicTreatment.lightness + } + }, + status: "Backdrop color reset to the current mode defaults." }); - setStatus("Backdrop color reset to the current mode defaults."); }; - const handleNewCueFromPreview = () => { - if (!selectedScene) { + const handleNewCueFromPreview = async () => { + if (!selectedScene || !previewParams || cueMutationInFlight) { return; } - setCueDraft(createCueDraft(undefined, selectedScene)); - setStatus("Preview detached from the armed cue. Saving now creates a new cue."); + const cueId = `cue-${crypto.randomUUID()}`; + const payload = buildCuePayload({ + id: cueId, + orderIndex: cueStack.length + }); + if (!payload) { + return; + } + + dispatchAdmin({ type: "cueMutationStarted" }); + try { + const createdCue = await createCue(payload); + dispatchAdmin({ type: "cueUpsertSucceeded", cue: createdCue, scene: selectedScene }); + syncPreviewFromCue(createdCue); + await refreshLiveState(); + setStatus(`New cue created: ${createdCue.notes ?? createdCue.id}`); + } catch (error) { + dispatchAdmin({ type: "cueMutationFinished" }); + setStatus(error instanceof Error ? error.message : "Could not create cue."); + } }; const renderControl = (path: string) => { @@ -1536,11 +1501,9 @@ export const App = () => { max={control.max} step={control.step} value={value} - onChange={(event) => - setPreviewParams((current) => - current ? setSceneParamValue(current, path, Number(event.target.value)) : current - ) - } + onChange={(event) => { + dispatchAdmin({ type: "previewParamChanged", path, value: Number(event.target.value) }); + }} /> ); @@ -1555,11 +1518,9 @@ export const App = () => { - setPreviewParams((current) => - current ? setSceneParamValue(current, path, event.target.checked) : current - ) - } + onChange={(event) => { + dispatchAdmin({ type: "previewParamChanged", path, value: event.target.checked }); + }} /> ); @@ -1573,11 +1534,9 @@ export const App = () => { - setPreviewParams((current) => - current ? setSceneParamValue(current, path, event.target.value) : current - ) - } + onChange={(event) => { + dispatchAdmin({ type: "previewParamChanged", path, value: event.target.value }); + }} /> ); @@ -1708,9 +1665,12 @@ export const App = () => { if (!selectedScene) { return; } - setActivePresetId(preset.id); - setPreviewParams(buildParamsForScene(selectedScene, undefined, preset)); - setStatus(`Preview mode loaded: ${preset.name}`); + dispatchAdmin({ + type: "previewPresetApplied", + presetId: preset.id, + params: buildParamsForScene(selectedScene, undefined, preset), + status: `Preview mode loaded: ${preset.name}` + }); }} > {preset.name} @@ -1771,9 +1731,12 @@ export const App = () => { if (!selectedScene) { return; } - setActivePresetId(preset.id); - setPreviewParams(buildParamsForScene(selectedScene, undefined, preset)); - setStatus(`Preview mode loaded: ${preset.name}`); + dispatchAdmin({ + type: "previewPresetApplied", + presetId: preset.id, + params: buildParamsForScene(selectedScene, undefined, preset), + status: `Preview mode loaded: ${preset.name}` + }); }} > {preset.name} @@ -1841,7 +1804,7 @@ export const App = () => { key={asset.id} className={`selected-asset ${index === 0 ? "selected-asset--anchor" : ""} ${metadataAssetId === asset.id ? "selected-asset--editing" : ""}`} title={`${index === 0 ? "Anchor" : `Slot ${index + 1}`} ยท ${assetLabel}`} - onClick={() => setMetadataAssetId(asset.id)} + onClick={() => focusMetadataAsset(asset)} >
{asset.thumbKey ? :
} @@ -1917,6 +1880,26 @@ export const App = () => {

+ +