Revert "feat: migrate frontend to Astro SSG with design system"
This reverts commit 8ad888da28.
This commit is contained in:
Generated
-26
@@ -267,9 +267,6 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 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:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: latest
|
specifier: latest
|
||||||
@@ -6050,24 +6047,6 @@ packages:
|
|||||||
zod@4.4.3:
|
zod@4.4.3:
|
||||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
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:
|
zwitch@2.0.4:
|
||||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||||
|
|
||||||
@@ -11535,9 +11514,4 @@ snapshots:
|
|||||||
|
|
||||||
zod@4.4.3: {}
|
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: {}
|
zwitch@2.0.4: {}
|
||||||
|
|||||||
Vendored
-1
@@ -1,2 +1 @@
|
|||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
/// <reference path="content.d.ts" />
|
|
||||||
@@ -36,8 +36,10 @@ export default defineConfig({
|
|||||||
"imphnen.asepharyana.web.id",
|
"imphnen.asepharyana.web.id",
|
||||||
],
|
],
|
||||||
watch: {
|
watch: {
|
||||||
|
// Penting: Astro punya public/ dir sendiri, jangan bentrok
|
||||||
ignored: ["!**/node_modules/**"],
|
ignored: ["!**/node_modules/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// PostCSS otomatis terdeteksi dari root project
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,8 +23,7 @@
|
|||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0"
|
||||||
"zustand": "^5.0.14"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// ─── App.client.tsx — Astro React island entry point ────────────────────────
|
||||||
|
// DILOAD OLEH Astro client:only="react"
|
||||||
|
// Menyediakan <div id="root"> dan mount App dengan provider yang diperlukan
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import App from "./App";
|
||||||
|
import { ToastProvider } from "./shared/ui";
|
||||||
|
|
||||||
|
export default function AppClient() {
|
||||||
|
return (
|
||||||
|
<React.StrictMode>
|
||||||
|
<ToastProvider>
|
||||||
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
// ─── App.tsx — The God Component ──────────────────────────────────────────────
|
||||||
|
// TODO: Decompose into smaller focused components (M20).
|
||||||
|
// This component currently handles auth, socket lifecycle, speaker tracking,
|
||||||
|
// voice control, media, PTT, command palette, and tab navigation.
|
||||||
|
// Each concern should be extracted into its own hook or sub-component.
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { AnimatePresence } from "framer-motion";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { ActiveSpeaker } from "./entities/voice/types.js";
|
||||||
|
import { AuthOverlay } from "./features/auth";
|
||||||
|
import { DashboardPanel } from "./features/dashboard";
|
||||||
|
import { LivePanel } from "./features/live";
|
||||||
|
import { useMediaControl } from "./features/live/hooks/useMediaControl";
|
||||||
|
import { useVoiceControl } from "./features/live/hooks/useVoiceControl";
|
||||||
|
import { MessagesPanel } from "./features/messages";
|
||||||
|
import { ModerationAlertListener } from "./features/messages/components/ModerationAlertListener";
|
||||||
|
import {
|
||||||
|
mergeMessages,
|
||||||
|
useMessages,
|
||||||
|
} from "./features/messages/hooks/useMessages";
|
||||||
|
import { SettingsPanel } from "./features/settings";
|
||||||
|
import { useNotificationBadge } from "./hooks/useNotificationBadge";
|
||||||
|
import { useTheme } from "./hooks/useTheme";
|
||||||
|
import {
|
||||||
|
getAppConfig,
|
||||||
|
getSessionToken,
|
||||||
|
getAdminPassword,
|
||||||
|
clearSessionToken,
|
||||||
|
clearAdminPassword,
|
||||||
|
} from "./shared/api/client";
|
||||||
|
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
|
||||||
|
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
|
||||||
|
import { useUIState } from "./shared/hooks/useUIState";
|
||||||
|
import { CommandPalette } from "./shared/ui/CommandPalette";
|
||||||
|
import { ErrorBoundary } from "./shared/ui/error-boundary";
|
||||||
|
import { MobileTabBar } from "./shared/ui/MobileTabBar";
|
||||||
|
import type { DashboardTab } from "./entities/ui/types.js";
|
||||||
|
import { useDashboardSocket } from "./shared/ws/socket";
|
||||||
|
import { DashboardLayout } from "./widgets/DashboardLayout";
|
||||||
|
|
||||||
|
type AuthState = "loading" | "authenticated" | "unauthenticated";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { uiState, patchUIState } = useUIState();
|
||||||
|
const { theme, mode, isDark, toggle: toggleTheme, setMode } = useTheme();
|
||||||
|
const voice = useVoiceControl();
|
||||||
|
const media = useMediaControl();
|
||||||
|
const messages = useMessages();
|
||||||
|
const [activeSpeakers, setActiveSpeakers] = useState<
|
||||||
|
(ActiveSpeaker & { heardAt?: number })[]>([]);
|
||||||
|
const [monitorGuildId, setMonitorGuildId] = useState("");
|
||||||
|
|
||||||
|
// ── Command palette state ────────────────────────────────────────────────
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
const [paletteMode, setPaletteMode] = useState<"search" | "shortcuts" | null>(null);
|
||||||
|
|
||||||
|
// ── Auth state ─────────────────────────────────────────────────────────────
|
||||||
|
const [authState, setAuthState] = useState<AuthState>("loading");
|
||||||
|
const [dashboardIsPublic, setDashboardIsPublic] = useState(false);
|
||||||
|
const [configError, setConfigError] = useState<string | null>(null);
|
||||||
|
const configRetryRef = useRef(0);
|
||||||
|
const configTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const MAX_CONFIG_RETRIES = 3;
|
||||||
|
|
||||||
|
// ── Notification badge ─────────────────────────────────────────────────────
|
||||||
|
const activeTab: DashboardTab = (uiState.activeTab as DashboardTab) || "messages";
|
||||||
|
const notifBadge = useNotificationBadge(activeTab);
|
||||||
|
|
||||||
|
// On mount: check config for public/private mode, and check stored session token
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear legacy admin-password from localStorage — only use token auth now
|
||||||
|
clearAdminPassword();
|
||||||
|
|
||||||
|
// Validate existing token by calling config endpoint
|
||||||
|
// If server returns 401, clear the invalid token
|
||||||
|
const attempt = () => {
|
||||||
|
getAppConfig()
|
||||||
|
.then((cfg) => {
|
||||||
|
configRetryRef.current = 0;
|
||||||
|
setConfigError(null);
|
||||||
|
setMonitorGuildId(cfg.monitorGuildId ?? "");
|
||||||
|
setDashboardIsPublic(cfg.dashboardIsPublic);
|
||||||
|
|
||||||
|
// Check if we have a session token (new auth) or legacy password (backward compat)
|
||||||
|
const sessionToken = getSessionToken();
|
||||||
|
const storedPassword = getAdminPassword();
|
||||||
|
if (sessionToken || cfg.dashboardIsPublic || storedPassword) {
|
||||||
|
setAuthState("authenticated");
|
||||||
|
} else {
|
||||||
|
setAuthState("unauthenticated");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// If server responds with 401, token is invalid — clear it
|
||||||
|
if (err?.statusCode === 401 || err?.status === 401) {
|
||||||
|
clearSessionToken();
|
||||||
|
setAuthState("unauthenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
configRetryRef.current += 1;
|
||||||
|
const isNetwork =
|
||||||
|
err instanceof TypeError &&
|
||||||
|
(err.message === "Failed to fetch" ||
|
||||||
|
err.message.includes("NetworkError") ||
|
||||||
|
err.message.includes("network"));
|
||||||
|
|
||||||
|
if (isNetwork && configRetryRef.current < MAX_CONFIG_RETRIES) {
|
||||||
|
// Retry with backoff: 1s, 2s, 3s
|
||||||
|
const delay = configRetryRef.current * 1000;
|
||||||
|
configTimeoutRef.current = setTimeout(attempt, delay);
|
||||||
|
} else {
|
||||||
|
// Final failure — show auth overlay with retry button
|
||||||
|
setConfigError(
|
||||||
|
isNetwork
|
||||||
|
? "Cannot reach server. Check your connection and try again."
|
||||||
|
: "Failed to load configuration.",
|
||||||
|
);
|
||||||
|
setAuthState("unauthenticated");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
attempt();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (configTimeoutRef.current) {
|
||||||
|
clearTimeout(configTimeoutRef.current);
|
||||||
|
configTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const audio = useAudioPlayback();
|
||||||
|
const selectedVoiceGuild =
|
||||||
|
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||||
|
|
||||||
|
// Resolve monitor guild name from the full guild list (has real names now)
|
||||||
|
const monitorGuildName = useMemo(
|
||||||
|
() =>
|
||||||
|
monitorGuildId
|
||||||
|
? (voice.guilds.find((g) => g.id === monitorGuildId)?.name ?? null)
|
||||||
|
: null,
|
||||||
|
[monitorGuildId, voice.guilds],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update speaker list from incremental voice_active_user events
|
||||||
|
const updateSpeakerList = (
|
||||||
|
prev: (ActiveSpeaker & { heardAt?: number })[],
|
||||||
|
data: Partial<ActiveSpeaker> & {
|
||||||
|
userId?: string;
|
||||||
|
id?: string;
|
||||||
|
speaking: boolean;
|
||||||
|
},
|
||||||
|
): (ActiveSpeaker & { heardAt?: number })[] => {
|
||||||
|
const key = data.userId ?? data.id;
|
||||||
|
if (!key) return prev;
|
||||||
|
const now = Date.now();
|
||||||
|
const idx = prev.findIndex((s) => (s.userId ?? s.id) === key);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = { ...next[idx], ...data, heardAt: now };
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{ ...data, heardAt: now } as ActiveSpeaker & { heardAt?: number },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const socket = useDashboardSocket({
|
||||||
|
onBinary: (d) => audio.handleIncomingBinary(d),
|
||||||
|
onUserState: (users) =>
|
||||||
|
setActiveSpeakers(
|
||||||
|
users.map((u) => ({
|
||||||
|
...u,
|
||||||
|
heardAt: Date.now(),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
onVoiceActiveUser: (data) => {
|
||||||
|
if (data.userId) audio.registerUserId(data.userId);
|
||||||
|
setActiveSpeakers((prev: (ActiveSpeaker & { heardAt?: number })[]) =>
|
||||||
|
updateSpeakerList(prev, {
|
||||||
|
userId: data.userId,
|
||||||
|
username: data.username,
|
||||||
|
avatar: data.avatar,
|
||||||
|
speaking: data.speaking,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onVoiceRecordingStarted: (data) =>
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("voice_recording_started", { detail: data }),
|
||||||
|
),
|
||||||
|
onVoiceRecordingStopped: (data) =>
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("voice_recording_stopped", { detail: data }),
|
||||||
|
),
|
||||||
|
onVoiceAnalyzed: (data) =>
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("voice_analyzed", { detail: data }),
|
||||||
|
),
|
||||||
|
onMessageCreated: (m) =>
|
||||||
|
messages.setMessages((prev) => {
|
||||||
|
// Skip if message already exists with same status (dedup)
|
||||||
|
const existing = prev.find((i) => i.id === m.id);
|
||||||
|
if (existing && existing.ai_status === m.ai_status) return prev;
|
||||||
|
return mergeMessages(prev, [m]);
|
||||||
|
}),
|
||||||
|
onMessageUpdated: (m) =>
|
||||||
|
messages.setMessages((prev) =>
|
||||||
|
prev.map((i) => (i.id === m.id ? { ...i, ...m } : i)),
|
||||||
|
),
|
||||||
|
onMessageDeleted: (m) =>
|
||||||
|
messages.setMessages((prev) =>
|
||||||
|
prev.map((i) =>
|
||||||
|
i.id === m.id ? { ...i, type: "deleted" as const } : i,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onMessageAnalyzed: (msg) => {
|
||||||
|
messages.setMessages((prev) => {
|
||||||
|
// Skip if message already analyzed with same status (dedup)
|
||||||
|
const existing = prev.find((i) => i.id === msg.id);
|
||||||
|
if (existing && existing.ai_status === msg.ai_status) return prev;
|
||||||
|
return mergeMessages(prev, [msg]);
|
||||||
|
});
|
||||||
|
const status = msg.ai_status;
|
||||||
|
if (status === "flagged") {
|
||||||
|
const username = msg.username || msg.user_id || "unknown";
|
||||||
|
const severity = msg.ai_severity || "";
|
||||||
|
const categories = msg.ai_categories || "";
|
||||||
|
const brief = msg.ai_analysis?.slice(0, 80) ?? "Message flagged by AI";
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("moderation_alert", {
|
||||||
|
detail: { type: status, username, severity, categories, brief },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAttachmentUploaded: () =>
|
||||||
|
messages
|
||||||
|
.fetchMessages(monitorGuildId || undefined)
|
||||||
|
.catch(() => undefined),
|
||||||
|
onAttachmentCreated: () =>
|
||||||
|
messages
|
||||||
|
.fetchMessages(monitorGuildId || undefined)
|
||||||
|
.catch(() => undefined),
|
||||||
|
onMediaState: (state) => media.setMediaState(state),
|
||||||
|
onVoiceRecordingUploaded: (d) =>
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("voice_recording_uploaded", { detail: d }),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const transmit = useAudioTransmit(socket.socketRef);
|
||||||
|
|
||||||
|
// Load voice channels when guild changes (Live tab)
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedVoiceGuild)
|
||||||
|
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||||
|
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
|
||||||
|
|
||||||
|
// Auto-fetch messages for the monitor guild
|
||||||
|
useEffect(() => {
|
||||||
|
if (monitorGuildId)
|
||||||
|
messages.fetchMessages(monitorGuildId).catch(() => undefined);
|
||||||
|
}, [monitorGuildId, messages.fetchMessages]);
|
||||||
|
|
||||||
|
// Periodic refetch — keeps dashboard in sync even if WS events missed
|
||||||
|
const monitorGuildRef = useRef(monitorGuildId);
|
||||||
|
monitorGuildRef.current = monitorGuildId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentGuild = monitorGuildRef.current;
|
||||||
|
if (!currentGuild) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
messages.fetchMessages(monitorGuildRef.current).catch(() => undefined);
|
||||||
|
}, 15_000);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [monitorGuildId]);
|
||||||
|
|
||||||
|
// Stale speaker pruning — remove speakers not heard from in 30s
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setActiveSpeakers((prev: (ActiveSpeaker & { heardAt?: number })[]) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const pruned = prev.filter(
|
||||||
|
(s) => s.speaking || (s.heardAt && now - s.heardAt < 30_000),
|
||||||
|
);
|
||||||
|
return pruned.length < prev.length ? pruned : prev;
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Push-to-Talk — hold Space to transmit
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
e.target instanceof HTMLInputElement ||
|
||||||
|
e.target instanceof HTMLTextAreaElement ||
|
||||||
|
e.target instanceof HTMLSelectElement
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (e.code === "Space" && !transmit.isStreaming && e.repeat === false) {
|
||||||
|
e.preventDefault();
|
||||||
|
transmit.startTransmit().catch(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === "Space" && transmit.isStreaming) {
|
||||||
|
transmit.stopTransmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("keyup", handleKeyUp);
|
||||||
|
};
|
||||||
|
}, [transmit]);
|
||||||
|
|
||||||
|
// ── Command palette keyboard shortcut handler ──────────────────────────────
|
||||||
|
const handlePaletteOpen = useCallback((mode: "search" | "shortcuts") => {
|
||||||
|
setPaletteMode(mode);
|
||||||
|
setPaletteOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePaletteClose = useCallback(() => {
|
||||||
|
setPaletteOpen(false);
|
||||||
|
setPaletteMode(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePaletteNavigate = useCallback(
|
||||||
|
(tab: string) => {
|
||||||
|
patchUIState({ activeTab: tab as DashboardTab });
|
||||||
|
handlePaletteClose();
|
||||||
|
},
|
||||||
|
[patchUIState, handlePaletteClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Tab navigation handler ─────────────────────────────────────────────────
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(tab: DashboardTab) => {
|
||||||
|
patchUIState({ activeTab: tab });
|
||||||
|
},
|
||||||
|
[patchUIState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Render main content based on active tab ────────────────────────────────
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case "live":
|
||||||
|
return (
|
||||||
|
<ErrorBoundary message="Live panel crashed">
|
||||||
|
<LivePanel
|
||||||
|
guilds={voice.guilds}
|
||||||
|
voiceChannels={voice.voiceChannels}
|
||||||
|
selectedGuild={selectedVoiceGuild}
|
||||||
|
selectedChannel={uiState.selectedVoiceChannel || ""}
|
||||||
|
micLevel={0}
|
||||||
|
status={voice.voiceStatus}
|
||||||
|
voiceLoading={voice.loading}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
levels={audio.levels}
|
||||||
|
isListening={audio.isListening}
|
||||||
|
isStreaming={transmit.isStreaming}
|
||||||
|
mediaState={media.mediaState}
|
||||||
|
mediaLoading={media.loading}
|
||||||
|
onGuildChange={(id) =>
|
||||||
|
patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })
|
||||||
|
}
|
||||||
|
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
|
||||||
|
onJoin={() =>
|
||||||
|
voice.joinVoice(
|
||||||
|
selectedVoiceGuild,
|
||||||
|
uiState.selectedVoiceChannel || "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDisconnect={() => voice.leaveVoice()}
|
||||||
|
onListenToggle={audio.toggleListening}
|
||||||
|
onStreamingToggle={transmit.toggle}
|
||||||
|
onQueueMusic={(s) => media.enqueue(s, "music")}
|
||||||
|
onStartScreen={(s) => media.enqueue(s, "screen")}
|
||||||
|
onSkip={media.skip}
|
||||||
|
onStop={media.stop}
|
||||||
|
onVolumeChange={media.setVolume}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
case "dashboard":
|
||||||
|
return (
|
||||||
|
<ErrorBoundary message="Dashboard panel crashed">
|
||||||
|
<DashboardPanel />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
case "settings":
|
||||||
|
return (
|
||||||
|
<ErrorBoundary message="Settings panel crashed">
|
||||||
|
<SettingsPanel
|
||||||
|
themeMode={mode}
|
||||||
|
isDark={isDark}
|
||||||
|
onThemeModeChange={setMode}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<ErrorBoundary message="Messages panel crashed">
|
||||||
|
<MessagesPanel
|
||||||
|
guildName={monitorGuildName}
|
||||||
|
messages={messages.messages}
|
||||||
|
onReanalyze={messages.reanalyze}
|
||||||
|
onReanalyzeAllErrors={messages.reanalyzeAllErrors}
|
||||||
|
onLoadMore={messages.loadMore}
|
||||||
|
hasMore={messages.hasMore}
|
||||||
|
loadingMore={messages.loadingMore}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render: Auth loading ─────────────────────────────────────────────────
|
||||||
|
if (authState === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{configError
|
||||||
|
? "Connection lost — retrying..."
|
||||||
|
: `Connecting to server${".".repeat(configRetryRef.current)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: Auth overlay ─────────────────────────────────────────────────
|
||||||
|
if (authState === "unauthenticated") {
|
||||||
|
return (
|
||||||
|
<AuthOverlay
|
||||||
|
isPublic={dashboardIsPublic}
|
||||||
|
onAuthenticated={() => setAuthState("authenticated")}
|
||||||
|
configError={configError}
|
||||||
|
onRetryConfig={() => {
|
||||||
|
setConfigError(null);
|
||||||
|
setAuthState("loading");
|
||||||
|
configRetryRef.current = 0;
|
||||||
|
// Re-trigger the config fetch by forcing remount via key trick
|
||||||
|
// Actually: just re-run attempt logic
|
||||||
|
getAppConfig()
|
||||||
|
.then((cfg) => {
|
||||||
|
setMonitorGuildId(cfg.monitorGuildId ?? "");
|
||||||
|
setDashboardIsPublic(cfg.dashboardIsPublic);
|
||||||
|
const sessionToken = getSessionToken();
|
||||||
|
const storedPassword = getAdminPassword();
|
||||||
|
if (sessionToken || cfg.dashboardIsPublic || storedPassword) {
|
||||||
|
setAuthState("authenticated");
|
||||||
|
} else {
|
||||||
|
setAuthState("unauthenticated");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setConfigError("Server still unreachable. Try again later.");
|
||||||
|
setAuthState("unauthenticated");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: Main app (authenticated) ─────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardLayout
|
||||||
|
activeTab={activeTab}
|
||||||
|
wsStatus={socket.status}
|
||||||
|
voiceStatus={voice.voiceStatus}
|
||||||
|
themeMode={mode}
|
||||||
|
isDark={isDark}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
onThemeToggle={toggleTheme}
|
||||||
|
recentMessages={messages.messages}
|
||||||
|
guildId={monitorGuildId}
|
||||||
|
channelId={
|
||||||
|
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
|
||||||
|
}
|
||||||
|
notificationCount={notifBadge.count}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{renderContent()}
|
||||||
|
</AnimatePresence>
|
||||||
|
</DashboardLayout>
|
||||||
|
<MobileTabBar
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
/>
|
||||||
|
<ModerationAlertListener />
|
||||||
|
<CommandPalette
|
||||||
|
isOpen={paletteOpen}
|
||||||
|
mode={paletteMode}
|
||||||
|
onClose={handlePaletteClose}
|
||||||
|
onNavigate={handlePaletteNavigate}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── Header.astro — App header bar ──────────────────────────────────────────
|
|
||||||
// Features: sticky top, backdrop blur, border-bottom, actions slot
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { title } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<header
|
|
||||||
class="header sticky top-0 z-[var(--z-header)] flex items-center justify-between border-b border-border px-6 py-3"
|
|
||||||
>
|
|
||||||
<!-- Left: title -->
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<h1 class="text-lg font-semibold text-foreground">{title}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right: actions slot -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<slot name="actions" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.header {
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
background-color: oklch(var(--glass-bg));
|
|
||||||
min-height: 3.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── NavItem.astro — Sidebar navigation item ────────────────────────────────
|
|
||||||
// Handles: active state highlighting, collapsed icon-only mode,
|
|
||||||
// notification badge / dot indicators.
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
href: string;
|
|
||||||
icon: string;
|
|
||||||
label: string;
|
|
||||||
active?: boolean;
|
|
||||||
collapsed?: boolean;
|
|
||||||
notificationCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
href,
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
active = false,
|
|
||||||
collapsed = false,
|
|
||||||
notificationCount = 0,
|
|
||||||
} = Astro.props;
|
|
||||||
|
|
||||||
const navClass = active
|
|
||||||
? "bg-[var(--clr-interactive-selected,oklch(var(--primary-soft)))] text-primary-foreground"
|
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground";
|
|
||||||
---
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
class:list={[
|
|
||||||
"nav-item group relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium no-underline transition-all duration-150",
|
|
||||||
collapsed ? "justify-center px-2.5" : "",
|
|
||||||
navClass,
|
|
||||||
]}
|
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
aria-label={collapsed ? label : undefined}
|
|
||||||
>
|
|
||||||
<span class="size-5 shrink-0" aria-hidden="true" set:html={icon} />
|
|
||||||
|
|
||||||
{!collapsed && <span class="truncate">{label}</span>}
|
|
||||||
|
|
||||||
{!collapsed && notificationCount > 0 && (
|
|
||||||
<span
|
|
||||||
class="ml-auto inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none text-white"
|
|
||||||
style="background-color: var(--clr-severity-high, oklch(var(--destructive)))"
|
|
||||||
>
|
|
||||||
{notificationCount > 99 ? "99+" : notificationCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{collapsed && notificationCount > 0 && (
|
|
||||||
<span
|
|
||||||
class="absolute right-1.5 top-1.5 size-2 rounded-full"
|
|
||||||
style="background-color: var(--clr-severity-high, oklch(var(--destructive)))"
|
|
||||||
aria-label={`${notificationCount} notifications`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── Sidebar.astro — Main app sidebar navigation ──────────────────────────
|
|
||||||
// Features: collapsed / expanded (64px / 256px), logo, nav items, version
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import NavItem from "./NavItem.astro";
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
collapsed?: boolean;
|
|
||||||
version?: string;
|
|
||||||
currentPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { collapsed = false, version = "1.0.0", currentPath = "/live" } =
|
|
||||||
Astro.props;
|
|
||||||
|
|
||||||
const ICONS = {
|
|
||||||
live: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>`,
|
|
||||||
messages: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
|
|
||||||
recordings: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>`,
|
|
||||||
settings: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
|
|
||||||
} as const;
|
|
||||||
---
|
|
||||||
|
|
||||||
<aside
|
|
||||||
class="sidebar fixed left-0 top-0 z-30 flex h-screen flex-col border-r border-border bg-background transition-all duration-300 ease-out"
|
|
||||||
class:list={[collapsed ? "w-16" : "w-64"]}
|
|
||||||
aria-label="Main navigation"
|
|
||||||
>
|
|
||||||
<!-- ── Header / Logo ──────────────────────────────────────────── -->
|
|
||||||
<div class="flex items-center border-b border-border px-4 py-4">
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="flex items-center gap-3 no-underline"
|
|
||||||
aria-label="Dashboard home"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex size-8 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground"
|
|
||||||
>
|
|
||||||
B
|
|
||||||
</span>
|
|
||||||
{!collapsed && (
|
|
||||||
<span class="text-lg font-extrabold gradient-text">BETE</span>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Navigation ─────────────────────────────────────────────── -->
|
|
||||||
<nav class="flex flex-1 flex-col gap-1 overflow-y-auto px-3 py-4">
|
|
||||||
<NavItem
|
|
||||||
href="/live"
|
|
||||||
icon={ICONS.live}
|
|
||||||
label="Live"
|
|
||||||
active={currentPath.startsWith("/live")}
|
|
||||||
collapsed={collapsed}
|
|
||||||
/>
|
|
||||||
<NavItem
|
|
||||||
href="/messages"
|
|
||||||
icon={ICONS.messages}
|
|
||||||
label="Messages"
|
|
||||||
active={currentPath.startsWith("/messages")}
|
|
||||||
collapsed={collapsed}
|
|
||||||
notificationCount={12}
|
|
||||||
/>
|
|
||||||
<NavItem
|
|
||||||
href="/recordings"
|
|
||||||
icon={ICONS.recordings}
|
|
||||||
label="Recordings"
|
|
||||||
active={currentPath.startsWith("/recordings")}
|
|
||||||
collapsed={collapsed}
|
|
||||||
/>
|
|
||||||
<NavItem
|
|
||||||
href="/settings"
|
|
||||||
icon={ICONS.settings}
|
|
||||||
label="Settings"
|
|
||||||
active={currentPath.startsWith("/settings")}
|
|
||||||
collapsed={collapsed}
|
|
||||||
/>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- ── Footer / Version ───────────────────────────────────────── -->
|
|
||||||
<footer class="border-t border-border px-4 py-3">
|
|
||||||
{!collapsed && (
|
|
||||||
<span class="text-xs text-muted-foreground">v{version}</span>
|
|
||||||
)}
|
|
||||||
</footer>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sidebar {
|
|
||||||
will-change: width;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── EmptyState.astro — Empty / no-data state ───────────────────────────────
|
|
||||||
// Features: centered layout, icon, title, description, actions slot
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
icon: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { icon, title, description } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex min-h-64 flex-col items-center justify-center gap-4 px-6 py-12 text-center"
|
|
||||||
>
|
|
||||||
<!-- Icon -->
|
|
||||||
<div class="flex size-16 items-center justify-center" aria-hidden="true" set:html={icon} />
|
|
||||||
|
|
||||||
<!-- Title -->
|
|
||||||
<h2 class="text-xl font-semibold text-foreground">{title}</h2>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
{description && (
|
|
||||||
<p class="max-w-sm text-sm text-muted-foreground">{description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<!-- Actions slot (buttons, links, etc.) -->
|
|
||||||
{Astro.slots.has("actions") && (
|
|
||||||
<div class="mt-2">
|
|
||||||
<slot name="actions" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── ErrorState.astro — Error / failure state ───────────────────────────────
|
|
||||||
// Features: role="alert", severity-critical bg tint, error icon, retry slot
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { message } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
class="flex min-h-48 flex-col items-center justify-center gap-4 rounded-xl px-6 py-10 text-center"
|
|
||||||
style="background-color: oklch(var(--destructive) / 0.08)"
|
|
||||||
>
|
|
||||||
<!-- Error icon circle -->
|
|
||||||
<div
|
|
||||||
class="flex size-12 items-center justify-center rounded-full"
|
|
||||||
style="background-color: oklch(var(--destructive) / 0.15)"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
style="color: oklch(var(--destructive))"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" y1="8" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error message -->
|
|
||||||
<p class="max-w-sm text-sm font-medium text-foreground">{message}</p>
|
|
||||||
|
|
||||||
<!-- Retry slot -->
|
|
||||||
{Astro.slots.has("retry") && (
|
|
||||||
<div class="mt-2">
|
|
||||||
<slot name="retry" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── LoadingSkeleton.astro — Placeholder loading states ─────────────────────
|
|
||||||
// Uses the shared Skeleton React component in 3 layout variants.
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { Skeleton } from "../../shared/components/skeleton";
|
|
||||||
|
|
||||||
export type Variant = "card" | "list" | "detail";
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
/** Visual layout variant */
|
|
||||||
variant?: Variant;
|
|
||||||
/** Number of skeleton items to render (default 1) */
|
|
||||||
count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { variant = "card", count = 1 } = Astro.props;
|
|
||||||
|
|
||||||
const items = Array.from({ length: count });
|
|
||||||
---
|
|
||||||
|
|
||||||
<div role="presentation" aria-hidden="true" class="flex flex-col gap-4">
|
|
||||||
{
|
|
||||||
items.map((_, i) => (
|
|
||||||
<div class="skeleton-group" data-variant={variant}>
|
|
||||||
{variant === "card" && (
|
|
||||||
<div class="overflow-hidden rounded-xl border border-border">
|
|
||||||
<Skeleton class="h-40 w-full rounded-none" />
|
|
||||||
<div class="space-y-3 p-4">
|
|
||||||
<Skeleton class="h-4 w-3/4" />
|
|
||||||
<Skeleton class="h-3 w-1/2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{variant === "list" && (
|
|
||||||
<div class="flex items-center gap-3 rounded-lg p-3">
|
|
||||||
<Skeleton class="size-10 shrink-0 rounded-full" />
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<Skeleton class="h-3.5 w-1/3" />
|
|
||||||
<Skeleton class="h-3 w-2/3" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{variant === "detail" && (
|
|
||||||
<div class="space-y-4 rounded-xl p-4">
|
|
||||||
<Skeleton class="h-6 w-1/2" />
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Skeleton class="h-4 w-full" />
|
|
||||||
<Skeleton class="h-4 w-full" />
|
|
||||||
<Skeleton class="h-4 w-3/4" />
|
|
||||||
</div>
|
|
||||||
<Skeleton class="h-10 w-32" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
---
|
|
||||||
export interface Props {
|
|
||||||
variant?: "default" | "secondary" | "success" | "warning" | "destructive" | "outline";
|
|
||||||
size?: "sm" | "default";
|
|
||||||
dot?: boolean;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
variant = "default",
|
|
||||||
size = "default",
|
|
||||||
dot = false,
|
|
||||||
class: className = "",
|
|
||||||
} = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<span class:list={[`badge badge--${variant} badge--${size}`, className]}>
|
|
||||||
{dot && <span class="badge__dot" aria-hidden="true" />}
|
|
||||||
<slot />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style is:global>
|
|
||||||
.badge {
|
|
||||||
--fs-xs: 0.75rem;
|
|
||||||
--fs-xxs: 0.625rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
font-weight: var(--fw-medium, 500);
|
|
||||||
transition:
|
|
||||||
background-color var(--duration-fast, 150ms) var(--ease-out, ease-out),
|
|
||||||
color var(--duration-fast, 150ms) var(--ease-out, ease-out);
|
|
||||||
white-space: nowrap;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge--default {
|
|
||||||
padding-inline: 0.625rem;
|
|
||||||
padding-block: 0.125rem;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
background-color: oklch(var(--primary));
|
|
||||||
color: oklch(var(--primary-foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge--secondary {
|
|
||||||
padding-inline: 0.625rem;
|
|
||||||
padding-block: 0.125rem;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
background-color: oklch(var(--muted));
|
|
||||||
color: oklch(var(--muted-foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge--success {
|
|
||||||
padding-inline: 0.625rem;
|
|
||||||
padding-block: 0.125rem;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
background-color: oklch(var(--success) / 0.15);
|
|
||||||
color: oklch(var(--success));
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge--warning {
|
|
||||||
padding-inline: 0.625rem;
|
|
||||||
padding-block: 0.125rem;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
background-color: oklch(var(--warning) / 0.15);
|
|
||||||
color: oklch(var(--warning));
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge--destructive {
|
|
||||||
padding-inline: 0.625rem;
|
|
||||||
padding-block: 0.125rem;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
background-color: oklch(var(--destructive) / 0.15);
|
|
||||||
color: oklch(var(--destructive));
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge--outline {
|
|
||||||
padding-inline: 0.625rem;
|
|
||||||
padding-block: 0.125rem;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
border-color: oklch(var(--border));
|
|
||||||
background-color: transparent;
|
|
||||||
color: oklch(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Size sm ───────────────────────────────────── */
|
|
||||||
|
|
||||||
.badge--sm {
|
|
||||||
padding-inline: 0.375rem;
|
|
||||||
padding-block: 0.0625rem;
|
|
||||||
font-size: var(--fs-xxs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Dot indicator ─────────────────────────────── */
|
|
||||||
|
|
||||||
.badge__dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 0.375rem;
|
|
||||||
height: 0.375rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: currentColor;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
---
|
|
||||||
export interface Props {
|
|
||||||
variant?: "primary" | "secondary" | "destructive" | "outline" | "ghost";
|
|
||||||
size?: "sm" | "default" | "lg" | "icon";
|
|
||||||
disabled?: boolean;
|
|
||||||
href?: string;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
variant = "primary",
|
|
||||||
size = "default",
|
|
||||||
disabled = false,
|
|
||||||
href,
|
|
||||||
class: className = "",
|
|
||||||
} = Astro.props;
|
|
||||||
|
|
||||||
const Tag = href ? "a" : "button";
|
|
||||||
|
|
||||||
const attrs: Record<string, string | number | boolean> = {
|
|
||||||
class: `btn btn--${variant} btn--${size} ${className}`,
|
|
||||||
};
|
|
||||||
if (Tag === "button") {
|
|
||||||
attrs.disabled = disabled;
|
|
||||||
} else if (disabled) {
|
|
||||||
attrs["aria-disabled"] = true;
|
|
||||||
attrs.role = "link";
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<Tag {...attrs}>
|
|
||||||
<slot />
|
|
||||||
</Tag>
|
|
||||||
|
|
||||||
<style is:global>
|
|
||||||
.btn {
|
|
||||||
--rd-md: calc(var(--radius, 1rem) - 2px);
|
|
||||||
--fs-sm: 0.875rem;
|
|
||||||
--fw-medium: 500;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
border-radius: var(--rd-md);
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
font-weight: var(--fw-medium);
|
|
||||||
line-height: 1.25rem;
|
|
||||||
transition:
|
|
||||||
background-color var(--duration-fast, 150ms) var(--ease-out, ease-out),
|
|
||||||
color var(--duration-fast, 150ms) var(--ease-out, ease-out),
|
|
||||||
border-color var(--duration-fast, 150ms) var(--ease-out, ease-out),
|
|
||||||
box-shadow var(--duration-fast, 150ms) var(--ease-out, ease-out),
|
|
||||||
transform var(--duration-fast, 150ms) var(--ease-out, ease-out);
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
text-decoration: none;
|
|
||||||
outline: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:focus-visible {
|
|
||||||
outline: 2px solid oklch(var(--ring));
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:active {
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled,
|
|
||||||
.btn[aria-disabled="true"] {
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Variants ──────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.btn--primary {
|
|
||||||
background-color: oklch(var(--primary));
|
|
||||||
color: oklch(var(--primary-foreground));
|
|
||||||
box-shadow: 0 1px 2px oklch(0 0 0 / 0.05);
|
|
||||||
}
|
|
||||||
.btn--primary:hover {
|
|
||||||
background-color: oklch(var(--primary) / 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--secondary {
|
|
||||||
background-color: oklch(var(--secondary));
|
|
||||||
color: oklch(var(--secondary-foreground));
|
|
||||||
}
|
|
||||||
.btn--secondary:hover {
|
|
||||||
background-color: oklch(var(--secondary) / 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--destructive {
|
|
||||||
background-color: oklch(var(--destructive));
|
|
||||||
color: oklch(var(--destructive-foreground));
|
|
||||||
box-shadow: 0 1px 2px oklch(0 0 0 / 0.05);
|
|
||||||
}
|
|
||||||
.btn--destructive:hover {
|
|
||||||
background-color: oklch(var(--destructive) / 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--outline {
|
|
||||||
border-color: oklch(var(--border));
|
|
||||||
background-color: oklch(var(--background));
|
|
||||||
color: oklch(var(--foreground));
|
|
||||||
}
|
|
||||||
.btn--outline:hover {
|
|
||||||
background-color: oklch(var(--accent));
|
|
||||||
color: oklch(var(--accent-foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--ghost {
|
|
||||||
background-color: transparent;
|
|
||||||
color: oklch(var(--foreground));
|
|
||||||
}
|
|
||||||
.btn--ghost:hover {
|
|
||||||
background-color: oklch(var(--accent));
|
|
||||||
color: oklch(var(--accent-foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sizes ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.btn--sm {
|
|
||||||
height: 2.25rem;
|
|
||||||
padding-inline: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--default {
|
|
||||||
height: 2.5rem;
|
|
||||||
padding-inline: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--lg {
|
|
||||||
height: 2.75rem;
|
|
||||||
padding-inline: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--icon {
|
|
||||||
height: 2.5rem;
|
|
||||||
width: 2.5rem;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
---
|
|
||||||
export interface Props {
|
|
||||||
variant?: "default" | "elevated" | "glass" | "interactive";
|
|
||||||
padding?: "none" | "sm" | "md" | "lg";
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
variant = "default",
|
|
||||||
padding = "md",
|
|
||||||
class: className = "",
|
|
||||||
} = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class:list={[`card card--${variant} card--pad-${padding}`, className]}>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style is:global>
|
|
||||||
.card {
|
|
||||||
--rd-lg: var(--radius, 1rem);
|
|
||||||
border-radius: var(--rd-lg);
|
|
||||||
transition:
|
|
||||||
background-color var(--duration-normal, 300ms) var(--ease-out, ease-out),
|
|
||||||
color var(--duration-normal, 300ms) var(--ease-out, ease-out),
|
|
||||||
border-color var(--duration-normal, 300ms) var(--ease-out, ease-out),
|
|
||||||
box-shadow var(--duration-normal, 300ms) var(--ease-out, ease-out),
|
|
||||||
transform var(--duration-normal, 300ms) var(--ease-out, ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Padding variants ──────────────────────────── */
|
|
||||||
|
|
||||||
.card--pad-none {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card--pad-sm {
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card--pad-md {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card--pad-lg {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Variant: default ──────────────────────────── */
|
|
||||||
|
|
||||||
.card--default {
|
|
||||||
background-color: oklch(var(--card));
|
|
||||||
color: oklch(var(--card-foreground));
|
|
||||||
border: 1px solid oklch(var(--border));
|
|
||||||
box-shadow: 0 1px 2px oklch(0 0 0 / 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Variant: elevated ─────────────────────────── */
|
|
||||||
|
|
||||||
.card--elevated {
|
|
||||||
background-color: oklch(var(--card));
|
|
||||||
color: oklch(var(--card-foreground));
|
|
||||||
border: 1px solid oklch(var(--border));
|
|
||||||
box-shadow:
|
|
||||||
0 4px 6px -1px oklch(0 0 0 / 0.1),
|
|
||||||
0 2px 4px -2px oklch(0 0 0 / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Variant: glass ────────────────────────────── */
|
|
||||||
|
|
||||||
.card--glass {
|
|
||||||
background-color: oklch(var(--glass-bg));
|
|
||||||
border: 1px solid oklch(var(--glass-border));
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
box-shadow: 0 4px 24px oklch(var(--glass-shadow));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Variant: interactive ──────────────────────── */
|
|
||||||
|
|
||||||
.card--interactive {
|
|
||||||
background-color: oklch(var(--card));
|
|
||||||
color: oklch(var(--card-foreground));
|
|
||||||
border: 1px solid oklch(var(--border));
|
|
||||||
box-shadow: 0 1px 2px oklch(0 0 0 / 0.05);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card--interactive:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow:
|
|
||||||
0 10px 15px -3px oklch(0 0 0 / 0.1),
|
|
||||||
0 4px 6px -4px oklch(0 0 0 / 0.1);
|
|
||||||
border-color: oklch(var(--primary) / 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card--interactive:focus-visible {
|
|
||||||
outline: 2px solid oklch(var(--ring));
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
---
|
|
||||||
export interface Props {
|
|
||||||
severity: "safe" | "low" | "medium" | "high" | "critical";
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { severity, class: className = "" } = Astro.props;
|
|
||||||
|
|
||||||
const ICONS: Record<string, string> = {
|
|
||||||
safe: "✔",
|
|
||||||
low: "↗",
|
|
||||||
medium: "⚠",
|
|
||||||
high: "⚡",
|
|
||||||
critical: "✖",
|
|
||||||
};
|
|
||||||
|
|
||||||
const icon = ICONS[severity] ?? "";
|
|
||||||
---
|
|
||||||
|
|
||||||
<span
|
|
||||||
class:list={[`severity-badge severity-badge--${severity}`, className]}
|
|
||||||
data-severity={severity}
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<span class="severity-badge__icon" aria-hidden="true" set:html={icon} />
|
|
||||||
<span class="severity-badge__label"><slot /></span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style is:global>
|
|
||||||
.severity-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding-inline: 0.5rem;
|
|
||||||
padding-block: 0.2rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
line-height: 1;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-badge__icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-badge__label {
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Severity: safe → success ──────────────────── */
|
|
||||||
|
|
||||||
.severity-badge--safe {
|
|
||||||
background-color: oklch(var(--success) / 0.15);
|
|
||||||
color: oklch(var(--success));
|
|
||||||
border-color: oklch(var(--success) / 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Severity: low → info ──────────────────────── */
|
|
||||||
|
|
||||||
.severity-badge--low {
|
|
||||||
background-color: oklch(var(--info) / 0.15);
|
|
||||||
color: oklch(var(--info));
|
|
||||||
border-color: oklch(var(--info) / 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Severity: medium → warning ────────────────── */
|
|
||||||
|
|
||||||
.severity-badge--medium {
|
|
||||||
background-color: oklch(var(--warning) / 0.15);
|
|
||||||
color: oklch(var(--warning));
|
|
||||||
border-color: oklch(var(--warning) / 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Severity: high → destructive ──────────────── */
|
|
||||||
|
|
||||||
.severity-badge--high {
|
|
||||||
background-color: oklch(var(--destructive) / 0.12);
|
|
||||||
color: oklch(var(--destructive));
|
|
||||||
border-color: oklch(var(--destructive) / 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Severity: critical ────────────────────────── */
|
|
||||||
|
|
||||||
.severity-badge--critical {
|
|
||||||
background-color: oklch(var(--destructive) / 0.25);
|
|
||||||
color: oklch(var(--destructive));
|
|
||||||
border-color: oklch(var(--destructive) / 0.4);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
---
|
|
||||||
export interface Props {
|
|
||||||
variant?: "text" | "card" | "circle" | "rect";
|
|
||||||
width?: string;
|
|
||||||
height?: string;
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
variant = "text",
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
class: className = "",
|
|
||||||
} = Astro.props;
|
|
||||||
|
|
||||||
const style: Record<string, string> = {};
|
|
||||||
if (width) style.width = width;
|
|
||||||
if (height) style.height = height;
|
|
||||||
---
|
|
||||||
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
role="presentation"
|
|
||||||
class:list={[`skeleton skeleton--${variant}`, className]}
|
|
||||||
style={Object.keys(style).length > 0 ? style : undefined}
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style is:global>
|
|
||||||
.skeleton {
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
oklch(var(--muted)) 25%,
|
|
||||||
oklch(var(--muted-foreground) / 0.15) 50%,
|
|
||||||
oklch(var(--muted)) 75%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 1.5s ease-in-out infinite;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Variant: text (default) ───────────────────── */
|
|
||||||
|
|
||||||
.skeleton--text {
|
|
||||||
width: 100%;
|
|
||||||
height: 1rem;
|
|
||||||
border-radius: calc(var(--radius, 1rem) - 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Variant: card ─────────────────────────────── */
|
|
||||||
|
|
||||||
.skeleton--card {
|
|
||||||
width: 100%;
|
|
||||||
height: 8rem;
|
|
||||||
border-radius: var(--radius, 1rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Variant: circle ───────────────────────────── */
|
|
||||||
|
|
||||||
.skeleton--circle {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Variant: rect ─────────────────────────────── */
|
|
||||||
|
|
||||||
.skeleton--rect {
|
|
||||||
width: 100%;
|
|
||||||
height: 3rem;
|
|
||||||
border-radius: calc(var(--radius, 1rem) - 2px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
---
|
|
||||||
export interface Props {
|
|
||||||
size?: "sm" | "default" | "lg";
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { size = "default", class: className = "" } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<span
|
|
||||||
aria-label="Loading"
|
|
||||||
role="status"
|
|
||||||
class:list={[`spinner spinner--${size}`, className]}
|
|
||||||
>
|
|
||||||
<span class="spinner__ring" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style is:global>
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner__ring {
|
|
||||||
display: block;
|
|
||||||
border-radius: 50%;
|
|
||||||
border-style: solid;
|
|
||||||
border-color: oklch(var(--muted));
|
|
||||||
border-top-color: oklch(var(--primary));
|
|
||||||
animation: spin 0.6s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sizes ─────────────────────────────────────── */
|
|
||||||
|
|
||||||
.spinner--sm .spinner__ring {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
border-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner--default .spinner__ring {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
border-width: 2.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner--lg .spinner__ring {
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-width: 3px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { GuildVoiceEntry } from "./guild";
|
import type { GuildVoiceEntry } from "../guild/types";
|
||||||
|
|
||||||
export interface VoiceStatus {
|
export interface VoiceStatus {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { AdminSettings } from "../../shared/api/client";
|
||||||
|
import {
|
||||||
|
getAdminSettings,
|
||||||
|
updateAdminSettings,
|
||||||
|
clearSessionToken,
|
||||||
|
logout,
|
||||||
|
} from "../../shared/api/client";
|
||||||
|
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../shared/ui";
|
||||||
|
|
||||||
|
export function AdminPanel() {
|
||||||
|
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
// Call server-side logout to increment token version
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
} catch {
|
||||||
|
// Even if server call fails, still clear local state for security
|
||||||
|
}
|
||||||
|
// Clear local token and legacy password
|
||||||
|
clearSessionToken();
|
||||||
|
localStorage.removeItem("admin-password");
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await getAdminSettings();
|
||||||
|
setSettings(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load settings");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTogglePublic = async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
const newValue = !settings.dashboardIsPublic;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
const updated = await updateAdminSettings({
|
||||||
|
dashboardIsPublic: newValue,
|
||||||
|
});
|
||||||
|
setSettings(updated);
|
||||||
|
setSuccess(
|
||||||
|
newValue
|
||||||
|
? "Dashboard is now public — accessible without password."
|
||||||
|
: "Dashboard is now private — admin password required.",
|
||||||
|
);
|
||||||
|
setTimeout(() => setSuccess(null), 4000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to update settings");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-primary">Admin Settings</CardTitle>
|
||||||
|
<CardDescription>Loading settings...</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !settings) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-primary">Admin Settings</CardTitle>
|
||||||
|
<CardDescription className="text-destructive">{error}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button onClick={fetchSettings} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" /> Retry
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPublic = settings?.dashboardIsPublic ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div variants={cardStagger} initial="initial" animate="animate">
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card className="border-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-primary">
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
Admin Settings
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage dashboard visibility and runtime configuration.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={fetchSettings}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* ── Success / Error messages ── */}
|
||||||
|
{success && (
|
||||||
|
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-600 dark:text-emerald-400">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Dashboard Visibility ── */}
|
||||||
|
<div className="rounded-xl border border-border bg-card p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isPublic ? (
|
||||||
|
<Globe className="h-4 w-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Lock className="h-4 w-4 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
Dashboard Visibility:{" "}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isPublic ? "text-emerald-500" : "text-amber-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPublic ? "Public" : "Private"}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isPublic
|
||||||
|
? "Anyone can view the dashboard without a password. Admin password is still required for management actions."
|
||||||
|
: "Admin password is required to access any part of the dashboard."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleTogglePublic}
|
||||||
|
disabled={saving}
|
||||||
|
variant={isPublic ? "outline" : "default"}
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : isPublic ? (
|
||||||
|
<>
|
||||||
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
|
Make Private
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
Make Public
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Status indicators ── */}
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg bg-muted/50 px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Runtime</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
|
isPublic ? "bg-emerald-400" : "bg-amber-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{isPublic ? "Public" : "Private"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Env Default
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
|
settings?.envDashboardIsPublic
|
||||||
|
? "bg-emerald-400"
|
||||||
|
: "bg-amber-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{settings?.envDashboardIsPublic ? "Public" : "Private"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Logout ── */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Info card ── */}
|
||||||
|
<div className="rounded-xl border border-border/50 bg-muted/30 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Shield className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
<strong>Admin password</strong> is configured via the
|
||||||
|
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||||
|
ADMIN_PASSWORD
|
||||||
|
</code>
|
||||||
|
environment variable. For security, it cannot be changed
|
||||||
|
through this panel — update it in your deployment
|
||||||
|
configuration and restart the service.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
Runtime settings are persisted across restarts in the
|
||||||
|
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||||
|
data/settings.json
|
||||||
|
</code>
|
||||||
|
file. Changes take effect immediately, no restart needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+5
-6
@@ -1,7 +1,7 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Lock, RefreshCw, Shield, Unlock, WifiOff } from "lucide-react";
|
import { Lock, Unlock, Shield, WifiOff, RefreshCw } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { login, setSessionToken } from "../../api/client.js";
|
import { login, setSessionToken } from "../../shared/api/client.js";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Input,
|
Input,
|
||||||
} from "../index";
|
} from "../../shared/ui";
|
||||||
|
|
||||||
interface AuthOverlayProps {
|
interface AuthOverlayProps {
|
||||||
onAuthenticated: () => void;
|
onAuthenticated: () => void;
|
||||||
@@ -153,8 +153,7 @@ export function AuthOverlay({
|
|||||||
<p className="mt-4 text-xs text-center text-muted-foreground">
|
<p className="mt-4 text-xs text-center text-muted-foreground">
|
||||||
<Unlock className="inline h-3 w-3 mr-1" />
|
<Unlock className="inline h-3 w-3 mr-1" />
|
||||||
The dashboard is in public mode — most data is visible without
|
The dashboard is in public mode — most data is visible without
|
||||||
authentication. Admin password is only needed for management
|
authentication. Admin password is only needed for management actions.
|
||||||
actions.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Hash } from "lucide-react";
|
||||||
|
import type { DashboardChannelDetail } from "../../../entities/dashboard/types.js";
|
||||||
|
import { ProfileDetail } from "../../../shared/ui";
|
||||||
|
|
||||||
|
interface ChannelProfileDetailProps {
|
||||||
|
detail: DashboardChannelDetail | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onBack: () => void;
|
||||||
|
onRefetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelProfileDetail({
|
||||||
|
detail,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onBack,
|
||||||
|
onRefetch,
|
||||||
|
}: ChannelProfileDetailProps) {
|
||||||
|
if (!detail && !loading && !error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileDetail
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
onRetry={onRefetch}
|
||||||
|
onBack={onBack}
|
||||||
|
icon={<Hash className="h-8 w-8" />}
|
||||||
|
title={detail ? `#${detail.channel_name ?? detail.channel_id}` : ""}
|
||||||
|
subtitle={detail?.channel_id}
|
||||||
|
summaryLabel="AI Channel Summary"
|
||||||
|
summaryText={detail?.culture_summary ?? undefined}
|
||||||
|
lastAnalyzedLabel={
|
||||||
|
detail?.last_analyzed_at
|
||||||
|
? `Last analyzed: ${new Date(detail.last_analyzed_at).toLocaleString()}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
stats={{
|
||||||
|
totalLabel: "Total Messages",
|
||||||
|
totalValue: detail?.total_messages ?? 0,
|
||||||
|
cleanLabel: "Clean",
|
||||||
|
cleanValue: detail?.clean_count ?? 0,
|
||||||
|
flaggedLabel: "Flagged",
|
||||||
|
flaggedValue: detail?.flagged_count ?? 0,
|
||||||
|
}}
|
||||||
|
messages={
|
||||||
|
detail?.recent_messages.map((msg) => ({
|
||||||
|
id: msg.id,
|
||||||
|
content: msg.content,
|
||||||
|
created_at: new Date(msg.created_at).toISOString(),
|
||||||
|
ai_status: msg.ai_status,
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Hash } from "lucide-react";
|
||||||
|
import type { DashboardChannel } from "../../../entities/dashboard/types.js";
|
||||||
|
import type { SummaryItem } from "../../../shared/ui";
|
||||||
|
import { SummaryList } from "../../../shared/ui";
|
||||||
|
|
||||||
|
interface ChannelSummaryListProps {
|
||||||
|
channels: DashboardChannel[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
hasMore: boolean;
|
||||||
|
onRefetch: () => void;
|
||||||
|
onSelectChannel: (channelId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelSummaryList({
|
||||||
|
channels,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
onLoadMore,
|
||||||
|
hasMore,
|
||||||
|
onRefetch,
|
||||||
|
onSelectChannel,
|
||||||
|
}: ChannelSummaryListProps) {
|
||||||
|
const items: SummaryItem[] = channels.map((ch) => ({
|
||||||
|
id: ch.channel_id,
|
||||||
|
label: `#${ch.channel_name ?? ch.channel_id}`,
|
||||||
|
subtitle: ch.flagged_count > 0 ? `${ch.flagged_count} flagged` : undefined,
|
||||||
|
summaryText: ch.culture_summary ?? `${ch.total_messages} messages`,
|
||||||
|
onClick: () => onSelectChannel(ch.channel_id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SummaryList
|
||||||
|
items={items}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
onRetry={onRefetch}
|
||||||
|
hasMore={hasMore}
|
||||||
|
onLoadMore={onLoadMore}
|
||||||
|
loadingMore={loading}
|
||||||
|
renderIcon={() => (
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Hash className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
emptyMessage="No channels found."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
BarChart3,
|
||||||
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
|
RefreshCw,
|
||||||
|
ShieldAlert,
|
||||||
|
UserCheck,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
|
||||||
|
import { useUIState } from "../../../shared/hooks/useUIState.js";
|
||||||
|
import { cn } from "../../../shared/lib/utils";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Skeleton,
|
||||||
|
StatusBadge,
|
||||||
|
} from "../../../shared/ui";
|
||||||
|
import { useDashboardStats } from "../hooks/useDashboard";
|
||||||
|
|
||||||
|
export function DashboardStatsContent() {
|
||||||
|
const { stats, loading, error, refetch } = useDashboardStats();
|
||||||
|
const { patchUIState } = useUIState();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <StatsSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
|
||||||
|
<AlertCircle className="h-10 w-10 text-destructive" />
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" /> Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
|
||||||
|
<BarChart3 className="h-10 w-10" />
|
||||||
|
<p className="text-sm">No data available yet.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
title: "Total Messages",
|
||||||
|
value: stats.total_messages.toLocaleString(),
|
||||||
|
icon: MessageSquare,
|
||||||
|
color: "text-primary",
|
||||||
|
bg: "bg-primary/10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Today's Messages",
|
||||||
|
value: stats.today_messages.toLocaleString(),
|
||||||
|
icon: MessageSquare,
|
||||||
|
color: "text-emerald-500",
|
||||||
|
bg: "bg-emerald-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Total Users",
|
||||||
|
value: stats.total_users.toLocaleString(),
|
||||||
|
icon: Users,
|
||||||
|
color: "text-blue-500",
|
||||||
|
bg: "bg-blue-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Active Users (24h)",
|
||||||
|
value: stats.active_users_24h.toLocaleString(),
|
||||||
|
icon: UserCheck,
|
||||||
|
color: "text-violet-500",
|
||||||
|
bg: "bg-violet-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Flagged",
|
||||||
|
value: stats.total_flagged.toLocaleString(),
|
||||||
|
icon: ShieldAlert,
|
||||||
|
color: "text-destructive",
|
||||||
|
bg: "bg-destructive/10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Clean",
|
||||||
|
value: stats.total_clean.toLocaleString(),
|
||||||
|
icon: ShieldAlert,
|
||||||
|
color: "text-emerald-600",
|
||||||
|
bg: "bg-emerald-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Voice Recordings",
|
||||||
|
value: stats.total_voice_recordings.toLocaleString(),
|
||||||
|
icon: Mic,
|
||||||
|
color: "text-cyan-500",
|
||||||
|
bg: "bg-cyan-100",
|
||||||
|
onClick: () => patchUIState({ activeTab: "live" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AI Profiles",
|
||||||
|
value: stats.total_profiles.toLocaleString(),
|
||||||
|
icon: Users,
|
||||||
|
color: "text-amber-500",
|
||||||
|
bg: "bg-amber-100",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="grid gap-6"
|
||||||
|
variants={cardStagger}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
>
|
||||||
|
{/* Summary cards grid */}
|
||||||
|
<motion.div
|
||||||
|
variants={cardItem}
|
||||||
|
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
|
||||||
|
>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Card
|
||||||
|
key={card.title}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden",
|
||||||
|
card.onClick &&
|
||||||
|
"cursor-pointer transition-colors hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
onClick={card.onClick}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
{card.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold tracking-tight">
|
||||||
|
{card.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={cn("rounded-xl p-2.5", card.bg)}>
|
||||||
|
<card.icon className={cn("h-5 w-5", card.color)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Top channels */}
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-primary">Top Channels</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{stats.top_channels.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No channel data yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.top_channels.map((ch) => (
|
||||||
|
<div
|
||||||
|
key={ch.channel_id}
|
||||||
|
className="flex items-center justify-between rounded-lg bg-muted/50 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
#{ch.channel_name ?? ch.channel_id}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 shrink-0 font-medium">
|
||||||
|
{ch.message_count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Moderation overview */}
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-primary">Moderation Queue</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<StatusBadge status="pending" />
|
||||||
|
<StatusBadge status="processing" />
|
||||||
|
<StatusBadge status="error" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3 mt-4">
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-muted-foreground">
|
||||||
|
{stats.moderation_overview.pending}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Pending</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-amber-500">
|
||||||
|
{stats.moderation_overview.processing}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Processing</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-destructive">
|
||||||
|
{stats.moderation_overview.error}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Errors</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { User } from "lucide-react";
|
||||||
|
import type { DashboardUserDetail } from "../../../entities/dashboard/types.js";
|
||||||
|
import { ProfileDetail } from "../../../shared/ui";
|
||||||
|
|
||||||
|
interface UserProfileDetailProps {
|
||||||
|
detail: DashboardUserDetail | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onBack: () => void;
|
||||||
|
onRefetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserProfileDetail({
|
||||||
|
detail,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onBack,
|
||||||
|
onRefetch,
|
||||||
|
}: UserProfileDetailProps) {
|
||||||
|
if (!detail && !loading && !error) return null;
|
||||||
|
|
||||||
|
const icon = detail?.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={detail.avatar_url}
|
||||||
|
alt=""
|
||||||
|
className="h-16 w-16 rounded-full object-cover ring-2 ring-border"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<User className="h-8 w-8" />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileDetail
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
onRetry={onRefetch}
|
||||||
|
onBack={onBack}
|
||||||
|
icon={icon}
|
||||||
|
title={detail?.username ?? detail?.user_id ?? ""}
|
||||||
|
subtitle={detail?.user_id}
|
||||||
|
summaryLabel="AI Profile Summary"
|
||||||
|
summaryText={detail?.profile_summary ?? undefined}
|
||||||
|
lastAnalyzedLabel={
|
||||||
|
detail?.last_analyzed_at
|
||||||
|
? `Last analyzed: ${new Date(detail.last_analyzed_at).toLocaleString()}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
stats={{
|
||||||
|
totalLabel: "Total Messages",
|
||||||
|
totalValue: detail?.total_messages ?? 0,
|
||||||
|
cleanLabel: "Clean",
|
||||||
|
cleanValue: detail?.clean_count ?? 0,
|
||||||
|
flaggedLabel: "Flagged",
|
||||||
|
flaggedValue: detail?.flagged_count ?? 0,
|
||||||
|
}}
|
||||||
|
messages={
|
||||||
|
detail?.recent_messages.map((msg) => ({
|
||||||
|
id: msg.id,
|
||||||
|
content: msg.content,
|
||||||
|
created_at: new Date(msg.created_at).toISOString(),
|
||||||
|
ai_status: msg.ai_status,
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { User } from "lucide-react";
|
||||||
|
import type { DashboardUser } from "../../../entities/dashboard/types.js";
|
||||||
|
import type { SummaryItem } from "../../../shared/ui";
|
||||||
|
import { SummaryList } from "../../../shared/ui";
|
||||||
|
|
||||||
|
interface UserSummaryListProps {
|
||||||
|
users: DashboardUser[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
hasMore: boolean;
|
||||||
|
onRefetch: () => void;
|
||||||
|
onSelectUser: (userId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserSummaryList({
|
||||||
|
users,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
onLoadMore,
|
||||||
|
hasMore,
|
||||||
|
onRefetch,
|
||||||
|
onSelectUser,
|
||||||
|
}: UserSummaryListProps) {
|
||||||
|
const items: SummaryItem[] = users.map((u) => ({
|
||||||
|
id: u.user_id,
|
||||||
|
label: u.username ?? u.user_id,
|
||||||
|
subtitle: u.trust_score !== null ? `Trust: ${u.trust_score}` : undefined,
|
||||||
|
summaryText: u.profile_summary ?? `${u.total_messages} messages`,
|
||||||
|
onClick: () => onSelectUser(u.user_id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const avatarMap = new Map(users.map((u) => [u.user_id, u.avatar_url]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SummaryList
|
||||||
|
items={items}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
onRetry={onRefetch}
|
||||||
|
hasMore={hasMore}
|
||||||
|
onLoadMore={onLoadMore}
|
||||||
|
loadingMore={loading}
|
||||||
|
renderIcon={(item) => {
|
||||||
|
const avatarUrl = avatarMap.get(item.id);
|
||||||
|
return avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||||
|
<User className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
emptyMessage="No users found."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { DashboardStats } from "../../../entities/dashboard/types.js";
|
||||||
|
import {
|
||||||
|
getDashboardChannelDetail,
|
||||||
|
getDashboardStats,
|
||||||
|
getDashboardUserDetail,
|
||||||
|
listDashboardChannels,
|
||||||
|
listDashboardUsers,
|
||||||
|
} from "../../../shared/api/client.js";
|
||||||
|
import { useItemDetail } from "../../../shared/hooks/useItemDetail";
|
||||||
|
import { usePaginatedList } from "../../../shared/hooks/usePaginatedList";
|
||||||
|
|
||||||
|
import { createLogger } from "../../../shared/lib/logger.js";
|
||||||
|
|
||||||
|
const logger = createLogger("use-dashboard");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch dashboard aggregate stats.
|
||||||
|
*/
|
||||||
|
export function useDashboardStats() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await getDashboardStats();
|
||||||
|
setStats(data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "Failed to load stats";
|
||||||
|
setError(msg);
|
||||||
|
logger.error("[useDashboardStats]", { error: msg });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch().catch(() => undefined);
|
||||||
|
}, [fetch]);
|
||||||
|
|
||||||
|
return { stats, loading, error, refetch: fetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch paginated user list with optional search.
|
||||||
|
*/
|
||||||
|
export function useDashboardUsers() {
|
||||||
|
const paginated = usePaginatedList(
|
||||||
|
(params) =>
|
||||||
|
listDashboardUsers({
|
||||||
|
limit: params.limit,
|
||||||
|
search: params.search,
|
||||||
|
cursor: params.cursor,
|
||||||
|
}).then((r) => ({ data: r.data, nextCursor: r.nextCursor })),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: paginated.data,
|
||||||
|
loading: paginated.loading,
|
||||||
|
error: paginated.error,
|
||||||
|
search: paginated.search,
|
||||||
|
setSearch: paginated.setSearch,
|
||||||
|
loadMore: paginated.loadMore,
|
||||||
|
hasMore: paginated.hasMore,
|
||||||
|
refetch: paginated.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single user detail by userId.
|
||||||
|
*/
|
||||||
|
export function useDashboardUserDetail(userId: string | null) {
|
||||||
|
const { data, loading, error, refetch } = useItemDetail(
|
||||||
|
(_guildId, entityId) => getDashboardUserDetail(entityId),
|
||||||
|
"",
|
||||||
|
userId,
|
||||||
|
"user",
|
||||||
|
);
|
||||||
|
|
||||||
|
return { detail: data, loading, error, refetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch paginated channel list with optional search.
|
||||||
|
*/
|
||||||
|
export function useDashboardChannels() {
|
||||||
|
const paginated = usePaginatedList(
|
||||||
|
(params) =>
|
||||||
|
listDashboardChannels({
|
||||||
|
limit: params.limit,
|
||||||
|
search: params.search,
|
||||||
|
guild_id: params.guildId,
|
||||||
|
cursor: params.cursor,
|
||||||
|
}).then((r) => ({ data: r.data, nextCursor: r.nextCursor })),
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
channels: paginated.data,
|
||||||
|
loading: paginated.loading,
|
||||||
|
error: paginated.error,
|
||||||
|
search: paginated.search,
|
||||||
|
setSearch: paginated.setSearch,
|
||||||
|
loadMore: paginated.loadMore,
|
||||||
|
hasMore: paginated.hasMore,
|
||||||
|
refetch: paginated.refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single channel detail by channelId.
|
||||||
|
*/
|
||||||
|
export function useDashboardChannelDetail(channelId: string | null) {
|
||||||
|
const { data, loading, error, refetch } = useItemDetail(
|
||||||
|
(_guildId, entityId) => getDashboardChannelDetail(entityId),
|
||||||
|
"",
|
||||||
|
channelId,
|
||||||
|
"channel",
|
||||||
|
);
|
||||||
|
|
||||||
|
return { detail: data, loading, error, refetch };
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { Settings } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AdminPanel } from "../../features/admin/AdminPanel";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui";
|
||||||
|
import { ChannelProfileDetail } from "./components/ChannelProfileDetail";
|
||||||
|
import { ChannelSummaryList } from "./components/ChannelSummaryList";
|
||||||
|
import { DashboardStatsContent } from "./components/DashboardStats";
|
||||||
|
import { UserProfileDetail } from "./components/UserProfileDetail";
|
||||||
|
import { UserSummaryList } from "./components/UserSummaryList";
|
||||||
|
import {
|
||||||
|
useDashboardChannelDetail,
|
||||||
|
useDashboardChannels,
|
||||||
|
useDashboardUserDetail,
|
||||||
|
useDashboardUsers,
|
||||||
|
} from "./hooks/useDashboard";
|
||||||
|
|
||||||
|
export function DashboardPanel() {
|
||||||
|
const [activeTab, setActiveTab] = useState("stats");
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
|
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
users,
|
||||||
|
loading: usersLoading,
|
||||||
|
error: usersError,
|
||||||
|
search: userSearch,
|
||||||
|
setSearch: setUserSearch,
|
||||||
|
loadMore: loadMoreUsers,
|
||||||
|
hasMore: hasMoreUsers,
|
||||||
|
refetch: refetchUsers,
|
||||||
|
} = useDashboardUsers();
|
||||||
|
const {
|
||||||
|
detail: userDetail,
|
||||||
|
loading: userDetailLoading,
|
||||||
|
error: userDetailError,
|
||||||
|
refetch: refetchUserDetail,
|
||||||
|
} = useDashboardUserDetail(selectedUserId);
|
||||||
|
const {
|
||||||
|
channels,
|
||||||
|
loading: channelsLoading,
|
||||||
|
error: channelsError,
|
||||||
|
search: channelSearch,
|
||||||
|
setSearch: setChannelSearch,
|
||||||
|
loadMore: loadMoreChannels,
|
||||||
|
hasMore: hasMoreChannels,
|
||||||
|
refetch: refetchChannels,
|
||||||
|
} = useDashboardChannels();
|
||||||
|
const {
|
||||||
|
detail: channelDetail,
|
||||||
|
loading: channelDetailLoading,
|
||||||
|
error: channelDetailError,
|
||||||
|
refetch: refetchChannelDetail,
|
||||||
|
} = useDashboardChannelDetail(selectedChannelId);
|
||||||
|
|
||||||
|
// Show user detail view
|
||||||
|
if (selectedUserId) {
|
||||||
|
return (
|
||||||
|
<UserProfileDetail
|
||||||
|
detail={userDetail}
|
||||||
|
loading={userDetailLoading}
|
||||||
|
error={userDetailError}
|
||||||
|
onBack={() => {
|
||||||
|
setSelectedUserId(null);
|
||||||
|
}}
|
||||||
|
onRefetch={refetchUserDetail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show channel detail view
|
||||||
|
if (selectedChannelId) {
|
||||||
|
return (
|
||||||
|
<ChannelProfileDetail
|
||||||
|
detail={channelDetail}
|
||||||
|
loading={channelDetailLoading}
|
||||||
|
error={channelDetailError}
|
||||||
|
onBack={() => {
|
||||||
|
setSelectedChannelId(null);
|
||||||
|
}}
|
||||||
|
onRefetch={refetchChannelDetail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
|
<TabsList className="mb-6">
|
||||||
|
<TabsTrigger value="stats">Stats</TabsTrigger>
|
||||||
|
<TabsTrigger value="users">Users</TabsTrigger>
|
||||||
|
<TabsTrigger value="channels">Channels</TabsTrigger>
|
||||||
|
<TabsTrigger value="admin" className="flex items-center gap-1.5">
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
Admin
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="stats">
|
||||||
|
<DashboardStatsContent />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="users">
|
||||||
|
<UserSummaryList
|
||||||
|
users={users}
|
||||||
|
loading={usersLoading}
|
||||||
|
error={usersError}
|
||||||
|
search={userSearch}
|
||||||
|
onSearchChange={setUserSearch}
|
||||||
|
onLoadMore={loadMoreUsers}
|
||||||
|
hasMore={hasMoreUsers}
|
||||||
|
onRefetch={refetchUsers}
|
||||||
|
onSelectUser={setSelectedUserId}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="channels">
|
||||||
|
<ChannelSummaryList
|
||||||
|
channels={channels}
|
||||||
|
loading={channelsLoading}
|
||||||
|
error={channelsError}
|
||||||
|
search={channelSearch}
|
||||||
|
onSearchChange={setChannelSearch}
|
||||||
|
onLoadMore={loadMoreChannels}
|
||||||
|
hasMore={hasMoreChannels}
|
||||||
|
onRefetch={refetchChannels}
|
||||||
|
onSelectChannel={setSelectedChannelId}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="admin">
|
||||||
|
<AdminPanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ActiveSpeaker } from "../../../entities/voice/types.js";
|
||||||
|
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||||
|
|
||||||
|
interface ActiveSpeakersProps {
|
||||||
|
speakers: ActiveSpeaker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||||
|
if (speakers.length === 0) {
|
||||||
|
return <EmptyStateMascot />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{speakers.map((s) => {
|
||||||
|
const key = s.userId ?? s.id ?? `speaker-${s.username}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-border bg-card p-3"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={s.avatar}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 rounded-full object-cover ring-2 ring-primary/30"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">{s.username}</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
|
s.speaking ? "bg-emerald-500" : "bg-muted-foreground/40"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
s.speaking ? "text-emerald-600" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.speaking ? "Speaking" : "Silent"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface AudioVisualizerProps {
|
||||||
|
levels: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!canvas || !container) return;
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = 128 * dpr;
|
||||||
|
canvas.style.height = "128px";
|
||||||
|
});
|
||||||
|
ro.observe(container);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const width = canvas.width / dpr;
|
||||||
|
const height = canvas.height / dpr;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
const barWidth = width / levels.length;
|
||||||
|
const maxBarHeight = height * 0.85;
|
||||||
|
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||||
|
gradient.addColorStop(0, "#23a1eb");
|
||||||
|
gradient.addColorStop(1, "#3eb0f2");
|
||||||
|
|
||||||
|
for (let i = 0; i < levels.length; i++) {
|
||||||
|
const level = levels[i];
|
||||||
|
const barHeight = Math.min(maxBarHeight, level * maxBarHeight);
|
||||||
|
const x = i * barWidth;
|
||||||
|
const y = height - barHeight;
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
|
||||||
|
const radius = barWidth * 0.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + barWidth - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + radius);
|
||||||
|
ctx.lineTo(x + barWidth, height);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}, [levels]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-full">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={0}
|
||||||
|
height={0}
|
||||||
|
className="w-full rounded-lg bg-primary/5"
|
||||||
|
style={{ height: "128px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// ─── Mic level meter — vertical bar showing outgoing audio RMS level ─────────
|
||||||
|
|
||||||
|
interface MicLevelMeterProps {
|
||||||
|
level: number; // 0-1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MicLevelMeter({ level }: MicLevelMeterProps) {
|
||||||
|
const pct = Math.round(level * 100);
|
||||||
|
|
||||||
|
// Color gradient: green <-> yellow <-> red
|
||||||
|
const hue = 120 - level * 120; // 120 (green) -> 0 (red)
|
||||||
|
const bg = `hsl(${hue}, 80%, 45%)`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex h-6 w-24 overflow-hidden rounded-full bg-muted"
|
||||||
|
role="meter"
|
||||||
|
aria-valuenow={pct}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label="Microphone level"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-[width,background-color] duration-75 ease-linear"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: bg }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Music2, SkipForward, Square, Volume2, VolumeX } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Input } from "../../../shared/ui";
|
||||||
|
|
||||||
|
interface MusicSubPanelProps {
|
||||||
|
volume: number;
|
||||||
|
onVolumeChange: (v: number) => void;
|
||||||
|
onQueue: (source: string) => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MusicSubPanel({
|
||||||
|
volume,
|
||||||
|
onVolumeChange,
|
||||||
|
onQueue,
|
||||||
|
onSkip,
|
||||||
|
onStop,
|
||||||
|
loading,
|
||||||
|
}: MusicSubPanelProps) {
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
const safeVolume = Number.isFinite(volume)
|
||||||
|
? Math.max(0, Math.min(1, volume))
|
||||||
|
: 1;
|
||||||
|
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
|
||||||
|
const [muted, setMuted] = useState(false);
|
||||||
|
const prevVolumeRef = useRef(safeVolume);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Proper debounce: setTimeout instead of setInterval polling
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
const normalized = draftVolume / 100;
|
||||||
|
if (Math.abs(normalized - safeVolume) >= 0.001)
|
||||||
|
onVolumeChange(normalized);
|
||||||
|
}, 200);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [draftVolume, safeVolume, onVolumeChange]);
|
||||||
|
|
||||||
|
const handleMute = useCallback(() => {
|
||||||
|
if (muted) {
|
||||||
|
// Unmute: restore previous volume
|
||||||
|
const restore = prevVolumeRef.current;
|
||||||
|
setDraftVolume(Math.round(restore * 100));
|
||||||
|
onVolumeChange(restore);
|
||||||
|
setMuted(false);
|
||||||
|
} else {
|
||||||
|
// Mute: save current, set to 0
|
||||||
|
prevVolumeRef.current = safeVolume;
|
||||||
|
setDraftVolume(0);
|
||||||
|
onVolumeChange(0);
|
||||||
|
setMuted(true);
|
||||||
|
}
|
||||||
|
}, [muted, safeVolume, onVolumeChange]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const t = source.trim();
|
||||||
|
if (!t) return;
|
||||||
|
onQueue(t);
|
||||||
|
setSource("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-4">
|
||||||
|
<Input
|
||||||
|
value={source}
|
||||||
|
onChange={(e) => setSource(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||||
|
placeholder="YouTube URL, Spotify track, or search terms"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleMute}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{muted ? (
|
||||||
|
<VolumeX className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Volume2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={draftVolume}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraftVolume(Number(e.target.value));
|
||||||
|
if (muted) setMuted(false);
|
||||||
|
}}
|
||||||
|
className="h-2 w-full cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
||||||
|
{draftVolume}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||||
|
<Music2 className="mr-1.5 h-4 w-4" /> Queue
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||||
|
<SkipForward className="mr-1.5 h-4 w-4" /> Skip
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" disabled={loading} onClick={onStop}>
|
||||||
|
<Square className="mr-1.5 h-4 w-4" /> Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { MonitorUp, Music2 } from "lucide-react";
|
||||||
|
import type { MediaItem } from "../../../entities/media/types.js";
|
||||||
|
import { Badge } from "../../../shared/ui";
|
||||||
|
|
||||||
|
interface NowPlayingProps {
|
||||||
|
current: MediaItem | null;
|
||||||
|
queue: MediaItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NowPlaying({ current, queue }: NowPlayingProps) {
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
{current.mode === "screen" ? (
|
||||||
|
<MonitorUp className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Music2 className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium">{current.title}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{current.source}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={current.mode === "screen" ? "warning" : "success"}>
|
||||||
|
{current.mode ?? "music"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{queue.length > 0 && (
|
||||||
|
<div className="border-t border-border p-4">
|
||||||
|
<div className="mb-2 text-sm font-medium">Queue ({queue.length})</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{queue.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={`${item.source}-${i}`}
|
||||||
|
className="flex items-center gap-3 rounded-lg border-l-2 border-l-primary border-border bg-card p-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{item.title}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{item.source}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// ─── Recordings Sub-Panel ──
|
||||||
|
|
||||||
|
import { Download, Mic, Trash2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { VoiceRecording } from "../../../entities/recording/types.js";
|
||||||
|
import { deleteRecording, listRecordings } from "../../../shared/api/client";
|
||||||
|
import { formatBytes, formatDate } from "../../../shared/lib/utils";
|
||||||
|
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||||||
|
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||||
|
import { WaveformPlayer } from "./WaveformPlayer";
|
||||||
|
|
||||||
|
export function RecordingsSubPanel() {
|
||||||
|
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const loadRecordings = useCallback(
|
||||||
|
async (opts?: { signal?: AbortSignal }) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await listRecordings({ limit: 50 });
|
||||||
|
if (!opts?.signal?.aborted) {
|
||||||
|
setRecordings(data.items);
|
||||||
|
setNextCursor(data.nextCursor);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!opts?.signal?.aborted)
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
if (!opts?.signal?.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (!nextCursor || loadingMore) return;
|
||||||
|
try {
|
||||||
|
setLoadingMore(true);
|
||||||
|
const data = await listRecordings({ limit: 50, cursor: nextCursor });
|
||||||
|
setRecordings((prev) => [...prev, ...data.items]);
|
||||||
|
setNextCursor(data.nextCursor);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [nextCursor, loadingMore]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ab = new AbortController();
|
||||||
|
loadRecordings({ signal: ab.signal });
|
||||||
|
const handler = () => loadRecordings();
|
||||||
|
window.addEventListener("voice_recording_uploaded", handler);
|
||||||
|
return () => {
|
||||||
|
ab.abort();
|
||||||
|
window.removeEventListener("voice_recording_uploaded", handler);
|
||||||
|
};
|
||||||
|
}, [loadRecordings]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async (id: string) => {
|
||||||
|
if (!confirm("Delete this recording?")) return;
|
||||||
|
setDeletingIds((prev) => new Set(prev).add(id));
|
||||||
|
try {
|
||||||
|
await deleteRecording(id);
|
||||||
|
setRecordings((prev) => prev.filter((r) => r.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setDeletingIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-4 rounded-xl border border-border bg-card p-4"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-3 w-64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-dashed border-destructive p-6 text-center text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
<div className="mt-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => loadRecordings()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordings.length === 0) {
|
||||||
|
return <EmptyStateMascot />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{recordings.map((rec) => (
|
||||||
|
<div key={rec.id} className="rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-4 p-4">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Mic className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium">{rec.filename}</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
|
||||||
|
<span>{rec.username}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{rec.channel_name ?? rec.channel_id ?? "unknown"}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{formatDate(rec.created_at)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{formatBytes(rec.size_bytes)}</span>
|
||||||
|
</div>
|
||||||
|
{rec.upload_error && (
|
||||||
|
<div className="mt-1 text-xs text-destructive">
|
||||||
|
{rec.upload_error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rec.transcription && (
|
||||||
|
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground italic">
|
||||||
|
{rec.transcription}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={deletingIds.has(rec.id)}
|
||||||
|
onClick={() => handleDelete(rec.id)}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
rec.upload_status === "uploaded"
|
||||||
|
? "success"
|
||||||
|
: rec.upload_status === "failed"
|
||||||
|
? "destructive"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{rec.upload_status}
|
||||||
|
</Badge>
|
||||||
|
{rec.download_url && (
|
||||||
|
<a
|
||||||
|
href={rec.download_url}
|
||||||
|
download={rec.filename}
|
||||||
|
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{rec.download_url && (
|
||||||
|
<div className="-mt-2 px-4 pb-4">
|
||||||
|
<WaveformPlayer
|
||||||
|
downloadUrl={rec.download_url}
|
||||||
|
filename={rec.filename}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex justify-center pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={loadingMore}
|
||||||
|
onClick={loadMore}
|
||||||
|
>
|
||||||
|
{loadingMore ? "Loading..." : "Load More"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { MonitorUp, SkipForward, Square } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Input } from "../../../shared/ui";
|
||||||
|
|
||||||
|
interface ScreenSubPanelProps {
|
||||||
|
onStart: (source: string) => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScreenSubPanel({
|
||||||
|
onStart,
|
||||||
|
onSkip,
|
||||||
|
onStop,
|
||||||
|
loading,
|
||||||
|
}: ScreenSubPanelProps) {
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
const submit = () => {
|
||||||
|
const t = source.trim();
|
||||||
|
if (!t) return;
|
||||||
|
onStart(t);
|
||||||
|
setSource("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-4">
|
||||||
|
<Input
|
||||||
|
value={source}
|
||||||
|
onChange={(e) => setSource(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||||
|
placeholder="Screen share URL or local file path"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||||
|
<MonitorUp className="mr-1.5 h-4 w-4" /> Start
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||||
|
<SkipForward className="mr-1.5 h-4 w-4" /> Skip
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" disabled={loading} onClick={onStop}>
|
||||||
|
<Square className="mr-1.5 h-4 w-4" /> Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Headphones, Radio } from "lucide-react";
|
||||||
|
import type { Channel, Guild } from "../../../entities/guild/types.js";
|
||||||
|
import type { VoiceStatus } from "../../../entities/voice/types.js";
|
||||||
|
import { Button, Select } from "../../../shared/ui";
|
||||||
|
import { MicLevelMeter } from "./MicLevelMeter";
|
||||||
|
|
||||||
|
interface VoiceConnectionCardProps {
|
||||||
|
guilds: Guild[];
|
||||||
|
voiceChannels: Channel[];
|
||||||
|
selectedGuild: string;
|
||||||
|
selectedChannel: string;
|
||||||
|
status: VoiceStatus;
|
||||||
|
voiceLoading: boolean;
|
||||||
|
isListening: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
micLevel: number;
|
||||||
|
onGuildChange: (id: string) => void;
|
||||||
|
onChannelChange: (id: string) => void;
|
||||||
|
onJoin: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
onListenToggle: () => void;
|
||||||
|
onStreamingToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceConnectionCard({
|
||||||
|
guilds,
|
||||||
|
voiceChannels,
|
||||||
|
selectedGuild,
|
||||||
|
selectedChannel,
|
||||||
|
status,
|
||||||
|
voiceLoading,
|
||||||
|
isListening,
|
||||||
|
isStreaming,
|
||||||
|
micLevel,
|
||||||
|
onGuildChange,
|
||||||
|
onChannelChange,
|
||||||
|
onJoin,
|
||||||
|
onDisconnect,
|
||||||
|
onListenToggle,
|
||||||
|
onStreamingToggle,
|
||||||
|
}: VoiceConnectionCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
|
||||||
|
<Radio className="h-5 w-5 text-primary" /> Voice Bridge
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Join a Discord voice channel, listen, and transmit audio.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">Guild</label>
|
||||||
|
<Select
|
||||||
|
value={selectedGuild}
|
||||||
|
onChange={(e) => onGuildChange(e.target.value)}
|
||||||
|
placeholder="Select guild"
|
||||||
|
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
Voice Channel
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={selectedChannel}
|
||||||
|
onChange={(e) => onChannelChange(e.target.value)}
|
||||||
|
placeholder="Select voice channel"
|
||||||
|
options={voiceChannels.map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={!selectedGuild || !selectedChannel || voiceLoading}
|
||||||
|
onClick={onJoin}
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{status.connected ? "Reconnect" : "Join Voice"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!status.connected || voiceLoading}
|
||||||
|
onClick={onDisconnect}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={isListening ? "secondary" : "outline"}
|
||||||
|
onClick={onListenToggle}
|
||||||
|
>
|
||||||
|
<Headphones className="mr-1.5 h-4 w-4" />{" "}
|
||||||
|
{isListening ? "Stop Listening" : "Listen"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={isStreaming ? "secondary" : "outline"}
|
||||||
|
onClick={onStreamingToggle}
|
||||||
|
>
|
||||||
|
<Radio className="mr-1.5 h-4 w-4" />{" "}
|
||||||
|
{isStreaming ? "Stop Transmit" : "Transmit"}
|
||||||
|
</Button>
|
||||||
|
{isStreaming && (
|
||||||
|
<div className="flex items-center gap-2 pl-1">
|
||||||
|
<span className="animate-pulse rounded-full bg-emerald-500 px-2 py-0.5 text-xs font-medium text-white">
|
||||||
|
Hold Space
|
||||||
|
</span>
|
||||||
|
<MicLevelMeter level={micLevel} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// ─── Waveform Player — audio visualizer with seekable waveform bars ──────────
|
||||||
|
|
||||||
|
import { Pause, Play } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { createLogger } from "../../../shared/lib/logger";
|
||||||
|
|
||||||
|
const logger = createLogger("waveform-player");
|
||||||
|
|
||||||
|
const BAR_COUNT = 64;
|
||||||
|
const SAMPLE_RATE = 24000;
|
||||||
|
|
||||||
|
interface WaveformPlayerProps {
|
||||||
|
downloadUrl: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WaveformPlayer({ downloadUrl, filename }: WaveformPlayerProps) {
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [peaks, setPeaks] = useState<number[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||||
|
const startTimeRef = useRef(0);
|
||||||
|
const startOffsetRef = useRef(0);
|
||||||
|
const rafRef = useRef<number>(0);
|
||||||
|
const decodedRef = useRef<AudioBuffer | null>(null);
|
||||||
|
const durationRef = useRef(0);
|
||||||
|
|
||||||
|
// Decode audio on mount
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
audioContextRef.current = ctx;
|
||||||
|
|
||||||
|
fetch(downloadUrl)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.arrayBuffer();
|
||||||
|
})
|
||||||
|
.then((buf) => ctx.decodeAudioData(buf))
|
||||||
|
.then((audioBuffer) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
decodedRef.current = audioBuffer;
|
||||||
|
durationRef.current = audioBuffer.duration;
|
||||||
|
|
||||||
|
// Compute waveform peaks
|
||||||
|
const channel = audioBuffer.getChannelData(0);
|
||||||
|
const samplesPerBar = Math.floor(channel.length / BAR_COUNT);
|
||||||
|
const peakValues: number[] = [];
|
||||||
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
|
let max = 0;
|
||||||
|
const start = i * samplesPerBar;
|
||||||
|
const end = Math.min(start + samplesPerBar, channel.length);
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
const abs = Math.abs(channel[j]);
|
||||||
|
if (abs > max) max = abs;
|
||||||
|
}
|
||||||
|
// Clamp so silent sections still show a tiny bar
|
||||||
|
peakValues.push(Math.max(0.01, max));
|
||||||
|
}
|
||||||
|
setPeaks(peakValues);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.error("Failed to decode audio", { error: msg });
|
||||||
|
setError(msg);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
ctx.close();
|
||||||
|
};
|
||||||
|
}, [downloadUrl]);
|
||||||
|
|
||||||
|
// Draw waveform on canvas whenever peaks change or while playing
|
||||||
|
const drawWaveform = useCallback(
|
||||||
|
(progress = 0) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!canvas || !container) return;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = 64 * dpr;
|
||||||
|
canvas.style.height = "64px";
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, rect.width, 64);
|
||||||
|
|
||||||
|
if (peaks.length === 0) return;
|
||||||
|
|
||||||
|
const barWidth = rect.width / peaks.length;
|
||||||
|
const barGap = Math.max(1, barWidth * 0.15);
|
||||||
|
const barActualWidth = barWidth - barGap;
|
||||||
|
const progressPixel = rect.width * progress;
|
||||||
|
|
||||||
|
for (let i = 0; i < peaks.length; i++) {
|
||||||
|
const x = i * barWidth;
|
||||||
|
const height = Math.max(2, peaks[i] * 50);
|
||||||
|
const y = 32 - height / 2;
|
||||||
|
|
||||||
|
// Color: played vs unplayed
|
||||||
|
const isPlayed = x + barWidth <= progressPixel;
|
||||||
|
ctx.fillStyle = isPlayed ? "#23a1eb" : "#334155";
|
||||||
|
ctx.fillRect(x, y, barActualWidth, height);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[peaks],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial draw when peaks change
|
||||||
|
useEffect(() => {
|
||||||
|
drawWaveform();
|
||||||
|
}, [drawWaveform]);
|
||||||
|
|
||||||
|
// Animation loop while playing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing || !decodedRef.current) return;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!audioContextRef.current) return;
|
||||||
|
const elapsed =
|
||||||
|
audioContextRef.current.currentTime - startTimeRef.current;
|
||||||
|
const progress = (elapsed + startOffsetRef.current) / durationRef.current;
|
||||||
|
drawWaveform(Math.min(1, Math.max(0, progress)));
|
||||||
|
|
||||||
|
if (progress >= 1) {
|
||||||
|
setPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(rafRef.current);
|
||||||
|
}, [playing, drawWaveform]);
|
||||||
|
|
||||||
|
const handleTogglePlay = useCallback(() => {
|
||||||
|
const ctx = audioContextRef.current;
|
||||||
|
const buffer = decodedRef.current;
|
||||||
|
if (!ctx || !buffer) return;
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
// Pause
|
||||||
|
if (sourceRef.current) {
|
||||||
|
startOffsetRef.current += ctx.currentTime - startTimeRef.current;
|
||||||
|
sourceRef.current.stop();
|
||||||
|
sourceRef.current.disconnect();
|
||||||
|
sourceRef.current = null;
|
||||||
|
}
|
||||||
|
setPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume / start
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(ctx.destination);
|
||||||
|
source.start(0, startOffsetRef.current);
|
||||||
|
startTimeRef.current = ctx.currentTime;
|
||||||
|
sourceRef.current = source;
|
||||||
|
setPlaying(true);
|
||||||
|
|
||||||
|
source.onended = () => {
|
||||||
|
if (sourceRef.current === source) {
|
||||||
|
setPlaying(false);
|
||||||
|
sourceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [playing]);
|
||||||
|
|
||||||
|
const handleSeek = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!decodedRef.current) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const progress = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
const offset = progress * durationRef.current;
|
||||||
|
|
||||||
|
const ctx = audioContextRef.current;
|
||||||
|
if (ctx && sourceRef.current) {
|
||||||
|
sourceRef.current.stop();
|
||||||
|
sourceRef.current.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
startOffsetRef.current = offset;
|
||||||
|
startTimeRef.current = ctx?.currentTime ?? 0;
|
||||||
|
drawWaveform(progress);
|
||||||
|
|
||||||
|
if (playing && ctx) {
|
||||||
|
const buffer = decodedRef.current;
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(ctx.destination);
|
||||||
|
source.start(0, offset);
|
||||||
|
startTimeRef.current = ctx.currentTime;
|
||||||
|
sourceRef.current = source;
|
||||||
|
source.onended = () => {
|
||||||
|
if (sourceRef.current === source) {
|
||||||
|
setPlaying(false);
|
||||||
|
sourceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[playing, drawWaveform],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="h-16 w-full animate-pulse rounded-md bg-muted" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="h-16 w-full rounded-md bg-destructive/10 flex items-center justify-center text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peaks.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTogglePlay}
|
||||||
|
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{playing ? (
|
||||||
|
<Pause className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative flex-1 cursor-pointer"
|
||||||
|
onClick={handleSeek}
|
||||||
|
role="slider"
|
||||||
|
aria-label={`Playback seek for ${filename}`}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} className="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { MediaState } from "../../../entities/media/types.js";
|
||||||
|
import {
|
||||||
|
getMediaStatus,
|
||||||
|
queueMedia,
|
||||||
|
setMediaVolume,
|
||||||
|
skipMedia,
|
||||||
|
stopMedia,
|
||||||
|
} from "../../../shared/api/client";
|
||||||
|
import { useAsyncAction } from "../../../shared/hooks/useAsyncAction.js";
|
||||||
|
import { createLogger } from "../../../shared/lib/logger.js";
|
||||||
|
|
||||||
|
const logger = createLogger("use-media-control");
|
||||||
|
|
||||||
|
const emptyMediaState: MediaState = {
|
||||||
|
playing: false,
|
||||||
|
musicVolume: 1,
|
||||||
|
current: null,
|
||||||
|
queue: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMediaControl() {
|
||||||
|
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
|
||||||
|
const { loading, error, execute, clearError } = useAsyncAction();
|
||||||
|
|
||||||
|
const refreshMedia = useCallback(async () => {
|
||||||
|
const state = await getMediaStatus();
|
||||||
|
setMediaState(state);
|
||||||
|
return state;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enqueue = useCallback(
|
||||||
|
async (source: string, mode: "music" | "screen") => {
|
||||||
|
const result = await execute(() => queueMedia(source, mode));
|
||||||
|
if (result) {
|
||||||
|
setMediaState(result);
|
||||||
|
logger.info("Media queued", { source, mode });
|
||||||
|
} else {
|
||||||
|
logger.error("Failed to queue media", { source, mode });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[execute],
|
||||||
|
);
|
||||||
|
|
||||||
|
const skip = useCallback(async () => {
|
||||||
|
const result = await execute(() => skipMedia());
|
||||||
|
if (result) {
|
||||||
|
setMediaState(result);
|
||||||
|
logger.info("Media skipped");
|
||||||
|
} else {
|
||||||
|
logger.error("Failed to skip media");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [execute]);
|
||||||
|
|
||||||
|
const stop = useCallback(async () => {
|
||||||
|
const result = await execute(() => stopMedia());
|
||||||
|
if (result) {
|
||||||
|
setMediaState(result);
|
||||||
|
logger.info("Media stopped");
|
||||||
|
} else {
|
||||||
|
logger.error("Failed to stop media");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [execute]);
|
||||||
|
|
||||||
|
const setVolume = useCallback(
|
||||||
|
async (volume: number) => {
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
const state = await setMediaVolume(volume);
|
||||||
|
setMediaState(state);
|
||||||
|
logger.info("Volume set", { volume });
|
||||||
|
return state;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.error("Failed to set volume", { volume, error: message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clearError],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshMedia().catch((err) =>
|
||||||
|
logger.error("Failed to refresh media state on mount", {
|
||||||
|
error: String(err),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [refreshMedia]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaState,
|
||||||
|
setMediaState,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refreshMedia,
|
||||||
|
enqueue,
|
||||||
|
skip,
|
||||||
|
stop,
|
||||||
|
setVolume,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { Channel, Guild } from "../../../entities/guild/types.js";
|
||||||
|
import type { VoiceStatus } from "../../../entities/voice/types.js";
|
||||||
|
import {
|
||||||
|
connectVoice,
|
||||||
|
disconnectVoice,
|
||||||
|
getGuilds,
|
||||||
|
getTextChannels,
|
||||||
|
getVoiceChannels,
|
||||||
|
getVoiceStatus,
|
||||||
|
} from "../../../shared/api/client";
|
||||||
|
import { useAsyncAction } from "../../../shared/hooks/useAsyncAction.js";
|
||||||
|
import { createLogger } from "../../../shared/lib/logger.js";
|
||||||
|
|
||||||
|
const logger = createLogger("use-voice-control");
|
||||||
|
|
||||||
|
export function useVoiceControl() {
|
||||||
|
const [guilds, setGuilds] = useState<Guild[]>([]);
|
||||||
|
const [voiceChannels, setVoiceChannels] = useState<Channel[]>([]);
|
||||||
|
const [textChannels, setTextChannels] = useState<Channel[]>([]);
|
||||||
|
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>({
|
||||||
|
connected: false,
|
||||||
|
activeGuildId: null,
|
||||||
|
activeChannelId: null,
|
||||||
|
activeChannelName: null,
|
||||||
|
connections: [],
|
||||||
|
});
|
||||||
|
const { loading, error, execute, clearError } = useAsyncAction();
|
||||||
|
|
||||||
|
const refreshGuilds = useCallback(async () => {
|
||||||
|
clearError();
|
||||||
|
const nextGuilds = await getGuilds();
|
||||||
|
setGuilds(nextGuilds);
|
||||||
|
return nextGuilds;
|
||||||
|
}, [clearError]);
|
||||||
|
|
||||||
|
const refreshVoiceStatus = useCallback(async () => {
|
||||||
|
const status = await getVoiceStatus();
|
||||||
|
setVoiceStatus(status);
|
||||||
|
return status;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadVoiceChannels = useCallback(async (guildId: string) => {
|
||||||
|
if (!guildId) {
|
||||||
|
setVoiceChannels([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const channels = await getVoiceChannels(guildId);
|
||||||
|
setVoiceChannels(channels);
|
||||||
|
return channels;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTextTargets = useCallback(async (guildId: string) => {
|
||||||
|
if (!guildId) {
|
||||||
|
setTextChannels([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const channels = await getTextChannels(guildId);
|
||||||
|
setTextChannels(channels);
|
||||||
|
return channels;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const joinVoice = useCallback(
|
||||||
|
async (guildId: string, channelId: string) => {
|
||||||
|
const result = await execute(() => connectVoice(guildId, channelId));
|
||||||
|
if (result) {
|
||||||
|
setVoiceStatus(result);
|
||||||
|
logger.info("Connected to voice", { guildId, channelId });
|
||||||
|
} else {
|
||||||
|
logger.error("Failed to connect to voice", { guildId, channelId });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[execute],
|
||||||
|
);
|
||||||
|
|
||||||
|
const leaveVoice = useCallback(async () => {
|
||||||
|
const result = await execute(() => disconnectVoice());
|
||||||
|
if (result) {
|
||||||
|
setVoiceStatus(result);
|
||||||
|
logger.info("Disconnected from voice");
|
||||||
|
} else {
|
||||||
|
logger.error("Failed to disconnect from voice");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [execute]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshGuilds().catch((err) =>
|
||||||
|
logger.error("Failed to refresh guilds on mount", { error: String(err) }),
|
||||||
|
);
|
||||||
|
refreshVoiceStatus().catch((err) =>
|
||||||
|
logger.error("Failed to refresh voice status on mount", {
|
||||||
|
error: String(err),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [refreshGuilds, refreshVoiceStatus]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
guilds,
|
||||||
|
voiceChannels,
|
||||||
|
textChannels,
|
||||||
|
voiceStatus,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refreshGuilds,
|
||||||
|
refreshVoiceStatus,
|
||||||
|
loadVoiceChannels,
|
||||||
|
loadTextTargets,
|
||||||
|
joinVoice,
|
||||||
|
leaveVoice,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// ─── Live Panel — thin composition layer ────────────────────────────────────
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Mic, MonitorUp, Music2 } from "lucide-react";
|
||||||
|
import type { Channel, Guild } from "../../entities/guild/types.js";
|
||||||
|
import type { MediaState } from "../../entities/media/types.js";
|
||||||
|
import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js";
|
||||||
|
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "../../shared/ui";
|
||||||
|
import { ActiveSpeakers } from "./components/ActiveSpeakers";
|
||||||
|
import { AudioVisualizer } from "./components/AudioVisualizer";
|
||||||
|
import { MusicSubPanel } from "./components/MusicSubPanel";
|
||||||
|
import { NowPlaying } from "./components/NowPlaying";
|
||||||
|
import { RecordingsSubPanel } from "./components/RecordingsSubPanel";
|
||||||
|
import { ScreenSubPanel } from "./components/ScreenSubPanel";
|
||||||
|
import { VoiceConnectionCard } from "./components/VoiceConnectionCard";
|
||||||
|
|
||||||
|
interface LivePanelProps {
|
||||||
|
guilds: Guild[];
|
||||||
|
voiceChannels: Channel[];
|
||||||
|
selectedGuild: string;
|
||||||
|
selectedChannel: string;
|
||||||
|
status: VoiceStatus;
|
||||||
|
voiceLoading: boolean;
|
||||||
|
activeSpeakers: ActiveSpeaker[];
|
||||||
|
levels: number[];
|
||||||
|
isListening: boolean;
|
||||||
|
isStreaming: boolean;
|
||||||
|
micLevel: number;
|
||||||
|
mediaState: MediaState;
|
||||||
|
mediaLoading: boolean;
|
||||||
|
onGuildChange: (id: string) => void;
|
||||||
|
onChannelChange: (id: string) => void;
|
||||||
|
onJoin: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
onListenToggle: () => void;
|
||||||
|
onStreamingToggle: () => void;
|
||||||
|
onQueueMusic: (source: string) => void;
|
||||||
|
onStartScreen: (source: string) => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onVolumeChange: (v: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LivePanel({
|
||||||
|
guilds,
|
||||||
|
voiceChannels,
|
||||||
|
selectedGuild,
|
||||||
|
selectedChannel,
|
||||||
|
status,
|
||||||
|
voiceLoading,
|
||||||
|
activeSpeakers,
|
||||||
|
levels,
|
||||||
|
isListening,
|
||||||
|
isStreaming,
|
||||||
|
micLevel,
|
||||||
|
mediaState,
|
||||||
|
mediaLoading,
|
||||||
|
onGuildChange,
|
||||||
|
onChannelChange,
|
||||||
|
onJoin,
|
||||||
|
onDisconnect,
|
||||||
|
onListenToggle,
|
||||||
|
onStreamingToggle,
|
||||||
|
onQueueMusic,
|
||||||
|
onStartScreen,
|
||||||
|
onSkip,
|
||||||
|
onStop,
|
||||||
|
onVolumeChange,
|
||||||
|
}: LivePanelProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={cardStagger}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
className="grid gap-6"
|
||||||
|
>
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<VoiceConnectionCard
|
||||||
|
guilds={guilds}
|
||||||
|
voiceChannels={voiceChannels}
|
||||||
|
selectedGuild={selectedGuild}
|
||||||
|
selectedChannel={selectedChannel}
|
||||||
|
status={status}
|
||||||
|
voiceLoading={voiceLoading}
|
||||||
|
isListening={isListening}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
micLevel={micLevel}
|
||||||
|
onGuildChange={onGuildChange}
|
||||||
|
onChannelChange={onChannelChange}
|
||||||
|
onJoin={onJoin}
|
||||||
|
onDisconnect={onDisconnect}
|
||||||
|
onListenToggle={onListenToggle}
|
||||||
|
onStreamingToggle={onStreamingToggle}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
variants={cardItem}
|
||||||
|
className="grid gap-6 xl:grid-cols-[1fr_320px]"
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Live Audio</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AudioVisualizer levels={levels} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Active Speakers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActiveSpeakers speakers={activeSpeakers} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<NowPlaying current={mediaState.current} queue={mediaState.queue} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Tabs defaultValue="music">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="music">
|
||||||
|
<Music2 className="mr-1.5 h-4 w-4" /> Music
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="screen">
|
||||||
|
<MonitorUp className="mr-1.5 h-4 w-4" /> Screen Share
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="recordings">
|
||||||
|
<Mic className="mr-1.5 h-4 w-4" /> Recordings
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="music">
|
||||||
|
<MusicSubPanel
|
||||||
|
volume={mediaState.musicVolume}
|
||||||
|
onVolumeChange={onVolumeChange}
|
||||||
|
onQueue={onQueueMusic}
|
||||||
|
onSkip={onSkip}
|
||||||
|
onStop={onStop}
|
||||||
|
loading={mediaLoading}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="screen">
|
||||||
|
<ScreenSubPanel
|
||||||
|
onStart={onStartScreen}
|
||||||
|
onSkip={onSkip}
|
||||||
|
onStop={onStop}
|
||||||
|
loading={mediaLoading}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="recordings">
|
||||||
|
<RecordingsSubPanel />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import type { MessageRecord } from "../../../entities/message/types.js";
|
||||||
|
import { parseMetadata } from "../../../shared/lib/utils.js";
|
||||||
|
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||||
|
|
||||||
|
interface ImageItem {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
kind: "attachment" | "embed" | "sticker";
|
||||||
|
message: MessageRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindBadge(kind: ImageItem["kind"]): string {
|
||||||
|
switch (kind) {
|
||||||
|
case "sticker":
|
||||||
|
return "bg-primary/10 text-primary border-primary/20";
|
||||||
|
case "attachment":
|
||||||
|
return "bg-primary-soft text-primary border-primary/30";
|
||||||
|
case "embed":
|
||||||
|
return "bg-purple-100 text-purple-700 border-purple-200";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||||
|
const images: ImageItem[] = [];
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const metadata = parseMetadata(message.metadata);
|
||||||
|
|
||||||
|
// Stickers
|
||||||
|
for (const sticker of metadata.stickers ?? []) {
|
||||||
|
if (sticker.url) {
|
||||||
|
images.push({
|
||||||
|
url: sticker.url,
|
||||||
|
title: sticker.name || "sticker",
|
||||||
|
kind: "sticker",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
for (const attachment of metadata.attachments ?? []) {
|
||||||
|
if (
|
||||||
|
attachment.url &&
|
||||||
|
(attachment.contentType?.startsWith("image/") ||
|
||||||
|
/\.(png|jpe?g|gif|webp)$/i.test(attachment.name))
|
||||||
|
) {
|
||||||
|
images.push({
|
||||||
|
url: attachment.url,
|
||||||
|
title: attachment.name,
|
||||||
|
kind: "attachment",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed images
|
||||||
|
for (const embed of metadata.embeds ?? []) {
|
||||||
|
for (const imgUrl of [embed.image, embed.thumbnail].filter(Boolean)) {
|
||||||
|
images.push({
|
||||||
|
url: imgUrl as string,
|
||||||
|
title: embed.title || "embed image",
|
||||||
|
kind: "embed",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return <EmptyStateMascot />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
|
{images.map((image) => {
|
||||||
|
// Stable key using message.id + url
|
||||||
|
const stableKey = `${image.message.id}-${image.kind}-${image.url}`;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={stableKey}
|
||||||
|
href={image.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="group overflow-hidden rounded-xl border border-primary/20 bg-card shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video overflow-hidden">
|
||||||
|
{image.kind === "sticker" ? (
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={image.title}
|
||||||
|
className="h-full w-full object-contain bg-muted/30 p-2 transition-transform group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={image.url}
|
||||||
|
alt={image.title}
|
||||||
|
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`absolute right-2 top-2 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider shadow-sm backdrop-blur ${kindBadge(image.kind)}`}
|
||||||
|
>
|
||||||
|
{image.kind}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="truncate text-sm font-medium">{image.title}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-4 w-4 overflow-hidden rounded-full ring-1 ring-primary/30">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
image.message.avatar_url ??
|
||||||
|
"https://cdn.discordapp.com/embed/avatars/0.png"
|
||||||
|
}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{image.message.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Forward,
|
||||||
|
Hash,
|
||||||
|
Image as ImageIcon,
|
||||||
|
MessageCircle,
|
||||||
|
Pencil,
|
||||||
|
Reply,
|
||||||
|
RotateCw,
|
||||||
|
Smile,
|
||||||
|
Trash2,
|
||||||
|
Video,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { MessageRecord } from "../../../entities/message/types.js";
|
||||||
|
import { parseMetadata } from "../../../shared/lib/utils.js";
|
||||||
|
import { getMessageById } from "../../../shared/api/client.js";
|
||||||
|
import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui";
|
||||||
|
|
||||||
|
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
|
||||||
|
|
||||||
|
function renderContentWithCustomEmojis(content: string): React.ReactNode {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
const regex = new RegExp(CUSTOM_EMOJI_REGEX.source, "g");
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(content.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
const [, animated, name, id] = match;
|
||||||
|
const ext = animated ? "gif" : "png";
|
||||||
|
const url = `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`;
|
||||||
|
parts.push(
|
||||||
|
<img
|
||||||
|
key={`${id}-${match.index}`}
|
||||||
|
src={url}
|
||||||
|
alt={name}
|
||||||
|
className="inline-block h-[22px] w-[22px] align-middle object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
draggable={false}
|
||||||
|
title={`:${name}:`}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
lastIndex = regex.lastIndex;
|
||||||
|
}
|
||||||
|
if (lastIndex < content.length) {
|
||||||
|
parts.push(content.slice(lastIndex));
|
||||||
|
}
|
||||||
|
if (parts.length === 0) return content;
|
||||||
|
return <Fragment>{parts}</Fragment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface MessageCardProps {
|
||||||
|
messages: MessageRecord[];
|
||||||
|
onReanalyze: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseStringList(value?: string | null): string[] {
|
||||||
|
if (!value) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown;
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.filter((item): item is string => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityColor(severity: string) {
|
||||||
|
switch (severity) {
|
||||||
|
case "critical":
|
||||||
|
return "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800";
|
||||||
|
case "high":
|
||||||
|
return "bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800";
|
||||||
|
case "medium":
|
||||||
|
return "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800";
|
||||||
|
case "low":
|
||||||
|
return "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800";
|
||||||
|
default:
|
||||||
|
return "bg-muted text-muted-foreground border-border";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(ts: number): string {
|
||||||
|
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||||
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||||
|
return new Date(ts).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Single message row inside a group ───────────────────────────────────────
|
||||||
|
|
||||||
|
function MessageRow({
|
||||||
|
message,
|
||||||
|
onReanalyze,
|
||||||
|
}: {
|
||||||
|
message: MessageRecord;
|
||||||
|
onReanalyze: (id: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const metadata = useMemo(
|
||||||
|
() => parseMetadata(message.metadata),
|
||||||
|
[message.metadata],
|
||||||
|
);
|
||||||
|
const displayContent = message.edited_content ?? message.content;
|
||||||
|
const aiStatus = message.ai_status ?? "pending";
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const list = parseStringList(
|
||||||
|
message.ai_categories ?? message.ai_moderation_flags,
|
||||||
|
);
|
||||||
|
return list.filter((c) => c !== "analysis_incomplete");
|
||||||
|
}, [message.ai_categories, message.ai_moderation_flags]);
|
||||||
|
const confidence =
|
||||||
|
message.ai_confidence ?? message.ai_moderation_score ?? null;
|
||||||
|
const [isReanalyzing, setIsReanalyzing] = useState(false);
|
||||||
|
|
||||||
|
// ── Fetch referenced message content for replies if not in metadata ──
|
||||||
|
const referenceMeta = metadata.reference;
|
||||||
|
const [fetchedRefContent, setFetchedRefContent] = useState<{
|
||||||
|
username: string;
|
||||||
|
content: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
message.is_reply &&
|
||||||
|
referenceMeta?.messageId &&
|
||||||
|
!referenceMeta?.content &&
|
||||||
|
!message.deleted_at
|
||||||
|
) {
|
||||||
|
getMessageById(referenceMeta.messageId)
|
||||||
|
.then((refMsg) => {
|
||||||
|
if (refMsg) {
|
||||||
|
setFetchedRefContent({
|
||||||
|
username: refMsg.username,
|
||||||
|
content: refMsg.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Referenced message might not exist in our DB
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [message.is_reply, referenceMeta?.messageId, referenceMeta?.content, message.deleted_at]);
|
||||||
|
|
||||||
|
const analysisSummary = useMemo(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (categories.length > 0) {
|
||||||
|
parts.push(categories.slice(0, 3).join(", "));
|
||||||
|
if (categories.length > 3) parts.push(`+${categories.length - 3} more`);
|
||||||
|
}
|
||||||
|
if (message.ai_severity && message.ai_severity !== "none") {
|
||||||
|
parts.push(message.ai_severity);
|
||||||
|
}
|
||||||
|
if (confidence != null) {
|
||||||
|
parts.push(`${Math.round(confidence * 100)}% confidence`);
|
||||||
|
}
|
||||||
|
if (parts.length === 0) return "View AI analysis";
|
||||||
|
return parts.join(" · ");
|
||||||
|
}, [categories, message.ai_severity, confidence]);
|
||||||
|
|
||||||
|
const stickers = metadata.stickers ?? [];
|
||||||
|
const attachments = metadata.attachments ?? [];
|
||||||
|
const imageAttachments = attachments.filter(
|
||||||
|
(a) =>
|
||||||
|
a.contentType?.startsWith("image/") ||
|
||||||
|
/\.(png|jpe?g|gif|webp)$/i.test(a.name),
|
||||||
|
);
|
||||||
|
const videoAttachments = attachments.filter(
|
||||||
|
(a) =>
|
||||||
|
a.contentType?.startsWith("video/") ||
|
||||||
|
/\.(mp4|webm|mov|mkv|avi)$/i.test(a.name),
|
||||||
|
);
|
||||||
|
const hasImages = imageAttachments.length > 0;
|
||||||
|
const hasVideos = videoAttachments.length > 0;
|
||||||
|
|
||||||
|
/** Hide the fallback text ("[Attachment: ...]", "[Sticker: ...]", "[Embed]") when the actual media IS already shown visually. */
|
||||||
|
const isFallbackText =
|
||||||
|
/^\[(Attachment|Sticker):/i.test(displayContent) ||
|
||||||
|
/^\[Embed\]/i.test(displayContent);
|
||||||
|
const shouldShowContent = displayContent && !isFallbackText;
|
||||||
|
|
||||||
|
const handleReanalyze = async () => {
|
||||||
|
setIsReanalyzing(true);
|
||||||
|
try {
|
||||||
|
await onReanalyze(message.id);
|
||||||
|
} finally {
|
||||||
|
setIsReanalyzing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Reference context (reply / forward / crosspost) ─────────────────
|
||||||
|
const renderReferenceIndicator = () => {
|
||||||
|
// Use fetched content if metadata doesn't have it
|
||||||
|
const effectiveRepliedUsername =
|
||||||
|
referenceMeta?.repliedUsername ?? fetchedRefContent?.username ?? null;
|
||||||
|
const effectiveRepliedContent =
|
||||||
|
referenceMeta?.content ?? fetchedRefContent?.content ?? null;
|
||||||
|
|
||||||
|
if (message.is_reply) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-muted-foreground/20 pl-2.5 py-1 hover:border-primary/40 transition-colors">
|
||||||
|
<Reply className="h-3 w-3 mt-0.5 shrink-0" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="font-medium text-foreground/60">
|
||||||
|
Replying to{" "}
|
||||||
|
{effectiveRepliedUsername
|
||||||
|
? `@${effectiveRepliedUsername}`
|
||||||
|
: "a message"}
|
||||||
|
</span>
|
||||||
|
{effectiveRepliedContent && (
|
||||||
|
<span className="block truncate max-w-[400px] text-ellipsis text-[11px] text-muted-foreground/50 mt-0.5">
|
||||||
|
{effectiveRepliedContent}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.is_forward) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-amber-400/40 pl-2.5 py-1">
|
||||||
|
<Forward className="h-3 w-3 shrink-0 text-amber-500" />
|
||||||
|
<span className="font-medium text-amber-600/70">Forwarded</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.is_crosspost) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-sky-400/40 pl-2.5 py-1">
|
||||||
|
<MessageCircle className="h-3 w-3 shrink-0 text-sky-500" />
|
||||||
|
<span className="font-medium text-sky-600/70">Crossposted</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const referenceIndicator = renderReferenceIndicator();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Row header: time + edit/delete indicators + AI badges */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span
|
||||||
|
className="text-[11px] text-muted-foreground/70"
|
||||||
|
title={new Date(message.created_at).toLocaleString()}
|
||||||
|
>
|
||||||
|
{formatTime(message.created_at)}
|
||||||
|
</span>
|
||||||
|
{message.edited_at && (
|
||||||
|
<span className="flex items-center gap-0.5 text-[11px] text-muted-foreground/70">
|
||||||
|
<Pencil className="h-2.5 w-2.5" /> edited
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{message.deleted_at && (
|
||||||
|
<span className="flex items-center gap-0.5 text-[11px] text-destructive/70">
|
||||||
|
<Trash2 className="h-2.5 w-2.5" /> deleted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<StatusBadge status={aiStatus} className="text-[10px] px-1.5 py-0">
|
||||||
|
{aiStatus === "clean" && <CheckCircle2 className="h-3 w-3" />}
|
||||||
|
{aiStatus === "flagged" && <AlertCircle className="h-3 w-3" />}
|
||||||
|
{aiStatus === "error" && <AlertCircle className="h-3 w-3" />}
|
||||||
|
</StatusBadge>
|
||||||
|
{message.ai_severity && message.ai_severity !== "none" && (
|
||||||
|
<Badge
|
||||||
|
className={`text-[10px] px-1.5 py-0 ${severityColor(message.ai_severity)}`}
|
||||||
|
>
|
||||||
|
{message.ai_severity}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{confidence != null && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] px-1.5 py-0 tabular-nums"
|
||||||
|
>
|
||||||
|
{Math.round(confidence * 100)}%
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reference context: reply / forward / crosspost */}
|
||||||
|
{referenceIndicator}
|
||||||
|
|
||||||
|
{/* Content — hidden when it's just an "[Attachment: ...]" fallback and the image is shown below */}
|
||||||
|
{shouldShowContent ? (
|
||||||
|
<p
|
||||||
|
className={`whitespace-pre-wrap break-words text-sm leading-6 ${
|
||||||
|
message.deleted_at
|
||||||
|
? "text-muted-foreground/60"
|
||||||
|
: "text-foreground/90"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{renderContentWithCustomEmojis(displayContent)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Stickers */}
|
||||||
|
{stickers.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stickers.map((sticker) => (
|
||||||
|
<div
|
||||||
|
key={sticker.name || sticker.url}
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{sticker.url ? (
|
||||||
|
<img
|
||||||
|
src={sticker.url}
|
||||||
|
alt={sticker.name || "sticker"}
|
||||||
|
className="h-12 w-12 rounded-lg border border-border object-contain bg-muted/50"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
|
||||||
|
<Smile className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attached images */}
|
||||||
|
{hasImages && (
|
||||||
|
<div className="flex gap-2 overflow-x-auto">
|
||||||
|
{imageAttachments.slice(0, 4).map((img) => (
|
||||||
|
<a
|
||||||
|
key={img.url}
|
||||||
|
href={img.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="shrink-0 overflow-hidden rounded-lg border border-border"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={img.url}
|
||||||
|
alt={img.name}
|
||||||
|
className="h-16 w-16 object-cover transition-transform hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{imageAttachments.length > 4 && (
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
|
||||||
|
+{imageAttachments.length - 4}
|
||||||
|
<ImageIcon className="ml-0.5 h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attached videos */}
|
||||||
|
{hasVideos && (
|
||||||
|
<div className="flex gap-2 overflow-x-auto">
|
||||||
|
{videoAttachments.slice(0, 4).map((vid) => (
|
||||||
|
<video
|
||||||
|
key={vid.url}
|
||||||
|
src={vid.url}
|
||||||
|
controls
|
||||||
|
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-muted"
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{videoAttachments.length > 4 && (
|
||||||
|
<div className="flex h-28 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
|
||||||
|
+{videoAttachments.length - 4}
|
||||||
|
<Video className="ml-0.5 h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Badge key={category} variant="secondary" className="text-[10px]">
|
||||||
|
{category}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Analysis — always expanded */}
|
||||||
|
{message.ai_analysis ? (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border-l-[3px] px-3 py-2 ${
|
||||||
|
aiStatus === "flagged"
|
||||||
|
? "border-l-pink-400 dark:border-l-pink-600 bg-pink-50/40 dark:bg-pink-950/30"
|
||||||
|
: "border-l-emerald-400 dark:border-l-emerald-600 bg-emerald-50/40 dark:bg-emerald-950/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 text-[11px]">
|
||||||
|
<span className="mt-0.5 shrink-0">
|
||||||
|
{aiStatus === "flagged" ? "🚨" : "ℹ️"}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="block font-medium text-foreground/70 mb-1">
|
||||||
|
{analysisSummary}
|
||||||
|
</span>
|
||||||
|
<div className="text-[12px] text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||||
|
{message.ai_analysis}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* AI Error */}
|
||||||
|
{message.ai_error ? (
|
||||||
|
<div className="rounded-lg bg-pink-50/40 dark:bg-pink-950/30 px-3 py-2 text-[12px] text-pink-600 dark:text-pink-400">
|
||||||
|
AI error: {message.ai_error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Re-analyze button */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={aiStatus === "error" ? "destructive" : "outline"}
|
||||||
|
onClick={handleReanalyze}
|
||||||
|
disabled={aiStatus === "pending" || isReanalyzing}
|
||||||
|
className="text-[11px] h-7 px-2.5"
|
||||||
|
>
|
||||||
|
<RotateCw
|
||||||
|
className={`h-3 w-3 ${isReanalyzing ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
||||||
|
</Button>
|
||||||
|
{aiStatus === "error" && (
|
||||||
|
<span className="text-[11px] text-pink-600/70 dark:text-pink-400/70">
|
||||||
|
Click to retry analysis
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Group card: one card per user group ─────────────────────────────────────
|
||||||
|
|
||||||
|
export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
||||||
|
const firstMsg = messages[0];
|
||||||
|
const hasMultiple = messages.length > 1;
|
||||||
|
const meta = useMemo(
|
||||||
|
() => parseMetadata(firstMsg.metadata),
|
||||||
|
[firstMsg.metadata],
|
||||||
|
);
|
||||||
|
const channelMeta = meta.channel;
|
||||||
|
const locationLabel = useMemo(() => {
|
||||||
|
if (channelMeta?.threadName) {
|
||||||
|
return `# ${channelMeta.channelName || "unknown"} › ${channelMeta.threadName}`;
|
||||||
|
}
|
||||||
|
if (channelMeta?.channelName) {
|
||||||
|
return `# ${channelMeta.channelName}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [channelMeta]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
|
||||||
|
firstMsg.deleted_at ? "border-red-200 dark:border-red-900/50 opacity-60" : "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 p-4">
|
||||||
|
{/* Avatar — only for first message */}
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
firstMsg.avatar_url ??
|
||||||
|
"https://cdn.discordapp.com/embed/avatars/0.png"
|
||||||
|
}
|
||||||
|
alt=""
|
||||||
|
className="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
target.src = "https://cdn.discordapp.com/embed/avatars/0.png";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Group header: username + location + timestamp */}
|
||||||
|
<div className="flex items-baseline gap-2 mb-2">
|
||||||
|
<span className="font-semibold text-sm text-foreground">
|
||||||
|
{firstMsg.username || firstMsg.user_id}
|
||||||
|
</span>
|
||||||
|
{locationLabel && (
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded-full">
|
||||||
|
<Hash className="h-2.5 w-2.5" />
|
||||||
|
{locationLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-[11px] text-muted-foreground/60"
|
||||||
|
title={new Date(firstMsg.created_at).toLocaleString()}
|
||||||
|
>
|
||||||
|
{formatTimeAgo(firstMsg.created_at)}
|
||||||
|
{hasMultiple && ` · ${messages.length} messages`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message rows — divided by separator when multiple */}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
hasMultiple ? "divide-y divide-border/30 space-y-2.5" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{messages.map((msg, idx) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={hasMultiple && idx > 0 ? "pt-2.5" : ""}
|
||||||
|
>
|
||||||
|
<MessageRow message={msg} onReanalyze={onReanalyze} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Skeleton ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function MessageCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<article className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-6 w-16 rounded-full" />
|
||||||
|
<Skeleton className="h-6 w-20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import type { MessageRecord } from "../../../entities/message/types.js";
|
||||||
|
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
|
||||||
|
import { ScrollArea } from "../../../shared/ui";
|
||||||
|
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||||
|
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
|
||||||
|
|
||||||
|
export interface MessageFeedProps {
|
||||||
|
messages: MessageRecord[];
|
||||||
|
onReanalyze: (id: string) => Promise<void>;
|
||||||
|
emptyText?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Messages from the same user within 5 minutes are visually grouped. */
|
||||||
|
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
interface MessageGroup {
|
||||||
|
messages: MessageRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMessages(messages: MessageRecord[]): MessageGroup[] {
|
||||||
|
const groups: MessageGroup[] = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const lastGroup = groups[groups.length - 1];
|
||||||
|
if (
|
||||||
|
lastGroup &&
|
||||||
|
lastGroup.messages[0].user_id === msg.user_id &&
|
||||||
|
lastGroup.messages[lastGroup.messages.length - 1].created_at -
|
||||||
|
msg.created_at <
|
||||||
|
GROUP_WINDOW_MS
|
||||||
|
) {
|
||||||
|
lastGroup.messages.push(msg);
|
||||||
|
} else {
|
||||||
|
groups.push({ messages: [msg] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageFeed({
|
||||||
|
messages,
|
||||||
|
onReanalyze,
|
||||||
|
emptyText: _emptyText,
|
||||||
|
loading,
|
||||||
|
onLoadMore,
|
||||||
|
hasMore,
|
||||||
|
loadingMore,
|
||||||
|
}: MessageFeedProps) {
|
||||||
|
// IntersectionObserver for infinite scroll — fires when sentinel becomes visible
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onLoadMore || !hasMore) return;
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) onLoadMore();
|
||||||
|
},
|
||||||
|
{ rootMargin: "400px" },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [onLoadMore, hasMore]);
|
||||||
|
|
||||||
|
const groupedMessages = useMemo(() => groupMessages(messages), [messages]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<MessageCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return <EmptyStateMascot />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||||
|
<motion.div
|
||||||
|
className="space-y-3"
|
||||||
|
variants={cardStagger}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
>
|
||||||
|
{groupedMessages.map((group) => (
|
||||||
|
<motion.div key={group.messages[0].id} variants={cardItem}>
|
||||||
|
<MessageCard messages={group.messages} onReanalyze={onReanalyze} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Infinite-scroll sentinel */}
|
||||||
|
{hasMore && (
|
||||||
|
<div
|
||||||
|
ref={sentinelRef}
|
||||||
|
className="flex items-center justify-center py-4"
|
||||||
|
>
|
||||||
|
{loadingMore ? (
|
||||||
|
<MessageCardSkeleton />
|
||||||
|
) : (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-primary/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// ─── Moderation alert toast listener ───────────────────────────────────────
|
||||||
|
// Listens for "moderation_alert" custom events dispatched from WebSocket
|
||||||
|
// message_analyzed handler, and shows toast notifications for flagged
|
||||||
|
// messages so moderators don't miss important alerts.
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useToast } from "../../../shared/ui";
|
||||||
|
|
||||||
|
interface AlertDetail {
|
||||||
|
type: "flagged";
|
||||||
|
username: string;
|
||||||
|
severity: string;
|
||||||
|
categories: string;
|
||||||
|
brief: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityToToastType(
|
||||||
|
severity: string,
|
||||||
|
): "error" | "warning" | "info" | "success" {
|
||||||
|
switch (severity) {
|
||||||
|
case "critical":
|
||||||
|
case "high":
|
||||||
|
return "error";
|
||||||
|
case "medium":
|
||||||
|
return "warning";
|
||||||
|
case "low":
|
||||||
|
return "info";
|
||||||
|
default:
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModerationAlertListener() {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const { username, severity, categories, brief } = (
|
||||||
|
e as CustomEvent<AlertDetail>
|
||||||
|
).detail;
|
||||||
|
|
||||||
|
const sevLabel = severity ? `[${severity}]` : "";
|
||||||
|
const catLabel = categories
|
||||||
|
? ` — ${categories.split(",").slice(0, 2).join(", ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
addToast(
|
||||||
|
`🚨 ${username} ${sevLabel}${catLabel}: ${brief}`,
|
||||||
|
severityToToastType(severity),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("moderation_alert", handler);
|
||||||
|
return () => window.removeEventListener("moderation_alert", handler);
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+41
-19
@@ -1,17 +1,16 @@
|
|||||||
import type { MessageRecord } from "@bete/shared";
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useMessageStore } from "../../stores/message-store.js";
|
import type { MessageRecord } from "../../../entities/message/types.js";
|
||||||
import { listMessages, reanalyzeMessage } from "../api/client.js";
|
import {
|
||||||
import { createLogger } from "../lib/logger.js";
|
listMessages,
|
||||||
|
reanalyzeErrorBatch,
|
||||||
|
reanalyzeMessage,
|
||||||
|
} from "../../../shared/api/client";
|
||||||
|
import { createLogger } from "../../../shared/lib/logger.js";
|
||||||
|
|
||||||
const logger = createLogger("use-messages");
|
const logger = createLogger("use-messages");
|
||||||
|
|
||||||
const PAGE_SIZE = 100;
|
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(
|
export function mergeMessages(
|
||||||
current: MessageRecord[],
|
current: MessageRecord[],
|
||||||
incoming: MessageRecord[],
|
incoming: MessageRecord[],
|
||||||
@@ -25,20 +24,14 @@ export function mergeMessages(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for fetching, paginating, and re-analyzing messages.
|
|
||||||
* Reads/writes through the zustand `useMessageStore` so that WebSocket updates
|
|
||||||
* (handled by the store directly via prependMessage/updateMessage/removeMessage)
|
|
||||||
* are reflected without duplicating state.
|
|
||||||
*/
|
|
||||||
export function useMessages() {
|
export function useMessages() {
|
||||||
|
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [cursor, setCursor] = useState<string | null>(null);
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const currentGuild = useRef<string | null>(null);
|
const currentGuild = useRef<string | null>(null);
|
||||||
const { messages, setMessages } = useMessageStore();
|
|
||||||
|
|
||||||
const fetchMessages = useCallback(async (guildId?: string) => {
|
const fetchMessages = useCallback(async (guildId?: string) => {
|
||||||
if (!guildId) {
|
if (!guildId) {
|
||||||
@@ -55,7 +48,6 @@ export function useMessages() {
|
|||||||
guildId,
|
guildId,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
// Guard against stale responses if guildId changed mid-flight
|
|
||||||
if (currentGuild.current === guildId) {
|
if (currentGuild.current === guildId) {
|
||||||
setMessages(result.data);
|
setMessages(result.data);
|
||||||
setCursor(result.nextCursor);
|
setCursor(result.nextCursor);
|
||||||
@@ -81,7 +73,7 @@ export function useMessages() {
|
|||||||
cursor,
|
cursor,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
setMessages((prev) => mergeMessages(prev, result.data));
|
setMessages((prev) => [...prev, ...result.data]);
|
||||||
setCursor(result.nextCursor);
|
setCursor(result.nextCursor);
|
||||||
setHasMore(!!result.nextCursor);
|
setHasMore(!!result.nextCursor);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -93,7 +85,8 @@ export function useMessages() {
|
|||||||
}, [cursor, loadingMore]);
|
}, [cursor, loadingMore]);
|
||||||
|
|
||||||
const reanalyze = useCallback(async (id: string): Promise<void> => {
|
const reanalyze = useCallback(async (id: string): Promise<void> => {
|
||||||
// Snapshot the current state so we can revert on HTTP failure
|
// Capture prior state inside the functional updater so we don't need
|
||||||
|
// `messages` as a useCallback dependency (avoids stale closure churn).
|
||||||
let saved: MessageRecord | undefined;
|
let saved: MessageRecord | undefined;
|
||||||
|
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
@@ -113,7 +106,7 @@ export function useMessages() {
|
|||||||
try {
|
try {
|
||||||
await reanalyzeMessage(id);
|
await reanalyzeMessage(id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Revert optimistic update on failure
|
// HTTP failed — revert the optimistic update so the UI stays truthful.
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const snapshot = saved;
|
const snapshot = saved;
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
@@ -126,13 +119,42 @@ export function useMessages() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const reanalyzeAllErrors = useCallback(async (): Promise<number> => {
|
||||||
|
// Optimistically mark all error messages as pending
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.ai_status === "error"
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
ai_status: "pending" as const,
|
||||||
|
ai_error: null,
|
||||||
|
ai_analysis: null,
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const { count } = await reanalyzeErrorBatch({
|
||||||
|
guildId: currentGuild.current ?? undefined,
|
||||||
|
});
|
||||||
|
logger.info("Reanalyze all errors complete", { count });
|
||||||
|
return count;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.error("Failed to reanalyze error batch", { error: message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
|
setMessages,
|
||||||
loading,
|
loading,
|
||||||
loadingMore,
|
loadingMore,
|
||||||
error,
|
error,
|
||||||
fetchMessages,
|
fetchMessages,
|
||||||
reanalyze,
|
reanalyze,
|
||||||
|
reanalyzeAllErrors,
|
||||||
loadMore,
|
loadMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Filter, RotateCw, Search, X } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { MessageRecord } from "../../shared/api/client";
|
||||||
|
import { request } from "../../shared/api/client";
|
||||||
|
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Input,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "../../shared/ui";
|
||||||
|
import { ImageGrid } from "./components/ImageGrid";
|
||||||
|
import { MessageFeed } from "./components/MessageFeed";
|
||||||
|
|
||||||
|
interface MessagesPanelProps {
|
||||||
|
guildName: string | null;
|
||||||
|
messages: MessageRecord[];
|
||||||
|
onReanalyze: (id: string) => Promise<void>;
|
||||||
|
onReanalyzeAllErrors?: () => Promise<number>;
|
||||||
|
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<MessageRecord[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [showSearch, setShowSearch] = useState(false);
|
||||||
|
const [aiFilter, setAiFilter] = useState<AiFilter>("analyzed");
|
||||||
|
const [viewTab, setViewTab] = useState<"all" | "images">("all");
|
||||||
|
const [retryingAll, setRetryingAll] = useState(false);
|
||||||
|
const [retriedCount, setRetriedCount] = useState<number | null>(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 (
|
||||||
|
<motion.div
|
||||||
|
className="grid gap-6"
|
||||||
|
variants={cardStagger}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
>
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-primary">Messages</CardTitle>
|
||||||
|
{guildName && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Monitoring all text channels in{" "}
|
||||||
|
<span className="font-medium text-foreground">{guildName}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Messages are automatically captured from all text channels in the
|
||||||
|
monitored guild. Real-time updates arrive via WebSocket.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{stats.total > 0 && (
|
||||||
|
<motion.div
|
||||||
|
variants={cardItem}
|
||||||
|
className="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs border-primary/40 text-primary"
|
||||||
|
>
|
||||||
|
{stats.total} total{hasMore && !showSearch ? "+" : ""}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs bg-emerald-100 text-emerald-700 border-emerald-200"
|
||||||
|
>
|
||||||
|
{stats.clean} clean
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs bg-primary/10 text-primary border-primary/20"
|
||||||
|
>
|
||||||
|
{stats.flagged} flagged
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs bg-orange-100 text-orange-700 border-orange-200"
|
||||||
|
>
|
||||||
|
{stats.error} error
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs text-muted-foreground border-border"
|
||||||
|
>
|
||||||
|
{stats.pending} pending
|
||||||
|
</Badge>
|
||||||
|
{stats.deleted > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs bg-red-100 text-red-700 border-red-200"
|
||||||
|
>
|
||||||
|
{stats.deleted} deleted
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{stats.edited > 0 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{stats.edited} edited
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
variants={cardItem}
|
||||||
|
className="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-primary" />
|
||||||
|
<Input
|
||||||
|
className="pl-9 rounded-full focus-visible:ring-primary"
|
||||||
|
placeholder="Search message content..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
disabled={isSearching}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={isSearching || !searchQuery.trim()}
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
>
|
||||||
|
{isSearching ? "Searching..." : "Search"}
|
||||||
|
</Button>
|
||||||
|
{showSearch && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowSearch(false);
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearchQuery("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="mr-1 h-3 w-3" /> Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{stats.error > 0 && onReanalyzeAllErrors && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={retryingAll}
|
||||||
|
onClick={async () => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<RotateCw
|
||||||
|
className={`mr-1.5 h-3.5 w-3.5 ${retryingAll ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
{retryingAll ? "Retrying..." : `Retry All Errors (${stats.error})`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{retriedCount !== null && (
|
||||||
|
<span className="text-xs text-emerald-600">
|
||||||
|
{retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for
|
||||||
|
re-analysis
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
|
<Filter className="h-4 w-4 text-primary" />
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
"all",
|
||||||
|
"analyzed",
|
||||||
|
"clean",
|
||||||
|
"flagged",
|
||||||
|
"error",
|
||||||
|
"pending",
|
||||||
|
] as AiFilter[]
|
||||||
|
).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => 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}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{showSearch && searchResults.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
variants={cardItem}
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Found {searchResults.length} result
|
||||||
|
{searchResults.length !== 1 ? "s" : ""}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Tabs
|
||||||
|
value={viewTab}
|
||||||
|
onValueChange={(v) => setViewTab(v as "all" | "images")}
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="all">
|
||||||
|
{showSearch
|
||||||
|
? `Search (${filteredMessages.length})`
|
||||||
|
: `All (${filteredMessages.length})`}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="images">Images</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="all">
|
||||||
|
<MessageFeed
|
||||||
|
messages={filteredMessages}
|
||||||
|
onReanalyze={onReanalyze}
|
||||||
|
emptyText={
|
||||||
|
showSearch
|
||||||
|
? "No messages found matching your search."
|
||||||
|
: "No captures yet."
|
||||||
|
}
|
||||||
|
onLoadMore={showSearch ? undefined : onLoadMore}
|
||||||
|
hasMore={showSearch ? false : hasMore}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="images">
|
||||||
|
<ImageGrid messages={filteredMessages} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
BellOff,
|
||||||
|
Moon,
|
||||||
|
Palette,
|
||||||
|
Sun,
|
||||||
|
Monitor,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { ThemeMode } from "../../hooks/useTheme";
|
||||||
|
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, Button } from "../../shared/ui";
|
||||||
|
import {
|
||||||
|
getAdminSettings,
|
||||||
|
updateAdminSettings,
|
||||||
|
clearSessionToken,
|
||||||
|
} from "../../shared/api/client";
|
||||||
|
import type { AdminSettings as AdminSettingsType } from "../../entities/ui/types";
|
||||||
|
|
||||||
|
/* ─── Storage keys ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const NOTIF_ENABLED_KEY = "bete-notif-enabled";
|
||||||
|
const NOTIF_SOUND_KEY = "bete-notif-sound";
|
||||||
|
|
||||||
|
/* ─── Types ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface NotificationPrefs {
|
||||||
|
enabled: boolean;
|
||||||
|
sound: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNotifPrefs(): NotificationPrefs {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(NOTIF_ENABLED_KEY);
|
||||||
|
const soundRaw = localStorage.getItem(NOTIF_SOUND_KEY);
|
||||||
|
return {
|
||||||
|
enabled: raw !== "false", // default true
|
||||||
|
sound: soundRaw !== "false", // default true
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { enabled: true, sound: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Props ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface SettingsPanelProps {
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
isDark: boolean;
|
||||||
|
onThemeModeChange: (mode: ThemeMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Component ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function SettingsPanel({
|
||||||
|
themeMode,
|
||||||
|
isDark,
|
||||||
|
onThemeModeChange,
|
||||||
|
}: SettingsPanelProps) {
|
||||||
|
const [notifPrefs, setNotifPrefs] = useState<NotificationPrefs>(loadNotifPrefs);
|
||||||
|
const [adminSettings, setAdminSettings] = useState<AdminSettingsType | null>(null);
|
||||||
|
const [adminSaving, setAdminSaving] = useState(false);
|
||||||
|
const [adminError, setAdminError] = useState<string | null>(null);
|
||||||
|
const [adminSuccess, setAdminSuccess] = useState<string | null>(null);
|
||||||
|
const adminSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | 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<NotificationPrefs>) => {
|
||||||
|
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 (
|
||||||
|
<motion.div
|
||||||
|
className="mx-auto max-w-2xl space-y-6"
|
||||||
|
variants={cardStagger}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
>
|
||||||
|
{/* ── Theme section ────────────────────────────────────────────── */}
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-primary">
|
||||||
|
<Palette className="h-5 w-5" />
|
||||||
|
Theme
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
{themeOptions.map((opt) => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
const isActive = themeMode === opt.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => 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"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={`h-6 w-6 ${
|
||||||
|
opt.value === "dark" && !isActive
|
||||||
|
? "text-indigo-400"
|
||||||
|
: opt.value === "light" && !isActive
|
||||||
|
? "text-amber-500"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold">{opt.label}</span>
|
||||||
|
<span className="text-xs">{opt.desc}</span>
|
||||||
|
{isActive && (
|
||||||
|
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">
|
||||||
|
Current: <span className="font-medium text-foreground capitalize">{isDark ? "Dark" : "Light"}</span>
|
||||||
|
{themeMode === "system" && " (follows system)"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Notifications section ────────────────────────────────────── */}
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-primary">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
Notifications
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Toggle — enable/disable all notifs */}
|
||||||
|
<label className="flex items-center justify-between rounded-lg border border-border p-3 cursor-pointer hover:bg-accent/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{notifPrefs.enabled ? (
|
||||||
|
<Bell className="h-5 w-5 text-primary" />
|
||||||
|
) : (
|
||||||
|
<BellOff className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
Moderation alerts
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Show toast when a message is flagged by AI
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={notifPrefs.enabled}
|
||||||
|
onClick={() => updateNotif({ enabled: !notifPrefs.enabled })}
|
||||||
|
className={`
|
||||||
|
relative h-6 w-11 rounded-full transition-colors
|
||||||
|
${notifPrefs.enabled ? "bg-primary" : "bg-muted"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow-sm transition-transform
|
||||||
|
${notifPrefs.enabled ? "translate-x-5" : "translate-x-0"}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Toggle — sound */}
|
||||||
|
<label className="flex items-center justify-between rounded-lg border border-border p-3 cursor-pointer hover:bg-accent/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{notifPrefs.sound ? (
|
||||||
|
<Volume2 className="h-5 w-5 text-primary" />
|
||||||
|
) : (
|
||||||
|
<VolumeX className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
Sound effects
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Play a sound when new moderation alerts arrive
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={notifPrefs.sound}
|
||||||
|
onClick={() => updateNotif({ sound: !notifPrefs.sound })}
|
||||||
|
className={`
|
||||||
|
relative h-6 w-11 rounded-full transition-colors
|
||||||
|
${notifPrefs.sound ? "bg-primary" : "bg-muted"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow-sm transition-transform
|
||||||
|
${notifPrefs.sound ? "translate-x-5" : "translate-x-0"}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── Admin section ────────────────────────────────────────────── */}
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card className="border-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-primary">
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
Admin Settings
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Dashboard visibility toggle */}
|
||||||
|
<div className="rounded-lg border border-border p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{adminSettings?.dashboardIsPublic ? (
|
||||||
|
<Globe className="mt-0.5 h-5 w-5 text-emerald-500 dark:text-emerald-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Lock className="mt-0.5 h-5 w-5 text-amber-500 dark:text-amber-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
Dashboard Visibility:{" "}
|
||||||
|
<span className={adminSettings?.dashboardIsPublic ? "text-emerald-500 dark:text-emerald-400" : "text-amber-500 dark:text-amber-400"}>
|
||||||
|
{adminSettings?.dashboardIsPublic ? "Public" : "Private"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{adminSettings?.dashboardIsPublic
|
||||||
|
? "Anyone can view the dashboard. Admin password still required for management."
|
||||||
|
: "Admin password required to access the dashboard."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleTogglePublic}
|
||||||
|
disabled={adminSaving}
|
||||||
|
variant={adminSettings?.dashboardIsPublic ? "outline" : "default"}
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{adminSaving ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
) : adminSettings?.dashboardIsPublic ? (
|
||||||
|
"Make Private"
|
||||||
|
) : (
|
||||||
|
"Make Public"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{adminError && (
|
||||||
|
<p className="mt-2 text-xs text-destructive">{adminError}</p>
|
||||||
|
)}
|
||||||
|
{adminSuccess && (
|
||||||
|
<p className="mt-2 text-xs text-emerald-500 dark:text-emerald-400">{adminSuccess}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status indicators */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg bg-muted/50 px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Runtime</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${adminSettings?.dashboardIsPublic ? "bg-emerald-400 dark:bg-emerald-500" : "bg-amber-400 dark:bg-amber-500"}`} />
|
||||||
|
<span className="text-sm font-medium">{adminSettings?.dashboardIsPublic ? "Public" : "Private"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Env Default</p>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className={`inline-block h-2 w-2 rounded-full ${adminSettings?.envDashboardIsPublic ? "bg-emerald-400 dark:bg-emerald-500" : "bg-amber-400 dark:bg-amber-500"}`} />
|
||||||
|
<span className="text-sm font-medium">{adminSettings?.envDashboardIsPublic ? "Public" : "Private"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<div className="flex justify-end border-t border-border pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="rounded-lg bg-muted/30 px-3 py-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Shield className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Admin password is set via the <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">ADMIN_PASSWORD</code> env var.
|
||||||
|
Runtime settings are persisted in <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">data/settings.json</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* ── About section ────────────────────────────────────────────── */}
|
||||||
|
<motion.div variants={cardItem}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-muted-foreground">About</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Bete Dashboard v1.0 — Discord AI Moderation & Voice Recording
|
||||||
|
System.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Theme settings are saved locally. Notification preferences are
|
||||||
|
persisted across sessions.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks incoming moderation_alert events and maintains a badge counter.
|
||||||
|
* Clears when the user navigates to the messages tab.
|
||||||
|
*/
|
||||||
|
export function useNotificationBadge(activeTab: string) {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const prevActiveTab = useRef(activeTab);
|
||||||
|
|
||||||
|
// Clear badge when user switches TO messages tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === "messages" && prevActiveTab.current !== "messages") {
|
||||||
|
setCount(0);
|
||||||
|
}
|
||||||
|
prevActiveTab.current = activeTab;
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const increment = useCallback(() => {
|
||||||
|
setCount((c) => c + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen for moderation_alert custom events
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => increment();
|
||||||
|
window.addEventListener("moderation_alert", handler);
|
||||||
|
return () => window.removeEventListener("moderation_alert", handler);
|
||||||
|
}, [increment]);
|
||||||
|
|
||||||
|
return { count, clear: () => setCount(0) };
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// ─── ActiveSpeakers.tsx — Live voice speaker list island ────────────────────
|
|
||||||
// Self-contained: reads from useVoiceStore, renders speaker entries with
|
|
||||||
// animated speaking-indicator bars.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { useVoiceStore } from "../stores/voice-store.js";
|
|
||||||
|
|
||||||
export default function ActiveSpeakers() {
|
|
||||||
const activeSpeakers = useVoiceStore((state) => state.activeSpeakers);
|
|
||||||
|
|
||||||
if (activeSpeakers.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
|
||||||
<div className="mb-2 text-3xl opacity-30">🎙</div>
|
|
||||||
<p className="text-sm">No active speakers</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{activeSpeakers.map((speaker) => {
|
|
||||||
const key =
|
|
||||||
speaker.userId ?? speaker.id ?? `speaker-${speaker.username}`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex items-center gap-3 rounded-xl border border-border bg-card p-3"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={speaker.avatar}
|
|
||||||
alt=""
|
|
||||||
className="h-8 w-8 rounded-full object-cover ring-2 ring-primary/30"
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-sm font-medium">
|
|
||||||
{speaker.username}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{speaker.speaking && (
|
|
||||||
<div className="flex items-end gap-[3px]">
|
|
||||||
{[0, 1, 2, 3].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="w-[5px] rounded-full bg-primary animate-bar-pulse"
|
|
||||||
style={{
|
|
||||||
height: `${12 + i * 4}px`,
|
|
||||||
animationDelay: `${i * 75}ms`,
|
|
||||||
transformOrigin: "bottom",
|
|
||||||
opacity: 1 - i * 0.12,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
// ─── AudioVisualizer.tsx — Canvas-based frequency bar visualizer ────────────
|
|
||||||
// Uses requestAnimationFrame to draw placeholder random frequency bars.
|
|
||||||
// Bar color uses the OKLCH primary token at varying opacity.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
interface AudioVisualizerProps {
|
|
||||||
barCount?: number;
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AudioVisualizer({
|
|
||||||
barCount = 48,
|
|
||||||
height = 32,
|
|
||||||
}: AudioVisualizerProps) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(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 (
|
|
||||||
<div className="relative w-full">
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="w-full rounded-lg bg-primary/5"
|
|
||||||
style={{ height: `${height}px` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
// ─── AuthGuard.tsx — Standalone authentication island ────────────────────────
|
|
||||||
// Three-state auth wrapper: null=loading, false=unauthenticated (login form),
|
|
||||||
// true=authenticated (renders children).
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import {
|
|
||||||
type FormEvent,
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
getAdminPassword,
|
|
||||||
getSessionToken,
|
|
||||||
setAdminPassword,
|
|
||||||
} from "../shared/api/client.js";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Input,
|
|
||||||
} from "../shared/components/index.js";
|
|
||||||
|
|
||||||
interface AuthGuardProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuthGuard({ children }: AuthGuardProps) {
|
|
||||||
const [authState, setAuthState] = useState<boolean | null>(null);
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(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 (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background text-foreground">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Unauthenticated — show login form ──────────────────────────────────────
|
|
||||||
if (authState === false) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center p-4">
|
|
||||||
<Card className="w-full max-w-md border-primary/30 shadow-lg shadow-primary/10">
|
|
||||||
<CardHeader className="text-center">
|
|
||||||
<div className="mx-auto mb-4 flex items-center justify-center">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
||||||
<svg
|
|
||||||
className="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CardTitle>Access Required</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Enter the admin password to access the dashboard.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter admin password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" className="w-full" disabled={!password}>
|
|
||||||
Unlock
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Authenticated — render children ────────────────────────────────────────
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
// ─── LiveShell.tsx — Page shell for /live ────────────────────────────────────
|
|
||||||
// Composes Sidebar, Header, ParticleBackground, and the four live islands
|
|
||||||
// (VoiceControls, ActiveSpeakers, AudioVisualizer, NowPlaying) into a full-page
|
|
||||||
// responsive layout.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
getGuilds,
|
|
||||||
getVoiceChannels,
|
|
||||||
getVoiceStatus,
|
|
||||||
} from "../shared/api/client.js";
|
|
||||||
import { Header } from "../shared/components/Header.js";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "../shared/components/index.js";
|
|
||||||
import { ParticleBackground } from "../shared/components/particles/ParticleBackground.js";
|
|
||||||
import { Sidebar } from "../shared/components/Sidebar.js";
|
|
||||||
import { useTheme } from "../shared/hooks/useTheme.js";
|
|
||||||
import type { Channel, Guild } from "../shared/types/guild.js";
|
|
||||||
import type { VoiceStatus } from "../shared/types/voice.js";
|
|
||||||
import { useVoiceStore } from "../stores/voice-store.js";
|
|
||||||
import ActiveSpeakers from "./ActiveSpeakers.js";
|
|
||||||
import AudioVisualizer from "./AudioVisualizer.js";
|
|
||||||
import NowPlaying from "./NowPlaying.js";
|
|
||||||
import VoiceControls from "./VoiceControls.js";
|
|
||||||
|
|
||||||
const emptyVoiceStatus: VoiceStatus = {
|
|
||||||
connected: false,
|
|
||||||
activeGuildId: null,
|
|
||||||
activeChannelId: null,
|
|
||||||
activeChannelName: null,
|
|
||||||
connections: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LiveShell() {
|
|
||||||
const { mode, isDark, toggle: toggleTheme } = useTheme();
|
|
||||||
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>(emptyVoiceStatus);
|
|
||||||
const [guilds, setGuilds] = useState<Guild[]>([]);
|
|
||||||
const [voiceChannels, setVoiceChannels] = useState<Channel[]>([]);
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<div className="relative min-h-screen bg-background text-foreground">
|
|
||||||
{/* Background layers */}
|
|
||||||
<ParticleBackground />
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 pointer-events-none grid-pattern opacity-[0.03]"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative flex min-h-screen">
|
|
||||||
<Sidebar
|
|
||||||
activeTab="live"
|
|
||||||
onTabChange={handleTabChange}
|
|
||||||
collapsed={false}
|
|
||||||
recentMessages={[]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
|
||||||
<Header
|
|
||||||
activeTab="live"
|
|
||||||
wsStatus="disconnected"
|
|
||||||
voiceStatus={voiceStatus}
|
|
||||||
themeMode={mode}
|
|
||||||
isDark={isDark}
|
|
||||||
onThemeToggle={toggleTheme}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-4 md:p-6 lg:p-8 pb-16 md:pb-0">
|
|
||||||
{/* Responsive grid: stacks on <1024px */}
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
|
||||||
{/* Main content column */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<VoiceControls guilds={guilds} voiceChannels={voiceChannels} />
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Live Audio</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<AudioVisualizer barCount={48} height={32} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<NowPlaying />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar column */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Active Speakers</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ActiveSpeakers />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
// ─── MascotChat.tsx — Floating chat bubble island ───────────────────────────
|
|
||||||
// Fixed-position toggle button that opens a glass-styled chat panel.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
|
|
||||||
const GREETING = "Hi! I'm Bete's mascot. Ask me anything about the server.";
|
|
||||||
|
|
||||||
export default function MascotChat() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
|
||||||
setIsOpen((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
(e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!message.trim()) return;
|
|
||||||
// TODO: wire up chat submission
|
|
||||||
setMessage("");
|
|
||||||
},
|
|
||||||
[message],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-6 right-6" style={{ zIndex: 60 }}>
|
|
||||||
{/* ── Chat Panel ──────────────────────────────────────────────────── */}
|
|
||||||
{isOpen && (
|
|
||||||
<div className="glass-strong mb-4 flex w-80 flex-col overflow-hidden rounded-2xl shadow-xl animate-fade-in-up">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground font-bold">
|
|
||||||
B
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-semibold text-foreground">
|
|
||||||
Mascot
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex flex-col gap-3 px-4 py-4">
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{GREETING}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Quick suggestions */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{["What can you do?", "Show me the dashboard"].map(
|
|
||||||
(suggestion) => (
|
|
||||||
<button
|
|
||||||
key={suggestion}
|
|
||||||
type="button"
|
|
||||||
className="rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs text-primary transition-colors hover:bg-primary/15"
|
|
||||||
onClick={() => setMessage(suggestion)}
|
|
||||||
>
|
|
||||||
{suggestion}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input */}
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="flex items-center gap-2 border-t border-white/10 px-4 py-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
placeholder="Type a message..."
|
|
||||||
className="min-w-0 flex-1 rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/40 focus:outline-none focus:ring-1 focus:ring-primary/30"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!message.trim()}
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-xs text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M5 12h14M12 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Toggle Button ───────────────────────────────────────────────── */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggle}
|
|
||||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-primary text-white shadow-xl transition-transform duration-200 ease-out hover:scale-105 active:scale-95 motion-safe:hover:scale-105"
|
|
||||||
aria-label={isOpen ? "Close chat" : "Open chat"}
|
|
||||||
>
|
|
||||||
{isOpen ? (
|
|
||||||
<span className="text-lg leading-none" aria-hidden="true">
|
|
||||||
✕
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-lg leading-none" aria-hidden="true">
|
|
||||||
✦
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
// ─── MessageFeed.tsx — Standalone message feed island ─────────────────────────
|
|
||||||
// Self-contained message list with WebSocket bridging, infinite scroll,
|
|
||||||
// loading/empty/error states, and re-analyze per message.
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import type { MessageRecord } from "@bete/shared";
|
|
||||||
import { AlertCircle, RefreshCw, RotateCw } from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { getAppConfig } from "../shared/api/client.js";
|
|
||||||
import { Badge, Button, Skeleton } from "../shared/components/index.js";
|
|
||||||
import { useMessages } from "../shared/hooks/useMessages.js";
|
|
||||||
import { useDashboardSocket } from "../shared/ws/socket.js";
|
|
||||||
import { useMessageStore } from "../stores/message-store.js";
|
|
||||||
|
|
||||||
interface MessageFeedProps {
|
|
||||||
/** Guild to fetch messages for. If omitted, the list stays empty. */
|
|
||||||
guildId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Severity badge colouring ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<string, string> = {
|
|
||||||
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<void>;
|
|
||||||
}) {
|
|
||||||
const [isReanalyzing, setIsReanalyzing] = useState(false);
|
|
||||||
const isDeleted = !!message.deleted_at;
|
|
||||||
|
|
||||||
const handleReanalyze = async () => {
|
|
||||||
setIsReanalyzing(true);
|
|
||||||
try {
|
|
||||||
await onReanalyze(message.id);
|
|
||||||
} finally {
|
|
||||||
setIsReanalyzing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
className={`group rounded-xl border bg-card p-4 shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
|
|
||||||
isDeleted
|
|
||||||
? "border-red-200 dark:border-red-900/50 opacity-50"
|
|
||||||
: "border-border"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{/* Avatar */}
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
message.avatar_url ??
|
|
||||||
"https://cdn.discordapp.com/embed/avatars/0.png"
|
|
||||||
}
|
|
||||||
alt=""
|
|
||||||
className="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30"
|
|
||||||
onError={(e) => {
|
|
||||||
e.currentTarget.src =
|
|
||||||
"https://cdn.discordapp.com/embed/avatars/0.png";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 space-y-1.5">
|
|
||||||
{/* Row: username + badges */}
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-semibold text-sm text-foreground">
|
|
||||||
{message.username || message.user_id}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{message.ai_severity && message.ai_severity !== "none" && (
|
|
||||||
<Badge
|
|
||||||
className={`text-[10px] px-1.5 py-0 ${severityColor(message.ai_severity)}`}
|
|
||||||
>
|
|
||||||
{message.ai_severity}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isDeleted && (
|
|
||||||
<span className="text-[11px] text-destructive/70">deleted</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span
|
|
||||||
className="ml-auto text-[11px] text-muted-foreground/60"
|
|
||||||
title={new Date(message.created_at).toLocaleString()}
|
|
||||||
>
|
|
||||||
{formatTimeAgo(message.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<p
|
|
||||||
className={`whitespace-pre-wrap break-words text-sm leading-6 ${
|
|
||||||
isDeleted ? "text-muted-foreground/60" : "text-foreground/90"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{message.content}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Re-analyze button */}
|
|
||||||
<div className="flex items-center gap-2 pt-1">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={
|
|
||||||
message.ai_status === "error" ? "destructive" : "outline"
|
|
||||||
}
|
|
||||||
onClick={handleReanalyze}
|
|
||||||
disabled={isReanalyzing || message.ai_status === "pending"}
|
|
||||||
className="text-[11px] h-7 px-2.5"
|
|
||||||
>
|
|
||||||
<RotateCw
|
|
||||||
className={`h-3 w-3 mr-1 ${isReanalyzing ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
|
||||||
</Button>
|
|
||||||
{message.ai_status === "error" && (
|
|
||||||
<span className="text-[11px] text-pink-600/70 dark:text-pink-400/70">
|
|
||||||
Click to retry
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI analysis summary */}
|
|
||||||
{message.ai_analysis && (
|
|
||||||
<div
|
|
||||||
className={`mt-2 rounded-lg border-l-[3px] px-3 py-2 ${
|
|
||||||
message.ai_status === "flagged"
|
|
||||||
? "border-l-pink-400 dark:border-l-pink-600 bg-pink-50/40 dark:bg-pink-950/30"
|
|
||||||
: "border-l-emerald-400 dark:border-l-emerald-600 bg-emerald-50/40 dark:bg-emerald-950/30"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-[12px] text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
|
||||||
{message.ai_analysis}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI error */}
|
|
||||||
{message.ai_error && (
|
|
||||||
<div className="mt-2 rounded-lg bg-pink-50/40 dark:bg-pink-950/30 px-3 py-2 text-[12px] text-pink-600 dark:text-pink-400">
|
|
||||||
AI error: {message.ai_error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Skeleton placeholder ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function MessageSkeleton() {
|
|
||||||
return (
|
|
||||||
<article className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
|
|
||||||
<div className="min-w-0 flex-1 space-y-3">
|
|
||||||
<Skeleton className="h-5 w-48" />
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function EmptyState() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-2">
|
|
||||||
<p className="text-muted-foreground font-medium">No messages captured</p>
|
|
||||||
<p className="text-sm text-muted-foreground/60">
|
|
||||||
Messages will appear here once they are captured from the monitored
|
|
||||||
guild.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Error state ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ErrorState({
|
|
||||||
message,
|
|
||||||
onRetry,
|
|
||||||
}: {
|
|
||||||
message: string;
|
|
||||||
onRetry: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
|
||||||
<AlertCircle className="h-12 w-12 text-destructive" />
|
|
||||||
<p className="text-destructive font-medium">Failed to load messages</p>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-md text-center">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
<Button onClick={onRetry}>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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<HTMLDivElement | null>(null);
|
|
||||||
const [initialFetchDone, setInitialFetchDone] = useState(false);
|
|
||||||
const [resolvedGuildId, setResolvedGuildId] = useState<string | undefined>(
|
|
||||||
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 (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{[1, 2, 3, 4, 5].map((i) => (
|
|
||||||
<MessageSkeleton key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Error state ───────────────────────────────────────────────────────────
|
|
||||||
if (error && messages.length === 0) {
|
|
||||||
return (
|
|
||||||
<ErrorState
|
|
||||||
message={error}
|
|
||||||
onRetry={() => resolvedGuildId && fetchMessages(resolvedGuildId)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Empty state ───────────────────────────────────────────────────────────
|
|
||||||
if (!loading && messages.length === 0) {
|
|
||||||
return <EmptyState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Success — message list with infinite scroll ───────────────────────────
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<MessageCard key={msg.id} message={msg} onReanalyze={reanalyze} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{hasMore && (
|
|
||||||
<div
|
|
||||||
ref={sentinelRef}
|
|
||||||
className="flex items-center justify-center py-4"
|
|
||||||
>
|
|
||||||
{loadingMore ? (
|
|
||||||
<MessageSkeleton />
|
|
||||||
) : (
|
|
||||||
<div className="h-2 w-2 rounded-full bg-primary/40" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
// ─── NowPlaying.tsx — Current media track display island ───────────────────
|
|
||||||
// Self-contained: fetches /api/media/status on mount, shows current track or
|
|
||||||
// "Nothing playing" fallback.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { Music2 } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { getMediaStatus } from "../shared/api/client.js";
|
|
||||||
import type { MediaState } from "../shared/types/media.js";
|
|
||||||
|
|
||||||
export default function NowPlaying() {
|
|
||||||
const [mediaState, setMediaState] = useState<MediaState | null>(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 (
|
|
||||||
<div className="rounded-xl border border-border bg-card p-4">
|
|
||||||
<p className="text-sm text-muted-foreground">Nothing playing</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mediaState || !mediaState.current) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-border bg-card p-4">
|
|
||||||
<p className="text-sm text-muted-foreground">Nothing playing</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { current } = mediaState;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-border bg-card p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
||||||
<Music2 className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate font-medium">{current.title}</div>
|
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
|
||||||
{current.source}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{current.mode && (
|
|
||||||
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
|
|
||||||
{current.mode}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
// ─── PageHeader.tsx — Standalone header island for non-SPA pages ────────────
|
|
||||||
// Renders a title on the left and the ThemeToggle on the right.
|
|
||||||
// Does not depend on wsStatus / voiceStatus / theme context.
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import ThemeToggle from "./ThemeToggle.js";
|
|
||||||
|
|
||||||
interface PageHeaderProps {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PageHeader({ title, subtitle }: PageHeaderProps) {
|
|
||||||
return (
|
|
||||||
<header className="sticky top-0 z-10 border-b border-border/50 bg-background/70 px-4 py-4 backdrop-blur-sm md:px-8">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<img
|
|
||||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
|
|
||||||
alt="IMPHNEN"
|
|
||||||
className="h-7 w-7"
|
|
||||||
/>
|
|
||||||
<h1 className="text-xl font-bold tracking-tight">
|
|
||||||
<span className="gradient-text">IMPHNEN</span>
|
|
||||||
<span className="mx-2 text-muted-foreground">·</span>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{subtitle && (
|
|
||||||
<p className="hidden text-sm text-muted-foreground md:block">
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
// ─── PageSidebar.tsx — Standalone sidebar island for non-SPA pages ───────────
|
|
||||||
// Wraps the existing Sidebar widget with URL-based navigation so it can be
|
|
||||||
// embedded as an Astro island without needing to pass callback functions.
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { Sidebar } from "../shared/components/Sidebar.js";
|
|
||||||
import type { DashboardTab } from "../shared/types/ui-types.js";
|
|
||||||
|
|
||||||
const TAB_URLS: Record<DashboardTab, string> = {
|
|
||||||
live: "/live",
|
|
||||||
messages: "/messages",
|
|
||||||
dashboard: "/",
|
|
||||||
settings: "/settings",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PageSidebarProps {
|
|
||||||
activeTab: DashboardTab;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PageSidebar({ activeTab }: PageSidebarProps) {
|
|
||||||
return (
|
|
||||||
<Sidebar
|
|
||||||
activeTab={activeTab}
|
|
||||||
collapsed={false}
|
|
||||||
onTabChange={(tab: DashboardTab) => {
|
|
||||||
window.location.href = TAB_URLS[tab] || "/messages";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// ─── Particles.tsx — Canvas-based particle background island ────────────────
|
|
||||||
// Renders 30 animated particles as a decorative background layer.
|
|
||||||
// Uses requestAnimationFrame, handles resize, cleans up on unmount.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
interface Particle {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
vx: number;
|
|
||||||
vy: number;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PARTICLE_COUNT = 30;
|
|
||||||
const PARTICLE_COLOR = "oklch(0.62 0.15 255 / 0.15)";
|
|
||||||
const MAX_SPEED = 0.4;
|
|
||||||
const MIN_SIZE = 2;
|
|
||||||
const MAX_SIZE = 6;
|
|
||||||
|
|
||||||
function randomRange(min: number, max: number): number {
|
|
||||||
return min + Math.random() * (max - min);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createParticle(w: number, h: number): Particle {
|
|
||||||
return {
|
|
||||||
x: Math.random() * w,
|
|
||||||
y: Math.random() * h,
|
|
||||||
vx: randomRange(-MAX_SPEED, MAX_SPEED),
|
|
||||||
vy: randomRange(-MAX_SPEED, MAX_SPEED),
|
|
||||||
size: randomRange(MIN_SIZE, MAX_SIZE),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Particles() {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(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 (
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none fixed inset-0"
|
|
||||||
style={{ zIndex: -10 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
// ─── RecordingsList.tsx — Voice recordings list island ──────────────────────
|
|
||||||
// Fetches recordings from API, renders loading/error/empty/success states.
|
|
||||||
// Each recording card shows username, channel, timestamp, download button.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { Download, Mic } from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { listRecordings } from "../shared/api/client";
|
|
||||||
import { Skeleton } from "../shared/components/skeleton";
|
|
||||||
import { formatBytes, formatDate } from "../shared/lib/utils";
|
|
||||||
import type { VoiceRecording } from "../shared/types/recording";
|
|
||||||
|
|
||||||
type ViewState = "loading" | "error" | "empty" | "success";
|
|
||||||
|
|
||||||
export default function RecordingsList() {
|
|
||||||
const [state, setState] = useState<ViewState>("loading");
|
|
||||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className="flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-3 rounded-lg p-3">
|
|
||||||
<Skeleton className="size-10 shrink-0 rounded-full" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<Skeleton className="h-3.5 w-1/3" />
|
|
||||||
<Skeleton className="h-3 w-2/3" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Error ────────────────────────────────────────────────────────────── */
|
|
||||||
if (state === "error") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="flex min-h-48 flex-col items-center justify-center gap-4 rounded-xl px-6 py-10 text-center"
|
|
||||||
style={{ backgroundColor: "oklch(var(--destructive) / 0.08)" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex size-12 items-center justify-center rounded-full"
|
|
||||||
style={{ backgroundColor: "oklch(var(--destructive) / 0.15)" }}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
style={{ color: "oklch(var(--destructive))" }}
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" y1="8" x2="12" y2="12" />
|
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="max-w-sm text-sm font-medium text-foreground">{error}</p>
|
|
||||||
<div className="mt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={fetchRecordings}
|
|
||||||
className="btn btn--outline btn--sm"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Empty ────────────────────────────────────────────────────────────── */
|
|
||||||
if (state === "empty") {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-64 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
|
|
||||||
<div
|
|
||||||
className="flex size-16 items-center justify-center"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<Mic
|
|
||||||
className="size-10"
|
|
||||||
style={{ color: "oklch(var(--muted-foreground))" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-foreground">No recordings</h2>
|
|
||||||
<p className="max-w-sm text-sm text-muted-foreground">
|
|
||||||
Voice recordings will appear here once users start speaking in
|
|
||||||
monitored voice channels.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Success ──────────────────────────────────────────────────────────── */
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{recordings.map((recording) => (
|
|
||||||
<RecordingCard key={recording.id} recording={recording} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Individual Recording Card ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function RecordingCard({ recording }: { recording: VoiceRecording }) {
|
|
||||||
return (
|
|
||||||
<div className="card card--default card--pad-md">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
{/* Left: avatar + info */}
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted">
|
|
||||||
{recording.avatar_url ? (
|
|
||||||
<img
|
|
||||||
src={recording.avatar_url}
|
|
||||||
alt={recording.username}
|
|
||||||
className="size-10 rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Mic className="size-5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">
|
|
||||||
{recording.username}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
{recording.channel_name && (
|
|
||||||
<span className="truncate">#{recording.channel_name}</span>
|
|
||||||
)}
|
|
||||||
<span className="shrink-0">
|
|
||||||
{formatDate(recording.created_at)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0">
|
|
||||||
{formatBytes(recording.size_bytes)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: download button */}
|
|
||||||
{recording.download_url && (
|
|
||||||
<a
|
|
||||||
href={recording.download_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn btn--ghost btn--icon shrink-0"
|
|
||||||
aria-label={`Download recording by ${recording.username}`}
|
|
||||||
>
|
|
||||||
<Download className="size-4" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// ─── SettingsForm.tsx — Appearance settings island ─────────────────────────
|
|
||||||
// Reads/writes theme preference (dark/light/system) to localStorage key
|
|
||||||
// "bete-dashboard-theme", applies data-theme attribute and .dark class.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
type Theme = "dark" | "light" | "system";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "bete-dashboard-theme";
|
|
||||||
|
|
||||||
const THEME_OPTIONS: { value: Theme; label: string }[] = [
|
|
||||||
{ value: "dark", label: "Dark" },
|
|
||||||
{ value: "light", label: "Light" },
|
|
||||||
{ value: "system", label: "System" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function getStoredTheme(): Theme {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (stored === "dark" || stored === "light" || stored === "system") {
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// localStorage unavailable
|
|
||||||
}
|
|
||||||
return "dark";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTheme(theme: Theme): "dark" | "light" {
|
|
||||||
if (theme === "system") {
|
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
||||||
? "dark"
|
|
||||||
: "light";
|
|
||||||
}
|
|
||||||
return theme;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTheme(theme: Theme): void {
|
|
||||||
const root = document.documentElement;
|
|
||||||
const actual = resolveTheme(theme);
|
|
||||||
root.setAttribute("data-theme", actual);
|
|
||||||
root.classList.toggle("dark", actual === "dark");
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeTheme(theme: Theme): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, theme);
|
|
||||||
} catch {
|
|
||||||
// quota exceeded or unavailable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SettingsForm() {
|
|
||||||
const [theme, setTheme] = useState<Theme>(getStoredTheme);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
applyTheme(theme);
|
|
||||||
storeTheme(theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const handleThemeChange = useCallback((value: Theme) => {
|
|
||||||
setTheme(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Appearance</h2>
|
|
||||||
<p className="mb-4 text-sm text-muted-foreground">
|
|
||||||
Choose your preferred color scheme for the dashboard.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{THEME_OPTIONS.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={`btn ${
|
|
||||||
theme === option.value ? "btn--primary" : "btn--outline"
|
|
||||||
} btn--sm`}
|
|
||||||
onClick={() => handleThemeChange(option.value)}
|
|
||||||
aria-pressed={theme === option.value}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
// ─── ThemeToggle.tsx — Standalone dark/light toggle island ──────────────────
|
|
||||||
// Reads/writes localStorage key "bete-dashboard-theme", updates data-theme
|
|
||||||
// attribute and .dark class on <html>.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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<Theme>(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 (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn--ghost btn--icon"
|
|
||||||
onClick={toggle}
|
|
||||||
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? (
|
|
||||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
// ─── VoiceControls.tsx — Voice bridge island ────────────────────────────────
|
|
||||||
// Self-contained: reads/writes useVoiceStore, calls API for connect/disconnect.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { Radio } from "lucide-react";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { connectVoice, disconnectVoice } from "../shared/api/client.js";
|
|
||||||
import { Button, Select } from "../shared/components/index.js";
|
|
||||||
import type { Channel, Guild } from "../shared/types/guild.js";
|
|
||||||
import { useVoiceStore } from "../stores/voice-store.js";
|
|
||||||
|
|
||||||
interface VoiceControlsProps {
|
|
||||||
guilds: Guild[];
|
|
||||||
voiceChannels: Channel[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VoiceControls({
|
|
||||||
guilds,
|
|
||||||
voiceChannels,
|
|
||||||
}: VoiceControlsProps) {
|
|
||||||
const connected = useVoiceStore((state) => state.connected);
|
|
||||||
const guildId = useVoiceStore((state) => state.guildId);
|
|
||||||
const channelId = useVoiceStore((state) => state.channelId);
|
|
||||||
const setGuildChannel = useVoiceStore((state) => state.setGuildChannel);
|
|
||||||
const setConnected = useVoiceStore((state) => state.setConnected);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleConnect = useCallback(async () => {
|
|
||||||
if (!guildId || !channelId) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const status = await connectVoice(guildId, channelId);
|
|
||||||
setConnected(status.connected);
|
|
||||||
} catch {
|
|
||||||
// API error handled by global handler
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [guildId, channelId, setConnected]);
|
|
||||||
|
|
||||||
const handleDisconnect = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const status = await disconnectVoice();
|
|
||||||
setConnected(status.connected);
|
|
||||||
} catch {
|
|
||||||
// API error handled by global handler
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [setConnected]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="glass rounded-xl p-6">
|
|
||||||
<h3 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
|
|
||||||
<Radio className="h-5 w-5 text-primary" /> Voice Bridge
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
Join a Discord voice channel to monitor live audio.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-foreground">Guild</label>
|
|
||||||
<Select
|
|
||||||
value={guildId}
|
|
||||||
onChange={(e) => setGuildChannel(e.target.value, channelId)}
|
|
||||||
placeholder="Select guild"
|
|
||||||
disabled={connected}
|
|
||||||
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-foreground">
|
|
||||||
Voice Channel
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={channelId}
|
|
||||||
onChange={(e) => setGuildChannel(guildId, e.target.value)}
|
|
||||||
placeholder="Select channel"
|
|
||||||
disabled={connected || !guildId}
|
|
||||||
options={voiceChannels.map((c) => ({
|
|
||||||
value: c.id,
|
|
||||||
label: c.name,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2">
|
|
||||||
{connected ? (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? "Disconnecting..." : "Disconnect"}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={handleConnect}
|
|
||||||
disabled={!guildId || !channelId || loading}
|
|
||||||
>
|
|
||||||
{loading ? "Connecting..." : "Join Voice"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── AuthLayout.astro — Centered authentication layout ─────────────────────
|
|
||||||
// Wraps BaseLayout with a full-screen centered flex container and a glass card.
|
|
||||||
// Used for login, password reset, and other auth-related pages.
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
import BaseLayout from "./BaseLayout.astro";
|
|
||||||
---
|
|
||||||
|
|
||||||
<BaseLayout>
|
|
||||||
<main class="flex min-h-screen items-center justify-center p-4">
|
|
||||||
<div class="glass w-full max-w-sm rounded-xl p-6">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</BaseLayout>
|
|
||||||
@@ -4,17 +4,10 @@
|
|||||||
// All interactive content is delegated to React islands via <slot/>.
|
// All interactive content is delegated to React islands via <slot/>.
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Import global design system styles (Tailwind 4 + tokens)
|
|
||||||
import "../styles/base.css";
|
|
||||||
|
|
||||||
// Font dari design system: Outfit (menggantikan Poppins)
|
// Font dari design system: Outfit (menggantikan Poppins)
|
||||||
const FONT_HREF =
|
const FONT_HREF =
|
||||||
"https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap";
|
"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_TITLE = "IMPHNEN — Discord Moderation";
|
||||||
const FALLBACK_DESC =
|
const FALLBACK_DESC =
|
||||||
"Real-time Discord AI Moderation & Voice Recording Dashboard";
|
"Real-time Discord AI Moderation & Voice Recording Dashboard";
|
||||||
@@ -28,7 +21,6 @@ const FALLBACK_IMAGE =
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" id="meta-theme-color" content="#ffffff" />
|
<meta name="theme-color" id="meta-theme-color" content="#ffffff" />
|
||||||
<meta name="view-transition" content="same-origin" />
|
|
||||||
|
|
||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<title>{FALLBACK_TITLE}</title>
|
<title>{FALLBACK_TITLE}</title>
|
||||||
@@ -50,13 +42,10 @@ const FALLBACK_IMAGE =
|
|||||||
<!-- Icon -->
|
<!-- Icon -->
|
||||||
<link rel="icon" type="image/svg+xml" href={FALLBACK_IMAGE} />
|
<link rel="icon" type="image/svg+xml" href={FALLBACK_IMAGE} />
|
||||||
|
|
||||||
<!-- Font Preconnect & Preload -->
|
<!-- Font Preconnect -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link rel="preload" as="style" href={FONT_HREF} />
|
|
||||||
<link rel="preload" as="style" href={FONT_HREF_MONO} />
|
|
||||||
<link href={FONT_HREF} rel="stylesheet" />
|
<link href={FONT_HREF} rel="stylesheet" />
|
||||||
<link href={FONT_HREF_MONO} rel="stylesheet" />
|
|
||||||
|
|
||||||
<!-- ── Anti-FOUC: apply saved theme BEFORE React renders ────────────── -->
|
<!-- ── Anti-FOUC: apply saved theme BEFORE React renders ────────────── -->
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
@@ -87,30 +76,6 @@ const FALLBACK_IMAGE =
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- ── View Transitions ────────────────────────────────── -->
|
|
||||||
<style is:global>
|
|
||||||
@keyframes fade-out {
|
|
||||||
from { opacity: 1; }
|
|
||||||
to { opacity: 0; }
|
|
||||||
}
|
|
||||||
@keyframes fade-in-up {
|
|
||||||
from { opacity: 0; transform: translateY(20px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
::view-transition-old(root) {
|
|
||||||
animation: fade-out 250ms ease-out forwards;
|
|
||||||
}
|
|
||||||
::view-transition-new(root) {
|
|
||||||
animation: fade-in-up 350ms ease-out forwards;
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
::view-transition-old(root),
|
|
||||||
::view-transition-new(root) {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- React island: seluruh app di-render oleh React -->
|
<!-- React island: seluruh app di-render oleh React -->
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── 404.astro — Not Found page ────────────────────────────────────────────
|
|
||||||
// Uses BaseLayout with a centered 404 message, gradient-text heading,
|
|
||||||
// and a "Go Home" button linking to the root.
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
||||||
---
|
|
||||||
|
|
||||||
<BaseLayout>
|
|
||||||
<main class="flex min-h-screen flex-col items-center justify-center gap-6 p-4 text-center">
|
|
||||||
<h1 class="gradient-text text-8xl font-bold leading-none">404</h1>
|
|
||||||
<p class="text-xl text-muted-foreground">Page not found</p>
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Go Home
|
|
||||||
</a>
|
|
||||||
</main>
|
|
||||||
</BaseLayout>
|
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
---
|
---
|
||||||
// ─── index.astro — Redirect to live dashboard ──────────────────────────────
|
// ─── index.astro — BETE's main entry point ──────────────────────────────────
|
||||||
// The root path now redirects to /live where the full SPA lives.
|
// Shell statis: semua interaktivitas di-delegate ke React island
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
return Astro.redirect("/live", 301);
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||||
|
import AppClient from "../App.client";
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<BaseLayout>
|
||||||
|
<!-- React island: seluruh SPA di-render oleh React -->
|
||||||
|
<AppClient client:only="react" />
|
||||||
|
</BaseLayout>
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── live.astro — Voice & Media live monitoring page ────────────────────────
|
|
||||||
// Full-page layout with Sidebar navigation, Header, and the LiveShell React
|
|
||||||
// island that composes all live components (VoiceControls, ActiveSpeakers,
|
|
||||||
// AudioVisualizer, NowPlaying).
|
|
||||||
// Particles and MascotChat are embedded inside the LiveShell island.
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
||||||
import LiveShell from "../islands/LiveShell";
|
|
||||||
---
|
|
||||||
|
|
||||||
<BaseLayout>
|
|
||||||
<LiveShell client:only="react" />
|
|
||||||
</BaseLayout>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── login.astro — Admin authentication page ───────────────────────────────
|
|
||||||
// Renders the AuthGuard React island inside the AuthLayout shell.
|
|
||||||
// Authenticated users are redirected to /live.
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
import AuthLayout from "../layouts/AuthLayout.astro";
|
|
||||||
import AuthGuard from "../shared/components/auth/AuthGuard.client";
|
|
||||||
---
|
|
||||||
|
|
||||||
<AuthLayout>
|
|
||||||
<h1 class="gradient-text text-2xl font-bold text-center mb-6">Login — BETE</h1>
|
|
||||||
<AuthGuard client:load redirectTo="/live" />
|
|
||||||
</AuthLayout>
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── messages.astro — Standalone messages & moderation page ─────────────────
|
|
||||||
// Shows a full-page message feed with sidebar navigation, page header, and
|
|
||||||
// real-time WebSocket updates via the MessageFeed React island.
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
||||||
import PageHeader from "../islands/PageHeader.tsx";
|
|
||||||
import PageSidebar from "../islands/PageSidebar.tsx";
|
|
||||||
import MessageFeed from "../islands/MessageFeed.tsx";
|
|
||||||
---
|
|
||||||
|
|
||||||
<BaseLayout>
|
|
||||||
<div class="flex min-h-screen">
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<PageSidebar client:load activeTab="messages" />
|
|
||||||
|
|
||||||
<!-- Main content area -->
|
|
||||||
<main class="flex min-w-0 flex-1 flex-col">
|
|
||||||
<!-- Header with title and built-in theme toggle -->
|
|
||||||
<PageHeader client:load title="Messages" />
|
|
||||||
|
|
||||||
<!-- Message feed -->
|
|
||||||
<div class="flex-1 overflow-auto p-4 md:p-6 lg:p-8">
|
|
||||||
<MessageFeed client:load />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</BaseLayout>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── recordings.astro — Voice Recordings page ─────────────────────────────
|
|
||||||
// Lists all voice recordings with loading/error/empty/success states.
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
||||||
import Sidebar from "../components/sidebar/Sidebar.astro";
|
|
||||||
import Header from "../components/header/Header.astro";
|
|
||||||
import Card from "../components/ui/Card.astro";
|
|
||||||
import ThemeToggle from "../islands/ThemeToggle";
|
|
||||||
import RecordingsList from "../islands/RecordingsList";
|
|
||||||
---
|
|
||||||
|
|
||||||
<BaseLayout>
|
|
||||||
<div class="flex min-h-screen">
|
|
||||||
<Sidebar currentPath={Astro.url.pathname} />
|
|
||||||
<div class="ml-64 flex flex-1 flex-col">
|
|
||||||
<Header title="Recordings">
|
|
||||||
<ThemeToggle slot="actions" client:load />
|
|
||||||
</Header>
|
|
||||||
<main class="flex-1 p-6">
|
|
||||||
<Card>
|
|
||||||
<RecordingsList client:load />
|
|
||||||
</Card>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</BaseLayout>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
// ─── settings.astro — Dashboard Settings page ─────────────────────────────
|
|
||||||
// Appearance settings: theme selection (dark/light/system).
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
||||||
import Sidebar from "../components/sidebar/Sidebar.astro";
|
|
||||||
import Header from "../components/header/Header.astro";
|
|
||||||
import Card from "../components/ui/Card.astro";
|
|
||||||
import ThemeToggle from "../islands/ThemeToggle";
|
|
||||||
import SettingsForm from "../islands/SettingsForm";
|
|
||||||
---
|
|
||||||
|
|
||||||
<BaseLayout>
|
|
||||||
<div class="flex min-h-screen">
|
|
||||||
<Sidebar currentPath={Astro.url.pathname} />
|
|
||||||
<div class="ml-64 flex flex-1 flex-col">
|
|
||||||
<Header title="Settings">
|
|
||||||
<ThemeToggle slot="actions" client:load />
|
|
||||||
</Header>
|
|
||||||
<main class="flex-1 p-6">
|
|
||||||
<Card class="max-w-2xl">
|
|
||||||
<SettingsForm client:idle />
|
|
||||||
</Card>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</BaseLayout>
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// ─── Shared HTTP client — all API endpoints in one file ──────────────────────
|
// ─── Shared HTTP client — all API endpoints in one file ──────────────────────
|
||||||
|
|
||||||
import type { MessageRecord, PageResult } from "@bete/shared";
|
import type { MessageRecord, PageResult } from "@bete/shared";
|
||||||
import { createLogger } from "../lib/logger.js";
|
|
||||||
import type {
|
import type {
|
||||||
ChatResponse,
|
ChatResponse,
|
||||||
DashboardChannel,
|
DashboardChannel,
|
||||||
@@ -9,20 +8,29 @@ import type {
|
|||||||
DashboardStats,
|
DashboardStats,
|
||||||
DashboardUser,
|
DashboardUser,
|
||||||
DashboardUserDetail,
|
DashboardUserDetail,
|
||||||
} from "../types/dashboard.js";
|
} from "../../entities/dashboard/types.js";
|
||||||
import type { Channel, Guild, GuildVoiceEntry } from "../types/guild.js";
|
import type {
|
||||||
import type { MediaItem, MediaMode, MediaState } from "../types/media.js";
|
Channel,
|
||||||
|
Guild,
|
||||||
|
GuildVoiceEntry,
|
||||||
|
} from "../../entities/guild/types.js";
|
||||||
|
import type {
|
||||||
|
MediaItem,
|
||||||
|
MediaMode,
|
||||||
|
MediaState,
|
||||||
|
} from "../../entities/media/types.js";
|
||||||
import type {
|
import type {
|
||||||
VoiceRecording,
|
VoiceRecording,
|
||||||
VoiceRecordingListResponse,
|
VoiceRecordingListResponse,
|
||||||
} from "../types/recording.js";
|
} from "../../entities/recording/types.js";
|
||||||
import type {
|
import type {
|
||||||
AdminSettings,
|
AdminSettings,
|
||||||
AppConfig,
|
AppConfig,
|
||||||
DashboardTab,
|
DashboardTab,
|
||||||
UIState,
|
UIState,
|
||||||
} from "../types/ui-types.js";
|
} from "../../entities/ui/types.js";
|
||||||
import type { ActiveSpeaker, VoiceStatus } from "../types/voice.js";
|
import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js";
|
||||||
|
import { createLogger } from "../lib/logger.js";
|
||||||
|
|
||||||
const logger = createLogger("api");
|
const logger = createLogger("api");
|
||||||
|
|
||||||
@@ -222,7 +230,9 @@ export function reanalyzeMessage(id: string): Promise<void> {
|
|||||||
return request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
|
return request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMessageById(id: string): Promise<MessageRecord | null> {
|
export function getMessageById(
|
||||||
|
id: string,
|
||||||
|
): Promise<MessageRecord | null> {
|
||||||
return request<MessageRecord | null>(`/api/messages/detail/${id}`);
|
return request<MessageRecord | null>(`/api/messages/detail/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,9 +335,7 @@ export function deleteRecording(id: string): Promise<void> {
|
|||||||
|
|
||||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function login(
|
export function login(password: string): Promise<{ ok: boolean; token?: string }> {
|
||||||
password: string,
|
|
||||||
): Promise<{ ok: boolean; token?: string }> {
|
|
||||||
return request<{ ok: boolean; token?: string }>("/api/auth/login", {
|
return request<{ ok: boolean; token?: string }>("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
// ─── AuthGuard.client.tsx — Astro React island for login page ──────────────
|
|
||||||
// Thin wrapper around AuthOverlay that redirects to the main app on success.
|
|
||||||
// Loaded via client:load on the login page.
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { AuthOverlay } from "./index";
|
|
||||||
|
|
||||||
interface AuthGuardProps {
|
|
||||||
redirectTo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuthGuard({ redirectTo = "/live" }: AuthGuardProps) {
|
|
||||||
const handleAuthenticated = useCallback(() => {
|
|
||||||
window.location.href = redirectTo;
|
|
||||||
}, [redirectTo]);
|
|
||||||
|
|
||||||
return <AuthOverlay isPublic={false} onAuthenticated={handleAuthenticated} />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import type { ChatResponse } from "../../entities/dashboard/types.js";
|
||||||
import { request } from "../api/client";
|
import { request } from "../api/client";
|
||||||
import { createLogger } from "../lib/logger";
|
import { createLogger } from "../lib/logger";
|
||||||
import type { ChatResponse } from "../types/dashboard.js";
|
|
||||||
|
|
||||||
const logger = createLogger("useMascotChat");
|
const logger = createLogger("useMascotChat");
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import type { UIState } from "../types/ui-types.js";
|
import type { UIState } from "../../entities/ui/types.js";
|
||||||
import { uiStateValidator, useLocalStorage } from "./useLocalStorage";
|
import { uiStateValidator, useLocalStorage } from "./useLocalStorage";
|
||||||
|
|
||||||
export function useUIState() {
|
export function useUIState() {
|
||||||
|
|||||||
+1
-1
@@ -13,11 +13,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { KeyboardEvent } from "react";
|
import type { KeyboardEvent } from "react";
|
||||||
import {
|
import {
|
||||||
type ChangeEvent,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { MessageRecord } from "../api/client";
|
import type { MessageRecord } from "../api/client";
|
||||||
import { request } from "../api/client";
|
import { request } from "../api/client";
|
||||||
+2
-4
@@ -1,7 +1,7 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { LayoutDashboard, MessageSquare, Radio, Settings } from "lucide-react";
|
import { LayoutDashboard, MessageSquare, Radio, Settings } from "lucide-react";
|
||||||
|
import type { DashboardTab } from "../../entities/ui/types.js";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import type { DashboardTab } from "../types/ui-types.js";
|
|
||||||
|
|
||||||
const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
|
const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
|
||||||
{ id: "messages", label: "Messages", Icon: MessageSquare },
|
{ id: "messages", label: "Messages", Icon: MessageSquare },
|
||||||
@@ -44,9 +44,7 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
|||||||
className="absolute -top-px left-1/4 right-1/4 h-0.5 rounded-full bg-primary"
|
className="absolute -top-px left-1/4 right-1/4 h-0.5 rounded-full bg-primary"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Icon
|
<Icon className={cn("h-5 w-5", activeTab === id && "drop-shadow-sm")} />
|
||||||
className={cn("h-5 w-5", activeTab === id && "drop-shadow-sm")}
|
|
||||||
/>
|
|
||||||
<span className="text-[10px]">{label}</span>
|
<span className="text-[10px]">{label}</span>
|
||||||
{activeTab === id && (
|
{activeTab === id && (
|
||||||
<motion.div
|
<motion.div
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
|
||||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Button } from "./button";
|
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
interface ErrorBoundaryProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -6,9 +6,9 @@ import type {
|
|||||||
VoiceRecordingUploadData,
|
VoiceRecordingUploadData,
|
||||||
} from "@bete/shared";
|
} from "@bete/shared";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getSessionToken } from "../api/client.js";
|
import type { MediaState } from "../../entities/media/types.js";
|
||||||
import { createLogger } from "../lib/logger.js";
|
import { createLogger } from "../lib/logger.js";
|
||||||
import type { MediaState } from "../types/media.js";
|
import { getSessionToken } from "../api/client.js";
|
||||||
import type { ActiveSpeakerData } from "./events.js";
|
import type { ActiveSpeakerData } from "./events.js";
|
||||||
|
|
||||||
const logger = createLogger("socket");
|
const logger = createLogger("socket");
|
||||||
@@ -391,43 +391,3 @@ export function useDashboardSocket(handlers: WsHandlers) {
|
|||||||
|
|
||||||
return { status, send, socketRef: { current: _wsInstance } };
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
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<MessageRecord>) => void;
|
|
||||||
removeMessage: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useMessageStore = create<MessageState & MessageActions>((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,
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user