feat: POST /auth/workspaces — create an additional collective from an existing account

Until now collectives only existed at registration; an account with a
personal workspace had no path to start one. The creator becomes Owner
and gets a session scoped to the new workspace.
This commit is contained in:
glass beadian 2026-10-14 17:16:17 +00:00
parent 177ca6eb2b
commit cbdb8c0f81
18 changed files with 2584 additions and 8 deletions

View file

@ -0,0 +1,76 @@
"use client";
import { Badge, Card } from "../../../components/ui/primitives";
import { Loading } from "../../../components/brand/loading";
import { EmptyState } from "../../../components/brand/empty-state";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { useApiResource } from "../../../lib/use-api";
import { useAuth } from "../../../lib/auth-store";
import { GroupSubmissionsCard } from "../../../components/submissions/group-submissions-card";
import type { SubmissionGroupSummary } from "../../../lib/submissions-types";
/**
* Groups screen — the moderation home for the anonymous submission boxes.
*
* GET /groups/mine lists the caller's groups; each renders a
* GroupSubmissionsCard with its own "Submission box" section (public link,
* rotate + enable/disable) and moderation queue (pending / approved /
* rejected). All four admin endpoints are gated on
* workspace.members.manage, so the cards stay read-only for plain members.
*
* The API is offline (or the session is anonymous, which useApiResource folds
* into sample) → the list is read-only fixtures rather than fake mutations.
*/
const SAMPLE_GROUPS: SubmissionGroupSummary[] = [
{ id: "sample-group-1", name: "Collective media" },
{ id: "sample-group-2", name: "Street footage" },
];
const identityGroups = (groups: SubmissionGroupSummary[]): SubmissionGroupSummary[] => groups;
export default function GroupsPage(): React.ReactElement {
const { can, session } = useAuth();
const groups = useApiResource<SubmissionGroupSummary[], SubmissionGroupSummary[]>("/groups/mine", identityGroups, SAMPLE_GROUPS);
const live = groups.source === "live";
const canManage = live && (can("workspace.members.manage") || session?.role === "Owner" || session?.role === "Admin");
return (
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
<div className="mb-5 flex flex-wrap items-center gap-3">
<h1 className="text-lg font-semibold">Groups</h1>
<OfflineBadge source={groups.source} />
{!live && <Badge tone="warn">read-only sample</Badge>}
</div>
<p className="mb-5 max-w-2xl text-xs leading-relaxed text-text-faint">
Each group can open a public submission box: a link anyone can use to send footage and
photos — no account, no sign-in. Everything lands as <span className="text-text-dim">pending</span> until
the group&apos;s managers approve or reject it with a note.
</p>
{groups.error !== null && (
<Card className="mb-5 p-4">
<p className="text-xs text-danger">{groups.error}</p>
</Card>
)}
{groups.source === "loading" ? (
<div className="flex justify-center py-10"><Loading size={28} label="Loading groups" /></div>
) : groups.data.length === 0 ? (
<EmptyState title="No groups yet" hint="Join or create a collective and its groups appear here." />
) : (
<div className="flex flex-col gap-4">
{groups.data.map((group) => (
<GroupSubmissionsCard key={group.id} group={group} canManage={canManage} live={live} />
))}
</div>
)}
{!live && (
<p className="mt-5 text-2xs leading-relaxed text-text-faint">
The API is unreachable, so this screen shows sample groups and cannot change anything.
</p>
)}
</div>
);
}

View file

