Revert "feat: migrate frontend to Astro SSG with design system"

This reverts commit 8ad888da28.
This commit is contained in:
asepharyana
2026-07-02 03:54:44 +07:00
parent 880c68fbab
commit bf86fcda8e
110 changed files with 4980 additions and 3396 deletions
-1
View File
@@ -1,2 +1 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />
+2
View File
@@ -36,8 +36,10 @@ export default defineConfig({
"imphnen.asepharyana.web.id",
],
watch: {
// Penting: Astro punya public/ dir sendiri, jangan bentrok
ignored: ["!**/node_modules/**"],
},
},
// PostCSS otomatis terdeteksi dari root project
},
});
+1 -2
View File
@@ -23,8 +23,7 @@
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@biomejs/biome": "latest",
+18
View File
@@ -0,0 +1,18 @@
// ─── App.client.tsx — Astro React island entry point ────────────────────────
// DILOAD OLEH Astro client:only="react"
// Menyediakan <div id="root"> dan mount App dengan provider yang diperlukan
// ─────────────────────────────────────────────────────────────────────────────
import React from "react";
import App from "./App";
import { ToastProvider } from "./shared/ui";
export default function AppClient() {
return (
<React.StrictMode>
<ToastProvider>
<App />
</ToastProvider>
</React.StrictMode>
);
}
+512
View File
@@ -0,0 +1,512 @@
// ─── App.tsx — The God Component ──────────────────────────────────────────────
// TODO: Decompose into smaller focused components (M20).
// This component currently handles auth, socket lifecycle, speaker tracking,
// voice control, media, PTT, command palette, and tab navigation.
// Each concern should be extracted into its own hook or sub-component.
// ───────────────────────────────────────────────────────────────────────────────
import { AnimatePresence } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ActiveSpeaker } from "./entities/voice/types.js";
import { AuthOverlay } from "./features/auth";
import { DashboardPanel } from "./features/dashboard";
import { LivePanel } from "./features/live";
import { useMediaControl } from "./features/live/hooks/useMediaControl";
import { useVoiceControl } from "./features/live/hooks/useVoiceControl";
import { MessagesPanel } from "./features/messages";
import { ModerationAlertListener } from "./features/messages/components/ModerationAlertListener";
import {
mergeMessages,
useMessages,
} from "./features/messages/hooks/useMessages";
import { SettingsPanel } from "./features/settings";
import { useNotificationBadge } from "./hooks/useNotificationBadge";
import { useTheme } from "./hooks/useTheme";
import {
getAppConfig,
getSessionToken,
getAdminPassword,
clearSessionToken,
clearAdminPassword,
} from "./shared/api/client";
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
import { useUIState } from "./shared/hooks/useUIState";
import { CommandPalette } from "./shared/ui/CommandPalette";
import { ErrorBoundary } from "./shared/ui/error-boundary";
import { MobileTabBar } from "./shared/ui/MobileTabBar";
import type { DashboardTab } from "./entities/ui/types.js";
import { useDashboardSocket } from "./shared/ws/socket";
import { DashboardLayout } from "./widgets/DashboardLayout";
type AuthState = "loading" | "authenticated" | "unauthenticated";
export default function App() {
const { uiState, patchUIState } = useUIState();
const { theme, mode, isDark, toggle: toggleTheme, setMode } = useTheme();
const voice = useVoiceControl();
const media = useMediaControl();
const messages = useMessages();
const [activeSpeakers, setActiveSpeakers] = useState<
(ActiveSpeaker & { heardAt?: number })[]>([]);
const [monitorGuildId, setMonitorGuildId] = useState("");
// ── Command palette state ────────────────────────────────────────────────
const [paletteOpen, setPaletteOpen] = useState(false);
const [paletteMode, setPaletteMode] = useState<"search" | "shortcuts" | null>(null);
// ── Auth state ─────────────────────────────────────────────────────────────
const [authState, setAuthState] = useState<AuthState>("loading");
const [dashboardIsPublic, setDashboardIsPublic] = useState(false);
const [configError, setConfigError] = useState<string | null>(null);
const configRetryRef = useRef(0);
const configTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const MAX_CONFIG_RETRIES = 3;
// ── Notification badge ─────────────────────────────────────────────────────
const activeTab: DashboardTab = (uiState.activeTab as DashboardTab) || "messages";
const notifBadge = useNotificationBadge(activeTab);
// On mount: check config for public/private mode, and check stored session token
useEffect(() => {
// Clear legacy admin-password from localStorage — only use token auth now
clearAdminPassword();
// Validate existing token by calling config endpoint
// If server returns 401, clear the invalid token
const attempt = () => {
getAppConfig()
.then((cfg) => {
configRetryRef.current = 0;
setConfigError(null);
setMonitorGuildId(cfg.monitorGuildId ?? "");
setDashboardIsPublic(cfg.dashboardIsPublic);
// Check if we have a session token (new auth) or legacy password (backward compat)
const sessionToken = getSessionToken();
const storedPassword = getAdminPassword();
if (sessionToken || cfg.dashboardIsPublic || storedPassword) {
setAuthState("authenticated");
} else {
setAuthState("unauthenticated");
}
})
.catch((err) => {
// If server responds with 401, token is invalid — clear it
if (err?.statusCode === 401 || err?.status === 401) {
clearSessionToken();
setAuthState("unauthenticated");
return;
}
configRetryRef.current += 1;
const isNetwork =
err instanceof TypeError &&
(err.message === "Failed to fetch" ||
err.message.includes("NetworkError") ||
err.message.includes("network"));
if (isNetwork && configRetryRef.current < MAX_CONFIG_RETRIES) {
// Retry with backoff: 1s, 2s, 3s
const delay = configRetryRef.current * 1000;
configTimeoutRef.current = setTimeout(attempt, delay);
} else {
// Final failure — show auth overlay with retry button
setConfigError(
isNetwork
? "Cannot reach server. Check your connection and try again."
: "Failed to load configuration.",
);
setAuthState("unauthenticated");
}
});
};
attempt();
return () => {
if (configTimeoutRef.current) {
clearTimeout(configTimeoutRef.current);
configTimeoutRef.current = null;
}
};
}, []);
const audio = useAudioPlayback();
const selectedVoiceGuild =
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
// Resolve monitor guild name from the full guild list (has real names now)
const monitorGuildName = useMemo(
() =>
monitorGuildId
? (voice.guilds.find((g) => g.id === monitorGuildId)?.name ?? null)
: null,
[monitorGuildId, voice.guilds],
);
// Update speaker list from incremental voice_active_user events
const updateSpeakerList = (
prev: (ActiveSpeaker & { heardAt?: number })[],
data: Partial<ActiveSpeaker> & {
userId?: string;
id?: string;
speaking: boolean;
},
): (ActiveSpeaker & { heardAt?: number })[] => {
const key = data.userId ?? data.id;
if (!key) return prev;
const now = Date.now();
const idx = prev.findIndex((s) => (s.userId ?? s.id) === key);
if (idx >= 0) {
const next = [...prev];
next[idx] = { ...next[idx], ...data, heardAt: now };
return next;
}
return [
...prev,
{ ...data, heardAt: now } as ActiveSpeaker & { heardAt?: number },
];
};
const socket = useDashboardSocket({
onBinary: (d) => audio.handleIncomingBinary(d),
onUserState: (users) =>
setActiveSpeakers(
users.map((u) => ({
...u,
heardAt: Date.now(),
})),
),
onVoiceActiveUser: (data) => {
if (data.userId) audio.registerUserId(data.userId);
setActiveSpeakers((prev: (ActiveSpeaker & { heardAt?: number })[]) =>
updateSpeakerList(prev, {
userId: data.userId,
username: data.username,
avatar: data.avatar,
speaking: data.speaking,
}),
);
},
onVoiceRecordingStarted: (data) =>
window.dispatchEvent(
new CustomEvent("voice_recording_started", { detail: data }),
),
onVoiceRecordingStopped: (data) =>
window.dispatchEvent(
new CustomEvent("voice_recording_stopped", { detail: data }),
),
onVoiceAnalyzed: (data) =>
window.dispatchEvent(
new CustomEvent("voice_analyzed", { detail: data }),
),
onMessageCreated: (m) =>
messages.setMessages((prev) => {
// Skip if message already exists with same status (dedup)
const existing = prev.find((i) => i.id === m.id);
if (existing && existing.ai_status === m.ai_status) return prev;
return mergeMessages(prev, [m]);
}),
onMessageUpdated: (m) =>
messages.setMessages((prev) =>
prev.map((i) => (i.id === m.id ? { ...i, ...m } : i)),
),
onMessageDeleted: (m) =>
messages.setMessages((prev) =>
prev.map((i) =>
i.id === m.id ? { ...i, type: "deleted" as const } : i,
),
),
onMessageAnalyzed: (msg) => {
messages.setMessages((prev) => {
// Skip if message already analyzed with same status (dedup)
const existing = prev.find((i) => i.id === msg.id);
if (existing && existing.ai_status === msg.ai_status) return prev;
return mergeMessages(prev, [msg]);
});
const status = msg.ai_status;
if (status === "flagged") {
const username = msg.username || msg.user_id || "unknown";
const severity = msg.ai_severity || "";
const categories = msg.ai_categories || "";
const brief = msg.ai_analysis?.slice(0, 80) ?? "Message flagged by AI";
window.dispatchEvent(
new CustomEvent("moderation_alert", {
detail: { type: status, username, severity, categories, brief },
}),
);
}
},
onAttachmentUploaded: () =>
messages
.fetchMessages(monitorGuildId || undefined)
.catch(() => undefined),
onAttachmentCreated: () =>
messages
.fetchMessages(monitorGuildId || undefined)
.catch(() => undefined),
onMediaState: (state) => media.setMediaState(state),
onVoiceRecordingUploaded: (d) =>
window.dispatchEvent(
new CustomEvent("voice_recording_uploaded", { detail: d }),
),
});
const transmit = useAudioTransmit(socket.socketRef);
// Load voice channels when guild changes (Live tab)
useEffect(() => {
if (selectedVoiceGuild)
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
// Auto-fetch messages for the monitor guild
useEffect(() => {
if (monitorGuildId)
messages.fetchMessages(monitorGuildId).catch(() => undefined);
}, [monitorGuildId, messages.fetchMessages]);
// Periodic refetch — keeps dashboard in sync even if WS events missed
const monitorGuildRef = useRef(monitorGuildId);
monitorGuildRef.current = monitorGuildId;
useEffect(() => {
const currentGuild = monitorGuildRef.current;
if (!currentGuild) return;
const interval = setInterval(() => {
messages.fetchMessages(monitorGuildRef.current).catch(() => undefined);
}, 15_000);
return () => {
clearInterval(interval);
};
}, [monitorGuildId]);
// Stale speaker pruning — remove speakers not heard from in 30s
useEffect(() => {
const interval = setInterval(() => {
setActiveSpeakers((prev: (ActiveSpeaker & { heardAt?: number })[]) => {
const now = Date.now();
const pruned = prev.filter(
(s) => s.speaking || (s.heardAt && now - s.heardAt < 30_000),
);
return pruned.length < prev.length ? pruned : prev;
});
}, 30_000);
return () => clearInterval(interval);
}, []);
// Push-to-Talk — hold Space to transmit
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
)
return;
if (e.code === "Space" && !transmit.isStreaming && e.repeat === false) {
e.preventDefault();
transmit.startTransmit().catch(() => undefined);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space" && transmit.isStreaming) {
transmit.stopTransmit();
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [transmit]);
// ── Command palette keyboard shortcut handler ──────────────────────────────
const handlePaletteOpen = useCallback((mode: "search" | "shortcuts") => {
setPaletteMode(mode);
setPaletteOpen(true);
}, []);
const handlePaletteClose = useCallback(() => {
setPaletteOpen(false);
setPaletteMode(null);
}, []);
const handlePaletteNavigate = useCallback(
(tab: string) => {
patchUIState({ activeTab: tab as DashboardTab });
handlePaletteClose();
},
[patchUIState, handlePaletteClose],
);
// ── Tab navigation handler ─────────────────────────────────────────────────
const handleTabChange = useCallback(
(tab: DashboardTab) => {
patchUIState({ activeTab: tab });
},
[patchUIState],
);
// ── Render main content based on active tab ────────────────────────────────
const renderContent = () => {
switch (activeTab) {
case "live":
return (
<ErrorBoundary message="Live panel crashed">
<LivePanel
guilds={voice.guilds}
voiceChannels={voice.voiceChannels}
selectedGuild={selectedVoiceGuild}
selectedChannel={uiState.selectedVoiceChannel || ""}
micLevel={0}
status={voice.voiceStatus}
voiceLoading={voice.loading}
activeSpeakers={activeSpeakers}
levels={audio.levels}
isListening={audio.isListening}
isStreaming={transmit.isStreaming}
mediaState={media.mediaState}
mediaLoading={media.loading}
onGuildChange={(id) =>
patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })
}
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
onJoin={() =>
voice.joinVoice(
selectedVoiceGuild,
uiState.selectedVoiceChannel || "",
)
}
onDisconnect={() => voice.leaveVoice()}
onListenToggle={audio.toggleListening}
onStreamingToggle={transmit.toggle}
onQueueMusic={(s) => media.enqueue(s, "music")}
onStartScreen={(s) => media.enqueue(s, "screen")}
onSkip={media.skip}
onStop={media.stop}
onVolumeChange={media.setVolume}
/>
</ErrorBoundary>
);
case "dashboard":
return (
<ErrorBoundary message="Dashboard panel crashed">
<DashboardPanel />
</ErrorBoundary>
);
case "settings":
return (
<ErrorBoundary message="Settings panel crashed">
<SettingsPanel
themeMode={mode}
isDark={isDark}
onThemeModeChange={setMode}
/>
</ErrorBoundary>
);
default:
return (
<ErrorBoundary message="Messages panel crashed">
<MessagesPanel
guildName={monitorGuildName}
messages={messages.messages}
onReanalyze={messages.reanalyze}
onReanalyzeAllErrors={messages.reanalyzeAllErrors}
onLoadMore={messages.loadMore}
hasMore={messages.hasMore}
loadingMore={messages.loadingMore}
/>
</ErrorBoundary>
);
}
};
// ── Render: Auth loading ─────────────────────────────────────────────────
if (authState === "loading") {
return (
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">
{configError
? "Connection lost — retrying..."
: `Connecting to server${".".repeat(configRetryRef.current)}`}
</p>
</div>
</div>
);
}
// ── Render: Auth overlay ─────────────────────────────────────────────────
if (authState === "unauthenticated") {
return (
<AuthOverlay
isPublic={dashboardIsPublic}
onAuthenticated={() => setAuthState("authenticated")}
configError={configError}
onRetryConfig={() => {
setConfigError(null);
setAuthState("loading");
configRetryRef.current = 0;
// Re-trigger the config fetch by forcing remount via key trick
// Actually: just re-run attempt logic
getAppConfig()
.then((cfg) => {
setMonitorGuildId(cfg.monitorGuildId ?? "");
setDashboardIsPublic(cfg.dashboardIsPublic);
const sessionToken = getSessionToken();
const storedPassword = getAdminPassword();
if (sessionToken || cfg.dashboardIsPublic || storedPassword) {
setAuthState("authenticated");
} else {
setAuthState("unauthenticated");
}
})
.catch(() => {
setConfigError("Server still unreachable. Try again later.");
setAuthState("unauthenticated");
});
}}
/>
);
}
// ── Render: Main app (authenticated) ─────────────────────────────────────
return (
<>
<DashboardLayout
activeTab={activeTab}
wsStatus={socket.status}
voiceStatus={voice.voiceStatus}
themeMode={mode}
isDark={isDark}
onTabChange={handleTabChange}
onThemeToggle={toggleTheme}
recentMessages={messages.messages}
guildId={monitorGuildId}
channelId={
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
}
notificationCount={notifBadge.count}
>
<AnimatePresence mode="wait">
{renderContent()}
</AnimatePresence>
</DashboardLayout>
<MobileTabBar
activeTab={activeTab}
onTabChange={handleTabChange}
/>
<ModerationAlertListener />
<CommandPalette
isOpen={paletteOpen}
mode={paletteMode}
onClose={handlePaletteClose}
onNavigate={handlePaletteNavigate}
onToggleTheme={toggleTheme}
isDark={isDark}
/>
</>
);
}
@@ -1,34 +0,0 @@
---
// ─── Header.astro — App header bar ──────────────────────────────────────────
// Features: sticky top, backdrop blur, border-bottom, actions slot
// ──────────────────────────────────────────────────────────────────────────────
export interface Props {
title: string;
}
const { title } = Astro.props;
---
<header
class="header sticky top-0 z-[var(--z-header)] flex items-center justify-between border-b border-border px-6 py-3"
>
<!-- Left: title -->
<div class="flex items-center gap-3">
<h1 class="text-lg font-semibold text-foreground">{title}</h1>
</div>
<!-- Right: actions slot -->
<div class="flex items-center gap-2">
<slot name="actions" />
</div>
</header>
<style>
.header {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background-color: oklch(var(--glass-bg));
min-height: 3.5rem;
}
</style>
@@ -1,60 +0,0 @@
---
// ─── NavItem.astro — Sidebar navigation item ────────────────────────────────
// Handles: active state highlighting, collapsed icon-only mode,
// notification badge / dot indicators.
// ────────────────────────────────────────────────────────────────────────────────
export interface Props {
href: string;
icon: string;
label: string;
active?: boolean;
collapsed?: boolean;
notificationCount?: number;
}
const {
href,
icon,
label,
active = false,
collapsed = false,
notificationCount = 0,
} = Astro.props;
const navClass = active
? "bg-[var(--clr-interactive-selected,oklch(var(--primary-soft)))] text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground";
---
<a
href={href}
class:list={[
"nav-item group relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium no-underline transition-all duration-150",
collapsed ? "justify-center px-2.5" : "",
navClass,
]}
aria-current={active ? "page" : undefined}
aria-label={collapsed ? label : undefined}
>
<span class="size-5 shrink-0" aria-hidden="true" set:html={icon} />
{!collapsed && <span class="truncate">{label}</span>}
{!collapsed && notificationCount > 0 && (
<span
class="ml-auto inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none text-white"
style="background-color: var(--clr-severity-high, oklch(var(--destructive)))"
>
{notificationCount > 99 ? "99+" : notificationCount}
</span>
)}
{collapsed && notificationCount > 0 && (
<span
class="absolute right-1.5 top-1.5 size-2 rounded-full"
style="background-color: var(--clr-severity-high, oklch(var(--destructive)))"
aria-label={`${notificationCount} notifications`}
/>
)}
</a>
File diff suppressed because one or more lines are too long
@@ -1,35 +0,0 @@
---
// ─── EmptyState.astro — Empty / no-data state ───────────────────────────────
// Features: centered layout, icon, title, description, actions slot
// ──────────────────────────────────────────────────────────────────────────────
export interface Props {
icon: string;
title: string;
description?: string;
}
const { icon, title, description } = Astro.props;
---
<div
class="flex min-h-64 flex-col items-center justify-center gap-4 px-6 py-12 text-center"
>
<!-- Icon -->
<div class="flex size-16 items-center justify-center" aria-hidden="true" set:html={icon} />
<!-- Title -->
<h2 class="text-xl font-semibold text-foreground">{title}</h2>
<!-- Description -->
{description && (
<p class="max-w-sm text-sm text-muted-foreground">{description}</p>
)}
<!-- Actions slot (buttons, links, etc.) -->
{Astro.slots.has("actions") && (
<div class="mt-2">
<slot name="actions" />
</div>
)}
</div>
@@ -1,51 +0,0 @@
---
// ─── ErrorState.astro — Error / failure state ───────────────────────────────
// Features: role="alert", severity-critical bg tint, error icon, retry slot
// ──────────────────────────────────────────────────────────────────────────────
export interface Props {
message: string;
}
const { message } = Astro.props;
---
<div
role="alert"
class="flex min-h-48 flex-col items-center justify-center gap-4 rounded-xl px-6 py-10 text-center"
style="background-color: oklch(var(--destructive) / 0.08)"
>
<!-- Error icon circle -->
<div
class="flex size-12 items-center justify-center rounded-full"
style="background-color: oklch(var(--destructive) / 0.15)"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
style="color: oklch(var(--destructive))"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<!-- Error message -->
<p class="max-w-sm text-sm font-medium text-foreground">{message}</p>
<!-- Retry slot -->
{Astro.slots.has("retry") && (
<div class="mt-2">
<slot name="retry" />
</div>
)}
</div>
@@ -1,60 +0,0 @@
---
// ─── LoadingSkeleton.astro — Placeholder loading states ─────────────────────
// Uses the shared Skeleton React component in 3 layout variants.
// ──────────────────────────────────────────────────────────────────────────────
import { Skeleton } from "../../shared/components/skeleton";
export type Variant = "card" | "list" | "detail";
export interface Props {
/** Visual layout variant */
variant?: Variant;
/** Number of skeleton items to render (default 1) */
count?: number;
}
const { variant = "card", count = 1 } = Astro.props;
const items = Array.from({ length: count });
---
<div role="presentation" aria-hidden="true" class="flex flex-col gap-4">
{
items.map((_, i) => (
<div class="skeleton-group" data-variant={variant}>
{variant === "card" && (
<div class="overflow-hidden rounded-xl border border-border">
<Skeleton class="h-40 w-full rounded-none" />
<div class="space-y-3 p-4">
<Skeleton class="h-4 w-3/4" />
<Skeleton class="h-3 w-1/2" />
</div>
</div>
)}
{variant === "list" && (
<div class="flex items-center gap-3 rounded-lg p-3">
<Skeleton class="size-10 shrink-0 rounded-full" />
<div class="flex-1 space-y-2">
<Skeleton class="h-3.5 w-1/3" />
<Skeleton class="h-3 w-2/3" />
</div>
</div>
)}
{variant === "detail" && (
<div class="space-y-4 rounded-xl p-4">
<Skeleton class="h-6 w-1/2" />
<div class="space-y-2">
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-3/4" />
</div>
<Skeleton class="h-10 w-32" />
</div>
)}
</div>
))
}
</div>
@@ -1,106 +0,0 @@
---
export interface Props {
variant?: "default" | "secondary" | "success" | "warning" | "destructive" | "outline";
size?: "sm" | "default";
dot?: boolean;
class?: string;
}
const {
variant = "default",
size = "default",
dot = false,
class: className = "",
} = Astro.props;
---
<span class:list={[`badge badge--${variant} badge--${size}`, className]}>
{dot && <span class="badge__dot" aria-hidden="true" />}
<slot />
</span>
<style is:global>
.badge {
--fs-xs: 0.75rem;
--fs-xxs: 0.625rem;
display: inline-flex;
align-items: center;
gap: 0.375rem;
border-radius: 999px;
border: 1px solid transparent;
font-weight: var(--fw-medium, 500);
transition:
background-color var(--duration-fast, 150ms) var(--ease-out, ease-out),
color var(--duration-fast, 150ms) var(--ease-out, ease-out);
white-space: nowrap;
user-select: none;
}
.badge--default {
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: var(--fs-xs);
background-color: oklch(var(--primary));
color: oklch(var(--primary-foreground));
}
.badge--secondary {
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: var(--fs-xs);
background-color: oklch(var(--muted));
color: oklch(var(--muted-foreground));
}
.badge--success {
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: var(--fs-xs);
background-color: oklch(var(--success) / 0.15);
color: oklch(var(--success));
}
.badge--warning {
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: var(--fs-xs);
background-color: oklch(var(--warning) / 0.15);
color: oklch(var(--warning));
}
.badge--destructive {
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: var(--fs-xs);
background-color: oklch(var(--destructive) / 0.15);
color: oklch(var(--destructive));
}
.badge--outline {
padding-inline: 0.625rem;
padding-block: 0.125rem;
font-size: var(--fs-xs);
border-color: oklch(var(--border));
background-color: transparent;
color: oklch(var(--foreground));
}
/* ── Size sm ───────────────────────────────────── */
.badge--sm {
padding-inline: 0.375rem;
padding-block: 0.0625rem;
font-size: var(--fs-xxs);
}
/* ── Dot indicator ─────────────────────────────── */
.badge__dot {
display: inline-block;
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background-color: currentColor;
flex-shrink: 0;
}
</style>
@@ -1,146 +0,0 @@
---
export interface Props {
variant?: "primary" | "secondary" | "destructive" | "outline" | "ghost";
size?: "sm" | "default" | "lg" | "icon";
disabled?: boolean;
href?: string;
class?: string;
}
const {
variant = "primary",
size = "default",
disabled = false,
href,
class: className = "",
} = Astro.props;
const Tag = href ? "a" : "button";
const attrs: Record<string, string | number | boolean> = {
class: `btn btn--${variant} btn--${size} ${className}`,
};
if (Tag === "button") {
attrs.disabled = disabled;
} else if (disabled) {
attrs["aria-disabled"] = true;
attrs.role = "link";
}
---
<Tag {...attrs}>
<slot />
</Tag>
<style is:global>
.btn {
--rd-md: calc(var(--radius, 1rem) - 2px);
--fs-sm: 0.875rem;
--fw-medium: 500;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
white-space: nowrap;
border-radius: var(--rd-md);
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
line-height: 1.25rem;
transition:
background-color var(--duration-fast, 150ms) var(--ease-out, ease-out),
color var(--duration-fast, 150ms) var(--ease-out, ease-out),
border-color var(--duration-fast, 150ms) var(--ease-out, ease-out),
box-shadow var(--duration-fast, 150ms) var(--ease-out, ease-out),
transform var(--duration-fast, 150ms) var(--ease-out, ease-out);
cursor: pointer;
border: 1px solid transparent;
text-decoration: none;
outline: none;
user-select: none;
}
.btn:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: 2px;
}
.btn:active {
transform: scale(0.97);
}
.btn:disabled,
.btn[aria-disabled="true"] {
pointer-events: none;
opacity: 0.5;
}
/* ── Variants ──────────────────────────────────────────── */
.btn--primary {
background-color: oklch(var(--primary));
color: oklch(var(--primary-foreground));
box-shadow: 0 1px 2px oklch(0 0 0 / 0.05);
}
.btn--primary:hover {
background-color: oklch(var(--primary) / 0.9);
}
.btn--secondary {
background-color: oklch(var(--secondary));
color: oklch(var(--secondary-foreground));
}
.btn--secondary:hover {
background-color: oklch(var(--secondary) / 0.8);
}
.btn--destructive {
background-color: oklch(var(--destructive));
color: oklch(var(--destructive-foreground));
box-shadow: 0 1px 2px oklch(0 0 0 / 0.05);
}
.btn--destructive:hover {
background-color: oklch(var(--destructive) / 0.9);
}
.btn--outline {
border-color: oklch(var(--border));
background-color: oklch(var(--background));
color: oklch(var(--foreground));
}
.btn--outline:hover {
background-color: oklch(var(--accent));
color: oklch(var(--accent-foreground));
}
.btn--ghost {
background-color: transparent;
color: oklch(var(--foreground));
}
.btn--ghost:hover {
background-color: oklch(var(--accent));
color: oklch(var(--accent-foreground));
}
/* ── Sizes ─────────────────────────────────────────────── */
.btn--sm {
height: 2.25rem;
padding-inline: 0.75rem;
}
.btn--default {
height: 2.5rem;
padding-inline: 1rem;
}
.btn--lg {
height: 2.75rem;
padding-inline: 2rem;
}
.btn--icon {
height: 2.5rem;
width: 2.5rem;
padding: 0;
}
</style>
@@ -1,101 +0,0 @@
---
export interface Props {
variant?: "default" | "elevated" | "glass" | "interactive";
padding?: "none" | "sm" | "md" | "lg";
class?: string;
}
const {
variant = "default",
padding = "md",
class: className = "",
} = Astro.props;
---
<div class:list={[`card card--${variant} card--pad-${padding}`, className]}>
<slot />
</div>
<style is:global>
.card {
--rd-lg: var(--radius, 1rem);
border-radius: var(--rd-lg);
transition:
background-color var(--duration-normal, 300ms) var(--ease-out, ease-out),
color var(--duration-normal, 300ms) var(--ease-out, ease-out),
border-color var(--duration-normal, 300ms) var(--ease-out, ease-out),
box-shadow var(--duration-normal, 300ms) var(--ease-out, ease-out),
transform var(--duration-normal, 300ms) var(--ease-out, ease-out);
}
/* ── Padding variants ──────────────────────────── */
.card--pad-none {
padding: 0;
}
.card--pad-sm {
padding: 0.75rem;
}
.card--pad-md {
padding: 1.5rem;
}
.card--pad-lg {
padding: 2rem;
}
/* ── Variant: default ──────────────────────────── */
.card--default {
background-color: oklch(var(--card));
color: oklch(var(--card-foreground));
border: 1px solid oklch(var(--border));
box-shadow: 0 1px 2px oklch(0 0 0 / 0.05);
}
/* ── Variant: elevated ─────────────────────────── */
.card--elevated {
background-color: oklch(var(--card));
color: oklch(var(--card-foreground));
border: 1px solid oklch(var(--border));
box-shadow:
0 4px 6px -1px oklch(0 0 0 / 0.1),
0 2px 4px -2px oklch(0 0 0 / 0.1);
}
/* ── Variant: glass ────────────────────────────── */
.card--glass {
background-color: oklch(var(--glass-bg));
border: 1px solid oklch(var(--glass-border));
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 24px oklch(var(--glass-shadow));
}
/* ── Variant: interactive ──────────────────────── */
.card--interactive {
background-color: oklch(var(--card));
color: oklch(var(--card-foreground));
border: 1px solid oklch(var(--border));
box-shadow: 0 1px 2px oklch(0 0 0 / 0.05);
cursor: pointer;
}
.card--interactive:hover {
transform: translateY(-4px);
box-shadow:
0 10px 15px -3px oklch(0 0 0 / 0.1),
0 4px 6px -4px oklch(0 0 0 / 0.1);
border-color: oklch(var(--primary) / 0.3);
}
.card--interactive:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: 2px;
}
</style>
@@ -1,97 +0,0 @@
---
export interface Props {
severity: "safe" | "low" | "medium" | "high" | "critical";
class?: string;
}
const { severity, class: className = "" } = Astro.props;
const ICONS: Record<string, string> = {
safe: "&#x2714;",
low: "&#x2197;",
medium: "&#x26A0;",
high: "&#x26A1;",
critical: "&#x2716;",
};
const icon = ICONS[severity] ?? "";
---
<span
class:list={[`severity-badge severity-badge--${severity}`, className]}
data-severity={severity}
role="status"
>
<span class="severity-badge__icon" aria-hidden="true" set:html={icon} />
<span class="severity-badge__label"><slot /></span>
</span>
<style is:global>
.severity-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding-inline: 0.5rem;
padding-block: 0.2rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid transparent;
line-height: 1;
user-select: none;
}
.severity-badge__icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
line-height: 1;
flex-shrink: 0;
}
.severity-badge__label {
text-transform: capitalize;
}
/* ── Severity: safe → success ──────────────────── */
.severity-badge--safe {
background-color: oklch(var(--success) / 0.15);
color: oklch(var(--success));
border-color: oklch(var(--success) / 0.25);
}
/* ── Severity: low → info ──────────────────────── */
.severity-badge--low {
background-color: oklch(var(--info) / 0.15);
color: oklch(var(--info));
border-color: oklch(var(--info) / 0.25);
}
/* ── Severity: medium → warning ────────────────── */
.severity-badge--medium {
background-color: oklch(var(--warning) / 0.15);
color: oklch(var(--warning));
border-color: oklch(var(--warning) / 0.25);
}
/* ── Severity: high → destructive ──────────────── */
.severity-badge--high {
background-color: oklch(var(--destructive) / 0.12);
color: oklch(var(--destructive));
border-color: oklch(var(--destructive) / 0.2);
}
/* ── Severity: critical ────────────────────────── */
.severity-badge--critical {
background-color: oklch(var(--destructive) / 0.25);
color: oklch(var(--destructive));
border-color: oklch(var(--destructive) / 0.4);
font-weight: 600;
}
</style>
@@ -1,74 +0,0 @@
---
export interface Props {
variant?: "text" | "card" | "circle" | "rect";
width?: string;
height?: string;
class?: string;
}
const {
variant = "text",
width,
height,
class: className = "",
} = Astro.props;
const style: Record<string, string> = {};
if (width) style.width = width;
if (height) style.height = height;
---
<div
aria-hidden="true"
role="presentation"
class:list={[`skeleton skeleton--${variant}`, className]}
style={Object.keys(style).length > 0 ? style : undefined}
>
<slot />
</div>
<style is:global>
.skeleton {
background: linear-gradient(
90deg,
oklch(var(--muted)) 25%,
oklch(var(--muted-foreground) / 0.15) 50%,
oklch(var(--muted)) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 0.5rem;
}
/* ── Variant: text (default) ───────────────────── */
.skeleton--text {
width: 100%;
height: 1rem;
border-radius: calc(var(--radius, 1rem) - 4px);
}
/* ── Variant: card ─────────────────────────────── */
.skeleton--card {
width: 100%;
height: 8rem;
border-radius: var(--radius, 1rem);
}
/* ── Variant: circle ───────────────────────────── */
.skeleton--circle {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
}
/* ── Variant: rect ─────────────────────────────── */
.skeleton--rect {
width: 100%;
height: 3rem;
border-radius: calc(var(--radius, 1rem) - 2px);
}
</style>
@@ -1,60 +0,0 @@
---
export interface Props {
size?: "sm" | "default" | "lg";
class?: string;
}
const { size = "default", class: className = "" } = Astro.props;
---
<span
aria-label="Loading"
role="status"
class:list={[`spinner spinner--${size}`, className]}
>
<span class="spinner__ring" aria-hidden="true" />
</span>
<style is:global>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.spinner__ring {
display: block;
border-radius: 50%;
border-style: solid;
border-color: oklch(var(--muted));
border-top-color: oklch(var(--primary));
animation: spin 0.6s linear infinite;
}
/* ── Sizes ─────────────────────────────────────── */
.spinner--sm .spinner__ring {
width: 1rem;
height: 1rem;
border-width: 2px;
}
.spinner--default .spinner__ring {
width: 1.5rem;
height: 1.5rem;
border-width: 2.5px;
}
.spinner--lg .spinner__ring {
width: 2.5rem;
height: 2.5rem;
border-width: 3px;
}
</style>
@@ -1,4 +1,4 @@
import type { GuildVoiceEntry } from "./guild";
import type { GuildVoiceEntry } from "../guild/types";
export interface VoiceStatus {
connected: boolean;
@@ -0,0 +1,295 @@
import { motion } from "framer-motion";
import {
Eye,
EyeOff,
Globe,
Lock,
RefreshCw,
Save,
Settings,
Shield,
} from "lucide-react";
import { useEffect, useState } from "react";
import type { AdminSettings } from "../../shared/api/client";
import {
getAdminSettings,
updateAdminSettings,
clearSessionToken,
logout,
} from "../../shared/api/client";
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../shared/ui";
export function AdminPanel() {
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleLogout = async () => {
// Call server-side logout to increment token version
try {
await logout();
} catch {
// Even if server call fails, still clear local state for security
}
// Clear local token and legacy password
clearSessionToken();
localStorage.removeItem("admin-password");
window.location.reload();
};
const fetchSettings = async () => {
setLoading(true);
setError(null);
try {
const data = await getAdminSettings();
setSettings(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load settings");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSettings();
}, []);
const handleTogglePublic = async () => {
if (!settings) return;
const newValue = !settings.dashboardIsPublic;
setSaving(true);
setError(null);
setSuccess(null);
try {
const updated = await updateAdminSettings({
dashboardIsPublic: newValue,
});
setSettings(updated);
setSuccess(
newValue
? "Dashboard is now public — accessible without password."
: "Dashboard is now private — admin password required.",
);
setTimeout(() => setSuccess(null), 4000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update settings");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-primary">Admin Settings</CardTitle>
<CardDescription>Loading settings...</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
</CardContent>
</Card>
);
}
if (error && !settings) {
return (
<Card>
<CardHeader>
<CardTitle className="text-primary">Admin Settings</CardTitle>
<CardDescription className="text-destructive">{error}</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={fetchSettings} variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" /> Retry
</Button>
</CardContent>
</Card>
);
}
const isPublic = settings?.dashboardIsPublic ?? false;
return (
<motion.div variants={cardStagger} initial="initial" animate="animate">
<motion.div variants={cardItem}>
<Card className="border-primary/20">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-primary">
<Settings className="h-5 w-5" />
Admin Settings
</CardTitle>
<CardDescription>
Manage dashboard visibility and runtime configuration.
</CardDescription>
</div>
<Button
onClick={fetchSettings}
variant="ghost"
size="sm"
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* ── Success / Error messages ── */}
{success && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-600 dark:text-emerald-400">
{success}
</div>
)}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{/* ── Dashboard Visibility ── */}
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
{isPublic ? (
<Globe className="h-4 w-4 text-emerald-500" />
) : (
<Lock className="h-4 w-4 text-amber-500" />
)}
<h3 className="font-semibold">
Dashboard Visibility:{" "}
<span
className={
isPublic ? "text-emerald-500" : "text-amber-500"
}
>
{isPublic ? "Public" : "Private"}
</span>
</h3>
</div>
<p className="text-sm text-muted-foreground">
{isPublic
? "Anyone can view the dashboard without a password. Admin password is still required for management actions."
: "Admin password is required to access any part of the dashboard."}
</p>
</div>
<Button
onClick={handleTogglePublic}
disabled={saving}
variant={isPublic ? "outline" : "default"}
size="sm"
className="shrink-0"
>
{saving ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Saving...
</>
) : isPublic ? (
<>
<Lock className="mr-2 h-4 w-4" />
Make Private
</>
) : (
<>
<Eye className="mr-2 h-4 w-4" />
Make Public
</>
)}
</Button>
</div>
{/* ── Status indicators ── */}
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-lg bg-muted/50 px-3 py-2">
<p className="text-xs text-muted-foreground">Runtime</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span
className={`inline-block h-2 w-2 rounded-full ${
isPublic ? "bg-emerald-400" : "bg-amber-400"
}`}
/>
<span className="text-sm font-medium">
{isPublic ? "Public" : "Private"}
</span>
</div>
</div>
<div className="rounded-lg bg-muted/50 px-3 py-2">
<p className="text-xs text-muted-foreground">
Env Default
</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span
className={`inline-block h-2 w-2 rounded-full ${
settings?.envDashboardIsPublic
? "bg-emerald-400"
: "bg-amber-400"
}`}
/>
<span className="text-sm font-medium">
{settings?.envDashboardIsPublic ? "Public" : "Private"}
</span>
</div>
</div>
</div>
</div>
{/* ── Logout ── */}
<div className="flex justify-end">
<Button
onClick={handleLogout}
variant="outline"
size="sm"
className="text-muted-foreground"
>
<Lock className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
{/* ── Info card ── */}
<div className="rounded-xl border border-border/50 bg-muted/30 p-4">
<div className="flex items-start gap-3">
<Shield className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="space-y-1 text-xs text-muted-foreground">
<p>
<strong>Admin password</strong> is configured via the
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
ADMIN_PASSWORD
</code>
environment variable. For security, it cannot be changed
through this panel — update it in your deployment
configuration and restart the service.
</p>
<p className="mt-2">
Runtime settings are persisted across restarts in the
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
data/settings.json
</code>
file. Changes take effect immediately, no restart needed.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
);
}
@@ -1,7 +1,7 @@
import { motion } from "framer-motion";
import { Lock, RefreshCw, Shield, Unlock, WifiOff } from "lucide-react";
import { useCallback, useState } from "react";
import { login, setSessionToken } from "../../api/client.js";
import { Lock, Unlock, Shield, WifiOff, RefreshCw } from "lucide-react";
import { useState, useCallback } from "react";
import { login, setSessionToken } from "../../shared/api/client.js";
import {
Button,
Card,
@@ -10,7 +10,7 @@ import {
CardHeader,
CardTitle,
Input,
} from "../index";
} from "../../shared/ui";
interface AuthOverlayProps {
onAuthenticated: () => void;
@@ -153,8 +153,7 @@ export function AuthOverlay({
<p className="mt-4 text-xs text-center text-muted-foreground">
<Unlock className="inline h-3 w-3 mr-1" />
The dashboard is in public mode — most data is visible without
authentication. Admin password is only needed for management
actions.
authentication. Admin password is only needed for management actions.
</p>
)}
</CardContent>
@@ -0,0 +1,56 @@
import { Hash } from "lucide-react";
import type { DashboardChannelDetail } from "../../../entities/dashboard/types.js";
import { ProfileDetail } from "../../../shared/ui";
interface ChannelProfileDetailProps {
detail: DashboardChannelDetail | null;
loading: boolean;
error: string | null;
onBack: () => void;
onRefetch: () => void;
}
export function ChannelProfileDetail({
detail,
loading,
error,
onBack,
onRefetch,
}: ChannelProfileDetailProps) {
if (!detail && !loading && !error) return null;
return (
<ProfileDetail
loading={loading}
error={error}
onRetry={onRefetch}
onBack={onBack}
icon={<Hash className="h-8 w-8" />}
title={detail ? `#${detail.channel_name ?? detail.channel_id}` : ""}
subtitle={detail?.channel_id}
summaryLabel="AI Channel Summary"
summaryText={detail?.culture_summary ?? undefined}
lastAnalyzedLabel={
detail?.last_analyzed_at
? `Last analyzed: ${new Date(detail.last_analyzed_at).toLocaleString()}`
: undefined
}
stats={{
totalLabel: "Total Messages",
totalValue: detail?.total_messages ?? 0,
cleanLabel: "Clean",
cleanValue: detail?.clean_count ?? 0,
flaggedLabel: "Flagged",
flaggedValue: detail?.flagged_count ?? 0,
}}
messages={
detail?.recent_messages.map((msg) => ({
id: msg.id,
content: msg.content,
created_at: new Date(msg.created_at).toISOString(),
ai_status: msg.ai_status,
})) ?? []
}
/>
);
}
@@ -0,0 +1,56 @@
import { Hash } from "lucide-react";
import type { DashboardChannel } from "../../../entities/dashboard/types.js";
import type { SummaryItem } from "../../../shared/ui";
import { SummaryList } from "../../../shared/ui";
interface ChannelSummaryListProps {
channels: DashboardChannel[];
loading: boolean;
error: string | null;
search: string;
onSearchChange: (value: string) => void;
onLoadMore: () => void;
hasMore: boolean;
onRefetch: () => void;
onSelectChannel: (channelId: string) => void;
}
export function ChannelSummaryList({
channels,
loading,
error,
search,
onSearchChange,
onLoadMore,
hasMore,
onRefetch,
onSelectChannel,
}: ChannelSummaryListProps) {
const items: SummaryItem[] = channels.map((ch) => ({
id: ch.channel_id,
label: `#${ch.channel_name ?? ch.channel_id}`,
subtitle: ch.flagged_count > 0 ? `${ch.flagged_count} flagged` : undefined,
summaryText: ch.culture_summary ?? `${ch.total_messages} messages`,
onClick: () => onSelectChannel(ch.channel_id),
}));
return (
<SummaryList
items={items}
loading={loading}
error={error}
searchValue={search}
onSearchChange={onSearchChange}
onRetry={onRefetch}
hasMore={hasMore}
onLoadMore={onLoadMore}
loadingMore={loading}
renderIcon={() => (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<Hash className="h-5 w-5 text-muted-foreground" />
</div>
)}
emptyMessage="No channels found."
/>
);
}
@@ -0,0 +1,246 @@
import { motion } from "framer-motion";
import {
AlertCircle,
BarChart3,
MessageSquare,
Mic,
RefreshCw,
ShieldAlert,
UserCheck,
Users,
} from "lucide-react";
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
import { useUIState } from "../../../shared/hooks/useUIState.js";
import { cn } from "../../../shared/lib/utils";
import {
Card,
CardContent,
CardHeader,
CardTitle,
Skeleton,
StatusBadge,
} from "../../../shared/ui";
import { useDashboardStats } from "../hooks/useDashboard";
export function DashboardStatsContent() {
const { stats, loading, error, refetch } = useDashboardStats();
const { patchUIState } = useUIState();
if (loading) {
return <StatsSkeleton />;
}
if (error) {
return (
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
<AlertCircle className="h-10 w-10 text-destructive" />
<p className="text-sm">{error}</p>
<button
onClick={() => refetch()}
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
>
<RefreshCw className="h-4 w-4" /> Retry
</button>
</div>
);
}
if (!stats) {
return (
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
<BarChart3 className="h-10 w-10" />
<p className="text-sm">No data available yet.</p>
</div>
);
}
const cards = [
{
title: "Total Messages",
value: stats.total_messages.toLocaleString(),
icon: MessageSquare,
color: "text-primary",
bg: "bg-primary/10",
},
{
title: "Today's Messages",
value: stats.today_messages.toLocaleString(),
icon: MessageSquare,
color: "text-emerald-500",
bg: "bg-emerald-100",
},
{
title: "Total Users",
value: stats.total_users.toLocaleString(),
icon: Users,
color: "text-blue-500",
bg: "bg-blue-100",
},
{
title: "Active Users (24h)",
value: stats.active_users_24h.toLocaleString(),
icon: UserCheck,
color: "text-violet-500",
bg: "bg-violet-100",
},
{
title: "Flagged",
value: stats.total_flagged.toLocaleString(),
icon: ShieldAlert,
color: "text-destructive",
bg: "bg-destructive/10",
},
{
title: "Clean",
value: stats.total_clean.toLocaleString(),
icon: ShieldAlert,
color: "text-emerald-600",
bg: "bg-emerald-100",
},
{
title: "Voice Recordings",
value: stats.total_voice_recordings.toLocaleString(),
icon: Mic,
color: "text-cyan-500",
bg: "bg-cyan-100",
onClick: () => patchUIState({ activeTab: "live" }),
},
{
title: "AI Profiles",
value: stats.total_profiles.toLocaleString(),
icon: Users,
color: "text-amber-500",
bg: "bg-amber-100",
},
];
return (
<motion.div
className="grid gap-6"
variants={cardStagger}
initial="initial"
animate="animate"
>
{/* Summary cards grid */}
<motion.div
variants={cardItem}
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
{cards.map((card) => (
<Card
key={card.title}
className={cn(
"overflow-hidden",
card.onClick &&
"cursor-pointer transition-colors hover:bg-accent/50",
)}
onClick={card.onClick}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
{card.title}
</p>
<p className="text-2xl font-bold tracking-tight">
{card.value}
</p>
</div>
<div className={cn("rounded-xl p-2.5", card.bg)}>
<card.icon className={cn("h-5 w-5", card.color)} />
</div>
</div>
</CardContent>
</Card>
))}
</motion.div>
{/* Top channels */}
<motion.div variants={cardItem}>
<Card>
<CardHeader>
<CardTitle className="text-primary">Top Channels</CardTitle>
</CardHeader>
<CardContent>
{stats.top_channels.length === 0 ? (
<p className="text-sm text-muted-foreground">
No channel data yet.
</p>
) : (
<div className="space-y-2">
{stats.top_channels.map((ch) => (
<div
key={ch.channel_id}
className="flex items-center justify-between rounded-lg bg-muted/50 px-3 py-2 text-sm"
>
<span className="truncate text-xs text-muted-foreground">
#{ch.channel_name ?? ch.channel_id}
</span>
<span className="ml-2 shrink-0 font-medium">
{ch.message_count.toLocaleString()}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* Moderation overview */}
<motion.div variants={cardItem}>
<Card>
<CardHeader>
<CardTitle className="text-primary">Moderation Queue</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
<StatusBadge status="pending" />
<StatusBadge status="processing" />
<StatusBadge status="error" />
</div>
<div className="grid gap-4 sm:grid-cols-3 mt-4">
<div className="rounded-xl border border-border bg-card p-4 text-center">
<p className="text-2xl font-bold text-muted-foreground">
{stats.moderation_overview.pending}
</p>
<p className="text-xs text-muted-foreground mt-1">Pending</p>
</div>
<div className="rounded-xl border border-border bg-card p-4 text-center">
<p className="text-2xl font-bold text-amber-500">
{stats.moderation_overview.processing}
</p>
<p className="text-xs text-muted-foreground mt-1">Processing</p>
</div>
<div className="rounded-xl border border-border bg-card p-4 text-center">
<p className="text-2xl font-bold text-destructive">
{stats.moderation_overview.error}
</p>
<p className="text-xs text-muted-foreground mt-1">Errors</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
);
}
function StatsSkeleton() {
return (
<div className="grid gap-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-7 w-16" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import { User } from "lucide-react";
import type { DashboardUserDetail } from "../../../entities/dashboard/types.js";
import { ProfileDetail } from "../../../shared/ui";
interface UserProfileDetailProps {
detail: DashboardUserDetail | null;
loading: boolean;
error: string | null;
onBack: () => void;
onRefetch: () => void;
}
export function UserProfileDetail({
detail,
loading,
error,
onBack,
onRefetch,
}: UserProfileDetailProps) {
if (!detail && !loading && !error) return null;
const icon = detail?.avatar_url ? (
<img
src={detail.avatar_url}
alt=""
className="h-16 w-16 rounded-full object-cover ring-2 ring-border"
/>
) : (
<User className="h-8 w-8" />
);
return (
<ProfileDetail
loading={loading}
error={error}
onRetry={onRefetch}
onBack={onBack}
icon={icon}
title={detail?.username ?? detail?.user_id ?? ""}
subtitle={detail?.user_id}
summaryLabel="AI Profile Summary"
summaryText={detail?.profile_summary ?? undefined}
lastAnalyzedLabel={
detail?.last_analyzed_at
? `Last analyzed: ${new Date(detail.last_analyzed_at).toLocaleString()}`
: undefined
}
stats={{
totalLabel: "Total Messages",
totalValue: detail?.total_messages ?? 0,
cleanLabel: "Clean",
cleanValue: detail?.clean_count ?? 0,
flaggedLabel: "Flagged",
flaggedValue: detail?.flagged_count ?? 0,
}}
messages={
detail?.recent_messages.map((msg) => ({
id: msg.id,
content: msg.content,
created_at: new Date(msg.created_at).toISOString(),
ai_status: msg.ai_status,
})) ?? []
}
/>
);
}
@@ -0,0 +1,67 @@
import { User } from "lucide-react";
import type { DashboardUser } from "../../../entities/dashboard/types.js";
import type { SummaryItem } from "../../../shared/ui";
import { SummaryList } from "../../../shared/ui";
interface UserSummaryListProps {
users: DashboardUser[];
loading: boolean;
error: string | null;
search: string;
onSearchChange: (value: string) => void;
onLoadMore: () => void;
hasMore: boolean;
onRefetch: () => void;
onSelectUser: (userId: string) => void;
}
export function UserSummaryList({
users,
loading,
error,
search,
onSearchChange,
onLoadMore,
hasMore,
onRefetch,
onSelectUser,
}: UserSummaryListProps) {
const items: SummaryItem[] = users.map((u) => ({
id: u.user_id,
label: u.username ?? u.user_id,
subtitle: u.trust_score !== null ? `Trust: ${u.trust_score}` : undefined,
summaryText: u.profile_summary ?? `${u.total_messages} messages`,
onClick: () => onSelectUser(u.user_id),
}));
const avatarMap = new Map(users.map((u) => [u.user_id, u.avatar_url]));
return (
<SummaryList
items={items}
loading={loading}
error={error}
searchValue={search}
onSearchChange={onSearchChange}
onRetry={onRefetch}
hasMore={hasMore}
onLoadMore={onLoadMore}
loadingMore={loading}
renderIcon={(item) => {
const avatarUrl = avatarMap.get(item.id);
return avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<User className="h-5 w-5 text-muted-foreground" />
</div>
);
}}
emptyMessage="No users found."
/>
);
}
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useState } from "react";
import type { DashboardStats } from "../../../entities/dashboard/types.js";
import {
getDashboardChannelDetail,
getDashboardStats,
getDashboardUserDetail,
listDashboardChannels,
listDashboardUsers,
} from "../../../shared/api/client.js";
import { useItemDetail } from "../../../shared/hooks/useItemDetail";
import { usePaginatedList } from "../../../shared/hooks/usePaginatedList";
import { createLogger } from "../../../shared/lib/logger.js";
const logger = createLogger("use-dashboard");
/**
* Fetch dashboard aggregate stats.
*/
export function useDashboardStats() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetch = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getDashboardStats();
setStats(data);
} catch (e) {
const msg = e instanceof Error ? e.message : "Failed to load stats";
setError(msg);
logger.error("[useDashboardStats]", { error: msg });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetch().catch(() => undefined);
}, [fetch]);
return { stats, loading, error, refetch: fetch };
}
/**
* Fetch paginated user list with optional search.
*/
export function useDashboardUsers() {
const paginated = usePaginatedList(
(params) =>
listDashboardUsers({
limit: params.limit,
search: params.search,
cursor: params.cursor,
}).then((r) => ({ data: r.data, nextCursor: r.nextCursor })),
"",
);
return {
users: paginated.data,
loading: paginated.loading,
error: paginated.error,
search: paginated.search,
setSearch: paginated.setSearch,
loadMore: paginated.loadMore,
hasMore: paginated.hasMore,
refetch: paginated.refetch,
};
}
/**
* Fetch a single user detail by userId.
*/
export function useDashboardUserDetail(userId: string | null) {
const { data, loading, error, refetch } = useItemDetail(
(_guildId, entityId) => getDashboardUserDetail(entityId),
"",
userId,
"user",
);
return { detail: data, loading, error, refetch };
}
/**
* Fetch paginated channel list with optional search.
*/
export function useDashboardChannels() {
const paginated = usePaginatedList(
(params) =>
listDashboardChannels({
limit: params.limit,
search: params.search,
guild_id: params.guildId,
cursor: params.cursor,
}).then((r) => ({ data: r.data, nextCursor: r.nextCursor })),
"",
);
return {
channels: paginated.data,
loading: paginated.loading,
error: paginated.error,
search: paginated.search,
setSearch: paginated.setSearch,
loadMore: paginated.loadMore,
hasMore: paginated.hasMore,
refetch: paginated.refetch,
};
}
/**
* Fetch a single channel detail by channelId.
*/
export function useDashboardChannelDetail(channelId: string | null) {
const { data, loading, error, refetch } = useItemDetail(
(_guildId, entityId) => getDashboardChannelDetail(entityId),
"",
channelId,
"channel",
);
return { detail: data, loading, error, refetch };
}
@@ -0,0 +1,135 @@
import { Settings } from "lucide-react";
import { useState } from "react";
import { AdminPanel } from "../../features/admin/AdminPanel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui";
import { ChannelProfileDetail } from "./components/ChannelProfileDetail";
import { ChannelSummaryList } from "./components/ChannelSummaryList";
import { DashboardStatsContent } from "./components/DashboardStats";
import { UserProfileDetail } from "./components/UserProfileDetail";
import { UserSummaryList } from "./components/UserSummaryList";
import {
useDashboardChannelDetail,
useDashboardChannels,
useDashboardUserDetail,
useDashboardUsers,
} from "./hooks/useDashboard";
export function DashboardPanel() {
const [activeTab, setActiveTab] = useState("stats");
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(
null,
);
const {
users,
loading: usersLoading,
error: usersError,
search: userSearch,
setSearch: setUserSearch,
loadMore: loadMoreUsers,
hasMore: hasMoreUsers,
refetch: refetchUsers,
} = useDashboardUsers();
const {
detail: userDetail,
loading: userDetailLoading,
error: userDetailError,
refetch: refetchUserDetail,
} = useDashboardUserDetail(selectedUserId);
const {
channels,
loading: channelsLoading,
error: channelsError,
search: channelSearch,
setSearch: setChannelSearch,
loadMore: loadMoreChannels,
hasMore: hasMoreChannels,
refetch: refetchChannels,
} = useDashboardChannels();
const {
detail: channelDetail,
loading: channelDetailLoading,
error: channelDetailError,
refetch: refetchChannelDetail,
} = useDashboardChannelDetail(selectedChannelId);
// Show user detail view
if (selectedUserId) {
return (
<UserProfileDetail
detail={userDetail}
loading={userDetailLoading}
error={userDetailError}
onBack={() => {
setSelectedUserId(null);
}}
onRefetch={refetchUserDetail}
/>
);
}
// Show channel detail view
if (selectedChannelId) {
return (
<ChannelProfileDetail
detail={channelDetail}
loading={channelDetailLoading}
error={channelDetailError}
onBack={() => {
setSelectedChannelId(null);
}}
onRefetch={refetchChannelDetail}
/>
);
}
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="stats">Stats</TabsTrigger>
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="channels">Channels</TabsTrigger>
<TabsTrigger value="admin" className="flex items-center gap-1.5">
<Settings className="h-3.5 w-3.5" />
Admin
</TabsTrigger>
</TabsList>
<TabsContent value="stats">
<DashboardStatsContent />
</TabsContent>
<TabsContent value="users">
<UserSummaryList
users={users}
loading={usersLoading}
error={usersError}
search={userSearch}
onSearchChange={setUserSearch}
onLoadMore={loadMoreUsers}
hasMore={hasMoreUsers}
onRefetch={refetchUsers}
onSelectUser={setSelectedUserId}
/>
</TabsContent>
<TabsContent value="channels">
<ChannelSummaryList
channels={channels}
loading={channelsLoading}
error={channelsError}
search={channelSearch}
onSearchChange={setChannelSearch}
onLoadMore={loadMoreChannels}
hasMore={hasMoreChannels}
onRefetch={refetchChannels}
onSelectChannel={setSelectedChannelId}
/>
</TabsContent>
<TabsContent value="admin">
<AdminPanel />
</TabsContent>
</Tabs>
);
}
@@ -0,0 +1,49 @@
import type { ActiveSpeaker } from "../../../entities/voice/types.js";
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
interface ActiveSpeakersProps {
speakers: ActiveSpeaker[];
}
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
if (speakers.length === 0) {
return <EmptyStateMascot />;
}
return (
<div className="space-y-2">
{speakers.map((s) => {
const key = s.userId ?? s.id ?? `speaker-${s.username}`;
return (
<div
key={key}
className="flex items-center gap-3 rounded-xl border border-border bg-card p-3"
>
<img
src={s.avatar}
alt=""
className="h-8 w-8 rounded-full object-cover ring-2 ring-primary/30"
/>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{s.username}</div>
<div className="flex items-center gap-1.5">
<span
className={`inline-block h-2 w-2 rounded-full ${
s.speaking ? "bg-emerald-500" : "bg-muted-foreground/40"
}`}
/>
<span
className={`text-xs font-medium ${
s.speaking ? "text-emerald-600" : "text-muted-foreground"
}`}
>
{s.speaking ? "Speaking" : "Silent"}
</span>
</div>
</div>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,79 @@
import { useEffect, useRef } from "react";
interface AudioVisualizerProps {
levels: number[];
}
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ro = new ResizeObserver(() => {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = 128 * dpr;
canvas.style.height = "128px";
});
ro.observe(container);
return () => ro.disconnect();
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const width = canvas.width / dpr;
const height = canvas.height / dpr;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const barWidth = width / levels.length;
const maxBarHeight = height * 0.85;
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, "#23a1eb");
gradient.addColorStop(1, "#3eb0f2");
for (let i = 0; i < levels.length; i++) {
const level = levels[i];
const barHeight = Math.min(maxBarHeight, level * maxBarHeight);
const x = i * barWidth;
const y = height - barHeight;
ctx.fillStyle = gradient;
const radius = barWidth * 0.4;
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + barWidth - radius, y);
ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + radius);
ctx.lineTo(x + barWidth, height);
ctx.lineTo(x, height);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.fill();
}
}, [levels]);
return (
<div ref={containerRef} className="relative w-full">
<canvas
ref={canvasRef}
width={0}
height={0}
className="w-full rounded-lg bg-primary/5"
style={{ height: "128px" }}
/>
</div>
);
}
@@ -0,0 +1,29 @@
// ─── Mic level meter — vertical bar showing outgoing audio RMS level ─────────
interface MicLevelMeterProps {
level: number; // 0-1
}
export function MicLevelMeter({ level }: MicLevelMeterProps) {
const pct = Math.round(level * 100);
// Color gradient: green <-> yellow <-> red
const hue = 120 - level * 120; // 120 (green) -> 0 (red)
const bg = `hsl(${hue}, 80%, 45%)`;
return (
<div
className="relative flex h-6 w-24 overflow-hidden rounded-full bg-muted"
role="meter"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Microphone level"
>
<div
className="h-full rounded-full transition-[width,background-color] duration-75 ease-linear"
style={{ width: `${pct}%`, backgroundColor: bg }}
/>
</div>
);
}
@@ -0,0 +1,116 @@
import { Music2, SkipForward, Square, Volume2, VolumeX } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Input } from "../../../shared/ui";
interface MusicSubPanelProps {
volume: number;
onVolumeChange: (v: number) => void;
onQueue: (source: string) => void;
onSkip: () => void;
onStop: () => void;
loading: boolean;
}
export function MusicSubPanel({
volume,
onVolumeChange,
onQueue,
onSkip,
onStop,
loading,
}: MusicSubPanelProps) {
const [source, setSource] = useState("");
const safeVolume = Number.isFinite(volume)
? Math.max(0, Math.min(1, volume))
: 1;
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
const [muted, setMuted] = useState(false);
const prevVolumeRef = useRef(safeVolume);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Proper debounce: setTimeout instead of setInterval polling
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const normalized = draftVolume / 100;
if (Math.abs(normalized - safeVolume) >= 0.001)
onVolumeChange(normalized);
}, 200);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [draftVolume, safeVolume, onVolumeChange]);
const handleMute = useCallback(() => {
if (muted) {
// Unmute: restore previous volume
const restore = prevVolumeRef.current;
setDraftVolume(Math.round(restore * 100));
onVolumeChange(restore);
setMuted(false);
} else {
// Mute: save current, set to 0
prevVolumeRef.current = safeVolume;
setDraftVolume(0);
onVolumeChange(0);
setMuted(true);
}
}, [muted, safeVolume, onVolumeChange]);
const submit = () => {
const t = source.trim();
if (!t) return;
onQueue(t);
setSource("");
};
return (
<div className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-4">
<Input
value={source}
onChange={(e) => setSource(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="YouTube URL, Spotify track, or search terms"
/>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleMute}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
{muted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</button>
<input
type="range"
min={0}
max={100}
step={1}
value={draftVolume}
onChange={(e) => {
setDraftVolume(Number(e.target.value));
if (muted) setMuted(false);
}}
className="h-2 w-full cursor-pointer accent-primary"
/>
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
{draftVolume}%
</span>
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={loading || !source.trim()} onClick={submit}>
<Music2 className="mr-1.5 h-4 w-4" /> Queue
</Button>
<Button variant="secondary" disabled={loading} onClick={onSkip}>
<SkipForward className="mr-1.5 h-4 w-4" /> Skip
</Button>
<Button variant="destructive" disabled={loading} onClick={onStop}>
<Square className="mr-1.5 h-4 w-4" /> Stop
</Button>
</div>
</div>
);
}
@@ -0,0 +1,61 @@
import { MonitorUp, Music2 } from "lucide-react";
import type { MediaItem } from "../../../entities/media/types.js";
import { Badge } from "../../../shared/ui";
interface NowPlayingProps {
current: MediaItem | null;
queue: MediaItem[];
}
export function NowPlaying({ current, queue }: NowPlayingProps) {
if (!current) return null;
return (
<div className="rounded-xl border border-border bg-card shadow-sm">
<div className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
{current.mode === "screen" ? (
<MonitorUp className="h-5 w-5" />
) : (
<Music2 className="h-5 w-5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{current.title}</div>
<div className="truncate text-xs text-muted-foreground">
{current.source}
</div>
</div>
<Badge variant={current.mode === "screen" ? "warning" : "success"}>
{current.mode ?? "music"}
</Badge>
</div>
</div>
{queue.length > 0 && (
<div className="border-t border-border p-4">
<div className="mb-2 text-sm font-medium">Queue ({queue.length})</div>
<div className="space-y-1.5">
{queue.map((item, i) => (
<div
key={`${item.source}-${i}`}
className="flex items-center gap-3 rounded-lg border-l-2 border-l-primary border-border bg-card p-2.5 text-sm"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{i + 1}
</span>
<div className="min-w-0">
<div className="truncate font-medium">{item.title}</div>
<div className="truncate text-xs text-muted-foreground">
{item.source}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,207 @@
// ─── Recordings Sub-Panel ──
import { Download, Mic, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { VoiceRecording } from "../../../entities/recording/types.js";
import { deleteRecording, listRecordings } from "../../../shared/api/client";
import { formatBytes, formatDate } from "../../../shared/lib/utils";
import { Badge, Button, Skeleton } from "../../../shared/ui";
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
import { WaveformPlayer } from "./WaveformPlayer";
export function RecordingsSubPanel() {
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const loadRecordings = useCallback(
async (opts?: { signal?: AbortSignal }) => {
try {
setLoading(true);
setError(null);
const data = await listRecordings({ limit: 50 });
if (!opts?.signal?.aborted) {
setRecordings(data.items);
setNextCursor(data.nextCursor);
setHasMore(data.hasMore);
}
} catch (err) {
if (!opts?.signal?.aborted)
setError(err instanceof Error ? err.message : String(err));
} finally {
if (!opts?.signal?.aborted) setLoading(false);
}
},
[],
);
const loadMore = useCallback(async () => {
if (!nextCursor || loadingMore) return;
try {
setLoadingMore(true);
const data = await listRecordings({ limit: 50, cursor: nextCursor });
setRecordings((prev) => [...prev, ...data.items]);
setNextCursor(data.nextCursor);
setHasMore(data.hasMore);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoadingMore(false);
}
}, [nextCursor, loadingMore]);
useEffect(() => {
const ab = new AbortController();
loadRecordings({ signal: ab.signal });
const handler = () => loadRecordings();
window.addEventListener("voice_recording_uploaded", handler);
return () => {
ab.abort();
window.removeEventListener("voice_recording_uploaded", handler);
};
}, [loadRecordings]);
const handleDelete = useCallback(async (id: string) => {
if (!confirm("Delete this recording?")) return;
setDeletingIds((prev) => new Set(prev).add(id));
try {
await deleteRecording(id);
setRecordings((prev) => prev.filter((r) => r.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setDeletingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
}, []);
if (loading) {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex items-center gap-4 rounded-xl border border-border bg-card p-4"
>
<Skeleton className="h-10 w-10 rounded-xl" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-64" />
</div>
</div>
))}
</div>
);
}
if (error) {
return (
<div className="rounded-xl border border-dashed border-destructive p-6 text-center text-sm text-destructive">
{error}
<div className="mt-2">
<Button size="sm" variant="outline" onClick={() => loadRecordings()}>
Retry
</Button>
</div>
</div>
);
}
if (recordings.length === 0) {
return <EmptyStateMascot />;
}
return (
<div className="space-y-3">
{recordings.map((rec) => (
<div key={rec.id} className="rounded-xl border border-border bg-card">
<div className="flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Mic className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{rec.filename}</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<span>{rec.username}</span>
<span>·</span>
<span>{rec.channel_name ?? rec.channel_id ?? "unknown"}</span>
<span>·</span>
<span>{formatDate(rec.created_at)}</span>
<span>·</span>
<span>{formatBytes(rec.size_bytes)}</span>
</div>
{rec.upload_error && (
<div className="mt-1 text-xs text-destructive">
{rec.upload_error}
</div>
)}
{rec.transcription && (
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground italic">
{rec.transcription}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant="ghost"
disabled={deletingIds.has(rec.id)}
onClick={() => handleDelete(rec.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
<Badge
variant={
rec.upload_status === "uploaded"
? "success"
: rec.upload_status === "failed"
? "destructive"
: "secondary"
}
>
{rec.upload_status}
</Badge>
{rec.download_url && (
<a
href={rec.download_url}
download={rec.filename}
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Download className="h-4 w-4" />
</a>
)}
</div>
</div>
{rec.download_url && (
<div className="-mt-2 px-4 pb-4">
<WaveformPlayer
downloadUrl={rec.download_url}
filename={rec.filename}
/>
</div>
)}
</div>
))}
{hasMore && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
disabled={loadingMore}
onClick={loadMore}
>
{loadingMore ? "Loading..." : "Load More"}
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,47 @@
import { MonitorUp, SkipForward, Square } from "lucide-react";
import { useState } from "react";
import { Button, Input } from "../../../shared/ui";
interface ScreenSubPanelProps {
onStart: (source: string) => void;
onSkip: () => void;
onStop: () => void;
loading: boolean;
}
export function ScreenSubPanel({
onStart,
onSkip,
onStop,
loading,
}: ScreenSubPanelProps) {
const [source, setSource] = useState("");
const submit = () => {
const t = source.trim();
if (!t) return;
onStart(t);
setSource("");
};
return (
<div className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-4">
<Input
value={source}
onChange={(e) => setSource(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="Screen share URL or local file path"
/>
<div className="flex flex-wrap gap-2">
<Button disabled={loading || !source.trim()} onClick={submit}>
<MonitorUp className="mr-1.5 h-4 w-4" /> Start
</Button>
<Button variant="secondary" disabled={loading} onClick={onSkip}>
<SkipForward className="mr-1.5 h-4 w-4" /> Skip
</Button>
<Button variant="destructive" disabled={loading} onClick={onStop}>
<Square className="mr-1.5 h-4 w-4" /> Stop
</Button>
</div>
</div>
);
}
@@ -0,0 +1,119 @@
import { Headphones, Radio } from "lucide-react";
import type { Channel, Guild } from "../../../entities/guild/types.js";
import type { VoiceStatus } from "../../../entities/voice/types.js";
import { Button, Select } from "../../../shared/ui";
import { MicLevelMeter } from "./MicLevelMeter";
interface VoiceConnectionCardProps {
guilds: Guild[];
voiceChannels: Channel[];
selectedGuild: string;
selectedChannel: string;
status: VoiceStatus;
voiceLoading: boolean;
isListening: boolean;
isStreaming: boolean;
micLevel: number;
onGuildChange: (id: string) => void;
onChannelChange: (id: string) => void;
onJoin: () => void;
onDisconnect: () => void;
onListenToggle: () => void;
onStreamingToggle: () => void;
}
export function VoiceConnectionCard({
guilds,
voiceChannels,
selectedGuild,
selectedChannel,
status,
voiceLoading,
isListening,
isStreaming,
micLevel,
onGuildChange,
onChannelChange,
onJoin,
onDisconnect,
onListenToggle,
onStreamingToggle,
}: VoiceConnectionCardProps) {
return (
<div className="rounded-xl border border-border bg-card shadow-sm">
<div className="p-6">
<h3 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
<Radio className="h-5 w-5 text-primary" /> Voice Bridge
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Join a Discord voice channel, listen, and transmit audio.
</p>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Guild</label>
<Select
value={selectedGuild}
onChange={(e) => onGuildChange(e.target.value)}
placeholder="Select guild"
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Voice Channel
</label>
<Select
value={selectedChannel}
onChange={(e) => onChannelChange(e.target.value)}
placeholder="Select voice channel"
options={voiceChannels.map((c) => ({
value: c.id,
label: c.name,
}))}
/>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
disabled={!selectedGuild || !selectedChannel || voiceLoading}
onClick={onJoin}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{status.connected ? "Reconnect" : "Join Voice"}
</Button>
<Button
variant="destructive"
disabled={!status.connected || voiceLoading}
onClick={onDisconnect}
>
Disconnect
</Button>
<Button
variant={isListening ? "secondary" : "outline"}
onClick={onListenToggle}
>
<Headphones className="mr-1.5 h-4 w-4" />{" "}
{isListening ? "Stop Listening" : "Listen"}
</Button>
<Button
variant={isStreaming ? "secondary" : "outline"}
onClick={onStreamingToggle}
>
<Radio className="mr-1.5 h-4 w-4" />{" "}
{isStreaming ? "Stop Transmit" : "Transmit"}
</Button>
{isStreaming && (
<div className="flex items-center gap-2 pl-1">
<span className="animate-pulse rounded-full bg-emerald-500 px-2 py-0.5 text-xs font-medium text-white">
Hold Space
</span>
<MicLevelMeter level={micLevel} />
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,256 @@
// ─── Waveform Player — audio visualizer with seekable waveform bars ──────────
import { Pause, Play } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createLogger } from "../../../shared/lib/logger";
const logger = createLogger("waveform-player");
const BAR_COUNT = 64;
const SAMPLE_RATE = 24000;
interface WaveformPlayerProps {
downloadUrl: string;
filename: string;
}
export function WaveformPlayer({ downloadUrl, filename }: WaveformPlayerProps) {
const [playing, setPlaying] = useState(false);
const [peaks, setPeaks] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
const startTimeRef = useRef(0);
const startOffsetRef = useRef(0);
const rafRef = useRef<number>(0);
const decodedRef = useRef<AudioBuffer | null>(null);
const durationRef = useRef(0);
// Decode audio on mount
useEffect(() => {
let cancelled = false;
const ctx = new AudioContext();
audioContextRef.current = ctx;
fetch(downloadUrl)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then((buf) => ctx.decodeAudioData(buf))
.then((audioBuffer) => {
if (cancelled) return;
decodedRef.current = audioBuffer;
durationRef.current = audioBuffer.duration;
// Compute waveform peaks
const channel = audioBuffer.getChannelData(0);
const samplesPerBar = Math.floor(channel.length / BAR_COUNT);
const peakValues: number[] = [];
for (let i = 0; i < BAR_COUNT; i++) {
let max = 0;
const start = i * samplesPerBar;
const end = Math.min(start + samplesPerBar, channel.length);
for (let j = start; j < end; j++) {
const abs = Math.abs(channel[j]);
if (abs > max) max = abs;
}
// Clamp so silent sections still show a tiny bar
peakValues.push(Math.max(0.01, max));
}
setPeaks(peakValues);
setLoading(false);
})
.catch((err) => {
if (cancelled) return;
const msg = err instanceof Error ? err.message : String(err);
logger.error("Failed to decode audio", { error: msg });
setError(msg);
setLoading(false);
});
return () => {
cancelled = true;
ctx.close();
};
}, [downloadUrl]);
// Draw waveform on canvas whenever peaks change or while playing
const drawWaveform = useCallback(
(progress = 0) => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = 64 * dpr;
canvas.style.height = "64px";
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, 64);
if (peaks.length === 0) return;
const barWidth = rect.width / peaks.length;
const barGap = Math.max(1, barWidth * 0.15);
const barActualWidth = barWidth - barGap;
const progressPixel = rect.width * progress;
for (let i = 0; i < peaks.length; i++) {
const x = i * barWidth;
const height = Math.max(2, peaks[i] * 50);
const y = 32 - height / 2;
// Color: played vs unplayed
const isPlayed = x + barWidth <= progressPixel;
ctx.fillStyle = isPlayed ? "#23a1eb" : "#334155";
ctx.fillRect(x, y, barActualWidth, height);
}
},
[peaks],
);
// Initial draw when peaks change
useEffect(() => {
drawWaveform();
}, [drawWaveform]);
// Animation loop while playing
useEffect(() => {
if (!playing || !decodedRef.current) return;
const tick = () => {
if (!audioContextRef.current) return;
const elapsed =
audioContextRef.current.currentTime - startTimeRef.current;
const progress = (elapsed + startOffsetRef.current) / durationRef.current;
drawWaveform(Math.min(1, Math.max(0, progress)));
if (progress >= 1) {
setPlaying(false);
return;
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [playing, drawWaveform]);
const handleTogglePlay = useCallback(() => {
const ctx = audioContextRef.current;
const buffer = decodedRef.current;
if (!ctx || !buffer) return;
if (playing) {
// Pause
if (sourceRef.current) {
startOffsetRef.current += ctx.currentTime - startTimeRef.current;
sourceRef.current.stop();
sourceRef.current.disconnect();
sourceRef.current = null;
}
setPlaying(false);
return;
}
// Resume / start
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.start(0, startOffsetRef.current);
startTimeRef.current = ctx.currentTime;
sourceRef.current = source;
setPlaying(true);
source.onended = () => {
if (sourceRef.current === source) {
setPlaying(false);
sourceRef.current = null;
}
};
}, [playing]);
const handleSeek = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!decodedRef.current) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const progress = Math.max(0, Math.min(1, x / rect.width));
const offset = progress * durationRef.current;
const ctx = audioContextRef.current;
if (ctx && sourceRef.current) {
sourceRef.current.stop();
sourceRef.current.disconnect();
}
startOffsetRef.current = offset;
startTimeRef.current = ctx?.currentTime ?? 0;
drawWaveform(progress);
if (playing && ctx) {
const buffer = decodedRef.current;
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
source.start(0, offset);
startTimeRef.current = ctx.currentTime;
sourceRef.current = source;
source.onended = () => {
if (sourceRef.current === source) {
setPlaying(false);
sourceRef.current = null;
}
};
}
},
[playing, drawWaveform],
);
if (loading) {
return <div className="h-16 w-full animate-pulse rounded-md bg-muted" />;
}
if (error) {
return (
<div className="h-16 w-full rounded-md bg-destructive/10 flex items-center justify-center text-xs text-destructive">
{error}
</div>
);
}
if (peaks.length === 0) return null;
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleTogglePlay}
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90"
>
{playing ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</button>
<div
ref={containerRef}
className="relative flex-1 cursor-pointer"
onClick={handleSeek}
role="slider"
aria-label={`Playback seek for ${filename}`}
tabIndex={0}
>
<canvas ref={canvasRef} className="w-full" />
</div>
</div>
);
}
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useState } from "react";
import type { MediaState } from "../../../entities/media/types.js";
import {
getMediaStatus,
queueMedia,
setMediaVolume,
skipMedia,
stopMedia,
} from "../../../shared/api/client";
import { useAsyncAction } from "../../../shared/hooks/useAsyncAction.js";
import { createLogger } from "../../../shared/lib/logger.js";
const logger = createLogger("use-media-control");
const emptyMediaState: MediaState = {
playing: false,
musicVolume: 1,
current: null,
queue: [],
};
export function useMediaControl() {
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
const { loading, error, execute, clearError } = useAsyncAction();
const refreshMedia = useCallback(async () => {
const state = await getMediaStatus();
setMediaState(state);
return state;
}, []);
const enqueue = useCallback(
async (source: string, mode: "music" | "screen") => {
const result = await execute(() => queueMedia(source, mode));
if (result) {
setMediaState(result);
logger.info("Media queued", { source, mode });
} else {
logger.error("Failed to queue media", { source, mode });
}
return result;
},
[execute],
);
const skip = useCallback(async () => {
const result = await execute(() => skipMedia());
if (result) {
setMediaState(result);
logger.info("Media skipped");
} else {
logger.error("Failed to skip media");
}
return result;
}, [execute]);
const stop = useCallback(async () => {
const result = await execute(() => stopMedia());
if (result) {
setMediaState(result);
logger.info("Media stopped");
} else {
logger.error("Failed to stop media");
}
return result;
}, [execute]);
const setVolume = useCallback(
async (volume: number) => {
clearError();
try {
const state = await setMediaVolume(volume);
setMediaState(state);
logger.info("Volume set", { volume });
return state;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error("Failed to set volume", { volume, error: message });
throw err;
}
},
[clearError],
);
useEffect(() => {
refreshMedia().catch((err) =>
logger.error("Failed to refresh media state on mount", {
error: String(err),
}),
);
}, [refreshMedia]);
return {
mediaState,
setMediaState,
loading,
error,
refreshMedia,
enqueue,
skip,
stop,
setVolume,
};
}
@@ -0,0 +1,113 @@
import { useCallback, useEffect, useState } from "react";
import type { Channel, Guild } from "../../../entities/guild/types.js";
import type { VoiceStatus } from "../../../entities/voice/types.js";
import {
connectVoice,
disconnectVoice,
getGuilds,
getTextChannels,
getVoiceChannels,
getVoiceStatus,
} from "../../../shared/api/client";
import { useAsyncAction } from "../../../shared/hooks/useAsyncAction.js";
import { createLogger } from "../../../shared/lib/logger.js";
const logger = createLogger("use-voice-control");
export function useVoiceControl() {
const [guilds, setGuilds] = useState<Guild[]>([]);
const [voiceChannels, setVoiceChannels] = useState<Channel[]>([]);
const [textChannels, setTextChannels] = useState<Channel[]>([]);
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>({
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
connections: [],
});
const { loading, error, execute, clearError } = useAsyncAction();
const refreshGuilds = useCallback(async () => {
clearError();
const nextGuilds = await getGuilds();
setGuilds(nextGuilds);
return nextGuilds;
}, [clearError]);
const refreshVoiceStatus = useCallback(async () => {
const status = await getVoiceStatus();
setVoiceStatus(status);
return status;
}, []);
const loadVoiceChannels = useCallback(async (guildId: string) => {
if (!guildId) {
setVoiceChannels([]);
return [];
}
const channels = await getVoiceChannels(guildId);
setVoiceChannels(channels);
return channels;
}, []);
const loadTextTargets = useCallback(async (guildId: string) => {
if (!guildId) {
setTextChannels([]);
return [];
}
const channels = await getTextChannels(guildId);
setTextChannels(channels);
return channels;
}, []);
const joinVoice = useCallback(
async (guildId: string, channelId: string) => {
const result = await execute(() => connectVoice(guildId, channelId));
if (result) {
setVoiceStatus(result);
logger.info("Connected to voice", { guildId, channelId });
} else {
logger.error("Failed to connect to voice", { guildId, channelId });
}
return result;
},
[execute],
);
const leaveVoice = useCallback(async () => {
const result = await execute(() => disconnectVoice());
if (result) {
setVoiceStatus(result);
logger.info("Disconnected from voice");
} else {
logger.error("Failed to disconnect from voice");
}
return result;
}, [execute]);
useEffect(() => {
refreshGuilds().catch((err) =>
logger.error("Failed to refresh guilds on mount", { error: String(err) }),
);
refreshVoiceStatus().catch((err) =>
logger.error("Failed to refresh voice status on mount", {
error: String(err),
}),
);
}, [refreshGuilds, refreshVoiceStatus]);
return {
guilds,
voiceChannels,
textChannels,
voiceStatus,
loading,
error,
refreshGuilds,
refreshVoiceStatus,
loadVoiceChannels,
loadTextTargets,
joinVoice,
leaveVoice,
};
}
@@ -0,0 +1,171 @@
// ─── Live Panel — thin composition layer ────────────────────────────────────
import { motion } from "framer-motion";
import { Mic, MonitorUp, Music2 } from "lucide-react";
import type { Channel, Guild } from "../../entities/guild/types.js";
import type { MediaState } from "../../entities/media/types.js";
import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js";
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
import {
Card,
CardContent,
CardHeader,
CardTitle,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../shared/ui";
import { ActiveSpeakers } from "./components/ActiveSpeakers";
import { AudioVisualizer } from "./components/AudioVisualizer";
import { MusicSubPanel } from "./components/MusicSubPanel";
import { NowPlaying } from "./components/NowPlaying";
import { RecordingsSubPanel } from "./components/RecordingsSubPanel";
import { ScreenSubPanel } from "./components/ScreenSubPanel";
import { VoiceConnectionCard } from "./components/VoiceConnectionCard";
interface LivePanelProps {
guilds: Guild[];
voiceChannels: Channel[];
selectedGuild: string;
selectedChannel: string;
status: VoiceStatus;
voiceLoading: boolean;
activeSpeakers: ActiveSpeaker[];
levels: number[];
isListening: boolean;
isStreaming: boolean;
micLevel: number;
mediaState: MediaState;
mediaLoading: boolean;
onGuildChange: (id: string) => void;
onChannelChange: (id: string) => void;
onJoin: () => void;
onDisconnect: () => void;
onListenToggle: () => void;
onStreamingToggle: () => void;
onQueueMusic: (source: string) => void;
onStartScreen: (source: string) => void;
onSkip: () => void;
onStop: () => void;
onVolumeChange: (v: number) => void;
}
export function LivePanel({
guilds,
voiceChannels,
selectedGuild,
selectedChannel,
status,
voiceLoading,
activeSpeakers,
levels,
isListening,
isStreaming,
micLevel,
mediaState,
mediaLoading,
onGuildChange,
onChannelChange,
onJoin,
onDisconnect,
onListenToggle,
onStreamingToggle,
onQueueMusic,
onStartScreen,
onSkip,
onStop,
onVolumeChange,
}: LivePanelProps) {
return (
<motion.div
variants={cardStagger}
initial="initial"
animate="animate"
className="grid gap-6"
>
<motion.div variants={cardItem}>
<VoiceConnectionCard
guilds={guilds}
voiceChannels={voiceChannels}
selectedGuild={selectedGuild}
selectedChannel={selectedChannel}
status={status}
voiceLoading={voiceLoading}
isListening={isListening}
isStreaming={isStreaming}
micLevel={micLevel}
onGuildChange={onGuildChange}
onChannelChange={onChannelChange}
onJoin={onJoin}
onDisconnect={onDisconnect}
onListenToggle={onListenToggle}
onStreamingToggle={onStreamingToggle}
/>
</motion.div>
<motion.div
variants={cardItem}
className="grid gap-6 xl:grid-cols-[1fr_320px]"
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Live Audio</CardTitle>
</CardHeader>
<CardContent>
<AudioVisualizer levels={levels} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Active Speakers</CardTitle>
</CardHeader>
<CardContent>
<ActiveSpeakers speakers={activeSpeakers} />
</CardContent>
</Card>
</motion.div>
<motion.div variants={cardItem}>
<NowPlaying current={mediaState.current} queue={mediaState.queue} />
</motion.div>
<motion.div variants={cardItem}>
<Tabs defaultValue="music">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="music">
<Music2 className="mr-1.5 h-4 w-4" /> Music
</TabsTrigger>
<TabsTrigger value="screen">
<MonitorUp className="mr-1.5 h-4 w-4" /> Screen Share
</TabsTrigger>
<TabsTrigger value="recordings">
<Mic className="mr-1.5 h-4 w-4" /> Recordings
</TabsTrigger>
</TabsList>
<TabsContent value="music">
<MusicSubPanel
volume={mediaState.musicVolume}
onVolumeChange={onVolumeChange}
onQueue={onQueueMusic}
onSkip={onSkip}
onStop={onStop}
loading={mediaLoading}
/>
</TabsContent>
<TabsContent value="screen">
<ScreenSubPanel
onStart={onStartScreen}
onSkip={onSkip}
onStop={onStop}
loading={mediaLoading}
/>
</TabsContent>
<TabsContent value="recordings">
<RecordingsSubPanel />
</TabsContent>
</Tabs>
</motion.div>
</motion.div>
);
}
@@ -0,0 +1,132 @@
import type { MessageRecord } from "../../../entities/message/types.js";
import { parseMetadata } from "../../../shared/lib/utils.js";
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
interface ImageItem {
url: string;
title: string;
kind: "attachment" | "embed" | "sticker";
message: MessageRecord;
}
function kindBadge(kind: ImageItem["kind"]): string {
switch (kind) {
case "sticker":
return "bg-primary/10 text-primary border-primary/20";
case "attachment":
return "bg-primary-soft text-primary border-primary/30";
case "embed":
return "bg-purple-100 text-purple-700 border-purple-200";
}
}
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
const images: ImageItem[] = [];
for (const message of messages) {
const metadata = parseMetadata(message.metadata);
// Stickers
for (const sticker of metadata.stickers ?? []) {
if (sticker.url) {
images.push({
url: sticker.url,
title: sticker.name || "sticker",
kind: "sticker",
message,
});
}
}
// Attachments
for (const attachment of metadata.attachments ?? []) {
if (
attachment.url &&
(attachment.contentType?.startsWith("image/") ||
/\.(png|jpe?g|gif|webp)$/i.test(attachment.name))
) {
images.push({
url: attachment.url,
title: attachment.name,
kind: "attachment",
message,
});
}
}
// Embed images
for (const embed of metadata.embeds ?? []) {
for (const imgUrl of [embed.image, embed.thumbnail].filter(Boolean)) {
images.push({
url: imgUrl as string,
title: embed.title || "embed image",
kind: "embed",
message,
});
}
}
}
if (images.length === 0) {
return <EmptyStateMascot />;
}
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{images.map((image) => {
// Stable key using message.id + url
const stableKey = `${image.message.id}-${image.kind}-${image.url}`;
return (
<a
key={stableKey}
href={image.url}
target="_blank"
rel="noreferrer"
className="group overflow-hidden rounded-xl border border-primary/20 bg-card shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
>
<div className="relative aspect-video overflow-hidden">
{image.kind === "sticker" ? (
<img
src={image.url}
alt={image.title}
className="h-full w-full object-contain bg-muted/30 p-2 transition-transform group-hover:scale-105"
loading="lazy"
/>
) : (
<img
src={image.url}
alt={image.title}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
)}
<span
className={`absolute right-2 top-2 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider shadow-sm backdrop-blur ${kindBadge(image.kind)}`}
>
{image.kind}
</span>
</div>
<div className="p-3">
<div className="truncate text-sm font-medium">{image.title}</div>
<div className="flex items-center gap-2">
<div className="h-4 w-4 overflow-hidden rounded-full ring-1 ring-primary/30">
<img
src={
image.message.avatar_url ??
"https://cdn.discordapp.com/embed/avatars/0.png"
}
alt=""
className="h-full w-full object-cover"
/>
</div>
<span className="truncate text-xs text-muted-foreground">
{image.message.username}
</span>
</div>
</div>
</a>
);
})}
</div>
);
}
@@ -0,0 +1,577 @@
import {
AlertCircle,
CheckCircle2,
Forward,
Hash,
Image as ImageIcon,
MessageCircle,
Pencil,
Reply,
RotateCw,
Smile,
Trash2,
Video,
} from "lucide-react";
import { Fragment, useEffect, useMemo, useState } from "react";
import type { MessageRecord } from "../../../entities/message/types.js";
import { parseMetadata } from "../../../shared/lib/utils.js";
import { getMessageById } from "../../../shared/api/client.js";
import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui";
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
function renderContentWithCustomEmojis(content: string): React.ReactNode {
const parts: React.ReactNode[] = [];
const regex = new RegExp(CUSTOM_EMOJI_REGEX.source, "g");
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
if (match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index));
}
const [, animated, name, id] = match;
const ext = animated ? "gif" : "png";
const url = `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`;
parts.push(
<img
key={`${id}-${match.index}`}
src={url}
alt={name}
className="inline-block h-[22px] w-[22px] align-middle object-contain"
loading="lazy"
draggable={false}
title={`:${name}:`}
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
}}
/>,
);
lastIndex = regex.lastIndex;
}
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex));
}
if (parts.length === 0) return content;
return <Fragment>{parts}</Fragment>;
}
// ─── Props ───────────────────────────────────────────────────────────────────
interface MessageCardProps {
messages: MessageRecord[];
onReanalyze: (id: string) => Promise<void>;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function parseStringList(value?: string | null): string[] {
if (!value) return [];
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [];
} catch {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
}
function severityColor(severity: string) {
switch (severity) {
case "critical":
return "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800";
case "high":
return "bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800";
case "medium":
return "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800";
case "low":
return "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800";
default:
return "bg-muted text-muted-foreground border-border";
}
}
function formatTimeAgo(ts: number): string {
const seconds = Math.floor((Date.now() - ts) / 1000);
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return new Date(ts).toLocaleDateString();
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
// ─── Single message row inside a group ───────────────────────────────────────
function MessageRow({
message,
onReanalyze,
}: {
message: MessageRecord;
onReanalyze: (id: string) => Promise<void>;
}) {
const metadata = useMemo(
() => parseMetadata(message.metadata),
[message.metadata],
);
const displayContent = message.edited_content ?? message.content;
const aiStatus = message.ai_status ?? "pending";
const categories = useMemo(() => {
const list = parseStringList(
message.ai_categories ?? message.ai_moderation_flags,
);
return list.filter((c) => c !== "analysis_incomplete");
}, [message.ai_categories, message.ai_moderation_flags]);
const confidence =
message.ai_confidence ?? message.ai_moderation_score ?? null;
const [isReanalyzing, setIsReanalyzing] = useState(false);
// ── Fetch referenced message content for replies if not in metadata ──
const referenceMeta = metadata.reference;
const [fetchedRefContent, setFetchedRefContent] = useState<{
username: string;
content: string;
} | null>(null);
useEffect(() => {
if (
message.is_reply &&
referenceMeta?.messageId &&
!referenceMeta?.content &&
!message.deleted_at
) {
getMessageById(referenceMeta.messageId)
.then((refMsg) => {
if (refMsg) {
setFetchedRefContent({
username: refMsg.username,
content: refMsg.content,
});
}
})
.catch(() => {
// Referenced message might not exist in our DB
});
}
}, [message.is_reply, referenceMeta?.messageId, referenceMeta?.content, message.deleted_at]);
const analysisSummary = useMemo(() => {
const parts: string[] = [];
if (categories.length > 0) {
parts.push(categories.slice(0, 3).join(", "));
if (categories.length > 3) parts.push(`+${categories.length - 3} more`);
}
if (message.ai_severity && message.ai_severity !== "none") {
parts.push(message.ai_severity);
}
if (confidence != null) {
parts.push(`${Math.round(confidence * 100)}% confidence`);
}
if (parts.length === 0) return "View AI analysis";
return parts.join(" · ");
}, [categories, message.ai_severity, confidence]);
const stickers = metadata.stickers ?? [];
const attachments = metadata.attachments ?? [];
const imageAttachments = attachments.filter(
(a) =>
a.contentType?.startsWith("image/") ||
/\.(png|jpe?g|gif|webp)$/i.test(a.name),
);
const videoAttachments = attachments.filter(
(a) =>
a.contentType?.startsWith("video/") ||
/\.(mp4|webm|mov|mkv|avi)$/i.test(a.name),
);
const hasImages = imageAttachments.length > 0;
const hasVideos = videoAttachments.length > 0;
/** Hide the fallback text ("[Attachment: ...]", "[Sticker: ...]", "[Embed]") when the actual media IS already shown visually. */
const isFallbackText =
/^\[(Attachment|Sticker):/i.test(displayContent) ||
/^\[Embed\]/i.test(displayContent);
const shouldShowContent = displayContent && !isFallbackText;
const handleReanalyze = async () => {
setIsReanalyzing(true);
try {
await onReanalyze(message.id);
} finally {
setIsReanalyzing(false);
}
};
// ── Reference context (reply / forward / crosspost) ─────────────────
const renderReferenceIndicator = () => {
// Use fetched content if metadata doesn't have it
const effectiveRepliedUsername =
referenceMeta?.repliedUsername ?? fetchedRefContent?.username ?? null;
const effectiveRepliedContent =
referenceMeta?.content ?? fetchedRefContent?.content ?? null;
if (message.is_reply) {
return (
<div className="flex items-start gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-muted-foreground/20 pl-2.5 py-1 hover:border-primary/40 transition-colors">
<Reply className="h-3 w-3 mt-0.5 shrink-0" />
<span className="min-w-0">
<span className="font-medium text-foreground/60">
Replying to{" "}
{effectiveRepliedUsername
? `@${effectiveRepliedUsername}`
: "a message"}
</span>
{effectiveRepliedContent && (
<span className="block truncate max-w-[400px] text-ellipsis text-[11px] text-muted-foreground/50 mt-0.5">
{effectiveRepliedContent}
</span>
)}
</span>
</div>
);
}
if (message.is_forward) {
return (
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-amber-400/40 pl-2.5 py-1">
<Forward className="h-3 w-3 shrink-0 text-amber-500" />
<span className="font-medium text-amber-600/70">Forwarded</span>
</div>
);
}
if (message.is_crosspost) {
return (
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-sky-400/40 pl-2.5 py-1">
<MessageCircle className="h-3 w-3 shrink-0 text-sky-500" />
<span className="font-medium text-sky-600/70">Crossposted</span>
</div>
);
}
return null;
};
const referenceIndicator = renderReferenceIndicator();
return (
<div className="space-y-2">
{/* Row header: time + edit/delete indicators + AI badges */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span
className="text-[11px] text-muted-foreground/70"
title={new Date(message.created_at).toLocaleString()}
>
{formatTime(message.created_at)}
</span>
{message.edited_at && (
<span className="flex items-center gap-0.5 text-[11px] text-muted-foreground/70">
<Pencil className="h-2.5 w-2.5" /> edited
</span>
)}
{message.deleted_at && (
<span className="flex items-center gap-0.5 text-[11px] text-destructive/70">
<Trash2 className="h-2.5 w-2.5" /> deleted
</span>
)}
<div className="ml-auto flex items-center gap-1">
<StatusBadge status={aiStatus} className="text-[10px] px-1.5 py-0">
{aiStatus === "clean" && <CheckCircle2 className="h-3 w-3" />}
{aiStatus === "flagged" && <AlertCircle className="h-3 w-3" />}
{aiStatus === "error" && <AlertCircle className="h-3 w-3" />}
</StatusBadge>
{message.ai_severity && message.ai_severity !== "none" && (
<Badge
className={`text-[10px] px-1.5 py-0 ${severityColor(message.ai_severity)}`}
>
{message.ai_severity}
</Badge>
)}
{confidence != null && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 tabular-nums"
>
{Math.round(confidence * 100)}%
</Badge>
)}
</div>
</div>
{/* Reference context: reply / forward / crosspost */}
{referenceIndicator}
{/* Content — hidden when it's just an "[Attachment: ...]" fallback and the image is shown below */}
{shouldShowContent ? (
<p
className={`whitespace-pre-wrap break-words text-sm leading-6 ${
message.deleted_at
? "text-muted-foreground/60"
: "text-foreground/90"
}`}
>
{renderContentWithCustomEmojis(displayContent)}
</p>
) : null}
{/* Stickers */}
{stickers.length > 0 && (
<div className="flex flex-wrap gap-2">
{stickers.map((sticker) => (
<div
key={sticker.name || sticker.url}
className="flex items-center gap-1.5"
>
{sticker.url ? (
<img
src={sticker.url}
alt={sticker.name || "sticker"}
className="h-12 w-12 rounded-lg border border-border object-contain bg-muted/50"
loading="lazy"
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
}}
/>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
<Smile className="h-6 w-6 text-muted-foreground" />
</div>
)}
</div>
))}
</div>
)}
{/* Attached images */}
{hasImages && (
<div className="flex gap-2 overflow-x-auto">
{imageAttachments.slice(0, 4).map((img) => (
<a
key={img.url}
href={img.url}
target="_blank"
rel="noreferrer"
className="shrink-0 overflow-hidden rounded-lg border border-border"
>
<img
src={img.url}
alt={img.name}
className="h-16 w-16 object-cover transition-transform hover:scale-105"
loading="lazy"
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
}}
/>
</a>
))}
{imageAttachments.length > 4 && (
<div className="flex h-16 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
+{imageAttachments.length - 4}
<ImageIcon className="ml-0.5 h-3 w-3" />
</div>
)}
</div>
)}
{/* Attached videos */}
{hasVideos && (
<div className="flex gap-2 overflow-x-auto">
{videoAttachments.slice(0, 4).map((vid) => (
<video
key={vid.url}
src={vid.url}
controls
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-muted"
preload="metadata"
/>
))}
{videoAttachments.length > 4 && (
<div className="flex h-28 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
+{videoAttachments.length - 4}
<Video className="ml-0.5 h-3 w-3" />
</div>
)}
</div>
)}
{/* Categories */}
{categories.length > 0 && (
<div className="flex flex-wrap gap-1">
{categories.map((category) => (
<Badge key={category} variant="secondary" className="text-[10px]">
{category}
</Badge>
))}
</div>
)}
{/* AI Analysis — always expanded */}
{message.ai_analysis ? (
<div
className={`rounded-lg border-l-[3px] px-3 py-2 ${
aiStatus === "flagged"
? "border-l-pink-400 dark:border-l-pink-600 bg-pink-50/40 dark:bg-pink-950/30"
: "border-l-emerald-400 dark:border-l-emerald-600 bg-emerald-50/40 dark:bg-emerald-950/30"
}`}
>
<div className="flex items-start gap-2 text-[11px]">
<span className="mt-0.5 shrink-0">
{aiStatus === "flagged" ? "🚨" : "ℹ️"}
</span>
<div className="min-w-0 flex-1">
<span className="block font-medium text-foreground/70 mb-1">
{analysisSummary}
</span>
<div className="text-[12px] text-muted-foreground leading-relaxed whitespace-pre-wrap">
{message.ai_analysis}
</div>
</div>
</div>
</div>
) : null}
{/* AI Error */}
{message.ai_error ? (
<div className="rounded-lg bg-pink-50/40 dark:bg-pink-950/30 px-3 py-2 text-[12px] text-pink-600 dark:text-pink-400">
AI error: {message.ai_error}
</div>
) : null}
{/* Re-analyze button */}
<div className="flex items-center gap-2">
<Button
size="sm"
variant={aiStatus === "error" ? "destructive" : "outline"}
onClick={handleReanalyze}
disabled={aiStatus === "pending" || isReanalyzing}
className="text-[11px] h-7 px-2.5"
>
<RotateCw
className={`h-3 w-3 ${isReanalyzing ? "animate-spin" : ""}`}
/>
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
</Button>
{aiStatus === "error" && (
<span className="text-[11px] text-pink-600/70 dark:text-pink-400/70">
Click to retry analysis
</span>
)}
</div>
</div>
);
}
// ─── Group card: one card per user group ─────────────────────────────────────
export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
const firstMsg = messages[0];
const hasMultiple = messages.length > 1;
const meta = useMemo(
() => parseMetadata(firstMsg.metadata),
[firstMsg.metadata],
);
const channelMeta = meta.channel;
const locationLabel = useMemo(() => {
if (channelMeta?.threadName) {
return `# ${channelMeta.channelName || "unknown"} › ${channelMeta.threadName}`;
}
if (channelMeta?.channelName) {
return `# ${channelMeta.channelName}`;
}
return null;
}, [channelMeta]);
return (
<article
className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
firstMsg.deleted_at ? "border-red-200 dark:border-red-900/50 opacity-60" : "border-border"
}`}
>
<div className="flex gap-3 p-4">
{/* Avatar — only for first message */}
<img
src={
firstMsg.avatar_url ??
"https://cdn.discordapp.com/embed/avatars/0.png"
}
alt=""
className="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30"
onError={(e) => {
const target = e.currentTarget;
target.src = "https://cdn.discordapp.com/embed/avatars/0.png";
}}
/>
<div className="min-w-0 flex-1">
{/* Group header: username + location + timestamp */}
<div className="flex items-baseline gap-2 mb-2">
<span className="font-semibold text-sm text-foreground">
{firstMsg.username || firstMsg.user_id}
</span>
{locationLabel && (
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded-full">
<Hash className="h-2.5 w-2.5" />
{locationLabel}
</span>
)}
<span
className="text-[11px] text-muted-foreground/60"
title={new Date(firstMsg.created_at).toLocaleString()}
>
{formatTimeAgo(firstMsg.created_at)}
{hasMultiple && ` · ${messages.length} messages`}
</span>
</div>
{/* Message rows — divided by separator when multiple */}
<div
className={
hasMultiple ? "divide-y divide-border/30 space-y-2.5" : ""
}
>
{messages.map((msg, idx) => (
<div
key={msg.id}
className={hasMultiple && idx > 0 ? "pt-2.5" : ""}
>
<MessageRow message={msg} onReanalyze={onReanalyze} />
</div>
))}
</div>
</div>
</div>
</article>
);
}
// ─── Skeleton ────────────────────────────────────────────────────────────────
export function MessageCardSkeleton() {
return (
<article className="rounded-xl border border-border bg-card p-4 shadow-sm">
<div className="flex gap-3">
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-3">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<div className="flex gap-2">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
</div>
</div>
</article>
);
}
@@ -0,0 +1,120 @@
import { motion } from "framer-motion";
import { useEffect, useMemo, useRef } from "react";
import type { MessageRecord } from "../../../entities/message/types.js";
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
import { ScrollArea } from "../../../shared/ui";
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
export interface MessageFeedProps {
messages: MessageRecord[];
onReanalyze: (id: string) => Promise<void>;
emptyText?: string;
loading?: boolean;
onLoadMore?: () => void;
hasMore?: boolean;
loadingMore?: boolean;
}
/** Messages from the same user within 5 minutes are visually grouped. */
const GROUP_WINDOW_MS = 5 * 60 * 1000;
interface MessageGroup {
messages: MessageRecord[];
}
function groupMessages(messages: MessageRecord[]): MessageGroup[] {
const groups: MessageGroup[] = [];
for (const msg of messages) {
const lastGroup = groups[groups.length - 1];
if (
lastGroup &&
lastGroup.messages[0].user_id === msg.user_id &&
lastGroup.messages[lastGroup.messages.length - 1].created_at -
msg.created_at <
GROUP_WINDOW_MS
) {
lastGroup.messages.push(msg);
} else {
groups.push({ messages: [msg] });
}
}
return groups;
}
export function MessageFeed({
messages,
onReanalyze,
emptyText: _emptyText,
loading,
onLoadMore,
hasMore,
loadingMore,
}: MessageFeedProps) {
// IntersectionObserver for infinite scroll — fires when sentinel becomes visible
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!onLoadMore || !hasMore) return;
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) onLoadMore();
},
{ rootMargin: "400px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [onLoadMore, hasMore]);
const groupedMessages = useMemo(() => groupMessages(messages), [messages]);
if (loading) {
return (
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<MessageCardSkeleton key={i} />
))}
</div>
</ScrollArea>
);
}
if (messages.length === 0) {
return <EmptyStateMascot />;
}
return (
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
<motion.div
className="space-y-3"
variants={cardStagger}
initial="initial"
animate="animate"
>
{groupedMessages.map((group) => (
<motion.div key={group.messages[0].id} variants={cardItem}>
<MessageCard messages={group.messages} onReanalyze={onReanalyze} />
</motion.div>
))}
{/* Infinite-scroll sentinel */}
{hasMore && (
<div
ref={sentinelRef}
className="flex items-center justify-center py-4"
>
{loadingMore ? (
<MessageCardSkeleton />
) : (
<div className="h-2 w-2 rounded-full bg-primary/40" />
)}
</div>
)}
</motion.div>
</ScrollArea>
);
}
@@ -0,0 +1,57 @@
// ─── Moderation alert toast listener ───────────────────────────────────────
// Listens for "moderation_alert" custom events dispatched from WebSocket
// message_analyzed handler, and shows toast notifications for flagged
// messages so moderators don't miss important alerts.
import { useEffect } from "react";
import { useToast } from "../../../shared/ui";
interface AlertDetail {
type: "flagged";
username: string;
severity: string;
categories: string;
brief: string;
}
function severityToToastType(
severity: string,
): "error" | "warning" | "info" | "success" {
switch (severity) {
case "critical":
case "high":
return "error";
case "medium":
return "warning";
case "low":
return "info";
default:
return "warning";
}
}
export function ModerationAlertListener() {
const { addToast } = useToast();
useEffect(() => {
const handler = (e: Event) => {
const { username, severity, categories, brief } = (
e as CustomEvent<AlertDetail>
).detail;
const sevLabel = severity ? `[${severity}]` : "";
const catLabel = categories
? ` — ${categories.split(",").slice(0, 2).join(", ")}`
: "";
addToast(
`🚨 ${username} ${sevLabel}${catLabel}: ${brief}`,
severityToToastType(severity),
);
};
window.addEventListener("moderation_alert", handler);
return () => window.removeEventListener("moderation_alert", handler);
}, [addToast]);
return null;
}
@@ -1,17 +1,16 @@
import type { MessageRecord } from "@bete/shared";
import { useCallback, useRef, useState } from "react";
import { useMessageStore } from "../../stores/message-store.js";
import { listMessages, reanalyzeMessage } from "../api/client.js";
import { createLogger } from "../lib/logger.js";
import type { MessageRecord } from "../../../entities/message/types.js";
import {
listMessages,
reanalyzeErrorBatch,
reanalyzeMessage,
} from "../../../shared/api/client";
import { createLogger } from "../../../shared/lib/logger.js";
const logger = createLogger("use-messages");
const PAGE_SIZE = 100;
/**
* Merge two message arrays, deduplicating by id and sorting by created_at desc.
* Later entries overwrite earlier ones for the same id (useful for WS updates).
*/
export function mergeMessages(
current: MessageRecord[],
incoming: MessageRecord[],
@@ -25,20 +24,14 @@ export function mergeMessages(
);
}
/**
* Hook for fetching, paginating, and re-analyzing messages.
* Reads/writes through the zustand `useMessageStore` so that WebSocket updates
* (handled by the store directly via prependMessage/updateMessage/removeMessage)
* are reflected without duplicating state.
*/
export function useMessages() {
const [messages, setMessages] = useState<MessageRecord[]>([]);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const currentGuild = useRef<string | null>(null);
const { messages, setMessages } = useMessageStore();
const fetchMessages = useCallback(async (guildId?: string) => {
if (!guildId) {
@@ -55,7 +48,6 @@ export function useMessages() {
guildId,
limit: PAGE_SIZE,
});
// Guard against stale responses if guildId changed mid-flight
if (currentGuild.current === guildId) {
setMessages(result.data);
setCursor(result.nextCursor);
@@ -81,7 +73,7 @@ export function useMessages() {
cursor,
limit: PAGE_SIZE,
});
setMessages((prev) => mergeMessages(prev, result.data));
setMessages((prev) => [...prev, ...result.data]);
setCursor(result.nextCursor);
setHasMore(!!result.nextCursor);
} catch (err) {
@@ -93,7 +85,8 @@ export function useMessages() {
}, [cursor, loadingMore]);
const reanalyze = useCallback(async (id: string): Promise<void> => {
// Snapshot the current state so we can revert on HTTP failure
// Capture prior state inside the functional updater so we don't need
// `messages` as a useCallback dependency (avoids stale closure churn).
let saved: MessageRecord | undefined;
setMessages((prev) => {
@@ -113,7 +106,7 @@ export function useMessages() {
try {
await reanalyzeMessage(id);
} catch (err) {
// Revert optimistic update on failure
// HTTP failed — revert the optimistic update so the UI stays truthful.
if (saved) {
const snapshot = saved;
setMessages((prev) =>
@@ -126,13 +119,42 @@ export function useMessages() {
}
}, []);
const reanalyzeAllErrors = useCallback(async (): Promise<number> => {
// Optimistically mark all error messages as pending
setMessages((prev) =>
prev.map((message) =>
message.ai_status === "error"
? {
...message,
ai_status: "pending" as const,
ai_error: null,
ai_analysis: null,
}
: message,
),
);
try {
const { count } = await reanalyzeErrorBatch({
guildId: currentGuild.current ?? undefined,
});
logger.info("Reanalyze all errors complete", { count });
return count;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error("Failed to reanalyze error batch", { error: message });
throw err;
}
}, []);
return {
messages,
setMessages,
loading,
loadingMore,
error,
fetchMessages,
reanalyze,
reanalyzeAllErrors,
loadMore,
hasMore,
};

Some files were not shown because too many files have changed in this diff Show More