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