Initial commit

This commit is contained in:
2026-04-08 10:01:19 -07:00
commit 6657125a1e
68 changed files with 15886 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Good Grief Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -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"
}
}
File diff suppressed because it is too large Load Diff
+152
View File
@@ -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<ProgramOutputState | null>(() => readProgramOutputState());
const [overlayVisible, setOverlayVisible] = useState(true);
const [overlayDismissed, setOverlayDismissed] = useState(false);
const hideTimeoutRef = useRef<number | null>(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 (
<main className="output-shell" onMouseMove={handlePointerMove} onMouseLeave={handlePointerLeave}>
<SceneViewport
presentation={outputState?.presentation ?? null}
blackout={outputState?.blackout ?? false}
transition={transition}
activationKey={`${outputState?.presentationHash ?? "program-empty"}:${outputState?.outputRevision ?? 0}:${outputState?.blackout ? "blackout" : "live"}`}
/>
<div className={`output-overlay ${overlayVisible ? "output-overlay--visible" : ""}`}>
<div>
<p>Program Output</p>
<strong>{outputState?.presentation?.label ?? "Awaiting cue"}</strong>
<span>{outputState?.blackout ? "Blackout active" : "Move this window to the projector display."}</span>
</div>
<div className="output-overlay__actions">
<button onClick={() => void enterFullscreen()}>{document.fullscreenElement ? "Exit fullscreen" : "Fullscreen"}</button>
<button
onClick={() => {
setOverlayDismissed(true);
clearHideTimer();
setOverlayVisible(false);
}}
>
Hide info
</button>
</div>
</div>
</main>
);
};
File diff suppressed because it is too large Load Diff
+111
View File
@@ -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);
}
@@ -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<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const surfaceRef = useRef<RenderSurfaceType | null>(null);
const presentationRef = useRef<SurfacePresentation | null>(presentation);
const activationRef = useRef<string | undefined>(activationKey);
const transitionRef = useRef<CueTransition | null | undefined>(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 (
<div ref={frameRef} className="surface-viewport">
<canvas ref={canvasRef} className="surface-viewport__canvas" />
</div>
);
};
+113
View File
@@ -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 <T>(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<RepositoryState> => {
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<RepositoryState> =>
requestJson<RepositoryState>("/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<Cue>("/api/cues", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
export const updateCue = async (cueId: string, payload: CueUpsertPayload) =>
requestJson<Cue>(`/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<CueUpsertPayload>("/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}.`);
}
};
+126
View File
@@ -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<ProgramOutputState>;
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);
};
};
+28
View File
@@ -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;
}
+12
View File
@@ -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(
<React.StrictMode>
{mode === "output" ? <ProgramOutputApp /> : <App />}
</React.StrictMode>
);
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src",
"vite.config.ts"
]
}
+15
View File
@@ -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
}
}
});
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Good Grief Submission</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -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"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Routes, Route } from "react-router-dom";
import { SubmissionRoute } from "../routes/SubmissionRoute";
import "./app.css";
export const App = () => (
<Routes>
<Route path="*" element={<SubmissionRoute />} />
</Routes>
);
+201
View File
@@ -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;
}
}
@@ -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<CreateSubmissionResponse> =>
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);
});
@@ -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<SubmissionFormState>(initialState);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<string | null>(null);
const [statusTone, setStatusTone] = useState<"neutral" | "error" | "success">("neutral");
const [submitting, setSubmitting] = useState(false);
const updateField = <Key extends keyof SubmissionFormState>(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
};
};
+12
View File
@@ -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(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
@@ -0,0 +1,126 @@
import { useSubmissionForm } from "../features/submission/useSubmissionForm";
export const SubmissionRoute = () => {
const { state, progress, status, statusTone, submitting, updateField, submit } = useSubmissionForm();
return (
<main className="submission-shell">
<div className="submission-stage">
<section className="submission-hero">
<p className="submission-kicker">Good Grief</p>
<h1 className="submission-title">Offer a photo to tonight&apos;s memory field.</h1>
<p className="submission-copy">
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.
</p>
</section>
<section className="submission-card">
<div className="submission-grid">
<div className="submission-field">
<label htmlFor="file">Photo</label>
<div className="submission-file">
<input
id="file"
type="file"
accept="image/jpeg,image/png,image/heic,image/heif"
onChange={(event) => updateField("file", event.target.files?.[0] ?? null)}
/>
<p className="submission-status">
One image only. Common phone photos work best. Unsupported files will be declined.
</p>
</div>
</div>
<div className="submission-field">
<label htmlFor="displayName">Name or initials (optional)</label>
<input
id="displayName"
type="text"
value={state.displayName}
maxLength={80}
onChange={(event) => updateField("displayName", event.target.value)}
/>
</div>
<div className="submission-field">
<label htmlFor="caption">Caption or note (optional)</label>
<textarea
id="caption"
rows={3}
maxLength={180}
placeholder="A short caption, dedication, or line of context."
value={state.caption}
onChange={(event) => updateField("caption", event.target.value)}
/>
</div>
<div className="submission-field">
<label htmlFor="promptAnswer">Optional prompt</label>
<textarea
id="promptAnswer"
rows={4}
maxLength={240}
placeholder="What would you want this image to carry tonight?"
value={state.promptAnswer}
onChange={(event) => updateField("promptAnswer", event.target.value)}
/>
</div>
<div className="submission-checkboxes">
<p className="submission-label">Consent</p>
<label className="submission-checkbox">
<input
type="checkbox"
checked={state.hasRights}
onChange={(event) => updateField("hasRights", event.target.checked)}
/>
<span>I have the right to share this photo, and I understand it may be declined.</span>
</label>
<label className="submission-checkbox">
<input
type="checkbox"
checked={state.allowProjection}
onChange={(event) => updateField("allowProjection", event.target.checked)}
/>
<span>I consent to this image being used in a live theatrical performance.</span>
</label>
<label className="submission-checkbox">
<input
type="checkbox"
checked={state.acknowledgePublicPerformance}
onChange={(event) => updateField("acknowledgePublicPerformance", event.target.checked)}
/>
<span>I understand this is a public performance setting and projection is not guaranteed.</span>
</label>
<label className="submission-checkbox">
<input
type="checkbox"
checked={state.allowArchive}
onChange={(event) => updateField("allowArchive", event.target.checked)}
/>
<span>Optional: you may retain this image briefly after the show for archive review.</span>
</label>
</div>
{submitting ? (
<div className="submission-progress" aria-hidden="true">
<span style={{ width: `${progress}%` }} />
</div>
) : null}
<div className="submission-actions">
<p className="submission-status" data-tone={statusTone}>
{status ??
"This flow is intentionally simple: one image, clear consent, moderated review, no public gallery."}
</p>
<button className="submission-button" type="button" disabled={submitting} onClick={() => void submit()}>
{submitting ? "Uploading..." : "Submit Photo"}
</button>
</div>
</div>
</section>
</div>
</main>
);
};
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src",
"vite.config.ts"
]
}
+15
View File
@@ -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: 4100,
proxy: {
"/api": apiProxyTarget,
"/uploads": apiProxyTarget
}
}
});