diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5d573ff..34efb2c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -267,9 +267,6 @@ importers:
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
- zustand:
- specifier: ^5.0.14
- version: 5.0.14(@types/react@19.2.14)(react@19.2.6)
devDependencies:
'@biomejs/biome':
specifier: latest
@@ -6050,24 +6047,6 @@ packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
- zustand@5.0.14:
- resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
- engines: {node: '>=12.20.0'}
- peerDependencies:
- '@types/react': '>=18.0.0'
- immer: '>=9.0.6'
- react: '>=18.0.0'
- use-sync-external-store: '>=1.2.0'
- peerDependenciesMeta:
- '@types/react':
- optional: true
- immer:
- optional: true
- react:
- optional: true
- use-sync-external-store:
- optional: true
-
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -11535,9 +11514,4 @@ snapshots:
zod@4.4.3: {}
- zustand@5.0.14(@types/react@19.2.14)(react@19.2.6):
- optionalDependencies:
- '@types/react': 19.2.14
- react: 19.2.6
-
zwitch@2.0.4: {}
diff --git a/services/frontend/.astro/types.d.ts b/services/frontend/.astro/types.d.ts
index 03d7cc4..f964fe0 100644
--- a/services/frontend/.astro/types.d.ts
+++ b/services/frontend/.astro/types.d.ts
@@ -1,2 +1 @@
///
-///
\ No newline at end of file
diff --git a/services/frontend/astro.config.mjs b/services/frontend/astro.config.mjs
index 332f594..3bae84b 100644
--- a/services/frontend/astro.config.mjs
+++ b/services/frontend/astro.config.mjs
@@ -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
},
});
diff --git a/services/frontend/package.json b/services/frontend/package.json
index 0703268..934f9da 100644
--- a/services/frontend/package.json
+++ b/services/frontend/package.json
@@ -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",
diff --git a/services/frontend/src/App.client.tsx b/services/frontend/src/App.client.tsx
new file mode 100644
index 0000000..2ec3bd5
--- /dev/null
+++ b/services/frontend/src/App.client.tsx
@@ -0,0 +1,18 @@
+// ─── App.client.tsx — Astro React island entry point ────────────────────────
+// DILOAD OLEH Astro client:only="react"
+// Menyediakan
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 (
+
+
+
+
+
+ );
+}
diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx
new file mode 100644
index 0000000..ad3b3b9
--- /dev/null
+++ b/services/frontend/src/App.tsx
@@ -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
("loading");
+ const [dashboardIsPublic, setDashboardIsPublic] = useState(false);
+ const [configError, setConfigError] = useState(null);
+ const configRetryRef = useRef(0);
+ const configTimeoutRef = useRef | 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 & {
+ 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 (
+
+
+ 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}
+ />
+
+ );
+ case "dashboard":
+ return (
+
+
+
+ );
+ case "settings":
+ return (
+
+
+
+ );
+ default:
+ return (
+
+
+
+ );
+ }
+ };
+
+ // ── Render: Auth loading ─────────────────────────────────────────────────
+ if (authState === "loading") {
+ return (
+
+
+
+
+ {configError
+ ? "Connection lost — retrying..."
+ : `Connecting to server${".".repeat(configRetryRef.current)}`}
+
+
+
+ );
+ }
+
+ // ── Render: Auth overlay ─────────────────────────────────────────────────
+ if (authState === "unauthenticated") {
+ return (
+ 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 (
+ <>
+
+
+ {renderContent()}
+
+
+
+
+
+ >
+ );
+}
diff --git a/services/frontend/src/components/header/Header.astro b/services/frontend/src/components/header/Header.astro
deleted file mode 100644
index eaa1884..0000000
--- a/services/frontend/src/components/header/Header.astro
+++ /dev/null
@@ -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;
----
-
-
-
-
diff --git a/services/frontend/src/components/sidebar/NavItem.astro b/services/frontend/src/components/sidebar/NavItem.astro
deleted file mode 100644
index 29484f0..0000000
--- a/services/frontend/src/components/sidebar/NavItem.astro
+++ /dev/null
@@ -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";
----
-
-
-
-
- {!collapsed && {label} }
-
- {!collapsed && notificationCount > 0 && (
-
- {notificationCount > 99 ? "99+" : notificationCount}
-
- )}
-
- {collapsed && notificationCount > 0 && (
-
- )}
-
diff --git a/services/frontend/src/components/sidebar/Sidebar.astro b/services/frontend/src/components/sidebar/Sidebar.astro
deleted file mode 100644
index 34fa960..0000000
--- a/services/frontend/src/components/sidebar/Sidebar.astro
+++ /dev/null
@@ -1,93 +0,0 @@
----
-// ─── Sidebar.astro — Main app sidebar navigation ──────────────────────────
-// Features: collapsed / expanded (64px / 256px), logo, nav items, version
-// ──────────────────────────────────────────────────────────────────────────────
-
-import NavItem from "./NavItem.astro";
-
-export interface Props {
- collapsed?: boolean;
- version?: string;
- currentPath?: string;
-}
-
-const { collapsed = false, version = "1.0.0", currentPath = "/live" } =
- Astro.props;
-
-const ICONS = {
- live: ` `,
- messages: ` `,
- recordings: ` `,
- settings: ` `,
-} as const;
----
-
-
-
-
diff --git a/services/frontend/src/components/states/EmptyState.astro b/services/frontend/src/components/states/EmptyState.astro
deleted file mode 100644
index bdc19b1..0000000
--- a/services/frontend/src/components/states/EmptyState.astro
+++ /dev/null
@@ -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;
----
-
-
-
-
-
-
-
{title}
-
-
- {description && (
-
{description}
- )}
-
-
- {Astro.slots.has("actions") && (
-
-
-
- )}
-
diff --git a/services/frontend/src/components/states/ErrorState.astro b/services/frontend/src/components/states/ErrorState.astro
deleted file mode 100644
index f3a7cae..0000000
--- a/services/frontend/src/components/states/ErrorState.astro
+++ /dev/null
@@ -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;
----
-
-
-
-
-
-
-
-
-
-
-
-
-
{message}
-
-
- {Astro.slots.has("retry") && (
-
-
-
- )}
-
diff --git a/services/frontend/src/components/states/LoadingSkeleton.astro b/services/frontend/src/components/states/LoadingSkeleton.astro
deleted file mode 100644
index 486f801..0000000
--- a/services/frontend/src/components/states/LoadingSkeleton.astro
+++ /dev/null
@@ -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 });
----
-
-
- {
- items.map((_, i) => (
-
- {variant === "card" && (
-
- )}
-
- {variant === "list" && (
-
- )}
-
- {variant === "detail" && (
-
- )}
-
- ))
- }
-
diff --git a/services/frontend/src/components/ui/Badge.astro b/services/frontend/src/components/ui/Badge.astro
deleted file mode 100644
index 0b6ab54..0000000
--- a/services/frontend/src/components/ui/Badge.astro
+++ /dev/null
@@ -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;
----
-
-
- {dot && }
-
-
-
-
diff --git a/services/frontend/src/components/ui/Button.astro b/services/frontend/src/components/ui/Button.astro
deleted file mode 100644
index 886a0db..0000000
--- a/services/frontend/src/components/ui/Button.astro
+++ /dev/null
@@ -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 = {
- class: `btn btn--${variant} btn--${size} ${className}`,
-};
-if (Tag === "button") {
- attrs.disabled = disabled;
-} else if (disabled) {
- attrs["aria-disabled"] = true;
- attrs.role = "link";
-}
----
-
-
-
-
-
-
diff --git a/services/frontend/src/components/ui/Card.astro b/services/frontend/src/components/ui/Card.astro
deleted file mode 100644
index 2ab6f4f..0000000
--- a/services/frontend/src/components/ui/Card.astro
+++ /dev/null
@@ -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;
----
-
-
-
-
-
-
diff --git a/services/frontend/src/components/ui/SeverityBadge.astro b/services/frontend/src/components/ui/SeverityBadge.astro
deleted file mode 100644
index 1aa0cb3..0000000
--- a/services/frontend/src/components/ui/SeverityBadge.astro
+++ /dev/null
@@ -1,97 +0,0 @@
----
-export interface Props {
- severity: "safe" | "low" | "medium" | "high" | "critical";
- class?: string;
-}
-
-const { severity, class: className = "" } = Astro.props;
-
-const ICONS: Record = {
- safe: "✔",
- low: "↗",
- medium: "⚠",
- high: "⚡",
- critical: "✖",
-};
-
-const icon = ICONS[severity] ?? "";
----
-
-
-
-
-
-
-
diff --git a/services/frontend/src/components/ui/Skeleton.astro b/services/frontend/src/components/ui/Skeleton.astro
deleted file mode 100644
index b1a8e20..0000000
--- a/services/frontend/src/components/ui/Skeleton.astro
+++ /dev/null
@@ -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 = {};
-if (width) style.width = width;
-if (height) style.height = height;
----
-
- 0 ? style : undefined}
->
-
-
-
-
diff --git a/services/frontend/src/components/ui/Spinner.astro b/services/frontend/src/components/ui/Spinner.astro
deleted file mode 100644
index 13c0aba..0000000
--- a/services/frontend/src/components/ui/Spinner.astro
+++ /dev/null
@@ -1,60 +0,0 @@
----
-export interface Props {
- size?: "sm" | "default" | "lg";
- class?: string;
-}
-
-const { size = "default", class: className = "" } = Astro.props;
----
-
-
-
-
-
-
diff --git a/services/frontend/src/shared/types/dashboard.ts b/services/frontend/src/entities/dashboard/types.ts
similarity index 100%
rename from services/frontend/src/shared/types/dashboard.ts
rename to services/frontend/src/entities/dashboard/types.ts
diff --git a/services/frontend/src/shared/types/guild.ts b/services/frontend/src/entities/guild/types.ts
similarity index 100%
rename from services/frontend/src/shared/types/guild.ts
rename to services/frontend/src/entities/guild/types.ts
diff --git a/services/frontend/src/shared/types/media.ts b/services/frontend/src/entities/media/types.ts
similarity index 100%
rename from services/frontend/src/shared/types/media.ts
rename to services/frontend/src/entities/media/types.ts
diff --git a/services/frontend/src/shared/types/message.ts b/services/frontend/src/entities/message/types.ts
similarity index 100%
rename from services/frontend/src/shared/types/message.ts
rename to services/frontend/src/entities/message/types.ts
diff --git a/services/frontend/src/shared/types/recording.ts b/services/frontend/src/entities/recording/types.ts
similarity index 100%
rename from services/frontend/src/shared/types/recording.ts
rename to services/frontend/src/entities/recording/types.ts
diff --git a/services/frontend/src/shared/types/ui-types.ts b/services/frontend/src/entities/ui/types.ts
similarity index 100%
rename from services/frontend/src/shared/types/ui-types.ts
rename to services/frontend/src/entities/ui/types.ts
diff --git a/services/frontend/src/shared/types/voice.ts b/services/frontend/src/entities/voice/types.ts
similarity index 85%
rename from services/frontend/src/shared/types/voice.ts
rename to services/frontend/src/entities/voice/types.ts
index 324788c..71571df 100644
--- a/services/frontend/src/shared/types/voice.ts
+++ b/services/frontend/src/entities/voice/types.ts
@@ -1,4 +1,4 @@
-import type { GuildVoiceEntry } from "./guild";
+import type { GuildVoiceEntry } from "../guild/types";
export interface VoiceStatus {
connected: boolean;
diff --git a/services/frontend/src/features/admin/AdminPanel.tsx b/services/frontend/src/features/admin/AdminPanel.tsx
new file mode 100644
index 0000000..fb3aa04
--- /dev/null
+++ b/services/frontend/src/features/admin/AdminPanel.tsx
@@ -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(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(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 (
+
+
+ Admin Settings
+ Loading settings...
+
+
+
+
+
+ );
+ }
+
+ if (error && !settings) {
+ return (
+
+
+ Admin Settings
+ {error}
+
+
+
+ Retry
+
+
+
+ );
+ }
+
+ const isPublic = settings?.dashboardIsPublic ?? false;
+
+ return (
+
+
+
+
+
+
+
+
+ Admin Settings
+
+
+ Manage dashboard visibility and runtime configuration.
+
+
+
+
+
+
+
+
+ {/* ── Success / Error messages ── */}
+ {success && (
+
+ {success}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* ── Dashboard Visibility ── */}
+
+
+
+
+ {isPublic ? (
+
+ ) : (
+
+ )}
+
+ Dashboard Visibility:{" "}
+
+ {isPublic ? "Public" : "Private"}
+
+
+
+
+ {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."}
+
+
+
+ {saving ? (
+ <>
+
+ Saving...
+ >
+ ) : isPublic ? (
+ <>
+
+ Make Private
+ >
+ ) : (
+ <>
+
+ Make Public
+ >
+ )}
+
+
+
+ {/* ── Status indicators ── */}
+
+
+
Runtime
+
+
+
+ {isPublic ? "Public" : "Private"}
+
+
+
+
+
+ Env Default
+
+
+
+
+ {settings?.envDashboardIsPublic ? "Public" : "Private"}
+
+
+
+
+
+
+ {/* ── Logout ── */}
+
+
+
+ Logout
+
+
+
+ {/* ── Info card ── */}
+
+
+
+
+
+ Admin password is configured via the
+
+ ADMIN_PASSWORD
+
+ environment variable. For security, it cannot be changed
+ through this panel — update it in your deployment
+ configuration and restart the service.
+
+
+ Runtime settings are persisted across restarts in the
+
+ data/settings.json
+
+ file. Changes take effect immediately, no restart needed.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/services/frontend/src/shared/components/auth/index.tsx b/services/frontend/src/features/auth/index.tsx
similarity index 95%
rename from services/frontend/src/shared/components/auth/index.tsx
rename to services/frontend/src/features/auth/index.tsx
index 34f6aab..7f5faba 100644
--- a/services/frontend/src/shared/components/auth/index.tsx
+++ b/services/frontend/src/features/auth/index.tsx
@@ -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({
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.
)}
diff --git a/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx b/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx
new file mode 100644
index 0000000..fb44b92
--- /dev/null
+++ b/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx
@@ -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 (
+ }
+ 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,
+ })) ?? []
+ }
+ />
+ );
+}
diff --git a/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx b/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx
new file mode 100644
index 0000000..2f82463
--- /dev/null
+++ b/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx
@@ -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 (
+ (
+
+
+
+ )}
+ emptyMessage="No channels found."
+ />
+ );
+}
diff --git a/services/frontend/src/features/dashboard/components/DashboardStats.tsx b/services/frontend/src/features/dashboard/components/DashboardStats.tsx
new file mode 100644
index 0000000..11a5537
--- /dev/null
+++ b/services/frontend/src/features/dashboard/components/DashboardStats.tsx
@@ -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 ;
+ }
+
+ if (error) {
+ return (
+
+
+
{error}
+
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"
+ >
+ Retry
+
+
+ );
+ }
+
+ if (!stats) {
+ return (
+
+
+
No data available yet.
+
+ );
+ }
+
+ 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 (
+
+ {/* Summary cards grid */}
+
+ {cards.map((card) => (
+
+
+
+
+
+ {card.title}
+
+
+ {card.value}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {/* Top channels */}
+
+
+
+ Top Channels
+
+
+ {stats.top_channels.length === 0 ? (
+
+ No channel data yet.
+
+ ) : (
+
+ {stats.top_channels.map((ch) => (
+
+
+ #{ch.channel_name ?? ch.channel_id}
+
+
+ {ch.message_count.toLocaleString()}
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Moderation overview */}
+
+
+
+ Moderation Queue
+
+
+
+
+
+
+
+
+
+
+ {stats.moderation_overview.pending}
+
+
Pending
+
+
+
+ {stats.moderation_overview.processing}
+
+
Processing
+
+
+
+ {stats.moderation_overview.error}
+
+
Errors
+
+
+
+
+
+
+ );
+}
+
+function StatsSkeleton() {
+ return (
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx b/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx
new file mode 100644
index 0000000..632b252
--- /dev/null
+++ b/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx
@@ -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 ? (
+
+ ) : (
+
+ );
+
+ return (
+ ({
+ id: msg.id,
+ content: msg.content,
+ created_at: new Date(msg.created_at).toISOString(),
+ ai_status: msg.ai_status,
+ })) ?? []
+ }
+ />
+ );
+}
diff --git a/services/frontend/src/features/dashboard/components/UserSummaryList.tsx b/services/frontend/src/features/dashboard/components/UserSummaryList.tsx
new file mode 100644
index 0000000..66b51d2
--- /dev/null
+++ b/services/frontend/src/features/dashboard/components/UserSummaryList.tsx
@@ -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 (
+ {
+ const avatarUrl = avatarMap.get(item.id);
+ return avatarUrl ? (
+
+ ) : (
+
+
+
+ );
+ }}
+ emptyMessage="No users found."
+ />
+ );
+}
diff --git a/services/frontend/src/features/dashboard/hooks/useDashboard.ts b/services/frontend/src/features/dashboard/hooks/useDashboard.ts
new file mode 100644
index 0000000..ee983ef
--- /dev/null
+++ b/services/frontend/src/features/dashboard/hooks/useDashboard.ts
@@ -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(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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 };
+}
diff --git a/services/frontend/src/features/dashboard/index.tsx b/services/frontend/src/features/dashboard/index.tsx
new file mode 100644
index 0000000..9d4ecb8
--- /dev/null
+++ b/services/frontend/src/features/dashboard/index.tsx
@@ -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(null);
+ const [selectedChannelId, setSelectedChannelId] = useState(
+ 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 (
+ {
+ setSelectedUserId(null);
+ }}
+ onRefetch={refetchUserDetail}
+ />
+ );
+ }
+
+ // Show channel detail view
+ if (selectedChannelId) {
+ return (
+ {
+ setSelectedChannelId(null);
+ }}
+ onRefetch={refetchChannelDetail}
+ />
+ );
+ }
+
+ return (
+
+
+ Stats
+ Users
+ Channels
+
+
+ Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/services/frontend/src/features/live/components/ActiveSpeakers.tsx b/services/frontend/src/features/live/components/ActiveSpeakers.tsx
new file mode 100644
index 0000000..96e80ce
--- /dev/null
+++ b/services/frontend/src/features/live/components/ActiveSpeakers.tsx
@@ -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 ;
+ }
+
+ return (
+
+ {speakers.map((s) => {
+ const key = s.userId ?? s.id ?? `speaker-${s.username}`;
+ return (
+
+
+
+
{s.username}
+
+
+
+ {s.speaking ? "Speaking" : "Silent"}
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/services/frontend/src/features/live/components/AudioVisualizer.tsx b/services/frontend/src/features/live/components/AudioVisualizer.tsx
new file mode 100644
index 0000000..ab6c113
--- /dev/null
+++ b/services/frontend/src/features/live/components/AudioVisualizer.tsx
@@ -0,0 +1,79 @@
+import { useEffect, useRef } from "react";
+
+interface AudioVisualizerProps {
+ levels: number[];
+}
+
+export function AudioVisualizer({ levels }: AudioVisualizerProps) {
+ const canvasRef = useRef(null);
+ const containerRef = useRef(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 (
+
+
+
+ );
+}
diff --git a/services/frontend/src/features/live/components/MicLevelMeter.tsx b/services/frontend/src/features/live/components/MicLevelMeter.tsx
new file mode 100644
index 0000000..e83b52d
--- /dev/null
+++ b/services/frontend/src/features/live/components/MicLevelMeter.tsx
@@ -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 (
+
+ );
+}
diff --git a/services/frontend/src/features/live/components/MusicSubPanel.tsx b/services/frontend/src/features/live/components/MusicSubPanel.tsx
new file mode 100644
index 0000000..1b19855
--- /dev/null
+++ b/services/frontend/src/features/live/components/MusicSubPanel.tsx
@@ -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 | 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 (
+
+
setSource(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && submit()}
+ placeholder="YouTube URL, Spotify track, or search terms"
+ />
+
+
+ {muted ? (
+
+ ) : (
+
+ )}
+
+ {
+ setDraftVolume(Number(e.target.value));
+ if (muted) setMuted(false);
+ }}
+ className="h-2 w-full cursor-pointer accent-primary"
+ />
+
+ {draftVolume}%
+
+
+
+
+ Queue
+
+
+ Skip
+
+
+ Stop
+
+
+
+ );
+}
diff --git a/services/frontend/src/features/live/components/NowPlaying.tsx b/services/frontend/src/features/live/components/NowPlaying.tsx
new file mode 100644
index 0000000..2ea9d74
--- /dev/null
+++ b/services/frontend/src/features/live/components/NowPlaying.tsx
@@ -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 (
+
+
+
+
+ {current.mode === "screen" ? (
+
+ ) : (
+
+ )}
+
+
+
{current.title}
+
+ {current.source}
+
+
+
+ {current.mode ?? "music"}
+
+
+
+
+ {queue.length > 0 && (
+
+
Queue ({queue.length})
+
+ {queue.map((item, i) => (
+
+
+ {i + 1}
+
+
+
{item.title}
+
+ {item.source}
+
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx
new file mode 100644
index 0000000..43ecfad
--- /dev/null
+++ b/services/frontend/src/features/live/components/RecordingsSubPanel.tsx
@@ -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([]);
+ const [nextCursor, setNextCursor] = useState(null);
+ const [hasMore, setHasMore] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [error, setError] = useState(null);
+ const [deletingIds, setDeletingIds] = useState>(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 (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ loadRecordings()}>
+ Retry
+
+
+
+ );
+ }
+
+ if (recordings.length === 0) {
+ return ;
+ }
+
+ return (
+
+ {recordings.map((rec) => (
+
+
+
+
+
+
+
{rec.filename}
+
+ {rec.username}
+ ·
+ {rec.channel_name ?? rec.channel_id ?? "unknown"}
+ ·
+ {formatDate(rec.created_at)}
+ ·
+ {formatBytes(rec.size_bytes)}
+
+ {rec.upload_error && (
+
+ {rec.upload_error}
+
+ )}
+ {rec.transcription && (
+
+ {rec.transcription}
+
+ )}
+
+
+
handleDelete(rec.id)}
+ className="text-muted-foreground hover:text-destructive"
+ >
+
+
+
+ {rec.upload_status}
+
+ {rec.download_url && (
+
+
+
+ )}
+
+
+ {rec.download_url && (
+
+
+
+ )}
+
+ ))}
+ {hasMore && (
+
+
+ {loadingMore ? "Loading..." : "Load More"}
+
+
+ )}
+
+ );
+}
diff --git a/services/frontend/src/features/live/components/ScreenSubPanel.tsx b/services/frontend/src/features/live/components/ScreenSubPanel.tsx
new file mode 100644
index 0000000..05c8dbd
--- /dev/null
+++ b/services/frontend/src/features/live/components/ScreenSubPanel.tsx
@@ -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 (
+
+
setSource(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && submit()}
+ placeholder="Screen share URL or local file path"
+ />
+
+
+ Start
+
+
+ Skip
+
+
+ Stop
+
+
+
+ );
+}
diff --git a/services/frontend/src/features/live/components/VoiceConnectionCard.tsx b/services/frontend/src/features/live/components/VoiceConnectionCard.tsx
new file mode 100644
index 0000000..47381de
--- /dev/null
+++ b/services/frontend/src/features/live/components/VoiceConnectionCard.tsx
@@ -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 (
+
+
+
+ Voice Bridge
+
+
+ Join a Discord voice channel, listen, and transmit audio.
+
+
+
+
+ Guild
+ onGuildChange(e.target.value)}
+ placeholder="Select guild"
+ options={guilds.map((g) => ({ value: g.id, label: g.name }))}
+ />
+
+
+
+ Voice Channel
+
+ onChannelChange(e.target.value)}
+ placeholder="Select voice channel"
+ options={voiceChannels.map((c) => ({
+ value: c.id,
+ label: c.name,
+ }))}
+ />
+
+
+
+
+
+ {status.connected ? "Reconnect" : "Join Voice"}
+
+
+ Disconnect
+
+
+ {" "}
+ {isListening ? "Stop Listening" : "Listen"}
+
+
+ {" "}
+ {isStreaming ? "Stop Transmit" : "Transmit"}
+
+ {isStreaming && (
+
+
+ Hold Space
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/services/frontend/src/features/live/components/WaveformPlayer.tsx b/services/frontend/src/features/live/components/WaveformPlayer.tsx
new file mode 100644
index 0000000..5a7418b
--- /dev/null
+++ b/services/frontend/src/features/live/components/WaveformPlayer.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const canvasRef = useRef(null);
+ const containerRef = useRef(null);
+ const audioContextRef = useRef(null);
+ const sourceRef = useRef(null);
+ const startTimeRef = useRef(0);
+ const startOffsetRef = useRef(0);
+ const rafRef = useRef(0);
+ const decodedRef = useRef(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) => {
+ 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
;
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ if (peaks.length === 0) return null;
+
+ return (
+
+
+ {playing ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/services/frontend/src/features/live/hooks/useMediaControl.ts b/services/frontend/src/features/live/hooks/useMediaControl.ts
new file mode 100644
index 0000000..915f369
--- /dev/null
+++ b/services/frontend/src/features/live/hooks/useMediaControl.ts
@@ -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(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,
+ };
+}
diff --git a/services/frontend/src/features/live/hooks/useVoiceControl.ts b/services/frontend/src/features/live/hooks/useVoiceControl.ts
new file mode 100644
index 0000000..318f5f0
--- /dev/null
+++ b/services/frontend/src/features/live/hooks/useVoiceControl.ts
@@ -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([]);
+ const [voiceChannels, setVoiceChannels] = useState([]);
+ const [textChannels, setTextChannels] = useState([]);
+ const [voiceStatus, setVoiceStatus] = useState({
+ 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,
+ };
+}
diff --git a/services/frontend/src/features/live/index.tsx b/services/frontend/src/features/live/index.tsx
new file mode 100644
index 0000000..60f5783
--- /dev/null
+++ b/services/frontend/src/features/live/index.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ Live Audio
+
+
+
+
+
+
+
+ Active Speakers
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Music
+
+
+ Screen Share
+
+
+ Recordings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/services/frontend/src/features/messages/components/ImageGrid.tsx b/services/frontend/src/features/messages/components/ImageGrid.tsx
new file mode 100644
index 0000000..c01b629
--- /dev/null
+++ b/services/frontend/src/features/messages/components/ImageGrid.tsx
@@ -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 ;
+ }
+
+ return (
+
+ );
+}
diff --git a/services/frontend/src/features/messages/components/MessageCard.tsx b/services/frontend/src/features/messages/components/MessageCard.tsx
new file mode 100644
index 0000000..8fcf787
--- /dev/null
+++ b/services/frontend/src/features/messages/components/MessageCard.tsx
@@ -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(
+ {
+ 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 {parts} ;
+}
+
+// ─── Props ───────────────────────────────────────────────────────────────────
+
+interface MessageCardProps {
+ messages: MessageRecord[];
+ onReanalyze: (id: string) => Promise;
+}
+
+// ─── 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;
+}) {
+ 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 (
+
+
+
+
+ Replying to{" "}
+ {effectiveRepliedUsername
+ ? `@${effectiveRepliedUsername}`
+ : "a message"}
+
+ {effectiveRepliedContent && (
+
+ {effectiveRepliedContent}
+
+ )}
+
+
+ );
+ }
+
+ if (message.is_forward) {
+ return (
+
+
+ Forwarded
+
+ );
+ }
+
+ if (message.is_crosspost) {
+ return (
+
+
+ Crossposted
+
+ );
+ }
+
+ return null;
+ };
+
+ const referenceIndicator = renderReferenceIndicator();
+
+ return (
+
+ {/* Row header: time + edit/delete indicators + AI badges */}
+
+
+ {formatTime(message.created_at)}
+
+ {message.edited_at && (
+
+ edited
+
+ )}
+ {message.deleted_at && (
+
+ deleted
+
+ )}
+
+
+ {aiStatus === "clean" && }
+ {aiStatus === "flagged" && }
+ {aiStatus === "error" && }
+
+ {message.ai_severity && message.ai_severity !== "none" && (
+
+ {message.ai_severity}
+
+ )}
+ {confidence != null && (
+
+ {Math.round(confidence * 100)}%
+
+ )}
+
+
+
+ {/* Reference context: reply / forward / crosspost */}
+ {referenceIndicator}
+
+ {/* Content — hidden when it's just an "[Attachment: ...]" fallback and the image is shown below */}
+ {shouldShowContent ? (
+
+ {renderContentWithCustomEmojis(displayContent)}
+
+ ) : null}
+
+ {/* Stickers */}
+ {stickers.length > 0 && (
+
+ {stickers.map((sticker) => (
+
+ {sticker.url ? (
+
{
+ const target = e.currentTarget;
+ target.style.display = "none";
+ }}
+ />
+ ) : (
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Attached images */}
+ {hasImages && (
+
+ )}
+
+ {/* Attached videos */}
+ {hasVideos && (
+
+ {videoAttachments.slice(0, 4).map((vid) => (
+
+ ))}
+ {videoAttachments.length > 4 && (
+
+ +{videoAttachments.length - 4}
+
+
+ )}
+
+ )}
+
+ {/* Categories */}
+ {categories.length > 0 && (
+
+ {categories.map((category) => (
+
+ {category}
+
+ ))}
+
+ )}
+
+ {/* AI Analysis — always expanded */}
+ {message.ai_analysis ? (
+
+
+
+ {aiStatus === "flagged" ? "🚨" : "ℹ️"}
+
+
+
+ {analysisSummary}
+
+
+ {message.ai_analysis}
+
+
+
+
+ ) : null}
+
+ {/* AI Error */}
+ {message.ai_error ? (
+
+ AI error: {message.ai_error}
+
+ ) : null}
+
+ {/* Re-analyze button */}
+
+
+
+ {isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
+
+ {aiStatus === "error" && (
+
+ Click to retry analysis
+
+ )}
+
+
+ );
+}
+
+// ─── 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 (
+
+
+ {/* Avatar — only for first message */}
+
{
+ const target = e.currentTarget;
+ target.src = "https://cdn.discordapp.com/embed/avatars/0.png";
+ }}
+ />
+
+
+ {/* Group header: username + location + timestamp */}
+
+
+ {firstMsg.username || firstMsg.user_id}
+
+ {locationLabel && (
+
+
+ {locationLabel}
+
+ )}
+
+ {formatTimeAgo(firstMsg.created_at)}
+ {hasMultiple && ` · ${messages.length} messages`}
+
+
+
+ {/* Message rows — divided by separator when multiple */}
+
+ {messages.map((msg, idx) => (
+
0 ? "pt-2.5" : ""}
+ >
+
+
+ ))}
+
+
+
+
+ );
+}
+
+// ─── Skeleton ────────────────────────────────────────────────────────────────
+
+export function MessageCardSkeleton() {
+ return (
+
+
+
+ );
+}
diff --git a/services/frontend/src/features/messages/components/MessageFeed.tsx b/services/frontend/src/features/messages/components/MessageFeed.tsx
new file mode 100644
index 0000000..2064fd3
--- /dev/null
+++ b/services/frontend/src/features/messages/components/MessageFeed.tsx
@@ -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;
+ 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(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 (
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (messages.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+ {groupedMessages.map((group) => (
+
+
+
+ ))}
+
+ {/* Infinite-scroll sentinel */}
+ {hasMore && (
+
+ {loadingMore ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/services/frontend/src/features/messages/components/ModerationAlertListener.tsx b/services/frontend/src/features/messages/components/ModerationAlertListener.tsx
new file mode 100644
index 0000000..a11afe6
--- /dev/null
+++ b/services/frontend/src/features/messages/components/ModerationAlertListener.tsx
@@ -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
+ ).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;
+}
diff --git a/services/frontend/src/shared/hooks/useMessages.ts b/services/frontend/src/features/messages/hooks/useMessages.ts
similarity index 69%
rename from services/frontend/src/shared/hooks/useMessages.ts
rename to services/frontend/src/features/messages/hooks/useMessages.ts
index e6c802b..24b23d4 100644
--- a/services/frontend/src/shared/hooks/useMessages.ts
+++ b/services/frontend/src/features/messages/hooks/useMessages.ts
@@ -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([]);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(null);
const [cursor, setCursor] = useState(null);
const [hasMore, setHasMore] = useState(false);
const currentGuild = useRef(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 => {
- // 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 => {
+ // 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,
};
diff --git a/services/frontend/src/features/messages/index.tsx b/services/frontend/src/features/messages/index.tsx
new file mode 100644
index 0000000..b52b1fd
--- /dev/null
+++ b/services/frontend/src/features/messages/index.tsx
@@ -0,0 +1,315 @@
+import { motion } from "framer-motion";
+import { Filter, RotateCw, Search, X } from "lucide-react";
+import { useMemo, useState } from "react";
+import type { MessageRecord } from "../../shared/api/client";
+import { request } from "../../shared/api/client";
+import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
+import {
+ Badge,
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ Input,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "../../shared/ui";
+import { ImageGrid } from "./components/ImageGrid";
+import { MessageFeed } from "./components/MessageFeed";
+
+interface MessagesPanelProps {
+ guildName: string | null;
+ messages: MessageRecord[];
+ onReanalyze: (id: string) => Promise;
+ onReanalyzeAllErrors?: () => Promise;
+ onLoadMore?: () => void;
+ hasMore?: boolean;
+ loadingMore?: boolean;
+}
+
+type AiFilter = "all" | "analyzed" | "clean" | "flagged" | "error" | "pending";
+
+export function MessagesPanel({
+ guildName,
+ messages,
+ onReanalyze,
+ onReanalyzeAllErrors,
+ onLoadMore,
+ hasMore,
+ loadingMore,
+}: MessagesPanelProps) {
+ const [searchQuery, setSearchQuery] = useState("");
+ const [searchResults, setSearchResults] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const [showSearch, setShowSearch] = useState(false);
+ const [aiFilter, setAiFilter] = useState("analyzed");
+ const [viewTab, setViewTab] = useState<"all" | "images">("all");
+ const [retryingAll, setRetryingAll] = useState(false);
+ const [retriedCount, setRetriedCount] = useState(null);
+
+ const handleSearch = async () => {
+ if (!searchQuery.trim()) {
+ setSearchResults([]);
+ setShowSearch(false);
+ return;
+ }
+ setIsSearching(true);
+ try {
+ const params = new URLSearchParams({ q: searchQuery, limit: "50" });
+ const data = await request<{ results: MessageRecord[] }>(
+ `/api/analysis/search?${params}`,
+ );
+ setSearchResults(data.results || []);
+ setShowSearch(true);
+ } catch {
+ setSearchResults([]);
+ } finally {
+ setIsSearching(false);
+ }
+ };
+
+ const stats = useMemo(() => {
+ const base = showSearch ? searchResults : messages;
+ return {
+ total: base.length,
+ clean: base.filter((m) => m.ai_status === "clean").length,
+ flagged: base.filter((m) => m.ai_status === "flagged").length,
+ error: base.filter((m) => m.ai_status === "error").length,
+ pending: base.filter((m) => m.ai_status === "pending" || !m.ai_status)
+ .length,
+ deleted: base.filter((m) => m.deleted_at).length,
+ edited: base.filter((m) => m.edited_at).length,
+ };
+ }, [messages, searchResults, showSearch]);
+
+ const filteredMessages = useMemo(() => {
+ const base = showSearch ? searchResults : messages;
+ if (aiFilter === "all") return base;
+ return base.filter((m) => {
+ const status = m.ai_status ?? "pending";
+ if (aiFilter === "analyzed")
+ return status !== "pending" && status !== null && status !== undefined;
+ if (aiFilter === "pending")
+ return status === "pending" || status === null || status === undefined;
+ return status === aiFilter;
+ });
+ }, [messages, searchResults, showSearch, aiFilter]);
+
+ return (
+
+
+
+
+ Messages
+ {guildName && (
+
+ Monitoring all text channels in{" "}
+ {guildName}
+
+ )}
+
+
+
+ Messages are automatically captured from all text channels in the
+ monitored guild. Real-time updates arrive via WebSocket.
+
+
+
+
+
+ {stats.total > 0 && (
+
+
+ {stats.total} total{hasMore && !showSearch ? "+" : ""}
+
+
+ {stats.clean} clean
+
+
+ {stats.flagged} flagged
+
+
+ {stats.error} error
+
+
+ {stats.pending} pending
+
+ {stats.deleted > 0 && (
+
+ {stats.deleted} deleted
+
+ )}
+ {stats.edited > 0 && (
+
+ {stats.edited} edited
+
+ )}
+
+ )}
+
+
+
+
+ setSearchQuery(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleSearch()}
+ disabled={isSearching}
+ />
+
+
+ {isSearching ? "Searching..." : "Search"}
+
+ {showSearch && (
+ {
+ setShowSearch(false);
+ setSearchResults([]);
+ setSearchQuery("");
+ }}
+ >
+ Clear
+
+ )}
+ {stats.error > 0 && onReanalyzeAllErrors && (
+ {
+ setRetryingAll(true);
+ setRetriedCount(null);
+ try {
+ const count = await onReanalyzeAllErrors();
+ setRetriedCount(count);
+ } finally {
+ setRetryingAll(false);
+ }
+ }}
+ className="rounded-xl bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/20"
+ >
+
+ {retryingAll ? "Retrying..." : `Retry All Errors (${stats.error})`}
+
+ )}
+ {retriedCount !== null && (
+
+ {retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for
+ re-analysis
+
+ )}
+
+
+ {(
+ [
+ "all",
+ "analyzed",
+ "clean",
+ "flagged",
+ "error",
+ "pending",
+ ] as AiFilter[]
+ ).map((f) => (
+ setAiFilter(f)}
+ className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
+ aiFilter === f
+ ? "bg-primary text-primary-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground hover:bg-accent"
+ }`}
+ >
+ {f}
+
+ ))}
+
+
+
+ {showSearch && searchResults.length > 0 && (
+
+ Found {searchResults.length} result
+ {searchResults.length !== 1 ? "s" : ""}
+
+ )}
+
+
+ setViewTab(v as "all" | "images")}
+ >
+
+
+ {showSearch
+ ? `Search (${filteredMessages.length})`
+ : `All (${filteredMessages.length})`}
+
+ Images
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/services/frontend/src/features/settings/index.tsx b/services/frontend/src/features/settings/index.tsx
new file mode 100644
index 0000000..c5f5b66
--- /dev/null
+++ b/services/frontend/src/features/settings/index.tsx
@@ -0,0 +1,426 @@
+import { motion } from "framer-motion";
+import {
+ Bell,
+ BellOff,
+ Moon,
+ Palette,
+ Sun,
+ Monitor,
+ Settings,
+ Shield,
+ Globe,
+ Lock,
+ Volume2,
+ VolumeX,
+} from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { ThemeMode } from "../../hooks/useTheme";
+import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
+import { Card, CardContent, CardHeader, CardTitle, Button } from "../../shared/ui";
+import {
+ getAdminSettings,
+ updateAdminSettings,
+ clearSessionToken,
+} from "../../shared/api/client";
+import type { AdminSettings as AdminSettingsType } from "../../entities/ui/types";
+
+/* ─── Storage keys ─────────────────────────────────────────────────────── */
+
+const NOTIF_ENABLED_KEY = "bete-notif-enabled";
+const NOTIF_SOUND_KEY = "bete-notif-sound";
+
+/* ─── Types ────────────────────────────────────────────────────────────── */
+
+interface NotificationPrefs {
+ enabled: boolean;
+ sound: boolean;
+}
+
+function loadNotifPrefs(): NotificationPrefs {
+ try {
+ const raw = localStorage.getItem(NOTIF_ENABLED_KEY);
+ const soundRaw = localStorage.getItem(NOTIF_SOUND_KEY);
+ return {
+ enabled: raw !== "false", // default true
+ sound: soundRaw !== "false", // default true
+ };
+ } catch {
+ return { enabled: true, sound: true };
+ }
+}
+
+/* ─── Props ────────────────────────────────────────────────────────────── */
+
+interface SettingsPanelProps {
+ themeMode: ThemeMode;
+ isDark: boolean;
+ onThemeModeChange: (mode: ThemeMode) => void;
+}
+
+/* ─── Component ────────────────────────────────────────────────────────── */
+
+export function SettingsPanel({
+ themeMode,
+ isDark,
+ onThemeModeChange,
+}: SettingsPanelProps) {
+ const [notifPrefs, setNotifPrefs] = useState(loadNotifPrefs);
+ const [adminSettings, setAdminSettings] = useState(null);
+ const [adminSaving, setAdminSaving] = useState(false);
+ const [adminError, setAdminError] = useState(null);
+ const [adminSuccess, setAdminSuccess] = useState(null);
+ const adminSuccessTimerRef = useRef | null>(null);
+
+ // Load admin settings on mount
+ useEffect(() => {
+ getAdminSettings()
+ .then(setAdminSettings)
+ .catch(() => {
+ // Not authenticated — ignore
+ });
+ }, []);
+
+ const handleTogglePublic = async () => {
+ if (!adminSettings) return;
+ const newValue = !adminSettings.dashboardIsPublic;
+ setAdminSaving(true);
+ setAdminError(null);
+ setAdminSuccess(null);
+ // Clear any existing auto-clear timer
+ if (adminSuccessTimerRef.current) {
+ clearTimeout(adminSuccessTimerRef.current);
+ }
+ try {
+ const updated = await updateAdminSettings({ dashboardIsPublic: newValue });
+ setAdminSettings(updated);
+ setAdminSuccess(
+ newValue
+ ? "Dashboard is now public — accessible without password."
+ : "Dashboard is now private — admin password required.",
+ );
+ // Auto-clear success message after 4s
+ adminSuccessTimerRef.current = setTimeout(() => setAdminSuccess(null), 4000);
+ } catch (err) {
+ setAdminError(err instanceof Error ? err.message : "Failed to update");
+ } finally {
+ setAdminSaving(false);
+ }
+ };
+
+ const handleLogout = () => {
+ clearSessionToken();
+ window.location.reload();
+ };
+
+ const updateNotif = useCallback(
+ (patch: Partial) => {
+ setNotifPrefs((prev) => {
+ const next = { ...prev, ...patch };
+ try {
+ localStorage.setItem(NOTIF_ENABLED_KEY, String(next.enabled));
+ localStorage.setItem(NOTIF_SOUND_KEY, String(next.sound));
+ } catch {
+ /* quota */
+ }
+ // Dispatch event so other components can react
+ window.dispatchEvent(
+ new CustomEvent("notif_prefs_changed", { detail: next }),
+ );
+ return next;
+ });
+ },
+ [],
+ );
+
+ const themeOptions: Array<{
+ value: ThemeMode;
+ label: string;
+ icon: typeof Sun;
+ desc: string;
+ }> = [
+ {
+ value: "light",
+ label: "Light",
+ icon: Sun,
+ desc: "Always use light theme",
+ },
+ {
+ value: "dark",
+ label: "Dark",
+ icon: Moon,
+ desc: "Always use dark theme",
+ },
+ {
+ value: "system",
+ label: "System",
+ icon: Monitor,
+ desc: "Follow system preference",
+ },
+ ];
+
+ return (
+
+ {/* ── Theme section ────────────────────────────────────────────── */}
+
+
+
+
+
+ Theme
+
+
+
+
+ {themeOptions.map((opt) => {
+ const Icon = opt.icon;
+ const isActive = themeMode === opt.value;
+ return (
+ onThemeModeChange(opt.value)}
+ className={`
+ flex flex-col items-center gap-2 rounded-xl border-2 p-4 text-center transition-all
+ ${
+ isActive
+ ? "border-primary bg-primary/5 text-primary"
+ : "border-border text-muted-foreground hover:border-primary/40 hover:text-foreground"
+ }
+ `}
+ >
+
+ {opt.label}
+ {opt.desc}
+ {isActive && (
+
+ )}
+
+ );
+ })}
+
+
+ Current: {isDark ? "Dark" : "Light"}
+ {themeMode === "system" && " (follows system)"}
+
+
+
+
+
+ {/* ── Notifications section ────────────────────────────────────── */}
+
+
+
+
+
+ Notifications
+
+
+
+ {/* Toggle — enable/disable all notifs */}
+
+
+ {notifPrefs.enabled ? (
+
+ ) : (
+
+ )}
+
+
+ Moderation alerts
+
+
+ Show toast when a message is flagged by AI
+
+
+
+ updateNotif({ enabled: !notifPrefs.enabled })}
+ className={`
+ relative h-6 w-11 rounded-full transition-colors
+ ${notifPrefs.enabled ? "bg-primary" : "bg-muted"}
+ `}
+ >
+
+
+
+
+ {/* Toggle — sound */}
+
+
+ {notifPrefs.sound ? (
+
+ ) : (
+
+ )}
+
+
+ Sound effects
+
+
+ Play a sound when new moderation alerts arrive
+
+
+
+ updateNotif({ sound: !notifPrefs.sound })}
+ className={`
+ relative h-6 w-11 rounded-full transition-colors
+ ${notifPrefs.sound ? "bg-primary" : "bg-muted"}
+ `}
+ >
+
+
+
+
+
+
+
+ {/* ── Admin section ────────────────────────────────────────────── */}
+
+
+
+
+
+ Admin Settings
+
+
+
+ {/* Dashboard visibility toggle */}
+
+
+
+ {adminSettings?.dashboardIsPublic ? (
+
+ ) : (
+
+ )}
+
+
+ Dashboard Visibility:{" "}
+
+ {adminSettings?.dashboardIsPublic ? "Public" : "Private"}
+
+
+
+ {adminSettings?.dashboardIsPublic
+ ? "Anyone can view the dashboard. Admin password still required for management."
+ : "Admin password required to access the dashboard."}
+
+
+
+
+ {adminSaving ? (
+
+ ) : adminSettings?.dashboardIsPublic ? (
+ "Make Private"
+ ) : (
+ "Make Public"
+ )}
+
+
+ {adminError && (
+
{adminError}
+ )}
+ {adminSuccess && (
+
{adminSuccess}
+ )}
+
+
+ {/* Status indicators */}
+
+
+
Runtime
+
+
+ {adminSettings?.dashboardIsPublic ? "Public" : "Private"}
+
+
+
+
Env Default
+
+
+ {adminSettings?.envDashboardIsPublic ? "Public" : "Private"}
+
+
+
+
+ {/* Logout */}
+
+
+
+ Logout
+
+
+
+ {/* Info */}
+
+
+
+
+ Admin password is set via the ADMIN_PASSWORD env var.
+ Runtime settings are persisted in data/settings.json.
+
+
+
+
+
+
+
+ {/* ── About section ────────────────────────────────────────────── */}
+
+
+
+ About
+
+
+
+ Bete Dashboard v1.0 — Discord AI Moderation & Voice Recording
+ System.
+
+
+ Theme settings are saved locally. Notification preferences are
+ persisted across sessions.
+
+
+
+
+
+ );
+}
diff --git a/services/frontend/src/hooks/useNotificationBadge.ts b/services/frontend/src/hooks/useNotificationBadge.ts
new file mode 100644
index 0000000..4c75f04
--- /dev/null
+++ b/services/frontend/src/hooks/useNotificationBadge.ts
@@ -0,0 +1,31 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+/**
+ * Tracks incoming moderation_alert events and maintains a badge counter.
+ * Clears when the user navigates to the messages tab.
+ */
+export function useNotificationBadge(activeTab: string) {
+ const [count, setCount] = useState(0);
+ const prevActiveTab = useRef(activeTab);
+
+ // Clear badge when user switches TO messages tab
+ useEffect(() => {
+ if (activeTab === "messages" && prevActiveTab.current !== "messages") {
+ setCount(0);
+ }
+ prevActiveTab.current = activeTab;
+ }, [activeTab]);
+
+ const increment = useCallback(() => {
+ setCount((c) => c + 1);
+ }, []);
+
+ // Listen for moderation_alert custom events
+ useEffect(() => {
+ const handler = () => increment();
+ window.addEventListener("moderation_alert", handler);
+ return () => window.removeEventListener("moderation_alert", handler);
+ }, [increment]);
+
+ return { count, clear: () => setCount(0) };
+}
diff --git a/services/frontend/src/shared/hooks/useTheme.ts b/services/frontend/src/hooks/useTheme.ts
similarity index 100%
rename from services/frontend/src/shared/hooks/useTheme.ts
rename to services/frontend/src/hooks/useTheme.ts
diff --git a/services/frontend/src/islands/ActiveSpeakers.tsx b/services/frontend/src/islands/ActiveSpeakers.tsx
deleted file mode 100644
index 42f6c9d..0000000
--- a/services/frontend/src/islands/ActiveSpeakers.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-// ─── ActiveSpeakers.tsx — Live voice speaker list island ────────────────────
-// Self-contained: reads from useVoiceStore, renders speaker entries with
-// animated speaking-indicator bars.
-// ─────────────────────────────────────────────────────────────────────────────
-
-import { useVoiceStore } from "../stores/voice-store.js";
-
-export default function ActiveSpeakers() {
- const activeSpeakers = useVoiceStore((state) => state.activeSpeakers);
-
- if (activeSpeakers.length === 0) {
- return (
-
-
🎙
-
No active speakers
-
- );
- }
-
- return (
-
- {activeSpeakers.map((speaker) => {
- const key =
- speaker.userId ?? speaker.id ?? `speaker-${speaker.username}`;
- return (
-
-
-
-
- {speaker.username}
-
-
- {speaker.speaking && (
-
- {[0, 1, 2, 3].map((i) => (
-
- ))}
-
- )}
-
- );
- })}
-
- );
-}
diff --git a/services/frontend/src/islands/AudioVisualizer.tsx b/services/frontend/src/islands/AudioVisualizer.tsx
deleted file mode 100644
index c6d8421..0000000
--- a/services/frontend/src/islands/AudioVisualizer.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-// ─── AudioVisualizer.tsx — Canvas-based frequency bar visualizer ────────────
-// Uses requestAnimationFrame to draw placeholder random frequency bars.
-// Bar color uses the OKLCH primary token at varying opacity.
-// ─────────────────────────────────────────────────────────────────────────────
-
-import { useEffect, useRef } from "react";
-
-interface AudioVisualizerProps {
- barCount?: number;
- height?: number;
-}
-
-export default function AudioVisualizer({
- barCount = 48,
- height = 32,
-}: AudioVisualizerProps) {
- const canvasRef = useRef(null);
-
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas) return;
-
- const ctx = canvas.getContext("2d");
- if (!ctx) return;
-
- const dpr = window.devicePixelRatio || 1;
- let animationId: number;
-
- const resize = () => {
- const rect = canvas.getBoundingClientRect();
- canvas.width = rect.width * dpr;
- canvas.height = height * dpr;
- };
-
- resize();
-
- const draw = () => {
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
-
- const w = canvas.getBoundingClientRect().width;
- const h = height;
- const barWidth = w / barCount;
- const maxBarHeight = h * 0.85;
-
- for (let i = 0; i < barCount; i++) {
- // Random bar height as a placeholder (real data from WebSocket)
- const barHeight = Math.random() * maxBarHeight;
- const x = i * barWidth;
- const y = h - barHeight;
-
- // Vary opacity across bars for a more dynamic look
- const opacity = 0.3 + Math.random() * 0.7;
- ctx.fillStyle = `oklch(0.62 0.15 255 / ${opacity})`;
-
- // Rounded-top bars
- const radius = Math.min(barWidth * 0.3, 4);
- const gap = 1;
-
- ctx.beginPath();
- ctx.moveTo(x + gap + radius, y);
- ctx.lineTo(x + barWidth - gap - radius, y);
- ctx.quadraticCurveTo(
- x + barWidth - gap,
- y,
- x + barWidth - gap,
- y + radius,
- );
- ctx.lineTo(x + barWidth - gap, h);
- ctx.lineTo(x + gap, h);
- ctx.lineTo(x + gap, y + radius);
- ctx.quadraticCurveTo(x + gap, y, x + gap + radius, y);
- ctx.fill();
- }
-
- animationId = requestAnimationFrame(draw);
- };
-
- draw();
-
- return () => {
- cancelAnimationFrame(animationId);
- };
- }, [barCount, height]);
-
- return (
-
-
-
- );
-}
diff --git a/services/frontend/src/islands/AuthGuard.tsx b/services/frontend/src/islands/AuthGuard.tsx
deleted file mode 100644
index de72a4e..0000000
--- a/services/frontend/src/islands/AuthGuard.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-// ─── AuthGuard.tsx — Standalone authentication island ────────────────────────
-// Three-state auth wrapper: null=loading, false=unauthenticated (login form),
-// true=authenticated (renders children).
-// ─────────────────────────────────────────────────────────────────────────────
-
-import {
- type FormEvent,
- type ReactNode,
- useCallback,
- useEffect,
- useState,
-} from "react";
-import {
- getAdminPassword,
- getSessionToken,
- setAdminPassword,
-} from "../shared/api/client.js";
-import {
- Button,
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
- Input,
-} from "../shared/components/index.js";
-
-interface AuthGuardProps {
- children: ReactNode;
-}
-
-export default function AuthGuard({ children }: AuthGuardProps) {
- const [authState, setAuthState] = useState(null);
- const [password, setPassword] = useState("");
- const [error, setError] = useState(null);
-
- // On mount: check for existing session token or legacy admin password
- useEffect(() => {
- const token = getSessionToken();
- const storedPassword = getAdminPassword();
- setAuthState(token || storedPassword ? true : false);
- }, []);
-
- const handleLogin = useCallback(
- (e: FormEvent) => {
- e.preventDefault();
- if (!password) return;
- setError(null);
- // Store the password to localStorage; actual validation happens
- // when child components call the API (gets 401 on invalid creds).
- setAdminPassword(password);
- setAuthState(true);
- },
- [password],
- );
-
- // ── Loading state ──────────────────────────────────────────────────────────
- if (authState === null) {
- return (
-
- );
- }
-
- // ── Unauthenticated — show login form ──────────────────────────────────────
- if (authState === false) {
- return (
-
-
-
-
- Access Required
-
- Enter the admin password to access the dashboard.
-
-
-
-
-
-
-
- );
- }
-
- // ── Authenticated — render children ────────────────────────────────────────
- return <>{children}>;
-}
diff --git a/services/frontend/src/islands/LiveShell.tsx b/services/frontend/src/islands/LiveShell.tsx
deleted file mode 100644
index 4c8f7ad..0000000
--- a/services/frontend/src/islands/LiveShell.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-// ─── LiveShell.tsx — Page shell for /live ────────────────────────────────────
-// Composes Sidebar, Header, ParticleBackground, and the four live islands
-// (VoiceControls, ActiveSpeakers, AudioVisualizer, NowPlaying) into a full-page
-// responsive layout.
-// ─────────────────────────────────────────────────────────────────────────────
-
-import { useCallback, useEffect, useState } from "react";
-import {
- getGuilds,
- getVoiceChannels,
- getVoiceStatus,
-} from "../shared/api/client.js";
-import { Header } from "../shared/components/Header.js";
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "../shared/components/index.js";
-import { ParticleBackground } from "../shared/components/particles/ParticleBackground.js";
-import { Sidebar } from "../shared/components/Sidebar.js";
-import { useTheme } from "../shared/hooks/useTheme.js";
-import type { Channel, Guild } from "../shared/types/guild.js";
-import type { VoiceStatus } from "../shared/types/voice.js";
-import { useVoiceStore } from "../stores/voice-store.js";
-import ActiveSpeakers from "./ActiveSpeakers.js";
-import AudioVisualizer from "./AudioVisualizer.js";
-import NowPlaying from "./NowPlaying.js";
-import VoiceControls from "./VoiceControls.js";
-
-const emptyVoiceStatus: VoiceStatus = {
- connected: false,
- activeGuildId: null,
- activeChannelId: null,
- activeChannelName: null,
- connections: [],
-};
-
-export default function LiveShell() {
- const { mode, isDark, toggle: toggleTheme } = useTheme();
- const [voiceStatus, setVoiceStatus] = useState(emptyVoiceStatus);
- const [guilds, setGuilds] = useState([]);
- const [voiceChannels, setVoiceChannels] = useState([]);
-
- // Watch store for guild changes so we can fetch voice channels
- const guildId = useVoiceStore((state) => state.guildId);
-
- // Fetch initial data on mount
- useEffect(() => {
- getVoiceStatus()
- .then(setVoiceStatus)
- .catch(() => {
- /* offline — leave default */
- });
- getGuilds()
- .then(setGuilds)
- .catch(() => {
- /* offline — leave default */
- });
- }, []);
-
- // Refetch voice channels when guild selection changes
- useEffect(() => {
- if (guildId) {
- getVoiceChannels(guildId)
- .then(setVoiceChannels)
- .catch(() => setVoiceChannels([]));
- } else {
- setVoiceChannels([]);
- }
- }, [guildId]);
-
- const handleTabChange = useCallback((tab: string) => {
- if (tab !== "live") {
- window.location.href = "/";
- }
- }, []);
-
- return (
-
- {/* Background layers */}
-
-
-
-
-
-
-
-
-
-
- {/* Responsive grid: stacks on <1024px */}
-
- {/* Main content column */}
-
-
-
-
-
- Live Audio
-
-
-
-
-
-
-
-
-
- {/* Sidebar column */}
-
-
-
- Active Speakers
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/services/frontend/src/islands/MascotChat.tsx b/services/frontend/src/islands/MascotChat.tsx
deleted file mode 100644
index c24cc79..0000000
--- a/services/frontend/src/islands/MascotChat.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-// ─── MascotChat.tsx — Floating chat bubble island ───────────────────────────
-// Fixed-position toggle button that opens a glass-styled chat panel.
-// ─────────────────────────────────────────────────────────────────────────────
-
-import { useCallback, useState } from "react";
-
-const GREETING = "Hi! I'm Bete's mascot. Ask me anything about the server.";
-
-export default function MascotChat() {
- const [isOpen, setIsOpen] = useState(false);
- const [message, setMessage] = useState("");
-
- const toggle = useCallback(() => {
- setIsOpen((prev) => !prev);
- }, []);
-
- const handleSubmit = useCallback(
- (e: React.FormEvent) => {
- e.preventDefault();
- if (!message.trim()) return;
- // TODO: wire up chat submission
- setMessage("");
- },
- [message],
- );
-
- return (
-
- {/* ── Chat Panel ──────────────────────────────────────────────────── */}
- {isOpen && (
-
- {/* Header */}
-
-
- {/* Body */}
-
-
- {GREETING}
-
-
- {/* Quick suggestions */}
-
- {["What can you do?", "Show me the dashboard"].map(
- (suggestion) => (
- setMessage(suggestion)}
- >
- {suggestion}
-
- ),
- )}
-
-
-
- {/* Input */}
-
-
- )}
-
- {/* ── Toggle Button ───────────────────────────────────────────────── */}
-
- {isOpen ? (
-
- ✕
-
- ) : (
-
- ✦
-
- )}
-
-
- );
-}
diff --git a/services/frontend/src/islands/MessageFeed.tsx b/services/frontend/src/islands/MessageFeed.tsx
deleted file mode 100644
index 519f0f7..0000000
--- a/services/frontend/src/islands/MessageFeed.tsx
+++ /dev/null
@@ -1,356 +0,0 @@
-// ─── MessageFeed.tsx — Standalone message feed island ─────────────────────────
-// Self-contained message list with WebSocket bridging, infinite scroll,
-// loading/empty/error states, and re-analyze per message.
-// ──────────────────────────────────────────────────────────────────────────────
-
-import type { MessageRecord } from "@bete/shared";
-import { AlertCircle, RefreshCw, RotateCw } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-import { getAppConfig } from "../shared/api/client.js";
-import { Badge, Button, Skeleton } from "../shared/components/index.js";
-import { useMessages } from "../shared/hooks/useMessages.js";
-import { useDashboardSocket } from "../shared/ws/socket.js";
-import { useMessageStore } from "../stores/message-store.js";
-
-interface MessageFeedProps {
- /** Guild to fetch messages for. If omitted, the list stays empty. */
- guildId?: string;
-}
-
-// ─── Severity badge colouring ─────────────────────────────────────────────────
-
-const SEVERITY_COLORS: Record = {
- critical:
- "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800",
- high: "bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800",
- medium:
- "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800",
- low: "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800",
-};
-
-function severityColor(severity: string): string {
- return (
- SEVERITY_COLORS[severity] ?? "bg-muted text-muted-foreground border-border"
- );
-}
-
-// ─── Single message card ──────────────────────────────────────────────────────
-
-function MessageCard({
- message,
- onReanalyze,
-}: {
- message: MessageRecord;
- onReanalyze: (id: string) => Promise;
-}) {
- const [isReanalyzing, setIsReanalyzing] = useState(false);
- const isDeleted = !!message.deleted_at;
-
- const handleReanalyze = async () => {
- setIsReanalyzing(true);
- try {
- await onReanalyze(message.id);
- } finally {
- setIsReanalyzing(false);
- }
- };
-
- return (
-
-
- {/* Avatar */}
-
{
- e.currentTarget.src =
- "https://cdn.discordapp.com/embed/avatars/0.png";
- }}
- />
-
-
- {/* Row: username + badges */}
-
-
- {message.username || message.user_id}
-
-
- {message.ai_severity && message.ai_severity !== "none" && (
-
- {message.ai_severity}
-
- )}
-
- {isDeleted && (
- deleted
- )}
-
-
- {formatTimeAgo(message.created_at)}
-
-
-
- {/* Content */}
-
- {message.content}
-
-
- {/* Re-analyze button */}
-
-
-
- {isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
-
- {message.ai_status === "error" && (
-
- Click to retry
-
- )}
-
-
- {/* AI analysis summary */}
- {message.ai_analysis && (
-
-
- {message.ai_analysis}
-
-
- )}
-
- {/* AI error */}
- {message.ai_error && (
-
- AI error: {message.ai_error}
-
- )}
-
-
-
- );
-}
-
-// ─── Skeleton placeholder ─────────────────────────────────────────────────────
-
-function MessageSkeleton() {
- return (
-
-
-
- );
-}
-
-// ─── Empty state ──────────────────────────────────────────────────────────────
-
-function EmptyState() {
- return (
-
-
No messages captured
-
- Messages will appear here once they are captured from the monitored
- guild.
-
-
- );
-}
-
-// ─── Error state ──────────────────────────────────────────────────────────────
-
-function ErrorState({
- message,
- onRetry,
-}: {
- message: string;
- onRetry: () => void;
-}) {
- return (
-
-
-
Failed to load messages
-
- {message}
-
-
-
- Retry
-
-
- );
-}
-
-// ─── Helpers ──────────────────────────────────────────────────────────────────
-
-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();
-}
-
-// ─── Main component ───────────────────────────────────────────────────────────
-
-export default function MessageFeed({
- guildId: propGuildId,
-}: MessageFeedProps) {
- const {
- messages,
- loading,
- loadingMore,
- error,
- fetchMessages,
- loadMore,
- reanalyze,
- hasMore,
- } = useMessages();
- const { prependMessage, updateMessage } = useMessageStore();
- const sentinelRef = useRef(null);
- const [initialFetchDone, setInitialFetchDone] = useState(false);
- const [resolvedGuildId, setResolvedGuildId] = useState(
- propGuildId,
- );
-
- // ── Resolve guildId from app config when not provided as prop ────────────
- useEffect(() => {
- if (propGuildId) {
- setResolvedGuildId(propGuildId);
- return;
- }
- getAppConfig()
- .then((config) => {
- if (config.monitorGuildId) setResolvedGuildId(config.monitorGuildId);
- })
- .catch(() => {
- // config unavailable — messages will stay empty until guildId is set
- });
- }, [propGuildId]);
-
- // ── WebSocket bridge ──────────────────────────────────────────────────────
- // Forward real-time Discord events into the zustand store so the message list
- // updates without refetching the entire page.
- useDashboardSocket({
- onMessageCreated: (data) => prependMessage(data),
- onMessageUpdated: (data) => updateMessage(data.id, data),
- onMessageDeleted: (data) =>
- updateMessage(data.id, {
- type: "deleted" as const,
- deleted_at: data.deleted_at,
- }),
- onMessageAnalyzed: (data) => updateMessage(data.id, data),
- });
-
- // ── Initial fetch ─────────────────────────────────────────────────────────
- useEffect(() => {
- if (resolvedGuildId && !initialFetchDone) {
- setInitialFetchDone(true);
- fetchMessages(resolvedGuildId).catch(() => {
- // error is captured by the hook's state
- });
- }
- }, [resolvedGuildId, initialFetchDone, fetchMessages]);
-
- // ── Infinite scroll via IntersectionObserver ──────────────────────────────
- useEffect(() => {
- if (!loadMore || !hasMore) return;
- const el = sentinelRef.current;
- if (!el) return;
-
- const observer = new IntersectionObserver(
- (entries) => {
- if (entries[0]?.isIntersecting) loadMore();
- },
- { rootMargin: "400px" },
- );
- observer.observe(el);
- return () => observer.disconnect();
- }, [loadMore, hasMore]);
-
- // ── Loading state ─────────────────────────────────────────────────────────
- if (loading && messages.length === 0) {
- return (
-
- {[1, 2, 3, 4, 5].map((i) => (
-
- ))}
-
- );
- }
-
- // ── Error state ───────────────────────────────────────────────────────────
- if (error && messages.length === 0) {
- return (
- resolvedGuildId && fetchMessages(resolvedGuildId)}
- />
- );
- }
-
- // ── Empty state ───────────────────────────────────────────────────────────
- if (!loading && messages.length === 0) {
- return ;
- }
-
- // ── Success — message list with infinite scroll ───────────────────────────
- return (
-
- {messages.map((msg) => (
-
- ))}
-
- {hasMore && (
-
- {loadingMore ? (
-
- ) : (
-
- )}
-
- )}
-
- );
-}
diff --git a/services/frontend/src/islands/NowPlaying.tsx b/services/frontend/src/islands/NowPlaying.tsx
deleted file mode 100644
index 2ecdbc1..0000000
--- a/services/frontend/src/islands/NowPlaying.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-// ─── NowPlaying.tsx — Current media track display island ───────────────────
-// Self-contained: fetches /api/media/status on mount, shows current track or
-// "Nothing playing" fallback.
-// ─────────────────────────────────────────────────────────────────────────────
-
-import { Music2 } from "lucide-react";
-import { useEffect, useState } from "react";
-import { getMediaStatus } from "../shared/api/client.js";
-import type { MediaState } from "../shared/types/media.js";
-
-export default function NowPlaying() {
- const [mediaState, setMediaState] = useState(null);
- const [errored, setErrored] = useState(false);
-
- useEffect(() => {
- let cancelled = false;
-
- getMediaStatus()
- .then((state) => {
- if (!cancelled) setMediaState(state);
- })
- .catch(() => {
- if (!cancelled) setErrored(true);
- });
-
- return () => {
- cancelled = true;
- };
- }, []);
-
- if (errored) {
- return (
-
- );
- }
-
- if (!mediaState || !mediaState.current) {
- return (
-
- );
- }
-
- const { current } = mediaState;
-
- return (
-
-
-
-
-
-
-
{current.title}
-
- {current.source}
-
-
- {current.mode && (
-
- {current.mode}
-
- )}
-
-
- );
-}
diff --git a/services/frontend/src/islands/PageHeader.tsx b/services/frontend/src/islands/PageHeader.tsx
deleted file mode 100644
index 1ceb904..0000000
--- a/services/frontend/src/islands/PageHeader.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-// ─── PageHeader.tsx — Standalone header island for non-SPA pages ────────────
-// Renders a title on the left and the ThemeToggle on the right.
-// Does not depend on wsStatus / voiceStatus / theme context.
-// ──────────────────────────────────────────────────────────────────────────────
-
-import ThemeToggle from "./ThemeToggle.js";
-
-interface PageHeaderProps {
- title: string;
- subtitle?: string;
-}
-
-export default function PageHeader({ title, subtitle }: PageHeaderProps) {
- return (
-
-
-
-
-
- IMPHNEN
- ·
- {title}
-
-
-
- {subtitle && (
-
- {subtitle}
-
- )}
-
-
-
-
- );
-}
diff --git a/services/frontend/src/islands/PageSidebar.tsx b/services/frontend/src/islands/PageSidebar.tsx
deleted file mode 100644
index ddea34f..0000000
--- a/services/frontend/src/islands/PageSidebar.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-// ─── PageSidebar.tsx — Standalone sidebar island for non-SPA pages ───────────
-// Wraps the existing Sidebar widget with URL-based navigation so it can be
-// embedded as an Astro island without needing to pass callback functions.
-// ──────────────────────────────────────────────────────────────────────────────
-
-import { Sidebar } from "../shared/components/Sidebar.js";
-import type { DashboardTab } from "../shared/types/ui-types.js";
-
-const TAB_URLS: Record = {
- live: "/live",
- messages: "/messages",
- dashboard: "/",
- settings: "/settings",
-};
-
-interface PageSidebarProps {
- activeTab: DashboardTab;
-}
-
-export default function PageSidebar({ activeTab }: PageSidebarProps) {
- return (
- {
- window.location.href = TAB_URLS[tab] || "/messages";
- }}
- />
- );
-}
diff --git a/services/frontend/src/islands/Particles.tsx b/services/frontend/src/islands/Particles.tsx
deleted file mode 100644
index d698cdf..0000000
--- a/services/frontend/src/islands/Particles.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-// ─── Particles.tsx — Canvas-based particle background island ────────────────
-// Renders 30 animated particles as a decorative background layer.
-// Uses requestAnimationFrame, handles resize, cleans up on unmount.
-// ─────────────────────────────────────────────────────────────────────────────
-
-import { useEffect, useRef } from "react";
-
-interface Particle {
- x: number;
- y: number;
- vx: number;
- vy: number;
- size: number;
-}
-
-const PARTICLE_COUNT = 30;
-const PARTICLE_COLOR = "oklch(0.62 0.15 255 / 0.15)";
-const MAX_SPEED = 0.4;
-const MIN_SIZE = 2;
-const MAX_SIZE = 6;
-
-function randomRange(min: number, max: number): number {
- return min + Math.random() * (max - min);
-}
-
-function createParticle(w: number, h: number): Particle {
- return {
- x: Math.random() * w,
- y: Math.random() * h,
- vx: randomRange(-MAX_SPEED, MAX_SPEED),
- vy: randomRange(-MAX_SPEED, MAX_SPEED),
- size: randomRange(MIN_SIZE, MAX_SIZE),
- };
-}
-
-export default function Particles() {
- const canvasRef = useRef(null);
-
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas) return;
-
- const ctx = canvas.getContext("2d");
- if (!ctx) return;
-
- let animationId: number;
-
- function resize() {
- if (!canvas) return;
- canvas.width = window.innerWidth;
- canvas.height = window.innerHeight;
- }
-
- resize();
- window.addEventListener("resize", resize);
-
- const particles: Particle[] = [];
- for (let i = 0; i < PARTICLE_COUNT; i++) {
- particles.push(createParticle(canvas.width, canvas.height));
- }
-
- function animate() {
- if (!canvas || !ctx) return;
- const w = canvas.width;
- const h = canvas.height;
-
- ctx.clearRect(0, 0, w, h);
- ctx.fillStyle = PARTICLE_COLOR;
-
- for (const p of particles) {
- p.x += p.vx;
- p.y += p.vy;
-
- // Wrap around edges
- if (p.x < 0) p.x = w;
- if (p.x > w) p.x = 0;
- if (p.y < 0) p.y = h;
- if (p.y > h) p.y = 0;
-
- ctx.beginPath();
- ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
- ctx.fill();
- }
-
- animationId = requestAnimationFrame(animate);
- }
-
- animate();
-
- return () => {
- cancelAnimationFrame(animationId);
- window.removeEventListener("resize", resize);
- };
- }, []);
-
- return (
-
- );
-}
diff --git a/services/frontend/src/islands/RecordingsList.tsx b/services/frontend/src/islands/RecordingsList.tsx
deleted file mode 100644
index 2b8bb9b..0000000
--- a/services/frontend/src/islands/RecordingsList.tsx
+++ /dev/null
@@ -1,192 +0,0 @@
-// ─── RecordingsList.tsx — Voice recordings list island ──────────────────────
-// Fetches recordings from API, renders loading/error/empty/success states.
-// Each recording card shows username, channel, timestamp, download button.
-// ─────────────────────────────────────────────────────────────────────────────
-
-import { Download, Mic } from "lucide-react";
-import { useCallback, useEffect, useState } from "react";
-import { listRecordings } from "../shared/api/client";
-import { Skeleton } from "../shared/components/skeleton";
-import { formatBytes, formatDate } from "../shared/lib/utils";
-import type { VoiceRecording } from "../shared/types/recording";
-
-type ViewState = "loading" | "error" | "empty" | "success";
-
-export default function RecordingsList() {
- const [state, setState] = useState("loading");
- const [recordings, setRecordings] = useState([]);
- const [error, setError] = useState("");
-
- const fetchRecordings = useCallback(async () => {
- setState("loading");
- try {
- const res = await listRecordings();
- if (res.items.length === 0) {
- setState("empty");
- } else {
- setRecordings(res.items);
- setState("success");
- }
- } catch (err) {
- setError(
- err instanceof Error ? err.message : "Failed to load recordings",
- );
- setState("error");
- }
- }, []);
-
- useEffect(() => {
- fetchRecordings();
- }, [fetchRecordings]);
-
- /* ── Loading ──────────────────────────────────────────────────────────── */
- if (state === "loading") {
- return (
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
- );
- }
-
- /* ── Error ────────────────────────────────────────────────────────────── */
- if (state === "error") {
- return (
-
-
-
-
-
-
-
-
-
{error}
-
-
- Retry
-
-
-
- );
- }
-
- /* ── Empty ────────────────────────────────────────────────────────────── */
- if (state === "empty") {
- return (
-
-
-
-
-
No recordings
-
- Voice recordings will appear here once users start speaking in
- monitored voice channels.
-
-
- );
- }
-
- /* ── Success ──────────────────────────────────────────────────────────── */
- return (
-
- {recordings.map((recording) => (
-
- ))}
-
- );
-}
-
-/* ─── Individual Recording Card ─────────────────────────────────────────── */
-
-function RecordingCard({ recording }: { recording: VoiceRecording }) {
- return (
-
-
- {/* Left: avatar + info */}
-
-
- {recording.avatar_url ? (
-
- ) : (
-
- )}
-
-
-
-
- {recording.username}
-
-
- {recording.channel_name && (
- #{recording.channel_name}
- )}
-
- {formatDate(recording.created_at)}
-
-
- {formatBytes(recording.size_bytes)}
-
-
-
-
-
- {/* Right: download button */}
- {recording.download_url && (
-
-
-
- )}
-
-
- );
-}
diff --git a/services/frontend/src/islands/SettingsForm.tsx b/services/frontend/src/islands/SettingsForm.tsx
deleted file mode 100644
index 3b19f1c..0000000
--- a/services/frontend/src/islands/SettingsForm.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-// ─── SettingsForm.tsx — Appearance settings island ─────────────────────────
-// Reads/writes theme preference (dark/light/system) to localStorage key
-// "bete-dashboard-theme", applies data-theme attribute and .dark class.
-// ─────────────────────────────────────────────────────────────────────────────
-
-import { useCallback, useEffect, useState } from "react";
-
-type Theme = "dark" | "light" | "system";
-
-const STORAGE_KEY = "bete-dashboard-theme";
-
-const THEME_OPTIONS: { value: Theme; label: string }[] = [
- { value: "dark", label: "Dark" },
- { value: "light", label: "Light" },
- { value: "system", label: "System" },
-];
-
-function getStoredTheme(): Theme {
- try {
- const stored = localStorage.getItem(STORAGE_KEY);
- if (stored === "dark" || stored === "light" || stored === "system") {
- return stored;
- }
- } catch {
- // localStorage unavailable
- }
- return "dark";
-}
-
-function resolveTheme(theme: Theme): "dark" | "light" {
- if (theme === "system") {
- return window.matchMedia("(prefers-color-scheme: dark)").matches
- ? "dark"
- : "light";
- }
- return theme;
-}
-
-function applyTheme(theme: Theme): void {
- const root = document.documentElement;
- const actual = resolveTheme(theme);
- root.setAttribute("data-theme", actual);
- root.classList.toggle("dark", actual === "dark");
-}
-
-function storeTheme(theme: Theme): void {
- try {
- localStorage.setItem(STORAGE_KEY, theme);
- } catch {
- // quota exceeded or unavailable
- }
-}
-
-export default function SettingsForm() {
- const [theme, setTheme] = useState(getStoredTheme);
-
- useEffect(() => {
- applyTheme(theme);
- storeTheme(theme);
- }, [theme]);
-
- const handleThemeChange = useCallback((value: Theme) => {
- setTheme(value);
- }, []);
-
- return (
-
- Appearance
-
- Choose your preferred color scheme for the dashboard.
-
-
- {THEME_OPTIONS.map((option) => (
- handleThemeChange(option.value)}
- aria-pressed={theme === option.value}
- >
- {option.label}
-
- ))}
-
-
- );
-}
diff --git a/services/frontend/src/islands/ThemeToggle.tsx b/services/frontend/src/islands/ThemeToggle.tsx
deleted file mode 100644
index de7f607..0000000
--- a/services/frontend/src/islands/ThemeToggle.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-// ─── ThemeToggle.tsx — Standalone dark/light toggle island ──────────────────
-// Reads/writes localStorage key "bete-dashboard-theme", updates data-theme
-// attribute and .dark class on .
-// ─────────────────────────────────────────────────────────────────────────────
-
-import { Moon, Sun } from "lucide-react";
-import { useCallback, useEffect, useState } from "react";
-
-type Theme = "dark" | "light";
-
-const STORAGE_KEY = "bete-dashboard-theme";
-
-function getInitialTheme(): Theme {
- try {
- const stored = localStorage.getItem(STORAGE_KEY);
- if (stored === "light" || stored === "dark") return stored;
- } catch {
- // localStorage unavailable
- }
- return "dark";
-}
-
-function applyTheme(theme: Theme): void {
- const root = document.documentElement;
- root.setAttribute("data-theme", theme);
- if (theme === "dark") {
- root.classList.add("dark");
- } else {
- root.classList.remove("dark");
- }
-}
-
-function storeTheme(theme: Theme): void {
- try {
- localStorage.setItem(STORAGE_KEY, theme);
- } catch {
- // quota exceeded or unavailable
- }
-}
-
-export default function ThemeToggle() {
- const [theme, setTheme] = useState(getInitialTheme);
-
- // Apply theme on mount and whenever it changes
- useEffect(() => {
- applyTheme(theme);
- storeTheme(theme);
- }, [theme]);
-
- const toggle = useCallback(() => {
- setTheme((prev) => (prev === "dark" ? "light" : "dark"));
- }, []);
-
- return (
-
- {theme === "dark" ? (
-
- ) : (
-
- )}
-
- );
-}
diff --git a/services/frontend/src/islands/VoiceControls.tsx b/services/frontend/src/islands/VoiceControls.tsx
deleted file mode 100644
index daa86aa..0000000
--- a/services/frontend/src/islands/VoiceControls.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-// ─── VoiceControls.tsx — Voice bridge island ────────────────────────────────
-// Self-contained: reads/writes useVoiceStore, calls API for connect/disconnect.
-// ─────────────────────────────────────────────────────────────────────────────
-
-import { Radio } from "lucide-react";
-import { useCallback, useState } from "react";
-import { connectVoice, disconnectVoice } from "../shared/api/client.js";
-import { Button, Select } from "../shared/components/index.js";
-import type { Channel, Guild } from "../shared/types/guild.js";
-import { useVoiceStore } from "../stores/voice-store.js";
-
-interface VoiceControlsProps {
- guilds: Guild[];
- voiceChannels: Channel[];
-}
-
-export default function VoiceControls({
- guilds,
- voiceChannels,
-}: VoiceControlsProps) {
- const connected = useVoiceStore((state) => state.connected);
- const guildId = useVoiceStore((state) => state.guildId);
- const channelId = useVoiceStore((state) => state.channelId);
- const setGuildChannel = useVoiceStore((state) => state.setGuildChannel);
- const setConnected = useVoiceStore((state) => state.setConnected);
- const [loading, setLoading] = useState(false);
-
- const handleConnect = useCallback(async () => {
- if (!guildId || !channelId) return;
- setLoading(true);
- try {
- const status = await connectVoice(guildId, channelId);
- setConnected(status.connected);
- } catch {
- // API error handled by global handler
- } finally {
- setLoading(false);
- }
- }, [guildId, channelId, setConnected]);
-
- const handleDisconnect = useCallback(async () => {
- setLoading(true);
- try {
- const status = await disconnectVoice();
- setConnected(status.connected);
- } catch {
- // API error handled by global handler
- } finally {
- setLoading(false);
- }
- }, [setConnected]);
-
- return (
-
-
- Voice Bridge
-
-
- Join a Discord voice channel to monitor live audio.
-
-
-
-
- Guild
- setGuildChannel(e.target.value, channelId)}
- placeholder="Select guild"
- disabled={connected}
- options={guilds.map((g) => ({ value: g.id, label: g.name }))}
- />
-
-
-
- Voice Channel
-
- setGuildChannel(guildId, e.target.value)}
- placeholder="Select channel"
- disabled={connected || !guildId}
- options={voiceChannels.map((c) => ({
- value: c.id,
- label: c.name,
- }))}
- />
-
-
-
-
- {connected ? (
-
- {loading ? "Disconnecting..." : "Disconnect"}
-
- ) : (
-
- {loading ? "Connecting..." : "Join Voice"}
-
- )}
-
-
- );
-}
diff --git a/services/frontend/src/layouts/AuthLayout.astro b/services/frontend/src/layouts/AuthLayout.astro
deleted file mode 100644
index 4ce71b1..0000000
--- a/services/frontend/src/layouts/AuthLayout.astro
+++ /dev/null
@@ -1,15 +0,0 @@
----
-// ─── AuthLayout.astro — Centered authentication layout ─────────────────────
-// Wraps BaseLayout with a full-screen centered flex container and a glass card.
-// Used for login, password reset, and other auth-related pages.
-// ────────────────────────────────────────────────────────────────────────────
-import BaseLayout from "./BaseLayout.astro";
----
-
-
-
-
-
-
-
-
diff --git a/services/frontend/src/layouts/BaseLayout.astro b/services/frontend/src/layouts/BaseLayout.astro
index 97d7584..f033fef 100644
--- a/services/frontend/src/layouts/BaseLayout.astro
+++ b/services/frontend/src/layouts/BaseLayout.astro
@@ -4,17 +4,10 @@
// All interactive content is delegated to React islands via .
// ──────────────────────────────────────────────────────────────────────────────
-// Import global design system styles (Tailwind 4 + tokens)
-import "../styles/base.css";
-
// Font dari design system: Outfit (menggantikan Poppins)
const FONT_HREF =
"https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap";
-// JetBrains Mono untuk code/monospace
-const FONT_HREF_MONO =
- "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap";
-
const FALLBACK_TITLE = "IMPHNEN — Discord Moderation";
const FALLBACK_DESC =
"Real-time Discord AI Moderation & Voice Recording Dashboard";
@@ -28,7 +21,6 @@ const FALLBACK_IMAGE =
-
{FALLBACK_TITLE}
@@ -50,13 +42,10 @@ const FALLBACK_IMAGE =
-
+
-
-
-
-
-
-
diff --git a/services/frontend/src/pages/404.astro b/services/frontend/src/pages/404.astro
deleted file mode 100644
index 24cfeef..0000000
--- a/services/frontend/src/pages/404.astro
+++ /dev/null
@@ -1,20 +0,0 @@
----
-// ─── 404.astro — Not Found page ────────────────────────────────────────────
-// Uses BaseLayout with a centered 404 message, gradient-text heading,
-// and a "Go Home" button linking to the root.
-// ────────────────────────────────────────────────────────────────────────────
-import BaseLayout from "../layouts/BaseLayout.astro";
----
-
-
-
- 404
- Page not found
-
- Go Home
-
-
-
diff --git a/services/frontend/src/pages/index.astro b/services/frontend/src/pages/index.astro
index a5dbee1..e005c1a 100644
--- a/services/frontend/src/pages/index.astro
+++ b/services/frontend/src/pages/index.astro
@@ -1,6 +1,12 @@
---
-// ─── index.astro — Redirect to live dashboard ──────────────────────────────
-// The root path now redirects to /live where the full SPA lives.
-// ────────────────────────────────────────────────────────────────────────────
-return Astro.redirect("/live", 301);
+// ─── index.astro — BETE's main entry point ──────────────────────────────────
+// Shell statis: semua interaktivitas di-delegate ke React island
+// ──────────────────────────────────────────────────────────────────────────────
+import BaseLayout from "../layouts/BaseLayout.astro";
+import AppClient from "../App.client";
---
+
+
+
+
+
diff --git a/services/frontend/src/pages/live.astro b/services/frontend/src/pages/live.astro
deleted file mode 100644
index ed35ea6..0000000
--- a/services/frontend/src/pages/live.astro
+++ /dev/null
@@ -1,14 +0,0 @@
----
-// ─── live.astro — Voice & Media live monitoring page ────────────────────────
-// Full-page layout with Sidebar navigation, Header, and the LiveShell React
-// island that composes all live components (VoiceControls, ActiveSpeakers,
-// AudioVisualizer, NowPlaying).
-// Particles and MascotChat are embedded inside the LiveShell island.
-// ────────────────────────────────────────────────────────────────────────────
-import BaseLayout from "../layouts/BaseLayout.astro";
-import LiveShell from "../islands/LiveShell";
----
-
-
-
-
diff --git a/services/frontend/src/pages/login.astro b/services/frontend/src/pages/login.astro
deleted file mode 100644
index e95d8ad..0000000
--- a/services/frontend/src/pages/login.astro
+++ /dev/null
@@ -1,13 +0,0 @@
----
-// ─── login.astro — Admin authentication page ───────────────────────────────
-// Renders the AuthGuard React island inside the AuthLayout shell.
-// Authenticated users are redirected to /live.
-// ────────────────────────────────────────────────────────────────────────────
-import AuthLayout from "../layouts/AuthLayout.astro";
-import AuthGuard from "../shared/components/auth/AuthGuard.client";
----
-
-
- Login — BETE
-
-
diff --git a/services/frontend/src/pages/messages.astro b/services/frontend/src/pages/messages.astro
deleted file mode 100644
index 66305ce..0000000
--- a/services/frontend/src/pages/messages.astro
+++ /dev/null
@@ -1,28 +0,0 @@
----
-// ─── messages.astro — Standalone messages & moderation page ─────────────────
-// Shows a full-page message feed with sidebar navigation, page header, and
-// real-time WebSocket updates via the MessageFeed React island.
-// ──────────────────────────────────────────────────────────────────────────────
-import BaseLayout from "../layouts/BaseLayout.astro";
-import PageHeader from "../islands/PageHeader.tsx";
-import PageSidebar from "../islands/PageSidebar.tsx";
-import MessageFeed from "../islands/MessageFeed.tsx";
----
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/services/frontend/src/pages/recordings.astro b/services/frontend/src/pages/recordings.astro
deleted file mode 100644
index fa7b37a..0000000
--- a/services/frontend/src/pages/recordings.astro
+++ /dev/null
@@ -1,27 +0,0 @@
----
-// ─── recordings.astro — Voice Recordings page ─────────────────────────────
-// Lists all voice recordings with loading/error/empty/success states.
-// ────────────────────────────────────────────────────────────────────────────
-import BaseLayout from "../layouts/BaseLayout.astro";
-import Sidebar from "../components/sidebar/Sidebar.astro";
-import Header from "../components/header/Header.astro";
-import Card from "../components/ui/Card.astro";
-import ThemeToggle from "../islands/ThemeToggle";
-import RecordingsList from "../islands/RecordingsList";
----
-
-
-
-
diff --git a/services/frontend/src/pages/settings.astro b/services/frontend/src/pages/settings.astro
deleted file mode 100644
index a18f8b9..0000000
--- a/services/frontend/src/pages/settings.astro
+++ /dev/null
@@ -1,27 +0,0 @@
----
-// ─── settings.astro — Dashboard Settings page ─────────────────────────────
-// Appearance settings: theme selection (dark/light/system).
-// ────────────────────────────────────────────────────────────────────────────
-import BaseLayout from "../layouts/BaseLayout.astro";
-import Sidebar from "../components/sidebar/Sidebar.astro";
-import Header from "../components/header/Header.astro";
-import Card from "../components/ui/Card.astro";
-import ThemeToggle from "../islands/ThemeToggle";
-import SettingsForm from "../islands/SettingsForm";
----
-
-
-
-
diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts
index 071c3be..80ae557 100644
--- a/services/frontend/src/shared/api/client.ts
+++ b/services/frontend/src/shared/api/client.ts
@@ -1,7 +1,6 @@
// ─── Shared HTTP client — all API endpoints in one file ──────────────────────
import type { MessageRecord, PageResult } from "@bete/shared";
-import { createLogger } from "../lib/logger.js";
import type {
ChatResponse,
DashboardChannel,
@@ -9,20 +8,29 @@ import type {
DashboardStats,
DashboardUser,
DashboardUserDetail,
-} from "../types/dashboard.js";
-import type { Channel, Guild, GuildVoiceEntry } from "../types/guild.js";
-import type { MediaItem, MediaMode, MediaState } from "../types/media.js";
+} from "../../entities/dashboard/types.js";
+import type {
+ Channel,
+ Guild,
+ GuildVoiceEntry,
+} from "../../entities/guild/types.js";
+import type {
+ MediaItem,
+ MediaMode,
+ MediaState,
+} from "../../entities/media/types.js";
import type {
VoiceRecording,
VoiceRecordingListResponse,
-} from "../types/recording.js";
+} from "../../entities/recording/types.js";
import type {
AdminSettings,
AppConfig,
DashboardTab,
UIState,
-} from "../types/ui-types.js";
-import type { ActiveSpeaker, VoiceStatus } from "../types/voice.js";
+} from "../../entities/ui/types.js";
+import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js";
+import { createLogger } from "../lib/logger.js";
const logger = createLogger("api");
@@ -222,7 +230,9 @@ export function reanalyzeMessage(id: string): Promise {
return request(`/api/messages/${id}/reanalyze`, { method: "POST" });
}
-export function getMessageById(id: string): Promise {
+export function getMessageById(
+ id: string,
+): Promise {
return request(`/api/messages/detail/${id}`);
}
@@ -325,9 +335,7 @@ export function deleteRecording(id: string): Promise {
// ─── Auth ────────────────────────────────────────────────────────────────────
-export function login(
- password: string,
-): Promise<{ ok: boolean; token?: string }> {
+export function login(password: string): Promise<{ ok: boolean; token?: string }> {
return request<{ ok: boolean; token?: string }>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ password }),
diff --git a/services/frontend/src/shared/components/auth/AuthGuard.client.tsx b/services/frontend/src/shared/components/auth/AuthGuard.client.tsx
deleted file mode 100644
index cee66c4..0000000
--- a/services/frontend/src/shared/components/auth/AuthGuard.client.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-// ─── AuthGuard.client.tsx — Astro React island for login page ──────────────
-// Thin wrapper around AuthOverlay that redirects to the main app on success.
-// Loaded via client:load on the login page.
-// ────────────────────────────────────────────────────────────────────────────
-
-import { useCallback } from "react";
-import { AuthOverlay } from "./index";
-
-interface AuthGuardProps {
- redirectTo?: string;
-}
-
-export default function AuthGuard({ redirectTo = "/live" }: AuthGuardProps) {
- const handleAuthenticated = useCallback(() => {
- window.location.href = redirectTo;
- }, [redirectTo]);
-
- return ;
-}
diff --git a/services/frontend/src/shared/hooks/useMascotChat.ts b/services/frontend/src/shared/hooks/useMascotChat.ts
index fa99140..43eaf6f 100644
--- a/services/frontend/src/shared/hooks/useMascotChat.ts
+++ b/services/frontend/src/shared/hooks/useMascotChat.ts
@@ -1,7 +1,7 @@
import { useCallback, useState } from "react";
+import type { ChatResponse } from "../../entities/dashboard/types.js";
import { request } from "../api/client";
import { createLogger } from "../lib/logger";
-import type { ChatResponse } from "../types/dashboard.js";
const logger = createLogger("useMascotChat");
diff --git a/services/frontend/src/shared/hooks/useUIState.ts b/services/frontend/src/shared/hooks/useUIState.ts
index 5ce0d15..2ad1acc 100644
--- a/services/frontend/src/shared/hooks/useUIState.ts
+++ b/services/frontend/src/shared/hooks/useUIState.ts
@@ -1,5 +1,5 @@
import { useCallback } from "react";
-import type { UIState } from "../types/ui-types.js";
+import type { UIState } from "../../entities/ui/types.js";
import { uiStateValidator, useLocalStorage } from "./useLocalStorage";
export function useUIState() {
diff --git a/services/frontend/src/shared/components/CommandPalette.tsx b/services/frontend/src/shared/ui/CommandPalette.tsx
similarity index 100%
rename from services/frontend/src/shared/components/CommandPalette.tsx
rename to services/frontend/src/shared/ui/CommandPalette.tsx
index 6fd39cb..01773d3 100644
--- a/services/frontend/src/shared/components/CommandPalette.tsx
+++ b/services/frontend/src/shared/ui/CommandPalette.tsx
@@ -13,11 +13,11 @@ import {
} from "lucide-react";
import type { KeyboardEvent } from "react";
import {
- type ChangeEvent,
useCallback,
useEffect,
useRef,
useState,
+ type ChangeEvent,
} from "react";
import type { MessageRecord } from "../api/client";
import { request } from "../api/client";
diff --git a/services/frontend/src/shared/components/MobileTabBar.tsx b/services/frontend/src/shared/ui/MobileTabBar.tsx
similarity index 92%
rename from services/frontend/src/shared/components/MobileTabBar.tsx
rename to services/frontend/src/shared/ui/MobileTabBar.tsx
index c89460f..dd17642 100644
--- a/services/frontend/src/shared/components/MobileTabBar.tsx
+++ b/services/frontend/src/shared/ui/MobileTabBar.tsx
@@ -1,7 +1,7 @@
import { motion } from "framer-motion";
import { LayoutDashboard, MessageSquare, Radio, Settings } from "lucide-react";
+import type { DashboardTab } from "../../entities/ui/types.js";
import { cn } from "../lib/utils";
-import type { DashboardTab } from "../types/ui-types.js";
const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
{ id: "messages", label: "Messages", Icon: MessageSquare },
@@ -44,9 +44,7 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
className="absolute -top-px left-1/4 right-1/4 h-0.5 rounded-full bg-primary"
/>
)}
-
+
{label}
{activeTab === id && (
MessageRecord[]);
-
-interface MessageState {
- messages: MessageRecord[];
-}
-
-interface MessageActions {
- setMessages: (updater: MessagesUpdater) => void;
- prependMessage: (message: MessageRecord) => void;
- updateMessage: (id: string, updates: Partial) => void;
- removeMessage: (id: string) => void;
-}
-
-export const useMessageStore = create((set) => ({
- messages: [],
-
- setMessages: (updater) =>
- set((state) => ({
- messages:
- typeof updater === "function" ? updater(state.messages) : updater,
- })),
-
- prependMessage: (message) =>
- set((state) => {
- if (state.messages.some((m) => m.id === message.id)) {
- return state;
- }
- return { messages: [message, ...state.messages] };
- }),
-
- updateMessage: (id, updates) =>
- set((state) => ({
- messages: state.messages.map((m) =>
- m.id === id ? { ...m, ...updates } : m,
- ),
- })),
-
- removeMessage: (id) =>
- set((state) => ({
- messages: state.messages.map((m) =>
- m.id === id ? { ...m, type: "deleted" as const } : m,
- ),
- })),
-}));
diff --git a/services/frontend/src/stores/ui-store.ts b/services/frontend/src/stores/ui-store.ts
deleted file mode 100644
index 2a03871..0000000
--- a/services/frontend/src/stores/ui-store.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { create } from "zustand";
-
-export type DashboardTab =
- | "live"
- | "messages"
- | "recordings"
- | "settings"
- | "dashboard";
-
-type Theme = "dark" | "light" | "system";
-
-interface UIState {
- sidebarCollapsed: boolean;
- activeTab: DashboardTab;
- theme: Theme;
- selectedVoiceGuild: string;
- selectedVoiceChannel: string;
-}
-
-interface UIActions {
- toggleSidebar: () => void;
- setActiveTab: (tab: DashboardTab) => void;
- setTheme: (theme: Theme) => void;
- setSelectedVoiceGuild: (guildId: string) => void;
- setSelectedVoiceChannel: (channelId: string) => void;
-}
-
-export const useUIStore = create((set) => ({
- sidebarCollapsed: false,
- activeTab: "dashboard",
- theme: "system",
- selectedVoiceGuild: "",
- selectedVoiceChannel: "",
-
- toggleSidebar: () =>
- set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
-
- setActiveTab: (tab) => set({ activeTab: tab }),
-
- setTheme: (theme) => set({ theme }),
-
- setSelectedVoiceGuild: (guildId) => set({ selectedVoiceGuild: guildId }),
-
- setSelectedVoiceChannel: (channelId) =>
- set({ selectedVoiceChannel: channelId }),
-}));
diff --git a/services/frontend/src/stores/voice-store.ts b/services/frontend/src/stores/voice-store.ts
deleted file mode 100644
index 035a09c..0000000
--- a/services/frontend/src/stores/voice-store.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { create } from "zustand";
-import type { ActiveSpeaker, VoiceStatus } from "~/shared/types/voice.js";
-
-interface SpeakerEntry extends ActiveSpeaker {
- heardAt: number;
-}
-
-interface VoiceState {
- connected: boolean;
- status: VoiceStatus | null;
- activeSpeakers: SpeakerEntry[];
- guildId: string;
- channelId: string;
-}
-
-interface VoiceActions {
- setConnected: (connected: boolean) => void;
- setStatus: (status: VoiceStatus | null) => void;
- setActiveSpeakers: (speakers: ActiveSpeaker[]) => void;
- updateSpeaker: (update: Partial & { userId: string }) => void;
- setGuildChannel: (guildId: string, channelId: string) => void;
-}
-
-export const useVoiceStore = create((set) => ({
- connected: false,
- status: null,
- activeSpeakers: [],
- guildId: "",
- channelId: "",
-
- setConnected: (connected) => set({ connected }),
-
- setStatus: (status) => set({ status }),
-
- setActiveSpeakers: (speakers) =>
- set({
- activeSpeakers: speakers.map((s) => ({
- ...s,
- heardAt: Date.now(),
- })),
- }),
-
- updateSpeaker: (update) =>
- set((state) => {
- const existing = state.activeSpeakers.find(
- (s) => s.userId === update.userId,
- );
- if (existing) {
- return {
- activeSpeakers: state.activeSpeakers.map((s) =>
- s.userId === update.userId
- ? { ...s, ...update, heardAt: Date.now() }
- : s,
- ),
- };
- }
- return {
- activeSpeakers: [
- ...state.activeSpeakers,
- {
- ...update,
- username: "",
- avatar: "",
- speaking: false,
- heardAt: Date.now(),
- } as SpeakerEntry,
- ],
- };
- }),
-
- setGuildChannel: (guildId, channelId) => set({ guildId, channelId }),
-}));
diff --git a/services/frontend/src/styles.css b/services/frontend/src/styles.css
new file mode 100644
index 0000000..e60ba36
--- /dev/null
+++ b/services/frontend/src/styles.css
@@ -0,0 +1,199 @@
+@import "tailwindcss";
+
+@layer base {
+ /* ── Light theme (default) ───────────────────────────────────────────── */
+ :root {
+ --background: 1 0 0;
+ --foreground: 0.141 0.005 285.823;
+ --card: 1 0 0;
+ --card-foreground: 0.141 0.005 285.823;
+ --primary: 0.623 0.214 259.815;
+ --primary-soft: 0.92 0.04 259.815;
+ --primary-foreground: 0.97 0.014 254.604;
+ --secondary: 0.967 0.001 286.375;
+ --secondary-foreground: 0.21 0.006 285.885;
+ --muted: 0.967 0.001 286.375;
+ --muted-foreground: 0.552 0.016 285.938;
+ --accent: 0.967 0.001 286.375;
+ --accent-foreground: 0.21 0.006 285.885;
+ --destructive: 0.577 0.245 27.325;
+ --destructive-foreground: 0.97 0.014 254.604;
+ --border: 0.92 0.004 286.32;
+ --input: 0.92 0.004 286.32;
+ --ring: 0.623 0.214 259.815;
+ --radius: 1rem;
+ --primary-glow: 0.623 0.214 259.815 / 0.15;
+ --accent-glow: 0.552 0.016 285.938 / 0.15;
+ --card-shadow: 0.92 0.004 286.32 / 0.3;
+ --particle-primary: 0.623 0.214 259.815 / 0.1;
+ --particle-secondary: 0.552 0.016 285.938 / 0.1;
+ --brand-gradient-from: var(--primary);
+ --brand-gradient-to: 0.623 0.2 200;
+ --scrollbar-track: 0.967 0.001 286.375;
+ --scrollbar-thumb: 0.92 0.004 286.32;
+ }
+
+ /* ── Dark theme ──────────────────────────────────────────────────────── */
+ [data-theme="dark"] {
+ --background: 0.147 0.004 285.823;
+ --foreground: 0.92 0.004 286.32;
+ --card: 0.162 0.008 286.034;
+ --card-foreground: 0.92 0.004 286.32;
+ --primary: 0.623 0.214 259.815;
+ --primary-soft: 0.3 0.04 259.815;
+ --primary-foreground: 0.97 0.014 254.604;
+ --secondary: 0.2 0.008 286.034;
+ --secondary-foreground: 0.85 0.008 286.034;
+ --muted: 0.2 0.008 286.034;
+ --muted-foreground: 0.6 0.016 285.938;
+ --accent: 0.2 0.008 286.034;
+ --accent-foreground: 0.85 0.008 286.034;
+ --destructive: 0.577 0.245 27.325;
+ --destructive-foreground: 0.97 0.014 254.604;
+ --border: 0.25 0.008 286.034;
+ --input: 0.25 0.008 286.034;
+ --ring: 0.623 0.214 259.815;
+ --primary-glow: 0.623 0.214 259.815 / 0.08;
+ --accent-glow: 0.552 0.016 285.938 / 0.08;
+ --card-shadow: 0 0 0 / 0.5;
+ --particle-primary: 0.623 0.214 259.815 / 0.06;
+ --particle-secondary: 0.552 0.016 285.938 / 0.06;
+ --brand-gradient-from: var(--primary);
+ --brand-gradient-to: 0.7 0.2 220;
+ --scrollbar-track: 0.147 0.004 285.823;
+ --scrollbar-thumb: 0.3 0.008 286.034;
+ }
+
+ /* ── Base styles ─────────────────────────────────────────────────────── */
+ [data-theme] {
+ border-color: oklch(var(--border));
+ transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
+ }
+
+ body {
+ background-color: oklch(var(--background));
+ color: oklch(var(--foreground));
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-family: Outfit, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ }
+
+ html,
+ body,
+ #root {
+ min-height: 100%;
+ }
+
+ /* ── Scrollbar styling ───────────────────────────────────────────────── */
+ ::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+ ::-webkit-scrollbar-track {
+ background: oklch(var(--scrollbar-track));
+ }
+ ::-webkit-scrollbar-thumb {
+ background: oklch(var(--scrollbar-thumb));
+ border-radius: 999px;
+ }
+ ::-webkit-scrollbar-thumb:hover {
+ background: oklch(var(--muted-foreground) / 0.5);
+ }
+
+ /* ── Focus ring consistency ──────────────────────────────────────────── */
+ :focus-visible {
+ outline: 2px solid oklch(var(--ring));
+ outline-offset: 2px;
+ }
+}
+
+@layer utilities {
+ .glass-card {
+ @apply backdrop-blur-sm rounded-xl;
+ background-color: oklch(var(--card) / 0.7);
+ border: 1px solid oklch(var(--border));
+ }
+
+ .grid-pattern {
+ background-image:
+ linear-gradient(oklch(var(--border) / 0.3) 1px, transparent 1px),
+ linear-gradient(90deg, oklch(var(--border) / 0.3) 1px, transparent 1px);
+ background-size: 40px 40px;
+ }
+
+ .gradient-text {
+ @apply bg-clip-text text-transparent;
+ background-image: linear-gradient(to right, oklch(var(--brand-gradient-from)), oklch(var(--brand-gradient-to)));
+ }
+
+ .animate-fade-in-up {
+ animation: fadeInUp 0.5s ease-out;
+ }
+
+ .animate-fade-in {
+ animation: fadeIn 0.3s ease-out;
+ }
+}
+
+@keyframes bar-pulse {
+ 0%, 100% {
+ transform: scaleY(0.8);
+ }
+ 50% {
+ transform: scaleY(1.2);
+ }
+}
+
+@keyframes shimmer {
+ 0% {
+ background-position: -200% 0;
+ }
+ 100% {
+ background-position: 200% 0;
+ }
+}
+
+@keyframes fadeInUp {
+ from { opacity: 0; transform: translateY(20px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes glow-pulse {
+ 0%, 100% { opacity: 0.4; transform: scale(1); }
+ 50% { opacity: 0.8; transform: scale(1.05); }
+}
+
+.animate-bar-pulse {
+ animation: bar-pulse 0.4s ease-in-out infinite;
+ transform-origin: bottom;
+}
+
+.animate-shimmer {
+ 90deg,
+ oklch(var(--border) / 0.5) 0%,
+ oklch(var(--muted)) 40%,
+ oklch(var(--border) / 0.5) 80%,
+ oklch(var(--border) / 0.7) 100%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.5s ease-in-out infinite;
+}
+
+.animate-glow-pulse {
+ animation: glow-pulse 4s ease-in-out infinite;
+}
+
+/* ── Reduced motion ──────────────────────────────────────────────────── */
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/services/frontend/src/styles/base.css b/services/frontend/src/styles/base.css
deleted file mode 100644
index ffad835..0000000
--- a/services/frontend/src/styles/base.css
+++ /dev/null
@@ -1,352 +0,0 @@
-/* ═══════════════════════════════════════════════════════════════════════════
- base.css — BETE Design System
- Tailwind 4 CSS-first configuration + design tokens + base styles
- ═══════════════════════════════════════════════════════════════════════════ */
-
-@import "tailwindcss/index.css";
-
-/* ───────────────────────────────────────────────────────────────────────────
- 1. Design Tokens — Dark Theme (default)
- All colors in OKLCH space: lightness chroma hue
- ─────────────────────────────────────────────────────────────────────────── */
-:root {
- /* ── Surfaces ────────────────────────────────────────────────────────── */
- --background: 0.147 0.004 285.823;
- --foreground: 0.92 0.004 286.32;
- --card: 0.162 0.008 286.034;
- --card-foreground: 0.92 0.004 286.32;
- --muted: 0.2 0.008 286.034;
- --muted-foreground: 0.6 0.016 285.938;
-
- /* ── Brand / Interaction ─────────────────────────────────────────────── */
- --primary: 0.623 0.214 259.815;
- --primary-soft: 0.3 0.04 259.815;
- --primary-foreground: 0.97 0.014 254.604;
- --secondary: 0.2 0.008 286.034;
- --secondary-foreground:0.85 0.008 286.034;
- --accent: 0.2 0.008 286.034;
- --accent-foreground: 0.85 0.008 286.034;
-
- /* ── Severity ────────────────────────────────────────────────────────── */
- --destructive: 0.577 0.245 27.325;
- --destructive-foreground: 0.97 0.014 254.604;
- --success: 0.62 0.18 150;
- --success-foreground: 0.95 0.02 150;
- --warning: 0.7 0.18 85;
- --warning-foreground: 0.95 0.02 85;
- --info: 0.6 0.15 240;
- --info-foreground: 0.95 0.02 240;
-
- /* ── Borders & Rings ─────────────────────────────────────────────────── */
- --border: 0.25 0.008 286.034;
- --input: 0.25 0.008 286.034;
- --ring: 0.623 0.214 259.815;
-
- /* ── Glows & Effects ─────────────────────────────────────────────────── */
- --primary-glow: 0.623 0.214 259.815 / 0.08;
- --accent-glow: 0.552 0.016 285.938 / 0.08;
- --card-shadow: 0 0 0 / 0.5;
- --particle-primary: 0.623 0.214 259.815 / 0.06;
- --particle-secondary: 0.552 0.016 285.938 / 0.06;
-
- /* ── Glass ───────────────────────────────────────────────────────────── */
- --glass-bg: 0.162 0.008 286.034 / 0.4;
- --glass-border: 0.25 0.008 286.034 / 0.2;
- --glass-shadow: 0 0 0 / 0.3;
-
- /* ── Gradients ───────────────────────────────────────────────────────── */
- --brand-gradient-from: var(--primary);
- --brand-gradient-to: 0.7 0.2 220;
-
- /* ── Scrollbar ───────────────────────────────────────────────────────── */
- --scrollbar-track: 0.147 0.004 285.823;
- --scrollbar-thumb: 0.3 0.008 286.034;
-
- /* ── Spacing Scale (static) ──────────────────────────────────────────── */
- --spacing-xs: 0.25rem;
- --spacing-sm: 0.5rem;
- --spacing-md: 1rem;
- --spacing-lg: 1.5rem;
- --spacing-xl: 2rem;
- --spacing-2xl: 3rem;
-
- /* ── Border Radius ───────────────────────────────────────────────────── */
- --radius: 1rem;
-
- /* ── Z-Index Registry (static) ───────────────────────────────────────── */
- --z-base: 1;
- --z-header: 10;
- --z-overlay: 50;
- --z-modal: 100;
- --z-toast: 50;
-
- /* ── Duration & Easing (static) ──────────────────────────────────────── */
- --duration-fast: 150ms;
- --duration-normal: 300ms;
- --duration-slow: 500ms;
- --ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
- --ease-in: cubic-bezier(0.55, 0.085, 0.68, 0.53);
- --ease-in-out: cubic-bezier(0.455, 0.03, 0.515, 0.955);
-}
-
-/* ───────────────────────────────────────────────────────────────────────────
- 2. Design Tokens — Light Theme
- ─────────────────────────────────────────────────────────────────────────── */
-[data-theme="light"] {
- /* ── Surfaces ────────────────────────────────────────────────────────── */
- --background: 1 0 0;
- --foreground: 0.141 0.005 285.823;
- --card: 1 0 0;
- --card-foreground: 0.141 0.005 285.823;
- --muted: 0.967 0.001 286.375;
- --muted-foreground: 0.552 0.016 285.938;
-
- /* ── Brand / Interaction ─────────────────────────────────────────────── */
- --primary: 0.623 0.214 259.815;
- --primary-soft: 0.92 0.04 259.815;
- --primary-foreground: 0.97 0.014 254.604;
- --secondary: 0.967 0.001 286.375;
- --secondary-foreground:0.21 0.006 285.885;
- --accent: 0.967 0.001 286.375;
- --accent-foreground: 0.21 0.006 285.885;
-
- /* ── Severity ────────────────────────────────────────────────────────── */
- --destructive: 0.577 0.245 27.325;
- --destructive-foreground: 0.97 0.014 254.604;
- --success: 0.55 0.18 150;
- --success-foreground: 0.2 0.06 150;
- --warning: 0.65 0.18 85;
- --warning-foreground: 0.3 0.08 85;
- --info: 0.55 0.15 240;
- --info-foreground: 0.2 0.06 240;
-
- /* ── Borders & Rings ─────────────────────────────────────────────────── */
- --border: 0.92 0.004 286.32;
- --input: 0.92 0.004 286.32;
- --ring: 0.623 0.214 259.815;
-
- /* ── Glows & Effects ─────────────────────────────────────────────────── */
- --primary-glow: 0.623 0.214 259.815 / 0.15;
- --accent-glow: 0.552 0.016 285.938 / 0.15;
- --card-shadow: 0.92 0.004 286.32 / 0.3;
- --particle-primary: 0.623 0.214 259.815 / 0.1;
- --particle-secondary: 0.552 0.016 285.938 / 0.1;
-
- /* ── Glass ───────────────────────────────────────────────────────────── */
- --glass-bg: 1 0 0 / 0.4;
- --glass-border: 0.92 0.004 286.32 / 0.3;
- --glass-shadow: 0.92 0.004 286.32 / 0.15;
-
- /* ── Gradients ───────────────────────────────────────────────────────── */
- --brand-gradient-from: var(--primary);
- --brand-gradient-to: 0.623 0.2 200;
-
- /* ── Scrollbar ───────────────────────────────────────────────────────── */
- --scrollbar-track: 0.967 0.001 286.375;
- --scrollbar-thumb: 0.92 0.004 286.32;
-}
-
-/* ───────────────────────────────────────────────────────────────────────────
- 3. Tailwind 4 @theme — Map design tokens to utility classes
- See: https://tailwindcss.com/docs/theme
- ─────────────────────────────────────────────────────────────────────────── */
-@theme {
- /* ── Semantic Colors ─────────────────────────────────────────────────── */
- --color-background: oklch(var(--background));
- --color-foreground: oklch(var(--foreground));
- --color-card: oklch(var(--card));
- --color-card-foreground: oklch(var(--card-foreground));
- --color-muted: oklch(var(--muted));
- --color-muted-foreground: oklch(var(--muted-foreground));
-
- --color-primary: oklch(var(--primary));
- --color-primary-soft: oklch(var(--primary-soft));
- --color-primary-foreground: oklch(var(--primary-foreground));
- --color-secondary: oklch(var(--secondary));
- --color-secondary-foreground: oklch(var(--secondary-foreground));
- --color-accent: oklch(var(--accent));
- --color-accent-foreground: oklch(var(--accent-foreground));
-
- --color-destructive: oklch(var(--destructive));
- --color-destructive-foreground: oklch(var(--destructive-foreground));
- --color-success: oklch(var(--success));
- --color-success-foreground: oklch(var(--success-foreground));
- --color-warning: oklch(var(--warning));
- --color-warning-foreground: oklch(var(--warning-foreground));
- --color-info: oklch(var(--info));
- --color-info-foreground: oklch(var(--info-foreground));
-
- --color-border: oklch(var(--border));
- --color-input: oklch(var(--input));
- --color-ring: oklch(var(--ring));
-
- --color-primary-glow: oklch(var(--primary-glow));
- --color-accent-glow: oklch(var(--accent-glow));
- --color-card-shadow: oklch(var(--card-shadow));
-
- /* ── Fonts ───────────────────────────────────────────────────────────── */
- --font-sans: "Outfit", ui-sans-serif, system-ui, -apple-system,
- BlinkMacSystemFont, "Segoe UI", sans-serif;
-
- /* ── Border Radius ───────────────────────────────────────────────────── */
- --radius-sm: calc(var(--radius) - 4px);
- --radius-md: calc(var(--radius) - 2px);
- --radius-lg: var(--radius);
-
- /* ── Animations ──────────────────────────────────────────────────────── */
- --animate-fade-in: fadeIn 0.3s ease-out;
- --animate-fade-in-up: fadeInUp 0.5s ease-out;
- --animate-shimmer: shimmer 1.5s ease-in-out infinite;
- --animate-scale-in: scaleIn 0.3s ease-out;
- --animate-glow-pulse: glowPulse 3s ease-in-out infinite;
- --animate-slide-up: slideUp 0.3s ease-out;
- --animate-slide-down: slideDown 0.3s ease-out;
- --animate-bar-pulse: barPulse 0.4s ease-in-out infinite;
-}
-
-/* ───────────────────────────────────────────────────────────────────────────
- 4. Keyframes
- ─────────────────────────────────────────────────────────────────────────── */
-@keyframes fadeIn {
- from { opacity: 0; }
- to { opacity: 1; }
-}
-
-@keyframes fadeInUp {
- from { opacity: 0; transform: translateY(20px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-@keyframes shimmer {
- 0% { background-position: -200% 0; }
- 100% { background-position: 200% 0; }
-}
-
-@keyframes scaleIn {
- from { opacity: 0; transform: scale(0.95); }
- to { opacity: 1; transform: scale(1); }
-}
-
-@keyframes glowPulse {
- 0%, 100% { opacity: 0.4; }
- 50% { opacity: 0.8; }
-}
-
-@keyframes slideUp {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-@keyframes slideDown {
- from { opacity: 0; transform: translateY(-10px); }
- to { opacity: 1; transform: translateY(0); }
-}
-
-@keyframes barPulse {
- 0%, 100% { transform: scaleY(0.8); }
- 50% { transform: scaleY(1.2); }
-}
-
-/* ───────────────────────────────────────────────────────────────────────────
- 5. @layer base — Global defaults
- ─────────────────────────────────────────────────────────────────────────── */
-@layer base {
- /* ── Base element defaults ───────────────────────────────────────────── */
- [data-theme] {
- border-color: oklch(var(--border));
- transition:
- background-color var(--duration-normal) var(--ease-out),
- color var(--duration-normal) var(--ease-out),
- border-color var(--duration-normal) var(--ease-out),
- box-shadow var(--duration-normal) var(--ease-out);
- }
-
- body {
- background-color: oklch(var(--background));
- color: oklch(var(--foreground));
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- font-family: "Outfit", ui-sans-serif, system-ui, -apple-system,
- BlinkMacSystemFont, "Segoe UI", sans-serif;
- }
-
- html,
- body,
- #root {
- min-height: 100%;
- }
-
- /* ── Scrollbar ──────────────────────────────────────────────────────── */
- ::-webkit-scrollbar {
- width: 6px;
- height: 6px;
- }
-
- ::-webkit-scrollbar-track {
- background: oklch(var(--scrollbar-track));
- }
-
- ::-webkit-scrollbar-thumb {
- background: oklch(var(--scrollbar-thumb));
- border-radius: 999px;
- }
-
- ::-webkit-scrollbar-thumb:hover {
- background: oklch(var(--muted-foreground) / 0.5);
- }
-
- /* ── Focus ring ─────────────────────────────────────────────────────── */
- :focus-visible {
- outline: 2px solid oklch(var(--ring));
- outline-offset: 2px;
- }
-}
-
-/* ───────────────────────────────────────────────────────────────────────────
- 6. @layer components — Design system utilities
- ─────────────────────────────────────────────────────────────────────────── */
-@layer components {
- /* ── Glass surface ───────────────────────────────────────────────────── */
- .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));
- }
-
- /* ── Stronger glass surface (less transparent) ───────────────────────── */
- .glass-strong {
- background-color: oklch(var(--glass-bg) / 0.7);
- border: 1px solid oklch(var(--glass-border));
- backdrop-filter: blur(16px);
- -webkit-backdrop-filter: blur(16px);
- box-shadow: 0 4px 32px oklch(var(--glass-shadow));
- }
-
- /* ── Gradient text ───────────────────────────────────────────────────── */
- .gradient-text {
- -webkit-background-clip: text;
- background-clip: text;
- color: transparent;
- background-image: linear-gradient(
- to right,
- oklch(var(--brand-gradient-from)),
- oklch(var(--brand-gradient-to))
- );
- }
-}
-
-/* ───────────────────────────────────────────────────────────────────────────
- 7. @media (prefers-reduced-motion: reduce)
- ─────────────────────────────────────────────────────────────────────────── */
-@media (prefers-reduced-motion: reduce) {
- *,
- *::before,
- *::after {
- animation-duration: 0.01ms !important;
- animation-iteration-count: 1 !important;
- transition-duration: 0.01ms !important;
- scroll-behavior: auto !important;
- }
-}
diff --git a/services/frontend/src/widgets/DashboardLayout.tsx b/services/frontend/src/widgets/DashboardLayout.tsx
new file mode 100644
index 0000000..6624b25
--- /dev/null
+++ b/services/frontend/src/widgets/DashboardLayout.tsx
@@ -0,0 +1,85 @@
+import { AnimatePresence, motion } from "framer-motion";
+import type { ReactNode } from "react";
+import type { MessageRecord } from "../entities/message/types.js";
+import type { DashboardTab } from "../entities/ui/types.js";
+import type { VoiceStatus } from "../entities/voice/types.js";
+import type { ThemeMode } from "../hooks/useTheme";
+import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
+import type { WsStatus } from "../shared/ws/socket";
+import { Header } from "./Header";
+import { ParticleBackground } from "./particles/ParticleBackground";
+import { Sidebar } from "./Sidebar";
+
+interface DashboardLayoutProps {
+ activeTab: DashboardTab;
+ wsStatus: WsStatus;
+ voiceStatus: VoiceStatus;
+ themeMode: ThemeMode;
+ isDark: boolean;
+ onTabChange: (tab: DashboardTab) => void;
+ onThemeToggle: () => void;
+ children: ReactNode;
+ recentMessages?: MessageRecord[];
+ guildId?: string;
+ channelId?: string;
+ notificationCount?: number;
+}
+
+export function DashboardLayout({
+ activeTab,
+ wsStatus,
+ voiceStatus,
+ themeMode,
+ isDark,
+ onTabChange,
+ onThemeToggle,
+ children,
+ recentMessages = [],
+ guildId,
+ channelId,
+ notificationCount = 0,
+}: DashboardLayoutProps) {
+ return (
+
+ {/* Background layers */}
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/services/frontend/src/shared/components/Header.tsx b/services/frontend/src/widgets/Header.tsx
similarity index 88%
rename from services/frontend/src/shared/components/Header.tsx
rename to services/frontend/src/widgets/Header.tsx
index ff4c81e..384052d 100644
--- a/services/frontend/src/shared/components/Header.tsx
+++ b/services/frontend/src/widgets/Header.tsx
@@ -1,12 +1,12 @@
import { motion } from "framer-motion";
import { Moon, Sun, Wifi, WifiOff } from "lucide-react";
-import { fadeSlideUp } from "../hooks/useFramerStagger";
+import type { DashboardTab } from "../entities/ui/types.js";
+import type { VoiceStatus } from "../entities/voice/types.js";
import type { ThemeMode } from "../hooks/useTheme";
-import { cn } from "../lib/utils";
-import type { DashboardTab } from "../types/ui-types.js";
-import type { VoiceStatus } from "../types/voice.js";
-import type { WsStatus } from "../ws/socket";
-import { Badge } from "./badge";
+import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
+import { cn } from "../shared/lib/utils";
+import { Badge } from "../shared/ui";
+import type { WsStatus } from "../shared/ws/socket";
const titles: Record = {
messages: "Messages & Moderation",
@@ -19,8 +19,7 @@ const subtitles: Record = {
messages: "Capture, analyse, and moderate Discord messages.",
live: "Join voice channels, play media, stream audio, and browse recordings.",
dashboard: "Server statistics, user profiles, and AI moderation overview.",
- settings:
- "Manage dashboard visibility, runtime configuration, and authentication.",
+ settings: "Manage dashboard visibility, runtime configuration, and authentication.",
};
interface HeaderProps {
@@ -70,14 +69,7 @@ function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) {
);
}
-export function Header({
- activeTab,
- wsStatus,
- voiceStatus,
- themeMode,
- isDark,
- onThemeToggle,
-}: HeaderProps) {
+export function Header({ activeTab, wsStatus, voiceStatus, themeMode, isDark, onThemeToggle }: HeaderProps) {
return (
@@ -119,9 +111,7 @@ export function Header({
) : (
)}
-
- {isDark ? "Light" : "Dark"}
-
+
{isDark ? "Light" : "Dark"}
{/* WS Badge */}
diff --git a/services/frontend/src/shared/components/Sidebar.tsx b/services/frontend/src/widgets/Sidebar.tsx
similarity index 93%
rename from services/frontend/src/shared/components/Sidebar.tsx
rename to services/frontend/src/widgets/Sidebar.tsx
index 0945bdc..4a44ded 100644
--- a/services/frontend/src/shared/components/Sidebar.tsx
+++ b/services/frontend/src/widgets/Sidebar.tsx
@@ -6,10 +6,10 @@ import {
Radio,
Settings,
} from "lucide-react";
-import { useMascotChat } from "../hooks/useMascotChat";
-import { cn } from "../lib/utils";
-import type { MessageRecord } from "../types/message.js";
-import type { DashboardTab } from "../types/ui-types.js";
+import type { MessageRecord } from "../entities/message/types.js";
+import type { DashboardTab } from "../entities/ui/types.js";
+import { useMascotChat } from "../shared/hooks/useMascotChat";
+import { cn } from "../shared/lib/utils";
import { MascotChatbot } from "./mascot/MascotChatbot";
import { MascotImage } from "./mascot/MascotImage";
@@ -115,8 +115,7 @@ export function Sidebar({
>
{!collapsed &&
{item.label} }
- {!collapsed &&
- item.id === "messages" &&
+ {!collapsed && item.id === "messages" &&
notificationCount !== undefined &&
notificationCount > 0 && (
@@ -124,8 +123,7 @@ export function Sidebar({
)}
{/* Collapsed badge — top-right dot */}
- {collapsed &&
- item.id === "messages" &&
+ {collapsed && item.id === "messages" &&
notificationCount !== undefined &&
notificationCount > 0 && (
diff --git a/services/frontend/src/shared/components/mascot/MascotChatbot.tsx b/services/frontend/src/widgets/mascot/MascotChatbot.tsx
similarity index 99%
rename from services/frontend/src/shared/components/mascot/MascotChatbot.tsx
rename to services/frontend/src/widgets/mascot/MascotChatbot.tsx
index 69d65f1..6fe1faa 100644
--- a/services/frontend/src/shared/components/mascot/MascotChatbot.tsx
+++ b/services/frontend/src/widgets/mascot/MascotChatbot.tsx
@@ -1,8 +1,8 @@
import { AnimatePresence, motion } from "framer-motion";
import { Maximize2, MessageCircle, Minimize2, Send, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
-import { createLogger } from "../../lib/logger.js";
-import { cn } from "../../lib/utils";
+import { createLogger } from "../../shared/lib/logger.js";
+import { cn } from "../../shared/lib/utils";
const logger = createLogger("mascot-chat");
diff --git a/services/frontend/src/shared/components/mascot/MascotImage.tsx b/services/frontend/src/widgets/mascot/MascotImage.tsx
similarity index 96%
rename from services/frontend/src/shared/components/mascot/MascotImage.tsx
rename to services/frontend/src/widgets/mascot/MascotImage.tsx
index 50b5f55..2f1eed4 100644
--- a/services/frontend/src/shared/components/mascot/MascotImage.tsx
+++ b/services/frontend/src/widgets/mascot/MascotImage.tsx
@@ -53,9 +53,7 @@ export function MascotImage({
return (
{imgError ? (
-
+
) : (
diff --git a/services/frontend/src/shared/components/particles/ParticleBackground.tsx b/services/frontend/src/widgets/particles/ParticleBackground.tsx
similarity index 72%
rename from services/frontend/src/shared/components/particles/ParticleBackground.tsx
rename to services/frontend/src/widgets/particles/ParticleBackground.tsx
index 395405b..21f6638 100644
--- a/services/frontend/src/shared/components/particles/ParticleBackground.tsx
+++ b/services/frontend/src/widgets/particles/ParticleBackground.tsx
@@ -20,19 +20,14 @@ export function ParticleBackground() {
style={{ zIndex: -1 }}
>
{/* Top-right glow orb */}
-
{/* Bottom-left glow orb */}