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,481 @@
"use client";
import { Fragment, useCallback, useEffect, useState } from "react";
import { Badge, Button, Card } from "../../../components/ui/primitives";
import { EmptyState } from "../../../components/brand/empty-state";
import { Loading } from "../../../components/brand/loading";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { api, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import type { DataSource } from "../../../lib/use-api";
/**
* Analytics wiring: GET /analytics/summary (current vs previous month with
* deltas) and GET /analytics/snapshots (month history, keyset-paginated by
* cursor). Response types mirror apps/api/src/analytics/snapshot.service.ts.
*
* Honesty rules (audit-page pattern): no analytics fixtures exist, so the
* offline fallback is an empty screen behind the offline badge — never invented
* numbers — and on a live API error the data is withheld rather than faked.
* The production API may not serve /analytics yet, in which case the page
* degrades to the error state with a retry.
*/
interface SnapshotRecord {
id: string;
workspaceId: string;
month: string; // YYYY-MM
publicationsTotal: number;
publicationsByProvider: Record<string, number>;
postsArchivedTotal: number;
activeConnections: number;
draftsCreatedTotal: number;
publishingAttemptsTotal: number;
publishFailuresTotal: number;
snapshotStartedAt: string;
snapshotCompletedAt: string;
}
type SnapshotMetrics = Pick<
SnapshotRecord,
| "publicationsTotal"
| "postsArchivedTotal"
| "activeConnections"
| "draftsCreatedTotal"
| "publishingAttemptsTotal"
| "publishFailuresTotal"
>;
/** Deltas from GET /analytics/summary (monthDeltas shape); provider deltas stay per-provider. */
interface MetricDeltas {
publicationsTotal: number;
publicationsByProvider: Record<string, number>;
postsArchivedTotal: number;
activeConnections: number;
draftsCreatedTotal: number;
publishingAttemptsTotal: number;
publishFailuresTotal: number;
}
interface SummaryResponse {
currentMonth: string;
previousMonth: string;
current: SnapshotRecord | null;
previous: SnapshotRecord | null;
deltas: MetricDeltas | null;
}
interface SnapshotPage {
items: SnapshotRecord[];
nextCursor: string | null;
}
const identity = <T,>(value: T): T => value;
const PAGE_SIZE = 12; // server default; hard cap is 24
interface MetricDef {
key: keyof SnapshotMetrics;
label: string;
/** Fewer failures is good, so invert the up/down coloring for that metric. */
invert?: boolean;
}
const METRICS: MetricDef[] = [
{ key: "publicationsTotal", label: "Publications" },
{ key: "postsArchivedTotal", label: "Posts archived" },
{ key: "activeConnections", label: "Active channels" },
{ key: "draftsCreatedTotal", label: "Drafts created" },
{ key: "publishingAttemptsTotal", label: "Publish attempts" },
{ key: "publishFailuresTotal", label: "Publish failures", invert: true },
];
function monthLabel(month: string): string {
return new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
timeZone: "UTC",
});
}
function numberDelta(deltas: MetricDeltas | null, key: keyof SnapshotMetrics): number | null {
return deltas === null ? null : deltas[key];
}
function providerDeltas(deltas: MetricDeltas | null): Record<string, number> | null {
return deltas === null ? null : deltas.publicationsByProvider;
}
function Delta({
value,
previousMonth,
invert,
}: {
value: number;
previousMonth: string;
invert?: boolean;
}): React.ReactElement {
if (value === 0) {
return (
<span className="text-2xs text-text-faint" title={`No change vs ${monthLabel(previousMonth)}`}>
—
</span>
);
}
const up = value > 0;
const good = invert ? !up : up;
return (
<span
title={`${up ? "+" : "−"}${Math.abs(value).toLocaleString()} vs ${monthLabel(previousMonth)}`}
className={`text-2xs font-medium tabular-nums ${good ? "text-live" : "text-danger"}`}
>
{up ? "▲" : "▼"} {Math.abs(value).toLocaleString()}
</span>
);
}
function MetricCard({
metric,
value,
delta,
previousMonth,
}: {
metric: MetricDef;
value: number;
delta: number | null;
previousMonth: string;
}): React.ReactElement {
return (
<div className="rounded-lg border border-border-subtle bg-surface p-3">
<div className="text-2xs uppercase tracking-wide text-text-faint">{metric.label}</div>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-xl font-semibold tabular-nums">{value.toLocaleString()}</span>
{delta !== null && <Delta value={delta} previousMonth={previousMonth} invert={metric.invert} />}
</div>
</div>
);
}
/** Provider breakdown chips with a relative bar; optional per-provider deltas. */
function ProviderBreakdown({
byProvider,
deltas,
previousMonth,
}: {
byProvider: Record<string, number>;
deltas: Record<string, number> | null;
previousMonth: string;
}): React.ReactElement {
const entries = Object.entries(byProvider).sort((a, b) => b[1] - a[1]);
if (entries.length === 0) {
return <p className="text-2xs text-text-faint">No publications by any provider.</p>;
}
const max = Math.max(...entries.map(([, n]) => n), 1);
return (
<div className="flex flex-wrap gap-2">
{entries.map(([provider, count]) => {
const delta = deltas === null ? undefined : deltas[provider];
return (
<span
key={provider}
className="inline-flex min-w-[7.5rem] flex-col gap-1 rounded-md border border-border-subtle bg-surface-2 px-2 py-1.5"
>
<span className="flex items-center justify-between gap-3 text-2xs">
<span className="font-medium text-text-dim">{provider}</span>
<span className="tabular-nums">{count.toLocaleString()}</span>
</span>
<span
className="h-1 rounded-full bg-accent"
style={{ width: `${Math.max(8, Math.round((count / max) * 100))}%` }}
/>
{delta !== undefined && <Delta value={delta} previousMonth={previousMonth} />}
</span>
);
})}
</div>
);
}
export default function Page(): React.ReactElement {
const { status, can } = useAuth();
const live = status === "authenticated";
// Offline screens render sample data (house pattern); permission checks only
// bite against a real session — can() is false for everyone while offline.
const allowed = !live || can("analytics.view");
// Summary panel: current month vs previous month, via the house hook.
const summary = useApiResource<SummaryResponse, SummaryResponse | null>("/analytics/summary", identity, null);
// Snapshot history: audit-page-style manual fetch because keyset "load older"
// appends pages instead of replacing the view.
const [historySource, setHistorySource] = useState<DataSource>("loading");
const [rows, setRows] = useState<SnapshotRecord[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
const reload = useCallback(() => setNonce((n) => n + 1), []);
const [generating, setGenerating] = useState(false);
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
useEffect(() => {
if (status === "loading" || !allowed) return;
if (status !== "authenticated") {
setHistorySource("sample");
return;
}
const controller = new AbortController();
setHistorySource("loading");
api
.get<SnapshotPage>("/analytics/snapshots", { limit: PAGE_SIZE }, controller.signal)
.then((page) => {
setRows(page.items);
setCursor(page.nextCursor);
setHistorySource("live");
setHistoryError(null);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
if (err instanceof ApiUnreachableError) setHistorySource("sample");
else {
setHistorySource("live");
setHistoryError(errorMessage(err));
}
});
return () => controller.abort();
}, [status, allowed, nonce]);
async function loadOlder(): Promise<void> {
if (cursor === null || historySource !== "live") return;
setLoadingMore(true);
try {
const page = await api.get<SnapshotPage>("/analytics/snapshots", { cursor, limit: PAGE_SIZE });
setRows((current) => [...current, ...page.items]);
setCursor(page.nextCursor);
} catch (err) {
setHistoryError(errorMessage(err));
} finally {
setLoadingMore(false);
}
}
/** Manual trigger for the current month (POST /analytics/snapshot). */
async function generateSnapshot(): Promise<void> {
setGenerating(true);
setMessage(null);
try {
const record = await api.post<SnapshotRecord>("/analytics/snapshot", {});
setMessage({ tone: "ok", text: `Snapshot generated for ${monthLabel(record.month)}.` });
reload();
} catch (err) {
setMessage({ tone: "error", text: errorMessage(err) });
} finally {
setGenerating(false);
}
}
if (!allowed) {
return (
<div className="flex h-full items-center justify-center p-6">
<EmptyState
title="No access"
hint="Analytics needs the analytics.view permission, which your role does not have. Ask an Owner or Admin for access."
/>
</div>
);
}
const sampleMode = summary.source === "sample" || historySource === "sample";
const view = summary.data;
const canGenerate = live && historySource !== "loading";
return (
<div className="h-full overflow-y-auto p-6 scrollbar-thin">
<div className="mb-6">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">Analytics</h1>
<OfflineBadge source={sampleMode ? "sample" : historySource} />
</div>
<p className="mt-1 text-2xs text-text-faint">
Monthly rollups of publishing activity · deltas compare the current month with the previous one
</p>
</div>
{/* Summary: current month + previous-month deltas */}
{summary.source === "loading" ? (
<Card className="mb-6 flex items-center gap-2 p-4 text-xs text-text-faint">
<Loading size={14} label="Loading summary" /> Loading summary…
</Card>
) : summary.error !== null ? (
<Card className="mb-6 p-4">
<p className="text-xs text-danger">Could not load the monthly summary — {summary.error}</p>
<Button variant="outline" size="sm" className="mt-2" onClick={reload}>
Retry
</Button>
</Card>
) : view !== null ? (
<Card className="mb-6 p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold">{monthLabel(view.currentMonth)}</h2>
<Badge tone="neutral">vs {monthLabel(view.previousMonth)}</Badge>
</div>
{view.current === null ? (
<div className="rounded-md border border-border-subtle bg-surface-2 p-3 text-xs text-text-faint">
No snapshot exists for {monthLabel(view.currentMonth)} yet — it is generated on schedule, or
immediately via <code className="text-accent">POST /analytics/snapshot</code>.
</div>
) : (
<>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3">
{METRICS.map((metric) => (
<MetricCard
key={metric.key}
metric={metric}
value={view.current![metric.key]}
delta={numberDelta(view.deltas, metric.key)}
previousMonth={view.previousMonth}
/>
))}
</div>
<div className="mt-4">
<h3 className="mb-2 text-2xs font-medium uppercase tracking-wide text-text-faint">
Publications by provider
</h3>
<ProviderBreakdown
byProvider={view.current.publicationsByProvider}
deltas={providerDeltas(view.deltas)}
previousMonth={view.previousMonth}
/>
</div>
{view.previous === null && (
<p className="mt-3 text-2xs text-text-faint">
No snapshot for {monthLabel(view.previousMonth)} — deltas appear once the previous month has one.
</p>
)}
</>
)}
</Card>
) : null}
{/* Snapshot history */}
<Card className="p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold">Snapshot history</h2>
{rows.length > 0 && <span className="text-2xs text-text-faint">{rows.length} months</span>}
</div>
{message !== null && (
<p className={`mb-3 text-2xs ${message.tone === "ok" ? "text-live" : "text-danger"}`}>{message.text}</p>
)}
{historySource === "loading" ? (
<div className="flex items-center gap-2 py-3 text-xs text-text-faint">
<Loading size={14} label="Loading snapshots" /> Loading snapshots…
</div>
) : historyError !== null && rows.length === 0 ? (
<div className="py-2">
<p className="text-xs text-danger">Failed to load analytics snapshots — {historyError}</p>
<Button variant="outline" size="sm" className="mt-2" onClick={reload}>
Retry
</Button>
</div>
) : rows.length === 0 ? (
<>
<EmptyState
title={sampleMode ? "No analytics data while offline" : "No analytics snapshots yet"}
hint={
sampleMode
? "The API is unreachable, so there is no snapshot data to show. Analytics is live-API only — reconnect to see real numbers."
: "Snapshots roll up one row per month. They are generated on schedule, or immediately via POST /analytics/snapshot."
}
/>
{!sampleMode && historyError === null && (
<div className="flex justify-center pb-2">
<Button variant="outline" size="sm" disabled={!canGenerate || generating} onClick={() => void generateSnapshot()}>
{generating ? "Generating…" : "Generate current-month snapshot"}
</Button>
</div>
)}
</>
) : (
<>
{historyError !== null && (
<p className="mb-3 text-xs text-danger">Could not load older months — {historyError}</p>
)}
<div className="rounded-lg border border-border-subtle">
<table className="mobile-stack-table w-full text-sm">
<thead>
<tr className="border-b border-border-subtle text-left text-2xs text-text-faint">
<th className="px-4 py-2 font-medium">Month</th>
<th className="px-4 py-2 font-medium">Publications</th>
<th className="px-4 py-2 font-medium">Archived</th>
<th className="px-4 py-2 font-medium">Drafts</th>
<th className="px-4 py-2 font-medium">Attempts</th>
<th className="px-4 py-2 font-medium">Failures</th>
<th className="px-4 py-2 font-medium">Channels</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody>
{rows.map((record) => (
<Fragment key={record.id}>
<tr
className="cursor-pointer border-b border-border-subtle transition-colors duration-150 hover:bg-surface-2"
onClick={() => setExpanded(expanded === record.id ? null : record.id)}
>
<td className="px-4 py-2.5 text-xs font-medium">{monthLabel(record.month)}</td>
<td className="px-4 py-2.5 text-xs tabular-nums">{record.publicationsTotal.toLocaleString()}</td>
<td className="px-4 py-2.5 text-xs tabular-nums">{record.postsArchivedTotal.toLocaleString()}</td>
<td className="px-4 py-2.5 text-xs tabular-nums">{record.draftsCreatedTotal.toLocaleString()}</td>
<td className="px-4 py-2.5 text-xs tabular-nums">{record.publishingAttemptsTotal.toLocaleString()}</td>
<td className="px-4 py-2.5 text-xs tabular-nums">
<span className={record.publishFailuresTotal > 0 ? "text-danger" : undefined}>
{record.publishFailuresTotal.toLocaleString()}
</span>
</td>
<td className="px-4 py-2.5 text-xs tabular-nums">{record.activeConnections.toLocaleString()}</td>
<td className="px-4 py-2.5 text-right text-2xs text-text-faint">{expanded === record.id ? "▾" : "▸"}</td>
</tr>
{expanded === record.id && (
<tr className="border-b border-border-subtle bg-bg">
<td colSpan={8} className="px-4 py-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{METRICS.map((metric) => (
<div key={metric.key}>
<div className="text-2xs text-text-faint">{metric.label}</div>
<div className="text-sm tabular-nums">{record[metric.key].toLocaleString()}</div>
</div>
))}
</div>
<div className="mt-3">
<ProviderBreakdown byProvider={record.publicationsByProvider} deltas={null} previousMonth={record.month} />
</div>
<p className="mt-3 text-2xs text-text-faint">
Generated {new Date(record.snapshotCompletedAt).toLocaleString()}
</p>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
{historySource === "live" && cursor !== null && (
<div className="mt-3 flex justify-center">
<Button variant="outline" size="sm" disabled={loadingMore} onClick={() => void loadOlder()}>
{loadingMore ? "Loading…" : "Load older months"}
</Button>
</div>
)}
</>
)}
<p className="mt-3 text-2xs text-text-faint">
Snapshots are recomputed per (workspace, month) from source tables — re-running a month overwrites it in place.
</p>
</Card>
</div>
);
}

View file

@ -0,0 +1,5 @@
import InboxPage from "../inbox/page";
export default function ApprovalsPage(): React.ReactElement {
return <InboxPage defaultSegment="approvals" />;
}

View file

@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import { isBotConnection } from "../../../lib/connections-client";
import { Badge, Button, Card } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { EmptyState } from "../../../components/brand/empty-state";
import { api, errorMessage } from "../../../lib/api-client";
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import type { ConnectionSummary, DraftRecord, DraftState } from "../../../lib/api-types";
interface MediaRef { fileId: string; status: string; thumbUrl: string | null; contentType: string }
interface ArchiveItem {
id: string; remoteRef: string; text: string; media: MediaRef[];
attribution: { channelTitle?: string | null; channelUsername?: string | null; authorSignature?: string | null; forwardOrigin?: Record<string, unknown> | null };
protectedContent: boolean; postedAt: string; revision: number;
}
interface Page { items: ArchiveItem[]; nextCursor: string | null }
const SAMPLE: Page = { items: [{
id: "sample", remoteRef: "-100123456789:42", text: "A new channel post appears here for review.",
media: [], attribution: { channelTitle: "Example channel", authorSignature: "Editor" },
protectedContent: false, postedAt: "2026-10-07T12:00:00.000Z", revision: 1,
}], nextCursor: null };
const identity = (page: Page): Page => page;
const noConnections: ConnectionSummary[] = [];
const same = (value: ConnectionSummary[]): ConnectionSummary[] => value;
export default function ArchivePage(): React.ReactElement {
const { can } = useAuth();
const { data, source, error, reload } = useApiResource<Page, Page>("/archive/items", identity, SAMPLE);
const { data: connections } = useApiResource<ConnectionSummary[], ConnectionSummary[]>("/connections", same, noConnections);
const [more, setMore] = useState<ArchiveItem[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [draftStates, setDraftStates] = useState<Record<string, DraftState>>({});
const [connectionId, setConnectionId] = useState("");
const [attribution, setAttribution] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const telegramConnections = connections.filter(isBotConnection);
const nextCursor = cursor ?? data.nextCursor;
const items = [...data.items, ...more];
async function createDraft(item: ArchiveItem): Promise<void> {
setBusy(item.id); setActionError(null);
try {
const draft = await api.post<DraftRecord>(`/archive/items/${item.id}/drafts`, {});
setDrafts((current) => ({ ...current, [item.id]: draft.id }));
setDraftStates((current) => ({ ...current, [item.id]: draft.state }));
setNotice("Draft created. Submit it for review, then approve it before amplifying.");
} catch (err) { setActionError(errorMessage(err)); }
finally { setBusy(null); }
}
async function submitDraft(item: ArchiveItem): Promise<void> {
const draftId = drafts[item.id];
if (!draftId) return;
setBusy(item.id); setActionError(null);
try {
const draft = await api.post<DraftRecord>(`/publishing/drafts/${draftId}/submit`);
setDraftStates((current) => ({ ...current, [item.id]: draft.state }));
setNotice("Submitted for review.");
} catch (err) { setActionError(errorMessage(err)); }
finally { setBusy(null); }
}
async function approveDraft(item: ArchiveItem): Promise<void> {
const draftId = drafts[item.id];
if (!draftId) return;
setBusy(item.id); setActionError(null);
try {
const draft = await api.post<DraftRecord>(`/publishing/drafts/${draftId}/approve`);
setDraftStates((current) => ({ ...current, [item.id]: draft.state }));
setNotice("Draft approved — it can now be amplified.");
} catch (err) { setActionError(errorMessage(err)); }
finally { setBusy(null); }
}
async function amplify(item: ArchiveItem): Promise<void> {
const draftId = drafts[item.id];
if (!draftId || !connectionId) return;
setBusy(item.id); setActionError(null);
try {
await api.post(`/archive/items/${item.id}/amplify`, { draftId, connectionId, attribution });
setNotice("Amplified to Telegram.");
} catch (err) { setActionError(errorMessage(err)); }
finally { setBusy(null); }
}
async function loadMore(): Promise<void> {
if (!nextCursor) return;
setBusy("more");
try {
const page = await api.get<Page>("/archive/items", { cursor: nextCursor });
setMore((current) => [...current, ...page.items]); setCursor(page.nextCursor);
} catch (err) { setActionError(errorMessage(err)); }
finally { setBusy(null); }
}
return <div className="h-full overflow-y-auto p-4 pb-24 lg:p-6">
<div className="mb-5 flex items-center gap-3"><h1 className="text-lg font-semibold">Archive</h1><OfflineBadge source={source} /></div>
<p className="mb-4 text-sm text-text-dim">Posts from channels where your Telegram bot is an admin arrive here. Check that you have the right to reuse someone else&apos;s work before sharing it.</p>
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
{actionError && <p role="alert" className="mb-3 text-sm text-danger">{actionError}</p>}
{notice && <p role="status" className="mb-3 text-sm text-live">{notice}</p>}
{items.length === 0 && <EmptyState title="No archived posts yet" hint="New channel posts appear after your bot joins a subscribed channel." />}
<div className="space-y-3">{items.map((item) => <Card key={item.id} className="p-4">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-text-faint">
<strong className="text-text">{item.attribution.channelTitle ?? item.attribution.channelUsername ?? item.remoteRef}</strong>
{item.attribution.authorSignature && <span>by {item.attribution.authorSignature}</span>}
<time dateTime={item.postedAt}>{new Date(item.postedAt).toLocaleString()}</time>
{item.revision > 1 && <Badge>edited</Badge>}
{item.protectedContent && <Badge tone="warn">amplify unavailable</Badge>}
</div>
{item.text && <p className="mb-3 whitespace-pre-wrap text-sm">{item.text}</p>}
{item.attribution.forwardOrigin && <p className="mb-2 text-xs text-text-faint">Forwarded origin retained in attribution.</p>}
{item.media.length > 0 && <div className="mb-3 flex flex-wrap gap-2">{item.media.map((media: MediaRef) => typeof media.thumbUrl === "string" && media.contentType.startsWith("image/")
? <img key={media.fileId} src={media.thumbUrl} alt="Archived Telegram media" className="h-24 w-24 rounded object-cover" />
: <span key={media.fileId} className="rounded border border-border p-2 text-xs">{media.status}</span>)}</div>}
{source === "live" && <div className="flex flex-wrap items-center gap-2">
{can("draft.create") && <Button size="sm" onClick={() => void createDraft(item)} disabled={busy === item.id}>Create draft</Button>}
{drafts[item.id] && <>
<span className="text-xs text-text-faint">Draft {drafts[item.id]?.slice(0, 8)} · state: {draftStates[item.id] ?? "editing"}</span>
{(draftStates[item.id] === undefined || draftStates[item.id] === "editing" || draftStates[item.id] === "rejected") && (
<Button size="sm" variant="outline" disabled={busy === item.id} onClick={() => void submitDraft(item)}>Submit</Button>
)}
{draftStates[item.id] === "in_review" && can("approval.grant") && (
<Button size="sm" variant="outline" disabled={busy === item.id} onClick={() => void approveDraft(item)}>Approve</Button>
)}
<select aria-label="Telegram target" value={connectionId} onChange={(event) => setConnectionId(event.target.value)} className="rounded border border-border bg-surface p-2 text-xs">
<option value="">Choose target</option>{telegramConnections.map((c) => <option key={c.id} value={c.id}>{c.channelId}</option>)}
</select>
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={attribution} onChange={(event) => setAttribution(event.target.checked)} /> Include attribution</label>
{can("publication.schedule") && <Button size="sm" disabled={item.protectedContent || !connectionId || busy === item.id || draftStates[item.id] !== "approved"} title={draftStates[item.id] !== "approved" ? "Submit and approve the draft first" : undefined} onClick={() => void amplify(item)}>Amplify approved draft</Button>}
</>}
</div>}
</Card>)}</div>
{nextCursor && source === "live" && <Button className="mt-4" disabled={busy === "more"} onClick={() => void loadMore()}>Load more</Button>}
{source === "live" && <button type="button" onClick={() => { setMore([]); setCursor(null); reload(); }} className="mt-4 block text-xs text-text-faint underline">Refresh archive</button>}
</div>;
}

View file

@ -0,0 +1,267 @@
"use client";
import { Fragment, useEffect, useState } from "react";
import { Badge, Button } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { AUDIT_ENTRIES, ageLabel } from "../../../lib/fixtures";
import { api, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import type { AuditEntryDto, AuditPage as AuditPageDto } from "../../../lib/api-types";
import { useAuth } from "../../../lib/auth-store";
import type { DataSource } from "../../../lib/use-api";
import { Loading } from "../../../components/brand/loading";
type DiffValue = string | number | boolean | null | unknown[] | Record<string, unknown>;
function DiffCell({ label, value }: { label: string; value: DiffValue }): React.ReactElement {
return (
<div className="grid grid-cols-[110px_1fr] gap-2 text-xs">
<span className="text-text-faint">{label}</span>
<span className="font-mono text-text-dim">{JSON.stringify(value)}</span>
</div>
);
}
function DiffViewer({ diff }: { diff: Record<string, unknown> }): React.ReactElement {
return (
<div className="space-y-1.5 rounded-md border border-border-subtle bg-surface-2 p-3">
{Object.entries(diff).map(([key, value]) => {
if (Array.isArray(value) && value.length === 2 && typeof value[0] !== "object") {
return (
<div key={key} className="grid grid-cols-[110px_1fr] gap-2 text-xs">
<span className="text-text-faint">{key}</span>
<span className="font-mono">
<span className="text-removed line-through">{JSON.stringify(value[0])}</span>
<span className="mx-1 text-text-faint">→</span>
<span className="text-added">{JSON.stringify(value[1])}</span>
</span>
</div>
);
}
return <DiffCell key={key} label={key} value={value as DiffValue} />;
})}
</div>
);
}
interface AuditRow {
id: string;
actor: string;
action: string;
targetType: string;
target: string;
minutes: number;
diff: Record<string, unknown> | null;
}
interface FilterOption {
value: string;
label: string;
}
const PAGE_SIZE = 25;
const ALL = "all";
function fromApi(e: AuditEntryDto): AuditRow {
return {
id: e.id,
actor: e.actorEmail ?? (e.actorUserId === null ? "system" : e.actorUserId),
action: e.action,
targetType: e.targetType,
target: e.targetId,
minutes: Math.max(0, Math.floor((Date.now() - Date.parse(e.at)) / 60_000)),
diff: e.diff,
};
}
const SAMPLE_ROWS: AuditRow[] = AUDIT_ENTRIES.map((e) => ({ ...e, id: String(e.id) }));
function csvCell(value: string | number | null): string {
const text = value === null ? "" : String(value);
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function toCsv(rows: AuditRow[]): string {
const header = ["id", "actor", "action", "target_type", "target", "minutes_ago", "diff"];
const lines = rows.map((e) =>
[e.id, e.actor, e.action, e.targetType, e.target, e.minutes, e.diff === null ? null : JSON.stringify(e.diff)].map(csvCell).join(","));
return [header.join(","), ...lines].join("\n");
}
function downloadCsv(rows: AuditRow[]): void {
const blob = new Blob([toCsv(rows)], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `audit-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
export default function AuditPage(): React.ReactElement {
const { status } = useAuth();
const [actor, setActor] = useState(ALL);
const [action, setAction] = useState(ALL);
const [expanded, setExpanded] = useState<string | null>(null);
const [source, setSource] = useState<DataSource>("loading");
const [rows, setRows] = useState<AuditRow[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [actorOptions, setActorOptions] = useState<FilterOption[]>([]);
const [actionOptions, setActionOptions] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
// Live: filters are applied server-side and pages are fetched by seq cursor.
useEffect(() => {
if (status === "loading") return;
if (status !== "authenticated") {
setSource("sample");
return;
}
const controller = new AbortController();
api
.get<AuditPageDto>("/audit", { action: action === ALL ? undefined : action, actor: actor === ALL ? undefined : actor, limit: PAGE_SIZE }, controller.signal)
.then((page) => {
setRows(page.entries.map(fromApi));
setCursor(page.nextCursor);
setActionOptions(page.facets.actions);
setActorOptions(page.facets.actors.map((a) => ({ value: a.id ?? "system", label: a.email ?? (a.id === null ? "system" : a.id) })));
setSource("live");
setError(null);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
if (err instanceof ApiUnreachableError) setSource("sample");
else {
setSource("live");
setError(errorMessage(err));
}
});
return () => controller.abort();
}, [status, action, actor]);
async function loadMore(): Promise<void> {
if (cursor === null) return;
setLoadingMore(true);
try {
const page = await api.get<AuditPageDto>("/audit", {
action: action === ALL ? undefined : action,
actor: actor === ALL ? undefined : actor,
before: cursor,
limit: PAGE_SIZE,
});
setRows((current) => [...current, ...page.entries.map(fromApi)]);
setCursor(page.nextCursor);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoadingMore(false);
}
}
const live = source === "live";
const actors: FilterOption[] = live
? actorOptions
: [...new Set(SAMPLE_ROWS.map((e) => e.actor))].map((a) => ({ value: a, label: a }));
const actions = live ? actionOptions : [...new Set(SAMPLE_ROWS.map((e) => e.action))];
const visible = live
? rows
: SAMPLE_ROWS.filter((e) => (actor === ALL || e.actor === actor) && (action === ALL || e.action === action));
return (
<div className="h-full overflow-y-auto p-6 scrollbar-thin">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">Audit trail</h1>
<OfflineBadge source={source} />
</div>
<button
type="button"
disabled={source === "loading" || visible.length === 0}
onClick={() => downloadCsv(visible)}
className="rounded-md border border-border px-3 py-1.5 text-xs text-text-dim transition-colors duration-150 hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-border disabled:hover:text-text-dim"
>
Export CSV
</button>
</div>
<div className="mb-4 flex flex-wrap gap-3">
<select value={actor} onChange={(e) => setActor(e.target.value)} className="rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs">
<option value={ALL}>All actors</option>
{actors.map((a) => (
<option key={a.value} value={a.value}>{a.label}</option>
))}
</select>
<select value={action} onChange={(e) => setAction(e.target.value)} className="rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs">
<option value={ALL}>All actions</option>
{actions.map((a) => (
<option key={a} value={a}>{a}</option>
))}
</select>
<span className="ml-auto self-center text-2xs text-text-faint">hash-chained · tamper-evident</span>
</div>
{error !== null && <p className="mb-3 text-xs text-danger">{error}</p>}
<div className="rounded-lg border border-border-subtle">
<table className="mobile-stack-table w-full text-sm">
<thead>
<tr className="border-b border-border-subtle text-left text-2xs text-text-faint">
<th className="px-4 py-2 font-medium">Actor</th>
<th className="px-4 py-2 font-medium">Action</th>
<th className="px-4 py-2 font-medium">Target</th>
<th className="px-4 py-2 font-medium">When</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody>
{source === "loading" && (
<tr><td colSpan={5} className="px-4 py-3"><span className="inline-flex items-center gap-2 text-xs text-text-faint"><Loading size={14} label="Loading audit trail" /> Loading audit trail…</span></td></tr>
)}
{source !== "loading" && visible.length === 0 && (
error !== null
? <tr><td colSpan={5} className="px-4 py-3 text-xs text-danger">Failed to load audit entries — {error}</td></tr>
: <tr><td colSpan={5} className="px-4 py-3 text-xs text-text-faint">No audit entries match.</td></tr>
)}
{source !== "loading" && visible.map((e) => (
<Fragment key={e.id}>
<tr
className="cursor-pointer border-b border-border-subtle transition-colors duration-150 hover:bg-surface-2"
onClick={() => setExpanded(expanded === e.id ? null : e.id)}
>
<td className="px-4 py-2.5 text-xs">{e.actor}</td>
<td className="px-4 py-2.5"><code className="text-xs text-accent">{e.action}</code></td>
<td className="px-4 py-2.5 text-xs text-text-dim">{e.targetType}:{e.target}</td>
<td className="px-4 py-2.5 text-2xs text-text-faint">{ageLabel(e.minutes)} ago</td>
<td className="px-4 py-2.5 text-right text-2xs text-text-faint">{expanded === e.id ? "▾" : "▸"}</td>
</tr>
{expanded === e.id && (
<tr className="border-b border-border-subtle bg-bg">
<td colSpan={5} className="px-4 py-3">
{e.diff === null ? (
<span className="text-xs text-text-faint">no payload</span>
) : (
<DiffViewer diff={e.diff} />
)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
{live && cursor !== null && (
<div className="mt-3 flex justify-center">
<Button variant="outline" size="sm" disabled={loadingMore} onClick={() => void loadMore()}>
{loadingMore ? "Loading…" : "Load older entries"}
</Button>
</div>
)}
<p className="mt-3 text-2xs text-text-faint">
Secret-shaped keys render as <Badge tone="danger">[REDACTED]</Badge> by construction — they never enter the chain.
</p>
</div>
);
}

View file

@ -0,0 +1,291 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Badge, Button } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { EmptyState } from "../../../components/brand/empty-state";
import { LoadingPanel } from "../../../components/brand/loading";
import { api, errorMessage } from "../../../lib/api-client";
import { PLATFORMS } from "../../../lib/platforms";
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import type { ConnectionSummary, DraftRecord } from "../../../lib/api-types";
/**
* Calendar = the scheduled slice of the publishing pipeline. There is no
* dedicated schedule-list endpoint, so this reads the same draft list the
* approvals queue uses (`GET /publishing/drafts?state=scheduled`) and joins
* each draft to its target connection for a readable label.
*/
interface ScheduledRow {
id: string;
text: string;
/** ISO — the queue's `post.scheduledAt` when recorded, else the draft's last update. */
publishAt: string;
fromScheduledAt: boolean;
targetLabel: string;
}
interface DayGroup {
key: number;
label: string;
rows: ScheduledRow[];
}
type Notice = { tone: "ok" | "error"; text: string };
const identity = (drafts: DraftRecord[]): DraftRecord[] => drafts;
const same = (connections: ConnectionSummary[]): ConnectionSummary[] => connections;
const noConnections: ConnectionSummary[] = [];
/** Offline / unauthenticated fallback — one future-dated row so the layout is visible. */
const SAMPLE: DraftRecord[] = [
{
id: "sample-scheduled",
workspaceId: "sample",
authorUserId: "sample-author",
post: {
text: "Sample scheduled post — the weekly roundup goes out to every connected channel.",
mediaAssetIds: [],
visibility: "public",
providerMetadata: { connectionId: "sample-connection" },
scheduledAt: new Date(Date.now() + 3_600_000).toISOString(),
},
state: "scheduled",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
function connectionIdOf(draft: DraftRecord): string | null {
const id = draft.post.providerMetadata.connectionId;
return typeof id === "string" && id !== "" ? id : null;
}
function labelFor(connection: ConnectionSummary | undefined, id: string): string {
if (connection === undefined) return `unknown connection ${id.slice(0, 8)}`;
const handle = typeof connection.fields.handle === "string" ? connection.fields.handle : connection.channelId;
return `${PLATFORMS[connection.provider]?.displayName ?? connection.provider} · ${handle}`;
}
function toRows(drafts: DraftRecord[], connections: ConnectionSummary[]): ScheduledRow[] {
return drafts
.map((draft): ScheduledRow => {
const connectionId = connectionIdOf(draft);
const connection = connectionId === null ? undefined : connections.find((c) => c.id === connectionId);
return {
id: draft.id,
text: draft.post.text,
publishAt: draft.post.scheduledAt ?? draft.updatedAt,
fromScheduledAt: draft.post.scheduledAt !== undefined,
targetLabel: connectionId === null ? "no target recorded" : labelFor(connection, connectionId),
};
})
.sort((a, b) => Date.parse(a.publishAt) - Date.parse(b.publishAt));
}
/** Group by the viewer's local calendar day — the list equivalent of a week view. */
function groupByDay(rows: ScheduledRow[]): DayGroup[] {
const groups = new Map<number, ScheduledRow[]>();
for (const row of rows) {
const date = new Date(row.publishAt);
const key = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const bucket = groups.get(key);
if (bucket === undefined) groups.set(key, [row]);
else bucket.push(row);
}
return [...groups.entries()].map(([key, dayRows]) => ({
key,
label: new Date(key).toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" }),
rows: dayRows,
}));
}
const localTime = (iso: string): string =>
new Date(iso).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
const utcTime = (iso: string): string => `${new Date(iso).toISOString().slice(0, 16).replace("T", " ")} UTC`;
function relativeLabel(iso: string, now: number): { text: string; overdue: boolean } {
const delta = Date.parse(iso) - now;
const minutes = Math.max(0, Math.round(Math.abs(delta) / 60_000));
const span =
minutes < 60
? `${minutes}m`
: minutes < 1_440
? `${Math.floor(minutes / 60)}h ${minutes % 60}m`
: `${Math.floor(minutes / 1_440)}d ${Math.floor((minutes % 1_440) / 60)}h`;
return { text: delta >= 0 ? `in ${span}` : `${span} overdue`, overdue: delta < 0 };
}
export default function CalendarPage(): React.ReactElement {
const { can } = useAuth();
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, SAMPLE, {
state: "scheduled",
limit: 100,
});
const connections = useApiResource<ConnectionSummary[], ConnectionSummary[]>("/connections", same, noConnections);
const live = drafts.source === "live";
const canSchedule = can("publication.schedule");
const [cancelling, setCancelling] = useState<ScheduledRow | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [notice, setNotice] = useState<Notice | null>(null);
const [now, setNow] = useState<number>(Date.now());
// Same cadence as the streams wall: refresh on a 30s timer.
useEffect(() => {
const timer = setInterval(() => {
drafts.reload();
connections.reload();
setNow(Date.now());
}, 30_000);
return () => clearInterval(timer);
}, [drafts.reload, connections.reload]);
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
const groups = useMemo(() => groupByDay(rows), [rows]);
async function cancel(row: ScheduledRow): Promise<void> {
if (!live) {
setNotice({ tone: "error", text: "Offline — sample mode, nothing was sent." });
setCancelling(null);
return;
}
setBusyId(row.id);
setNotice(null);
try {
const draft = await api.del<DraftRecord>(`/publishing/drafts/${row.id}/schedule`);
drafts.setData((current) => current.filter((item) => item.id !== row.id));
setNotice({ tone: "ok", text: `Schedule cancelled — the draft is back to ${draft.state.replace("_", " ")}.` });
setCancelling(null);
drafts.reload();
} catch (err) {
setNotice({ tone: "error", text: errorMessage(err) });
} finally {
setBusyId(null);
}
}
return (
<div className="h-full overflow-y-auto p-4 pb-24 lg:p-6">
<div className="mb-1 flex items-center gap-3">
<h1 className="text-lg font-semibold">Calendar</h1>
<OfflineBadge source={drafts.source} />
<span className="ml-auto text-2xs text-text-faint">
{rows.length} scheduled · refreshes every 30s
</span>
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); setNow(Date.now()); }}>
Refresh
</Button>
</div>
<p className="mb-4 text-xs text-text-faint">
Publications queued by the publishing pipeline, grouped by your local day. Each row shows the local time with the
UTC time beside it; execution state lives in the audit log, not this list.
</p>
{notice !== null && (
<p role="status" className={`mb-3 text-xs ${notice.tone === "ok" ? "text-live" : "text-danger"}`}>
{notice.text}
</p>
)}
{drafts.error !== null && <p role="alert" className="mb-3 text-xs text-danger">{drafts.error}</p>}
{!live && drafts.error === null && (
<p className="mb-3 text-xs text-text-faint">Offline sample — connect to the API to see and cancel real schedules.</p>
)}
{drafts.source === "loading" && <LoadingPanel label="Loading scheduled publications…" />}
{drafts.source !== "loading" && rows.length === 0 && drafts.error === null && (
<EmptyState
title="Nothing scheduled"
hint="Queue a post from Compose (or approve one with a schedule time) and it appears here until it publishes or is cancelled."
/>
)}
{drafts.source !== "loading" &&
groups.map((group) => (
<section key={group.key} className="mb-6">
<h2 className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-text-faint">
{group.label}
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-2xs font-medium normal-case tracking-normal text-text-dim">
{group.rows.length}
</span>
</h2>
<ul className="divide-y divide-border-subtle rounded-lg border border-border-subtle bg-surface">
{group.rows.map((row) => {
const relative = relativeLabel(row.publishAt, now);
return (
<li key={row.id} className="flex flex-col gap-2 p-4 lg:flex-row lg:items-start lg:gap-4">
<div className="flex w-44 shrink-0 flex-col gap-0.5">
<span className="font-mono text-sm text-text">{localTime(row.publishAt)}</span>
<span className="text-2xs text-text-faint">{utcTime(row.publishAt)}</span>
<span className={`text-2xs ${relative.overdue ? "text-warn" : "text-text-faint"}`}>{relative.text}</span>
</div>
<div className="min-w-0 flex-1">
<p className="line-clamp-2 whitespace-pre-wrap break-words text-sm text-text">{row.text}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-2xs text-text-faint">
<Badge tone="accent">scheduled</Badge>
<span>{row.targetLabel}</span>
{!row.fromScheduledAt && (
<span title="The queue keeps its own timestamp, but the API only exposes the draft — this is the draft's last update.">
time from last draft update
</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center">
{live && canSchedule && (
<Button
variant="danger"
size="sm"
disabled={busyId !== null}
onClick={() => { setNotice(null); setCancelling(row); }}
>
Cancel
</Button>
)}
{live && !canSchedule && (
<span className="text-2xs text-text-faint" title="Cancelling a schedule requires the publication.schedule permission.">
cancelling needs publication.schedule
</span>
)}
</div>
</li>
);
})}
</ul>
</section>
))}
{cancelling !== null && (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60"
onClick={() => { if (busyId === null) setCancelling(null); }}
>
<div
className="w-[480px] max-w-[90vw] rounded-lg border border-border bg-surface p-4 shadow-2xl"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold">Cancel this scheduled publication?</h3>
<p className="mt-1 text-2xs text-text-faint">
{utcTime(cancelling.publishAt)} · {cancelling.targetLabel}
</p>
<p className="mt-3 max-h-24 overflow-y-auto whitespace-pre-wrap break-words rounded-md border border-border-subtle bg-surface-2 p-2 text-xs leading-relaxed text-text-dim scrollbar-thin">
{cancelling.text}
</p>
<p className="mt-3 text-xs text-text-dim">
Nothing is published. The draft returns to editing so it can be rescheduled or changed, and the cancellation
is recorded in the audit log.
</p>
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" disabled={busyId !== null} onClick={() => setCancelling(null)}>Keep it</Button>
<Button variant="danger" disabled={busyId !== null} onClick={() => void cancel(cancelling)}>
{busyId === cancelling.id ? "Cancelling…" : "Cancel publication"}
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,324 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import CharacterCount from "@tiptap/extension-character-count";
import { Badge, Button, Input } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { MOCK_TARGETS, PLATFORMS } from "../../../lib/platforms";
import { api, errorMessage } from "../../../lib/api-client";
import type { ConnectionSummary, DraftPost, DraftRecord, DraftState, ScheduledPublicationRecord, Visibility } from "../../../lib/api-types";
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import { Sheet } from "../../../components/mobile/sheet";
/** A publish target: a real connection when live, a fixture platform when offline. */
interface Target {
key: string;
provider: string;
label: string;
}
function connectionTargets(connections: ConnectionSummary[]): Target[] {
return connections.map((c) => {
const handle = typeof c.fields.handle === "string" ? c.fields.handle : c.channelId;
return { key: c.id, provider: c.provider, label: `${PLATFORMS[c.provider]?.displayName ?? c.provider} · ${handle}` };
});
}
const SAMPLE_TARGETS: Target[] = MOCK_TARGETS.map((t) => ({ key: t, provider: t, label: PLATFORMS[t]?.displayName ?? t }));
const identity = <T,>(value: T): T => value;
const NO_DRAFTS: DraftRecord[] = [];
const STATE_TONE: Record<DraftState, "neutral" | "accent" | "live" | "warn" | "danger"> = {
editing: "neutral",
in_review: "warn",
approved: "accent",
rejected: "danger",
scheduled: "accent",
published: "live",
};
type Notice = { tone: "ok" | "error"; text: string };
export default function ComposePage(): React.ReactElement {
const router = useRouter();
const { can } = useAuth();
const targetsResource = useApiResource<ConnectionSummary[], Target[]>("/connections", connectionTargets, SAMPLE_TARGETS);
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, NO_DRAFTS, { limit: 10 });
const live = targetsResource.source === "live";
const available = targetsResource.data;
const [picked, setPicked] = useState<string[] | null>(null);
const [scheduledAt, setScheduledAt] = useState("");
const [visibility, setVisibility] = useState<Visibility>("public");
const [approval, setApproval] = useState("draft");
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice | null>(null);
const [scheduleOpen, setScheduleOpen] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [keyboardOffset, setKeyboardOffset] = useState(0);
const [media, setMedia] = useState<Array<{ name: string; url: string; alt: string }>>([]);
const mediaRef = useRef(media);
mediaRef.current = media;
// Default selection: the first available target, until the user picks.
const targets = (picked ?? available.slice(0, 1).map((t) => t.key)).filter((k) => available.some((t) => t.key === k));
const selected = available.filter((t) => targets.includes(t.key));
const limit = selected.length === 0 ? 500 : Math.min(...selected.map((t) => PLATFORMS[t.provider]?.charLimit ?? 500));
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: "What's shipping? (bold **so** / heading # supported)" }),
CharacterCount,
],
content: "",
immediatelyRender: false,
// Tiptap v3 no longer re-renders per keystroke by default; the char counter,
// previews and button enablement all read editor state during render.
shouldRerenderOnTransaction: true,
});
const chars = editor?.storage.characterCount.characters() ?? 0;
const over = chars > limit;
const mediaBlocked = media.length > 0;
useEffect(() => {
const viewport = window.visualViewport;
if (!viewport) return;
const update = (): void => setKeyboardOffset(Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop)));
update();
viewport.addEventListener("resize", update);
viewport.addEventListener("scroll", update);
return () => { viewport.removeEventListener("resize", update); viewport.removeEventListener("scroll", update); };
}, []);
useEffect(() => {
if (!editor) return;
const params = new URLSearchParams(window.location.search);
const text = params.get("text");
if (text) editor.commands.setContent(text);
const requested = params.get("targets")?.split(",").filter(Boolean);
if (requested?.length) setPicked(requested);
}, [editor]);
useEffect(() => () => { mediaRef.current.forEach((item) => URL.revokeObjectURL(item.url)); }, []);
function postFor(providerMetadata: Record<string, unknown>): DraftPost {
return {
text: editor?.getText() ?? "",
mediaAssetIds: [],
visibility,
providerMetadata,
...(scheduledAt !== "" ? { scheduledAt: new Date(scheduledAt).toISOString() } : {}),
};
}
async function run(action: () => Promise<string>): Promise<void> {
if (!live) {
setNotice({ tone: "error", text: "Offline — sample mode, nothing was sent." });
return;
}
setBusy(true);
setNotice(null);
try {
setNotice({ tone: "ok", text: await action() });
drafts.reload();
} catch (err) {
setNotice({ tone: "error", text: errorMessage(err) });
} finally {
setBusy(false);
}
}
const saveDraft = (): Promise<void> =>
run(async () => {
await api.post<DraftRecord>("/publishing/drafts", { post: postFor({ targetConnectionIds: targets }) });
return "Draft saved.";
});
/**
* A scheduled publication binds one draft to one connection (UNIQUE draft_id),
* so multi-target posts become one draft per target. Each runs the pipeline as
* far as the caller's permissions allow: create → submit → approve → schedule.
*/
const publish = (): Promise<void> =>
run(async () => {
const stopAtReview = approval === "review" || !can("approval.grant");
const canSchedule = can("publication.schedule");
const publishAt = scheduledAt !== "" ? new Date(scheduledAt).toISOString() : new Date().toISOString();
const outcomes: string[] = [];
for (const target of selected) {
const draft = await api.post<DraftRecord>("/publishing/drafts", { post: postFor({ connectionId: target.key }) });
await api.post<DraftRecord>(`/publishing/drafts/${draft.id}/submit`);
if (stopAtReview) {
outcomes.push(`${target.label}: submitted for review`);
continue;
}
await api.post<DraftRecord>(`/publishing/drafts/${draft.id}/approve`);
if (!canSchedule) {
outcomes.push(`${target.label}: approved (you lack publication.schedule)`);
continue;
}
await api.post<ScheduledPublicationRecord>(`/publishing/drafts/${draft.id}/schedule`, { connectionId: target.key, publishAt });
outcomes.push(`${target.label}: ${scheduledAt !== "" ? `scheduled for ${new Date(publishAt).toLocaleString()}` : "queued to publish now"}`);
}
return outcomes.join(" · ");
});
return (
<div className="flex min-h-full min-w-0 pb-40 lg:h-full lg:pb-0">
<div className="flex min-w-0 flex-1 flex-col p-4 lg:p-6">
<div className="mb-4 flex items-center gap-3">
<h1 className="text-lg font-semibold">Compose</h1>
<OfflineBadge source={targetsResource.source} />
</div>
<div className="mb-4 flex items-center gap-2 overflow-x-auto pb-2 lg:flex-wrap lg:overflow-visible">
{live && available.length === 0 && (
<span className="text-xs text-text-faint">No channels connected — add one under Channels to publish.</span>
)}
{available.map((t) => {
const active = targets.includes(t.key);
return (
<button
key={t.key}
onClick={() => setPicked(active ? targets.filter((x) => x !== t.key) : [...targets, t.key])}
className={`min-h-11 shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors duration-150 ${
active ? "border-accent bg-accent-dim text-accent" : "border-border text-text-dim hover:border-text-faint"
}`}
>
{t.label} · {PLATFORMS[t.provider]?.charLimit ?? 500}
</button>
);
})}
</div>
<div className="min-h-[220px] flex-1 overflow-y-auto rounded-lg border border-border-subtle bg-surface p-4 scrollbar-thin">
<EditorContent editor={editor} className="prose-invert max-w-none text-sm [&_.ProseMirror]:min-h-[180px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-text-faint [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none" />
</div>
<div className="mt-3 lg:hidden">
<div className="flex items-center gap-3"><div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-full border-4 text-xs font-bold ${over ? "border-danger text-danger" : chars >= limit * 0.9 ? "border-warn text-warn" : "border-accent text-accent"}`}>{Math.max(0, limit - chars)}</div><div className="min-w-0 flex-1 overflow-x-auto"><div className="flex gap-2">{selected.map((target) => { const max = PLATFORMS[target.provider]?.charLimit ?? 500; return <button key={target.key} type="button" onClick={() => setPreviewOpen(true)} className={`min-h-11 shrink-0 rounded-md border px-3 text-xs ${chars > max ? "border-danger text-danger" : "border-border text-text-dim"}`}>{PLATFORMS[target.provider]?.displayName ?? target.provider} {chars}/{max}</button>; })}</div></div></div>
{media.length > 0 && <div className="mt-2 flex gap-2 overflow-x-auto">{media.map((item) => <div key={item.url} className="w-28 shrink-0 rounded-md border border-border p-2"><div className="h-16 overflow-hidden rounded bg-surface-2"><img src={item.url} alt={item.alt || item.name} className="h-full w-full object-cover" /></div><input aria-label={`Alt text for ${item.name}`} value={item.alt} onChange={(event) => setMedia((current) => current.map((value) => value.url === item.url ? { ...value, alt: event.target.value } : value))} placeholder="ALT text" className="mt-1 min-h-11 w-full bg-transparent text-2xs outline-none" /><span className={`text-2xs ${item.alt ? "text-live" : "text-warn"}`}>{item.alt ? "ALT ready" : "Add ALT"}</span><button type="button" className="min-h-11 w-full text-2xs text-danger" onClick={() => { URL.revokeObjectURL(item.url); setMedia((current) => current.filter((value) => value.url !== item.url)); }}>Remove</button></div>)}</div>}
{mediaBlocked && <p className="mt-2 text-xs text-warn">Media upload is not available in the publishing API yet. Remove previews to save or post text.</p>}
<button type="button" onClick={() => setScheduleOpen(true)} className="mt-3 flex min-h-12 w-full items-center justify-between border-b border-border-subtle text-left text-sm"><span>Schedule</span><span className="text-xs text-text-faint">{scheduledAt ? new Date(scheduledAt).toLocaleString() : "Post now"} ›</span></button>
<label className="flex min-h-12 items-center justify-between border-b border-border-subtle text-sm"><span>Request approval</span><input type="checkbox" className="h-6 w-6 accent-accent" checked={approval === "review" || !can("approval.grant")} disabled={!can("approval.grant")} onChange={(event) => setApproval(event.target.checked ? "review" : "draft")} /></label>
{!can("approval.grant") && <p className="mt-1 text-2xs text-text-faint">Your role submits posts for editor approval.</p>}
</div>
<div className="mt-3 hidden flex-wrap items-center gap-3 lg:flex">
<span className={`font-mono text-xs ${over ? "text-danger" : "text-text-faint"}`}>
{chars} / {limit}
</span>
<span className="flex items-center gap-2 text-xs text-text-dim">
<label htmlFor="visibility" className="text-text-faint">Visibility</label>
<select
id="visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value as Visibility)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
>
<option value="public">public</option>
<option value="unlisted">unlisted</option>
<option value="followers">followers</option>
<option value="private">private</option>
<option value="provider_default">provider default</option>
</select>
</span>
<span className="flex items-center gap-2 text-xs text-text-dim">
<label htmlFor="approval" className="text-text-faint">Approval</label>
<select
id="approval"
value={approval}
onChange={(e) => setApproval(e.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
>
<option value="draft">draft</option>
<option value="review">submit for review</option>
{can("approval.grant") && <option value="approved">approved</option>}
</select>
</span>
<span className="flex items-center gap-2 text-xs text-text-dim">
<label htmlFor="schedule" className="text-text-faint">Schedule</label>
<Input
id="schedule"
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="w-52 py-1 text-xs"
/>
</span>
<div className="ml-auto flex gap-2">
<Button variant="outline" disabled={busy || chars === 0} onClick={() => void saveDraft()}>Save draft</Button>
<Button variant="primary" disabled={busy || targets.length === 0 || chars === 0 || over} onClick={() => void publish()}>
{scheduledAt !== "" ? "Schedule" : approval === "review" ? "Submit" : "Publish"}
</Button>
</div>
</div>
{notice !== null && (
<p className={`mt-2 text-xs ${notice.tone === "ok" ? "text-live" : "text-danger"}`}>{notice.text}</p>
)}
<div className="fixed left-0 right-0 z-30 border-t border-border bg-surface px-4 py-2 lg:hidden" style={{ bottom: `calc(var(--mobile-tab-height) + var(--mobile-safe-bottom) + ${keyboardOffset}px)` }}>
<p className="mb-2 truncate text-2xs text-text-faint">{approval === "review" || !can("approval.grant") ? "→ Editors approve" : "→ Ready to publish"} · {scheduledAt ? `publishes ${new Date(scheduledAt).toLocaleString()}` : "post now"} · {selected.length} accounts</p>
<div className="flex gap-2"><Button variant="ghost" className="min-h-11" onClick={() => router.back()}>Cancel</Button><Button variant="outline" className="min-h-11 flex-1" disabled={busy || chars === 0 || mediaBlocked} onClick={() => void saveDraft()}>Draft</Button><Button variant="primary" className="min-h-11 flex-1" disabled={busy || selected.length === 0 || chars === 0 || over || mediaBlocked} onClick={() => void publish()}>{scheduledAt ? "Schedule" : approval === "review" || !can("approval.grant") ? "Submit" : "Post"}</Button></div>
</div>
<Sheet open={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule post" footer={<Button variant="primary" className="min-h-11 w-full justify-center" onClick={() => setScheduleOpen(false)}>Done</Button>}><label className="block text-xs text-text-faint" htmlFor="mobile-schedule">Date and time</label><Input id="mobile-schedule" type="datetime-local" value={scheduledAt} onChange={(event) => setScheduledAt(event.target.value)} className="mt-2 min-h-12" /><div className="mt-4 flex gap-2">{[1, 24, 48].map((hours) => <button key={hours} type="button" className="min-h-11 rounded-md border border-border px-3 text-xs" onClick={() => { const date = new Date(Date.now() + hours * 3600000); setScheduledAt(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`); }}>{hours === 1 ? "In 1 hour" : hours === 24 ? "Tomorrow" : "In 2 days"}</button>)}</div><button type="button" onClick={() => setScheduledAt("")} className="mt-4 min-h-11 text-xs text-text-faint">Clear schedule</button></Sheet>
<Sheet open={previewOpen} onClose={() => setPreviewOpen(false)} title="Target previews">{selected.map((target) => <div key={target.key} className="mb-3 rounded-md border border-border p-3"><p className="text-xs text-text-faint">{target.label}</p><p className="mt-2 whitespace-pre-wrap text-sm">{editor?.getText() || "Your post preview appears here…"}</p></div>)}</Sheet>
{/* Anchor target for the mobile "Resume drafts" shortcut — rendered at every breakpoint. */}
{drafts.source === "live" && drafts.data.length > 0 && <section id="drafts" className="mt-6 scroll-mt-4">
<h2 className="mb-3 text-sm font-semibold">Recent drafts</h2>
{drafts.data.map((draft) => <button key={draft.id} onClick={() => editor?.commands.setContent(draft.post.text)} className="mb-2 block min-h-14 w-full rounded-md border border-border-subtle p-3 text-left text-xs">
<span className="mb-1 block text-text-faint">{draft.state.replace("_", " ")}</span>
<span className="line-clamp-2 text-text-dim">{draft.post.text}</span>
</button>)}
</section>}
</div>
<aside className="hidden w-80 shrink-0 overflow-y-auto border-l border-border-subtle p-6 scrollbar-thin lg:block">
<h2 className="mb-4 text-sm font-semibold">Previews</h2>
{targets.length === 0 && <p className="text-xs text-text-faint">Select target platforms to preview.</p>}
{selected.map((t) => {
const charLimit = PLATFORMS[t.provider]?.charLimit ?? 500;
return (
<div key={t.key} className="mb-4 rounded-lg border border-border-subtle bg-surface p-3">
<div className="mb-2 flex items-center justify-between">
<span className="truncate text-2xs font-medium text-text-faint">{t.label}</span>
<Badge tone={chars > charLimit ? "danger" : "neutral"}>
{chars}/{charLimit}
</Badge>
</div>
<p className="line-clamp-6 text-xs leading-relaxed text-text-dim">
{editor?.getText() || "Your post preview appears here…"}
</p>
</div>
);
})}
{drafts.source === "live" && (
<>
<h2 className="mb-3 mt-6 text-sm font-semibold">Recent drafts</h2>
{drafts.error !== null && <p className="text-xs text-danger">{drafts.error}</p>}
{drafts.data.length === 0 && <p className="text-xs text-text-faint">No drafts yet.</p>}
{drafts.data.map((d) => (
<button
key={d.id}
onClick={() => editor?.commands.setContent(d.post.text)}
title="Load this text into the editor"
className="mb-2 block w-full rounded-md border border-border-subtle p-2 text-left transition-colors duration-150 hover:border-accent"
>
<div className="mb-1 flex items-center justify-between">
<Badge tone={STATE_TONE[d.state]}>{d.state.replace("_", " ")}</Badge>
<span className="text-2xs text-text-faint">{new Date(d.updatedAt).toLocaleString()}</span>
</div>
<p className="line-clamp-2 text-2xs text-text-dim">{d.post.text}</p>
</button>
))}
</>
)}
</aside>
</div>
);
}

View file

@ -0,0 +1,236 @@
"use client";
import { useEffect, useState } from "react";
import { capacitySnapshot, humanizeMs } from "@tactical-media-broadcast-hub/connector-sdk/rate-policy";
import { Badge, Button, Card } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { CONNECTED_ACCOUNTS, CONNECTION_CAPACITIES, DOCUMENTED_RATES, PLATFORMS, type ConnectionCapacity } from "../../../lib/platforms";
import { CapacityChip } from "../../../components/connections/capacity-chip";
import { EmptyState } from "../../../components/brand/empty-state";
import { Loading } from "../../../components/brand/loading";
import type { ConnectionSummary } from "../../../lib/api-types";
import { useApiResource } from "../../../lib/use-api";
import { api, errorMessage } from "../../../lib/api-client";
import { useAuth } from "../../../lib/auth-store";
import { reissueSession } from "../../../lib/api-client";
import { setSessionToken } from "../../../lib/session-token";
import { linkBotToken, type ConnectProvider } from "../../../lib/connections-client";
const NO_PROVIDERS: ConnectProvider[] = [];
const providersFromApi = (providers: ConnectProvider[]): ConnectProvider[] => providers;
interface AccountView {
key: string;
provider: string;
handle: string;
health: "healthy" | "expiring" | "needs_credentials" | "unavailable";
capabilities?: string[];
instanceUrl?: string;
capacity: ConnectionCapacity;
}
function handleOf(c: ConnectionSummary): string {
const fromFields = c.fields.handle ?? c.fields.displayName;
return typeof fromFields === "string" && fromFields !== "" ? fromFields : c.channelId;
}
function fromApi(connections: ConnectionSummary[]): AccountView[] {
return connections.map((c) => {
const handle = handleOf(c);
return {
key: c.id,
provider: c.provider,
handle,
health: c.health,
capabilities: c.capabilities,
instanceUrl: typeof c.fields.instanceUrl === "string" ? c.fields.instanceUrl : undefined,
capacity: {
handle,
platform: c.provider,
resolvedCap: DOCUMENTED_RATES[c.provider]?.dailyPostCap,
used: c.recentPublishes.length,
recentPublishes: c.recentPublishes,
},
};
});
}
const SAMPLE: AccountView[] = CONNECTED_ACCOUNTS.map((a) => ({
key: a.handle,
provider: a.platform,
handle: a.handle,
health: a.status,
capacity: CONNECTION_CAPACITIES.find((c) => c.handle === a.handle) ?? { handle: a.handle, platform: a.platform, used: 0, recentPublishes: [] },
}));
function HealthBadge({ health }: { health: AccountView["health"] }): React.ReactElement {
if (health === "unavailable") return <Badge tone="warn">provider unavailable</Badge>;
if (health === "expiring") return <Badge tone="warn">token expiring</Badge>;
if (health === "needs_credentials") return <Badge tone="warn">needs credentials</Badge>;
return <Badge tone="live">healthy</Badge>;
}
export default function ConnectionsPage(): React.ReactElement {
const { data: accounts, source, error, reload } = useApiResource<ConnectionSummary[], AccountView[]>("/connections", fromApi, SAMPLE);
const live = source === "live";
const { can } = useAuth();
const { data: providers } = useApiResource<ConnectProvider[], ConnectProvider[]>("/connections/providers", providersFromApi, NO_PROVIDERS);
const [selected, setSelected] = useState<ConnectProvider | null>(null);
const [reconnectId, setReconnectId] = useState<string | undefined>();
const [instanceUrl, setInstanceUrl] = useState("");
const [token, setToken] = useState("");
const [target, setTarget] = useState("");
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const manageable = live && can("connection.manage") && !busy;
useEffect(() => {
const url = new URL(window.location.href);
const result = url.searchParams.get("connect");
if (result === null) return;
if (result === "success") void reissueSession().then(() => { reload(); }).catch(() => setNotice("Sign in again to refresh account access."));
setNotice(result === "success" ? "Account connected successfully." : "Account linking failed or expired. Please try again.");
url.searchParams.delete("connect");
window.history.replaceState(null, "", url);
}, []);
// Onboarding (and any deep link) opens the connect dialog via /connections?start=<providerId>.
useEffect(() => {
if (source !== "live") return;
const url = new URL(window.location.href);
const startId = url.searchParams.get("start");
if (startId === null) return;
const provider = providers.find((item) => item.id === startId);
url.searchParams.delete("start");
window.history.replaceState(null, "", url);
if (provider !== undefined) { setSelected(provider); setNotice(null); }
}, [source, providers]);
function openConnect(provider: ConnectProvider, account?: AccountView): void {
setSelected(provider); setReconnectId(account?.key); setInstanceUrl(account?.instanceUrl ?? ""); setToken(""); setTarget(""); setNotice(null);
}
async function submitConnect(event: React.FormEvent): Promise<void> {
event.preventDefault();
if (!selected || !manageable) return;
setBusy(true);
try {
if (selected.kind === "token") {
await linkBotToken(token, target, reconnectId);
setToken(""); setSelected(null); reload(); setNotice("Bot connected successfully.");
} else {
const result = reconnectId
? await api.post<{ redirectUrl: string }>(`/connections/${reconnectId}/reconnect`)
: await api.post<{ redirectUrl: string }>("/connections/authorize", { provider: selected.id, instanceUrl });
window.location.assign(result.redirectUrl);
}
} catch (err) { setNotice(errorMessage(err)); }
finally { setBusy(false); }
}
function reconnect(account: AccountView): void {
const provider = providers.find((item) => item.id === account.provider);
if (provider) openConnect(provider, account);
}
async function remove(account: AccountView): Promise<void> {
if (!window.confirm(`Remove ${account.handle}? This removes local schedules and publication records for this account. Published posts stay on the provider.`)) return;
setBusy(true);
try { const result = await api.del<{ removed: true; token: string }>(`/connections/${account.key}`); setSessionToken(result.token); reload(); setNotice("Account removed."); }
catch (err) { setNotice(errorMessage(err)); }
finally { setBusy(false); }
}
const [filter, setFilter] = useState<"all" | "attention" | "capacity">("all");
const atCapacity = (account: AccountView): boolean => account.capacity.resolvedCap !== undefined && account.capacity.used >= account.capacity.resolvedCap;
const urgent = [...accounts].sort((a, b) => Number(b.health !== "healthy") - Number(a.health !== "healthy") || Number(atCapacity(b)) - Number(atCapacity(a)));
const filtered = urgent.filter((account) => filter === "all" || (filter === "attention" && account.health !== "healthy") || (filter === "capacity" && atCapacity(account)));
return (
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">Channels</h1>
<OfflineBadge source={source} />
</div>
<Button variant="primary" size="sm" disabled={!manageable} onClick={() => { const provider = providers.find((item) => item.enabled); if (provider) openConnect(provider); }}>Connect account</Button>
</div>
{notice && <div role="status" className="mb-4 rounded-lg border border-accent p-3 text-sm">{notice}<button type="button" className="ml-4 text-xs underline" onClick={() => setNotice(null)}>Dismiss</button></div>}
{selected && <form onSubmit={(event) => void submitConnect(event)} className="mb-6 space-y-3 rounded-lg border border-accent bg-surface p-4">
<h2 className="font-semibold">{reconnectId ? "Reconnect" : "Connect"} {selected.displayName}</h2>
{!reconnectId && <label className="block text-sm">Provider<select className="ml-3 rounded border border-border bg-bg p-2" value={selected.id} disabled={busy} onChange={(event) => { const provider = providers.find((item) => item.id === event.target.value); if (provider) openConnect(provider); }}>{providers.map((provider) => <option key={provider.id} value={provider.id} disabled={!provider.enabled}>{provider.displayName}{!provider.enabled ? " (unavailable)" : ""}</option>)}</select></label>}
{selected.instanceRequired && <label className="block text-sm">Instance URL<input className="mt-1 block w-full rounded border border-border bg-bg p-2" value={instanceUrl} onChange={(event) => setInstanceUrl(event.target.value)} placeholder="https://your-instance.example" required disabled={Boolean(reconnectId) || busy} /></label>}
{selected.kind === "token" && <><label className="block text-sm">Bot token<input className="mt-1 block w-full rounded border border-border bg-bg p-2" type="password" autoComplete="off" value={token} onChange={(event) => setToken(event.target.value)} required disabled={busy} /></label><label className="block text-sm">Chat or channel ID (optional)<input className="mt-1 block w-full rounded border border-border bg-bg p-2" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="@channel or numeric chat ID" disabled={busy} /></label></>}
{selected.note && <p className="text-xs text-text-faint">{selected.note}</p>}
<div className="flex gap-2"><Button type="submit" variant="primary" disabled={!manageable || !selected.enabled}>{busy ? "Connecting…" : selected.kind === "token" ? "Validate and connect" : "Continue to provider"}</Button><Button type="button" disabled={busy} onClick={() => { setSelected(null); setToken(""); }}>Cancel</Button></div>
</form>}
<div className="mb-4 grid grid-cols-3 gap-2 lg:hidden">{([
{ id: "all", label: "Connected", count: accounts.length },
{ id: "attention", label: "Need attention", count: accounts.filter((account) => account.health !== "healthy").length },
{ id: "capacity", label: "At capacity", count: accounts.filter(atCapacity).length },
] as const).map((tile) => <button key={tile.id} type="button" onClick={() => setFilter(tile.id)} className={`min-h-20 min-w-0 rounded-lg border p-2 text-left ${filter === tile.id ? "border-accent bg-accent-dim" : "border-border bg-surface"}`}><span className="block text-xl font-bold">{tile.count}</span><span className="text-2xs text-text-dim">{tile.label}</span></button>)}</div>
<div className="space-y-2 lg:hidden">{filtered.map((account) => {
const platform = PLATFORMS[account.provider];
const cap = account.capacity.resolvedCap;
const snapshot = capacitySnapshot({ used: account.capacity.used, cap, recentPublishes: account.capacity.recentPublishes }, { requestsPerSecond: DOCUMENTED_RATES[account.provider]?.requestsPerSecond ?? 1, dailyPostCap: cap });
return <article key={account.key} className="rounded-lg border border-border bg-surface p-4"><div className="flex items-start gap-3"><span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border-2 border-accent bg-black text-lg font-bold text-accent">{(platform?.displayName ?? account.provider).slice(0, 1)}</span><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><h2 className="truncate text-sm font-semibold">{platform?.displayName ?? account.provider}</h2><span className={`h-2 w-2 shrink-0 rounded-full ${account.health === "healthy" ? "bg-live" : "bg-warn"}`} aria-label={account.health} /></div><p className="truncate text-xs text-text-faint">{account.handle}</p></div>{account.health !== "healthy" && <button type="button" disabled={!manageable || !providers.find((item) => item.id === account.provider)?.enabled} onClick={() => reconnect(account)} className="min-h-11 rounded-md border border-warn px-3 text-xs text-warn">Fix</button>}</div>
<div className="mt-4 flex items-center justify-between text-xs"><span className="text-text-faint">{cap === undefined ? `${account.capacity.used} posts · no documented cap` : `${account.capacity.used}/${cap} posts in 24h`}</span>{snapshot.remaining === 0 && snapshot.nextSlotInMs !== undefined && <span className="text-accent">frees in {humanizeMs(snapshot.nextSlotInMs)}</span>}</div>
<div className="mt-3 flex gap-2"><Button size="sm" disabled={!manageable || !providers.find((item) => item.id === account.provider)?.enabled} onClick={() => reconnect(account)}>Reconnect</Button><Button size="sm" variant="danger" disabled={!manageable} onClick={() => void remove(account)}>Remove</Button></div>
<div className={`mt-2 h-2 overflow-hidden rounded-full ${cap === undefined ? "border border-dashed border-border" : "bg-surface-2"}`}>{cap !== undefined && <div className={`h-full ${snapshot.remaining === 0 ? "bg-accent" : "bg-live"}`} style={{ width: `${Math.min(100, Math.round((account.capacity.used / Math.max(1, cap)) * 100))}%` }} />}</div>
</article>;
})}{filtered.length === 0 && <p className="py-8 text-center text-xs text-text-faint">No accounts in this filter.</p>}</div>
{error !== null && <p className="mb-4 text-xs text-danger">{error}</p>}
{source === "loading" && (
<div className="mb-4 flex items-center gap-2 text-xs text-text-faint">
<Loading size={16} label="Loading channels" /> Loading channels…
</div>
)}
<div className="hidden grid-cols-1 gap-4 md:grid-cols-2 lg:grid">
{live && accounts.length === 0 && (
<div className="md:col-span-2">
<EmptyState
title="No channels connected yet"
hint="Connect an account below to start publishing. Account passwords never touch this system."
/>
</div>
)}
{source !== "loading" && accounts.map((account) => {
const platform = PLATFORMS[account.provider];
const caps = account.capabilities ?? [];
return (
<Card key={account.key} className="p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="text-base font-semibold">{platform?.displayName ?? account.provider}</span>
<HealthBadge health={account.health} />
</div>
<div className="mt-0.5 text-xs text-text-faint">{account.handle}</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<CapacityChip cap={account.capacity} />
<Button size="sm" variant="outline" disabled={!manageable || !providers.find((item) => item.id === account.provider)?.enabled} onClick={() => reconnect(account)}>Reconnect</Button>
<Button size="sm" variant="danger" disabled={!manageable} onClick={() => void remove(account)}>Remove</Button>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{caps.map((cap) => (
<Badge key={cap} tone="accent">{cap}</Badge>
))}
</div>
</Card>
);
})}
</div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
{providers.map((provider) => <Card key={provider.id} className="border-dashed p-4"><div className="flex items-center justify-between gap-3"><span className="font-semibold">{provider.displayName}</span><Button size="sm" variant="outline" disabled={!manageable || !provider.enabled} onClick={() => openConnect(provider)}>Connect</Button></div>{provider.note && <p className="mt-2 text-xs text-text-faint">{provider.note}</p>}</Card>)}
</div>
<p className="mt-6 text-2xs leading-relaxed text-text-faint">
Connections store OAuth tokens or bot tokens — passwords never transit this system. Tokens live in the
workspace vault (envelope-encrypted); rotate or revoke any time.
</p>
</div>
);
}

View file

@ -0,0 +1,16 @@
.control-room .cr-field {
display: block;
width: 100%;
max-width: 100%;
min-width: 44px;
min-height: 44px;
border: 1px solid var(--color-border, #444);
border-radius: 6px;
background: var(--color-surface-2, #222);
padding: 8px 10px;
color: inherit;
font-size: 14px;
}
.control-room label { max-width: 100%; }
.control-room button { min-width: 44px; }
.control-room :focus-visible { outline: 2px solid #ff1610; outline-offset: 3px; }

View file

@ -0,0 +1,111 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "../../../lib/auth-store";
import { API_URL, ApiError, ApiUnreachableError } from "../../../lib/api-client";
import { getSessionToken } from "../../../lib/session-token";
import { Badge, Button } from "../../../components/ui/primitives";
import { AudioMonitor } from "../../../components/control-room/audio-monitor";
import { MultiviewGrid, liveTapIds, type GridTier } from "../../../components/control-room/multiview-grid";
import { OverlayEditor, type RenderInput } from "../../../components/control-room/overlay-editor";
import { PstPreview } from "../../../components/control-room/pst-preview";
import { SwitcherBar, type DirectorAction } from "../../../components/control-room/switcher-bar";
import { controlRequest } from "../../../components/control-room/client";
import { fixtureChannel } from "../../../components/control-room/fixtures";
import { RealtimeClient } from "../../../components/control-room/state-store";
import { initialTally, refetchTally, tallyReducer, type TallyState } from "../../../components/control-room/tally";
import type { Receipt, Status } from "../../../components/control-room/types";
import "./control-room.css";
export default function ControlRoom(): React.ReactElement {
const { session, can, status: authStatus } = useAuth();
const [channels, setChannels] = useState<Status["channels"]>([]);
const [selected, setSelected] = useState("");
const [state, setState] = useState<TallyState | null>(null);
const [fixture, setFixture] = useState(false);
const [connection, setConnection] = useState("loading");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [mobile, setMobile] = useState(true);
const [tier, setTier] = useState<GridTier>("2x2");
const [focused, setFocused] = useState<string | null>(null);
const [upgrades, setUpgrades] = useState<string[]>([]);
const [reload, setReload] = useState(0);
useEffect(() => {
const query = matchMedia("(max-width: 1023px)"); const update = (): void => setMobile(query.matches);
update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update);
}, []);
useEffect(() => {
if (authStatus === "loading") return;
let cancelled = false;
const abort = new AbortController();
setConnection("loading"); setState(null); setFixture(false); setMessage("");
void controlRequest<Status>("/media-control/status", undefined, abort.signal).then(result => {
if (cancelled) return;
setChannels(result.channels); setSelected(result.channels[0]?.channelId ?? ""); setConnection(result.channels.length ? "connecting" : "empty");
}).catch(error => {
if (cancelled) return;
if (error instanceof ApiUnreachableError || error instanceof ApiError && error.status >= 500) {
const sample = fixtureChannel(); setFixture(true); setChannels([sample]); setSelected(sample.channelId); setConnection("fixture");
} else { setChannels([]); setSelected(""); setConnection("error"); setMessage(error instanceof Error ? error.message : "Status unavailable"); }
});
return () => { cancelled = true; abort.abort(); };
}, [authStatus, session?.workspace.id, reload]);
useEffect(() => {
const channel = channels.find(item => item.channelId === selected);
setFocused(null); setUpgrades([]);
if (!channel) { setState(null); return; }
setState(initialTally(channel));
if (fixture || !session) return;
const url = new URL(`${API_URL}/realtime`); url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
const client = new RealtimeClient({ url: url.href, token: getSessionToken, workspaceId: session.workspace.id, channelId: selected,
socket: address => new WebSocket(address), status: () => controlRequest<Status>("/media-control/status"),
onFrame: frame => setState(previous => previous ? tallyReducer(previous, frame) : previous),
onStatus: result => { const current = result.channels.find(item => item.channelId === selected); setState(previous => current ? refetchTally(current, previous) : null); },
onConnection: setConnection });
client.start(); return () => client.stop();
}, [channels, selected, fixture, session]);
const production = state?.channel.desired?.production;
const liveIds = useMemo(() => liveTapIds(production?.sources ?? [], tier, focused, upgrades, mobile), [production, tier, focused, upgrades, mobile]);
const action = async (operation: DirectorAction, body: object): Promise<void> => {
if (!state?.channel.desired) return;
setBusy(true); setMessage("");
try {
if (fixture) {
const desired = structuredClone(state.channel.desired);
if (desired.production) {
if (operation === "pst-select" && "sceneId" in body) desired.production.buses.preview = String(body.sceneId);
if (operation === "cut" || operation === "take") desired.production.buses.program = desired.production.buses.preview;
if (operation === "lock" && "enabled" in body && desired.director) desired.director.locked = Boolean(body.enabled);
if (operation === "override" && "mode" in body && desired.director) desired.director.override = body.mode === null ? null : { mode: body.mode as NonNullable<typeof desired.director.override>["mode"], expiresAt: null };
}
desired.desiredVersion += 1;
setState(previous => previous ? tallyReducer(previous, { type: "state", workspaceId: "fixture", channelId: selected, kind: "desired", seq: previous.lastSeq + 1, data: desired }) : previous);
setMessage("Fixture request recorded. Tally awaits a simulated observed frame.");
} else {
const receipt = await controlRequest<Receipt>(`/media-control/channels/${selected}/${operation}`, body);
setMessage(`Request accepted · version ${receipt.desiredVersion}. Waiting for observed state.`);
}
} catch (error) { setMessage(error instanceof Error ? error.message : "Command failed"); }
finally { setBusy(false); }
};
const render = async (id: string, input: RenderInput): Promise<void> => {
if (fixture) throw new Error("Fixture mode has no render service. Reconnect to render an overlay.");
await controlRequest<Receipt>(`/production/overlays/${id}`, input);
};
const simulate = (): void => setState(previous => previous ? tallyReducer(previous, { type: "state", workspaceId: "fixture", channelId: selected, kind: "observed", seq: previous.lastSeq + 1, data: { workspaceId: "fixture", channelId: selected, workerId: previous.channel.workerId, assignmentEpoch: previous.channel.assignmentEpoch, realizedVersion: previous.channel.desired?.desiredVersion ?? null, state: "RUNNING", reason: "Fixture simulation", updatedAt: new Date().toISOString() } }) : previous);
const offline = connection !== "live" && connection !== "polling" && !fixture;
return <div className="control-room h-full min-w-0 overflow-y-auto p-3 sm:p-5">
<header className="mb-4 flex flex-wrap items-center justify-between gap-3"><div><h1 className="text-xl font-semibold">Control Room</h1><p className="mt-1 text-xs text-text-faint">Preview taps · server composition · observed tally</p></div><div className="flex flex-wrap items-center gap-2"><Badge tone={fixture ? "warn" : connection === "live" ? "live" : "neutral"}>{connection === "fixture" ? "FIXTURE · sample data" : connection}</Badge><Button className="min-h-11" variant="outline" onClick={() => setReload(value => value + 1)}>Reconnect</Button></div></header>
{fixture && <div className="mb-4 rounded-lg border border-warn/40 bg-warn/10 p-3 text-sm"><p>API unavailable. This is a fixture rehearsal with sample scenes, no broadcast, and no actual program state. Actions stay local.</p><Button className="mt-2 min-h-11" variant="outline" onClick={simulate}>Simulate observed frame</Button></div>}
{message && <p role="status" className="mb-4 break-words rounded border border-border p-3 text-sm">{message}</p>}
{channels.length > 0 && <label className="mb-4 block max-w-lg text-xs">Production channel<select className="cr-field mt-1" value={selected} onChange={event => setSelected(event.target.value)}>{channels.map(channel => <option key={channel.channelId} value={channel.channelId}>{fixture ? "Sample channel" : channel.channelId}</option>)}</select></label>}
{!production || production.scenes.length === 0 ? <section className="rounded-lg border border-border-subtle bg-surface p-6"><h2 className="text-lg font-semibold">{connection === "loading" ? "Loading production status…" : connection === "error" ? "Production status unavailable" : "No production scenes configured"}</h2><p className="mt-3 max-w-xl text-sm text-text-dim">{connection === "error" ? "Reconnect or ask a workspace administrator to check your media.view permission. Production state cannot be determined from this response." : "Configure a virtual channel, assign a media worker, add ingest sources and production scenes, then select the preview bus. Program tally appears only after the worker confirms a realized version."}</p></section> : <>
<div className="mb-4 flex flex-wrap gap-3 text-xs text-text-dim"><span>Desired v{state?.channel.desired?.desiredVersion}</span><span>Observed {state?.channel.observed?.realizedVersion === null || !state?.channel.observed ? "unconfirmed" : `v${state.channel.observed.realizedVersion} · ${state.channel.observed.state}`}</span><span>{state?.observedProduction ? "Tally confirmed" : "Tally unknown · awaiting matching observed version"}</span></div>
<SwitcherBar desired={state?.channel.desired ?? { desiredVersion: 0, desiredStatus: "STOPPED" }} disabled={offline || !fixture && !can("director.take")} busy={busy} onAction={(operation, body) => { if (!fixture && !can(operation === "lock" ? "director.lock_program" : operation === "override" ? "director.override_auto" : "director.take")) { setMessage("Your role cannot perform this action."); return; } void action(operation, body); }} />
<div className="mt-4 grid min-w-0 gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(260px,1fr)]">
<MultiviewGrid production={production} observed={state?.observedProduction ?? null} channelId={selected} fixture={fixture} mobile={mobile} liveIds={liveIds} focused={focused} onFocus={setFocused} tier={tier} onTier={value => { setTier(value); setUpgrades([]); }} upgrades={upgrades} onUpgrade={id => setUpgrades(previous => previous.includes(id) ? previous.filter(item => item !== id) : liveIds.length < 4 ? [...previous, id] : previous)} />
<div className="min-w-0 space-y-4"><PstPreview production={production} channelId={selected} fixture={fixture} /><AudioMonitor production={production} observed={state?.observedProduction ?? null} liveIds={liveIds} focused={focused} channelId={selected} fixture={fixture} mobile={mobile} /><OverlayEditor overlays={production.overlays} disabled={fixture || offline || !can("director.change_layout")} onRender={render} /></div>
</div>
</>}
</div>;
}

View file

@ -0,0 +1,18 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { CONVERSATIONS, THREADS, ageLabel } from "../../../../lib/fixtures";
import { Avatar } from "../../../../components/ui/primitives";
export function ConversationView({ conversationId }: { conversationId: string }): React.ReactElement {
const [reply, setReply] = useState("");
const id = Number(conversationId);
const conversation = CONVERSATIONS.find((item) => item.id === id);
const thread = THREADS[id] ?? [];
return <div className="flex min-h-full flex-col">
<header className="flex min-h-16 items-center gap-3 border-b border-border-subtle px-4"><Link href="/inbox" aria-label="Back to Inbox" className="flex h-11 w-11 items-center justify-center text-xl">←</Link>{conversation && <Avatar handle={conversation.handle} />}<div className="min-w-0"><h1 className="truncate text-sm font-semibold">{conversation?.participant ?? "Conversation unavailable"}</h1><p className="text-xs text-text-faint">{conversation?.handle}</p></div></header>
<div className="min-h-[40vh] flex-1 space-y-3 p-4">{thread.map((message) => <div key={message.id} className={`flex ${message.mine ? "justify-end" : "justify-start"}`}><div className={`max-w-[85%] rounded-lg p-3 text-sm ${message.mine ? "bg-accent-dim" : "border border-border-subtle bg-surface"}`}><span className="mb-1 block text-2xs text-text-faint">{message.from} · {ageLabel(message.minutes)} ago</span>{message.text}</div></div>)}</div>
<footer className="sticky bottom-0 border-t border-border-subtle bg-bg p-4"><div className="flex gap-2"><input value={reply} onChange={(event) => setReply(event.target.value)} placeholder={`Reply to ${conversation?.handle ?? "conversation"}`} className="min-h-11 min-w-0 flex-1 rounded-md border border-border bg-surface-2 px-3 text-sm" /><button type="button" disabled className="min-h-11 rounded-md bg-accent px-4 text-xs text-white opacity-40">Send</button></div><p className="mt-2 text-2xs text-text-faint">Sample thread · messaging API is not connected yet.</p></footer>
</div>;
}

View file

@ -0,0 +1,5 @@
import { ConversationView } from "./conversation-view";
export default function ConversationPage({ params }: { params: { conversationId: string } }): React.ReactElement {
return <ConversationView conversationId={params.conversationId} />;
}

View file

@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Avatar } from "../../../components/ui/primitives";
import { CONVERSATIONS, THREADS, ageLabel, type Conversation, type FeedItem } from "../../../lib/fixtures";
import { getPlatform, MOCK_COLUMN_DEFS, PLATFORMS } from "../../../lib/platforms";
import { Segmented } from "../../../components/mobile/segmented";
import { SwipeItem } from "../../../components/mobile/swipe-item";
import { ApprovalsSegment } from "../../../components/mobile/approvals-segment";
import { AmplifySheet } from "../../../components/mobile/amplify-sheet";
import { useApiResource } from "../../../lib/use-api";
import type { DraftRecord } from "../../../lib/api-types";
type Segment = "all" | "mentions" | "approvals" | "dms";
type Kind = "spike" | "mention" | "approval" | "reply" | "dm" | "health";
interface NoticeRow { id: string; kind: Kind; text: string; conversation?: Conversation; unread: boolean; }
const extra: NoticeRow[] = [
{ id: "spike", kind: "spike", text: "Sample spike alert · a post is getting attention", unread: true },
{ id: "approval-sample", kind: "approval", text: "Sample draft waiting for review", unread: true },
{ id: "health", kind: "health", text: "Facebook connection needs attention", unread: true },
];
const initial: NoticeRow[] = [...extra, ...CONVERSATIONS.map((conversation, i) => ({ id: `conversation-${conversation.id}`, kind: (i === 0 ? "mention" : i === 1 ? "dm" : "reply") as Kind, text: conversation.preview, conversation, unread: conversation.unread > 0 }))];
const identity = (value: DraftRecord[]): DraftRecord[] => value;
const empty: DraftRecord[] = [];
const spikePost: FeedItem = { id: -1, platform: MOCK_COLUMN_DEFS[0]?.platformId ?? "unknown", author: "Sample author", handle: "@sample", text: "Sample spike alert post", ageMinutes: 4, likes: 120, reposts: 30, replies: 12, hasMedia: false };
export default function InboxPage({ defaultSegment = "all" }: { defaultSegment?: "all" | "approvals" }): React.ReactElement {
const router = useRouter();
const [segment, setSegment] = useState<Segment>(defaultSegment);
const [rows, setRows] = useState(initial);
const [backup, setBackup] = useState<NoticeRow[] | null>(null);
const [activeId, setActiveId] = useState(CONVERSATIONS[0]?.id ?? 1);
const [amplify, setAmplify] = useState(false);
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, empty, { state: "in_review", limit: 100 });
const approvals = drafts.data.length;
// Fixture rows are only shown while the API is unreachable (sample mode).
const sample = drafts.source === "sample";
useEffect(() => { const requested = new URLSearchParams(window.location.search).get("segment"); if (requested === "approvals" || requested === "mentions" || requested === "dms") setSegment(requested); }, []);
function change(value: Segment): void { setSegment(value); window.history.replaceState(window.history.state, "", value === "all" ? "/inbox" : `/inbox?segment=${value}`); }
function changeRows(update: (current: NoticeRow[]) => NoticeRow[]): void { setBackup(rows); setRows(update); }
const visible = sample ? rows.filter((row) => segment === "all" || (segment === "mentions" && row.kind === "mention") || (segment === "dms" && row.kind === "dm")) : [];
const selected = CONVERSATIONS.find((conversation) => conversation.id === activeId) ?? CONVERSATIONS[0];
return <div className="flex h-full min-w-0 flex-col lg:flex-row">
<div className="min-w-0 flex-1 lg:hidden">
<div className="flex items-center justify-between px-4 py-2"><h1 className="text-lg font-semibold">Inbox</h1>{sample && <button type="button" className="min-h-11 rounded-md px-3 text-xs text-accent" onClick={() => changeRows((current) => current.map((row) => ({ ...row, unread: false })))}>✓ Mark read</button>}</div>
{backup && <div role="status" className="flex min-h-11 items-center justify-between bg-surface-2 px-4 text-xs">Inbox updated<button type="button" className="min-h-11 min-w-11 text-accent" onClick={() => { setRows(backup); setBackup(null); }}>Undo</button></div>}
<div className="border-b border-border-subtle px-2 pb-2"><Segmented options={[{ value: "all", label: "All", count: visible.filter((row) => row.unread).length + approvals }, { value: "mentions", label: "Mentions", count: visible.filter((row) => row.unread && row.kind === "mention").length }, { value: "approvals", label: "Approvals", count: approvals }, { value: "dms", label: "DMs", count: visible.filter((row) => row.unread && row.kind === "dm").length }]} value={segment} onChange={change} /></div>
{segment === "approvals" ? <ApprovalsSegment /> : <div>{visible.map((row) => <SwipeItem key={row.id} leftActions={[{ label: "Dismiss", tone: "accent", onClick: () => changeRows((current) => current.filter((item) => item.id !== row.id)) }]} rightActions={row.kind === "mention" || row.kind === "dm" ? [{ label: "Reply", onClick: () => { if (row.conversation) router.push(`/inbox/${row.conversation.id}`); } }] : []}>
<div className={`flex min-h-20 items-start gap-3 border-b border-border-subtle px-4 py-3 ${row.kind === "spike" ? "border-l-4 border-l-accent" : row.kind === "health" ? "border-l-4 border-l-warn" : row.unread ? "border-l-4 border-l-accent/60" : ""}`}>
{row.conversation ? <Avatar handle={row.conversation.handle} /> : <span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${row.kind === "spike" || row.kind === "approval" ? "bg-accent" : "bg-warn/20 text-warn"}`}>{row.kind === "spike" ? "↗" : "!"}</span>}
<div className="min-w-0 flex-1"><div className="flex items-center gap-2"><span className="text-xs font-semibold capitalize">{row.kind}</span><span className="rounded border border-border px-1 text-2xs uppercase tracking-wide text-text-faint">sample</span>{row.conversation && <span className="truncate text-xs text-text-faint">{row.conversation.participant}</span>}{row.conversation && <span className="ml-auto text-2xs text-text-faint">{ageLabel(row.conversation.lastMinutes)}</span>}</div><p className="mt-1 text-xs text-text-dim">{row.text}</p><div className="mt-2 flex gap-2">{row.conversation && <button type="button" className="min-h-11 rounded-md border border-border px-3 text-xs" onClick={() => router.push(`/inbox/${row.conversation?.id}`)}>Open thread</button>}{row.kind === "spike" && <button type="button" className="min-h-11 rounded-md bg-accent px-3 text-xs text-white" onClick={() => setAmplify(true)}>Crosspost to…</button>}{row.kind === "approval" && <button type="button" className="min-h-11 rounded-md border border-accent px-3 text-xs text-accent" onClick={() => router.push("/approvals")}>Review</button>}{row.kind === "health" && <button type="button" className="min-h-11 rounded-md border border-warn px-3 text-xs text-warn" onClick={() => router.push("/connections")}>View connection</button>}</div></div>
</div></SwipeItem>)}
{!sample && segment === "all" && <p className="px-4 py-6 text-xs text-text-faint">No messages yet — direct messages arrive when connector messaging support lands.</p>}
</div>}
</div>
{segment === "approvals" ? <div className="hidden min-w-0 flex-1 lg:flex"><ApprovalsSegment /></div> : sample ? <><aside className="hidden w-80 shrink-0 overflow-y-auto border-r border-border-subtle lg:block"><div className="border-b border-border-subtle p-4 text-sm font-semibold">Inbox · sample messages</div>{CONVERSATIONS.map((conversation) => <button key={conversation.id} type="button" onClick={() => setActiveId(conversation.id)} className={`flex min-h-16 w-full items-start gap-3 border-b border-border-subtle p-3 text-left ${conversation.id === activeId ? "bg-accent-dim" : ""}`}><Avatar handle={conversation.handle} /><span className="min-w-0"><span className="block truncate text-sm font-semibold">{conversation.participant}</span><span className="block truncate text-xs text-text-faint">{conversation.preview}</span></span></button>)}</aside>
<section className="hidden min-w-0 flex-1 flex-col lg:flex"><div className="border-b border-border-subtle p-4 text-sm font-semibold">{selected?.participant} · {PLATFORMS[selected?.platform ?? ""]?.displayName}</div><div className="flex-1 overflow-y-auto p-4">{(THREADS[selected?.id ?? 1] ?? []).map((message, index) => <div key={index} className={`mb-3 flex ${message.mine ? "justify-end" : "justify-start"}`}><p className="max-w-[70%] rounded-lg border border-border-subtle bg-surface p-3 text-sm">{message.text}</p></div>)}</div><p className="border-t border-border-subtle p-4 text-xs text-text-faint">Sample thread · replies await connector messaging support.</p></section></> : <section className="hidden min-w-0 flex-1 flex-col lg:flex"><div className="border-b border-border-subtle p-4 text-sm font-semibold">Inbox</div><div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-text-faint">No messages yet — direct messages arrive when connector messaging support lands.</div></section>}
<AmplifySheet post={spikePost} platform={getPlatform(spikePost.platform)} open={amplify} onClose={() => setAmplify(false)} />
</div>;
}

View file

@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { usePathname } from "next/navigation";
import { Sidebar } from "../../components/shell/sidebar";
import { CommandPalette } from "../../components/shell/command-palette";
import { BottomNav } from "../../components/mobile/bottom-nav";
import { MobileTopBar } from "../../components/mobile/top-bar";
import { AuthProvider, useAuth } from "../../lib/auth-store";
import { AuthGate, SessionStatus } from "../../components/auth/auth-gate";
import { useApiResource } from "../../lib/use-api";
import type { DraftRecord } from "../../lib/api-types";
import { useIsMobile } from "../../hooks/use-is-mobile";
const titles: Record<string, string> = {
streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post",
calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team",
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive",
};
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
const scrollPositions = new Map<string, { main: number; nested: number[] }>();
const identity = (drafts: DraftRecord[]): DraftRecord[] => drafts;
const emptyDrafts: DraftRecord[] = [];
function scrollableChildren(main: HTMLElement): HTMLElement[] {
return Array.from(main.querySelectorAll<HTMLElement>("*")).filter((element) =>
element.scrollHeight > element.clientHeight && /auto|scroll/.test(getComputedStyle(element).overflowY));
}
function Shell({ children }: { children: ReactNode }): React.ReactElement {
const pathname = usePathname();
const mobile = useIsMobile();
const { can } = useAuth();
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, emptyDrafts, { state: "in_review", limit: 100 });
// Badge comes from live approvals-in-review only — 0 while loading/sample/forbidden, never fixture counts.
const approvals = can("approval.grant") && drafts.source === "live" ? drafts.data.length : 0;
const mainRef = useRef<HTMLElement>(null);
useEffect(() => {
const main = mainRef.current;
if (main === null || !mobile) return;
const saved = scrollPositions.get(pathname);
const restore = window.setTimeout(() => {
main.scrollTop = saved?.main ?? 0;
scrollableChildren(main).forEach((element, index) => { element.scrollTop = saved?.nested[index] ?? 0; });
}, 0);
return () => {
window.clearTimeout(restore);
scrollPositions.set(pathname, { main: main.scrollTop, nested: scrollableChildren(main).map((element) => element.scrollTop) });
};
}, [pathname, mobile]);
const title = titles[pathname.split("/")[1] ?? ""] ?? "Tactical Media Broadcast Hub";
return (
<div className="flex h-dvh min-w-0 overflow-hidden lg:h-screen">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<div className="hidden h-11 shrink-0 items-center justify-between border-b border-border-subtle bg-surface/60 px-4 backdrop-blur lg:flex">
<div className="text-2xs text-text-faint">Press <kbd className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono">⌘K</kbd> for commands</div>
<div className="text-2xs text-text-faint"><SessionStatus /></div>
</div>
{mobile && <MobileTopBar title={title} back={!rootTabs.has(pathname)} />}
<main ref={mainRef} data-mobile-main className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto bg-bg lg:overflow-hidden">
<AuthGate>{children}</AuthGate>
</main>
{mobile && <BottomNav badges={{ inbox: approvals }} />}
</div>
{!mobile && <CommandPalette />}
</div>
);
}
export default function MainLayout({ children }: { children: ReactNode }): React.ReactNode {
return <AuthProvider><Shell>{children}</Shell></AuthProvider>;
}

View file

@ -0,0 +1,70 @@
"use client";
import Link from "next/link";
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import { Avatar } from "../../../components/ui/primitives";
import type { DraftRecord } from "../../../lib/api-types";
interface GroupSummary { id: string; name: string; }
const identity = (groups: GroupSummary[]): GroupSummary[] => groups;
const identityDrafts = (drafts: DraftRecord[]): DraftRecord[] => drafts;
const emptyDrafts: DraftRecord[] = [];
function Tile({ href, title, detail }: { href: string; title: string; detail: string }): React.ReactElement {
return <Link href={href} className="flex min-h-[92px] flex-col justify-between rounded-lg border border-border bg-surface p-3.5 hover:border-accent">
<span className="text-accent">✦</span><span><strong className="block text-sm">{title}</strong><span className="text-xs text-text-faint">{detail}</span></span>
</Link>;
}
function Row({ href, title, detail }: { href: string; title: string; detail?: string }): React.ReactElement {
return <Link href={href} className="flex min-h-[56px] items-center gap-3 border-b border-border-subtle px-4 py-2">
<span className="min-w-0 flex-1"><strong className="block text-sm font-medium">{title}</strong>{detail && <span className="text-xs text-text-faint">{detail}</span>}</span>
<span aria-hidden="true" className="text-text-faint">›</span>
</Link>;
}
export default function MorePage(): React.ReactElement {
const { session, can, logout } = useAuth();
const groups = useApiResource<GroupSummary[], GroupSummary[]>("/groups/mine", identity, []);
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identityDrafts, emptyDrafts, { limit: 100 });
const canApprove = can("approval.grant") || session?.role === "Editor";
const canManage = can("workspace.members.manage");
const canAudit = can("audit.view");
return <div className="mx-auto max-w-2xl pb-8">
<section className="flex items-center gap-4 px-5 py-5">
<div className="scale-125"><Avatar handle={session?.user.displayName || session?.user.username || "AP"} /></div>
<div className="min-w-0 flex-1 pl-2">
<h1 className="truncate text-xl font-bold">{session?.user.displayName || session?.user.username || "Tactical Media Broadcast Hub"}</h1>
<p className="truncate text-xs text-text-faint">@{session?.user.username || "offline"}{session?.user.email ? ` · ${session.user.email}` : ""}</p>
<div className="mt-2 flex items-center gap-2"><span className="rounded-full bg-accent px-2.5 py-1 text-[11px] font-bold uppercase text-white">{session?.role || "Preview"}</span><span className="truncate text-xs text-text-dim">{session?.workspace.name || "Studio"}</span></div>
</div>
</section>
<h2 className="px-4 pb-2 text-xs font-bold uppercase tracking-wider text-text-faint">Your groups</h2>
<div className="flex min-h-11 flex-wrap gap-2 px-4 pb-3">{groups.data.length ? groups.data.map((group) => <span key={group.id} className="flex min-h-11 items-center rounded-full border border-border px-3 text-xs">{group.name}</span>) : <span className="text-xs text-text-faint">No groups yet</span>}</div>
{(canApprove || canManage || canAudit) && <>
<h2 className="px-4 py-3 text-xs font-bold uppercase tracking-wider text-text-faint">Team</h2>
<div className="grid grid-cols-2 gap-2.5 px-4">
{canApprove && <Tile href="/approvals" title="Approvals" detail={`${drafts.data.filter((draft) => draft.state === "in_review").length} waiting`} />}
{canManage && <Tile href="/team" title="Invite member" detail="Manage invitations" />}
{canManage && <Tile href="/team" title="Roles" detail={`${groups.data.length} groups`} />}
{canAudit && <Tile href="/audit" title="Audit log" detail="Recent activity" />}
</div>
</>}
<h2 className="px-4 pt-6 pb-2 text-xs font-bold uppercase tracking-wider text-text-faint">Help</h2>
<div className="grid grid-cols-2 gap-2.5 px-4">
<Tile href="/wiki" title="Help & docs" detail="Manuals · EN / ES" />
</div>
<h2 className="px-4 pt-6 pb-2 text-xs font-bold uppercase tracking-wider text-text-faint">Settings</h2>
<div className="border-y border-border-subtle bg-surface">
<Row href="/connections" title="Connections" detail="Accounts and health" />
<Row href="/settings" title="Settings" />
<Row href="/settings" title="Security & sessions" />
<Row href="/calendar" title="Calendar" />
<Row href="/analytics" title="Analytics" />
<Row href="/video" title="Video library" />
<Row href="/onboarding" title="Onboarding" />
<button onClick={() => void logout()} className="flex min-h-[56px] w-full items-center px-4 text-left text-sm font-medium text-danger">Sign out</button>
</div>
</div>;
}

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>
);
}

View file

@ -0,0 +1,58 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { generateFeed, type FeedItem } from "../../../lib/fixtures";
import { getPlatform, MOCK_COLUMN_DEFS, PLATFORMS, type Platform } from "../../../lib/platforms";
import { readSavedSearches, saveSearches } from "../../../lib/saved-searches";
import { PostCard } from "../../../components/mobile/post-card";
import { SourceChips } from "../../../components/mobile/source-chips";
import { AmplifySheet } from "../../../components/mobile/amplify-sheet";
import { useApiResource } from "../../../lib/use-api";
import type { ConnectionSummary } from "../../../lib/api-types";
const identity = (value: ConnectionSummary[]): ConnectionSummary[] => value;
const empty: ConnectionSummary[] = [];
const sample = MOCK_COLUMN_DEFS.flatMap((column, i) => generateFeed(column.platformId, 80, (i + 1) * 11));
// Trending values are local fixtures until tenant-scoped aggregation exists.
const trends = [{ label: "product", bars: [2, 3, 4, 3, 5, 8, 12] }, { label: "connector", bars: [3, 3, 5, 4, 6, 7, 8] }, { label: "audit", bars: [2, 2, 3, 3, 4, 4, 6] }];
const RECENT_KEY = "tmbh.recent-searches";
export default function SearchPage(): React.ReactElement {
const router = useRouter();
const connections = useApiResource<ConnectionSummary[], ConnectionSummary[]>("/connections", identity, empty);
const input = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [active, setActive] = useState("all");
const [saved, setSaved] = useState<string[]>([]);
const [recent, setRecent] = useState<string[]>([]);
const [amplify, setAmplify] = useState<{ item: FeedItem; platform: Platform } | null>(null);
useEffect(() => {
setSaved(readSavedSearches());
try { const value: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]"); if (Array.isArray(value)) setRecent(value.filter((part): part is string => typeof part === "string")); } catch { /* corrupt local preference */ }
const focus = (): void => input.current?.focus();
window.addEventListener("mobile-search-focus", focus);
return () => window.removeEventListener("mobile-search-focus", focus);
}, []);
const results = useMemo(() => query.trim() === "" ? [] : sample.filter((item) => `${item.text} ${item.handle} ${item.author}`.toLowerCase().includes(query.trim().toLowerCase())), [query]);
const visible = active === "all" ? results : results.filter((item) => item.platform === active);
const localProviders = new Set(MOCK_COLUMN_DEFS.map((column) => column.platformId));
function remember(term: string): void { const values = [term.trim(), ...recent.filter((part) => part !== term.trim())].filter(Boolean).slice(0, 8); setRecent(values); localStorage.setItem(RECENT_KEY, JSON.stringify(values)); }
function save(term: string): void { saveSearches([...saved, term]); setSaved(readSavedSearches()); remember(term); }
return <div className="mx-auto min-h-full max-w-2xl pb-6">
<div className="sticky top-0 z-10 border-b border-border-subtle bg-bg px-4 py-3"><div className="flex items-center gap-2"><button type="button" className="min-h-11 min-w-11 text-xl" aria-label="Clear search" onClick={() => { setQuery(""); setActive("all"); }}>←</button><span className="shrink-0 rounded-full border border-border px-2 py-0.5 text-2xs text-text-faint">sample results</span><input ref={input} id="mobile-search" data-search-field value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { remember(query); input.current?.blur(); } }} placeholder="Search posts and accounts" className="min-h-[54px] min-w-0 flex-1 rounded-lg border border-border bg-surface-2 px-4 text-sm outline-none focus:border-accent" /></div></div>
{query.trim() === "" ? <div className="space-y-6 p-4">
<p className="text-xs text-text-faint">Search and trends use sample feeds. Cross-platform search is not connected yet. {connections.source === "live" ? `${connections.data.length} accounts connected.` : ""}</p>
<section><h2 className="mb-2 text-sm font-semibold">Saved searches</h2><div className="flex flex-wrap gap-2">{saved.map((term) => <button key={term} type="button" onClick={() => setQuery(term)} className="min-h-11 rounded-full border border-border px-4 text-xs">{term} · pinned on Home</button>)}</div></section>
<section><h2 className="mb-2 text-sm font-semibold">Trending in sample feeds</h2>{trends.map((trend) => <button key={trend.label} type="button" onClick={() => { setQuery(trend.label); remember(trend.label); }} className="flex min-h-14 w-full items-center justify-between border-b border-border-subtle text-left text-sm"><span>#{trend.label}</span><span className="flex h-8 items-end gap-0.5" aria-hidden="true">{trend.bars.map((bar, i) => <span key={i} className={`w-1.5 ${bar > 9 ? "bg-accent" : "bg-text-faint"}`} style={{ height: `${bar * 2}px` }} />)}</span></button>)}</section>
<section><h2 className="mb-2 text-sm font-semibold">Recent</h2>{recent.map((term) => <div key={term} className="flex items-center"><button type="button" onClick={() => setQuery(term)} className="min-h-11 flex-1 text-left text-sm">{term}</button><button type="button" aria-label={`Clear ${term}`} className="min-h-11 min-w-11" onClick={() => { const values = recent.filter((part) => part !== term); setRecent(values); localStorage.setItem(RECENT_KEY, JSON.stringify(values)); }}>×</button></div>)}</section>
</div> : <div>
<div className="flex items-center justify-between px-4 py-2"><span className="text-xs text-text-faint">{results.length} posts</span><button type="button" className="min-h-11 rounded-md border border-accent px-3 text-xs text-accent" onClick={() => save(query)}>Save search</button></div>
<SourceChips sources={[{ id: "all", label: "All", count: results.length }, ...[...localProviders].map((id) => ({ id, label: PLATFORMS[id]?.displayName ?? id, dot: id, count: results.filter((item) => item.platform === id).length }))]} value={active} onChange={setActive} />
<div className="flex gap-2 overflow-x-auto px-4 pb-2">{Object.keys(PLATFORMS).filter((id) => !localProviders.has(id)).map((id) => <span key={id} className="rounded-full border border-border px-3 py-2 text-xs text-text-faint">{PLATFORMS[id]?.displayName} –</span>)}</div>
{visible.map((item) => <PostCard key={`${item.platform}-${item.id}`} item={item} platform={getPlatform(item.platform)} match={query} onAmplify={() => setAmplify({ item, platform: getPlatform(item.platform) })} onReply={() => router.push(`/compose?text=${encodeURIComponent(`@${item.handle.replace(/^@/, "")} `)}`)} />)}
{visible.length === 0 && <p className="p-6 text-center text-sm text-text-faint">No matches in the sample feeds.</p>}
</div>}
<AmplifySheet post={amplify?.item ?? null} platform={amplify?.platform ?? null} open={amplify !== null} onClose={() => setAmplify(null)} />
</div>;
}

View file

@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Avatar, Button } from "../../../components/ui/primitives";
import { errorMessage } from "../../../lib/api-client";
import { useAuth } from "../../../lib/auth-store";
/**
* Deliberately small: this page states exactly what the API knows about the
* current session and how to end it. There is no notifications/preferences
* endpoint in the API, so none of that is faked here — no toggles that
* pretend to persist.
*/
function Field({ label, value, mono = false }: { label: string; value: string; mono?: boolean }): React.ReactElement {
return (
<div className="flex items-start justify-between gap-4 px-4 py-3">
<span className="text-xs text-text-faint">{label}</span>
<span className={`min-w-0 break-all text-right text-xs text-text ${mono ? "font-mono" : ""}`}>{value}</span>
</div>
);
}
export default function SettingsPage(): React.ReactElement {
const router = useRouter();
const { session, status, offline, logout } = useAuth();
const [signingOut, setSigningOut] = useState(false);
const [error, setError] = useState<string | null>(null);
async function signOut(): Promise<void> {
setSigningOut(true);
setError(null);
try {
// The store's logout also clears the local token; a network failure must
// still sign you out of this device, hence the swallow-then-state reset.
await logout();
router.push("/");
} catch (err) {
setError(errorMessage(err));
setSigningOut(false);
}
}
const statusLine =
status === "authenticated" ? "signed in" : status === "loading" ? "checking session…" : offline ? "offline" : status;
return (
<div className="h-full overflow-y-auto p-4 pb-24 lg:p-6">
<div className="mx-auto max-w-2xl">
<h1 className="mb-1 text-lg font-semibold">Settings</h1>
<p className="mb-5 text-xs text-text-faint">
What the API knows about this session. Workspace-level configuration lives in Team and Connections; there is no
per-user preference store in the API, so none is shown here.
</p>
<section className="mb-6 rounded-lg border border-border-subtle bg-surface">
<div className="flex items-center gap-4 border-b border-border-subtle p-4">
<Avatar handle={session?.user.displayName || session?.user.username || "AP"} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">
{session?.user.displayName || session?.user.username || "Not signed in"}
</p>
<p className="text-xs text-text-faint">
{session === null ? "No verified session on this device." : `@${session.user.username}`}
</p>
</div>
<span className="rounded-full bg-accent px-2.5 py-1 text-2xs font-bold uppercase text-white">
{session?.role ?? statusLine}
</span>
</div>
<div className="divide-y divide-border-subtle">
<Field label="Username" value={session?.user.username ?? "—"} />
<Field label="Workspace" value={session?.workspace.name ?? "—"} />
<Field label="Role" value={session?.role ?? "—"} />
<Field label="Workspace slug" value={session?.workspace.slug ?? "—"} mono />
<Field
label="Permissions"
value={session === null ? "—" : session.permissions.length > 0 ? session.permissions.join(", ") : "none"}
mono
/>
<Field
label="Session expires"
value={
session === null
? "—"
: `${new Date(session.expiresAt).toLocaleString()} (${Intl.DateTimeFormat().resolvedOptions().timeZone})`
}
/>
</div>
</section>
{error !== null && <p role="alert" className="mb-3 text-xs text-danger">{error}</p>}
<Button
variant="danger"
disabled={signingOut || session === null}
onClick={() => void signOut()}
title={session === null ? "No session to end." : "Ends the workspace session on the API and clears this device's token."}
>
{signingOut ? "Signing out…" : "Sign out"}
</Button>
<p className="mt-2 text-2xs text-text-faint">
Signing out calls <code className="font-mono">POST /auth/logout</code> on the API, clears the token here, and
returns you to the landing page.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,284 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { generateFeed } from "../../../lib/fixtures";
import { MOCK_COLUMN_DEFS, type Platform } from "../../../lib/platforms";
import { StreamColumn, type ColumnDef } from "../../../components/streams/column";
import { Button } from "../../../components/ui/primitives";
import { EmptyState } from "../../../components/brand/empty-state";
import { PostCard } from "../../../components/mobile/post-card";
import { SourceChips } from "../../../components/mobile/source-chips";
import { PullToRefresh } from "../../../components/mobile/pull-to-refresh";
import { AmplifySheet } from "../../../components/mobile/amplify-sheet";
import { readSavedSearches, saveSearches } from "../../../lib/saved-searches";
import { useApiResource } from "../../../lib/use-api";
import { api, errorMessage } from "../../../lib/api-client";
import { liveColumns, streamItemKey, streamPlatform, type FeedsResponse, type StreamItem as FeedItem } from "../../../lib/streams-data";
import Link from "next/link";
import { useRouter } from "next/navigation";
interface Toast {
id: number;
text: string;
}
interface QuoteTarget {
item: FeedItem;
platform: Platform;
}
const COLUMN_SEEDS = [11, 22, 33];
const COLUMNS: ColumnDef[] = MOCK_COLUMN_DEFS.map((def, i) => ({
...def,
items: generateFeed(def.platformId, [500, 420, 380][i] ?? 400, COLUMN_SEEDS[i] ?? 1),
}));
const SAMPLE_RESPONSE: FeedsResponse = { items: [], nextCursor: null, meta: { configuredFeeds: 0, connections: [], excludedConnections: [] } };
const toFeeds = (response: FeedsResponse): FeedsResponse => response;
export default function StreamsPage(): React.ReactElement {
const router = useRouter();
const resource = useApiResource("/feeds", toFeeds, SAMPLE_RESPONSE, { limit: 200 });
// Mock columns only when the API is unreachable; a live workspace with zero
// configured feeds gets an honest empty state instead of sample data.
const mock = resource.source === "sample";
const noFeeds = resource.source === "live" && !resource.error && resource.data.meta.configuredFeeds === 0;
const columns = useMemo(() => mock ? COLUMNS : resource.error || noFeeds ? [] : liveColumns(resource.data), [mock, noFeeds, resource.data, resource.error]);
const statusLabel = resource.source === "loading" ? "Loading feeds…" : mock ? "Offline — sample feed" : noFeeds ? "No configured feeds" : resource.error ?? "Live feeds";
useEffect(() => {
const timer = setInterval(resource.reload, 30_000);
return () => clearInterval(timer);
}, [resource.reload]);
const [source, setSource] = useState("all");
const [refreshKey, setRefreshKey] = useState(0);
const [amplify, setAmplify] = useState<QuoteTarget | null>(null);
const [saved, setSaved] = useState<string[]>([]);
const [selectedFlat, setSelectedFlat] = useState(0);
const [toasts, setToasts] = useState<Toast[]>([]);
const [quote, setQuote] = useState<QuoteTarget | null>(null);
const [quoteText, setQuoteText] = useState("");
useEffect(() => {
const sync = (): void => setSaved(readSavedSearches());
sync();
window.addEventListener("saved-searches-change", sync);
return () => window.removeEventListener("saved-searches-change", sync);
}, []);
const feed = useMemo(() => columns.flatMap((column) => column.items).sort((a, b) => a.ageMinutes - b.ageMinutes), [columns]);
const extraColumns: ColumnDef[] = useMemo(() => saved.filter((term) => !MOCK_COLUMN_DEFS.some((column) => column.title === `Saved: ${term}`)).map((term) => {
const items = feed.filter((item) => item.text.toLowerCase().includes(term.toLowerCase()));
return { key: `saved:${term}`, title: `Saved: ${term}`, platformId: items[0]?.platform ?? MOCK_COLUMN_DEFS[0]?.platformId ?? "limited", staleness: mock ? "sample" : "live", platform: items[0] ? streamPlatform(items[0]) : undefined, items };
}), [feed, saved, mock]);
const shown = source === "all" || source === "columns" ? feed : source.startsWith("saved:") ? feed.filter((item) => item.text.toLowerCase().includes(source.slice(6).toLowerCase())) : columns.find((column) => column.key === source)?.items ?? [];
const allColumns = useMemo(() => source === "columns" ? [...columns, ...extraColumns] : [{ key: source, title: source === "all" ? "All feeds" : columns.find((column) => column.key === source)?.title ?? source.slice(6), platformId: shown[0]?.platform ?? MOCK_COLUMN_DEFS[0]?.platformId ?? "limited", platform: shown[0] ? streamPlatform(shown[0]) : undefined, staleness: mock ? "sample" : "live", items: shown }], [columns, extraColumns, shown, source, mock]);
useEffect(() => { setSelectedFlat(0); }, [source]);
const offsets = useMemo(() => allColumns.reduce<number[]>((acc, _column, index) => [...acc, (acc[index - 1] ?? 0) + (allColumns[index - 1]?.items.length ?? 0)], []), [allColumns]);
const flatMax = (offsets[offsets.length - 1] ?? 0) + (allColumns[allColumns.length - 1]?.items.length ?? 0) - 1;
useEffect(() => { setSelectedFlat((current) => Math.max(0, Math.min(current, flatMax))); }, [flatMax]);
useEffect(() => {
if (source !== "all" && source !== "columns" && !source.startsWith("saved:") && !columns.some((column) => column.key === source)) setSource("all");
}, [columns, source]);
const mobileItems = source === "columns" ? allColumns.flatMap((column) => column.items) : shown;
const sources = [{ id: "all", label: "All feeds", count: feed.length }, { id: "columns", label: "Columns" }, ...columns.map((column) => ({ id: column.key, label: column.title, dot: column.platformId, count: column.items.length })), ...saved.map((term) => ({ id: `saved:${term}`, label: `Saved: ${term}`, count: feed.filter((item) => item.text.toLowerCase().includes(term.toLowerCase())).length }))];
const refresh = async (): Promise<void> => {
try {
if (!mock) await api.post("/feeds/refresh");
resource.reload();
setRefreshKey((key) => key + 1);
} catch (error) { pushToast(errorMessage(error)); }
};
useEffect(() => {
if (window.matchMedia("(max-width: 1023px)").matches) document.querySelector('[data-stream-selected="true"]')?.scrollIntoView({ block: "nearest" });
}, [selectedFlat]);
const pushToast = useCallback((text: string) => {
const id = Date.now() + Math.random();
setToasts((t) => [...t, { id, text }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600);
}, []);
const locate = useCallback(
(flat: number): { column: ColumnDef; item: FeedItem; platform: Platform } | null => {
for (let i = allColumns.length - 1; i >= 0; i -= 1) {
const start = offsets[i] ?? 0;
if (flat >= start) {
const item = allColumns[i]?.items[flat - start];
const column = allColumns[i];
if (item === undefined || column === undefined) return null;
return { column, item, platform: streamPlatform(item) };
}
}
return null;
}, [allColumns, offsets]);
const notConnectedToast = useCallback(() => {
pushToast(mock ? "sample — connect an account to boost" : "Not connected — connect an account to boost");
}, [mock, pushToast]);
const doRepost = useCallback(
(item: FeedItem, platform: Platform) => {
// No connection id: there is nothing to amplify through — never fake success.
if (item.connectionId) { setAmplify({ item, platform }); return; }
notConnectedToast();
},
[notConnectedToast],
);
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (quote !== null) {
if (e.key === "Escape") setQuote(null);
return;
}
const target = e.target as HTMLElement | null;
if (target !== null && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
if (e.key === "j" || e.key === "ArrowDown") {
e.preventDefault();
setSelectedFlat((f) => Math.max(0, Math.min(f + 1, flatMax)));
} else if (e.key === "k" || e.key === "ArrowUp") {
e.preventDefault();
setSelectedFlat((f) => Math.max(f - 1, 0));
} else if (e.key === "r") {
const loc = locate(selectedFlat);
if (loc !== null && (loc.platform.capabilities.amplifyRepost || loc.platform.capabilities.amplifyShare)) {
doRepost(loc.item, loc.platform);
} else if (loc !== null) {
pushToast(`${loc.platform.displayName}: no repost capability — API exposes none`);
}
} else if (e.key === "q") {
const loc = locate(selectedFlat);
if (loc !== null && loc.item.connectionId === undefined) {
notConnectedToast();
} else if (loc !== null && (loc.platform.capabilities.amplifyQuote || loc.platform.capabilities.publishText)) {
setQuoteText("");
setQuote(loc);
} else if (loc !== null) {
pushToast(`${loc.platform.displayName}: no quote capability — API exposes none`);
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [locate, doRepost, notConnectedToast, pushToast, quote, selectedFlat, flatMax]);
const headerNote = "j/k move · r amplify · q quote";
return (
<div className="flex h-full min-w-0 flex-col">
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-2"><span role="status" className="text-xs text-text-faint">{statusLabel}{!mock && resource.data.meta.excludedConnections.length > 0 ? ` · ${resource.data.meta.excludedConnections.length} accounts do not support feeds` : ""}</span><Button size="sm" variant="ghost" onClick={() => void refresh()}>Refresh</Button></div>
<SourceChips sources={sources} value={source} onChange={setSource} />
<div className="flex min-h-0 flex-1 flex-col lg:hidden">
<div className="flex items-center justify-end border-b border-border-subtle px-4 py-2"><button type="button" className="min-h-11 min-w-11 text-xl" aria-label="Search" onClick={() => router.push("/search")}>⌕</button></div>
{noFeeds ? (
<div className="p-6">
<EmptyState title="No configured feeds" hint="None of your connected accounts have feeds configured yet." />
<Link href="/connections" className="mt-3 flex min-h-11 items-center justify-center rounded-md bg-accent px-3 text-xs font-medium text-white hover:bg-accent-hover">Connect an account</Link>
</div>
) : (
<PullToRefresh sourceCount={columns.length} onRefresh={refresh}>
<div key={refreshKey}>{mobileItems.map((item, index) => <PostCard key={`${streamItemKey(item)}:${index}`} item={item} selected={selectedFlat === index} onSelect={() => setSelectedFlat(index)} platform={streamPlatform(item)} onAmplify={() => doRepost(item, streamPlatform(item))} onReply={() => router.push(`/compose?text=${encodeURIComponent(`@${item.handle.replace(/^@/, "")} `)}`)} />)}</div>
{mobileItems.length === 0 && <EmptyState title="No posts in this source" hint="Choose another feed or saved search." />}
</PullToRefresh>
)}
</div>
<div className="hidden min-h-0 flex-1 flex-col lg:flex">
<div className="flex shrink-0 items-center justify-between border-b border-border-subtle bg-surface/60 px-4 py-1.5">
<span className="text-2xs text-text-faint">{headerNote}</span>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => { const term = window.prompt("Save a search as a column"); if (term?.trim()) saveSearches([...readSavedSearches(), term]); }}>+ Column</Button>
</div>
</div>
<div className="flex min-h-0 flex-1">
{noFeeds ? (
<div className="flex w-full flex-col items-center justify-center gap-3 self-center">
<EmptyState className="w-full self-center" title="No configured feeds" hint="None of your connected accounts have feeds configured yet." />
<Link href="/connections" className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">Connect an account</Link>
</div>
) : allColumns.length === 0 && (
<EmptyState
className="w-full self-center"
title="No columns"
hint="Nothing on the wall yet — add a column to pull in a feed."
/>
)}
{allColumns.map((column, i) => (
<StreamColumn
key={column.key}
column={column}
flatStartIndex={offsets[i] ?? 0}
selectedFlat={selectedFlat}
onSelectFlat={setSelectedFlat}
onRepost={doRepost}
onQuote={(item, platform) => {
// No connection id: nothing to quote through — say so instead of faking it.
if (item.connectionId === undefined) { notConnectedToast(); return; }
setQuoteText("");
setQuote({ item, platform });
}}
/>
))}
</div>
</div>
{!mock && !noFeeds && !resource.error && resource.data.nextCursor && <Button size="sm" variant="ghost" onClick={async () => {
try {
const next = await api.get<FeedsResponse>("/feeds", { limit: 200, cursor: resource.data.nextCursor ?? undefined });
resource.setData((current) => ({ ...next, items: [...new Map([...current.items, ...next.items].map((item) => [`${item.connectionId}:${item.id}`, item])).values()] }));
} catch (error) { pushToast(errorMessage(error)); }
}}>Load older posts</Button>}
<AmplifySheet post={amplify?.item ?? null} platform={amplify?.platform ?? null} open={amplify !== null} onClose={() => setAmplify(null)} />
{quote !== null && (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60" onClick={() => setQuote(null)}>
<div className="w-[520px] max-w-[90vw] rounded-lg border border-border bg-surface p-4 shadow-2xl" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">Quote {quote.item.handle}</span>
<span className="text-2xs text-text-faint">{quote.platform.displayName}</span>
</div>
<blockquote className="mb-3 max-h-24 overflow-y-auto rounded-md border border-border-subtle bg-surface-2 p-2 text-xs leading-relaxed text-text-dim scrollbar-thin">
{quote.item.text}
</blockquote>
<textarea
autoFocus
value={quoteText}
onChange={(e) => setQuoteText(e.target.value)}
rows={3}
placeholder="Add your commentary…"
className="w-full resize-none rounded-md border border-border bg-surface-2 p-2 text-sm text-text placeholder:text-text-faint focus:border-accent focus:outline-none"
/>
<div className="mt-3 flex items-center justify-between">
<span className="text-2xs text-text-faint">{quoteText.length} / {quote.platform.charLimit}</span>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => setQuote(null)}>Cancel</Button>
<Button
variant="primary"
disabled={quoteText.trim() === "" || quote.item.connectionId === undefined}
title={quote.item.connectionId === undefined ? "not connected" : undefined}
onClick={() => {
if (quote.item.connectionId) {
const params = new URLSearchParams({ text: `${quoteText}\n\n“${quote.item.text}”\nVia ${quote.item.handle}`, targets: quote.item.connectionId });
router.push(`/compose?${params.toString()}`);
setQuote(null);
}
}}
>
{quote.item.connectionId ? "Open compose" : "Not connected"}
</Button>
</div>
</div>
</div>
</div>
)}
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((t) => (
<div key={t.id} className="flex items-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-xs text-text shadow-lg">
<span className="h-3 w-0.5 shrink-0 rounded-full bg-accent" />
{t.text}
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,439 @@
"use client";
import { useState } from "react";
import { Badge, Button, Card, Input } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { ASSETS, MEMBERS, ROLES } from "../../../lib/fixtures";
import { ApiError, api, errorMessage } from "../../../lib/api-client";
import type { InviteLink, InviteLinkStatus, Member, MemberMutationResult } from "../../../lib/api-types";
import { useAuth } from "../../../lib/auth-store";
import { useApiResource } from "../../../lib/use-api";
import { Loading } from "../../../components/brand/loading";
const OWNER = "Owner";
const SAMPLE_MEMBERS: Member[] = MEMBERS.map((m) => ({
id: String(m.id),
userId: String(m.id),
username: m.email.split("@")[0] ?? "member",
email: m.email,
displayName: m.name,
role: m.role,
status: m.status,
createdAt: new Date(0).toISOString(),
}));
const memberLabel = (m: Member): string => m.email ?? `@${m.username}`;
/** Invite-link lifetime choices, in hours. */
const LINK_TTL_OPTIONS: Array<{ hours: number; label: string }> = [
{ hours: 24, label: "24 hours" },
{ hours: 168, label: "7 days" },
{ hours: 720, label: "30 days" },
];
const DEFAULT_LINK_TTL = 168;
const DEFAULT_LINK_ROLE = "Editor";
const SAMPLE_LINKS: InviteLink[] = [
{
id: "sample-1", role: "Publisher", createdAt: "2024-05-01T09:00:00.000Z",
expiresAt: new Date(Date.now() + 86_400_000 * 3).toISOString(), maxUses: 50, useCount: 38,
revokedAt: null, status: "active", createdByUserId: "sample-owner",
},
{
id: "sample-2", role: "Editor", createdAt: "2024-04-01T09:00:00.000Z",
expiresAt: new Date(Date.now() - 86_400_000).toISOString(), maxUses: null, useCount: 12,
revokedAt: null, status: "expired", createdByUserId: "sample-owner",
},
{
id: "sample-3", role: "Approver", createdAt: "2024-03-01T09:00:00.000Z",
expiresAt: new Date(Date.now() + 86_400_000).toISOString(), maxUses: 10, useCount: 10,
revokedAt: null, status: "exhausted", createdByUserId: "sample-owner",
},
{
id: "sample-4", role: "Analyst", createdAt: "2024-02-01T09:00:00.000Z",
expiresAt: new Date(Date.now() + 86_400_000).toISOString(), maxUses: null, useCount: 3,
revokedAt: "2024-02-04T09:00:00.000Z", status: "revoked", createdByUserId: "sample-owner",
},
];
const linkStatusTone = (status: InviteLinkStatus): "live" | "warn" | "neutral" =>
status === "active" ? "live" : status === "revoked" ? "neutral" : "warn";
const usesLabel = (link: InviteLink): string =>
link.maxUses === null ? "unlimited" : `${link.useCount} / ${link.maxUses}`;
const identity = <T,>(value: T): T => value;
export default function TeamPage(): React.ReactElement {
const { session, can, adoptToken, logout } = useAuth();
const members = useApiResource<Member[], Member[]>("/team/members", identity, SAMPLE_MEMBERS);
const roles = useApiResource<string[], string[]>("/team/roles", identity, ROLES);
const links = useApiResource<InviteLink[], InviteLink[]>("/team/invite-links", identity, SAMPLE_LINKS);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("Editor");
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
const [linkRole, setLinkRole] = useState(DEFAULT_LINK_ROLE);
const [linkTtl, setLinkTtl] = useState(String(DEFAULT_LINK_TTL));
const [linkMaxUses, setLinkMaxUses] = useState("");
const [createdLink, setCreatedLink] = useState<InviteLink | null>(null);
const [inviteLink, setInviteLink] = useState<InviteLink | null>(null);
const [copied, setCopied] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
const live = members.source === "live";
// Either resource falling back to fixtures means the screen is showing sample data somewhere.
const sampleMode = members.source === "sample" || links.source === "sample";
const canManage = !live || can("workspace.members.manage");
const isOwner = !live || session?.role === OWNER;
async function mutate(run: () => Promise<MemberMutationResult>, apply: (result: MemberMutationResult) => void | Promise<void>, ok: string): Promise<void> {
if (!live) {
setMessage({ tone: "error", text: "Offline — sample data is read-only." });
return;
}
setBusy(true);
setMessage(null);
try {
const result = await run();
// Membership changes bump the authz epoch; without the re-issued token our next call would 401.
adoptToken(result.token);
await apply(result);
if (ok !== "") setMessage({ tone: "ok", text: ok });
} catch (err) {
setMessage({ tone: "error", text: errorMessage(err) });
} finally {
setBusy(false);
}
}
const changeRole = (member: Member, role: string): Promise<void> =>
mutate(
() => api.patch<MemberMutationResult>(`/team/members/${member.id}/role`, { role }),
(result) => {
const updated = result.member;
if (updated !== null) members.setData((list) => list.map((m) => (m.id === updated.id ? updated : m)));
},
`${memberLabel(member)} is now ${role}.`,
);
const invite = (): Promise<void> =>
mutate(
() => api.post<MemberMutationResult>("/team/members/invite", { email: inviteEmail.trim(), role: inviteRole }),
async (result) => {
const added = result.member;
if (added !== null) members.setData((list) => [...list, added]);
setInviteEmail("");
// No mailer exists, so the invitee would never see anything. Create a
// join link for the same role and hand it to the inviter instead.
const link = await api.post<InviteLink>("/team/invite-links", { role: inviteRole, expiresInHours: DEFAULT_LINK_TTL });
links.setData((list) => [link, ...list.filter((l) => l.id !== link.id)]);
setInviteLink(link);
setCopied(false);
},
"",
);
const remove = (member: Member): Promise<void> =>
mutate(
() => api.del<MemberMutationResult>(`/team/members/${member.id}`),
(result) => {
members.setData((list) => list.filter((m) => m.id !== member.id));
setConfirmRemove(null);
// Removing yourself ends your membership: no replacement token.
if (result.token === null && member.userId === session?.user.id) void logout();
},
`Removed ${memberLabel(member)}.`,
);
// Invite links: no epoch bump on create, but the returned token is displayed exactly once.
async function createLink(): Promise<void> {
if (!live) {
setMessage({ tone: "error", text: "Offline — sample data is read-only." });
return;
}
const maxUsesRaw = linkMaxUses.trim();
const maxUses = maxUsesRaw === "" ? undefined : Number(maxUsesRaw);
if (maxUses !== undefined && (!Number.isInteger(maxUses) || maxUses < 1)) {
setMessage({ tone: "error", text: "Max uses must be a whole number of 1 or more." });
return;
}
setBusy(true);
setMessage(null);
try {
const created = await api.post<InviteLink>("/team/invite-links", {
role: linkRole,
expiresInHours: Number(linkTtl),
...(maxUses === undefined ? {} : { maxUses }),
});
links.setData((list) => [created, ...list.filter((l) => l.id !== created.id)]);
setCreatedLink(created);
setCopied(false);
setLinkMaxUses("");
setMessage({ tone: "ok", text: `Invite link created — ${created.role} access, shown once below.` });
} catch (err) {
setMessage({ tone: "error", text: errorMessage(err) });
} finally {
setBusy(false);
}
}
// Revoke returns the updated link (no re-issued token), so it skips `mutate`'s MemberMutationResult shape.
async function revokeLink(link: InviteLink): Promise<void> {
if (!live) {
setMessage({ tone: "error", text: "Offline — sample data is read-only." });
return;
}
setBusy(true);
setMessage(null);
try {
const revoked = await api.del<InviteLink>(`/team/invite-links/${link.id}`);
links.setData((list) => list.map((l) => (l.id === revoked.id ? revoked : l)));
setCreatedLink((current) => (current !== null && current.id === revoked.id ? revoked : current));
setMessage({ tone: "ok", text: `Revoked the ${link.role} invite link.` });
} catch (err) {
setMessage({ tone: "error", text: errorMessage(err) });
} finally {
setBusy(false);
}
}
const assignable = (current: string): string[] => roles.data.filter((r) => r !== OWNER || isOwner || current === OWNER);
return (
<div className="h-full overflow-y-auto p-6 scrollbar-thin">
<div className="mb-6 flex items-center gap-3">
<h1 className="text-lg font-semibold">Team</h1>
<OfflineBadge source={sampleMode ? "sample" : members.source} />
</div>
<Card className="mb-6 p-4">
{members.error !== null && <p className="mb-3 text-xs text-danger">{members.error}</p>}
{/* On a live error (e.g. 403) the sample roster is stale, so the table is withheld rather than faked. */}
{members.source !== "loading" && members.error !== null ? null : (
<table className="mobile-stack-table w-full text-sm">
<thead>
<tr className="border-b border-border-subtle text-left text-2xs text-text-faint">
<th className="pb-2 font-medium">Member</th>
<th className="pb-2 font-medium">Role</th>
<th className="pb-2 font-medium">Status</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{members.source === "loading" && (
<tr><td colSpan={4} className="py-3"><span className="inline-flex items-center gap-2 text-xs text-text-faint"><Loading size={14} label="Loading members" /> Loading members…</span></td></tr>
)}
{members.source !== "loading" && members.data.map((m) => {
const ownerLocked = m.role === OWNER && !isOwner;
return (
<tr key={m.id} className="border-b border-border-subtle last:border-b-0">
<td className="py-2.5">
<div className="font-medium">{m.displayName}</div>
<div className="text-2xs text-text-faint">{memberLabel(m)}</div>
</td>
<td className="py-2.5">
<select
value={m.role}
disabled={!canManage || ownerLocked || busy}
onChange={(e) => void changeRole(m, e.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs disabled:opacity-40"
>
{assignable(m.role).map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</td>
<td className="py-2.5">
{m.status === "active" ? <Badge tone="live">active</Badge> : <Badge tone="warn">invited</Badge>}
</td>
<td className="py-2.5 text-right">
{confirmRemove === m.id ? (
<span className="flex items-center justify-end gap-2">
<Button size="sm" variant="ghost" onClick={() => setConfirmRemove(null)}>Cancel</Button>
<Button size="sm" variant="danger" disabled={busy} onClick={() => void remove(m)}>Confirm remove</Button>
</span>
) : (
<Button size="sm" variant="ghost" disabled={!canManage || ownerLocked} onClick={() => setConfirmRemove(m.id)}>
Remove
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
{canManage && (
<div className="mt-4 flex flex-wrap gap-2">
<Input
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="teammate@studio.io"
className="max-w-xs"
/>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
aria-label="Role for the invitee"
>
{assignable("").map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
<Button variant="outline" disabled={!inviteEmail.includes("@") || busy} onClick={() => void invite()}>Send invite</Button>
</div>
)}
{message !== null && (
<p className={`mt-2 text-2xs ${message.tone === "ok" ? "text-live" : "text-danger"}`}>{message.text}</p>
)}
<p className="mt-1.5 text-2xs text-text-faint">
Role changes and removals bump the workspace authz epoch — live sessions re-check and fail closed.
No invite email is sent — after inviting, share the join link shown above so the member can activate.
</p>
</Card>
<Card className="mb-6 p-4">
<h2 className="mb-1 text-sm font-semibold">Invite links</h2>
<p className="mb-4 text-xs text-text-faint">
Anyone with the link joins this workspace at the chosen role — no email needed. Links expire and can be revoked.
</p>
{canManage && (
<div className="mb-4 flex flex-wrap items-center gap-2">
<select
value={linkRole}
onChange={(e) => setLinkRole(e.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
aria-label="Role for the invite link"
>
{assignable("").map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
<select
value={linkTtl}
onChange={(e) => setLinkTtl(e.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
aria-label="How long the invite link stays valid"
>
{LINK_TTL_OPTIONS.map((option) => (
<option key={option.hours} value={String(option.hours)}>{option.label}</option>
))}
</select>
<Input
type="number"
min={1}
step={1}
value={linkMaxUses}
onChange={(e) => setLinkMaxUses(e.target.value)}
placeholder="Max uses (optional)"
className="max-w-[11rem]"
/>
<Button variant="outline" disabled={busy || roles.data.length === 0} onClick={() => void createLink()}>Create link</Button>
</div>
)}
{canManage && inviteLink !== null && (
<p className="mb-3 rounded-md border border-accent bg-accent-dim px-3 py-2 text-xs text-accent">Member added — share this link to activate (shown below).</p>
)}
{(inviteLink !== null || (createdLink !== null && createdLink.token !== undefined)) && (() => {
const link = inviteLink ?? createdLink;
if (link === null || link.token === undefined) return null;
return (
<div className="mb-4 rounded-md border border-border-subtle bg-surface-2 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium">
{inviteLink !== null ? "Member added — share this link to activate" : "Share this link — shown once"}
</span>
<Badge tone="accent">{link.role}</Badge>
</div>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md border border-border bg-bg px-2 py-1.5 font-mono text-2xs text-text">
{`${window.location.origin}/join/${link.token}`}
</code>
<Button
size="sm"
variant="outline"
onClick={() => {
void navigator.clipboard?.writeText(`${window.location.origin}/join/${link.token}`).then(() => {
setCopied(true);
}).catch(() => setCopied(false));
}}
>
{copied ? "Copied" : "Copy"}
</Button>
</div>
<p className="mt-2 text-2xs text-text-faint">
{inviteLink !== null
? "No invite email is sent — copy this link and give it to the member. The token is shown only once."
: "The token is never shown again — copy it now. Revoke the link below if it leaks."}
</p>
</div>
);
})()}
{links.error !== null && <p className="mb-3 text-xs text-danger">{links.error}</p>}
{/* On a live error (e.g. 403) the sample data is stale, so the list is withheld rather than faked. */}
{links.error === null && (
<table className="mobile-stack-table w-full text-sm">
<thead>
<tr className="border-b border-border-subtle text-left text-2xs text-text-faint">
<th className="pb-2 font-medium">Role</th>
<th className="pb-2 font-medium">Uses</th>
<th className="pb-2 font-medium">Status</th>
<th className="pb-2 font-medium">Expires</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{links.source === "loading" && (
<tr><td colSpan={5} className="py-3"><span className="inline-flex items-center gap-2 text-xs text-text-faint"><Loading size={14} label="Loading invite links" /> Loading invite links…</span></td></tr>
)}
{links.source !== "loading" && links.data.length === 0 && (
<tr><td colSpan={5} className="py-3 text-xs text-text-faint">No invite links yet — create one above.</td></tr>
)}
{links.source !== "loading" && links.data.map((link) => (
<tr key={link.id} className="border-b border-border-subtle last:border-b-0">
<td className="py-2.5 font-medium">{link.role}</td>
<td className="py-2.5 text-2xs text-text-faint">{usesLabel(link)}</td>
<td className="py-2.5"><Badge tone={linkStatusTone(link.status)}>{link.status}</Badge></td>
<td className="py-2.5 text-2xs text-text-faint">{new Date(link.expiresAt).toLocaleDateString()}</td>
<td className="py-2.5 text-right">
{link.status === "active" ? (
<Button size="sm" variant="ghost" disabled={!canManage || busy} onClick={() => void revokeLink(link)}>Revoke</Button>
) : (
<span className="text-2xs text-text-faint">{link.revokedAt !== null ? `revoked ${new Date(link.revokedAt).toLocaleDateString()}` : "—"}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
<p className="mt-1.5 text-2xs text-text-faint">
Tokens are returned once, at creation; the list never exposes them. Joining as <em>Owner</em> requires an Owner to have created the link.
</p>
</Card>
{/* Asset library stays on fixtures: the media library API is not part of this wiring pass. */}
<h2 className="mb-3 text-sm font-semibold">Asset library <Badge tone="neutral">sample</Badge></h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
{ASSETS.map((a) => (
<Card key={a.id} className="p-3">
<div className={`flex h-16 items-center justify-center rounded-md border font-mono text-2xs uppercase ${
a.locked ? "border-border-subtle bg-surface-2 text-text-faint" : "border-border-subtle text-text-dim"
}`}>
{a.name.split(".").pop()}
</div>
<div className="mt-2 flex items-center justify-between">
<span className="truncate text-2xs">{a.name}</span>
{a.locked && <Badge tone="neutral">group-locked</Badge>}
</div>
<div className="mt-1 text-2xs text-text-faint">{a.group}</div>
</Card>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,243 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "../../../components/ui/primitives";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { EmptyState } from "../../../components/brand/empty-state";
import { Loading } from "../../../components/brand/loading";
import { api, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import { useAuth } from "../../../lib/auth-store";
import { SAMPLE_VIDEO_ENTRIES } from "../../../lib/video-fixtures";
import type { VideoLibraryEntryDto, VideoLibraryPageDto, VideoStatusDto } from "../../../lib/video-types";
import { VideoCard, UploadingCard } from "../../../components/video/video-card";
import { UploadDropzone } from "../../../components/video/upload-dropzone";
import { VideoDetailDrawer } from "../../../components/video/video-detail-drawer";
import { DeleteVideoModal } from "../../../components/video/delete-video-modal";
/**
* Video library + upload. Live mode pages through GET /video by cursor (audit
* page's load-more pattern); offline the screen renders the sample library
* read-only per useApiResource conventions. In-flight assets auto-refresh so
* queued → probing → ready advances without a manual reload.
*/
const PAGE_SIZE = 24;
const IN_FLIGHT: VideoLibraryEntryDto["processingState"][] = ["queued", "probing", "transcoding"];
const REFRESH_MS = 6_000;
/** A pick in flight: shows as an "uploading…" card until the API answers. */
interface UploadDraft {
key: string;
name: string;
detail: string;
}
export default function VideoPage(): React.ReactElement {
const { status: authStatus, can } = useAuth();
const [items, setItems] = useState<VideoLibraryEntryDto[] | null>(null);
const [source, setSource] = useState<"loading" | "live" | "sample">("loading");
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined);
const [loadingMore, setLoadingMore] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<VideoLibraryEntryDto | null>(null);
const [deleting, setDeleting] = useState(false);
const [optimistic, setOptimistic] = useState<UploadDraft[]>([]);
const [notice, setNotice] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
const live = source === "live";
const canManage = !live || can("media.manage");
const loadFirstPage = useCallback((signal: AbortSignal): void => {
api
.get<VideoLibraryPageDto>("/video", { limit: PAGE_SIZE }, signal)
.then((page) => {
setItems(page.items);
setNextCursor(page.nextCursor);
setSource("live");
setError(null);
})
.catch((err: unknown) => {
if (signal.aborted) return;
if (err instanceof ApiUnreachableError) {
setSource("sample");
return;
}
setSource("live");
setError(errorMessage(err));
});
}, []);
useEffect(() => {
if (authStatus === "loading") return;
if (authStatus !== "authenticated") {
setSource("sample");
return;
}
const controller = new AbortController();
loadFirstPage(controller.signal);
return () => controller.abort();
}, [authStatus, loadFirstPage]);
const anyInFlight = useMemo(
() => (items ?? []).some((e) => IN_FLIGHT.includes(e.processingState)),
[items],
);
// Status refresh: keeps queued/probing/transcoding rows moving without a manual reload.
useEffect(() => {
if (!live || !anyInFlight) return;
const timer = setInterval(() => loadFirstPage(new AbortController().signal), REFRESH_MS);
return () => clearInterval(timer);
}, [live, anyInFlight, loadFirstPage]);
async function loadMore(): Promise<void> {
if (nextCursor === undefined) return;
setLoadingMore(true);
try {
const page = await api.get<VideoLibraryPageDto>("/video", { cursor: nextCursor, limit: PAGE_SIZE });
setItems((current) => {
const seen = new Set((current ?? []).map((e) => e.videoAsset.id));
return [...(current ?? []), ...page.items.filter((e) => !seen.has(e.videoAsset.id))];
});
setNextCursor(page.nextCursor);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoadingMore(false);
}
}
const onUploadStarted = useCallback((draft: UploadDraft): void => {
setOptimistic((current) => [draft, ...current]);
}, []);
const onUploadSettled = useCallback((key: string): void => {
setOptimistic((current) => current.filter((d) => d.key !== key));
}, []);
const applyStatus = useCallback((status: VideoStatusDto): void => {
const entry: VideoLibraryEntryDto = {
videoAsset: status.videoAsset,
processingState: status.processingJob.state,
publications: [],
};
setItems((current) => [entry, ...(current ?? []).filter((e) => e.videoAsset.id !== entry.videoAsset.id)]);
setSelectedId(entry.videoAsset.id);
}, []);
async function remove(entry: VideoLibraryEntryDto): Promise<void> {
setDeleting(true);
try {
await api.del<void>(`/video/${entry.videoAsset.id}`);
setItems((current) => (current ?? []).filter((e) => e.videoAsset.id !== entry.videoAsset.id));
if (selectedId === entry.videoAsset.id) setSelectedId(null);
setConfirmDelete(null);
setNotice({ tone: "ok", text: "Video deleted." });
} catch (err) {
setNotice({ tone: "error", text: errorMessage(err) });
} finally {
setDeleting(false);
}
}
const visible = live ? (items ?? []) : SAMPLE_VIDEO_ENTRIES;
const drafts = live ? optimistic : [];
const selected = visible.find((e) => e.videoAsset.id === selectedId) ?? null;
return (
<div className="h-full overflow-y-auto p-6 scrollbar-thin">
<div className="mb-5 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">Video</h1>
<OfflineBadge source={source} />
</div>
<Button
size="sm"
variant="outline"
disabled={!live}
onClick={() => loadFirstPage(new AbortController().signal)}
>
Refresh
</Button>
</div>
{error !== null && <p className="mb-3 text-xs text-danger">{error}</p>}
<UploadDropzone
live={live}
canUpload={canManage}
onStarted={onUploadStarted}
onSettled={onUploadSettled}
onUploaded={applyStatus}
/>
{source === "loading" && (
<div className="flex items-center gap-2 text-xs text-text-faint">
<Loading size={16} label="Loading video library" /> Loading video library…
</div>
)}
{source !== "loading" && visible.length === 0 && (
<EmptyState
title="No videos yet"
hint="Drop a file above to upload it to this workspace. Videos are stored encrypted and probed before they are ready to attach to posts."
/>
)}
{source !== "loading" && (drafts.length > 0 || visible.length > 0) && (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{drafts.map((draft) => (
<UploadingCard key={draft.key} name={draft.name} detail={draft.detail} />
))}
{visible.map((entry) => (
<VideoCard
key={entry.videoAsset.id}
entry={entry}
active={entry.videoAsset.id === selectedId}
onOpen={() => setSelectedId(entry.videoAsset.id)}
onDelete={() => {
if (!canManage) {
setNotice({ tone: "error", text: "Deleting videos needs the media.manage permission." });
return;
}
if (!live) {
setNotice({ tone: "error", text: "Offline — sample data is read-only." });
return;
}
setConfirmDelete(entry);
}}
/>
))}
</div>
)}
{live && nextCursor !== undefined && (
<div className="mt-4 flex justify-center">
<Button variant="outline" size="sm" disabled={loadingMore} onClick={() => void loadMore()}>
{loadingMore ? "Loading…" : "Load more"}
</Button>
</div>
)}
{notice !== null && (
<p className={`mt-3 text-xs ${notice.tone === "ok" ? "text-live" : "text-danger"}`}>{notice.text}</p>
)}
<p className="mt-6 text-2xs leading-relaxed text-text-faint">
Videos are stored encrypted and probed on upload. The original file is retained;
rendering and transcoding run separately in the media worker.
</p>
{selected !== null && <VideoDetailDrawer key={selected.videoAsset.id} entry={selected} onClose={() => setSelectedId(null)} />}
{confirmDelete !== null && (
<DeleteVideoModal
asset={confirmDelete.videoAsset}
busy={deleting}
onCancel={() => setConfirmDelete(null)}
onConfirm={() => void remove(confirmDelete)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,88 @@
"use client";
import Link from "next/link";
import { useWikiLang, useWikiPages } from "../lang-context";
import { WikiBody } from "../body";
interface WikiSlugPageProps {
params: { slug: string };
}
/**
* One manual: title, blurb, then every section in order, with prev/next
* navigation in reading order. Unknown slugs render a "not found" panel that
* links back to the table of contents.
*/
export default function WikiSlugPage({ params }: WikiSlugPageProps): React.ReactElement | null {
const pages = useWikiPages();
const { t } = useWikiLang();
const index = pages.findIndex((page) => page.slug === params.slug);
const page = index >= 0 ? pages[index] : undefined;
if (page === undefined) {
return (
<div className="rounded-lg border border-border-subtle bg-surface p-6">
<h1 className="mb-2 text-lg font-semibold">{t({ en: "Page not found", es: "Página no encontrada" })}</h1>
<p className="mb-4 text-sm text-text-dim">
{t({
en: "That manual does not exist. Head back to the table of contents and pick from the list.",
es: "Ese manual no existe. Vuelve al índice y elige de la lista.",
})}
</p>
<Link href="/wiki" className="text-sm font-medium text-accent hover:underline">
{t({ en: "← All manuals", es: "← Todos los manuales" })}
</Link>
</div>
);
}
const previous = index > 0 ? pages[index - 1] : undefined;
const next = index < pages.length - 1 ? pages[index + 1] : undefined;
return (
<article>
<Link href="/wiki" className="mb-3 inline-block text-2xs text-text-faint transition-colors duration-150 hover:text-accent">
{t({ en: "← Contents", es: "← Contenido" })}
</Link>
<p className="mb-1 font-mono text-2xs font-bold uppercase tracking-wider text-accent">
{t({ en: "Manual", es: "Manual" })} {String(index + 1).padStart(2, "0")} / {String(pages.length).padStart(2, "0")}
</p>
<h1 className="mb-2 text-2xl font-bold">{t(page.title)}</h1>
<p className="mb-6 border-l-2 border-accent pl-3 text-sm leading-relaxed text-text-dim">{t(page.blurb)}</p>
<div className="flex flex-col gap-7">
{page.sections.map((section, sectionIndex) => (
<section key={`${page.slug}-${sectionIndex}`}>
<h2 className="mb-2 text-base font-semibold text-text">{t(section.heading)}</h2>
<div className="text-sm text-text-dim">
<WikiBody text={t(section.body)} />
</div>
</section>
))}
</div>
<nav className="mt-10 flex flex-col gap-2 border-t border-border-subtle pt-5 sm:flex-row">
{previous !== undefined ? (
<Link
href={`/wiki/${previous.slug}`}
className="min-w-0 flex-1 rounded-lg border border-border-subtle bg-surface p-3 transition-colors duration-150 hover:border-accent"
>
<span className="block text-2xs text-text-faint">{t({ en: "Previous", es: "Anterior" })}</span>
<span className="block truncate text-sm font-medium">{t(previous.title)}</span>
</Link>
) : (
<span className="hidden min-w-0 flex-1 sm:block" />
)}
{next !== undefined ? (
<Link
href={`/wiki/${next.slug}`}
className="min-w-0 flex-1 rounded-lg border border-border-subtle bg-surface p-3 text-right transition-colors duration-150 hover:border-accent"
>
<span className="block text-2xs text-text-faint">{t({ en: "Next", es: "Siguiente" })}</span>
<span className="block truncate text-sm font-medium">{t(next.title)}</span>
</Link>
) : (
<span className="hidden min-w-0 flex-1 sm:block" />
)}
</nav>
</article>
);
}

View file

@ -0,0 +1,52 @@
"use client";
import { Fragment, type ReactNode } from "react";
/**
* Minimal markup for the wiki bodies, matching what the content file promises:
* blank-line paragraphs, "- " bullets, numbered steps, and **bold** for
* on-screen labels. Kept deliberately small — no HTML from content is ever
* injected, so manuals stay safe to edit for non-technical contributors.
*/
function inline(text: string, keyPrefix: string): ReactNode[] {
return text.split(/(\*\*[^*]+\*\*)/g).filter(Boolean).map((part, index) =>
part.startsWith("**") && part.endsWith("**") && part.length > 4 ? (
<strong key={`${keyPrefix}-${index}`} className="font-semibold text-text">
{part.slice(2, -2)}
</strong>
) : (
<Fragment key={`${keyPrefix}-${index}`}>{part}</Fragment>
),
);
}
function renderBlock(block: string, key: string): ReactNode {
const lines = block.split("\n");
const isBullets = lines.every((line) => line.startsWith("- "));
const isSteps = lines.length > 0 && lines.every((line) => /^\d+\.\s/.test(line));
if (isBullets) {
return (
<ul key={key} className="my-3 list-disc space-y-1.5 pl-5 marker:text-accent">
{lines.map((line, index) => (
<li key={`${key}-${index}`}>{inline(line.slice(2), `${key}-${index}`)}</li>
))}
</ul>
);
}
if (isSteps) {
return (
<ol key={key} className="my-3 list-decimal space-y-1.5 pl-5 marker:font-mono marker:text-text-faint">
{lines.map((line, index) => (
<li key={`${key}-${index}`}>{inline(line.replace(/^\d+\.\s/, ""), `${key}-${index}`)}</li>
))}
</ol>
);
}
return <p key={key}>{inline(block, key)}</p>;
}
export function WikiBody({ text }: { text: string }): React.ReactElement {
const blocks = text.split(/\n{2,}/).filter((block) => block.trim().length > 0);
return <div className="space-y-1.5 leading-relaxed">{blocks.map((block, index) => renderBlock(block, `b${index}`))}</div>;
}

View file

@ -0,0 +1,65 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { WIKI_PAGES, type LocalizedText } from "../../../lib/wiki-content";
export type WikiLang = "en" | "es";
interface WikiLangContextValue {
lang: WikiLang;
setLang: (lang: WikiLang) => void;
/** Pick the string for the current language — the one helper every wiki screen uses. */
t: (text: LocalizedText) => string;
}
const STORAGE_KEY = "tmbh.wiki.lang";
const WikiLangContext = createContext<WikiLangContextValue | null>(null);
function readStoredLang(): WikiLang {
if (typeof window === "undefined") return "en";
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === "es" ? "es" : "en";
} catch {
return "en";
}
}
/**
* Language toggle for the wiki only. The choice is per-browser (localStorage)
* and never sent to the server: it is a reading preference, not an account
* setting, and the app has no per-user preference store to put it in.
*/
export function WikiLangProvider({ children }: { children: ReactNode }): React.ReactElement {
const [lang, setLangState] = useState<WikiLang>("en");
useEffect(() => {
setLangState(readStoredLang());
}, []);
const setLang = useCallback((next: WikiLang) => {
setLangState(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Private-browsing modes can refuse storage; the choice still holds for
// this screen, it just will not survive a reload.
}
}, []);
const t = useCallback((text: LocalizedText) => text[lang], [lang]);
const value = useMemo<WikiLangContextValue>(() => ({ lang, setLang, t }), [lang, setLang, t]);
return <WikiLangContext.Provider value={value}>{children}</WikiLangContext.Provider>;
}
export function useWikiLang(): WikiLangContextValue {
const context = useContext(WikiLangContext);
if (context === null) throw new Error("useWikiLang must be used inside WikiLangProvider");
return context;
}
/** The page list in reading order — sidebar, table of contents and prev/next all share it. */
export function useWikiPages() {
return WIKI_PAGES;
}

View file

@ -0,0 +1,73 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useWikiLang, useWikiPages } from "./lang-context";
/**
* Wiki shell: page list on the left (collapses to a top strip on mobile) and
* the EN/ES toggle. The language lives in localStorage per browser — see
* lang-context for why it is not an account setting.
*/
export default function WikiLayout({ children }: { children: ReactNode }): React.ReactElement {
const pages = useWikiPages();
const { lang, setLang, t } = useWikiLang();
const pathname = usePathname();
const activeSlug = pathname.split("/")[2] ?? "";
return (
<div className="flex h-full min-h-0 flex-col lg:flex-row">
<aside className="flex min-h-0 shrink-0 flex-col border-b border-border-subtle bg-surface lg:w-64 lg:border-b-0 lg:border-r">
<div className="flex items-center gap-3 px-4 py-3">
<Link href="/wiki" className="min-w-0 flex-1">
<span className="block text-sm font-bold">
{t({ en: "Help & docs", es: "Ayuda y documentación" })}
</span>
<span className="block truncate text-2xs text-text-faint">
{t({ en: "TMBH manuals", es: "Manuales de TMBH" })}
</span>
</Link>
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label="Language">
{(["en", "es"] as const).map((code) => (
<button
key={code}
type="button"
onClick={() => setLang(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>
</div>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-3 lg:pb-6">
<ul className="flex flex-col gap-0.5">
{pages.map((page, index) => {
const active = page.slug === activeSlug;
return (
<li key={page.slug}>
<Link
href={`/wiki/${page.slug}`}
className={`flex min-h-9 items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors duration-150 ${
active ? "bg-accent-dim font-semibold text-accent" : "text-text-dim hover:bg-surface-2 hover:text-text"
}`}
>
<span className="w-4 shrink-0 text-right font-mono text-2xs text-text-faint">{index + 1}</span>
<span className="min-w-0 truncate">{t(page.title)}</span>
</Link>
</li>
);
})}
</ul>
</nav>
</aside>
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl px-4 py-6 pb-24 lg:px-8 lg:py-8">{children}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import { useWikiLang, useWikiPages } from "./lang-context";
/**
* Wiki home: table of contents with every manual title and a one-line summary.
* Numbered so a printed or shared copy reads as a course, not a link dump.
*/
export default function WikiHomePage(): React.ReactElement {
const pages = useWikiPages();
const { t } = useWikiLang();
return (
<div>
<p className="mb-1 text-2xs font-bold uppercase tracking-wider text-accent">
{t({ en: "Documentation", es: "Documentación" })}
</p>
<h1 className="mb-2 text-2xl font-bold">{t({ en: "TMBH manual", es: "Manual de TMBH" })}</h1>
<p className="mb-6 text-sm leading-relaxed text-text-dim">
{t({
en: "Step-by-step guides for every part of the newsroom, written for people who organise — not for developers. Switch language with the EN / ES buttons above; your choice is remembered on this device.",
es: "Guías paso a paso de cada parte de la redacción, escritas para gente que organiza, no para programadores. Cambia de idioma con los botones EN / ES de arriba; tu elección queda recordada en este dispositivo.",
})}
</p>
<h2 className="mb-3 text-xs font-bold uppercase tracking-wider text-text-faint">
{t({ en: "Contents", es: "Contenido" })}
</h2>
<ol className="flex flex-col gap-2.5">
{pages.map((page, index) => (
<li key={page.slug}>
<Link
href={`/wiki/${page.slug}`}
className="flex gap-3 rounded-lg border border-border-subtle bg-surface p-3.5 transition-colors duration-150 hover:border-accent"
>
<span className="shrink-0 font-mono text-xs font-bold text-accent">{String(index + 1).padStart(2, "0")}</span>
<span className="min-w-0">
<span className="block text-sm font-semibold">{t(page.title)}</span>
<span className="mt-0.5 block text-xs leading-relaxed text-text-faint">{t(page.blurb)}</span>
</span>
<span aria-hidden="true" className="ml-auto self-center text-text-faint">›</span>
</Link>
</li>
))}
</ol>
</div>
);
}

View file

@ -0,0 +1,102 @@
@import "tailwindcss";
@theme {
--color-bg: oklch(0.11 0.008 25);
--color-surface: oklch(0.15 0.01 25);
--color-surface-2: oklch(0.19 0.012 25);
--color-border: oklch(0.26 0.02 25);
--color-border-subtle: oklch(0.22 0.015 25);
--color-text: oklch(0.93 0.005 25);
--color-text-dim: oklch(0.62 0.02 25);
--color-text-faint: oklch(0.46 0.02 25);
--color-accent: #ff1610;
--color-accent-hover: #e3130e;
--color-accent-dim: rgb(255 22 16 / 0.15);
--color-live: oklch(0.72 0.19 145);
--color-warn: oklch(0.78 0.16 85);
--color-danger: oklch(0.55 0.24 15);
--color-added: oklch(0.72 0.16 145);
--color-removed: oklch(0.55 0.24 15);
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--text-2xs: 0.6875rem;
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.875rem;
--text-lg: 1rem;
--mobile-tab-height: 76px;
--mobile-fab-size: 64px;
--mobile-sheet-radius: 28px;
--mobile-safe-bottom: env(safe-area-inset-bottom, 0px);
}
@theme inline {
--color-brand: var(--color-accent);
}
html {
color-scheme: dark;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
font-size: var(--text-base);
-webkit-font-smoothing: antialiased;
}
html, body { max-width: 100%; }
@media (max-width: 1023px) {
body { overflow-x: hidden; }
[data-mobile-main] :is(button, a[href], input, select, textarea, [role="tab"]) {
min-height: 44px;
}
[data-mobile-main] :is(button, a[href], [role="tab"]) {
min-width: 44px;
}
[data-mobile-main] p a[href] { display: inline-flex; align-items: center; }
[data-mobile-main] .mobile-stack-table,
[data-mobile-main] .mobile-stack-table tbody,
[data-mobile-main] .mobile-stack-table tr,
[data-mobile-main] .mobile-stack-table td { display: block; width: 100%; min-width: 0; }
[data-mobile-main] .mobile-stack-table thead { display: none; }
[data-mobile-main] .mobile-stack-table tr { padding: 8px 0; }
[data-mobile-main] .mobile-stack-table td { overflow-wrap: anywhere; }
[data-mobile-main] .mobile-stack-table td > * { max-width: 100%; }
}
::selection {
background: var(--color-accent-dim);
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}

View file

@ -0,0 +1,328 @@
"use client";
import { useEffect, useState, type FormEvent } from "react";
import Link from "next/link";
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 { useAuth } from "../../../lib/auth-store";
const MIN_PASSWORD = 10;
const USERNAME_PATTERN = /^[a-z0-9][a-z0-9_.-]{1,31}$/;
const USERNAME_TITLE = "2-32 characters: lowercase letters, digits, dot, underscore or hyphen";
const API_UNREACHABLE = "API unreachable — check that the API server is running.";
type Phase = "loading" | "preview" | "dead" | "unreachable";
/** Why the link no longer works — drives the copy on the dead-token card. */
type DeadReason = "unknown" | "expired" | "exhausted";
/** 404 (unknown/revoked) vs 410 (expired or exhausted, told apart by the message). Anything else reads as unknown. */
function deadReasonFrom(err: unknown): DeadReason {
if (err instanceof ApiError && err.status === 410) {
const message = err.message.toLowerCase();
if (message.includes("expired")) return "expired";
if (message.includes("used up") || message.includes("no uses left")) return "exhausted";
}
return "unknown";
}
const isAlreadyMember = (err: unknown): boolean => err instanceof ApiError && err.status === 409;
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()}`;
}
/** Sign in with an existing account, then claim: login installs the account session, claimInvite swaps it for the workspace one. */
function JoinSignInForm({
token,
onSwitch,
onAlreadyMember,
}: {
token: string;
onSwitch: () => void;
onAlreadyMember: () => void;
}): React.ReactElement {
const { login, claimInvite } = 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);
await claimInvite(token);
window.location.assign("/streams");
} catch (err) {
// Login worked but the workspace already holds this user: that is a success state.
if (isAlreadyMember(err)) {
setBusy(false);
onAlreadyMember();
return;
}
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}>New here? Create an account</Button>
<Button type="submit" variant="primary" disabled={busy || username.trim() === "" || password === ""}>
{busy ? "Joining…" : "Sign in & join"}
</Button>
</div>
</form>
);
}
/**
* Create-or-authenticate inline, then claim. Registering normally spins up a
* new workspace, so the claim endpoint takes the credentials itself and hands
* back a session issued in the invited workspace.
*/
function RegisterToJoinForm({
token,
onSwitch,
onAlreadyMember,
}: {
token: string;
onSwitch: () => void;
onAlreadyMember: () => void;
}): React.ReactElement {
const { claimInviteWithCredentials } = useAuth();
const [displayName, setDisplayName] = useState("");
const [username, setUsername] = useState("");
const [email, setEmail] = 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 claimInviteWithCredentials(token, {
username: username.trim(),
password,
...(displayName.trim() === "" ? {} : { displayName: displayName.trim() }),
...(email.trim() === "" ? {} : { email: email.trim() }),
});
window.location.assign("/streams");
} catch (err) {
if (isAlreadyMember(err)) {
setBusy(false);
onAlreadyMember();
return;
}
setError(errorMessage(err));
setBusy(false);
}
}
const ready = USERNAME_PATTERN.test(username.trim()) && password.length >= MIN_PASSWORD;
return (
<form onSubmit={(e) => void submit(e)}>
<h2 className="mb-1 text-base font-semibold">Create your account</h2>
<p className="mb-4 text-xs text-text-faint">
Registering accepts the invite in one step — no workspace of your own is created.
</p>
<div className="space-y-2">
<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={USERNAME_TITLE}
/>
<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 — password recovery)" autoComplete="email" />
</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}>I already have an account</Button>
<Button type="submit" variant="primary" disabled={busy || !ready}>
{busy ? "Joining…" : "Join workspace"}
</Button>
</div>
</form>
);
}
export default function JoinWorkspacePage({ params }: { params: { token: string } }): React.ReactElement {
const { status, session, isLoading, claimInvite } = useAuth();
const [phase, setPhase] = useState<Phase>("loading");
const [info, setInfo] = useState<InviteInfo | null>(null);
const [deadReason, setDeadReason] = useState<DeadReason>("unknown");
const [mode, setMode] = useState<"register" | "signin">("register");
const [claiming, setClaiming] = useState(false);
const [claimError, setClaimError] = useState<string | null>(null);
const [alreadyMember, setAlreadyMember] = useState(false);
const [attempt, setAttempt] = useState(0);
// The preview is public: fetch it immediately rather than waiting for the session to settle.
useEffect(() => {
let cancelled = false;
setPhase("loading");
apiFetch<InviteInfo>(`/invites/${encodeURIComponent(params.token)}/info`, { redirectOn401: false })
.then((preview) => {
if (cancelled) return;
setInfo(preview);
setPhase("preview");
})
.catch((err: unknown) => {
if (cancelled) return;
if (err instanceof ApiUnreachableError) setPhase("unreachable");
else {
setDeadReason(deadReasonFrom(err));
setPhase("dead");
}
});
return () => {
cancelled = true;
};
}, [params.token, attempt]);
const retry = (): void => setAttempt((n) => n + 1);
async function claim(): Promise<void> {
setClaiming(true);
setClaimError(null);
try {
await claimInvite(params.token);
// Full navigation so the (main) shell mounts with the freshly installed session.
window.location.assign("/streams");
} catch (err) {
if (isAlreadyMember(err)) {
setAlreadyMember(true);
setClaiming(false);
return;
}
setClaimError(errorMessage(err));
setClaiming(false);
}
}
if (phase === "loading" || isLoading) return <LoadingPanel label="Checking invite…" />;
if (phase === "unreachable") {
return (
<Card className="w-[520px] max-w-full p-6 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-warn/15 text-lg text-warn">!</div>
<h1 className="text-base font-semibold">Can&apos;t reach the server</h1>
<p className="mt-1 text-xs text-text-faint">
{API_UNREACHABLE} Your invite link is still fine — try again.
</p>
<Button variant="outline" className="mt-6" onClick={retry}>Retry</Button>
</Card>
);
}
if (phase === "dead") {
return (
<Card className="w-[520px] max-w-full p-6 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-danger/15 text-lg text-danger">✕</div>
<h1 className="text-base font-semibold">This invite link isn&apos;t valid</h1>
<p className="mt-1 text-xs text-text-faint">
{deadReason === "expired"
? "It expired."
: deadReason === "exhausted"
? "It has already been used up."
: "It was revoked or never existed."}{" "}
Ask a workspace owner for a fresh invite.
</p>
<Link href="/onboarding" className="mt-6 inline-block rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">
Start or sign in to your own workspace
</Link>
</Card>
);
}
if (info === null) return <LoadingPanel label="Checking invite…" />;
const memberCta = alreadyMember ? (
<div className="mt-6 flex items-center justify-between">
<span className="text-xs text-text-faint">You&apos;re already in this workspace.</span>
<Link href="/streams" className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">Go to Streams</Link>
</div>
) : (
<div className="mt-6 flex items-center justify-between">
<span className="text-2xs text-text-faint">Signed in as @{session?.user.username}</span>
<Button variant="primary" disabled={claiming} onClick={() => void claim()}>
{claiming ? "Joining…" : "Join workspace"}
</Button>
</div>
);
// Signed in already: one click claims the invite with the current session.
if (status === "authenticated" && session !== null) {
return (
<Card className="w-[520px] max-w-full p-6">
<h1 className="text-xl font-semibold tracking-tight">Join {info.workspaceName}</h1>
<p className="mt-1 text-xs text-text-faint">
You&apos;ve been invited to join <span className="text-text">{info.workspaceName}</span> as{" "}
<Badge tone="accent">{info.role}</Badge>.
</p>
<p className="mt-2 text-2xs text-text-faint">{usesLeftLine(info)}</p>
{claimError !== null && <p className="mt-2 text-xs text-danger">{claimError}</p>}
{memberCta}
</Card>
);
}
// Anonymous (or the API dropped between mount and now): hero + register/sign-in forms.
return (
<Card className="w-[520px] max-w-full p-6">
<div className="mb-5 text-center">
<img
src="/brand/hero.png"
alt="Tactical Media Broadcast Hub — arrows from a circle, warm red on black"
width={1024}
height={1024}
className="mx-auto mb-3 h-20 w-20 rounded-lg object-cover"
/>
<h1 className="text-xl font-semibold tracking-tight">Join {info.workspaceName}</h1>
<p className="mt-1 text-2xs text-text-faint">
You&apos;ve been invited as <Badge tone="accent">{info.role}</Badge> · {usesLeftLine(info)}
</p>
</div>
{mode === "signin" ? (
<JoinSignInForm token={params.token} onSwitch={() => setMode("register")} onAlreadyMember={() => setAlreadyMember(true)} />
) : (
<RegisterToJoinForm token={params.token} onSwitch={() => setMode("signin")} onAlreadyMember={() => setAlreadyMember(true)} />
)}
{alreadyMember && (
<div className="mt-5 border-t border-border-subtle pt-4 text-center">
<p className="text-xs text-text-faint">You&apos;re already in this workspace.</p>
<Link href="/streams" className="mt-2 inline-block rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">
Go to Streams
</Link>
</div>
)}
</Card>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import type { ReactNode } from "react";
import { AuthProvider } from "../../lib/auth-store";
/**
* Anonymous-safe layout for the invite claim flow. It deliberately lives
* outside the `(main)` group: that layout mounts the app Shell and wraps
* children in `AuthGate`, which bounces anonymous visitors to /onboarding —
* the opposite of what a shared invite link needs. Here we mount only
* `AuthProvider` so the page can branch on "already signed in" vs anonymous.
*/
export default function JoinLayout({ children }: { children: ReactNode }): React.ReactNode {
return (
<AuthProvider>
<div className="flex h-dvh items-center justify-center overflow-y-auto bg-bg p-6">{children}</div>
</AuthProvider>
);
}

View file

@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Tactical Media Broadcast Hub",
description: "Tactical Media Broadcast Hub — post, schedule and amplify across every network you refuse to be owned by.",
icons: { icon: "/brand/favicon.svg" },
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
interactiveWidget: "resizes-content",
themeColor: "#FF1610",
};
export default function RootLayout({ children }: { children: ReactNode }): React.ReactNode {
return (
<html lang="en">
<body className="min-h-screen antialiased">{children}</body>
</html>
);
}

View file

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage(): never {
redirect("/streams");
}

View file

@ -0,0 +1,50 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "../../lib/auth-store";
import { ONBOARDING_PATH } from "../../lib/api-client";
import { LoadingPanel } from "../brand/loading";
/** Sends visitors without a session to onboarding. Offline (API unreachable) renders sample-data screens instead. */
export function AuthGate({ children }: { children: ReactNode }): React.ReactElement {
const { status } = useAuth();
const pathname = usePathname();
const router = useRouter();
const onOnboarding = pathname === ONBOARDING_PATH;
useEffect(() => {
if (status === "anonymous" && !onOnboarding) router.replace(ONBOARDING_PATH);
}, [status, onOnboarding, router]);
if (!onOnboarding && (status === "loading" || status === "anonymous")) {
return <LoadingPanel label="Checking session…" />;
}
return <>{children}</>;
}
export function SessionStatus(): React.ReactElement {
const { status, session, logout } = useAuth();
if (status === "offline") {
return (
<span className="flex items-center gap-2" title="API unreachable — screens show sample data">
<span className="h-1.5 w-1.5 rounded-full bg-warn" />
offline — sample data
</span>
);
}
if (status !== "authenticated" || session === null) {
return <span>{status === "loading" ? "connecting…" : "signed out"}</span>;
}
return (
<span className="flex items-center gap-3">
<span className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-live" />
@{session.user.username} · {session.role}
</span>
<button onClick={() => void logout()} className="text-text-dim transition-colors duration-150 hover:text-accent">
Sign out
</button>
</span>
);
}

View file

@ -0,0 +1,28 @@
import type { ReactElement } from "react";
import { CircleA } from "./logo";
/**
* Branded empty state: the circle-A outline sitting low-contrast behind the
* message, the way a screen-printed mark sits behind text on a black tee.
*/
export function EmptyState({
title,
hint,
size = 64,
className = "",
}: {
title: string;
hint?: string;
size?: number;
className?: string;
}): ReactElement {
return (
<div className={`flex flex-col items-center justify-center gap-3 py-8 text-center ${className}`}>
<CircleA size={size} className="text-accent/30" />
<p className="text-sm font-medium text-text-dim">{title}</p>
{hint !== undefined && (
<p className="max-w-sm text-2xs leading-relaxed text-text-faint">{hint}</p>
)}
</div>
);
}

View file

@ -0,0 +1,90 @@
import type { ReactElement } from "react";
interface LogoProps {
size?: number;
withText?: boolean;
}
export function Logo({ size = 32, withText = false }: LogoProps): ReactElement {
return (
<span className="flex items-center gap-2">
<svg width={size} height={size} viewBox="0 0 1200 1200" aria-label="Tactical Media Broadcast Hub">
<g fill="none" stroke="var(--color-accent)" strokeWidth="72" strokeLinecap="round" strokeLinejoin="round">
<circle cx="600" cy="610" r="330" />
<path d="M 365 915 L 600 250 L 835 915" strokeWidth="92" />
<path d="M 250 610 L 950 610" strokeWidth="82" />
</g>
<g fill="var(--color-accent)">
<polygon points="600.0,145.0 660.0,270.0 540.0,270.0" />
<polygon points="125.0,610.0 250.0,550.0 250.0,670.0" />
<polygon points="1075.0,610.0 950.0,670.0 950.0,550.0" />
<polygon points="285.0,1050.0 331.0,919.2 415.8,1004.0" />
<polygon points="915.0,1050.0 784.2,1004.0 869.0,919.2" />
</g>
</svg>
{withText && (
<span className="text-sm font-bold tracking-tight" style={{ color: "var(--color-accent)" }}>
Tactical Media Broadcast Hub
</span>
)}
</span>
);
}
export function Spinner({ size = 24 }: { size?: number }): ReactElement {
return (
<svg
width={size}
height={size}
viewBox="0 0 1200 1200"
className="animate-spin"
style={{ animationDuration: "1.2s" }}
aria-label="Loading"
>
<path d="M 600 280 A 330 330 0 0 1 930 610" fill="none" stroke="var(--color-accent)" strokeWidth="90" strokeLinecap="round" />
<path d="M 930 610 A 330 330 0 0 1 600 940" fill="none" stroke="var(--color-accent)" strokeWidth="90" strokeLinecap="round" opacity="0.2" />
<path d="M 600 940 A 330 330 0 0 1 270 610" fill="none" stroke="var(--color-accent)" strokeWidth="90" strokeLinecap="round" opacity="0.2" />
<path d="M 270 610 A 330 330 0 0 1 600 280" fill="none" stroke="var(--color-accent)" strokeWidth="90" strokeLinecap="round" opacity="0.2" />
</svg>
);
}
export function EmptyState({ message, size = 120 }: { message: string; size?: number }): ReactElement {
return (
<div className="flex flex-col items-center justify-center gap-4 py-12">
<svg width={size} height={size} viewBox="0 0 1200 1200" opacity="0.25">
<g fill="none" stroke="var(--color-accent)" strokeWidth="80" strokeLinecap="round" strokeLinejoin="round">
<circle cx="600" cy="610" r="330" />
<path d="M 365 915 L 600 250 L 835 915" strokeWidth="100" />
<path d="M 250 610 L 950 610" strokeWidth="80" />
</g>
</svg>
<p className="text-xs text-text-faint">{message}</p>
</div>
);
}
export function PlatformIcon({ platform, size = 20 }: { platform: string; size?: number }): ReactElement {
return (
<img
src={`/brand/icon-${platform}.svg`}
alt={platform}
width={size}
height={size}
className="rounded-full"
loading="lazy"
/>
);
}
export function NavIcon({ name, size = 18 }: { name: string; size?: number }): ReactElement {
return (
<img
src={`/brand/nav-${name}.svg`}
alt={name}
width={size}
height={size}
loading="lazy"
/>
);
}

View file

@ -0,0 +1,62 @@
import type { ReactElement } from "react";
import { LogoMark } from "./logo";
/**
* Branded loading state: the mark upright inside a spinning ring.
*
* The mark itself is radially asymmetric (the A's apex sits high in its
* bounding box), so rotating the mark would wobble instead of spin. The ring
* carries the rotation; the A stays plumb. `animation` rather than
* Tailwind's animate-spin so globals.css's prefers-reduced-motion override
* (0.01ms) still applies via the transition/animation shorthand.
*/
export function Loading({
size = 28,
label = "Loading…",
className = "",
}: {
size?: number;
label?: string;
className?: string;
}): ReactElement {
return (
<span
role="status"
aria-label={label}
className={`relative inline-flex items-center justify-center ${className}`}
style={{ width: size, height: size }}
>
<svg
width={size}
height={size}
viewBox="0 0 100 100"
aria-hidden="true"
className="absolute inset-0 text-accent"
style={{ animation: "anp-spin 0.9s linear infinite" }}
>
{/* Three-quarter arc: the gap becomes the motion cue. */}
<circle
cx="50"
cy="50"
r="44"
fill="none"
stroke="currentColor"
strokeWidth="6"
strokeLinecap="round"
strokeDasharray="207 276"
/>
</svg>
<LogoMark size={Math.round(size * 0.5)} className="text-accent" title="" />
</span>
);
}
/** Full-panel variant for screens that are still fetching their first page. */
export function LoadingPanel({ label }: { label?: string }): ReactElement {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<Loading size={32} label={label ?? "Loading…"} />
{label !== undefined && <span className="text-2xs text-text-faint">{label}</span>}
</div>
);
}

View file

@ -0,0 +1,122 @@
import type { ReactElement } from "react";
/**
* The Tactical Media Broadcast Hub mark, inline.
*
* Source of truth: `public/brand/tactical-media-broadcast-hub.svg` — the arrows-from-circle
* mark, tilted −17.5° counterclockwise so the top arrow points upper-left
* (nothing static; everything points outward). The strokes use `currentColor`
* so the mark always rides the workspace accent, which in the warm palette
* lands within a hair of the brand red (#FF1610 ≈ oklch(0.63 0.24 29) vs the
* token's oklch(0.62 0.22 30)). Geometry is copied 1:1 from the asset on its
* 1700×1700 grid, minus the black plate and the screen-print distress paths
* (those only read on pure black at full size).
*/
export function LogoMark({
size = 32,
className = "text-accent",
title = "Tactical Media Broadcast Hub",
}: {
size?: number;
className?: string;
title?: string;
}): ReactElement {
return (
<svg
width={size}
height={size}
viewBox="0 0 1700 1700"
role="img"
aria-label={title}
className={className}
>
<g transform="rotate(-17.5 850 850)">
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<circle cx="850" cy="850" r="350" strokeWidth="74" />
<path d="M 850 405 L 464.77 1269.52" strokeWidth="88" />
<path d="M 850 405 L 1235.23 1269.52" strokeWidth="88" />
<path d="M 850 405 L 850 350" strokeWidth="88" />
<path d="M 345 850 L 1355 850" strokeWidth="88" />
</g>
<g fill="currentColor">
<polygon points="850,240 925,385 775,385" />
<polygon points="235,850 380,775 380,925" />
<polygon points="1465,850 1320,925 1320,775" />
<polygon points="420,1370 410.51,1207.03 547.52,1268.08" />
<polygon points="1280,1370 1152.48,1268.08 1289.49,1207.03" />
</g>
</g>
</svg>
);
}
/**
* The quiet version of the mark: just the circle-A outline, no arrowheads.
* Used wherever the brand needs to whisper — empty states, placeholders.
*/
export function CircleA({
size = 64,
className = "text-accent",
}: {
size?: number;
className?: string;
}): ReactElement {
return (
<svg
width={size}
height={size}
viewBox="0 0 1700 1700"
aria-hidden="true"
className={className}
>
<g transform="rotate(-17.5 850 850)">
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<circle cx="850" cy="850" r="350" strokeWidth="74" />
<path d="M 850 405 L 464.77 1269.52" strokeWidth="88" />
<path d="M 850 405 L 1235.23 1269.52" strokeWidth="88" />
<path d="M 850 405 L 850 350" strokeWidth="88" />
<path d="M 345 850 L 1355 850" strokeWidth="88" />
</g>
</g>
</svg>
);
}
/**
* A single arrowhead lifted from the mark — the brand's bullet point.
* Points the same way the right-hand barb of the circle-A does.
*/
export function ArrowGlyph({
size = 12,
className = "text-accent",
}: {
size?: number;
className?: string;
}): ReactElement {
return (
<svg width={size} height={size} viewBox="0 0 120 120" aria-hidden="true" className={className}>
<polygon points="120,60 30,14 30,106" fill="currentColor" />
</svg>
);
}
/**
* Mark + wordmark. Inline below ~96px (sidebar, palette); stacked above it
* (onboarding hero) so the wordmark never gets dwarfed by the mark.
*/
export function Logo({ size = 32, withText = true }: { size?: number; withText?: boolean }): ReactElement {
if (!withText) return <LogoMark size={size} />;
const stacked = size >= 96;
return (
<span className={`inline-flex ${stacked ? "flex-col items-center gap-3" : "items-center gap-2.5"}`}>
<LogoMark size={size} />
<span
className={`font-semibold tracking-tight text-text ${stacked ? "text-2xl" : ""}`}
style={stacked ? undefined : { fontSize: `${Math.max(13, Math.round(size * 0.45))}px` }}
>
Tactical Media Broadcast Hub
</span>
</span>
);
}

View file

@ -0,0 +1,93 @@
"use client";
import { useMemo, useState } from "react";
import { Badge, Button } from "../ui/primitives";
import { DOCUMENTED_RATES, type ConnectionCapacity } from "../../lib/platforms";
import { capacitySnapshot, humanizeMs } from "@tactical-media-broadcast-hub/connector-sdk/rate-policy";
function snapshotFor(cap: ConnectionCapacity) {
const documented = DOCUMENTED_RATES[cap.platform] ?? {};
const policy = {
requestsPerSecond: documented.requestsPerSecond ?? 1,
dailyPostCap: cap.resolvedCap,
};
return capacitySnapshot({ used: cap.used, cap: cap.resolvedCap, recentPublishes: cap.recentPublishes }, policy);
}
export function CapacityChip({ cap }: { cap: ConnectionCapacity }): React.ReactElement {
const [open, setOpen] = useState(false);
const snap = useMemo(() => snapshotFor(cap), [cap]);
const documented = DOCUMENTED_RATES[cap.platform] ?? {};
const overDocumented =
cap.overrideCap !== undefined && documented.dailyPostCap !== undefined && cap.overrideCap > documented.dailyPostCap;
const tone = snap.remaining === 0 ? "danger" : snap.remaining !== undefined && snap.remaining <= 3 ? "warn" : "live";
return (
<span className="relative">
<button
onClick={() => setOpen((v) => !v)}
title="Post capacity — click for details"
className="inline-flex items-center gap-1.5 rounded-full bg-surface-2 px-2.5 py-1 font-mono text-2xs text-text-dim transition-colors duration-150 hover:border hover:border-accent hover:text-accent"
>
<span className={`h-1.5 w-1.5 rounded-full ${tone === "danger" ? "bg-danger" : tone === "warn" ? "bg-warn" : "bg-live"}`} />
{snap.remaining !== undefined ? `${snap.remaining}/${snap.cap} posts left` : `${snap.used} today · no cap`}
</button>
{open && (
<div className="absolute right-0 top-8 z-30 w-72 rounded-lg border border-border bg-surface p-3 shadow-xl">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold">Rolling 24h capacity</span>
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}>×</Button>
</div>
{snap.remaining !== undefined ? (
<>
<div className="mb-2 h-2 overflow-hidden rounded-full bg-surface-2">
<div
className={`h-full rounded-full transition-all duration-300 ${
tone === "danger" ? "bg-danger" : tone === "warn" ? "bg-warn" : "bg-accent"
}`}
style={{ width: `${Math.round((snap.used / (snap.cap ?? 1)) * 100)}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-2xs">
<span className="text-text-faint">used</span>
<span className="text-right font-mono">{snap.used}</span>
<span className="text-text-faint">cap</span>
<span className="text-right font-mono">{snap.cap}</span>
<span className="text-text-faint">remaining</span>
<span className="text-right font-mono">{snap.remaining}</span>
</div>
<div className="mt-2 border-t border-border-subtle pt-2 text-2xs leading-relaxed">
{snap.nextSlotInMs !== undefined ? (
<>
<span className="text-warn">next slot frees in {humanizeMs(snap.nextSlotInMs)}</span>
<span className="text-text-faint"> — the oldest post exits the 24h window at that point, then continuously as others age out.</span>
</>
) : (
<span className="text-live">slots available now — each post holds its slot for 24h, then frees it</span>
)}
</div>
{cap.tierNote !== undefined && (
<div className="mt-2 border-t border-border-subtle pt-2">
<Badge tone="accent">{cap.tierNote}</Badge>
</div>
)}
{overDocumented && (
<div className="mt-1.5 text-2xs leading-relaxed text-warn">
Cap exceeds documented standard ({documented.dailyPostCap}/24h). Your API tier must genuinely
permit {cap.overrideCap} — the provider enforces its real limit server-side.
</div>
)}
</>
) : (
<p className="text-2xs leading-relaxed text-text-faint">
{snap.used} posts today. This connection has no configured cap — pacing is request-rate only.
</p>
)}
</div>
)}
</span>
);
}

View file

@ -0,0 +1,69 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "../ui/primitives";
import { usePreviewTap } from "./preview-tap";
import type { Production } from "./types";
function AudioTap({ channelId, id, fixture, context, master, gain }: { channelId: string; id: string; fixture: boolean; context: AudioContext | null; master: GainNode | null; gain: number }): React.ReactElement {
const audio = useRef<HTMLAudioElement>(null);
const node = useRef<GainNode | null>(null);
const mediaSource = useRef<MediaElementAudioSourceNode | null>(null);
const urls = usePreviewTap(channelId, id, fixture, 40000);
const [available, setAvailable] = useState(false);
useEffect(() => { setAvailable(Boolean(document.createElement("audio").canPlayType("application/vnd.apple.mpegurl"))); }, []);
useEffect(() => {
if (!context || !master || !audio.current) return;
const source = mediaSource.current ?? context.createMediaElementSource(audio.current);
mediaSource.current = source;
const volume = context.createGain(); node.current = volume; volume.gain.value = 0;
source.connect(volume); volume.connect(master);
const element = audio.current;
return () => { element.pause(); source.disconnect(); volume.disconnect(); node.current = null; };
}, [context, master]);
useEffect(() => {
if (node.current) node.current.gain.setTargetAtTime(gain, context?.currentTime ?? 0, 0.015);
if (context && gain > 0 && available) void audio.current?.play().catch(() => undefined);
else audio.current?.pause();
}, [gain, context, urls, available]);
return <audio ref={audio} data-audio-tap={id} src={available ? urls?.audio : undefined} preload="none" crossOrigin="anonymous" />;
}
export function AudioMonitor({ production, liveIds, channelId, fixture, mobile, focused, observed }: { production: Production; liveIds: string[]; channelId: string; fixture: boolean; mobile: boolean; focused: string | null; observed: Production | null }): React.ReactElement {
const [mode, setMode] = useState("program");
const [context, setContext] = useState<AudioContext | null>(null);
const [master, setMaster] = useState<GainNode | null>(null);
const graph = useRef<AudioContext | null>(null);
const [gains, setGains] = useState<Record<string, number>>({});
const [error, setError] = useState("");
const [native, setNative] = useState(false);
useEffect(() => {
setNative(Boolean(document.createElement("audio").canPlayType("application/vnd.apple.mpegurl")));
return () => { void graph.current?.close(); };
}, []);
const arm = useCallback(async (): Promise<void> => {
try {
let ctx = graph.current;
if (!ctx) {
ctx = new AudioContext(); graph.current = ctx;
const output = ctx.createGain(); output.gain.value = 1; output.connect(ctx.destination);
setContext(ctx); setMaster(output);
}
await ctx.resume(); setError("");
} catch { setError("Audio could not be armed on this device."); }
}, []);
const selection = mobile ? focused ?? "program" : mode;
const ids = mobile ? [selection] : [...new Set(["program", ...(selection !== "custom" && selection !== "program" ? [selection] : []), ...liveIds.filter(id => id !== "program")])].slice(0, 5);
const audible = (id: string): number => selection === "custom" ? gains[id] ?? 0 : id === selection ? 1 : 0;
const programScene = observed?.scenes.find(scene => scene.id === observed.buses.program);
return <section aria-label="Audio monitor" className="rounded-lg border border-border-subtle bg-surface p-3">
<h2 className="mb-3 text-sm font-semibold">Audio monitor</h2>
<div className="flex flex-wrap items-end gap-3">
<Button className="min-h-11" variant="outline" disabled={fixture || !native} onClick={() => { void arm(); }}>{context ? "Resume audio" : "Arm audio"}</Button>
{!mobile && <label className="min-w-0 flex-1 text-xs">Monitor select<select className="cr-field mt-1" value={mode} onChange={event => setMode(event.target.value)}><option value="program">Follow program</option><option value="custom">Custom monitor mix</option>{production.sources.map(source => <option key={source.id} value={source.id}>Solo · {source.label}</option>)}</select></label>}
</div>
{selection === "custom" && ids.filter(id => id !== "program").map(id => <label key={id} className="mt-3 block text-xs">{production.sources.find(source => source.id === id)?.label}<input aria-label={`Monitor gain ${id}`} className="mt-1 h-11 w-full" type="range" min={0} max={1} step={0.01} value={gains[id] ?? 0} onChange={event => setGains(previous => ({ ...previous, [id]: Number(event.target.value) }))} /></label>)}
{ids.map(id => <AudioTap key={id} channelId={channelId} id={id} fixture={fixture} context={context} master={master} gain={audible(id)} />)}
<p className="mt-3 text-xs text-text-dim" data-program-audio>Program audio: {programScene ? `${programScene.label} · ${programScene.slots.filter(slot => slot.audioMode !== "off").length} audible slots · master ${observed?.audio.masterGainDb ?? 0} dB` : "unconfirmed"}</p>
<p className="mt-2 text-xs text-text-faint">{mobile ? "Monitor follows the watched tile. " : "Monitor gains are private to this browser. "}Same-origin signed audio taps avoid Web Audio’s cross-origin silence constraint. {fixture ? "Fixture has no audio." : !native ? "Native HLS audio is unavailable in this browser." : ""}</p>
{error && <p role="alert" className="mt-2 text-xs text-danger">{error}</p>}
</section>;
}

View file

@ -0,0 +1,32 @@
import { API_URL, ApiError, ApiUnreachableError } from "../../lib/api-client";
import { getSessionToken } from "../../lib/session-token";
export function idempotencyKey(): string {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
// getRandomValues also works on HTTP development hosts where randomUUID is unavailable.
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
// Kept local: the shared API client does not support custom request headers.
export async function controlRequest<T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
const token = getSessionToken();
let response: Response;
try {
response = await fetch(`${API_URL}${path}`, { method: body === undefined ? "GET" : "POST", credentials: "include", signal,
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey() }) },
body: body === undefined ? undefined : JSON.stringify(body) });
} catch (error) { if (signal?.aborted) throw error; throw new ApiUnreachableError(error); }
const text = await response.text();
let result: unknown = null;
if (text) { try { result = JSON.parse(text); } catch { result = text; } }
if (!response.ok) throw new ApiError(response.status, typeof result === "object" && result && "message" in result ? String(result.message) : `HTTP ${response.status}`, result);
return result as T;
}
export function signedSameOrigin(raw: string): string {
const url = new URL(raw, window.location.origin);
if (url.origin !== window.location.origin || !url.pathname.startsWith("/media-control/preview/")) throw new Error("Preview must use signed same-origin media URLs");
return url.href;
}

View file

@ -0,0 +1,152 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { webcrypto } from "node:crypto";
import { idempotencyKey, controlRequest } from "./client";
import { fixtureChannel } from "./fixtures";
import { initialTally, refetchTally, tallyReducer, tileTally } from "./tally";
import { RealtimeClient, type SocketLike } from "./state-store";
import { liveTapIds } from "./multiview-grid";
import { slotRect } from "./pst-preview";
import type { StateFrame, Observed } from "./types";
if (!globalThis.crypto) Object.defineProperty(globalThis, "crypto", { value: webcrypto });
function observed(channel = fixtureChannel(), version = 1): Observed {
return { workspaceId: "workspace", channelId: channel.channelId, workerId: channel.workerId, assignmentEpoch: 1, state: "RUNNING", realizedVersion: version, updatedAt: new Date().toISOString(), reason: "" };
}
test("PST and CUT desired changes never move tally; exact observed version does", () => {
const channel = fixtureChannel(); channel.observed = observed();
let state = initialTally(channel);
const first = channel.desired?.production?.sources[0]?.id ?? "";
const second = channel.desired?.production?.sources[1]?.id ?? "";
assert.equal(tileTally(state.observedProduction, first), "program");
const desired = structuredClone(channel.desired);
assert.ok(desired?.production);
desired.desiredVersion = 2; desired.production.buses.preview = desired.production.scenes[1]?.id ?? null;
state = tallyReducer(state, { type: "state", workspaceId: "workspace", channelId: channel.channelId, kind: "desired", seq: 1, data: desired });
assert.equal(tileTally(state.observedProduction, second), "none");
state = tallyReducer(state, { type: "state", workspaceId: "workspace", channelId: channel.channelId, kind: "observed", seq: 2, data: observed(channel, 2) });
assert.equal(tileTally(state.observedProduction, second), "preview");
const cut = structuredClone(desired); assert.ok(cut.production); cut.desiredVersion = 3; cut.production.buses.program = cut.production.buses.preview;
state = tallyReducer(state, { type: "state", workspaceId: "workspace", channelId: channel.channelId, kind: "desired", seq: 3, data: cut });
assert.equal(tileTally(state.observedProduction, first), "program");
assert.equal(tileTally(state.observedProduction, second), "preview");
state = tallyReducer(state, { type: "state", workspaceId: "workspace", channelId: channel.channelId, kind: "observed", seq: 4, data: observed(channel, 3) });
assert.equal(tileTally(state.observedProduction, first), "none");
assert.equal(tileTally(state.observedProduction, second), "program");
});
test("unknown version, missing observation and stale assignment cannot claim program", () => {
const channel = fixtureChannel();
assert.equal(initialTally(channel).observedProduction, null);
channel.observed = observed(channel, 99); assert.equal(initialTally(channel).observedProduction, null);
channel.observed = { ...observed(), assignmentEpoch: 2 }; assert.equal(initialTally(channel).observedProduction, null);
channel.observed = { ...observed(), workerId: "other" }; assert.equal(initialTally(channel).observedProduction, null);
channel.observed = { ...observed(), state: "STOPPED" }; assert.equal(initialTally(channel).observedProduction, null);
});
test("duplicate and wrong-channel frames are ignored", () => {
const channel = fixtureChannel(); const state = { ...initialTally(channel), lastSeq: 4 };
const frame: StateFrame = { type: "state", workspaceId: "workspace", channelId: channel.channelId, kind: "observed", seq: 4, data: observed() };
assert.equal(tallyReducer(state, frame), state);
assert.equal(tallyReducer(state, { ...frame, seq: 5, channelId: "other" }), state);
});
test("late desired snapshot cannot fabricate a tally update", () => {
const channel = fixtureChannel(); channel.observed = observed(channel, 2);
const state = initialTally(channel); const desired = structuredClone(channel.desired); assert.ok(desired); desired.desiredVersion = 2;
const next = tallyReducer(state, { type: "state", workspaceId: "workspace", channelId: channel.channelId, kind: "desired", seq: 1, data: desired });
assert.equal(next.observedProduction, null);
});
test("Idempotency-Key is a distinct printable UUID for every action", async () => {
const keys = Array.from({ length: 100 }, idempotencyKey);
assert.equal(new Set(keys).size, keys.length);
for (const key of keys) { assert.match(key, /^[\x20-\x7e]{1,128}$/); assert.match(key, /^[0-9a-f-]{36}$/); }
const original = globalThis.fetch; const headers: Headers[] = [];
globalThis.fetch = async (_url, options) => { headers.push(new Headers(options?.headers)); return Response.json({ desiredVersion: 1, commandId: "test" }); };
try { await controlRequest("/media-control/channels/test/cut", {}); await controlRequest("/production/overlays/test", { templateId: "logo", fields: {} }); }
finally { globalThis.fetch = original; }
assert.ok(headers[0]?.get("Idempotency-Key")); assert.notEqual(headers[0]?.get("Idempotency-Key"), headers[1]?.get("Idempotency-Key"));
});
class FakeSocket implements SocketLike {
onopen: SocketLike["onopen"] = null; onmessage: SocketLike["onmessage"] = null; onclose: SocketLike["onclose"] = null; onerror: SocketLike["onerror"] = null;
sent: Record<string, unknown>[] = [];
send(raw: string): void { this.sent.push(JSON.parse(raw) as Record<string, unknown>); }
close(): void {}
}
test("WS auth waits for ack, resume uses last consumed frame and resync fully refetches", async () => {
const sockets: FakeSocket[] = []; const frames: StateFrame[] = []; let refetches = 0;
const channel = fixtureChannel();
const client = new RealtimeClient({ url: "ws://test/realtime", workspaceId: "workspace", channelId: channel.channelId, token: () => "secret", retryMs: 1,
socket: () => { const socket = new FakeSocket(); sockets.push(socket); return socket; },
status: async () => { refetches += 1; return { channels: [channel] }; }, onStatus: () => undefined, onFrame: frame => frames.push(frame), onConnection: () => undefined });
try {
client.start(); sockets[0]?.onopen?.(new Event("open"));
assert.deepEqual(sockets[0]?.sent, [{ type: "auth", token: "secret", workspaceId: "workspace" }]);
await client.receive(JSON.stringify({ type: "auth_ack" }));
assert.deepEqual(sockets[0]?.sent[1], { type: "subscribe", channelId: channel.channelId });
await client.receive(JSON.stringify({ type: "subscribe_ack", seq: 99 })); assert.equal(client.lastSeq, undefined);
const frame = { type: "state", workspaceId: "workspace", channelId: channel.channelId, seq: 7, kind: "observed", data: observed() };
await client.receive(JSON.stringify(frame)); await client.receive(JSON.stringify(frame));
assert.equal(frames.length, 1); assert.equal(client.lastSeq, 7);
sockets[0]?.onclose?.({ code: 1006 } as CloseEvent);
await new Promise(resolve => setTimeout(resolve, 10));
await client.receive(JSON.stringify({ type: "auth_ack" }));
assert.deepEqual(sockets[1]?.sent[0], { type: "subscribe", channelId: channel.channelId, lastSeq: 7 });
await client.receive(JSON.stringify({ type: "RESYNC_REQUIRED", channelId: channel.channelId }));
assert.equal(refetches, 1); assert.equal(client.lastSeq, undefined);
assert.deepEqual(sockets[1]?.sent.at(-1), { type: "subscribe", channelId: channel.channelId });
await client.receive(JSON.stringify({ ...frame, seq: 1 })); assert.equal(client.lastSeq, 1);
} finally { client.stop(); }
});
test("live budgets enforce large tier defaults, promotions, and mobile cap", () => {
const sources = fixtureChannel().desired?.production?.sources ?? [];
assert.equal(liveTapIds(sources, "2x2", null, [], false).length, 4);
assert.equal(liveTapIds(sources, "3x3", null, [], false).length, 1);
assert.equal(liveTapIds(sources, "4x4", sources[1]?.id ?? null, [], false).length, 2);
assert.equal(liveTapIds(sources, "custom", sources[0]?.id ?? null, sources.map(source => source.id), false).length, 4);
assert.deepEqual(liveTapIds(sources, "4x4", sources[0]?.id ?? null, [], true), [sources[0]?.id]);
});
test("PST uses scene slot geometry rather than multiview geometry", () => {
const slot = fixtureChannel().desired?.production?.scenes[0]?.slots[0]; assert.ok(slot);
assert.deepEqual(slotRect("2x2", { ...slot, index: 3 }), { x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
assert.deepEqual(slotRect("custom", { ...slot, rect: { x: 1000, y: 2000, w: 4000, h: 3000 } }), { x: 0.1, y: 0.2, w: 0.4, h: 0.3 });
});
test("authorization close clears retries and never reconnects a revoked socket", async () => {
const socket = new FakeSocket(); let connections = 0; const states: string[] = [];
const client = new RealtimeClient({ url: "ws://test/realtime", workspaceId: "workspace", channelId: fixtureChannel().channelId, token: () => "revoked", retryMs: 1,
socket: () => { connections += 1; return socket; }, status: async () => ({ channels: [] }), onStatus: () => undefined, onFrame: () => undefined, onConnection: state => states.push(state) });
client.start(); socket.onclose?.({ code: 1008 } as CloseEvent);
await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(connections, 1); assert.equal(states.at(-1), "denied"); client.stop();
});
test("status refetch keeps the realized snapshot during version lag and clears it on reassignment", () => {
const channel = fixtureChannel(); channel.observed = observed();
const initial = initialTally(channel); const nextChannel = structuredClone(channel);
assert.ok(nextChannel.desired?.production);
nextChannel.desired.desiredVersion = 2;
nextChannel.desired.production.buses.program = nextChannel.desired.production.scenes[1]?.id ?? null;
const resynced = refetchTally(nextChannel, initial);
assert.equal(resynced.observedProduction?.buses.program, channel.desired?.production?.buses.program);
assert.equal(resynced.lastSeq, 0);
assert.equal(refetchTally({ ...nextChannel, assignmentEpoch: 2 }, initial).observedProduction, null);
});
test("RESYNC failure stays in labeled polling mode and successful full refetch resubscribes", async () => {
const socket = new FakeSocket(); let unavailable = true, refetches = 0; const connections: string[] = [];
const client = new RealtimeClient({ url: "ws://test/realtime", workspaceId: "workspace", channelId: fixtureChannel().channelId, token: () => "token",
socket: () => socket, status: async () => { refetches += 1; if (unavailable) throw new Error("offline"); return { channels: [fixtureChannel()] }; },
onStatus: () => undefined, onFrame: () => undefined, onConnection: state => connections.push(state) });
try {
client.start(); await client.receive(JSON.stringify({ type: "RESYNC_REQUIRED" }));
assert.equal(connections.at(-1), "polling"); assert.equal(socket.sent.length, 0);
unavailable = false; await client.receive(JSON.stringify({ type: "RESYNC_REQUIRED" }));
assert.equal(refetches, 2); assert.deepEqual(socket.sent.at(-1), { type: "subscribe", channelId: fixtureChannel().channelId });
} finally { client.stop(); }
});
test("UUID fallback supports non-secure development hosts with Web Crypto entropy", () => {
const descriptor = Object.getOwnPropertyDescriptor(crypto, "randomUUID");
Object.defineProperty(crypto, "randomUUID", { value: undefined, configurable: true });
try {
const keys = Array.from({ length: 100 }, idempotencyKey);
assert.equal(new Set(keys).size, 100);
for (const key of keys) assert.match(key, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
} finally { if (descriptor) Object.defineProperty(crypto, "randomUUID", descriptor); else Reflect.deleteProperty(crypto, "randomUUID"); }
});

View file

@ -0,0 +1,10 @@
import type { Channel, Production } from "./types";
export const fixtureChannelId = "10000000-0000-4000-8000-000000000001";
export const fixtureProduction: Production = {
sources: Array.from({ length: 16 }, (_, i) => ({ id: `20000000-0000-4000-8000-${String(i + 1).padStart(12, "0")}`, label: `Sample source ${i + 1}`, kind: "hls" })),
scenes: [0, 1].map(i => ({ id: `30000000-0000-4000-8000-${String(i + 1).padStart(12, "0")}`, label: i === 0 ? "Sample studio" : "Sample field", layout: "single", slots: [{ index: 0, sourceId: `20000000-0000-4000-8000-${String(i + 1).padStart(12, "0")}`, rect: null, fit: "contain", audioMode: "primary", gainDb: 0 }] })),
buses: { program: "30000000-0000-4000-8000-000000000001", preview: "30000000-0000-4000-8000-000000000001" },
overlays: ["lower_third", "chyron", "logo"].map((kind, i) => ({ id: `40000000-0000-4000-8000-${String(i + 1).padStart(12, "0")}`, sceneId: "30000000-0000-4000-8000-000000000001", kind: kind as "lower_third" | "chyron" | "logo", enabled: true, layer: i, position: { x: 0, y: 8000 }, revisionId: null, assetId: null })),
audio: { masterGainDb: 0 },
};
export function fixtureChannel(): Channel { return { channelId: fixtureChannelId, workerId: "sample-worker", assignmentEpoch: 1, desired: { desiredVersion: 1, desiredStatus: "RUNNING", production: structuredClone(fixtureProduction), director: { locked: false, override: null } }, observed: null, converged: false, workerAlive: false }; }

View file

@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import { Button } from "../ui/primitives";
import { PreviewTap } from "./preview-tap";
import { tileTally } from "./tally";
import type { Production, Source } from "./types";
export type GridTier = "2x1" | "2x2" | "3x3" | "4x4" | "custom";
export function liveTapIds(sources: Source[], tier: GridTier, focused: string | null, upgrades: string[], mobile: boolean): string[] {
const ids = sources.map(source => source.id);
const valid = (id: string): boolean => id === "program" || ids.includes(id);
const candidates = mobile ? [focused ?? "program"] : tier === "2x1" || tier === "2x2" ? ["program", ...(focused ? [focused] : []), ...ids] : ["program", ...(focused ? [focused] : []), ...upgrades];
return [...new Set(candidates)].filter(valid).slice(0, mobile ? 1 : Math.min(4, tier === "2x1" ? 2 : 4));
}
export function MultiviewGrid({ production, observed, channelId, fixture, mobile, liveIds, focused, onFocus, tier, onTier, upgrades, onUpgrade }: { production: Production; observed: Production | null; channelId: string; fixture: boolean; mobile: boolean; liveIds: string[]; focused: string | null; onFocus: (id: string) => void; tier: GridTier; onTier: (tier: GridTier) => void; upgrades: string[]; onUpgrade: (id: string) => void }): React.ReactElement {
const [columns, setColumns] = useState(3);
const capacity = tier === "2x1" ? 2 : tier === "2x2" ? 4 : tier === "3x3" ? 9 : 16;
const tiles = [{ id: "program", label: "PGM · encoder output", kind: "hls" as const }, ...production.sources].slice(0, capacity);
const selectedSourceIds = production.scenes.find(scene => scene.id === production.buses.preview)?.slots.map(slot => slot.sourceId) ?? [];
return <section aria-label="Multiview" className="min-w-0 rounded-lg border border-border-subtle bg-surface p-3">
<div className="mb-3 flex flex-wrap items-end gap-3">
<label className="text-xs">Multiview layout<select className="cr-field mt-1" value={tier} onChange={event => onTier(event.target.value as GridTier)}>{["2x1", "2x2", "3x3", "4x4", "custom"].map(value => <option key={value}>{value}</option>)}</select></label>
{tier === "custom" && <label className="text-xs">Columns<input className="cr-field mt-1" type="number" min={1} max={4} value={columns} onChange={event => setColumns(Math.max(1, Math.min(4, Number(event.target.value) || 1)))} /></label>}
<span className="pb-3 text-xs text-text-faint">{liveIds.length}/{mobile ? 1 : 4} live slots · tap to focus</span>
</div>
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${mobile ? (tier === "2x1" ? 1 : 2) : tier === "custom" ? columns : tier === "3x3" ? 3 : tier === "4x4" ? 4 : 2}, minmax(0, 1fr))` }}>
{tiles.map(source => {
const tally = source.id === "program" ? observed?.buses.program ? "program" : "none" : tileTally(observed, source.id);
return <div key={source.id} data-source={source.id} data-tally={tally} className={`min-w-0 rounded-md border-2 p-1 ${tally === "program" ? "border-red-500" : tally === "preview" ? "border-green-500" : "border-border"}`}>
<button className="min-h-11 w-full text-left" aria-label={`Focus ${source.label}`} aria-pressed={focused === source.id} onClick={() => onFocus(source.id)}>
<div className="mb-1 flex min-h-7 flex-wrap items-center justify-between gap-1 px-1 text-xs"><span className="break-words">{source.label}</span><span className="font-mono">{tally === "program" ? "PGM" : tally === "preview" ? "PST" : "—"}</span></div>
<PreviewTap channelId={channelId} tap={source.id} fixture={fixture} live={liveIds.includes(source.id)} selected={selectedSourceIds.includes(source.id)} label={source.label} />
</button>
<p className="px-1 py-1 text-[10px] text-text-faint">{fixture ? "SAMPLE" : "Health not reported"}</p>
{!mobile && (tier === "3x3" || tier === "4x4" || tier === "custom") && source.id !== "program" && source.id !== focused && <Button className="min-h-11 w-full justify-center" disabled={!upgrades.includes(source.id) && liveIds.length >= 4} onClick={() => onUpgrade(source.id)}>{upgrades.includes(source.id) ? "Remove live" : "Upgrade live"}</Button>}
</div>;
})}
</div>
<p className="mt-3 text-xs text-text-faint">Extra live tiles can strain the device. Red/green tally uses the worker’s realized version. Unknown versions have no tally.</p>
</section>;
}

View file

@ -0,0 +1,31 @@
"use client";
import { useState } from "react";
import { Button } from "../ui/primitives";
import type { Overlay } from "./types";
export interface RenderInput { templateId: "lower_third" | "chyron" | "logo"; fields: { title?: string; subtitle?: string; style?: "dark" | "red"; svg?: string } }
function Editor({ overlay, disabled, onRender }: { overlay: Overlay; disabled: boolean; onRender: (id: string, input: RenderInput) => Promise<void> }): React.ReactElement {
const [title, setTitle] = useState(""); const [subtitle, setSubtitle] = useState(""); const [style, setStyle] = useState<"dark" | "red">("dark"); const [svg, setSvg] = useState("");
const [busy, setBusy] = useState(false); const [message, setMessage] = useState("");
if (overlay.kind === "clock") return <p className="text-xs text-text-faint">Clock overlay is server-generated.</p>;
const templateId = overlay.kind;
return <form className="min-w-0 space-y-3" onSubmit={event => {
event.preventDefault(); setBusy(true); setMessage("");
const input: RenderInput = { templateId, fields: templateId === "logo" ? { svg } : { title, subtitle, style } };
void onRender(overlay.id, input).then(() => setMessage("Render accepted. Waiting for the current revision in status.")).catch(error => setMessage(error instanceof Error ? error.message : "Render failed")).finally(() => setBusy(false));
}}>
<p className="text-xs text-text-faint">Template · {templateId}</p>
{templateId === "logo" ? <label className="block text-xs">Logo SVG<textarea className="cr-field mt-1 min-h-28" required maxLength={64000} value={svg} onChange={event => setSvg(event.target.value)} placeholder="<svg>…</svg>" /><span className="mt-1 block text-text-faint">Geometric SVG only; no scripts, external assets, or text.</span></label> : <>
<label className="block text-xs">{templateId === "chyron" ? "Chyron text" : "Title"} · {title.length}/80<input className="cr-field mt-1" maxLength={80} value={title} onChange={event => setTitle(event.target.value)} /></label>
<label className="block text-xs">Subtitle · {subtitle.length}/120<input className="cr-field mt-1" maxLength={120} value={subtitle} onChange={event => setSubtitle(event.target.value)} /></label>
<label className="block text-xs">Style<select className="cr-field mt-1" value={style} onChange={event => setStyle(event.target.value as "dark" | "red")}><option value="dark">Dark</option><option value="red">Red</option></select></label>
</>}
<Button className="min-h-11" variant="outline" disabled={disabled || busy} type="submit">{busy ? "Rendering…" : "Render overlay"}</Button>
<p className="break-all text-xs text-text-faint">Current revision: {overlay.revisionId ?? "None"}</p>
{message && <p role="status" className="text-xs">{message}</p>}
</form>;
}
export function OverlayEditor({ overlays, disabled, onRender }: { overlays: Overlay[]; disabled: boolean; onRender: (id: string, input: RenderInput) => Promise<void> }): React.ReactElement {
const [selected, setSelected] = useState("");
const overlay = overlays.find(item => item.id === selected) ?? overlays[0];
return <section aria-label="Overlay editor" className="min-w-0 rounded-lg border border-border-subtle bg-surface p-3"><h2 className="mb-3 text-sm font-semibold">Overlay editor</h2>{overlay ? <><label className="mb-3 block text-xs">Overlay slot<select className="cr-field mt-1" value={overlay.id} onChange={event => setSelected(event.target.value)}>{overlays.map((item, index) => <option key={item.id} value={item.id}>{item.kind} · slot {index + 1}</option>)}</select></label><Editor key={overlay.id} overlay={overlay} disabled={disabled} onRender={onRender} /></> : <p className="text-xs text-text-faint">Configure an overlay slot on a production scene to render a lower third, chyron, or logo.</p>}</section>;
}

View file

@ -0,0 +1,62 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { controlRequest, signedSameOrigin } from "./client";
import type { PreviewUrls } from "./types";
export function usePreviewTap(channelId: string, tap: string, fixture: boolean, cadence = 2000): PreviewUrls | null {
const [urls, setUrls] = useState<PreviewUrls | null>(null);
useEffect(() => {
setUrls(null);
if (fixture) return;
let visible = true, busy = false;
const abort = new AbortController();
const update = async (): Promise<void> => {
if (!visible || document.hidden || busy) return;
busy = true;
try {
const result = await controlRequest<PreviewUrls>(`/media-control/channels/${channelId}/preview/${tap}`, undefined, abort.signal);
if (!abort.signal.aborted) setUrls({ thumbnail: `${signedSameOrigin(result.thumbnail)}&refresh=${Date.now()}`, video: signedSameOrigin(result.video), audio: signedSameOrigin(result.audio) });
} catch { if (!abort.signal.aborted) setUrls(null); }
finally { busy = false; }
};
const listener = (): void => { if (!document.hidden) void update(); };
document.addEventListener("visibilitychange", listener);
// Consumers pause their own polling through cadence=0 while off-screen.
if (cadence > 0) void update(); else visible = false;
const timer = cadence > 0 ? setInterval(() => { void update(); }, cadence) : null;
return () => { abort.abort(); if (timer) clearInterval(timer); document.removeEventListener("visibilitychange", listener); };
}, [channelId, tap, fixture, cadence]);
return urls;
}
export function PreviewTap({ channelId, tap, fixture, live, selected, label }: { channelId: string; tap: string; fixture: boolean; live: boolean; selected: boolean; label: string }): React.ReactElement {
const root = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(true);
const [pageVisible, setPageVisible] = useState(true);
const [native, setNative] = useState(false);
const [failed, setFailed] = useState(false);
const active = visible && pageVisible;
const urls = usePreviewTap(channelId, tap, fixture, active ? (selected ? 1000 : 2000) : 0);
useEffect(() => {
setNative(Boolean(document.createElement("video").canPlayType("application/vnd.apple.mpegurl")));
const observer = new IntersectionObserver(entries => setVisible(entries[0]?.isIntersecting ?? false));
const update = (): void => setPageVisible(!document.hidden);
update(); document.addEventListener("visibilitychange", update);
if (root.current) observer.observe(root.current);
return () => { observer.disconnect(); document.removeEventListener("visibilitychange", update); };
}, []);
useEffect(() => setFailed(false), [tap, live]);
// Refresh thumbnail signatures continuously, but keep a live manifest stable until its TTL nears expiry.
const [videoUrl, setVideoUrl] = useState<string>();
useEffect(() => {
if (!urls || !live || !active) { setVideoUrl(undefined); return; }
setVideoUrl(previous => !previous || Number(new URL(previous).searchParams.get("expires")) * 1000 < Date.now() + 10000 ? urls.video : previous);
}, [urls, live, active]);
const playing = live && active && native && !failed && Boolean(videoUrl);
return <div ref={root} className="relative flex aspect-video items-center justify-center overflow-hidden rounded bg-black/60">
{playing ? <video data-live-decode src={videoUrl} autoPlay muted playsInline preload="none" className="h-full w-full object-contain" onError={() => setFailed(true)} />
: urls ? <img src={urls.thumbnail} alt={`${label} preview tap`} className="h-full w-full object-contain" onLoad={event => { event.currentTarget.style.visibility = "visible"; }} onError={event => { event.currentTarget.style.visibility = "hidden"; }} />
: <span className="px-3 text-center text-xs text-text-faint">{fixture ? "Fixture · no media" : "Preview tap unavailable"}</span>}
<span className="absolute bottom-1 left-1 rounded bg-black/80 px-2 py-1 text-[10px] text-white">{playing ? "live preview" : `thumbnail · ${selected ? "1" : "2"} s`}{live && !native && !fixture ? " · native HLS unavailable" : ""}</span>
</div>;
}

View file

@ -0,0 +1,64 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { controlRequest, signedSameOrigin } from "./client";
import type { Production, Slot } from "./types";
export function slotRect(layout: string, slot: Slot): { x: number; y: number; w: number; h: number } {
if (slot.rect) return { x: slot.rect.x / 10000, y: slot.rect.y / 10000, w: slot.rect.w / 10000, h: slot.rect.h / 10000 };
const cols = layout === "3x3" ? 3 : layout === "4x4" ? 4 : layout === "2x1" || layout === "2x2" ? 2 : 1;
const rows = layout === "2x1" || cols === 1 ? 1 : cols;
return { x: (slot.index % cols) / cols, y: Math.floor(slot.index / cols) / rows, w: 1 / cols, h: 1 / rows };
}
export function PstPreview({ production, channelId, fixture }: { production: Production; channelId: string; fixture: boolean }): React.ReactElement {
const canvas = useRef<HTMLCanvasElement>(null);
const [visible, setVisible] = useState(true);
const scene = production.scenes.find(item => item.id === production.buses.preview);
useEffect(() => {
const observer = new IntersectionObserver(entries => setVisible(entries[0]?.isIntersecting ?? false));
if (canvas.current) observer.observe(canvas.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
const element = canvas.current;
if (!element) return;
const buffer = document.createElement("canvas"); buffer.width = element.width; buffer.height = element.height;
const context = buffer.getContext("2d");
if (!context) return;
const abort = new AbortController();
let busy = false;
const draw = async (): Promise<void> => {
if (document.hidden || !visible || busy) return;
busy = true;
context.fillStyle = "#101010"; context.fillRect(0, 0, element.width, element.height);
try {
for (const slot of [...(scene?.slots ?? [])].sort((a, b) => a.index - b.index)) {
const rect = slotRect(scene?.layout ?? "single", slot);
const x = rect.x * element.width, y = rect.y * element.height, w = rect.w * element.width, h = rect.h * element.height;
context.save(); context.beginPath(); context.rect(x, y, w, h); context.clip();
context.fillStyle = "#222"; context.fillRect(x, y, w, h);
let painted = false;
if (!fixture && slot.sourceId) {
try {
const urls = await controlRequest<{ thumbnail: string }>(`/media-control/channels/${channelId}/preview/${slot.sourceId}`, undefined, abort.signal);
const response = await fetch(signedSameOrigin(urls.thumbnail), { signal: abort.signal });
if (!response.ok) throw new Error("Thumbnail unavailable");
const bitmap = await createImageBitmap(await response.blob());
if (!abort.signal.aborted) {
const scale = slot.fit === "contain" ? Math.min(w / bitmap.width, h / bitmap.height) : Math.max(w / bitmap.width, h / bitmap.height);
context.drawImage(bitmap, x + (w - bitmap.width * scale) / 2, y + (h - bitmap.height * scale) / 2, bitmap.width * scale, bitmap.height * scale);
painted = true;
}
bitmap.close();
} catch { /* Label unavailable taps. */ }
}
if (!painted && !abort.signal.aborted) { context.fillStyle = "#aaa"; context.font = "16px sans-serif"; context.fillText(fixture ? "Fixture · no media" : "Tap unavailable", x + 16, y + 30, Math.max(1, w - 32)); }
context.restore();
}
if (!abort.signal.aborted) element.getContext("2d")?.drawImage(buffer, 0, 0);
} finally { busy = false; }
};
void draw(); const timer = setInterval(() => { void draw(); }, 1000);
return () => { abort.abort(); clearInterval(timer); };
}, [scene, channelId, fixture, visible]);
const overlays = production.overlays.filter(overlay => overlay.enabled && overlay.sceneId === scene?.id);
return <section className="rounded-lg border border-border-subtle bg-surface p-3" aria-label="PST look-ahead"><h2 className="mb-2 text-sm font-semibold">PST · {scene?.label ?? "No scene selected"}</h2><canvas ref={canvas} width={640} height={360} className="aspect-video w-full rounded bg-black" aria-label={`Look-ahead of ${scene?.label ?? "empty preview"}`} /><p className="mt-2 text-xs text-text-faint">PST is a look-ahead; PGM is the truth.</p>{overlays.length > 0 && <p className="mt-2 text-xs text-text-faint">{overlays.length} overlay slots configured. Status provides revision IDs without render pixels; overlay appearance is unavailable in this look-ahead.</p>}</section>;
}

View file

@ -0,0 +1,90 @@
import type { StateFrame, Status } from "./types";
export interface SocketLike {
onopen: ((event: Event) => void) | null; onmessage: ((event: MessageEvent) => void) | null; onclose: ((event: CloseEvent) => void) | null; onerror: ((event: Event) => void) | null;
send(value: string): void; close(): void;
}
interface Options {
url: string; token: () => string | null; workspaceId: string; channelId: string;
socket: (url: string) => SocketLike; status: () => Promise<Status>;
onFrame: (frame: StateFrame) => void; onStatus: (status: Status) => void;
onConnection: (state: "connecting" | "live" | "polling" | "denied") => void;
retryMs?: number;
}
export class RealtimeClient {
private socket: SocketLike | null = null;
private retry: ReturnType<typeof setTimeout> | null = null;
private poll: ReturnType<typeof setInterval> | null = null;
private stopped = false;
private seq: number | undefined;
private backoff = 1000;
private resyncing = false;
private resyncPending = false;
private handshake: ReturnType<typeof setTimeout> | null = null;
private messages: Promise<void> = Promise.resolve();
constructor(private readonly options: Options) {}
get lastSeq(): number | undefined { return this.seq; }
start(): void { this.stopped = false; this.connect(); }
private connect(): void {
if (this.stopped) return;
this.options.onConnection("connecting");
const socket = this.options.socket(this.options.url); this.socket = socket;
socket.onopen = () => socket.send(JSON.stringify({ type: "auth", token: this.options.token(), workspaceId: this.options.workspaceId }));
socket.onmessage = event => { this.messages = this.messages.then(() => this.receive(String(event.data))).catch(() => socket.close()); };
this.handshake = setTimeout(() => socket.close(), 5000);
socket.onerror = () => socket.close();
socket.onclose = event => {
if (this.stopped) return;
if (this.handshake) clearTimeout(this.handshake);
if (event.code === 1008) { this.options.onConnection("denied"); this.stop(); return; }
this.options.onConnection("polling");
if (!this.poll) this.poll = setInterval(() => { void this.refresh(); }, 2000);
this.retry = setTimeout(() => this.connect(), this.options.retryMs ?? this.backoff);
this.backoff = Math.min(8000, this.backoff * 2);
};
}
async receive(raw: string): Promise<void> {
if (this.stopped) return;
let frame: Record<string, unknown>;
try { const value: unknown = JSON.parse(raw); if (!value || typeof value !== "object") return; frame = value as Record<string, unknown>; } catch { return; }
if (frame.type === "auth_ack") this.socket?.send(JSON.stringify({ type: "subscribe", channelId: this.options.channelId, ...(this.seq === undefined ? {} : { lastSeq: this.seq }) }));
if (frame.type === "subscribe_denied") { this.options.onConnection("denied"); this.stop(); }
if (frame.type === "subscribe_ack") {
if (this.handshake) clearTimeout(this.handshake);
this.backoff = 1000; if (this.poll) clearInterval(this.poll); this.poll = null;
// ACK advertises the ring head, NOT the last consumed frame. Do not advance seq.
this.options.onConnection("live");
}
if (frame.type === "RESYNC_REQUIRED" && (!frame.channelId || frame.channelId === this.options.channelId)) {
this.seq = undefined;
this.resyncPending = true;
this.options.onConnection("polling");
if (!this.poll) this.poll = setInterval(() => { void this.refresh(); }, 2000);
await this.refresh();
}
if (frame.type === "state" && frame.channelId === this.options.channelId && frame.workspaceId === this.options.workspaceId && (frame.kind === "desired" || frame.kind === "observed") && Number.isSafeInteger(frame.seq) && Number(frame.seq) > (this.seq ?? -1)) {
this.seq = Number(frame.seq); this.options.onFrame(frame as unknown as StateFrame);
this.socket?.send(JSON.stringify({ type: "ack", channelId: this.options.channelId, seq: this.seq }));
}
}
private async refresh(): Promise<boolean> {
if (this.resyncing) return false;
this.resyncing = true;
const startedSeq = this.seq;
try {
const status = await this.options.status();
// An old REST poll must not overwrite newer WS observations after reconnect.
if (!this.stopped && this.seq === startedSeq) {
this.options.onStatus(status);
if (this.resyncPending) {
this.seq = undefined; this.resyncPending = false;
this.socket?.send(JSON.stringify({ type: "subscribe", channelId: this.options.channelId }));
}
}
return !this.stopped;
}
catch { return false; }
finally { this.resyncing = false; }
}
stop(): void { this.stopped = true; if (this.retry) clearTimeout(this.retry); if (this.poll) clearInterval(this.poll); if (this.handshake) clearTimeout(this.handshake); this.socket?.close(); }
}

View file

@ -0,0 +1,16 @@
"use client";
import { Button } from "../ui/primitives";
import type { Desired, OverrideMode } from "./types";
export type DirectorAction = "pst-select" | "take" | "cut" | "lock" | "override";
export function SwitcherBar({ desired, disabled, busy, onAction }: { desired: Desired; disabled: boolean; busy: boolean; onAction: (action: DirectorAction, body: object) => void }): React.ReactElement {
const production = desired.production;
const locked = desired.director?.locked ?? false;
return <section aria-label="Switcher" className="flex flex-wrap items-end gap-3 rounded-lg border border-border-subtle bg-surface p-3">
<label className="min-w-0 flex-1 text-xs">PST scene<select aria-label="PST scene" className="cr-field mt-1" disabled={disabled || busy} value={production?.buses.preview ?? ""} onChange={event => onAction("pst-select", { sceneId: event.target.value })}><option value="">Select a scene</option>{production?.scenes.map(scene => <option key={scene.id} value={scene.id}>{scene.label}</option>)}</select></label>
<Button className="min-h-11 min-w-20 justify-center" variant="outline" disabled={disabled || busy || !production?.buses.preview} title="PST to PGM at the next ready frame boundary (CUT transition)" onClick={() => onAction("take", {})}>TAKE</Button>
<Button className="min-h-11 min-w-20 justify-center" variant="primary" disabled={disabled || busy || !production?.buses.preview} title="Request an immediate CUT; tally waits for worker observation" onClick={() => onAction("cut", {})}>CUT</Button>
<Button className="min-h-11 justify-center" variant="outline" disabled={disabled || busy} aria-pressed={locked} onClick={() => onAction("lock", { enabled: !locked })}>{locked ? "Unlock program" : "Lock program"}</Button>
<label className="text-xs">Operator override<select className="cr-field mt-1" disabled={disabled || busy} value={desired.director?.override?.mode ?? "auto"} onChange={event => onAction("override", { mode: event.target.value === "auto" ? null : event.target.value as OverrideMode })}><option value="auto">Return to auto</option><option value="until_released">Until released</option><option value="five_minutes">Five minutes</option><option value="until_source_offline">Until source offline</option><option value="until_next_event">Until next event</option></select></label>
<p className="w-full text-xs text-text-faint">{locked ? "LOCKED" : desired.director?.override ? "MANUAL" : "AUTO"} · TAKE uses the next ready frame boundary; CUT requests an immediate switch. Both wait for worker confirmation.</p>
</section>;
}

View file

@ -0,0 +1,43 @@
import type { Channel, Desired, Observed, Production, StateFrame } from "./types";
export interface TallyState { channel: Channel; snapshots: ReadonlyMap<number, Production>; observedProduction: Production | null; lastSeq: number }
function boundSnapshots(snapshots: Map<number, Production>, realizedVersion: number | null | undefined): void {
while (snapshots.size > 256) {
const oldest = [...snapshots.keys()].find(key => key !== realizedVersion);
if (oldest === undefined) break;
snapshots.delete(oldest);
}
}
function realized(channel: Channel, snapshots: ReadonlyMap<number, Production>): Production | null {
const observed = channel.observed;
return observed && observed.state !== "STOPPED" && observed.state !== "FAILED" && observed.channelId === channel.channelId && observed.assignmentEpoch === channel.assignmentEpoch && observed.workerId === channel.workerId && observed.realizedVersion !== null
? snapshots.get(observed.realizedVersion) ?? null : null;
}
export function initialTally(channel: Channel): TallyState {
const snapshots = new Map<number, Production>();
if (channel.desired?.production) snapshots.set(channel.desired.desiredVersion, channel.desired.production);
return { channel, snapshots, observedProduction: realized(channel, snapshots), lastSeq: 0 };
}
export function refetchTally(channel: Channel, previous: TallyState | null): TallyState {
const fresh = initialTally(channel);
if (!previous || previous.channel.channelId !== channel.channelId || previous.channel.workerId !== channel.workerId || previous.channel.assignmentEpoch !== channel.assignmentEpoch) return fresh;
const snapshots = new Map([...previous.snapshots, ...fresh.snapshots]);
boundSnapshots(snapshots, channel.observed?.realizedVersion);
return { ...fresh, snapshots, observedProduction: realized(channel, snapshots) };
}
export function tallyReducer(state: TallyState, frame: StateFrame): TallyState {
if (frame.channelId !== state.channel.channelId || frame.seq <= state.lastSeq) return state;
const channel = { ...state.channel };
const snapshots = new Map(state.snapshots);
if (frame.kind === "desired") {
channel.desired = frame.data as Desired | null;
if (channel.desired?.production) snapshots.set(channel.desired.desiredVersion, channel.desired.production);
} else channel.observed = frame.data as Observed | null;
// Keep the realized snapshot while bounding memory during long sessions.
boundSnapshots(snapshots, channel.observed?.realizedVersion);
return { channel, snapshots, observedProduction: frame.kind === "observed" ? realized(channel, snapshots) : state.observedProduction, lastSeq: frame.seq };
}
export function tileTally(production: Production | null, sourceId: string): "program" | "preview" | "none" {
const contains = (id: string | null | undefined): boolean => Boolean(production?.scenes.find(scene => scene.id === id)?.slots.some(slot => slot.sourceId === sourceId));
return contains(production?.buses.program) ? "program" : contains(production?.buses.preview) ? "preview" : "none";
}

View file

@ -0,0 +1,14 @@
// Web-facing subset of the live media-control contracts. No remote source URLs are consumed.
export interface Slot { index: number; sourceId: string | null; rect: { x: number; y: number; w: number; h: number } | null; fit: "contain" | "cover" | "crop"; audioMode: "off" | "on" | "primary"; gainDb: number }
export interface Scene { id: string; label: string; layout: "single" | "pip" | "2x1" | "2x2" | "3x3" | "4x4" | "custom"; slots: Slot[] }
export interface Source { id: string; label: string; kind: "hls" | "playlist" }
export interface Overlay { id: string; sceneId: string; kind: "lower_third" | "chyron" | "logo" | "clock"; enabled: boolean; layer: number; position: { x: number; y: number }; revisionId: string | null; assetId: string | null }
export interface Production { scenes: Scene[]; sources: Source[]; buses: { preview: string | null; program: string | null }; overlays: Overlay[]; audio: { masterGainDb: number } }
export type OverrideMode = "until_released" | "five_minutes" | "until_source_offline" | "until_next_event";
export interface Desired { desiredVersion: number; desiredStatus: string; production?: Production; director?: { locked: boolean; override: { mode: OverrideMode; expiresAt: string | null } | null } }
export interface Observed { workspaceId: string; channelId: string; workerId: string; assignmentEpoch: number; state: string; realizedVersion: number | null; updatedAt: string; reason: string }
export interface Channel { channelId: string; workerId: string; assignmentEpoch: number; desired: Desired | null; observed: Observed | null; converged: boolean; workerAlive: boolean }
export interface Status { channels: Channel[] }
export interface StateFrame { type: "state"; workspaceId: string; channelId: string; kind: "desired" | "observed"; seq: number; data: Desired | Observed | null }
export interface PreviewUrls { thumbnail: string; video: string; audio: string }
export interface Receipt { desiredVersion: number; commandId: string }

View file

@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import type { ChatMessage, Delivery, LiveChatCapability, ModerateChatBody, SendChatMessageBody } from "../../../../../packages/core/src/chat-contract";
import { CHAT_CONTRACT_EXAMPLES } from "../../../../../packages/core/src/chat-contract.schema";
export interface ChatPanelProps {
messages?: ChatMessage[];
capabilities?: LiveChatCapability[];
delivery?: Delivery[];
readOnlyEmbedUrl?: string;
onSend?: (body: SendChatMessageBody) => Promise<void>;
onModerate?: (messageId: string, body: ModerateChatBody) => Promise<void>;
}
const fixtures: ChatMessage[] = [CHAT_CONTRACT_EXAMPLES.chatMessage];
const fixtureCapabilities: LiveChatCapability[] = [CHAT_CONTRACT_EXAMPLES.capability];
/** Standalone surface. The Control Room supplies REST/WS state and callbacks. */
export function ChatPanel({ messages = fixtures, capabilities = fixtureCapabilities, delivery = [], readOnlyEmbedUrl, onSend, onModerate }: ChatPanelProps) {
const [platform, setPlatform] = useState("all");
const [target, setTarget] = useState("all");
const [text, setText] = useState("");
const [replyTo, setReplyTo] = useState<string>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [demoMessages, setDemoMessages] = useState<ChatMessage[]>([]);
const [hidden, setHidden] = useState<string[]>([]);
const rows = [...messages, ...demoMessages].filter(message => !message.isModerated && !hidden.includes(message.id) && (platform === "all" || message.provider === platform));
const canSend = capabilities.some(cap => cap.reply && (cap.state === "ok" || cap.state === "degraded") && (target === "all" || cap.connectionId === target));
const reply = async () => {
if (!text.trim() || busy || !canSend) return;
setBusy(true); setError("");
try {
const body: SendChatMessageBody = { text, targets: target === "all" ? "all" : [target], ...(replyTo ? { replyTo } : {}) };
if (onSend) await onSend(body);
else setDemoMessages(current => [...current, { ...CHAT_CONTRACT_EXAMPLES.chatMessage, id: crypto.randomUUID(), seq: messages.length + current.length + 1, text, author: { handle: "studio", color: "hsl(200 70% 62%)" }, sentAt: new Date().toISOString(), receivedAt: new Date().toISOString(), ...(replyTo ? { replyTo } : {}) }]);
setText(""); setReplyTo(undefined);
} catch (cause) { setError(cause instanceof Error ? cause.message : "Reply failed"); }
finally { setBusy(false); }
};
const moderate = async (message: ChatMessage, body: ModerateChatBody) => {
setBusy(true); setError("");
try {
if (onModerate) await onModerate(message.id, body);
else setHidden(current => [...current, message.id]);
} catch (cause) { setError(cause instanceof Error ? cause.message : "Moderation failed"); }
finally { setBusy(false); }
};
return <section aria-label="Live chat" className="flex min-h-80 flex-col gap-3 rounded-lg border border-white/10 bg-zinc-950 p-4 text-zinc-100">
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">Live chat</h2>
<label>Platform <select aria-label="Filter platform" value={platform} onChange={event => setPlatform(event.target.value)} className="rounded bg-zinc-800 p-1">
<option value="all">All platforms</option>{[...new Set(messages.map(message => message.provider))].map(provider => <option key={provider}>{provider}</option>)}
</select></label>
</div>
{!onSend && <p className="text-xs text-zinc-400">Fixture preview</p>}
{readOnlyEmbedUrl && readOnlyEmbedUrl.startsWith("https://") && <iframe title="Read-only live chat" src={readOnlyEmbedUrl} className="h-80 w-full rounded" sandbox="allow-scripts allow-same-origin" referrerPolicy="no-referrer" />}
{capabilities.filter(cap => cap.reason).map(cap => <p key={cap.connectionId} className="text-xs text-zinc-400">{cap.provider}: {cap.reason}</p>)}
<ol aria-live="polite" className="max-h-96 flex-1 space-y-3 overflow-auto">
{rows.map(message => <li key={message.id} className="rounded bg-zinc-900 p-2">
<div className="flex flex-wrap items-center gap-2 text-sm"><span className="rounded bg-zinc-800 px-2 text-xs">{message.provider}</span>
<strong style={{ color: message.author.color }}>{message.author.displayName ?? message.author.handle}</strong>
{message.author.badges?.map(badge => <span key={badge} className="text-xs text-zinc-400">{badge}</span>)}
<time className="text-xs text-zinc-500" dateTime={message.sentAt}>{new Date(message.sentAt).toLocaleTimeString()}</time>
</div><p className="whitespace-pre-wrap break-words">{message.text}</p>
<div className="mt-1 flex gap-3 text-xs"><button type="button" onClick={() => setReplyTo(message.id)}>Reply</button>
{capabilities.some(cap => cap.connectionId === message.connectionId && cap.moderate && (cap.state === "ok" || cap.state === "degraded")) && <>
<button type="button" disabled={busy} onClick={() => void moderate(message, { action: "delete" })}>Delete</button>
<button type="button" disabled={busy} onClick={() => void moderate(message, { action: "timeout", durationSeconds: 600 })}>Timeout 10 min</button>
</>}
</div>
</li>)}
</ol>
{rows.length === 0 && <p className="text-sm text-zinc-400">No messages yet.</p>}
<form className="space-y-2" onSubmit={event => { event.preventDefault(); void reply(); }}>
{replyTo && <p className="text-xs">Replying to a message <button type="button" onClick={() => setReplyTo(undefined)}>Cancel</button></p>}
<label className="block text-sm">Send to <select aria-label="Reply targets" className="rounded bg-zinc-800 p-1" value={target} onChange={event => setTarget(event.target.value)}>
<option value="all">All capable</option>{capabilities.map(cap => <option key={cap.connectionId} value={cap.connectionId} disabled={!cap.reply || (cap.state !== "ok" && cap.state !== "degraded")}>{cap.provider}{cap.reason ? ` — ${cap.reason}` : ""}</option>)}
</select></label>
<div className="flex gap-2"><input aria-label="Chat reply" value={text} onChange={event => setText(event.target.value)} maxLength={500} placeholder="Write a reply…" className="min-w-0 flex-1 rounded bg-zinc-800 p-2" />
<button disabled={busy || !text.trim() || !canSend} className="rounded bg-indigo-600 px-4 disabled:opacity-40">Send</button></div>
{delivery.map(row => <p key={row.connectionId} className="text-xs">{row.provider}: {row.status}{row.error ? ` — ${row.error}` : ""}</p>)}
{error && <p role="alert" className="text-sm text-red-400">{error}</p>}
</form>
</section>;
}

View file

@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { FeedItem } from "../../lib/fixtures";
import { PLATFORMS, type Platform } from "../../lib/platforms";
import type { ConnectionSummary } from "../../lib/api-types";
import { useApiResource } from "../../lib/use-api";
import { Sheet } from "./sheet";
import { nativeAmplifyActions } from "../../lib/amplify";
const identity = (value: ConnectionSummary[]): ConnectionSummary[] => value;
const empty: ConnectionSummary[] = [];
export function AmplifySheet({ post, platform, open, onClose }: { post: FeedItem | null; platform: Platform | null; open: boolean; onClose: () => void }): React.ReactElement {
const router = useRouter();
const connections = useApiResource<ConnectionSummary[], ConnectionSummary[]>("/connections", identity, empty);
const [picked, setPicked] = useState<string[]>([]);
const [notice, setNotice] = useState("");
const native = platform === null ? [] : nativeAmplifyActions(platform);
function compose(ids: string[]): void {
if (post === null) return;
const params = new URLSearchParams({ text: `${post.text}\n\nVia ${post.handle}`, targets: ids.join(",") });
onClose();
router.push(`/compose?${params.toString()}`);
}
async function share(): Promise<void> {
if (post === null) return;
const url = window.location.href;
if (navigator.share) await navigator.share({ title: `Post by ${post.handle}`, text: post.text, url }).catch(() => undefined);
else if (navigator.clipboard) { await navigator.clipboard.writeText(`${post.text}\n${url}`); setNotice("Post text copied"); }
}
return <Sheet open={open} onClose={onClose} title={post === null ? "Amplify" : `Amplify ${post.handle}`} footer={<div className="flex gap-2"><button type="button" className="min-h-11 min-w-11 rounded-md border border-border px-3" onClick={() => void share()}>Share</button><button type="button" className="min-h-11 flex-1 rounded-md bg-accent px-3 text-sm font-semibold text-white disabled:opacity-40" disabled={picked.length === 0} onClick={() => compose(picked)}>Crosspost to {picked.length}</button></div>}>
{platform !== null && <><h3 className="mt-2 text-xs font-semibold uppercase text-text-faint">Native on {platform.displayName}</h3>
{native.length === 0 ? <p className="mt-3 text-xs text-text-faint">This platform exposes no native amplify action.</p> : native.map((action) => <button key={action.kind} type="button" className="mt-2 flex min-h-12 w-full items-center rounded-md border border-border px-4 text-left text-sm" onClick={() => action.kind === "quote" ? compose([]) : setNotice(`${action.label} is available in the platform, but its connector action is not wired yet.`)}>{action.label}{action.kind === "quote" && <span className="ml-auto text-xs text-text-faint">Opens compose</span>}</button>)}
</>}
{notice && <p className="mt-2 text-xs text-warn" role="status">{notice}</p>}
<h3 className="mt-6 text-xs font-semibold uppercase text-text-faint">Crosspost to your other accounts</h3>
{connections.source !== "live" && <p className="mt-2 text-xs text-text-faint">Connect to the API to choose publishing accounts.</p>}
{connections.data.filter((connection) => connection.provider !== platform?.id).map((connection) => {
const target = PLATFORMS[connection.provider];
const reason = !target?.capabilities.publishText ? "Text publishing unavailable" : target.capabilities.requiresMediaForPublish && !post?.hasMedia ? `${target.displayName} needs media` : connection.health !== "healthy" ? "Reconnect account first" : "";
return <label key={connection.id} className={`mt-2 flex min-h-14 items-center gap-3 rounded-md border border-border px-3 ${reason ? "opacity-60" : ""}`}>
<input type="checkbox" className="h-5 w-5 accent-accent" disabled={reason !== ""} checked={picked.includes(connection.id)} onChange={(event) => setPicked((current) => event.target.checked ? [...current, connection.id] : current.filter((id) => id !== connection.id))} />
<span className="min-w-0 flex-1"><span className="block text-sm">{target?.displayName ?? connection.provider}</span><span className="block text-xs text-text-faint">{reason || "Creates a new attributed post in compose"}</span></span>
</label>;
})}
</Sheet>;
}

View file

@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { api, errorMessage } from "../../lib/api-client";
import type { ConnectionSummary, DraftRecord, ScheduledPublicationRecord } from "../../lib/api-types";
import { useApiResource } from "../../lib/use-api";
import { useAuth } from "../../lib/auth-store";
import { DOCUMENTED_RATES, MOCK_COLUMN_DEFS, PLATFORMS } from "../../lib/platforms";
import { OfflineBadge } from "../ui/offline-badge";
import { Sheet } from "./sheet";
const sample: DraftRecord[] = [{ id: "sample-review", workspaceId: "sample", authorUserId: "sample-contributor", state: "in_review", createdAt: "2026-10-07T08:00:00Z", updatedAt: "2026-10-07T08:00:00Z", post: { text: "Draft launch update. Please review the target wording before it goes out.", mediaAssetIds: [], visibility: "public", providerMetadata: { targetConnectionIds: [MOCK_COLUMN_DEFS[0]?.platformId] } } }];
const identity = (value: DraftRecord[]): DraftRecord[] => value;
const connectionIdentity = (value: ConnectionSummary[]): ConnectionSummary[] => value;
const noConnections: ConnectionSummary[] = [];
const quickReasons = ["Needs edits", "Wrong target", "Please add context"];
export function ApprovalsSegment(): React.ReactElement {
const { can } = useAuth();
const resource = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, sample, { state: "in_review", limit: 100 });
const connections = useApiResource<ConnectionSummary[], ConnectionSummary[]>("/connections", connectionIdentity, noConnections);
const [rejecting, setRejecting] = useState<DraftRecord | null>(null);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [notice, setNotice] = useState("");
const live = resource.source === "live";
async function decide(draft: DraftRecord, action: "approve" | "reject"): Promise<void> {
if (!live || !can("approval.grant")) return;
setBusy(draft.id);
setNotice("");
try {
await api.post<DraftRecord>(`/publishing/drafts/${draft.id}/${action}`, action === "reject" ? { reason } : {});
const connectionId = draft.post.providerMetadata.connectionId;
const queueScheduled = action === "approve" && draft.post.scheduledAt && typeof connectionId === "string" && can("publication.schedule");
if (queueScheduled) await api.post<ScheduledPublicationRecord>(`/publishing/drafts/${draft.id}/schedule`, { connectionId, publishAt: draft.post.scheduledAt });
resource.setData((current) => current.filter((item) => item.id !== draft.id));
setNotice(`${action === "approve" ? queueScheduled ? "Approved and queued" : "Approved" : "Rejected"} · decision recorded in audit log.`);
setRejecting(null);
setReason("");
resource.reload();
} catch (error) { setNotice(errorMessage(error)); }
finally { setBusy(null); }
}
return <section className="min-w-0 flex-1 overflow-y-auto p-4" aria-label="Approvals">
<div className="mb-4 flex items-center justify-between"><div><h2 className="text-lg font-semibold">Approvals</h2><p className="text-xs text-text-faint">{resource.data.length} waiting for review</p></div><OfflineBadge source={resource.source} /></div>
{resource.source !== "live" && <p className="mb-3 text-xs text-text-faint">Sample review only. Connect to the API to approve or reject.</p>}
{resource.error && <p className="mb-3 text-xs text-danger">{resource.error}</p>}
{notice && <div role="status" className="mb-3 flex min-h-11 items-center justify-between gap-2 rounded-md border border-border px-3 text-xs"><span>{notice}</span><Link href="/audit" className="shrink-0 text-accent">View audit</Link></div>}
{resource.data.length === 0 && <p className="py-8 text-center text-sm text-text-faint">No drafts waiting for review.</p>}
<div className="space-y-3">{resource.data.map((draft) => {
const ids = Array.isArray(draft.post.providerMetadata.targetConnectionIds) ? draft.post.providerMetadata.targetConnectionIds : [draft.post.providerMetadata.connectionId].filter(Boolean);
const targets = ids.filter((id): id is string => typeof id === "string");
const scheduled = draft.post.scheduledAt;
const targetConnections = targets.map((id) => connections.data.find((connection) => connection.id === id)).filter((connection): connection is ConnectionSummary => connection !== undefined);
const tooLong = targetConnections.length > 0 ? targetConnections.some((connection) => draft.post.text.length > (PLATFORMS[connection.provider]?.charLimit ?? 500)) : draft.post.text.length > 500;
const missingMedia = targetConnections.some((connection) => PLATFORMS[connection.provider]?.capabilities.requiresMediaForPublish && draft.post.mediaAssetIds.length === 0);
const atCapacity = targetConnections.some((connection) => { const cap = DOCUMENTED_RATES[connection.provider]?.dailyPostCap; return cap !== undefined && connection.recentPublishes.length >= cap; });
return <article key={draft.id} className="rounded-lg border border-border bg-surface p-4">
<div className="flex items-center justify-between gap-2"><span className="flex min-w-0 items-center gap-2"><span className="truncate text-sm font-semibold">{draft.authorUserId === "sample-contributor" ? "Sample contributor" : `Contributor ${draft.authorUserId.slice(0, 8)}`}</span>{!live && <span className="shrink-0 rounded-full border border-border px-2 py-0.5 text-2xs text-text-faint">sample</span>}</span><span className={`rounded-full px-2 py-1 text-2xs ${scheduled ? "bg-accent-dim text-accent" : "bg-surface-2 text-text-dim"}`}>{scheduled ? new Date(scheduled).toLocaleString() : "Post now"}</span></div>
<p className="mt-3 whitespace-pre-wrap break-words text-sm">{draft.post.text}</p>
<div className="mt-3 flex flex-wrap gap-1">{targets.map((id) => { const connection = connections.data.find((item) => item.id === id); return <span key={id} className="rounded-full border border-border px-2 py-1 text-2xs text-text-dim">{PLATFORMS[connection?.provider ?? id]?.displayName ?? id.slice(0, 8)}</span>; })}</div>
{(tooLong || missingMedia || atCapacity) && <p className="mt-3 text-xs text-warn">Preflight: {[tooLong && "over a target text limit", missingMedia && "target needs media", atCapacity && "target at capacity"].filter(Boolean).join(" · ")}. Review before approving.</p>}
{scheduled && <p className="mt-2 text-xs text-text-faint">{can("publication.schedule") && typeof draft.post.providerMetadata.connectionId === "string" ? "Approving queues this scheduled post; it does not publish now." : "Approval changes the draft state. A publisher must queue the scheduled post."}</p>}
<div className="mt-4 flex gap-2"><button type="button" disabled className="min-h-11 rounded-md border border-border px-3 text-xs opacity-40" title="Review comments are not available in the API">Reply</button><button type="button" disabled={!live || !can("approval.grant") || busy !== null} onClick={() => { setRejecting(draft); setReason(""); }} className="min-h-11 rounded-md border border-danger px-3 text-xs text-danger disabled:opacity-40">Reject</button><button type="button" disabled={!live || !can("approval.grant") || busy !== null} onClick={() => void decide(draft, "approve")} className="min-h-11 flex-1 rounded-md bg-accent px-3 text-xs font-semibold text-white disabled:opacity-40">Approve</button></div>
{tooLong && <p className="mt-2 text-2xs text-text-faint">Auto-trim requires an edit operation during review and is unavailable.</p>}
</article>;
})}</div>
<Sheet open={rejecting !== null} onClose={() => setRejecting(null)} title="Reject draft" footer={<button type="button" disabled={busy !== null || reason.trim() === ""} onClick={() => { if (rejecting) void decide(rejecting, "reject"); }} className="min-h-11 w-full rounded-md bg-danger px-3 text-sm font-semibold text-white disabled:opacity-40">Reject with reason</button>}>
<p className="mb-3 text-xs text-text-faint">Choose a reason or write your own.</p><div className="flex flex-wrap gap-2">{quickReasons.map((pick) => <button key={pick} type="button" onClick={() => setReason(pick)} className="min-h-11 rounded-full border border-border px-3 text-xs">{pick}</button>)}</div><textarea value={reason} onChange={(event) => setReason(event.target.value)} placeholder="Reason for rejection" className="mt-3 min-h-24 w-full rounded-md border border-border bg-bg p-3 text-sm" />
</Sheet>
</section>;
}

View file

@ -0,0 +1,101 @@
"use client";
import Link from "next/link";
import type { MouseEvent } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { CreateFab } from "./create-fab";
import { SpeedDial } from "./speed-dial";
import { useApiResource } from "../../lib/use-api";
import type { DraftRecord } from "../../lib/api-types";
import { NavIcon } from "../brand";
const emptyDrafts: DraftRecord[] = [];
const identity = (drafts: DraftRecord[]): DraftRecord[] => drafts;
const tabs = [
{ href: "/streams", label: "Home", icon: "home" },
{ href: "/search", label: "Search", icon: "search" },
{ href: "/inbox", label: "Inbox", icon: "inbox" },
{ href: "/more", label: "More", icon: "more" },
] as const;
/** Exact-match-first tab ranking: a route that is a tab itself outranks one that
* merely lives under it, and routes like /compose match nothing at all — so
* /compose no longer highlights Home and More no longer swallows calendar/video. */
function activeTab(pathname: string): string {
const candidates: Array<[string, number]> = [
["/inbox", pathname === "/inbox" || pathname.startsWith("/inbox/") ? 5 : -1],
["/approvals", pathname === "/approvals" ? 4 : -1],
["/streams", pathname === "/streams" ? 3 : -1],
["/search", pathname === "/search" ? 3 : -1],
["/more", pathname === "/more" ? 3 : -1],
["/team", pathname.startsWith("/team") ? 1 : -1],
["/connections", pathname.startsWith("/connections") ? 1 : -1],
["/audit", pathname.startsWith("/audit") ? 1 : -1],
["/settings", pathname.startsWith("/settings") ? 1 : -1],
["/onboarding", pathname.startsWith("/onboarding") ? 1 : -1],
["/calendar", pathname.startsWith("/calendar") ? 1 : -1],
["/analytics", pathname.startsWith("/analytics") ? 1 : -1],
["/video", pathname.startsWith("/video") ? 1 : -1],
];
let best = "";
let bestScore = 0;
for (const [href, score] of candidates) {
if (score > bestScore) { best = href; bestScore = score; }
}
return best;
}
export function BottomNav({ badges }: { badges: { inbox: number } }): React.ReactElement {
const pathname = usePathname();
const router = useRouter();
const [open, setOpen] = useState(false);
const [lastTap, setLastTap] = useState<{ href: string; time: number } | null>(null);
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, emptyDrafts, { limit: 100 });
// The unread badge is supplied by the shell from live approvals-in-review; keep
// this fetch only for draft counts (Speed dial / create button).
const actionable = badges.inbox;
const active = activeTab(pathname);
const activate = (href: string, event: MouseEvent<HTMLAnchorElement>): void => {
if (active !== href || pathname !== href) return;
event.preventDefault();
const now = Date.now();
const scroller = document.querySelector<HTMLElement>("[data-mobile-main]");
scroller?.scrollTo({ top: 0, behavior: "smooth" });
scroller?.querySelectorAll<HTMLElement>("*").forEach((element) => {
if (element.scrollTop > 0 && /auto|scroll/.test(getComputedStyle(element).overflowY)) element.scrollTo({ top: 0, behavior: "smooth" });
});
if (href === "/search") document.querySelector<HTMLInputElement>("[data-search-field]")?.focus();
if (lastTap?.href === href && now - lastTap.time < 750) router.refresh();
setLastTap({ href, time: now });
};
return (
<>
<SpeedDial open={open} onClose={() => setOpen(false)} onResumeDrafts={() => router.push("/compose#drafts")} draftCount={drafts.data.length} />
<nav aria-label="Primary navigation" className={`relative z-50 grid h-[calc(var(--mobile-tab-height)+var(--mobile-safe-bottom))] shrink-0 grid-cols-5 border-t border-border-subtle bg-surface pb-[var(--mobile-safe-bottom)] lg:hidden ${open ? "pointer-events-none" : ""}`}>
{tabs.slice(0, 2).map((tab) => <Tab key={tab.href} {...tab} active={active === tab.href} badge={0} onClick={(event) => activate(tab.href, event)} />)}
<div className="relative flex items-start justify-center">
<div className="pointer-events-auto absolute -top-6 z-10"><CreateFab draftCount={drafts.data.length} open={open} onCompose={() => router.push("/compose")} onLongPress={() => setOpen((value) => !value)} /></div>
<button type="button" aria-label="Post" onClick={() => router.push("/compose")} className="pointer-events-auto absolute bottom-0 flex min-h-11 min-w-11 items-end justify-center pb-1 text-[11px] text-text-dim">Post</button>
</div>
{tabs.slice(2).map((tab) => <Tab key={tab.href} {...tab} active={active === tab.href} badge={tab.href === "/inbox" ? actionable : 0} onClick={(event) => activate(tab.href, event)} />)}
</nav>
</>
);
}
function Tab({ href, label, icon, active, badge, onClick }: {
href: string; label: string; icon: string; active: boolean; badge: number; onClick: (event: MouseEvent<HTMLAnchorElement>) => void;
}): React.ReactElement {
return (
<Link href={href} scroll={false} onClick={onClick} aria-current={active ? "page" : undefined}
className="relative flex min-h-11 flex-col items-center justify-center gap-0.5 text-[11px] font-semibold text-text-dim">
<span className={`relative flex h-8 w-[60px] items-center justify-center rounded-full text-[24px] leading-none ${active ? "bg-accent-dim text-accent" : ""}`}>
<NavIcon name={icon} size={24} />
{badge > 0 && <span aria-label={`${badge} unread`} className="absolute -right-1 -top-1 flex min-h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1 text-[10px] text-white">{badge > 99 ? "99+" : badge}</span>}
</span>
<span className={active ? "text-accent" : ""}>{label}</span>
</Link>
);
}

View file

@ -0,0 +1,28 @@
"use client";
import { useRef } from "react";
export function CreateFab({ draftCount, onCompose, onLongPress, open = false }: {
draftCount: number;
onCompose: () => void;
onLongPress: () => void;
open?: boolean;
}): React.ReactElement {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressed = useRef(false);
const clear = (): void => { if (timer.current !== null) clearTimeout(timer.current); timer.current = null; };
return (
<button type="button" aria-label={open ? "Close create menu" : `Create post${draftCount > 0 ? `, ${draftCount} drafts` : ""}`}
aria-expanded={open}
onPointerDown={() => { longPressed.current = false; clear(); timer.current = setTimeout(() => {
longPressed.current = true;
if ("vibrate" in navigator) navigator.vibrate(20);
onLongPress();
}, 450); }}
onPointerUp={clear} onPointerCancel={clear} onPointerLeave={clear}
onClick={() => { if (longPressed.current) { longPressed.current = false; return; } if (open) onLongPress(); else onCompose(); }}
className={`relative flex h-[var(--mobile-fab-size)] w-[var(--mobile-fab-size)] items-center justify-center rounded-full border-4 border-bg text-3xl font-light transition-transform ${open ? "rotate-45 bg-white text-bg" : "bg-accent text-white"}`}>
+
</button>
);
}

View file

@ -0,0 +1,28 @@
"use client";
import { Avatar } from "../ui/primitives";
import { ageLabel } from "../../lib/fixtures";
import type { StreamItem as FeedItem } from "../../lib/streams-data";
import type { Platform } from "../../lib/platforms";
import { SwipeItem } from "./swipe-item";
export function PostCard({ item, platform, onAmplify, onReply, match = "", selected = false, onSelect }: { item: FeedItem; platform: Platform; onAmplify: () => void; onReply: () => void; match?: string; selected?: boolean; onSelect?: () => void }): React.ReactElement {
const index = match.trim() === "" ? -1 : item.text.toLowerCase().indexOf(match.trim().toLowerCase());
const text = index < 0 ? item.text : <>{item.text.slice(0, index)}<strong className="font-bold text-accent">{item.text.slice(index, index + match.trim().length)}</strong>{item.text.slice(index + match.trim().length)}</>;
return <SwipeItem leftActions={[{ label: platform.amplifyLabels.repost ?? platform.amplifyLabels.share ?? "Amplify", onClick: onAmplify, tone: "accent" }, { label: "Quote", onClick: onAmplify }, { label: "Crosspost", onClick: onAmplify }]}>
<article onMouseEnter={onSelect} data-stream-selected={selected || undefined} className={`border-b border-border-subtle px-4 py-4 ${selected ? "bg-accent-dim" : ""}`} data-post-card>
<div className="flex gap-3"><Avatar handle={item.handle} /><div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2"><span className="truncate text-sm font-semibold">{item.author}</span><span className="truncate text-xs text-text-faint">{item.handle}</span><span className="ml-auto shrink-0 text-xs text-text-faint">{ageLabel(item.ageMinutes)}</span></div>
<span className="mt-1 inline-flex rounded-full border border-border px-2 py-0.5 text-2xs text-text-dim"><span className="mr-1.5 h-2 w-2 self-center rounded-full bg-accent" />{item.sourceLabel ?? platform.displayName}</span>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-relaxed">{text}</p>
{item.mediaUrls?.map((url) => <a key={url} href={url} target="_blank" rel="noreferrer" className="mr-2 text-xs text-accent">View media ↗</a>)}
{item.hasMedia && !item.mediaUrls && <div className="mt-3 flex min-h-28 items-center justify-center rounded-lg border border-border-subtle bg-surface-2 text-xs text-text-faint">Media preview · sample feed</div>}
<div className="mt-3 flex items-center justify-between text-xs text-text-faint">
<button type="button" className="min-h-11 min-w-11 text-left" onClick={onReply} aria-label={`Reply to ${item.handle}`}>↩ {item.replies}</button>
<button type="button" className={`min-h-11 min-w-11 ${item.connectionId === undefined ? "opacity-50" : ""}`} onClick={onAmplify} title={item.connectionId === undefined ? "not connected — connect an account to boost" : undefined} aria-label={`Amplify ${item.handle}`}>↻ {item.reposts}</button>
<span className="flex min-h-11 min-w-11 items-center justify-end" aria-label={`${item.likes} likes`}>♡ {item.likes}</span>
</div>
</div></div>
</article>
</SwipeItem>;
}

View file

@ -0,0 +1,28 @@
"use client";
import { useRef, useState, type ReactNode, type TouchEvent } from "react";
import { Loading } from "../brand/loading";
export function PullToRefresh({ onRefresh, sourceCount, children }: { onRefresh: () => Promise<void>; sourceCount: number; children: ReactNode }): React.ReactElement {
const start = useRef<number | null>(null);
const [distance, setDistance] = useState(0);
const [busy, setBusy] = useState(false);
function touchStart(event: TouchEvent<HTMLDivElement>): void {
if (event.currentTarget.scrollTop === 0) start.current = event.touches[0]?.clientY ?? null;
}
function touchMove(event: TouchEvent<HTMLDivElement>): void {
if (start.current === null || event.currentTarget.scrollTop > 0) return;
setDistance(Math.max(0, Math.min(90, ((event.touches[0]?.clientY ?? start.current) - start.current) * 0.55)));
}
function touchEnd(): void {
start.current = null;
if (distance >= 60 && !busy) {
setBusy(true);
void onRefresh().finally(() => { setBusy(false); setDistance(0); });
} else setDistance(0);
}
return <div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain" onTouchStart={touchStart} onTouchMove={touchMove} onTouchEnd={touchEnd} data-pull-to-refresh>
<div className="flex items-center justify-center gap-2 overflow-hidden text-xs text-text-faint" style={{ height: busy ? 48 : distance }} aria-live="polite">{(busy || distance > 16) && <><Loading size={18} label="Refreshing" />{busy ? "Refreshing" : "Pull to refresh"} · {sourceCount} sources</>}</div>
{children}
</div>;
}

View file

@ -0,0 +1,25 @@
"use client";
export interface SegmentOption<T extends string> {
value: T;
label: string;
count?: number;
}
export function Segmented<T extends string>({ options, value, onChange }: {
options: SegmentOption<T>[];
value: T;
onChange: (value: T) => void;
}): React.ReactElement {
return (
<div role="tablist" className="flex min-w-0 gap-1 overflow-x-auto rounded-lg bg-surface-2 p-1 scrollbar-thin">
{options.map((option) => (
<button key={option.value} role="tab" aria-selected={option.value === value}
onClick={() => onChange(option.value)}
className={`flex min-h-11 shrink-0 items-center justify-center gap-1 rounded-md px-3 text-xs font-semibold ${option.value === value ? "bg-accent-dim text-accent" : "text-text-dim"}`}>
{option.label}{option.count !== undefined && option.count > 0 && <span>{option.count > 99 ? "99+" : option.count}</span>}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
export function Sheet({ open, onClose, title, children, footer }: {
open: boolean;
onClose: () => void;
title: ReactNode;
children: ReactNode;
footer?: ReactNode;
}): React.ReactElement | null {
const panel = useRef<HTMLDivElement>(null);
const startY = useRef<number | null>(null);
useEffect(() => {
if (!open) return;
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
panel.current?.focus();
const onKey = (event: KeyboardEvent): void => {
if (event.key === "Escape") onClose();
if (event.key !== "Tab" || panel.current === null) return;
const focusable = Array.from(panel.current.querySelectorAll<HTMLElement>("button:not(:disabled), a[href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex='-1'])"));
if (focusable.length === 0) { event.preventDefault(); return; }
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
};
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("keydown", onKey); previous?.focus(); };
}, [open, onClose]);
if (!open || typeof document === "undefined") return null;
return createPortal(
<div className="fixed inset-0 z-[70] flex items-end" role="presentation">
<button className="absolute inset-0 bg-black/70" aria-label="Close sheet" onClick={onClose} />
<div ref={panel} tabIndex={-1} role="dialog" aria-modal="true" aria-label={typeof title === "string" ? title : "Options"}
className="relative flex max-h-[90dvh] w-full flex-col overflow-hidden rounded-t-[var(--mobile-sheet-radius)] border-t border-border bg-surface-2 pb-[var(--mobile-safe-bottom)] outline-none">
<div role="button" tabIndex={0} aria-label="Drag down to close" className="flex h-11 shrink-0 items-start justify-center pt-2"
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") onClose(); }}
onTouchStart={(event) => { startY.current = event.touches[0]?.clientY ?? null; }}
onTouchEnd={(event) => { if (startY.current !== null && (event.changedTouches[0]?.clientY ?? 0) - startY.current > 80) onClose(); startY.current = null; }}>
<span className="h-1 w-10 rounded-full bg-border" />
</div>
<header className="flex min-h-14 items-center justify-between gap-3 px-4">
<h2 className="text-base font-semibold">{title}</h2>
<button className="flex h-11 w-11 items-center justify-center text-xl" aria-label="Close" onClick={onClose}>×</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">{children}</div>
{footer && <footer className="border-t border-border p-4">{footer}</footer>}
</div>
</div>, document.body,
);
}

View file

@ -0,0 +1,24 @@
"use client";
import { useRef, type TouchEvent } from "react";
export interface SourceChip { id: string; label: string; dot?: string; count?: number; }
export function SourceChips({ sources, value, onChange }: { sources: SourceChip[]; value: string; onChange: (value: string) => void }): React.ReactElement {
const start = useRef<{ x: number; y: number } | null>(null);
function end(event: TouchEvent<HTMLDivElement>): void {
if (start.current === null) return;
const dx = (event.changedTouches[0]?.clientX ?? start.current.x) - start.current.x;
const dy = (event.changedTouches[0]?.clientY ?? start.current.y) - start.current.y;
start.current = null;
if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
const index = sources.findIndex((source) => source.id === value);
const next = sources[Math.max(0, Math.min(sources.length - 1, index + (dx < 0 ? 1 : -1)))];
if (next) onChange(next.id);
}
return <div className="flex min-w-0 gap-2 overflow-x-auto px-4 py-2 scrollbar-thin" role="tablist" aria-label="Feed sources" onTouchStart={(event) => { start.current = { x: event.touches[0]?.clientX ?? 0, y: event.touches[0]?.clientY ?? 0 }; }} onTouchEnd={end}>
{sources.map((source) => <button key={source.id} type="button" role="tab" aria-selected={value === source.id} onClick={() => onChange(source.id)} className={`flex min-h-11 shrink-0 items-center gap-2 rounded-full border px-4 text-xs font-semibold ${value === source.id ? "border-accent bg-accent-dim text-accent" : "border-border text-text-dim"}`}>
{source.dot && <span className="h-2 w-2 rounded-full bg-accent" aria-hidden="true" />}{source.label}{source.count !== undefined && <span className="text-text-faint">{source.count}</span>}
</button>)}
</div>;
}

View file

@ -0,0 +1,44 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
// Photo/Link entries removed: compose has no ?media= handling yet, so those
// actions dead-ended. Re-add them once media attach is wired.
const items = [
{ label: "Compose", href: "/compose", position: "bottom-[168px] left-1/2 -translate-x-1/2" },
{ label: "Video", href: "/video", position: "bottom-[145px] right-[18%]" },
];
export function SpeedDial({ open, onClose, onResumeDrafts, draftCount = 0 }: {
open: boolean;
onClose: () => void;
onResumeDrafts: () => void;
draftCount?: number;
}): React.ReactElement | null {
const router = useRouter();
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent): void => { if (event.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-40 lg:hidden" role="dialog" aria-modal="true" aria-label="Create options">
<button type="button" aria-label="Close create options" className="absolute inset-0 bg-black/65" onClick={onClose} />
<div className="pointer-events-none absolute inset-x-0 bottom-[calc(var(--mobile-tab-height)+var(--mobile-safe-bottom))] h-[250px]">
{items.map((item) => (
<button key={item.label} type="button" onClick={() => { onClose(); router.push(item.href); }}
className={`pointer-events-auto absolute ${item.position} flex h-[60px] min-w-[60px] flex-col items-center justify-center rounded-full border border-accent bg-surface-2 px-2 text-xs font-semibold text-text`}>
<span className="text-lg text-accent">+</span>{item.label}
</button>
))}
{draftCount > 0 && <button type="button" onClick={() => { onClose(); onResumeDrafts(); }}
className="pointer-events-auto absolute bottom-[88px] left-[8%] flex min-h-11 items-center rounded-full border border-accent/50 bg-surface-2 px-3 text-xs text-accent">
Resume drafts · {draftCount}
</button>}
</div>
</div>
);
}

View file

@ -0,0 +1,46 @@
"use client";
import { useRef, useState, type ReactNode, type PointerEvent } from "react";
import { swipeIntent, swipeResult, type SwipeIntent } from "../../lib/gestures";
export interface SwipeAction { label: string; onClick: () => void; tone?: "accent" | "neutral"; }
export function SwipeItem({ leftActions, rightActions = [], children }: { leftActions: SwipeAction[]; rightActions?: SwipeAction[]; children: ReactNode }): React.ReactElement {
const [offset, setOffset] = useState(0);
const start = useRef<{ x: number; y: number; time: number; base: number; intent: SwipeIntent } | null>(null);
const leftWidth = leftActions.length * 68;
const rightWidth = rightActions.length * 68;
function down(event: PointerEvent<HTMLDivElement>): void {
if (event.pointerType === "mouse" && event.button !== 0) return;
start.current = { x: event.clientX, y: event.clientY, time: event.timeStamp, base: offset, intent: "pending" };
}
function move(event: PointerEvent<HTMLDivElement>): void {
const gesture = start.current;
if (gesture === null) return;
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
if (gesture.intent === "pending") gesture.intent = swipeIntent(dx, dy);
if (gesture.intent !== "horizontal") return;
setOffset(Math.max(-leftWidth, Math.min(rightWidth, gesture.base + dx)));
}
function end(event: PointerEvent<HTMLDivElement>): void {
const gesture = start.current;
start.current = null;
if (gesture?.intent !== "horizontal") return;
const dx = event.clientX - gesture.x;
const velocity = Math.abs(dx) / Math.max(1, event.timeStamp - gesture.time);
const direction = dx < 0 ? "left" : "right";
const actions = direction === "left" ? leftActions : rightActions;
const width = direction === "left" ? leftWidth : rightWidth;
if (actions.length === 0) { setOffset(0); return; }
const result = swipeResult(dx, velocity, direction, width);
if (result === "full") { setOffset(0); actions[0]?.onClick(); }
else setOffset(result === "open" ? (direction === "left" ? -width : width) : 0);
}
return <div className="relative overflow-hidden" data-swipe-item>
{rightActions.length > 0 && <div className="absolute inset-y-0 left-0 flex" style={{ width: rightWidth }}>{rightActions.map((action) => <button key={action.label} type="button" className="flex h-full min-w-[68px] flex-1 items-center justify-center bg-surface-2 px-1 text-xs" onClick={() => { setOffset(0); action.onClick(); }}>{action.label}</button>)}</div>}
{leftActions.length > 0 && <div className="absolute inset-y-0 right-0 flex" style={{ width: leftWidth }}>{leftActions.map((action) => <button key={action.label} type="button" className={`flex h-full min-w-[68px] flex-1 items-center justify-center px-1 text-xs ${action.tone === "accent" ? "bg-accent text-white" : "bg-surface-2"}`} onClick={() => { setOffset(0); action.onClick(); }}>{action.label}</button>)}</div>}
<div style={{ transform: `translateX(${offset}px)`, touchAction: "pan-y" }} onPointerDown={down} onPointerMove={move} onPointerUp={end} onPointerCancel={() => { start.current = null; setOffset(0); }} className="relative bg-bg">{children}</div>
</div>;
}

View file

@ -0,0 +1,22 @@
"use client";
import type { ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import { LogoMark } from "../brand/logo";
export function MobileTopBar({ title, actions, back = false }: { title: ReactNode; actions?: ReactNode; back?: boolean }): React.ReactElement {
const router = useRouter();
const pathname = usePathname();
return (
<header className="flex h-[60px] shrink-0 items-center gap-3 border-b border-border-subtle bg-surface px-4 lg:hidden">
{back ? (
<button type="button" aria-label="Go back" onClick={() => {
if (window.history.length > 1) router.back();
else router.push(pathname.startsWith("/inbox/") ? "/inbox" : "/more");
}} className="-ml-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-xl">←</button>
) : <LogoMark size={32} />}
<div className="min-w-0 flex-1 truncate text-lg font-bold">{title}</div>
{actions}
</header>
);
}

View file

@ -0,0 +1,87 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Command } from "cmdk";
import { LogoMark } from "../brand/logo";
import { EmptyState } from "../brand/empty-state";
const PAGES = [
{ href: "/streams", label: "Go to Streams" },
{ href: "/compose", label: "Go to Compose" },
{ href: "/video", label: "Go to Video" },
{ href: "/inbox", label: "Go to Inbox" },
{ href: "/archive", label: "Go to Archive" },
{ href: "/connections", label: "Go to Channels" },
{ href: "/team", label: "Go to Team" },
{ href: "/audit", label: "Go to Audit" },
{ href: "/onboarding", label: "Go to Onboarding" },
];
export function CommandPalette(): React.ReactElement {
const [open, setOpen] = useState(false);
const router = useRouter();
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((v) => !v);
}
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
if (!open) return <></>;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 pt-[15vh]" onClick={() => setOpen(false)}>
<Command
className="w-[480px] max-w-[90vw] overflow-hidden rounded-lg border border-border bg-surface shadow-2xl"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<div className="flex items-center gap-2.5 border-b border-border-subtle px-4">
<LogoMark size={16} title="Tactical Media Broadcast Hub" />
<Command.Input
autoFocus
placeholder="Type a command or search…"
className="w-full bg-transparent py-3 text-sm text-text placeholder:text-text-faint focus:outline-none"
/>
</div>
<Command.List className="max-h-72 overflow-y-auto p-2 scrollbar-thin">
<Command.Empty>
<EmptyState
size={40}
title="Nothing here."
hint="No command matches — the circle-A keeps watch anyway."
className="py-4"
/>
</Command.Empty>
<Command.Group heading="Navigate" className="text-2xs text-text-faint [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1">
{PAGES.map((p) => (
<Item key={p.href} onSelect={() => { setOpen(false); router.push(p.href); }}>
{p.label}
</Item>
))}
</Command.Group>
<Command.Group heading="Actions" className="text-2xs text-text-faint [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1">
<Item onSelect={() => { setOpen(false); router.push("/compose"); }}>New post (Compose)</Item>
</Command.Group>
</Command.List>
</Command>
</div>
);
}
function Item({ children, onSelect }: { children: React.ReactNode; onSelect: () => void }): React.ReactElement {
return (
<Command.Item
onSelect={onSelect}
className="cursor-pointer rounded-md px-3 py-2 text-sm text-text-dim data-[selected=true]:bg-accent-dim data-[selected=true]:text-text"
>
{children}
</Command.Item>
);
}

View file

@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "../../lib/auth-store";
import { useApiResource } from "../../lib/use-api";
import type { DraftRecord } from "../../lib/api-types";
import { LogoMark } from "../brand/logo";
import { NavIcon } from "../brand";
const NAV = [
{ href: "/streams", label: "Streams" },
{ href: "/compose", label: "Compose" },
{ href: "/calendar", label: "Calendar" },
{ href: "/video", label: "Video" },
{ href: "/control-room", label: "Control Room" },
{ href: "/inbox", label: "Inbox" },
{ href: "/approvals", label: "Approvals" },
{ href: "/archive", label: "Archive" },
{ href: "/analytics", label: "Analytics" },
{ href: "/connections", label: "Channels" },
{ href: "/team", label: "Team" },
{ href: "/audit", label: "Audit" },
{ href: "/onboarding", label: "Onboarding" },
{ href: "/settings", label: "Settings" },
];
const identity = (drafts: DraftRecord[]): DraftRecord[] => drafts;
const emptyDrafts: DraftRecord[] = [];
export function Sidebar(): React.ReactElement {
const pathname = usePathname();
const { session, can } = useAuth();
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, emptyDrafts, { state: "in_review", limit: 100 });
// Real in-review count from the API; hidden while loading/offline and for non-approvers.
const approvals = can("approval.grant") && drafts.source === "live" ? drafts.data.length : 0;
const workspaceName = session?.workspace.name ?? "Studio";
return (
<aside className="hidden h-screen w-52 shrink-0 flex-col border-r border-border-subtle bg-surface lg:flex">
<div className="flex items-center gap-2.5 border-b border-border-subtle px-4 py-4">
<LogoMark size={30} title="Tactical Media Broadcast Hub" />
<div className="min-w-0">
<div className="truncate text-sm font-semibold tracking-tight">Tactical Media Broadcast Hub</div>
<div className="truncate text-2xs text-text-faint" title={workspaceName}>
{workspaceName} ▾
</div>
</div>
</div>
<nav className="flex-1 overflow-y-auto scrollbar-thin p-2">
{NAV.map((item) => {
const active = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`group flex items-center justify-between rounded-md px-3 py-1.5 text-sm transition-colors duration-150 ${item.href === "/control-room" ? "min-h-11" : ""} ${
active ? "bg-accent-dim text-accent" : "text-text-dim hover:bg-surface-2 hover:text-text"
}`}
>
<span className="flex items-center gap-2.5">
{item.href === "/video" || item.href === "/control-room" ? (
<NavIcon name="video" size={14} />
) : (
<span className={`h-1.5 w-1.5 rounded-full ${active ? "bg-accent" : "bg-border group-hover:bg-text-faint"}`} />
)}
{item.label}
</span>
{item.href === "/approvals" && approvals > 0 && (
<span className="text-2xs text-text-faint">{approvals}</span>
)}
</Link>
);
})}
</nav>
</aside>
);
}

View file

@ -0,0 +1,104 @@
"use client";
import { Badge } from "../ui/primitives";
import type { StreamItem as FeedItem } from "../../lib/streams-data";
import { ageLabel } from "../../lib/fixtures";
import type { Platform } from "../../lib/platforms";
export function FeedCard({
item,
platform,
selected,
onSelect,
onRepost,
onQuote,
}: {
item: FeedItem;
platform: Platform;
selected: boolean;
onSelect: () => void;
onRepost: () => void;
onQuote: () => void;
}): React.ReactElement {
const caps = platform.capabilities;
const canAmplify = caps.amplifyRepost || caps.amplifyQuote || caps.amplifyShare;
const fallbackQuote = !caps.amplifyQuote && caps.publishText;
// Sample/live items without a connection cannot actually be amplified.
const connected = item.connectionId !== undefined;
const notConnectedTitle = connected ? undefined : "not connected — connect an account to boost";
return (
<div
data-stream-selected={selected || undefined}
onMouseEnter={onSelect}
className={`group border-b border-border-subtle px-3 py-2.5 transition-colors duration-150 ${
selected ? "bg-accent-dim" : "hover:bg-surface-2"
}`}
>
<div className="flex items-start gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="truncate text-sm font-medium">{item.author}</span>
<span className="truncate text-2xs text-text-faint">{item.handle}</span>
<span className="ml-auto shrink-0 text-2xs text-text-faint">{ageLabel(item.ageMinutes)}</span>
</div>
{item.sourceLabel && <span className="text-2xs text-text-faint">{item.sourceLabel}</span>}
<p className="mt-1 line-clamp-4 text-sm leading-snug text-text-dim">{item.text}</p>
{item.mediaUrls?.map((url) => <a key={url} href={url} target="_blank" rel="noreferrer" className="mr-2 text-xs text-accent">View media ↗</a>)}
{item.hasMedia && !item.mediaUrls && <div className="mt-1.5 h-16 rounded-md border border-border-subtle bg-surface-2" />}
<div className="mt-1.5 flex items-center gap-3 text-2xs text-text-faint">
<span>♡ {item.likes}</span>
<span>⇆ {item.reposts}</span>
<span>↩ {item.replies}</span>
{selected && (canAmplify || fallbackQuote) && (
<span className="ml-auto flex items-center gap-1 opacity-100 transition-opacity duration-150">
{caps.amplifyRepost && (
<button
onClick={onRepost}
disabled={!connected}
title={notConnectedTitle}
className="rounded border border-border px-1.5 py-0.5 font-medium text-text-dim transition-colors duration-150 hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-50"
>
{platform.amplifyLabels.repost ?? "Repost"}
</button>
)}
{(caps.amplifyQuote || fallbackQuote) && (
<button
onClick={onQuote}
disabled={!connected}
title={notConnectedTitle ?? (caps.amplifyQuote ? "Native quote via API" : "Composes your own post quoting this item with attribution")}
className={`rounded border px-1.5 py-0.5 font-medium transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50 ${
caps.amplifyQuote
? "border-border text-text-dim hover:border-accent hover:text-accent"
: "border-dashed border-border text-text-faint hover:border-accent hover:text-accent"
}`}
>
{caps.amplifyQuote ? (platform.amplifyLabels.quote ?? "Quote") : "Quote…"}
</button>
)}
{caps.amplifyShare && !caps.amplifyRepost && !caps.amplifyQuote && (
<button
onClick={onRepost}
disabled={!connected}
title={notConnectedTitle}
className="rounded border border-border px-1.5 py-0.5 font-medium text-text-dim transition-colors duration-150 hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-50"
>
{platform.amplifyLabels.share ?? "Share"}
</button>
)}
</span>
)}
</div>
</div>
</div>
</div>
);
}
export function NoAmplifyNote(): React.ReactElement {
return (
<div className="flex items-center gap-2 border-b border-border-subtle px-3 py-2">
<Badge tone="neutral">no amplify</Badge>
<span className="text-2xs text-text-faint">this platform&apos;s API exposes no reshare action</span>
</div>
);
}

View file

@ -0,0 +1,81 @@
"use client";
import { useEffect, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { streamItemKey, streamPlatform, type StreamItem as FeedItem } from "../../lib/streams-data";
import { getPlatform, type ColumnDef as BaseColumnDef } from "../../lib/platforms";
import { FeedCard } from "./card";
export type ColumnDef = BaseColumnDef & { items: FeedItem[]; platform?: import("../../lib/platforms").Platform };
export function StreamColumn({
column,
flatStartIndex,
selectedFlat,
onSelectFlat,
onRepost,
onQuote,
}: {
column: ColumnDef;
flatStartIndex: number;
selectedFlat: number;
onSelectFlat: (i: number) => void;
onRepost: (item: FeedItem, platform: import("../../lib/platforms").Platform) => void;
onQuote: (item: FeedItem, platform: import("../../lib/platforms").Platform) => void;
}): React.ReactElement {
const parentRef = useRef<HTMLDivElement>(null);
const platform = column.platform ?? getPlatform(column.platformId);
const virtualizer = useVirtualizer({
count: column.items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 110,
overscan: 8,
});
useEffect(() => {
const local = selectedFlat - flatStartIndex;
if (local >= 0 && local < column.items.length) {
virtualizer.scrollToIndex(local, { align: "auto" });
}
}, [selectedFlat, flatStartIndex, column.items.length, virtualizer]);
return (
<section className="flex h-full min-w-0 flex-1 flex-col border-r border-border-subtle last:border-r-0">
<header className="flex shrink-0 items-center justify-between border-b border-border-subtle bg-surface px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{column.title}</span>
<span className="rounded-full bg-surface-2 px-1.5 py-0.5 text-2xs text-text-faint">{column.key === "all" ? "All accounts" : platform.displayName}</span>
</div>
<span className="text-2xs text-text-faint">{column.staleness}</span>
</header>
{!platform.capabilities.amplifyRepost && !platform.capabilities.amplifyShare && <div className="border-b border-border-subtle px-3 py-1.5 text-2xs text-text-faint">Quote with attribution is available when this account can publish text.</div>}
{column.items.length === 0 && <p className="p-4 text-sm text-text-faint">{column.staleness === "Waiting for feed" ? "Waiting for the first successful poll." : "This feed has no posts yet."}</p>}
<div ref={parentRef} className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = column.items[virtualRow.index];
if (item === undefined) return null;
const flat = flatStartIndex + virtualRow.index;
return (
<div
key={streamItemKey(item)}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${virtualRow.start}px)` }}
>
<FeedCard
item={item}
platform={streamPlatform(item)}
selected={flat === selectedFlat}
onSelect={() => onSelectFlat(flat)}
onRepost={() => onRepost(item, streamPlatform(item))}
onQuote={() => onQuote(item, streamPlatform(item))}
/>
</div>
);
})}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,15 @@
import type { DataSource } from "../../lib/use-api";
/** Subtle marker that a screen is rendering fixture data because the API is unreachable. */
export function OfflineBadge({ source }: { source: DataSource }): React.ReactElement | null {
if (source !== "sample") return null;
return (
<span
title="The API is unreachable, so this screen shows built-in sample data. Nothing you do here is saved."
className="inline-flex items-center gap-1.5 rounded-full border border-border-subtle px-2 py-0.5 text-2xs text-text-faint"
>
<span className="h-1.5 w-1.5 rounded-full bg-warn" />
offline — showing sample data
</span>
);
}

View file

@ -0,0 +1,84 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
type Variant = "primary" | "ghost" | "outline" | "danger";
const variants: Record<Variant, string> = {
primary: "bg-accent text-white hover:bg-accent-hover",
ghost: "text-text-dim hover:bg-surface-2 hover:text-text",
outline: "border border-border text-text hover:border-accent hover:text-accent",
danger: "bg-danger/10 text-danger border border-danger/30 hover:bg-danger/20",
};
export function Button({
variant = "ghost",
size = "md",
className = "",
children,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant; size?: "sm" | "md" }): React.ReactElement {
const sizing = size === "sm" ? "px-2 py-1 text-2xs" : "px-3 py-1.5 text-xs";
return (
<button
className={`inline-flex items-center gap-1.5 rounded-md font-medium transition-colors duration-150 disabled:opacity-40 disabled:pointer-events-none ${variants[variant]} ${sizing} ${className}`}
{...rest}
>
{children}
</button>
);
}
export function Card({ className = "", children }: { className?: string; children: ReactNode }): React.ReactElement {
return <div className={`rounded-lg border border-border-subtle bg-surface ${className}`}>{children}</div>;
}
export function Badge({
tone = "neutral",
children,
}: {
tone?: "neutral" | "accent" | "live" | "warn" | "danger";
children: ReactNode;
}): React.ReactElement {
const tones: Record<string, string> = {
neutral: "bg-surface-2 text-text-dim",
accent: "border border-accent/30 bg-accent-dim text-accent",
live: "bg-live/15 text-live",
warn: "bg-warn/15 text-warn",
danger: "bg-danger/15 text-danger",
};
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-2xs font-medium ${tones[tone]}`}>
{children}
</span>
);
}
export function Input({ className = "", ...rest }: React.InputHTMLAttributes<HTMLInputElement>): React.ReactElement {
return (
<input
className={`w-full rounded-md border border-border bg-surface-2 px-3 py-1.5 text-sm text-text placeholder:text-text-faint focus:border-accent focus:outline-none transition-colors duration-150 ${className}`}
{...rest}
/>
);
}
/**
* Initials avatar on a deterministic warm hue. The hash maps into the accent
* family (0–60° in oklch hue terms ≈ red → orange → amber) so every avatar
* reads as part of the palette; only lightness/chroma wobble per identity,
* which keeps distinct people distinguishable without breaking the scheme.
*/
export function Avatar({ handle }: { handle: string }): React.ReactElement {
const initials = handle.replace(/@.*/, "").slice(0, 2).toUpperCase();
const hash = [...handle].reduce((a, c) => a + c.charCodeAt(0), 0);
const hue = hash % 60; // 0–60: red through amber, matching the accent family
const lightness = 0.42 + ((hash % 7) * 0.025); // 0.42–0.57
const chroma = 0.13 + ((hash % 5) * 0.015); // 0.13–0.19
return (
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-2xs font-semibold text-white"
style={{ background: `oklch(${lightness.toFixed(3)} ${chroma.toFixed(3)} ${hue})` }}
>
{initials}
</div>
);
}

View file

@ -0,0 +1,41 @@
import { Button } from "../ui/primitives";
import { formatBytes } from "./format";
import type { VideoAssetDto } from "../../lib/video-types";
/** Confirm modal before DELETE /video/:id (media.manage-gated upstream). */
export function DeleteVideoModal({
asset,
busy,
onCancel,
onConfirm,
}: {
asset: VideoAssetDto;
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}): React.ReactElement {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onCancel}>
<div
className="w-[440px] max-w-[90vw] rounded-lg border border-border bg-surface p-4 shadow-2xl"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<h2 className="text-sm font-semibold">Delete video</h2>
<p className="mt-2 text-xs leading-relaxed text-text-dim">
This removes the asset, its processing jobs, and its probe metadata from the workspace library.
Published posts that reference it are not deleted.
</p>
<p className="mt-3 truncate font-mono text-2xs text-text-faint" title={asset.storageKey}>
{asset.storageKey}
</p>
<p className="mt-1 text-2xs text-text-faint">{formatBytes(asset.sizeBytes)}</p>
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
<Button variant="danger" disabled={busy} onClick={onConfirm}>
{busy ? "Deleting…" : "Delete video"}
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
/** Display formatters for the video library — pure, no React. */
export function formatDuration(ms: number): string {
const total = Math.max(0, Math.round(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const mm = hours > 0 ? String(minutes).padStart(2, "0") : String(minutes);
const ss = String(seconds).padStart(2, "0");
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
}
export 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"}`;
}
export function formatBitrate(bitsPerSecond: number): string {
if (bitsPerSecond >= 1_000_000) return `${(bitsPerSecond / 1_000_000).toFixed(1)} Mbps`;
return `${Math.max(1, Math.round(bitsPerSecond / 1000))} kbps`;
}
export function fpsLabel(frameRate: number): string {
return `${Number.isInteger(frameRate) ? frameRate.toFixed(0) : frameRate.toFixed(2)} fps`;
}
/** The object-storage key's final path segment — the closest thing to a filename. */
export function fileNameOf(storageKey: string): string {
const parts = storageKey.split("/");
return parts[parts.length - 1] ?? storageKey;
}

View file

@ -0,0 +1,26 @@
import { Badge } from "../ui/primitives";
import type { VideoProcessingState } from "../../lib/video-types";
/**
* Chip for video_processing_jobs.state. Labels are the actual job states the
* API emits (queued/probing/transcoding/complete/failed) — never a friendlier
* invented word, so what the UI says always matches what the pipeline says.
*/
const TONES: Record<VideoProcessingState, "accent" | "live" | "warn" | "danger"> = {
queued: "warn",
probing: "accent",
transcoding: "accent",
complete: "live",
failed: "danger",
};
export function StatusChip({ state }: { state: VideoProcessingState }): React.ReactElement {
const inFlight = state === "queued" || state === "probing" || state === "transcoding";
return (
<Badge tone={TONES[state]}>
{inFlight && <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-current" />}
{state}
</Badge>
);
}

View file

@ -0,0 +1,168 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "../ui/primitives";
import { Loading } from "../brand/loading";
import { apiUploadFile, errorMessage } from "../../lib/api-client";
import type { VideoStatusDto } from "../../lib/video-types";
import { formatBytes } from "./format";
/**
* Drag-drop + file-picker target for POST /video/upload.
* The API stores encrypted bytes, mints the content-addressed key, and probes
* the stored object before responding. Progress measures the actual transfer.
*/
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
export interface UploadDraftInfo {
key: string;
name: string;
detail: string;
}
interface Props {
live: boolean;
canUpload: boolean;
onStarted: (draft: UploadDraftInfo) => void;
onSettled: (key: string) => void;
onUploaded: (status: VideoStatusDto) => void;
}
export function UploadDropzone({ live, canUpload, onStarted, onSettled, onUploaded }: Props): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null);
const busyRef = useRef(false);
const [dragging, setDragging] = useState(false);
const [uploading, setUploading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const register = useCallback(
async (file: File): Promise<void> => {
if (!live || !canUpload || busyRef.current) return;
const draft: UploadDraftInfo = { key: `${file.name}:${file.size}:${file.lastModified}`, name: file.name, detail: formatBytes(file.size) };
if (!file.type.startsWith("video/")) {
setError(`${file.name}: not a video file (${file.type === "" ? "unknown type" : file.type})`);
return;
}
if (file.size === 0 || file.size > MAX_UPLOAD_BYTES) {
setError("Choose a non-empty video up to 100 MiB.");
return;
}
setError(null);
busyRef.current = true;
setProgress(0);
setUploading(`${file.name} (${formatBytes(file.size)})`);
onStarted(draft);
try {
const result = await apiUploadFile<VideoStatusDto>("/video/upload", file, setProgress);
onUploaded(result);
if (result.processingJob.state === "failed") setError(result.processingJob.error ?? "Video probing failed.");
} catch (err) {
setError(errorMessage(err));
} finally {
onSettled(draft.key);
setUploading(null);
busyRef.current = false;
}
},
[live, canUpload, onStarted, onSettled, onUploaded],
);
// Paste a video file straight into the library.
useEffect(() => {
if (!live) return;
const onPaste = (e: ClipboardEvent): void => {
const file = Array.from(e.clipboardData?.files ?? []).find((f) => f.type.startsWith("video/"));
if (file !== undefined) void register(file);
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
}, [live, register]);
const disabled = !live || !canUpload || uploading !== null;
const hint = !live
? "Offline — connect to the API to upload."
: !canUpload
? "Uploading needs the media.manage permission."
: "Drop a video file, paste one, or browse. Up to 100 MiB; stored encrypted and checked before it is ready.";
return (
<div className="mb-4">
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
aria-label="Upload a video"
onClick={() => {
if (!disabled) inputRef.current?.click();
}}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
if (!disabled) setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (disabled) return;
const file = e.dataTransfer.files[0];
if (file !== undefined) void register(file);
}}
className={`flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center transition-colors duration-150 ${
disabled
? "cursor-not-allowed border-border-subtle opacity-50"
: dragging
? "cursor-pointer border-accent bg-accent-dim"
: "cursor-pointer border-border hover:border-accent hover:bg-surface-2"
}`}
>
{uploading !== null ? (
<div className="w-full max-w-md space-y-2">
<span className="inline-flex items-center gap-2 text-xs text-text-dim">
<Loading size={14} label={progress < 100 ? "Uploading" : "Processing"} />
{progress < 100 ? `Uploading ${progress}%` : "Upload sent — storing and probing…"} {uploading}
</span>
<progress className="h-2 w-full" aria-label="File upload progress" value={progress} max={100} />
</div>
) : (
<>
<span className="text-sm text-text">Drop a video here</span>
<span className="text-2xs leading-relaxed text-text-faint">{hint}</span>
{!disabled && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
inputRef.current?.click();
}}
>
Browse files
</Button>
)}
</>
)}
<input
ref={inputRef}
type="file"
accept="video/mp4,video/webm,video/quicktime"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file !== undefined) void register(file);
}}
/>
</div>
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
</div>
);
}

View file

@ -0,0 +1,96 @@
import { Badge, Card } from "../ui/primitives";
import { CircleA } from "../brand/logo";
import { Loading } from "../brand/loading";
import type { VideoLibraryEntryDto } from "../../lib/video-types";
import { formatBytes, formatDuration, fileNameOf } from "./format";
import { StatusChip } from "./status-chip";
/**
* Library card. The poster area is the quiet circle-A mark (no thumbnail is
* stored yet — video_assets.thumbnail_key exists but nothing populates it);
* probe fields render when present and read as em-dashes before probing.
*/
export function VideoCard({
entry,
active,
onOpen,
onDelete,
}: {
entry: VideoLibraryEntryDto;
active: boolean;
onOpen: () => void;
onDelete: () => void;
}): React.ReactElement {
const { videoAsset: a, processingState, publications } = entry;
const dims = a.width !== undefined && a.height !== undefined ? `${a.width}×${a.height}` : "—";
return (
<Card
className={`cursor-pointer p-3 transition-colors duration-150 hover:border-accent/40 ${
active ? "border-accent/50 ring-1 ring-accent/30" : ""
}`}
>
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
className="relative flex h-24 cursor-pointer items-center justify-center rounded-md border border-border-subtle bg-bg transition-colors duration-150 hover:border-accent/40"
>
<CircleA size={44} className="text-accent/25" />
{a.durationMs !== undefined && (
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/70 px-1.5 py-0.5 font-mono text-2xs text-white">
{formatDuration(a.durationMs)}
</span>
)}
</div>
<div className="mt-2 flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-medium" title={fileNameOf(a.storageKey)}>
{fileNameOf(a.storageKey)}
</div>
<div className="mt-0.5 text-2xs text-text-faint">
{dims} · {formatBytes(a.sizeBytes)} · {publications.length > 0 ? `${publications.length} publication${publications.length === 1 ? "" : "s"}` : "unpublished"}
</div>
</div>
<StatusChip state={processingState} />
</div>
<div className="mt-2 flex items-center justify-between border-t border-border-subtle pt-2">
<span className="truncate font-mono text-2xs text-text-faint">{a.mimeType}</span>
<button
type="button"
className="text-2xs text-text-faint transition-colors duration-150 hover:text-danger"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
Delete
</button>
</div>
</Card>
);
}
/** Optimistic card while a pick is being hashed and registered. */
export function UploadingCard({ name, detail }: { name: string; detail: string }): React.ReactElement {
return (
<Card className="border-dashed p-3 opacity-70">
<div className="flex h-24 items-center justify-center rounded-md border border-border-subtle bg-bg">
<Loading size={22} label="Uploading" />
</div>
<div className="mt-2 flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-medium text-text-dim" title={name}>{name}</div>
<div className="mt-0.5 text-2xs text-text-faint">{detail}</div>
</div>
<Badge tone="warn">uploading…</Badge>
</div>
</Card>
);
}

View file

@ -0,0 +1,195 @@
"use client";
import { useEffect, useState } from "react";
import { Badge, Button } from "../ui/primitives";
import { api, errorMessage } from "../../lib/api-client";
import type { VideoLibraryEntryDto, VideoProcessingState, VideoStatusDto } from "../../lib/video-types";
import { fileNameOf, formatBitrate, formatBytes, formatDuration, fpsLabel } from "./format";
import { StatusChip } from "./status-chip";
/**
* Right-hand detail drawer: probe metadata, derived bitrate, the processing
* job timeline, and where the asset has been published. Live assets poll
* GET /video/:id/status while their job is still in flight.
*/
const PIPELINE: ReadonlyArray<{ state: VideoProcessingState; label: string }> = [
{ state: "queued", label: "Queued" },
{ state: "probing", label: "Probing (ffprobe)" },
{ state: "transcoding", label: "Transcoding" },
{ state: "complete", label: "Ready" },
];
const POLL_MS = 4_000;
const IN_FLIGHT: VideoProcessingState[] = ["queued", "probing", "transcoding"];
export function VideoDetailDrawer({
entry,
onClose,
}: {
entry: VideoLibraryEntryDto;
onClose: () => void;
}): React.ReactElement {
const [status, setStatus] = useState<VideoStatusDto | null>(null);
const [error, setError] = useState<string | null>(null);
const asset = entry.videoAsset;
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// Poll the job while it is in flight; the library page refreshes from this too.
const inFlight = IN_FLIGHT.includes(status?.processingJob.state ?? entry.processingState);
useEffect(() => {
let cancelled = false;
const fetchStatus = (): void => {
api
.get<VideoStatusDto>(`/video/${asset.id}/status`)
.then((next) => {
if (!cancelled) {
setStatus(next);
setError(null);
}
})
.catch((err: unknown) => {
if (!cancelled) setError(errorMessage(err));
});
};
fetchStatus();
const timer = inFlight ? setInterval(fetchStatus, POLL_MS) : undefined;
return () => {
cancelled = true;
if (timer !== undefined) clearInterval(timer);
};
}, [asset.id, inFlight]);
const job = status?.processingJob;
const state = job?.state ?? entry.processingState;
const duration = status?.videoAsset.durationMs ?? asset.durationMs;
const width = status?.videoAsset.width ?? asset.width;
const height = status?.videoAsset.height ?? asset.height;
const codec = status?.videoAsset.codec ?? asset.codec;
const audioCodec = status?.videoAsset.audioCodec ?? asset.audioCodec;
const frameRate = status?.videoAsset.frameRate ?? asset.frameRate;
const sizeBytes = status?.videoAsset.sizeBytes ?? asset.sizeBytes;
const bitrate = duration !== undefined && duration > 0 ? (sizeBytes * 8) / (duration / 1000) : undefined;
const reachedIndex = PIPELINE.findIndex((step) => step.state === state);
return (
<div className="fixed inset-0 z-40 flex justify-end bg-black/60" onClick={onClose}>
<aside
className="flex h-full w-[440px] max-w-[92vw] flex-col overflow-y-auto border-l border-border bg-surface scrollbar-thin"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<header className="sticky top-0 z-10 flex items-start justify-between gap-3 border-b border-border-subtle bg-surface px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-sm font-semibold" title={fileNameOf(asset.storageKey)}>
{fileNameOf(asset.storageKey)}
</h2>
<StatusChip state={state} />
</div>
<p className="mt-1 truncate font-mono text-2xs text-text-faint" title={asset.storageKey}>
{asset.storageKey}
</p>
</div>
<Button size="sm" variant="ghost" onClick={onClose}>Close (esc)</Button>
</header>
<div className="space-y-4 p-4">
{error !== null && <p className="text-xs text-danger">{error}</p>}
{job !== undefined && (
<section>
<h3 className="mb-2 text-2xs font-medium uppercase tracking-wide text-text-faint">Processing</h3>
<ol className="space-y-1.5">
{PIPELINE.map((step, i) => {
const done = state === "complete" || i < reachedIndex;
const current = step.state === state;
return (
<li
key={step.state}
className={`flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs ${
current
? "border-accent/40 bg-accent-dim text-accent"
: done
? "border-border-subtle bg-surface-2 text-text-dim"
: "border-border-subtle text-text-faint"
}`}
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${done || current ? "bg-current" : "bg-border"}`} />
{step.label}
{current && job.progressPercent !== undefined && (
<span className="ml-auto font-mono text-2xs">{job.progressPercent}%</span>
)}
</li>
);
})}
{state === "failed" && (
<li className="rounded-md border border-danger/30 bg-danger/10 px-2.5 py-1.5 text-xs text-danger">
<span className="font-medium">failed</span>
{job.error !== undefined && <span className="ml-1 font-mono text-2xs">{job.error}</span>}
</li>
)}
</ol>
{job.processingNote !== undefined && <p className="mt-2 text-2xs text-text-faint">{job.processingNote}</p>}
</section>
)}
<section>
<h3 className="mb-2 text-2xs font-medium uppercase tracking-wide text-text-faint">Probe metadata</h3>
<dl className="space-y-1 text-xs">
<Row label="MIME type" value={asset.mimeType} />
<Row label="Size" value={formatBytes(sizeBytes)} />
<Row label="Duration" value={duration !== undefined ? formatDuration(duration) : "—"} />
<Row label="Resolution" value={width !== undefined && height !== undefined ? `${width} × ${height}` : "—"} />
<Row label="Frame rate" value={frameRate !== undefined ? fpsLabel(frameRate) : "—"} />
<Row label="Video codec" value={codec ?? "—"} />
<Row label="Audio codec" value={audioCodec ?? "—"} />
<Row label="Bitrate (derived)" value={bitrate !== undefined ? formatBitrate(bitrate) : "—"} />
</dl>
{duration === undefined && (
<p className="mt-2 text-2xs leading-relaxed text-text-faint">
{state === "failed" ? "The probe failed; see the processing error above." : "Metadata fills in after the stored video is probed."}
</p>
)}
</section>
<section>
<h3 className="mb-2 text-2xs font-medium uppercase tracking-wide text-text-faint">Publications</h3>
{entry.publications.length === 0 ? (
<p className="text-xs text-text-faint">Not published anywhere yet.</p>
) : (
<ul className="space-y-1.5">
{entry.publications.map((p) => (
<li key={`${p.connectionId}:${p.remoteId}`} className="flex items-center gap-2 rounded-md border border-border-subtle bg-surface-2 px-2.5 py-1.5 text-xs">
<Badge tone="accent">{p.platform}</Badge>
<span className="font-mono text-2xs text-text-faint">{p.remoteId}</span>
<span className="ml-auto text-2xs text-text-faint">{new Date(p.publishedAt).toLocaleString()}</span>
</li>
))}
</ul>
)}
</section>
<p className="text-2xs leading-relaxed text-text-faint">
Asset <span className="font-mono">{asset.id}</span>
</p>
</div>
</aside>
</div>
);
}
function Row({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-text-faint">{label}</dt>
<dd className="truncate font-mono text-text-dim">{value}</dd>
</div>
);
}

View file

@ -0,0 +1,15 @@
"use client";
import { useSyncExternalStore } from "react";
const QUERY = "(max-width: 1023px)";
function subscribe(onChange: () => void): () => void {
const media = window.matchMedia(QUERY);
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}
export function useIsMobile(): boolean {
return useSyncExternalStore(subscribe, () => window.matchMedia(QUERY).matches, () => false);
}

View file

@ -0,0 +1,11 @@
import type { Platform } from "./platforms";
export interface NativeAmplifyAction { kind: "repost" | "quote" | "share"; label: string; }
export function nativeAmplifyActions(platform: Platform): NativeAmplifyAction[] {
return [
...(platform.capabilities.amplifyRepost ? [{ kind: "repost" as const, label: platform.amplifyLabels.repost ?? "Repost" }] : []),
...(platform.capabilities.amplifyQuote ? [{ kind: "quote" as const, label: platform.amplifyLabels.quote ?? "Quote" }] : []),
...(platform.capabilities.amplifyShare ? [{ kind: "share" as const, label: platform.amplifyLabels.share ?? "Share" }] : []),
];
}

View file

@ -0,0 +1,188 @@
import { getSessionToken, setSessionToken } from "./session-token";
/**
* The single path from the web app to the API. Every call goes through
* `apiFetch` so auth, error shape, and the offline signal stay uniform.
*/
export const API_URL = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000").replace(/\/$/, "");
/** The API answered with a non-2xx status. */
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
readonly body: unknown,
) {
super(message);
this.name = "ApiError";
}
}
/** The API could not be reached at all (server down, DNS, CORS). Screens fall back to sample data. */
export class ApiUnreachableError extends Error {
constructor(override readonly cause: unknown) {
super("API unreachable");
this.name = "ApiUnreachableError";
}
}
type QueryValue = string | number | undefined | null;
export interface ApiRequestOptions {
method?: "GET" | "POST" | "PATCH" | "DELETE";
body?: unknown;
query?: Record<string, QueryValue>;
signal?: AbortSignal;
/** Default true. Auth endpoints turn this off so a bad password shows an error instead of navigating away. */
redirectOn401?: boolean;
}
export const ONBOARDING_PATH = "/onboarding";
let reissuePromise: Promise<string> | null = null;
function buildUrl(path: string, query: Record<string, QueryValue> | undefined): string {
const url = new URL(`${API_URL}${path.startsWith("/") ? path : `/${path}`}`);
for (const [key, value] of Object.entries(query ?? {})) {
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
}
return url.toString();
}
function messageFrom(body: unknown, fallback: string): string {
if (typeof body === "object" && body !== null && "message" in body) {
const message = (body as { message: unknown }).message;
if (typeof message === "string") return message;
if (Array.isArray(message)) return message.filter((m): m is string => typeof m === "string").join("; ");
}
return fallback;
}
/** Multipart bytes with browser upload progress; shares the API's auth/error rules. */
export async function apiUploadFile<T>(path: string, file: File, onProgress: (percent: number) => void): Promise<T> {
const send = async (retried: boolean): Promise<T> => {
const token = getSessionToken();
const response = await new Promise<{ status: number; text: string }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", buildUrl(path, undefined));
xhr.withCredentials = true;
xhr.timeout = 120_000;
if (token !== null) xhr.setRequestHeader("Authorization", `Bearer ${token}`);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(Math.floor((event.loaded / event.total) * 100));
};
xhr.upload.onload = () => onProgress(100);
xhr.onload = () => resolve({ status: xhr.status, text: xhr.responseText });
xhr.onerror = () => reject(new ApiUnreachableError(new Error("Upload network error")));
xhr.ontimeout = () => reject(new Error("Upload timed out; check the video library before retrying."));
xhr.onabort = () => reject(new Error("Upload cancelled"));
const form = new FormData();
form.append("file", file);
xhr.send(form);
});
let body: unknown = null;
if (response.text !== "") {
try { body = JSON.parse(response.text) as unknown; } catch { body = response.text; }
}
if (response.status >= 200 && response.status < 300) return body as T;
if (response.status === 401 && !retried && token !== null && messageFrom(body, "").includes("authorization epoch stale")) {
let reissued = false;
try {
await reissueSession();
reissued = true;
} catch { /* Ordinary 401 handling below. */ }
if (reissued) { onProgress(0); return send(true); }
}
if (response.status === 401) redirectToSession();
throw new ApiError(response.status, messageFrom(body, `HTTP ${response.status}`), body);
};
return send(false);
}
export function reissueSession(): Promise<string> {
if (reissuePromise === null) {
reissuePromise = apiFetch<{ token: string }>("/auth/reissue", { method: "POST", redirectOn401: false })
.then((result) => {
setSessionToken(result.token);
return result.token;
}).finally(() => { reissuePromise = null; });
}
return reissuePromise;
}
function redirectToSession(): void {
setSessionToken(null);
if (typeof window !== "undefined" && window.location.pathname !== ONBOARDING_PATH) {
window.location.assign(`${ONBOARDING_PATH}?reason=session`);
}
}
export async function apiFetch<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
return fetchWithEpochRetry<T>(path, options, false);
}
async function fetchWithEpochRetry<T>(path: string, options: ApiRequestOptions, retried: boolean): Promise<T> {
const headers: Record<string, string> = {};
const token = getSessionToken();
if (token !== null) headers.Authorization = `Bearer ${token}`;
if (options.body !== undefined) headers["Content-Type"] = "application/json";
let response: Response;
try {
response = await fetch(buildUrl(path, options.query), {
method: options.method ?? "GET",
credentials: "include",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
signal: options.signal,
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
throw new ApiUnreachableError(err);
}
const text = await response.text();
let body: unknown = null;
if (text !== "") {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!response.ok) {
if (response.status === 401 && !retried && token !== null && path !== "/auth/reissue" &&
messageFrom(body, "").includes("authorization epoch stale")) {
let reissued = false;
try {
await reissueSession();
reissued = true;
} catch {
// A revoked or expired session follows the ordinary 401 path below.
}
if (reissued) return fetchWithEpochRetry<T>(path, options, true);
}
if (response.status === 401 && (options.redirectOn401 ?? true)) {
redirectToSession();
}
throw new ApiError(response.status, messageFrom(body, `HTTP ${response.status}`), body);
}
return body as T;
}
export const api = {
get: <T>(path: string, query?: Record<string, QueryValue>, signal?: AbortSignal): Promise<T> =>
apiFetch<T>(path, { query, signal }),
post: <T>(path: string, body?: unknown, options: Omit<ApiRequestOptions, "method" | "body"> = {}): Promise<T> =>
apiFetch<T>(path, { ...options, method: "POST", body: body ?? {} }),
patch: <T>(path: string, body: unknown): Promise<T> => apiFetch<T>(path, { method: "PATCH", body }),
del: <T>(path: string): Promise<T> => apiFetch<T>(path, { method: "DELETE" }),
};
export function errorMessage(err: unknown): string {
if (err instanceof ApiUnreachableError) return "API unreachable — check that the API server is running.";
if (err instanceof ApiError) return err.message;
if (err instanceof Error) return err.message;
return "Unexpected error";
}

View file

@ -0,0 +1,140 @@
/** Response shapes of apps/api — kept in sync by hand with the controllers. */
export interface MeResponse {
user: { id: string; username: string; email: string | null; displayName: string };
workspace: { id: string; name: string; slug: string };
role: string;
permissions: string[];
expiresAt: number;
}
export interface AuthResult {
token: string;
me: MeResponse;
}
export interface ConnectionSummary {
id: string;
provider: string;
channelId: string;
fields: Record<string, unknown>;
health: "healthy" | "needs_credentials" | "unavailable";
capabilities?: string[];
credentialRotatedAt: string | null;
recentPublishes: string[];
createdAt: string;
}
export type MemberStatus = "active" | "invited";
export interface Member {
id: string;
userId: string;
username: string;
email: string | null;
displayName: string;
role: string;
status: MemberStatus;
createdAt: string;
}
/** Membership mutations bump the workspace authz epoch; `token` is the caller's re-issued session. */
export interface MemberMutationResult {
member: Member | null;
token: string | null;
}
/** Public, unauthenticated preview of a workspace invite link — GET /invites/:token/info. */
export interface InviteInfo {
workspaceName: string;
role: string;
/** ISO timestamp; the link is dead after this. */
expiresAt: string;
/** null = unlimited uses. */
maxUses: number | null;
usedCount: number;
}
/** Claiming installs a fresh workspace session — same envelope as POST /auth/login. */
export type InviteClaimResult = AuthResult;
/** Anonymous claimant credentials; ignored when the request carries a valid session. */
export interface InviteClaimInput {
username: string;
password: string;
displayName?: string;
email?: string;
}
export type InviteLinkStatus = "active" | "expired" | "revoked" | "exhausted";
/** Admin view of a workspace invite link. `token`/`url` are present only on the 201 create response. */
export interface InviteLink {
id: string;
role: string;
createdAt: string;
expiresAt: string;
maxUses: number | null;
useCount: number;
revokedAt: string | null;
status: InviteLinkStatus;
createdByUserId: string;
token?: string;
url?: string;
}
export interface InviteLinkCreateInput {
role: string;
expiresInHours: number;
maxUses?: number;
}
export interface AuditEntryDto {
id: string;
seq: string;
at: string;
actorUserId: string | null;
actorEmail: string | null;
actorRole: string | null;
action: string;
targetType: string;
targetId: string;
diff: Record<string, unknown> | null;
}
export interface AuditPage {
entries: AuditEntryDto[];
nextCursor: string | null;
facets: { actions: string[]; actors: Array<{ id: string | null; email: string | null }> };
}
export type DraftState = "editing" | "in_review" | "approved" | "rejected" | "scheduled" | "published";
export type Visibility = "public" | "unlisted" | "private" | "followers" | "provider_default";
export interface DraftPost {
text: string;
mediaAssetIds: string[];
visibility: Visibility;
scheduledAt?: string;
providerMetadata: Record<string, unknown>;
}
export interface DraftRecord {
id: string;
workspaceId: string;
authorUserId: string;
post: DraftPost;
state: DraftState;
reviewerUserId?: string;
createdAt: string;
updatedAt: string;
}
export interface ScheduledPublicationRecord {
id: string;
draftId: string;
connectionId: string;
publishAt: string;
state: "pending" | "in_flight" | "done" | "failed";
}

View file

@ -0,0 +1,168 @@
"use client";
import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { api, apiFetch, ApiError, ApiUnreachableError } from "./api-client";
import type { AuthResult, InviteClaimInput, InviteClaimResult, MeResponse } from "./api-types";
import { getSessionToken, onSessionTokenChange, setSessionToken } from "./session-token";
/**
* - loading: verifying a stored token / probing the API
* - authenticated: token verified via GET /auth/me
* - anonymous: API reachable, no valid session → the gate sends you to /onboarding
* - offline: API unreachable → dev convenience: screens render sample data
*/
export type AuthStatus = "loading" | "authenticated" | "anonymous" | "offline";
export interface RegisterInput {
workspaceName: string;
username: string;
password: string;
displayName?: string;
email?: string;
}
export interface AuthContextValue {
session: MeResponse | null;
status: AuthStatus;
isLoading: boolean;
offline: boolean;
login: (username: string, password: string) => Promise<MeResponse>;
register: (input: RegisterInput) => Promise<MeResponse>;
/** Claim an invite link with the current session; installs the returned workspace token. */
claimInvite: (inviteToken: string) => Promise<MeResponse>;
/** Register-or-authenticate inline, then claim; installs the returned workspace token. */
claimInviteWithCredentials: (inviteToken: string, input: InviteClaimInput) => Promise<MeResponse>;
logout: () => Promise<void>;
/** Install a re-issued token (membership mutations bump the authz epoch and return one). */
adoptToken: (token: string | null) => void;
can: (permission: string) => boolean;
/** Re-run session verification (e.g. after the API comes back). */
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
async function probeReachable(): Promise<boolean> {
try {
await apiFetch<unknown>("/healthz", { redirectOn401: false });
return true;
} catch (err) {
return !(err instanceof ApiUnreachableError);
}
}
export function AuthProvider({ children }: { children: ReactNode }): React.ReactElement {
const [session, setSession] = useState<MeResponse | null>(null);
const [status, setStatus] = useState<AuthStatus>("loading");
const verify = useCallback(async (): Promise<void> => {
const token = getSessionToken();
if (token === null) {
setSession(null);
setStatus((await probeReachable()) ? "anonymous" : "offline");
return;
}
try {
const me = await apiFetch<MeResponse>("/auth/me", { redirectOn401: false });
setSession(me);
setStatus("authenticated");
} catch (err) {
if (err instanceof ApiUnreachableError) {
setStatus("offline");
} else if (err instanceof ApiError && err.status === 401) {
setSessionToken(null);
setSession(null);
setStatus("anonymous");
} else {
setStatus("offline");
}
}
}, []);
useEffect(() => {
void verify();
}, [verify]);
// The API client clears the token on 401; mirror that into React state.
useEffect(
() =>
onSessionTokenChange((token) => {
if (token === null) {
setSession(null);
setStatus((s) => (s === "offline" ? s : "anonymous"));
}
}),
[],
);
const accept = useCallback((result: AuthResult): MeResponse => {
setSessionToken(result.token);
setSession(result.me);
setStatus("authenticated");
return result.me;
}, []);
const login = useCallback(
async (username: string, password: string) =>
accept(await api.post<AuthResult>("/auth/login", { username, password }, { redirectOn401: false })),
[accept],
);
const register = useCallback(
async (input: RegisterInput) => accept(await api.post<AuthResult>("/auth/register", input, { redirectOn401: false })),
[accept],
);
// Both claim paths return an AuthResult issued in the invite's workspace, so `accept`
// installs token + session + status atomically, exactly like login/register.
const claimInvite = useCallback(
async (inviteToken: string) =>
accept(await api.post<InviteClaimResult>(`/invites/${encodeURIComponent(inviteToken)}/claim`, {}, { redirectOn401: false })),
[accept],
);
const claimInviteWithCredentials = useCallback(
async (inviteToken: string, input: InviteClaimInput) =>
accept(await api.post<InviteClaimResult>(`/invites/${encodeURIComponent(inviteToken)}/claim`, input, { redirectOn401: false })),
[accept],
);
const logout = useCallback(async () => {
await api.post<unknown>("/auth/logout", {}, { redirectOn401: false }).catch(() => undefined);
setSessionToken(null);
setSession(null);
setStatus("anonymous");
}, []);
const adoptToken = useCallback((token: string | null) => {
if (token !== null) setSessionToken(token);
}, []);
const can = useCallback((permission: string) => session?.permissions.includes(permission) ?? false, [session]);
const value = useMemo<AuthContextValue>(
() => ({
session,
status,
isLoading: status === "loading",
offline: status === "offline",
login,
register,
claimInvite,
claimInviteWithCredentials,
logout,
adoptToken,
can,
refresh: verify,
}),
[session, status, login, register, claimInvite, claimInviteWithCredentials, logout, adoptToken, can, verify],
);
return createElement(AuthContext.Provider, { value }, children);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (ctx === null) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}

View file

@ -0,0 +1,22 @@
import { setSessionToken } from "./session-token";
import { api } from "./api-client";
export interface ConnectProvider {
id: string;
displayName: string;
kind: "oauth2-redirect" | "per-instance-oauth2" | "token";
enabled: boolean;
note?: string;
instanceRequired: boolean;
targetField?: string;
capabilities: string[];
}
export async function linkBotToken(token: string, channelId: string, connectionId?: string): Promise<{ connected: true }> {
const result = await api.post<{ connected: true; token: string }>("/connections/telegram/bot-token", { token, channelId: channelId || undefined, connectionId });
setSessionToken(result.token);
return result;
}
/** Shared provider boundary for the bot archive view. */
export const isBotConnection = (connection: { provider: string }): boolean => connection.provider === "telegram";

View file

@ -0,0 +1,173 @@
export interface FeedItem {
id: number;
platform: string;
author: string;
handle: string;
text: string;
ageMinutes: number;
likes: number;
reposts: number;
replies: number;
hasMedia: boolean;
}
const AUTHORS: ReadonlyArray<readonly [string, string]> = [
["Ada Lovelace", "@ada"],
["Grace Hopper", "@amazing.grace"],
["Alan Turing", "@turingtest"],
["Katherine Johnson", "@kjohnson"],
["Edsger Dijkstra", "@goesto"],
["Barbara Liskov", "@abl"],
["Donald Knuth", "@taocp"],
["Radia Perlman", "@spanning"],
["Linus Torvalds", "@torvalds"],
["Margaret Hamilton", "@apollo"],
];
const TEXTS = [
"Shipped the tenant isolation suite today — RLS catches what code review misses.",
"Hot take: your audit log should be hash-chained from day one, not after the breach.",
"The connector manifest pattern is paying off: new platform, zero core changes.",
"Reminder: capability-driven UI means no grayed-out lies. If the API can't do it, the button isn't there.",
"Spent the morning fighting a trigger that referenced OLD on INSERT. COALESCE(NEW, OLD), friends.",
"Our streaming budget math finally balances. Per-host token buckets for the win.",
"Quote-posting this because the thread below is exactly why we deferred X.",
"The compositor held 8 hours without dropping a frame. Soak test verdict: green.",
"Anyone else maintaining three different rate-limit spreadsheets? There has to be a better way.",
"FFmpeg filter graphs are write-only code unless you diagram them first. Lesson learned.",
"Just watched the auto-director fail over mid-stream and nobody in chat noticed. Perfect.",
"OAuth tokens in the vault, passwords never touched. The only sane architecture.",
"Drafts → approvals → schedule → publish. The pipeline is boring and I love it.",
"New rule: every migration ships with its rollback tested, not just written.",
"The mock connectors now drive the entire E2E suite offline. Deterministic tests or bust.",
"Every ping of the source monitor is adaptive now. The instances stop noticing us.",
];
function mulberry32(seed: number): () => number {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function generateFeed(platform: string, count: number, seed: number): FeedItem[] {
const rand = mulberry32(seed);
const items: FeedItem[] = [];
for (let i = 0; i < count; i += 1) {
const pair = AUTHORS[Math.floor(rand() * AUTHORS.length)] ?? ["unknown", "@unknown"];
const [author, handle] = pair;
items.push({
id: i,
platform,
author,
handle,
text: TEXTS[Math.floor(rand() * TEXTS.length)] ?? "",
ageMinutes: Math.floor(rand() * 1440),
likes: Math.floor(rand() * 500),
reposts: Math.floor(rand() * 120),
replies: Math.floor(rand() * 60),
hasMedia: rand() > 0.75,
});
}
return items;
}
export function ageLabel(minutes: number): string {
if (minutes < 60) return `${minutes}m`;
if (minutes < 1440) return `${Math.floor(minutes / 60)}h`;
return `${Math.floor(minutes / 1440)}d`;
}
export interface Conversation {
id: number;
platform: string;
participant: string;
handle: string;
preview: string;
unread: number;
assignee: string | null;
lastMinutes: number;
}
export const CONVERSATIONS: Conversation[] = [
{ id: 1, platform: "mastodon", participant: "Ada Lovelace", handle: "@ada", preview: "Is the boost button capability-gated per column?", unread: 2, assignee: "GH", lastMinutes: 4 },
{ id: 2, platform: "instagram", participant: "Grace Hopper", handle: "@amazing.grace", preview: "Story scheduling would be huge for us", unread: 0, assignee: null, lastMinutes: 22 },
{ id: 3, platform: "facebook", participant: "Alan Turing", handle: "@turingtest", preview: "The page token expires tomorrow — can we rotate?", unread: 1, assignee: "MC", lastMinutes: 65 },
{ id: 4, platform: "mastodon", participant: "Katherine Johnson", handle: "@kjohnson", preview: "Trajectories confirmed. Launch window 0400.", unread: 0, assignee: null, lastMinutes: 190 },
];
export interface ThreadMessage {
id: number;
from: string;
mine: boolean;
text: string;
minutes: number;
}
export const THREADS: Record<number, ThreadMessage[]> = {
1: [
{ id: 1, from: "@ada", mine: false, text: "Is the boost button capability-gated per column?", minutes: 30 },
{ id: 2, from: "you", mine: true, text: "Yes — the column renders only what its platform manifest declares.", minutes: 25 },
{ id: 3, from: "@ada", mine: false, text: "Beautiful. No grayed-out lies?", minutes: 4 },
],
2: [
{ id: 1, from: "@amazing.grace", mine: false, text: "Story scheduling would be huge for us", minutes: 22 },
],
3: [
{ id: 1, from: "@turingtest", mine: false, text: "The page token expires tomorrow — can we rotate?", minutes: 65 },
],
4: [
{ id: 1, from: "@kjohnson", mine: false, text: "Trajectories confirmed. Launch window 0400.", minutes: 190 },
],
};
export const ROLES = [
"Owner", "Admin", "Publisher", "Editor", "Approver", "Analyst", "Moderator", "VideoPublisher", "StreamOperator", "BroadcastOperator",
];
export interface Member {
id: number;
name: string;
email: string;
role: string;
status: "active" | "invited";
}
export const MEMBERS: Member[] = [
{ id: 1, name: "Marcus Chen", email: "marcus@studio.io", role: "Owner", status: "active" },
{ id: 2, name: "Priya Sharma", email: "priya@studio.io", role: "Publisher", status: "active" },
{ id: 3, name: "Yuki Tanaka", email: "yuki@studio.io", role: "BroadcastOperator", status: "active" },
{ id: 4, name: "Sofia Rossi", email: "sofia@studio.io", role: "Approver", status: "invited" },
];
export const ASSETS = [
{ id: 1, name: "logo-dark.svg", group: "Brand", locked: false },
{ id: 2, name: "launch-banner.png", group: "Launch", locked: false },
{ id: 3, name: "q4-roadmap.pdf", group: "Internal", locked: true },
{ id: 4, name: "team-photo.jpg", group: "Brand", locked: false },
{ id: 5, name: "keynote-clip.mp4", group: "Launch", locked: true },
{ id: 6, name: "press-kit.zip", group: "PR", locked: false },
];
export interface AuditEntry {
id: number;
actor: string;
action: string;
targetType: string;
target: string;
minutes: number;
diff: Record<string, unknown> | null;
}
export const AUDIT_ENTRIES: AuditEntry[] = [
{ id: 1, actor: "marcus@studio.io", action: "connection.created", targetType: "connection", target: "mastodon/@studio", minutes: 12, diff: { provider: "mastodon", scopes: ["read", "write"] } },
{ id: 2, actor: "priya@studio.io", action: "publication.scheduled", targetType: "publication", target: "draft-8821", minutes: 34, diff: { scheduledAt: "2026-10-07T09:00Z", targets: 3 } },
{ id: 3, actor: "yuki@studio.io", action: "membership.role_changed", targetType: "membership", target: "sofia@studio.io", minutes: 58, diff: { role: ["Editor", "Approver"] } },
{ id: 4, actor: "system", action: "credential.rotated", targetType: "credential", target: "cred-117", minutes: 90, diff: { keyVersion: [1, 2], accessToken: "[REDACTED]" } },
{ id: 5, actor: "marcus@studio.io", action: "publication.published", targetType: "publication", target: "pub-3390", minutes: 140, diff: { remoteUrl: "https://fosstodon.org/@studio/1099", streamKey: "[REDACTED]" } },
{ id: 6, actor: "sofia@studio.io", action: "approval.granted", targetType: "draft", target: "draft-8819", minutes: 210, diff: null },
];

View file

@ -0,0 +1,14 @@
export type SwipeIntent = "pending" | "horizontal" | "vertical";
/** Lock a gesture only after it has moved far enough to distinguish scrolling. */
export function swipeIntent(dx: number, dy: number, threshold = 12): SwipeIntent {
if (Math.hypot(dx, dy) < threshold) return "pending";
return Math.abs(dx) > Math.abs(dy) * 1.5 ? "horizontal" : "vertical";
}
export function swipeResult(dx: number, velocity: number, direction: "left" | "right", width = 204): "closed" | "open" | "full" {
const travel = direction === "left" ? -dx : dx;
if (travel <= 0) return "closed";
if (travel >= width * 0.82 || (travel > width * 0.45 && velocity > 0.75)) return "full";
return travel >= width * 0.25 ? "open" : "closed";
}

View file

@ -0,0 +1,122 @@
export interface PlatformCapabilities {
publishText: boolean;
requiresMediaForPublish?: boolean;
amplifyRepost: boolean;
amplifyQuote: boolean;
amplifyShare: boolean;
feed: boolean;
directMessages: boolean;
comments: boolean;
}
export interface Platform {
id: string;
displayName: string;
charLimit: number;
capabilities: PlatformCapabilities;
amplifyLabels: { repost?: string; quote?: string; share?: string };
}
export const PLATFORMS: Record<string, Platform> = {
mastodon: {
id: "mastodon",
displayName: "Mastodon",
charLimit: 500,
capabilities: { publishText: true, amplifyRepost: true, amplifyQuote: true, amplifyShare: false, feed: true, directMessages: true, comments: true },
amplifyLabels: { repost: "Boost", quote: "Quote" },
},
telegram: {
id: "telegram",
displayName: "Telegram",
charLimit: 4096,
capabilities: { publishText: true, amplifyRepost: false, amplifyQuote: false, amplifyShare: true, feed: true, directMessages: false, comments: false },
amplifyLabels: { share: "Forward" },
},
limited: {
id: "limited",
displayName: "Limited API",
charLimit: 2200,
capabilities: { publishText: true, amplifyRepost: false, amplifyQuote: false, amplifyShare: false, feed: true, directMessages: false, comments: true },
amplifyLabels: {},
},
facebook: {
id: "facebook",
displayName: "Facebook",
charLimit: 63206,
capabilities: { publishText: true, amplifyRepost: false, amplifyQuote: false, amplifyShare: true, feed: true, directMessages: true, comments: true },
amplifyLabels: { share: "Share link" },
},
instagram: {
id: "instagram",
displayName: "Instagram",
charLimit: 2200,
capabilities: { publishText: true, requiresMediaForPublish: true, amplifyRepost: false, amplifyQuote: false, amplifyShare: false, feed: true, directMessages: true, comments: true },
amplifyLabels: {},
},
};
export const CONNECTED_ACCOUNTS = [
{ platform: "mastodon", handle: "@studio@fosstodon.org", status: "healthy" as const },
{ platform: "telegram", handle: "@studiochannel", status: "healthy" as const },
{ platform: "limited", handle: "@studio", status: "healthy" as const },
{ platform: "facebook", handle: "Studio Page", status: "expiring" as const },
];
export function getPlatform(id: string): Platform {
const platform = PLATFORMS[id];
if (platform === undefined) throw new Error(`unknown platform: ${id}`);
return platform;
}
export interface DocumentedRate {
dailyPostCap?: number;
requestsPerSecond?: number;
commentsPerHour?: number;
tierNote?: string;
}
export const DOCUMENTED_RATES: Record<string, DocumentedRate> = {
instagram: { dailyPostCap: 25, requestsPerSecond: 0.5, commentsPerHour: 200 },
threads: { dailyPostCap: 250, requestsPerSecond: 0.5 },
mastodon: { requestsPerSecond: 0.5, dailyPostCap: 300 },
telegram: { requestsPerSecond: 1 },
facebook: { requestsPerSecond: 2 },
bluesky: { requestsPerSecond: 1 },
limited: {},
};
export interface ConnectionCapacity {
handle: string;
platform: string;
resolvedCap?: number;
used: number;
recentPublishes: string[];
tierNote?: string;
overrideCap?: number;
}
const HOUR = 3600_000;
const now = Date.now();
const ago = (h: number): string => new Date(now - h * HOUR).toISOString();
export const CONNECTION_CAPACITIES: ConnectionCapacity[] = [
{ handle: "@studio@fosstodon.org", platform: "mastodon", resolvedCap: 300, used: 42, recentPublishes: Array.from({ length: 42 }, (_, i) => ago(0.5 + i * 0.4)) },
{ handle: "@studiochannel", platform: "telegram", resolvedCap: undefined, used: 15, recentPublishes: Array.from({ length: 15 }, (_, i) => ago(1 + i * 0.8)) },
{ handle: "Studio News", platform: "instagram", resolvedCap: 100, used: 100, recentPublishes: Array.from({ length: 100 }, (_, i) => ago(0.2 + i * 0.22)), tierNote: "news-publisher advanced access", overrideCap: 100 },
{ handle: "Studio Page", platform: "facebook", resolvedCap: undefined, used: 8, recentPublishes: Array.from({ length: 8 }, (_, i) => ago(2 + i)) },
];
export interface ColumnDef {
key: string;
title: string;
platformId: string;
staleness: string;
}
export const MOCK_COLUMN_DEFS: ColumnDef[] = [
{ key: "home", title: "Home", platformId: "mastodon", staleness: "live" },
{ key: "mentions", title: "Mentions", platformId: "telegram", staleness: "live" },
{ key: "search", title: "Saved: product", platformId: "limited", staleness: "12s old" },
];
export const MOCK_TARGETS = ["mastodon", "telegram", "facebook", "instagram"];

View file

@ -0,0 +1,16 @@
export const SAVED_SEARCHES_KEY = "tmbh.saved-searches";
export const DEFAULT_SAVED_SEARCHES = ["product"];
export function readSavedSearches(): string[] {
if (typeof window === "undefined") return DEFAULT_SAVED_SEARCHES;
try {
const raw = window.localStorage.getItem(SAVED_SEARCHES_KEY);
const parsed: unknown = raw === null ? DEFAULT_SAVED_SEARCHES : JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : DEFAULT_SAVED_SEARCHES;
} catch { return DEFAULT_SAVED_SEARCHES; }
}
export function saveSearches(values: string[]): void {
window.localStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify([...new Set(values.map((value) => value.trim()).filter(Boolean))]));
window.dispatchEvent(new Event("saved-searches-change"));
}

View file

@ -0,0 +1,31 @@
/**
* Session token holder: memory first, mirrored to localStorage so a reload
* keeps the session. Listeners let the auth store react when the API client
* clears a token (401) or a mutation hands back a re-issued one.
*/
const STORAGE_KEY = "tmbh.session";
let memory: string | null | undefined;
const listeners = new Set<(token: string | null) => void>();
export function getSessionToken(): string | null {
if (memory !== undefined) return memory;
if (typeof window === "undefined") return null;
memory = window.localStorage.getItem(STORAGE_KEY);
return memory;
}
export function setSessionToken(token: string | null): void {
memory = token;
if (typeof window !== "undefined") {
if (token === null) window.localStorage.removeItem(STORAGE_KEY);
else window.localStorage.setItem(STORAGE_KEY, token);
}
for (const listener of listeners) listener(token);
}
export function onSessionTokenChange(listener: (token: string | null) => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}

View file

@ -0,0 +1,49 @@
import type { FeedItem } from "./fixtures";
import { getPlatform, PLATFORMS, type Platform } from "./platforms";
import type { ColumnDef } from "../components/streams/column";
export interface StreamItem extends FeedItem {
connectionId?: string;
remoteId?: string;
feedId?: string;
mediaUrls?: string[];
sourceLabel?: string;
livePlatform?: Platform;
}
export interface FeedsResponse {
items: Array<{ id: string; remoteId: string; connectionId: string; provider: string; authorHandle: string; text: string; mediaUrls: string[]; createdAt: string; metrics: Record<string, number> }>;
nextCursor: string | null;
meta: {
configuredFeeds: number;
connections: Array<{ id: string; provider: string; handle: string; displayName: string; capabilities: string[]; fetchedAt: string | null; status: "ready" | "pending" }>;
excludedConnections: Array<{ id: string; provider: string; reason: string }>;
};
}
export const streamItemKey = (item: StreamItem): string => `${item.connectionId ?? item.platform}:${item.feedId ?? item.remoteId ?? item.id}`;
export const streamPlatform = (item: StreamItem): Platform => item.livePlatform ?? getPlatform(item.platform);
export function liveColumns(response: FeedsResponse): ColumnDef[] {
return response.meta.connections.map((connection) => {
const enabled = (key: string): boolean => connection.capabilities.includes(key);
const known = PLATFORMS[connection.provider];
const platform: Platform = {
id: connection.provider, displayName: known?.displayName ?? connection.provider,
charLimit: known?.charLimit ?? 500, amplifyLabels: known?.amplifyLabels ?? { repost: "Repost", quote: "Quote" },
capabilities: { publishText: enabled("publishText"), amplifyRepost: enabled("amplifyRepost"), amplifyQuote: enabled("amplifyQuote"), amplifyShare: enabled("amplifyShare"), feed: true, directMessages: enabled("directMessages"), comments: enabled("comments") },
};
const items: StreamItem[] = response.items.filter((item) => item.connectionId === connection.id).map((item) => ({
// Existing mobile cards use numeric IDs; selection and React keys use the
// actual (connection, feed item) identity instead of this presentation ID.
id: Array.from(`${connection.id}:${item.id}`).reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) >>> 0, 0),
platform: connection.provider, author: item.authorHandle, handle: item.authorHandle,
text: item.text, ageMinutes: Math.max(0, (Date.now() - Date.parse(item.createdAt)) / 60_000),
likes: item.metrics.likes ?? item.metrics.favourites ?? 0, reposts: item.metrics.reposts ?? item.metrics.reblogs ?? 0,
replies: item.metrics.replies ?? 0, hasMedia: item.mediaUrls.length > 0,
connectionId: connection.id, remoteId: item.remoteId, feedId: item.id, mediaUrls: item.mediaUrls,
sourceLabel: `${connection.displayName} (${connection.handle})`, livePlatform: platform,
}));
return {
key: connection.id, title: `${connection.displayName} · ${connection.handle}`, platformId: connection.provider,
platform, staleness: connection.fetchedAt ? `Updated ${new Date(connection.fetchedAt).toLocaleTimeString()}` : "Waiting for feed", items,
};
});
}

View file

@ -0,0 +1,68 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { api, ApiUnreachableError, errorMessage } from "./api-client";
import { useAuth } from "./auth-store";
export type DataSource = "loading" | "live" | "sample";
export interface ApiResource<T> {
data: T;
source: DataSource;
error: string | null;
reload: () => void;
setData: (update: (current: T) => T) => void;
}
/**
* GET a resource once the session is verified. When the API is unreachable
* the screen gets `sample` (its fixture data) so local UI work never blocks
* on a running backend — pages show an "offline — sample data" indicator.
*/
export function useApiResource<Live, View>(
path: string,
toView: (live: Live) => View,
sample: View,
query?: Record<string, string | number | undefined>,
): ApiResource<View> {
const { status } = useAuth();
const [data, setDataState] = useState<View>(sample);
const [source, setSource] = useState<DataSource>("loading");
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
const queryKey = JSON.stringify(query ?? {});
useEffect(() => {
if (status === "loading") return;
if (status !== "authenticated") {
setDataState(sample);
setSource("sample");
return;
}
const controller = new AbortController();
const parsedQuery = JSON.parse(queryKey) as Record<string, string | number | undefined>;
api
.get<Live>(path, parsedQuery, controller.signal)
.then((live) => {
setDataState(toView(live));
setSource("live");
setError(null);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
if (err instanceof ApiUnreachableError) {
setDataState(sample);
setSource("sample");
return;
}
setError(errorMessage(err));
setSource("live");
});
return () => controller.abort();
// `toView` / `sample` must be stable (module-level); keying on them would refetch every render.
}, [status, path, queryKey, nonce]);
const reload = useCallback(() => setNonce((n) => n + 1), []);
const setData = useCallback((update: (current: View) => View) => setDataState(update), []);
return { data, source, error, reload, setData };
}

View file

@ -0,0 +1,90 @@
import type { VideoLibraryEntryDto } from "./video-types";
/**
* Offline sample library for the video screen (useApiResource conventions: when
* the API is unreachable the screen renders these read-only). Lives under
* src/lib so provider literals stay inside the mock-registry boundary.
*/
export const SAMPLE_VIDEO_ENTRIES: VideoLibraryEntryDto[] = [
{
videoAsset: {
id: "1f0a2b3c-4d5e-6f70-8192-a3b4c5d6e7f8",
workspaceId: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
storageKey: "sha256/9f2c7ab41d06e5b38c0f4a71d295e6b83c10d47a9e52f68b14c3d2e1f0a9b8c7.mp4",
mimeType: "video/mp4",
sizeBytes: 851_738_112,
width: 1920,
height: 1080,
durationMs: 362_400,
frameRate: 29.97,
codec: "h264",
audioCodec: "aac",
},
processingState: "complete",
publications: [
{
connectionId: "3c4d5e6f-7081-9293-a4b5-c6d7e8f90a1b",
remoteId: "981",
platform: "peertube",
publishedAt: "2026-10-05T09:12:00.000Z",
},
{
connectionId: "4d5e6f70-8192-93a4-b5c6-d7e8f90a1b2c",
remoteId: "dQw4w9WgXcQ",
platform: "youtube",
publishedAt: "2026-10-04T18:40:00.000Z",
},
],
},
{
videoAsset: {
id: "2a1b3c4d-5e6f-7081-9293-b4c5d6e7f8a9",
workspaceId: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
storageKey: "sha256/1c8d3e9f2a6b4d08e7c5a19f3b6d2e8c4a7f1b9e3d6c2a8f4e1b7d3c9a5e2f0.webm",
mimeType: "video/webm",
sizeBytes: 218_103_808,
width: 1280,
height: 720,
durationMs: 95_200,
frameRate: 30,
codec: "vp9",
audioCodec: "opus",
},
processingState: "transcoding",
publications: [],
},
{
videoAsset: {
id: "3b2c4d5e-6f70-8192-93a4-c5d6e7f8a9b0",
workspaceId: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
storageKey: "sha256/6e1f0a2b3c4d5e6f70819293a4b5c6d7e8f90a1b2c3d4e5f6071829384a5b.mov",
mimeType: "video/quicktime",
sizeBytes: 41_943_040,
},
processingState: "probing",
publications: [],
},
{
videoAsset: {
id: "4c3d5e6f-7081-9293-a4b5-d6e7f8a9b0c1",
workspaceId: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
storageKey: "sha256/2d9e4a1f7c3b5d08e6a19f4b7d2e8c3a5f1b9e6d3c2a8f4e0b7d3c9a5e2f1a8.mp4",
mimeType: "video/mp4",
sizeBytes: 12_582_912,
},
processingState: "queued",
publications: [],
},
{
videoAsset: {
id: "5d4e6f70-8192-93a4-b5c6-e7f8a9b0c1d2",
workspaceId: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9",
storageKey: "sha256/8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8.mp4",
mimeType: "video/mp4",
sizeBytes: 68_157_440,
},
processingState: "failed",
publications: [],
},
];

View file

@ -0,0 +1,57 @@
/**
* Response shapes of the /video routes — kept in sync by hand with
* apps/api VideoController/VideoService (same standing as api-types.ts;
* declared here because api-client.ts is owned by another worker right now).
*/
/** video_processing_jobs.state — the processing pipeline a video walks. */
export type VideoProcessingState = "queued" | "probing" | "transcoding" | "complete" | "failed";
export interface VideoAssetDto {
id: string;
workspaceId: string;
storageKey: string;
mimeType: string;
sizeBytes: number;
width?: number;
height?: number;
durationMs?: number;
frameRate?: number;
codec?: string;
audioCodec?: string;
thumbnailKey?: string;
}
export interface VideoProcessingJobDto {
id: string;
workspaceId: string;
videoAssetId: string;
state: VideoProcessingState;
progressPercent?: number;
outputKey?: string;
error?: string;
processingNote?: string;
}
export interface VideoPublicationDto {
connectionId: string;
remoteId: string;
platform: string;
publishedAt: string;
}
export interface VideoLibraryEntryDto {
videoAsset: VideoAssetDto;
processingState: VideoProcessingState;
publications: VideoPublicationDto[];
}
export interface VideoLibraryPageDto {
items: VideoLibraryEntryDto[];
nextCursor?: string;
}
export interface VideoStatusDto {
videoAsset: VideoAssetDto;
processingJob: VideoProcessingJobDto;
}

View file

@ -0,0 +1,886 @@
/**
* Bilingual (EN/ES) wiki content for the in-app documentation section.
*
* Every manual is written for a non-technical activist: plain language, no
* unexplained jargon, and concrete "click this, then that" steps. Spanish is
* written in the register activist collectives actually use (tú form, assembly
* vocabulary), not machine translation.
*
* Body text conventions understood by the wiki renderer:
* - paragraphs are separated by a blank line ("\n\n")
* - lines starting with "- " render as a bullet list
* - lines starting with "1. " (any number) render as an ordered list
* - **text** renders as bold — use it for on-screen button and field labels
*/
export interface LocalizedText {
readonly en: string;
readonly es: string;
}
export interface WikiSection {
readonly heading: LocalizedText;
readonly body: LocalizedText;
}
export interface WikiPage {
readonly slug: string;
readonly title: LocalizedText;
readonly blurb: LocalizedText;
readonly sections: readonly WikiSection[];
}
export const WIKI_PAGES: readonly WikiPage[] = [
{
slug: "getting-started",
title: { en: "Getting started", es: "Primeros pasos" },
blurb: {
en: "Create your account, join your collective's workspace, and learn what each role can do.",
es: "Crea tu cuenta, únete al espacio de trabajo de tu colectivo y aprende qué puede hacer cada rol.",
},
sections: [
{
heading: { en: "What TMBH is", es: "Qué es TMBH" },
body: {
en: "TMBH is a broadcast newsroom your collective runs itself: one place to write, review, schedule and publish to many platforms at once. It also watches live feeds, bridges your chats, runs votes, and keeps an archive that survives takedowns. You do not need to install anything — everything happens in your browser at app.occupymedia.cc.\n\nThis wiki explains every feature step by step. If a word is new to you, it is explained the first time it appears.",
es: "TMBH es una sala de redacción que tu colectivo administra por su cuenta: un solo lugar para escribir, revisar, programar y publicar en muchas plataformas a la vez. También vigila fuentes en vivo, une tus chats, hace votaciones y mantiene un archivo que sobrevive a los borradones. No hay que instalar nada: todo ocurre en tu navegador, en app.occupymedia.cc.\n\nEsta wiki explica cada función paso a paso. Si una palabra es nueva, se explica la primera vez que aparece.",
},
},
{
heading: { en: "Creating your account", es: "Crear tu cuenta" },
body: {
en: "Open app.occupymedia.cc. The first screen shows **Sign in** and **Create account** — choose **Create account**.\n\n1. Pick a username (no spaces, no @) and a display name others in the collective will recognise.\n2. Enter your email so invites and recovery can reach you.\n3. Choose a password of at least 10 characters. A long phrase you can remember is stronger than a short messy one.\n4. Press **Create account**. You land in your new, empty workspace as its **Owner**.",
es: "Abre app.occupymedia.cc. La primera pantalla muestra **Sign in** y **Create account** — elige **Create account**.\n\n1. Elige un nombre de usuario (sin espacios y sin @) y un nombre visible que el colectivo reconozca.\n2. Escribe tu correo para que te lleguen invitaciones y recuperación.\n3. Elige una contraseña de al menos 10 caracteres. Una frase larga que recuerdes es más fuerte que una corta y enredada.\n4. Pulsa **Create account**. Entrarás en tu espacio de trabajo nuevo y vacío como su **Owner** (propietario).",
},
},
{
heading: { en: "Joining an existing workspace", es: "Unirte a un espacio de trabajo existente" },
body: {
en: "Most people do not create a workspace — they join their collective's. Ask a team admin for an **invite link** and open it in your browser.\n\nThe link shows the workspace name, the role you will get, and how many times the link can still be used. Press **Join** and you are in; the link stops working once it hits its use limit or its expiry date. If the link has expired, ask for a fresh one — that expiry is a safety feature, not a bug.",
es: "La mayoría no crea un espacio de trabajo: se une al de su colectivo. Pide a quien administra el equipo un **enlace de invitación** y ábrelo en tu navegador.\n\nEl enlace muestra el nombre del espacio, el rol que recibirás y cuántos usos le quedan. Pulsa **Join** y ya estarás dentro; el enlace deja de funcionar al agotar sus usos o al llegar a su fecha de caducidad. Si el enlace caducó, pide uno nuevo: esa caducidad es una medida de seguridad, no un fallo.",
},
},
{
heading: { en: "Signing in and out", es: "Iniciar y cerrar sesión" },
body: {
en: "To sign in later, open the app, enter your username and password, and press **Sign in**. Your session is remembered on that device until you sign out.\n\nOn a shared or borrowed computer always sign out when you finish: open **More** (bottom right on mobile, or the sidebar) and press **Sign out**. On your own laptop or phone, staying signed in is fine and saves time.",
es: "Para entrar más tarde, abre la app, escribe tu usuario y contraseña y pulsa **Sign in**. La sesión queda recordada en ese dispositivo hasta que la cierres.\n\nEn un equipo compartido o prestado, cierra siempre la sesión al terminar: abre **More** (abajo a la derecha en el móvil, o en la barra lateral) y pulsa **Sign out**. En tu propio portátil o móvil puedes quedarte con la sesión abierta y ahorrar tiempo.",
},
},
{
heading: { en: "Understanding roles", es: "Entender los roles" },
body: {
en: "A role is the set of things a person is allowed to do. Roles keep the newsroom honest: no single person can publish, moderate and audit at the same time without anyone noticing.\n\n- **Owner** — runs the workspace, can do everything, including deleting it.\n- **Admin** — manages members, connections and settings.\n- **Editor** — writes and edits drafts, requests approval.\n- **Approver** — votes on drafts in review; can block with a reason.\n- **Publisher** — dispatches approved posts and manages schedules.\n- **Moderator** — handles the bridged chats: deletes, times out, resolves flags.\n- **BroadcastOperator** — drives the live Control Room and stream destinations.\n- **Viewer** — read-only: watches streams, calendar and archive, changes nothing.\n\nOne person can hold more than one role in small collectives, but splitting writing from approving is the cheapest safety you can buy. See the Team management manual for how to assign them.",
es: "Un rol es el conjunto de cosas que una persona puede hacer. Los roles mantienen la redacción honesta: nadie puede publicar, moderar y auditar a la vez sin que se note.\n\n- **Owner** — administra el espacio, puede hacer todo, incluso borrarlo.\n- **Admin** — gestiona miembros, conexiones y ajustes.\n- **Editor** — escribe y edita borradores, pide aprobación.\n- **Approver** — vota los borradores en revisión; puede bloquear con un motivo.\n- **Publisher** — despacha las publicaciones aprobadas y gestiona la programación.\n- **Moderator** — cuida los chats unidos: borra, expulsa temporalmente, resuelve avisos.\n- **BroadcastOperator** — maneja la sala de control en vivo y los destinos de transmisión.\n- **Viewer** — solo lectura: mira streams, calendario y archivo, no cambia nada.\n\nEn colectivos pequeños una persona puede tener más de un rol, pero separar quien escribe de quien aprueba es la seguridad más barata que existe. Mira el manual de Gestión del equipo para asignarlos.",
},
},
{
heading: { en: "Finding your way around", es: "Orientarte en la app" },
body: {
en: "On a laptop the sidebar on the left lists every section: **Streams**, **Search**, **Inbox**, **Calendar**, **Video**, **Analytics**, **Connections**, **Control Room**, **Team**, **Audit** and more. On a phone the same sections live in the bottom bar and under **More**.\n\nThe fastest way anywhere is the keyboard: press **⌘K** (or **Ctrl-K**) to open the command palette and type where you want to go. This wiki is always reachable from **More → Help & docs**.",
es: "En un portátil, la barra lateral izquierda lista todas las secciones: **Streams**, **Search**, **Inbox**, **Calendar**, **Video**, **Analytics**, **Connections**, **Control Room**, **Team**, **Audit** y más. En el móvil las mismas secciones están en la barra inferior y dentro de **More**.\n\nLa forma más rápida de moverse es el teclado: pulsa **⌘K** (o **Ctrl-K**) para abrir la paleta de comandos y escribe a dónde quieres ir. Esta wiki siempre está en **More → Ayuda y documentación**.",
},
},
],
},
{
slug: "composing-posts",
title: { en: "Composing posts", es: "Redactar publicaciones" },
blurb: {
en: "Write a draft, pick the platforms it goes to, preview each version, and send it for group review.",
es: "Escribe un borrador, elige a qué plataformas va, previsualiza cada versión y envíalo a revisión del grupo.",
},
sections: [
{
heading: { en: "Where composing happens", es: "Dónde se redacta" },
body: {
en: "Open **Compose** from the sidebar, the bottom bar, or by pressing **⌘K** and typing compose. The same editor opens from the pencil button on the streams wall, so you can write while watching the feeds.\n\nEverything you write starts as a **draft**. A draft is private to the workspace and cannot reach the outside world until the group approves it — that is the whole point of the pipeline.",
es: "Abre **Compose** desde la barra lateral, la barra inferior, o pulsando **⌘K** y escribiendo compose. El mismo editor se abre desde el botón de lápiz en el muro de streams, así que puedes escribir mientras miras las fuentes.\n\nTodo lo que escribes empieza como **borrador**. Un borrador es privado del espacio de trabajo y no puede salir al exterior hasta que el grupo lo apruebe: esa es la gracia de todo el circuito.",
},
},
{
heading: { en: "Writing the draft", es: "Escribir el borrador" },
body: {
en: "Type your text in the main editor box. Links, hashtags and line breaks are kept as you write them. If the text runs long, the editor shows a character counter per platform so you know what still fits.\n\nA few habits that save rework later: put the call to action in the first line (platforms truncate), write out dates and places instead of \"tomorrow\", and avoid thread-dependent meaning, because each platform gets one post.",
es: "Escribe tu texto en el cuadro principal del editor. Los enlaces, hashtags y saltos de línea se conservan tal cual. Si el texto se alarga, el editor muestra un contador de caracteres por plataforma para que sepas qué cabe todavía.\n\nAlgunos hábitos que ahorran retrabajo: pon la llamada a la acción en la primera línea (las plataformas cortan el texto), escribe fechas y lugares completos en vez de \"mañana\", y evita sentidos que dependan de un hilo, porque cada plataforma recibe una sola publicación.",
},
},
{
heading: { en: "Choosing target platforms", es: "Elegir las plataformas destino" },
body: {
en: "Below the editor, tick every platform this post should reach: Mastodon, Telegram, Bluesky, PeerTube, Facebook, Instagram, Threads, LinkedIn, Reddit, Twitch, Discord or Slack. Only the accounts your collective has connected appear as choices — see the Connections manual if one is missing.\n\nPick several at once; that is the fan-out. One approval covers all the boxes you tick, and each platform gets its own copy sized to its rules.",
es: "Debajo del editor, marca todas las plataformas a las que debe llegar esta publicación: Mastodon, Telegram, Bluesky, PeerTube, Facebook, Instagram, Threads, LinkedIn, Reddit, Twitch, Discord o Slack. Solo aparecen como opciones las cuentas que tu colectivo conectó; si falta alguna, mira el manual de Conexiones.\n\nMarca varias a la vez: eso es la difusión múltiple. Una sola aprobación cubre todas las casillas que marques, y cada plataforma recibe su propia copia ajustada a sus reglas.",
},
},
{
heading: { en: "Previews: seeing what each platform gets", es: "Previsualizaciones: ver qué recibe cada plataforma" },
body: {
en: "The **Previews** panel renders your draft exactly as each ticked platform will show it, including truncation and media layout. Check it before submitting — a post that looks fine for one network can look broken on another.\n\nIf the preview shows a warning (for example, a platform that requires media), fix the draft or untick that platform rather than hoping for the best.",
es: "El panel **Previews** muestra tu borrador exactamente como lo presentará cada plataforma marcada, incluidos los cortes y la disposición de los medios. Revísalo antes de enviar: una publicación que se ve bien en una red puede verse rota en otra.\n\nSi la vista previa muestra un aviso (por ejemplo, una plataforma que exige medios), corrige el borrador o desmarca esa plataforma en vez de cruzar los dedos.",
},
},
{
heading: { en: "Visibility and media", es: "Visibilidad y medios" },
body: {
en: "Use **Visibility** to set who can see the post where the platform supports it — public, followers-only, or unlisted. Followers-only is the right choice for internal callouts you do not want screenshotted to fascists.\n\nAttach photos or video with the media button before you submit; attached media travels with the draft through review and archive. Video you upload here is transcoded automatically — see the Video upload manual.",
es: "Usa **Visibility** para definir quién puede ver la publicación donde la plataforma lo permita: pública, solo seguidores o no listada. \"Solo seguidores\" es la opción correcta para convocatorias internas que no quieres que capten por captura de pantalla.\n\nAdjunta fotos o video con el botón de medios antes de enviar; los medios acompañan al borrador durante la revisión y quedan en el archivo. El video que subas aquí se transcodifica solo: mira el manual de Subida de video.",
},
},
{
heading: { en: "Saving a draft to finish later", es: "Guardar un borrador para terminarlo después" },
body: {
en: "Press **Save draft** at any moment. Nothing is sent anywhere, and the draft waits for you under **Recent drafts** on the same page. Reopen it by tapping its title and keep writing.\n\nDrafts also serve as a parking lot during actions: sketch the text while it is fresh, then finish and submit once the assembly has decided the line.",
es: "Pulsa **Save draft** en cualquier momento. No se envía nada a ninguna parte y el borrador queda esperando en **Recent drafts**, en la misma página. Vuélvelo a abrir tocando su título y sigue escribiendo.\n\nLos borradores también sirven de estacionamiento durante las acciones: esboza el texto mientras esté fresco y termínalo y envíalo cuando la asamblea haya decidido la línea.",
},
},
{
heading: { en: "Submitting for review", es: "Enviar a revisión" },
body: {
en: "When the draft is ready, press **Request approval**. The draft moves to state **in review** and appears in the queue of everyone who can approve (Approvers, and Editors depending on your setup). You cannot publish it yourself from here — that separation is deliberate.\n\nIf you also set **Date and time** before requesting approval, the post is scheduled the moment it is approved, and you can skip the scheduling step. Full details are in the Approval workflow and Scheduling posts manuals.",
es: "Cuando el borrador esté listo, pulsa **Request approval**. El borrador pasa al estado **in review** y aparece en la cola de quien pueda aprobar (Approver y, según la configuración, Editor). Desde aquí no puedes publicarlo tú: esa separación es deliberada.\n\nSi además fijaste **Date and time** antes de pedir aprobación, la publicación queda programada en cuanto se apruebe y te saltas el paso de programación. Los detalles están en los manuales de Flujo de aprobación y de Programar publicaciones.",
},
},
],
},
{
slug: "approval-workflow",
title: { en: "Approval workflow", es: "Flujo de aprobación" },
blurb: {
en: "How group review works: the review queue, quorum rules, voting from chat, blocking with reason codes, and the pre-dispatch gate.",
es: "Cómo funciona la revisión en grupo: la cola de revisión, las reglas de quórum, votar desde el chat, bloquear con códigos de motivo y la compuerta previa al despacho.",
},
sections: [
{
heading: { en: "Why group approval exists", es: "Por qué existe la aprobación en grupo" },
body: {
en: "Anything published in the collective's name is a risk the whole collective carries: legal heat, doxxing, burn of a hard-won account. Group approval spreads that decision across several people instead of leaving it to whoever typed fastest.\n\nThe pipeline is strict on purpose: draft → review → approved → scheduled → dispatched → receipt. A post cannot skip a stage, and every stage leaves a mark in the audit log.",
es: "Todo lo que se publica en nombre del colectivo es un riesgo que carga todo el colectivo: presión legal, doxxing, quemar una cuenta costosa de conseguir. La aprobación en grupo reparte esa decisión entre varias personas en vez de dejarla en manos de quien escribió más rápido.\n\nEl circuito es estricto a propósito: borrador → revisión → aprobado → programado → despachado → acuse. Una publicación no puede saltarse una etapa, y cada etapa deja marca en el registro de auditoría.",
},
},
{
heading: { en: "Where reviews wait", es: "Dónde esperan las revisiones" },
body: {
en: "Open **Inbox** and switch to the **Approvals** segment (or press **Approvals** directly in the sidebar). Every draft in state **in review** is listed there, newest first, with its author, target platforms and any deadline.\n\nThe badge on the Inbox tab counts drafts waiting in review right now, so nobody can claim they did not see it.",
es: "Abre **Inbox** y cambia al segmento **Approvals** (o pulsa **Approvals** directamente en la barra lateral). Ahí se listan todos los borradores en estado **in review**, los más nuevos primero, con su autor, plataformas destino y fecha límite si la hay.\n\nEl contador en la pestaña del Inbox indica cuántos borradores esperan revisión ahora mismo, así que nadie puede decir que no lo vio.",
},
},
{
heading: { en: "Reviewing a draft (the E1 gate)", es: "Revisar un borrador (la compuerta E1)" },
body: {
en: "Open a draft from the queue. You see the full text, the per-platform previews and the history of votes so far. Read it as an outsider would: is it true, is it safe, does it name anyone who did not consent?\n\nThen choose **Approve** to add your yes, or **Block** to stop it. Approval here is the E1 gate — the first checkpoint a draft must pass on its way out of the building.",
es: "Abre un borrador desde la cola. Verás el texto completo, las vistas previas por plataforma y el historial de votos hasta ahora. Léelo como lo leería alguien de afuera: ¿es cierto, es seguro, nombra a alguien que no consintió?\n\nLuego elige **Approve** para sumar tu sí, o **Block** para frenarlo. La aprobación aquí es la compuerta E1: el primer control que un borrador debe pasar antes de salir del edificio.",
},
},
{
heading: { en: "Quorum rules, in plain words", es: "Las reglas de quórum, en palabras simples" },
body: {
en: "A quorum is the number of votes needed before the draft counts as approved. Your collective configures it, typically as x out of y: 3 of 5 approvers, or half of the editors, and so on.\n\nThe queue always shows the gap to quorum — how many more yes votes are needed. Until that gap closes, the draft simply waits; it is never approved by silence. If the collective set a mixed rule (for example, 1 of 2 admins AND 2 of 5 participants), both arms must be satisfied.",
es: "Un quórum es la cantidad de votos necesaria para que el borrador cuente como aprobado. Tu colectivo lo configura, normalmente como x de y: 3 de 5 aprobadores, o la mitad de quienes editan, etc.\n\nLa cola siempre muestra lo que falta para el quórum: cuántos sí más se necesitan. Hasta cerrar esa brecha, el borrador simplemente espera; nunca se aprueba por silencio. Si el colectivo fijó una regla mixta (por ejemplo, 1 de 2 admins Y 2 de 5 participantes), deben cumplirse los dos brazos.",
},
},
{
heading: { en: "Blocking and reason codes", es: "Bloquear y códigos de motivo" },
body: {
en: "**Block** stops the draft immediately, no matter how many approvals it had. A block is not a veto war: you must attach a **reason code**, a short machine-readable tag saying why — for example unsafe-claim, legal-risk, wrong-target, needs-source, tone.\n\nCodes matter because they are counted. After a month you can see whether blocks cluster on \"needs-source\" and fix the writing process, instead of arguing about personalities. The blocked item sits in the blocked queue with the gap to quorum spelled out, so the author knows exactly what to change and resubmit.",
es: "**Block** frena el borrador de inmediato, sin importar cuántas aprobaciones tuviera. Un bloqueo no es una guerra de vetos: debes adjuntar un **código de motivo**, una etiqueta corta y legible por máquinas que diga por qué — por ejemplo afirmación-insegura, riesgo-legal, destino-equivocado, falta-fuente, tono.\n\nLos códigos importan porque se cuentan. Tras un mes puedes ver si los bloqueos se concentran en \"falta-fuente\" y arreglar el proceso de escritura, en vez de discutir personalidades. El ítem bloqueado queda en la cola de bloqueados con la brecha hasta el quórum a la vista, así quien escribió sabe exactamente qué cambiar y reenviar.",
},
},
{
heading: { en: "Voting from chat", es: "Votar desde el chat" },
body: {
en: "You do not have to be at a laptop. The collective can vote from the bridged chats — Telegram, Twitch or Discord — with a short command under the draft the bot posts. The web and the chat see the same tally, because it is the same vote.\n\nThis is the difference between deciding in the street and deciding in the meeting: people who are kettled can still approve the statement. See the Chat bridge manual for how the overlay works.",
es: "No hace falta estar frente a un portátil. El colectivo puede votar desde los chats unidos — Telegram, Twitch o Discord — con un comando corto bajo el borrador que publica el bot. La web y el chat ven el mismo recuento, porque es la misma votación.\n\nEsta es la diferencia entre decidir en la calle y decidir en la reunión: quien está rodeado por la policía puede igualmente aprobar el comunicado. Mira el manual de Puente de chat para entender la superposición.",
},
},
{
heading: { en: "The pre-dispatch gate (E3)", es: "La compuerta previa al despacho (E3)" },
body: {
en: "Approval is not the last look. At the moment of dispatch there is a second, smaller gate (E3) that re-checks the post against the platforms it is about to hit — token still valid, media ready, schedule still in the future.\n\nIf anything fails the gate, the dispatch is fenced: nothing half-published goes out, and the failure is recorded with a reason instead of silently retrying into the void.",
es: "La aprobación no es la última mirada. En el momento del despacho hay una segunda compuerta, más chica (E3), que vuelve a revisar la publicación contra las plataformas que va a tocar: token vigente, medios listos, programación todavía en el futuro.\n\nSi algo no pasa la compuerta, el despacho queda cercado: no sale nada publicado a medias, y el fallo queda registrado con su motivo en vez de reintentarse en silencio hacia la nada.",
},
},
],
},
{
slug: "scheduling-posts",
title: { en: "Scheduling posts", es: "Programar publicaciones" },
blurb: {
en: "Publish at the right hour in any timezone, read the calendar, trust the daylight-saving handling, and cancel safely.",
es: "Publica a la hora correcta en cualquier zona horaria, lee el calendario, confía en el manejo del horario de verano y cancela con seguridad.",
},
sections: [
{
heading: { en: "Why schedule instead of posting now", es: "Por qué programar en vez de publicar ya" },
body: {
en: "Timing decides reach. A callout posted at 3 a.m. local time dies quietly; the same text at 8 a.m. rides the morning scroll. Scheduling also lets the group approve tonight what goes out before tomorrow's march, without anyone waking up early to press a button.\n\nScheduling in TMBH is timezone-correct and daylight-saving-proof, so an action at a fixed local hour stays at that local hour all year.",
es: "El momento decide el alcance. Una convocatoria publicada a las 3 de la madrugada muere en silencio; el mismo texto a las 8 cabalga el scroll matinal. Programar también permite que el grupo apruebe esta noche lo que saldrá antes de la marcha de mañana, sin que nadie madrugue a apretar un botón.\n\nLa programación en TMBH respeta zonas horarias y el cambio de horario, así que una acción a una hora local fija se mantiene en esa hora local todo el año.",
},
},
{
heading: { en: "Setting date and time", es: "Fijar fecha y hora" },
body: {
en: "In **Compose**, fill **Date and time** under the editor. The time you enter is interpreted in your own timezone and shown back to you with the timezone spelled out — check that suffix before saving, especially if you coordinate across continents.\n\nThen press **Schedule** (or **Request approval** with the time already set, so the post schedules itself once approved). The draft leaves your hands and appears on the calendar.",
es: "En **Compose**, completa **Date and time** debajo del editor. La hora que escribas se interpreta en tu propia zona horaria y se te muestra de vuelta con la zona explícita: revisa ese sufijo antes de guardar, sobre todo si coordinas entre continentes.\n\nLuego pulsa **Schedule** (o **Request approval** con la hora ya fijada, para que la publicación se programe sola al aprobarse). El borrador sale de tus manos y aparece en el calendario.",
},
},
{
heading: { en: "Reading the calendar", es: "Leer el calendario" },
body: {
en: "Open **Calendar** to see every scheduled publication on a timeline: what goes out, to which platforms, and when in your local time. Scheduled items are fenced, which means the text is frozen — editing it would need a new approval round.\n\nUse the calendar as the newsroom's shared clock: before proposing a new time, look for collisions with actions, streams or other posts in the same window.",
es: "Abre **Calendar** para ver todas las publicaciones programadas en una línea de tiempo: qué sale, a qué plataformas y cuándo en tu hora local. Los ítems programados están cercados, lo que significa que el texto queda congelado; editarlo exigiría una nueva ronda de aprobación.\n\nUsa el calendario como el reloj compartido de la redacción: antes de proponer una hora nueva, mira choques con acciones, transmisiones u otras publicaciones en la misma ventana.",
},
},
{
heading: { en: "Daylight saving, handled", es: "El horario de verano, resuelto" },
body: {
en: "Clocks change twice a year and they change on different dates in different countries. TMBH stores each schedule as an exact instant, computed from the timezone rules in force on that date — not as \"9 a.m.\" floating free.\n\nIn practice: a post scheduled for 9 a.m. Madrid time lands at 9 a.m. Madrid time, whether Spain is on winter or summer clock, and colleagues in Buenos Aires see the correct local equivalent. You never need to reschedule around a clock change.",
es: "Los relojes cambian dos veces al año y cambian en fechas distintas según el país. TMBH guarda cada programación como un instante exacto, calculado con las reglas de zona horaria vigentes en esa fecha, no como un \"9:00\" flotando en el aire.\n\nEn la práctica: una publicación programada para las 9:00 de Madrid sale a las 9:00 de Madrid, esté España en horario de invierno o de verano, y quien esté en Buenos Aires ve el equivalente local correcto. Nunca hay que reprogramar por un cambio de hora.",
},
},
{
heading: { en: "Cancelling or changing a scheduled post", es: "Cancelar o cambiar una publicación programada" },
body: {
en: "Open the item on the calendar and press **Cancel**. You will be asked **Cancel this scheduled publication?** — confirm with the cancel button or back out with **Keep it**. Cancelling is safe at any point before dispatch, and the cancellation is written to the audit log with who did it.\n\nTo change the text rather than the time, cancel and compose a new draft: the old one stays archived, so you can copy from it. To move only the time, cancel and reschedule the same approved content if your role allows it.",
es: "Abre el ítem en el calendario y pulsa **Cancel**. Te preguntarán **Cancel this scheduled publication?** — confirma con el botón de cancelar o vuelve atrás con **Keep it**. Cancelar es seguro en cualquier momento antes del despacho, y la cancelación queda escrita en el registro de auditoría con quién la hizo.\n\nPara cambiar el texto y no la hora, cancela y redacta un borrador nuevo: el viejo queda archivado y puedes copiar de él. Para mover solo la hora, cancela y reprograma el mismo contenido aprobado si tu rol lo permite.",
},
},
{
heading: { en: "What happens at dispatch time", es: "Qué pasa en el momento del despacho" },
body: {
en: "When the moment arrives, the dispatcher fans the post out to every target platform through the pre-dispatch gate. Each platform gets its own attempt, its own retry with backoff, and its own outcome.\n\nYou do not have to watch it happen, but you can: the receipts arrive in the Inbox and each platform's result is recorded, so a partial failure (three platforms ok, one refused) is visible instead of guessed.",
es: "Cuando llega el momento, el despachador reparte la publicación a todas las plataformas destino a través de la compuerta previa al despacho. Cada plataforma recibe su propio intento, su propio reintento con espera progresiva y su propio resultado.\n\nNo hace falta que lo mires, pero puedes: los acuses llegan al Inbox y el resultado de cada plataforma queda registrado, así que un fallo parcial (tres plataformas bien, una rechazada) se ve en vez de adivinarse.",
},
},
{
heading: { en: "Receipts", es: "Acuses de recibo" },
body: {
en: "A receipt is the proof of what actually went out: the text as published, the platform, the timestamp, and the remote reference — the ID the platform assigned. Receipts land in the **Inbox** and are kept in the archive.\n\nKeep them. When a platform throttles, deletes or \"loses\" a post, the receipt plus the archived copy is what lets your collective show what was said and when.",
es: "Un acuse es la prueba de lo que realmente salió: el texto tal como se publicó, la plataforma, la marca de tiempo y la referencia remota, el ID que asignó la plataforma. Los acuses llegan al **Inbox** y se guardan en el archivo.\n\nConsérvalos. Cuando una plataforma limita, borra o \"pierde\" una publicación, el acuse más la copia archivada son lo que permite a tu colectivo demostrar qué se dijo y cuándo.",
},
},
],
},
{
slug: "streams",
title: { en: "Streams — the column wall", es: "Streams — el muro de columnas" },
blurb: {
en: "Watch many platforms at once, add columns, trust that hostile content is rendered safely, and understand cooldowns.",
es: "Mira muchas plataformas a la vez, añade columnas, confía en que el contenido hostil se muestra de forma segura y entiende los tiempos de espera.",
},
sections: [
{
heading: { en: "What the wall is", es: "Qué es el muro" },
body: {
en: "**Streams** is the home screen: a wall of live columns side by side, in the style of the old TweetDeck. Each column is one feed — a Mastodon timeline, a Telegram channel, a Bluesky feed, a saved search — scrolling as new posts arrive.\n\nThe point is situational awareness during an action: instead of switching apps, you see the street, the press, the trolls and your own channels in one glance.",
es: "**Streams** es la pantalla de inicio: un muro de columnas en vivo una al lado de la otra, al estilo del viejo TweetDeck. Cada columna es una fuente — una línea de tiempo de Mastodon, un canal de Telegram, un feed de Bluesky, una búsqueda guardada — que se desplaza cuando llegan publicaciones nuevas.\n\nLa idea es tener lectura de situación durante una acción: en vez de saltar entre apps, ves la calle, la prensa, los trolls y tus propios canales de un vistazo.",
},
},
{
heading: { en: "Adding a column", es: "Añadir una columna" },
body: {
en: "Press **Columns** at the top of the streams page (on mobile it is the columns button in the top bar). Pick a feed from the list of connected accounts, or choose **All feeds** to watch everything at once. The column opens immediately at the right edge of your wall.\n\nTo watch a search instead of a feed, run the search from **Search** and use **Save a search as a column** — then the column shows every new post matching those words, on every platform you follow.",
es: "Pulsa **Columns** en la parte superior de la página de streams (en el móvil es el botón de columnas en la barra de arriba). Elige una fuente de la lista de cuentas conectadas, o marca **All feeds** para verlo todo junto. La columna se abre de inmediato en el borde derecho de tu muro.\n\nPara vigilar una búsqueda y no una fuente, hazla desde **Search** y usa **Save a search as a column**: la columna mostrará cada publicación nueva que coincida con esas palabras, en todas las plataformas que sigues.",
},
},
{
heading: { en: "Moving around the wall", es: "Moverse por el muro" },
body: {
en: "Columns scroll independently: drag within one column to read its history, and scroll the page sideways to reach the next. With a keyboard, **j** and **k** move between posts, **r** reposts and **q** quotes — the shortcut hints are printed under the wall.\n\n**Load older posts** at the bottom of a column pages further back in time. Loading older content never disturbs the live top of the column, so you cannot lose the newest post by reading history.",
es: "Las columnas se desplazan de forma independiente: arrastra dentro de una columna para leer su historial y desplázate de lado en la página para llegar a la siguiente. Con teclado, **j** y **k** mueven entre publicaciones, **r** repite y **q** cita — las pistas de atajos están impresas bajo el muro.\n\n**Load older posts** al pie de una columna avanza más atrás en el tiempo. Cargar contenido viejo nunca molesta la parte superior en vivo de la columna, así que no puedes perder la publicación más nueva por leer el historial.",
},
},
{
heading: { en: "Acting on a post", es: "Actuar sobre una publicación" },
body: {
en: "Hover or tap a post to get its actions: **Repost** (or **Boost** on Mastodon), **Quote** with your own commentary, and **Share** where the platform supports forwarding. **Open compose** takes the text into a new draft when you want to answer at length.\n\nActions only appear for platforms that truly allow them — there is no \"no amplify\" dead button. If an account is read-only, the column still shows the feed; the buttons simply do not offer what the platform forbids.",
es: "Pasa el cursor o toca una publicación para ver sus acciones: **Repost** (o **Boost** en Mastodon), **Quote** con tu propio comentario y **Share** donde la plataforma permita reenviar. **Open compose** lleva el texto a un borrador nuevo cuando quieres responder con calma.\n\nLas acciones solo aparecen en plataformas que de verdad las permiten: no hay botones muertos. Si una cuenta es de solo lectura, la columna muestra la fuente igual, pero los botones no ofrecen lo que la plataforma prohíbe.",
},
},
{
heading: { en: "Safe rendering: why a nasty post cannot hurt you", es: "Renderizado seguro: por qué una publicación maliciosa no puede dañarte" },
body: {
en: "Columns show content written by strangers, some of them hostile. TMBH never runs that content as-is: it is sanitised and rendered inside a locked frame, so hidden scripts, tracking pixels and auto-loading junk are stripped before anything reaches your browser.\n\nThis has been tested against deliberately hostile HTML. Practically: you can leave the wall open all day without a post executing anything on your machine. Media you do see is proxied through the server, so platforms do not learn your members' IP addresses.",
es: "Las columnas muestran contenido escrito por desconocidos, algunos hostiles. TMBH nunca ejecuta ese contenido tal cual: se limpia y se renderiza dentro de un marco cerrado, de modo que scripts ocultos, píxeles rastreadores y basura de carga automática se eliminan antes de llegar a tu navegador.\n\nEsto se probó contra HTML deliberadamente hostil. En la práctica: puedes dejar el muro abierto todo el día sin que una publicación ejecute nada en tu máquina. Los medios que sí ves pasan por el proxy del servidor, así las plataformas no conocen las IP de tu gente.",
},
},
{
heading: { en: "Cooldowns and refresh: why columns pause", es: "Enfriamientos y refresco: por qué las columnas se pausan" },
body: {
en: "Each source has a cooldown — a minimum pause between fetches. When a platform is slow, refuses, or rate-limits us, the source backs off progressively (the pause grows) instead of hammering it and burning the account. The column shows this state instead of pretending to be fresh.\n\nSo a paused or slow column is the system protecting your account, not a bug. **Refresh** forces one immediate fetch, but it cannot override the platform's limits; if a source stays degraded for long, check the Connections page, where health is tracked per account.",
es: "Cada fuente tiene un enfriamiento: una pausa mínima entre consultas. Cuando una plataforma va lenta, se niega o nos limita, la fuente retrocede de forma progresiva (la pausa crece) en vez de golpearla y quemar la cuenta. La columna muestra ese estado en vez de fingir frescura.\n\nAsí que una columna pausada o lenta es el sistema protegiendo tu cuenta, no un fallo. **Refresh** fuerza una consulta inmediata, pero no puede saltarse los límites de la plataforma; si una fuente queda degradada mucho tiempo, revisa la página de Connections, donde se sigue la salud por cuenta.",
},
},
],
},
{
slug: "connections",
title: { en: "Connections", es: "Conexiones" },
blurb: {
en: "Connect platform accounts, read their health, set per-account rules, and keep several accounts per platform.",
es: "Conecta cuentas de plataformas, lee su salud, define reglas por cuenta y mantén varias cuentas por plataforma.",
},
sections: [
{
heading: { en: "What a connection is", es: "Qué es una conexión" },
body: {
en: "A connection is one account of one platform, linked to the workspace: your collective's Mastodon account, its Telegram channel, its Twitch channel, and so on. Nothing can be published, watched or bridged until the connection exists.\n\nCredentials never sit in the browser. They are handed to the encrypted vault (see Settings & security) and only an opaque handle is stored with the connection, so a stolen laptop leaks nothing.",
es: "Una conexión es una cuenta de una plataforma, vinculada al espacio de trabajo: la cuenta de Mastodon del colectivo, su canal de Telegram, su canal de Twitch, etc. Nada puede publicarse, vigilarse ni unirse hasta que exista la conexión.\n\nLas credenciales nunca quedan en el navegador. Se entregan a la bóveda cifrada (mira Ajustes y seguridad) y con la conexión solo se guarda un identificador opaco, así que un portátil robado no filtra nada.",
},
},
{
heading: { en: "Connecting an account", es: "Conectar una cuenta" },
body: {
en: "Open **Connections** and press **Connect account**. Choose the **Provider** (the platform), then fill what it asks: for Mastodon the **Instance URL** like https://your-instance.example and the OAuth login; for a Telegram bot the **Bot token**; for other providers the equivalent fields, one step at a time.\n\nFinish with **Connect**. The new account appears in the list with a health indicator. If the platform refuses, the error is shown in full — wrong instance, expired token, missing permission — so you can fix it instead of guessing.",
es: "Abre **Connections** y pulsa **Connect account**. Elige el **Provider** (la plataforma) y completa lo que pida: para Mastodon la **Instance URL** tipo https://tu-instancia.ejemplo y el login por OAuth; para un bot de Telegram el **Bot token**; para otras plataformas sus campos equivalentes, un paso por vez.\n\nTermina con **Connect**. La cuenta nueva aparece en la lista con su indicador de salud. Si la plataforma se niega, el error se muestra completo — instancia equivocada, token vencido, permiso faltante — para que lo arregles en vez de adivinar.",
},
},
{
heading: { en: "Reading health", es: "Leer la salud" },
body: {
en: "Every connection shows a state: live (working), degraded (slow or partially limited), or failing. Degraded usually means the platform is rate-limiting us and the system has backed off — the account recovers by itself once the pause expires.\n\nA failing connection needs a human: press **Reconnect** to renew the credential. Channels you can act on are grouped under **Channels**; the page also lists per-account capacity so you can see which account is close to its posting limits before you schedule a fan-out.",
es: "Cada conexión muestra un estado: en vivo (funcionando), degradada (lenta o parcialmente limitada) o fallando. Degradada suele significar que la plataforma nos está limitando y el sistema retrocedió; la cuenta se recupera sola cuando expira la pausa.\n\nUna conexión fallida necesita persona: pulsa **Reconnect** para renovar la credencial. Los canales sobre los que puedes actuar se agrupan bajo **Channels**; la página también lista la capacidad por cuenta, para ver cuál está cerca de sus límites de publicación antes de programar una difusión múltiple.",
},
},
{
heading: { en: "Several accounts per platform", es: "Varias cuentas por plataforma" },
body: {
en: "You can connect more than one account on the same platform — the neighbourhood Mastodon plus the press-account Mastodon, two Telegram channels, and so on. Use the **All accounts** filter at the top of the page to see every account at once, or filter by platform.\n\nWhen composing, each connected account is a separate target you can tick or leave out, so a post can go to one Telegram channel and not the other. Keeping accounts separate like this is what lets a burned account be removed without touching the rest.",
es: "Puedes conectar más de una cuenta en la misma plataforma: el Mastodon del barrio más el Mastodon de prensa, dos canales de Telegram, etc. Usa el filtro **All accounts** arriba en la página para ver todas las cuentas juntas, o filtra por plataforma.\n\nAl redactar, cada cuenta conectada es un destino aparte que puedes marcar o dejar fuera, así una publicación puede ir a un canal de Telegram y no al otro. Mantener las cuentas separadas así permite retirar una cuenta quemada sin tocar las demás.",
},
},
{
heading: { en: "Per-account rules", es: "Reglas por cuenta" },
body: {
en: "Each connection carries its own rules: posting cadence, cooldown, whether its feed feeds the wall, whether it joins the chat bridge. Set these when you connect or edit them later — they live with the account, not globally, because one platform's limits are not another's.\n\nUse rules to protect scarce accounts: a hard-won press account can be limited to a couple of posts a day while the throwaway accounts carry the volume.",
es: "Cada conexión lleva sus propias reglas: cadencia de publicación, enfriamiento, si su fuente alimenta el muro, si se une al puente de chat. Se definen al conectar o se editan después; viven con la cuenta, no de forma global, porque los límites de una plataforma no son los de otra.\n\nUsa las reglas para proteger las cuentas escasas: una cuenta de prensa costosa de conseguir puede limitarse a un par de publicaciones diarias mientras las cuentas desechables cargan el volumen.",
},
},
{
heading: { en: "Removing a connection", es: "Quitar una conexión" },
body: {
en: "Open the account and press **Remove**, then confirm. Removing a connection stops all publishing and watching for that account, and revokes its credential handle from the vault.\n\nDo this as soon as an account is compromised or seized — a removed connection cannot be used to post in your name. History (posts, receipts, archived copies) is not deleted; it stays in the archive as evidence.",
es: "Abre la cuenta y pulsa **Remove**, luego confirma. Quitar una conexión detiene toda publicación y vigilancia de esa cuenta y revoca su credencial en la bóveda.\n\nHazlo apenas una cuenta esté comprometida o intervenida: una conexión quitada no puede usarse para publicar en tu nombre. El historial (publicaciones, acuses, copias archivadas) no se borra; queda en el archivo como evidencia.",
},
},
],
},
{
slug: "live-control-room",
title: { en: "Live control room", es: "Sala de control en vivo" },
blurb: {
en: "Open the multiview, put a source on preview, TAKE it to program, build scenes, add overlays, and mix audio.",
es: "Abre la multivista, pon una fuente en previsualización, haz TAKE a programa, arma escenas, añade sobreimpresos y mezcla el audio.",
},
sections: [
{
heading: { en: "What the control room is for", es: "Para qué sirve la sala de control" },
body: {
en: "The **Control Room** is the web version of a TV gallery: you watch every live source at once in a grid, choose what the audience sees, and dress it with titles and tickers. It exists so a collective with laptops and a phone camera can produce a broadcast that looks deliberate.\n\nEverything runs in the browser — no OBS install, no capture cards. Output goes to a PeerTube channel or any generic RTMP/RTMPS destination.",
es: "La **Sala de control** es la versión web de una cabina de TV: ves todas las fuentes en vivo a la vez en una cuadrícula, eliges lo que ve la audiencia y lo vistes con títulos y cintillos. Existe para que un colectivo con portátiles y la cámara del móvil produzca una transmisión con pinta de intencional.\n\nTodo corre en el navegador: sin instalar OBS y sin capturadoras. La salida va a un canal de PeerTube o a cualquier destino genérico RTMP/RTMPS.",
},
},
{
heading: { en: "Opening it and reading the grid", es: "Abrirla y leer la cuadrícula" },
body: {
en: "Open **Control Room** from the sidebar. The **multiview layout** selector lets you switch between grid shapes and a columns view, so you can fit four sources on a laptop or a dozen on a big screen. Each tile shows the source name and its state.\n\nTiles are labelled: **PGM** is what is on air right now, **PST** is the preview, and sources that are only samples show **SAMPLE** until the real feed connects. If the production channel is missing, pick it from **Production channel** and press **Reconnect**.",
es: "Abre **Control Room** desde la barra lateral. El selector de **multiview layout** permite cambiar entre formas de cuadrícula y una vista de columnas, para meter cuatro fuentes en un portátil o una docena en una pantalla grande. Cada recuadro muestra el nombre de la fuente y su estado.\n\nLos recuadros están rotulados: **PGM** es lo que está al aire ahora, **PST** es la previsualización, y las fuentes que son muestra dicen **SAMPLE** hasta que se conecte la señal real. Si falta el canal de producción, elígelo en **Production channel** y pulsa **Reconnect**.",
},
},
{
heading: { en: "Preview, program, TAKE and CUT", es: "Previsualización, programa, TAKE y CUT" },
body: {
en: "The professional habit is: never switch blind. Tap a source to send it to **PST** (preview) and check it — is the frame right, is the audio up? The audience still sees the old source the whole time.\n\nWhen preview looks good, press **TAKE** to move it to program with a transition, or **CUT** for an instant hard change. Use CUT when something must stop now (a dox attempt on a guest), TAKE the rest of the time so the broadcast breathes. **Lock program** freezes the output so not even Auto-Director can change it — the switcher then shows **LOCKED** until you unlock.",
es: "El hábito profesional es: nunca cambiar a ciegas. Toca una fuente para mandarla a **PST** (previsualización) y revísala: ¿el encuadre está bien, el audio subió? La audiencia sigue viendo la fuente vieja todo ese tiempo.\n\nCuando la previsualización luzca bien, pulsa **TAKE** para pasarla a programa con transición, o **CUT** para un cambio seco e inmediato. Usa CUT cuando algo deba parar ya (un intento de doxx sobre una persona invitada), y TAKE el resto del tiempo para que la transmisión respire. **Lock program** congela la salida de modo que ni el Autodirector pueda cambiarla: el conmutador muestra **LOCKED** hasta que la liberes.",
},
},
{
heading: { en: "Scenes and layouts", es: "Escenas y composiciones" },
body: {
en: "A scene is a saved arrangement: one source full screen, picture-in-picture, split screens, and so on. Build a layout once — speaker big, slide small, camera corner — and reuse it all night instead of dragging tiles by hand.\n\nUse the **PST scene** selector on the switcher bar to choose the next scene, then TAKE it. **Select a scene** lists what exists; scenes are part of the production configuration, so an operator change mid-stream does not lose the rundown.",
es: "Una escena es una composición guardada: una fuente a pantalla completa, imagen dentro de imagen, pantallas divididas, etc. Arma la composición una vez — oradora grande, cartel chico, cámara en la esquina — y reusala toda la noche en vez de arrastrar recuadros a mano.\n\nUsa el selector **PST scene** en la barra del conmutador para elegir la próxima escena y luego haz TAKE. **Select a scene** lista las existentes; las escenas son parte de la configuración de producción, así que un cambio de operador en mitad de la transmisión no pierde el guion.",
},
},
{
heading: { en: "Overlays: lower thirds, chyrons, clocks, logos", es: "Sobreimpresos: tercios inferiores, cintillos, relojes y logotipos" },
body: {
en: "Overlays are the graphics laid on top of the program: **lower_third** (name and role under a speaker), **chyron** (the scrolling ticker along the bottom), **clock**, and **logo**. Open the **Overlay editor**, pick the type, and fill its fields — **Title** for the name line and **Chyron text** for the ticker.\n\nChoose a variant and colour (dark/red/outline read best on busy footage), then **Render overlay** to see it composited before anyone else does. If the render shows **Render failed**, the text is usually too long for the box — shorten it rather than fighting the layout.",
es: "Los sobreimpresos son los gráficos colocados encima del programa: **lower_third** (nombre y rol bajo quien habla), **chyron** (el cintillo que corre abajo), **clock** y **logo**. Abre el **Overlay editor**, elige el tipo y completa sus campos: **Title** para la línea de nombre y **Chyron text** para el cintillo.\n\nElige variante y color (dark/red/outline se leen mejor sobre imágenes movidas) y pulsa **Render overlay** para verlo compuesto antes que nadie. Si el render muestra **Render failed**, casi siempre el texto es demasiado largo para la caja: acórtalo en vez de pelear con la composición.",
},
},
{
heading: { en: "Audio mixing", es: "Mezcla de audio" },
body: {
en: "Open the **Audio monitor** panel. Each source has its own fader, so you can keep the translation feed low while the street mic carries. **Monitor select** chooses what you hear in your headphones; **Follow program** automatically listens to whatever is on air, and **Custom monitor mix** lets you build your own.\n\nMix with your ears, not your eyes: set levels before the stream starts, during silence, and write the fader positions down — the next operator will thank you. Remember the mix you monitor and the mix the audience gets are controlled separately on purpose.",
es: "Abre el panel **Audio monitor**. Cada fuente tiene su fader, así que puedes dejar baja la traducción mientras manda el micrófono de la calle. **Monitor select** elige lo que escuchas en tus auriculares; **Follow program** escucha automáticamente lo que está al aire, y **Custom monitor mix** te deja armar tu propia mezcla.\n\nMezcla con los oídos, no con los ojos: fija los niveles antes de empezar, durante el silencio, y anota las posiciones de los faders: quien opere después te lo agradecerá. Recuerda que la mezcla que vigilas y la que recibe la audiencia se controlan por separado, a propósito.",
},
},
{
heading: { en: "Going live", es: "Salir al aire" },
body: {
en: "Output is defined as destinations: a PeerTube channel or a generic RTMP/RTMPS address, one at a time — a destination is either an address or a vault credential handle, never both at once. When you go live, the credential is redeemed from the vault for the session, so the stream key is never pasted by hand.\n\nThe tally lights on each source confirm what is on program, in green, from the switcher's own state rather than a promise. When the stream ends, stop the destination and the credential handle is released.",
es: "La salida se define como destinos: un canal de PeerTube o una dirección RTMP/RTMPS genérica, de a uno — un destino es o una dirección o una credencial de la bóveda, nunca ambas a la vez. Al salir al aire, la credencial se canje de la bóveda para esa sesión, así la clave de transmisión nunca se pega a mano.\n\nLas luces de tally en cada fuente confirman qué está en programa, en verde, según el estado real del conmutador y no una promesa. Al terminar la transmisión, detén el destino y la credencial se libera.",
},
},
],
},
{
slug: "auto-director",
title: { en: "Auto-Director", es: "Autodirector" },
blurb: {
en: "Teach the switcher which source wins, how long a source must be live before it can be cut, and what happens when everything falls over.",
es: "Enseña al conmutador qué fuente gana, cuánto debe estar viva una fuente antes de poder cortarla y qué pasa cuando todo se cae.",
},
sections: [
{
heading: { en: "What Auto-Director does", es: "Qué hace el Autodirector" },
body: {
en: "Auto-Director is the switcher driving itself. When a higher-priority source comes online, it is previewed and taken to program automatically, following the rules your collective wrote. It exists for the nights when one person is handling chat, audio and three cameras at once.\n\nIt is a colleague with perfect attention and no taste: it follows your rules exactly, so write rules that encode your collective's judgement about what matters.",
es: "El Autodirector es el conmutador manejándose solo. Cuando aparece en línea una fuente de mayor prioridad, la previsualiza y la pasa a programa automáticamente, siguiendo las reglas que tu colectivo escribió. Existe para las noches en que una sola persona atiende chat, audio y tres cámaras a la vez.\n\nEs un colega con atención perfecta y sin criterio propio: sigue tus reglas al pie de la letra, así que escribe reglas que codifiquen el juicio de tu colectivo sobre qué importa.",
},
},
{
heading: { en: "Setting priority rules", es: "Definir las reglas de prioridad" },
body: {
en: "Priorities are an ordered list of sources: first in the list wins when it is live. Put the breaking street feed first, the speaker second, the slide deck third, and so on. A source not in the list is never auto-taken — that is how you keep the ambient camera from stealing program.\n\nRules are part of the production configuration, so they survive page reloads and operator changes. Change them in rehearsal, not during the action; a mid-stream rule change takes effect on the next switch, which surprises everyone but you.",
es: "Las prioridades son una lista ordenada de fuentes: la primera de la lista gana cuando está en vivo. Pon el corte de calle primero, a quien oradora segundo, las diapositivas tercero, y así. Una fuente fuera de la lista nunca se toma automáticamente: así evitas que la cámara ambiente robe el programa.\n\nLas reglas son parte de la configuración de producción, así que sobreviven a recargas de página y cambios de operador. Cámbialas en el ensayo, no durante la acción: un cambio de reglas en plena transmisión surte efecto en el próximo cambio y sorprende a todos menos a ti.",
},
},
{
heading: { en: "Dwell: no strobe broadcasts", es: "Permanencia: nada de transmisiones estroboscópicas" },
body: {
en: "A minimum dwell is the shortest time a source must stay on program before anything else can replace it. Without it, two flapping feeds would cut back and forth every second and the audience would get seasick.\n\nSet dwell longer than the typical burst: five minutes for a march, shorter for a press conference. The switcher queues the pending switch and takes it when the dwell expires, so nothing is lost — it just waits its turn.",
es: "La permanencia mínima es el tiempo más corto que una fuente debe quedarse en programa antes de que otra la reemplace. Sin ella, dos señales intermitentes cortarían de un lado a otro cada segundo y la audiencia marearía.\n\nDefine una permanencia mayor que la ráfaga típica: cinco minutos para una marcha, menos para una rueda de prensa. El conmutador deja el cambio en cola y lo ejecuta al expirar, así no se pierde nada: solo espera su turno.",
},
},
{
heading: { en: "Manual, auto, and the override window", es: "Manual, auto y la ventana de anulación" },
body: {
en: "The switcher bar shows the current mode: **MANUAL** means every change is yours; **AUTO** means Auto-Director is driving; **LOCKED** means program is frozen and nothing moves. While in AUTO you can still grab a source manually — that opens an **override** window where your hand beats the rules.\n\nChoose how long the override lasts: **until_released** (until you let go), **five_minutes**, **until_next_event**, or **until_source_offline**. After it expires, Auto-Director resumes command without needing to be re-armed.",
es: "La barra del conmutador muestra el modo actual: **MANUAL** significa que cada cambio es tuyo; **AUTO**, que el Autodirector conduce; **LOCKED**, que el programa está congelado y nada se mueve. En AUTO igual puedes tomar una fuente a mano: eso abre una ventana de **override** donde tu mano vence a las reglas.\n\nElige cuánto dura la anulación: **until_released** (hasta que la sueltes), **five_minutes**, **until_next_event** o **until_source_offline**. Al expirar, el Autodirector retoma el mando sin necesidad de reactivarlo.",
},
},
{
heading: { en: "Fallback behaviour", es: "Comportamiento de respaldo" },
body: {
en: "When the program source goes offline mid-stream, Auto-Director does not freeze on a dead frame: it falls to the next live source in priority order, and if nothing is live it goes to the designated hold state rather than black.\n\nThis is why the last entry in your priority list should be something that is always available — a logo card or a looped clip. The fallback is the difference between a glitch and a broadcast that looks like it meant to do that.",
es: "Cuando la fuente de programa se cae en plena transmisión, el Autodirector no se congela en un cuadro muerto: cae a la siguiente fuente viva según el orden de prioridad, y si no hay nada vivo pasa al estado de espera designado en vez de a negro.\n\nPor eso la última entrada de tu lista debería ser algo siempre disponible: una tarjeta con el logotipo o un clip en bucle. El respaldo es la diferencia entre una falla y una transmisión que parece planeada.",
},
},
],
},
{
slug: "chat-bridge",
title: { en: "Chat bridge", es: "Puente de chat" },
blurb: {
en: "Bring Telegram, Twitch and Discord into one overlay, moderate from one place, and let flood governors absorb raids.",
es: "Reúne Telegram, Twitch y Discord en una sola superposición, modera desde un lugar y deja que los gobernadores de flujo absorban los raids.",
},
sections: [
{
heading: { en: "What bridging means", es: "Qué significa unir los chats" },
body: {
en: "During a stream your audience is split: some on Telegram, some in Twitch chat, some in Discord. The chat bridge pulls all of them into one **Live chat** overlay, with each message labelled by its platform, so moderators read one conversation instead of three windows.\n\nBridging is read-and-reply, not teleportation: people stay on their platform, and your replies go back out to the right one. Reply targets are explicit, so you never answer a Twitch message into the wrong channel.",
es: "Durante una transmisión tu audiencia está repartida: parte en Telegram, parte en el chat de Twitch, parte en Discord. El puente de chat junta todo en una superposición de **Live chat**, con cada mensaje etiquetado por plataforma, así quien modera lee una sola conversación en vez de tres ventanas.\n\nUnir no es teletransportar: la gente se queda en su plataforma y tus respuestas salen a la correcta. Los destinos de respuesta son explícitos, así que nunca contestas un mensaje de Twitch en el canal equivocado.",
},
},
{
heading: { en: "Opening the overlay", es: "Abrir la superposición" },
body: {
en: "The **Live chat** panel opens over the streams wall or the control room, wherever the action is. Use **Filter platform** to focus — all, or a single platform — when one room is on fire and the rest can wait.\n\nIf a platform shows as read-only (the **Read-only live chat** marker), your collective has not connected write access for it: you can still see everything, you just cannot post back yet. Ask an Admin to connect the account.",
es: "El panel de **Live chat** se abre sobre el muro de streams o la sala de control, donde esté la acción. Usa **Filter platform** para concentrarte — todas, o una sola — cuando una sala se incendia y el resto puede esperar.\n\nSi una plataforma aparece como de solo lectura (la marca **Read-only live chat**), tu colectivo no conectó permiso de escritura: puedes verlo todo, pero todavía no responder. Pide a un Admin que conecte la cuenta.",
},
},
{
heading: { en: "Replying and crossposting", es: "Responder y republicar en cruz" },
body: {
en: "Pick the reply target, type in **Write a reply…** and **Send**. A single answer can go to several platforms at once when it makes sense — a correction, for example, belongs everywhere the mistake travelled.\n\n**Crosspost to…** pushes a message you choose to other connected rooms, clearly framed as coming from the collective. Use it for announcements (\"stream moving, new link in bio\"), not for arguing: crossposted arguments read as panic.",
es: "Elige el destino de la respuesta, escribe en **Write a reply…** y **Send**. Una sola respuesta puede ir a varias plataformas a la vez cuando tiene sentido: una corrección, por ejemplo, pertenece a todos lados donde viajó el error.\n\n**Crosspost to…** lleva un mensaje que elijas a otras salas conectadas, claramente enmarcado como del colectivo. Úsalo para anuncios (\"la transmisión se muda, enlace nuevo en la bio\"), no para discutir: las discusiones republicadas en cruz se leen como pánico.",
},
},
{
heading: { en: "Moderation tools", es: "Herramientas de moderación" },
body: {
en: "Moderators get per-message actions: **Delete** removes a message from the overlay, and a timeout removes its author for ten minutes (**Timeout 10 min**). Actions apply on the originating platform too, so a deleted message is deleted where it was written.\n\nIf a moderation action fails — the bot lacks rights in that room — the panel says **Moderation failed** instead of pretending it worked. Fix by giving the bot the moderation permission it needs on that platform, then retry.",
es: "Quien modera tiene acciones por mensaje: **Delete** quita un mensaje de la superposición, y un tiempo de espera expulsa a su autor por diez minutos (**Timeout 10 min**). Las acciones se aplican también en la plataforma de origen, así que un mensaje borrado queda borrado donde fue escrito.\n\nSi una acción de moderación falla — al bot le faltan permisos en esa sala — el panel dice **Moderation failed** en vez de fingir que funcionó. Se arregla dándole al bot el permiso de moderación que necesita en esa plataforma, y reintenta.",
},
},
{
heading: { en: "Flood governors and raids", es: "Gobernadores de flujo y raids" },
body: {
en: "A flood governor is a valve on how many messages per second are let through from each room. When a room is raided — hundreds of identical messages at once — the governor drops the excess before it reaches your moderators, and the overlay keeps working instead of melting.\n\nGovernors are per-room, so a flood on Twitch does not silence Telegram. Legitimate heavy traffic still gets through; only the machine-gun rate is cut. If a raid is sustained, the connection's backoff also widens, exactly like on the streams wall.",
es: "Un gobernador de flujo es una válvula sobre cuántos mensajes por segundo se dejan pasar de cada sala. Cuando un raid golpea — cientos de mensajes idénticos a la vez — el gobernador descarta el exceso antes de que llegue a quien modera, y la superposición sigue funcionando en vez de derretirse.\n\nLos gobernadores son por sala, así que un flood en Twitch no silencia a Telegram. El tráfico legítimo intenso pasa igual; solo se corta la cadencia de ametralladora. Si el raid se sostiene, la espera de la conexión también se amplía, igual que en el muro de streams.",
},
},
{
heading: { en: "Moderation culture, briefly", es: "Cultura de moderación, en breve" },
body: {
en: "Agree your moderation line before the stream, not during: what gets deleted, what gets timed out, what gets answered. Write it down where every moderator can see it — the wiki is a fine place.\n\nDelete doxx and slurs instantly, time out sealions, answer good-faith questions once and in public so a hundred people read the answer. Nobody should be moderating alone: two short shifts beat one heroic one.",
es: "Acuerden la línea de moderación antes de la transmisión, no durante: qué se borra, qué recibe tiempo de espera, qué se responde. Escríbanla donde toda la moderación pueda verla; esta wiki sirve.\n\nBorra doxx e insultos al instante, expulsa temporalmente a quien acosa con falsa buena fe, responde las preguntas honestas una vez y en público para que cien personas lean la respuesta. Nadie debería moderar solo: dos turnos cortos valen más que uno heroico.",
},
},
],
},
{
slug: "polls-and-voting",
title: { en: "Polls and voting", es: "Encuestas y votaciones" },
blurb: {
en: "Run a vote in the bridged chats, configure quorum arms, vote from Telegram or the web, and let the result post itself.",
es: "Haz una votación en los chats unidos, configura los brazos del quórum, vota desde Telegram o la web y deja que el resultado se publique solo.",
},
sections: [
{
heading: { en: "What polls are for", es: "Para qué sirven las encuestas" },
body: {
en: "A poll turns a scattered chat into a decision with a record. It is the tool for the questions that used to be settled by whoever shouted loudest: do we publish the statement, which frame leads the video, do we name the company.\n\nEvery poll has a configuration, a live tally both places can see, and a written outcome that lands in the audit log — so the decision can be revisited without relitigating what was decided.",
es: "Una encuesta convierte un chat disperso en una decisión con registro. Es la herramienta para las preguntas que antes resolvía quien gritaba más fuerte: publicamos el comunicado, qué plano abre el video, nombramos a la empresa.\n\nCada encuesta tiene una configuración, un recuento en vivo visible en ambos lados y un resultado escrito que cae en el registro de auditoría, así la decisión puede revisarse sin reabrir lo ya decidido.",
},
},
{
heading: { en: "Creating a poll", es: "Crear una encuesta" },
body: {
en: "Open the chat overlay and create a poll from the poll action. Write the question in plain language, one readable sentence, and give each option a short label — long options get truncated on Telegram.\n\nSet the **timebox**, the hard end of the vote, so it cannot drag forever, and choose whether the vote is anonymous. Anonymous is right for anything where a public position could expose a member.",
es: "Abre la superposición de chat y crea una encuesta desde la acción de encuesta. Escribe la pregunta en lenguaje claro, una frase legible, y da a cada opción una etiqueta corta: las opciones largas se cortan en Telegram.\n\nFija el **timebox**, el fin duro de la votación, para que no se arrastre eternamente, y elige si el voto es anónimo. Anónimo es lo correcto para todo lo que una posición pública pueda exponer a alguien.",
},
},
{
heading: { en: "Configuring quorum", es: "Configurar el quórum" },
body: {
en: "Quorum is how you say what a valid decision looks like. There are two kinds of arms: **x of y admins** (a role count) and **x of y participants** (whoever actually voted), and they can be joined with **AND** (both must be satisfied) or **OR** (either is enough).\n\nExample: 1 of 2 admins AND 3 of 5 participants means the vote passes only with an admin's yes and broader support — a guard against a single admin deciding alone, and against a flash mob deciding without any steward. A **veto** arm can also be enabled so one named role can stop a result outright, and **min-dwell** requires a voter to have been present for some time before their vote counts.",
es: "El quórum es cómo dices qué aspecto tiene una decisión válida. Hay dos tipos de brazos: **x de y admins** (un conteo por rol) y **x de y participantes** (quienes efectivamente votaron), y se pueden unir con **AND** (ambos deben cumplirse) o **OR** (basta uno).\n\nEjemplo: 1 de 2 admins Y 3 de 5 participantes significa que la votación pasa solo con un sí de un admin y apoyo más amplio: una guarda contra un admin decidiendo solo y contra una turba decidiendo sin ninguna mayordomía. También puede activarse un brazo de **veto** para que un rol nombrado frene el resultado de raíz, y una **permanencia mínima** que exige haber estado presente un tiempo antes de que el voto cuente.",
},
},
{
heading: { en: "Voting from Telegram or the web", es: "Votar desde Telegram o la web" },
body: {
en: "The bot posts the poll into each bridged chat with tappable options, and the same poll is on the web with buttons. Vote wherever you are: a tap on the phone in the street counts exactly the same as a click on a laptop.\n\nThe tally is shared — the bot's message updates as votes arrive, and the web shows the same numbers. Because it is one vote, not a recount, nobody can vote twice on two platforms.",
es: "El bot publica la encuesta en cada chat unido con opciones tocables, y la misma encuesta está en la web con botones. Vota donde estés: un toque en el móvil en la calle cuenta exactamente lo mismo que un clic en un portátil.\n\nEl recuento es compartido: el mensaje del bot se actualiza conforme llegan votos y la web muestra los mismos números. Como es una sola votación y no un recuento doble, nadie puede votar dos veces en dos plataformas.",
},
},
{
heading: { en: "When quorum passes", es: "Cuando se alcanza el quórum" },
body: {
en: "The moment quorum is met — inside the timebox — the poll closes and the outcome is recorded with the exact tally. If the poll is bound to a draft, the result flows straight into the approval pipeline: an approved poll can authorise the pending post, which then schedules and dispatches on its own.\n\nIf quorum is not met by the end of the timebox, the poll is recorded as not passed. That is a valid outcome, not a failure: it means the group did not decide, and the draft stays where it was.",
es: "En el momento en que se alcanza el quórum — dentro del tiempo límite — la encuesta se cierra y el resultado queda registrado con el recuento exacto. Si la encuesta está ligada a un borrador, el resultado entra directo al circuito de aprobación: una encuesta aprobada puede autorizar la publicación pendiente, que luego se programa y despacha sola.\n\nSi el quórum no se alcanza al terminar el tiempo, la encuesta queda registrada como no aprobada. Es un resultado válido, no un fracaso: significa que el grupo no decidió, y el borrador se queda donde estaba.",
},
},
{
heading: { en: "Reading the blocked queue", es: "Leer la cola de bloqueados" },
body: {
en: "Polls and approvals share the blocked queue: anything stopped shows there with the gap to quorum spelled out — which arm is missing, how many votes short, whose veto landed. That turns \"it didn't pass\" into actionable information.\n\nReopen by addressing the named gap, not by rerunning the vote louder. A poll re-run without changes is how collectives burn out their own voters.",
es: "Las encuestas y las aprobaciones comparten la cola de bloqueados: todo lo frenado aparece ahí con la brecha hasta el quórum explicada — qué brazo falta, cuántos votos faltan, de quién fue el veto. Eso convierte \"no pasó\" en información accionable.\n\nReabre atendiendo la brecha nombrada, no repitiendo la votación más fuerte. Repetir una encuesta sin cambios es como los colectivos agotan a su propia gente votante.",
},
},
],
},
{
slug: "archive",
title: { en: "Archive", es: "Archivo" },
blurb: {
en: "How content-addressed encrypted storage works, how to search it, how to export, and why deleted posts survive here.",
es: "Cómo funciona el almacenamiento cifrado por contenido, cómo buscarlo, cómo exportarlo y por qué lo borrado sobrevive aquí.",
},
sections: [
{
heading: { en: "What the archive is", es: "Qué es el archivo" },
body: {
en: "The **Archive** keeps a copy of everything that flowed through the workspace: posts published, posts received from followed sources, their media, and their receipts. Storage is content-addressed and encrypted on S3-compatible storage your collective controls.\n\n\"Content-addressed\" means each item is filed under a fingerprint of its own content rather than a chosen name. Two consequences matter to you: nothing can be silently swapped (a different file has a different address), and identical items are stored once no matter how many times they arrive.",
es: "El **Archivo** guarda una copia de todo lo que pasó por el espacio de trabajo: lo publicado, lo recibido de las fuentes seguidas, sus medios y sus acuses. El almacenamiento es por contenido, cifrado y en un S3 compatible que tu colectivo controla.\n\n\"Por contenido\" significa que cada ítem se archiva bajo la huella de su propio contenido y no bajo un nombre elegido. Dos consecuencias te importan: nada puede cambiarse en silencio (un archivo distinto tiene dirección distinta) y los ítems idénticos se guardan una sola vez, lleguen cuantas veces lleguen.",
},
},
{
heading: { en: "Why deleted platform content survives here", es: "Por qué lo borrado en las plataformas sobrevive aquí" },
body: {
en: "When a platform deletes a post — by moderation, by government request, or by \"accident\" — it deletes its copy. Your archive's copy was taken at publish time, addressed by its own fingerprint, and encrypted on your storage. The platform has no lever to reach it.\n\nThis is the archive's political job: the record of what your collective said and what was done to it cannot be edited by the party being documented. Keep the archive storage in hands your assembly trusts; that is now the primary copy of your history.",
es: "Cuando una plataforma borra una publicación — por moderación, por pedido gubernamental o por \"accidente\" — borra su copia. La copia de tu archivo se tomó al publicar, quedó identificada por su propia huella y cifrada en tu almacenamiento. La plataforma no tiene palanca para alcanzarla.\n\nEsa es la tarea política del archivo: el registro de lo que dijo tu colectivo y de lo que se le hizo no puede editarlo la parte documentada. Mantén el almacenamiento del archivo en manos que tu asamblea confíe; esa es ahora la copia primaria de tu historia.",
},
},
{
heading: { en: "Browsing and searching", es: "Navegar y buscar" },
body: {
en: "Open **Archive** to browse items newest first, or use **Search**, which runs full-text search across the archive: every word of every stored post is indexed. Search results carry their attribution — channel, author signature, forward origin — so you can tell who said it before you amplify it.\n\nSearch also reaches the same index from the streams wall, so a phrase you half-remember from a march can be found months later. Saved searches can be pinned as columns to watch a topic continuously.",
es: "Abre **Archive** para navegar los ítems más nuevos primero, o usa **Search**, que hace búsqueda de texto completo sobre el archivo: cada palabra de cada publicación guardada está indexada. Los resultados traen su atribución — canal, firma de autor, origen del reenvío — así sabes quién lo dijo antes de amplificarlo.\n\nLa búsqueda también llega al mismo índice desde el muro de streams, así que una frase que recuerdas a medias de una marcha aparece meses después. Las búsquedas guardadas pueden fijarse como columnas para vigilar un tema de continuo.",
},
},
{
heading: { en: "Turning an archived item back into a post", es: "Convertir un ítem archivado en publicación" },
body: {
en: "Archived items are not read-only tombstones. From an item you can create a draft — the text and media come along, and attribution can be kept or dropped with the toggle. The draft then enters the normal approval pipeline like any other.\n\nThis is the repost workflow that respects the process: nothing leaves the archive straight to the public; it passes the same review as fresh writing. Keep attribution on unless the source asked otherwise — naming sources is how networks stay trustworthy.",
es: "Los ítems archivados no son lápidas de solo lectura. Desde un ítem puedes crear un borrador: el texto y los medios vienen con él, y la atribución puede conservarse o quitarse con el interruptor. El borrador entra luego al circuito de aprobación normal, como cualquier otro.\n\nEste es el flujo de republicación que respeta el proceso: nada sale del archivo directo al público; pasa la misma revisión que lo recién escrito. Conserva la atribución salvo que la fuente pidiera lo contrario: nombrar las fuentes es como las redes se mantienen confiables.",
},
},
{
heading: { en: "Exporting", es: "Exportar" },
body: {
en: "The archive exports to **CSV** (open in any spreadsheet) and **JSON** (for tools and scripts). Export is how the record leaves the building: hand a copy to lawyers, journalists, or another collective that will carry it if your server is seized.\n\nDo exports on a schedule, not on the worst day. A monthly JSON export stored somewhere separate from the server is cheap insurance; the audit log's own export (see that manual) documents who exported what.",
es: "El archivo exporta a **CSV** (se abre en cualquier hoja de cálculo) y **JSON** (para herramientas y scripts). Exportar es cómo el registro sale del edificio: entrega una copia a abogacía, prensa u otro colectivo que la cargue si tu servidor es intervenido.\n\nHaz exportaciones con calendario, no el peor día. Un JSON mensual guardado en un lugar separado del servidor es un seguro barato; la exportación del propio registro de auditoría (mira ese manual) documenta quién exportó qué.",
},
},
],
},
{
slug: "video-transcoding",
title: { en: "Video upload and transcoding", es: "Subida y transcodificación de video" },
blurb: {
en: "Upload footage, watch it move through the transcode queue, and understand the jailed workers that do the heavy lifting.",
es: "Sube material, mira cómo avanza por la cola de transcodificación y entiende a los trabajadores enjaulados que hacen el esfuerzo.",
},
sections: [
{
heading: { en: "Why transcoding exists", es: "Por qué existe la transcodificación" },
body: {
en: "A phone records huge files that most platforms and most viewers' connections cannot handle. Transcoding converts your upload into the sizes and formats needed for web playback, thumbnails and live use — without you owning editing software.\n\nYou upload once; the system produces the variants. Upload the highest quality your camera gives, because every later copy is made from that master.",
es: "Un móvil graba archivos enormes que ni las plataformas ni las conexiones de la mayoría pueden manejar. La transcodificación convierte lo que subes en los tamaños y formatos necesarios para reproducir en la web, miniaturas y uso en vivo, sin que tengas software de edición.\n\nTú subes una vez; el sistema produce las variantes. Sube con la máxima calidad que dé tu cámara, porque cada copia posterior se hace desde ese máster.",
},
},
{
heading: { en: "Uploading", es: "Subir" },
body: {
en: "Open **Video** and drop your file on **Drop a video here**, or tap to choose it from the device. Large files upload in the background — you can leave the page, the upload continues and the library shows its progress.\n\nName uploads something findable (date-place-what), because in three months \"IMG_4472.mp4\" is a rumour. Once the upload lands, the item appears in the video library with a status chip showing where it is in the pipeline.",
es: "Abre **Video** y suelta tu archivo en **Drop a video here**, o tócalo para elegirlo desde el dispositivo. Los archivos grandes se suben en segundo plano: puedes salir de la página, la subida sigue y la biblioteca muestra su progreso.\n\nNombra las subidas de forma localizable (fecha-lugar-qué), porque en tres meses \"IMG_4472.mp4\" es un rumor. Al llegar la subida, el ítem aparece en la biblioteca de video con un chip de estado que indica dónde está en el circuito.",
},
},
{
heading: { en: "The pipeline and its states", es: "El circuito y sus estados" },
body: {
en: "An upload moves through states: uploaded, probing, queued, transcoding, then ready. **Probe metadata** in the detail drawer shows what was found in the file — duration, resolution, codecs — which is how you check a suspicious clip before using it.\n\nIf a job fails, the item says so with the reason; fix what it names (usually a corrupt file or an unsupported codec) and re-upload. Failed jobs never block the rest of the queue.",
es: "Una subida pasa por estados: subida, sondeo, en cola, transcodificando y lista. **Probe metadata** en el cajón de detalles muestra qué se encontró en el archivo — duración, resolución, códecs — y así revisas un clip sospechoso antes de usarlo.\n\nSi un trabajo falla, el ítem lo dice con el motivo; corrige lo que señale (casi siempre un archivo corrupto o un códec no soportado) y vuelve a subirlo. Los trabajos fallidos nunca bloquean el resto de la cola.",
},
},
{
heading: { en: "Distributed workers", es: "Trabajadores distribuidos" },
body: {
en: "Transcoding runs on worker machines — literally the GPUs and laptops of collective members — linked over Tailscale, a private network that makes them reachable without exposing anything to the public internet. Jobs are handed to whichever worker is free, and a capacity queue holds the excess until a worker can take it.\n\nSo a long queue does not mean broken; it means busy. If your collective needs faster turns, the fix is another machine running the worker, not faster clicking.",
es: "La transcodificación corre en máquinas trabajadoras — literalmente las GPU y portátiles de gente del colectivo — enlazadas por Tailscale, una red privada que las hace alcanzables sin exponer nada al internet público. Los trabajos se entregan a la máquina libre, y una cola de capacidad retiene el exceso hasta que alguna pueda tomarlo.\n\nUna cola larga no significa roto: significa ocupado. Si tu colectivo necesita respuestas más rápidas, la solución es otra máquina corriendo el trabajador, no clickear más rápido.",
},
},
{
heading: { en: "Hardware encoding", es: "Codificación por hardware" },
body: {
en: "Workers use NVENC, the encoder chip built into NVIDIA cards, when one is present. Hardware encoding turns minutes of CPU grinding into seconds — a graphics card from the RTX 3090 class has been proven end to end on this pipeline.\n\nThis is why the collective's gamer machine is genuinely useful infrastructure: plugged in as a worker, it clears the video queue while everyone sleeps. Software encoding still happens on machines without a card, just slower.",
es: "Las máquinas usan NVENC, el chip codificador integrado en las tarjetas NVIDIA, cuando hay una presente. La codificación por hardware convierte minutos de moliente CPU en segundos; una tarjeta de la clase de la RTX 3090 está probada de punta a punta en este circuito.\n\nPor eso la máquina gamer del colectivo es infraestructura de verdad: enchufada como trabajadora, despeja la cola de video mientras todos duermen. En máquinas sin tarjeta se codifica por software igual, solo más lento.",
},
},
{
heading: { en: "The sandbox: why untrusted video is safe", es: "El sandbox: por qué el video no confiable es seguro" },
body: {
en: "Video files are untrusted input: malformed files are a classic attack vector. Every FFmpeg job therefore runs in a sandbox — its own user ID, a seccomp filter (a list of system calls it may make), and no network access at all. Each job gets a fresh jail, and resource caps stop one job from starving the machine.\n\nFor you this means: upload footage from anyone, including hostile strangers, without the upload being able to touch the worker machine. Security here is structural, not a matter of trusting people.",
es: "Los archivos de video son material no confiable: los archivos malformados son un vector de ataque clásico. Por eso cada trabajo de FFmpeg corre en un sandbox: su propio UID, un filtro seccomp (una lista de llamadas al sistema permitidas) y ninguna clase de acceso a red. Cada trabajo recibe una jaula nueva, y los topes de recursos impiden que un trabajo hambre la máquina.\n\nPara ti esto significa: sube material de cualquiera, incluidos desconocidos hostiles, sin que la subida pueda tocar la máquina trabajadora. La seguridad aquí es estructural, no cuestión de confiar en personas.",
},
},
],
},
{
slug: "analytics",
title: { en: "Analytics", es: "Analítica" },
blurb: {
en: "Read the monthly snapshots, understand what the deltas mean, and break numbers down per provider.",
es: "Lee las instantáneas mensuales, entiende qué significan las diferencias y desglosa los números por proveedor.",
},
sections: [
{
heading: { en: "What analytics counts — and refuses to count", es: "Qué cuenta la analítica — y qué se niega a contar" },
body: {
en: "The **Analytics** page shows what your collective did: drafts written, publications sent, attempts per platform, failures, channels, archived items. It counts your outgoing work and the health of your accounts.\n\nWhat it does not do is track your audience like a corporation would. There is no profiling of individuals, no follower demographics, no \"engagement\" panopticon. Reach numbers belong to the platforms; your numbers describe your own machinery.",
es: "La página de **Analytics** muestra lo que hizo tu colectivo: borradores escritos, publicaciones enviadas, intentos por plataforma, fallos, canales, ítems archivados. Cuenta tu trabajo de salida y la salud de tus cuentas.\n\nLo que no hace es rastrear a tu audiencia como lo haría una corporación. No hay perfilado de personas, ni demografía de seguidores, ni panóptico de \"interacción\". Los números de alcance son de las plataformas; los tuyos describen tu propia maquinaria.",
},
},
{
heading: { en: "Monthly snapshots", es: "Instantáneas mensuales" },
body: {
en: "Numbers are stored as **Month** snapshots: one frozen picture per month, so a month never changes after it closes. The page shows the current month and the previous month side by side under **Snapshot history**.\n\nBecause each snapshot is fixed, the history is trustworthy — you cannot accidentally rewrite last month by running a report again. New activity lands in the current month's snapshot only.",
es: "Los números se guardan como instantáneas por **Month**: una foto congelada por mes, así un mes cerrado nunca cambia. La página muestra el mes en curso y el anterior lado a lado, bajo **Snapshot history**.\n\nComo cada instantánea queda fija, el historial es confiable: no puedes reescribir el mes pasado por accidente volviendo a correr un informe. La actividad nueva cae solo en la instantánea del mes en curso.",
},
},
{
heading: { en: "Reading the deltas", es: "Leer las diferencias" },
body: {
en: "A delta is the difference between this month and the one before, shown next to each figure. A delta answers \"did we do more or less than last month\" — publications up, failures down is the healthy shape.\n\nRead deltas with the calendar, not alone: a month with a big mobilisation should show more of everything; a quiet month does not mean the collective is dying. A rising **Failures** line while **Attempts** stays flat is the one pattern that deserves immediate attention — that is accounts degrading, not effort dropping.",
es: "La diferencia (delta) es el cambio entre este mes y el anterior, mostrada junto a cada cifra. Responde a \"hicimos más o menos que el mes pasado\": publicaciones arriba y fallos abajo es la forma sana.\n\nLee las diferencias junto al calendario, no solas: un mes con movilización grande debería mostrar más de todo; un mes tranquilo no significa que el colectivo se muere. Una línea de **Failures** que sube mientras **Attempts** se mantiene plana es el único patrón que merece atención inmediata: eso son cuentas degradándose, no esfuerzo cayendo.",
},
},
{
heading: { en: "Per-provider breakdown", es: "Desglose por proveedor" },
body: {
en: "The per-provider breakdown splits the same figures by platform: **Attempts** and **Failures** per provider, plus **Channels** and **Archived** counts. This is where you see which platform is quietly eating your posts.\n\nUse it for account strategy: a provider with persistent failures gets the fan-out reduced before it burns the account entirely, and the healthiest providers take the volume. Combined with the Connections page you can match failures to specific accounts and reconnect only what is actually broken.",
es: "El desglose por proveedor separa las mismas cifras por plataforma: **Attempts** y **Failures** por proveedor, más conteos de **Channels** y **Archived**. Aquí ves qué plataforma se está comiendo tus publicaciones en silencio.\n\nÚsalo para la estrategia de cuentas: a un proveedor con fallos persistentes se le reduce la difusión antes de que queme la cuenta del todo, y los más sanos cargan el volumen. Junto con la página de Connections puedes emparejar fallos con cuentas concretas y reconectar solo lo que de verdad está roto.",
},
},
],
},
{
slug: "team-management",
title: { en: "Team management", es: "Gestión del equipo" },
blurb: {
en: "Invite members, understand each role, control invite links, and remove access when someone leaves.",
es: "Invita miembros, entiende cada rol, controla los enlaces de invitación y quita el acceso cuando alguien se va.",
},
sections: [
{
heading: { en: "Where team lives", es: "Dónde está el equipo" },
body: {
en: "Open **Team** (Admins and Owners only). The page lists every member with their **Role** and **Status**, plus the active **Invite links** and their counters.\n\nOnly roles you are allowed to grant appear as choices; an Admin who cannot mint Owners simply will not see that option, which prevents accidental privilege escalation better than a warning dialog.",
es: "Abre **Team** (solo Admin y Owner). La página lista a cada miembro con su **Role** y **Status**, además de los **Invite links** activos y sus contadores.\n\nSolo aparecen como opciones los roles que puedes conceder; un Admin que no puede crear Owners simplemente no ve esa opción, lo que evita escaladas de privilegios accidentales mejor que un aviso emergente.",
},
},
{
heading: { en: "Inviting a member by email", es: "Invitar a una persona por correo" },
body: {
en: "Enter their address (teammate@studio.io), pick a role, and press **Send invite**. They receive a link that creates their account inside your workspace with the role you chose.\n\nBefore you send, ask yourself whether this person needs write access or just needs to see. Viewer is the safest role that still lets someone follow the work; you can always raise it later, and raising is easier than recovering from a leaked Editor password.",
es: "Escribe su correo (compañera@studio.io), elige un rol y pulsa **Send invite**. Recibirán un enlace que crea su cuenta dentro de tu espacio de trabajo con el rol que elegiste.\n\nAntes de enviar, pregúntate si esa persona necesita escribir o solo ver. Viewer es el rol más seguro que aún permite seguir el trabajo; siempre puedes subirlo después, y subir es más fácil que recuperarse de una contraseña de Editor filtrada.",
},
},
{
heading: { en: "Invite links, and why they expire", es: "Enlaces de invitación y por qué caducan" },
body: {
en: "Press **Create link** to mint a shareable invite instead of an email: set **Max uses (optional)** and an expiry under **Expires**, then hand the link over Signal or in person. The **Uses** counter shows how many people have joined through it.\n\nLinks exist because actions happen fast, but every live link is a door. **Revoke** any link the moment the people you expected have joined, and let the rest expire on their own — a link nobody can revoke is a liability, not a convenience.",
es: "Pulsa **Create link** para crear una invitación compartible en vez de un correo: fija **Max uses (opcional)** y una caducidad en **Expires**, y pasa el enlace por Signal o en persona. El contador de **Uses** muestra cuánta gente entró por él.\n\nLos enlaces existen porque las acciones son rápidas, pero cada enlace vivo es una puerta. **Revoke** cualquier enlace apenas entre la gente que esperabas, y deja que el resto caduque solo: un enlace que nadie puede revocar es un pasivo, no una comodidad.",
},
},
{
heading: { en: "Choosing the right role", es: "Elegir el rol correcto" },
body: {
en: "Grant the smallest role that lets the person do their job:\n\n- **Viewer** — follows streams, calendar, archive; changes nothing. Right for most of the collective.\n- **Moderator** — runs the bridged chats during streams.\n- **Editor** — writes drafts and requests approval.\n- **Approver** — votes and blocks in review.\n- **Publisher** — dispatches approved posts and owns the schedule.\n- **BroadcastOperator** — runs the control room and destinations.\n- **Admin** — members, connections, settings.\n- **Owner** — everything, including transfer and deletion. Keep Owners to the people the assembly would trust with the keys to the space.\n\nThe strongest protection is structural: whoever writes should not be the sole approver, and whoever dispatches should not be the only auditor.",
es: "Concede el rol más pequeño que le permita hacer su tarea:\n\n- **Viewer** — sigue streams, calendario y archivo; no cambia nada. Para la mayoría del colectivo.\n- **Moderator** — lleva los chats unidos durante las transmisiones.\n- **Editor** — escribe borradores y pide aprobación.\n- **Approver** — vota y bloquea en revisión.\n- **Publisher** — despacha lo aprobado y cuida la programación.\n- **BroadcastOperator** — maneja la sala de control y los destinos.\n- **Admin** — miembros, conexiones, ajustes.\n- **Owner** — todo, incluida la transferencia y el borrado. Reserva Owner para quien la asamblea dejaría con las llaves del local.\n\nLa protección más fuerte es estructural: quien escribe no debería ser el único que aprueba, y quien despacha no debería ser el único que audita.",
},
},
{
heading: { en: "Changing or removing access", es: "Cambiar o quitar el acceso" },
body: {
en: "Open the member row to change their role, or press remove and confirm with **Confirm remove**. Removal takes effect immediately: their session stops working and their invite is spent. Every one of these actions is written to the audit log with your name on it.\n\nRemove access the day someone leaves the collective, not the week after. Parting on good terms is compatible with locking the door — most leaks are old accounts nobody bothered to close.",
es: "Abre la fila de la persona para cambiar su rol, o pulsa quitar y confirma con **Confirm remove**. El retiro surte efecto de inmediato: su sesión deja de funcionar y su invitación queda gastada. Cada una de estas acciones queda escrita en el registro de auditoría con tu nombre.\n\nQuita el acceso el día que alguien se va del colectivo, no la semana siguiente. Despedirse bien es compatible con cerrar la puerta: la mayoría de las filtraciones son cuentas viejas que nadie se tomó el trabajo de cerrar.",
},
},
],
},
{
slug: "audit-log",
title: { en: "Audit log", es: "Registro de auditoría" },
blurb: {
en: "What gets recorded, how the hash chain makes the log tamper-evident, and how to export it for safekeeping.",
es: "Qué se registra, cómo la cadena de hashes hace evidente cualquier manipulación y cómo exportarlo para resguardarlo.",
},
sections: [
{
heading: { en: "What the audit log is", es: "Qué es el registro de auditoría" },
body: {
en: "Open **Audit** to see the **Audit trail**: one line per meaningful action, with **When** it happened, **Actor** (who did it), **Action** (what) and **Target** (to what). Publishes, approvals, blocks, role changes, connections, exports, removals — all of it.\n\nThe log answers the questions that come up when things go wrong: who approved that post, who removed that account, who exported the archive last month. It is the collective's memory of its own decisions.",
es: "Abre **Audit** para ver el **Audit trail**: una línea por acción relevante, con **When** (cuándo), **Actor** (quién), **Action** (qué) y **Target** (sobre qué). Publicaciones, aprobaciones, bloqueos, cambios de rol, conexiones, exportaciones, retiros: todo.\n\nEl registro responde las preguntas que aparecen cuando algo sale mal: quién aprobó esa publicación, quién quitó esa cuenta, quién exportó el archivo el mes pasado. Es la memoria del colectivo sobre sus propias decisiones.",
},
},
{
heading: { en: "Filtering the trail", es: "Filtrar el rastro" },
body: {
en: "Use the filters at the top: **All actions** / **All actors** narrow the list to one kind of event or one person. Filtering by actor plus action is how you reconstruct a timeline — everything a given person did around a specific incident.\n\nThe trail is long by design; the filters are how you read it. Do not scroll a year of entries when \"approvals by this member, this week\" is two clicks away.",
es: "Usa los filtros de arriba: **All actions** / **All actors** acotan la lista a un tipo de evento o a una persona. Filtrar por actor más acción es como reconstruyes una línea de tiempo: todo lo que hizo una persona alrededor de un incidente concreto.\n\nEl rastro es largo a propósito; los filtros son cómo se lee. No desplaces un año de entradas cuando \"aprobaciones de esta persona, esta semana\" está a dos clics.",
},
},
{
heading: { en: "The hash chain: tampering that shows", es: "La cadena de hashes: manipular se nota" },
body: {
en: "Every entry carries a cryptographic hash computed from its own content plus the hash of the entry before it — a chain. Change any entry after the fact and its hash no longer matches the next entry's link, so the break is visible.\n\nIn practice: nobody, including an Owner, can quietly rewrite who approved what. The log can be lost if storage is destroyed, but it cannot be edited in place without the damage being detectable. That asymmetry is what makes the log evidence rather than a diary.",
es: "Cada entrada lleva un hash criptográfico calculado a partir de su propio contenido más el hash de la entrada anterior: una cadena. Cambia una entrada después de hecho y su hash deja de coincidir con el enlace de la siguiente, así que la rotura se ve.\n\nEn la práctica: nadie, incluido un Owner, puede reescribir en silencio quién aprobó qué. El registro puede perderse si se destruye el almacenamiento, pero no puede editarse sin que el daño sea detectable. Esa asimetría es lo que convierte el registro en evidencia y no en diario.",
},
},
{
heading: { en: "Exporting for safekeeping", es: "Exportar para resguardar" },
body: {
en: "Export the trail to CSV from the audit page, and store the copy somewhere other than the server. A log that lives only on the machine that could be seized protects nobody.\n\nA healthy rhythm: export after any contested decision, and at least monthly. The export action is itself audited, so the record shows that a copy left — which is exactly what you want a court or a journalists' collective to be able to see.",
es: "Exporta el rastro a CSV desde la página de auditoría y guarda la copia en un lugar distinto del servidor. Un registro que vive solo en la máquina que pueden intervenir no protege a nadie.\n\nUn ritmo sano: exporta tras cada decisión discutida y al menos una vez al mes. La exportación está ella misma auditada, así que el registro muestra que salió una copia: exactamente lo que quieres que un tribunal o un colectivo de prensa pueda ver.",
},
},
],
},
{
slug: "settings-security",
title: { en: "Settings and security", es: "Ajustes y seguridad" },
blurb: {
en: "What the Settings page tells you, how sessions work, and how the encrypted vault protects every credential.",
es: "Qué te dice la página de Ajustes, cómo funcionan las sesiones y cómo la bóveda cifrada protege cada credencial.",
},
sections: [
{
heading: { en: "What Settings shows", es: "Qué muestra Ajustes" },
body: {
en: "Open **Settings** to see exactly what the server knows about your current session: username, workspace, role, permissions, and the session state. There are no fake toggles on this page — if the API has no preference for it, the app does not pretend to save one.\n\nWorkspace-level configuration (members, connections, destinations) lives in **Team** and **Connections**, not here. Settings is about you and your session, nothing else.",
es: "Abre **Settings** para ver exactamente lo que el servidor sabe de tu sesión actual: usuario, espacio de trabajo, rol, permisos y el estado de la sesión. No hay interruptores de mentira en esta página: si la API no tiene esa preferencia, la app no finge guardarla.\n\nLa configuración a nivel del espacio (miembros, conexiones, destinos) está en **Team** y **Connections**, no aquí. Ajustes trata de ti y de tu sesión, nada más.",
},
},
{
heading: { en: "Signing out and shared devices", es: "Cerrar sesión y dispositivos compartidos" },
body: {
en: "**Sign out** (from Settings or from **More**) ends the session on this device and clears the stored token. On a shared machine — a social centre laptop, a borrowed phone — sign out every single time; the two seconds are the whole defence.\n\nIf you suspect a session is still live somewhere you cannot reach, tell an Admin: access is removed on the server side by removing or re-roling the member, which kills their sessions too.",
es: "**Sign out** (desde Settings o desde **More**) termina la sesión en este dispositivo y borra el token guardado. En una máquina compartida — un portátil del centro social, un móvil prestado — cierra sesión cada vez; esos dos segundos son toda la defensa.\n\nSi sospechas que una sesión sigue viva donde no puedes llegar, avisa a un Admin: el acceso se quita del lado del servidor retirando o cambiando el rol de la persona, lo que también mata sus sesiones.",
},
},
{
heading: { en: "The encrypted vault", es: "La bóveda cifrada" },
body: {
en: "Every credential the workspace holds — platform tokens, bot tokens, stream keys — lives in an **encrypted vault**. The app stores only a handle: an opaque reference that lets the system use the credential without ever exposing the secret to a browser.\n\nYou will see this in three places: connecting an account, going live (the RTMP key is redeemed from the vault at the moment of the stream, never typed), and connections being removed (the handle is revoked). Nobody can read your Twitch key out of the UI, because the UI never receives it.",
es: "Cada credencial que guarda el espacio de trabajo — tokens de plataformas, tokens de bots, claves de transmisión — vive en una **bóveda cifrada**. La app guarda solo un identificador: una referencia opaca que permite al sistema usar la credencial sin exponer jamás el secreto a un navegador.\n\nLo verás en tres momentos: al conectar una cuenta, al salir al aire (la clave RTMP se canjea de la bóveda en el momento de la transmisión, nunca se teclea) y al quitar conexiones (el identificador se revoca). Nadie puede leer tu clave de Twitch desde la interfaz, porque la interfaz nunca la recibe.",
},
},
{
heading: { en: "Practical security habits for the collective", es: "Hábitos prácticos de seguridad para el colectivo" },
body: {
en: "Security here is mostly habits, not settings:\n\n- Long passwords, one per person; never share accounts — roles exist so you do not have to.\n- Invite links expire; revoke them once the expected people are in.\n- Remove access the day someone leaves.\n- Keep archive and audit exports somewhere other than the server.\n- Assume any platform account can be lost; the archive and the audit log are what survive.\n\nThe threat model is not exotic hackers: it is a seized laptop, a flipped member, a platform ban. All three are survivable if the credentials were compartmentalised and the records were exported.",
es: "La seguridad aquí es sobre todo hábitos, no ajustes:\n\n- Contraseñas largas, una por persona; nunca compartan cuentas: los roles existen para que no haga falta.\n- Los enlaces de invitación caducan; revócalos apenas entre la gente esperada.\n- Quita el acceso el día que alguien se va.\n- Guarda las exportaciones de archivo y auditoría en un lugar distinto del servidor.\n- Asume que cualquier cuenta de plataforma puede perderse; el archivo y el registro de auditoría son lo que sobrevive.\n\nEl modelo de amenaza no son hackers exóticos: es un portátil intervenido, un miembro que cambia de bando, un ban de plataforma. Los tres son sobrevivibles si las credenciales estaban compartimentadas y los registros exportados.",
},
},
],
},
{
slug: "intake-firehose",
title: { en: "Intake firehose", es: "Toma de fuentes" },
blurb: {
en: "Subscribe the workspace to RSS, Telegram, Mastodon and Bluesky sources; watch them flow into columns and the archive.",
es: "Suscribe el espacio de trabajo a fuentes RSS, Telegram, Mastodon y Bluesky; mira cómo fluyen a las columnas y al archivo.",
},
sections: [
{
heading: { en: "What intake is", es: "Qué es la toma de fuentes" },
body: {
en: "Intake is the workspace's subscription system: RSS or Atom feeds, Telegram channels, Mastodon accounts and Bluesky feeds you follow, so their new content flows in continuously. Subscribed content lands in two places: the streams wall as columns to read, and the archive as searchable, kept copies.\n\nThis is how a small collective runs a newsroom: you do not need people watching twenty accounts, you need the machine watching them and people reading what matters.",
es: "La toma es el sistema de suscripciones del espacio de trabajo: fuentes RSS o Atom, canales de Telegram, cuentas de Mastodon y feeds de Bluesky que sigues, para que su contenido nuevo entre de continuo. Lo suscrito llega a dos lugares: el muro de streams como columnas para leer, y el archivo como copias guardadas y buscables.\n\nAsí un colectivo pequeño maneja una redacción: no necesitas gente vigilando veinte cuentas, necesitas la máquina vigilándolas y gente leyendo lo que importa.",
},
},
{
heading: { en: "Subscribing to a source", es: "Suscribir una fuente" },
body: {
en: "Subscriptions are managed as connections: open **Connections**, press **Connect account**, choose the provider and give it what it needs — the feed URL for RSS, the @channel or numeric chat ID for Telegram, the account for Mastodon or Bluesky. Confirm with **Connect**.\n\nOnce connected, the source is available everywhere a feed can be picked: add it as a column on **Streams** with **Columns**, filter the wall to it, or search its accumulated content from **Search**. There is no separate \"intake\" page to remember — subscriptions are just accounts the workspace follows.",
es: "Las suscripciones se gestionan como conexiones: abre **Connections**, pulsa **Connect account**, elige el proveedor y dale lo que pida — la URL del feed para RSS, el @canal o ID numérico de chat para Telegram, la cuenta para Mastodon o Bluesky. Confirma con **Connect**.\n\nUna vez conectada, la fuente queda disponible dondequiera que se pueda elegir un feed: añádela como columna en **Streams** con **Columns**, filtra el muro a ella, o busca su contenido acumulado desde **Search**. No hay una página aparte de \"toma\" que recordar: las suscripciones son simplemente cuentas que el espacio de trabajo sigue.",
},
},
{
heading: { en: "From intake to column", es: "De la toma a la columna" },
body: {
en: "Each subscribed source can be added to the wall as its own column, and **All feeds** merges everything into one firehose view when a night is moving fast. Saved searches turn a phrase (a neighbourhood, a company, a hashtag) into a standing column across all subscribed sources.\n\nRemember the cooldown: each source is fetched on its own pace, so a column shows content as it is fetched, not literally live. That pace is what keeps the accounts healthy.",
es: "Cada fuente suscrita puede añadirse al muro como su propia columna, y **All feeds** funde todo en una vista de manguera cuando la noche avanza rápido. Las búsquedas guardadas convierten una frase (un barrio, una empresa, un hashtag) en una columna permanente sobre todas las fuentes suscritas.\n\nRecuerda el enfriamiento: cada fuente se consulta a su propio ritmo, así que una columna muestra el contenido a medida que se consulta, no literalmente en vivo. Ese ritmo es lo que mantiene sanas las cuentas.",
},
},
{
heading: { en: "From intake to archive", es: "De la toma al archivo" },
body: {
en: "Everything that arrives through intake is written to the archive with its attribution — channel title, author signature, forward origin — and its media. That means a channel you follow today is quotable in six months, even if the original was deleted.\n\nProtected-content sources (channels that block copying) are archived too, with the flag set; your record of what arrived is not the same as the platform's permission to redistribute it, so still pass reuse through review and keep attribution on.",
es: "Todo lo que llega por la toma se escribe en el archivo con su atribución — título del canal, firma de autor, origen del reenvío — y sus medios. Eso significa que un canal que sigues hoy es citable en seis meses, aunque el original se haya borrado.\n\nLas fuentes con contenido protegido (canales que bloquean la copia) también se archivan, con la marca activada; tu registro de lo que llegó no es lo mismo que el permiso de la plataforma para redistribuirlo, así que pasa igualmente el reúso por revisión y conserva la atribución.",
},
},
{
heading: { en: "Per-host budgets and backoff", es: "Presupuestos por host y retroceso" },
body: {
en: "Each host the intake fetches from has a **rate budget**: a ceiling on how often it may be contacted. Within one platform, each source also has its own cooldown, and when a host is slow or refuses, the system backs off progressively — the pause doubles rather than retries piling up.\n\nBudgets exist so one hungry subscription cannot burn the workspace's standing with a whole platform. If a source's column looks stale for a long while, that is usually backoff in effect: check its health on **Connections** and **Reconnect** only when the credential is the problem.",
es: "Cada host del que la toma consulta tiene un **presupuesto de tasa**: un techo sobre cuántas veces puede contactárselo. Dentro de una plataforma, cada fuente tiene además su propio enfriamiento, y cuando un host va lento o se niega, el sistema retrocede de forma progresiva: la pausa se duplica en vez de acumularse reintentos.\n\nLos presupuestos existen para que una suscripción hambrienta no queme la reputación del espacio ante toda una plataforma. Si la columna de una fuente se ve desactualizada un buen rato, suele ser el retroceso actuando: revisa su salud en **Connections** y usa **Reconnect** solo cuando el problema sea la credencial.",
},
},
{
heading: { en: "Live source monitoring", es: "Monitoreo de fuentes en vivo" },
body: {
en: "Live sources can announce themselves instead of being polled: a webhook ingest where the source calls us, verified with HMAC — a shared-secret signature on each delivery, so a forged call is rejected before it touches anything. HLS streams are checked by a probe driver that verifies a playlist is really playable.\n\nFor you this shows up as reliability: a live column that says live really is live, and when a source dies the wall says so instead of showing a frozen picture. Combine intake monitoring with the Control Room to cut between real sources as they come online.",
es: "Las fuentes en vivo pueden anunciarse en vez de ser consultadas: una entrada por webhook donde la fuente nos llama, verificada con HMAC — una firma de secreto compartido en cada entrega, así que una llamada falsificada se rechaza antes de tocar nada. Los streams HLS los revisa un controlador de sondeo que verifica que una lista de reproducción de verdad funcione.\n\nPara ti esto se traduce en confiabilidad: una columna en vivo que dice \"en vivo\" de verdad lo está, y cuando una fuente muere el muro lo dice en vez de mostrar una imagen congelada. Combina el monitoreo de la toma con la Sala de control para cortar entre fuentes reales a medida que aparecen.",
},
},
],
},
];