Initial commit
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [
|
||||
"src",
|
||||
"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: 4100,
|
||||
proxy: {
|
||||
"/api": apiProxyTarget,
|
||||
"/uploads": apiProxyTarget
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user