Stabilize admin state and submission metadata

This commit is contained in:
vance 2026-04-10 15:48:32 -07:00
parent 215ead0768
commit bd2087ba5f
14 changed files with 2171 additions and 463 deletions

View File

@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"check": "tsc --noEmit" "check": "tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@goodgrief/cue-engine": "file:../../packages/cue-engine", "@goodgrief/cue-engine": "file:../../packages/cue-engine",
@ -21,6 +22,7 @@
"@types/react-dom": "^19.1.3", "@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.5" "vite": "^6.3.5",
"vitest": "^4.1.4"
} }
} }

File diff suppressed because it is too large Load Diff

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);
});
});

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;
}
};

View File

@ -100,9 +100,14 @@ export const updateCue = async (cueId: string, payload: CueUpsertPayload) =>
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
export const moveCue = async (cueId: string, payload: CueMovePayload) => { export const moveCue = async (cueId: string, payload: CueMovePayload) =>
await postVoid(`/api/cues/${cueId}/move`, payload); requestJson<Cue[]>(`/api/cues/${cueId}/move`, {
}; method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
export const generateCue = async (payload: CueGeneratePayload) => export const generateCue = async (payload: CueGeneratePayload) =>
requestJson<CueUpsertPayload>("/api/cues/generate", { requestJson<CueUpsertPayload>("/api/cues/generate", {

View File

@ -6,6 +6,8 @@ export interface CreateSubmissionResponse {
} }
export interface CreateSubmissionInput { export interface CreateSubmissionInput {
contributorName?: string;
lovedOneName?: string;
displayName?: string; displayName?: string;
caption?: string; caption?: string;
promptAnswer?: string; promptAnswer?: string;
@ -29,7 +31,10 @@ export const createSubmission = async (
const formData = new FormData(); const formData = new FormData();
formData.append("file", input.file); 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("caption", input.caption ?? "");
formData.append("promptAnswer", input.promptAnswer ?? ""); formData.append("promptAnswer", input.promptAnswer ?? "");
formData.append("allowArchive", String(input.allowArchive)); formData.append("allowArchive", String(input.allowArchive));

View File

@ -2,7 +2,8 @@ import { useState } from "react";
import { createSubmission } from "./api"; import { createSubmission } from "./api";
export interface SubmissionFormState { export interface SubmissionFormState {
displayName: string; contributorName: string;
lovedOneName: string;
caption: string; caption: string;
promptAnswer: string; promptAnswer: string;
allowArchive: boolean; allowArchive: boolean;
@ -11,7 +12,8 @@ export interface SubmissionFormState {
} }
const initialState: SubmissionFormState = { const initialState: SubmissionFormState = {
displayName: "", contributorName: "",
lovedOneName: "",
caption: "", caption: "",
promptAnswer: "", promptAnswer: "",
allowArchive: false, allowArchive: false,
@ -56,7 +58,8 @@ export const useSubmissionForm = () => {
try { try {
await createSubmission( await createSubmission(
{ {
displayName: state.displayName, contributorName: state.contributorName,
lovedOneName: state.lovedOneName,
caption: state.caption, caption: state.caption,
promptAnswer: state.promptAnswer, promptAnswer: state.promptAnswer,
allowArchive: state.allowArchive, allowArchive: state.allowArchive,

View File

@ -32,13 +32,25 @@ export const SubmissionRoute = () => {
</div> </div>
<div className="submission-field"> <div className="submission-field">
<label htmlFor="displayName">Name or initials (optional)</label> <label htmlFor="contributorName">Your name (optional)</label>
<input <input
id="displayName" id="contributorName"
type="text" type="text"
value={state.displayName} value={state.contributorName}
maxLength={80} 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> </div>

371
package-lock.json generated
View File

@ -34,7 +34,8 @@
"@types/react-dom": "^19.1.3", "@types/react-dom": "^19.1.3",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.5" "vite": "^6.3.5",
"vitest": "^4.1.4"
} }
}, },
"apps/submission": { "apps/submission": {
@ -1933,6 +1934,13 @@
"win32" "win32"
] ]
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tweenjs/tween.js": { "node_modules/@tweenjs/tween.js": {
"version": "23.1.3", "version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
@ -1985,6 +1993,24 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -2073,6 +2099,119 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.4",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"@vitest/utils": "4.1.4",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@webgpu/types": { "node_modules/@webgpu/types": {
"version": "0.1.69", "version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
@ -2119,6 +2258,16 @@
} }
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/atomic-sleep": { "node_modules/atomic-sleep": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@ -2237,6 +2386,16 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@ -2383,6 +2542,13 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.7", "version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
@ -2441,6 +2607,26 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-decode-uri-component": { "node_modules/fast-decode-uri-component": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
@ -2856,6 +3042,16 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/meshoptimizer": { "node_modules/meshoptimizer": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
@ -2932,6 +3128,17 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/on-exit-leak-free": { "node_modules/on-exit-leak-free": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@ -2981,6 +3188,13 @@
"node": "20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -3423,6 +3637,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@ -3472,6 +3693,13 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -3481,6 +3709,13 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/std-env": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"dev": true,
"license": "MIT"
},
"node_modules/thread-stream": { "node_modules/thread-stream": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
@ -3499,6 +3734,23 @@
"integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==", "integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
"integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -3516,6 +3768,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/toad-cache": { "node_modules/toad-cache": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
@ -4172,6 +4434,96 @@
"@esbuild/win32-x64": "0.25.12" "@esbuild/win32-x64": "0.25.12"
} }
}, },
"node_modules/vitest": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
"@vitest/runner": "4.1.4",
"@vitest/snapshot": "4.1.4",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.4",
"@vitest/browser-preview": "4.1.4",
"@vitest/browser-webdriverio": "4.1.4",
"@vitest/coverage-istanbul": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"@vitest/ui": "4.1.4",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -4187,6 +4539,23 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -0,0 +1,38 @@
import type { Cue } from "./entities";
export const indexCueOrder = (cues: Cue[]) =>
cues.map((cue, index) => ({
...cue,
orderIndex: index
}));
export const sortCuesByOrder = (cues: Cue[]) =>
indexCueOrder([...cues].sort((left, right) => left.orderIndex - right.orderIndex));
export const moveCueInOrder = (cues: Cue[], cueId: string, direction: "up" | "down") => {
const sorted = sortCuesByOrder(cues);
const currentIndex = sorted.findIndex((cue) => cue.id === cueId);
if (currentIndex < 0) {
return sorted;
}
const swapIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
if (swapIndex < 0 || swapIndex >= sorted.length) {
return sorted;
}
const next = [...sorted];
[next[currentIndex], next[swapIndex]] = [next[swapIndex]!, next[currentIndex]!];
return indexCueOrder(next);
};
export const upsertCueInOrder = (cues: Cue[], cue: Cue) => {
const sorted = sortCuesByOrder(cues);
const existingIndex = sorted.findIndex((entry) => entry.id === cue.id);
if (existingIndex >= 0) {
sorted.splice(existingIndex, 1);
}
sorted.splice(Math.max(0, Math.min(cue.orderIndex, sorted.length)), 0, cue);
return indexCueOrder(sorted);
};

View File

@ -57,6 +57,9 @@ export interface Submission {
submittedAt: string; submittedAt: string;
status: SubmissionStatus; status: SubmissionStatus;
consentId: string; consentId: string;
contributorName?: string;
lovedOneName?: string;
/** Legacy/operator label retained for existing runtime data and older clients. */
displayName?: string; displayName?: string;
caption?: string; caption?: string;
promptAnswer?: string; promptAnswer?: string;
@ -344,6 +347,8 @@ export interface SessionEvent {
} }
export interface SubmissionPayload { export interface SubmissionPayload {
contributorName?: string;
lovedOneName?: string;
displayName?: string; displayName?: string;
caption?: string; caption?: string;
promptAnswer?: string; promptAnswer?: string;
@ -355,6 +360,8 @@ export interface SubmissionPayload {
} }
export interface SubmissionUpdatePayload { export interface SubmissionUpdatePayload {
contributorName?: string;
lovedOneName?: string;
displayName?: string; displayName?: string;
caption?: string; caption?: string;
promptAnswer?: string; promptAnswer?: string;

View File

@ -1,4 +1,5 @@
export * from "./entities"; export * from "./entities";
export * from "./cue-order";
export * from "./events"; export * from "./events";
export * from "./mock"; export * from "./mock";
export * from "./scene-params"; export * from "./scene-params";

View File

@ -174,8 +174,12 @@ const createSubmissionFromMultipart = async (
const storedAssetId = crypto.randomUUID(); const storedAssetId = crypto.randomUUID();
const originalKey = await storeUploadedFile(filePart, storedAssetId); const originalKey = await storeUploadedFile(filePart, storedAssetId);
const contributorName = fields.contributorName || defaults.contributorName || undefined;
const lovedOneName = fields.lovedOneName || defaults.lovedOneName || undefined;
const payload: SubmissionPayload = { const payload: SubmissionPayload = {
displayName: fields.displayName || defaults.displayName || undefined, contributorName,
lovedOneName,
displayName: fields.displayName || defaults.displayName || lovedOneName || contributorName || undefined,
caption: fields.caption || defaults.caption || undefined, caption: fields.caption || defaults.caption || undefined,
promptAnswer: fields.promptAnswer || defaults.promptAnswer || undefined, promptAnswer: fields.promptAnswer || defaults.promptAnswer || undefined,
allowArchive: defaults.allowArchive ?? coerceBoolean(fields.allowArchive), allowArchive: defaults.allowArchive ?? coerceBoolean(fields.allowArchive),

View File

@ -10,7 +10,9 @@ import {
defaultShowConfig, defaultShowConfig,
defaultTags, defaultTags,
flattenSceneParams, flattenSceneParams,
indexCueOrder,
mergeSceneParams, mergeSceneParams,
sortCuesByOrder,
setSceneParamValue, setSceneParamValue,
type ContributorConsent, type ContributorConsent,
type Cue, type Cue,
@ -174,6 +176,8 @@ const mergeImportedSubmission = (existing: Submission | undefined, imported: Sub
return { return {
...imported, ...imported,
...existing, ...existing,
contributorName: existing.contributorName ?? imported.contributorName,
lovedOneName: existing.lovedOneName ?? imported.lovedOneName,
displayName: existing.displayName ?? imported.displayName, displayName: existing.displayName ?? imported.displayName,
caption: existing.caption ?? imported.caption, caption: existing.caption ?? imported.caption,
promptAnswer: existing.promptAnswer ?? imported.promptAnswer, promptAnswer: existing.promptAnswer ?? imported.promptAnswer,
@ -181,13 +185,7 @@ const mergeImportedSubmission = (existing: Submission | undefined, imported: Sub
}; };
}; };
const normalizeCueOrder = (cues: Cue[]) => const normalizeCueOrder = sortCuesByOrder;
[...cues]
.sort((left, right) => left.orderIndex - right.orderIndex)
.map((cue, index) => ({
...cue,
orderIndex: index
}));
const ensureSafeCue = (cues: Cue[]) => { const ensureSafeCue = (cues: Cue[]) => {
const safeCue = defaultCueStack.find((cue) => cue.id === defaultShowConfig.safeSceneCueId); const safeCue = defaultCueStack.find((cue) => cue.id === defaultShowConfig.safeSceneCueId);
@ -346,7 +344,10 @@ const buildGeneratedCueDraft = (state: RepositoryState, payload: CueGeneratePayl
const anchorSubmission = state.submissions.find((submission) => submission.id === selectedAssets[0]?.submissionId); const anchorSubmission = state.submissions.find((submission) => submission.id === selectedAssets[0]?.submissionId);
const anchorLabel = const anchorLabel =
anchorSubmission?.caption?.trim() || anchorSubmission?.promptAnswer?.trim() || anchorSubmission?.displayName?.trim(); anchorSubmission?.caption?.trim() ||
anchorSubmission?.promptAnswer?.trim() ||
anchorSubmission?.lovedOneName?.trim() ||
anchorSubmission?.displayName?.trim();
const transitionOptions = const transitionOptions =
scene.sceneFamily === "arrival" scene.sceneFamily === "arrival"
? (["shutter_reveal", "mist_reveal", "dissolve"] as const) ? (["shutter_reveal", "mist_reveal", "dissolve"] as const)
@ -444,7 +445,13 @@ export class StateStore {
submittedAt: now, submittedAt: now,
status: "processing", status: "processing",
consentId, consentId,
displayName: input.displayName, contributorName: normalizeEditableText(input.contributorName) || undefined,
lovedOneName: normalizeEditableText(input.lovedOneName) || undefined,
displayName:
normalizeEditableText(input.displayName) ||
normalizeEditableText(input.lovedOneName) ||
normalizeEditableText(input.contributorName) ||
undefined,
caption: input.caption, caption: input.caption,
promptAnswer: input.promptAnswer promptAnswer: input.promptAnswer
}; };
@ -489,6 +496,12 @@ export class StateStore {
throw new Error("Submission not found."); throw new Error("Submission not found.");
} }
if (Object.prototype.hasOwnProperty.call(payload, "contributorName")) {
submission.contributorName = normalizeEditableText(payload.contributorName);
}
if (Object.prototype.hasOwnProperty.call(payload, "lovedOneName")) {
submission.lovedOneName = normalizeEditableText(payload.lovedOneName);
}
if (Object.prototype.hasOwnProperty.call(payload, "displayName")) { if (Object.prototype.hasOwnProperty.call(payload, "displayName")) {
submission.displayName = normalizeEditableText(payload.displayName); submission.displayName = normalizeEditableText(payload.displayName);
} }
@ -671,7 +684,7 @@ export class StateStore {
sorted.splice(targetIndex, 0, baseCue); sorted.splice(targetIndex, 0, baseCue);
} }
state.cues = normalizeCueOrder(sorted); state.cues = indexCueOrder(sorted);
return state; return state;
}); });
} }
@ -698,7 +711,7 @@ export class StateStore {
const current = sorted[currentIndex]!; const current = sorted[currentIndex]!;
sorted[currentIndex] = sorted[swapIndex]!; sorted[currentIndex] = sorted[swapIndex]!;
sorted[swapIndex] = current; sorted[swapIndex] = current;
state.cues = normalizeCueOrder(sorted); state.cues = indexCueOrder(sorted);
return state; return state;
}); });
} }