feat(frontend): Ambient/WebGL console revamp + lint/type cleanup
Ground-up rebuild of the GMW frontend as an Ambient Field console: - WebGL ambient background (Three.js shader, drifting motes, reduced-motion aware) - Glassmorphism dark cyber theme across all 8 routes - SSR page + client view split with SWR fallback; realtime via WebSocket - Command palette (Cmd+K), chatbot FAB, guild/channel pickers - Chart primitives: donut, radial-gauge, area-activity, sparkline, equalizer Cleanup (review pass): - Remove stray Puppeteer nav-test/nav-debug scripts - Replace non-null assertions with guards (dashboard/moderation) - Drop unused useGuilds fetches in messages/voice views - Type implicit-any `let` declarations across pages - Add a11y roles/labels to SVG charts and audio, tidy imports
This commit is contained in:
@@ -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 <a> 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); });
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
@@ -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;
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Search, Sparkles, TrendingUp, Hash } from "lucide-react";
|
||||
import { useMessageSearch, useTopReactors, useChannels } from "@/hooks";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, GlassCard, Avatar, Input, Badge } from "@/components/primitives";
|
||||
import { SectionHeader, EmptyState, LoadingState } from "@/components/shared";
|
||||
import { renderMessageContent, getMessageChannelLabel } from "@/lib/format";
|
||||
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
|
||||
import { EmptyState, LoadingState, SectionHeader } from "@/components/shared";
|
||||
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
|
||||
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
|
||||
import type { AiStatus } from "@/lib/types";
|
||||
|
||||
function aiTone(s?: AiStatus | null): "signal" | "amber" | "vermilion" | "neutral" {
|
||||
function aiTone(
|
||||
s?: AiStatus | null,
|
||||
): "signal" | "amber" | "vermilion" | "neutral" {
|
||||
if (s === "clean") return "signal";
|
||||
if (s === "warn") return "amber";
|
||||
if (s === "flagged" || s === "error") return "vermilion";
|
||||
@@ -24,7 +26,11 @@ export function AnalysisView() {
|
||||
const ambient = useAmbient();
|
||||
|
||||
useEffect(() => {
|
||||
ambient.set(query ? "amber" : "signal", 0.3, query ? "analyzing" : "search");
|
||||
ambient.set(
|
||||
query ? "amber" : "signal",
|
||||
0.3,
|
||||
query ? "analyzing" : "search",
|
||||
);
|
||||
}, [query, ambient]);
|
||||
|
||||
return (
|
||||
@@ -49,28 +55,57 @@ export function AnalysisView() {
|
||||
/>
|
||||
</div>
|
||||
{query.trim().length > 0 && query.trim().length < 2 && (
|
||||
<div className="mono mt-2 text-xs text-ink-faint">Type at least 2 characters…</div>
|
||||
<div className="mono mt-2 text-xs text-ink-faint">
|
||||
Type at least 2 characters…
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader eyebrow="results" title="Matches" action={<span className="mono text-xs text-ink-faint">{(search.data ?? []).length}</span>} />
|
||||
{query.trim().length >= 2 && search.isLoading && <LoadingState label="Scanning" />}
|
||||
<SectionHeader
|
||||
eyebrow="results"
|
||||
title="Matches"
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{(search.data ?? []).length}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{query.trim().length >= 2 && search.isLoading && (
|
||||
<LoadingState label="Scanning" />
|
||||
)}
|
||||
{(search.data ?? []).length === 0 ? (
|
||||
<EmptyState icon={<Search className="size-7" />} title="No matches yet" description="Run a search to surface messages across the guild." />
|
||||
<EmptyState
|
||||
icon={<Search className="size-7" />}
|
||||
title="No matches yet"
|
||||
description="Run a search to surface messages across the guild."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{(search.data ?? []).map((m) => (
|
||||
<div key={m.id} className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3">
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3"
|
||||
>
|
||||
<Avatar src={m.avatar_url} name={m.username} size={32} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-ink">{m.username}</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)}</span>
|
||||
{m.ai_status && <Badge tone={aiTone(m.ai_status)} className="ml-auto">{m.ai_status}</Badge>}
|
||||
<span className="text-sm font-semibold text-ink">
|
||||
{m.username}
|
||||
</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{getMessageChannelLabel(m)}
|
||||
</span>
|
||||
{m.ai_status && (
|
||||
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
||||
{m.ai_status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm text-ink-soft">
|
||||
{renderMessageContent(m.content, m.metadata) || "(embed)"}
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm text-ink-soft">{renderMessageContent(m.content, m.metadata) || "(embed)"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -80,29 +115,63 @@ export function AnalysisView() {
|
||||
|
||||
<div className="space-y-5 lg:col-span-2">
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="culture" title={<span className="flex items-center gap-2"><TrendingUp className="size-4 text-signal" /> Top reactors</span>} />
|
||||
<SectionHeader
|
||||
eyebrow="culture"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<TrendingUp className="size-4 text-signal" /> Top reactors
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{(reactors ?? []).slice(0, 6).map((r, i) => (
|
||||
<div key={r.user_id} className="flex items-center gap-3 text-sm">
|
||||
<div
|
||||
key={r.user_id}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<span className="flex-1 truncate text-ink">{r.username}</span>
|
||||
<span className="mono text-xs text-signal">+{r.net_count}</span>
|
||||
<span className="mono text-xs text-signal">
|
||||
+{r.net_count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(reactors ?? []).length === 0 && <div className="py-4 text-center text-xs text-ink-faint">No data</div>}
|
||||
{(reactors ?? []).length === 0 && (
|
||||
<div className="py-4 text-center text-xs text-ink-faint">
|
||||
No data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="channels" title={<span className="flex items-center gap-2"><Hash className="size-4 text-signal" /> Top channels</span>} />
|
||||
<SectionHeader
|
||||
eyebrow="channels"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Hash className="size-4 text-signal" /> Top channels
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{(channels ?? []).slice(0, 6).map((c) => (
|
||||
<div key={c.channel_id} className="flex items-center gap-3 text-sm">
|
||||
<span className="flex-1 truncate text-ink-soft">{c.channel_name ?? c.channel_id.slice(0, 8)}</span>
|
||||
<span className="mono text-xs text-ink-faint">{c.total_messages}</span>
|
||||
<div
|
||||
key={c.channel_id}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<span className="flex-1 truncate text-ink-soft">
|
||||
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{c.total_messages}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(channels ?? []).length === 0 && <div className="py-4 text-center text-xs text-ink-faint">No data</div>}
|
||||
{(channels ?? []).length === 0 && (
|
||||
<div className="py-4 text-center text-xs text-ink-faint">
|
||||
No data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,13 @@ import { DashboardView } from "./view";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
let stats = undefined;
|
||||
let activity = undefined;
|
||||
let stats: Awaited<ReturnType<typeof getDashboardStats>> | undefined;
|
||||
let activity: Awaited<ReturnType<typeof getActivity>> | undefined;
|
||||
try {
|
||||
[stats, activity] = await Promise.all([getDashboardStats(), getActivity(14)]);
|
||||
[stats, activity] = await Promise.all([
|
||||
getDashboardStats(),
|
||||
getActivity(14),
|
||||
]);
|
||||
} catch {
|
||||
// Backend unavailable — client hooks will surface the error state.
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Flag,
|
||||
@@ -10,22 +9,18 @@ import {
|
||||
ShieldAlert,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { AreaActivity, RadialGauge } from "@/components/charts";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { ErrorState, LoadingState } from "@/components/shared";
|
||||
import { MetricTile, SectionHeader } from "@/components/shared/section";
|
||||
import {
|
||||
useActivity,
|
||||
useStats,
|
||||
useTopReactors,
|
||||
useTopReactions,
|
||||
useTopReactors,
|
||||
} from "@/hooks";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, GlassCard } from "@/components/primitives";
|
||||
import {
|
||||
AreaActivity,
|
||||
Donut,
|
||||
RadialGauge,
|
||||
Sparkline,
|
||||
} from "@/components/charts";
|
||||
import { MetricTile, SectionHeader } from "@/components/shared/section";
|
||||
import { ErrorState, LoadingState } from "@/components/shared";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type { DashboardStats } from "@/lib/types";
|
||||
|
||||
@@ -33,8 +28,10 @@ function deriveSignal(stats?: DashboardStats) {
|
||||
if (!stats) return { tone: "signal" as const, label: "nominal" };
|
||||
const total = stats.total_flagged + stats.total_clean || 1;
|
||||
const ratio = stats.total_flagged / total;
|
||||
if (stats.moderation_overview.error > 0) return { tone: "vermilion" as const, label: "moderation fault" };
|
||||
if (ratio > 0.25) return { tone: "vermilion" as const, label: "elevated flags" };
|
||||
if (stats.moderation_overview.error > 0)
|
||||
return { tone: "vermilion" as const, label: "moderation fault" };
|
||||
if (ratio > 0.25)
|
||||
return { tone: "vermilion" as const, label: "elevated flags" };
|
||||
if (ratio > 0.1) return { tone: "amber" as const, label: "watch" };
|
||||
return { tone: "signal" as const, label: "nominal" };
|
||||
}
|
||||
@@ -54,156 +51,243 @@ export function DashboardView({
|
||||
|
||||
useEffect(() => {
|
||||
const s = deriveSignal(stats);
|
||||
ambient.set(s.tone, 0.3 + Math.min(0.5, (stats?.today_flagged ?? 0) / 50), s.label);
|
||||
ambient.set(
|
||||
s.tone,
|
||||
0.3 + Math.min(0.5, (stats?.today_flagged ?? 0) / 50),
|
||||
s.label,
|
||||
);
|
||||
}, [stats, ambient]);
|
||||
|
||||
if (error && !stats) return <ErrorState error={error} />;
|
||||
if (!stats && isLoading) return <LoadingState label="Reading grid" />;
|
||||
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
||||
|
||||
const s = stats!;
|
||||
const s = stats;
|
||||
const total = s.total_flagged + s.total_clean || 1;
|
||||
const cleanRatio = s.total_clean / total;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Hero */}
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="eyebrow mb-2">GMW · Operations Grid</div>
|
||||
<h2 className="display text-[2.6rem] leading-none text-ink glow-signal">
|
||||
Ambient Field
|
||||
</h2>
|
||||
<p className="mt-2 max-w-md text-sm text-ink-soft">
|
||||
Real-time moderation, voice & media presence across the monitored
|
||||
guild. {formatNumber(s.total_messages)} messages captured.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-ink-soft">
|
||||
<Radio className="size-4 text-signal animate-breathe" />
|
||||
<span className="mono text-xs uppercase tracking-wider">
|
||||
{deriveSignal(s).label}
|
||||
</span>
|
||||
</div>
|
||||
{/* Hero */}
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="eyebrow mb-2">GMW · Operations Grid</div>
|
||||
<h2 className="display text-[2.6rem] leading-none text-ink glow-signal">
|
||||
Ambient Field
|
||||
</h2>
|
||||
<p className="mt-2 max-w-md text-sm text-ink-soft">
|
||||
Real-time moderation, voice & media presence across the monitored
|
||||
guild. {formatNumber(s.total_messages)} messages captured.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile label="Messages" value={formatNumber(s.total_messages)} tone="signal" icon={<MessageSquare className="size-3.5" />} />
|
||||
<MetricTile label="Flagged" value={formatNumber(s.total_flagged)} tone={s.total_flagged > 0 ? "vermilion" : "neutral"} hint={`${s.today_flagged} today`} />
|
||||
<MetricTile label="Active 24h" value={formatNumber(s.active_users_24h)} tone="signal" icon={<Users className="size-3.5" />} />
|
||||
<MetricTile label="Voice clips" value={formatNumber(s.total_voice_recordings)} icon={<Mic className="size-3.5" />} />
|
||||
<div className="flex items-center gap-2 text-ink-soft">
|
||||
<Radio className="size-4 text-signal animate-breathe" />
|
||||
<span className="mono text-xs uppercase tracking-wider">
|
||||
{deriveSignal(s).label}
|
||||
</span>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
{/* Activity */}
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="14-day signal"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Activity className="size-4 text-signal" /> Activity & moderation
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<div className="flex items-center gap-3 text-xs text-ink-soft">
|
||||
<span className="flex items-center gap-1.5"><span className="size-2 rounded-full bg-signal" /> messages</span>
|
||||
<span className="flex items-center gap-1.5"><span className="size-2 rounded-full bg-vermilion" /> flagged</span>
|
||||
</div>
|
||||
}
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile
|
||||
label="Messages"
|
||||
value={formatNumber(s.total_messages)}
|
||||
tone="signal"
|
||||
icon={<MessageSquare className="size-3.5" />}
|
||||
/>
|
||||
{activity ? (
|
||||
<AreaActivity daily={activity.daily} />
|
||||
) : (
|
||||
<LoadingState label="streaming" />
|
||||
)}
|
||||
<MetricTile
|
||||
label="Flagged"
|
||||
value={formatNumber(s.total_flagged)}
|
||||
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
|
||||
hint={`${s.today_flagged} today`}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Active 24h"
|
||||
value={formatNumber(s.active_users_24h)}
|
||||
tone="signal"
|
||||
icon={<Users className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Voice clips"
|
||||
value={formatNumber(s.total_voice_recordings)}
|
||||
icon={<Mic className="size-3.5" />}
|
||||
/>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Activity */}
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="14-day signal"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Activity className="size-4 text-signal" /> Activity & moderation
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<div className="flex items-center gap-3 text-xs text-ink-soft">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-signal" /> messages
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-vermilion" /> flagged
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{activity ? (
|
||||
<AreaActivity daily={activity.daily} />
|
||||
) : (
|
||||
<LoadingState label="streaming" />
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
{/* Two-column: channels + moderation */}
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader eyebrow="throughput" title="Top channels" />
|
||||
<div className="space-y-2.5">
|
||||
{s.top_channels.slice(0, 7).map((c) => {
|
||||
const pct =
|
||||
(c.message_count / (s.top_channels[0]?.message_count || 1)) *
|
||||
100;
|
||||
return (
|
||||
<div key={c.channel_id} className="flex items-center gap-3">
|
||||
<span className="w-40 truncate text-sm text-ink-soft">
|
||||
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
||||
</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||
<div
|
||||
className="h-full rounded-full bg-signal/70"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-14 text-right text-xs text-ink-faint">
|
||||
{formatNumber(c.message_count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Two-column: channels + moderation */}
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader eyebrow="throughput" title="Top channels" />
|
||||
<div className="space-y-2.5">
|
||||
{s.top_channels.slice(0, 7).map((c) => {
|
||||
const pct = (c.message_count / (s.top_channels[0]?.message_count || 1)) * 100;
|
||||
return (
|
||||
<div key={c.channel_id} className="flex items-center gap-3">
|
||||
<span className="w-40 truncate text-sm text-ink-soft">{c.channel_name ?? c.channel_id.slice(0, 8)}</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||
<div className="h-full rounded-full bg-signal/70" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="mono w-14 text-right text-xs text-ink-faint">{formatNumber(c.message_count)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="trust" title="Moderation" />
|
||||
<div className="flex items-center gap-5">
|
||||
<RadialGauge
|
||||
value={cleanRatio}
|
||||
tone={cleanRatio > 0.8 ? "signal" : cleanRatio > 0.6 ? "amber" : "vermilion"}
|
||||
label={`${Math.round(cleanRatio * 100)}%`}
|
||||
sublabel="clean"
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="trust" title="Moderation" />
|
||||
<div className="flex items-center gap-5">
|
||||
<RadialGauge
|
||||
value={cleanRatio}
|
||||
tone={
|
||||
cleanRatio > 0.8
|
||||
? "signal"
|
||||
: cleanRatio > 0.6
|
||||
? "amber"
|
||||
: "vermilion"
|
||||
}
|
||||
label={`${Math.round(cleanRatio * 100)}%`}
|
||||
sublabel="clean"
|
||||
/>
|
||||
<div className="flex-1 space-y-2 text-sm">
|
||||
<Row
|
||||
icon={<ShieldAlert className="size-4 text-signal" />}
|
||||
label="Clean"
|
||||
value={formatNumber(s.total_clean)}
|
||||
/>
|
||||
<Row
|
||||
icon={<Flag className="size-4 text-vermilion" />}
|
||||
label="Flagged"
|
||||
value={formatNumber(s.total_flagged)}
|
||||
/>
|
||||
<Row
|
||||
icon={<Activity className="size-4 text-amber" />}
|
||||
label="Warned"
|
||||
value={formatNumber(s.total_warned)}
|
||||
/>
|
||||
<div className="flex-1 space-y-2 text-sm">
|
||||
<Row icon={<ShieldAlert className="size-4 text-signal" />} label="Clean" value={formatNumber(s.total_clean)} />
|
||||
<Row icon={<Flag className="size-4 text-vermilion" />} label="Flagged" value={formatNumber(s.total_flagged)} />
|
||||
<Row icon={<Activity className="size-4 text-amber" />} label="Warned" value={formatNumber(s.total_warned)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-around border-t border-hairline pt-3 text-center">
|
||||
<Mini label="pending" value={s.moderation_overview.pending} tone="amber" />
|
||||
<Mini label="processing" value={s.moderation_overview.processing} tone="signal" />
|
||||
<Mini label="errors" value={s.moderation_overview.error} tone="vermilion" />
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
{/* Reactors + reactions */}
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="engagement" title="Top reactors" />
|
||||
<div className="space-y-2">
|
||||
{(reactors ?? []).slice(0, 6).map((r, i) => (
|
||||
<div key={r.user_id} className="flex items-center gap-3">
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<span className="flex-1 truncate text-sm text-ink">{r.username}</span>
|
||||
<span className="mono text-xs text-signal">+{formatNumber(r.net_count)}</span>
|
||||
</div>
|
||||
))}
|
||||
{(reactors ?? []).length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="culture" title="Top reactions" />
|
||||
<div className="space-y-3">
|
||||
{(reactions ?? []).slice(0, 5).map((m) => (
|
||||
<div key={m.message_id} className="flex items-start gap-3">
|
||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||
{m.top_emojis.slice(0, 3).map((e, i) => (
|
||||
<span key={i} className="text-lg leading-none">{e.emoji}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-ink">{m.content || "(no text)"}</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}</div>
|
||||
</div>
|
||||
<span className="mono text-xs text-ink-soft">{m.reaction_count}</span>
|
||||
</div>
|
||||
))}
|
||||
{(reactions ?? []).length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-around border-t border-hairline pt-3 text-center">
|
||||
<Mini
|
||||
label="pending"
|
||||
value={s.moderation_overview.pending}
|
||||
tone="amber"
|
||||
/>
|
||||
<Mini
|
||||
label="processing"
|
||||
value={s.moderation_overview.processing}
|
||||
tone="signal"
|
||||
/>
|
||||
<Mini
|
||||
label="errors"
|
||||
value={s.moderation_overview.error}
|
||||
tone="vermilion"
|
||||
/>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
{/* Reactors + reactions */}
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="engagement" title="Top reactors" />
|
||||
<div className="space-y-2">
|
||||
{(reactors ?? []).slice(0, 6).map((r, i) => (
|
||||
<div key={r.user_id} className="flex items-center gap-3">
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<span className="flex-1 truncate text-sm text-ink">
|
||||
{r.username}
|
||||
</span>
|
||||
<span className="mono text-xs text-signal">
|
||||
+{formatNumber(r.net_count)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(reactors ?? []).length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="culture" title="Top reactions" />
|
||||
<div className="space-y-3">
|
||||
{(reactions ?? []).slice(0, 5).map((m) => (
|
||||
<div key={m.message_id} className="flex items-start gap-3">
|
||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||
{m.top_emojis.slice(0, 3).map((e, i) => (
|
||||
<span key={`${m.message_id}-${i}`} className="text-lg leading-none">
|
||||
{e.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-ink">
|
||||
{m.content || "(no text)"}
|
||||
</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">
|
||||
{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="mono text-xs text-ink-soft">
|
||||
{m.reaction_count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(reactions ?? []).length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
function Row({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
{icon}
|
||||
@@ -213,8 +297,21 @@ function Row({ icon, label, value }: { icon: React.ReactNode; label: string; val
|
||||
);
|
||||
}
|
||||
|
||||
function Mini({ label, value, tone }: { label: string; value: number; tone: "signal" | "amber" | "vermilion" }) {
|
||||
const color = tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal";
|
||||
function Mini({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "signal" | "amber" | "vermilion";
|
||||
}) {
|
||||
const color =
|
||||
tone === "vermilion"
|
||||
? "text-vermilion"
|
||||
: tone === "amber"
|
||||
? "text-amber"
|
||||
: "text-signal";
|
||||
return (
|
||||
<div>
|
||||
<div className={`display text-xl ${color}`}>{value}</div>
|
||||
@@ -224,5 +321,9 @@ function Mini({ label, value, tone }: { label: string; value: number; tone: "sig
|
||||
}
|
||||
|
||||
function EmptyHint() {
|
||||
return <div className="py-6 text-center text-xs text-ink-faint">Awaiting data…</div>;
|
||||
return (
|
||||
<div className="py-6 text-center text-xs text-ink-faint">
|
||||
Awaiting data…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AmbientProvider } from "@/components/ambient/ambient-context";
|
||||
import { WsProvider } from "@/lib/ws/context";
|
||||
import { AppFrame } from "@/components/shell";
|
||||
import { Chatbot } from "@/components/chatbot/chatbot";
|
||||
import { CommandPalette } from "@/components/command/command-palette";
|
||||
import { AppFrame } from "@/components/shell";
|
||||
import { WsProvider } from "@/lib/ws/context";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MediaView } from "./view";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MediaPage() {
|
||||
let status = undefined;
|
||||
let status: import("@/lib/types").MediaState | undefined;
|
||||
try {
|
||||
status = await getMediaStatus();
|
||||
} catch {
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ListMusic,
|
||||
Pause,
|
||||
Play,
|
||||
Radio,
|
||||
Repeat,
|
||||
SkipForward,
|
||||
Square,
|
||||
Radio,
|
||||
} from "lucide-react";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { Button, GlassPanel, Input, toast } from "@/components/primitives";
|
||||
import { ErrorState, LoadingState, SectionHeader } from "@/components/shared";
|
||||
import {
|
||||
useMediaState,
|
||||
useMediaLoop,
|
||||
useMediaQueue,
|
||||
useMediaSkip,
|
||||
useMediaState,
|
||||
useMediaStop,
|
||||
useMediaLoop,
|
||||
useMediaWsSync,
|
||||
} from "@/hooks";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, GlassCard, Button, Input } from "@/components/primitives";
|
||||
import { SectionHeader, ErrorState, LoadingState } from "@/components/shared";
|
||||
import { toast } from "@/components/primitives";
|
||||
import type { MediaState } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
const ws = useWebSocket();
|
||||
@@ -43,7 +41,11 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
|
||||
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
||||
useEffect(() => {
|
||||
ambient.set(tone, playing ? 0.5 : 0.25, playing ? "now playing" : "media idle");
|
||||
ambient.set(
|
||||
tone,
|
||||
playing ? 0.5 : 0.25,
|
||||
playing ? "now playing" : "media idle",
|
||||
);
|
||||
}, [tone, playing, ambient]);
|
||||
|
||||
const onPlay = async () => {
|
||||
@@ -57,7 +59,11 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
setUrl("");
|
||||
toast({ title: "Queued", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({ title: "Queue failed", description: String(e), tone: "vermilion" });
|
||||
toast({
|
||||
title: "Queue failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,16 +89,33 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
{current?.title ?? "Nothing queued"}
|
||||
</h2>
|
||||
{current?.source && (
|
||||
<div className="mono mt-1 truncate text-xs text-ink-faint">{current.source}</div>
|
||||
<div className="mono mt-1 truncate text-xs text-ink-faint">
|
||||
{current.source}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={onPlay} disabled={queue.isPending}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onPlay}
|
||||
disabled={queue.isPending}
|
||||
>
|
||||
<Play className="size-4" /> Queue & play
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => skip.mutate()} disabled={skip.isPending}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => skip.mutate()}
|
||||
disabled={skip.isPending}
|
||||
>
|
||||
<SkipForward className="size-4" /> Skip
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => stop.mutate()} disabled={stop.isPending}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => stop.mutate()}
|
||||
disabled={stop.isPending}
|
||||
>
|
||||
<Square className="size-4" /> Stop
|
||||
</Button>
|
||||
<Button
|
||||
@@ -121,22 +144,33 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
<SectionHeader
|
||||
eyebrow="up next"
|
||||
title="Queue"
|
||||
action={<span className="mono text-xs text-ink-faint">{queueList.length} tracks</span>}
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{queueList.length} tracks
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{queueList.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<Radio className="size-6 text-ink-faint" />
|
||||
<div className="text-sm text-ink-soft">Queue is empty</div>
|
||||
<div className="text-xs text-ink-faint">Paste a URL above to start playback.</div>
|
||||
<div className="text-xs text-ink-faint">
|
||||
Paste a URL above to start playback.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{queueList.map((item, i) => (
|
||||
<div key={`${item.source}-${i}`} className="flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5">
|
||||
<div
|
||||
key={`${item.source}-${i}`}
|
||||
className="flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"
|
||||
>
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-ink">{item.title}</div>
|
||||
<div className="mono truncate text-[0.65rem] text-ink-faint">{item.source}</div>
|
||||
<div className="mono truncate text-[0.65rem] text-ink-faint">
|
||||
{item.source}
|
||||
</div>
|
||||
</div>
|
||||
<span className="pill">{item.mode ?? "music"}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,17 @@ import { MessagesView } from "./view";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MessagesPage() {
|
||||
let config = undefined;
|
||||
let guilds = undefined;
|
||||
let config: import("@/lib/types/guild").AppConfig | undefined;
|
||||
let guilds: import("@/lib/types").Guild[] | undefined;
|
||||
try {
|
||||
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return <MessagesView initialGuilds={guilds} initialGuildId={config?.monitorGuildId ?? null} />;
|
||||
return (
|
||||
<MessagesView
|
||||
initialGuilds={guilds}
|
||||
initialGuildId={config?.monitorGuildId ?? null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
MessageSquare,
|
||||
Search,
|
||||
Paperclip,
|
||||
Image as ImageIcon,
|
||||
ShieldAlert,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import {
|
||||
useGuilds,
|
||||
Avatar,
|
||||
Badge,
|
||||
GlassPanel,
|
||||
Input,
|
||||
Skeleton,
|
||||
} from "@/components/primitives";
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
SectionHeader,
|
||||
} from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import {
|
||||
useMessageDetail,
|
||||
useMessageSearch,
|
||||
useMessages,
|
||||
useMessagesWsSync,
|
||||
useMessageSearch,
|
||||
useMessageDetail,
|
||||
} from "@/hooks";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, GlassCard, Avatar, Badge, Input, Skeleton } from "@/components/primitives";
|
||||
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import { renderMessageContent, getMessageChannelLabel, safeParseJsonArray, formatBytes } from "@/lib/format";
|
||||
import {
|
||||
formatBytes,
|
||||
getMessageChannelLabel,
|
||||
renderMessageContent,
|
||||
safeParseJsonArray,
|
||||
} from "@/lib/format";
|
||||
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
function relTime(ts?: number | null) {
|
||||
if (!ts) return "";
|
||||
@@ -37,7 +52,9 @@ function relTime(ts?: number | null) {
|
||||
return `${Math.floor(h / 24)}d`;
|
||||
}
|
||||
|
||||
function aiTone(s?: AiStatus | null): "signal" | "amber" | "vermilion" | "neutral" {
|
||||
function aiTone(
|
||||
s?: AiStatus | null,
|
||||
): "signal" | "amber" | "vermilion" | "neutral" {
|
||||
if (s === "clean") return "signal";
|
||||
if (s === "warn") return "amber";
|
||||
if (s === "flagged" || s === "error") return "vermilion";
|
||||
@@ -53,7 +70,6 @@ export function MessagesView({
|
||||
initialGuildId?: string | null;
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const { data: guilds } = useGuilds(initialGuilds);
|
||||
const [guildId, setGuildId] = useState<string | null>(
|
||||
initialGuildId ?? initialGuilds?.[0]?.id ?? null,
|
||||
);
|
||||
@@ -61,7 +77,11 @@ export function MessagesView({
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { data: messages, isLoading, error } = useMessages(guildId ?? "", channelId ?? undefined);
|
||||
const {
|
||||
data: messages,
|
||||
isLoading,
|
||||
error,
|
||||
} = useMessages(guildId ?? "", channelId ?? undefined);
|
||||
useMessagesWsSync(ws, guildId ?? "");
|
||||
const search = useMessageSearch(query, query.trim().length >= 2);
|
||||
const detail = useMessageDetail(selected);
|
||||
@@ -72,7 +92,7 @@ export function MessagesView({
|
||||
}, [query, ambient]);
|
||||
|
||||
const searching = query.trim().length >= 2;
|
||||
const list = searching ? search.data ?? [] : (messages ?? []);
|
||||
const list = searching ? (search.data ?? []) : (messages ?? []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -115,7 +135,11 @@ export function MessagesView({
|
||||
) : isLoading && !messages ? (
|
||||
<LoadingState label="Capturing" />
|
||||
) : list.length === 0 ? (
|
||||
<EmptyState icon={<MessageSquare className="size-7" />} title="No messages" description="Pick a guild to begin, or run a search." />
|
||||
<EmptyState
|
||||
icon={<MessageSquare className="size-7" />}
|
||||
title="No messages"
|
||||
description="Pick a guild to begin, or run a search."
|
||||
/>
|
||||
) : (
|
||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||
{list.map((m) => (
|
||||
@@ -124,18 +148,30 @@ export function MessagesView({
|
||||
type="button"
|
||||
onClick={() => setSelected(m.id)}
|
||||
className={`flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||
selected === m.id ? "border-signal/40 bg-signal/8" : "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||
selected === m.id
|
||||
? "border-signal/40 bg-signal/8"
|
||||
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||
}`}
|
||||
>
|
||||
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-ink">{m.username}</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)}</span>
|
||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">{relTime(m.created_at)}</span>
|
||||
<span className="truncate text-sm font-semibold text-ink">
|
||||
{m.username}
|
||||
</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{getMessageChannelLabel(m)}
|
||||
</span>
|
||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||
{relTime(m.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
||||
{renderMessageContent(m.content, m.metadata) || <span className="italic text-ink-faint">(empty / embed)</span>}
|
||||
{renderMessageContent(m.content, m.metadata) || (
|
||||
<span className="italic text-ink-faint">
|
||||
(empty / embed)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AiBadge status={m.ai_status} />
|
||||
@@ -148,14 +184,20 @@ export function MessagesView({
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="inspect" title="Detail" />
|
||||
{!selected ? (
|
||||
<EmptyState title="Select a message" description="Click any message to inspect AI analysis, attachments and edit history." />
|
||||
<EmptyState
|
||||
title="Select a message"
|
||||
description="Click any message to inspect AI analysis, attachments and edit history."
|
||||
/>
|
||||
) : detail.loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-20" />
|
||||
<Skeleton className="h-12" />
|
||||
</div>
|
||||
) : detail.message ? (
|
||||
<MessageDetail m={detail.message} attachments={detail.attachments} />
|
||||
<MessageDetail
|
||||
m={detail.message}
|
||||
attachments={detail.attachments}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState title="Not found" />
|
||||
)}
|
||||
@@ -169,15 +211,32 @@ function AiBadge({ status }: { status?: AiStatus | null }) {
|
||||
if (!status) return null;
|
||||
const tone = aiTone(status);
|
||||
const icon =
|
||||
status === "clean" ? <CheckCircle2 className="size-3" /> :
|
||||
status === "flagged" ? <ShieldAlert className="size-3" /> :
|
||||
status === "warn" ? <AlertTriangle className="size-3" /> :
|
||||
status === "processing" || status === "pending" ? <Loader2 className="size-3 animate-spin" /> :
|
||||
<AlertTriangle className="size-3" />;
|
||||
return <Badge tone={tone} dot={status === "processing" || status === "pending"}>{icon}{status}</Badge>;
|
||||
status === "clean" ? (
|
||||
<CheckCircle2 className="size-3" />
|
||||
) : status === "flagged" ? (
|
||||
<ShieldAlert className="size-3" />
|
||||
) : status === "warn" ? (
|
||||
<AlertTriangle className="size-3" />
|
||||
) : status === "processing" || status === "pending" ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<AlertTriangle className="size-3" />
|
||||
);
|
||||
return (
|
||||
<Badge tone={tone} dot={status === "processing" || status === "pending"}>
|
||||
{icon}
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageDetail({ m, attachments }: { m: MessageRecord; attachments: import("@/lib/types").AttachmentRecord[] }) {
|
||||
function MessageDetail({
|
||||
m,
|
||||
attachments,
|
||||
}: {
|
||||
m: MessageRecord;
|
||||
attachments: import("@/lib/types").AttachmentRecord[];
|
||||
}) {
|
||||
const flags = safeParseJsonArray(m.ai_moderation_flags);
|
||||
const cats = safeParseJsonArray(m.ai_categories);
|
||||
return (
|
||||
@@ -186,38 +245,63 @@ function MessageDetail({ m, attachments }: { m: MessageRecord; attachments: impo
|
||||
<Avatar src={m.avatar_url} name={m.username} size={40} />
|
||||
<div>
|
||||
<div className="font-semibold text-ink">{m.username}</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">{getMessageChannelLabel(m)} · {relTime(m.created_at)}</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">
|
||||
{getMessageChannelLabel(m)} · {relTime(m.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<AiBadge status={m.ai_status} />
|
||||
</div>
|
||||
<div className="ml-auto"><AiBadge status={m.ai_status} /></div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||
{renderMessageContent(m.edited_content ?? m.content, m.metadata) || "(no text)"}
|
||||
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
|
||||
"(no text)"}
|
||||
</div>
|
||||
|
||||
{m.ai_analysis && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1">AI analysis</div>
|
||||
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">{m.ai_analysis}</div>
|
||||
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||
{m.ai_analysis}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(flags.length > 0 || cats.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{flags.map((f) => <Badge key={f} tone="vermilion">{f}</Badge>)}
|
||||
{cats.map((c) => <Badge key={c} tone="amber">{c}</Badge>)}
|
||||
{flags.map((f) => (
|
||||
<Badge key={f} tone="vermilion">
|
||||
{f}
|
||||
</Badge>
|
||||
))}
|
||||
{cats.map((c) => (
|
||||
<Badge key={c} tone="amber">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1 flex items-center gap-1.5"><Paperclip className="size-3" /> Attachments ({attachments.length})</div>
|
||||
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
||||
<Paperclip className="size-3" /> Attachments ({attachments.length})
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{attachments.map((a) => (
|
||||
<a key={a.id} href={a.discord_url ?? a.uploaded_url ?? "#"} target="_blank" rel="noreferrer" className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink">
|
||||
<a
|
||||
key={a.id}
|
||||
href={a.discord_url ?? a.uploaded_url ?? "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink"
|
||||
>
|
||||
<ImageIcon className="size-3.5 text-signal" />
|
||||
<span className="flex-1 truncate">{a.filename}</span>
|
||||
<span className="mono text-ink-faint">{formatBytes(a.size)}</span>
|
||||
<span className="mono text-ink-faint">
|
||||
{formatBytes(a.size)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,8 @@ import { ModerationView } from "./view";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ModerationPage() {
|
||||
let stats = undefined;
|
||||
let actions = undefined;
|
||||
let stats: import("@/lib/types").ModerationStats | undefined;
|
||||
let actions: import("@/lib/types").ModerationAction[] | undefined;
|
||||
try {
|
||||
[stats, actions] = await Promise.all([
|
||||
getModerationStats(),
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ShieldAlert,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Ban,
|
||||
Trash2,
|
||||
MicOff,
|
||||
AlertTriangle,
|
||||
UserX,
|
||||
MessageSquareWarning,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Filter,
|
||||
MessageSquareWarning,
|
||||
MicOff,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
UserX,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useModerationStats,
|
||||
useModerationActions,
|
||||
} from "@/hooks";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, GlassCard, Badge, Select, type SelectOption } from "@/components/primitives";
|
||||
import { SectionHeader, MetricTile, ErrorState, LoadingState } from "@/components/shared";
|
||||
import { Donut } from "@/components/charts";
|
||||
import {
|
||||
Badge,
|
||||
GlassPanel,
|
||||
Select,
|
||||
type SelectOption,
|
||||
} from "@/components/primitives";
|
||||
import {
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
MetricTile,
|
||||
SectionHeader,
|
||||
} from "@/components/shared";
|
||||
import { useModerationActions, useModerationStats } from "@/hooks";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type {
|
||||
ModerationAction,
|
||||
@@ -64,7 +71,7 @@ export function ModerationView({
|
||||
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
||||
|
||||
const byAction = stats?.by_action ?? {};
|
||||
const segments = Object.entries(byAction).map(([k, v]) => ({
|
||||
const segments = Object.entries(byAction).map(([k, _v]) => ({
|
||||
value: 1,
|
||||
color:
|
||||
k === "ban_user" || k === "kick_user"
|
||||
@@ -86,6 +93,7 @@ export function ModerationView({
|
||||
|
||||
if (error && !stats) return <ErrorState error={error} />;
|
||||
if (!stats && isLoading) return <LoadingState label="Reading log" />;
|
||||
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
||||
|
||||
const statusOpts: SelectOption[] = [
|
||||
{ value: "", label: "All statuses" },
|
||||
@@ -95,16 +103,39 @@ export function ModerationView({
|
||||
];
|
||||
const typeOpts: SelectOption[] = [
|
||||
{ value: "", label: "All actions" },
|
||||
...Object.keys(byAction).map((k) => ({ value: k, label: ACTION_LABEL[k as ModerationActionType] ?? k })),
|
||||
...Object.keys(byAction).map((k) => ({
|
||||
value: k,
|
||||
label: ACTION_LABEL[k as ModerationActionType] ?? k,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile label="Total actions" value={formatNumber(stats!.total)} tone="signal" icon={<ShieldAlert className="size-3.5" />} />
|
||||
<MetricTile label="Executed" value={formatNumber(stats!.executed)} tone="signal" icon={<CheckCircle2 className="size-3.5" />} />
|
||||
<MetricTile label="Failed" value={formatNumber(stats!.failed)} tone={stats!.failed > 0 ? "vermilion" : "neutral"} icon={<XCircle className="size-3.5" />} />
|
||||
<MetricTile label="Pending" value={formatNumber(stats!.pending)} tone={stats!.pending > 0 ? "amber" : "neutral"} icon={<Clock className="size-3.5" />} />
|
||||
<MetricTile
|
||||
label="Total actions"
|
||||
value={formatNumber(stats.total)}
|
||||
tone="signal"
|
||||
icon={<ShieldAlert className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Executed"
|
||||
value={formatNumber(stats.executed)}
|
||||
tone="signal"
|
||||
icon={<CheckCircle2 className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Failed"
|
||||
value={formatNumber(stats.failed)}
|
||||
tone={stats.failed > 0 ? "vermilion" : "neutral"}
|
||||
icon={<XCircle className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Pending"
|
||||
value={formatNumber(stats?.pending)}
|
||||
tone={stats?.pending > 0 ? "amber" : "neutral"}
|
||||
icon={<Clock className="size-3.5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
@@ -112,7 +143,17 @@ export function ModerationView({
|
||||
<SectionHeader eyebrow="health" title="Breakdown" />
|
||||
<div className="flex items-center gap-5">
|
||||
<Donut
|
||||
segments={segments.length ? segments : [{ value: 1, color: "var(--color-ink-faint)", label: "none" }]}
|
||||
segments={
|
||||
segments.length
|
||||
? segments
|
||||
: [
|
||||
{
|
||||
value: 1,
|
||||
color: "var(--color-ink-faint)",
|
||||
label: "none",
|
||||
},
|
||||
]
|
||||
}
|
||||
centerLabel={`${Math.round(failedRate)}%`}
|
||||
centerSub="fail rate"
|
||||
/>
|
||||
@@ -121,14 +162,22 @@ export function ModerationView({
|
||||
const count = typeof v === "number" ? v : null;
|
||||
return (
|
||||
<div key={k} className="flex items-center gap-2.5">
|
||||
<span className="text-ink-soft">{ACTION_ICON[k as ModerationActionType]}</span>
|
||||
<span className="flex-1 text-ink-soft">{ACTION_LABEL[k as ModerationActionType] ?? k}</span>
|
||||
{count !== null && <span className="mono text-ink">{count}</span>}
|
||||
<span className="text-ink-soft">
|
||||
{ACTION_ICON[k as ModerationActionType]}
|
||||
</span>
|
||||
<span className="flex-1 text-ink-soft">
|
||||
{ACTION_LABEL[k as ModerationActionType] ?? k}
|
||||
</span>
|
||||
{count !== null && (
|
||||
<span className="mono text-ink">{count}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.keys(byAction).length === 0 && (
|
||||
<div className="text-xs text-ink-faint">No actions recorded yet.</div>
|
||||
<div className="text-xs text-ink-faint">
|
||||
No actions recorded yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,8 +190,20 @@ export function ModerationView({
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="size-3.5 text-ink-faint" />
|
||||
<Select value={typeFilter} onChange={setTypeFilter} options={typeOpts} size="sm" className="w-36" />
|
||||
<Select value={statusFilter} onChange={setStatusFilter} options={statusOpts} size="sm" className="w-32" />
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
options={typeOpts}
|
||||
size="sm"
|
||||
className="w-36"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={statusOpts}
|
||||
size="sm"
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -151,7 +212,9 @@ export function ModerationView({
|
||||
<ActionRow key={a.id} a={a} />
|
||||
))}
|
||||
{(actions ?? []).length === 0 && (
|
||||
<div className="py-10 text-center text-xs text-ink-faint">No matching actions.</div>
|
||||
<div className="py-10 text-center text-xs text-ink-faint">
|
||||
No matching actions.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
@@ -162,26 +225,42 @@ export function ModerationView({
|
||||
|
||||
function ActionRow({ a }: { a: ModerationAction }) {
|
||||
const tone =
|
||||
a.status === "executed" ? "signal" : a.status === "failed" ? "vermilion" : "amber";
|
||||
const icon = ACTION_ICON[a.action_type] ?? <AlertTriangle className="size-3.5" />;
|
||||
a.status === "executed"
|
||||
? "signal"
|
||||
: a.status === "failed"
|
||||
? "vermilion"
|
||||
: "amber";
|
||||
const icon = ACTION_ICON[a.action_type] ?? (
|
||||
<AlertTriangle className="size-3.5" />
|
||||
);
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3">
|
||||
<span className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}>{icon}</span>
|
||||
<span
|
||||
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-ink">{a.username ?? "unknown"}</span>
|
||||
<span className="text-sm font-semibold text-ink">
|
||||
{a.username ?? "unknown"}
|
||||
</span>
|
||||
<Badge tone={tone}>{a.status}</Badge>
|
||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||
{a.created_at ? new Date(a.created_at).toLocaleString() : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{a.reason && <div className="mt-0.5 text-xs text-ink-soft">“{a.reason}”</div>}
|
||||
{a.reason && (
|
||||
<div className="mt-0.5 text-xs text-ink-soft">“{a.reason}”</div>
|
||||
)}
|
||||
{a.content && (
|
||||
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint">
|
||||
{a.content}
|
||||
</div>
|
||||
)}
|
||||
{a.error && <div className="mt-1 text-xs text-vermilion">{a.error}</div>}
|
||||
{a.error && (
|
||||
<div className="mt-1 text-xs text-vermilion">{a.error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,9 @@ import { RecordingsView } from "./view";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RecordingsPage() {
|
||||
let recordings = undefined;
|
||||
let recordings:
|
||||
| import("@/lib/types/recording").PaginatedRecordings
|
||||
| undefined;
|
||||
try {
|
||||
recordings = await getRecordings(50);
|
||||
} catch {
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Headphones, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Headphones, Trash2, Download } from "lucide-react";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { useRecordings, useDeleteRecording, useRecordingsWsSync } from "@/hooks";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, GlassCard, Avatar, Button } from "@/components/primitives";
|
||||
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
GlassCard,
|
||||
GlassPanel,
|
||||
toast,
|
||||
} from "@/components/primitives";
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
SectionHeader,
|
||||
} from "@/components/shared";
|
||||
import {
|
||||
useDeleteRecording,
|
||||
useRecordings,
|
||||
useRecordingsWsSync,
|
||||
} from "@/hooks";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import { toast } from "@/components/primitives";
|
||||
import type { VoiceRecording } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording[] }) {
|
||||
export function RecordingsView({
|
||||
initialItems,
|
||||
}: {
|
||||
initialItems?: VoiceRecording[];
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const { data: items, isLoading, error } = useRecordings(initialItems);
|
||||
const del = useDeleteRecording();
|
||||
@@ -27,7 +45,11 @@ export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording
|
||||
await del.mutateAsync(id);
|
||||
toast({ title: "Recording deleted", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({ title: "Delete failed", description: String(e), tone: "vermilion" });
|
||||
toast({
|
||||
title: "Delete failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -39,10 +61,18 @@ export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording
|
||||
<SectionHeader
|
||||
eyebrow="voice captures"
|
||||
title="Recordings"
|
||||
action={<span className="mono text-xs text-ink-faint">{(items ?? []).length} clips</span>}
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{(items ?? []).length} clips
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{(items ?? []).length === 0 ? (
|
||||
<EmptyState icon={<Headphones className="size-7" />} title="No recordings" description="Voice clips captured by the bot appear here." />
|
||||
<EmptyState
|
||||
icon={<Headphones className="size-7" />}
|
||||
title="No recordings"
|
||||
description="Voice clips captured by the bot appear here."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{(items ?? []).map((r) => (
|
||||
@@ -50,17 +80,28 @@ export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-ink">{r.username}</div>
|
||||
<div className="truncate text-sm font-semibold text-ink">
|
||||
{r.username}
|
||||
</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">
|
||||
{r.channel_name ?? "voice"} · {new Date(r.created_at).toLocaleString()}
|
||||
{r.channel_name ?? "voice"} ·{" "}
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">{formatBytes(r.size_bytes)}</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{formatBytes(r.size_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{r.download_url ? (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<audio controls src={r.download_url} className="h-9 w-full" preload="none" />
|
||||
<audio
|
||||
controls
|
||||
src={r.download_url}
|
||||
className="h-9 w-full"
|
||||
preload="none"
|
||||
aria-label={`Voice recording ${r.id}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
|
||||
Upload pending…
|
||||
@@ -69,7 +110,12 @@ export function RecordingsView({ initialItems }: { initialItems?: VoiceRecording
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{r.download_url && (
|
||||
<a href={r.download_url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 rounded-[9px] border border-hairline px-2.5 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40">
|
||||
<a
|
||||
href={r.download_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-[9px] border border-hairline px-2.5 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40"
|
||||
>
|
||||
<Download className="size-3.5" /> Download
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { VoiceView } from "./view";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function VoicePage() {
|
||||
let status = undefined;
|
||||
let guilds = undefined;
|
||||
let status: import("@/lib/types").VoiceStatus | undefined;
|
||||
let guilds: import("@/lib/types").Guild[] | undefined;
|
||||
try {
|
||||
[status, guilds] = await Promise.all([getVoiceStatus(), getGuilds()]);
|
||||
} catch {
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Mic, MicOff, Headphones, PhoneOff, Radio, Volume2, Waves } from "lucide-react";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import {
|
||||
useGuilds,
|
||||
useVoiceStatus,
|
||||
Headphones,
|
||||
Mic,
|
||||
MicOff,
|
||||
PhoneOff,
|
||||
Radio,
|
||||
Volume2,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { Equalizer } from "@/components/charts";
|
||||
import { Button, GlassPanel, toast } from "@/components/primitives";
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
SectionHeader,
|
||||
} from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import { VoiceStage } from "@/components/voice/voice-stage";
|
||||
import {
|
||||
useMicTransmit,
|
||||
useSpeakers,
|
||||
useVoiceConnect,
|
||||
useVoiceDisconnect,
|
||||
useSpeakers,
|
||||
useMicTransmit,
|
||||
useVoiceListen,
|
||||
useVoiceStatus,
|
||||
} from "@/hooks";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { GlassPanel, Button } from "@/components/primitives";
|
||||
import { VoiceStage } from "@/components/voice/voice-stage";
|
||||
import { Equalizer } from "@/components/charts";
|
||||
import { SectionHeader, EmptyState, ErrorState, LoadingState } from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import { toast } from "@/components/primitives";
|
||||
import type { Guild, VoiceStatus } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function VoiceView({
|
||||
initialStatus,
|
||||
@@ -30,7 +41,6 @@ export function VoiceView({
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const { data: status, isLoading, error } = useVoiceStatus(initialStatus);
|
||||
const { data: guilds } = useGuilds(initialGuilds);
|
||||
const connect = useVoiceConnect();
|
||||
const disconnect = useVoiceDisconnect();
|
||||
const mic = useMicTransmit(ws);
|
||||
@@ -71,7 +81,11 @@ export function VoiceView({
|
||||
await connect.mutateAsync({ guildId, channelId });
|
||||
toast({ title: "Connected to voice", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({ title: "Connect failed", description: String(e), tone: "vermilion" });
|
||||
toast({
|
||||
title: "Connect failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,11 +114,21 @@ export function VoiceView({
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{connected ? (
|
||||
<Button variant="danger" size="sm" onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => disconnect.mutate()}
|
||||
disabled={disconnect.isPending}
|
||||
>
|
||||
<PhoneOff className="size-4" /> Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" size="sm" onClick={onConnect} disabled={connect.isPending}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onConnect}
|
||||
disabled={connect.isPending}
|
||||
>
|
||||
<Radio className="size-4" /> Connect
|
||||
</Button>
|
||||
)}
|
||||
@@ -127,7 +151,11 @@ export function VoiceView({
|
||||
size="sm"
|
||||
onClick={() => listen.toggle(!listen.active)}
|
||||
>
|
||||
{listen.active ? <Headphones className="size-4" /> : <Volume2 className="size-4" />}
|
||||
{listen.active ? (
|
||||
<Headphones className="size-4" />
|
||||
) : (
|
||||
<Volume2 className="size-4" />
|
||||
)}
|
||||
{listen.active ? "Listening" : "Listen in"}
|
||||
</Button>
|
||||
{listen.active && (
|
||||
@@ -154,7 +182,11 @@ export function VoiceView({
|
||||
<EmptyState
|
||||
icon={<MicOff className="size-7" />}
|
||||
title={connected ? "Silent right now" : "Not connected"}
|
||||
description={connected ? "Speakers appear as they talk." : "Connect to a voice channel to see presence."}
|
||||
description={
|
||||
connected
|
||||
? "Speakers appear as they talk."
|
||||
: "Connect to a voice channel to see presence."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -169,7 +201,9 @@ export function VoiceView({
|
||||
: "border-hairline bg-white/5 text-ink-soft"
|
||||
}`}
|
||||
>
|
||||
<span className={`size-1.5 rounded-full ${sp.speaking ? "bg-signal animate-breathe" : "bg-ink-faint"}`} />
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${sp.speaking ? "bg-signal animate-breathe" : "bg-ink-faint"}`}
|
||||
/>
|
||||
{sp.username}
|
||||
</span>
|
||||
))}
|
||||
@@ -182,14 +216,23 @@ export function VoiceView({
|
||||
<SectionHeader eyebrow="links" title="Connections" />
|
||||
<div className="space-y-2">
|
||||
{(status?.connections ?? []).map((c) => (
|
||||
<div key={`${c.guildId}-${c.channelId}`} className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-sm">
|
||||
<div
|
||||
key={`${c.guildId}-${c.channelId}`}
|
||||
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="size-2 rounded-full bg-signal" />
|
||||
<span className="flex-1 truncate text-ink-soft">{c.channelName}</span>
|
||||
<span className="mono text-[0.6rem] text-ink-faint">{new Date(c.connectedAt).toLocaleTimeString()}</span>
|
||||
<span className="flex-1 truncate text-ink-soft">
|
||||
{c.channelName}
|
||||
</span>
|
||||
<span className="mono text-[0.6rem] text-ink-faint">
|
||||
{new Date(c.connectedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(status?.connections ?? []).length === 0 && (
|
||||
<div className="py-6 text-center text-xs text-ink-faint">No active links</div>
|
||||
<div className="py-6 text-center text-xs text-ink-faint">
|
||||
No active links
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AmbientControls | null>(null);
|
||||
|
||||
@@ -38,14 +49,17 @@ export function AmbientProvider({ children }: { children: React.ReactNode }) {
|
||||
const targetRef = useRef<AmbientState>({ ...DEFAULT });
|
||||
const [state, setState] = useState<AmbientState>(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 };
|
||||
|
||||
@@ -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 (
|
||||
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className="w-full" style={{ height }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="w-full"
|
||||
style={{ height }}
|
||||
role="img"
|
||||
aria-label="Daily message vs flagged activity"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="area-msg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-signal)" stopOpacity="0.3" />
|
||||
@@ -31,14 +48,44 @@ export function AreaActivity({
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{[0.25, 0.5, 0.75].map((g) => (
|
||||
<line key={g} x1={pad} x2={w - pad} y1={height * g} y2={height * g} stroke="var(--color-hairline)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
<line
|
||||
key={g}
|
||||
x1={pad}
|
||||
x2={w - pad}
|
||||
y1={height * g}
|
||||
y2={height * g}
|
||||
stroke="var(--color-hairline)"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
<path d={msgArea} fill="url(#area-msg)" />
|
||||
<path d={msgLine} fill="none" stroke="var(--color-signal)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||
<path d={flagLine} fill="none" stroke="var(--color-vermilion)" strokeWidth={1.5} vectorEffect="non-scaling-stroke" strokeDasharray="3 3" />
|
||||
<path
|
||||
d={msgLine}
|
||||
fill="none"
|
||||
stroke="var(--color-signal)"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<path
|
||||
d={flagLine}
|
||||
fill="none"
|
||||
stroke="var(--color-vermilion)"
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
{daily.map((d, i) =>
|
||||
i % 2 === 0 ? (
|
||||
<text key={d.day} x={x(i)} y={height - 1} fill="var(--color-ink-faint)" fontSize={9} textAnchor="middle" className="mono">
|
||||
<text
|
||||
key={d.day}
|
||||
x={x(i)}
|
||||
y={height - 1}
|
||||
fill="var(--color-ink-faint)"
|
||||
fontSize={9}
|
||||
textAnchor="middle"
|
||||
className="mono"
|
||||
>
|
||||
{d.day.slice(5)}
|
||||
</text>
|
||||
) : null,
|
||||
|
||||
@@ -17,14 +17,24 @@ export function Donut({
|
||||
const c = 2 * Math.PI * r;
|
||||
let offset = 0;
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={thickness} />
|
||||
{segments.map((s, i) => {
|
||||
<div
|
||||
className="relative inline-flex items-center justify-center"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg width={size} height={size} className="-rotate-90" role="img" aria-label="Composition donut">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--color-hairline)"
|
||||
strokeWidth={thickness}
|
||||
/>
|
||||
{segments.map((s) => {
|
||||
const len = (s.value / total) * c;
|
||||
const el = (
|
||||
<circle
|
||||
key={i}
|
||||
key={`seg-${s.label}`}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
@@ -42,8 +52,14 @@ export function Donut({
|
||||
</svg>
|
||||
{(centerLabel || centerSub) && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{centerLabel && <span className="display text-lg">{centerLabel}</span>}
|
||||
{centerSub && <span className="mono text-[0.6rem] text-ink-faint">{centerSub}</span>}
|
||||
{centerLabel && (
|
||||
<span className="display text-lg">{centerLabel}</span>
|
||||
)}
|
||||
{centerSub && (
|
||||
<span className="mono text-[0.6rem] text-ink-faint">
|
||||
{centerSub}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={8} />
|
||||
<div
|
||||
className="relative inline-flex items-center justify-center"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg width={size} height={size} className="-rotate-90" role="img" aria-label="Progress gauge">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--color-hairline)"
|
||||
strokeWidth={8}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
@@ -32,14 +47,26 @@ export function RadialGauge({
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={c}
|
||||
strokeDashoffset={c * (1 - v)}
|
||||
style={{ transition: "stroke-dashoffset 0.6s ease", filter: `drop-shadow(0 0 6px ${stroke})` }}
|
||||
style={{
|
||||
transition: "stroke-dashoffset 0.6s ease",
|
||||
filter: `drop-shadow(0 0 6px ${stroke})`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={cn("display text-xl", tone === "vermilion" && "text-vermilion", tone === "amber" && "text-amber", tone === "signal" && "text-signal")}>
|
||||
<span
|
||||
className={cn(
|
||||
"display text-xl",
|
||||
tone === "vermilion" && "text-vermilion",
|
||||
tone === "amber" && "text-amber",
|
||||
tone === "signal" && "text-signal",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{sublabel && <span className="mono text-[0.6rem] text-ink-faint">{sublabel}</span>}
|
||||
{sublabel && (
|
||||
<span className="mono text-[0.6rem] text-ink-faint">{sublabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className={cn("w-full", className)} style={{ height }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className={cn("w-full", className)}
|
||||
style={{ height }}
|
||||
role="img"
|
||||
aria-label="Trend sparkline"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
|
||||
@@ -36,7 +47,13 @@ export function Sparkline({
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{fill && <path d={area} fill={`url(#${id})`} />}
|
||||
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,13 +15,17 @@ export function Equalizer({
|
||||
{bars.length === 0 ? (
|
||||
<div className="flex w-full items-end gap-[3px]">
|
||||
{Array.from({ length: 28 }).map((_, i) => (
|
||||
<span key={i} className="flex-1 rounded-full bg-white/10" style={{ height: "12%" }} />
|
||||
<span
|
||||
key={`eq-${i}`}
|
||||
className="flex-1 rounded-full bg-white/10"
|
||||
style={{ height: "12%" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
bars.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
key={`bar-${i}`}
|
||||
className="flex-1 rounded-full"
|
||||
style={{
|
||||
height: `${Math.max(6, b * 100)}%`,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, MessageCircle, Send, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bot, Send, X, MessageCircle } from "lucide-react";
|
||||
import { chatbotApi } from "@/lib/api";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
GlassPanel,
|
||||
Input,
|
||||
toast,
|
||||
} from "@/components/primitives";
|
||||
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
|
||||
import { GlassPanel, Input, Button, Avatar } from "@/components/primitives";
|
||||
import { toast } from "@/components/primitives";
|
||||
import { chatbotApi } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Msg {
|
||||
@@ -27,12 +32,10 @@ export function Chatbot() {
|
||||
.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 },
|
||||
]),
|
||||
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() {
|
||||
<Bot className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink">GMW Assistant</div>
|
||||
<div className="mono text-[0.6rem] text-ink-faint">context-aware</div>
|
||||
<div className="text-sm font-semibold text-ink">
|
||||
GMW Assistant
|
||||
</div>
|
||||
<div className="mono text-[0.6rem] text-ink-faint">
|
||||
context-aware
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-3">
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex-1 space-y-3 overflow-y-auto px-4 py-3"
|
||||
>
|
||||
{msgs.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-ink-faint">
|
||||
Ask about moderation, voice, or media.
|
||||
</div>
|
||||
)}
|
||||
{msgs.map((m, i) => (
|
||||
<div key={i} className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}>
|
||||
{m.role === "bot" && <Avatar name="GMW" size={26} className="mt-0.5 bg-signal/15 text-signal" />}
|
||||
<div
|
||||
key={`${m.role}-${i}`}
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
m.role === "user" ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{m.role === "bot" && (
|
||||
<Avatar
|
||||
name="GMW"
|
||||
size={26}
|
||||
className="mt-0.5 bg-signal/15 text-signal"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-2xl px-3 py-2 text-sm",
|
||||
@@ -108,8 +130,14 @@ export function Chatbot() {
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex gap-2">
|
||||
<Avatar name="GMW" size={26} className="bg-signal/15 text-signal" />
|
||||
<div className="rounded-2xl rounded-bl-sm bg-white/5 px-3 py-2 text-sm text-ink-faint">…</div>
|
||||
<Avatar
|
||||
name="GMW"
|
||||
size={26}
|
||||
className="bg-signal/15 text-signal"
|
||||
/>
|
||||
<div className="rounded-2xl rounded-bl-sm bg-white/5 px-3 py-2 text-sm text-ink-faint">
|
||||
…
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -121,7 +149,12 @@ export function Chatbot() {
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
/>
|
||||
<Button variant="primary" size="icon" onClick={send} disabled={loading}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="icon"
|
||||
onClick={send}
|
||||
disabled={loading}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<div
|
||||
className="fixed inset-0 z-[90] flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm"
|
||||
onMouseDown={() => setOpen(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<GlassPanel
|
||||
className="w-full max-w-[560px] overflow-hidden p-0"
|
||||
@@ -130,12 +132,16 @@ export function CommandPalette() {
|
||||
placeholder="Type a command or search…"
|
||||
className="flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-ink-faint"
|
||||
/>
|
||||
<kbd className="mono rounded bg-white/8 px-1.5 py-0.5 text-[0.6rem] text-ink-faint">ESC</kbd>
|
||||
<kbd className="mono rounded bg-white/8 px-1.5 py-0.5 text-[0.6rem] text-ink-faint">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-ink-faint">No commands</div>
|
||||
<div className="py-8 text-center text-xs text-ink-faint">
|
||||
No commands
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((c, i) => (
|
||||
<button
|
||||
@@ -144,23 +150,34 @@ export function CommandPalette() {
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => runAt(i)}
|
||||
className={`flex w-full items-center gap-3 rounded-[10px] px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
i === active ? "bg-signal/12 text-ink" : "text-ink-soft hover:bg-white/5"
|
||||
i === active
|
||||
? "bg-signal/12 text-ink"
|
||||
: "text-ink-soft hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center rounded-[8px] bg-white/5">
|
||||
{c.icon}
|
||||
</span>
|
||||
<span className="flex-1">{c.label}</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">{c.hint}</span>
|
||||
{i === active && <CornerDownLeft className="size-3.5 text-ink-faint" />}
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{c.hint}
|
||||
</span>
|
||||
{i === active && (
|
||||
<CornerDownLeft className="size-3.5 text-ink-faint" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 border-t border-hairline px-4 py-2 text-[0.65rem] text-ink-faint">
|
||||
<span className="flex items-center gap-1"><ArrowUp className="size-3" /><ArrowDown className="size-3" /> navigate</span>
|
||||
<span className="flex items-center gap-1"><CornerDownLeft className="size-3" /> select</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ArrowUp className="size-3" />
|
||||
<ArrowDown className="size-3" /> navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CornerDownLeft className="size-3" /> select
|
||||
</span>
|
||||
<span className="ml-auto mono">⌘K</span>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -17,10 +17,19 @@ export function Progress({
|
||||
? "var(--color-amber)"
|
||||
: "var(--color-signal)";
|
||||
return (
|
||||
<div className={cn("h-1.5 w-full overflow-hidden rounded-full bg-white/8", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-full overflow-hidden rounded-full bg-white/8",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${pct}%`, background: color, boxShadow: `0 0 12px -2px ${color}` }}
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
background: color,
|
||||
boxShadow: `0 0 12px -2px ${color}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn("size-4 shrink-0 text-ink-faint transition-transform", open && "rotate-180")}
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-ink-faint transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{o.label}</span>
|
||||
{o.hint && <span className="mono text-[0.65rem] text-ink-faint">{o.hint}</span>}
|
||||
{o.hint && (
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{o.hint}
|
||||
</span>
|
||||
)}
|
||||
{o.value === value && <Check className="size-3.5 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -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 <Link>/<a> in a Button.
|
||||
*/
|
||||
export const Slot = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }>(
|
||||
({ children, ...slotProps }, ref) => {
|
||||
if (!React.isValidElement(children)) return null;
|
||||
const childProps = children.props as Record<string, unknown>;
|
||||
const merged: Record<string, unknown> = { ...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<HTMLElement> & { children?: React.ReactNode }
|
||||
>(({ children, ...slotProps }, ref) => {
|
||||
if (!React.isValidElement(children)) return null;
|
||||
const childProps = children.props as Record<string, unknown>;
|
||||
const merged: Record<string, unknown> = { ...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";
|
||||
|
||||
@@ -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 (
|
||||
<div className={cn("pointer-events-none fixed z-[100] flex w-[min(92vw,360px)] flex-col gap-2", pos)}>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed z-[100] flex w-[min(92vw,360px)] flex-col gap-2",
|
||||
pos,
|
||||
)}
|
||||
>
|
||||
{items.map((t) => {
|
||||
const Icon = icons[t.tone];
|
||||
return (
|
||||
@@ -83,7 +88,9 @@ export function Toaster({ position = "bottom-right" }: { position?: string }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-ink">{t.title}</div>
|
||||
{t.description && (
|
||||
<div className="mt-0.5 text-xs text-ink-soft">{t.description}</div>
|
||||
<div className="mt-0.5 text-xs text-ink-soft">
|
||||
{t.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -21,6 +21,7 @@ export function Tooltip({
|
||||
onMouseLeave={() => setShow(false)}
|
||||
onFocus={() => setShow(true)}
|
||||
onBlur={() => setShow(false)}
|
||||
role="presentation"
|
||||
>
|
||||
{children}
|
||||
{show && (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGuilds, useTextChannels, useVoiceChannels } from "@/hooks";
|
||||
import { Select, type SelectOption } from "@/components/primitives";
|
||||
import { useGuilds, useTextChannels, useVoiceChannels } from "@/hooks";
|
||||
import type { Guild } from "@/lib/types";
|
||||
|
||||
export function GuildChannelPicker({
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { SectionHeader, MetricTile } from "./section";
|
||||
export { EmptyState, ErrorState, LoadingState } from "./states";
|
||||
export { GuildChannelPicker } from "./guild-picker";
|
||||
export { MetricTile, SectionHeader } from "./section";
|
||||
export { EmptyState, ErrorState, LoadingState } from "./states";
|
||||
|
||||
@@ -59,7 +59,9 @@ export function MetricTile({
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="mono mt-1 text-[0.68rem] text-ink-faint">{hint}</div>}
|
||||
{hint && (
|
||||
<div className="mono mt-1 text-[0.68rem] text-ink-faint">{hint}</div>
|
||||
)}
|
||||
{spark && spark.length > 1 && (
|
||||
<div className="mt-2">
|
||||
<div
|
||||
@@ -68,7 +70,11 @@ export function MetricTile({
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${Math.min(100, (spark[spark.length - 1] / (Math.max(...spark) || 1)) * 100)}%`, background: toneColor, opacity: 0.7 }}
|
||||
style={{
|
||||
width: `${Math.min(100, (spark[spark.length - 1] / (Math.max(...spark) || 1)) * 100)}%`,
|
||||
background: toneColor,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,10 +14,19 @@ export function EmptyState({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center gap-2 py-12 text-center", className)}>
|
||||
<div className="text-ink-faint">{icon ?? <Inbox className="size-7" />}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-2 py-12 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="text-ink-faint">
|
||||
{icon ?? <Inbox className="size-7" />}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-ink-soft">{title}</div>
|
||||
{description && <div className="max-w-xs text-xs text-ink-faint">{description}</div>}
|
||||
{description && (
|
||||
<div className="max-w-xs text-xs text-ink-faint">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +45,11 @@ export function ErrorState({
|
||||
<div className="glass flex flex-col items-center gap-3 p-8 text-center">
|
||||
<AlertTriangle className="size-7 text-vermilion" />
|
||||
<div className="text-sm font-medium text-ink">{title}</div>
|
||||
{msg && <div className="mono max-w-md break-words text-xs text-ink-faint">{msg}</div>}
|
||||
{msg && (
|
||||
<div className="mono max-w-md break-words text-xs text-ink-faint">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { AppFrame } from "./ambient-app";
|
||||
export { NavRail } from "./nav-rail";
|
||||
export { TopBar } from "./topbar";
|
||||
export { ConnectionStatus } from "./status-dot";
|
||||
export { TopBar } from "./topbar";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { LayoutDashboard } from "lucide-react";
|
||||
import { navItems, isActivePath } from "@/lib/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { isActivePath, navItems } from "@/lib/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function NavItem({
|
||||
@@ -64,4 +64,3 @@ export function NavRail() {
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { Tooltip } from "@/components/primitives/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
const MAP = {
|
||||
connected: { color: "bg-signal", label: "Live link" },
|
||||
@@ -18,8 +18,18 @@ export function ConnectionStatus({ compact = false }: { compact?: boolean }) {
|
||||
<Tooltip label={s.label}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="relative flex size-2.5">
|
||||
<span className={cn("absolute inline-flex h-full w-full rounded-full opacity-60 animate-pulse-ring", s.color)} />
|
||||
<span className={cn("relative inline-flex size-2.5 rounded-full", s.color)} />
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inline-flex h-full w-full rounded-full opacity-60 animate-pulse-ring",
|
||||
s.color,
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex size-2.5 rounded-full",
|
||||
s.color,
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="mono text-[0.7rem] uppercase tracking-wider text-ink-soft">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { navItems } from "@/lib/navigation";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { ConnectionStatus } from "./status-dot";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { navItems } from "@/lib/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConnectionStatus } from "./status-dot";
|
||||
|
||||
function useActiveLabel() {
|
||||
const pathname = usePathname();
|
||||
@@ -41,9 +41,7 @@ export function TopBar() {
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<span className={cn("pill", signalTone)}>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full bg-current animate-breathe",
|
||||
)}
|
||||
className={cn("size-1.5 rounded-full bg-current animate-breathe")}
|
||||
/>
|
||||
{state.label ?? "nominal"}
|
||||
</span>
|
||||
@@ -51,7 +49,9 @@ export function TopBar() {
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open command palette"
|
||||
onClick={() => window.dispatchEvent(new Event("command-palette:open"))}
|
||||
onClick={() =>
|
||||
window.dispatchEvent(new Event("command-palette:open"))
|
||||
}
|
||||
className="hidden items-center gap-1.5 rounded-[11px] border border-hairline bg-white/5 px-2.5 py-1.5 text-xs text-ink-soft transition-colors hover:text-ink hover:border-signal/40 sm:flex"
|
||||
>
|
||||
<span className="mono text-[0.65rem]">⌘K</span>
|
||||
|
||||
@@ -27,7 +27,9 @@ export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
|
||||
background: "oklch(1 0 0 / 0.04)",
|
||||
}}
|
||||
>
|
||||
<Radio className={`size-7 ${live ? "text-signal" : "text-ink-faint"}`} />
|
||||
<Radio
|
||||
className={`size-7 ${live ? "text-signal" : "text-ink-faint"}`}
|
||||
/>
|
||||
<span className="mono mt-1 text-xs text-ink-soft">{n} live</span>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +47,12 @@ export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
|
||||
>
|
||||
<div className="relative flex flex-col items-center gap-1">
|
||||
<span className="relative">
|
||||
<Avatar src={s.avatar} name={s.username} size={46} ring={s.speaking} />
|
||||
<Avatar
|
||||
src={s.avatar}
|
||||
name={s.username}
|
||||
size={46}
|
||||
ring={s.speaking}
|
||||
/>
|
||||
{s.speaking && (
|
||||
<span className="absolute inset-0 rounded-full ring-2 ring-signal animate-pulse-ring" />
|
||||
)}
|
||||
|
||||
@@ -65,15 +65,15 @@ export function useChannels(guildId?: string, search?: string) {
|
||||
|
||||
export function useUserDetail(userId: string | null) {
|
||||
return useSWR<DashboardUserDetail>(
|
||||
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<DashboardChannelDetail>(
|
||||
channelId ? ["dashboard-channel", channelId] : null,
|
||||
() => dashboardApi.getChannelDetail(channelId!),
|
||||
channelId ? (["dashboard-channel", channelId] as const) : null,
|
||||
() => dashboardApi.getChannelDetail(channelId ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user