Stabilize admin state and submission metadata
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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", {
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface CreateSubmissionResponse {
|
||||
}
|
||||
|
||||
export interface CreateSubmissionInput {
|
||||
contributorName?: string;
|
||||
lovedOneName?: string;
|
||||
displayName?: string;
|
||||
caption?: string;
|
||||
promptAnswer?: string;
|
||||
@@ -29,7 +31,10 @@ export const createSubmission = async (
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", input.file);
|
||||
formData.append("displayName", input.displayName ?? "");
|
||||
formData.append("contributorName", input.contributorName ?? "");
|
||||
formData.append("lovedOneName", input.lovedOneName ?? "");
|
||||
// Compatibility label for older deployed APIs that only know displayName.
|
||||
formData.append("displayName", input.displayName ?? input.lovedOneName ?? input.contributorName ?? "");
|
||||
formData.append("caption", input.caption ?? "");
|
||||
formData.append("promptAnswer", input.promptAnswer ?? "");
|
||||
formData.append("allowArchive", String(input.allowArchive));
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState } from "react";
|
||||
import { createSubmission } from "./api";
|
||||
|
||||
export interface SubmissionFormState {
|
||||
displayName: string;
|
||||
contributorName: string;
|
||||
lovedOneName: string;
|
||||
caption: string;
|
||||
promptAnswer: string;
|
||||
allowArchive: boolean;
|
||||
@@ -11,7 +12,8 @@ export interface SubmissionFormState {
|
||||
}
|
||||
|
||||
const initialState: SubmissionFormState = {
|
||||
displayName: "",
|
||||
contributorName: "",
|
||||
lovedOneName: "",
|
||||
caption: "",
|
||||
promptAnswer: "",
|
||||
allowArchive: false,
|
||||
@@ -56,7 +58,8 @@ export const useSubmissionForm = () => {
|
||||
try {
|
||||
await createSubmission(
|
||||
{
|
||||
displayName: state.displayName,
|
||||
contributorName: state.contributorName,
|
||||
lovedOneName: state.lovedOneName,
|
||||
caption: state.caption,
|
||||
promptAnswer: state.promptAnswer,
|
||||
allowArchive: state.allowArchive,
|
||||
|
||||
@@ -32,13 +32,25 @@ export const SubmissionRoute = () => {
|
||||
</div>
|
||||
|
||||
<div className="submission-field">
|
||||
<label htmlFor="displayName">Name or initials (optional)</label>
|
||||
<label htmlFor="contributorName">Your name (optional)</label>
|
||||
<input
|
||||
id="displayName"
|
||||
id="contributorName"
|
||||
type="text"
|
||||
value={state.displayName}
|
||||
value={state.contributorName}
|
||||
maxLength={80}
|
||||
onChange={(event) => updateField("displayName", event.target.value)}
|
||||
autoComplete="name"
|
||||
onChange={(event) => updateField("contributorName", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="submission-field">
|
||||
<label htmlFor="lovedOneName">Name of your loved one (optional)</label>
|
||||
<input
|
||||
id="lovedOneName"
|
||||
type="text"
|
||||
value={state.lovedOneName}
|
||||
maxLength={80}
|
||||
onChange={(event) => updateField("lovedOneName", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user