Stabilize admin state and submission metadata

This commit is contained in:
2026-04-10 15:48:32 -07:00
parent 215ead0768
commit bd2087ba5f
14 changed files with 2171 additions and 463 deletions
+4 -2
View File
@@ -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"
}
}
+416 -437
View File
File diff suppressed because it is too large Load Diff
+339
View File
@@ -0,0 +1,339 @@
import { describe, expect, it } from "vitest";
import { createCueRuntimeState } from "@goodgrief/cue-engine";
import {
createEmptyRepositoryState,
defaultCueStack,
defaultSceneDefinitions,
type Cue,
type PhotoAsset,
type Submission
} from "@goodgrief/shared-types";
import {
adminReducer,
createCueDraft,
createMetadataDraft,
initialAdminUiState,
sortCues
} from "./admin-state";
const scene = defaultSceneDefinitions[0]!;
const otherScene = defaultSceneDefinitions.find((entry) => entry.id !== scene.id)!;
const cue = (id: string, orderIndex: number, notes: string): Cue => ({
...defaultCueStack[0]!,
id,
orderIndex,
sceneDefinitionId: scene.id,
notes
});
const submission = (id: string, caption: string): Submission => ({
id,
source: "live",
submittedAt: "2026-04-10T00:00:00.000Z",
status: "approved_all",
consentId: `consent-${id}`,
displayName: `Display ${id}`,
caption
});
const asset = (
id: string,
submissionId: string,
moderationStatus: PhotoAsset["moderationStatus"] = "approved"
): PhotoAsset => ({
id,
submissionId,
originalKey: `/uploads/${id}.jpg`,
mimeType: "image/jpeg",
processingStatus: "ready",
moderationStatus,
createdAt: "2026-04-10T00:00:00.000Z"
});
const repositoryWithCues = (cues: Cue[]) => ({
...createEmptyRepositoryState(),
scenes: defaultSceneDefinitions,
cues: sortCues(cues),
photoAssets: [],
submissions: []
});
const bootState = (cues: Cue[], selectedCueId = cues[0]?.id ?? null) => {
const repository = repositoryWithCues(cues);
const selectedCue = selectedCueId ? repository.cues.find((entry) => entry.id === selectedCueId) : null;
return adminReducer(initialAdminUiState, {
type: "bootstrapLoaded",
payload: repository,
initial: {
cueState: selectedCue ? createCueRuntimeState(repository.cues) : createCueRuntimeState([]),
selectedSceneId: scene.id,
selectedAssetIds: [],
previewParams: scene.defaultParams,
activePresetId: scene.defaultPresetId,
cueDraft: createCueDraft(selectedCue, scene)
}
});
};
describe("adminReducer cue reconciliation", () => {
it("moves a cue immediately while keeping the moved cue selected and dirty draft intact", () => {
const cues = [cue("cue-a", 0, "A"), cue("cue-b", 1, "B"), cue("cue-c", 2, "C")];
const dirtyState = {
...bootState(cues, "cue-b"),
cueState: createCueRuntimeState(cues),
cueDraft: {
...createCueDraft(cues[1], scene),
notes: "Unsaved label"
},
cueDraftDirty: true
};
const moving = adminReducer(dirtyState, { type: "cueMoveOptimistic", cueId: "cue-b", direction: "up" });
expect(moving.repository?.cues.map((entry) => entry.id)).toEqual(["cue-b", "cue-a", "cue-c"]);
expect(moving.cueState.previewCueId).toBe("cue-b");
expect(moving.cueState.armedCueId).toBe("cue-b");
expect(moving.cueMoveInFlight).toBe(true);
expect(moving.cueDraftDirty).toBe(true);
expect(moving.cueDraft.notes).toBe("Unsaved label");
const stalePoll = adminReducer(moving, {
type: "liveLoaded",
cues,
pendingCount: 1,
approvedCount: 2
});
expect(stalePoll.repository?.cues.map((entry) => entry.id)).toEqual(["cue-b", "cue-a", "cue-c"]);
expect(stalePoll.pendingCount).toBe(1);
expect(stalePoll.approvedCount).toBe(2);
const moved = adminReducer(moving, {
type: "cueMoveSucceeded",
cueId: "cue-b",
cues: [cue("cue-b", 0, "B"), cue("cue-a", 1, "A"), cue("cue-c", 2, "C")]
});
expect(moved.repository?.cues.map((entry) => entry.id)).toEqual(["cue-b", "cue-a", "cue-c"]);
expect(moved.cueState.previewCueId).toBe("cue-b");
expect(moved.cueState.armedCueId).toBe("cue-b");
expect(moved.cueMoveInFlight).toBe(false);
expect(moved.cueDraftDirty).toBe(true);
expect(moved.cueDraft.notes).toBe("Unsaved label");
});
it("refreshes clean cue drafts from live polling but preserves dirty drafts", () => {
const cues = [cue("cue-a", 0, "A"), cue("cue-b", 1, "B")];
const cleanState = {
...bootState(cues, "cue-b"),
cueState: createCueRuntimeState(cues),
cueDraft: createCueDraft(cues[1], scene)
};
const cleanRefresh = adminReducer(cleanState, {
type: "liveLoaded",
cues: [cue("cue-a", 0, "A"), cue("cue-b", 1, "B updated")],
pendingCount: 0,
approvedCount: 0
});
const dirtyRefresh = adminReducer(
{
...cleanState,
cueDraft: {
...cleanState.cueDraft,
notes: "Unsaved local edit"
},
cueDraftDirty: true
},
{
type: "liveLoaded",
cues: [cue("cue-a", 0, "A"), cue("cue-b", 1, "B updated")],
pendingCount: 0,
approvedCount: 0
}
);
expect(cleanRefresh.cueDraft.notes).toBe("B updated");
expect(dirtyRefresh.cueDraft.notes).toBe("Unsaved local edit");
});
});
describe("adminReducer library reconciliation", () => {
const baseSubmission = submission("submission-a", "Caption");
it("prunes unavailable selected media while preserving dirty metadata drafts", () => {
const state = {
...bootState([cue("cue-a", 0, "A")]),
selectedAssetIds: ["asset-a", "asset-b"],
metadataAssetId: "asset-a",
metadataDraft: {
...createMetadataDraft(baseSubmission),
caption: "Unsaved caption"
},
metadataDirty: true
};
const reconciled = adminReducer(state, {
type: "libraryLoaded",
photoAssets: [asset("asset-a", baseSubmission.id, "approved"), asset("asset-b", baseSubmission.id, "hold")],
submissions: [
{
...baseSubmission,
caption: "Server caption"
}
],
collections: state.repository?.collections ?? []
});
expect(reconciled.selectedAssetIds).toEqual(["asset-a"]);
expect(reconciled.metadataAssetId).toBe("asset-a");
expect(reconciled.metadataDraft.caption).toBe("Unsaved caption");
expect(reconciled.approvedCount).toBe(1);
});
});
describe("adminReducer preview and operator controls", () => {
it("selects a scene as one clean preview transition", () => {
const state = bootState([cue("cue-a", 0, "A")], "cue-a");
const selected = adminReducer(state, {
type: "previewSceneSelected",
scene: otherScene,
presetId: "preset-test",
params: otherScene.defaultParams,
assetIds: ["asset-a", "asset-b"]
});
expect(selected.selectedSceneId).toBe(otherScene.id);
expect(selected.sceneBrowserFilter).toBe(otherScene.sceneFamily);
expect(selected.activePresetId).toBe("preset-test");
expect(selected.previewParams).toBe(otherScene.defaultParams);
expect(selected.selectedAssetIds).toEqual(["asset-a", "asset-b"]);
expect(selected.metadataAssetId).toBe("asset-a");
expect(selected.cueDraft.id).toBeNull();
expect(selected.cueDraftDirty).toBe(false);
});
it("keeps media selection ordering deterministic and marks saved cue drafts dirty", () => {
const state = {
...bootState([cue("cue-a", 0, "A")], "cue-a"),
selectedAssetIds: ["asset-a", "asset-b", "asset-c"],
metadataAssetId: "asset-a",
cueDraftDirty: false
};
const promoted = adminReducer(state, {
type: "selectedAssetPromoted",
assetId: "asset-b",
status: "Anchor image updated."
});
const reordered = adminReducer(promoted, {
type: "selectedAssetReordered",
assetId: "asset-b",
direction: "later"
});
const removed = adminReducer(reordered, {
type: "selectedAssetRemoved",
assetId: "asset-b"
});
expect(promoted.selectedAssetIds).toEqual(["asset-b", "asset-a", "asset-c"]);
expect(promoted.metadataAssetId).toBe("asset-b");
expect(promoted.cueDraftDirty).toBe(true);
expect(reordered.selectedAssetIds).toEqual(["asset-a", "asset-b", "asset-c"]);
expect(removed.selectedAssetIds).toEqual(["asset-a", "asset-c"]);
expect(removed.metadataAssetId).toBeNull();
});
it("marks cue drafts dirty when operator params or cue fields change", () => {
const state = {
...bootState([cue("cue-a", 0, "A")], "cue-a"),
cueDraftDirty: false
};
const paramChanged = adminReducer(state, {
type: "previewParamChanged",
path: "scenicTreatment.hue",
value: 0.42
});
const cueChanged = adminReducer(state, {
type: "cueDraftChanged",
field: "notes",
value: "Operator label"
});
expect(paramChanged.previewParams?.scenicTreatment.hue).toBe(0.42);
expect(paramChanged.cueDraftDirty).toBe(true);
expect(cueChanged.cueDraft.notes).toBe("Operator label");
expect(cueChanged.cueDraftDirty).toBe(true);
});
});
describe("adminReducer metadata and upload controls", () => {
it("preserves dirty metadata for the same target and hydrates when the target changes", () => {
const submissionA = submission("submission-a", "Saved A");
const submissionB = submission("submission-b", "Saved B");
const focused = adminReducer(bootState([cue("cue-a", 0, "A")]), {
type: "metadataTargetSelected",
assetId: "asset-a",
submission: submissionA
});
const edited = adminReducer(focused, {
type: "metadataDraftChanged",
field: "caption",
value: "Local edit"
});
const sameTargetRefresh = adminReducer(edited, {
type: "metadataTargetSelected",
assetId: "asset-a",
submission: {
...submissionA,
caption: "Server refresh"
}
});
const changedTarget = adminReducer(sameTargetRefresh, {
type: "metadataTargetSelected",
assetId: "asset-b",
submission: submissionB
});
expect(focused.metadataDraft.caption).toBe("Saved A");
expect(sameTargetRefresh.metadataDraft.caption).toBe("Local edit");
expect(sameTargetRefresh.metadataDirty).toBe(true);
expect(changedTarget.metadataDraft.caption).toBe("Saved B");
expect(changedTarget.metadataDirty).toBe(false);
});
it("resets upload fields and optionally adds uploaded media to the current cue draft", () => {
const state = {
...bootState([cue("cue-a", 0, "A")], "cue-a"),
selectedAssetIds: ["asset-a"],
uploadName: "Name",
uploadCaption: "Caption",
uploadPromptAnswer: "Prompt",
uploadAddToSelection: false,
cueDraftDirty: false
};
const uploaded = adminReducer(
{
...state,
uploadAddToSelection: true
},
{
type: "adminUploadSucceeded",
assetId: "asset-new",
addToSelection: true
}
);
expect(uploaded.selectedAssetIds).toEqual(["asset-new", "asset-a"]);
expect(uploaded.metadataAssetId).toBe("asset-new");
expect(uploaded.uploadName).toBe("");
expect(uploaded.uploadCaption).toBe("");
expect(uploaded.uploadPromptAnswer).toBe("");
expect(uploaded.uploadAddToSelection).toBe(true);
expect(uploaded.cueDraftDirty).toBe(true);
});
});
+931
View File
@@ -0,0 +1,931 @@
import {
armCue,
createCueRuntimeState,
takeCue,
triggerSafeScene,
type CueRuntimeState
} from "@goodgrief/cue-engine";
import { effectPresetLibrary } from "@goodgrief/effects";
import type { ProgramOutputState } from "../features/live/output-sync";
import {
moveCueInOrder,
sortCuesByOrder,
setSceneParamValue,
upsertCueInOrder,
Cue,
CueTransition,
type CueUpsertPayload,
PhotoAsset,
RepositoryState,
SceneDefinition,
SceneParamGroups,
type SceneParamScalar,
Submission
} from "@goodgrief/shared-types";
export interface CueDraftState {
id: string | null;
notes: string;
triggerMode: Cue["triggerMode"];
transitionInStyle: CueTransition["style"];
transitionInDurationMs: number;
transitionOutStyle: CueTransition["style"];
transitionOutDurationMs: number;
}
export interface MetadataDraftState {
contributorName: string;
lovedOneName: string;
displayName: string;
caption: string;
promptAnswer: string;
notes: string;
}
export type SceneBrowserFilter = "all" | SceneDefinition["sceneFamily"];
export type WorkspaceMode = "show" | "build";
export type ShowUtilityTab = "controls" | "notes" | "media" | "moderation";
export type BuildLibraryTab = "approved" | "pending" | "upload";
export const defaultCueTransition: CueTransition = {
style: "dissolve",
durationMs: 4000
};
export 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
});
export const createMetadataDraft = (submission?: Submission | null): MetadataDraftState => ({
contributorName: submission?.contributorName ?? "",
lovedOneName: submission?.lovedOneName ?? "",
displayName: submission?.displayName ?? "",
caption: submission?.caption ?? "",
promptAnswer: submission?.promptAnswer ?? "",
notes: submission?.notes ?? ""
});
export const sortCues = sortCuesByOrder;
export const moveCueInList = moveCueInOrder;
export const getApprovedAssets = (payload: Pick<RepositoryState, "photoAssets">) =>
payload.photoAssets.filter((asset) => asset.moderationStatus === "approved");
export 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";
});
export const getPendingModerationCount = (photoAssets: PhotoAsset[], submissions: Submission[]) =>
getPendingModerationAssets(photoAssets, submissions).length;
export const filterAvailableAssetIds = (payload: Pick<RepositoryState, "photoAssets">, assetIds: string[]) => {
const available = new Set(getApprovedAssets(payload).map((asset) => asset.id));
return assetIds.filter((assetId) => available.has(assetId));
};
export interface InitialLiveState {
cueState: CueRuntimeState;
selectedSceneId: string;
selectedAssetIds: string[];
previewParams?: SceneParamGroups;
activePresetId: string;
cueDraft: CueDraftState;
}
export interface AdminUiState {
repository: RepositoryState | null;
workspaceMode: WorkspaceMode;
showUtilityTab: ShowUtilityTab;
buildLibraryTab: BuildLibraryTab;
cueState: CueRuntimeState;
programOutputState: ProgramOutputState | null;
selectedSceneId: string;
sceneBrowserFilter: SceneBrowserFilter;
selectedAssetIds: string[];
metadataAssetId: string | null;
metadataDraft: MetadataDraftState;
metadataDirty: boolean;
metadataSaving: boolean;
metadataHydrationKey: string | null;
previewParams: SceneParamGroups | null;
activePresetId: string;
cueDraft: CueDraftState;
cueDraftDirty: boolean;
mediaSearch: string;
uploadName: string;
uploadCaption: string;
uploadPromptAnswer: string;
uploadFile: File | null;
uploadAddToSelection: boolean;
status: string;
pendingCount: number;
approvedCount: number;
cueMoveInFlight: boolean;
cueMutationInFlight: boolean;
}
export type AdminFieldUpdater<K extends keyof AdminUiState> =
| AdminUiState[K]
| ((current: AdminUiState[K]) => AdminUiState[K]);
export type AdminAction =
| {
type: "setField";
field: keyof AdminUiState;
value: unknown;
}
| {
type: "bootstrapLoaded";
payload: RepositoryState;
initial: InitialLiveState;
}
| {
type: "repositoryHydrated";
payload: RepositoryState;
}
| {
type: "liveLoaded";
cues: Cue[];
pendingCount: number;
approvedCount: number;
}
| {
type: "libraryLoaded";
photoAssets: PhotoAsset[];
submissions: Submission[];
collections: RepositoryState["collections"];
}
| {
type: "statusChanged";
status: string;
}
| {
type: "previewSceneSelected";
scene: SceneDefinition;
presetId: string;
params: SceneParamGroups;
assetIds: string[];
}
| {
type: "previewCueSelected";
cue: Cue;
scene: SceneDefinition;
presetId: string;
params: SceneParamGroups;
assetIds: string[];
armPreview?: boolean;
}
| {
type: "generatedCueDraftLoaded";
draft: CueUpsertPayload;
scene: SceneDefinition;
presetId: string;
params: SceneParamGroups;
assetIds: string[];
}
| {
type: "previewReset";
initial: InitialLiveState;
}
| {
type: "previewPresetApplied";
presetId: string;
params: SceneParamGroups;
status?: string;
}
| {
type: "previewParamsReplaced";
params: SceneParamGroups;
status?: string;
}
| {
type: "previewParamChanged";
path: string;
value: SceneParamScalar;
}
| {
type: "cueDraftChanged";
field: keyof CueDraftState;
value: CueDraftState[keyof CueDraftState];
}
| {
type: "selectedAssetsReplaced";
assetIds: string[];
metadataAssetId?: string | null;
status?: string;
}
| {
type: "selectedAssetsCleared";
status?: string;
}
| {
type: "selectedAssetToggled";
assetId: string;
}
| {
type: "selectedAssetFocused";
assetId: string | null;
}
| {
type: "selectedAssetPromoted";
assetId: string;
status?: string;
}
| {
type: "selectedAssetReordered";
assetId: string;
direction: "earlier" | "later";
}
| {
type: "selectedAssetRemoved";
assetId: string;
}
| {
type: "approvedAssetRemoved";
assetId: string;
}
| {
type: "metadataTargetSelected";
assetId: string | null;
submission?: Submission | null;
}
| {
type: "metadataDraftChanged";
field: keyof MetadataDraftState;
value: string;
}
| {
type: "metadataDraftReset";
submission?: Submission | null;
status?: string;
}
| {
type: "metadataSaveStarted";
}
| {
type: "metadataSaveFinished";
}
| {
type: "adminUploadSucceeded";
assetId: string | null;
addToSelection: boolean;
}
| {
type: "blackoutSet";
blackout: boolean;
}
| {
type: "previewCueSkipped";
cueId: string;
}
| {
type: "programCueTaken";
previewUsesArmedCue: boolean;
}
| {
type: "safeCueTriggered";
cueId: string;
}
| {
type: "cueMoveOptimistic";
cueId: string;
direction: "up" | "down";
}
| {
type: "cueMoveSucceeded";
cues: Cue[];
cueId: string;
}
| {
type: "cueMoveFailed";
}
| {
type: "cueMutationStarted";
}
| {
type: "cueMutationFinished";
}
| {
type: "cueUpsertSucceeded";
cue: Cue;
scene?: SceneDefinition;
}
| {
type: "cueDeleted";
cueId: string;
}
| {
type: "metadataSaved";
submission: Submission;
};
export const initialAdminUiState: AdminUiState = {
repository: null,
workspaceMode: "show",
showUtilityTab: "controls",
buildLibraryTab: "approved",
cueState: createCueRuntimeState([]),
programOutputState: null,
selectedSceneId: "",
sceneBrowserFilter: "all",
selectedAssetIds: [],
metadataAssetId: null,
metadataDraft: createMetadataDraft(),
metadataDirty: false,
metadataSaving: false,
metadataHydrationKey: null,
previewParams: null,
activePresetId: effectPresetLibrary[0]?.id ?? "",
cueDraft: createCueDraft(),
cueDraftDirty: false,
mediaSearch: "",
uploadName: "",
uploadCaption: "",
uploadPromptAnswer: "",
uploadFile: null,
uploadAddToSelection: true,
status: "Connecting to local show state...",
pendingCount: 0,
approvedCount: 0,
cueMoveInFlight: false,
cueMutationInFlight: false
};
const updateRepositoryCues = (repository: RepositoryState | null, cues: Cue[]) =>
repository ? { ...repository, cues: sortCues(cues) } : repository;
const reconcileCueDraft = (state: AdminUiState, cues: Cue[]) => {
if (!state.cueDraft.id || state.cueDraftDirty || !state.repository) {
return state;
}
const cue = cues.find((entry) => entry.id === state.cueDraft.id);
const scene = cue ? state.repository.scenes.find((entry) => entry.id === cue.sceneDefinitionId) : undefined;
if (!cue || !scene) {
return {
...state,
cueDraft: createCueDraft(undefined, state.repository.scenes.find((sceneEntry) => sceneEntry.id === state.selectedSceneId)),
cueDraftDirty: false,
status: "Selected cue changed externally. Preview is now an unsaved draft."
};
}
return {
...state,
cueDraft: createCueDraft(cue, scene)
};
};
const reconcileCues = (state: AdminUiState, cues: Cue[]) => {
const sorted = sortCues(cues);
const cueIds = new Set(sorted.map((cue) => cue.id));
const previewCueId = state.cueState.previewCueId && cueIds.has(state.cueState.previewCueId)
? state.cueState.previewCueId
: sorted[0]?.id ?? null;
const armedCueId = state.cueState.armedCueId && cueIds.has(state.cueState.armedCueId)
? state.cueState.armedCueId
: previewCueId;
const currentCueId = state.cueState.currentCueId && cueIds.has(state.cueState.currentCueId)
? state.cueState.currentCueId
: null;
return reconcileCueDraft(
{
...state,
repository: updateRepositoryCues(state.repository, sorted),
cueState: {
...state.cueState,
cueStack: sorted,
previewCueId,
armedCueId,
currentCueId
}
},
sorted
);
};
const upsertCueIntoList = (cues: Cue[], cue: Cue) => {
return upsertCueInOrder(cues, cue);
};
const moveItem = <T,>(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 markCueDraftDirty = (state: AdminUiState) =>
state.cueDraft.id ? { ...state, cueDraftDirty: true } : state;
const setSelectedAssets = (
state: AdminUiState,
assetIds: string[],
metadataAssetId: string | null | undefined = assetIds[0] ?? state.metadataAssetId,
status?: string
) =>
markCueDraftDirty({
...state,
selectedAssetIds: assetIds,
metadataAssetId: metadataAssetId ?? null,
...(typeof status === "string" ? { status } : {})
});
const metadataHydrationKey = (assetId: string | null, submission?: Submission | null) =>
assetId && submission ? `${assetId}:${submission.id}` : assetId;
export const adminReducer = (state: AdminUiState, action: AdminAction): AdminUiState => {
switch (action.type) {
case "setField": {
const current = state[action.field] as unknown;
const nextValue =
typeof action.value === "function"
? (action.value as (current: unknown) => unknown)(current)
: action.value;
return {
...state,
[action.field]: nextValue
} as AdminUiState;
}
case "statusChanged":
return {
...state,
status: action.status
};
case "bootstrapLoaded":
return {
...state,
repository: {
...action.payload,
cues: sortCues(action.payload.cues)
},
pendingCount: getPendingModerationCount(action.payload.photoAssets, action.payload.submissions),
approvedCount: getApprovedAssets(action.payload).length,
cueState: {
...action.initial.cueState,
cueStack: sortCues(action.initial.cueState.cueStack)
},
selectedSceneId: action.initial.selectedSceneId,
sceneBrowserFilter: "all",
selectedAssetIds: action.initial.selectedAssetIds,
metadataAssetId: action.initial.selectedAssetIds[0] ?? null,
metadataDraft: createMetadataDraft(),
metadataDirty: false,
metadataHydrationKey: null,
previewParams: action.initial.previewParams ?? null,
activePresetId: action.initial.activePresetId,
cueDraft: action.initial.cueDraft,
cueDraftDirty: false,
status: "Ready. Local show state loaded."
};
case "repositoryHydrated":
return reconcileCues(
{
...state,
repository: {
...action.payload,
cues: sortCues(action.payload.cues)
},
pendingCount: getPendingModerationCount(action.payload.photoAssets, action.payload.submissions),
approvedCount: getApprovedAssets(action.payload).length,
selectedAssetIds: filterAvailableAssetIds(action.payload, state.selectedAssetIds)
},
action.payload.cues
);
case "liveLoaded": {
const countedState = {
...state,
pendingCount: action.pendingCount,
approvedCount: action.approvedCount
};
if (state.cueMoveInFlight || state.cueMutationInFlight) {
return countedState;
}
return reconcileCues(
countedState,
action.cues
);
}
case "libraryLoaded": {
if (!state.repository) {
return state;
}
const availableIds = new Set(
action.photoAssets
.filter((asset) => asset.moderationStatus === "approved")
.map((asset) => asset.id)
);
const knownIds = new Set(action.photoAssets.map((asset) => asset.id));
const nextMetadataSubmission = state.metadataAssetId
? action.submissions.find((submission) =>
action.photoAssets.some(
(asset) => asset.id === state.metadataAssetId && asset.submissionId === submission.id
)
)
: undefined;
return {
...state,
repository: {
...state.repository,
photoAssets: action.photoAssets,
submissions: action.submissions,
collections: action.collections
},
pendingCount: getPendingModerationCount(action.photoAssets, action.submissions),
approvedCount: action.photoAssets.filter((asset) => asset.moderationStatus === "approved").length,
selectedAssetIds: state.selectedAssetIds.filter((assetId) => availableIds.has(assetId)),
metadataAssetId: state.metadataAssetId && knownIds.has(state.metadataAssetId) ? state.metadataAssetId : null,
metadataDraft:
state.metadataDirty || !nextMetadataSubmission
? state.metadataDraft
: createMetadataDraft(nextMetadataSubmission),
metadataHydrationKey:
state.metadataDirty || !state.metadataAssetId || !nextMetadataSubmission
? state.metadataHydrationKey
: metadataHydrationKey(state.metadataAssetId, nextMetadataSubmission)
};
}
case "previewSceneSelected":
return {
...state,
selectedSceneId: action.scene.id,
sceneBrowserFilter: action.scene.sceneFamily,
selectedAssetIds: action.assetIds,
metadataAssetId: action.assetIds[0] ?? null,
previewParams: action.params,
activePresetId: action.presetId,
cueDraft: createCueDraft(undefined, action.scene),
cueDraftDirty: false
};
case "previewCueSelected":
return {
...state,
cueState: action.armPreview ?? true ? armCue(state.cueState, action.cue.id) : state.cueState,
selectedSceneId: action.scene.id,
sceneBrowserFilter: action.scene.sceneFamily,
selectedAssetIds: action.assetIds,
metadataAssetId: action.assetIds[0] ?? null,
previewParams: action.params,
activePresetId: action.presetId,
cueDraft: createCueDraft(action.cue, action.scene),
cueDraftDirty: false
};
case "generatedCueDraftLoaded":
return {
...state,
selectedSceneId: action.scene.id,
sceneBrowserFilter: action.scene.sceneFamily,
selectedAssetIds: action.assetIds,
metadataAssetId: action.assetIds[0] ?? null,
previewParams: action.params,
activePresetId: action.presetId,
cueDraft: {
id: null,
notes: action.draft.notes ?? action.scene.name,
triggerMode: action.draft.triggerMode,
transitionInStyle: action.draft.transitionIn.style,
transitionInDurationMs: action.draft.transitionIn.durationMs,
transitionOutStyle: action.draft.transitionOut.style,
transitionOutDurationMs: action.draft.transitionOut.durationMs
},
cueDraftDirty: false
};
case "previewReset":
return {
...state,
cueState: action.initial.cueState,
selectedSceneId: action.initial.selectedSceneId,
sceneBrowserFilter: "all",
selectedAssetIds: action.initial.selectedAssetIds,
metadataAssetId: action.initial.selectedAssetIds[0] ?? null,
previewParams: action.initial.previewParams ?? null,
activePresetId: action.initial.activePresetId,
cueDraft: action.initial.cueDraft,
cueDraftDirty: false,
status: "Reset to safe hold on program and opening cue in preview."
};
case "previewPresetApplied":
return markCueDraftDirty({
...state,
activePresetId: action.presetId,
previewParams: action.params,
...(action.status ? { status: action.status } : {})
});
case "previewParamsReplaced":
return markCueDraftDirty({
...state,
previewParams: action.params,
...(action.status ? { status: action.status } : {})
});
case "previewParamChanged":
return state.previewParams
? markCueDraftDirty({
...state,
previewParams: setSceneParamValue(state.previewParams, action.path, action.value)
})
: state;
case "cueDraftChanged":
return markCueDraftDirty({
...state,
cueDraft: {
...state.cueDraft,
[action.field]: action.value
}
});
case "selectedAssetsReplaced":
return setSelectedAssets(state, action.assetIds, action.metadataAssetId, action.status);
case "selectedAssetsCleared":
return setSelectedAssets(state, [], null, action.status);
case "selectedAssetToggled": {
const nextAssetIds = state.selectedAssetIds.includes(action.assetId)
? state.selectedAssetIds.filter((candidate) => candidate !== action.assetId)
: [...state.selectedAssetIds, action.assetId].slice(-12);
return setSelectedAssets(state, nextAssetIds, action.assetId);
}
case "selectedAssetFocused":
return {
...state,
metadataAssetId: action.assetId
};
case "selectedAssetPromoted": {
const index = state.selectedAssetIds.indexOf(action.assetId);
return setSelectedAssets(
state,
index <= 0 ? state.selectedAssetIds : moveItem(state.selectedAssetIds, index, 0),
action.assetId,
action.status
);
}
case "selectedAssetReordered": {
const index = state.selectedAssetIds.indexOf(action.assetId);
if (index === -1) {
return state;
}
const nextIndex = action.direction === "earlier" ? index - 1 : index + 1;
return setSelectedAssets(state, moveItem(state.selectedAssetIds, index, nextIndex), action.assetId);
}
case "selectedAssetRemoved":
return setSelectedAssets(
state,
state.selectedAssetIds.filter((candidate) => candidate !== action.assetId),
state.metadataAssetId === action.assetId ? null : state.metadataAssetId
);
case "approvedAssetRemoved":
if (!state.selectedAssetIds.includes(action.assetId) && state.metadataAssetId !== action.assetId) {
return state;
}
return setSelectedAssets(
state,
state.selectedAssetIds.filter((candidate) => candidate !== action.assetId),
state.metadataAssetId === action.assetId ? null : state.metadataAssetId
);
case "metadataTargetSelected": {
const nextKey = metadataHydrationKey(action.assetId, action.submission);
const targetChanged = state.metadataHydrationKey !== nextKey;
if (!action.assetId) {
return {
...state,
metadataAssetId: null,
metadataHydrationKey: null,
metadataDraft: state.metadataDirty ? state.metadataDraft : createMetadataDraft()
};
}
return {
...state,
metadataAssetId: action.assetId,
metadataHydrationKey: nextKey,
metadataDraft:
targetChanged || !state.metadataDirty
? createMetadataDraft(action.submission ?? undefined)
: state.metadataDraft,
metadataDirty: targetChanged ? false : state.metadataDirty
};
}
case "metadataDraftChanged":
return {
...state,
metadataDraft: {
...state.metadataDraft,
[action.field]: action.value
},
metadataDirty: true
};
case "metadataDraftReset":
return {
...state,
metadataDraft: createMetadataDraft(action.submission ?? undefined),
metadataDirty: false,
...(action.status ? { status: action.status } : {})
};
case "metadataSaveStarted":
return {
...state,
metadataSaving: true
};
case "metadataSaveFinished":
return {
...state,
metadataSaving: false
};
case "adminUploadSucceeded": {
const selectedAssetIds =
action.addToSelection && action.assetId
? [action.assetId, ...state.selectedAssetIds.filter((assetId) => assetId !== action.assetId)].slice(0, 12)
: state.selectedAssetIds;
const next = {
...state,
selectedAssetIds,
metadataAssetId: action.assetId ?? state.metadataAssetId,
metadataDirty: false,
uploadFile: null,
uploadName: "",
uploadCaption: "",
uploadPromptAnswer: "",
uploadAddToSelection: true
};
return action.addToSelection && action.assetId ? markCueDraftDirty(next) : next;
}
case "blackoutSet":
return {
...state,
cueState: {
...state.cueState,
blackout: action.blackout
}
};
case "previewCueSkipped":
return {
...state,
cueState: armCue(state.cueState, action.cueId)
};
case "programCueTaken":
return {
...state,
cueState: action.previewUsesArmedCue
? takeCue(state.cueState)
: {
...state.cueState,
blackout: false,
safeSceneActive: false
}
};
case "safeCueTriggered":
return {
...state,
cueState: triggerSafeScene(state.cueState, action.cueId)
};
case "cueMoveOptimistic": {
const currentCues = state.repository?.cues ?? state.cueState.cueStack;
return reconcileCues(
{
...state,
cueMoveInFlight: true,
cueState: armCue(state.cueState, action.cueId)
},
moveCueInList(currentCues, action.cueId, action.direction)
);
}
case "cueMoveSucceeded":
return reconcileCues(
{
...state,
cueMoveInFlight: false,
cueState: armCue(state.cueState, action.cueId),
status: "Cue moved."
},
action.cues
);
case "cueMoveFailed":
return {
...state,
cueMoveInFlight: false
};
case "cueMutationStarted":
return {
...state,
cueMutationInFlight: true
};
case "cueMutationFinished":
return {
...state,
cueMutationInFlight: false
};
case "cueUpsertSucceeded": {
const currentCues = state.repository?.cues ?? state.cueState.cueStack;
const cues = upsertCueIntoList(currentCues, action.cue);
return reconcileCues(
{
...state,
cueMutationInFlight: false,
cueDraft: createCueDraft(action.cue, action.scene),
cueDraftDirty: false,
cueState: armCue(state.cueState, action.cue.id)
},
cues
);
}
case "cueDeleted": {
const cues = sortCues((state.repository?.cues ?? state.cueState.cueStack).filter((cue) => cue.id !== action.cueId));
const deletedIndex = state.cueState.cueStack.findIndex((cue) => cue.id === action.cueId);
const nextCue = cues[Math.min(Math.max(deletedIndex, 0), Math.max(cues.length - 1, 0))] ?? null;
return reconcileCues(
{
...state,
cueMutationInFlight: false,
cueDraft: createCueDraft(undefined, state.repository?.scenes.find((scene) => scene.id === state.selectedSceneId)),
cueDraftDirty: false,
cueState: nextCue ? armCue(state.cueState, nextCue.id) : state.cueState
},
cues
);
}
case "metadataSaved":
return {
...state,
repository: state.repository
? {
...state.repository,
submissions: state.repository.submissions.map((submission) =>
submission.id === action.submission.id ? action.submission : submission
)
}
: state.repository,
metadataDraft: createMetadataDraft(action.submission),
metadataDirty: false,
metadataSaving: false,
metadataHydrationKey: metadataHydrationKey(state.metadataAssetId, action.submission)
};
default:
return state;
}
};
+8 -3
View File
@@ -100,9 +100,14 @@ export const updateCue = async (cueId: string, payload: CueUpsertPayload) =>
body: JSON.stringify(payload)
});
export const moveCue = async (cueId: string, payload: CueMovePayload) => {
await postVoid(`/api/cues/${cueId}/move`, payload);
};
export const moveCue = async (cueId: string, payload: CueMovePayload) =>
requestJson<Cue[]>(`/api/cues/${cueId}/move`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
export const generateCue = async (payload: CueGeneratePayload) =>
requestJson<CueUpsertPayload>("/api/cues/generate", {