Tactical Media Broadcast Hub — initial release

Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
This commit is contained in:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,363 @@
"use client";
import { useEffect, useState, type FormEvent } from "react";
import Link from "next/link";
import { Badge, Button, Card, Input } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { ArrowGlyph } from "../../../components/brand/logo";
import { PLATFORMS } from "../../../lib/platforms";
import { useApiResource } from "../../../lib/use-api";
import { api, errorMessage } from "../../../lib/api-client";
import type { MemberMutationResult } from "../../../lib/api-types";
import type { ConnectProvider } from "../../../lib/connections-client";
import { useAuth } from "../../../lib/auth-store";
const STEPS = ["Create workspace", "Invite team", "Connect accounts"];
const MIN_PASSWORD = 10;
const INVITE_ROLE_CHOICES = ["Owner", "Editor", "Viewer"] as const;
const DEFAULT_INVITE_ROLE = "Editor";
const NO_PROVIDERS: ConnectProvider[] = [];
function platformName(id: string): string {
return PLATFORMS[id]?.displayName ?? id;
}
function SignInForm({ onSwitch }: { onSwitch: () => void }): React.ReactElement {
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: FormEvent): Promise<void> {
e.preventDefault();
setBusy(true);
setError(null);
try {
await login(username.trim(), password);
window.location.assign("/streams");
} catch (err) {
setError(errorMessage(err));
setBusy(false);
}
}
return (
<form onSubmit={(e) => void submit(e)}>
<h2 className="mb-1 text-base font-semibold">Sign in</h2>
<p className="mb-4 text-xs text-text-faint">Use the username and password you registered with.</p>
<div className="space-y-2">
<Input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" autoComplete="username" autoFocus />
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" autoComplete="current-password" />
</div>
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
<div className="mt-6 flex items-center justify-between">
<Button type="button" variant="ghost" onClick={onSwitch}>Create a workspace instead</Button>
<Button type="submit" variant="primary" disabled={busy || username.trim() === "" || password === ""}>
{busy ? "Signing in…" : "Sign in"}
</Button>
</div>
</form>
);
}
export default function OnboardingPage(): React.ReactElement {
const { status, session, register, adoptToken, logout } = useAuth();
const offline = status === "offline";
const [mode, setMode] = useState<"register" | "signin">("register");
const [step, setStep] = useState(0);
const [name, setName] = useState("");
const [displayName, setDisplayName] = useState("");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emails, setEmails] = useState("");
const [inviteRole, setInviteRole] = useState<string>(DEFAULT_INVITE_ROLE);
const { data: providers, source: providerSource } = useApiResource<ConnectProvider[], ConnectProvider[]>("/connections/providers", (p) => p, NO_PROVIDERS);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sessionExpired, setSessionExpired] = useState(false);
// The API client redirects here with ?reason=session on a 401: default to sign-in.
useEffect(() => {
if (new URLSearchParams(window.location.search).get("reason") === "session") {
setSessionExpired(true);
setMode("signin");
}
}, []);
async function createWorkspace(): Promise<void> {
if (offline) {
setStep(1);
return;
}
setBusy(true);
setError(null);
try {
await register({
workspaceName: name.trim(),
username: username.trim(),
password,
...(displayName.trim() === "" ? {} : { displayName: displayName.trim() }),
...(email.trim() === "" ? {} : { email: email.trim() }),
});
setStep(1);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function sendInvites(): Promise<void> {
const list = emails.split(/[\s,;]+/).map((e) => e.trim()).filter((e) => e.includes("@"));
if (offline || list.length === 0) {
setStep(2);
return;
}
setBusy(true);
setError(null);
const failures: string[] = [];
// Sequential: each invite bumps the authz epoch and returns the token the next call must use.
for (const invitee of list) {
try {
const result = await api.post<MemberMutationResult>("/team/members/invite", { email: invitee, role: inviteRole });
adoptToken(result.token);
} catch (err) {
failures.push(`${invitee}: ${errorMessage(err)}`);
}
}
setBusy(false);
if (failures.length > 0) setError(failures.join(" · "));
else setStep(2);
}
const usernameValid = /^[a-z0-9][a-z0-9_.-]{1,31}$/.test(username.trim());
const registerReady =
name.trim().length >= 2 && (offline || (usernameValid && password.length >= MIN_PASSWORD));
// Signed-in visitors who land here (e.g. from the sidebar) get a way out rather than a second registration form.
if (status === "authenticated" && session !== null && step === 0) {
return (
<div className="flex h-full items-center justify-center p-6">
<Card className="w-[520px] max-w-full p-6">
<h2 className="mb-1 text-base font-semibold">You&apos;re signed in</h2>
<p className="mb-6 text-xs text-text-faint">
@{session.user.username} · {session.role} of <span className="text-text">{session.workspace.name}</span>
</p>
<div className="flex justify-between">
<Button variant="ghost" onClick={() => void logout()}>Sign out</Button>
<Link href="/streams" className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">
Continue to Streams
</Link>
</div>
</Card>
</div>
);
}
return (
<div className="flex h-full items-center justify-center p-6">
<Card className="w-[520px] max-w-full p-6">
<div className="mb-4 flex items-center justify-end">
<Button
size="sm"
variant="outline"
onClick={() => { setMode(mode === "signin" ? "register" : "signin"); setStep(0); }}
>
{mode === "signin" ? "Create a workspace" : "Existing account? Log in"}
</Button>
</div>
{offline && (
<div className="mb-4 flex justify-end">
<OfflineBadge source="sample" />
</div>
)}
{sessionExpired && step === 0 && (
<p className="mb-4 rounded-md border border-warn/30 bg-warn/10 px-3 py-2 text-xs text-warn">
Your session ended (expired, or your role changed). Sign in again.
</p>
)}
{mode === "signin" && step === 0 && !offline ? (
<SignInForm onSwitch={() => setMode("register")} />
) : (
<>
<div className="mb-6 flex items-center gap-2">
{STEPS.map((s, i) => (
<div key={s} className="flex flex-1 items-center gap-2">
<span
className={`flex h-6 w-6 items-center justify-center rounded-full text-2xs font-semibold ${
i < step ? "bg-live/20 text-live" : i === step ? "bg-accent text-white" : "bg-surface-2 text-text-faint"
}`}
>
{i < step ? "✓" : i + 1}
</span>
<span className={`text-xs ${i === step ? "text-text" : "text-text-faint"}`}>{s}</span>
{i < STEPS.length - 1 && <span className="h-px flex-1 bg-border-subtle" />}
</div>
))}
</div>
{step === 0 && (
<form
onSubmit={(e) => {
e.preventDefault();
void createWorkspace();
}}
>
<div className="mb-5">
<div className="mb-4 overflow-hidden rounded-lg border border-border-subtle">
<img
src="/brand/hero.png"
alt="Tactical Media Broadcast Hub — arrows from a circle, warm red on black"
width={1024}
height={1024}
className="h-36 w-full object-cover"
/>
</div>
<div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight">Tactical Media Broadcast Hub</h1>
<p className="mt-1 text-2xs text-text-faint">Your platform. Their feeds. No algorithm in between.</p>
</div>
</div>
<h2 className="mb-1 text-base font-semibold">Name your workspace</h2>
<p className="mb-4 text-xs text-text-faint">The tenant boundary for accounts, drafts, and audit.</p>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your collective, your studio…" autoFocus />
{!offline && (
<div className="mt-4 space-y-2">
<p className="text-xs text-text-faint">Your owner account</p>
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Your name (optional)" autoComplete="name" />
<Input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase())}
placeholder="Username — this is how you sign in"
autoComplete="username"
pattern="[a-z0-9][a-z0-9_.-]{1,31}"
title="2-32 characters: lowercase letters, digits, dot, underscore or hyphen"
/>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={`Password (min ${MIN_PASSWORD} characters)`}
autoComplete="new-password"
/>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email (optional)"
autoComplete="email"
/>
</div>
)}
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
<div className="mt-6 flex items-center justify-between">
{offline ? <span /> : (
<Button type="button" variant="ghost" onClick={() => setMode("signin")}>I already have an account</Button>
)}
<Button type="submit" variant="primary" disabled={busy || !registerReady}>
{busy ? "Creating…" : "Continue"}
</Button>
</div>
</form>
)}
{step === 1 && (
<div>
<h2 className="mb-1 text-base font-semibold">Invite your team</h2>
<p className="mb-4 text-xs text-text-faint">
Comma-separated emails. Invitees join at the chosen role; adjust roles on the Team page.
</p>
<textarea
value={emails}
onChange={(e) => setEmails(e.target.value)}
rows={3}
placeholder="priya@studio.io, yuki@studio.io"
className="w-full resize-none rounded-md border border-border bg-surface-2 p-2 text-sm focus:border-accent focus:outline-none"
/>
<label className="mt-3 flex items-center gap-2 text-xs text-text-faint">
Invite role
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
>
{INVITE_ROLE_CHOICES.map((role) => (
<option key={role} value={role}>{role}</option>
))}
</select>
</label>
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
<div className="mt-6 flex justify-between">
{/* The workspace already exists once step 0 succeeds; going back would re-register. */}
<Button variant="ghost" onClick={() => setStep(2)}>Skip</Button>
<Button variant="primary" disabled={busy} onClick={() => void sendInvites()}>
{busy ? "Inviting…" : "Continue"}
</Button>
</div>
</div>
)}
{step === 2 && (
<div>
<h2 className="mb-1 text-base font-semibold">Connect your first accounts</h2>
<p className="mb-4 text-xs text-text-faint">
{providerSource === "live" && providers.length > 0
? `Available: ${providers.map((p) => platformName(p.id)).join(", ")}. Follow each linking flow — bot tokens where required, OAuth on your instance otherwise.`
: "Connect at least one account so your workspace can publish."}
</p>
<div className="space-y-2">
{providerSource === "live" && providers.length > 0 ? (
providers.map((p) => (
<div key={p.id} className="flex items-center justify-between rounded-md border border-border-subtle px-3 py-2.5">
<div className="flex items-center gap-3">
<ArrowGlyph size={11} className="text-accent" />
<span className="text-sm font-medium">{platformName(p.id)}</span>
</div>
<Button
size="sm"
variant="outline"
onClick={() => window.location.assign(`/connections?start=${p.id}`)}
>
Connect
</Button>
</div>
))
) : (
<p className={`rounded-md border px-3 py-2.5 text-xs ${providerSource === "loading" || providerSource === "live" ? "border-border-subtle text-text-faint" : "border-danger text-danger"}`}>
{providerSource === "loading"
? "Loading connectable platforms…"
: providerSource === "live"
? "No connectable platforms are available yet."
: "Could not load the platform list — check that the API is reachable, or continue and connect later from Connections."}
</p>
)}
</div>
<p className="mt-3 text-2xs text-text-faint">Connecting opens the account linking flow — you can also do this later from Connections.</p>
<div className="mt-6 flex justify-between">
<Button variant="ghost" onClick={() => setStep(1)}>Back</Button>
<Button variant="primary" onClick={() => setStep(3)}>Finish setup</Button>
</div>
</div>
)}
{step === 3 && (
<div className="py-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-live/20 text-lg text-live">✓</div>
<h2 className="text-base font-semibold">{session?.workspace.name ?? (name || "Workspace")} is ready</h2>
<p className="mt-1 text-xs text-text-faint">
Link accounts any time from <Link href="/connections" className="text-accent hover:underline">Connections</Link> — or head to{" "}
<Link href="/streams" className="text-accent hover:underline">Streams</Link> or{" "}
<Link href="/compose" className="text-accent hover:underline">Compose</Link>
</p>
</div>
)}
</>
)}
</Card>
</div>
);
}