diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 34efb2c..5d573ff 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -267,6 +267,9 @@ 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
@@ -6047,6 +6050,24 @@ 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==}
@@ -11514,4 +11535,9 @@ 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 f964fe0..03d7cc4 100644
--- a/services/frontend/.astro/types.d.ts
+++ b/services/frontend/.astro/types.d.ts
@@ -1 +1,2 @@
///
+///
\ No newline at end of file
diff --git a/services/frontend/astro.config.mjs b/services/frontend/astro.config.mjs
index 3bae84b..332f594 100644
--- a/services/frontend/astro.config.mjs
+++ b/services/frontend/astro.config.mjs
@@ -36,10 +36,8 @@ 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 934f9da..0703268 100644
--- a/services/frontend/package.json
+++ b/services/frontend/package.json
@@ -23,7 +23,8 @@
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
- "tailwind-merge": "^3.6.0"
+ "tailwind-merge": "^3.6.0",
+ "zustand": "^5.0.14"
},
"devDependencies": {
"@biomejs/biome": "latest",
diff --git a/services/frontend/src/App.client.tsx b/services/frontend/src/App.client.tsx
deleted file mode 100644
index 2ec3bd5..0000000
--- a/services/frontend/src/App.client.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-// ─── 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
deleted file mode 100644
index ad3b3b9..0000000
--- a/services/frontend/src/App.tsx
+++ /dev/null
@@ -1,512 +0,0 @@
-// ─── 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
new file mode 100644
index 0000000..eaa1884
--- /dev/null
+++ b/services/frontend/src/components/header/Header.astro
@@ -0,0 +1,34 @@
+---
+// ─── 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
new file mode 100644
index 0000000..29484f0
--- /dev/null
+++ b/services/frontend/src/components/sidebar/NavItem.astro
@@ -0,0 +1,60 @@
+---
+// ─── 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
new file mode 100644
index 0000000..34fa960
--- /dev/null
+++ b/services/frontend/src/components/sidebar/Sidebar.astro
@@ -0,0 +1,93 @@
+---
+// ─── 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
new file mode 100644
index 0000000..bdc19b1
--- /dev/null
+++ b/services/frontend/src/components/states/EmptyState.astro
@@ -0,0 +1,35 @@
+---
+// ─── 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
new file mode 100644
index 0000000..f3a7cae
--- /dev/null
+++ b/services/frontend/src/components/states/ErrorState.astro
@@ -0,0 +1,51 @@
+---
+// ─── 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
new file mode 100644
index 0000000..486f801
--- /dev/null
+++ b/services/frontend/src/components/states/LoadingSkeleton.astro
@@ -0,0 +1,60 @@
+---
+// ─── 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
new file mode 100644
index 0000000..0b6ab54
--- /dev/null
+++ b/services/frontend/src/components/ui/Badge.astro
@@ -0,0 +1,106 @@
+---
+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
new file mode 100644
index 0000000..886a0db
--- /dev/null
+++ b/services/frontend/src/components/ui/Button.astro
@@ -0,0 +1,146 @@
+---
+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
new file mode 100644
index 0000000..2ab6f4f
--- /dev/null
+++ b/services/frontend/src/components/ui/Card.astro
@@ -0,0 +1,101 @@
+---
+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
new file mode 100644
index 0000000..1aa0cb3
--- /dev/null
+++ b/services/frontend/src/components/ui/SeverityBadge.astro
@@ -0,0 +1,97 @@
+---
+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
new file mode 100644
index 0000000..b1a8e20
--- /dev/null
+++ b/services/frontend/src/components/ui/Skeleton.astro
@@ -0,0 +1,74 @@
+---
+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
new file mode 100644
index 0000000..13c0aba
--- /dev/null
+++ b/services/frontend/src/components/ui/Spinner.astro
@@ -0,0 +1,60 @@
+---
+export interface Props {
+ size?: "sm" | "default" | "lg";
+ class?: string;
+}
+
+const { size = "default", class: className = "" } = Astro.props;
+---
+
+
+
+
+
+
diff --git a/services/frontend/src/features/admin/AdminPanel.tsx b/services/frontend/src/features/admin/AdminPanel.tsx
deleted file mode 100644
index fb3aa04..0000000
--- a/services/frontend/src/features/admin/AdminPanel.tsx
+++ /dev/null
@@ -1,295 +0,0 @@
-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/features/dashboard/components/ChannelProfileDetail.tsx b/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx
deleted file mode 100644
index fb44b92..0000000
--- a/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-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
deleted file mode 100644
index 2f82463..0000000
--- a/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-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
deleted file mode 100644
index 11a5537..0000000
--- a/services/frontend/src/features/dashboard/components/DashboardStats.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-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
deleted file mode 100644
index 632b252..0000000
--- a/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-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
deleted file mode 100644
index 66b51d2..0000000
--- a/services/frontend/src/features/dashboard/components/UserSummaryList.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-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
deleted file mode 100644
index ee983ef..0000000
--- a/services/frontend/src/features/dashboard/hooks/useDashboard.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-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
deleted file mode 100644
index 9d4ecb8..0000000
--- a/services/frontend/src/features/dashboard/index.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-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
deleted file mode 100644
index 96e80ce..0000000
--- a/services/frontend/src/features/live/components/ActiveSpeakers.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-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
deleted file mode 100644
index ab6c113..0000000
--- a/services/frontend/src/features/live/components/AudioVisualizer.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-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
deleted file mode 100644
index e83b52d..0000000
--- a/services/frontend/src/features/live/components/MicLevelMeter.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-// ─── 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
deleted file mode 100644
index 1b19855..0000000
--- a/services/frontend/src/features/live/components/MusicSubPanel.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-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
deleted file mode 100644
index 2ea9d74..0000000
--- a/services/frontend/src/features/live/components/NowPlaying.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-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
deleted file mode 100644
index 43ecfad..0000000
--- a/services/frontend/src/features/live/components/RecordingsSubPanel.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-// ─── 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
deleted file mode 100644
index 05c8dbd..0000000
--- a/services/frontend/src/features/live/components/ScreenSubPanel.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-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
deleted file mode 100644
index 47381de..0000000
--- a/services/frontend/src/features/live/components/VoiceConnectionCard.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-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
deleted file mode 100644
index 5a7418b..0000000
--- a/services/frontend/src/features/live/components/WaveformPlayer.tsx
+++ /dev/null
@@ -1,256 +0,0 @@
-// ─── 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
deleted file mode 100644
index 915f369..0000000
--- a/services/frontend/src/features/live/hooks/useMediaControl.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-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
deleted file mode 100644
index 318f5f0..0000000
--- a/services/frontend/src/features/live/hooks/useVoiceControl.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-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
deleted file mode 100644
index 60f5783..0000000
--- a/services/frontend/src/features/live/index.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-// ─── 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
deleted file mode 100644
index c01b629..0000000
--- a/services/frontend/src/features/messages/components/ImageGrid.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-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
deleted file mode 100644
index 8fcf787..0000000
--- a/services/frontend/src/features/messages/components/MessageCard.tsx
+++ /dev/null
@@ -1,577 +0,0 @@
-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
deleted file mode 100644
index 2064fd3..0000000
--- a/services/frontend/src/features/messages/components/MessageFeed.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-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
deleted file mode 100644
index a11afe6..0000000
--- a/services/frontend/src/features/messages/components/ModerationAlertListener.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-// ─── 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/features/messages/index.tsx b/services/frontend/src/features/messages/index.tsx
deleted file mode 100644
index b52b1fd..0000000
--- a/services/frontend/src/features/messages/index.tsx
+++ /dev/null
@@ -1,315 +0,0 @@
-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
deleted file mode 100644
index c5f5b66..0000000
--- a/services/frontend/src/features/settings/index.tsx
+++ /dev/null
@@ -1,426 +0,0 @@
-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
deleted file mode 100644
index 4c75f04..0000000
--- a/services/frontend/src/hooks/useNotificationBadge.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-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/islands/ActiveSpeakers.tsx b/services/frontend/src/islands/ActiveSpeakers.tsx
new file mode 100644
index 0000000..42f6c9d
--- /dev/null
+++ b/services/frontend/src/islands/ActiveSpeakers.tsx
@@ -0,0 +1,61 @@
+// ─── 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
new file mode 100644
index 0000000..c6d8421
--- /dev/null
+++ b/services/frontend/src/islands/AudioVisualizer.tsx
@@ -0,0 +1,95 @@
+// ─── 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
new file mode 100644
index 0000000..de72a4e
--- /dev/null
+++ b/services/frontend/src/islands/AuthGuard.tsx
@@ -0,0 +1,122 @@
+// ─── 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
new file mode 100644
index 0000000..4c8f7ad
--- /dev/null
+++ b/services/frontend/src/islands/LiveShell.tsx
@@ -0,0 +1,142 @@
+// ─── 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
new file mode 100644
index 0000000..c24cc79
--- /dev/null
+++ b/services/frontend/src/islands/MascotChat.tsx
@@ -0,0 +1,122 @@
+// ─── 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
new file mode 100644
index 0000000..519f0f7
--- /dev/null
+++ b/services/frontend/src/islands/MessageFeed.tsx
@@ -0,0 +1,356 @@
+// ─── 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
new file mode 100644
index 0000000..2ecdbc1
--- /dev/null
+++ b/services/frontend/src/islands/NowPlaying.tsx
@@ -0,0 +1,69 @@
+// ─── 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
new file mode 100644
index 0000000..1ceb904
--- /dev/null
+++ b/services/frontend/src/islands/PageHeader.tsx
@@ -0,0 +1,40 @@
+// ─── 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
new file mode 100644
index 0000000..ddea34f
--- /dev/null
+++ b/services/frontend/src/islands/PageSidebar.tsx
@@ -0,0 +1,30 @@
+// ─── 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
new file mode 100644
index 0000000..d698cdf
--- /dev/null
+++ b/services/frontend/src/islands/Particles.tsx
@@ -0,0 +1,104 @@
+// ─── 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
new file mode 100644
index 0000000..2b8bb9b
--- /dev/null
+++ b/services/frontend/src/islands/RecordingsList.tsx
@@ -0,0 +1,192 @@
+// ─── 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
new file mode 100644
index 0000000..3b19f1c
--- /dev/null
+++ b/services/frontend/src/islands/SettingsForm.tsx
@@ -0,0 +1,89 @@
+// ─── 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
new file mode 100644
index 0000000..de7f607
--- /dev/null
+++ b/services/frontend/src/islands/ThemeToggle.tsx
@@ -0,0 +1,68 @@
+// ─── 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
new file mode 100644
index 0000000..daa86aa
--- /dev/null
+++ b/services/frontend/src/islands/VoiceControls.tsx
@@ -0,0 +1,110 @@
+// ─── 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
new file mode 100644
index 0000000..4ce71b1
--- /dev/null
+++ b/services/frontend/src/layouts/AuthLayout.astro
@@ -0,0 +1,15 @@
+---
+// ─── 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 f033fef..97d7584 100644
--- a/services/frontend/src/layouts/BaseLayout.astro
+++ b/services/frontend/src/layouts/BaseLayout.astro
@@ -4,10 +4,17 @@
// 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";
@@ -21,6 +28,7 @@ const FALLBACK_IMAGE =
+
{FALLBACK_TITLE}
@@ -42,10 +50,13 @@ const FALLBACK_IMAGE =
-
+
+
+
+
+
+
+
diff --git a/services/frontend/src/pages/404.astro b/services/frontend/src/pages/404.astro
new file mode 100644
index 0000000..24cfeef
--- /dev/null
+++ b/services/frontend/src/pages/404.astro
@@ -0,0 +1,20 @@
+---
+// ─── 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 e005c1a..a5dbee1 100644
--- a/services/frontend/src/pages/index.astro
+++ b/services/frontend/src/pages/index.astro
@@ -1,12 +1,6 @@
---
-// ─── 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";
+// ─── index.astro — Redirect to live dashboard ──────────────────────────────
+// The root path now redirects to /live where the full SPA lives.
+// ────────────────────────────────────────────────────────────────────────────
+return Astro.redirect("/live", 301);
---
-
-
-
-
-
diff --git a/services/frontend/src/pages/live.astro b/services/frontend/src/pages/live.astro
new file mode 100644
index 0000000..ed35ea6
--- /dev/null
+++ b/services/frontend/src/pages/live.astro
@@ -0,0 +1,14 @@
+---
+// ─── 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
new file mode 100644
index 0000000..e95d8ad
--- /dev/null
+++ b/services/frontend/src/pages/login.astro
@@ -0,0 +1,13 @@
+---
+// ─── 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
new file mode 100644
index 0000000..66305ce
--- /dev/null
+++ b/services/frontend/src/pages/messages.astro
@@ -0,0 +1,28 @@
+---
+// ─── 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
new file mode 100644
index 0000000..fa7b37a
--- /dev/null
+++ b/services/frontend/src/pages/recordings.astro
@@ -0,0 +1,27 @@
+---
+// ─── 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
new file mode 100644
index 0000000..a18f8b9
--- /dev/null
+++ b/services/frontend/src/pages/settings.astro
@@ -0,0 +1,27 @@
+---
+// ─── 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 80ae557..071c3be 100644
--- a/services/frontend/src/shared/api/client.ts
+++ b/services/frontend/src/shared/api/client.ts
@@ -1,6 +1,7 @@
// ─── 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,
@@ -8,29 +9,20 @@ import type {
DashboardStats,
DashboardUser,
DashboardUserDetail,
-} 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";
+} from "../types/dashboard.js";
+import type { Channel, Guild, GuildVoiceEntry } from "../types/guild.js";
+import type { MediaItem, MediaMode, MediaState } from "../types/media.js";
import type {
VoiceRecording,
VoiceRecordingListResponse,
-} from "../../entities/recording/types.js";
+} from "../types/recording.js";
import type {
AdminSettings,
AppConfig,
DashboardTab,
UIState,
-} from "../../entities/ui/types.js";
-import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js";
-import { createLogger } from "../lib/logger.js";
+} from "../types/ui-types.js";
+import type { ActiveSpeaker, VoiceStatus } from "../types/voice.js";
const logger = createLogger("api");
@@ -230,9 +222,7 @@ 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}`);
}
@@ -335,7 +325,9 @@ 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/ui/CommandPalette.tsx b/services/frontend/src/shared/components/CommandPalette.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/CommandPalette.tsx
rename to services/frontend/src/shared/components/CommandPalette.tsx
index 01773d3..6fd39cb 100644
--- a/services/frontend/src/shared/ui/CommandPalette.tsx
+++ b/services/frontend/src/shared/components/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/widgets/Header.tsx b/services/frontend/src/shared/components/Header.tsx
similarity index 88%
rename from services/frontend/src/widgets/Header.tsx
rename to services/frontend/src/shared/components/Header.tsx
index 384052d..ff4c81e 100644
--- a/services/frontend/src/widgets/Header.tsx
+++ b/services/frontend/src/shared/components/Header.tsx
@@ -1,12 +1,12 @@
import { motion } from "framer-motion";
import { Moon, Sun, Wifi, WifiOff } from "lucide-react";
-import type { DashboardTab } from "../entities/ui/types.js";
-import type { VoiceStatus } from "../entities/voice/types.js";
+import { fadeSlideUp } from "../hooks/useFramerStagger";
import type { ThemeMode } from "../hooks/useTheme";
-import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
-import { cn } from "../shared/lib/utils";
-import { Badge } from "../shared/ui";
-import type { WsStatus } from "../shared/ws/socket";
+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";
const titles: Record = {
messages: "Messages & Moderation",
@@ -19,7 +19,8 @@ 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 {
@@ -69,7 +70,14 @@ 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 (
@@ -111,7 +119,9 @@ export function Header({ activeTab, wsStatus, voiceStatus, themeMode, isDark, on
) : (
)}
-
{isDark ? "Light" : "Dark"}
+
+ {isDark ? "Light" : "Dark"}
+
{/* WS Badge */}
diff --git a/services/frontend/src/shared/ui/MobileTabBar.tsx b/services/frontend/src/shared/components/MobileTabBar.tsx
similarity index 92%
rename from services/frontend/src/shared/ui/MobileTabBar.tsx
rename to services/frontend/src/shared/components/MobileTabBar.tsx
index dd17642..c89460f 100644
--- a/services/frontend/src/shared/ui/MobileTabBar.tsx
+++ b/services/frontend/src/shared/components/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,7 +44,9 @@ 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 && (
{!collapsed && {item.label} }
- {!collapsed && item.id === "messages" &&
+ {!collapsed &&
+ item.id === "messages" &&
notificationCount !== undefined &&
notificationCount > 0 && (
@@ -123,7 +124,8 @@ 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/auth/AuthGuard.client.tsx b/services/frontend/src/shared/components/auth/AuthGuard.client.tsx
new file mode 100644
index 0000000..cee66c4
--- /dev/null
+++ b/services/frontend/src/shared/components/auth/AuthGuard.client.tsx
@@ -0,0 +1,19 @@
+// ─── 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/features/auth/index.tsx b/services/frontend/src/shared/components/auth/index.tsx
similarity index 95%
rename from services/frontend/src/features/auth/index.tsx
rename to services/frontend/src/shared/components/auth/index.tsx
index 7f5faba..34f6aab 100644
--- a/services/frontend/src/features/auth/index.tsx
+++ b/services/frontend/src/shared/components/auth/index.tsx
@@ -1,7 +1,7 @@
import { motion } from "framer-motion";
-import { Lock, Unlock, Shield, WifiOff, RefreshCw } from "lucide-react";
-import { useState, useCallback } from "react";
-import { login, setSessionToken } from "../../shared/api/client.js";
+import { Lock, RefreshCw, Shield, Unlock, WifiOff } from "lucide-react";
+import { useCallback, useState } from "react";
+import { login, setSessionToken } from "../../api/client.js";
import {
Button,
Card,
@@ -10,7 +10,7 @@ import {
CardHeader,
CardTitle,
Input,
-} from "../../shared/ui";
+} from "../index";
interface AuthOverlayProps {
onAuthenticated: () => void;
@@ -153,7 +153,8 @@ 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/shared/ui/badge.tsx b/services/frontend/src/shared/components/badge.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/badge.tsx
rename to services/frontend/src/shared/components/badge.tsx
diff --git a/services/frontend/src/shared/ui/button.tsx b/services/frontend/src/shared/components/button.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/button.tsx
rename to services/frontend/src/shared/components/button.tsx
diff --git a/services/frontend/src/shared/ui/card.tsx b/services/frontend/src/shared/components/card.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/card.tsx
rename to services/frontend/src/shared/components/card.tsx
diff --git a/services/frontend/src/shared/ui/error-boundary.tsx b/services/frontend/src/shared/components/error-boundary.tsx
similarity index 98%
rename from services/frontend/src/shared/ui/error-boundary.tsx
rename to services/frontend/src/shared/components/error-boundary.tsx
index 6450b74..0849e94 100644
--- a/services/frontend/src/shared/ui/error-boundary.tsx
+++ b/services/frontend/src/shared/components/error-boundary.tsx
@@ -1,7 +1,7 @@
-import { Component, type ErrorInfo, type ReactNode } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
-import { Button } from "../ui/button";
+import { Component, type ErrorInfo, type ReactNode } from "react";
import { cn } from "../lib/utils";
+import { Button } from "./button";
interface ErrorBoundaryProps {
children: ReactNode;
diff --git a/services/frontend/src/shared/ui/index.ts b/services/frontend/src/shared/components/index.ts
similarity index 100%
rename from services/frontend/src/shared/ui/index.ts
rename to services/frontend/src/shared/components/index.ts
diff --git a/services/frontend/src/shared/ui/input.tsx b/services/frontend/src/shared/components/input.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/input.tsx
rename to services/frontend/src/shared/components/input.tsx
diff --git a/services/frontend/src/widgets/mascot/MascotChatbot.tsx b/services/frontend/src/shared/components/mascot/MascotChatbot.tsx
similarity index 99%
rename from services/frontend/src/widgets/mascot/MascotChatbot.tsx
rename to services/frontend/src/shared/components/mascot/MascotChatbot.tsx
index 6fe1faa..69d65f1 100644
--- a/services/frontend/src/widgets/mascot/MascotChatbot.tsx
+++ b/services/frontend/src/shared/components/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 "../../shared/lib/logger.js";
-import { cn } from "../../shared/lib/utils";
+import { createLogger } from "../../lib/logger.js";
+import { cn } from "../../lib/utils";
const logger = createLogger("mascot-chat");
diff --git a/services/frontend/src/widgets/mascot/MascotImage.tsx b/services/frontend/src/shared/components/mascot/MascotImage.tsx
similarity index 96%
rename from services/frontend/src/widgets/mascot/MascotImage.tsx
rename to services/frontend/src/shared/components/mascot/MascotImage.tsx
index 2f1eed4..50b5f55 100644
--- a/services/frontend/src/widgets/mascot/MascotImage.tsx
+++ b/services/frontend/src/shared/components/mascot/MascotImage.tsx
@@ -53,7 +53,9 @@ export function MascotImage({
return (
{imgError ? (
-
+
) : (
diff --git a/services/frontend/src/widgets/particles/ParticleBackground.tsx b/services/frontend/src/shared/components/particles/ParticleBackground.tsx
similarity index 72%
rename from services/frontend/src/widgets/particles/ParticleBackground.tsx
rename to services/frontend/src/shared/components/particles/ParticleBackground.tsx
index 21f6638..395405b 100644
--- a/services/frontend/src/widgets/particles/ParticleBackground.tsx
+++ b/services/frontend/src/shared/components/particles/ParticleBackground.tsx
@@ -20,14 +20,19 @@ export function ParticleBackground() {
style={{ zIndex: -1 }}
>
{/* Top-right glow orb */}
-
{/* Bottom-left glow orb */}
diff --git a/services/frontend/src/shared/ui/profile-detail.tsx b/services/frontend/src/shared/components/profile-detail.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/profile-detail.tsx
rename to services/frontend/src/shared/components/profile-detail.tsx
diff --git a/services/frontend/src/shared/ui/scroll-area.tsx b/services/frontend/src/shared/components/scroll-area.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/scroll-area.tsx
rename to services/frontend/src/shared/components/scroll-area.tsx
diff --git a/services/frontend/src/shared/ui/select.tsx b/services/frontend/src/shared/components/select.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/select.tsx
rename to services/frontend/src/shared/components/select.tsx
diff --git a/services/frontend/src/shared/ui/skeleton.tsx b/services/frontend/src/shared/components/skeleton.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/skeleton.tsx
rename to services/frontend/src/shared/components/skeleton.tsx
diff --git a/services/frontend/src/shared/ui/status-badge.tsx b/services/frontend/src/shared/components/status-badge.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/status-badge.tsx
rename to services/frontend/src/shared/components/status-badge.tsx
diff --git a/services/frontend/src/shared/ui/summary-list.tsx b/services/frontend/src/shared/components/summary-list.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/summary-list.tsx
rename to services/frontend/src/shared/components/summary-list.tsx
diff --git a/services/frontend/src/shared/ui/tabs.tsx b/services/frontend/src/shared/components/tabs.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/tabs.tsx
rename to services/frontend/src/shared/components/tabs.tsx
diff --git a/services/frontend/src/shared/ui/toast.tsx b/services/frontend/src/shared/components/toast.tsx
similarity index 100%
rename from services/frontend/src/shared/ui/toast.tsx
rename to services/frontend/src/shared/components/toast.tsx
diff --git a/services/frontend/src/shared/hooks/useMascotChat.ts b/services/frontend/src/shared/hooks/useMascotChat.ts
index 43eaf6f..fa99140 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/features/messages/hooks/useMessages.ts b/services/frontend/src/shared/hooks/useMessages.ts
similarity index 69%
rename from services/frontend/src/features/messages/hooks/useMessages.ts
rename to services/frontend/src/shared/hooks/useMessages.ts
index 24b23d4..e6c802b 100644
--- a/services/frontend/src/features/messages/hooks/useMessages.ts
+++ b/services/frontend/src/shared/hooks/useMessages.ts
@@ -1,16 +1,17 @@
+import type { MessageRecord } from "@bete/shared";
import { useCallback, useRef, useState } from "react";
-import type { MessageRecord } from "../../../entities/message/types.js";
-import {
- listMessages,
- reanalyzeErrorBatch,
- reanalyzeMessage,
-} from "../../../shared/api/client";
-import { createLogger } from "../../../shared/lib/logger.js";
+import { useMessageStore } from "../../stores/message-store.js";
+import { listMessages, reanalyzeMessage } from "../api/client.js";
+import { createLogger } from "../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[],
@@ -24,14 +25,20 @@ 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) {
@@ -48,6 +55,7 @@ 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);
@@ -73,7 +81,7 @@ export function useMessages() {
cursor,
limit: PAGE_SIZE,
});
- setMessages((prev) => [...prev, ...result.data]);
+ setMessages((prev) => mergeMessages(prev, result.data));
setCursor(result.nextCursor);
setHasMore(!!result.nextCursor);
} catch (err) {
@@ -85,8 +93,7 @@ export function useMessages() {
}, [cursor, loadingMore]);
const reanalyze = useCallback(async (id: string): Promise => {
- // Capture prior state inside the functional updater so we don't need
- // `messages` as a useCallback dependency (avoids stale closure churn).
+ // Snapshot the current state so we can revert on HTTP failure
let saved: MessageRecord | undefined;
setMessages((prev) => {
@@ -106,7 +113,7 @@ export function useMessages() {
try {
await reanalyzeMessage(id);
} catch (err) {
- // HTTP failed — revert the optimistic update so the UI stays truthful.
+ // Revert optimistic update on failure
if (saved) {
const snapshot = saved;
setMessages((prev) =>
@@ -119,42 +126,13 @@ 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/hooks/useTheme.ts b/services/frontend/src/shared/hooks/useTheme.ts
similarity index 100%
rename from services/frontend/src/hooks/useTheme.ts
rename to services/frontend/src/shared/hooks/useTheme.ts
diff --git a/services/frontend/src/shared/hooks/useUIState.ts b/services/frontend/src/shared/hooks/useUIState.ts
index 2ad1acc..5ce0d15 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 "../../entities/ui/types.js";
+import type { UIState } from "../types/ui-types.js";
import { uiStateValidator, useLocalStorage } from "./useLocalStorage";
export function useUIState() {
diff --git a/services/frontend/src/entities/dashboard/types.ts b/services/frontend/src/shared/types/dashboard.ts
similarity index 100%
rename from services/frontend/src/entities/dashboard/types.ts
rename to services/frontend/src/shared/types/dashboard.ts
diff --git a/services/frontend/src/entities/guild/types.ts b/services/frontend/src/shared/types/guild.ts
similarity index 100%
rename from services/frontend/src/entities/guild/types.ts
rename to services/frontend/src/shared/types/guild.ts
diff --git a/services/frontend/src/entities/media/types.ts b/services/frontend/src/shared/types/media.ts
similarity index 100%
rename from services/frontend/src/entities/media/types.ts
rename to services/frontend/src/shared/types/media.ts
diff --git a/services/frontend/src/entities/message/types.ts b/services/frontend/src/shared/types/message.ts
similarity index 100%
rename from services/frontend/src/entities/message/types.ts
rename to services/frontend/src/shared/types/message.ts
diff --git a/services/frontend/src/entities/recording/types.ts b/services/frontend/src/shared/types/recording.ts
similarity index 100%
rename from services/frontend/src/entities/recording/types.ts
rename to services/frontend/src/shared/types/recording.ts
diff --git a/services/frontend/src/entities/ui/types.ts b/services/frontend/src/shared/types/ui-types.ts
similarity index 100%
rename from services/frontend/src/entities/ui/types.ts
rename to services/frontend/src/shared/types/ui-types.ts
diff --git a/services/frontend/src/entities/voice/types.ts b/services/frontend/src/shared/types/voice.ts
similarity index 85%
rename from services/frontend/src/entities/voice/types.ts
rename to services/frontend/src/shared/types/voice.ts
index 71571df..324788c 100644
--- a/services/frontend/src/entities/voice/types.ts
+++ b/services/frontend/src/shared/types/voice.ts
@@ -1,4 +1,4 @@
-import type { GuildVoiceEntry } from "../guild/types";
+import type { GuildVoiceEntry } from "./guild";
export interface VoiceStatus {
connected: boolean;
diff --git a/services/frontend/src/shared/ws/socket.ts b/services/frontend/src/shared/ws/socket.ts
index 96caa97..4670890 100644
--- a/services/frontend/src/shared/ws/socket.ts
+++ b/services/frontend/src/shared/ws/socket.ts
@@ -6,9 +6,9 @@ import type {
VoiceRecordingUploadData,
} from "@bete/shared";
import { useCallback, useEffect, useRef, useState } from "react";
-import type { MediaState } from "../../entities/media/types.js";
-import { createLogger } from "../lib/logger.js";
import { getSessionToken } from "../api/client.js";
+import { createLogger } from "../lib/logger.js";
+import type { MediaState } from "../types/media.js";
import type { ActiveSpeakerData } from "./events.js";
const logger = createLogger("socket");
@@ -391,3 +391,43 @@ export function useDashboardSocket(handlers: WsHandlers) {
return { status, send, socketRef: { current: _wsInstance } };
}
+
+/**
+ * Singleton manager for the WebSocket connection.
+ * Provides imperative connect/disconnect/send access alongside useDashboardSocket.
+ */
+export class SocketManager {
+ private static _instance: SocketManager;
+
+ private constructor() {}
+
+ static getInstance(): SocketManager {
+ if (!SocketManager._instance) {
+ SocketManager._instance = new SocketManager();
+ }
+ return SocketManager._instance;
+ }
+
+ /** Ensure the WebSocket is connected (reconnects if closed). */
+ connect(): void {
+ _closed = false;
+ ensureConnected();
+ }
+
+ /** Close the WebSocket and stop reconnection. */
+ disconnect(): void {
+ _closed = true;
+ if (_reconnectTimer) clearTimeout(_reconnectTimer);
+ if (_wsInstance) {
+ _wsInstance.close();
+ _wsInstance = null;
+ }
+ }
+
+ /** Send data through the WebSocket (no-op if not connected). */
+ send(data: ArrayBuffer | string): void {
+ if (_wsInstance?.readyState === WebSocket.OPEN) {
+ _wsInstance.send(data);
+ }
+ }
+}
diff --git a/services/frontend/src/stores/message-store.ts b/services/frontend/src/stores/message-store.ts
new file mode 100644
index 0000000..3e0a935
--- /dev/null
+++ b/services/frontend/src/stores/message-store.ts
@@ -0,0 +1,49 @@
+import type { MessageRecord } from "@bete/shared";
+import { create } from "zustand";
+
+type MessagesUpdater =
+ | MessageRecord[]
+ | ((prev: MessageRecord[]) => 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
new file mode 100644
index 0000000..2a03871
--- /dev/null
+++ b/services/frontend/src/stores/ui-store.ts
@@ -0,0 +1,46 @@
+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
new file mode 100644
index 0000000..035a09c
--- /dev/null
+++ b/services/frontend/src/stores/voice-store.ts
@@ -0,0 +1,72 @@
+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
deleted file mode 100644
index e60ba36..0000000
--- a/services/frontend/src/styles.css
+++ /dev/null
@@ -1,199 +0,0 @@
-@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
new file mode 100644
index 0000000..ffad835
--- /dev/null
+++ b/services/frontend/src/styles/base.css
@@ -0,0 +1,352 @@
+/* ═══════════════════════════════════════════════════════════════════════════
+ 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
deleted file mode 100644
index 6624b25..0000000
--- a/services/frontend/src/widgets/DashboardLayout.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-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}
-
-
-
-
-
- );
-}