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:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
481
apps/web/src/app/(main)/analytics/page.tsx
Normal file
481
apps/web/src/app/(main)/analytics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/app/(main)/approvals/page.tsx
Normal file
5
apps/web/src/app/(main)/approvals/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import InboxPage from "../inbox/page";
|
||||
|
||||
export default function ApprovalsPage(): React.ReactElement {
|
||||
return <InboxPage defaultSegment="approvals" />;
|
||||
}
|
||||
139
apps/web/src/app/(main)/archive/page.tsx
Normal file
139
apps/web/src/app/(main)/archive/page.tsx
Normal 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'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>;
|
||||
}
|
||||
267
apps/web/src/app/(main)/audit/page.tsx
Normal file
267
apps/web/src/app/(main)/audit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
291
apps/web/src/app/(main)/calendar/page.tsx
Normal file
291
apps/web/src/app/(main)/calendar/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
324
apps/web/src/app/(main)/compose/page.tsx
Normal file
324
apps/web/src/app/(main)/compose/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
apps/web/src/app/(main)/connections/page.tsx
Normal file
236
apps/web/src/app/(main)/connections/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/web/src/app/(main)/control-room/control-room.css
Normal file
16
apps/web/src/app/(main)/control-room/control-room.css
Normal 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; }
|
||||
111
apps/web/src/app/(main)/control-room/page.tsx
Normal file
111
apps/web/src/app/(main)/control-room/page.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
5
apps/web/src/app/(main)/inbox/[conversationId]/page.tsx
Normal file
5
apps/web/src/app/(main)/inbox/[conversationId]/page.tsx
Normal 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} />;
|
||||
}
|
||||
61
apps/web/src/app/(main)/inbox/page.tsx
Normal file
61
apps/web/src/app/(main)/inbox/page.tsx
Normal 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>;
|
||||
}
|
||||
73
apps/web/src/app/(main)/layout.tsx
Normal file
73
apps/web/src/app/(main)/layout.tsx
Normal 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>;
|
||||
}
|
||||
70
apps/web/src/app/(main)/more/page.tsx
Normal file
70
apps/web/src/app/(main)/more/page.tsx
Normal 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>;
|
||||
}
|
||||
363
apps/web/src/app/(main)/onboarding/page.tsx
Normal file
363
apps/web/src/app/(main)/onboarding/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/app/(main)/search/page.tsx
Normal file
58
apps/web/src/app/(main)/search/page.tsx
Normal 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>;
|
||||
}
|
||||
109
apps/web/src/app/(main)/settings/page.tsx
Normal file
109
apps/web/src/app/(main)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
284
apps/web/src/app/(main)/streams/page.tsx
Normal file
284
apps/web/src/app/(main)/streams/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
439
apps/web/src/app/(main)/team/page.tsx
Normal file
439
apps/web/src/app/(main)/team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
apps/web/src/app/(main)/video/page.tsx
Normal file
243
apps/web/src/app/(main)/video/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
apps/web/src/app/(main)/wiki/[slug]/page.tsx
Normal file
88
apps/web/src/app/(main)/wiki/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/app/(main)/wiki/body.tsx
Normal file
52
apps/web/src/app/(main)/wiki/body.tsx
Normal 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>;
|
||||
}
|
||||
65
apps/web/src/app/(main)/wiki/lang-context.tsx
Normal file
65
apps/web/src/app/(main)/wiki/lang-context.tsx
Normal 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;
|
||||
}
|
||||
73
apps/web/src/app/(main)/wiki/layout.tsx
Normal file
73
apps/web/src/app/(main)/wiki/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/web/src/app/(main)/wiki/page.tsx
Normal file
47
apps/web/src/app/(main)/wiki/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
apps/web/src/app/globals.css
Normal file
102
apps/web/src/app/globals.css
Normal 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;
|
||||
}
|
||||
328
apps/web/src/app/join/[token]/page.tsx
Normal file
328
apps/web/src/app/join/[token]/page.tsx
Normal 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'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'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'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'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'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'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>
|
||||
);
|
||||
}
|
||||
19
apps/web/src/app/join/layout.tsx
Normal file
19
apps/web/src/app/join/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/app/layout.tsx
Normal file
25
apps/web/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/app/page.tsx
Normal file
5
apps/web/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage(): never {
|
||||
redirect("/streams");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue