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