feat(frontend): Ambient/WebGL console revamp + lint/type cleanup

Ground-up rebuild of the GMW frontend as an Ambient Field console:
- WebGL ambient background (Three.js shader, drifting motes, reduced-motion aware)
- Glassmorphism dark cyber theme across all 8 routes
- SSR page + client view split with SWR fallback; realtime via WebSocket
- Command palette (Cmd+K), chatbot FAB, guild/channel pickers
- Chart primitives: donut, radial-gauge, area-activity, sparkline, equalizer

Cleanup (review pass):
- Remove stray Puppeteer nav-test/nav-debug scripts
- Replace non-null assertions with guards (dashboard/moderation)
- Drop unused useGuilds fetches in messages/voice views
- Type implicit-any `let` declarations across pages
- Add a11y roles/labels to SVG charts and audio, tidy imports
This commit is contained in:
asepharyana
2026-08-15 20:03:55 +07:00
parent 3c2c1c3b15
commit 392db8eba1
47 changed files with 1178 additions and 590 deletions
@@ -1,15 +1,17 @@
"use client";
import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
import { useEffect, useState } from "react";
import { Search, Sparkles, TrendingUp, Hash } from "lucide-react";
import { useMessageSearch, useTopReactors, useChannels } from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard, Avatar, Input, Badge } from "@/components/primitives";
import { SectionHeader, EmptyState, LoadingState } from "@/components/shared";
import { renderMessageContent, getMessageChannelLabel } from "@/lib/format";
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
import { EmptyState, LoadingState, SectionHeader } from "@/components/shared";
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
import type { AiStatus } from "@/lib/types";
function aiTone(s?: AiStatus | null): "signal" | "amber" | "vermilion" | "neutral" {
function aiTone(
s?: AiStatus | null,
): "signal" | "amber" | "vermilion" | "neutral" {
if (s === "clean") return "signal";
if (s === "warn") return "amber";
if (s === "flagged" || s === "error") return "vermilion";
@@ -24,7 +26,11 @@ export function AnalysisView() {
const ambient = useAmbient();
useEffect(() => {
ambient.set(query ? "amber" : "signal", 0.3, query ? "analyzing" : "search");
ambient.set(
query ? "amber" : "signal",
0.3,
query ? "analyzing" : "search",
);
}, [query, ambient]);
return (
@@ -49,28 +55,57 @@ export function AnalysisView() {
/>
</div>
{query.trim().length > 0 && query.trim().length < 2 && (
<div className="mono mt-2 text-xs text-ink-faint">Type at least 2 characters</div>
<div className="mono mt-2 text-xs text-ink-faint">
Type at least 2 characters
</div>
)}
</GlassPanel>
<div className="grid gap-5 lg:grid-cols-5">
<GlassPanel className="lg:col-span-3">
<SectionHeader eyebrow="results" title="Matches" action={<span className="mono text-xs text-ink-faint">{(search.data ?? []).length}</span>} />
{query.trim().length >= 2 && search.isLoading && <LoadingState label="Scanning" />}
<SectionHeader
eyebrow="results"
title="Matches"
action={
<span className="mono text-xs text-ink-faint">
{(search.data ?? []).length}
</span>
}
/>
{query.trim().length >= 2 && search.isLoading && (
<LoadingState label="Scanning" />
)}
{(search.data ?? []).length === 0 ? (
<EmptyState icon={<Search className="size-7" />} title="No matches yet" description="Run a search to surface messages across the guild." />
<EmptyState
icon={<Search className="size-7" />}
title="No matches yet"
description="Run a search to surface messages across the guild."
/>
) : (
<div className="space-y-1.5">
{(search.data ?? []).map((m) => (
<div key={m.id} className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3">
<div
key={m.id}
className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3"
>
<Avatar src={m.avatar_url} name={m.username} size={32} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink">{m.username}</span>
<span className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)}</span>
{m.ai_status && <Badge tone={aiTone(m.ai_status)} className="ml-auto">{m.ai_status}</Badge>}
<span className="text-sm font-semibold text-ink">
{m.username}
</span>
<span className="mono text-[0.65rem] text-ink-faint">
{getMessageChannelLabel(m)}
</span>
{m.ai_status && (
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
{m.ai_status}
</Badge>
)}
</div>
<div className="mt-0.5 text-sm text-ink-soft">
{renderMessageContent(m.content, m.metadata) || "(embed)"}
</div>
<div className="mt-0.5 text-sm text-ink-soft">{renderMessageContent(m.content, m.metadata) || "(embed)"}</div>
</div>
</div>
))}
@@ -80,29 +115,63 @@ export function AnalysisView() {
<div className="space-y-5 lg:col-span-2">
<GlassPanel>
<SectionHeader eyebrow="culture" title={<span className="flex items-center gap-2"><TrendingUp className="size-4 text-signal" /> Top reactors</span>} />
<SectionHeader
eyebrow="culture"
title={
<span className="flex items-center gap-2">
<TrendingUp className="size-4 text-signal" /> Top reactors
</span>
}
/>
<div className="space-y-2">
{(reactors ?? []).slice(0, 6).map((r, i) => (
<div key={r.user_id} className="flex items-center gap-3 text-sm">
<div
key={r.user_id}
className="flex items-center gap-3 text-sm"
>
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<span className="flex-1 truncate text-ink">{r.username}</span>
<span className="mono text-xs text-signal">+{r.net_count}</span>
<span className="mono text-xs text-signal">
+{r.net_count}
</span>
</div>
))}
{(reactors ?? []).length === 0 && <div className="py-4 text-center text-xs text-ink-faint">No data</div>}
{(reactors ?? []).length === 0 && (
<div className="py-4 text-center text-xs text-ink-faint">
No data
</div>
)}
</div>
</GlassPanel>
<GlassPanel>
<SectionHeader eyebrow="channels" title={<span className="flex items-center gap-2"><Hash className="size-4 text-signal" /> Top channels</span>} />
<SectionHeader
eyebrow="channels"
title={
<span className="flex items-center gap-2">
<Hash className="size-4 text-signal" /> Top channels
</span>
}
/>
<div className="space-y-2">
{(channels ?? []).slice(0, 6).map((c) => (
<div key={c.channel_id} className="flex items-center gap-3 text-sm">
<span className="flex-1 truncate text-ink-soft">{c.channel_name ?? c.channel_id.slice(0, 8)}</span>
<span className="mono text-xs text-ink-faint">{c.total_messages}</span>
<div
key={c.channel_id}
className="flex items-center gap-3 text-sm"
>
<span className="flex-1 truncate text-ink-soft">
{c.channel_name ?? c.channel_id.slice(0, 8)}
</span>
<span className="mono text-xs text-ink-faint">
{c.total_messages}
</span>
</div>
))}
{(channels ?? []).length === 0 && <div className="py-4 text-center text-xs text-ink-faint">No data</div>}
{(channels ?? []).length === 0 && (
<div className="py-4 text-center text-xs text-ink-faint">
No data
</div>
)}
</div>
</GlassPanel>
</div>
@@ -4,10 +4,13 @@ import { DashboardView } from "./view";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
let stats = undefined;
let activity = undefined;
let stats: Awaited<ReturnType<typeof getDashboardStats>> | undefined;
let activity: Awaited<ReturnType<typeof getActivity>> | undefined;
try {
[stats, activity] = await Promise.all([getDashboardStats(), getActivity(14)]);
[stats, activity] = await Promise.all([
getDashboardStats(),
getActivity(14),
]);
} catch {
// Backend unavailable — client hooks will surface the error state.
}
@@ -1,6 +1,5 @@
"use client";
import { useEffect } from "react";
import {
Activity,
Flag,
@@ -10,22 +9,18 @@ import {
ShieldAlert,
Users,
} from "lucide-react";
import { useEffect } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { AreaActivity, RadialGauge } from "@/components/charts";
import { GlassPanel } from "@/components/primitives";
import { ErrorState, LoadingState } from "@/components/shared";
import { MetricTile, SectionHeader } from "@/components/shared/section";
import {
useActivity,
useStats,
useTopReactors,
useTopReactions,
useTopReactors,
} from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard } from "@/components/primitives";
import {
AreaActivity,
Donut,
RadialGauge,
Sparkline,
} from "@/components/charts";
import { MetricTile, SectionHeader } from "@/components/shared/section";
import { ErrorState, LoadingState } from "@/components/shared";
import { formatNumber } from "@/lib/format";
import type { DashboardStats } from "@/lib/types";
@@ -33,8 +28,10 @@ function deriveSignal(stats?: DashboardStats) {
if (!stats) return { tone: "signal" as const, label: "nominal" };
const total = stats.total_flagged + stats.total_clean || 1;
const ratio = stats.total_flagged / total;
if (stats.moderation_overview.error > 0) return { tone: "vermilion" as const, label: "moderation fault" };
if (ratio > 0.25) return { tone: "vermilion" as const, label: "elevated flags" };
if (stats.moderation_overview.error > 0)
return { tone: "vermilion" as const, label: "moderation fault" };
if (ratio > 0.25)
return { tone: "vermilion" as const, label: "elevated flags" };
if (ratio > 0.1) return { tone: "amber" as const, label: "watch" };
return { tone: "signal" as const, label: "nominal" };
}
@@ -54,156 +51,243 @@ export function DashboardView({
useEffect(() => {
const s = deriveSignal(stats);
ambient.set(s.tone, 0.3 + Math.min(0.5, (stats?.today_flagged ?? 0) / 50), s.label);
ambient.set(
s.tone,
0.3 + Math.min(0.5, (stats?.today_flagged ?? 0) / 50),
s.label,
);
}, [stats, ambient]);
if (error && !stats) return <ErrorState error={error} />;
if (!stats && isLoading) return <LoadingState label="Reading grid" />;
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
const s = stats!;
const s = stats;
const total = s.total_flagged + s.total_clean || 1;
const cleanRatio = s.total_clean / total;
return (
<div className="space-y-5">
{/* Hero */}
<GlassPanel glow className="relative overflow-hidden">
<div className="scan-line absolute inset-x-0 top-0" />
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="eyebrow mb-2">GMW · Operations Grid</div>
<h2 className="display text-[2.6rem] leading-none text-ink glow-signal">
Ambient Field
</h2>
<p className="mt-2 max-w-md text-sm text-ink-soft">
Real-time moderation, voice & media presence across the monitored
guild. {formatNumber(s.total_messages)} messages captured.
</p>
</div>
<div className="flex items-center gap-2 text-ink-soft">
<Radio className="size-4 text-signal animate-breathe" />
<span className="mono text-xs uppercase tracking-wider">
{deriveSignal(s).label}
</span>
</div>
{/* Hero */}
<GlassPanel glow className="relative overflow-hidden">
<div className="scan-line absolute inset-x-0 top-0" />
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="eyebrow mb-2">GMW · Operations Grid</div>
<h2 className="display text-[2.6rem] leading-none text-ink glow-signal">
Ambient Field
</h2>
<p className="mt-2 max-w-md text-sm text-ink-soft">
Real-time moderation, voice & media presence across the monitored
guild. {formatNumber(s.total_messages)} messages captured.
</p>
</div>
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile label="Messages" value={formatNumber(s.total_messages)} tone="signal" icon={<MessageSquare className="size-3.5" />} />
<MetricTile label="Flagged" value={formatNumber(s.total_flagged)} tone={s.total_flagged > 0 ? "vermilion" : "neutral"} hint={`${s.today_flagged} today`} />
<MetricTile label="Active 24h" value={formatNumber(s.active_users_24h)} tone="signal" icon={<Users className="size-3.5" />} />
<MetricTile label="Voice clips" value={formatNumber(s.total_voice_recordings)} icon={<Mic className="size-3.5" />} />
<div className="flex items-center gap-2 text-ink-soft">
<Radio className="size-4 text-signal animate-breathe" />
<span className="mono text-xs uppercase tracking-wider">
{deriveSignal(s).label}
</span>
</div>
</GlassPanel>
</div>
{/* Activity */}
<GlassPanel>
<SectionHeader
eyebrow="14-day signal"
title={
<span className="flex items-center gap-2">
<Activity className="size-4 text-signal" /> Activity & moderation
</span>
}
action={
<div className="flex items-center gap-3 text-xs text-ink-soft">
<span className="flex items-center gap-1.5"><span className="size-2 rounded-full bg-signal" /> messages</span>
<span className="flex items-center gap-1.5"><span className="size-2 rounded-full bg-vermilion" /> flagged</span>
</div>
}
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile
label="Messages"
value={formatNumber(s.total_messages)}
tone="signal"
icon={<MessageSquare className="size-3.5" />}
/>
{activity ? (
<AreaActivity daily={activity.daily} />
) : (
<LoadingState label="streaming" />
)}
<MetricTile
label="Flagged"
value={formatNumber(s.total_flagged)}
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
hint={`${s.today_flagged} today`}
/>
<MetricTile
label="Active 24h"
value={formatNumber(s.active_users_24h)}
tone="signal"
icon={<Users className="size-3.5" />}
/>
<MetricTile
label="Voice clips"
value={formatNumber(s.total_voice_recordings)}
icon={<Mic className="size-3.5" />}
/>
</div>
</GlassPanel>
{/* Activity */}
<GlassPanel>
<SectionHeader
eyebrow="14-day signal"
title={
<span className="flex items-center gap-2">
<Activity className="size-4 text-signal" /> Activity & moderation
</span>
}
action={
<div className="flex items-center gap-3 text-xs text-ink-soft">
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full bg-signal" /> messages
</span>
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full bg-vermilion" /> flagged
</span>
</div>
}
/>
{activity ? (
<AreaActivity daily={activity.daily} />
) : (
<LoadingState label="streaming" />
)}
</GlassPanel>
{/* Two-column: channels + moderation */}
<div className="grid gap-5 lg:grid-cols-5">
<GlassPanel className="lg:col-span-3">
<SectionHeader eyebrow="throughput" title="Top channels" />
<div className="space-y-2.5">
{s.top_channels.slice(0, 7).map((c) => {
const pct =
(c.message_count / (s.top_channels[0]?.message_count || 1)) *
100;
return (
<div key={c.channel_id} className="flex items-center gap-3">
<span className="w-40 truncate text-sm text-ink-soft">
{c.channel_name ?? c.channel_id.slice(0, 8)}
</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8">
<div
className="h-full rounded-full bg-signal/70"
style={{ width: `${pct}%` }}
/>
</div>
<span className="mono w-14 text-right text-xs text-ink-faint">
{formatNumber(c.message_count)}
</span>
</div>
);
})}
</div>
</GlassPanel>
{/* Two-column: channels + moderation */}
<div className="grid gap-5 lg:grid-cols-5">
<GlassPanel className="lg:col-span-3">
<SectionHeader eyebrow="throughput" title="Top channels" />
<div className="space-y-2.5">
{s.top_channels.slice(0, 7).map((c) => {
const pct = (c.message_count / (s.top_channels[0]?.message_count || 1)) * 100;
return (
<div key={c.channel_id} className="flex items-center gap-3">
<span className="w-40 truncate text-sm text-ink-soft">{c.channel_name ?? c.channel_id.slice(0, 8)}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8">
<div className="h-full rounded-full bg-signal/70" style={{ width: `${pct}%` }} />
</div>
<span className="mono w-14 text-right text-xs text-ink-faint">{formatNumber(c.message_count)}</span>
</div>
);
})}
</div>
</GlassPanel>
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="trust" title="Moderation" />
<div className="flex items-center gap-5">
<RadialGauge
value={cleanRatio}
tone={cleanRatio > 0.8 ? "signal" : cleanRatio > 0.6 ? "amber" : "vermilion"}
label={`${Math.round(cleanRatio * 100)}%`}
sublabel="clean"
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="trust" title="Moderation" />
<div className="flex items-center gap-5">
<RadialGauge
value={cleanRatio}
tone={
cleanRatio > 0.8
? "signal"
: cleanRatio > 0.6
? "amber"
: "vermilion"
}
label={`${Math.round(cleanRatio * 100)}%`}
sublabel="clean"
/>
<div className="flex-1 space-y-2 text-sm">
<Row
icon={<ShieldAlert className="size-4 text-signal" />}
label="Clean"
value={formatNumber(s.total_clean)}
/>
<Row
icon={<Flag className="size-4 text-vermilion" />}
label="Flagged"
value={formatNumber(s.total_flagged)}
/>
<Row
icon={<Activity className="size-4 text-amber" />}
label="Warned"
value={formatNumber(s.total_warned)}
/>
<div className="flex-1 space-y-2 text-sm">
<Row icon={<ShieldAlert className="size-4 text-signal" />} label="Clean" value={formatNumber(s.total_clean)} />
<Row icon={<Flag className="size-4 text-vermilion" />} label="Flagged" value={formatNumber(s.total_flagged)} />
<Row icon={<Activity className="size-4 text-amber" />} label="Warned" value={formatNumber(s.total_warned)} />
</div>
</div>
<div className="mt-4 flex items-center justify-around border-t border-hairline pt-3 text-center">
<Mini label="pending" value={s.moderation_overview.pending} tone="amber" />
<Mini label="processing" value={s.moderation_overview.processing} tone="signal" />
<Mini label="errors" value={s.moderation_overview.error} tone="vermilion" />
</div>
</GlassPanel>
</div>
{/* Reactors + reactions */}
<div className="grid gap-5 lg:grid-cols-2">
<GlassPanel>
<SectionHeader eyebrow="engagement" title="Top reactors" />
<div className="space-y-2">
{(reactors ?? []).slice(0, 6).map((r, i) => (
<div key={r.user_id} className="flex items-center gap-3">
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<span className="flex-1 truncate text-sm text-ink">{r.username}</span>
<span className="mono text-xs text-signal">+{formatNumber(r.net_count)}</span>
</div>
))}
{(reactors ?? []).length === 0 && <EmptyHint />}
</div>
</GlassPanel>
<GlassPanel>
<SectionHeader eyebrow="culture" title="Top reactions" />
<div className="space-y-3">
{(reactions ?? []).slice(0, 5).map((m) => (
<div key={m.message_id} className="flex items-start gap-3">
<div className="flex flex-wrap gap-1 pt-0.5">
{m.top_emojis.slice(0, 3).map((e, i) => (
<span key={i} className="text-lg leading-none">{e.emoji}</span>
))}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink">{m.content || "(no text)"}</div>
<div className="mono text-[0.65rem] text-ink-faint">{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}</div>
</div>
<span className="mono text-xs text-ink-soft">{m.reaction_count}</span>
</div>
))}
{(reactions ?? []).length === 0 && <EmptyHint />}
</div>
</GlassPanel>
</div>
</div>
<div className="mt-4 flex items-center justify-around border-t border-hairline pt-3 text-center">
<Mini
label="pending"
value={s.moderation_overview.pending}
tone="amber"
/>
<Mini
label="processing"
value={s.moderation_overview.processing}
tone="signal"
/>
<Mini
label="errors"
value={s.moderation_overview.error}
tone="vermilion"
/>
</div>
</GlassPanel>
</div>
{/* Reactors + reactions */}
<div className="grid gap-5 lg:grid-cols-2">
<GlassPanel>
<SectionHeader eyebrow="engagement" title="Top reactors" />
<div className="space-y-2">
{(reactors ?? []).slice(0, 6).map((r, i) => (
<div key={r.user_id} className="flex items-center gap-3">
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<span className="flex-1 truncate text-sm text-ink">
{r.username}
</span>
<span className="mono text-xs text-signal">
+{formatNumber(r.net_count)}
</span>
</div>
))}
{(reactors ?? []).length === 0 && <EmptyHint />}
</div>
</GlassPanel>
<GlassPanel>
<SectionHeader eyebrow="culture" title="Top reactions" />
<div className="space-y-3">
{(reactions ?? []).slice(0, 5).map((m) => (
<div key={m.message_id} className="flex items-start gap-3">
<div className="flex flex-wrap gap-1 pt-0.5">
{m.top_emojis.slice(0, 3).map((e, i) => (
<span key={`${m.message_id}-${i}`} className="text-lg leading-none">
{e.emoji}
</span>
))}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink">
{m.content || "(no text)"}
</div>
<div className="mono text-[0.65rem] text-ink-faint">
{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}
</div>
</div>
<span className="mono text-xs text-ink-soft">
{m.reaction_count}
</span>
</div>
))}
{(reactions ?? []).length === 0 && <EmptyHint />}
</div>
</GlassPanel>
</div>
</div>
);
}
function Row({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
function Row({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-center gap-2.5">
{icon}
@@ -213,8 +297,21 @@ function Row({ icon, label, value }: { icon: React.ReactNode; label: string; val
);
}
function Mini({ label, value, tone }: { label: string; value: number; tone: "signal" | "amber" | "vermilion" }) {
const color = tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal";
function Mini({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "signal" | "amber" | "vermilion";
}) {
const color =
tone === "vermilion"
? "text-vermilion"
: tone === "amber"
? "text-amber"
: "text-signal";
return (
<div>
<div className={`display text-xl ${color}`}>{value}</div>
@@ -224,5 +321,9 @@ function Mini({ label, value, tone }: { label: string; value: number; tone: "sig
}
function EmptyHint() {
return <div className="py-6 text-center text-xs text-ink-faint">Awaiting data</div>;
return (
<div className="py-6 text-center text-xs text-ink-faint">
Awaiting data
</div>
);
}
@@ -1,8 +1,8 @@
import { AmbientProvider } from "@/components/ambient/ambient-context";
import { WsProvider } from "@/lib/ws/context";
import { AppFrame } from "@/components/shell";
import { Chatbot } from "@/components/chatbot/chatbot";
import { CommandPalette } from "@/components/command/command-palette";
import { AppFrame } from "@/components/shell";
import { WsProvider } from "@/lib/ws/context";
export default function DashboardLayout({
children,
@@ -4,7 +4,7 @@ import { MediaView } from "./view";
export const dynamic = "force-dynamic";
export default async function MediaPage() {
let status = undefined;
let status: import("@/lib/types").MediaState | undefined;
try {
status = await getMediaStatus();
} catch {
@@ -1,29 +1,27 @@
"use client";
import { useEffect, useState } from "react";
import {
ListMusic,
Pause,
Play,
Radio,
Repeat,
SkipForward,
Square,
Radio,
} from "lucide-react";
import { useWebSocket } from "@/lib/ws/context";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { Button, GlassPanel, Input, toast } from "@/components/primitives";
import { ErrorState, LoadingState, SectionHeader } from "@/components/shared";
import {
useMediaState,
useMediaLoop,
useMediaQueue,
useMediaSkip,
useMediaState,
useMediaStop,
useMediaLoop,
useMediaWsSync,
} from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard, Button, Input } from "@/components/primitives";
import { SectionHeader, ErrorState, LoadingState } from "@/components/shared";
import { toast } from "@/components/primitives";
import type { MediaState } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const ws = useWebSocket();
@@ -43,7 +41,11 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
useEffect(() => {
ambient.set(tone, playing ? 0.5 : 0.25, playing ? "now playing" : "media idle");
ambient.set(
tone,
playing ? 0.5 : 0.25,
playing ? "now playing" : "media idle",
);
}, [tone, playing, ambient]);
const onPlay = async () => {
@@ -57,7 +59,11 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
setUrl("");
toast({ title: "Queued", tone: "signal" });
} catch (e) {
toast({ title: "Queue failed", description: String(e), tone: "vermilion" });
toast({
title: "Queue failed",
description: String(e),
tone: "vermilion",
});
}
};
@@ -83,16 +89,33 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
{current?.title ?? "Nothing queued"}
</h2>
{current?.source && (
<div className="mono mt-1 truncate text-xs text-ink-faint">{current.source}</div>
<div className="mono mt-1 truncate text-xs text-ink-faint">
{current.source}
</div>
)}
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" onClick={onPlay} disabled={queue.isPending}>
<Button
variant="primary"
size="sm"
onClick={onPlay}
disabled={queue.isPending}
>
<Play className="size-4" /> Queue & play
</Button>
<Button variant="outline" size="sm" onClick={() => skip.mutate()} disabled={skip.isPending}>
<Button
variant="outline"
size="sm"
onClick={() => skip.mutate()}
disabled={skip.isPending}
>
<SkipForward className="size-4" /> Skip
</Button>
<Button variant="outline" size="sm" onClick={() => stop.mutate()} disabled={stop.isPending}>
<Button
variant="outline"
size="sm"
onClick={() => stop.mutate()}
disabled={stop.isPending}
>
<Square className="size-4" /> Stop
</Button>
<Button
@@ -121,22 +144,33 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
<SectionHeader
eyebrow="up next"
title="Queue"
action={<span className="mono text-xs text-ink-faint">{queueList.length} tracks</span>}
action={
<span className="mono text-xs text-ink-faint">
{queueList.length} tracks
</span>
}
/>
{queueList.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10 text-center">
<Radio className="size-6 text-ink-faint" />
<div className="text-sm text-ink-soft">Queue is empty</div>
<div className="text-xs text-ink-faint">Paste a URL above to start playback.</div>
<div className="text-xs text-ink-faint">
Paste a URL above to start playback.
</div>
</div>
) : (
<div className="space-y-2">
{queueList.map((item, i) => (
<div key={`${item.source}-${i}`} className="flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5">
<div
key={`${item.source}-${i}`}
className="flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"
>
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink">{item.title}</div>
<div className="mono truncate text-[0.65rem] text-ink-faint">{item.source}</div>
<div className="mono truncate text-[0.65rem] text-ink-faint">
{item.source}
</div>
</div>
<span className="pill">{item.mode ?? "music"}</span>
</div>
@@ -4,12 +4,17 @@ import { MessagesView } from "./view";
export const dynamic = "force-dynamic";
export default async function MessagesPage() {
let config = undefined;
let guilds = undefined;
let config: import("@/lib/types/guild").AppConfig | undefined;
let guilds: import("@/lib/types").Guild[] | undefined;
try {
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
} catch {
/* client hooks surface errors */
}
return <MessagesView initialGuilds={guilds} initialGuildId={config?.monitorGuildId ?? null} />;
return (
<MessagesView
initialGuilds={guilds}
initialGuildId={config?.monitorGuildId ?? null}
/>
);
}
@@ -1,30 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import {
MessageSquare,
Search,
Paperclip,
Image as ImageIcon,
ShieldAlert,
AlertTriangle,
CheckCircle2,
Image as ImageIcon,
Loader2,
MessageSquare,
Paperclip,
Search,
ShieldAlert,
} from "lucide-react";
import { useWebSocket } from "@/lib/ws/context";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import {
useGuilds,
Avatar,
Badge,
GlassPanel,
Input,
Skeleton,
} from "@/components/primitives";
import {
EmptyState,
ErrorState,
LoadingState,
SectionHeader,
} from "@/components/shared";
import { GuildChannelPicker } from "@/components/shared/guild-picker";
import {
useMessageDetail,
useMessageSearch,
useMessages,
useMessagesWsSync,
useMessageSearch,
useMessageDetail,
} from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard, Avatar, Badge, Input, Skeleton } from "@/components/primitives";
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
import { GuildChannelPicker } from "@/components/shared/guild-picker";
import { renderMessageContent, getMessageChannelLabel, safeParseJsonArray, formatBytes } from "@/lib/format";
import {
formatBytes,
getMessageChannelLabel,
renderMessageContent,
safeParseJsonArray,
} from "@/lib/format";
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
function relTime(ts?: number | null) {
if (!ts) return "";
@@ -37,7 +52,9 @@ function relTime(ts?: number | null) {
return `${Math.floor(h / 24)}d`;
}
function aiTone(s?: AiStatus | null): "signal" | "amber" | "vermilion" | "neutral" {
function aiTone(
s?: AiStatus | null,
): "signal" | "amber" | "vermilion" | "neutral" {
if (s === "clean") return "signal";
if (s === "warn") return "amber";
if (s === "flagged" || s === "error") return "vermilion";
@@ -53,7 +70,6 @@ export function MessagesView({
initialGuildId?: string | null;
}) {
const ws = useWebSocket();
const { data: guilds } = useGuilds(initialGuilds);
const [guildId, setGuildId] = useState<string | null>(
initialGuildId ?? initialGuilds?.[0]?.id ?? null,
);
@@ -61,7 +77,11 @@ export function MessagesView({
const [selected, setSelected] = useState<string | null>(null);
const [query, setQuery] = useState("");
const { data: messages, isLoading, error } = useMessages(guildId ?? "", channelId ?? undefined);
const {
data: messages,
isLoading,
error,
} = useMessages(guildId ?? "", channelId ?? undefined);
useMessagesWsSync(ws, guildId ?? "");
const search = useMessageSearch(query, query.trim().length >= 2);
const detail = useMessageDetail(selected);
@@ -72,7 +92,7 @@ export function MessagesView({
}, [query, ambient]);
const searching = query.trim().length >= 2;
const list = searching ? search.data ?? [] : (messages ?? []);
const list = searching ? (search.data ?? []) : (messages ?? []);
return (
<div className="space-y-4">
@@ -115,7 +135,11 @@ export function MessagesView({
) : isLoading && !messages ? (
<LoadingState label="Capturing" />
) : list.length === 0 ? (
<EmptyState icon={<MessageSquare className="size-7" />} title="No messages" description="Pick a guild to begin, or run a search." />
<EmptyState
icon={<MessageSquare className="size-7" />}
title="No messages"
description="Pick a guild to begin, or run a search."
/>
) : (
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
{list.map((m) => (
@@ -124,18 +148,30 @@ export function MessagesView({
type="button"
onClick={() => setSelected(m.id)}
className={`flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
selected === m.id ? "border-signal/40 bg-signal/8" : "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
selected === m.id
? "border-signal/40 bg-signal/8"
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
}`}
>
<Avatar src={m.avatar_url} name={m.username} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-ink">{m.username}</span>
<span className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)}</span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint">{relTime(m.created_at)}</span>
<span className="truncate text-sm font-semibold text-ink">
{m.username}
</span>
<span className="mono text-[0.65rem] text-ink-faint">
{getMessageChannelLabel(m)}
</span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
{relTime(m.created_at)}
</span>
</div>
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
{renderMessageContent(m.content, m.metadata) || <span className="italic text-ink-faint">(empty / embed)</span>}
{renderMessageContent(m.content, m.metadata) || (
<span className="italic text-ink-faint">
(empty / embed)
</span>
)}
</div>
</div>
<AiBadge status={m.ai_status} />
@@ -148,14 +184,20 @@ export function MessagesView({
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="inspect" title="Detail" />
{!selected ? (
<EmptyState title="Select a message" description="Click any message to inspect AI analysis, attachments and edit history." />
<EmptyState
title="Select a message"
description="Click any message to inspect AI analysis, attachments and edit history."
/>
) : detail.loading ? (
<div className="space-y-2">
<Skeleton className="h-20" />
<Skeleton className="h-12" />
</div>
) : detail.message ? (
<MessageDetail m={detail.message} attachments={detail.attachments} />
<MessageDetail
m={detail.message}
attachments={detail.attachments}
/>
) : (
<EmptyState title="Not found" />
)}
@@ -169,15 +211,32 @@ function AiBadge({ status }: { status?: AiStatus | null }) {
if (!status) return null;
const tone = aiTone(status);
const icon =
status === "clean" ? <CheckCircle2 className="size-3" /> :
status === "flagged" ? <ShieldAlert className="size-3" /> :
status === "warn" ? <AlertTriangle className="size-3" /> :
status === "processing" || status === "pending" ? <Loader2 className="size-3 animate-spin" /> :
<AlertTriangle className="size-3" />;
return <Badge tone={tone} dot={status === "processing" || status === "pending"}>{icon}{status}</Badge>;
status === "clean" ? (
<CheckCircle2 className="size-3" />
) : status === "flagged" ? (
<ShieldAlert className="size-3" />
) : status === "warn" ? (
<AlertTriangle className="size-3" />
) : status === "processing" || status === "pending" ? (
<Loader2 className="size-3 animate-spin" />
) : (
<AlertTriangle className="size-3" />
);
return (
<Badge tone={tone} dot={status === "processing" || status === "pending"}>
{icon}
{status}
</Badge>
);
}
function MessageDetail({ m, attachments }: { m: MessageRecord; attachments: import("@/lib/types").AttachmentRecord[] }) {
function MessageDetail({
m,
attachments,
}: {
m: MessageRecord;
attachments: import("@/lib/types").AttachmentRecord[];
}) {
const flags = safeParseJsonArray(m.ai_moderation_flags);
const cats = safeParseJsonArray(m.ai_categories);
return (
@@ -186,38 +245,63 @@ function MessageDetail({ m, attachments }: { m: MessageRecord; attachments: impo
<Avatar src={m.avatar_url} name={m.username} size={40} />
<div>
<div className="font-semibold text-ink">{m.username}</div>
<div className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)} · {relTime(m.created_at)}</div>
<div className="mono text-[0.65rem] text-ink-faint">
{getMessageChannelLabel(m)} · {relTime(m.created_at)}
</div>
</div>
<div className="ml-auto">
<AiBadge status={m.ai_status} />
</div>
<div className="ml-auto"><AiBadge status={m.ai_status} /></div>
</div>
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
{renderMessageContent(m.edited_content ?? m.content, m.metadata) || "(no text)"}
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
"(no text)"}
</div>
{m.ai_analysis && (
<div>
<div className="eyebrow mb-1">AI analysis</div>
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">{m.ai_analysis}</div>
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
{m.ai_analysis}
</div>
</div>
)}
{(flags.length > 0 || cats.length > 0) && (
<div className="flex flex-wrap gap-1.5">
{flags.map((f) => <Badge key={f} tone="vermilion">{f}</Badge>)}
{cats.map((c) => <Badge key={c} tone="amber">{c}</Badge>)}
{flags.map((f) => (
<Badge key={f} tone="vermilion">
{f}
</Badge>
))}
{cats.map((c) => (
<Badge key={c} tone="amber">
{c}
</Badge>
))}
</div>
)}
{attachments.length > 0 && (
<div>
<div className="eyebrow mb-1 flex items-center gap-1.5"><Paperclip className="size-3" /> Attachments ({attachments.length})</div>
<div className="eyebrow mb-1 flex items-center gap-1.5">
<Paperclip className="size-3" /> Attachments ({attachments.length})
</div>
<div className="space-y-1.5">
{attachments.map((a) => (
<a key={a.id} href={a.discord_url ?? a.uploaded_url ?? "#"} target="_blank" rel="noreferrer" className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink">
<a
key={a.id}
href={a.discord_url ?? a.uploaded_url ?? "#"}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink"
>
<ImageIcon className="size-3.5 text-signal" />
<span className="flex-1 truncate">{a.filename}</span>
<span className="mono text-ink-faint">{formatBytes(a.size)}</span>
<span className="mono text-ink-faint">
{formatBytes(a.size)}
</span>
</a>
))}
</div>
@@ -4,8 +4,8 @@ import { ModerationView } from "./view";
export const dynamic = "force-dynamic";
export default async function ModerationPage() {
let stats = undefined;
let actions = undefined;
let stats: import("@/lib/types").ModerationStats | undefined;
let actions: import("@/lib/types").ModerationAction[] | undefined;
try {
[stats, actions] = await Promise.all([
getModerationStats(),
@@ -1,27 +1,34 @@
"use client";
import { useEffect, useState } from "react";
import {
ShieldAlert,
CheckCircle2,
XCircle,
Clock,
Ban,
Trash2,
MicOff,
AlertTriangle,
UserX,
MessageSquareWarning,
Ban,
CheckCircle2,
Clock,
Filter,
MessageSquareWarning,
MicOff,
ShieldAlert,
Trash2,
UserX,
XCircle,
} from "lucide-react";
import {
useModerationStats,
useModerationActions,
} from "@/hooks";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard, Badge, Select, type SelectOption } from "@/components/primitives";
import { SectionHeader, MetricTile, ErrorState, LoadingState } from "@/components/shared";
import { Donut } from "@/components/charts";
import {
Badge,
GlassPanel,
Select,
type SelectOption,
} from "@/components/primitives";
import {
ErrorState,
LoadingState,
MetricTile,
SectionHeader,
} from "@/components/shared";
import { useModerationActions, useModerationStats } from "@/hooks";
import { formatNumber } from "@/lib/format";
import type {
ModerationAction,
@@ -64,7 +71,7 @@ export function ModerationView({
const failedRate = stats ? stats.failed_rate * 100 : 0;
const byAction = stats?.by_action ?? {};
const segments = Object.entries(byAction).map(([k, v]) => ({
const segments = Object.entries(byAction).map(([k, _v]) => ({
value: 1,
color:
k === "ban_user" || k === "kick_user"
@@ -86,6 +93,7 @@ export function ModerationView({
if (error && !stats) return <ErrorState error={error} />;
if (!stats && isLoading) return <LoadingState label="Reading log" />;
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
const statusOpts: SelectOption[] = [
{ value: "", label: "All statuses" },
@@ -95,16 +103,39 @@ export function ModerationView({
];
const typeOpts: SelectOption[] = [
{ value: "", label: "All actions" },
...Object.keys(byAction).map((k) => ({ value: k, label: ACTION_LABEL[k as ModerationActionType] ?? k })),
...Object.keys(byAction).map((k) => ({
value: k,
label: ACTION_LABEL[k as ModerationActionType] ?? k,
})),
];
return (
<div className="space-y-5">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile label="Total actions" value={formatNumber(stats!.total)} tone="signal" icon={<ShieldAlert className="size-3.5" />} />
<MetricTile label="Executed" value={formatNumber(stats!.executed)} tone="signal" icon={<CheckCircle2 className="size-3.5" />} />
<MetricTile label="Failed" value={formatNumber(stats!.failed)} tone={stats!.failed > 0 ? "vermilion" : "neutral"} icon={<XCircle className="size-3.5" />} />
<MetricTile label="Pending" value={formatNumber(stats!.pending)} tone={stats!.pending > 0 ? "amber" : "neutral"} icon={<Clock className="size-3.5" />} />
<MetricTile
label="Total actions"
value={formatNumber(stats.total)}
tone="signal"
icon={<ShieldAlert className="size-3.5" />}
/>
<MetricTile
label="Executed"
value={formatNumber(stats.executed)}
tone="signal"
icon={<CheckCircle2 className="size-3.5" />}
/>
<MetricTile
label="Failed"
value={formatNumber(stats.failed)}
tone={stats.failed > 0 ? "vermilion" : "neutral"}
icon={<XCircle className="size-3.5" />}
/>
<MetricTile
label="Pending"
value={formatNumber(stats?.pending)}
tone={stats?.pending > 0 ? "amber" : "neutral"}
icon={<Clock className="size-3.5" />}
/>
</div>
<div className="grid gap-5 lg:grid-cols-5">
@@ -112,7 +143,17 @@ export function ModerationView({
<SectionHeader eyebrow="health" title="Breakdown" />
<div className="flex items-center gap-5">
<Donut
segments={segments.length ? segments : [{ value: 1, color: "var(--color-ink-faint)", label: "none" }]}
segments={
segments.length
? segments
: [
{
value: 1,
color: "var(--color-ink-faint)",
label: "none",
},
]
}
centerLabel={`${Math.round(failedRate)}%`}
centerSub="fail rate"
/>
@@ -121,14 +162,22 @@ export function ModerationView({
const count = typeof v === "number" ? v : null;
return (
<div key={k} className="flex items-center gap-2.5">
<span className="text-ink-soft">{ACTION_ICON[k as ModerationActionType]}</span>
<span className="flex-1 text-ink-soft">{ACTION_LABEL[k as ModerationActionType] ?? k}</span>
{count !== null && <span className="mono text-ink">{count}</span>}
<span className="text-ink-soft">
{ACTION_ICON[k as ModerationActionType]}
</span>
<span className="flex-1 text-ink-soft">
{ACTION_LABEL[k as ModerationActionType] ?? k}
</span>
{count !== null && (
<span className="mono text-ink">{count}</span>
)}
</div>
);
})}
{Object.keys(byAction).length === 0 && (
<div className="text-xs text-ink-faint">No actions recorded yet.</div>
<div className="text-xs text-ink-faint">
No actions recorded yet.
</div>
)}
</div>
</div>
@@ -141,8 +190,20 @@ export function ModerationView({
action={
<div className="flex items-center gap-2">
<Filter className="size-3.5 text-ink-faint" />
<Select value={typeFilter} onChange={setTypeFilter} options={typeOpts} size="sm" className="w-36" />
<Select value={statusFilter} onChange={setStatusFilter} options={statusOpts} size="sm" className="w-32" />
<Select
value={typeFilter}
onChange={setTypeFilter}
options={typeOpts}
size="sm"
className="w-36"
/>
<Select
value={statusFilter}
onChange={setStatusFilter}
options={statusOpts}
size="sm"
className="w-32"
/>
</div>
}
/>
@@ -151,7 +212,9 @@ export function ModerationView({
<ActionRow key={a.id} a={a} />
))}
{(actions ?? []).length === 0 && (
<div className="py-10 text-center text-xs text-ink-faint">No matching actions.</div>
<div className="py-10 text-center text-xs text-ink-faint">
No matching actions.
</div>
)}
</div>
</GlassPanel>
@@ -162,26 +225,42 @@ export function ModerationView({
function ActionRow({ a }: { a: ModerationAction }) {
const tone =
a.status === "executed" ? "signal" : a.status === "failed" ? "vermilion" : "amber";
const icon = ACTION_ICON[a.action_type] ?? <AlertTriangle className="size-3.5" />;
a.status === "executed"
? "signal"
: a.status === "failed"
? "vermilion"
: "amber";
const icon = ACTION_ICON[a.action_type] ?? (
<AlertTriangle className="size-3.5" />
);
return (
<div className="flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3">
<span className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}>{icon}</span>
<span
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
>
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink">{a.username ?? "unknown"}</span>
<span className="text-sm font-semibold text-ink">
{a.username ?? "unknown"}
</span>
<Badge tone={tone}>{a.status}</Badge>
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
{a.created_at ? new Date(a.created_at).toLocaleString() : "—"}
</span>
</div>
{a.reason && <div className="mt-0.5 text-xs text-ink-soft">{a.reason}</div>}
{a.reason && (
<div className="mt-0.5 text-xs text-ink-soft">{a.reason}</div>
)}
{a.content && (
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint">
{a.content}
</div>
)}
{a.error && <div className="mt-1 text-xs text-vermilion">{a.error}</div>}
{a.error && (
<div className="mt-1 text-xs text-vermilion">{a.error}</div>
)}
</div>
</div>
);
@@ -4,7 +4,9 @@ import { RecordingsView } from "./view";
export const dynamic = "force-dynamic";
export default async function RecordingsPage() {
let recordings = undefined;
let recordings:
| import("@/lib/types/recording").PaginatedRecordings
| undefined;
try {
recordings = await getRecordings(50);
} catch {
@@ -1,17 +1,35 @@
"use client";
import { Download, Headphones, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { Headphones, Trash2, Download } from "lucide-react";
import { useWebSocket } from "@/lib/ws/context";
import { useRecordings, useDeleteRecording, useRecordingsWsSync } from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, GlassCard, Avatar, Button } from "@/components/primitives";
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
import {
Avatar,
Button,
GlassCard,
GlassPanel,
toast,
} from "@/components/primitives";
import {
EmptyState,
ErrorState,
LoadingState,
SectionHeader,
} from "@/components/shared";
import {
useDeleteRecording,
useRecordings,
useRecordingsWsSync,
} from "@/hooks";
import { formatBytes } from "@/lib/format";
import { toast } from "@/components/primitives";
import type { VoiceRecording } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording[] }) {
export function RecordingsView({
initialItems,
}: {
initialItems?: VoiceRecording[];
}) {
const ws = useWebSocket();
const { data: items, isLoading, error } = useRecordings(initialItems);
const del = useDeleteRecording();
@@ -27,7 +45,11 @@ export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording
await del.mutateAsync(id);
toast({ title: "Recording deleted", tone: "signal" });
} catch (e) {
toast({ title: "Delete failed", description: String(e), tone: "vermilion" });
toast({
title: "Delete failed",
description: String(e),
tone: "vermilion",
});
}
};
@@ -39,10 +61,18 @@ export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording
<SectionHeader
eyebrow="voice captures"
title="Recordings"
action={<span className="mono text-xs text-ink-faint">{(items ?? []).length} clips</span>}
action={
<span className="mono text-xs text-ink-faint">
{(items ?? []).length} clips
</span>
}
/>
{(items ?? []).length === 0 ? (
<EmptyState icon={<Headphones className="size-7" />} title="No recordings" description="Voice clips captured by the bot appear here." />
<EmptyState
icon={<Headphones className="size-7" />}
title="No recordings"
description="Voice clips captured by the bot appear here."
/>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{(items ?? []).map((r) => (
@@ -50,17 +80,28 @@ export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording
<div className="flex items-center gap-3">
<Avatar src={r.avatar_url} name={r.username} size={38} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-ink">{r.username}</div>
<div className="truncate text-sm font-semibold text-ink">
{r.username}
</div>
<div className="mono text-[0.65rem] text-ink-faint">
{r.channel_name ?? "voice"} · {new Date(r.created_at).toLocaleString()}
{r.channel_name ?? "voice"} ·{" "}
{new Date(r.created_at).toLocaleString()}
</div>
</div>
<span className="mono text-[0.65rem] text-ink-faint">{formatBytes(r.size_bytes)}</span>
<span className="mono text-[0.65rem] text-ink-faint">
{formatBytes(r.size_bytes)}
</span>
</div>
{r.download_url ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio controls src={r.download_url} className="h-9 w-full" preload="none" />
<audio
controls
src={r.download_url}
className="h-9 w-full"
preload="none"
aria-label={`Voice recording ${r.id}`}
/>
) : (
<div className="rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
Upload pending
@@ -69,7 +110,12 @@ export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording
<div className="flex items-center gap-2">
{r.download_url && (
<a href={r.download_url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 rounded-[9px] border border-hairline px-2.5 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40">
<a
href={r.download_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-[9px] border border-hairline px-2.5 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40"
>
<Download className="size-3.5" /> Download
</a>
)}
@@ -4,8 +4,8 @@ import { VoiceView } from "./view";
export const dynamic = "force-dynamic";
export default async function VoicePage() {
let status = undefined;
let guilds = undefined;
let status: import("@/lib/types").VoiceStatus | undefined;
let guilds: import("@/lib/types").Guild[] | undefined;
try {
[status, guilds] = await Promise.all([getVoiceStatus(), getGuilds()]);
} catch {
@@ -1,25 +1,36 @@
"use client";
import { useEffect, useState } from "react";
import { Mic, MicOff, Headphones, PhoneOff, Radio, Volume2, Waves } from "lucide-react";
import { useWebSocket } from "@/lib/ws/context";
import {
useGuilds,
useVoiceStatus,
Headphones,
Mic,
MicOff,
PhoneOff,
Radio,
Volume2,
Waves,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { Equalizer } from "@/components/charts";
import { Button, GlassPanel, toast } from "@/components/primitives";
import {
EmptyState,
ErrorState,
LoadingState,
SectionHeader,
} from "@/components/shared";
import { GuildChannelPicker } from "@/components/shared/guild-picker";
import { VoiceStage } from "@/components/voice/voice-stage";
import {
useMicTransmit,
useSpeakers,
useVoiceConnect,
useVoiceDisconnect,
useSpeakers,
useMicTransmit,
useVoiceListen,
useVoiceStatus,
} from "@/hooks";
import { useAmbient } from "@/components/ambient/ambient-context";
import { GlassPanel, Button } from "@/components/primitives";
import { VoiceStage } from "@/components/voice/voice-stage";
import { Equalizer } from "@/components/charts";
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
import { GuildChannelPicker } from "@/components/shared/guild-picker";
import { toast } from "@/components/primitives";
import type { Guild, VoiceStatus } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
export function VoiceView({
initialStatus,
@@ -30,7 +41,6 @@ export function VoiceView({
}) {
const ws = useWebSocket();
const { data: status, isLoading, error } = useVoiceStatus(initialStatus);
const { data: guilds } = useGuilds(initialGuilds);
const connect = useVoiceConnect();
const disconnect = useVoiceDisconnect();
const mic = useMicTransmit(ws);
@@ -71,7 +81,11 @@ export function VoiceView({
await connect.mutateAsync({ guildId, channelId });
toast({ title: "Connected to voice", tone: "signal" });
} catch (e) {
toast({ title: "Connect failed", description: String(e), tone: "vermilion" });
toast({
title: "Connect failed",
description: String(e),
tone: "vermilion",
});
}
};
@@ -100,11 +114,21 @@ export function VoiceView({
/>
<div className="flex items-center gap-2">
{connected ? (
<Button variant="danger" size="sm" onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
<Button
variant="danger"
size="sm"
onClick={() => disconnect.mutate()}
disabled={disconnect.isPending}
>
<PhoneOff className="size-4" /> Disconnect
</Button>
) : (
<Button variant="primary" size="sm" onClick={onConnect} disabled={connect.isPending}>
<Button
variant="primary"
size="sm"
onClick={onConnect}
disabled={connect.isPending}
>
<Radio className="size-4" /> Connect
</Button>
)}
@@ -127,7 +151,11 @@ export function VoiceView({
size="sm"
onClick={() => listen.toggle(!listen.active)}
>
{listen.active ? <Headphones className="size-4" /> : <Volume2 className="size-4" />}
{listen.active ? (
<Headphones className="size-4" />
) : (
<Volume2 className="size-4" />
)}
{listen.active ? "Listening" : "Listen in"}
</Button>
{listen.active && (
@@ -154,7 +182,11 @@ export function VoiceView({
<EmptyState
icon={<MicOff className="size-7" />}
title={connected ? "Silent right now" : "Not connected"}
description={connected ? "Speakers appear as they talk." : "Connect to a voice channel to see presence."}
description={
connected
? "Speakers appear as they talk."
: "Connect to a voice channel to see presence."
}
/>
) : (
<>
@@ -169,7 +201,9 @@ export function VoiceView({
: "border-hairline bg-white/5 text-ink-soft"
}`}
>
<span className={`size-1.5 rounded-full ${sp.speaking ? "bg-signal animate-breathe" : "bg-ink-faint"}`} />
<span
className={`size-1.5 rounded-full ${sp.speaking ? "bg-signal animate-breathe" : "bg-ink-faint"}`}
/>
{sp.username}
</span>
))}
@@ -182,14 +216,23 @@ export function VoiceView({
<SectionHeader eyebrow="links" title="Connections" />
<div className="space-y-2">
{(status?.connections ?? []).map((c) => (
<div key={`${c.guildId}-${c.channelId}`} className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-sm">
<div
key={`${c.guildId}-${c.channelId}`}
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-sm"
>
<span className="size-2 rounded-full bg-signal" />
<span className="flex-1 truncate text-ink-soft">{c.channelName}</span>
<span className="mono text-[0.6rem] text-ink-faint">{new Date(c.connectedAt).toLocaleTimeString()}</span>
<span className="flex-1 truncate text-ink-soft">
{c.channelName}
</span>
<span className="mono text-[0.6rem] text-ink-faint">
{new Date(c.connectedAt).toLocaleTimeString()}
</span>
</div>
))}
{(status?.connections ?? []).length === 0 && (
<div className="py-6 text-center text-xs text-ink-faint">No active links</div>
<div className="py-6 text-center text-xs text-ink-faint">
No active links
</div>
)}
</div>
<div className="mt-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft">