Collectives P1: participation landing (ES/EN), group tree, room provisioning APIs

- 030: groups.parent_id tree + kind discriminator + group_rooms mapping
- /collectives module: create/tree/join APIs, MatrixProvisioner (open room
  public+federated, closed room E2EE-at-creation, onboarding room), NullProvisioner
  fail-soft when ARCHIVE_PROVISIONER_* unset
- web root becomes the public participation manual (ES-first + EN toggle,
  three paths: join by invite, start collective, solo account)
- participation manual content in core
This commit is contained in:
glass beadian 2026-10-12 13:53:40 +00:00
parent c31762b82d
commit 4a7e78b28d
23 changed files with 2892 additions and 8 deletions

View file

@ -6,6 +6,7 @@ import { Badge, Button, Card, Input } from "../../../components/ui/primitives";
import { LoadingPanel } from "../../../components/brand/loading";
import { apiFetch, ApiError, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import type { InviteInfo } from "../../../lib/api-types";
import type { ManualLanguage } from "@tactical-media-broadcast-hub/core";
import { useAuth } from "../../../lib/auth-store";
const MIN_PASSWORD = 10;
@ -29,6 +30,16 @@ function deadReasonFrom(err: unknown): DeadReason {
const isAlreadyMember = (err: unknown): boolean => err instanceof ApiError && err.status === 409;
/**
* One-line collective labelling for the claim card ("Collectives: X · Y").
* Null when unknown or empty — the card must read fine without it.
*/
function collectiveLabel(collectives: Array<{ id: string; name: string }> | null, lang: ManualLanguage): string | null {
if (collectives === null || collectives.length === 0) return null;
const names = collectives.map((c) => c.name).join(" · ");
return lang === "es" ? `Colectivos: ${names}` : `Collectives: ${names}`;
}
function usesLeftLine(info: InviteInfo): string {
const left = info.maxUses === null ? "unlimited uses" : `${Math.max(info.maxUses - info.usedCount, 0)} use${info.maxUses - info.usedCount === 1 ? "" : "s"} left`;
return `${left} · expires ${new Date(info.expiresAt).toLocaleDateString()}`;
@ -183,6 +194,9 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
const [claimError, setClaimError] = useState<string | null>(null);
const [alreadyMember, setAlreadyMember] = useState(false);
const [attempt, setAttempt] = useState(0);
// Collective names for the claim card (names-only public read). Purely
// cosmetic labelling: a failure renders the card without it, never blocks.
const [collectives, setCollectives] = useState<Array<{ id: string; name: string }> | null>(null);
// The preview is public: fetch it immediately rather than waiting for the session to settle.
useEffect(() => {
@ -193,6 +207,14 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
if (cancelled) return;
setInfo(preview);
setPhase("preview");
// Best-effort collective labelling: the token is already proven valid.
apiFetch<{ collectives: Array<{ id: string; name: string }> }>(`/collectives-public/for-invite/${encodeURIComponent(params.token)}`, {
redirectOn401: false,
})
.then((result) => {
if (!cancelled) setCollectives(result.collectives);
})
.catch(() => undefined);
})
.catch((err: unknown) => {
if (cancelled) return;
@ -288,6 +310,9 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
<Badge tone="accent">{info.role}</Badge>.
</p>
<p className="mt-2 text-2xs text-text-faint">{usesLeftLine(info)}</p>
{collectiveLabel(collectives, "en") !== null && (
<p className="mt-1 text-2xs text-text-faint">{collectiveLabel(collectives, "en")}</p>
)}
{claimError !== null && <p className="mt-2 text-xs text-danger">{claimError}</p>}
{memberCta}
</Card>
@ -309,6 +334,9 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
<p className="mt-1 text-2xs text-text-faint">
You&apos;ve been invited as <Badge tone="accent">{info.role}</Badge> · {usesLeftLine(info)}
</p>
{collectiveLabel(collectives, "en") !== null && (
<p className="mt-0.5 text-2xs text-text-faint">{collectiveLabel(collectives, "en")}</p>
)}
</div>
{mode === "signin" ? (
<JoinSignInForm token={params.token} onSwitch={() => setMode("register")} onAlreadyMember={() => setAlreadyMember(true)} />

View file

@ -1,5 +1,190 @@
import { redirect } from "next/navigation";
"use client";
export default function RootPage(): never {
redirect("/streams");
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
MANUAL_INTRO,
MANUAL_PATHS_HEADING,
MANUAL_TITLE,
PARTICIPATION_MANUAL,
PARTICIPATION_PATHS,
isManualLanguage,
type ManualLanguage,
} from "@tactical-media-broadcast-hub/core";
import { Logo } from "../components/brand/logo";
import { AuthProvider, useAuth } from "../lib/auth-store";
/**
* Public participation-manual landing page (the app root).
*
* Design (docs/design/2026-10-12-collectives-architecture.md §"Participation
* manual"): the root explains the platform in plain language and offers the
* three participation paths — join a collective, start one, work on your own —
* before any auth wall. The copy is the SINGLE source in
* packages/core/src/participation-manual.ts: the provisioner posts the
* byte-identical text into onboarding rooms, so this page and the bot guide can
* never drift apart.
*
* Spanish is the primary language: the page opens in ES and the EN toggle is a
* one-tap switch, mirroring the bot's ES-first posting order.
*/
const STORAGE_KEY = "tmbh.manual.lang";
function readStoredLanguage(): ManualLanguage {
if (typeof window === "undefined") return "es";
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return isManualLanguage(stored) ? stored : "es";
} catch {
return "es";
}
}
/** Renders manual body text: blank-line paragraphs, "- " bullets. */
function ManualBody({ text }: { text: string }): React.ReactElement {
const blocks = text.split("\n\n");
return (
<>
{blocks.map((block, index) => {
const lines = block.split("\n");
return lines.every((line) => line.startsWith("- ")) ? (
<ul key={index} className="mb-3 ml-1 list-disc space-y-1.5 pl-4">
{lines.map((line, i) => (
<li key={i}>{line.slice(2)}</li>
))}
</ul>
) : (
<p key={index} className="mb-3 leading-relaxed">{block}</p>
);
})}
</>
);
}
/**
* The root sits OUTSIDE the (main) route group, so its AuthProvider must be
* local: the (main) layout's provider does not wrap this page, and useAuth
* without a provider throws during prerender.
*/
export default function ManualLandingPage(): React.ReactElement {
return (
<AuthProvider>
<ManualLanding />
</AuthProvider>
);
}
function ManualLanding(): React.ReactElement {
const { status, session } = useAuth();
const router = useRouter();
const [lang, setLang] = useState<ManualLanguage>("es");
useEffect(() => setLang(readStoredLanguage()), []);
function switchLanguage(next: ManualLanguage): void {
setLang(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Private-browsing refusal: the choice still holds for this view.
}
}
// Signed-in visitors go straight to the app; the manual stays reachable at /wiki.
useEffect(() => {
if (status === "authenticated") router.replace("/streams");
}, [status, router]);
const t = <T extends { es: string; en: string }>(text: T): string => text[lang];
return (
<div className="min-h-dvh bg-bg text-text">
<header className="border-b border-border-subtle bg-surface">
<div className="mx-auto flex max-w-2xl items-center gap-3 px-4 py-4">
<Logo size={26} />
<span className="flex-1 truncate text-xs font-bold tracking-tight text-accent">Tactical Media Broadcast Hub</span>
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label={t({ es: "Idioma", en: "Language" })}>
{(["es", "en"] as const).map((code) => (
<button
key={code}
type="button"
onClick={() => switchLanguage(code)}
aria-pressed={lang === code}
className={`min-h-8 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 ${
lang === code ? "bg-accent text-white" : "text-text-dim hover:text-text"
}`}
>
{code}
</button>
))}
</div>
<Link
href={status === "authenticated" ? "/streams" : "/onboarding"}
className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-2xs font-medium text-white hover:bg-accent-hover"
>
{status === "authenticated"
? t({ es: "Abrir la app", en: "Open app" })
: t({ es: "Entrar", en: "Sign in" })}
</Link>
</div>
</header>
<main className="mx-auto max-w-2xl px-4 pb-20 pt-8">
<h1 className="mb-2 text-2xl font-bold">{t(MANUAL_TITLE)}</h1>
<p className="mb-8 text-sm leading-relaxed text-text-dim">{t(MANUAL_INTRO)}</p>
{/* The three participation paths — the core of the page. */}
<section aria-labelledby="paths-heading" className="mb-10">
<h2 id="paths-heading" className="mb-3 text-xs font-bold uppercase tracking-wider text-text-faint">
{t(MANUAL_PATHS_HEADING)}
</h2>
<ul className="grid gap-3">
{PARTICIPATION_PATHS.map((path) => {
// All three land on /onboarding: that page holds the sign-in and
// create-account forms. A join link itself arrives from outside
// (the only /join route is /join/[token]); starting/solo need the
// same forms, so one destination is honest for all three.
const href = "/onboarding";
return (
<li key={path.id}>
<Link
href={href}
className="flex min-h-24 flex-col justify-center gap-1.5 rounded-lg border border-border-subtle bg-surface p-4 transition-colors duration-150 hover:border-accent"
>
<span className="text-sm font-semibold">{t(path.title)}</span>
<span className="text-xs leading-relaxed text-text-faint">{t(path.summary)}</span>
<span className="mt-1 text-2xs font-bold uppercase tracking-wider text-accent">{t(path.action)} ›</span>
</Link>
</li>
);
})}
</ul>
</section>
{/* The manual itself. */}
<div className="flex flex-col gap-8">
{PARTICIPATION_MANUAL.map((section, index) => (
<section key={section.id} aria-labelledby={`section-${section.id}`}>
<h2 id={`section-${section.id}`} className="mb-2 flex items-baseline gap-2.5 text-base font-bold">
<span className="font-mono text-xs font-bold text-accent">{String(index + 1).padStart(2, "0")}</span>
{t(section.heading)}
</h2>
<div className="text-sm text-text-dim">
<ManualBody text={section.body[lang]} />
</div>
</section>
))}
</div>
<footer className="mt-12 border-t border-border-subtle pt-6 text-2xs leading-relaxed text-text-faint">
{t({
es: "Sin cuenta no se puede entrar al área de trabajo, pero esta página es pública por diseño: compártela con quien quiera entender cómo funciona un colectivo antes de pedir su invitación.",
en: "The workspace itself needs an account, but this page is public by design: share it with anyone who wants to understand how a collective works before asking for their invite.",
})}
</footer>
</main>
</div>
);
}