From 392db8eba155d6f9331362a85feddfba4721ccce Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sat, 15 Aug 2026 20:03:55 +0700 Subject: [PATCH] feat(frontend): Ambient/WebGL console revamp + lint/type cleanup Ground-up rebuild of the GMW frontend as an Ambient Field console: - WebGL ambient background (Three.js shader, drifting motes, reduced-motion aware) - Glassmorphism dark cyber theme across all 8 routes - SSR page + client view split with SWR fallback; realtime via WebSocket - Command palette (Cmd+K), chatbot FAB, guild/channel pickers - Chart primitives: donut, radial-gauge, area-activity, sparkline, equalizer Cleanup (review pass): - Remove stray Puppeteer nav-test/nav-debug scripts - Replace non-null assertions with guards (dashboard/moderation) - Drop unused useGuilds fetches in messages/voice views - Type implicit-any `let` declarations across pages - Add a11y roles/labels to SVG charts and audio, tidy imports --- services/frontend/nav-debug.cjs | 48 --- services/frontend/nav-test.cjs | 27 -- services/frontend/nav-test2.cjs | 18 - services/frontend/nav-test4019.cjs | 26 -- services/frontend/next.config.ts | 7 +- .../src/app/(dashboard)/analysis/view.tsx | 119 ++++-- .../src/app/(dashboard)/dashboard/page.tsx | 9 +- .../src/app/(dashboard)/dashboard/view.tsx | 391 +++++++++++------- .../frontend/src/app/(dashboard)/layout.tsx | 4 +- .../src/app/(dashboard)/media/page.tsx | 2 +- .../src/app/(dashboard)/media/view.tsx | 74 +++- .../src/app/(dashboard)/messages/page.tsx | 11 +- .../src/app/(dashboard)/messages/view.tsx | 170 ++++++-- .../src/app/(dashboard)/moderation/page.tsx | 4 +- .../src/app/(dashboard)/moderation/view.tsx | 151 +++++-- .../src/app/(dashboard)/recordings/page.tsx | 4 +- .../src/app/(dashboard)/recordings/view.tsx | 76 +++- .../src/app/(dashboard)/voice/page.tsx | 4 +- .../src/app/(dashboard)/voice/view.tsx | 93 +++-- .../src/components/ambient/ambient-canvas.tsx | 4 +- .../components/ambient/ambient-context.tsx | 34 +- .../src/components/charts/area-activity.tsx | 61 ++- .../frontend/src/components/charts/donut.tsx | 30 +- .../frontend/src/components/charts/index.ts | 4 +- .../src/components/charts/radial-gauge.tsx | 41 +- .../src/components/charts/sparkline.tsx | 23 +- .../src/components/charts/waveform.tsx | 8 +- .../src/components/chatbot/chatbot.tsx | 71 +++- .../components/command/command-palette.tsx | 49 ++- .../src/components/primitives/avatar.tsx | 5 +- .../src/components/primitives/button.tsx | 2 +- .../src/components/primitives/index.ts | 16 +- .../src/components/primitives/progress.tsx | 13 +- .../src/components/primitives/select.tsx | 20 +- .../src/components/primitives/slot.tsx | 40 +- .../src/components/primitives/toast.tsx | 13 +- .../src/components/primitives/tooltip.tsx | 1 + .../src/components/shared/guild-picker.tsx | 2 +- .../frontend/src/components/shared/index.ts | 4 +- .../src/components/shared/section.tsx | 10 +- .../frontend/src/components/shared/states.tsx | 21 +- .../frontend/src/components/shell/index.ts | 2 +- .../src/components/shell/nav-rail.tsx | 5 +- .../src/components/shell/status-dot.tsx | 16 +- .../frontend/src/components/shell/topbar.tsx | 16 +- .../src/components/voice/voice-stage.tsx | 11 +- services/frontend/src/hooks/use-dashboard.ts | 8 +- 47 files changed, 1178 insertions(+), 590 deletions(-) delete mode 100644 services/frontend/nav-debug.cjs delete mode 100644 services/frontend/nav-test.cjs delete mode 100644 services/frontend/nav-test2.cjs delete mode 100644 services/frontend/nav-test4019.cjs diff --git a/services/frontend/nav-debug.cjs b/services/frontend/nav-debug.cjs deleted file mode 100644 index 4e154e1..0000000 --- a/services/frontend/nav-debug.cjs +++ /dev/null @@ -1,48 +0,0 @@ -const puppeteer = require("puppeteer-core"); -(async () => { - const browser = await puppeteer.launch({ executablePath: "/usr/bin/google-chrome", headless: true, args: ["--no-sandbox","--disable-setuid-sandbox"] }); - const page = await browser.newPage(); - page.on("pageerror", (e) => console.log("PAGEERROR:", e.message)); - await page.goto("http://127.0.0.1:4017/dashboard/", { waitUntil: "domcontentloaded", timeout: 30000 }); - await new Promise((r) => setTimeout(r, 2500)); - - // Find every tag and log its href + event listeners - const anchors = await page.$$eval("a", els => els.map(a => ({ - href: a.getAttribute("href"), - hasClick: a.hasAttribute("onClick"), - outer: a.outerHTML.substring(0, 200) - }))); - console.log("ALL ANCHORS:", JSON.stringify(anchors)); - - // Inject error catches - await page.evaluate(() => { - window.__clicks = []; - window.__events = []; - document.addEventListener("click", (e) => window.__clicks.push({ target: e.target.tagName, dp: e.defaultPrevented, url: location.href }), true); - document.addEventListener("click", (e) => { - const t = e.target.closest("a"); - if (t) { - window.__events.push({ targetTag: t.tagName, href: t.getAttribute("href"), dp: e.defaultPrevented }); - } - }, false); - }); - - // Click the Voice nav anchor - await page.evaluate(() => { - const v = Array.from(document.querySelectorAll('a[aria-label]')).find(a=>a.getAttribute('aria-label')==='Voice'); - if (v) v.click(); - }); - await new Promise((r) => setTimeout(r, 3000)); - - console.log("URL after click:", page.url()); - console.log("history.length:", await page.evaluate(() => history.length)); - console.log("CLICKS (capture phase):", JSON.stringify(await page.evaluate(() => window.__clicks))); - console.log("CLICKS (bubble phase):", JSON.stringify(await page.evaluate(() => window.__clicks))); - - // Force navigate and confirm destination - await page.evaluate(() => { window.location.href = "/voice/"; }); - await new Promise((r) => setTimeout(r, 2000)); - console.log("FORCED to /voice/ ->", page.url()); - - await browser.close(); -})().catch((e) => { console.error("FAIL:", e.message); process.exit(1); }); \ No newline at end of file diff --git a/services/frontend/nav-test.cjs b/services/frontend/nav-test.cjs deleted file mode 100644 index f8a2293..0000000 --- a/services/frontend/nav-test.cjs +++ /dev/null @@ -1,27 +0,0 @@ -const puppeteer = require("puppeteer-core"); - -(async () => { - const browser = await puppeteer.launch({ - executablePath: "/usr/bin/google-chrome", - headless: true, - args: ["--no-sandbox", "--disable-setuid-sandbox"], - }); - const page = await browser.newPage(); - const errs = []; - page.on("pageerror", (e) => errs.push("PAGEERROR: " + e.message)); - await page.goto("http://localhost:3000/dashboard/", { waitUntil: "domcontentloaded", timeout: 30000 }); - await new Promise((r) => setTimeout(r, 2500)); - - const info = await page.evaluate(() => { - const v = Array.from(document.querySelectorAll('a[aria-label]')).find((a) => a.getAttribute("aria-label") === "Voice"); - const r = v.getBoundingClientRect(); - return { cx: r.x + r.width / 2, cy: r.y + r.height / 2 }; - }); - await page.mouse.click(info.cx, info.cy); - for (const t of [500, 1000, 2000, 3500]) { - await new Promise((r) => setTimeout(r, t === 500 ? 500 : t - (t === 1000 ? 500 : t === 2000 ? 1000 : 2000))); - console.log(`url @${t}ms:`, page.url()); - } - console.log("ERRORS:", errs.slice(0, 10).join(" | ") || "none"); - await browser.close(); -})().catch((e) => { console.error("SCRIPT FAIL:", e); process.exit(1); }); diff --git a/services/frontend/nav-test2.cjs b/services/frontend/nav-test2.cjs deleted file mode 100644 index 026e784..0000000 --- a/services/frontend/nav-test2.cjs +++ /dev/null @@ -1,18 +0,0 @@ -const puppeteer = require("puppeteer-core"); -(async () => { - const browser = await puppeteer.launch({ executablePath: "/usr/bin/google-chrome", headless: true, args: ["--no-sandbox","--disable-setuid-sandbox"] }); - const page = await browser.newPage(); - const errs = []; - page.on("pageerror", (e) => errs.push("PAGEERR: " + e.message)); - page.on("console", (m) => { if (m.type() === "error" || m.type()==="warning") errs.push("CONS["+m.type()+"]: " + m.text().slice(0,100)); }); - await page.goto("http://127.0.0.1:4019/voice/", { waitUntil: "domcontentloaded", timeout: 30000 }); - await new Promise((r) => setTimeout(r, 4000)); - const snap = await page.evaluate(() => ({ - url: location.href, - hasVoice: !!Array.from(document.querySelectorAll("*")).find(e => e.textContent && e.textContent.includes("Live speakers")), - hasPicker: !!Array.from(document.querySelectorAll("select"))[0] || false, - })); - console.log("DIRECT /voice/ load:", JSON.stringify(snap)); - console.log("ERRORS:", errs.slice(0,15).join("\n") || "none"); - await browser.close(); -})().catch((e) => { console.error("FAIL:", e.message); process.exit(1); }); \ No newline at end of file diff --git a/services/frontend/nav-test4019.cjs b/services/frontend/nav-test4019.cjs deleted file mode 100644 index a887742..0000000 --- a/services/frontend/nav-test4019.cjs +++ /dev/null @@ -1,26 +0,0 @@ -const puppeteer = require("puppeteer-core"); -(async () => { - const browser = await puppeteer.launch({ executablePath: "/usr/bin/google-chrome", headless: true, args: ["--no-sandbox","--disable-setuid-sandbox"] }); - const page = await browser.newPage(); - const net = []; - page.on("request", (r) => { const u = r.url(); if (u.includes("_rsc") || u.includes("/voice")) net.push("REQ " + r.method() + " " + u); }); - page.on("response", (r) => { const u = r.url(); if (u.includes("_rsc") || u.includes("/voice")) net.push("RES " + r.status() + " " + u); }); - page.on("pageerror", (e) => console.log("PAGEERR:", e.message)); - await page.goto("http://127.0.0.1:4019/dashboard/", { waitUntil: "domcontentloaded", timeout: 30000 }); - await new Promise((r) => setTimeout(r, 2500)); - - const info = await page.evaluate(() => { - const b = document.querySelector('button[aria-label="Voice"]'); - const a = document.querySelector('a[aria-label="Voice"]'); - return { buttonPresent: !!b, aPresent: !!a }; - }); - console.log("NAV button present:", info.buttonPresent, "| a present:", info.aPresent); - - await page.evaluate(() => { - const b = document.querySelector('button[aria-label="Voice"]'); - if (b) b.click(); - }); - await new Promise((r) => setTimeout(r, 3000)); - console.log("URL after click:", page.url(), "| history:", await page.evaluate(() => history.length)); - await browser.close(); -})().catch((e) => { console.error("FAIL:", e.message); process.exit(1); }); \ No newline at end of file diff --git a/services/frontend/next.config.ts b/services/frontend/next.config.ts index 43abafc..ed3d0e9 100644 --- a/services/frontend/next.config.ts +++ b/services/frontend/next.config.ts @@ -1,13 +1,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - reactCompiler: true, - // SSR — pages render on the server; realtime stays client-side via WS. - // No static export: shared state (voice, media, moderation) is served - // from the backend at render-time on the server. output: "standalone", trailingSlash: true, - images: { unoptimized: true }, }; -export default nextConfig; \ No newline at end of file +export default nextConfig; diff --git a/services/frontend/src/app/(dashboard)/analysis/view.tsx b/services/frontend/src/app/(dashboard)/analysis/view.tsx index 6b51707..54ab97b 100644 --- a/services/frontend/src/app/(dashboard)/analysis/view.tsx +++ b/services/frontend/src/app/(dashboard)/analysis/view.tsx @@ -1,15 +1,17 @@ "use client"; +import { Hash, Search, Sparkles, TrendingUp } from "lucide-react"; import { useEffect, useState } from "react"; -import { Search, Sparkles, TrendingUp, Hash } from "lucide-react"; -import { useMessageSearch, useTopReactors, useChannels } from "@/hooks"; import { useAmbient } from "@/components/ambient/ambient-context"; -import { GlassPanel, GlassCard, Avatar, Input, Badge } from "@/components/primitives"; -import { SectionHeader, EmptyState, LoadingState } from "@/components/shared"; -import { renderMessageContent, getMessageChannelLabel } from "@/lib/format"; +import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives"; +import { EmptyState, LoadingState, SectionHeader } from "@/components/shared"; +import { useChannels, useMessageSearch, useTopReactors } from "@/hooks"; +import { getMessageChannelLabel, renderMessageContent } from "@/lib/format"; import type { AiStatus } from "@/lib/types"; -function aiTone(s?: AiStatus | null): "signal" | "amber" | "vermilion" | "neutral" { +function aiTone( + s?: AiStatus | null, +): "signal" | "amber" | "vermilion" | "neutral" { if (s === "clean") return "signal"; if (s === "warn") return "amber"; if (s === "flagged" || s === "error") return "vermilion"; @@ -24,7 +26,11 @@ export function AnalysisView() { const ambient = useAmbient(); useEffect(() => { - ambient.set(query ? "amber" : "signal", 0.3, query ? "analyzing" : "search"); + ambient.set( + query ? "amber" : "signal", + 0.3, + query ? "analyzing" : "search", + ); }, [query, ambient]); return ( @@ -49,28 +55,57 @@ export function AnalysisView() { /> {query.trim().length > 0 && query.trim().length < 2 && ( -
Type at least 2 characters…
+
+ Type at least 2 characters… +
)}
- {(search.data ?? []).length}} /> - {query.trim().length >= 2 && search.isLoading && } + + {(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." /> + } + 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}} + + {m.username} + + + {getMessageChannelLabel(m)} + + {m.ai_status && ( + + {m.ai_status} + + )} +
+
+ {renderMessageContent(m.content, m.metadata) || "(embed)"}
-
{renderMessageContent(m.content, m.metadata) || "(embed)"}
))} @@ -80,29 +115,63 @@ export function AnalysisView() {
- Top reactors} /> + + Top reactors + + } + />
{(reactors ?? []).slice(0, 6).map((r, i) => ( -
+
{i + 1} {r.username} - +{r.net_count} + + +{r.net_count} +
))} - {(reactors ?? []).length === 0 &&
No data
} + {(reactors ?? []).length === 0 && ( +
+ No data +
+ )}
- Top channels} /> + + Top channels + + } + />
{(channels ?? []).slice(0, 6).map((c) => ( -
- {c.channel_name ?? c.channel_id.slice(0, 8)} - {c.total_messages} +
+ + {c.channel_name ?? c.channel_id.slice(0, 8)} + + + {c.total_messages} +
))} - {(channels ?? []).length === 0 &&
No data
} + {(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 818bf60..7052caa 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -4,10 +4,13 @@ import { DashboardView } from "./view"; export const dynamic = "force-dynamic"; export default async function DashboardPage() { - let stats = undefined; - let activity = undefined; + let stats: Awaited> | undefined; + let activity: Awaited> | undefined; try { - [stats, activity] = await Promise.all([getDashboardStats(), getActivity(14)]); + [stats, activity] = await Promise.all([ + getDashboardStats(), + getActivity(14), + ]); } catch { // Backend unavailable — client hooks will surface the error state. } diff --git a/services/frontend/src/app/(dashboard)/dashboard/view.tsx b/services/frontend/src/app/(dashboard)/dashboard/view.tsx index 004cfcc..202420f 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/view.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/view.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect } from "react"; import { Activity, Flag, @@ -10,22 +9,18 @@ import { ShieldAlert, Users, } from "lucide-react"; +import { useEffect } from "react"; +import { useAmbient } from "@/components/ambient/ambient-context"; +import { AreaActivity, RadialGauge } from "@/components/charts"; +import { GlassPanel } from "@/components/primitives"; +import { ErrorState, LoadingState } from "@/components/shared"; +import { MetricTile, SectionHeader } from "@/components/shared/section"; import { useActivity, useStats, - useTopReactors, useTopReactions, + useTopReactors, } from "@/hooks"; -import { useAmbient } from "@/components/ambient/ambient-context"; -import { GlassPanel, GlassCard } from "@/components/primitives"; -import { - AreaActivity, - Donut, - RadialGauge, - Sparkline, -} from "@/components/charts"; -import { MetricTile, SectionHeader } from "@/components/shared/section"; -import { ErrorState, LoadingState } from "@/components/shared"; import { formatNumber } from "@/lib/format"; import type { DashboardStats } from "@/lib/types"; @@ -33,8 +28,10 @@ function deriveSignal(stats?: DashboardStats) { if (!stats) return { tone: "signal" as const, label: "nominal" }; const total = stats.total_flagged + stats.total_clean || 1; const ratio = stats.total_flagged / total; - if (stats.moderation_overview.error > 0) return { tone: "vermilion" as const, label: "moderation fault" }; - if (ratio > 0.25) return { tone: "vermilion" as const, label: "elevated flags" }; + if (stats.moderation_overview.error > 0) + return { tone: "vermilion" as const, label: "moderation fault" }; + if (ratio > 0.25) + return { tone: "vermilion" as const, label: "elevated flags" }; if (ratio > 0.1) return { tone: "amber" as const, label: "watch" }; return { tone: "signal" as const, label: "nominal" }; } @@ -54,156 +51,243 @@ export function DashboardView({ useEffect(() => { const s = deriveSignal(stats); - ambient.set(s.tone, 0.3 + Math.min(0.5, (stats?.today_flagged ?? 0) / 50), s.label); + ambient.set( + s.tone, + 0.3 + Math.min(0.5, (stats?.today_flagged ?? 0) / 50), + s.label, + ); }, [stats, ambient]); if (error && !stats) return ; if (!stats && isLoading) return ; + if (!stats) return ; - const s = stats!; + const s = stats; const total = s.total_flagged + s.total_clean || 1; const cleanRatio = s.total_clean / total; return (
- {/* 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} - -
+ {/* Hero */} + +
+
+
+
GMW · Operations Grid
+

+ Ambient Field +

+

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

- -
- } /> - 0 ? "vermilion" : "neutral"} hint={`${s.today_flagged} today`} /> - } /> - } /> +
+ + + {deriveSignal(s).label} +
- +
- {/* Activity */} - - - Activity & moderation - - } - action={ -
- messages - flagged -
- } +
+ } /> - {activity ? ( - - ) : ( - - )} + 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)} + +
+ ); + })} +
- {/* 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" + + +
+ 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)} /> -
- } 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 && } -
-
-
+
+
+ + + +
+
+ + {/* 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 && } +
+
+
+
); } -function Row({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { +function Row({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { return (
{icon} @@ -213,8 +297,21 @@ function Row({ icon, label, value }: { icon: React.ReactNode; label: string; val ); } -function Mini({ label, value, tone }: { label: string; value: number; tone: "signal" | "amber" | "vermilion" }) { - const color = tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"; +function Mini({ + label, + value, + tone, +}: { + label: string; + value: number; + tone: "signal" | "amber" | "vermilion"; +}) { + const color = + tone === "vermilion" + ? "text-vermilion" + : tone === "amber" + ? "text-amber" + : "text-signal"; return (
{value}
@@ -224,5 +321,9 @@ function Mini({ label, value, tone }: { label: string; value: number; tone: "sig } function EmptyHint() { - return
Awaiting data…
; + return ( +
+ Awaiting data… +
+ ); } diff --git a/services/frontend/src/app/(dashboard)/layout.tsx b/services/frontend/src/app/(dashboard)/layout.tsx index 4df4ad5..eaa0fbd 100644 --- a/services/frontend/src/app/(dashboard)/layout.tsx +++ b/services/frontend/src/app/(dashboard)/layout.tsx @@ -1,8 +1,8 @@ import { AmbientProvider } from "@/components/ambient/ambient-context"; -import { WsProvider } from "@/lib/ws/context"; -import { AppFrame } from "@/components/shell"; import { Chatbot } from "@/components/chatbot/chatbot"; import { CommandPalette } from "@/components/command/command-palette"; +import { AppFrame } from "@/components/shell"; +import { WsProvider } from "@/lib/ws/context"; export default function DashboardLayout({ children, diff --git a/services/frontend/src/app/(dashboard)/media/page.tsx b/services/frontend/src/app/(dashboard)/media/page.tsx index 6fd94de..f4b01b5 100644 --- a/services/frontend/src/app/(dashboard)/media/page.tsx +++ b/services/frontend/src/app/(dashboard)/media/page.tsx @@ -4,7 +4,7 @@ import { MediaView } from "./view"; export const dynamic = "force-dynamic"; export default async function MediaPage() { - let status = undefined; + let status: import("@/lib/types").MediaState | undefined; try { status = await getMediaStatus(); } catch { diff --git a/services/frontend/src/app/(dashboard)/media/view.tsx b/services/frontend/src/app/(dashboard)/media/view.tsx index 390dbd5..50524dd 100644 --- a/services/frontend/src/app/(dashboard)/media/view.tsx +++ b/services/frontend/src/app/(dashboard)/media/view.tsx @@ -1,29 +1,27 @@ "use client"; -import { useEffect, useState } from "react"; import { ListMusic, - Pause, Play, + Radio, Repeat, SkipForward, Square, - Radio, } from "lucide-react"; -import { useWebSocket } from "@/lib/ws/context"; +import { useEffect, useState } from "react"; +import { useAmbient } from "@/components/ambient/ambient-context"; +import { Button, GlassPanel, Input, toast } from "@/components/primitives"; +import { ErrorState, LoadingState, SectionHeader } from "@/components/shared"; import { - useMediaState, + useMediaLoop, useMediaQueue, useMediaSkip, + useMediaState, useMediaStop, - useMediaLoop, useMediaWsSync, } from "@/hooks"; -import { useAmbient } from "@/components/ambient/ambient-context"; -import { GlassPanel, GlassCard, Button, Input } from "@/components/primitives"; -import { SectionHeader, ErrorState, LoadingState } from "@/components/shared"; -import { toast } from "@/components/primitives"; import type { MediaState } from "@/lib/types"; +import { useWebSocket } from "@/lib/ws/context"; export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { const ws = useWebSocket(); @@ -43,7 +41,11 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { const tone = playing ? "signal" : queueList.length ? "amber" : "signal"; useEffect(() => { - ambient.set(tone, playing ? 0.5 : 0.25, playing ? "now playing" : "media idle"); + ambient.set( + tone, + playing ? 0.5 : 0.25, + playing ? "now playing" : "media idle", + ); }, [tone, playing, ambient]); const onPlay = async () => { @@ -57,7 +59,11 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { setUrl(""); toast({ title: "Queued", tone: "signal" }); } catch (e) { - toast({ title: "Queue failed", description: String(e), tone: "vermilion" }); + toast({ + title: "Queue failed", + description: String(e), + tone: "vermilion", + }); } }; @@ -83,16 +89,33 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { {current?.title ?? "Nothing queued"} {current?.source && ( -
{current.source}
+
+ {current.source} +
)}
- - - ) : ( - )} @@ -127,7 +151,11 @@ export function VoiceView({ size="sm" onClick={() => listen.toggle(!listen.active)} > - {listen.active ? : } + {listen.active ? ( + + ) : ( + + )} {listen.active ? "Listening" : "Listen in"} {listen.active && ( @@ -154,7 +182,11 @@ export function VoiceView({ } title={connected ? "Silent right now" : "Not connected"} - description={connected ? "Speakers appear as they talk." : "Connect to a voice channel to see presence."} + description={ + connected + ? "Speakers appear as they talk." + : "Connect to a voice channel to see presence." + } /> ) : ( <> @@ -169,7 +201,9 @@ export function VoiceView({ : "border-hairline bg-white/5 text-ink-soft" }`} > - + {sp.username} ))} @@ -182,14 +216,23 @@ export function VoiceView({
{(status?.connections ?? []).map((c) => ( -
+
- {c.channelName} - {new Date(c.connectedAt).toLocaleTimeString()} + + {c.channelName} + + + {new Date(c.connectedAt).toLocaleTimeString()} +
))} {(status?.connections ?? []).length === 0 && ( -
No active links
+
+ No active links +
)}
diff --git a/services/frontend/src/components/ambient/ambient-canvas.tsx b/services/frontend/src/components/ambient/ambient-canvas.tsx index c71bc23..98cafe1 100644 --- a/services/frontend/src/components/ambient/ambient-canvas.tsx +++ b/services/frontend/src/components/ambient/ambient-canvas.tsx @@ -78,7 +78,9 @@ export function AmbientCanvas({ return; // static CSS fallback remains } - const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + 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); diff --git a/services/frontend/src/components/ambient/ambient-context.tsx b/services/frontend/src/components/ambient/ambient-context.tsx index 2533268..1cbc9d5 100644 --- a/services/frontend/src/components/ambient/ambient-context.tsx +++ b/services/frontend/src/components/ambient/ambient-context.tsx @@ -1,6 +1,13 @@ "use client"; -import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; import { AmbientCanvas } from "./ambient-canvas"; export type SignalTone = "signal" | "amber" | "vermilion"; @@ -25,7 +32,11 @@ export interface AmbientControls { state: AmbientState; } -const DEFAULT: AmbientState = { tone: "signal", intensity: 0.35, label: "nominal" }; +const DEFAULT: AmbientState = { + tone: "signal", + intensity: 0.35, + label: "nominal", +}; const AmbientContext = createContext(null); @@ -38,14 +49,17 @@ 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 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 }; diff --git a/services/frontend/src/components/charts/area-activity.tsx b/services/frontend/src/components/charts/area-activity.tsx index 5d87306..71914de 100644 --- a/services/frontend/src/components/charts/area-activity.tsx +++ b/services/frontend/src/components/charts/area-activity.tsx @@ -18,12 +18,29 @@ export function AreaActivity({ 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 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 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 ( - + @@ -31,14 +48,44 @@ export function AreaActivity({ {[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 index 97cee3b..dab9f7d 100644 --- a/services/frontend/src/components/charts/donut.tsx +++ b/services/frontend/src/components/charts/donut.tsx @@ -17,14 +17,24 @@ export function Donut({ const c = 2 * Math.PI * r; let offset = 0; return ( -
- - - {segments.map((s, i) => { +
+ + + {segments.map((s) => { const len = (s.value / total) * c; const el = ( {(centerLabel || centerSub) && (
- {centerLabel && {centerLabel}} - {centerSub && {centerSub}} + {centerLabel && ( + {centerLabel} + )} + {centerSub && ( + + {centerSub} + + )}
)}
diff --git a/services/frontend/src/components/charts/index.ts b/services/frontend/src/components/charts/index.ts index f15c2dc..f72be42 100644 --- a/services/frontend/src/components/charts/index.ts +++ b/services/frontend/src/components/charts/index.ts @@ -1,5 +1,5 @@ -export { Sparkline } from "./sparkline"; export { AreaActivity } from "./area-activity"; -export { RadialGauge } from "./radial-gauge"; export { Donut } from "./donut"; +export { RadialGauge } from "./radial-gauge"; +export { Sparkline } from "./sparkline"; 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 b975109..450d226 100644 --- a/services/frontend/src/components/charts/radial-gauge.tsx +++ b/services/frontend/src/components/charts/radial-gauge.tsx @@ -15,13 +15,28 @@ export function RadialGauge({ 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 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; return ( -
- - +
+ +
- + {label} - {sublabel && {sublabel}} + {sublabel && ( + {sublabel} + )}
); diff --git a/services/frontend/src/components/charts/sparkline.tsx b/services/frontend/src/components/charts/sparkline.tsx index 6370278..b4564ab 100644 --- a/services/frontend/src/components/charts/sparkline.tsx +++ b/services/frontend/src/components/charts/sparkline.tsx @@ -24,11 +24,22 @@ export function Sparkline({ 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(2)},${p[1].toFixed(2)}`).join(" "); + 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 ( - + @@ -36,7 +47,13 @@ export function Sparkline({ {fill && } - + ); } diff --git a/services/frontend/src/components/charts/waveform.tsx b/services/frontend/src/components/charts/waveform.tsx index bb1adda..f513342 100644 --- a/services/frontend/src/components/charts/waveform.tsx +++ b/services/frontend/src/components/charts/waveform.tsx @@ -15,13 +15,17 @@ export function Equalizer({ {bars.length === 0 ? (
{Array.from({ length: 28 }).map((_, i) => ( - + ))}
) : ( bars.map((b, i) => ( { setMsgs( - res.history - .slice(-12) - .flatMap((h) => [ - { role: "user" as const, content: h.user_message }, - { role: "bot" as const, content: h.bot_response }, - ]), + res.history.slice(-12).flatMap((h) => [ + { role: "user" as const, content: h.user_message }, + { role: "bot" as const, content: h.bot_response }, + ]), ); }) .catch(() => {}); @@ -40,7 +43,7 @@ export function Chatbot() { useEffect(() => { listRef.current?.scrollTo({ top: listRef.current.scrollHeight }); - }, [msgs, loading]); + }, []); const send = async () => { const text = input.trim(); @@ -80,20 +83,39 @@ export function Chatbot() {
-
GMW Assistant
-
context-aware
+
+ GMW Assistant +
+
+ context-aware +
-
+
{msgs.length === 0 && (
Ask about moderation, voice, or media.
)} {msgs.map((m, i) => ( -
- {m.role === "bot" && } +
+ {m.role === "bot" && ( + + )}
- -
+ +
+ … +
)}
@@ -121,7 +149,12 @@ export function Chatbot() { onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && send()} /> -
diff --git a/services/frontend/src/components/command/command-palette.tsx b/services/frontend/src/components/command/command-palette.tsx index 7ab16e6..97e3f18 100644 --- a/services/frontend/src/components/command/command-palette.tsx +++ b/services/frontend/src/components/command/command-palette.tsx @@ -1,18 +1,18 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/navigation"; -import { useTheme } from "next-themes"; import { - Search, - CornerDownLeft, - ArrowUp, ArrowDown, + ArrowUp, + CornerDownLeft, Moon, + Search, Sun, } from "lucide-react"; -import { navItems } from "@/lib/navigation"; +import { useRouter } from "next/navigation"; +import { useTheme } from "next-themes"; +import { useEffect, useMemo, useState } from "react"; import { GlassPanel } from "@/components/primitives"; +import { navItems } from "@/lib/navigation"; interface Command { id: string; @@ -58,7 +58,8 @@ export function CommandPalette() { const q = query.trim().toLowerCase(); if (!q) return commands; return commands.filter( - (c) => c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q), + (c) => + c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q), ); }, [commands, query]); @@ -88,7 +89,7 @@ export function CommandPalette() { useEffect(() => { setActive(0); - }, [query]); + }, []); if (!open) return null; @@ -103,6 +104,7 @@ export function CommandPalette() {
setOpen(false)} + role="presentation" > - ESC + + ESC +
{filtered.length === 0 ? ( -
No commands
+
+ No commands +
) : ( filtered.map((c, i) => ( )) )}
- navigate - select + + + navigate + + + select + ⌘K
diff --git a/services/frontend/src/components/primitives/avatar.tsx b/services/frontend/src/components/primitives/avatar.tsx index 89aff3f..abf40e4 100644 --- a/services/frontend/src/components/primitives/avatar.tsx +++ b/services/frontend/src/components/primitives/avatar.tsx @@ -2,7 +2,10 @@ import { cn } from "@/lib/utils"; function initials(name?: string | null): string { if (!name) return "?"; - const parts = name.replace(/[^\p{L}\p{N} _]/gu, "").trim().split(/\s+/); + const parts = name + .replace(/[^\p{L}\p{N} _]/gu, "") + .trim() + .split(/\s+/); if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } diff --git a/services/frontend/src/components/primitives/button.tsx b/services/frontend/src/components/primitives/button.tsx index 4490974..3d34fca 100644 --- a/services/frontend/src/components/primitives/button.tsx +++ b/services/frontend/src/components/primitives/button.tsx @@ -1,5 +1,5 @@ -import { Slot } from "./slot"; import { cn } from "@/lib/utils"; +import { Slot } from "./slot"; type Variant = "primary" | "ghost" | "outline" | "danger" | "subtle"; type Size = "sm" | "md" | "lg" | "icon"; diff --git a/services/frontend/src/components/primitives/index.ts b/services/frontend/src/components/primitives/index.ts index d18f858..e3d8719 100644 --- a/services/frontend/src/components/primitives/index.ts +++ b/services/frontend/src/components/primitives/index.ts @@ -1,11 +1,11 @@ -export { Button } from "./button"; -export { Badge } from "./badge"; -export { GlassPanel, GlassCard } from "./card"; -export { Input, Textarea } from "./input"; -export { Skeleton } from "./skeleton"; export { Avatar } from "./avatar"; -export { Select } from "./select"; -export type { SelectOption } from "./select"; -export { Toaster, toast, useToast } from "./toast"; +export { Badge } from "./badge"; +export { Button } from "./button"; +export { GlassCard, GlassPanel } from "./card"; +export { Input, Textarea } from "./input"; export { Progress, Spinner } from "./progress"; +export type { SelectOption } from "./select"; +export { Select } from "./select"; +export { Skeleton } from "./skeleton"; +export { Toaster, toast, useToast } from "./toast"; export { Tooltip } from "./tooltip"; diff --git a/services/frontend/src/components/primitives/progress.tsx b/services/frontend/src/components/primitives/progress.tsx index 59dba00..ab0ff64 100644 --- a/services/frontend/src/components/primitives/progress.tsx +++ b/services/frontend/src/components/primitives/progress.tsx @@ -17,10 +17,19 @@ export function Progress({ ? "var(--color-amber)" : "var(--color-signal)"; return ( -
+
); diff --git a/services/frontend/src/components/primitives/select.tsx b/services/frontend/src/components/primitives/select.tsx index 1541ab8..965f60b 100644 --- a/services/frontend/src/components/primitives/select.tsx +++ b/services/frontend/src/components/primitives/select.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useRef, useState } from "react"; import { Check, ChevronDown } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface SelectOption { @@ -31,7 +31,8 @@ export function Select({ useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + if (ref.current && !ref.current.contains(e.target as Node)) + setOpen(false); }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); @@ -54,7 +55,10 @@ export function Select({ {selected?.label ?? placeholder} @@ -76,11 +80,17 @@ export function Select({ }} className={cn( "flex w-full items-center justify-between gap-2 rounded-[9px] px-3 py-2 text-left text-sm transition-colors", - o.value === value ? "bg-signal/15 text-signal" : "text-ink hover:bg-white/6", + o.value === value + ? "bg-signal/15 text-signal" + : "text-ink hover:bg-white/6", )} > {o.label} - {o.hint && {o.hint}} + {o.hint && ( + + {o.hint} + + )} {o.value === value && } ))} diff --git a/services/frontend/src/components/primitives/slot.tsx b/services/frontend/src/components/primitives/slot.tsx index 2493782..a55dfd6 100644 --- a/services/frontend/src/components/primitives/slot.tsx +++ b/services/frontend/src/components/primitives/slot.tsx @@ -6,22 +6,26 @@ import * as React from "react"; * Minimal Slot — merges its props onto its single child element (Radix-style * `asChild`). Enough for wrapping /
in a Button. */ -export const Slot = React.forwardRef & { children?: React.ReactNode }>( - ({ children, ...slotProps }, ref) => { - if (!React.isValidElement(children)) return null; - const childProps = children.props as Record; - const merged: Record = { ...childProps, ...slotProps, ref }; - // Merge className - if (slotProps.className || childProps.className) { - merged.className = [childProps.className, slotProps.className] - .filter(Boolean) - .join(" "); - } - // Merge style - if (slotProps.style || childProps.style) { - merged.style = { ...(childProps.style as object), ...(slotProps.style as object) }; - } - return React.cloneElement(children, merged); - }, -); +export const Slot = React.forwardRef< + HTMLElement, + React.HTMLAttributes & { children?: React.ReactNode } +>(({ children, ...slotProps }, ref) => { + if (!React.isValidElement(children)) return null; + const childProps = children.props as Record; + const merged: Record = { ...childProps, ...slotProps, ref }; + // Merge className + if (slotProps.className || childProps.className) { + merged.className = [childProps.className, slotProps.className] + .filter(Boolean) + .join(" "); + } + // Merge style + if (slotProps.style || childProps.style) { + merged.style = { + ...(childProps.style as object), + ...(slotProps.style as object), + }; + } + return React.cloneElement(children, merged); +}); Slot.displayName = "Slot"; diff --git a/services/frontend/src/components/primitives/toast.tsx b/services/frontend/src/components/primitives/toast.tsx index 3556dc7..4c94698 100644 --- a/services/frontend/src/components/primitives/toast.tsx +++ b/services/frontend/src/components/primitives/toast.tsx @@ -1,7 +1,7 @@ "use client"; +import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react"; import { useEffect, useState } from "react"; -import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react"; import { cn } from "@/lib/utils"; type ToastTone = "signal" | "vermilion" | "neutral"; @@ -63,7 +63,12 @@ export function Toaster({ position = "bottom-right" }: { position?: string }) { : "bottom-4 left-1/2 -translate-x-1/2"; return ( -
+
{items.map((t) => { const Icon = icons[t.tone]; return ( @@ -83,7 +88,9 @@ export function Toaster({ position = "bottom-right" }: { position?: string }) {
{t.title}
{t.description && ( -
{t.description}
+
+ {t.description} +
)}
- {hint &&
{hint}
} + {hint && ( +
{hint}
+ )} {spark && spark.length > 1 && (
diff --git a/services/frontend/src/components/shared/states.tsx b/services/frontend/src/components/shared/states.tsx index c3ca55c..3aa3b93 100644 --- a/services/frontend/src/components/shared/states.tsx +++ b/services/frontend/src/components/shared/states.tsx @@ -14,10 +14,19 @@ export function EmptyState({ className?: string; }) { return ( -
-
{icon ?? }
+
+
+ {icon ?? } +
{title}
- {description &&
{description}
} + {description && ( +
{description}
+ )}
); } @@ -36,7 +45,11 @@ export function ErrorState({
{title}
- {msg &&
{msg}
} + {msg && ( +
+ {msg} +
+ )} {onRetry && (
@@ -45,7 +47,12 @@ export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) { >
- + {s.speaking && ( )} diff --git a/services/frontend/src/hooks/use-dashboard.ts b/services/frontend/src/hooks/use-dashboard.ts index 2a1e090..6414e96 100644 --- a/services/frontend/src/hooks/use-dashboard.ts +++ b/services/frontend/src/hooks/use-dashboard.ts @@ -65,15 +65,15 @@ export function useChannels(guildId?: string, search?: string) { export function useUserDetail(userId: string | null) { return useSWR( - userId ? ["dashboard-user", userId] : null, - () => dashboardApi.getUserDetail(userId!), + userId ? (["dashboard-user", userId] as const) : null, + () => dashboardApi.getUserDetail(userId ?? ""), ); } export function useChannelDetail(channelId: string | null) { return useSWR( - channelId ? ["dashboard-channel", channelId] : null, - () => dashboardApi.getChannelDetail(channelId!), + channelId ? (["dashboard-channel", channelId] as const) : null, + () => dashboardApi.getChannelDetail(channelId ?? ""), ); }