From 6657125a1e0f99b9d980de090aaf10a0ddcd4dfb Mon Sep 17 00:00:00 2001 From: vance Date: Wed, 8 Apr 2026 10:01:19 -0700 Subject: [PATCH] Initial commit --- .dockerignore | 9 + .gitignore | 10 + Dockerfile | 37 + README.md | 80 + apps/admin/index.html | 12 + apps/admin/package.json | 26 + apps/admin/src/app/App.tsx | 2005 ++++++++ apps/admin/src/app/ProgramOutputApp.tsx | 152 + apps/admin/src/app/app.css | 1731 +++++++ apps/admin/src/app/output.css | 111 + .../admin/src/features/live/SceneViewport.tsx | 93 + apps/admin/src/features/live/api.ts | 113 + apps/admin/src/features/live/output-sync.ts | 126 + apps/admin/src/features/live/viewport.css | 28 + apps/admin/src/main.tsx | 12 + apps/admin/tsconfig.json | 7 + apps/admin/vite.config.ts | 15 + apps/submission/index.html | 12 + apps/submission/package.json | 24 + apps/submission/src/app/App.tsx | 9 + apps/submission/src/app/app.css | 201 + .../submission/src/features/submission/api.ts | 59 + .../features/submission/useSubmissionForm.ts | 91 + apps/submission/src/main.tsx | 12 + .../submission/src/routes/SubmissionRoute.tsx | 126 + apps/submission/tsconfig.json | 7 + apps/submission/vite.config.ts | 15 + assets/import-library/.gitkeep | 1 + data/.gitkeep | 1 + docker-compose.dev.yml | 44 + docker-compose.prod.yml | 22 + docker-compose.yml | 65 + docker/nginx/spa.conf | 29 + package-lock.json | 4267 +++++++++++++++++ package.json | 29 + packages/cue-engine/package.json | 12 + packages/cue-engine/src/index.ts | 62 + packages/cue-engine/tsconfig.json | 6 + packages/effects/package.json | 12 + packages/effects/src/index.ts | 98 + packages/effects/tsconfig.json | 6 + packages/render-engine/package.json | 13 + packages/render-engine/src/index.ts | 2485 ++++++++++ packages/render-engine/tsconfig.json | 6 + packages/shared-types/package.json | 12 + packages/shared-types/src/entities.ts | 374 ++ packages/shared-types/src/events.ts | 21 + packages/shared-types/src/index.ts | 5 + packages/shared-types/src/mock.ts | 113 + packages/shared-types/src/scene-params.ts | 108 + packages/shared-types/src/scenes.ts | 1168 +++++ packages/shared-types/tsconfig.json | 6 + scripts/reset-runtime.mjs | 30 + scripts/run-local.mjs | 187 + services/api/package.json | 25 + services/api/src/config.ts | 39 + services/api/src/index.ts | 14 + services/api/src/seed.ts | 198 + services/api/src/server.ts | 385 ++ services/api/src/state-store.ts | 648 +++ services/api/tsconfig.json | 12 + services/worker/package.json | 22 + services/worker/src/config.ts | 39 + services/worker/src/index.ts | 51 + services/worker/src/processor.ts | 113 + services/worker/tsconfig.json | 12 + storage/.gitkeep | 1 + tsconfig.base.json | 22 + 68 files changed, 15886 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 apps/admin/index.html create mode 100644 apps/admin/package.json create mode 100644 apps/admin/src/app/App.tsx create mode 100644 apps/admin/src/app/ProgramOutputApp.tsx create mode 100644 apps/admin/src/app/app.css create mode 100644 apps/admin/src/app/output.css create mode 100644 apps/admin/src/features/live/SceneViewport.tsx create mode 100644 apps/admin/src/features/live/api.ts create mode 100644 apps/admin/src/features/live/output-sync.ts create mode 100644 apps/admin/src/features/live/viewport.css create mode 100644 apps/admin/src/main.tsx create mode 100644 apps/admin/tsconfig.json create mode 100644 apps/admin/vite.config.ts create mode 100644 apps/submission/index.html create mode 100644 apps/submission/package.json create mode 100644 apps/submission/src/app/App.tsx create mode 100644 apps/submission/src/app/app.css create mode 100644 apps/submission/src/features/submission/api.ts create mode 100644 apps/submission/src/features/submission/useSubmissionForm.ts create mode 100644 apps/submission/src/main.tsx create mode 100644 apps/submission/src/routes/SubmissionRoute.tsx create mode 100644 apps/submission/tsconfig.json create mode 100644 apps/submission/vite.config.ts create mode 100644 assets/import-library/.gitkeep create mode 100644 data/.gitkeep create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docker/nginx/spa.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/cue-engine/package.json create mode 100644 packages/cue-engine/src/index.ts create mode 100644 packages/cue-engine/tsconfig.json create mode 100644 packages/effects/package.json create mode 100644 packages/effects/src/index.ts create mode 100644 packages/effects/tsconfig.json create mode 100644 packages/render-engine/package.json create mode 100644 packages/render-engine/src/index.ts create mode 100644 packages/render-engine/tsconfig.json create mode 100644 packages/shared-types/package.json create mode 100644 packages/shared-types/src/entities.ts create mode 100644 packages/shared-types/src/events.ts create mode 100644 packages/shared-types/src/index.ts create mode 100644 packages/shared-types/src/mock.ts create mode 100644 packages/shared-types/src/scene-params.ts create mode 100644 packages/shared-types/src/scenes.ts create mode 100644 packages/shared-types/tsconfig.json create mode 100644 scripts/reset-runtime.mjs create mode 100644 scripts/run-local.mjs create mode 100644 services/api/package.json create mode 100644 services/api/src/config.ts create mode 100644 services/api/src/index.ts create mode 100644 services/api/src/seed.ts create mode 100644 services/api/src/server.ts create mode 100644 services/api/src/state-store.ts create mode 100644 services/api/tsconfig.json create mode 100644 services/worker/package.json create mode 100644 services/worker/src/config.ts create mode 100644 services/worker/src/index.ts create mode 100644 services/worker/src/processor.ts create mode 100644 services/worker/tsconfig.json create mode 100644 storage/.gitkeep create mode 100644 tsconfig.base.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..76eca31 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +.codex +node_modules +**/node_modules +**/dist +npm-debug.log* +data +storage diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ac3061 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.codex +node_modules +dist +.DS_Store +coverage +.vite +*.log +.smoke +data/runtime +storage/runtime diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a2c148e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM docker.io/library/node:22-bookworm-slim AS workspace-base +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY apps/admin/package.json apps/admin/package.json +COPY apps/submission/package.json apps/submission/package.json +COPY packages/cue-engine/package.json packages/cue-engine/package.json +COPY packages/effects/package.json packages/effects/package.json +COPY packages/render-engine/package.json packages/render-engine/package.json +COPY packages/shared-types/package.json packages/shared-types/package.json +COPY services/api/package.json services/api/package.json +COPY services/worker/package.json services/worker/package.json + +RUN npm ci + +FROM workspace-base AS workspace-source +WORKDIR /app +COPY . . + +FROM workspace-source AS node-runtime +WORKDIR /app + +FROM workspace-source AS admin-build +WORKDIR /app +RUN npm run build --workspace @goodgrief/admin + +FROM workspace-source AS submission-build +WORKDIR /app +RUN npm run build --workspace @goodgrief/submission + +FROM docker.io/library/nginx:1.27-alpine AS admin-web +COPY docker/nginx/spa.conf /etc/nginx/conf.d/default.conf +COPY --from=admin-build /app/apps/admin/dist /usr/share/nginx/html + +FROM docker.io/library/nginx:1.27-alpine AS submission-web +COPY docker/nginx/spa.conf /etc/nginx/conf.d/default.conf +COPY --from=submission-build /app/apps/submission/dist /usr/share/nginx/html diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ceeba9 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Good Grief + +TypeScript-first monorepo for a live theatrical photo-submission and media-control system. + +## Workspace + +- `apps/submission`: mobile-first audience submission flow. +- `apps/admin`: operator and moderation console. +- `packages/shared-types`: shared domain entities and scene/cue data. +- `packages/effects`: reusable effect vocabulary and presets. +- `packages/cue-engine`: deterministic cue and transition helpers. +- `packages/render-engine`: scene registry and render host contracts. +- `services/api`: local-first Fastify API for submissions, moderation, and cueing. +- `services/worker`: derivative generation and retention worker. + +## Current status + +This repository implements the MVP foundation from the planning package: + +- shared TypeScript domain model +- a live-intake submission UI +- an operator-facing moderation and cue-control UI +- a local-first API/storage architecture scaffold +- scene, effect, and cue contracts for the rendering layer + +## Bootstrapping + +1. Install dependencies with `npm install`. +2. Start everything together: + - `npm run dev:all` +3. Or reset local runtime state first and then start everything: + - `npm run dev:all:reset` + +This starts: + +- submission UI at `http://localhost:4100` +- admin UI at `http://localhost:4200` +- API at `http://localhost:4300` +- worker health at `http://localhost:4301/health` + +## Individual services + +- `npm run dev:api` +- `npm run dev:worker` +- `npm run dev:submission` +- `npm run dev:admin` +- `npm run reset:runtime` + +Both apps expect the API on `http://localhost:4300` by default. Vite dev servers proxy `/api` and `/uploads` there. + +## Local testing notes + +- Place curated images in `assets/import-library/` and use the admin `Rescan library folder` action, or restart the API, to import them. +- The submission flow writes new uploads into `storage/runtime/`. +- The API state lives in `data/runtime/state.json`. +- Use the admin UI to approve or reject new uploads and take cues live to program. + +## Containers + +Base compose file contains the shared backend services. Add the prod override for the built frontends: + +- Podman: `podman compose -f docker-compose.yml -f docker-compose.prod.yml up --build` +- Docker: `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build` + +That starts: + +- submission UI at `http://localhost:4100` +- admin UI at `http://localhost:4200` +- API at `http://localhost:4300` +- worker health at `http://localhost:4301/health` + +For local frontend hot reload, add the dev override: + +- Podman: `podman compose -f docker-compose.yml -f docker-compose.dev.yml up --build` +- Docker: `docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build` + +To reset runtime state inside the container stack: + +- Podman: `podman compose -f docker-compose.yml run --rm api npm run reset:runtime` +- Docker: `docker compose -f docker-compose.yml run --rm api npm run reset:runtime` diff --git a/apps/admin/index.html b/apps/admin/index.html new file mode 100644 index 0000000..3958eaf --- /dev/null +++ b/apps/admin/index.html @@ -0,0 +1,12 @@ + + + + + + Good Grief Admin + + +
+ + + diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..b35fbc3 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,26 @@ +{ + "name": "@goodgrief/admin", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "check": "tsc --noEmit" + }, + "dependencies": { + "@goodgrief/cue-engine": "file:../../packages/cue-engine", + "@goodgrief/effects": "file:../../packages/effects", + "@goodgrief/render-engine": "file:../../packages/render-engine", + "@goodgrief/shared-types": "file:../../packages/shared-types", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.3", + "@types/react-dom": "^19.1.3", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "^5.8.3", + "vite": "^6.3.5" + } +} diff --git a/apps/admin/src/app/App.tsx b/apps/admin/src/app/App.tsx new file mode 100644 index 0000000..302eac5 --- /dev/null +++ b/apps/admin/src/app/App.tsx @@ -0,0 +1,2005 @@ +import { startTransition, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { + armCue, + createCueRuntimeState, + skipToCue, + takeCue, + triggerSafeScene +} from "@goodgrief/cue-engine"; +import type { SurfacePresentation } from "@goodgrief/render-engine"; +import type { + Collection, + Cue, + CueTransition, + CueUpsertPayload, + EffectPreset, + ModerationActionPayload, + PhotoAsset, + RepositoryState, + SceneDefinition, + SceneParamGroups, + SceneParamScalar, + Submission, + TextTreatmentMode +} from "@goodgrief/shared-types"; +import { + getSceneParamValue, + mergeSceneParams, + setSceneParamValue +} from "@goodgrief/shared-types"; +import { effectPresetLibrary } from "@goodgrief/effects"; +import { + createAdminUpload, + activateSafeCue, + createCue, + deleteCue, + fireCue, + generateCue, + loadState, + moderateAsset, + moveCue, + rescanLibrary, + updateCue +} from "../features/live/api"; +import { + createPresentationHash, + writeProgramOutputState, + type ProgramOutputState +} from "../features/live/output-sync"; +import { SceneViewport } from "../features/live/SceneViewport"; +import "./app.css"; + +interface CueDraftState { + id: string | null; + notes: string; + triggerMode: Cue["triggerMode"]; + transitionInStyle: CueTransition["style"]; + transitionInDurationMs: number; + transitionOutStyle: CueTransition["style"]; + transitionOutDurationMs: number; +} + +type SceneBrowserFilter = "all" | SceneDefinition["sceneFamily"]; +type WorkspaceMode = "show" | "build"; + +const defaultCueTransition: CueTransition = { + style: "dissolve", + durationMs: 900 +}; + +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 ?? "veil_wipe", + transitionOutDurationMs: cue?.transitionOut.durationMs ?? 1100 +}); + +const scenePaletteMap: Record = { + "scene-signal-shutters": { accent: "#ff9f6c", accentSoft: "#9bd9ff", ink: "#17110f" }, + "scene-window-grid": { accent: "#f4cb93", accentSoft: "#97bcff", ink: "#11161b" }, + "scene-monolith-sweep": { accent: "#ffe1a8", accentSoft: "#88c1ff", ink: "#121520" }, + "scene-aperture-spill": { accent: "#f1bf82", accentSoft: "#b4d7ff", ink: "#151622" }, + "scene-floor-ribbons": { accent: "#ffbc6d", accentSoft: "#8cd5ff", ink: "#101417" }, + "scene-edge-relay": { accent: "#84e6ff", accentSoft: "#ffb584", ink: "#09131a" }, + "scene-rupture-xerox": { accent: "#ff5aac", accentSoft: "#2fd9ff", ink: "#171219" }, + "scene-safe-hold": { accent: "#8fc8ff", accentSoft: "#e7c08c", ink: "#081016" } +}; + +const sceneBrowserFilters: Array<{ id: SceneBrowserFilter; label: string; summary: string }> = [ + { id: "all", label: "All", summary: "Entire show library" }, + { id: "hero", label: "Hero", summary: "Readable anchors and witness images" }, + { id: "chorus", label: "Chorus", summary: "Multi-image fields and collective bodies" }, + { id: "floor_paint", label: "Floor paint", summary: "Wall and performer-zone spill looks" }, + { id: "arrival", label: "Arrival", summary: "Live intake and edge relay states" }, + { id: "rupture", label: "Rupture", summary: "Deliberate disruption accents" }, + { id: "safe", label: "Safe", summary: "Neutral holds and recovery looks" } +]; + +const sceneFamilyLabelMap: Record = { + hero: "Hero", + chorus: "Chorus", + floor_paint: "Floor paint", + arrival: "Arrival", + rupture: "Rupture", + safe: "Safe" +}; + +const formatParamLabel = (path: string) => { + const [, key = path] = path.split("."); + return key.replace(/([A-Z])/g, " $1").replace(/^./, (char) => char.toUpperCase()); +}; + +const moveItem = (items: T[], fromIndex: number, toIndex: number) => { + if ( + fromIndex < 0 || + toIndex < 0 || + fromIndex >= items.length || + toIndex >= items.length || + fromIndex === toIndex + ) { + return items; + } + + const next = [...items]; + const [item] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, item); + return next; +}; + +const sharedLookControlPaths = [ + "scenicTreatment.fillHue", + "scenicTreatment.fillSaturation", + "scenicTreatment.fillLightness" +] as const; + +const textControlPaths = [ + "textTreatment.mode", + "textTreatment.opacity", + "textTreatment.density", + "textTreatment.scale" +] as const; + +const getScenePresets = (scene: SceneDefinition | undefined, presets: EffectPreset[] = effectPresetLibrary) => { + if (!scene) { + return presets; + } + + const allowed = new Set(scene.supportedPresetIds); + const filtered = presets.filter((preset) => allowed.has(preset.id)); + return filtered.length > 0 ? filtered : presets.filter((preset) => preset.compatibleSceneFamilies.includes(scene.sceneFamily)); +}; + +const matchPresetForScene = ( + scene: SceneDefinition | undefined, + presets: EffectPreset[] = effectPresetLibrary, + preferredPresetId?: string | null +) => { + if (presets.length === 0) { + return undefined; + } + + if (!scene) { + return (preferredPresetId ? presets.find((preset) => preset.id === preferredPresetId) : undefined) ?? presets[0]; + } + + const scenePresets = getScenePresets(scene, presets); + + if (preferredPresetId) { + const preferred = scenePresets.find((preset) => preset.id === preferredPresetId); + if (preferred) { + return preferred; + } + } + + return ( + scenePresets.find((preset) => preset.id === scene.defaultPresetId) ?? + scenePresets[0] ?? + presets[0] + ); +}; + +const getApprovedAssets = (payload: RepositoryState) => + payload.photoAssets.filter((asset) => asset.moderationStatus === "approved"); + +const filterAvailableAssetIds = (payload: RepositoryState, assetIds: string[]) => { + const available = new Set(getApprovedAssets(payload).map((asset) => asset.id)); + return assetIds.filter((assetId) => available.has(assetId)); +}; + +const getSubmissionByAsset = (payload: RepositoryState, asset: PhotoAsset) => + payload.submissions.find((submission) => submission.id === asset.submissionId); + +const getRenderableSubmissionTextFragments = (submission: Submission | undefined) => + [submission?.caption, submission?.promptAnswer] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + +const getSubmissionTextFragments = (submission: Submission | undefined) => + [submission?.displayName, submission?.caption, submission?.promptAnswer] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + +const buildPresentationTextPayload = (payload: RepositoryState, assets: PhotoAsset[]) => { + const textFragments = Array.from( + new Set( + assets.flatMap((asset) => { + const submission = getSubmissionByAsset(payload, asset); + const renderable = getRenderableSubmissionTextFragments(submission); + return renderable.length > 0 ? renderable : []; + }) + ) + ).slice(0, 12); + + const anchorSubmission = assets[0] ? getSubmissionByAsset(payload, assets[0]) : undefined; + const anchorCaption = + anchorSubmission?.caption?.trim() || + anchorSubmission?.promptAnswer?.trim() || + anchorSubmission?.displayName?.trim() || + null; + + return { + textFragments, + anchorCaption + }; +}; + +const formatSubmissionSource = (source: Submission["source"] | undefined) => { + switch (source) { + case "admin_upload": + return "Admin upload"; + case "library_import": + return "Library import"; + case "pre_show": + return "Pre-show"; + case "invite": + return "Invite"; + case "live": + default: + return "Live"; + } +}; + +const getAssetSearchText = (asset: PhotoAsset, submission: Submission | undefined) => + [ + asset.id, + asset.orientation, + submission?.displayName, + submission?.caption, + submission?.promptAnswer, + submission?.source + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + +const getDefaultAssetIds = (payload: RepositoryState) => { + const approvedIds = new Set(getApprovedAssets(payload).map((asset) => asset.id)); + const favorites = payload.collections.find((collection) => collection.kind === "favorites"); + const favoriteIds = favorites?.assetIds.filter((assetId) => approvedIds.has(assetId)) ?? []; + return (favoriteIds.length > 0 ? favoriteIds : Array.from(approvedIds)).slice(0, 12); +}; + +const findSceneById = (payload: RepositoryState, sceneId: string) => + payload.scenes.find((scene) => scene.id === sceneId); + +const findCueById = (payload: RepositoryState, cueId: string | null | undefined) => + payload.cues.find((cue) => cue.id === cueId); + +const findCollectionAssets = (payload: RepositoryState, collectionId: string | undefined) => { + if (!collectionId) { + return []; + } + + const collection = payload.collections.find((candidate) => candidate.id === collectionId); + return filterAvailableAssetIds(payload, collection?.assetIds ?? []); +}; + +const buildParamsForScene = ( + scene: SceneDefinition, + overrides?: Cue["parameterOverrides"], + preset?: EffectPreset +) => mergeSceneParams(scene.defaultParams, preset?.paramDefaults, overrides); + +const buildPresentationFromCue = ( + payload: RepositoryState, + cue: Cue | undefined, + fallbackAssetIds: string[] +): SurfacePresentation | null => { + if (!cue) { + return null; + } + + const definition = findSceneById(payload, cue.sceneDefinitionId); + if (!definition) { + return null; + } + + const cueAssetIds = + cue.assetIds && cue.assetIds.length > 0 + ? filterAvailableAssetIds(payload, cue.assetIds) + : findCollectionAssets(payload, cue.collectionId); + + const assetMap = new Map(payload.photoAssets.map((asset) => [asset.id, asset] as const)); + const assets = (cueAssetIds.length > 0 ? cueAssetIds : fallbackAssetIds) + .map((assetId) => assetMap.get(assetId)) + .filter((asset): asset is PhotoAsset => Boolean(asset)); + + const preset = matchPresetForScene( + definition, + payload.effectPresets.length > 0 ? payload.effectPresets : effectPresetLibrary, + cue.effectPresetId + ); + + return { + cue, + definition, + assets, + params: buildParamsForScene(definition, cue.parameterOverrides, preset), + effectPresetId: preset?.id ?? cue.effectPresetId, + modeKey: preset?.modeKey, + label: cue.notes ?? definition.name, + ...buildPresentationTextPayload(payload, assets) + }; +}; + +const getSceneCardStyle = (scene: SceneDefinition) => { + const palette = scenePaletteMap[scene.id] ?? { + accent: "#cf8b68", + accentSoft: "#8aa5b0", + ink: "#171d21" + }; + + return { + "--scene-accent": palette.accent, + "--scene-accent-soft": palette.accentSoft, + "--scene-ink": palette.ink + } as CSSProperties; +}; + +const getSuggestedAssetsForScene = (payload: RepositoryState, sceneId: string, fallbackAssetIds: string[]) => { + const scene = findSceneById(payload, sceneId); + const approved = getApprovedAssets(payload); + const curatedCollection = payload.collections.find((collection) => collection.id === "collection-curated-library"); + const curatedIds = new Set(curatedCollection?.assetIds ?? []); + const favorites = new Set(payload.collections.find((collection) => collection.kind === "favorites")?.assetIds ?? []); + const recommendedLimit = Math.min(scene?.inputRules.maxAssets ?? 8, 12); + const prioritized = approved + .slice() + .sort((left, right) => { + const leftScore = (curatedIds.has(left.id) ? 3 : 0) + (favorites.has(left.id) ? 2 : 0); + const rightScore = (curatedIds.has(right.id) ? 3 : 0) + (favorites.has(right.id) ? 2 : 0); + return rightScore - leftScore; + }) + .map((asset) => asset.id) + .slice(0, recommendedLimit); + + return prioritized.length > 0 ? prioritized : fallbackAssetIds.slice(0, recommendedLimit); +}; + +const createProgramState = ( + previous: ProgramOutputState | null, + presentation: SurfacePresentation | null, + blackout: boolean, + transition: CueTransition | null +) => { + const takenAt = new Date().toISOString(); + const presentationHash = createPresentationHash(presentation, blackout, transition); + const unchanged = + previous && + previous.presentationHash === presentationHash && + previous.blackout === blackout && + previous.transition?.style === transition?.style && + previous.transition?.durationMs === transition?.durationMs; + + if (previous && unchanged) { + return previous; + } + + return { + presentation, + blackout, + transition, + presentationHash, + outputRevision: (previous?.outputRevision ?? 0) + 1, + updatedAt: takenAt, + takenAt + } satisfies ProgramOutputState; +}; + +const getNumberControlMeta = (path: string, value: number, preset?: EffectPreset) => { + const fromPreset = preset?.safeRanges[path]; + if (fromPreset) { + return { + ...fromPreset, + step: + Number.isInteger(fromPreset.min) && + Number.isInteger(fromPreset.max) && + Math.abs(fromPreset.max - fromPreset.min) >= 2 && + !path.includes("contrast") && + !path.includes("saturation") + ? 1 + : 0.01 + }; + } + + if (path.includes("columns")) { + return { min: 2, max: 6, step: 1 }; + } + + if (path.includes("bands")) { + return { min: 2, max: 8, step: 1 }; + } + + if (path.includes("shutters")) { + return { min: 2, max: 9, step: 1 }; + } + + if (path.includes("tiles")) { + return { min: 3, max: 12, step: 1 }; + } + + if (path.includes("lanes")) { + return { min: 2, max: 6, step: 1 }; + } + + if (value > 1.5) { + return { min: 0, max: Math.max(value * 1.5, 6), step: 0.05 }; + } + + return { min: 0, max: 1, step: 0.01 }; +}; + +const createInitialLiveState = (payload: RepositoryState) => { + const defaultAssetIds = getDefaultAssetIds(payload); + const programCue = payload.cues.find((cue) => cue.id === payload.showConfig.safeSceneCueId) ?? payload.cues[0]; + const previewCue = payload.cues.find((cue) => cue.id !== programCue?.id); + const runtime = createCueRuntimeState(payload.cues); + const armedState = previewCue ? armCue(runtime, previewCue.id) : runtime; + const previewScene = + (previewCue ? findSceneById(payload, previewCue.sceneDefinitionId) : undefined) ?? + payload.scenes.find((scene) => scene.sceneFamily !== "safe") ?? + payload.scenes[0]; + const availablePresets = payload.effectPresets.length > 0 ? payload.effectPresets : effectPresetLibrary; + const previewPreset = matchPresetForScene(previewScene, availablePresets, previewCue?.effectPresetId); + const previewCueAssetIds = previewCue + ? previewCue.assetIds && previewCue.assetIds.length > 0 + ? filterAvailableAssetIds(payload, previewCue.assetIds) + : findCollectionAssets(payload, previewCue.collectionId) + : []; + + return { + cueState: armedState, + programPresentation: buildPresentationFromCue(payload, programCue, defaultAssetIds), + programTransition: programCue?.transitionIn ?? null, + selectedSceneId: previewScene?.id ?? payload.scenes[0]?.id ?? "", + selectedAssetIds: previewCueAssetIds.length > 0 + ? previewCueAssetIds + : previewScene + ? getSuggestedAssetsForScene(payload, previewScene.id, defaultAssetIds) + : defaultAssetIds, + previewParams: previewScene + ? buildParamsForScene(previewScene, previewCue?.parameterOverrides, previewPreset) + : payload.scenes[0]?.defaultParams, + activePresetId: previewPreset?.id ?? availablePresets[0]?.id ?? effectPresetLibrary[0]?.id ?? "", + cueDraft: createCueDraft(previewCue, previewScene) + }; +}; + +export const App = () => { + const [state, setState] = useState(null); + const [workspaceMode, setWorkspaceMode] = useState("show"); + const [cueState, setCueState] = useState(createCueRuntimeState([])); + const [programOutputState, setProgramOutputState] = useState(null); + const [selectedSceneId, setSelectedSceneId] = useState(""); + const [sceneBrowserFilter, setSceneBrowserFilter] = useState("all"); + const [selectedAssetIds, setSelectedAssetIds] = useState([]); + const [previewParams, setPreviewParams] = useState(null); + const [activePresetId, setActivePresetId] = useState(effectPresetLibrary[0]?.id ?? ""); + const [cueDraft, setCueDraft] = useState(createCueDraft()); + const [mediaSearch, setMediaSearch] = useState(""); + const [uploadName, setUploadName] = useState(""); + const [uploadCaption, setUploadCaption] = useState(""); + const [uploadPromptAnswer, setUploadPromptAnswer] = useState(""); + const [uploadFile, setUploadFile] = useState(null); + const [uploadAddToSelection, setUploadAddToSelection] = useState(true); + const [status, setStatus] = useState("Connecting to local show state..."); + const uploadInputRef = useRef(null); + + const publishProgramOutput = ( + presentation: SurfacePresentation | null, + blackout: boolean, + transition: CueTransition | null + ) => { + setProgramOutputState((current) => { + const next = createProgramState(current, presentation, blackout, transition); + if (next !== current) { + writeProgramOutputState(next); + } + return next; + }); + }; + + const hydrate = (payload: RepositoryState, initialize: boolean) => { + const pendingCount = payload.photoAssets.filter((asset) => asset.moderationStatus === "pending").length; + + startTransition(() => { + setState(payload); + setStatus(`Ready. ${pendingCount} pending / ${getApprovedAssets(payload).length} approved.`); + + if (initialize) { + const initial = createInitialLiveState(payload); + setCueState(initial.cueState); + setSelectedSceneId(initial.selectedSceneId); + setSceneBrowserFilter("all"); + setSelectedAssetIds(initial.selectedAssetIds); + setPreviewParams(initial.previewParams ?? null); + setActivePresetId(initial.activePresetId); + setCueDraft(initial.cueDraft); + publishProgramOutput(initial.programPresentation, false, initial.programTransition); + return; + } + + setCueState((current) => ({ + ...current, + cueStack: payload.cues + })); + setSelectedAssetIds((current) => { + const filtered = filterAvailableAssetIds(payload, current); + return filtered.length > 0 ? filtered : getDefaultAssetIds(payload); + }); + }); + }; + + const refresh = async (initialize = false) => { + const payload = await loadState(); + hydrate(payload, initialize); + }; + + useEffect(() => { + void refresh(true).catch((error) => { + setStatus(error instanceof Error ? error.message : "Could not load state."); + }); + }, []); + + useEffect(() => { + if (!state) { + return; + } + + const interval = window.setInterval(() => { + void refresh(false).catch(() => { + setStatus("Refresh failed. Local state may be stale."); + }); + }, 4000); + + return () => window.clearInterval(interval); + }, [state]); + + const pendingAssets = useMemo( + () => + state?.photoAssets.filter((asset) => { + if (asset.moderationStatus !== "pending") { + return false; + } + + const submission = state.submissions.find((entry) => entry.id === asset.submissionId); + return submission?.source !== "admin_upload"; + }) ?? [], + [state] + ); + const approvedAssets = useMemo(() => (state ? getApprovedAssets(state) : []), [state]); + const availablePresets = useMemo( + () => (state && state.effectPresets.length > 0 ? state.effectPresets : effectPresetLibrary), + [state] + ); + const selectedScene = useMemo( + () => (state ? findSceneById(state, selectedSceneId) : undefined), + [selectedSceneId, state] + ); + const selectedScenePresets = useMemo( + () => getScenePresets(selectedScene, availablePresets), + [availablePresets, selectedScene] + ); + const visibleScenes = useMemo( + () => + (state?.scenes ?? []).filter( + (scene) => sceneBrowserFilter === "all" || scene.sceneFamily === sceneBrowserFilter + ), + [sceneBrowserFilter, state] + ); + const sceneFamilyCounts = useMemo(() => { + const counts: Record = { + all: state?.scenes.length ?? 0, + hero: 0, + chorus: 0, + floor_paint: 0, + arrival: 0, + rupture: 0, + safe: 0 + }; + + for (const scene of state?.scenes ?? []) { + counts[scene.sceneFamily] += 1; + } + + return counts; + }, [state]); + const previewCue = useMemo( + () => (state ? findCueById(state, cueState.previewCueId) : undefined), + [cueState.previewCueId, state] + ); + const safeCue = useMemo( + () => state?.cues.find((cue) => cue.id === state.showConfig.safeSceneCueId), + [state] + ); + const cueStack = state?.cues ?? []; + const favoriteCollection: Collection | undefined = useMemo( + () => state?.collections.find((collection) => collection.kind === "favorites"), + [state] + ); + const curatedCollection: Collection | undefined = useMemo( + () => state?.collections.find((collection) => collection.id === "collection-curated-library"), + [state] + ); + const submissionMap = useMemo( + () => new Map((state?.submissions ?? []).map((submission) => [submission.id, submission] as const)), + [state] + ); + const selectedAssets = useMemo(() => { + const assetMap = new Map(approvedAssets.map((asset) => [asset.id, asset] as const)); + return selectedAssetIds + .map((assetId) => assetMap.get(assetId)) + .filter((asset): asset is PhotoAsset => Boolean(asset)); + }, [approvedAssets, selectedAssetIds]); + const filteredPendingAssets = useMemo(() => { + const query = mediaSearch.trim().toLowerCase(); + if (!query) { + return pendingAssets; + } + + return pendingAssets.filter((asset) => getAssetSearchText(asset, submissionMap.get(asset.submissionId)).includes(query)); + }, [mediaSearch, pendingAssets, submissionMap]); + const filteredApprovedAssets = useMemo(() => { + const query = mediaSearch.trim().toLowerCase(); + if (!query) { + return approvedAssets; + } + + return approvedAssets.filter((asset) => getAssetSearchText(asset, submissionMap.get(asset.submissionId)).includes(query)); + }, [approvedAssets, mediaSearch, submissionMap]); + const activePreset: EffectPreset | undefined = + selectedScenePresets.find((preset) => preset.id === activePresetId) ?? + availablePresets.find((preset) => preset.id === activePresetId); + const sceneColorControls = useMemo( + () => + selectedScene + ? sharedLookControlPaths.filter((path) => typeof getSceneParamValue(selectedScene.defaultParams, path) !== "undefined") + : [], + [selectedScene] + ); + const primarySceneControls = useMemo( + () => + selectedScene + ? selectedScene.operatorControls.filter((path) => !sceneColorControls.includes(path as (typeof sharedLookControlPaths)[number])) + : [], + [sceneColorControls, selectedScene] + ); + const textControls = useMemo(() => textControlPaths.map((path) => path), []); + const previewTextPayload = useMemo( + () => (state ? buildPresentationTextPayload(state, selectedAssets) : { textFragments: [], anchorCaption: null }), + [selectedAssets, state] + ); + const previewUsesArmedCue = Boolean(previewCue && cueDraft.id === previewCue.id && previewCue.sceneDefinitionId === selectedSceneId); + const previewLabel = selectedScene + ? previewUsesArmedCue + ? previewCue?.notes ?? selectedScene.name + : `${selectedScene.name} / free build` + : "No preview armed"; + + const previewPresentation = useMemo(() => { + if (!state || !selectedScene || !previewParams) { + return null; + } + + return { + cue: previewUsesArmedCue ? previewCue ?? null : null, + definition: selectedScene, + assets: selectedAssets, + params: previewParams, + effectPresetId: activePreset?.id, + modeKey: activePreset?.modeKey, + label: previewLabel, + ...buildPresentationTextPayload(state, selectedAssets) + }; + }, [activePreset?.id, activePreset?.modeKey, previewCue, previewLabel, previewParams, previewUsesArmedCue, selectedAssets, selectedScene, state]); + + const previewActivationKey = useMemo( + () => createPresentationHash(previewPresentation, false, null), + [previewPresentation] + ); + const programPresentation = programOutputState?.presentation ?? null; + const programActivationKey = programOutputState?.presentationHash ?? "program-empty"; + + const selectScene = (scene: SceneDefinition) => { + if (!state) { + return; + } + + const nextPreset = matchPresetForScene(scene, availablePresets); + const preservedAssetIds = filterAvailableAssetIds(state, selectedAssetIds); + setSelectedSceneId(scene.id); + setSceneBrowserFilter(scene.sceneFamily); + setActivePresetId(nextPreset?.id ?? availablePresets[0]?.id ?? effectPresetLibrary[0]?.id ?? ""); + setPreviewParams(buildParamsForScene(scene, undefined, nextPreset)); + setSelectedAssetIds( + preservedAssetIds.length > 0 + ? preservedAssetIds + : getSuggestedAssetsForScene(state, scene.id, getDefaultAssetIds(state)) + ); + setCueDraft(createCueDraft(undefined, scene)); + }; + + const syncPreviewFromCue = (cue: Cue, options: { armPreview?: boolean } = {}) => { + if (!state) { + return; + } + + const scene = findSceneById(state, cue.sceneDefinitionId); + if (!scene) { + return; + } + + const matchedPreset = matchPresetForScene(scene, availablePresets, cue.effectPresetId); + const cueAssetIds = + cue.assetIds && cue.assetIds.length > 0 + ? filterAvailableAssetIds(state, cue.assetIds) + : findCollectionAssets(state, cue.collectionId); + + if (options.armPreview ?? true) { + setCueState((current) => armCue(current, cue.id)); + } + setSelectedSceneId(scene.id); + setSceneBrowserFilter(scene.sceneFamily); + setActivePresetId(matchedPreset?.id ?? availablePresets[0]?.id ?? effectPresetLibrary[0]?.id ?? ""); + setPreviewParams(buildParamsForScene(scene, cue.parameterOverrides, matchedPreset)); + setCueDraft(createCueDraft(cue, scene)); + setSelectedAssetIds( + cueAssetIds.length > 0 ? cueAssetIds : getSuggestedAssetsForScene(state, scene.id, getDefaultAssetIds(state)) + ); + }; + + const openOutputWindow = () => { + const url = new URL(window.location.href); + url.searchParams.set("mode", "output"); + const outputWindow = window.open(url.toString(), "goodgrief-output", "popup=yes,width=1920,height=1080"); + outputWindow?.focus(); + setStatus("Program output window opened. Move it to the projector display and press fullscreen there."); + }; + + const syncPreviewFromDraft = (draft: CueUpsertPayload) => { + if (!state) { + return; + } + + const scene = findSceneById(state, draft.sceneDefinitionId); + if (!scene) { + return; + } + + const preset = matchPresetForScene(scene, availablePresets, draft.effectPresetId); + const nextAssetIds = + draft.assetIds && draft.assetIds.length > 0 + ? filterAvailableAssetIds(state, draft.assetIds) + : getSuggestedAssetsForScene(state, scene.id, getDefaultAssetIds(state)); + + setSelectedSceneId(scene.id); + setSceneBrowserFilter(scene.sceneFamily); + setActivePresetId(preset?.id ?? availablePresets[0]?.id ?? effectPresetLibrary[0]?.id ?? ""); + setPreviewParams(buildParamsForScene(scene, draft.parameterOverrides, preset)); + setSelectedAssetIds(nextAssetIds); + setCueDraft({ + id: null, + notes: draft.notes ?? scene.name, + triggerMode: draft.triggerMode, + transitionInStyle: draft.transitionIn.style, + transitionInDurationMs: draft.transitionIn.durationMs, + transitionOutStyle: draft.transitionOut.style, + transitionOutDurationMs: draft.transitionOut.durationMs + }); + }; + + const handleRescanLibrary = async () => { + try { + const payload = await rescanLibrary(); + hydrate(payload, true); + setStatus( + `Library rescanned. ${getApprovedAssets(payload).length} approved assets ready / ${payload.collections.find((collection) => collection.id === "collection-curated-library")?.assetIds.length ?? 0} curated.` + ); + } catch (error) { + setStatus(error instanceof Error ? error.message : "Library rescan failed."); + } + }; + + const handleGenerateCue = async () => { + if (!state) { + return; + } + + try { + const draft = await generateCue({}); + syncPreviewFromDraft(draft); + setStatus(`Generated cue draft: ${draft.notes ?? "Untitled cue"}.`); + } catch (error) { + setStatus(error instanceof Error ? error.message : "Could not generate a cue."); + } + }; + + const handleAdminUpload = async () => { + if (!uploadFile) { + setStatus("Choose an image to upload into the approved bank."); + return; + } + + const form = new FormData(); + form.append("file", uploadFile); + if (uploadName.trim()) { + form.append("displayName", uploadName.trim()); + } + if (uploadCaption.trim()) { + form.append("caption", uploadCaption.trim()); + } + if (uploadPromptAnswer.trim()) { + form.append("promptAnswer", uploadPromptAnswer.trim()); + } + + try { + const result = await createAdminUpload(form); + await refresh(false); + + if (uploadAddToSelection && result.assetId) { + setSelectedAssetIds((current) => { + const next = [result.assetId, ...current.filter((assetId) => assetId !== result.assetId)]; + return next.slice(0, 12); + }); + } + + setUploadFile(null); + if (uploadInputRef.current) { + uploadInputRef.current.value = ""; + } + setUploadName(""); + setUploadCaption(""); + setUploadPromptAnswer(""); + setUploadAddToSelection(true); + setStatus("Admin upload queued. It will appear in the approved bank as soon as processing completes."); + } catch (error) { + setStatus(error instanceof Error ? error.message : "Admin upload failed."); + } + }; + + const setBlackout = (blackout: boolean) => { + setCueState((current) => ({ + ...current, + blackout + })); + publishProgramOutput(programOutputState?.presentation ?? null, blackout, programOutputState?.transition ?? null); + }; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) { + return; + } + + if (event.code === "Space") { + event.preventDefault(); + void handleTakeCue(); + return; + } + + if (event.key.toLowerCase() === "b") { + setBlackout(!cueState.blackout); + return; + } + + if (event.key.toLowerCase() === "s") { + void handleSafeScene(); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + handleNextCue(); + return; + } + + if (event.key.toLowerCase() === "o") { + openOutputWindow(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }); + + const buildCuePayload = (overrides: Partial = {}): CueUpsertPayload | null => { + if (!state || !selectedScene || !previewParams) { + return null; + } + + return { + id: overrides.id, + showConfigId: state.showConfig.id, + orderIndex: overrides.orderIndex, + sceneDefinitionId: selectedScene.id, + triggerMode: cueDraft.triggerMode, + transitionIn: { + style: cueDraft.transitionInStyle, + durationMs: cueDraft.transitionInDurationMs + }, + transitionOut: { + style: cueDraft.transitionOutStyle, + durationMs: cueDraft.transitionOutDurationMs + }, + assetIds: selectedAssetIds, + effectPresetId: activePresetId || undefined, + parameterOverrides: previewParams, + notes: cueDraft.notes || selectedScene.name, + durationMs: overrides.durationMs, + collectionId: overrides.collectionId, + nextCueId: overrides.nextCueId + }; + }; + + const handleSaveCue = async () => { + const targetIndex = cueDraft.id + ? cueStack.find((cue) => cue.id === cueDraft.id)?.orderIndex ?? cueStack.length + : cueStack.length; + const cueId = cueDraft.id ?? `cue-${crypto.randomUUID()}`; + const payload = buildCuePayload({ + id: cueId, + orderIndex: targetIndex + }); + if (!payload) { + return; + } + + const savedCue = cueDraft.id ? await updateCue(cueId, payload) : await createCue(payload); + syncPreviewFromCue(savedCue); + await refresh(false); + setCueDraft(createCueDraft(savedCue, selectedScene)); + setStatus(cueDraft.id ? `Cue updated: ${savedCue.notes ?? savedCue.id}` : `Cue created: ${savedCue.notes ?? savedCue.id}`); + }; + + const handleCreateCueAfterCurrent = async () => { + const targetIndex = + cueDraft.id !== null + ? (cueStack.find((cue) => cue.id === cueDraft.id)?.orderIndex ?? cueStack.length - 1) + 1 + : cueStack.length; + const cueId = `cue-${crypto.randomUUID()}`; + const payload = buildCuePayload({ + id: cueId, + orderIndex: targetIndex + }); + if (!payload) { + return; + } + + const createdCue = await createCue(payload); + syncPreviewFromCue(createdCue); + await refresh(false); + setCueDraft(createCueDraft(createdCue, selectedScene)); + setStatus(`Cue inserted: ${createdCue.notes ?? createdCue.id}`); + }; + + const handleDuplicateCue = async () => { + const cueId = `cue-${crypto.randomUUID()}`; + const sourceCue = cueDraft.id ? cueStack.find((cue) => cue.id === cueDraft.id) : null; + const payload = buildCuePayload({ + id: cueId, + orderIndex: (sourceCue?.orderIndex ?? cueStack.length - 1) + 1 + }); + if (!payload) { + return; + } + + payload.notes = `${cueDraft.notes || selectedScene?.name || "Cue"} copy`; + const duplicatedCue = await createCue(payload); + syncPreviewFromCue(duplicatedCue); + await refresh(false); + setCueDraft(createCueDraft(duplicatedCue, selectedScene)); + setStatus(`Cue duplicated: ${duplicatedCue.notes ?? duplicatedCue.id}`); + }; + + const handleDeleteCue = async () => { + if (!cueDraft.id) { + return; + } + + await deleteCue(cueDraft.id); + await refresh(false); + setCueDraft(createCueDraft(undefined, selectedScene)); + setStatus("Cue deleted."); + }; + + const handleMoveCue = async (direction: "up" | "down") => { + if (!cueDraft.id) { + return; + } + + await moveCue(cueDraft.id, { direction }); + await refresh(false); + setStatus(`Cue moved ${direction}.`); + }; + + const handleModeration = async (asset: PhotoAsset, decision: ModerationActionPayload["decision"]) => { + await moderateAsset(asset.id, { + decision, + reasonCode: decision === "rejected" ? "operator_review" : undefined + }); + await refresh(false); + }; + + const handleTakeCue = async () => { + if (!previewPresentation) { + return; + } + + const nextTransition = previewUsesArmedCue ? previewCue?.transitionIn ?? defaultCueTransition : defaultCueTransition; + const currentPreviewIndex = previewCue ? cueStack.findIndex((cue) => cue.id === previewCue.id) : -1; + const nextCue = currentPreviewIndex >= 0 ? cueStack[currentPreviewIndex + 1] : undefined; + + publishProgramOutput(previewPresentation, false, nextTransition); + setCueState((current) => { + if (!previewUsesArmedCue) { + return { ...current, blackout: false, safeSceneActive: false }; + } + + const taken = takeCue(current); + return nextCue ? armCue(taken, nextCue.id) : taken; + }); + + if (nextCue) { + syncPreviewFromCue(nextCue, { armPreview: false }); + } + + setStatus(`Program live: ${previewLabel}`); + + if (previewUsesArmedCue && previewCue?.id) { + try { + await fireCue(previewCue.id); + } catch { + setStatus(`Program live: ${previewLabel}. Cue log failed.`); + } + } + }; + + const handleSafeScene = async () => { + if (!state || !safeCue) { + return; + } + + const definition = findSceneById(state, safeCue.sceneDefinitionId); + if (!definition) { + return; + } + + const presentation: SurfacePresentation = { + cue: safeCue, + definition, + assets: [], + params: buildParamsForScene( + definition, + safeCue.parameterOverrides, + matchPresetForScene(definition, availablePresets, safeCue.effectPresetId) + ), + effectPresetId: safeCue.effectPresetId, + modeKey: matchPresetForScene(definition, availablePresets, safeCue.effectPresetId)?.modeKey, + label: safeCue.notes ?? definition.name + }; + + publishProgramOutput(presentation, false, safeCue.transitionIn); + setCueState((current) => triggerSafeScene(current, safeCue.id)); + setStatus("Safe scene on program."); + + try { + await activateSafeCue(safeCue.id); + } catch { + setStatus("Safe scene on program. Cue log failed."); + } + }; + + const handleReset = () => { + if (!state) { + return; + } + + const initial = createInitialLiveState(state); + setCueState(initial.cueState); + setSelectedSceneId(initial.selectedSceneId); + setSceneBrowserFilter("all"); + setSelectedAssetIds(initial.selectedAssetIds); + setPreviewParams(initial.previewParams ?? null); + setActivePresetId(initial.activePresetId); + setCueDraft(initial.cueDraft); + publishProgramOutput(initial.programPresentation, false, initial.programTransition); + setStatus("Reset to safe hold on program and opening cue in preview."); + }; + + const handleNextCue = () => { + if (!state) { + return; + } + + const currentPreviewIndex = cueStack.findIndex((cue) => cue.id === cueState.previewCueId); + const nextCue = cueStack[currentPreviewIndex + 1]; + if (!nextCue) { + return; + } + + setCueState((current) => skipToCue(current, nextCue.id)); + syncPreviewFromCue(nextCue); + }; + + const toggleAssetSelection = (assetId: string) => { + setSelectedAssetIds((current) => + current.includes(assetId) ? current.filter((candidate) => candidate !== assetId) : [...current, assetId].slice(-12) + ); + }; + + const handleLoadSuggestedAssets = () => { + if (!state || !selectedScene) { + return; + } + + setSelectedAssetIds(getSuggestedAssetsForScene(state, selectedScene.id, getDefaultAssetIds(state))); + setStatus(`Suggested media loaded for ${selectedScene.name}.`); + }; + + const handleLoadFavorites = () => { + if (!state) { + return; + } + + setSelectedAssetIds(getDefaultAssetIds(state)); + setStatus("Favorites bank loaded into preview."); + }; + + const handleClearSelectedAssets = () => { + setSelectedAssetIds([]); + setStatus("Preview asset bank cleared."); + }; + + const handlePromoteAsset = (assetId: string) => { + setSelectedAssetIds((current) => { + const index = current.indexOf(assetId); + return index <= 0 ? current : moveItem(current, index, 0); + }); + setStatus("Anchor image updated."); + }; + + const handleReorderAsset = (assetId: string, direction: "earlier" | "later") => { + setSelectedAssetIds((current) => { + const index = current.indexOf(assetId); + if (index === -1) { + return current; + } + + return moveItem(current, index, direction === "earlier" ? index - 1 : index + 1); + }); + }; + + const handleRemoveSelectedAsset = (assetId: string) => { + setSelectedAssetIds((current) => current.filter((candidate) => candidate !== assetId)); + }; + + const handleResetModeDefaults = () => { + if (!selectedScene) { + return; + } + + const preset = matchPresetForScene(selectedScene, availablePresets, activePresetId); + setPreviewParams(buildParamsForScene(selectedScene, undefined, preset)); + setStatus(`Preview reset to ${preset?.name ?? selectedScene.name} defaults.`); + }; + + const handleResetFillColor = () => { + if (!selectedScene || !previewParams) { + return; + } + + const preset = matchPresetForScene(selectedScene, availablePresets, activePresetId); + const base = buildParamsForScene(selectedScene, undefined, preset); + setPreviewParams({ + ...previewParams, + scenicTreatment: { + ...previewParams.scenicTreatment, + fillHue: base.scenicTreatment.fillHue, + fillSaturation: base.scenicTreatment.fillSaturation, + fillLightness: base.scenicTreatment.fillLightness + } + }); + setStatus("Fill color reset to the current mode defaults."); + }; + + const handleNewCueFromPreview = () => { + if (!selectedScene) { + return; + } + + setCueDraft(createCueDraft(undefined, selectedScene)); + setStatus("Preview detached from the armed cue. Saving now creates a new cue."); + }; + + const renderControl = (path: string) => { + if (!selectedScene || !previewParams) { + return null; + } + + const value = getSceneParamValue(previewParams, path); + if (typeof value === "undefined") { + return null; + } + + if (typeof value === "number") { + const control = getNumberControlMeta(path, value, activePreset); + return ( + + ); + } + + if (typeof value === "boolean") { + return ( + + ); + } + + if (path === "composition.edge") { + return ( + + ); + } + + if (path === "textTreatment.mode") { + return ( + + ); + } + + return ( + + ); + }; + + return ( +
+
+
+

Good Grief / Live Control

+

Moderation, cueing, and scene shaping

+
+
+ {status} +
+ + +
+ + +
+
+ +
+
+
+
+

Moderation inbox

+

Pending submissions

+
+ {filteredPendingAssets.length} waiting +
+ +
+ {filteredPendingAssets.length === 0 ?

No pending photos right now.

: null} + {filteredPendingAssets.map((asset) => { + const submission = submissionMap.get(asset.submissionId); + + return ( +
+
+ {asset.thumbKey ? :
} +
+
+
+ {submission?.displayName?.trim() || submission?.caption?.trim() || asset.id} + {formatSubmissionSource(submission?.source)} +
+

+ {asset.orientation ?? "orientation pending"} / {asset.processingStatus} + {asset.qualityFlags?.tooSmall ? " / low-res" : ""} +

+ {submission?.caption ?

Caption: {submission.caption}

: null} + {submission?.promptAnswer ?

Prompt: {submission.promptAnswer}

: null} + {asset.id} +
+ + + +
+
+
+ ); + })} +
+
+
+
+

Direct upload

+

Bypass public intake

+
+
+
+ + + + + +
+
+ + +
+
+
+ +
+
+
+

Preview / program

+

Live surfaces

+
+
+ + + +
+
+ +
+
+
+

Preview

+ {previewLabel} + + {selectedAssets.length} selected / {selectedScene?.renderMode ?? "none"} + +
+ +
+ +
+
+

Program

+ {cueState.blackout ? "Blackout" : programPresentation?.label ?? "Waiting"} + + {cueState.blackout + ? "Output muted" + : `${programOutputState?.transition?.style ?? "cut"} / ${programOutputState?.transition?.durationMs ?? 0}ms`} + +
+ +
+
+ +
+ + + +
+
+ +
+
+
+

Live controls

+

{selectedScene?.name ?? "Select a scene"}

+
+
+ {selectedScene ? {sceneFamilyLabelMap[selectedScene.sceneFamily]} : null} + {activePreset ? {activePreset.name} : null} + {selectedAssets.length > 0 ? {selectedAssets.length} images : null} + {previewParams ? ( + + {previewParams.textTreatment.mode === "off" + ? "Text off" + : `Text ${previewParams.textTreatment.mode.replace(/_/g, " ")}`} + + ) : null} +
+
+ + {selectedScene && previewParams ? ( + <> +

{selectedScene.visualDescription}

+ {activePreset ? ( +

+ Mode live in preview: {activePreset.name}. {activePreset.artisticPurpose} +

+ ) : null} +
+ + + + +
+
+

Scene motion and structure

+
{primarySceneControls.map((path) => renderControl(path))}
+
+
+

Fill color

+
{sceneColorControls.map((path) => renderControl(path))}
+
+
+

Submission text treatment

+

+ {previewParams.textTreatment.mode === "off" + ? "Text overlay is off." + : `Text overlay uses contributor captions and prompt answers in ${previewParams.textTreatment.mode.replace(/_/g, " ")} mode, with names only as a fallback.`} +

+ {previewTextPayload.anchorCaption ? ( +

Anchor text: “{previewTextPayload.anchorCaption}”

+ ) : null} + {previewTextPayload.textFragments.length > 1 ? ( +

+ Also available: {previewTextPayload.textFragments.slice(1, 3).join(" • ")} +

+ ) : null} +
{textControls.map((path) => renderControl(path))}
+
+ + ) : ( +

Arm a cue or select a scene card to edit its live parameters.

+ )} +
+ +
+
+
+

Scene browser

+

Available looks

+
+
+
+ {sceneBrowserFilters.map((filter) => ( + + ))} +
+

+ {sceneBrowserFilters.find((filter) => filter.id === sceneBrowserFilter)?.summary ?? "Entire show library"} +

+
+ {visibleScenes.map((scene) => ( + + ))} +
+ {visibleScenes.length === 0 ?

No scenes in this family yet.

: null} +
+ +
+
+
+

Look modes

+

Composition behaviors

+
+
+
+ {selectedScenePresets.map((preset) => ( + + ))} +
+ {activePreset ?

{activePreset.performanceNotes}

: null} +
+ +
+
+
+

Approved bank

+

Ready for projection

+
+ {filteredApprovedAssets.length} approved +
+

+ Favorites collection: {favoriteCollection?.name ?? "None"} / {favoriteCollection?.assetIds.length ?? 0} assets +

+

+ Curated library: {curatedCollection?.assetIds.length ?? 0} imported assets +

+
+
+
+

Selected for preview

+

{selectedAssets.length} in current look

+

+ The first selected photo becomes the anchor image in most hero looks. Reorder this tray to change emphasis. +

+
+
+ + + +
+
+
+ {selectedAssets.map((asset, index) => ( + (() => { + const submission = submissionMap.get(asset.submissionId); + const assetLabel = submission?.caption?.trim() || submission?.promptAnswer?.trim() || submission?.displayName?.trim() || asset.id; + const assetDetail = submission?.caption?.trim() || submission?.promptAnswer?.trim() || ""; + const assetTitle = [ + index === 0 ? "Anchor image" : `Slot ${index + 1}`, + assetLabel, + submission?.caption ? `Caption: ${submission.caption}` : "", + submission?.promptAnswer ? `Prompt: ${submission.promptAnswer}` : "", + `Source: ${formatSubmissionSource(submission?.source)}` + ] + .filter(Boolean) + .join("\n"); + + return ( +
+
+ {asset.thumbKey ? :
} +
+
+
+ {index === 0 ? "Anchor" : `Slot ${index + 1}`} + {formatSubmissionSource(submission?.source)} +
+
+
+

{assetLabel}

+ {assetDetail ? {assetDetail} : null} +
+
+ + + + +
+
+ ); + })() + ))} + {selectedAssets.length === 0 ? ( +

Use the approved bank below or load a suggested set for the selected scene.

+ ) : null} +
+
+
+ {filteredApprovedAssets.map((asset) => { + const submission = submissionMap.get(asset.submissionId); + const assetLabel = submission?.caption?.trim() || submission?.promptAnswer?.trim() || submission?.displayName?.trim() || asset.id; + const assetDetail = submission?.caption?.trim() || submission?.promptAnswer?.trim() || ""; + const isSelected = selectedAssetIds.includes(asset.id); + const isAnchor = selectedAssetIds[0] === asset.id; + const assetTitle = [ + assetLabel, + submission?.caption ? `Caption: ${submission.caption}` : "", + submission?.promptAnswer ? `Prompt: ${submission.promptAnswer}` : "", + `${asset.orientation ?? "pending orientation"}${asset.qualityFlags?.tooSmall ? " / low-res" : ""}`, + `Source: ${formatSubmissionSource(submission?.source)}`, + isAnchor ? "Currently anchor image" : isSelected ? "Currently selected" : "" + ] + .filter(Boolean) + .join("\n"); + + return ( + + ); + })} + {filteredApprovedAssets.length === 0 ?

Approved photos will appear here after moderation.

: null} +
+
+ +
+
+
+

Cue builder

+

{cueDraft.id ? "Edit armed cue" : "Build new cue from preview"}

+
+ {cueDraft.id ?? "unsaved"} +
+

+ Saving captures the current preview scene, look mode, selected assets, live parameters, and transition timing. +

+
+ + + + + + + +
+
+ + + + + + + +
+
+ +
+
+
+

Cue stack

+

Program order

+
+
+
+ {cueStack.map((cue) => ( + (() => { + const definition = findSceneById(state!, cue.sceneDefinitionId); + const preset = definition + ? matchPresetForScene(definition, availablePresets, cue.effectPresetId) + : undefined; + const cueAssetCount = + cue.assetIds?.length && cue.assetIds.length > 0 + ? cue.assetIds.length + : findCollectionAssets(state!, cue.collectionId).length; + + return ( + + ); + })() + ))} +
+
+
+
+ ); +}; diff --git a/apps/admin/src/app/ProgramOutputApp.tsx b/apps/admin/src/app/ProgramOutputApp.tsx new file mode 100644 index 0000000..3b79f48 --- /dev/null +++ b/apps/admin/src/app/ProgramOutputApp.tsx @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { CueTransition } from "@goodgrief/shared-types"; +import { SceneViewport } from "../features/live/SceneViewport"; +import { readProgramOutputState, subscribeProgramOutput, type ProgramOutputState } from "../features/live/output-sync"; +import "./output.css"; + +const enterFullscreen = async () => { + if (document.fullscreenElement) { + await document.exitFullscreen(); + return; + } + + await document.documentElement.requestFullscreen(); +}; + +export const ProgramOutputApp = () => { + const [outputState, setOutputState] = useState(() => readProgramOutputState()); + const [overlayVisible, setOverlayVisible] = useState(true); + const [overlayDismissed, setOverlayDismissed] = useState(false); + const hideTimeoutRef = useRef(null); + + const clearHideTimer = useCallback(() => { + if (hideTimeoutRef.current !== null) { + window.clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + }, []); + + const scheduleHide = useCallback( + (delayMs: number) => { + clearHideTimer(); + hideTimeoutRef.current = window.setTimeout(() => { + setOverlayVisible(false); + }, delayMs); + }, + [clearHideTimer] + ); + + const showOverlay = useCallback( + (delayMs = 1800) => { + setOverlayVisible(true); + scheduleHide(delayMs); + }, + [scheduleHide] + ); + + useEffect(() => { + document.documentElement.classList.add("mode-output"); + document.body.classList.add("mode-output"); + + return () => { + clearHideTimer(); + document.documentElement.classList.remove("mode-output"); + document.body.classList.remove("mode-output"); + }; + }, [clearHideTimer]); + + useEffect( + () => + subscribeProgramOutput((payload) => + setOutputState((current) => { + if (!current || payload.outputRevision > current.outputRevision) { + return payload; + } + + return current; + }) + ), + [] + ); + + useEffect(() => { + setOverlayDismissed(false); + showOverlay(2800); + }, [outputState?.updatedAt, showOverlay]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key.toLowerCase() === "f") { + void enterFullscreen(); + } + if (event.key.toLowerCase() === "i") { + setOverlayDismissed((current) => { + const next = !current; + if (next) { + clearHideTimer(); + setOverlayVisible(false); + } else { + showOverlay(2800); + } + return next; + }); + } + }; + + const handleWindowBlur = () => { + clearHideTimer(); + setOverlayVisible(false); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("blur", handleWindowBlur); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("blur", handleWindowBlur); + }; + }, [clearHideTimer, showOverlay]); + + const handlePointerMove = () => { + if (overlayDismissed) { + return; + } + showOverlay(); + }; + + const handlePointerLeave = () => { + clearHideTimer(); + setOverlayVisible(false); + }; + + const transition: CueTransition | null = outputState?.transition ?? null; + + return ( +
+ +
+
+

Program Output

+ {outputState?.presentation?.label ?? "Awaiting cue"} + {outputState?.blackout ? "Blackout active" : "Move this window to the projector display."} +
+
+ + +
+
+
+ ); +}; diff --git a/apps/admin/src/app/app.css b/apps/admin/src/app/app.css new file mode 100644 index 0000000..4856a2a --- /dev/null +++ b/apps/admin/src/app/app.css @@ -0,0 +1,1731 @@ +:root { + color-scheme: dark; + font-family: "Trebuchet MS", "Segoe UI", sans-serif; + background: + radial-gradient(circle at top left, rgba(140, 113, 88, 0.16), transparent 28%), + radial-gradient(circle at bottom right, rgba(79, 95, 109, 0.2), transparent 32%), + linear-gradient(180deg, #0b0f11 0%, #13191e 100%); + color: #f3efe8; + --panel: rgba(22, 28, 33, 0.82); + --panel-border: rgba(244, 225, 208, 0.08); + --accent: #cf8b68; + --accent-cool: #8aa5b0; + --danger: #bb5f54; + --muted: #9aa8ad; + --type-3xs: 0.58rem; + --type-2xs: 0.62rem; + --type-xs: 0.68rem; + --type-sm: 0.74rem; + --type-md: 0.8rem; + --type-base: 0.84rem; + --type-lg: 0.92rem; + --type-xl: 1.1rem; + --type-title: clamp(1.2rem, 1.8vw, 1.8rem); + --type-title-compact: clamp(1rem, 1.4vw, 1.3rem); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +.admin-shell { + min-height: 100vh; + padding: 10px; +} + +.admin-shell--build { + padding: 8px; +} + +.admin-shell--show { + height: 100dvh; + min-height: 100dvh; + overflow: hidden; + display: grid; + grid-template-rows: auto 1fr; + gap: 8px; + padding: 6px; +} + +.admin-topbar { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: end; + margin-bottom: 10px; +} + +.admin-shell--build .admin-topbar { + gap: 8px; + margin-bottom: 6px; + align-items: center; +} + +.admin-shell--show .admin-topbar { + margin-bottom: 0; + align-items: center; +} + +.admin-shell--show .admin-topbar__title { + display: grid; + gap: 2px; +} + +.admin-topbar__title { + min-width: 0; +} + +.admin-kicker, +.admin-panel-kicker, +.surface-label { + margin: 0 0 3px; + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: var(--type-2xs); + color: var(--muted); +} + +.admin-title { + margin: 0; + font-size: var(--type-title); +} + +.admin-shell--build .admin-title { + font-size: var(--type-title-compact); +} + +.admin-shell--show .admin-title { + display: none; +} + +.admin-status { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px; + padding: 7px 10px; + background: rgba(15, 18, 22, 0.66); + border: 1px solid var(--panel-border); + border-radius: 999px; + color: var(--muted); + font-size: var(--type-base); +} + +.admin-shell--build .admin-status { + padding: 6px 8px; + gap: 4px; + font-size: var(--type-md); +} + +.admin-topbar__status-text { + min-width: 0; + max-width: 320px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.admin-shell--show .admin-status { + padding: 5px 8px; + gap: 4px; + font-size: var(--type-md); +} + +.admin-status button { + border: 1px solid rgba(244, 225, 208, 0.12); + background: rgba(27, 34, 40, 0.92); + color: inherit; + border-radius: 999px; + padding: 5px 9px; +} + +.admin-shell--build .admin-status button, +.admin-shell--build .workspace-toggle__button { + padding: 4px 7px; + font-size: var(--type-sm); +} + +.admin-shell--show .admin-status button, +.admin-shell--show .workspace-toggle__button { + padding: 4px 7px; + font-size: var(--type-sm); +} + +.admin-shell--show .admin-status__action--build { + display: none; +} + +.workspace-toggle { + display: inline-flex; + border: 1px solid rgba(244, 225, 208, 0.12); + border-radius: 999px; + overflow: hidden; +} + +.workspace-toggle__button { + border: 0; + border-right: 1px solid rgba(244, 225, 208, 0.08); + background: transparent; + color: inherit; + padding: 5px 9px; +} + +.workspace-toggle__button:last-child { + border-right: 0; +} + +.workspace-toggle__button--active { + background: rgba(207, 139, 104, 0.18); + color: #fff3e6; +} + +.admin-grid { + display: grid; + grid-template-columns: 1.05fr 1.3fr 1fr; + gap: 10px; +} + +.admin-shell--build .admin-grid { + gap: 8px; +} + +.admin-grid--build { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; +} + +.admin-grid--build .admin-panel--moderation, +.admin-grid--build .admin-panel--controls { + max-height: min(34vh, 360px); + overflow: auto; + scrollbar-gutter: stable; + overscroll-behavior: contain; +} + +.admin-grid--build .admin-panel--scene-browser, +.admin-grid--build .admin-panel--look-modes, +.admin-grid--build .admin-panel--cue-builder, +.admin-grid--build .admin-panel--cue-stack { + max-height: min(25vh, 280px); + overflow: auto; + scrollbar-gutter: stable; + overscroll-behavior: contain; +} + +.admin-grid--show { + min-height: 0; + height: 100%; + overflow: hidden; + grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.75fr); + grid-template-rows: minmax(0, 0.64fr) minmax(340px, 0.86fr); +} + +.admin-panel { + min-height: 240px; + padding: 10px; + border-radius: 14px; + background: var(--panel); + border: 1px solid var(--panel-border); + backdrop-filter: blur(18px); + box-shadow: 0 16px 44px rgba(0, 0, 0, 0.22); +} + +.admin-shell--build .admin-panel { + min-height: 0; + padding: 8px; + border-radius: 12px; +} + +.admin-grid--show .admin-panel { + min-height: 0; + overflow: auto; + padding: 8px; +} + +.admin-grid--show .admin-panel--controls, +.admin-grid--show .admin-panel--cue-stack { + scrollbar-gutter: stable; + overscroll-behavior: contain; +} + +.admin-shell--show .admin-panel { + border-radius: 12px; +} + +.admin-panel--output { + grid-column: span 2; +} + +.admin-grid--build .admin-panel--output { + grid-column: 1 / -1; + grid-row: 1; +} + +.admin-grid--build .admin-panel--moderation { + grid-column: 1; + grid-row: 2; +} + +.admin-grid--build .admin-panel--controls { + grid-column: 2; + grid-row: 2; +} + +.admin-grid--build .admin-panel--scene-browser { + grid-column: 1; + grid-row: 3; +} + +.admin-grid--build .admin-panel--look-modes { + grid-column: 2; + grid-row: 3; +} + +.admin-grid--build .admin-panel--bank { + grid-column: 1 / -1; + grid-row: 4; +} + +.admin-grid--build .admin-panel--cue-builder { + grid-column: 1; + grid-row: 5; +} + +.admin-grid--build .admin-panel--cue-stack { + grid-column: 2; + grid-row: 5; +} + +.admin-grid--show .admin-panel--moderation { + display: none; +} + +.admin-grid--show .admin-panel--output { + grid-column: 1; + grid-row: 1 / span 2; +} + +.admin-grid--show .admin-panel--controls { + grid-column: 2; + grid-row: 1; +} + +.admin-grid--show .admin-panel--scene-browser { + display: none; +} + +.admin-grid--show .admin-panel--look-modes { + display: none; +} + +.admin-grid--show .admin-panel--cue-stack { + grid-column: 2; + grid-row: 2; +} + +.admin-grid--show .admin-panel--build-only { + display: none; +} + +.admin-panel-header { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: start; + margin-bottom: 8px; +} + +.admin-shell--build .admin-panel-header { + gap: 6px; + margin-bottom: 6px; +} + +.admin-panel-header h2 { + margin: 0; + font-size: var(--type-lg); +} + +.admin-shell--build .admin-panel-header h2 { + font-size: var(--type-base); + line-height: 1.1; +} + +.admin-shell--show .admin-panel-header { + margin-bottom: 6px; + gap: 6px; +} + +.admin-shell--show .admin-panel-kicker { + display: none; +} + +.admin-shell--show .admin-panel-header h2 { + font-size: var(--type-base); + line-height: 1.1; +} + +.asset-list, +.cue-list, +.scene-grid { + display: grid; + gap: 8px; +} + +.admin-shell--build .asset-list, +.admin-shell--build .cue-list, +.admin-shell--build .scene-grid, +.admin-shell--build .preset-list, +.admin-shell--build .bank-list, +.admin-shell--build .selected-asset-list, +.admin-shell--build .cue-builder-grid, +.admin-shell--build .control-list { + gap: 6px; +} + +.preset-list, +.bank-list { + display: grid; + gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.admin-shell--build .preset-list, +.admin-shell--build .bank-list { + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); +} + +.selected-asset-list { + display: grid; + gap: 8px; +} + +.admin-shell--build .selected-asset-list { + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + align-items: start; +} + +.asset-card, +.preset-card, +.bank-item, +.cue-row { + border-radius: 14px; + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(9, 12, 15, 0.38); + color: inherit; +} + +.asset-card { + display: grid; + grid-template-columns: 88px 1fr; + gap: 8px; + overflow: hidden; +} + +.admin-shell--build .asset-card { + grid-template-columns: 72px 1fr; + gap: 6px; +} + +.asset-card__media, +.asset-card__placeholder, +.bank-item__thumb { + background: linear-gradient(160deg, #2d3438, #171d21); +} + +.asset-card__media img, +.asset-card__placeholder, +.bank-item__thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.asset-card__body { + padding: 8px 8px 8px 0; + display: grid; + gap: 6px; +} + +.admin-shell--build .asset-card__body { + gap: 4px; + padding: 6px 6px 6px 0; +} + +.asset-meta { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: start; +} + +.source-badge { + display: inline-flex; + align-items: center; + padding: 2px 6px; + border-radius: 999px; + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(17, 22, 27, 0.9); + color: var(--muted); + font-size: var(--type-xs); + white-space: nowrap; +} + +.asset-text-line, +.asset-id-line { + margin: 0; + color: var(--muted); + overflow-wrap: anywhere; +} + +.asset-id-line { + font-size: var(--type-md); +} + +.asset-card__body p, +.preset-card p, +.bank-item p, +.scene-summary, +.preset-note { + margin: 0; + color: var(--muted); +} + +.text-preview-note, +.text-preview-sample { + margin: 0; + color: var(--muted); + font-size: var(--type-base); + line-height: 1.35; +} + +.text-preview-sample { + color: #e7d8c2; +} + +.text-preview-sample--secondary { + color: var(--muted); +} + +.live-summary-chips { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; +} + +.summary-chip { + display: inline-flex; + align-items: center; + padding: 3px 7px; + border-radius: 999px; + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(13, 17, 21, 0.72); + color: var(--muted); + font-size: var(--type-xs); + white-space: nowrap; +} + +.control-group { + display: grid; + gap: 8px; +} + +.admin-shell--build .control-group { + gap: 6px; +} + +.control-group + .control-group { + margin-top: 10px; +} + +.admin-shell--build .control-group + .control-group { + margin-top: 8px; +} + +.control-group__label { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: var(--type-sm); + color: var(--muted); +} + +.admin-shell--build .control-group__label { + font-size: var(--type-xs); +} + +.utility-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.admin-shell--build .utility-actions { + gap: 4px; +} + +.utility-actions button { + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(17, 22, 27, 0.82); + color: inherit; + border-radius: 10px; + padding: 7px 9px; +} + +.admin-shell--build .utility-actions button { + padding: 5px 7px; + font-size: var(--type-sm); +} + +.utility-actions--controls { + margin-top: 8px; +} + +.admin-shell--build .utility-actions--controls { + margin-top: 4px; +} + +.admin-shell--show .utility-actions--controls { + margin-top: 4px; +} + +.admin-shell--show .utility-actions--controls button { + padding: 5px 7px; + font-size: var(--type-sm); +} + +.admin-search { + display: grid; + gap: 6px; + margin-bottom: 8px; +} + +.admin-shell--build .admin-search { + gap: 4px; + margin-bottom: 6px; +} + +.admin-search span { + color: var(--muted); + font-size: var(--type-md); +} + +.admin-shell--build .admin-search span { + font-size: var(--type-sm); +} + +.admin-search input { + width: 100%; + min-width: 0; + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(17, 22, 27, 0.82); + color: inherit; + border-radius: 10px; + padding: 7px 9px; +} + +.admin-shell--build .admin-search input { + padding: 6px 8px; +} + +.asset-card__actions, +.cue-transport, +.surface-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.admin-shell--build .asset-card__actions, +.admin-shell--build .cue-transport, +.admin-shell--build .surface-actions { + gap: 4px; +} + +.asset-card__actions button, +.cue-transport button, +.surface-actions button, +.scene-card, +.cue-row, +.preset-card, +.bank-item { + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(17, 22, 27, 0.82); + color: inherit; + border-radius: 10px; + padding: 7px 9px; +} + +.admin-shell--build .asset-card__actions button, +.admin-shell--build .cue-transport button, +.admin-shell--build .surface-actions button, +.admin-shell--build .scene-card, +.admin-shell--build .cue-row, +.admin-shell--build .preset-card, +.admin-shell--build .bank-item { + padding: 6px 7px; +} + +.admin-shell--build .asset-card__actions button, +.admin-shell--build .cue-transport button, +.admin-shell--build .surface-actions button { + font-size: var(--type-sm); +} + +.asset-card__actions .danger { + border-color: rgba(187, 95, 84, 0.28); + color: #f8cdc9; +} + +.surface-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 8px; +} + +.admin-shell--build .surface-grid { + gap: 6px; + margin-bottom: 6px; +} + +.admin-grid--show .surface-grid { + min-height: 0; + gap: 6px; + margin-bottom: 6px; +} + +.surface-card { + min-height: 0; + padding: 8px; + border-radius: 12px; + background: linear-gradient(180deg, rgba(17, 22, 27, 0.82), rgba(9, 12, 15, 0.92)); + border: 1px solid rgba(244, 225, 208, 0.08); + display: grid; + grid-template-rows: auto 1fr; + gap: 6px; +} + +.admin-shell--build .surface-card { + padding: 6px; + gap: 4px; +} + +.admin-grid--show .surface-card { + padding: 6px; +} + +.surface-card--program { + outline: 1px solid rgba(207, 139, 104, 0.28); +} + +.surface-card__meta { + display: grid; + gap: 4px; +} + +.surface-card__meta strong { + font-size: var(--type-lg); +} + +.surface-card__meta span { + color: var(--muted); + font-size: var(--type-md); +} + +.admin-shell--build .surface-card__meta { + gap: 2px; +} + +.admin-shell--build .surface-card__meta strong { + font-size: var(--type-base); +} + +.admin-shell--build .surface-card__meta span { + font-size: var(--type-sm); +} + +.admin-shell--show .surface-card__meta { + gap: 2px; +} + +.admin-shell--show .surface-card__meta strong { + font-size: var(--type-base); +} + +.admin-shell--show .surface-card__meta span { + font-size: var(--type-sm); + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 180ms ease, opacity 180ms ease; +} + +.admin-shell--show .surface-card:hover .surface-card__meta span, +.admin-shell--show .surface-card:focus-within .surface-card__meta span { + max-height: 1.4rem; + opacity: 1; +} + +.scene-grid { + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} + +.admin-shell--build .scene-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} + +.scene-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} + +.admin-shell--build .scene-filter-bar { + gap: 6px; + margin-bottom: 6px; +} + +.scene-filter-chip { + display: grid; + gap: 4px; + min-width: 92px; + text-align: left; + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(17, 22, 27, 0.82); + color: inherit; + border-radius: 12px; + padding: 7px 9px; +} + +.admin-shell--build .scene-filter-chip { + min-width: 84px; + gap: 2px; + padding: 5px 7px; +} + +.scene-filter-chip small { + color: var(--muted); + font-size: var(--type-xs); +} + +.scene-filter-chip strong { + font-size: var(--type-sm); + line-height: 1.1; +} + +.scene-filter-chip--active { + border-color: rgba(207, 139, 104, 0.42); + background: linear-gradient(180deg, rgba(47, 34, 28, 0.78), rgba(17, 22, 27, 0.96)); +} + +.scene-card { + display: grid; + gap: 6px; + text-align: left; + min-height: 112px; + position: relative; + overflow: hidden; + isolation: isolate; + background: + linear-gradient(160deg, color-mix(in srgb, var(--scene-ink, #171d21) 92%, black) 0%, rgba(10, 13, 16, 0.96) 100%); +} + +.admin-shell--build .scene-card { + gap: 4px; + min-height: 92px; +} + +.admin-shell--build .scene-card span, +.admin-shell--build .scene-card small, +.admin-shell--build .scene-card em { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 180ms ease, opacity 180ms ease; +} + +.admin-shell--build .scene-card:hover span, +.admin-shell--build .scene-card:hover small, +.admin-shell--build .scene-card:hover em, +.admin-shell--build .scene-card:focus-visible span, +.admin-shell--build .scene-card:focus-visible small, +.admin-shell--build .scene-card:focus-visible em, +.admin-shell--build .scene-card--active span, +.admin-shell--build .scene-card--active small, +.admin-shell--build .scene-card--active em { + max-height: 3rem; + opacity: 1; +} + +.scene-card span, +.scene-card small { + color: var(--muted); +} + +.scene-card strong { + font-size: var(--type-md); + line-height: 1.15; +} + +.scene-card span { + font-size: var(--type-sm); + line-height: 1.25; +} + +.scene-card small { + font-size: var(--type-xs); + line-height: 1.15; +} + +.scene-card strong, +.scene-card small, +.scene-card span, +.scene-card em { + position: relative; + z-index: 1; +} + +.scene-card em { + font-style: normal; + color: color-mix(in srgb, var(--scene-accent-soft, #8aa5b0) 82%, white); + font-size: var(--type-base); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.scene-card__wash { + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at 18% 18%, color-mix(in srgb, var(--scene-accent, #cf8b68) 34%, transparent), transparent 30%), + radial-gradient(circle at 82% 72%, color-mix(in srgb, var(--scene-accent-soft, #8aa5b0) 24%, transparent), transparent 28%), + linear-gradient(120deg, transparent 0 22%, color-mix(in srgb, var(--scene-accent, #cf8b68) 18%, transparent) 22% 27%, transparent 27% 100%); + opacity: 0.9; + z-index: 0; +} + +.scene-card--active { + border-color: color-mix(in srgb, var(--scene-accent, #cf8b68) 42%, transparent); + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--scene-accent-soft, #8aa5b0) 16%, transparent), + 0 18px 38px rgba(0, 0, 0, 0.24); +} + +.control-list { + display: grid; + gap: 8px; + margin-top: 8px; +} + +.admin-shell--build .control-list { + gap: 6px; + margin-top: 6px; +} + +.parameter-field { + display: grid; + gap: 8px; + padding: 8px; + border-radius: 12px; + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(9, 12, 15, 0.38); +} + +.admin-shell--build .parameter-field { + gap: 5px; + padding: 6px; + border-radius: 10px; +} + +.admin-shell--show .parameter-field { + gap: 5px; + padding: 6px; + border-radius: 10px; +} + +.parameter-field div { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.parameter-field span { + color: var(--muted); +} + +.admin-shell--build .parameter-field span { + font-size: var(--type-sm); +} + +.admin-shell--show .parameter-field span { + font-size: var(--type-sm); +} + +.parameter-field input[type="range"] { + width: 100%; +} + +.file-picker { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.file-picker__input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.file-picker__button { + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(17, 22, 27, 0.82); + color: inherit; + border-radius: 8px; + padding: 7px 9px; + flex: 0 0 auto; +} + +.file-picker__name { + min-width: 0; + color: var(--muted); + font-size: var(--type-md); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.parameter-field input[type="text"], +.parameter-field input[type="number"], +.parameter-field select { + width: 100%; + min-width: 0; + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(17, 22, 27, 0.82); + color: inherit; + border-radius: 8px; + padding: 7px 9px; +} + +.admin-shell--build .parameter-field input[type="text"], +.admin-shell--build .parameter-field input[type="number"], +.admin-shell--build .parameter-field select { + padding: 5px 7px; + font-size: var(--type-md); +} + +.admin-shell--build .file-picker__button, +.admin-shell--show .file-picker__button { + padding: 5px 7px; + font-size: var(--type-sm); +} + +.admin-shell--build .file-picker__name, +.admin-shell--show .file-picker__name { + font-size: var(--type-sm); +} + +.admin-shell--show .parameter-field input[type="text"], +.admin-shell--show .parameter-field input[type="number"], +.admin-shell--show .parameter-field select { + padding: 5px 7px; + font-size: var(--type-md); +} + +.cue-builder-grid { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 8px; +} + +.admin-shell--build .cue-builder-grid { + gap: 6px; + margin-top: 6px; +} + +.cue-builder-grid--single { + grid-template-columns: minmax(0, 1fr); +} + +.cue-builder-grid > * { + min-width: 0; +} + +.cue-transition-row { + display: grid; + gap: 8px; + grid-template-columns: minmax(0, 1fr) minmax(84px, 112px); + align-items: end; +} + +.admin-shell--build .cue-transition-row { + gap: 6px; +} + +.cue-transition-row > * { + width: 100%; + min-width: 0; +} + +.cue-builder-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.cue-builder-actions button { + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(17, 22, 27, 0.82); + color: inherit; + border-radius: 10px; + padding: 7px 9px; +} + +.cue-builder-actions .danger { + border-color: rgba(187, 95, 84, 0.28); + color: #f8cdc9; +} + +.admin-shell--build .cue-builder-actions { + gap: 6px; + margin-top: 6px; +} + +.admin-shell--build .cue-builder-actions button, +.admin-shell--show .cue-builder-actions button { + padding: 5px 7px; + font-size: var(--type-sm); +} + +.parameter-field--toggle { + grid-template-columns: 1fr auto; + align-items: center; +} + +.parameter-field--toggle div { + justify-content: start; +} + +.preset-card { + display: grid; + gap: 8px; + text-align: left; + min-height: 112px; + background: + radial-gradient(circle at top right, rgba(101, 140, 153, 0.2), transparent 28%), + linear-gradient(180deg, rgba(12, 18, 23, 0.88), rgba(17, 22, 27, 0.94)); +} + +.admin-shell--build .preset-card { + gap: 4px; + min-height: 92px; +} + +.preset-card strong { + font-size: var(--type-md); + line-height: 1.15; +} + +.preset-card p { + font-size: var(--type-sm); + line-height: 1.25; +} + +.preset-card small { + font-size: var(--type-xs); + line-height: 1.15; +} + +.admin-shell--build .preset-card p, +.admin-shell--build .preset-card small { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 180ms ease, opacity 180ms ease; +} + +.admin-shell--build .preset-card:hover p, +.admin-shell--build .preset-card:hover small, +.admin-shell--build .preset-card:focus-visible p, +.admin-shell--build .preset-card:focus-visible small, +.admin-shell--build .preset-card--active p, +.admin-shell--build .preset-card--active small { + max-height: 4rem; + opacity: 1; +} + +.admin-upload-panel { + display: grid; + gap: 10px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(244, 225, 208, 0.08); +} + +.admin-shell--build .admin-upload-panel { + gap: 8px; + margin-top: 8px; + padding-top: 8px; +} + +.admin-panel-header--subsection { + margin-bottom: 0; +} + +.preset-card--active { + border-color: rgba(138, 165, 176, 0.42); + background: linear-gradient(180deg, rgba(20, 35, 42, 0.85), rgba(17, 22, 27, 0.94)); +} + +.selected-bank { + display: grid; + gap: 8px; + margin: 10px 0; + padding: 8px; + border-radius: 12px; + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(9, 12, 15, 0.38); +} + +.admin-shell--build .selected-bank { + gap: 6px; + margin: 8px 0; + padding: 6px; +} + +.admin-grid--build .selected-bank { + margin: 12px 0; +} + +.selected-bank__header { + display: flex; + justify-content: space-between; + align-items: start; + gap: 10px; +} + +.selected-bank__header h3 { + margin: 0; + font-size: var(--type-lg); +} + +.admin-shell--build .selected-bank__header h3 { + font-size: var(--type-base); +} + +.selected-bank__note { + margin: 6px 0 0; + color: var(--muted); +} + +.admin-shell--build .selected-bank__note, +.admin-shell--build .bank-summary, +.admin-shell--build .scene-summary, +.admin-shell--build .preset-note, +.admin-shell--build .text-preview-note, +.admin-shell--build .text-preview-sample { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 180ms ease, opacity 180ms ease; +} + +.admin-shell--build .admin-panel--controls:hover .scene-summary, +.admin-shell--build .admin-panel--controls:hover .preset-note, +.admin-shell--build .admin-panel--controls:hover .text-preview-note, +.admin-shell--build .admin-panel--controls:hover .text-preview-sample, +.admin-shell--build .admin-panel--controls:focus-within .scene-summary, +.admin-shell--build .admin-panel--controls:focus-within .preset-note, +.admin-shell--build .admin-panel--controls:focus-within .text-preview-note, +.admin-shell--build .admin-panel--controls:focus-within .text-preview-sample, +.admin-shell--build .admin-panel--bank:hover .selected-bank__note, +.admin-shell--build .admin-panel--bank:hover .bank-summary, +.admin-shell--build .admin-panel--bank:focus-within .selected-bank__note, +.admin-shell--build .admin-panel--bank:focus-within .bank-summary, +.admin-shell--build .admin-panel--cue-builder:hover .scene-summary, +.admin-shell--build .admin-panel--cue-builder:focus-within .scene-summary, +.admin-shell--build .admin-panel--look-modes:hover .preset-note, +.admin-shell--build .admin-panel--look-modes:focus-within .preset-note, +.admin-shell--build .admin-panel--scene-browser:hover .scene-summary, +.admin-shell--build .admin-panel--scene-browser:focus-within .scene-summary { + max-height: 5rem; + opacity: 1; +} + +.selected-asset { + display: grid; + grid-template-columns: 56px 1fr auto; + gap: 8px; + align-items: center; + padding: 8px; + border-radius: 12px; + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(17, 22, 27, 0.82); +} + +.admin-shell--build .selected-asset { + grid-template-columns: 1fr; + gap: 6px; + padding: 6px; + align-items: start; +} + +.selected-asset--anchor { + border-color: rgba(207, 139, 104, 0.42); + background: linear-gradient(180deg, rgba(47, 34, 28, 0.75), rgba(17, 22, 27, 0.92)); +} + +.selected-asset__thumb { + width: 56px; + height: 56px; + border-radius: 8px; + overflow: hidden; + background: linear-gradient(160deg, #2d3438, #171d21); +} + +.admin-shell--build .selected-asset__thumb { + width: 100%; + height: auto; + aspect-ratio: 4 / 3; + border-radius: 7px; +} + +.selected-asset__thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.selected-asset__body { + display: grid; + gap: 4px; + min-width: 0; +} + +.selected-asset__meta { + display: grid; + gap: 4px; + min-width: 0; +} + +.selected-asset__body p, +.selected-asset__body small { + margin: 0; + color: var(--muted); +} + +.selected-asset__body p { + overflow-wrap: anywhere; +} + +.selected-asset__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.admin-shell--build .selected-asset__actions { + gap: 4px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.selected-asset__actions button { + border: 1px solid rgba(244, 225, 208, 0.08); + background: rgba(17, 22, 27, 0.82); + color: inherit; + border-radius: 10px; + padding: 7px 9px; +} + +.admin-shell--build .selected-asset__actions button, +.admin-shell--show .selected-asset__actions button { + padding: 5px 7px; + font-size: var(--type-sm); +} + +.admin-shell--build .selected-asset__body { + gap: 3px; +} + +.admin-shell--build .selected-asset__body p { + font-size: var(--type-md); + color: #ebe1d0; + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.admin-shell--build .selected-asset__body small { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 180ms ease, opacity 180ms ease; +} + +.admin-shell--build .selected-asset:hover .selected-asset__body small, +.admin-shell--build .selected-asset:focus-within .selected-asset__body small, +.admin-shell--build .selected-asset--anchor .selected-asset__body small { + max-height: 3rem; + opacity: 1; +} + +.admin-shell--build .selected-asset .asset-meta strong { + font-size: var(--type-sm); +} + +.bank-item { + display: grid; + grid-template-columns: 56px 1fr; + gap: 8px; + align-items: center; + text-align: left; +} + +.admin-shell--build .bank-item { + grid-template-columns: 1fr; + gap: 0; + padding: 0; + overflow: hidden; + aspect-ratio: 0.9; + position: relative; +} + +.bank-item__body { + display: grid; + gap: 4px; + min-width: 0; +} + +.bank-item__overlay { + display: grid; + gap: 3px; + min-width: 0; +} + +.bank-item__flags { + display: flex; + justify-content: space-between; + align-items: start; + gap: 4px; +} + +.bank-item__chip { + display: inline-flex; + align-items: center; + padding: 2px 5px; + border-radius: 999px; + background: rgba(12, 16, 20, 0.78); + border: 1px solid rgba(244, 225, 208, 0.08); + color: #efe2d1; + font-size: var(--type-2xs); + line-height: 1; +} + +.bank-item__thumb { + width: 56px; + height: 56px; + border-radius: 8px; + overflow: hidden; +} + +.admin-shell--build .bank-item__thumb { + width: 100%; + height: 100%; + border-radius: 0; +} + +.bank-item--selected { + border-color: rgba(207, 139, 104, 0.42); + background: linear-gradient(180deg, rgba(47, 34, 28, 0.75), rgba(17, 22, 27, 0.92)); +} + +.admin-shell--build .bank-item__overlay { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 6px; + background: linear-gradient(180deg, rgba(6, 8, 11, 0) 0%, rgba(6, 8, 11, 0.82) 46%, rgba(6, 8, 11, 0.96) 100%); +} + +.admin-shell--build .bank-item__overlay strong { + font-size: var(--type-sm); + line-height: 1.15; + color: #f1e5d5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.admin-shell--build .bank-item__overlay p { + font-size: var(--type-xs); + line-height: 1.1; + margin: 0; +} + +.admin-shell--build .bank-item__overlay .asset-text-line { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 180ms ease, opacity 180ms ease; +} + +.admin-shell--build .bank-item:hover .asset-text-line, +.admin-shell--build .bank-item:focus-visible .asset-text-line, +.admin-shell--build .bank-item--selected .asset-text-line { + max-height: 3rem; + opacity: 1; +} + +.admin-shell--build .bank-item .source-badge { + padding: 1px 4px; + font-size: var(--type-3xs); +} + +.cue-row { + display: grid; + grid-template-columns: 30px 1fr auto; + gap: 8px; + text-align: left; +} + +.admin-shell--build .cue-row { + grid-template-columns: 24px 1fr auto; + gap: 6px; + align-items: start; +} + +.admin-shell--show .cue-row { + grid-template-columns: 22px 1fr auto; + gap: 6px; + padding: 6px 7px; + align-items: start; +} + +.cue-row__body { + display: grid; + gap: 4px; +} + +.admin-shell--build .cue-row__body { + gap: 2px; +} + +.admin-shell--build .cue-row strong { + font-size: var(--type-md); + line-height: 1.15; +} + +.admin-shell--show .cue-row__body { + gap: 2px; +} + +.admin-shell--show .cue-row strong { + font-size: var(--type-md); + line-height: 1.15; +} + +.cue-row--armed { + border-color: rgba(138, 165, 176, 0.4); +} + +.cue-row--live { + border-color: rgba(207, 139, 104, 0.44); + background: linear-gradient(180deg, rgba(47, 34, 28, 0.78), rgba(17, 22, 27, 0.96)); +} + +.cue-row span, +.cue-row small, +.bank-summary, +.empty-state { + color: var(--muted); +} + +.admin-shell--build .cue-row__body small, +.admin-shell--build .cue-row > small { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 180ms ease, opacity 180ms ease; +} + +.admin-shell--build .cue-row:hover .cue-row__body small, +.admin-shell--build .cue-row:hover > small, +.admin-shell--build .cue-row:focus-visible .cue-row__body small, +.admin-shell--build .cue-row:focus-visible > small, +.admin-shell--build .cue-row--armed .cue-row__body small, +.admin-shell--build .cue-row--armed > small, +.admin-shell--build .cue-row--live .cue-row__body small, +.admin-shell--build .cue-row--live > small { + max-height: 2.8rem; + opacity: 1; +} + +.admin-shell--show .cue-row__body small, +.admin-shell--show .cue-row > small { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 180ms ease, opacity 180ms ease; +} + +.admin-shell--show .cue-row:hover .cue-row__body small, +.admin-shell--show .cue-row:hover > small, +.admin-shell--show .cue-row:focus-visible .cue-row__body small, +.admin-shell--show .cue-row:focus-visible > small, +.admin-shell--show .cue-row--armed .cue-row__body small, +.admin-shell--show .cue-row--armed > small, +.admin-shell--show .cue-row--live .cue-row__body small, +.admin-shell--show .cue-row--live > small { + max-height: 2.8rem; + opacity: 1; +} + +.admin-grid--show .cue-list, +.admin-grid--show .scene-grid, +.admin-grid--show .preset-list, +.admin-grid--show .asset-list { + gap: 6px; +} + +.admin-grid--show .cue-transport { + margin-top: 4px; + gap: 4px; +} + +.admin-shell--show .cue-transport button, +.admin-shell--show .surface-actions button { + padding: 5px 7px; + font-size: var(--type-sm); +} + +.admin-shell--show .scene-summary, +.admin-shell--show .preset-note, +.admin-shell--show .text-preview-note, +.admin-shell--show .text-preview-sample { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 180ms ease, opacity 180ms ease; +} + +.admin-shell--show .admin-panel--controls:hover .scene-summary, +.admin-shell--show .admin-panel--controls:hover .preset-note, +.admin-shell--show .admin-panel--controls:hover .text-preview-note, +.admin-shell--show .admin-panel--controls:hover .text-preview-sample, +.admin-shell--show .admin-panel--controls:focus-within .scene-summary, +.admin-shell--show .admin-panel--controls:focus-within .preset-note, +.admin-shell--show .admin-panel--controls:focus-within .text-preview-note, +.admin-shell--show .admin-panel--controls:focus-within .text-preview-sample { + max-height: 4.8rem; + opacity: 1; +} + +.admin-shell--show .control-group + .control-group { + margin-top: 8px; +} + +.admin-shell--show .control-group__label { + font-size: var(--type-xs); + letter-spacing: 0.11em; +} + +.admin-shell--show .control-list { + gap: 6px; + margin-top: 6px; +} + +@media (max-width: 1180px) { + .admin-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-panel--output { + grid-column: span 2; + } + + .selected-asset { + grid-template-columns: 72px 1fr; + } + + .selected-asset__actions { + grid-column: 1 / -1; + justify-content: start; + } +} + +@media (max-width: 820px) { + .admin-shell { + padding: 14px; + } + + .admin-topbar { + flex-direction: column; + align-items: start; + } + + .admin-grid, + .surface-grid { + grid-template-columns: 1fr; + } + + .cue-builder-grid, + .cue-transition-row, + .selected-bank__header { + grid-template-columns: 1fr; + } + + .admin-panel--output { + grid-column: span 1; + } + + .selected-bank__header { + display: grid; + } + + .scene-filter-chip { + min-width: 0; + flex: 1 1 120px; + } + + .cue-row { + grid-template-columns: 32px 1fr; + } +} diff --git a/apps/admin/src/app/output.css b/apps/admin/src/app/output.css new file mode 100644 index 0000000..7fa6905 --- /dev/null +++ b/apps/admin/src/app/output.css @@ -0,0 +1,111 @@ +html.mode-output { + --type-3xs: 0.58rem; + --type-2xs: 0.62rem; + --type-xs: 0.68rem; + --type-sm: 0.74rem; + --type-md: 0.8rem; + --type-base: 0.84rem; + --type-lg: 0.92rem; + --type-xl: 1.1rem; +} + +html.mode-output, +body.mode-output, +body.mode-output #root { + margin: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: #000; +} + +body.mode-output .output-shell { + position: fixed; + inset: 0; + width: 100vw; + height: 100dvh; + overflow: hidden; + background: #000; +} + +body.mode-output .output-shell .surface-viewport { + width: 100%; + height: 100%; + min-height: 0; + border: 0; + border-radius: 0; + background: #000; +} + +body.mode-output .output-shell .surface-viewport__canvas { + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; +} + +body.mode-output .output-overlay { + position: fixed; + left: 24px; + right: 24px; + bottom: 24px; + display: flex; + justify-content: space-between; + gap: 16px; + align-items: end; + padding: 16px 18px; + border-radius: 18px; + background: rgba(8, 12, 16, 0.78); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #f5f2ea; + backdrop-filter: blur(18px); + opacity: 0; + transform: translateY(12px); + pointer-events: none; + transition: opacity 220ms ease, transform 220ms ease; +} + +body.mode-output .output-overlay--visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +body.mode-output .output-overlay p, +body.mode-output .output-overlay strong, +body.mode-output .output-overlay span { + display: block; +} + +body.mode-output .output-overlay p { + margin: 0 0 6px; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: var(--type-sm); + color: #aeb7c0; +} + +body.mode-output .output-overlay strong { + font-size: var(--type-xl); + margin-bottom: 4px; +} + +body.mode-output .output-overlay span { + color: #aeb7c0; + font-size: var(--type-md); +} + +body.mode-output .output-overlay__actions { + display: flex; + gap: 10px; +} + +body.mode-output .output-overlay__actions button { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 999px; + padding: 10px 14px; + background: rgba(22, 28, 34, 0.92); + color: inherit; + cursor: pointer; + font-size: var(--type-sm); +} diff --git a/apps/admin/src/features/live/SceneViewport.tsx b/apps/admin/src/features/live/SceneViewport.tsx new file mode 100644 index 0000000..e5cfe46 --- /dev/null +++ b/apps/admin/src/features/live/SceneViewport.tsx @@ -0,0 +1,93 @@ +import { useEffect, useRef } from "react"; +import type { RenderSurface as RenderSurfaceType, SurfacePresentation } from "@goodgrief/render-engine"; +import type { CueTransition } from "@goodgrief/shared-types"; +import "./viewport.css"; + +interface SceneViewportProps { + presentation: SurfacePresentation | null; + blackout?: boolean; + transition?: CueTransition | null; + activationKey?: string; +} + +const defaultTransition: CueTransition = { + style: "cut", + durationMs: 0 +}; + +export const SceneViewport = ({ presentation, blackout = false, transition, activationKey }: SceneViewportProps) => { + const frameRef = useRef(null); + const canvasRef = useRef(null); + const surfaceRef = useRef(null); + const presentationRef = useRef(presentation); + const activationRef = useRef(activationKey); + const transitionRef = useRef(transition); + const blackoutRef = useRef(blackout); + + presentationRef.current = presentation; + activationRef.current = activationKey; + transitionRef.current = transition; + blackoutRef.current = blackout; + + useEffect(() => { + const frame = frameRef.current; + const canvas = canvasRef.current; + if (!frame || !canvas) { + return; + } + + let cancelled = false; + let observer: ResizeObserver | null = null; + + void import("@goodgrief/render-engine").then(({ RenderSurface, defaultScenePlugins }) => { + if (cancelled) { + return; + } + + const surface = new RenderSurface(canvas); + surface.registerMany(defaultScenePlugins); + surface.setBlackout(blackoutRef.current); + surfaceRef.current = surface; + + const resize = () => { + const { width, height } = frame.getBoundingClientRect(); + surface.setSize(Math.max(1, width), Math.max(1, height)); + }; + + resize(); + observer = new ResizeObserver(() => resize()); + observer.observe(frame); + + const initialPresentation = presentationRef.current; + if (initialPresentation) { + void surface.activate(initialPresentation, defaultTransition); + } + }); + + return () => { + cancelled = true; + observer?.disconnect(); + surfaceRef.current?.dispose(); + surfaceRef.current = null; + }; + }, []); + + useEffect(() => { + surfaceRef.current?.setBlackout(blackout); + }, [blackout]); + + useEffect(() => { + const surface = surfaceRef.current; + if (!surface) { + return; + } + + void surface.activate(presentationRef.current, transitionRef.current ?? defaultTransition); + }, [activationKey]); + + return ( +
+ +
+ ); +}; diff --git a/apps/admin/src/features/live/api.ts b/apps/admin/src/features/live/api.ts new file mode 100644 index 0000000..2bb0ab6 --- /dev/null +++ b/apps/admin/src/features/live/api.ts @@ -0,0 +1,113 @@ +import type { + Cue, + CueGeneratePayload, + CueMovePayload, + CueUpsertPayload, + ModerationActionPayload, + RepositoryState +} from "@goodgrief/shared-types"; + +const postVoid = async (url: string, body?: unknown) => { + const response = await fetch(url, { + method: "POST", + headers: body + ? { + "Content-Type": "application/json" + } + : undefined, + body: body ? JSON.stringify(body) : undefined + }); + + if (!response.ok) { + throw new Error(`Request failed for ${url}.`); + } +}; + +const requestJson = async (url: string, init?: RequestInit) => { + const response = await fetch(url, init); + if (!response.ok) { + let message = `Request failed for ${url}.`; + try { + const payload = (await response.json()) as { message?: string }; + if (payload.message) { + message = payload.message; + } + } catch { + // ignore non-json errors + } + throw new Error(message); + } + return (await response.json()) as T; +}; + +export const loadState = async (): Promise => { + const response = await fetch("/api/state"); + if (!response.ok) { + throw new Error("Could not load admin state."); + } + return (await response.json()) as RepositoryState; +}; + +export const rescanLibrary = async (): Promise => + requestJson("/api/library/rescan", { + method: "POST" + }); + +export const moderateAsset = async (assetId: string, payload: ModerationActionPayload) => { + await postVoid(`/api/assets/${assetId}/moderation`, payload); +}; + +export const fireCue = async (cueId: string) => { + await postVoid(`/api/cues/${cueId}/fire`); +}; + +export const activateSafeCue = async (cueId: string) => { + await postVoid(`/api/cues/${cueId}/safe`); +}; + +export const createCue = async (payload: CueUpsertPayload) => + requestJson("/api/cues", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }); + +export const updateCue = async (cueId: string, payload: CueUpsertPayload) => + requestJson(`/api/cues/${cueId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }); + +export const moveCue = async (cueId: string, payload: CueMovePayload) => { + await postVoid(`/api/cues/${cueId}/move`, payload); +}; + +export const generateCue = async (payload: CueGeneratePayload) => + requestJson("/api/cues/generate", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }); + +export const createAdminUpload = async (payload: FormData) => + requestJson<{ submission?: { id: string }; assetId: string }>("/api/admin/uploads", { + method: "POST", + body: payload + }); + +export const deleteCue = async (cueId: string) => { + const response = await fetch(`/api/cues/${cueId}`, { + method: "DELETE" + }); + + if (!response.ok) { + throw new Error(`Request failed for /api/cues/${cueId}.`); + } +}; diff --git a/apps/admin/src/features/live/output-sync.ts b/apps/admin/src/features/live/output-sync.ts new file mode 100644 index 0000000..e1fd913 --- /dev/null +++ b/apps/admin/src/features/live/output-sync.ts @@ -0,0 +1,126 @@ +import type { SurfacePresentation } from "@goodgrief/render-engine"; +import { flattenSceneParams, type CueTransition } from "@goodgrief/shared-types"; + +export interface ProgramOutputState { + presentation: SurfacePresentation | null; + blackout: boolean; + transition: CueTransition | null; + presentationHash: string; + outputRevision: number; + updatedAt: string; + takenAt: string; +} + +const storageKey = "goodgrief:program-output"; +const channelName = "goodgrief-program-output"; + +const canUseWindow = () => typeof window !== "undefined"; + +const createChannel = () => + canUseWindow() && "BroadcastChannel" in window ? new BroadcastChannel(channelName) : null; + +const hashString = (input: string) => { + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16); +}; + +export const createPresentationHash = ( + presentation: SurfacePresentation | null, + blackout: boolean, + transition: CueTransition | null +) => + hashString( + JSON.stringify({ + blackout, + transition: transition + ? { + style: transition.style, + durationMs: transition.durationMs + } + : null, + presentation: presentation + ? { + cueId: presentation.cue?.id ?? null, + definitionId: presentation.definition.id, + effectPresetId: presentation.effectPresetId ?? null, + modeKey: presentation.modeKey ?? null, + assetIds: presentation.assets.map((asset) => asset.id), + textFragments: presentation.textFragments ?? [], + anchorCaption: presentation.anchorCaption ?? null, + params: presentation.params ? flattenSceneParams(presentation.params) : null, + label: presentation.label ?? null + } + : null + }) + ); + +export const readProgramOutputState = (): ProgramOutputState | null => { + if (!canUseWindow()) { + return null; + } + + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial; + return { + presentation: parsed.presentation ?? null, + blackout: parsed.blackout ?? false, + transition: parsed.transition ?? null, + presentationHash: + parsed.presentationHash ?? createPresentationHash(parsed.presentation ?? null, parsed.blackout ?? false, parsed.transition ?? null), + outputRevision: parsed.outputRevision ?? 0, + updatedAt: parsed.updatedAt ?? new Date(0).toISOString(), + takenAt: parsed.takenAt ?? parsed.updatedAt ?? new Date(0).toISOString() + }; + } catch { + return null; + } +}; + +export const writeProgramOutputState = (payload: ProgramOutputState) => { + if (!canUseWindow()) { + return; + } + + window.localStorage.setItem(storageKey, JSON.stringify(payload)); + const channel = createChannel(); + channel?.postMessage(payload); + channel?.close(); +}; + +export const subscribeProgramOutput = (onMessage: (payload: ProgramOutputState) => void) => { + if (!canUseWindow()) { + return () => undefined; + } + + const channel = createChannel(); + const storageHandler = (event: StorageEvent) => { + if (event.key !== storageKey || !event.newValue) { + return; + } + + try { + onMessage(JSON.parse(event.newValue) as ProgramOutputState); + } catch { + // ignore malformed storage state + } + }; + + channel?.addEventListener("message", (event) => { + onMessage(event.data as ProgramOutputState); + }); + window.addEventListener("storage", storageHandler); + + return () => { + channel?.close(); + window.removeEventListener("storage", storageHandler); + }; +}; diff --git a/apps/admin/src/features/live/viewport.css b/apps/admin/src/features/live/viewport.css new file mode 100644 index 0000000..3200e1d --- /dev/null +++ b/apps/admin/src/features/live/viewport.css @@ -0,0 +1,28 @@ +.surface-viewport { + position: relative; + min-height: 0; + aspect-ratio: 16 / 9; + border-radius: 14px; + overflow: hidden; + border: 1px solid rgba(244, 225, 208, 0.08); + background: + radial-gradient(circle at center, rgba(143, 170, 190, 0.08), transparent 52%), + linear-gradient(180deg, rgba(9, 11, 14, 0.94), rgba(4, 5, 8, 0.98)); + isolation: isolate; +} + +.surface-viewport::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 22%, transparent 78%, rgba(255, 255, 255, 0.02)), + radial-gradient(circle at center, transparent 62%, rgba(0, 0, 0, 0.14)); +} + +.surface-viewport__canvas { + width: 100%; + height: 100%; + display: block; +} diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx new file mode 100644 index 0000000..845e3b3 --- /dev/null +++ b/apps/admin/src/main.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./app/App"; +import { ProgramOutputApp } from "./app/ProgramOutputApp"; + +const mode = new URLSearchParams(window.location.search).get("mode"); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + {mode === "output" ? : } + +); diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..3f05503 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src", + "vite.config.ts" + ] +} diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts new file mode 100644 index 0000000..fc5463b --- /dev/null +++ b/apps/admin/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +const apiProxyTarget = process.env.VITE_API_PROXY_TARGET ?? "http://localhost:4300"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 4200, + proxy: { + "/api": apiProxyTarget, + "/uploads": apiProxyTarget + } + } +}); diff --git a/apps/submission/index.html b/apps/submission/index.html new file mode 100644 index 0000000..65dc64a --- /dev/null +++ b/apps/submission/index.html @@ -0,0 +1,12 @@ + + + + + + Good Grief Submission + + +
+ + + diff --git a/apps/submission/package.json b/apps/submission/package.json new file mode 100644 index 0000000..2731e7c --- /dev/null +++ b/apps/submission/package.json @@ -0,0 +1,24 @@ +{ + "name": "@goodgrief/submission", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "check": "tsc --noEmit" + }, + "dependencies": { + "@goodgrief/shared-types": "file:../../packages/shared-types", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.0" + }, + "devDependencies": { + "@types/react": "^19.1.3", + "@types/react-dom": "^19.1.3", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "^5.8.3", + "vite": "^6.3.5" + } +} diff --git a/apps/submission/src/app/App.tsx b/apps/submission/src/app/App.tsx new file mode 100644 index 0000000..02279ff --- /dev/null +++ b/apps/submission/src/app/App.tsx @@ -0,0 +1,9 @@ +import { Routes, Route } from "react-router-dom"; +import { SubmissionRoute } from "../routes/SubmissionRoute"; +import "./app.css"; + +export const App = () => ( + + } /> + +); diff --git a/apps/submission/src/app/app.css b/apps/submission/src/app/app.css new file mode 100644 index 0000000..9daa9c5 --- /dev/null +++ b/apps/submission/src/app/app.css @@ -0,0 +1,201 @@ +:root { + color-scheme: only light; + font-family: "Georgia", "Times New Roman", serif; + background: + radial-gradient(circle at top, rgba(245, 224, 210, 0.65), transparent 40%), + linear-gradient(160deg, #f5efe9 0%, #e8ddd2 42%, #d7cabe 100%); + color: #1f1815; + --card: rgba(255, 250, 246, 0.78); + --border: rgba(73, 54, 42, 0.15); + --accent: #8a5037; + --accent-strong: #6a3422; + --soft: #5d524b; + --success: #2a6e52; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; +} + +button, +input, +textarea, +select { + font: inherit; +} + +.submission-shell { + min-height: 100vh; + padding: 24px 18px 48px; +} + +.submission-stage { + width: min(100%, 720px); + margin: 0 auto; + display: grid; + gap: 20px; +} + +.submission-hero { + padding: 24px; + border: 1px solid var(--border); + border-radius: 28px; + background: linear-gradient(180deg, rgba(255, 247, 240, 0.92), rgba(255, 251, 248, 0.72)); + box-shadow: 0 18px 60px rgba(70, 47, 29, 0.08); +} + +.submission-kicker { + margin: 0 0 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + font-size: 0.75rem; + color: var(--soft); +} + +.submission-title { + margin: 0; + font-size: clamp(2rem, 6vw, 4rem); + line-height: 0.95; +} + +.submission-copy { + margin: 16px 0 0; + max-width: 52ch; + line-height: 1.55; + color: var(--soft); +} + +.submission-card { + padding: 22px; + background: var(--card); + backdrop-filter: blur(18px); + border: 1px solid var(--border); + border-radius: 24px; + box-shadow: 0 18px 40px rgba(70, 47, 29, 0.08); +} + +.submission-grid { + display: grid; + gap: 16px; +} + +.submission-field { + display: grid; + gap: 8px; +} + +.submission-field label, +.submission-label { + font-size: 0.95rem; + font-weight: 600; +} + +.submission-field input[type="text"], +.submission-field textarea { + width: 100%; + border-radius: 16px; + border: 1px solid rgba(73, 54, 42, 0.18); + padding: 14px 16px; + background: rgba(255, 255, 255, 0.7); +} + +.submission-file { + border: 1px dashed rgba(73, 54, 42, 0.3); + border-radius: 18px; + padding: 18px; + background: rgba(255, 255, 255, 0.55); +} + +.submission-checkboxes { + display: grid; + gap: 12px; + padding: 16px; + border-radius: 18px; + background: rgba(251, 246, 240, 0.8); +} + +.submission-checkbox { + display: grid; + grid-template-columns: 20px 1fr; + gap: 12px; + align-items: start; + font-size: 0.95rem; + line-height: 1.45; +} + +.submission-actions { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.submission-button { + border: 0; + border-radius: 999px; + padding: 14px 24px; + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%); + color: #fff7f0; + cursor: pointer; + min-width: 160px; + transition: transform 120ms ease, box-shadow 120ms ease; + box-shadow: 0 12px 28px rgba(106, 52, 34, 0.24); +} + +.submission-button:disabled { + opacity: 0.55; + cursor: not-allowed; + box-shadow: none; +} + +.submission-button:not(:disabled):hover { + transform: translateY(-1px); +} + +.submission-status { + font-size: 0.92rem; + color: var(--soft); +} + +.submission-status[data-tone="error"] { + color: #9f3a2f; +} + +.submission-status[data-tone="success"] { + color: var(--success); +} + +.submission-progress { + height: 8px; + border-radius: 999px; + overflow: hidden; + background: rgba(73, 54, 42, 0.08); +} + +.submission-progress > span { + display: block; + height: 100%; + background: linear-gradient(90deg, #cd8a67 0%, #8a5037 100%); +} + +@media (max-width: 640px) { + .submission-shell { + padding: 16px 14px 36px; + } + + .submission-card, + .submission-hero { + padding: 18px; + border-radius: 22px; + } + + .submission-actions { + flex-direction: column; + align-items: stretch; + } +} diff --git a/apps/submission/src/features/submission/api.ts b/apps/submission/src/features/submission/api.ts new file mode 100644 index 0000000..aba9bfa --- /dev/null +++ b/apps/submission/src/features/submission/api.ts @@ -0,0 +1,59 @@ +import type { Submission } from "@goodgrief/shared-types"; + +export interface CreateSubmissionResponse { + submission: Submission; + assetId: string; +} + +export interface CreateSubmissionInput { + displayName?: string; + caption?: string; + promptAnswer?: string; + allowArchive: boolean; + hasRights: boolean; + allowProjection: boolean; + acknowledgePublicPerformance: boolean; + file: File; +} + +export const createSubmission = async ( + input: CreateSubmissionInput, + onProgress?: (progress: number) => void +): Promise => + new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append("file", input.file); + formData.append("displayName", input.displayName ?? ""); + formData.append("caption", input.caption ?? ""); + formData.append("promptAnswer", input.promptAnswer ?? ""); + formData.append("allowArchive", String(input.allowArchive)); + formData.append("hasRights", String(input.hasRights)); + formData.append("allowProjection", String(input.allowProjection)); + formData.append("acknowledgePublicPerformance", String(input.acknowledgePublicPerformance)); + formData.append("source", "live"); + + const request = new XMLHttpRequest(); + request.open("POST", "/api/submissions"); + request.responseType = "json"; + + request.upload.addEventListener("progress", (event) => { + if (event.lengthComputable) { + onProgress?.(Math.round((event.loaded / event.total) * 100)); + } + }); + + request.addEventListener("load", () => { + if (request.status >= 200 && request.status < 300) { + resolve(request.response as CreateSubmissionResponse); + return; + } + + reject(new Error((request.response as { message?: string } | null)?.message ?? "Upload failed.")); + }); + + request.addEventListener("error", () => { + reject(new Error("The upload could not be completed. Please try again.")); + }); + + request.send(formData); + }); diff --git a/apps/submission/src/features/submission/useSubmissionForm.ts b/apps/submission/src/features/submission/useSubmissionForm.ts new file mode 100644 index 0000000..832d6d2 --- /dev/null +++ b/apps/submission/src/features/submission/useSubmissionForm.ts @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { createSubmission } from "./api"; + +export interface SubmissionFormState { + displayName: string; + caption: string; + promptAnswer: string; + allowArchive: boolean; + hasRights: boolean; + allowProjection: boolean; + acknowledgePublicPerformance: boolean; + file: File | null; +} + +const initialState: SubmissionFormState = { + displayName: "", + caption: "", + promptAnswer: "", + allowArchive: false, + hasRights: false, + allowProjection: false, + acknowledgePublicPerformance: false, + file: null +}; + +export const useSubmissionForm = () => { + const [state, setState] = useState(initialState); + const [progress, setProgress] = useState(0); + const [status, setStatus] = useState(null); + const [statusTone, setStatusTone] = useState<"neutral" | "error" | "success">("neutral"); + const [submitting, setSubmitting] = useState(false); + + const updateField = (key: Key, value: SubmissionFormState[Key]) => { + setState((current) => ({ + ...current, + [key]: value + })); + }; + + const submit = async () => { + const { file } = state; + + if (!file) { + setStatusTone("error"); + setStatus("Please choose a photo before uploading."); + return; + } + + if (!state.hasRights || !state.allowProjection || !state.acknowledgePublicPerformance) { + setStatusTone("error"); + setStatus("Please review and accept the required consent items."); + return; + } + + setSubmitting(true); + setProgress(0); + setStatusTone("neutral"); + setStatus("Uploading for review..."); + + try { + await createSubmission( + { + ...state, + file + }, + (nextProgress) => setProgress(nextProgress) + ); + setStatusTone("success"); + setStatus( + "Thank you. Your image has been received and will be reviewed before it can appear during the show." + ); + setState(initialState); + setProgress(100); + } catch (error) { + setStatusTone("error"); + setStatus(error instanceof Error ? error.message : "Upload failed."); + } finally { + setSubmitting(false); + } + }; + + return { + state, + progress, + status, + statusTone, + submitting, + updateField, + submit + }; +}; diff --git a/apps/submission/src/main.tsx b/apps/submission/src/main.tsx new file mode 100644 index 0000000..533e44c --- /dev/null +++ b/apps/submission/src/main.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { App } from "./app/App"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/apps/submission/src/routes/SubmissionRoute.tsx b/apps/submission/src/routes/SubmissionRoute.tsx new file mode 100644 index 0000000..fb2159b --- /dev/null +++ b/apps/submission/src/routes/SubmissionRoute.tsx @@ -0,0 +1,126 @@ +import { useSubmissionForm } from "../features/submission/useSubmissionForm"; + +export const SubmissionRoute = () => { + const { state, progress, status, statusTone, submitting, updateField, submit } = useSubmissionForm(); + + return ( +
+
+
+

Good Grief

+

Offer a photo to tonight's memory field.

+

+ Share one image that carries memory, witness, humor, or tenderness for you. The creative team will + review each submission. Not every image will appear, and none will be shown without moderation. +

+
+ +
+
+
+ +
+ updateField("file", event.target.files?.[0] ?? null)} + /> +

+ One image only. Common phone photos work best. Unsupported files will be declined. +

+
+
+ +
+ + updateField("displayName", event.target.value)} + /> +
+ +
+ +