Stabilize admin state and submission metadata
This commit is contained in:
parent
215ead0768
commit
bd2087ba5f
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
339
apps/admin/src/app/admin-state.test.ts
Normal file
339
apps/admin/src/app/admin-state.test.ts
Normal file
@ -0,0 +1,339 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCueRuntimeState } from "@goodgrief/cue-engine";
|
||||
import {
|
||||
createEmptyRepositoryState,
|
||||
defaultCueStack,
|
||||
defaultSceneDefinitions,
|
||||
type Cue,
|
||||
type PhotoAsset,
|
||||
type Submission
|
||||
} from "@goodgrief/shared-types";
|
||||
import {
|
||||
adminReducer,
|
||||
createCueDraft,
|
||||
createMetadataDraft,
|
||||
initialAdminUiState,
|
||||
sortCues
|
||||
} from "./admin-state";
|
||||
|
||||
const scene = defaultSceneDefinitions[0]!;
|
||||
const otherScene = defaultSceneDefinitions.find((entry) => entry.id !== scene.id)!;
|
||||
|
||||
const cue = (id: string, orderIndex: number, notes: string): Cue => ({
|
||||
...defaultCueStack[0]!,
|
||||
id,
|
||||
orderIndex,
|
||||
sceneDefinitionId: scene.id,
|
||||
notes
|
||||
});
|
||||
|
||||
const submission = (id: string, caption: string): Submission => ({
|
||||
id,
|
||||
source: "live",
|
||||
submittedAt: "2026-04-10T00:00:00.000Z",
|
||||
status: "approved_all",
|
||||
consentId: `consent-${id}`,
|
||||
displayName: `Display ${id}`,
|
||||
caption
|
||||
});
|
||||
|
||||
const asset = (
|
||||
id: string,
|
||||
submissionId: string,
|
||||
moderationStatus: PhotoAsset["moderationStatus"] = "approved"
|
||||
): PhotoAsset => ({
|
||||
id,
|
||||
submissionId,
|
||||
originalKey: `/uploads/${id}.jpg`,
|
||||
mimeType: "image/jpeg",
|
||||
processingStatus: "ready",
|
||||
moderationStatus,
|
||||
createdAt: "2026-04-10T00:00:00.000Z"
|
||||
});
|
||||
|
||||
const repositoryWithCues = (cues: Cue[]) => ({
|
||||
...createEmptyRepositoryState(),
|
||||
scenes: defaultSceneDefinitions,
|
||||
cues: sortCues(cues),
|
||||
photoAssets: [],
|
||||
submissions: []
|
||||
});
|
||||
|
||||
const bootState = (cues: Cue[], selectedCueId = cues[0]?.id ?? null) => {
|
||||
const repository = repositoryWithCues(cues);
|
||||
const selectedCue = selectedCueId ? repository.cues.find((entry) => entry.id === selectedCueId) : null;
|
||||
return adminReducer(initialAdminUiState, {
|
||||
type: "bootstrapLoaded",
|
||||
payload: repository,
|
||||
initial: {
|
||||
cueState: selectedCue ? createCueRuntimeState(repository.cues) : createCueRuntimeState([]),
|
||||
selectedSceneId: scene.id,
|
||||
selectedAssetIds: [],
|
||||
previewParams: scene.defaultParams,
|
||||
activePresetId: scene.defaultPresetId,
|
||||
cueDraft: createCueDraft(selectedCue, scene)
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
describe("adminReducer cue reconciliation", () => {
|
||||
it("moves a cue immediately while keeping the moved cue selected and dirty draft intact", () => {
|
||||
const cues = [cue("cue-a", 0, "A"), cue("cue-b", 1, "B"), cue("cue-c", 2, "C")];
|
||||
const dirtyState = {
|
||||
...bootState(cues, "cue-b"),
|
||||
cueState: createCueRuntimeState(cues),
|
||||
cueDraft: {
|
||||
...createCueDraft(cues[1], scene),
|
||||
notes: "Unsaved label"
|
||||
},
|
||||
cueDraftDirty: true
|
||||
};
|
||||
|
||||
const moving = adminReducer(dirtyState, { type: "cueMoveOptimistic", cueId: "cue-b", direction: "up" });
|
||||
|
||||
expect(moving.repository?.cues.map((entry) => entry.id)).toEqual(["cue-b", "cue-a", "cue-c"]);
|
||||
expect(moving.cueState.previewCueId).toBe("cue-b");
|
||||
expect(moving.cueState.armedCueId).toBe("cue-b");
|
||||
expect(moving.cueMoveInFlight).toBe(true);
|
||||
expect(moving.cueDraftDirty).toBe(true);
|
||||
expect(moving.cueDraft.notes).toBe("Unsaved label");
|
||||
|
||||
const stalePoll = adminReducer(moving, {
|
||||
type: "liveLoaded",
|
||||
cues,
|
||||
pendingCount: 1,
|
||||
approvedCount: 2
|
||||
});
|
||||
|
||||
expect(stalePoll.repository?.cues.map((entry) => entry.id)).toEqual(["cue-b", "cue-a", "cue-c"]);
|
||||
expect(stalePoll.pendingCount).toBe(1);
|
||||
expect(stalePoll.approvedCount).toBe(2);
|
||||
|
||||
const moved = adminReducer(moving, {
|
||||
type: "cueMoveSucceeded",
|
||||
cueId: "cue-b",
|
||||
cues: [cue("cue-b", 0, "B"), cue("cue-a", 1, "A"), cue("cue-c", 2, "C")]
|
||||
});
|
||||
|
||||
expect(moved.repository?.cues.map((entry) => entry.id)).toEqual(["cue-b", "cue-a", "cue-c"]);
|
||||
expect(moved.cueState.previewCueId).toBe("cue-b");
|
||||
expect(moved.cueState.armedCueId).toBe("cue-b");
|
||||
expect(moved.cueMoveInFlight).toBe(false);
|
||||
expect(moved.cueDraftDirty).toBe(true);
|
||||
expect(moved.cueDraft.notes).toBe("Unsaved label");
|
||||
});
|
||||
|
||||
it("refreshes clean cue drafts from live polling but preserves dirty drafts", () => {
|
||||
const cues = [cue("cue-a", 0, "A"), cue("cue-b", 1, "B")];
|
||||
const cleanState = {
|
||||
...bootState(cues, "cue-b"),
|
||||
cueState: createCueRuntimeState(cues),
|
||||
cueDraft: createCueDraft(cues[1], scene)
|
||||
};
|
||||
|
||||
const cleanRefresh = adminReducer(cleanState, {
|
||||
type: "liveLoaded",
|
||||
cues: [cue("cue-a", 0, "A"), cue("cue-b", 1, "B updated")],
|
||||
pendingCount: 0,
|
||||
approvedCount: 0
|
||||
});
|
||||
|
||||
const dirtyRefresh = adminReducer(
|
||||
{
|
||||
...cleanState,
|
||||
cueDraft: {
|
||||
...cleanState.cueDraft,
|
||||
notes: "Unsaved local edit"
|
||||
},
|
||||
cueDraftDirty: true
|
||||
},
|
||||
{
|
||||
type: "liveLoaded",
|
||||
cues: [cue("cue-a", 0, "A"), cue("cue-b", 1, "B updated")],
|
||||
pendingCount: 0,
|
||||
approvedCount: 0
|
||||
}
|
||||
);
|
||||
|
||||
expect(cleanRefresh.cueDraft.notes).toBe("B updated");
|
||||
expect(dirtyRefresh.cueDraft.notes).toBe("Unsaved local edit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adminReducer library reconciliation", () => {
|
||||
const baseSubmission = submission("submission-a", "Caption");
|
||||
|
||||
it("prunes unavailable selected media while preserving dirty metadata drafts", () => {
|
||||
const state = {
|
||||
...bootState([cue("cue-a", 0, "A")]),
|
||||
selectedAssetIds: ["asset-a", "asset-b"],
|
||||
metadataAssetId: "asset-a",
|
||||
metadataDraft: {
|
||||
...createMetadataDraft(baseSubmission),
|
||||
caption: "Unsaved caption"
|
||||
},
|
||||
metadataDirty: true
|
||||
};
|
||||
|
||||
const reconciled = adminReducer(state, {
|
||||
type: "libraryLoaded",
|
||||
photoAssets: [asset("asset-a", baseSubmission.id, "approved"), asset("asset-b", baseSubmission.id, "hold")],
|
||||
submissions: [
|
||||
{
|
||||
...baseSubmission,
|
||||
caption: "Server caption"
|
||||
}
|
||||
],
|
||||
collections: state.repository?.collections ?? []
|
||||
});
|
||||
|
||||
expect(reconciled.selectedAssetIds).toEqual(["asset-a"]);
|
||||
expect(reconciled.metadataAssetId).toBe("asset-a");
|
||||
expect(reconciled.metadataDraft.caption).toBe("Unsaved caption");
|
||||
expect(reconciled.approvedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adminReducer preview and operator controls", () => {
|
||||
it("selects a scene as one clean preview transition", () => {
|
||||
const state = bootState([cue("cue-a", 0, "A")], "cue-a");
|
||||
const selected = adminReducer(state, {
|
||||
type: "previewSceneSelected",
|
||||
scene: otherScene,
|
||||
presetId: "preset-test",
|
||||
params: otherScene.defaultParams,
|
||||
assetIds: ["asset-a", "asset-b"]
|
||||
});
|
||||
|
||||
expect(selected.selectedSceneId).toBe(otherScene.id);
|
||||
expect(selected.sceneBrowserFilter).toBe(otherScene.sceneFamily);
|
||||
expect(selected.activePresetId).toBe("preset-test");
|
||||
expect(selected.previewParams).toBe(otherScene.defaultParams);
|
||||
expect(selected.selectedAssetIds).toEqual(["asset-a", "asset-b"]);
|
||||
expect(selected.metadataAssetId).toBe("asset-a");
|
||||
expect(selected.cueDraft.id).toBeNull();
|
||||
expect(selected.cueDraftDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps media selection ordering deterministic and marks saved cue drafts dirty", () => {
|
||||
const state = {
|
||||
...bootState([cue("cue-a", 0, "A")], "cue-a"),
|
||||
selectedAssetIds: ["asset-a", "asset-b", "asset-c"],
|
||||
metadataAssetId: "asset-a",
|
||||
cueDraftDirty: false
|
||||
};
|
||||
|
||||
const promoted = adminReducer(state, {
|
||||
type: "selectedAssetPromoted",
|
||||
assetId: "asset-b",
|
||||
status: "Anchor image updated."
|
||||
});
|
||||
const reordered = adminReducer(promoted, {
|
||||
type: "selectedAssetReordered",
|
||||
assetId: "asset-b",
|
||||
direction: "later"
|
||||
});
|
||||
const removed = adminReducer(reordered, {
|
||||
type: "selectedAssetRemoved",
|
||||
assetId: "asset-b"
|
||||
});
|
||||
|
||||
expect(promoted.selectedAssetIds).toEqual(["asset-b", "asset-a", "asset-c"]);
|
||||
expect(promoted.metadataAssetId).toBe("asset-b");
|
||||
expect(promoted.cueDraftDirty).toBe(true);
|
||||
expect(reordered.selectedAssetIds).toEqual(["asset-a", "asset-b", "asset-c"]);
|
||||
expect(removed.selectedAssetIds).toEqual(["asset-a", "asset-c"]);
|
||||
expect(removed.metadataAssetId).toBeNull();
|
||||
});
|
||||
|
||||
it("marks cue drafts dirty when operator params or cue fields change", () => {
|
||||
const state = {
|
||||
...bootState([cue("cue-a", 0, "A")], "cue-a"),
|
||||
cueDraftDirty: false
|
||||
};
|
||||
|
||||
const paramChanged = adminReducer(state, {
|
||||
type: "previewParamChanged",
|
||||
path: "scenicTreatment.hue",
|
||||
value: 0.42
|
||||
});
|
||||
const cueChanged = adminReducer(state, {
|
||||
type: "cueDraftChanged",
|
||||
field: "notes",
|
||||
value: "Operator label"
|
||||
});
|
||||
|
||||
expect(paramChanged.previewParams?.scenicTreatment.hue).toBe(0.42);
|
||||
expect(paramChanged.cueDraftDirty).toBe(true);
|
||||
expect(cueChanged.cueDraft.notes).toBe("Operator label");
|
||||
expect(cueChanged.cueDraftDirty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adminReducer metadata and upload controls", () => {
|
||||
it("preserves dirty metadata for the same target and hydrates when the target changes", () => {
|
||||
const submissionA = submission("submission-a", "Saved A");
|
||||
const submissionB = submission("submission-b", "Saved B");
|
||||
const focused = adminReducer(bootState([cue("cue-a", 0, "A")]), {
|
||||
type: "metadataTargetSelected",
|
||||
assetId: "asset-a",
|
||||
submission: submissionA
|
||||
});
|
||||
const edited = adminReducer(focused, {
|
||||
type: "metadataDraftChanged",
|
||||
field: "caption",
|
||||
value: "Local edit"
|
||||
});
|
||||
const sameTargetRefresh = adminReducer(edited, {
|
||||
type: "metadataTargetSelected",
|
||||
assetId: "asset-a",
|
||||
submission: {
|
||||
...submissionA,
|
||||
caption: "Server refresh"
|
||||
}
|
||||
});
|
||||
const changedTarget = adminReducer(sameTargetRefresh, {
|
||||
type: "metadataTargetSelected",
|
||||
assetId: "asset-b",
|
||||
submission: submissionB
|
||||
});
|
||||
|
||||
expect(focused.metadataDraft.caption).toBe("Saved A");
|
||||
expect(sameTargetRefresh.metadataDraft.caption).toBe("Local edit");
|
||||
expect(sameTargetRefresh.metadataDirty).toBe(true);
|
||||
expect(changedTarget.metadataDraft.caption).toBe("Saved B");
|
||||
expect(changedTarget.metadataDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("resets upload fields and optionally adds uploaded media to the current cue draft", () => {
|
||||
const state = {
|
||||
...bootState([cue("cue-a", 0, "A")], "cue-a"),
|
||||
selectedAssetIds: ["asset-a"],
|
||||
uploadName: "Name",
|
||||
uploadCaption: "Caption",
|
||||
uploadPromptAnswer: "Prompt",
|
||||
uploadAddToSelection: false,
|
||||
cueDraftDirty: false
|
||||
};
|
||||
|
||||
const uploaded = adminReducer(
|
||||
{
|
||||
...state,
|
||||
uploadAddToSelection: true
|
||||
},
|
||||
{
|
||||
type: "adminUploadSucceeded",
|
||||
assetId: "asset-new",
|
||||
addToSelection: true
|
||||
}
|
||||
);
|
||||
|
||||
expect(uploaded.selectedAssetIds).toEqual(["asset-new", "asset-a"]);
|
||||
expect(uploaded.metadataAssetId).toBe("asset-new");
|
||||
expect(uploaded.uploadName).toBe("");
|
||||
expect(uploaded.uploadCaption).toBe("");
|
||||
expect(uploaded.uploadPromptAnswer).toBe("");
|
||||
expect(uploaded.uploadAddToSelection).toBe(true);
|
||||
expect(uploaded.cueDraftDirty).toBe(true);
|
||||
});
|
||||
});
|
||||
931
apps/admin/src/app/admin-state.ts
Normal file
931
apps/admin/src/app/admin-state.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
|
||||
|
||||
371
package-lock.json
generated
371
package-lock.json
generated
@ -34,7 +34,8 @@
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"apps/submission": {
|
||||
@ -1933,6 +1934,13 @@
|
||||
"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": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
@ -1985,6 +1993,24 @@
|
||||
"@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": {
|
||||
"version": "1.0.8",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.1.69",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@ -2237,6 +2386,16 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
@ -2383,6 +2542,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
@ -2441,6 +2607,26 @@
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
|
||||
@ -2932,6 +3128,17 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"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_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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@ -3423,6 +3637,13 @@
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
@ -3472,6 +3693,13 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@ -3481,6 +3709,13 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
@ -3499,6 +3734,23 @@
|
||||
"integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==",
|
||||
"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": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@ -3516,6 +3768,16 @@
|
||||
"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": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
||||
@ -4172,6 +4434,96 @@
|
||||
"@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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@ -4187,6 +4539,23 @@
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
38
packages/shared-types/src/cue-order.ts
Normal file
38
packages/shared-types/src/cue-order.ts
Normal 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);
|
||||
};
|
||||
@ -57,6 +57,9 @@ export interface Submission {
|
||||
submittedAt: string;
|
||||
status: SubmissionStatus;
|
||||
consentId: string;
|
||||
contributorName?: string;
|
||||
lovedOneName?: string;
|
||||
/** Legacy/operator label retained for existing runtime data and older clients. */
|
||||
displayName?: string;
|
||||
caption?: string;
|
||||
promptAnswer?: string;
|
||||
@ -344,6 +347,8 @@ export interface SessionEvent {
|
||||
}
|
||||
|
||||
export interface SubmissionPayload {
|
||||
contributorName?: string;
|
||||
lovedOneName?: string;
|
||||
displayName?: string;
|
||||
caption?: string;
|
||||
promptAnswer?: string;
|
||||
@ -355,6 +360,8 @@ export interface SubmissionPayload {
|
||||
}
|
||||
|
||||
export interface SubmissionUpdatePayload {
|
||||
contributorName?: string;
|
||||
lovedOneName?: string;
|
||||
displayName?: string;
|
||||
caption?: string;
|
||||
promptAnswer?: string;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./entities";
|
||||
export * from "./cue-order";
|
||||
export * from "./events";
|
||||
export * from "./mock";
|
||||
export * from "./scene-params";
|
||||
|
||||
@ -174,8 +174,12 @@ const createSubmissionFromMultipart = async (
|
||||
|
||||
const storedAssetId = crypto.randomUUID();
|
||||
const originalKey = await storeUploadedFile(filePart, storedAssetId);
|
||||
const contributorName = fields.contributorName || defaults.contributorName || undefined;
|
||||
const lovedOneName = fields.lovedOneName || defaults.lovedOneName || undefined;
|
||||
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,
|
||||
promptAnswer: fields.promptAnswer || defaults.promptAnswer || undefined,
|
||||
allowArchive: defaults.allowArchive ?? coerceBoolean(fields.allowArchive),
|
||||
|
||||
@ -10,7 +10,9 @@ import {
|
||||
defaultShowConfig,
|
||||
defaultTags,
|
||||
flattenSceneParams,
|
||||
indexCueOrder,
|
||||
mergeSceneParams,
|
||||
sortCuesByOrder,
|
||||
setSceneParamValue,
|
||||
type ContributorConsent,
|
||||
type Cue,
|
||||
@ -174,6 +176,8 @@ const mergeImportedSubmission = (existing: Submission | undefined, imported: Sub
|
||||
return {
|
||||
...imported,
|
||||
...existing,
|
||||
contributorName: existing.contributorName ?? imported.contributorName,
|
||||
lovedOneName: existing.lovedOneName ?? imported.lovedOneName,
|
||||
displayName: existing.displayName ?? imported.displayName,
|
||||
caption: existing.caption ?? imported.caption,
|
||||
promptAnswer: existing.promptAnswer ?? imported.promptAnswer,
|
||||
@ -181,13 +185,7 @@ const mergeImportedSubmission = (existing: Submission | undefined, imported: Sub
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeCueOrder = (cues: Cue[]) =>
|
||||
[...cues]
|
||||
.sort((left, right) => left.orderIndex - right.orderIndex)
|
||||
.map((cue, index) => ({
|
||||
...cue,
|
||||
orderIndex: index
|
||||
}));
|
||||
const normalizeCueOrder = sortCuesByOrder;
|
||||
|
||||
const ensureSafeCue = (cues: Cue[]) => {
|
||||
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 anchorLabel =
|
||||
anchorSubmission?.caption?.trim() || anchorSubmission?.promptAnswer?.trim() || anchorSubmission?.displayName?.trim();
|
||||
anchorSubmission?.caption?.trim() ||
|
||||
anchorSubmission?.promptAnswer?.trim() ||
|
||||
anchorSubmission?.lovedOneName?.trim() ||
|
||||
anchorSubmission?.displayName?.trim();
|
||||
const transitionOptions =
|
||||
scene.sceneFamily === "arrival"
|
||||
? (["shutter_reveal", "mist_reveal", "dissolve"] as const)
|
||||
@ -444,7 +445,13 @@ export class StateStore {
|
||||
submittedAt: now,
|
||||
status: "processing",
|
||||
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,
|
||||
promptAnswer: input.promptAnswer
|
||||
};
|
||||
@ -489,6 +496,12 @@ export class StateStore {
|
||||
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")) {
|
||||
submission.displayName = normalizeEditableText(payload.displayName);
|
||||
}
|
||||
@ -671,7 +684,7 @@ export class StateStore {
|
||||
sorted.splice(targetIndex, 0, baseCue);
|
||||
}
|
||||
|
||||
state.cues = normalizeCueOrder(sorted);
|
||||
state.cues = indexCueOrder(sorted);
|
||||
return state;
|
||||
});
|
||||
}
|
||||
@ -698,7 +711,7 @@ export class StateStore {
|
||||
const current = sorted[currentIndex]!;
|
||||
sorted[currentIndex] = sorted[swapIndex]!;
|
||||
sorted[swapIndex] = current;
|
||||
state.cues = normalizeCueOrder(sorted);
|
||||
state.cues = indexCueOrder(sorted);
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user