- 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
190 lines
7.5 KiB
TypeScript
190 lines
7.5 KiB
TypeScript
"use client";
|
||
|
||
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>
|
||
);
|
||
}
|