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:
parent
177ca6eb2b
commit
cbdb8c0f81
18 changed files with 2584 additions and 8 deletions
76
apps/web/src/app/(main)/groups/page.tsx
Normal file
76
apps/web/src/app/(main)/groups/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
442
apps/web/src/app/submit/[token]/page.tsx
Normal file
442
apps/web/src/app/submit/[token]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue