diff --git a/services/frontend/src/app/(dashboard)/analysis/page.tsx b/services/frontend/src/app/(dashboard)/analysis/page.tsx index 9444804..dd76297 100644 --- a/services/frontend/src/app/(dashboard)/analysis/page.tsx +++ b/services/frontend/src/app/(dashboard)/analysis/page.tsx @@ -1,11 +1,7 @@ -"use client"; +import { AnalysisView } from "./view"; -import { SearchPanel } from "@/components/analysis/search-panel"; +export const dynamic = "force-dynamic"; export default function AnalysisPage() { - return ( -
- -
- ); + return ; } diff --git a/services/frontend/src/app/(dashboard)/analysis/view.tsx b/services/frontend/src/app/(dashboard)/analysis/view.tsx new file mode 100644 index 0000000..6b51707 --- /dev/null +++ b/services/frontend/src/app/(dashboard)/analysis/view.tsx @@ -0,0 +1,112 @@ +"use client"; + +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 type { AiStatus } from "@/lib/types"; + +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"; + return "neutral"; +} + +export function AnalysisView() { + const [query, setQuery] = useState(""); + const search = useMessageSearch(query, query.trim().length >= 2); + const { data: reactors } = useTopReactors(); + const { data: channels } = useChannels(); + const ambient = useAmbient(); + + useEffect(() => { + ambient.set(query ? "amber" : "signal", 0.3, query ? "analyzing" : "search"); + }, [query, ambient]); + + return ( +
+ +
+
+ +
+
Semantic search
+

Search the archive

+
+
+
+ + setQuery(e.target.value)} + autoFocus + /> +
+ {query.trim().length > 0 && query.trim().length < 2 && ( +
Type at least 2 characters…
+ )} + + +
+ + {(search.data ?? []).length}} /> + {query.trim().length >= 2 && search.isLoading && } + {(search.data ?? []).length === 0 ? ( + } title="No matches yet" description="Run a search to surface messages across the guild." /> + ) : ( +
+ {(search.data ?? []).map((m) => ( +
+ +
+
+ {m.username} + {getMessageChannelLabel(m)} + {m.ai_status && {m.ai_status}} +
+
{renderMessageContent(m.content, m.metadata) || "(embed)"}
+
+
+ ))} +
+ )} +
+ +
+ + Top reactors} /> +
+ {(reactors ?? []).slice(0, 6).map((r, i) => ( +
+ {i + 1} + {r.username} + +{r.net_count} +
+ ))} + {(reactors ?? []).length === 0 &&
No data
} +
+
+ + + Top channels} /> +
+ {(channels ?? []).slice(0, 6).map((c) => ( +
+ {c.channel_name ?? c.channel_id.slice(0, 8)} + {c.total_messages} +
+ ))} + {(channels ?? []).length === 0 &&
No data
} +
+
+
+
+
+ ); +} diff --git a/services/frontend/src/app/(dashboard)/dashboard/page.tsx b/services/frontend/src/app/(dashboard)/dashboard/page.tsx index 58656c2..818bf60 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -1,27 +1,15 @@ -/** - * Dashboard — Server Component. - * Fetches initial stats + activity on the server (SSR first paint), hands to - * the hydrated client View. Keeps the documented server-seed data flow. - */ import { getActivity, getDashboardStats } from "@/lib/api/server"; -import DashboardView from "./view"; +import { DashboardView } from "./view"; + +export const dynamic = "force-dynamic"; export default async function DashboardPage() { - const [stats, activity] = await Promise.allSettled([ - getDashboardStats().catch(() => undefined), - getActivity(14).catch(() => undefined), - ]); - - return ( - - ); + let stats = undefined; + let activity = undefined; + try { + [stats, activity] = await Promise.all([getDashboardStats(), getActivity(14)]); + } catch { + // Backend unavailable — client hooks will surface the error state. + } + return ; } diff --git a/services/frontend/src/app/(dashboard)/dashboard/view.tsx b/services/frontend/src/app/(dashboard)/dashboard/view.tsx index 8e330df..004cfcc 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/view.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/view.tsx @@ -1,144 +1,228 @@ "use client"; -/** - * Dashboard — Ambient Field layout. - * - * No top bar. No side rail. No grid. No panels. - * - * A full-bleed WebGL haze (AmbientField) is the page. Content floats over it: - * a giant headline bottom-left, a live metric cluster top-right, a drifting - * event ribbon mid-screen, a command whispher at the very bottom. Whitespace - * is the layout — density comes from data, not chrome. - */ +import { useEffect } from "react"; +import { + Activity, + Flag, + MessageSquare, + Mic, + Radio, + ShieldAlert, + Users, +} from "lucide-react"; +import { + useActivity, + useStats, + useTopReactors, + useTopReactions, +} 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"; -import { useCallback, useMemo, useState } from "react"; -import { AmbientField } from "@/components/ambient/ambient-field"; -import { DashCommandLine } from "@/components/command/dash-command-line"; -import type { DashboardActivity, DashboardStats } from "@/lib/types"; -import { useWebSocket } from "@/lib/ws/context"; +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 (ratio > 0.1) return { tone: "amber" as const, label: "watch" }; + return { tone: "signal" as const, label: "nominal" }; +} -export default function DashboardView({ +export function DashboardView({ initialStats, initialActivity, }: { initialStats?: DashboardStats; - initialActivity?: DashboardActivity; + initialActivity?: Awaited>["data"]; }) { - const ws = useWebSocket(); - const [signal, setSignal] = useState< - "signal" | "amber" | "vermilion" | "neutral" - >("signal"); - const [load, setLoad] = useState(0.3); + const { data: stats, isLoading, error } = useStats(initialStats); + const { data: activity } = useActivity(14, initialActivity as never); + const { data: reactors } = useTopReactors(); + const { data: reactions } = useTopReactions(); + const ambient = useAmbient(); - const total = initialStats?.total_messages ?? 0; - const clean = initialStats?.total_clean ?? 0; - const flagged = initialStats?.total_flagged ?? 0; - const warned = initialStats?.total_warned ?? 0; - const ratio = ((clean / (clean + flagged + warned || 1)) * 100).toFixed(1); + useEffect(() => { + const s = deriveSignal(stats); + ambient.set(s.tone, 0.3 + Math.min(0.5, (stats?.today_flagged ?? 0) / 50), s.label); + }, [stats, ambient]); - const _subscribe = useCallback( - (handler: (e: { severity: string; ts: number }) => void) => { - const unsub = ws.on("message_created", (data: any) => { - const s = data.ai_status; - setSignal( - s === "flagged" ? "vermilion" : s === "warn" ? "amber" : "signal", - ); - setLoad((l) => Math.min(1, l + 0.02)); - handler({ - severity: s ?? "neutral", - ts: data.created_at ?? Date.now(), - }); - }); - return unsub; - }, - [ws], - ); + if (error && !stats) return ; + if (!stats && isLoading) return ; - const seedEvents = useMemo(() => { - if (!initialActivity) return []; - return initialActivity.daily.slice(-10).flatMap((d) => - Array.from({ length: Math.min(3, d.messages) }, (_, i) => ({ - id: `seed-${d.day}-${i}`, - ts: Date.now() - i * 120_000, - severity: i < d.flagged ? "vermilion" : "signal", - actor: i < d.flagged ? "ai" : "user", - action: i < d.flagged ? "flagged" : "sent", - channel: "#general", - excerpt: `seed ${d.day}`, - })), - ); - }, [initialActivity]); + const s = stats!; + const total = s.total_flagged + s.total_clean || 1; + const cleanRatio = s.total_clean / total; return ( -
- - - {/* Metric cluster — top right, floating, no container */} -
- - watched - - - {total.toLocaleString()} - -
- - {clean.toLocaleString()} clean - - {warned} warn - {flagged} flag -
- - {ratio}% ratio - -
- - {/* Headline — bottom left, massive */} -
-

- GMW -
- Console -

-

- {(initialStats?.total_users ?? 0).toLocaleString()} users ·{" "} - {initialStats?.active_users_24h ?? 0} active 24h -

-
- - {/* Event ribbon — mid screen, drifting row */} -
-
- {seedEvents.slice(0, 6).map((e) => ( -
- - - {new Date(e.ts).toLocaleTimeString()} - - - {e.excerpt} +
+ {/* Hero */} + +
+
+
+
GMW · Operations Grid
+

+ Ambient Field +

+

+ Real-time moderation, voice & media presence across the monitored + guild. {formatNumber(s.total_messages)} messages captured. +

+
+
+ + + {deriveSignal(s).label}
- ))} +
+ +
+ } /> + 0 ? "vermilion" : "neutral"} hint={`${s.today_flagged} today`} /> + } /> + } /> +
+ + + {/* Activity */} + + + Activity & moderation + + } + action={ +
+ messages + flagged +
+ } + /> + {activity ? ( + + ) : ( + + )} +
+ + {/* Two-column: channels + moderation */} +
+ + +
+ {s.top_channels.slice(0, 7).map((c) => { + const pct = (c.message_count / (s.top_channels[0]?.message_count || 1)) * 100; + return ( +
+ {c.channel_name ?? c.channel_id.slice(0, 8)} +
+
+
+ {formatNumber(c.message_count)} +
+ ); + })} +
+ + + + +
+ 0.8 ? "signal" : cleanRatio > 0.6 ? "amber" : "vermilion"} + label={`${Math.round(cleanRatio * 100)}%`} + sublabel="clean" + /> +
+ } label="Clean" value={formatNumber(s.total_clean)} /> + } label="Flagged" value={formatNumber(s.total_flagged)} /> + } label="Warned" value={formatNumber(s.total_warned)} /> +
+
+
+ + + +
+
+
+ + {/* Reactors + reactions */} +
+ + +
+ {(reactors ?? []).slice(0, 6).map((r, i) => ( +
+ {i + 1} + {r.username} + +{formatNumber(r.net_count)} +
+ ))} + {(reactors ?? []).length === 0 && } +
+
+ + + +
+ {(reactions ?? []).slice(0, 5).map((m) => ( +
+
+ {m.top_emojis.slice(0, 3).map((e, i) => ( + {e.emoji} + ))} +
+
+
{m.content || "(no text)"}
+
{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}
+
+ {m.reaction_count} +
+ ))} + {(reactions ?? []).length === 0 && } +
+
+ ); +} - {/* Command whisper — very bottom, minimal */} -
- -
+function Row({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { + return ( +
+ {icon} + {label} + {value}
); } + +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 ( +
+
{value}
+
{label}
+
+ ); +} + +function EmptyHint() { + return
Awaiting data…
; +} diff --git a/services/frontend/src/app/(dashboard)/layout.tsx b/services/frontend/src/app/(dashboard)/layout.tsx index e15e565..4df4ad5 100644 --- a/services/frontend/src/app/(dashboard)/layout.tsx +++ b/services/frontend/src/app/(dashboard)/layout.tsx @@ -1,133 +1,19 @@ -"use client"; - -import { usePathname } from "next/navigation"; -import { Suspense, useEffect, useState } from "react"; -import { SWRConfig } from "swr"; -import { ChatbotContainer } from "@/components/chatbot/chatbot-container"; -import { - ChatbotProvider, - useChatbot, -} from "@/components/chatbot/chatbot-context"; -import { Spine } from "@/components/layout/spine"; -import { StatusBar } from "@/components/layout/status-bar"; -import { MiniPlayer } from "@/components/media/mini-player"; -import { RouteTransition } from "@/components/motion/route-transition"; -import { MediaPlayerProvider } from "@/lib/hooks/use-media-player"; -import { useWebSocket, WsProvider } from "@/lib/ws/context"; - -function ChatbotGuildSync({ guildId }: { guildId: string }) { - const { setGuildId } = useChatbot(); - useEffect(() => { - setGuildId(guildId); - }, [guildId, setGuildId]); - return null; -} - -function ChatbotExpressionSync() { - const ws = useWebSocket(); - const { setExpression } = useChatbot(); - useEffect(() => { - const unsub1 = ws.on("message_created", (data: any) => { - if (data.ai_status === "flagged" || data.ai_status === "warn") { - setExpression("surprise"); - setTimeout(() => setExpression("idle"), 2000); - } - }); - const unsub2 = ws.on("voice_active_user", () => setExpression("listening")); - return () => { - unsub1(); - unsub2(); - }; - }, [ws, setExpression]); - return null; -} - -/** - * Ambient shell — used only on /dashboard. - * - * No TopBar, no LeftRail, no main padding. The view itself is full-bleed - * (AmbientField + floating overlays). This is the ground-up rombak — not a - * re-skin of the classic dashboard template. - */ -function AmbientShell({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - -/** - * Classic shell — used on every other route under /(dashboard). - */ -function ClassicShell({ - children, - guildId, - setGuildId, -}: { - children: React.ReactNode; - guildId: string; - setGuildId: (g: string) => void; -}) { - return ( -
- -
- setGuildId(g)} /> -
-
- -
-
- } - > - {children} -
-
-
-
-
- ); -} +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"; export default function DashboardLayout({ children, -}: { - children: React.ReactNode; -}) { - const [guildId, setGuildId] = useState(""); - const pathname = usePathname(); - // Match exact /dashboard or /dashboard/ but not /dashboard/ - const isConsole = pathname === "/dashboard" || pathname === "/dashboard/"; - +}: Readonly<{ children: React.ReactNode }>) { return ( - - (err as { statusCode?: number })?.statusCode !== 404, - }} - > + - - - - - {isConsole ? ( - {children} - ) : ( - - {children} - - )} - - - - + {children} + + - + ); } diff --git a/services/frontend/src/app/(dashboard)/media/page.tsx b/services/frontend/src/app/(dashboard)/media/page.tsx index 3c72a65..6fd94de 100644 --- a/services/frontend/src/app/(dashboard)/media/page.tsx +++ b/services/frontend/src/app/(dashboard)/media/page.tsx @@ -1,13 +1,14 @@ -/** - * Media page — Server Component. Seeds the music player with the shared media - * state fetched on the server (same state every user sees), then live-updates - * over WS. - */ import { getMediaStatus } from "@/lib/api/server"; -import MediaView from "./view"; +import { MediaView } from "./view"; + +export const dynamic = "force-dynamic"; export default async function MediaPage() { - const status = await getMediaStatus().catch(() => undefined); - + let status = undefined; + try { + status = await getMediaStatus(); + } catch { + /* client hooks surface errors */ + } return ; } diff --git a/services/frontend/src/app/(dashboard)/media/view.tsx b/services/frontend/src/app/(dashboard)/media/view.tsx index c95f090..390dbd5 100644 --- a/services/frontend/src/app/(dashboard)/media/view.tsx +++ b/services/frontend/src/app/(dashboard)/media/view.tsx @@ -1,185 +1,149 @@ "use client"; -import { Pause, Play, Repeat2, SkipForward, Square, Volume2 } from "lucide-react"; -import { motion } from "motion/react"; -import { useState } from "react"; -import { Waveform } from "@/components/charts/waveform"; -import { StaggerGroup, StaggerItem } from "@/components/motion/stagger"; -import { Badge } from "@/components/primitives/badge"; -import { Button } from "@/components/primitives/button"; -import { Input } from "@/components/primitives/input"; -import { Progress } from "@/components/primitives/progress"; +import { useEffect, useState } from "react"; import { - useMediaLoop, + ListMusic, + Pause, + Play, + Repeat, + SkipForward, + Square, + Radio, +} from "lucide-react"; +import { useWebSocket } from "@/lib/ws/context"; +import { + useMediaState, 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 default function MediaView({ - initialStatus, -}: { - initialStatus?: MediaState; -}) { +export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { const ws = useWebSocket(); - const { data: state } = useMediaState(initialStatus); - const queueMut = useMediaQueue(); + const { data: media, isLoading, error } = useMediaState(initialStatus); + const queue = useMediaQueue(); const skip = useMediaSkip(); const stop = useMediaStop(); - const loopMut = useMediaLoop(); + const loop = useMediaLoop(); useMediaWsSync(ws); + const ambient = useAmbient(); - const current = state?.current; - const playing = state?.playing ?? false; - const queue = state?.queue ?? []; - const loop = state?.loop ?? false; + const [url, setUrl] = useState(""); - const duration = current?.durationMs ?? 0; - const [queueUrl, setQueueUrl] = useState(""); - const [screenMode, setScreenMode] = useState(false); + const playing = media?.playing ?? false; + const current = media?.current ?? null; + const queueList = media?.queue ?? []; - const handleQueue = () => { - if (!queueUrl.trim()) return; - queueMut.mutate({ url: queueUrl.trim(), mode: screenMode ? "screen" : "music" }); - setQueueUrl(""); + const tone = playing ? "signal" : queueList.length ? "amber" : "signal"; + useEffect(() => { + ambient.set(tone, playing ? 0.5 : 0.25, playing ? "now playing" : "media idle"); + }, [tone, playing, ambient]); + + const onPlay = async () => { + const u = url.trim(); + if (!u) { + toast({ title: "Enter a media URL", tone: "vermilion" }); + return; + } + try { + await queue.mutateAsync({ url: u, mode: "music" }); + setUrl(""); + toast({ title: "Queued", tone: "signal" }); + } catch (e) { + toast({ title: "Queue failed", description: String(e), tone: "vermilion" }); + } }; - return ( -
- {/* URL queue input */} -
- setQueueUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleQueue()} - className="flex-1 h-9" - /> - - -
+ if (error && !media) return ; + if (!media && isLoading) return ; - {/* Turntable hero */} -
- {current && ( - + +
+
+
- {current.title - - )} -
-
- {current?.title ?? "No track playing"} +
+ +
-
- {current?.source ?? "idle"} · {duration ? formatMs(duration) : "—"} -
-
- -
- 0:00 - {duration ? formatMs(duration) : "—"} + +
+
Now playing
+

+ {current?.title ?? "Nothing queued"} +

+ {current?.source && ( +
{current.source}
+ )} +
+ + + +
-
- {/* Transport */} - - - - - - - - - - - - - - - - - +
+ setUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onPlay()} + /> +
+ - {/* Queue */} - {queue.length > 0 && ( -
-
-

Queue ({queue.length})

- {loop ? "loop" : "queue"} + + {queueList.length} tracks} + /> + {queueList.length === 0 ? ( +
+ +
Queue is empty
+
Paste a URL above to start playback.
-
- {queue.map((item) => ( - - - {item.title} - + ) : ( +
+ {queueList.map((item, i) => ( +
+ {i + 1} +
+
{item.title}
+
{item.source}
+
+ {item.mode ?? "music"} +
))}
-
- )} + )} +
); } - -function formatMs(ms: number): string { - const m = Math.floor(ms / 60000); - const s = Math.floor((ms % 60000) / 1000); - return `${m}:${s.toString().padStart(2, "0")}`; -} diff --git a/services/frontend/src/app/(dashboard)/messages/page.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx index 6d98409..5cfa1db 100644 --- a/services/frontend/src/app/(dashboard)/messages/page.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -1,38 +1,15 @@ -/** - * Messages — Server Component. - * Reads URL guild/channel/selected/tab on the server; seeds first page SSR. - */ -import { getMessages, type MessagePageResult } from "@/lib/api/server"; -import MessagesView from "./view"; +import { getConfig, getGuilds } from "@/lib/api/server"; +import { MessagesView } from "./view"; -export default async function MessagesPage({ - searchParams, -}: { - searchParams: Promise>; -}) { - const sp = await searchParams; - const guild = typeof sp.guild === "string" ? sp.guild : ""; - const channel = typeof sp.channel === "string" ? sp.channel : ""; - const selected = typeof sp.selected === "string" ? sp.selected : null; - const tab = - typeof sp.tab === "string" && ["all", "images", "review"].includes(sp.tab) - ? (sp.tab as "all" | "images" | "review") - : "all"; +export const dynamic = "force-dynamic"; - let initialPage: MessagePageResult | undefined; - if (guild) { - initialPage = await getMessages(guild, channel || undefined).catch( - () => undefined, - ); +export default async function MessagesPage() { + let config = undefined; + let guilds = undefined; + try { + [config, guilds] = await Promise.all([getConfig(), getGuilds()]); + } catch { + /* client hooks surface errors */ } - - return ( - - ); + return ; } diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx index 91be505..8a5a753 100644 --- a/services/frontend/src/app/(dashboard)/messages/view.tsx +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -1,377 +1,228 @@ "use client"; -import { Flag, Image, Loader2, Search, Send, X } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; -import { Lightbox } from "@/components/messages/lightbox"; -import { MessageDetailView } from "@/components/messages/message-detail-view"; -import { MessageList } from "@/components/messages/message-list"; -import { SearchOverlay } from "@/components/messages/search-overlay"; -import { StaggerGroup, StaggerItem } from "@/components/motion/stagger"; -import { Avatar } from "@/components/primitives/avatar"; -import { Badge } from "@/components/primitives/badge"; -import { Dialog } from "@/components/primitives/dialog"; -import { Input } from "@/components/primitives/input"; -import { Select } from "@/components/primitives/select"; -import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared"; -import { GuildSelector } from "@/components/shared/guild-selector"; +import { useEffect, useState } from "react"; import { - useImages, - useLoadMore, - useMessageDetail, - useMessages, - useMessagesHasMore, - useMessagesWsSync, - useReview, - useTextChannels, -} from "@/hooks"; -import { renderMessageContent } from "@/lib/format"; -import type { MessageRecord } from "@/lib/types"; -import { cn } from "@/lib/utils"; + MessageSquare, + Search, + Paperclip, + Image as ImageIcon, + ShieldAlert, + AlertTriangle, + CheckCircle2, + Loader2, +} from "lucide-react"; import { useWebSocket } from "@/lib/ws/context"; +import { + useGuilds, + 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 type { AiStatus, Guild, MessageRecord } from "@/lib/types"; -type MessagesTab = "all" | "images" | "review"; - -interface MessagesViewProps { - initialGuild?: string; - initialChannel?: string; - initialDetailId?: string | null; - initialTab?: MessagesTab; - initialMessagePage?: { data: MessageRecord[]; nextCursor: string | null }; +function relTime(ts?: number | null) { + if (!ts) return ""; + const d = Date.now() - ts; + const m = Math.floor(d / 60000); + if (m < 1) return "just now"; + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h`; + return `${Math.floor(h / 24)}d`; } -export default function MessagesView({ - initialGuild = "", - initialChannel = "", - initialDetailId = null, - initialTab = "all", - initialMessagePage, -}: MessagesViewProps) { - const router = useRouter(); - const [guildId, setGuildId] = useState(initialGuild); - const [selectedChannel, setSelectedChannel] = useState(initialChannel); - const [detailId, setDetailId] = useState(initialDetailId); - const [tab, setTab] = useState(initialTab); - const [searchOpen, setSearchOpen] = useState(false); - const [lightbox, setLightbox] = useState<{ - images: Array<{ src: string; alt?: string }>; - index: number; - } | null>(null); +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"; + if (s === "processing" || s === "pending") return "neutral"; + return "neutral"; +} +export function MessagesView({ + initialGuilds, + initialGuildId, +}: { + initialGuilds?: Guild[]; + initialGuildId?: string | null; +}) { const ws = useWebSocket(); - const { data: channels = [] } = useTextChannels(guildId); - const { - data: messages, - error, - refetch, - } = useMessages( - guildId, - selectedChannel || undefined, - guildId === initialGuild && selectedChannel === initialChannel - ? initialMessagePage - : undefined, + const { data: guilds } = useGuilds(initialGuilds); + const [guildId, setGuildId] = useState( + initialGuildId ?? initialGuilds?.[0]?.id ?? null, ); - const { data: cursorData } = useMessagesHasMore( - guildId, - selectedChannel || undefined, - ); - const loadMoreMut = useLoadMore(); - const { data: images } = useImages(guildId); - const { data: reviews } = useReview(selectedChannel || undefined); - const { message: detailMessage, loading: detailLoading } = - useMessageDetail(detailId); + const [channelId, setChannelId] = useState(null); + const [selected, setSelected] = useState(null); + const [query, setQuery] = useState(""); - useMessagesWsSync(ws, guildId); + const { data: messages, isLoading, error } = useMessages(guildId ?? "", channelId ?? undefined); + useMessagesWsSync(ws, guildId ?? ""); + const search = useMessageSearch(query, query.trim().length >= 2); + const detail = useMessageDetail(selected); + const ambient = useAmbient(); useEffect(() => { - const params = new URLSearchParams(); - if (guildId) params.set("guild", guildId); - if (selectedChannel) params.set("channel", selectedChannel); - if (detailId) params.set("selected", detailId); - if (tab !== "all") params.set("tab", tab); - router.replace(`/messages?${params.toString()}`, { scroll: false }); - }, [guildId, selectedChannel, detailId, tab, router]); + ambient.set(query ? "amber" : "signal", 0.3, query ? "search" : "messages"); + }, [query, ambient]); - // global Cmd+K - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault(); - setSearchOpen(true); - } - }; - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, []); - - const handleLoadMore = useCallback(() => { - if (!cursorData?.cursor || loadMoreMut.isPending) return; - loadMoreMut.mutate({ - guildId, - channelId: selectedChannel || undefined, - cursor: cursorData.cursor, - }); - }, [cursorData, loadMoreMut, guildId, selectedChannel]); - - const handleGuildChange = useCallback((g: string) => { - setGuildId(g); - setSelectedChannel(""); - setDetailId(null); - }, []); - - const tabs: { id: MessagesTab; label: string; icon: React.ReactNode }[] = [ - { id: "all", label: "All", icon: null }, - { id: "images", label: "Images", icon: }, - { id: "review", label: "Review", icon: }, - ]; - - const currentMessages = messages ?? []; + const searching = query.trim().length >= 2; + const list = searching ? search.data ?? [] : (messages ?? []); return ( -
- {/* Controls */} -
- - {channels.length > 0 && ( - - )} - -
- - {/* Tabs */} -
- {tabs.map((t) => ( - - ))} -
- -
- {/* Left — timeline spine + entries */} -
- {error ? ( - - ) : !messages ? ( - - ) : tab === "all" ? ( - - ) : tab === "images" ? ( - - ) : ( - - )} -
- - {/* Right — detail */} - {detailId && ( -
-
- {detailLoading ? ( -
- -
- ) : detailMessage ? ( -
- - - {detailMessage && ( - setLightbox(null)} - images={extractImages(detailMessage.metadata)} - /> - )} -
- ) : null} -
-
- )} -
- - setSearchOpen(false)} - results={(currentMessages ?? []).map((m) => ({ - id: m.id, - content: m.edited_content ?? m.content, - username: m.username ?? "unknown", - channel: m.channel_id, - time: m.created_at - ? new Date(m.created_at * 1000).toLocaleTimeString() - : "", - }))} - onSelect={(msg) => { - const found = currentMessages.find((m) => m.id === msg.id); - if (found) setDetailId(found.id); - setTab("all"); - }} - /> - - {lightbox && ( - setLightbox(null)} - images={lightbox.images} - initialIndex={lightbox.index} +
+ + { + setGuildId(g); + setChannelId(c); + setSelected(null); + }} /> +
+ + setQuery(e.target.value)} + /> +
+
+ +
+ + + {list.length} shown + + } + /> + {error && !messages ? ( + + ) : isLoading && !messages ? ( + + ) : list.length === 0 ? ( + } title="No messages" description="Pick a guild to begin, or run a search." /> + ) : ( +
+ {list.map((m) => ( + + ))} +
+ )} +
+ + + + {!selected ? ( + + ) : detail.loading ? ( +
+ + +
+ ) : detail.message ? ( + + ) : ( + + )} +
+
+
+ ); +} + +function AiBadge({ status }: { status?: AiStatus | null }) { + if (!status) return null; + const tone = aiTone(status); + const icon = + status === "clean" ? : + status === "flagged" ? : + status === "warn" ? : + status === "processing" || status === "pending" ? : + ; + return {icon}{status}; +} + +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 ( +
+
+ +
+
{m.username}
+
{getMessageChannelLabel(m)} · {relTime(m.created_at)}
+
+
+
+ +
+ {renderMessageContent(m.edited_content ?? m.content, m.metadata) || "(no text)"} +
+ + {m.ai_analysis && ( +
+
AI analysis
+
{m.ai_analysis}
+
+ )} + + {(flags.length > 0 || cats.length > 0) && ( +
+ {flags.map((f) => {f})} + {cats.map((c) => {c})} +
+ )} + + {attachments.length > 0 && ( +
+
Attachments ({attachments.length})
+
+ {attachments.map((a) => ( + + + {a.filename} + {formatBytes(a.size)} + + ))} +
+
)}
); } - -function ImageGrid({ - items, - onSelect, -}: { - items: MessageRecord[]; - onSelect: (id: string) => void; -}) { - return !items.length ? ( - - ) : ( -
- {items.map((item) => { - const url = extractFirstImage(item.metadata); - return ( - - ); - })} -
- ); -} - -function ReviewList({ - items, - onSelect, -}: { - items: MessageRecord[]; - onSelect: (id: string) => void; -}) { - return !items.length ? ( - - ) : ( - - {items.map((item) => ( - - - - ))} - - ); -} - -function extractFirstImage(metadata?: string | null): string | null { - try { - if (!metadata) return null; - const m = JSON.parse(metadata); - const atts = m?.attachments ?? []; - const img = atts.find( - (a: { - contentType?: string | null; - url?: string; - discord_url?: string; - }) => /image/i.test(a.contentType ?? ""), - ); - return img?.url ?? img?.discord_url ?? null; - } catch { - return null; - } -} - -function extractImages(metadata?: string | null) { - try { - if (!metadata) return []; - const m = JSON.parse(metadata); - return (m?.attachments ?? []) - .filter((a: { contentType?: string | null }) => - /image/i.test(a.contentType ?? ""), - ) - .map((a: { url?: string; discord_url?: string; name?: string }) => ({ - src: a.url ?? a.discord_url ?? "", - alt: a.name, - })); - } catch { - return []; - } -} diff --git a/services/frontend/src/app/(dashboard)/moderation/page.tsx b/services/frontend/src/app/(dashboard)/moderation/page.tsx index 933293e..8ee70d4 100644 --- a/services/frontend/src/app/(dashboard)/moderation/page.tsx +++ b/services/frontend/src/app/(dashboard)/moderation/page.tsx @@ -1,25 +1,18 @@ -/** - * Moderation — Server Component. - * Seeds moderation stats + action log for SSR first paint; live via WS. - */ import { getModerationActions, getModerationStats } from "@/lib/api/server"; -import ModerationView from "./view"; +import { ModerationView } from "./view"; + +export const dynamic = "force-dynamic"; export default async function ModerationPage() { - const [stats, actions] = await Promise.allSettled([ - getModerationStats().catch(() => undefined), - getModerationActions(100).catch(() => undefined), - ]); - return ( - - ); + let stats = undefined; + let actions = undefined; + try { + [stats, actions] = await Promise.all([ + getModerationStats(), + getModerationActions(100), + ]); + } catch { + /* client hooks surface errors */ + } + return ; } diff --git a/services/frontend/src/app/(dashboard)/moderation/view.tsx b/services/frontend/src/app/(dashboard)/moderation/view.tsx index d98b833..a9558fd 100644 --- a/services/frontend/src/app/(dashboard)/moderation/view.tsx +++ b/services/frontend/src/app/(dashboard)/moderation/view.tsx @@ -1,19 +1,188 @@ "use client"; -import { ModerationSection } from "@/components/moderation/moderation-section"; -import type { ModerationAction, ModerationStats } from "@/lib/types"; +import { useEffect, useState } from "react"; +import { + ShieldAlert, + CheckCircle2, + XCircle, + Clock, + Ban, + Trash2, + MicOff, + AlertTriangle, + UserX, + MessageSquareWarning, + Filter, +} from "lucide-react"; +import { + useModerationStats, + useModerationActions, +} from "@/hooks"; +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 { formatNumber } from "@/lib/format"; +import type { + ModerationAction, + ModerationActionType, + ModerationStats, +} from "@/lib/types"; -export default function ModerationView({ +const ACTION_ICON: Record = { + delete_message: , + mute_user: , + warn_user: , + kick_user: , + ban_user: , +}; + +const ACTION_LABEL: Record = { + delete_message: "Delete", + mute_user: "Mute", + warn_user: "Warn", + kick_user: "Kick", + ban_user: "Ban", +}; + +export function ModerationView({ initialStats, initialActions, }: { initialStats?: ModerationStats; initialActions?: ModerationAction[]; }) { + const { data: stats, isLoading, error } = useModerationStats(initialStats); + const [statusFilter, setStatusFilter] = useState(""); + const [typeFilter, setTypeFilter] = useState(""); + const { data: actions } = useModerationActions( + statusFilter || undefined, + typeFilter || undefined, + !statusFilter && !typeFilter ? initialActions : undefined, + ); + + const failedRate = stats ? stats.failed_rate * 100 : 0; + + const byAction = stats?.by_action ?? {}; + const segments = Object.entries(byAction).map(([k, v]) => ({ + value: 1, + color: + k === "ban_user" || k === "kick_user" + ? "var(--color-vermilion)" + : k === "warn_user" + ? "var(--color-amber)" + : "var(--color-signal)", + label: k, + })); + + const ambient = useAmbient(); + useEffect(() => { + ambient.set( + failedRate > 20 ? "vermilion" : failedRate > 5 ? "amber" : "signal", + 0.3 + Math.min(0.4, failedRate / 50), + "moderation", + ); + }, [failedRate, ambient]); + + if (error && !stats) return ; + if (!stats && isLoading) return ; + + const statusOpts: SelectOption[] = [ + { value: "", label: "All statuses" }, + { value: "pending", label: "Pending" }, + { value: "executed", label: "Executed" }, + { value: "failed", label: "Failed" }, + ]; + const typeOpts: SelectOption[] = [ + { value: "", label: "All actions" }, + ...Object.keys(byAction).map((k) => ({ value: k, label: ACTION_LABEL[k as ModerationActionType] ?? k })), + ]; + return ( - +
+
+ } /> + } /> + 0 ? "vermilion" : "neutral"} icon={} /> + 0 ? "amber" : "neutral"} icon={} /> +
+ +
+ + +
+ +
+ {Object.entries(byAction).map(([k, v]) => { + const count = typeof v === "number" ? v : null; + return ( +
+ {ACTION_ICON[k as ModerationActionType]} + {ACTION_LABEL[k as ModerationActionType] ?? k} + {count !== null && {count}} +
+ ); + })} + {Object.keys(byAction).length === 0 && ( +
No actions recorded yet.
+ )} +
+
+
+ + + + + +
+ } + /> +
+ {(actions ?? []).map((a) => ( + + ))} + {(actions ?? []).length === 0 && ( +
No matching actions.
+ )} +
+ +
+
+ ); +} + +function ActionRow({ a }: { a: ModerationAction }) { + const tone = + a.status === "executed" ? "signal" : a.status === "failed" ? "vermilion" : "amber"; + const icon = ACTION_ICON[a.action_type] ?? ; + return ( +
+ {icon} +
+
+ {a.username ?? "unknown"} + {a.status} + + {a.created_at ? new Date(a.created_at).toLocaleString() : "—"} + +
+ {a.reason &&
“{a.reason}”
} + {a.content && ( +
+ {a.content} +
+ )} + {a.error &&
{a.error}
} +
+
); } diff --git a/services/frontend/src/app/(dashboard)/recordings/page.tsx b/services/frontend/src/app/(dashboard)/recordings/page.tsx index 571a4c2..2147584 100644 --- a/services/frontend/src/app/(dashboard)/recordings/page.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/page.tsx @@ -1,12 +1,14 @@ -/** - * Recordings — Server Component. - * Seeds the library from server-fetched recordings; live `voice_recording_uploaded` - * events (synced in the client View via WS) keep it fresh. - */ import { getRecordings } from "@/lib/api/server"; -import RecordingsView from "./view"; +import { RecordingsView } from "./view"; + +export const dynamic = "force-dynamic"; export default async function RecordingsPage() { - const data = await getRecordings(50).catch(() => undefined); - return ; + let recordings = undefined; + try { + recordings = await getRecordings(50); + } catch { + /* client hooks surface errors */ + } + return ; } diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx index d306afa..0594209 100644 --- a/services/frontend/src/app/(dashboard)/recordings/view.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx @@ -1,206 +1,92 @@ "use client"; -import { Delete, Download, Play } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useState } from "react"; -import { Waveform } from "@/components/charts/waveform"; -import { StaggerGroup, StaggerItem } from "@/components/motion/stagger"; -import { Avatar } from "@/components/primitives/avatar"; -import { Badge } from "@/components/primitives/badge"; -import { Button } from "@/components/primitives/button"; -import { Dialog } from "@/components/primitives/dialog"; -import { - useDeleteRecording, - useRecordings, - useRecordingsWsSync, -} from "@/hooks"; -import type { VoiceRecording } from "@/lib/types"; -import { cn } from "@/lib/utils"; +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 { formatBytes } from "@/lib/format"; +import { toast } from "@/components/primitives"; +import type { VoiceRecording } from "@/lib/types"; -interface RecordingsListProps { - recordings: VoiceRecording[]; - error: Error | null; - isLoading: boolean; - deleting: string | null; - onSelect: (rec: VoiceRecording) => void; - onDelete: (rec: VoiceRecording) => void; - preview: VoiceRecording | null; - onClosePreview: () => void; -} +export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording[] }) { + const ws = useWebSocket(); + const { data: items, isLoading, error } = useRecordings(initialItems); + const del = useDeleteRecording(); + useRecordingsWsSync(ws); + const ambient = useAmbient(); -function RecordingsList({ - recordings, - error, - isLoading, - deleting, - onSelect, - onDelete, - preview, - onClosePreview, -}: RecordingsListProps) { - if (isLoading) { - return ( -
- {Array.from({ length: 6 }).map((_, i) => ( -
- ))} -
- ); - } - if (error) - return ( -

- Failed to load: {error.message} -

- ); + useEffect(() => { + ambient.set("signal", 0.3, "recordings"); + }, [ambient]); + + const onDelete = async (id: string) => { + try { + await del.mutateAsync(id); + toast({ title: "Recording deleted", tone: "signal" }); + } catch (e) { + toast({ title: "Delete failed", description: String(e), tone: "vermilion" }); + } + }; + + if (error && !items) return ; + if (!items && isLoading) return ; return ( -
- - {recordings.map((rec) => ( - - - - -
-
- - {rec.username ?? "unknown"} - - - .{rec.filename.split(".").pop() ?? "mp3"} - -
-
- {(rec.size_bytes / 1024).toFixed(0)} KB ·{" "} - {new Date(rec.created_at * 1000).toLocaleTimeString()} + + {(items ?? []).length} clips} + /> + {(items ?? []).length === 0 ? ( + } title="No recordings" description="Voice clips captured by the bot appear here." /> + ) : ( +
+ {(items ?? []).map((r) => ( + +
+ +
+
{r.username}
+
+ {r.channel_name ?? "voice"} · {new Date(r.created_at).toLocaleString()} +
+ {formatBytes(r.size_bytes)}
-
- {rec.download_url && ( - <> - - - - - + + {r.download_url ? ( + // eslint-disable-next-line jsx-a11y/media-has-caption +
- ); -} - -function PreviewDialog({ - open, - onClose, - recording, -}: { - open: boolean; - onClose: () => void; - recording: VoiceRecording | null; -}) { - if (!recording) return null; - return ( - -
-
- {recording.filename} + + ))}
- {/* biome-ignore lint/a11y/useMediaCaption: voice recordings are uncaptioned audio previews — no transcript available */} -
-
- ); -} - -export default function RecordingsView({ - initialRecordings, -}: { - initialRecordings?: VoiceRecording[]; -}) { - const ws = useWebSocket(); - const { - data: recordings = [], - error, - isLoading, - } = useRecordings(initialRecordings); - const del = useDeleteRecording(); - const [deleting, setDeleting] = useState(null); - useRecordingsWsSync(ws); - - const handleDelete = useCallback( - (rec: VoiceRecording) => { - setDeleting(rec.id); - del.mutate(rec.id); - setTimeout(() => setDeleting(null), 800); - }, - [del], - ); - - const [preview, setPreview] = useState(null); - - return ( - setPreview(null)} - /> + )} + ); } diff --git a/services/frontend/src/app/(dashboard)/voice/page.tsx b/services/frontend/src/app/(dashboard)/voice/page.tsx index a36d4ea..1590f19 100644 --- a/services/frontend/src/app/(dashboard)/voice/page.tsx +++ b/services/frontend/src/app/(dashboard)/voice/page.tsx @@ -1,13 +1,15 @@ -/** - * Voice — Server Component. - * Seeds authoritative voice status (shared active speakers snapshot) on the - * server, then hands off to the client View for the 3D scene + WS live updates. - */ -import { getVoiceStatus } from "@/lib/api/server"; -import type { VoiceStatus } from "@/lib/types"; -import VoiceView from "./view"; +import { getGuilds, getVoiceStatus } from "@/lib/api/server"; +import { VoiceView } from "./view"; + +export const dynamic = "force-dynamic"; export default async function VoicePage() { - const status = await getVoiceStatus().catch(() => undefined); - return ; + let status = undefined; + let guilds = undefined; + try { + [status, guilds] = await Promise.all([getVoiceStatus(), getGuilds()]); + } catch { + /* client hooks surface errors */ + } + return ; } diff --git a/services/frontend/src/app/(dashboard)/voice/view.tsx b/services/frontend/src/app/(dashboard)/voice/view.tsx index 7b93704..04c76c7 100644 --- a/services/frontend/src/app/(dashboard)/voice/view.tsx +++ b/services/frontend/src/app/(dashboard)/voice/view.tsx @@ -1,232 +1,203 @@ "use client"; -import { Headphones, Loader2, Radio, RadioOff } from "lucide-react"; import { useEffect, useState } from "react"; -import { SignalField } from "@/components/three"; -import { WebGLGuard } from "@/components/three/webgl-guard"; -import { StaticFallback } from "@/components/three/static-fallback"; -import { StaggerGroup, StaggerItem } from "@/components/motion/stagger"; -import { Button } from "@/components/primitives/button"; -import { Badge } from "@/components/primitives/badge"; -import { Select } from "@/components/primitives/select"; -import { SpeakerWaveform } from "@/components/voice/speaker-waveform"; -import { SessionRibbon } from "@/components/charts/session-ribbon"; -import { ActiveSpeakersPanel } from "@/components/voice/active-speakers-panel"; -import { MicControl } from "@/components/voice/mic-control"; -import { ListenControl } from "@/components/voice/listen-control"; +import { Mic, MicOff, Headphones, PhoneOff, Radio, Volume2, Waves } from "lucide-react"; +import { useWebSocket } from "@/lib/ws/context"; import { useGuilds, - useMicTransmit, - useSpeakers, - useVoiceChannels, + useVoiceStatus, useVoiceConnect, useVoiceDisconnect, + useSpeakers, + useMicTransmit, useVoiceListen, - useVoiceStatus, } from "@/hooks"; -import type { VoiceStatus } from "@/lib/types"; -import { useWebSocket } from "@/lib/ws/context"; +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"; -export default function VoiceView({ initialStatus }: { initialStatus?: VoiceStatus }) { +export function VoiceView({ + initialStatus, + initialGuilds, +}: { + initialStatus?: VoiceStatus; + initialGuilds?: Guild[]; +}) { const ws = useWebSocket(); - const [selectedGuild, setSelectedGuild] = useState(""); - const [selectedChannel, setSelectedChannel] = useState(""); - - // Live connection status — SWR revalidates on connect/disconnect (the - // useVoiceConnect/Disconnect actions invalidate the "voice-status" key), - // so this reflects real-time state instead of the static SSR snapshot. - const { data: status } = useVoiceStatus(initialStatus); - const { speakers, subscribe } = useSpeakers(status?.activeSpeakers ?? []); - const { data: guilds = [] } = useGuilds(); - const { data: voiceChannels = [] } = useVoiceChannels(selectedGuild); + const { data: status, isLoading, error } = useVoiceStatus(initialStatus); + const { data: guilds } = useGuilds(initialGuilds); const connect = useVoiceConnect(); const disconnect = useVoiceDisconnect(); - const listen = useVoiceListen(ws); const mic = useMicTransmit(ws); - const [micActive, setMicActive] = useState(false); - const [micVolume, setMicVolume] = useState(75); + const listen = useVoiceListen(ws); + const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers); + const ambient = useAmbient(); + + const [guildId, setGuildId] = useState( + initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null, + ); + const [channelId, setChannelId] = useState( + initialStatus?.activeChannelId ?? null, + ); + const [micOn, setMicOn] = useState(false); useEffect(() => { const unsub = subscribe(ws); return unsub; }, [subscribe, ws]); - const active = speakers.filter((s) => s.speaking); - const connected = status?.connected ?? false; + useEffect(() => { + if (status?.connected) ambient.set("signal", 0.55, "voice live"); + else ambient.set("vermilion", 0.35, "voice idle"); + }, [status?.connected, ambient]); - const handleMicToggle = async (on: boolean) => { - if (on) { - try { - await mic.mutateAsync(true); - setMicActive(true); - } catch { - setMicActive(false); - } - } else { - setMicActive(false); - try { - await mic.mutateAsync(false); - } catch { - // Stop already tore down — ignore remote error - } + if (error && !status) return ; + if (!status && isLoading) return ; + + const connected = status?.connected ?? false; + const listenBars = Array.from(listen.levels.values()).slice(0, 32); + + const onConnect = async () => { + if (!guildId || !channelId) { + toast({ title: "Pick a guild + channel", tone: "vermilion" }); + return; + } + try { + await connect.mutateAsync({ guildId, channelId }); + toast({ title: "Connected to voice", tone: "signal" }); + } catch (e) { + toast({ title: "Connect failed", description: String(e), tone: "vermilion" }); } }; - const handleMicVolume = (v: number) => { - setMicVolume(v); - mic.setVolume(v); - }; - - const handleListenVolume = (v: number) => { - listen.setVolume(v); - }; - - const handleGuildChange = (e: React.ChangeEvent) => { - const g = e.target.value; - setSelectedGuild(g); - setSelectedChannel(""); + const onMic = async (on: boolean) => { + try { + await mic.mutateAsync(on); + setMicOn(on); + } catch (e) { + toast({ title: "Mic error", description: String(e), tone: "vermilion" }); + } }; return ( -
- {/* Connection bar with guild + voice channel pickers */} -
-
- - {connected ? "Connected" : "Disconnected"} - - {connected && status?.activeChannelName && ( - - - {status.activeChannelName} - +
+ +
+ { + setGuildId(g); + setChannelId(c); + }} + /> +
+ {connected ? ( + + ) : ( + + )} +
+
+ +
+ + + {listen.active && ( +
+ + +
)}
+
- {!connected ? ( -
- - - - - -
- ) : ( - - )} -
- - {/* Stage hero */} -
- + + + {speakers.length} present + + } + /> + {speakers.length === 0 ? ( + } + title={connected ? "Silent right now" : "Not connected"} + description={connected ? "Speakers appear as they talk." : "Connect to a voice channel to see presence."} /> - } - > - 0 ? 0.6 : 0.2} - className="absolute inset-0" - /> - -
- -
+ ) : ( + <> + +
+ {speakers.map((sp) => ( + + + {sp.username} + + ))} +
+ + )} + + + + +
+ {(status?.connections ?? []).map((c) => ( +
+ + {c.channelName} + {new Date(c.connectedAt).toLocaleTimeString()} +
+ ))} + {(status?.connections ?? []).length === 0 && ( +
No active links
+ )} +
+
+ channel{" "} + {status?.activeChannelName ?? "—"} +
+
- - - - - - - listen.toggle(on)} - volume={75} - onVolume={handleListenVolume} - /> - - - - {/* Activity timeline */} -
-

- Live session timeline -

- ({ - id: s.userId, - label: s.username, - value: s.speaking ? 3 : 1, - tone: s.speaking ? "signal" : "neutral", - }))} - /> -
- -
); } diff --git a/services/frontend/src/app/globals.css b/services/frontend/src/app/globals.css index 6dca6d7..50bc960 100644 --- a/services/frontend/src/app/globals.css +++ b/services/frontend/src/app/globals.css @@ -3,69 +3,66 @@ @custom-variant dark (&:is(.dark *)); /* - * GMW — new design system (visual overhaul). + * GMW — Ambient Ops Console design system. * - * Replaces the old teal-cyan + purple + glassmorphism language with a warm, - * signal-driven ops-console aesthetic. Hierarchy comes from scale/weight and - * tonal surface blocks, NOT from borders/shadows. Three semantic signals: - * lime = OK / live (--signal) - * amber = warn (--amber) - * vermilion = flag/danger (--vermilion) + * A signal-driven, immersive ops aesthetic. Hierarchy comes from scale/weight + * and tonal surface blocks, not borders. The whole app sits behind a live + * WebGL haze (see components/ambient) tinted by a semantic signal: + * lime = OK / live + * amber = warn + * vermilion = flag / danger */ @theme { - /* ── Surfaces ── */ - --color-canvas: oklch(0.96 0.012 80); - --color-surface: oklch(0.92 0.014 80); - --color-surface-2: oklch(0.88 0.016 80); + /* ── Surfaces (dark-first; light overrides below) ── */ + --color-canvas: oklch(0.12 0.014 70); + --color-canvas-2: oklch(0.16 0.02 70); + --color-surface: oklch(0.2 0.022 70 / 0.55); + --color-surface-2: oklch(0.26 0.024 70 / 0.45); /* ── Ink ── */ - --color-ink: oklch(0.22 0.02 70); - --color-ink-soft: oklch(0.46 0.02 70); + --color-ink: oklch(0.95 0.008 75); + --color-ink-soft: oklch(0.66 0.02 75); + --color-ink-faint: oklch(0.5 0.02 75); /* ── Structural ── */ - --color-hairline: oklch(0.22 0.02 70 / 0.1); + --color-hairline: oklch(1 0 0 / 0.1); --hairline-w: 1px; /* ── Semantic signals ── */ - --color-signal: oklch(0.78 0.17 125); - --color-signal-ink: oklch(0.20 0.03 70); - --color-signal-glow: oklch(0.78 0.17 125 / 0.35); - --color-amber: oklch(0.80 0.15 70); - --color-vermilion: oklch(0.62 0.21 25); - --color-vermilion-soft: oklch(0.62 0.21 25 / 0.15); + --color-signal: oklch(0.86 0.19 128); + --color-signal-ink: oklch(0.18 0.03 70); + --color-signal-glow: oklch(0.86 0.19 128 / 0.4); + --color-amber: oklch(0.85 0.15 72); + --color-vermilion: oklch(0.68 0.21 25); + --color-vermilion-glow: oklch(0.68 0.21 25 / 0.4); --color-ring: var(--color-signal); /* ── Fonts ── */ --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; - --font-mono: "JetBrains Mono", ui-monospace, monospace; - --font-display: "Bricolage Grotesque", "Inter", sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --font-display: "Bricolage Grotesque", "Inter", system-ui, sans-serif; /* ── Radii ── */ - --radius-r: 14px; + --radius-r: 16px; --radius-r-panel: 12px; - --radius-r-control: 8px; + --radius-r-control: 9px; --radius-r-pill: 9999px; } -/* ── Dark theme overrides ── */ -.dark { - --color-canvas: oklch(0.13 0.015 70); - --color-surface: oklch(0.18 0.02 70); - --color-surface-2: oklch(0.23 0.022 70); +/* ── Light theme overrides ── */ +.light { + --color-canvas: oklch(0.96 0.012 80); + --color-canvas-2: oklch(0.92 0.014 80); + --color-surface: oklch(1 0 0 / 0.7); + --color-surface-2: oklch(1 0 0 / 0.5); - --color-ink: oklch(0.93 0.01 75); - --color-ink-soft: oklch(0.62 0.02 75); + --color-ink: oklch(0.22 0.02 70); + --color-ink-soft: oklch(0.46 0.02 70); + --color-ink-faint: oklch(0.6 0.02 70); - --color-hairline: oklch(1 0 0 / 0.09); - - --color-signal: oklch(0.88 0.18 125); - --color-signal-ink: oklch(0.18 0.03 70); - --color-signal-glow: oklch(0.88 0.18 125 / 0.4); - --color-amber: oklch(0.85 0.15 70); - --color-vermilion: oklch(0.68 0.21 25); - --color-vermilion-soft: oklch(0.68 0.21 25 / 0.18); + --color-hairline: oklch(0.22 0.02 70 / 0.12); } @layer base { @@ -79,41 +76,28 @@ body { @apply bg-canvas text-ink font-sans antialiased; - /* warm dot-grid texture + faint glow — replaces old bluish radial layers */ - background-image: - radial-gradient(circle, oklch(0.45 0.03 70 / 0.05) 1px, transparent 1px), - radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.78 0.17 125 / 0.06), transparent), - radial-gradient(ellipse 50% 40% at 85% 90%, oklch(0.80 0.15 70 / 0.04), transparent); - background-size: 26px 26px, 100% 100%, 100% 100%; - } - - .dark body { - background-image: - radial-gradient(circle, oklch(1 0 0 / 0.022) 1px, transparent 1px), - radial-gradient(ellipse 80% 50% at 50% -20%, oklch(0.88 0.18 125 / 0.05), transparent), - radial-gradient(ellipse 50% 40% at 85% 90%, oklch(0.85 0.15 70 / 0.03), transparent); - background-size: 26px 26px, 100% 100%, 100% 100%; + min-height: 100dvh; } ::selection { - background: oklch(0.78 0.17 125 / 0.35); + background: var(--color-signal-glow); color: inherit; } - /* Scrollbar — warm */ + /* Scrollbar */ ::-webkit-scrollbar { - width: 7px; - height: 7px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { - background: oklch(0.4 0.02 70 / 0.22); + background: oklch(1 0 0 / 0.14); border-radius: 999px; } ::-webkit-scrollbar-thumb:hover { - background: oklch(0.4 0.02 70 / 0.38); + background: oklch(1 0 0 / 0.26); } :focus-visible { @@ -122,59 +106,48 @@ } } -@layer utilities { - /* Tonal block that replaces bordered cards */ - .surface { +@layer components { + /* Glass panel — the workhorse container. Floating, blurred, faint glow. */ + .glass { background: var(--color-surface); - border-radius: var(--radius-r); border: var(--hairline-w) solid var(--color-hairline); + border-radius: var(--radius-r); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); + box-shadow: + 0 1px 0 0 oklch(1 0 0 / 0.06) inset, + 0 18px 50px -28px oklch(0 0 0 / 0.8); } - .surface-2 { + + .glass-2 { background: var(--color-surface-2); + border: var(--hairline-w) solid var(--color-hairline); border-radius: var(--radius-r-panel); - border: var(--hairline-w) solid var(--color-hairline); + backdrop-filter: blur(14px) saturate(130%); + -webkit-backdrop-filter: blur(14px) saturate(130%); } - /* 1px animated pulse line marking a live/section header */ - .scan-tick { - position: relative; - overflow: hidden; - } - .scan-tick::after { - content: ""; - position: absolute; - inset-inline-start: 0; - inset-block-start: 0; - block-size: 1px; - inline-size: 100%; - background: linear-gradient( - 90deg, - transparent, - var(--color-signal) 20%, - var(--color-signal) 80%, - transparent - ); - background-size: 200% 100%; - animation: scan 2.6s linear infinite; - opacity: 0.7; - } - - .ticker { - background: var(--color-surface); - border-radius: var(--radius-r); - border: var(--hairline-w) solid var(--color-hairline); - padding: clamp(1rem, 2vw, 1.5rem); + /* Section label — small uppercase mono eyebrow */ + .eyebrow { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-ink-faint); } .pill { display: inline-flex; align-items: center; - gap: 0.35rem; - padding: 0.2rem 0.7rem; + gap: 0.4rem; + padding: 0.22rem 0.7rem; border-radius: var(--radius-r-pill); font-size: 0.72rem; font-weight: 600; - letter-spacing: 0.02em; + letter-spacing: 0.01em; + background: oklch(1 0 0 / 0.06); + border: 1px solid var(--color-hairline); + color: var(--color-ink-soft); } .mono { @@ -186,26 +159,58 @@ .display { font-family: var(--font-display); font-weight: 800; - letter-spacing: -0.02em; - line-height: 1.02; + letter-spacing: -0.03em; + line-height: 0.96; } +} - /* text tone helpers */ +@layer utilities { .text-signal { color: var(--color-signal); } .text-amber { color: var(--color-amber); } .text-vermilion { color: var(--color-vermilion); } .text-ink-soft { color: var(--color-ink-soft); } + .text-ink-faint { color: var(--color-ink-faint); } + + .glow-signal { + text-shadow: 0 0 22px var(--color-signal-glow); + } + .glow-vermilion { + text-shadow: 0 0 22px var(--color-vermilion-glow); + } + + /* animated scan line for live headers */ + .scan-line { + position: relative; + overflow: hidden; + } + .scan-line::after { + content: ""; + position: absolute; + left: 0; + top: 0; + height: 1px; + width: 100%; + background: linear-gradient( + 90deg, + transparent, + var(--color-signal) 25%, + var(--color-signal) 75%, + transparent + ); + background-size: 200% 100%; + animation: scan 2.8s linear infinite; + opacity: 0.7; + } - /* focus ring helper for interactive blocks */ .ring-focus { - transition: box-shadow 0.18s ease; + transition: box-shadow 0.18s ease, border-color 0.18s ease; } .ring-focus:hover { box-shadow: 0 0 0 1px var(--color-signal-glow); } } -/* ── Keyframes ──────────────────────────── */ +/* ── Keyframes ── */ @keyframes scan { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } @@ -216,54 +221,52 @@ 60% { transform: scaleY(0.5); } } @keyframes fade-up { - from { opacity: 0; transform: translateY(8px); } + from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } @keyframes spin-disc { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } -/* kept for live status dots */ @keyframes pulse-ring { - 0% { transform: scale(0.8); opacity: 1; } - 100% { transform: scale(2.5); opacity: 0; } + 0% { transform: scale(0.85); opacity: 1; } + 100% { transform: scale(2.6); opacity: 0; } } @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } +@keyframes breathe { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } +} .animate-eq { animation: eq 0.9s ease-in-out infinite; transform-origin: bottom; } -.animate-spin-disc { - animation: spin-disc 8s linear infinite; -} -.animate-spin-disc.paused { - animation-play-state: paused; -} -.animate-pulse-ring { - animation: pulse-ring 1.5s ease-out infinite; -} +.animate-spin-disc { animation: spin-disc 9s linear infinite; } +.animate-spin-disc.paused { animation-play-state: paused; } +.animate-pulse-ring { animation: pulse-ring 1.6s ease-out infinite; } .animate-shimmer { background: linear-gradient( 90deg, transparent, - oklch(0.78 0.17 125 / 0.08), + oklch(0.86 0.19 128 / 0.1), transparent ); background-size: 200% 100%; - animation: shimmer 1.5s infinite; + animation: shimmer 1.6s infinite; } +.animate-breathe { animation: breathe 3.5s ease-in-out infinite; } -/* ── Reduced motion: kill all decorative animation ── */ @media (prefers-reduced-motion: reduce) { - .scan-tick::after, + .scan-line::after, .animate-eq, .animate-spin-disc, .animate-pulse-ring, - .animate-shimmer { + .animate-shimmer, + .animate-breathe { animation: none !important; } html { scroll-behavior: auto; } diff --git a/services/frontend/src/components/ambient/ambient-canvas.tsx b/services/frontend/src/components/ambient/ambient-canvas.tsx new file mode 100644 index 0000000..36be34d --- /dev/null +++ b/services/frontend/src/components/ambient/ambient-canvas.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import * as THREE from "three"; +import { SIGNAL_RGB, type SignalTone } from "./ambient-context"; + +const VERT = /* glsl */ ` + varying vec2 vUv; + void main(){ + vUv = uv; + gl_Position = vec4(position.xy, 0.0, 1.0); + } +`; + +const FRAG = /* glsl */ ` + precision mediump float; + varying vec2 vUv; + uniform float uTime; + uniform vec3 uColor; + uniform float uIntensity; + uniform vec2 uRes; + + float hash(vec2 p){ p=fract(p*vec2(123.34,456.21)); p+=dot(p,p+45.32); return fract(p.x*p.y); } + float noise(vec2 p){ + vec2 i=floor(p); vec2 f=fract(p); + float a=hash(i), b=hash(i+vec2(1.,0.)), c=hash(i+vec2(0.,1.)), d=hash(i+vec2(1.,1.)); + vec2 u=f*f*(3.-2.*f); + return mix(mix(a,b,u.x),mix(c,d,u.x),u.y); + } + float fbm(vec2 p){ + float v=0.0, a=0.5; + mat2 m=mat2(1.6,1.2,-1.2,1.6); + for(int i=0;i<5;i++){ v+=a*noise(p); p=m*p; a*=0.5; } + return v; + } + + void main(){ + vec2 uv=vUv; + vec2 p=uv-0.5; + p.x*=uRes.x/uRes.y; + float t=uTime*0.04*(0.6+uIntensity); + vec2 q=vec2(fbm(p*1.5+t), fbm(p*1.5-t+5.0)); + float f=fbm(p*2.2 + q*1.8 + t*0.5); + vec2 c=vec2(sin(uTime*0.05)*0.25, cos(uTime*0.04)*0.18); + float d=length(p-c); + float glow=smoothstep(0.95,0.0,d)*0.5; + float haze=(f*0.7+glow)*uIntensity; + vec3 col=uColor*haze; + float g=hash(uv*uRes+uTime)*0.035; + col+=g; + float vig=smoothstep(1.25,0.15,length(p)); + col*=0.35+0.65*vig; + gl_FragColor=vec4(col,1.0); + } +`; + +const MOTE_COUNT = 140; + +export function AmbientCanvas({ + targetRef, +}: { + targetRef: React.MutableRefObject<{ tone: SignalTone; intensity: number }>; +}) { + const mountRef = useRef(null); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return; + + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ + antialias: false, + alpha: false, + powerPreference: "high-performance", + }); + } catch { + return; // static CSS fallback remains + } + + const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const dpr = Math.min(window.devicePixelRatio || 1, 1.5); + renderer.setPixelRatio(dpr); + renderer.setSize(mount.clientWidth, mount.clientHeight); + mount.appendChild(renderer.domElement); + renderer.domElement.style.width = "100%"; + renderer.domElement.style.height = "100%"; + renderer.domElement.style.display = "block"; + + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + + const uniforms = { + uTime: { value: 0 }, + uColor: { value: new THREE.Color(...SIGNAL_RGB.signal) }, + uIntensity: { value: 0.35 }, + uRes: { value: new THREE.Vector2(1, 1) }, + }; + + const quad = new THREE.Mesh( + new THREE.PlaneGeometry(2, 2), + new THREE.ShaderMaterial({ + vertexShader: VERT, + fragmentShader: FRAG, + uniforms, + depthTest: false, + depthWrite: false, + }), + ); + scene.add(quad); + + // — Drifting motes — + const positions = new Float32Array(MOTE_COUNT * 3); + const speeds = new Float32Array(MOTE_COUNT); + for (let i = 0; i < MOTE_COUNT; i++) { + positions[i * 3] = (Math.random() - 0.5) * 2; + positions[i * 3 + 1] = (Math.random() - 0.5) * 2; + positions[i * 3 + 2] = 0; + speeds[i] = 0.01 + Math.random() * 0.03; + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + const moteMat = new THREE.PointsMaterial({ + size: 0.012, + color: new THREE.Color(...SIGNAL_RGB.signal), + transparent: true, + opacity: 0.5, + blending: THREE.AdditiveBlending, + depthTest: false, + depthWrite: false, + }); + const motes = new THREE.Points(geo, moteMat); + scene.add(motes); + + const color = new THREE.Color(); + const target = new THREE.Color(); + let targetIntensity = 0.35; + let intensity = 0.35; + let raf = 0; + let last = performance.now(); + let running = !reduce; + + const resize = () => { + const w = mount.clientWidth || 1; + const h = mount.clientHeight || 1; + renderer.setSize(w, h); + uniforms.uRes.value.set(w * dpr, h * dpr); + }; + const ro = new ResizeObserver(resize); + ro.observe(mount); + resize(); + + const onVisibility = () => { + running = !document.hidden && !reduce; + if (running) { + last = performance.now(); + loop(); + } + }; + document.addEventListener("visibilitychange", onVisibility); + + const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + + const frame = (now: number) => { + const dt = Math.min((now - last) / 1000, 0.05); + last = now; + uniforms.uTime.value += dt; + + // Ease toward target signal/intensity each frame (no React re-render). + const tgt = targetRef.current; + target.set(...SIGNAL_RGB[tgt.tone]); + color.lerp(target, 0.04); + uniforms.uColor.value.copy(color); + moteMat.color.copy(color); + targetIntensity = 0.2 + tgt.intensity * 0.8; + intensity = lerp(intensity, targetIntensity, 0.04); + uniforms.uIntensity.value = intensity; + + const pos = geo.attributes.position as THREE.BufferAttribute; + for (let i = 0; i < MOTE_COUNT; i++) { + let y = pos.getY(i) + speeds[i] * dt * (0.5 + tgt.intensity); + if (y > 1.1) y = -1.1; + pos.setY(i, y); + } + pos.needsUpdate = true; + + renderer.render(scene, camera); + if (running) raf = requestAnimationFrame(frame); + }; + + const loop = () => { + if (raf) cancelAnimationFrame(raf); + last = performance.now(); + raf = requestAnimationFrame(frame); + }; + + if (reduce) { + // single static frame + uniforms.uIntensity.value = 0.3; + renderer.render(scene, camera); + } else { + loop(); + } + + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + document.removeEventListener("visibilitychange", onVisibility); + geo.dispose(); + moteMat.dispose(); + (quad.geometry as THREE.BufferGeometry).dispose(); + (quad.material as THREE.Material).dispose(); + renderer.dispose(); + if (renderer.domElement.parentNode === mount) { + mount.removeChild(renderer.domElement); + } + }; + }, [targetRef]); + + return ( +
+ ); +} diff --git a/services/frontend/src/components/ambient/ambient-context.tsx b/services/frontend/src/components/ambient/ambient-context.tsx new file mode 100644 index 0000000..2533268 --- /dev/null +++ b/services/frontend/src/components/ambient/ambient-context.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; +import { AmbientCanvas } from "./ambient-canvas"; + +export type SignalTone = "signal" | "amber" | "vermilion"; + +/** sRGB triplets for the three semantic signals (matches globals.css). */ +export const SIGNAL_RGB: Record = { + signal: [0.42, 1.0, 0.52], + amber: [1.0, 0.76, 0.28], + vermilion: [1.0, 0.34, 0.28], +}; + +export interface AmbientState { + tone: SignalTone; + /** 0..1 — drives haze density + drift speed (e.g. server load). */ + intensity: number; + label?: string; +} + +export interface AmbientControls { + set: (tone: SignalTone, intensity?: number, label?: string) => void; + reset: () => void; + state: AmbientState; +} + +const DEFAULT: AmbientState = { tone: "signal", intensity: 0.35, label: "nominal" }; + +const AmbientContext = createContext(null); + +/** + * Holds the live ambient signal. The canvas reads `targetRef` inside its + * render loop (no React re-render per frame); `state` is mirrored into React + * only so small UI bits (topbar) can reflect the current tone. + */ +export function AmbientProvider({ children }: { children: React.ReactNode }) { + const targetRef = useRef({ ...DEFAULT }); + const [state, setState] = useState(DEFAULT); + + const set = useCallback((tone: SignalTone, intensity?: number, label?: string) => { + targetRef.current = { + tone, + intensity: intensity ?? targetRef.current.intensity, + label: label ?? targetRef.current.label, + }; + setState({ ...targetRef.current }); + }, []); + + const reset = useCallback(() => { + targetRef.current = { ...DEFAULT }; + setState({ ...DEFAULT }); + }, []); + + const value = useMemo( + () => ({ set, reset, state }), + [set, reset, state], + ); + + return ( + + + {children} + + ); +} + +export function useAmbient(): AmbientControls { + const ctx = useContext(AmbientContext); + if (!ctx) throw new Error("useAmbient must be used within "); + return ctx; +} diff --git a/services/frontend/src/components/ambient/ambient-field.tsx b/services/frontend/src/components/ambient/ambient-field.tsx deleted file mode 100644 index 3503d99..0000000 --- a/services/frontend/src/components/ambient/ambient-field.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; - -/** - * AmbientField — full-bleed WebGL particle haze that reacts to live data. - * - * No container, no grid, no chrome. Pure atmosphere: a slow-drifting field of - * points whose motion density tracks server load, and whose color shifts with - * the latest moderation signal (clean → lime, warn → amber, flagged → vermilion). - * - * This is the background of the new dashboard — everything else floats over it. - */ - -type Signal = "neutral" | "signal" | "amber" | "vermilion"; - -const SIGNAL_RGB: Record = { - neutral: [0.52, 0.49, 0.46], - signal: [0.78, 0.85, 0.62], - amber: [0.95, 0.78, 0.42], - vermilion: [0.86, 0.32, 0.28], -}; - -interface AmbientFieldProps { - /** 0..1 — drives particle drift speed + density. */ - load?: number; - /** Latest moderation signal — tints the haze. */ - signal?: Signal; -} - -export function AmbientField({ - load = 0.3, - signal = "signal", -}: AmbientFieldProps) { - const canvasRef = useRef(null); - const loadRef = useRef(load); - const signalRef = useRef<[number, number, number]>(SIGNAL_RGB[signal]); - const rafRef = useRef(0); - - useEffect(() => { - loadRef.current = load; - }, [load]); - - useEffect(() => { - signalRef.current = SIGNAL_RGB[signal]; - }, [signal]); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - let w = 0; - let h = 0; - const dpr = Math.min(window.devicePixelRatio || 1, 2); - - const resize = () => { - w = canvas.clientWidth; - h = canvas.clientHeight; - canvas.width = w * dpr; - canvas.height = h * dpr; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - }; - resize(); - const ro = new ResizeObserver(resize); - ro.observe(canvas); - - // Particle haze - const N = 90; - const pts = Array.from({ length: N }, () => ({ - x: Math.random(), - y: Math.random(), - z: Math.random() * 0.8 + 0.2, - vx: (Math.random() - 0.5) * 0.0004, - vy: (Math.random() - 0.5) * 0.0004, - r: Math.random() * 1.5 + 0.5, - })); - - const draw = () => { - const [cr, cg, cb] = signalRef.current; - const speed = 0.4 + loadRef.current * 1.6; - - // Trail fade - ctx.fillStyle = "rgba(244, 240, 234, 0.06)"; - ctx.fillRect(0, 0, w, h); - - for (const p of pts) { - p.x += p.vx * speed; - p.y += p.vy * speed; - if (p.x < 0) p.x += 1; - if (p.x > 1) p.x -= 1; - if (p.y < 0) p.y += 1; - if (p.y > 1) p.y -= 1; - - const px = p.x * w; - const py = p.y * h; - const rad = p.r * p.z * (1 + loadRef.current); - const alpha = 0.05 + p.z * 0.12; - ctx.beginPath(); - ctx.arc(px, py, rad, 0, Math.PI * 2); - ctx.fillStyle = `rgba(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)}, ${alpha})`; - ctx.fill(); - } - - // Faint vignette glow center - const grad = ctx.createRadialGradient( - w / 2, - h / 2, - 0, - w / 2, - h / 2, - Math.max(w, h) * 0.6, - ); - grad.addColorStop( - 0, - `rgba(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)}, 0.03)`, - ); - grad.addColorStop(1, "rgba(0,0,0,0)"); - ctx.fillStyle = grad; - ctx.fillRect(0, 0, w, h); - - rafRef.current = requestAnimationFrame(draw); - }; - rafRef.current = requestAnimationFrame(draw); - - return () => { - cancelAnimationFrame(rafRef.current); - ro.disconnect(); - }; - }, []); - - return ( - - ); -} diff --git a/services/frontend/src/components/analysis/search-panel.tsx b/services/frontend/src/components/analysis/search-panel.tsx deleted file mode 100644 index 7260af0..0000000 --- a/services/frontend/src/components/analysis/search-panel.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; - -import { Loader2, Search, Sparkles } from "lucide-react"; -import { useCallback, useState } from "react"; -import { Avatar } from "@/components/primitives/avatar"; -import { Badge } from "@/components/primitives/badge"; -import { Button } from "@/components/primitives/button"; -import { Input } from "@/components/primitives/input"; -import { Progress } from "@/components/primitives/progress"; -import { EmptyState, LoadingSkeleton } from "@/components/shared"; -import { useMessageSearch } from "@/hooks"; -import { renderMessageContent, safeParseJsonArray } from "@/lib/format"; - -export function SearchPanel() { - const [query, setQuery] = useState(""); - const [enabled, setEnabled] = useState(false); - - const { data: results, isValidating: isFetching } = useMessageSearch( - query, - enabled, - ); - - const handleSearch = useCallback(() => { - if (!query.trim()) return; - setEnabled(true); - }, [query]); - - return ( -
-
-
- - setQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - className="pl-9 h-9" - /> -
- -
- - {isFetching ? ( - - ) : results !== undefined ? ( - <> -

- Found {results.length} result{results.length !== 1 ? "s" : ""} -

- {results.length === 0 ? ( - - ) : ( -
- {results.map((msg) => ( -
-
- -
-
- - {msg.username} - - - {msg.created_at - ? new Date(msg.created_at).toLocaleString() - : ""} - - {msg.ai_status && ( - - {msg.ai_status} - - )} -
-

- {renderMessageContent( - msg.edited_content ?? msg.content, - msg.metadata, - )} -

- {msg.ai_moderation_flags && - msg.ai_moderation_flags !== "[]" && ( -
- {safeParseJsonArray(msg.ai_moderation_flags).map( - (flag) => ( - - {flag} - - ), - )} -
- )} - {msg.ai_analysis && ( -

- - {msg.ai_analysis} -

- )} - {msg.ai_confidence != null && ( -
- - - {(msg.ai_confidence * 100).toFixed(0)}% - -
- )} -
-
-
- ))} -
- )} - - ) : ( -
- -

- Enter a search query to find messages across all channels. -

-

- Searches message content, AI flags, and analysis text. -

-
- )} -
- ); -} diff --git a/services/frontend/src/components/charts/area-activity.tsx b/services/frontend/src/components/charts/area-activity.tsx index 00f29ac..5d87306 100644 --- a/services/frontend/src/components/charts/area-activity.tsx +++ b/services/frontend/src/components/charts/area-activity.tsx @@ -1,83 +1,48 @@ -"use client"; - -import { motion, useReducedMotion } from "motion/react"; -import { useId } from "react"; - -export interface AreaPoint { - label: string; - value: number; -} - -export interface AreaActivityProps { - data: AreaPoint[]; - height?: number; - stroke?: string; - className?: string; - label?: string; -} +import type { DailyActivityPoint } from "@/lib/types"; +/** + * Dual-area activity chart: total messages (signal) vs flagged (vermilion). + * Pure SVG, scales to container. Includes a 7-day trailing window hint. + */ export function AreaActivity({ - data, - height = 160, - stroke = "var(--color-signal)", - className, - label, -}: AreaActivityProps) { - const id = useId().replace(/:/g, ""); - const reduce = useReducedMotion(); - const width = 600; - if (data.length === 0) - return
; + daily, + height = 200, +}: { + daily: DailyActivityPoint[]; + height?: number; +}) { + const w = 720; + const pad = 8; + const n = daily.length; + const max = Math.max(...daily.map((d) => d.messages), 1); + const x = (i: number) => pad + (i / Math.max(n - 1, 1)) * (w - pad * 2); + const y = (v: number) => height - pad - (v / max) * (height - pad * 2); - const max = Math.max(...data.map((d) => d.value), 1); - const stepX = width / Math.max(data.length - 1, 1); - const pts = data.map((d, i) => { - const x = i * stepX; - const y = height - (d.value / max) * (height - 10) - 5; - return [x, y] as const; - }); - const line = pts - .map( - (p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`, - ) - .join(" "); - const area = `${line} L${width},${height} L0,${height} Z`; - const pathLen = 1400; + const msgLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.messages).toFixed(1)}`).join(" "); + const flagLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.flagged).toFixed(1)}`).join(" "); + const msgArea = `${msgLine} L${x(n - 1).toFixed(1)},${height - pad} L${x(0).toFixed(1)},${height - pad} Z`; return ( - + - - - + + + - - + {[0.25, 0.5, 0.75].map((g) => ( + + ))} + + + + {daily.map((d, i) => + i % 2 === 0 ? ( + + {d.day.slice(5)} + + ) : null, + )} ); } diff --git a/services/frontend/src/components/charts/donut.tsx b/services/frontend/src/components/charts/donut.tsx new file mode 100644 index 0000000..97cee3b --- /dev/null +++ b/services/frontend/src/components/charts/donut.tsx @@ -0,0 +1,51 @@ +/** Stacked donut for moderation overview / composition. */ +export function Donut({ + segments, + size = 132, + thickness = 14, + centerLabel, + centerSub, +}: { + segments: { value: number; color: string; label: string }[]; + size?: number; + thickness?: number; + centerLabel?: string; + centerSub?: string; +}) { + const total = segments.reduce((s, x) => s + x.value, 0) || 1; + const r = size / 2 - thickness / 2; + const c = 2 * Math.PI * r; + let offset = 0; + return ( +
+ + + {segments.map((s, i) => { + const len = (s.value / total) * c; + const el = ( + + ); + offset += len; + return el; + })} + + {(centerLabel || centerSub) && ( +
+ {centerLabel && {centerLabel}} + {centerSub && {centerSub}} +
+ )} +
+ ); +} diff --git a/services/frontend/src/components/charts/index.ts b/services/frontend/src/components/charts/index.ts new file mode 100644 index 0000000..f15c2dc --- /dev/null +++ b/services/frontend/src/components/charts/index.ts @@ -0,0 +1,5 @@ +export { Sparkline } from "./sparkline"; +export { AreaActivity } from "./area-activity"; +export { RadialGauge } from "./radial-gauge"; +export { Donut } from "./donut"; +export { Equalizer } from "./waveform"; diff --git a/services/frontend/src/components/charts/radial-gauge.tsx b/services/frontend/src/components/charts/radial-gauge.tsx index d7a603b..b975109 100644 --- a/services/frontend/src/components/charts/radial-gauge.tsx +++ b/services/frontend/src/components/charts/radial-gauge.tsx @@ -1,90 +1,45 @@ -"use client"; - -import { motion, useReducedMotion } from "motion/react"; -import { useId } from "react"; - -export interface RadialGaugeProps { - /** 0..1 health ratio */ - value: number; - size?: number; - label?: string; - sublabel?: string; - tone?: "signal" | "amber" | "vermilion"; -} - -const toneColor = { - signal: "var(--color-signal)", - amber: "var(--color-amber)", - vermilion: "var(--color-vermilion)", -}; +import { cn } from "@/lib/utils"; +/** Circular progress gauge. value 0..1. */ export function RadialGauge({ value, - size = 160, label, sublabel, tone = "signal", -}: RadialGaugeProps) { - const id = useId().replace(/:/g, ""); - const reduce = useReducedMotion(); - const stroke = 12; - const r = (size - stroke) / 2; + size = 120, +}: { + value: number; + label: string; + sublabel?: string; + tone?: "signal" | "amber" | "vermilion"; + size?: number; +}) { + const v = Math.max(0, Math.min(1, value)); + const stroke = tone === "vermilion" ? "var(--color-vermilion)" : tone === "amber" ? "var(--color-amber)" : "var(--color-signal)"; + const r = size / 2 - 10; const c = 2 * Math.PI * r; - const pct = Math.max(0, Math.min(1, value)); - const dash = c * pct; - return ( -
- +
+ + - -
- - {Math.round(pct * 100)}% +
+ + {label} - {label && ( - - {label} - - )} - {sublabel && ( - - {sublabel} - - )} + {sublabel && {sublabel}}
); diff --git a/services/frontend/src/components/charts/session-ribbon.tsx b/services/frontend/src/components/charts/session-ribbon.tsx deleted file mode 100644 index a2e6e4d..0000000 --- a/services/frontend/src/components/charts/session-ribbon.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; - -export interface RibbonSegment { - id: string; - label: string; - value: number; // relative duration - tone?: "signal" | "amber" | "vermilion" | "neutral"; -} - -const toneClass = { - signal: "bg-[var(--color-signal)]", - amber: "bg-[var(--color-amber)]", - vermilion: "bg-[var(--color-vermilion)]", - neutral: "bg-[var(--color-ink-soft)]/40", -}; - -export interface SessionRibbonProps { - segments: RibbonSegment[]; - className?: string; - height?: number; -} - -export function SessionRibbon({ - segments, - className, - height = 28, -}: SessionRibbonProps) { - const total = segments.reduce((s, x) => s + x.value, 0) || 1; - return ( -
- {segments.map((s) => ( -
- - {s.label} - -
- ))} -
- ); -} diff --git a/services/frontend/src/components/charts/sparkline.tsx b/services/frontend/src/components/charts/sparkline.tsx index 61a9a6f..6370278 100644 --- a/services/frontend/src/components/charts/sparkline.tsx +++ b/services/frontend/src/components/charts/sparkline.tsx @@ -1,77 +1,42 @@ -"use client"; - -import { useId } from "react"; - -export interface SparklineProps { - data: number[]; - width?: number; - height?: number; - stroke?: string; - className?: string; - fill?: boolean; -} +import { cn } from "@/lib/utils"; +/** Minimal sparkline. Pure SVG, scales to container width. */ export function Sparkline({ - data, - width = 120, - height = 36, - stroke = "var(--color-signal)", + values, className, + stroke = "var(--color-signal)", fill = true, -}: SparklineProps) { - const id = useId().replace(/:/g, ""); - if (data.length < 2) - return ( - - ); - - const min = Math.min(...data); - const max = Math.max(...data); + height = 40, +}: { + values: number[]; + className?: string; + stroke?: string; + fill?: boolean; + height?: number; +}) { + if (values.length === 0) return null; + const w = 100; + const max = Math.max(...values, 1); + const min = Math.min(...values, 0); const span = max - min || 1; - const stepX = width / (data.length - 1); - const pts = data.map((v, i) => { - const x = i * stepX; + const pts = values.map((v, i) => { + const x = (i / (values.length - 1)) * w; const y = height - ((v - min) / span) * (height - 4) - 2; return [x, y] as const; }); - const line = pts - .map( - (p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`, - ) - .join(" "); - const area = `${line} L${width},${height} L0,${height} Z`; - + const line = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(" "); + const area = `${line} L${w},${height} L0,${height} Z`; + const id = `spark-${stroke.replace(/[^a-z0-9]/gi, "")}`; return ( - + - - + + - {fill && } - + {fill && } + ); } diff --git a/services/frontend/src/components/charts/waveform.tsx b/services/frontend/src/components/charts/waveform.tsx index 746e756..bb1adda 100644 --- a/services/frontend/src/components/charts/waveform.tsx +++ b/services/frontend/src/components/charts/waveform.tsx @@ -1,76 +1,37 @@ -"use client"; - -import { motion, useReducedMotion } from "motion/react"; -import { useMemo } from "react"; import { cn } from "@/lib/utils"; -export interface WaveformProps { - seed: string | number; - bars?: number; - height?: number; - className?: string; - tone?: "signal" | "amber" | "vermilion"; -} - -// deterministic pseudo-random from seed so the shape is stable per recording -function hashSeed(seed: string | number): number { - const s = String(seed); - let h = 2166136261; - for (let i = 0; i < s.length; i++) { - h ^= s.charCodeAt(i); - h = Math.imul(h, 16777619); - } - return h >>> 0; -} - -export function Waveform({ - seed, - bars = 40, - height = 40, +/** Live equalizer bars. `bars` are 0..1 levels. */ +export function Equalizer({ + bars, + color = "var(--color-signal)", className, - tone = "signal", -}: WaveformProps) { - const reduce = useReducedMotion(); - const values = useMemo(() => { - let state = hashSeed(seed) || 1; - const out: number[] = []; - for (let i = 0; i < bars; i++) { - state = (Math.imul(state, 1103515245) + 12345) >>> 0; - const r = (state % 1000) / 1000; - // envelope: louder in the middle, quieter at edges - const env = Math.sin((i / (bars - 1)) * Math.PI); - out.push(0.18 + r * 0.82 * (0.4 + env * 0.6)); - } - return out; - }, [seed, bars]); - - const color = { - signal: "var(--color-signal)", - amber: "var(--color-amber)", - vermilion: "var(--color-vermilion)", - }[tone]; - +}: { + bars: number[]; + color?: string; + className?: string; +}) { return ( -
- {values.map((v, i) => ( - - ))} +
+ {bars.length === 0 ? ( +
+ {Array.from({ length: 28 }).map((_, i) => ( + + ))} +
+ ) : ( + bars.map((b, i) => ( + 0.05 ? `0 0 8px ${color}` : "none", + transition: "height 90ms linear", + }} + /> + )) + )}
); } diff --git a/services/frontend/src/components/chatbot/chat-panel.tsx b/services/frontend/src/components/chatbot/chat-panel.tsx deleted file mode 100644 index 1bff9da..0000000 --- a/services/frontend/src/components/chatbot/chat-panel.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"use client"; - -import { Eraser, Send, Sparkles } from "lucide-react"; -import { useEffect, useRef } from "react"; -import { useChatbot } from "./chatbot-context"; - -interface ChatPanelProps { - inputRef?: React.RefObject; -} - -function formatTime(ts: string): string { - const d = new Date(ts); - if (Number.isNaN(d.getTime())) return ""; - return d.toLocaleTimeString("id-ID", { - hour: "2-digit", - minute: "2-digit", - }); -} - -const SUGGESTIONS = [ - "Gimana suasana server hari ini?", - "Channel mana yang paling ramai?", - "Total pesan di server?", - "Ada pesan bermasalah?", -]; - -export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) { - const { messages, sendMessage, clearMessages, isTyping } = useChatbot(); - const listRef = useRef(null); - const internalInputRef = useRef(null); - const inputRef = externalInputRef ?? internalInputRef; - - // Auto-scroll to bottom on new messages - // biome-ignore lint/correctness/useExhaustiveDependencies: re-run on message arrival; scroll is a visual effect keyed on new content - useEffect(() => { - if (listRef.current) { - listRef.current.scrollTop = listRef.current.scrollHeight; - } - }, [messages, isTyping]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const input = inputRef.current; - if (!input || !input.value.trim() || isTyping) return; - sendMessage(input.value); - input.value = ""; - }; - - const handleSuggestion = (text: string) => { - if (isTyping) return; - sendMessage(text); - }; - - return ( -
- {/* Chat messages */} -
- {messages.length === 0 ? ( -
-

- Halo! 👋 Aku tau soal server ini — pesan, flag, dan aktivitas. -

-
- {SUGGESTIONS.map((s) => ( - - ))} -
-
- ) : ( - messages.map((msg, i) => ( -
-
- {msg.content} -
- - {formatTime(msg.timestamp)} - -
- )) - )} - {isTyping && ( -
-
- - - - - -
-
- )} -
- - {/* Input bar */} -
- - {messages.length > 0 && ( - - )} - -
-
- ); -} diff --git a/services/frontend/src/components/chatbot/chatbot-container.tsx b/services/frontend/src/components/chatbot/chatbot-container.tsx deleted file mode 100644 index c798b4b..0000000 --- a/services/frontend/src/components/chatbot/chatbot-container.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"use client"; - -import { Bot, Minimize2 } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { ChatPanel } from "./chat-panel"; -import { useChatbot } from "./chatbot-context"; - -export function ChatbotContainer() { - const { minimized, setMinimized } = useChatbot(); - const [position, setPosition] = useState({ x: 0, y: 0 }); - const [dragging, setDragging] = useState(false); - const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); - const inputRef = useRef(null); - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - setDragging(true); - setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y }); - }, - [position], - ); - - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - if (!dragging) return; - setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }); - }, - [dragging, dragStart], - ); - - const handleMouseUp = useCallback(() => setDragging(false), []); - - // Focus input when chat opens - useEffect(() => { - if (!minimized) { - const id = setTimeout(() => inputRef.current?.focus(), 150); - return () => clearTimeout(id); - } - }, [minimized]); - - return ( - // biome-ignore lint/a11y/noStaticElementInteractions: drag container — mouse-move gesture surface, not keyboard-interactive content -
-
- {minimized ? ( - - ) : ( -
- {/* Drag handle + controls */} - {/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle — mouse-only gesture, keyboard users use the buttons in this header */} -
- - - Chatbot - -
- -
-
- - {/* Chat panel — always open when bubble is expanded */} -
- -
-
- )} -
-
- ); -} diff --git a/services/frontend/src/components/chatbot/chatbot-context.tsx b/services/frontend/src/components/chatbot/chatbot-context.tsx deleted file mode 100644 index 986d1c7..0000000 --- a/services/frontend/src/components/chatbot/chatbot-context.tsx +++ /dev/null @@ -1,188 +0,0 @@ -"use client"; - -import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from "react"; -import { useChatbotUserId } from "@/hooks/use-chatbot-user"; -import { chatbotApi } from "@/lib/api"; - -export type ChatbotExpression = - | "idle" - | "listening" - | "surprise" - | "happy" - | "sad" - | "talking"; - -interface ChatbotMessage { - role: "user" | "assistant"; - content: string; - timestamp: string; -} - -interface ChatbotContextValue { - /** Expression the chatbot avatar should display */ - expression: ChatbotExpression; - setExpression: (expr: ChatbotExpression) => void; - - /** Whether the enlarged bubble is minimized to a small icon */ - minimized: boolean; - setMinimized: (v: boolean) => void; - - /** - * @deprecated Use `minimized` / `setMinimized` instead. - * Legacy toggle alias kept for compatibility. - */ - isOpen: boolean; - setOpen: (open: boolean) => void; - toggle: () => void; - - /** Chat messages with real API backend */ - messages: ChatbotMessage[]; - sendMessage: (content: string) => Promise; - clearMessages: () => Promise; - isTyping: boolean; - - /** Active guild context sent to the backend so answers reference the server */ - guildId: string; - setGuildId: (g: string) => void; -} - -const ChatbotContext = createContext(null); - -export function ChatbotProvider({ children }: { children: ReactNode }) { - const [expression, setExpression] = useState("idle"); - const [minimized, setMinimized] = useState(true); - const [messages, setMessages] = useState([]); - const [isTyping, setIsTyping] = useState(false); - const [guildId, setGuildId] = useState(""); - const historyFetched = useRef(false); - const userId = useChatbotUserId(); - - // Derived legacy state - const isOpen = !minimized; - - const setOpen = useCallback((open: boolean) => { - setMinimized(!open); - }, []); - - const toggle = useCallback(() => { - setMinimized((prev) => !prev); - }, []); - - // Load chat history on first mount (per-device user history) - useEffect(() => { - if (historyFetched.current || !userId) return; - historyFetched.current = true; - - chatbotApi - .getHistory(userId) - .then((res) => { - // Backend returns rows {user_message, bot_response, created_at} — - // interleave each user message with its bot reply. - const withReplies: ChatbotMessage[] = []; - for (const row of res.history ?? []) { - withReplies.push({ - role: "user", - content: row.user_message, - timestamp: row.created_at, - }); - withReplies.push({ - role: "assistant", - content: row.bot_response, - timestamp: row.created_at, - }); - } - setMessages(withReplies); - }) - .catch(() => { - // API may not be available yet — silently ignore - }); - }, [userId]); - - const sendMessage = useCallback( - async (content: string) => { - if (!content.trim()) return; - - const userMsg: ChatbotMessage = { - role: "user", - content: content.trim(), - timestamp: new Date().toISOString(), - }; - setMessages((prev) => [...prev, userMsg]); - setExpression("listening"); - setIsTyping(true); - - try { - // Send active guild as context so the backend can answer with - // real server insights (serverInsights path in chatbot.service), - // and the per-device user id so the history stays isolated. - const res = await chatbotApi.send(content.trim(), guildId, userId); - const botMsg: ChatbotMessage = { - role: "assistant", - content: res.response, - timestamp: res.timestamp ?? new Date().toISOString(), - }; - setMessages((prev) => [...prev, botMsg]); - setExpression("happy"); - } catch { - const errorMsg: ChatbotMessage = { - role: "assistant", - content: - "Maaf, aku lagi gagal nyambung ke server. Coba tanya lagi ya 🙏", - timestamp: new Date().toISOString(), - }; - setMessages((prev) => [...prev, errorMsg]); - setExpression("sad"); - } finally { - setIsTyping(false); - } - }, - [guildId, userId], - ); - - const clearMessages = useCallback(async () => { - try { - await chatbotApi.clearHistory(userId); - } catch { - // Best-effort clear - } - setMessages([]); - }, [userId]); - - return ( - - {children} - - ); -} - -export function useChatbot(): ChatbotContextValue { - const ctx = useContext(ChatbotContext); - if (!ctx) { - throw new Error("useChatbot must be used within a ChatbotProvider"); - } - return ctx; -} diff --git a/services/frontend/src/components/chatbot/chatbot.tsx b/services/frontend/src/components/chatbot/chatbot.tsx new file mode 100644 index 0000000..0c34fa6 --- /dev/null +++ b/services/frontend/src/components/chatbot/chatbot.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Bot, Send, X, MessageCircle } from "lucide-react"; +import { chatbotApi } from "@/lib/api"; +import { useChatbotUserId } from "@/hooks/use-chatbot-user"; +import { GlassPanel, Input, Button, Avatar } from "@/components/primitives"; +import { toast } from "@/components/primitives"; +import { cn } from "@/lib/utils"; + +interface Msg { + role: "user" | "bot"; + content: string; +} + +export function Chatbot() { + const userId = useChatbotUserId(); + const [open, setOpen] = useState(false); + const [msgs, setMsgs] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const listRef = useRef(null); + + useEffect(() => { + if (!open || !userId) return; + chatbotApi + .getHistory(userId) + .then((res) => { + setMsgs( + res.history + .slice(-12) + .flatMap((h) => [ + { role: "user" as const, content: h.user_message }, + { role: "bot" as const, content: h.bot_response }, + ]), + ); + }) + .catch(() => {}); + }, [open, userId]); + + useEffect(() => { + listRef.current?.scrollTo({ top: listRef.current.scrollHeight }); + }, [msgs, loading]); + + const send = async () => { + const text = input.trim(); + if (!text || loading || !userId) return; + setInput(""); + setMsgs((m) => [...m, { role: "user", content: text }]); + setLoading(true); + try { + const res = await chatbotApi.send(text, undefined, userId); + setMsgs((m) => [...m, { role: "bot", content: res.response }]); + } catch (e) { + toast({ title: "Chat error", description: String(e), tone: "vermilion" }); + } finally { + setLoading(false); + } + }; + + return ( + <> + + + {open && ( + +
+ + + +
+
GMW Assistant
+
context-aware
+
+
+ +
+ {msgs.length === 0 && ( +
+ Ask about moderation, voice, or media. +
+ )} + {msgs.map((m, i) => ( +
+ {m.role === "bot" && } +
+ {m.content} +
+
+ ))} + {loading && ( +
+ +
+
+ )} +
+ +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && send()} + /> + +
+
+ )} + + ); +} diff --git a/services/frontend/src/components/chatbot/index.ts b/services/frontend/src/components/chatbot/index.ts deleted file mode 100644 index 88d4a4b..0000000 --- a/services/frontend/src/components/chatbot/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { ChatPanel } from "./chat-panel"; -export { ChatbotContainer } from "./chatbot-container"; -export { ChatbotProvider, useChatbot } from "./chatbot-context"; diff --git a/services/frontend/src/components/command/command-palette.tsx b/services/frontend/src/components/command/command-palette.tsx new file mode 100644 index 0000000..7ab16e6 --- /dev/null +++ b/services/frontend/src/components/command/command-palette.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTheme } from "next-themes"; +import { + Search, + CornerDownLeft, + ArrowUp, + ArrowDown, + Moon, + Sun, +} from "lucide-react"; +import { navItems } from "@/lib/navigation"; +import { GlassPanel } from "@/components/primitives"; + +interface Command { + id: string; + label: string; + hint: string; + icon: React.ReactNode; + run: () => void; +} + +export function CommandPalette() { + const router = useRouter(); + const { theme, setTheme } = useTheme(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + + const commands = useMemo(() => { + const nav: Command[] = navItems.map((n) => ({ + id: `nav:${n.href}`, + label: `Go to ${n.label}`, + hint: n.href, + icon: , + run: () => router.push(n.href), + })); + const actions: Command[] = [ + { + id: "act:theme", + label: "Toggle theme", + hint: "appearance", + icon: + theme === "light" ? ( + + ) : ( + + ), + run: () => setTheme(theme === "light" ? "dark" : "light"), + }, + ]; + return [...nav, ...actions]; + }, [router, theme, setTheme]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return commands; + return commands.filter( + (c) => c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q), + ); + }, [commands, query]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setOpen((o) => !o); + } + if (e.key === "Escape") setOpen(false); + }; + const onOpen = () => setOpen(true); + window.addEventListener("keydown", onKey); + window.addEventListener("command-palette:open", onOpen); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("command-palette:open", onOpen); + }; + }, []); + + useEffect(() => { + if (open) { + setQuery(""); + setActive(0); + } + }, [open]); + + useEffect(() => { + setActive(0); + }, [query]); + + if (!open) return null; + + const runAt = (i: number) => { + const c = filtered[i]; + if (!c) return; + setOpen(false); + c.run(); + }; + + return ( +
setOpen(false)} + > + e.stopPropagation()} + > +
+ + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((a) => Math.min(a + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((a) => Math.max(a - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + runAt(active); + } + }} + placeholder="Type a command or search…" + className="flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-ink-faint" + /> + ESC +
+ +
+ {filtered.length === 0 ? ( +
No commands
+ ) : ( + filtered.map((c, i) => ( + + )) + )} +
+ +
+ navigate + select + ⌘K +
+
+
+ ); +} diff --git a/services/frontend/src/components/command/dash-command-line.tsx b/services/frontend/src/components/command/dash-command-line.tsx deleted file mode 100644 index 0c63f4d..0000000 --- a/services/frontend/src/components/command/dash-command-line.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"use client"; - -/** - * DashCommandLine — sticky bottom prompt for ops actions. - * - * The signature element of the new dashboard. Pure mono input; parses a - * slash-prefixed verb and dispatches to existing APIs or client-side - * actions. Autocomplete is intentionally light (suggestions render in - * monospace below the input). - */ - -import { - type FormEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { cn } from "@/lib/utils"; - -type CommandVerb = "mute" | "jump" | "find" | "clear"; - -interface CommandResult { - ok: boolean; - message: string; -} - -const VERBS: CommandVerb[] = ["mute", "jump", "find", "clear"]; - -interface DashCommandLineProps { - onCommand?: (verb: CommandVerb, args: string) => CommandResult | undefined; - placeholder?: string; -} - -export function DashCommandLine({ - onCommand, - placeholder = "type a command — /mute @user 10m, /jump #channel, /find text, /clear", -}: DashCommandLineProps) { - const [value, setValue] = useState(""); - const [history, setHistory] = useState([]); - const [_historyIdx, setHistoryIdx] = useState(-1); - const [result, setResult] = useState(null); - const inputRef = useRef(null); - - // Global "/" focuses the command line (skip when typing in another input). - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return; - const t = e.target as HTMLElement | null; - const tag = t?.tagName?.toLowerCase(); - if (tag === "input" || tag === "textarea" || t?.isContentEditable) return; - e.preventDefault(); - inputRef.current?.focus(); - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, []); - - const suggestions = useMemo(() => { - const trimmed = value.trimStart(); - if (!trimmed.startsWith("/")) return [] as CommandVerb[]; - const verb = trimmed.slice(1).split(/\s+/)[0]?.toLowerCase() ?? ""; - if (!verb) return VERBS; - return VERBS.filter((v) => v.startsWith(verb)); - }, [value]); - - const submit = useCallback( - (raw: string) => { - const trimmed = raw.trim(); - if (!trimmed.startsWith("/")) { - setResult({ ok: false, message: "commands start with /" }); - return; - } - const body = trimmed.slice(1); - const [verbRaw, ...rest] = body.split(/\s+/); - const verb = (verbRaw?.toLowerCase() ?? "") as CommandVerb; - if (!VERBS.includes(verb)) { - setResult({ - ok: false, - message: `unknown verb "${verbRaw}" — try ${VERBS.join(", ")}`, - }); - return; - } - const args = rest.join(" "); - try { - const ret = onCommand?.(verb, args); - const message = - (ret && typeof ret === "object" && "message" in ret && ret.message) || - defaultMessage(verb, args); - setResult({ ok: true, message }); - } catch (err) { - setResult({ - ok: false, - message: err instanceof Error ? err.message : "command failed", - }); - } - setHistory((h) => [trimmed, ...h].slice(0, 32)); - setHistoryIdx(-1); - }, - [onCommand], - ); - - const onSubmit = (e: FormEvent) => { - e.preventDefault(); - if (value.trim()) { - submit(value); - setValue(""); - } - }; - - const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "ArrowUp") { - e.preventDefault(); - setHistoryIdx((idx) => { - const next = idx + 1; - if (next >= history.length) return idx; - setValue(history[next] ?? ""); - return next; - }); - } else if (e.key === "ArrowDown") { - e.preventDefault(); - setHistoryIdx((idx) => { - const next = idx - 1; - if (next < -1) return idx; - setValue(next === -1 ? "" : (history[next] ?? "")); - return next; - }); - } - }; - - return ( -
- {">"} - setValue(e.target.value)} - onKeyDown={onKeyDown} - placeholder={placeholder} - spellCheck={false} - autoComplete="off" - aria-label="Command line" - className="min-w-0 flex-1 bg-transparent text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]" - /> - {result ? ( - - {result.message} - - ) : suggestions.length > 0 ? ( - - {suggestions.map((s) => `/${s}`).join(" ")} - - ) : null} -
- ); -} - -function defaultMessage(verb: CommandVerb, args: string): string { - switch (verb) { - case "mute": - return args ? `mute queued — ${args}` : "mute needs a target"; - case "jump": - return args ? `jump queued — ${args}` : "jump needs a channel"; - case "find": - return args ? `find queued — ${args}` : "find needs text"; - case "clear": - return "feed cleared"; - } -} diff --git a/services/frontend/src/components/dashboard/activity-chart.tsx b/services/frontend/src/components/dashboard/activity-chart.tsx deleted file mode 100644 index c958f0f..0000000 --- a/services/frontend/src/components/dashboard/activity-chart.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { AreaPoint } from "@/components/charts/area-activity"; -import { AreaActivity } from "@/components/charts/area-activity"; - -export interface ActivityChartProps { - data: { - day: string; - messages: number; - flagged: number; - active_users: number; - }[]; -} - -export function ActivityChart({ data }: ActivityChartProps) { - const points: AreaPoint[] = data.map((d) => ({ - label: d.day, - value: d.messages, - })); - return ( -
-
-

Daily messages

- - {data.length}d - -
- -
- ); -} - -function ActivityChartInner({ points }: { points: AreaPoint[] }) { - return ( - - ); -} diff --git a/services/frontend/src/components/dashboard/channels-section.tsx b/services/frontend/src/components/dashboard/channels-section.tsx deleted file mode 100644 index 4bad6f8..0000000 --- a/services/frontend/src/components/dashboard/channels-section.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -import { Hash, Search } from "lucide-react"; -import { useCallback, useState } from "react"; -import { Badge } from "@/components/primitives/badge"; -import { Button } from "@/components/primitives/button"; -import { Input } from "@/components/primitives/input"; -import { EmptyState, LoadingSkeleton } from "@/components/shared"; -import { useChannelDetail, useChannels } from "@/hooks"; -import type { DashboardChannel } from "@/lib/types"; - -export function ChannelsSection({ guildId }: { guildId?: string }) { - const [search, setSearch] = useState(""); - const [selectedId, setSelectedId] = useState(null); - - const { data: channels = [], isLoading } = useChannels(guildId ?? "", search); - const { data: detail } = useChannelDetail(selectedId); - - const handleSearch = useCallback((v: string) => setSearch(v), []); - - if (isLoading) return ; - if (channels.length === 0) - return ( - - ); - - return ( -
-
-
- - handleSearch(e.target.value)} - className="pl-9" - /> -
- {channels.map((c) => ( - setSelectedId(c.channel_id)} - /> - ))} -
- -
- {detail ? ( -
-
- - - -
-
- {detail.channel_name ?? detail.channel_id} -
-
- {detail.total_messages.toLocaleString()} messages -
-
-
- -

- {detail.culture_summary ?? "No data yet."} -

-
- ) : ( -

- Select a channel to inspect. -

- )} -
-
- ); -} - -function ChannelRow({ - channel, - selected, - onSelect, -}: { - channel: DashboardChannel; - selected: boolean; - onSelect: () => void; -}) { - const total = channel.total_messages + channel.flagged_count || 1; - return ( - - ); -} - -function Stat({ - label, - value, - tone, -}: { - label: string; - value: number; - tone: "vermilion"; -}) { - return ( -
- {label} - - {value} - -
- ); -} diff --git a/services/frontend/src/components/dashboard/hourly-activity-chart.tsx b/services/frontend/src/components/dashboard/hourly-activity-chart.tsx deleted file mode 100644 index 3aba1a4..0000000 --- a/services/frontend/src/components/dashboard/hourly-activity-chart.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { AreaPoint } from "@/components/charts/area-activity"; -import { AreaActivity } from "@/components/charts/area-activity"; - -export interface HourlyActivityChartProps { - data: { hour: number; messages: number; flagged: number }[]; -} - -export function HourlyActivityChart({ data }: HourlyActivityChartProps) { - const points: AreaPoint[] = data.map((d) => ({ - label: `${d.hour}:00`, - value: d.messages, - })); - return ( -
-
-

Hourly distribution

- - 00:00 – 23:00 - -
- -
- ); -} diff --git a/services/frontend/src/components/dashboard/moderation-donut.tsx b/services/frontend/src/components/dashboard/moderation-donut.tsx deleted file mode 100644 index 08e4307..0000000 --- a/services/frontend/src/components/dashboard/moderation-donut.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { RadialGauge } from "@/components/charts/radial-gauge"; -import type { DashboardStats } from "@/lib/types"; - -export interface ModerationDonutProps { - stats?: DashboardStats; -} - -export function ModerationDonut({ stats }: ModerationDonutProps) { - const clean = stats?.total_clean ?? 0; - const flagged = stats?.total_flagged ?? 0; - const warned = stats?.total_warned ?? 0; - const total = clean + flagged + warned || 1; - const ratio = clean / total; - - return ( -
-

Moderation health

- 0.8 ? "signal" : ratio > 0.6 ? "amber" : "vermilion"} - /> -
- - - -
-
- ); -} - -function Row({ - label, - value, - tone, -}: { - label: string; - value: number; - tone: string; -}) { - return ( -
- - - {label} - - - {value.toLocaleString()} - -
- ); -} diff --git a/services/frontend/src/components/dashboard/reactions-section.tsx b/services/frontend/src/components/dashboard/reactions-section.tsx deleted file mode 100644 index a5d6416..0000000 --- a/services/frontend/src/components/dashboard/reactions-section.tsx +++ /dev/null @@ -1,89 +0,0 @@ -"use client"; - -import { Flame, Heart, SmilePlus } from "lucide-react"; -import { Avatar } from "@/components/primitives/avatar"; -import { Badge } from "@/components/primitives/badge"; -import { EmptyState, LoadingSkeleton } from "@/components/shared"; -import { useTopReactions, useTopReactors } from "@/hooks"; - -export interface ReactionsSectionProps { - initialReactions?: Awaited>["data"]; -} - -export function ReactionsSection() { - const { data: reactions, isLoading: reactionsLoading } = useTopReactions(); - const { data: reactors, isLoading: reactorsLoading } = useTopReactors(); - - if (reactionsLoading || reactorsLoading) return ; - - const topReactions = (reactions ?? []).slice(0, 6); - const topReactors = (reactors ?? []).slice(0, 6); - - return ( -
-
-

- - Top reactions -

- {topReactions.length === 0 ? ( - - ) : ( -
- {topReactions - .flatMap((m) => - m.top_emojis.map((e) => ({ emoji: e.emoji, count: e.count })), - ) - .reduce<{ emoji: string; count: number }[]>((acc, cur) => { - const found = acc.find((x) => x.emoji === cur.emoji); - if (found) found.count += cur.count; - else acc.push(cur); - return acc; - }, []) - .sort((a, b) => b.count - a.count) - .slice(0, 8) - .map((r) => ( - - {r.emoji} - - {r.count} - - - ))} -
- )} -
- -
-

- - Top reactors -

- {topReactors.length === 0 ? ( - - ) : ( -
- {topReactors.map((r) => ( -
- - {r.username} - +{r.net_count} -
- ))} -
- )} -
-
- ); -} diff --git a/services/frontend/src/components/dashboard/top-channels-chart.tsx b/services/frontend/src/components/dashboard/top-channels-chart.tsx deleted file mode 100644 index d162018..0000000 --- a/services/frontend/src/components/dashboard/top-channels-chart.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Hash } from "lucide-react"; -import type { TopChannel } from "@/lib/types"; - -export interface TopChannelsChartProps { - channels: TopChannel[]; -} - -export function TopChannelsChart({ channels }: TopChannelsChartProps) { - const max = Math.max(...channels.map((c) => c.message_count), 1); - const top = [...channels] - .sort((a, b) => b.message_count - a.message_count) - .slice(0, 8); - return ( -
-

Top channels

-
- {top.map((c) => ( -
- - - - - {c.channel_name ?? c.channel_id} - -
-
-
- - {c.message_count.toLocaleString()} - -
- ))} -
-
- ); -} diff --git a/services/frontend/src/components/dashboard/users-section.tsx b/services/frontend/src/components/dashboard/users-section.tsx deleted file mode 100644 index 0b3a908..0000000 --- a/services/frontend/src/components/dashboard/users-section.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import { Search, Users } from "lucide-react"; -import { useCallback, useState } from "react"; -import { Avatar } from "@/components/primitives/avatar"; -import { Badge } from "@/components/primitives/badge"; -import { Input } from "@/components/primitives/input"; -import { EmptyState, LoadingSkeleton } from "@/components/shared"; -import { useUserDetail, useUsers } from "@/hooks"; - -const TRUST_TIERS = [ - { min: 75, label: "Trusted", tone: "signal" as const }, - { min: 40, label: "Neutral", tone: "neutral" as const }, - { min: 10, label: "At Risk", tone: "amber" as const }, - { min: 0, label: "Critical", tone: "vermilion" as const }, -]; - -function trustTier(score?: number | null) { - const s = score ?? 0; - return ( - TRUST_TIERS.find((t) => s >= t.min) ?? TRUST_TIERS[TRUST_TIERS.length - 1] - ); -} - -export function UsersSection() { - const [search, setSearch] = useState(""); - const [selectedId, setSelectedId] = useState(null); - - const { data: users = [], isLoading } = useUsers(search); - const { data: detail } = useUserDetail(selectedId); - - const handleSearch = useCallback((v: string) => setSearch(v), []); - - if (isLoading) return ; - if (users.length === 0) - return ( - - ); - - return ( -
-
-
- - handleSearch(e.target.value)} - className="pl-9" - /> -
-
- {users.map((u) => { - const tier = trustTier(u.trust_score); - return ( - - ); - })} -
-
- -
- {detail ? ( -
-
- -
-
{detail.username}
-
- {detail.total_messages.toLocaleString()} messages -
-
-
-
- - - - -
-

- {detail.profile_summary} -

-
- ) : ( -

- Select a user to inspect. -

- )} -
-
- ); -} - -function Stat({ - label, - value, - tone, -}: { - label: string; - value: string; - tone?: "amber" | "vermilion"; -}) { - return ( -
-
- {label} -
-
- {value} -
-
- ); -} diff --git a/services/frontend/src/components/feed/event-feed.tsx b/services/frontend/src/components/feed/event-feed.tsx deleted file mode 100644 index c294195..0000000 --- a/services/frontend/src/components/feed/event-feed.tsx +++ /dev/null @@ -1,273 +0,0 @@ -"use client"; - -/** - * EventFeed — horizontal scroll-snap timeline that ingests live events. - * - * The feed is the central column of the dashboard. Time runs left → right - * (older → newer). New events append at the right edge; the feed scrolls - * right when the user is at the live edge and pauses when the user drags - * back to inspect history. - * - * Ring buffer keeps the DOM bounded (200 items). A `NowMarker` is inserted - * every 10 events or every 30 seconds to break the row rhythm with a pulse - * summary — see `useFeedPulse`. - */ - -import { - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { EventRow, type FeedEvent } from "@/components/feed/event-row"; -import { ClusterMarker, PulseMarker } from "@/components/feed/now-marker"; -import { cn } from "@/lib/utils"; - -const RING_BUFFER_MAX = 200; -const PULSE_EVERY_N_EVENTS = 10; -const PULSE_EVERY_MS = 30_000; - -export type FeedItem = - | { kind: "event"; event: FeedEvent } - | { - kind: "pulse"; - key: string; - ts: number; - label: string; - summary: string; - tone?: "signal" | "amber" | "vermilion"; - } - | { - kind: "cluster"; - key: string; - ts: number; - label: string; - bands: { - tone: "neutral" | "signal" | "amber" | "vermilion"; - ratio: number; - }[]; - tone?: "signal" | "amber" | "vermilion"; - }; - -interface EventFeedProps { - initialEvents: FeedEvent[]; - subscribe: (handler: (e: FeedEvent) => void) => () => void; - className?: string; - emptyState?: ReactNode; -} - -export function EventFeed({ - initialEvents, - subscribe, - className, - emptyState, -}: EventFeedProps) { - const [items, setItems] = useState(() => - injectMarkers(initialEvents.slice(-RING_BUFFER_MAX)), - ); - const [selectedId, setSelectedId] = useState(null); - const [following, setFollowing] = useState(true); - const scrollerRef = useRef(null); - const lastPulseAt = useRef(Date.now()); - - // Live WS ingest - useEffect(() => { - const unsub = subscribe((e) => { - setItems((prev) => appendWithMarker(prev, e)); - }); - return unsub; - }, [subscribe]); - - // Periodic pulse even if traffic is slow — keeps the feed rhythm alive. - useEffect(() => { - const id = window.setInterval(() => { - setItems((prev) => { - if (Date.now() - lastPulseAt.current < PULSE_EVERY_MS) return prev; - return appendPulse(prev, "system", "live · standing by"); - }); - }, PULSE_EVERY_MS); - return () => window.clearInterval(id); - }, []); - - // Auto-scroll on append when following. - useEffect(() => { - if (!following) return; - const el = scrollerRef.current; - if (!el) return; - el.scrollTo({ left: el.scrollWidth, behavior: "smooth" }); - }, [following]); - - const handleScroll = useCallback(() => { - const el = scrollerRef.current; - if (!el) return; - const distFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth; - setFollowing(distFromRight < 24); - }, []); - - const handleSelect = useCallback((id: string) => { - setSelectedId((cur) => (cur === id ? null : id)); - }, []); - - const visibleItems = useMemo(() => { - if (items.length <= RING_BUFFER_MAX) return items; - return items.slice(items.length - RING_BUFFER_MAX); - }, [items]); - - return ( -
-
- event horizon - - {visibleItems.filter((i) => i.kind === "event").length} events ·{" "} - {following ? "live" : "paused"} - -
- -
- {visibleItems.length === 0 && emptyState ? ( -
- {emptyState} -
- ) : ( - visibleItems.map((item) => { - if (item.kind === "event") { - return ( -
- -
- ); - } - if (item.kind === "cluster") { - return ( -
- -
- ); - } - return ( -
- -
- ); - }) - )} -
-
- ); -} - -// ── Ring + pulse helpers ──────────────────────────────────────── - -function injectMarkers(events: FeedEvent[]): FeedItem[] { - if (events.length === 0) return []; - const out: FeedItem[] = []; - let count = 0; - for (const e of events) { - out.push({ kind: "event", event: e }); - count++; - if (count % PULSE_EVERY_N_EVENTS === 0) { - out.push({ - kind: "cluster", - key: `cluster-${e.id}`, - ts: e.ts, - label: "pulse", - bands: deriveBands( - events.slice(Math.max(0, count - PULSE_EVERY_N_EVENTS), count), - ), - tone: "signal", - }); - } - } - return out; -} - -function deriveBands( - window: FeedEvent[], -): { tone: "neutral" | "signal" | "amber" | "vermilion"; ratio: number }[] { - const counts: Record<"neutral" | "signal" | "amber" | "vermilion", number> = { - neutral: 0, - signal: 0, - amber: 0, - vermilion: 0, - }; - for (const e of window) counts[e.severity]++; - const total = window.length || 1; - return (Object.keys(counts) as Array).map((k) => ({ - tone: k, - ratio: counts[k] / total, - })); -} - -function appendWithMarker(prev: FeedItem[], e: FeedEvent): FeedItem[] { - const next = [...prev, { kind: "event" as const, event: e }]; - const eventsSinceLastPulse = next.filter((i) => i.kind === "event").length; - if (eventsSinceLastPulse % PULSE_EVERY_N_EVENTS === 0) { - const recentEvents = next - .filter((i) => i.kind === "event") - .slice(-PULSE_EVERY_N_EVENTS) - .map((i) => (i as { kind: "event"; event: FeedEvent }).event); - next.push({ - kind: "cluster", - key: `cluster-${e.id}`, - ts: e.ts, - label: "pulse", - bands: deriveBands(recentEvents), - tone: "signal", - }); - } - if (next.length > RING_BUFFER_MAX * 2) { - return next.slice(next.length - RING_BUFFER_MAX); - } - return next; -} - -function appendPulse( - prev: FeedItem[], - label: string, - summary: string, -): FeedItem[] { - return [ - ...prev, - { - kind: "pulse", - key: `pulse-${Date.now()}`, - ts: Date.now(), - label, - summary, - tone: "signal", - }, - ]; -} diff --git a/services/frontend/src/components/feed/event-row.tsx b/services/frontend/src/components/feed/event-row.tsx deleted file mode 100644 index 7174e5b..0000000 --- a/services/frontend/src/components/feed/event-row.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -/** - * EventRow — single row in the horizontal event-feed timeline. - * - * No card chrome. The row is a single typographic line: mono timestamp, - * severity dot, actor mention, action verb, channel jump, excerpt. - * - * Hover reveals full excerpt and selection state; click toggles selection - * so the right rail / command line can target the event. - */ - -import { type ReactNode, useCallback } from "react"; -import { cn } from "@/lib/utils"; - -export type EventSeverity = "neutral" | "signal" | "amber" | "vermilion"; - -export interface FeedEvent { - /** Stable id from the upstream record. Used as React key. */ - id: string; - /** Unix epoch ms. */ - ts: number; - /** Severity tone — drives dot color and zebra fill. */ - severity: EventSeverity; - /** Display label for the actor ("alice", "@everyone", "Carl-bot"). */ - actor: string; - /** Verb describing the action ("sent", "flagged", "joined", "muted"). */ - action: string; - /** Channel reference (monogram display only — no chrome). */ - channel?: string | null; - /** Message excerpt or action payload text. Truncated when long. */ - excerpt: string; - /** Optional metadata tag (e.g. "ai:flag", "voice:join"). */ - tag?: string | null; -} - -interface EventRowProps { - event: FeedEvent; - selected?: boolean; - onSelect?: (id: string) => void; -} - -const SEVERITY_DOT: Record = { - neutral: "oklch(0.46 0.02 70)", - signal: "var(--color-signal)", - amber: "var(--color-amber)", - vermilion: "var(--color-vermilion)", -}; - -const SEVERITY_FILL: Record = { - neutral: "transparent", - signal: "oklch(0.78 0.17 125 / 0.06)", - amber: "oklch(0.80 0.15 70 / 0.07)", - vermilion: "oklch(0.62 0.21 25 / 0.08)", -}; - -function formatTimestamp(ts: number): string { - const d = new Date(ts); - const pad = (n: number) => String(n).padStart(2, "0"); - return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; -} - -export function EventRow({ event, selected, onSelect }: EventRowProps) { - const handleClick = useCallback(() => { - onSelect?.(event.id); - }, [event.id, onSelect]); - - const dot: ReactNode = ( - - ); - - return ( - - ); -} diff --git a/services/frontend/src/components/feed/now-marker.tsx b/services/frontend/src/components/feed/now-marker.tsx deleted file mode 100644 index 22c3f65..0000000 --- a/services/frontend/src/components/feed/now-marker.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -/** - * NowMarker — inline callout that breaks the feed timeline rhythm. - * - * Two variants: `pulse` (one-line summary) and `cluster` (horizontal stack bar - * visualising severity distribution across a recent window). Both use a - * border-tip on the left in signal tone; no card chrome, no shadow. - */ - -import { cn } from "@/lib/utils"; - -type Tone = "signal" | "amber" | "vermilion" | "neutral"; - -interface PulseMarkerProps { - tone?: Tone; - label: string; - timestamp: number; - /** Optional small caps label on the right. */ - trailing?: string; -} - -interface ClusterMarkerProps { - tone?: Tone; - label: string; - timestamp: number; - /** Fractions of each severity band; must sum to 1. */ - bands: { tone: Tone; ratio: number }[]; -} - -const TONE_TIP: Record = { - signal: "var(--color-signal)", - amber: "var(--color-amber)", - vermilion: "var(--color-vermilion)", - neutral: "oklch(0.46 0.02 70)", -}; - -const TONE_FILL: Record = { - signal: "oklch(0.78 0.17 125 / 0.12)", - amber: "oklch(0.80 0.15 70 / 0.14)", - vermilion: "oklch(0.62 0.21 25 / 0.12)", - neutral: "oklch(0.46 0.02 70 / 0.08)", -}; - -function formatTimestamp(ts: number): string { - const d = new Date(ts); - const pad = (n: number) => String(n).padStart(2, "0"); - return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; -} - -function MarkerShell({ - tone, - label, - timestamp, - trailing, - children, -}: { - tone: Tone; - label: string; - timestamp: number; - trailing?: string; - children?: React.ReactNode; -}) { - return ( -
- - - {formatTimestamp(timestamp)} - - - {label} - - - {children} - - {trailing ? ( - - {trailing} - - ) : null} -
- ); -} - -export function PulseMarker({ - tone = "signal", - label, - timestamp, - trailing, -}: PulseMarkerProps) { - return ( - - {/* children rendered by parent via composition — see NowMarker union below */} - - ); -} - -export function ClusterMarker({ - tone = "signal", - label, - timestamp, - bands, -}: ClusterMarkerProps) { - return ( - -
- {bands.map((b) => ( - - ))} -
-
- ); -} diff --git a/services/frontend/src/components/layout/dash-left-rail.tsx b/services/frontend/src/components/layout/dash-left-rail.tsx deleted file mode 100644 index 403ca9b..0000000 --- a/services/frontend/src/components/layout/dash-left-rail.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -/** - * DashLeftRail — 80px vertical monogram nav. - * - * Each item is a glyph + label. Active state uses an accent bar on the left - * and full ink colour. No backgrounds, no boxes. - */ - -import { - Activity, - BarChart3, - Flag, - MessagesSquare, - Mic, - ShieldCheck, - Users, -} from "lucide-react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { cn } from "@/lib/utils"; - -interface NavItem { - href: string; - glyph: React.ReactNode; - label: string; -} - -const ITEMS: NavItem[] = [ - { - href: "/dashboard", - glyph: , - label: "Console", - }, - { - href: "/messages", - glyph: , - label: "Messages", - }, - { - href: "/moderation", - glyph: , - label: "Moderation", - }, - { href: "/voice", glyph: , label: "Voice" }, - { href: "/media", glyph: , label: "Media" }, - { - href: "/recordings", - glyph: , - label: "Recordings", - }, - { href: "/analysis", glyph: , label: "Analysis" }, -]; - -export function DashLeftRail() { - const pathname = usePathname(); - return ( - - ); -} diff --git a/services/frontend/src/components/layout/dash-right-rail.tsx b/services/frontend/src/components/layout/dash-right-rail.tsx deleted file mode 100644 index ed23c0b..0000000 --- a/services/frontend/src/components/layout/dash-right-rail.tsx +++ /dev/null @@ -1,183 +0,0 @@ -"use client"; - -/** - * DashRightRail — 320px collapsible drawer. - * - * Holds the live AI verdict stream, active voice speakers, and the latest - * moderation actions. Reads from existing hooks (`useVoice`, etc.) — no - * new fetches; just re-presentation. - */ - -import { ChevronRight } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useSpeakers } from "@/hooks/use-voice"; -import type { ActiveSpeaker } from "@/lib/types"; -import { cn } from "@/lib/utils"; -import { useWebSocket } from "@/lib/ws/context"; - -interface DashRightRailProps { - pendingVerdicts?: { id: string; ts: number; text: string }[]; - recentActions?: { id: string; ts: number; verb: string; target: string }[]; -} - -export function DashRightRail({ - pendingVerdicts = [], - recentActions = [], -}: DashRightRailProps) { - const [collapsed, setCollapsed] = useState(false); - const { subscribe } = useSpeakers(); - const ws = useWebSocket(); - const [speakers, _setSpeakers] = useState([]); - useEffect(() => subscribe(ws), [ws, subscribe]); - - return ( - - ); -} - -function Section({ - title, - children, - vertical, -}: { - title: string; - children?: React.ReactNode; - vertical?: boolean; -}) { - return ( -
-

- {title} -

- {children} -
- ); -} - -function Empty({ msg }: { msg: string }) { - return ( - - {msg} - - ); -} - -function formatTs(ts: number): string { - const d = new Date(ts); - const pad = (n: number) => String(n).padStart(2, "0"); - return `${pad(d.getHours())}:${pad(d.getMinutes())}`; -} diff --git a/services/frontend/src/components/layout/dash-top-bar.tsx b/services/frontend/src/components/layout/dash-top-bar.tsx deleted file mode 100644 index 5a7a276..0000000 --- a/services/frontend/src/components/layout/dash-top-bar.tsx +++ /dev/null @@ -1,115 +0,0 @@ -"use client"; - -/** - * DashTopBar — 48px utility strip. - * - * No navigation chrome — just brand monogram, guild indicator, WS connection - * state, clock, and focus mode. Designed to read as a single line of - * instrument readout, not a navbar. - */ - -import { useEffect, useState } from "react"; -import { cn } from "@/lib/utils"; -import { useWebSocket } from "@/lib/ws/context"; - -type FocusMode = "quiet" | "standard" | "triage"; -const FOCUS_MODES: FocusMode[] = ["quiet", "standard", "triage"]; - -interface DashTopBarProps { - guildName: string; - botName?: string; -} - -function formatClock(d: Date): string { - const pad = (n: number) => String(n).padStart(2, "0"); - return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`; -} - -export function DashTopBar({ guildName, botName = "GMW" }: DashTopBarProps) { - const ws = useWebSocket(); - const [now, setNow] = useState(null); - const [focus, setFocus] = useState("standard"); - const [tz, setTz] = useState<"utc" | "local">("local"); - - useEffect(() => { - setNow(new Date()); - const id = window.setInterval(() => setNow(new Date()), 1000); - return () => window.clearInterval(id); - }, []); - - const connected = ws.status === "connected"; - - return ( -
-
- - {botName} - - · - {guildName} -
- -
-
- - - {ws.status} - -
- - - -
- {FOCUS_MODES.map((m) => ( - - ))} -
-
-
- ); -} - -function formatLocal(d: Date): string { - const pad = (n: number) => String(n).padStart(2, "0"); - return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; -} diff --git a/services/frontend/src/components/layout/spine.tsx b/services/frontend/src/components/layout/spine.tsx deleted file mode 100644 index adb54d4..0000000 --- a/services/frontend/src/components/layout/spine.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import { motion } from "motion/react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { navItems } from "@/lib/navigation"; -import { cn } from "@/lib/utils"; - -const titleFromPath: Record = { - "/dashboard": "Overview", - "/messages": "Messages", - "/voice": "Voice", - "/media": "Media", - "/recordings": "Recordings", - "/moderation": "Moderation", - "/analysis": "Analysis", -}; - -export function Spine() { - const pathname = usePathname(); - - return ( - <> - {/* Desktop rail */} - - - {/* Mobile bottom tab-bar */} - - - ); -} - -export function PageTitle() { - const pathname = usePathname(); - const key = - Object.keys(titleFromPath).find((k) => pathname.startsWith(k)) ?? - "/dashboard"; - return ( - {titleFromPath[key]} - ); -} diff --git a/services/frontend/src/components/layout/status-bar.tsx b/services/frontend/src/components/layout/status-bar.tsx deleted file mode 100644 index 587367d..0000000 --- a/services/frontend/src/components/layout/status-bar.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useChatbot } from "@/components/chatbot/chatbot-context"; -import { GuildSelector } from "@/components/shared/guild-selector"; -import { cn } from "@/lib/utils"; -import { useWebSocket } from "@/lib/ws/context"; -import { PageTitle } from "./spine"; -import { ThemeToggle } from "./theme-toggle"; - -const statusTone: Record = { - connected: "bg-[var(--color-signal)]", - connecting: "bg-[var(--color-amber)]", - disconnected: "bg-[var(--color-ink-soft)]", - error: "bg-[var(--color-vermilion)]", -}; - -export function StatusBar({ - guildId, - onGuildChange, -}: { - guildId: string; - onGuildChange: (g: string) => void; -}) { - const ws = useWebSocket(); - const { expression } = useChatbot(); - const [clock, setClock] = useState("--:--:--"); - - useEffect(() => { - const tick = () => - setClock(new Date().toLocaleTimeString("en-GB", { hour12: false })); - tick(); - const id = setInterval(tick, 1000); - return () => clearInterval(id); - }, []); - - return ( -
- -
- - - {ws.status} - - - - {expression} - - - - {clock} - - onGuildChange(g ?? "")} - /> - -
-
- ); -} diff --git a/services/frontend/src/components/layout/theme-toggle.tsx b/services/frontend/src/components/layout/theme-toggle.tsx deleted file mode 100644 index b9d8863..0000000 --- a/services/frontend/src/components/layout/theme-toggle.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { Moon, Sun } from "lucide-react"; -import { motion } from "motion/react"; -import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; -import { cn } from "@/lib/utils"; - -export function ThemeToggle() { - const { resolvedTheme, setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); - - const isDark = resolvedTheme === "dark"; - - return ( - - ); -} diff --git a/services/frontend/src/components/media/mini-player.tsx b/services/frontend/src/components/media/mini-player.tsx deleted file mode 100644 index f531fa1..0000000 --- a/services/frontend/src/components/media/mini-player.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { Pause, Play, SkipForward, Volume2 } from "lucide-react"; -import { motion } from "motion/react"; -import { Button } from "@/components/primitives/button"; -import { useMediaSkip, useMediaState, useMediaWsSync } from "@/hooks"; -import { useMediaPlayer } from "@/lib/hooks/use-media-player"; -import { cn } from "@/lib/utils"; -import { useWebSocket } from "@/lib/ws/context"; - -export function MiniPlayer() { - const ws = useWebSocket(); - const { data: state } = useMediaState(); - const { playing, current } = useMediaPlayer(); - useMediaWsSync(ws); - const skip = useMediaSkip(); - - if (!current) return null; - - return ( - - {current.title} -
-
{current.title}
-
- {current.source} -
-
-
- - - -
-
- ); -} diff --git a/services/frontend/src/components/messages/ai-analysis-panel.tsx b/services/frontend/src/components/messages/ai-analysis-panel.tsx deleted file mode 100644 index caf04bb..0000000 --- a/services/frontend/src/components/messages/ai-analysis-panel.tsx +++ /dev/null @@ -1,159 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Badge } from "@/components/primitives/badge"; -import { Progress } from "@/components/primitives/progress"; -import { cn } from "@/lib/utils"; - -interface AiAnalysisPanelProps { - status?: string | null; - severity?: string | null; - confidence?: number | null; - flags?: string[] | string | null; - categories?: string[] | string | null; - action?: string | null; - score?: number | null; - analysis?: string | null; -} - -const severityColor: Record = { - none: "text-[var(--color-ink-soft)]", - low: "text-[var(--color-ink-soft)]", - medium: "text-[var(--color-amber)]", - high: "text-orange-500", - critical: "text-[var(--color-vermilion)]", -}; - -export function AiAnalysisPanel({ - status, - severity, - confidence, - flags, - categories, - action, - score, - analysis, -}: AiAnalysisPanelProps) { - const [expanded, setExpanded] = useState(false); - - if (!status || status === "pending") { - return ( -
- - AI analysis pending - -
- ); - } - - const flagsArray = - typeof flags === "string" ? (flags ? JSON.parse(flags) : []) : flags || []; - const categoriesArray = - typeof categories === "string" - ? categories - ? JSON.parse(categories) - : [] - : categories || []; - - const statusTone = - status === "clean" - ? "signal" - : status === "flagged" - ? "vermilion" - : status === "warn" - ? "amber" - : "neutral"; - - return ( -
-
- - AI Analysis - - {status} -
- - {severity && ( -
- Severity: - - {severity} - -
- )} - - {confidence !== null && confidence !== undefined && ( -
- Confidence - -
- )} - - {score !== null && score !== undefined && ( -
- Score - {score.toFixed(2)} -
- )} - - {flagsArray.length > 0 && ( -
- {flagsArray.map((f: string) => ( - - {f} - - ))} -
- )} - - {categoriesArray.length > 0 && ( -
- {categoriesArray.map((c: string) => ( - - {c} - - ))} -
- )} - - {analysis && ( -
-

- {analysis} -

- {analysis.length > 120 && ( - - )} -
- )} - - {action && action !== "none" && ( -
- Recommended: - {action} -
- )} -
- ); -} diff --git a/services/frontend/src/components/messages/ai-status-badge.tsx b/services/frontend/src/components/messages/ai-status-badge.tsx deleted file mode 100644 index d73829f..0000000 --- a/services/frontend/src/components/messages/ai-status-badge.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import type { AiSeverity, AiStatus } from "@/lib/types"; -import { cn } from "@/lib/utils"; - -const severityTick: Record, string> = { - none: "border-[var(--color-signal)]/30", - low: "border-[var(--color-amber)]/50", - medium: "border-[var(--color-amber)]", - high: "border-orange-500/80", - critical: "border-[var(--color-vermilion)]", -}; - -const statusBadge: Record, string> = { - pending: "bg-[var(--color-ink-soft)]/20 text-[var(--color-ink-soft)]", - processing: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]", - clean: "bg-[var(--color-signal)]/15 text-[var(--color-signal)]", - warn: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]", - flagged: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]", - error: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]", -}; - -export function SeverityTick({ severity }: { severity?: AiSeverity | null }) { - const cls = severity ? severityTick[severity] : "border-transparent"; - return ( -