@ -0,0 +1,442 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { LogoMark } from "../../../components/brand/logo";
import { Loading } from "../../../components/brand/loading";
import { ApiError, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import {
SUBMISSION_CONTACT_MAX,
SUBMISSION_CREDIT_MAX,
SUBMISSION_LICENSE_TEXT,
SUBMISSION_SNIFF_BYTES,
submissionMaxBytes,
type SubmissionBoxInfo,
type SubmissionKind,
type SubmissionReceiveResult,
} from "../../../lib/submissions-types";
/**
* /submit/[token] — the public, accountless intake for a group's submission
* box (backend 599a68c). Everything here works signed-out: metadata comes
* from GET /public/submit/:token, the file goes to POST /public/submit/:token.
*
* The route deliberately lives OUTSIDE the (main) group: that layout mounts
* the app Shell and wraps children in AuthGate, which bounces anonymous
* visitors to /onboarding — the exact opposite of what a handed-out link
* needs (same reasoning as app/join/layout.tsx).
*
* Client-side pre-checks mirror the API's receive path
* (apps/api/src/submissions/submissions.service.ts): magic bytes decide the
* kind — never the name or the browser's declared mime — mp4/webm video caps
* at 200 MiB, jpeg/png photo at 50 MiB, and the licence box is mandatory.
* Failing them here gives a friendly one-line reason instead of a 400.
*/
type Phase = "checking" | "open" | "unreachable" | "gone";
const API_URL = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000").replace(/\/$/, "");
/** Byte-level kind sniff, mirroring the API's sniffMedia() (32-byte window). */
async function sniffFile(file: File): Promise<SubmissionKind | null> {
const head = new Uint8Array(await file.slice(0, SUBMISSION_SNIFF_BYTES).arrayBuffer());
if (head.length < SUBMISSION_SNIFF_BYTES) return null;
const latin = (start: number, length: number): string => {
let out = "";
for (let i = start; i < start + length; i += 1) out += String.fromCharCode(head[i] ?? 0);
return out;
};
const hex = (start: number, length: number): string => {
let out = "";
for (let i = start; i < start + length; i += 1) out += (head[i] ?? 0).toString(16).padStart(2, "0");
return out;
};
// mp4 (ftyp at offset 4) and webm/mkv (EBML) are video; jpeg and png are photo.
if (latin(4, 4) === "ftyp") return "video";
if (hex(0, 4) === "1a45dfa3") return "video";
if (hex(0, 3) === "ffd8ff") return "photo";
if (hex(0, 8) === "89504e470d0a1a0a") return "photo";
return null;
}
function formatBytes(bytes: number): string {
const mib = bytes / (1024 * 1024);
return mib >= 10 ? `${Math.round(mib)} MiB` : `${mib.toFixed(1)} MiB`;
}
/** The API's JSON {message} bodies, softened where the field-level text leaks implementation detail. */
function friendlyError(err: unknown): string {
if (err instanceof ApiUnreachableError) return "Can't reach the server — check your connection and try again.";
if (err instanceof ApiError) {
const m = err.message.toLowerCase();
if (err.status === 403 && m.includes("too many")) return "Too many submissions from this network already — try again in a little while.";
if (err.status === 403 && m.includes("full")) return "This box is full right now — the collective has to review what's already in it first.";
if (err.status === 400 && m.includes("already submitted")) return "You already sent this exact file to this box.";
if (err.status === 400 && m.includes("unrecognised")) return "That file type isn't accepted — mp4 or webm video, jpeg or png photo.";
if (err.status === 400 && m.includes("licence") || m.includes("license")) {
return "Tick the licence box so the collective knows they may share your footage.";
}
if (err.status === 503) return "The box's storage isn't ready — try again shortly.";
if (err.status === 404) return "This box isn't open anymore — ask the collective for a fresh link.";
return err.message;
}
return errorMessage(err);
}
export default function SubmitPage({ params }: { params: { token: string } }): React.ReactElement {
const token = params.token;
const [phase, setPhase] = useState<Phase>("checking");
const [box, setBox] = useState<SubmissionBoxInfo | null>(null);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setPhase("checking");
fetch(`${API_URL}/public/submit/${encodeURIComponent(token)}`, { credentials: "omit" })
.then(async (response) => {
const text = await response.text();
let body: unknown = null;
if (text !== "") {
try { body = JSON.parse(text) as unknown; } catch { body = text; }
}
if (!response.ok) throw new ApiError(response.status, typeof body === "object" && body !== null && "message" in body && typeof (body as { message: unknown }).message === "string" ? (body as { message: string }).message : `HTTP ${response.status}`, body);
return body as SubmissionBoxInfo;
})
.then((info) => {
if (cancelled) return;
setBox(info);
setPhase("open");
})
.catch((err: unknown) => {
if (cancelled) return;
// A missing box renders the dead card; only a network failure offers a retry.
if (err instanceof ApiUnreachableError) setPhase("unreachable");
else setPhase("gone");
});
return () => {
cancelled = true;
};
}, [token, attempt]);
if (phase === "checking") {
return (
<main className="flex min-h-dvh items-center justify-center bg-[#060403] px-6">
<div className="flex flex-col items-center gap-3">
<Loading size={32} label="Opening the box…" />
<span className="text-2xs text-[#a4a09e]">Opening the box…</span>
</div>
</main>
);
}
if (phase === "unreachable") {
return (
<main className="flex min-h-dvh items-center justify-center bg-[#060403] p-6">
<DeadCard
tone="warn"
title="Can't reach the server"
body="Your link is still fine — the server isn't answering. Try again in a moment."
action={<button type="button" onClick={() => setAttempt((n) => n + 1)} className="mt-6 rounded-md bg-[#FF1610] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#e3130e]">Retry</button>}
/>
</main>
);
}
if (phase === "gone" || box === null) {
return (
<main className="flex min-h-dvh items-center justify-center bg-[#060403] p-6">
<DeadCard
tone="danger"
title="This box isn't open"
body="The link is no longer valid — it may have been rotated, or the box is closed. Ask the collective for a fresh link."
/>
</main>
);
}
return (
<main className="min-h-dvh bg-[#060403] px-4 py-10 sm:px-6">
<SubmitForm key={token} token={token} box={box} />
</main>
);
}
function DeadCard({ tone, title, body, action }: { tone: "warn" | "danger"; title: string; body: string; action?: React.ReactNode }): React.ReactElement {
return (
<div className="w-full max-w-md rounded-lg border border-[#2a2320] bg-[#0F0A09] p-6 text-center">
<div
className={`mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full text-lg ${
tone === "warn" ? "bg-[#FF1610]/15 text-[#FF1610]" : "bg-[#FF1610]/15 text-[#FF1610]"
}`}
>
{tone === "warn" ? "!" : "✕"}
</div>
<h1 className="text-base font-semibold text-[#f5efed]">{title}</h1>
<p className="mt-2 text-xs leading-relaxed text-[#a4a09e]">{body}</p>
{action}
</div>
);
}
function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null);
const busyRef = useRef(false);
const [file, setFile] = useState<File | null>(null);
const [kind, setKind] = useState<SubmissionKind | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
const [creditName, setCreditName] = useState("");
const [contact, setContact] = useState("");
const [licence, setLicence] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<SubmissionReceiveResult | null>(null);
const [phase, setPhase] = useState<"idle" | "uploading" | "sent">("idle");
const pick = useCallback(async (picked: File): Promise<void> => {
if (busyRef.current || phase === "uploading") return;
setFileError(null);
const sniffed = await sniffFile(picked).catch(() => null);
if (sniffed === null) {
setFile(null);
setKind(null);
setFileError(`${picked.name}: that file type isn't recognised — mp4 or webm video, jpeg or png photo.`);
return;
}
const cap = submissionMaxBytes(sniffed);
if (picked.size === 0 || picked.size > cap) {
setFile(null);
setKind(null);
setFileError(`${picked.name} is ${formatBytes(picked.size)} — the limit for a ${sniffed} is ${formatBytes(cap)}.`);
return;
}
setFile(picked);
setKind(sniffed);
}, [phase]);
useEffect(() => {
// Paste a file straight into the box (the video library's pattern).
const onPaste = (event: ClipboardEvent): void => {
const pasted = Array.from(event.clipboardData?.files ?? [])[0];
if (pasted !== undefined) void pick(pasted);
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
}, [pick]);
const creditTooLong = creditName.length > SUBMISSION_CREDIT_MAX;
const contactTooLong = contact.length > SUBMISSION_CONTACT_MAX;
const ready = file !== null && kind !== null && licence && !creditTooLong && !contactTooLong;
async function submit(event: React.FormEvent): Promise<void> {
event.preventDefault();
if (!ready || file === null || busyRef.current) return;
busyRef.current = true;
setPhase("uploading");
setProgress(0);
setError(null);
try {
const result = await new Promise<SubmissionReceiveResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${API_URL}/public/submit/${encodeURIComponent(token)}`);
xhr.timeout = 900_000; // a 200 MiB video on a slow line needs room.
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) setProgress(Math.floor((event.loaded / event.total) * 100));
};
xhr.upload.onload = () => setProgress(100);
xhr.onload = () => {
let body: unknown = null;
if (xhr.responseText !== "") {
try { body = JSON.parse(xhr.responseText) as unknown; } catch { body = xhr.responseText; }
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(body as SubmissionReceiveResult);
} else {
const message =
typeof body === "object" && body !== null && "message" in body
? String((body as { message: unknown }).message)
: `HTTP ${xhr.status}`;
reject(new ApiError(xhr.status, message, body));
}
};
xhr.onerror = () => reject(new ApiUnreachableError(new Error("network")));
xhr.ontimeout = () => reject(new Error("The upload timed out — check your connection and try again."));
xhr.onabort = () => reject(new Error("The upload was cancelled."));
const form = new FormData();
form.append("file", file);
form.append("licenseAccepted", "true");
if (creditName.trim() !== "") form.append("creditName", creditName.trim());
if (contact.trim() !== "") form.append("contact", contact.trim());
xhr.send(form);
});
setDone(result);
setPhase("sent");
} catch (err) {
setError(friendlyError(err));
setPhase("idle");
} finally {
busyRef.current = false;
}
}
if (phase === "sent" && done !== null) {
return (
<div className="mx-auto flex w-full max-w-xl flex-col items-center gap-4 rounded-lg border border-[#2a2320] bg-[#0F0A09] p-8 text-center">
<LogoMark size={44} className="text-[#FF1610]" title="" />
<h1 className="text-xl font-semibold tracking-tight text-[#f5efed]">Received</h1>
<p className="max-w-sm text-sm leading-relaxed text-[#c7c3c1]">
Received — the collective will review it. Nothing is published until they approve it.
</p>
<p className="max-w-sm text-2xs leading-relaxed text-[#a4a09e]">
Your footage stays under {box.licenseDefault}: it may be shared and remixed with attribution.
</p>
</div>
);
}
const uploading = phase === "uploading";
const dropDisabled = uploading;
return (
<form
onSubmit={(event) => void submit(event)}
className="mx-auto flex w-full max-w-xl flex-col gap-6 rounded-lg border border-[#2a2320] bg-[#0F0A09] p-6 sm:p-8"
>
<header className="flex flex-col items-center gap-3 text-center">
<LogoMark size={36} className="text-[#FF1610]" title="Tactical Media Broadcast Hub" />
<div>
<h1 className="text-xl font-semibold tracking-tight text-[#f5efed]">Send footage to the collective</h1>
<p className="mt-1 text-xs leading-relaxed text-[#a4a09e]">
No account needed. mp4 or webm video up to 200 MiB, jpeg or png photo up to 50 MiB.
</p>
</div>
</header>
<div
role="button"
tabIndex={dropDisabled ? -1 : 0}
aria-disabled={dropDisabled}
aria-label="Choose a file to send"
onClick={() => {
if (!dropDisabled) inputRef.current?.click();
}}
onKeyDown={(event) => {
if (dropDisabled) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(event) => {
event.preventDefault();
}}
onDrop={(event) => {
event.preventDefault();
if (dropDisabled) return;
const dropped = event.dataTransfer.files[0];
if (dropped !== undefined) void pick(dropped);
}}
className={`flex min-h-[180px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors duration-150 ${
uploading
? "cursor-not-allowed border-[#3a312e] opacity-60"
: "cursor-pointer border-[#3a312e] hover:border-[#FF1610] focus-visible:border-[#FF1610]"
}`}
>
{uploading ? (
<div className="w-full max-w-md space-y-2">
<span className="inline-flex items-center gap-2 text-xs text-[#c7c3c1]">
<Loading size={14} label="Uploading" />
{progress < 100 ? `Uploading ${progress}%` : "Upload sent — the collective's server is taking it in…"}{" "}
{file !== null && `${file.name} (${formatBytes(file.size)})`}
</span>
<progress className="h-2 w-full accent-[#FF1610]" aria-label="Upload progress" value={progress} max={100} />
</div>
) : file !== null && kind !== null ? (
<>
<span className="text-sm font-medium text-[#f5efed]">{file.name}</span>
<span className="text-2xs text-[#a4a09e]">
{kind === "video" ? "video" : "photo"} · {formatBytes(file.size)} · click or drop to choose another
</span>
</>
) : (
<>
<span className="text-sm text-[#c7c3c1]">Drop your video or photo here</span>
<span className="text-2xs leading-relaxed text-[#a4a09e]">
or browse your device, or paste a file. Nothing is sent until you press Send.
</span>
<span className="mt-2 rounded-md bg-[#FF1610] px-3 py-1.5 text-xs font-medium text-white">Browse files</span>
</>
)}
<input
ref={inputRef}
type="file"
accept="video/mp4,video/webm,image/jpeg,image/png,.mp4,.webm,.jpg,.jpeg,.png"
className="hidden"
onChange={(event) => {
const picked = event.target.files?.[0];
event.target.value = "";
if (picked !== undefined) void pick(picked);
}}
/>
</div>
{fileError !== null && <p role="alert" className="text-xs text-[#ff6f6b]">{fileError}</p>}
{error !== null && <p role="alert" className="rounded-md border border-[#FF1610]/30 bg-[#FF1610]/10 px-3 py-2 text-xs text-[#ff9b97]">{error}</p>}
<div className="space-y-4">
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border border-[#3a312e] bg-[#060403] p-3">
<input
type="checkbox"
checked={licence}
onChange={(event) => setLicence(event.target.checked)}
disabled={uploading}
className="mt-0.5 h-4 w-4 shrink-0 accent-[#FF1610]"
/>
<span className="text-xs leading-relaxed text-[#c7c3c1]">
{SUBMISSION_LICENSE_TEXT} <span className="text-[#FF1610]">(required)</span>
</span>
</label>
<label className="block text-2xs text-[#a4a09e]">
Credit name (optional, max {SUBMISSION_CREDIT_MAX})
<input
type="text"
value={creditName}
maxLength={SUBMISSION_CREDIT_MAX}
onChange={(event) => setCreditName(event.target.value)}
disabled={uploading}
placeholder="How your name should appear in the credit"
className="mt-1 w-full rounded-md border border-[#3a312e] bg-[#060403] px-3 py-2 text-sm text-[#f5efed] placeholder:text-[#6b6563] focus:border-[#FF1610] focus:outline-none"
/>
{creditTooLong && <span className="mt-1 block text-[#ff6f6b]">Up to {SUBMISSION_CREDIT_MAX} characters.</span>}
</label>
<label className="block text-2xs text-[#a4a09e]">
Contact (optional, max {SUBMISSION_CONTACT_MAX})
<textarea
value={contact}
maxLength={SUBMISSION_CONTACT_MAX}
onChange={(event) => setContact(event.target.value)}
disabled={uploading}
rows={2}
placeholder="How licensors can reach you — shown only to admins"
className="mt-1 w-full resize-none rounded-md border border-[#3a312e] bg-[#060403] px-3 py-2 text-sm text-[#f5efed] placeholder:text-[#6b6563] focus:border-[#FF1610] focus:outline-none"
/>
{contactTooLong && <span className="mt-1 block text-[#ff6f6b]">Up to {SUBMISSION_CONTACT_MAX} characters.</span>}
</label>
</div>
<button
type="submit"
disabled={!ready || uploading}
className="w-full rounded-md bg-[#FF1610] px-4 py-2.5 text-sm font-semibold text-white transition-colors duration-150 hover:bg-[#e3130e] disabled:cursor-not-allowed disabled:opacity-40"
>
{uploading ? `Sending… ${progress}%` : "Send to the collective"}
</button>
<p className="text-center text-2xs leading-relaxed text-[#a4a09e]">
The collective reviews every submission before anything is used. Your contact details stay with their admins.
</p>
</form>
);
}

View file

@ -0,0 +1,397 @@
"use client";
import { useEffect, useState } from "react";
import { Badge, Button, Card } from "../ui/primitives";
import { Loading } from "../brand/loading";
import { EmptyState } from "../brand/empty-state";
import { api, ApiError, errorMessage } from "../../lib/api-client";
import {
SUBMISSION_NOTE_MAX,
submissionLink,
type SubmissionBoxConfig,
type SubmissionGroupSummary,
type SubmissionQueueFilter,
type SubmissionRecord,
} from "../../lib/submissions-types";
/**
* One group's card on the Groups screen: the "Submission box" admin section
* (public link show/copy, rotate token, enable/disable) plus the moderation
* queue — pending by default, approved/rejected tabs, approve and
* reject-with-note.
*
* Both halves are permission-aware in the API (workspace.members.manage on
* all four endpoints), so the card hides its controls for plain members and
* still shows their group name — matching how the More page lists groups.
*/
/** Local byte formatter (components/video/format.ts shape, kept unshared per file ownership). */
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
const text = value >= 100 ? String(Math.round(value)) : value.toFixed(1);
return `${text} ${units[unit] ?? "KB"}`;
}
function formatDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
const kindLabel = (kind: SubmissionRecord["kind"]): string => (kind === "video" ? "video" : "photo");
const stateTone = (state: SubmissionRecord["state"]): "live" | "danger" | "warn" =>
state === "approved" ? "live" : state === "rejected" ? "danger" : "warn";
type TabFilter = Exclude<SubmissionQueueFilter, "all">;
export function GroupSubmissionsCard({
group,
canManage,
live,
}: {
group: SubmissionGroupSummary;
canManage: boolean;
live: boolean;
}): React.ReactElement {
const [box, setBox] = useState<SubmissionBoxConfig | null>(null);
const [boxState, setBoxState] = useState<"loading" | "ready" | "missing" | "error">("loading");
const [boxError, setBoxError] = useState<string | null>(null);
const [linkShown, setLinkShown] = useState(false);
const [copied, setCopied] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [queue, setQueue] = useState<SubmissionRecord[]>([]);
const [queueState, setQueueState] = useState<"loading" | "ready" | "error">("loading");
const [queueError, setQueueError] = useState<string | null>(null);
const [filter, setFilter] = useState<TabFilter>("pending");
const [rejecting, setRejecting] = useState<SubmissionRecord | null>(null);
const [note, setNote] = useState("");
const [boxNonce, setBoxNonce] = useState(0);
const [queueNonce, setQueueNonce] = useState(0);
// The box config is nested under /groups/:id and shares its busy/error
// surface with the rotate/enable mutations, so it owns a plain effect rather
// than useApiResource.
useEffect(() => {
if (!canManage || !live) {
setBoxState("loading");
return;
}
let cancelled = false;
setBoxState("loading");
api
.get<SubmissionBoxConfig>(`/groups/${encodeURIComponent(group.id)}/submission-box`)
.then((config) => {
if (cancelled) return;
setBox(config);
setBoxState("ready");
setBoxError(null);
})
.catch((err: unknown) => {
if (cancelled) return;
if (err instanceof ApiError && err.status === 404) {
// "this group has no submission box" is a state, not a failure.
setBoxState("missing");
setBoxError(null);
return;
}
setBoxState("error");
setBoxError(errorMessage(err));
});
return () => {
cancelled = true;
};
}, [group.id, canManage, live, boxNonce]);
// The moderation queue: ?state=pending|approved|rejected|all (the API folds
// anything else into pending).
useEffect(() => {
if (!live) {
setQueue([]);
setQueueState("ready");
return;
}
let cancelled = false;
setQueueState("loading");
api
.get<SubmissionRecord[]>(`/groups/${encodeURIComponent(group.id)}/submissions`, { state: filter })
.then((rows) => {
if (cancelled) return;
setQueue(rows);
setQueueState("ready");
setQueueError(null);
})
.catch((err: unknown) => {
if (cancelled) return;
if (err instanceof ApiError && err.status === 404) {
// No box for this group yet — nothing has ever been submitted.
setQueue([]);
setQueueState("ready");
setQueueError(null);
return;
}
setQueueState("error");
setQueueError(errorMessage(err));
});
return () => {
cancelled = true;
};
}, [group.id, filter, live, queueNonce]);
async function configure(action: { enabled?: boolean; rotateToken?: boolean }, label: string): Promise<void> {
if (!live) return;
setBusy(label);
setActionError(null);
setNotice(null);
try {
const config = await api.post<SubmissionBoxConfig>(`/groups/${encodeURIComponent(group.id)}/submission-box`, action);
setBox(config);
setBoxState("ready");
if (action.rotateToken === true) {
// The old token died with the rotate: never show a stale link again.
setLinkShown(false);
setCopied(false);
setNotice("Token rotated — the previous link no longer works. Copy the new one.");
} else if (action.enabled === true) {
setNotice("Box open — the public link accepts submissions again.");
} else if (action.enabled === false) {
setNotice("Box closed — the public link now says the box isn't open.");
}
} catch (err) {
setActionError(errorMessage(err));
} finally {
setBusy(null);
}
}
async function decide(submission: SubmissionRecord, decision: "approve" | "reject", rejectionNote?: string): Promise<void> {
if (!live) return;
setBusy(submission.id);
setActionError(null);
setNotice(null);
try {
const updated = await api.post<SubmissionRecord>(`/submissions/${encodeURIComponent(submission.id)}/${decision}`, {
groupId: group.id,
...(decision === "reject" && rejectionNote !== undefined ? { note: rejectionNote } : {}),
});
// The pending tab drops the row; the decided tabs show the new state.
setQueue((current) =>
filter === "pending"
? current.filter((row) => row.id !== updated.id)
: current.map((row) => (row.id === updated.id ? updated : row)),
);
setRejecting(null);
setNote("");
setNotice(
decision === "approve"
? `Approved — the ${kindLabel(updated.kind)} is in the ${updated.kind === "video" ? "video library" : "group's assets"}.`
: "Rejected — the note is kept in the moderation log.",
);
setQueueNonce((n) => n + 1);
} catch (err) {
setActionError(errorMessage(err));
} finally {
setBusy(null);
}
}
const link = box !== null ? submissionLink(box.token) : null;
const tabs: Array<{ value: TabFilter; label: string }> = [
{ value: "pending", label: "Pending" },
{ value: "approved", label: "Approved" },
{ value: "rejected", label: "Rejected" },
];
return (
<Card className="p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold">{group.name}</h2>
{canManage && boxState === "ready" && box !== null && (
<Badge tone={box.enabled ? "live" : "neutral"}>{box.enabled ? "box open" : "box closed"}</Badge>
)}
</div>
{canManage && (
<section className="mt-4" aria-label={`Submission box for ${group.name}`}>
<h3 className="mb-2 text-2xs font-bold uppercase tracking-wider text-text-faint">Submission box</h3>
{boxState === "loading" && (
<span className="inline-flex items-center gap-2 text-xs text-text-faint"><Loading size={14} label="Loading the box" /> Loading…</span>
)}
{boxState === "error" && <p role="alert" className="text-xs text-danger">{boxError}</p>}
{boxState === "missing" && (
<>
<p className="text-xs leading-relaxed text-text-faint">
No submission box for this group yet — it is created the first time it is configured.
</p>
<Button size="sm" variant="outline" className="mt-2" disabled={busy !== null} onClick={() => setBoxNonce((n) => n + 1)}>
{busy === null ? "Create box" : "Working…"}
</Button>
</>
)}
{boxState === "ready" && box !== null && link !== null && (
<>
<div className="flex flex-wrap items-center gap-2">
{linkShown ? (
<code className="min-w-0 flex-1 basis-64 truncate rounded-md border border-border bg-surface-2 px-2 py-1.5 font-mono text-2xs text-text">{link}</code>
) : (
<Button size="sm" variant="outline" onClick={() => setLinkShown(true)}>Show public link</Button>
)}
{linkShown && (
<Button
size="sm"
onClick={() => {
void navigator.clipboard?.writeText(link).then(() => setCopied(true)).catch(() => setCopied(false));
}}
>
{copied ? "Copied" : "Copy"}
</Button>
)}
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => void configure({ rotateToken: true }, "rotate")}>
{busy === "rotate" ? "Rotating…" : "Rotate token"}
</Button>
<Button
size="sm"
variant={box.enabled ? "danger" : "primary"}
disabled={busy !== null}
onClick={() => void configure({ enabled: !box.enabled }, "toggle")}
>
{busy === "toggle" ? "Saving…" : box.enabled ? "Disable box" : "Enable box"}
</Button>
</div>
<p className="mt-2 text-2xs leading-relaxed text-text-faint">
Anyone with this link can send footage and photos — no account needed. Rotating kills the old link immediately;
disabling keeps the token but turns the link away.
</p>
</>
)}
</section>
)}
<section className="mt-5" aria-label={`Submissions for ${group.name}`}>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-2xs font-bold uppercase tracking-wider text-text-faint">Submissions</h3>
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label="Submission state">
{tabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setFilter(tab.value)}
aria-pressed={filter === tab.value}
className={`min-h-8 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 ${
filter === tab.value ? "bg-accent text-white" : "text-text-dim hover:text-text"
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{notice !== null && <p className="mb-2 text-2xs text-live">{notice}</p>}
{actionError !== null && <p role="alert" className="mb-2 text-xs text-danger">{actionError}</p>}
{queueError !== null && <p role="alert" className="mb-2 text-xs text-danger">{queueError}</p>}
{queueState === "loading" && (
<span className="inline-flex items-center gap-2 text-xs text-text-faint"><Loading size={14} label="Loading submissions" /> Loading…</span>
)}
{queueState === "ready" && queue.length === 0 && (
<EmptyState
size={40}
title={filter === "pending" ? "Nothing waiting for review" : `No ${filter} submissions yet`}
hint={filter === "pending" ? "Hand out the public link — whatever people send lands here." : undefined}
/>
)}
{queueState === "ready" && queue.length > 0 && (
<ul className="divide-y divide-border-subtle">
{queue.map((row) => (
<li key={row.id} className="flex flex-wrap items-start justify-between gap-3 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge tone="accent">{kindLabel(row.kind)}</Badge>
<span className="font-mono text-2xs text-text-dim">{formatBytes(row.sizeBytes)}</span>
<span className="text-2xs text-text-faint">{formatDate(row.createdAt)}</span>
{filter !== "pending" && <Badge tone={stateTone(row.state)}>{row.state}</Badge>}
</div>
<div className="mt-1 text-xs text-text-dim">
{row.creditName !== null && row.creditName !== "" ? `credit: ${row.creditName}` : "no credit name given"}
</div>
{row.contact !== null && row.contact !== "" && (
<div className="mt-0.5 text-2xs text-text-faint">contact (admins only): {row.contact}</div>
)}
{row.rejectionNote !== null && row.rejectionNote !== "" && (
<div className="mt-0.5 text-2xs text-danger">note: {row.rejectionNote}</div>
)}
</div>
{row.state === "pending" && canManage && (
<div className="flex shrink-0 items-center gap-2">
<Button size="sm" variant="primary" disabled={busy !== null} onClick={() => void decide(row, "approve")}>
{busy === row.id ? "Approving…" : "Approve"}
</Button>
<Button size="sm" variant="danger" disabled={busy !== null} onClick={() => { setRejecting(row); setNote(""); setActionError(null); }}>
Reject…
</Button>
</div>
)}
</li>
))}
</ul>
)}
{queueState === "ready" && queue.length > 0 && filter === "pending" && (
<p className="mt-2 text-2xs text-text-faint">
Approving sends video to the video library and photos to the group&apos;s assets; rejecting keeps only the note.
</p>
)}
</section>
{rejecting !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setRejecting(null)}>
<div
role="dialog"
aria-modal="true"
aria-label="Reject submission"
className="w-[460px] max-w-[92vw] rounded-lg border border-border bg-surface p-4 shadow-2xl"
onClick={(event: React.MouseEvent) => event.stopPropagation()}
>
<h2 className="text-sm font-semibold">Reject this submission</h2>
<p className="mt-1 text-xs text-text-dim">
{kindLabel(rejecting.kind)} · {formatBytes(rejecting.sizeBytes)} · {formatDate(rejecting.createdAt)}
</p>
<p className="mt-2 text-xs leading-relaxed text-text-faint">
A note is required — it goes in the moderation log.
</p>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
maxLength={SUBMISSION_NOTE_MAX}
rows={3}
placeholder="Why this isn't being used (kept in the moderation log)"
className="mt-3 w-full resize-none rounded-md border border-border bg-surface-2 p-2 text-sm text-text focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-right text-2xs text-text-faint">{note.length}/{SUBMISSION_NOTE_MAX}</p>
{actionError !== null && <p role="alert" className="mt-2 text-xs text-danger">{actionError}</p>}
<div className="mt-3 flex justify-end gap-2">
<Button variant="ghost" onClick={() => { setRejecting(null); setNote(""); setActionError(null); }}>Cancel</Button>
<Button
variant="danger"
disabled={busy !== null || note.trim() === ""}
onClick={() => void decide(rejecting, "reject", note.trim())}
>
{busy !== null ? "Rejecting…" : "Reject with note"}
</Button>
</div>
</div>
</div>
)}
</Card>
);
}

View file

@ -0,0 +1,100 @@
/**
* Wire shapes for the anonymous submission boxes (apps/api/src/submissions).
*
* Kept local to the web app so it does not collide with the shared
* api-types.ts other builders may be editing in parallel — the same standing
* as components/files/types.ts. Hand-synced with submissions.controller.ts
* and submissions.service.ts (commit 599a68c).
*
* JSON note: the API returns `createdAt: Date`, which serialises to an ISO
* string on the wire, so every date in here is typed as string.
*/
export type SubmissionKind = "video" | "photo";
export type SubmissionState = "pending" | "approved" | "rejected";
/** Moderation queue filter the API accepts (GET ?state=). */
export type SubmissionQueueFilter = "pending" | "approved" | "rejected" | "all";
/** GET /public/submit/:token — public box metadata for the submit page. */
export interface SubmissionBoxInfo {
id: string;
groupId: string;
licenseDefault: string;
enabled: boolean;
}
/** POST /public/submit/:token → 202 */
export interface SubmissionReceiveResult {
submissionId: string;
state: "pending";
}
/** GET/POST /groups/:groupId/submission-box — token + open switch. */
export interface SubmissionBoxConfig {
token: string;
enabled: boolean;
}
/** GET /groups/:groupId/submissions — contact is licensing-inquiry data that
* only this moderation surface ever renders; it is never copied onto assets. */
export interface SubmissionRecord {
id: string;
boxId: string;
groupId: string;
kind: SubmissionKind;
mime: string;
sizeBytes: number;
sha256: string;
license: string;
creditName: string | null;
contact: string | null;
state: SubmissionState;
rejectionNote: string | null;
createdAt: string;
}
/** POST /submissions/:id/approve|reject — the decided record plus where it landed. */
export type SubmissionDecisionResult = SubmissionRecord & {
videoAssetId?: string;
groupAssetId?: string;
};
/** The id/name slice of GET /groups/mine the submission surfaces need. */
export interface SubmissionGroupSummary {
id: string;
name: string;
}
/* Intake caps, mirroring apps/api/src/submissions/submissions.service.ts. */
export const SUBMISSION_MAX_VIDEO_BYTES = 200 * 1024 * 1024;
export const SUBMISSION_MAX_PHOTO_BYTES = 50 * 1024 * 1024;
/** The magic-byte window the API reads before deciding a type; shorter files are "unrecognised". */
export const SUBMISSION_SNIFF_BYTES = 32;
/** optionalText() caps in the API's receive path. */
export const SUBMISSION_CREDIT_MAX = 100;
export const SUBMISSION_CONTACT_MAX = 200;
/** submissions.rejection_note CHECK constraint (≤ 500). */
export const SUBMISSION_NOTE_MAX = 500;
/** The qualifier every submitter must tick — exact text from the box design. */
export const SUBMISSION_LICENSE_TEXT =
"I took or own this footage and release it under CC BY-SA 4.0 — it may be shared and remixed with attribution";
/** Canonical public origin of the web app; overridable for local/dev runs. */
export const SUBMIT_APP_ORIGIN = (process.env.NEXT_PUBLIC_APP_ORIGIN ?? "https://app.occupymedia.cc").replace(/\/$/, "");
/** The link a collective hands out: https://app.occupymedia.cc/submit/<token>. */
export function submissionLink(token: string): string {
return `${SUBMIT_APP_ORIGIN}/submit/${token}`;
}
/** Size cap per kind — the same rule the API applies after sniffing the bytes. */
export function submissionMaxBytes(kind: SubmissionKind): number {
return kind === "video" ? SUBMISSION_MAX_VIDEO_BYTES : SUBMISSION_MAX_PHOTO_BYTES;
}