Revert "feat: migrate frontend to Astro + expand AI moderation + backend admin/runtime config"
This reverts commit d59b59a7a7.
This commit is contained in:
-155
@@ -1,155 +0,0 @@
|
||||
declare module 'astro:content' {
|
||||
export interface RenderResult {
|
||||
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
|
||||
headings: import('astro').MarkdownHeading[];
|
||||
remarkPluginFrontmatter: Record<string, any>;
|
||||
}
|
||||
interface Render {
|
||||
'.md': Promise<RenderResult>;
|
||||
}
|
||||
|
||||
export interface RenderedContent {
|
||||
html: string;
|
||||
metadata?: {
|
||||
imagePaths: Array<string>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
|
||||
|
||||
export type CollectionKey = keyof DataEntryMap;
|
||||
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
|
||||
|
||||
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
|
||||
|
||||
export type ReferenceDataEntry<
|
||||
C extends CollectionKey,
|
||||
E extends keyof DataEntryMap[C] = string,
|
||||
> = {
|
||||
collection: C;
|
||||
id: E;
|
||||
};
|
||||
|
||||
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
|
||||
collection: C;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
|
||||
collection: C,
|
||||
filter?: (entry: CollectionEntry<C>) => entry is E,
|
||||
): Promise<E[]>;
|
||||
export function getCollection<C extends keyof DataEntryMap>(
|
||||
collection: C,
|
||||
filter?: (entry: CollectionEntry<C>) => unknown,
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
|
||||
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
|
||||
collection: C,
|
||||
filter?: LiveLoaderCollectionFilterType<C>,
|
||||
): Promise<
|
||||
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
|
||||
>;
|
||||
|
||||
export function getEntry<
|
||||
C extends keyof DataEntryMap,
|
||||
E extends keyof DataEntryMap[C] | (string & {}),
|
||||
>(
|
||||
entry: ReferenceDataEntry<C, E>,
|
||||
): E extends keyof DataEntryMap[C]
|
||||
? Promise<DataEntryMap[C][E]>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getEntry<
|
||||
C extends keyof DataEntryMap,
|
||||
E extends keyof DataEntryMap[C] | (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
id: E,
|
||||
): E extends keyof DataEntryMap[C]
|
||||
? string extends keyof DataEntryMap[C]
|
||||
? Promise<DataEntryMap[C][E]> | undefined
|
||||
: Promise<DataEntryMap[C][E]>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
|
||||
collection: C,
|
||||
filter: string | LiveLoaderEntryFilterType<C>,
|
||||
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
|
||||
|
||||
/** Resolve an array of entry references from the same collection */
|
||||
export function getEntries<C extends keyof DataEntryMap>(
|
||||
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
|
||||
export function render<C extends keyof DataEntryMap>(
|
||||
entry: DataEntryMap[C][string],
|
||||
): Promise<RenderResult>;
|
||||
|
||||
export function reference<
|
||||
C extends
|
||||
| keyof DataEntryMap
|
||||
// Allow generic `string` to avoid excessive type errors in the config
|
||||
// if `dev` is not running to update as you edit.
|
||||
// Invalid collection names will be caught at build time.
|
||||
| (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
): import('astro/zod').ZodPipe<
|
||||
import('astro/zod').ZodString,
|
||||
import('astro/zod').ZodTransform<
|
||||
C extends keyof DataEntryMap
|
||||
? {
|
||||
collection: C;
|
||||
id: string;
|
||||
}
|
||||
: never,
|
||||
string
|
||||
>
|
||||
>;
|
||||
|
||||
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
|
||||
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
|
||||
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
|
||||
>;
|
||||
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
|
||||
type InferLoaderSchema<
|
||||
C extends keyof DataEntryMap,
|
||||
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
|
||||
> = L extends { schema: import('astro/zod').ZodSchema }
|
||||
? import('astro/zod').infer<L['schema']>
|
||||
: any;
|
||||
|
||||
type DataEntryMap = {
|
||||
|
||||
};
|
||||
|
||||
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
|
||||
infer TData,
|
||||
infer TEntryFilter,
|
||||
infer TCollectionFilter,
|
||||
infer TError
|
||||
>
|
||||
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
|
||||
: { data: never; entryFilter: never; collectionFilter: never; error: never };
|
||||
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
|
||||
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
|
||||
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
|
||||
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
|
||||
|
||||
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
|
||||
LiveContentConfig['collections'][C]['schema'] extends undefined
|
||||
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
|
||||
: import('astro/zod').infer<
|
||||
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
|
||||
>;
|
||||
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
|
||||
LiveContentConfig['collections'][C]['loader']
|
||||
>;
|
||||
|
||||
export type ContentConfig = never;
|
||||
export type LiveContentConfig = never;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"pid": 866059,
|
||||
"port": 3000,
|
||||
"url": "http://localhost:3000",
|
||||
"urls": {
|
||||
"local": [
|
||||
"http://localhost:3000/"
|
||||
],
|
||||
"network": [
|
||||
"http://192.168.1.65:3000/",
|
||||
"http://100.114.19.66:3000/",
|
||||
"http://172.27.0.1:3000/",
|
||||
"http://172.26.0.1:3000/"
|
||||
]
|
||||
},
|
||||
"background": false,
|
||||
"startedAt": "2026-07-01T15:35:45.072Z"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1782920146192
|
||||
}
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="astro/client" />
|
||||
@@ -1,45 +0,0 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
import react from "@astrojs/react";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// BETE – Astro Configuration
|
||||
// Tailwind v4 ditangani via PostCSS (postcss.config.js)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export default defineConfig({
|
||||
integrations: [react()],
|
||||
|
||||
output: "static",
|
||||
|
||||
// Dev server
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 3000,
|
||||
},
|
||||
|
||||
// Preview
|
||||
preview: {
|
||||
host: true,
|
||||
port: 3000,
|
||||
allowedHosts: [
|
||||
"imphnen.asepharyana.my.id",
|
||||
"imphnen.asepharyana.tech",
|
||||
"imphnen.asepharyana.web.id",
|
||||
],
|
||||
},
|
||||
|
||||
// Vite config
|
||||
vite: {
|
||||
server: {
|
||||
allowedHosts: [
|
||||
"imphnen.asepharyana.my.id",
|
||||
"imphnen.asepharyana.tech",
|
||||
"imphnen.asepharyana.web.id",
|
||||
],
|
||||
watch: {
|
||||
// Penting: Astro punya public/ dir sendiri, jangan bentrok
|
||||
ignored: ["!**/node_modules/**"],
|
||||
},
|
||||
},
|
||||
// PostCSS otomatis terdeteksi dari root project
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#23a1eb" />
|
||||
<title>IMPHNEN — Discord Moderation</title>
|
||||
<link rel="icon" type="image/svg+xml" href="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,20 +4,18 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev --host 0.0.0.0",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview --host 0.0.0.0 --port 3000",
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc --noEmit && NODE_NO_WARNINGS=1 vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 3000",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check --diagnostic-level=error src/",
|
||||
"format": "biome format --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^6.0.0",
|
||||
"@bete/shared": "workspace:*",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"astro": "^7.0.4",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.4.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
@@ -30,9 +28,11 @@
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// ─── 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>
|
||||
);
|
||||
}
|
||||
+88
-324
@@ -1,14 +1,5 @@
|
||||
// ─── 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 { useEffect, useMemo, 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";
|
||||
@@ -19,118 +10,26 @@ 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 { getAppConfig } 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 })[]>([]);
|
||||
(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 activeTab = uiState.activeTab || "messages";
|
||||
const selectedVoiceGuild =
|
||||
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||
|
||||
@@ -178,7 +77,7 @@ export default function App() {
|
||||
),
|
||||
onVoiceActiveUser: (data) => {
|
||||
if (data.userId) audio.registerUserId(data.userId);
|
||||
setActiveSpeakers((prev: (ActiveSpeaker & { heardAt?: number })[]) =>
|
||||
setActiveSpeakers((prev) =>
|
||||
updateSpeakerList(prev, {
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
@@ -187,25 +86,12 @@ export default function App() {
|
||||
}),
|
||||
);
|
||||
},
|
||||
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 }),
|
||||
),
|
||||
onVoiceRecordingStarted: () =>
|
||||
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
||||
onVoiceRecordingStopped: () =>
|
||||
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
||||
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]);
|
||||
}),
|
||||
messages.setMessages((prev) => mergeMessages(prev, [m])),
|
||||
onMessageUpdated: (m) =>
|
||||
messages.setMessages((prev) =>
|
||||
prev.map((i) => (i.id === m.id ? { ...i, ...m } : i)),
|
||||
@@ -217,12 +103,7 @@ export default function App() {
|
||||
),
|
||||
),
|
||||
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]);
|
||||
});
|
||||
messages.setMessages((prev) => mergeMessages(prev, [msg]));
|
||||
const status = msg.ai_status;
|
||||
if (status === "flagged") {
|
||||
const username = msg.username || msg.user_id || "unknown";
|
||||
@@ -253,6 +134,17 @@ export default function App() {
|
||||
|
||||
const transmit = useAudioTransmit(socket.socketRef);
|
||||
|
||||
// Load app config on mount
|
||||
useEffect(() => {
|
||||
getAppConfig()
|
||||
.then((c) => {
|
||||
if (c.monitorGuildId) {
|
||||
setMonitorGuildId(c.monitorGuildId);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
// Load voice channels when guild changes (Live tab)
|
||||
useEffect(() => {
|
||||
if (selectedVoiceGuild)
|
||||
@@ -266,24 +158,18 @@ export default function App() {
|
||||
}, [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;
|
||||
if (!monitorGuildId) return;
|
||||
const interval = setInterval(() => {
|
||||
messages.fetchMessages(monitorGuildRef.current).catch(() => undefined);
|
||||
messages.fetchMessages(monitorGuildId).catch(() => undefined);
|
||||
}, 15_000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [monitorGuildId]);
|
||||
return () => clearInterval(interval);
|
||||
}, [monitorGuildId, messages.fetchMessages]);
|
||||
|
||||
// Stale speaker pruning — remove speakers not heard from in 30s
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActiveSpeakers((prev: (ActiveSpeaker & { heardAt?: number })[]) => {
|
||||
setActiveSpeakers((prev) => {
|
||||
const now = Date.now();
|
||||
const pruned = prev.filter(
|
||||
(s) => s.speaking || (s.heardAt && now - s.heardAt < 30_000),
|
||||
@@ -321,192 +207,70 @@ export default function App() {
|
||||
};
|
||||
}, [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>
|
||||
<DashboardLayout
|
||||
activeTab={activeTab}
|
||||
wsStatus={socket.status}
|
||||
voiceStatus={voice.voiceStatus}
|
||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||
recentMessages={messages.messages}
|
||||
guildId={monitorGuildId}
|
||||
channelId={
|
||||
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
|
||||
}
|
||||
>
|
||||
{activeTab === "live" ? (
|
||||
<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}
|
||||
/>
|
||||
) : activeTab === "dashboard" ? (
|
||||
<DashboardPanel />
|
||||
) : (
|
||||
<MessagesPanel
|
||||
guildName={monitorGuildName}
|
||||
messages={messages.messages}
|
||||
onReanalyze={messages.reanalyze}
|
||||
onReanalyzeAllErrors={messages.reanalyzeAllErrors}
|
||||
onLoadMore={messages.loadMore}
|
||||
hasMore={messages.hasMore}
|
||||
loadingMore={messages.loadingMore}
|
||||
/>
|
||||
)}
|
||||
<MobileTabBar
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||
/>
|
||||
<ModerationAlertListener />
|
||||
<CommandPalette
|
||||
isOpen={paletteOpen}
|
||||
mode={paletteMode}
|
||||
onClose={handlePaletteClose}
|
||||
onNavigate={handlePaletteNavigate}
|
||||
onToggleTheme={toggleTheme}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,20 +6,13 @@ export interface UIState {
|
||||
selectedTextChannel?: string;
|
||||
selectedAnalyticsGuild?: string;
|
||||
selectedAnalyticsChannel?: string;
|
||||
activeTab?: DashboardTab;
|
||||
activeTab?: "live" | "messages" | "dashboard";
|
||||
isListening?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export type DashboardTab = "live" | "messages" | "dashboard" | "settings";
|
||||
export type DashboardTab = "live" | "messages" | "dashboard";
|
||||
|
||||
export interface AppConfig {
|
||||
monitorGuildId: string | null;
|
||||
dashboardIsPublic: boolean;
|
||||
}
|
||||
|
||||
/** Response from GET /api/admin/settings */
|
||||
export interface AdminSettings {
|
||||
dashboardIsPublic: boolean;
|
||||
envDashboardIsPublic: boolean;
|
||||
}
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
Lock,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AdminSettings } from "../../shared/api/client";
|
||||
import {
|
||||
getAdminSettings,
|
||||
updateAdminSettings,
|
||||
clearSessionToken,
|
||||
logout,
|
||||
} from "../../shared/api/client";
|
||||
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../shared/ui";
|
||||
|
||||
export function AdminPanel() {
|
||||
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const handleLogout = async () => {
|
||||
// Call server-side logout to increment token version
|
||||
try {
|
||||
await logout();
|
||||
} catch {
|
||||
// Even if server call fails, still clear local state for security
|
||||
}
|
||||
// Clear local token and legacy password
|
||||
clearSessionToken();
|
||||
localStorage.removeItem("admin-password");
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const fetchSettings = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getAdminSettings();
|
||||
setSettings(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load settings");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const handleTogglePublic = async () => {
|
||||
if (!settings) return;
|
||||
const newValue = !settings.dashboardIsPublic;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const updated = await updateAdminSettings({
|
||||
dashboardIsPublic: newValue,
|
||||
});
|
||||
setSettings(updated);
|
||||
setSuccess(
|
||||
newValue
|
||||
? "Dashboard is now public — accessible without password."
|
||||
: "Dashboard is now private — admin password required.",
|
||||
);
|
||||
setTimeout(() => setSuccess(null), 4000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update settings");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary">Admin Settings</CardTitle>
|
||||
<CardDescription>Loading settings...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !settings) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary">Admin Settings</CardTitle>
|
||||
<CardDescription className="text-destructive">{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={fetchSettings} variant="outline" size="sm">
|
||||
<RefreshCw className="mr-2 h-4 w-4" /> Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const isPublic = settings?.dashboardIsPublic ?? false;
|
||||
|
||||
return (
|
||||
<motion.div variants={cardStagger} initial="initial" animate="animate">
|
||||
<motion.div variants={cardItem}>
|
||||
<Card className="border-primary/20">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-primary">
|
||||
<Settings className="h-5 w-5" />
|
||||
Admin Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage dashboard visibility and runtime configuration.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
onClick={fetchSettings}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* ── Success / Error messages ── */}
|
||||
{success && (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Dashboard Visibility ── */}
|
||||
<div className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{isPublic ? (
|
||||
<Globe className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4 text-amber-500" />
|
||||
)}
|
||||
<h3 className="font-semibold">
|
||||
Dashboard Visibility:{" "}
|
||||
<span
|
||||
className={
|
||||
isPublic ? "text-emerald-500" : "text-amber-500"
|
||||
}
|
||||
>
|
||||
{isPublic ? "Public" : "Private"}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isPublic
|
||||
? "Anyone can view the dashboard without a password. Admin password is still required for management actions."
|
||||
: "Admin password is required to access any part of the dashboard."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleTogglePublic}
|
||||
disabled={saving}
|
||||
variant={isPublic ? "outline" : "default"}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Saving...
|
||||
</>
|
||||
) : isPublic ? (
|
||||
<>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Make Private
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Make Public
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Status indicators ── */}
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg bg-muted/50 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">Runtime</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
isPublic ? "bg-emerald-400" : "bg-amber-400"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{isPublic ? "Public" : "Private"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Env Default
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
settings?.envDashboardIsPublic
|
||||
? "bg-emerald-400"
|
||||
: "bg-amber-400"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{settings?.envDashboardIsPublic ? "Public" : "Private"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Logout ── */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Info card ── */}
|
||||
<div className="rounded-xl border border-border/50 bg-muted/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>
|
||||
<strong>Admin password</strong> is configured via the
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||
ADMIN_PASSWORD
|
||||
</code>
|
||||
environment variable. For security, it cannot be changed
|
||||
through this panel — update it in your deployment
|
||||
configuration and restart the service.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Runtime settings are persisted across restarts in the
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
|
||||
data/settings.json
|
||||
</code>
|
||||
file. Changes take effect immediately, no restart needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Lock, Unlock, Shield, WifiOff, RefreshCw } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { login, setSessionToken } from "../../shared/api/client.js";
|
||||
import { Lock } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { login } from "../../shared/api/client.js";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -14,148 +14,67 @@ import {
|
||||
|
||||
interface AuthOverlayProps {
|
||||
onAuthenticated: () => void;
|
||||
isPublic: boolean;
|
||||
configError?: string | null;
|
||||
onRetryConfig?: () => void;
|
||||
}
|
||||
|
||||
export function AuthOverlay({
|
||||
onAuthenticated,
|
||||
isPublic,
|
||||
configError,
|
||||
onRetryConfig,
|
||||
}: AuthOverlayProps) {
|
||||
export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isNetworkError, setIsNetworkError] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setIsNetworkError(false);
|
||||
try {
|
||||
const result = await login(password);
|
||||
// Store session token (new auth method)
|
||||
if (result.token) {
|
||||
setSessionToken(result.token);
|
||||
}
|
||||
// Clean up legacy stored password from localStorage if it was there
|
||||
// from a previous session (before JWT migration)
|
||||
localStorage.removeItem("admin-password");
|
||||
await login(password);
|
||||
localStorage.setItem("admin-password", password);
|
||||
onAuthenticated();
|
||||
} catch (err) {
|
||||
const isNetwork =
|
||||
err instanceof TypeError &&
|
||||
(err.message === "Failed to fetch" ||
|
||||
err.message.includes("NetworkError") ||
|
||||
err.message.includes("network"));
|
||||
setIsNetworkError(isNetwork);
|
||||
setError(
|
||||
isNetwork
|
||||
? "Cannot reach server — check your connection or try again."
|
||||
: "Invalid password",
|
||||
);
|
||||
} catch {
|
||||
setError("Invalid password");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Retry config fetch (initial loading state) ──────────────────────────────
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setRetryCount((r) => r + 1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="flex min-h-screen items-center justify-center p-4"
|
||||
className="flex 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">
|
||||
{isPublic ? (
|
||||
<Shield className="h-6 w-6" />
|
||||
) : (
|
||||
<Lock className="h-6 w-6" />
|
||||
)}
|
||||
<Lock className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle>
|
||||
{isPublic ? "Admin Authentication" : "Admin Access Required"}
|
||||
</CardTitle>
|
||||
<CardTitle>Admin Access Required</CardTitle>
|
||||
<CardDescription>
|
||||
{isPublic
|
||||
? "Enter the admin password to manage settings and perform administrative actions."
|
||||
: "Enter the admin password to access the dashboard."}
|
||||
Enter the admin password to access Voice and Media controls.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{configError && (
|
||||
<div className="mb-4 flex flex-col items-center gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 text-center">
|
||||
<WifiOff className="h-6 w-6 text-amber-500" />
|
||||
<p className="text-xs text-amber-600">{configError}</p>
|
||||
{onRetryConfig && (
|
||||
<Button
|
||||
onClick={onRetryConfig}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 border-amber-500/30 text-amber-600 hover:bg-amber-500/10"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Retry Connection
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter admin password"
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-lg p-2 text-xs ${
|
||||
isNetworkError
|
||||
? "bg-amber-500/10 text-amber-600"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{isNetworkError ? (
|
||||
<WifiOff className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || !password}
|
||||
>
|
||||
{loading ? "Authenticating..." : "Unlock"}
|
||||
{loading ? "Authenticating..." : "Unlock Controls"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{isPublic && (
|
||||
<p className="mt-4 text-xs text-center text-muted-foreground">
|
||||
<Unlock className="inline h-3 w-3 mr-1" />
|
||||
The dashboard is in public mode — most data is visible without
|
||||
authentication. Admin password is only needed for management actions.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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";
|
||||
@@ -89,10 +87,6 @@ export function DashboardPanel() {
|
||||
<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">
|
||||
@@ -126,10 +120,6 @@ export function DashboardPanel() {
|
||||
onSelectChannel={setSelectedChannelId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="admin">
|
||||
<AdminPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ export function RecordingsSubPanel() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{recordings.map((rec) => (
|
||||
<div key={rec.id} className="rounded-xl border border-border bg-card">
|
||||
<div key={rec.id} className="rounded-xl border border-sky-200 bg-white">
|
||||
<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" />
|
||||
|
||||
@@ -82,7 +82,7 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||
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"
|
||||
className="group overflow-hidden rounded-xl border border-primary/20 bg-white shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden">
|
||||
{image.kind === "sticker" ? (
|
||||
|
||||
@@ -42,10 +42,6 @@ function renderContentWithCustomEmojis(content: string): React.ReactNode {
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
title={`:${name}:`}
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
lastIndex = regex.lastIndex;
|
||||
@@ -84,13 +80,13 @@ function parseStringList(value?: string | null): string[] {
|
||||
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";
|
||||
return "bg-red-100 text-red-700 border-red-200";
|
||||
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";
|
||||
return "bg-orange-100 text-orange-700 border-orange-200";
|
||||
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";
|
||||
return "bg-yellow-100 text-yellow-700 border-yellow-200";
|
||||
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";
|
||||
return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
@@ -337,10 +333,6 @@ function MessageRow({
|
||||
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">
|
||||
@@ -368,10 +360,6 @@ function MessageRow({
|
||||
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>
|
||||
))}
|
||||
@@ -392,7 +380,7 @@ function MessageRow({
|
||||
key={vid.url}
|
||||
src={vid.url}
|
||||
controls
|
||||
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-muted"
|
||||
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-black"
|
||||
preload="metadata"
|
||||
/>
|
||||
))}
|
||||
@@ -421,8 +409,8 @@ function MessageRow({
|
||||
<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"
|
||||
? "border-l-pink-400 bg-pink-50/40"
|
||||
: "border-l-emerald-400 bg-emerald-50/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2 text-[11px]">
|
||||
@@ -443,7 +431,7 @@ function MessageRow({
|
||||
|
||||
{/* 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">
|
||||
<div className="rounded-lg bg-pink-50/40 px-3 py-2 text-[12px] text-pink-600">
|
||||
AI error: {message.ai_error}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -463,7 +451,7 @@ function MessageRow({
|
||||
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
||||
</Button>
|
||||
{aiStatus === "error" && (
|
||||
<span className="text-[11px] text-pink-600/70 dark:text-pink-400/70">
|
||||
<span className="text-[11px] text-pink-600/70">
|
||||
Click to retry analysis
|
||||
</span>
|
||||
)}
|
||||
@@ -495,7 +483,7 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
||||
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"
|
||||
firstMsg.deleted_at ? "border-red-200 opacity-60" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3 p-4">
|
||||
@@ -507,10 +495,6 @@ export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
||||
}
|
||||
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">
|
||||
|
||||
@@ -1,426 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Moon,
|
||||
Palette,
|
||||
Sun,
|
||||
Monitor,
|
||||
Settings,
|
||||
Shield,
|
||||
Globe,
|
||||
Lock,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ThemeMode } from "../../hooks/useTheme";
|
||||
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button } from "../../shared/ui";
|
||||
import {
|
||||
getAdminSettings,
|
||||
updateAdminSettings,
|
||||
clearSessionToken,
|
||||
} from "../../shared/api/client";
|
||||
import type { AdminSettings as AdminSettingsType } from "../../entities/ui/types";
|
||||
|
||||
/* ─── Storage keys ─────────────────────────────────────────────────────── */
|
||||
|
||||
const NOTIF_ENABLED_KEY = "bete-notif-enabled";
|
||||
const NOTIF_SOUND_KEY = "bete-notif-sound";
|
||||
|
||||
/* ─── Types ────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface NotificationPrefs {
|
||||
enabled: boolean;
|
||||
sound: boolean;
|
||||
}
|
||||
|
||||
function loadNotifPrefs(): NotificationPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(NOTIF_ENABLED_KEY);
|
||||
const soundRaw = localStorage.getItem(NOTIF_SOUND_KEY);
|
||||
return {
|
||||
enabled: raw !== "false", // default true
|
||||
sound: soundRaw !== "false", // default true
|
||||
};
|
||||
} catch {
|
||||
return { enabled: true, sound: true };
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Props ────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface SettingsPanelProps {
|
||||
themeMode: ThemeMode;
|
||||
isDark: boolean;
|
||||
onThemeModeChange: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
/* ─── Component ────────────────────────────────────────────────────────── */
|
||||
|
||||
export function SettingsPanel({
|
||||
themeMode,
|
||||
isDark,
|
||||
onThemeModeChange,
|
||||
}: SettingsPanelProps) {
|
||||
const [notifPrefs, setNotifPrefs] = useState<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>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Tracks incoming moderation_alert events and maintains a badge counter.
|
||||
* Clears when the user navigates to the messages tab.
|
||||
*/
|
||||
export function useNotificationBadge(activeTab: string) {
|
||||
const [count, setCount] = useState(0);
|
||||
const prevActiveTab = useRef(activeTab);
|
||||
|
||||
// Clear badge when user switches TO messages tab
|
||||
useEffect(() => {
|
||||
if (activeTab === "messages" && prevActiveTab.current !== "messages") {
|
||||
setCount(0);
|
||||
}
|
||||
prevActiveTab.current = activeTab;
|
||||
}, [activeTab]);
|
||||
|
||||
const increment = useCallback(() => {
|
||||
setCount((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
// Listen for moderation_alert custom events
|
||||
useEffect(() => {
|
||||
const handler = () => increment();
|
||||
window.addEventListener("moderation_alert", handler);
|
||||
return () => window.removeEventListener("moderation_alert", handler);
|
||||
}, [increment]);
|
||||
|
||||
return { count, clear: () => setCount(0) };
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
export type ThemeMode = Theme | "system";
|
||||
|
||||
const THEME_STORAGE_KEY = "bete-dashboard-theme";
|
||||
|
||||
function getSystemTheme(): Theme {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function loadThemeMode(): ThemeMode {
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system")
|
||||
return stored;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
function resolveTheme(mode: ThemeMode): Theme {
|
||||
return mode === "system" ? getSystemTheme() : mode;
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
root.setAttribute("data-theme", theme);
|
||||
// Also toggle Tailwind dark class for utility-based approach
|
||||
if (theme === "dark") {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [mode, setModeState] = useState<ThemeMode>(loadThemeMode);
|
||||
|
||||
const theme = useMemo(() => resolveTheme(mode), [mode]);
|
||||
|
||||
const setMode = useCallback((newMode: ThemeMode) => {
|
||||
setModeState(newMode);
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, newMode);
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setMode(theme === "dark" ? "light" : "dark");
|
||||
}, [theme, setMode]);
|
||||
|
||||
// Apply theme on mount and when mode changes
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
// Listen for system preference changes when in "system" mode
|
||||
useEffect(() => {
|
||||
if (mode !== "system") return;
|
||||
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => {
|
||||
applyTheme(resolveTheme("system"));
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [mode]);
|
||||
|
||||
return { theme, mode, setMode, toggle, isDark: theme === "dark" };
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
// ─── BaseLayout.astro — BETE's eternal shell ─────────────────────────────────
|
||||
// Handles: anti-FOUC theme, font loading, global CSS, meta tags.
|
||||
// All interactive content is delegated to React islands via <slot/>.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Font dari design system: Outfit (menggantikan Poppins)
|
||||
const FONT_HREF =
|
||||
"https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap";
|
||||
|
||||
const FALLBACK_TITLE = "IMPHNEN — Discord Moderation";
|
||||
const FALLBACK_DESC =
|
||||
"Real-time Discord AI Moderation & Voice Recording Dashboard";
|
||||
const FALLBACK_IMAGE =
|
||||
"https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg";
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" id="meta-theme-color" content="#ffffff" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{FALLBACK_TITLE}</title>
|
||||
<meta name="title" content={FALLBACK_TITLE} />
|
||||
<meta name="description" content={FALLBACK_DESC} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={FALLBACK_TITLE} />
|
||||
<meta property="og:description" content={FALLBACK_DESC} />
|
||||
<meta property="og:image" content={FALLBACK_IMAGE} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:title" content={FALLBACK_TITLE} />
|
||||
<meta property="twitter:description" content={FALLBACK_DESC} />
|
||||
<meta property="twitter:image" content={FALLBACK_IMAGE} />
|
||||
|
||||
<!-- Icon -->
|
||||
<link rel="icon" type="image/svg+xml" href={FALLBACK_IMAGE} />
|
||||
|
||||
<!-- Font Preconnect -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href={FONT_HREF} rel="stylesheet" />
|
||||
|
||||
<!-- ── Anti-FOUC: apply saved theme BEFORE React renders ────────────── -->
|
||||
<script is:inline>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("bete-dashboard-theme");
|
||||
var theme = "light";
|
||||
if (stored === "dark") {
|
||||
theme = "dark";
|
||||
} else if (stored === "system") {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
var root = document.documentElement;
|
||||
root.setAttribute("data-theme", theme);
|
||||
if (theme === "dark") root.classList.add("dark");
|
||||
// Sync meta theme-color
|
||||
var meta = document.getElementById("meta-theme-color");
|
||||
if (meta) {
|
||||
meta.setAttribute(
|
||||
"content",
|
||||
theme === "dark" ? "#0f0f12" : "#ffffff",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage unavailable — safe to ignore
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- React island: seluruh app di-render oleh React -->
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { ToastProvider } from "./shared/ui";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
// ─── index.astro — BETE's main entry point ──────────────────────────────────
|
||||
// Shell statis: semua interaktivitas di-delegate ke React island
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import AppClient from "../App.client";
|
||||
---
|
||||
|
||||
<BaseLayout>
|
||||
<!-- React island: seluruh SPA di-render oleh React -->
|
||||
<AppClient client:only="react" />
|
||||
</BaseLayout>
|
||||
@@ -24,7 +24,6 @@ import type {
|
||||
VoiceRecordingListResponse,
|
||||
} from "../../entities/recording/types.js";
|
||||
import type {
|
||||
AdminSettings,
|
||||
AppConfig,
|
||||
DashboardTab,
|
||||
UIState,
|
||||
@@ -50,53 +49,14 @@ class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache admin session token in memory — read from sessionStorage once on first call
|
||||
// NOTE: _cachedToken is intentionally removed; we read directly from sessionStorage
|
||||
// to support multi-tab sync (L8 fix).
|
||||
// Cache admin password in memory — read from localStorage once on first call
|
||||
let _cachedPassword: string | null = null;
|
||||
|
||||
/**
|
||||
* Get the current session token from sessionStorage.
|
||||
* Always reads directly from sessionStorage to support multi-tab sync.
|
||||
* Returns null if not authenticated.
|
||||
*/
|
||||
export function getSessionToken(): string | null {
|
||||
return sessionStorage.getItem("admin-token");
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a session token after successful login.
|
||||
*/
|
||||
export function setSessionToken(token: string): void {
|
||||
sessionStorage.setItem("admin-token", token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the session token (logout).
|
||||
*/
|
||||
export function clearSessionToken(): void {
|
||||
sessionStorage.removeItem("admin-token");
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getSessionToken() instead.
|
||||
* Kept for backward compatibility during migration.
|
||||
*/
|
||||
export function getAdminPassword(): string | null {
|
||||
return localStorage.getItem("admin-password");
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use setSessionToken() instead.
|
||||
*/
|
||||
export function setAdminPassword(password: string): void {
|
||||
localStorage.setItem("admin-password", password);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use clearSessionToken() instead.
|
||||
*/
|
||||
export function clearAdminPassword(): void {
|
||||
localStorage.removeItem("admin-password");
|
||||
function getAdminPassword(): string | null {
|
||||
if (_cachedPassword === null) {
|
||||
_cachedPassword = localStorage.getItem("admin-password");
|
||||
}
|
||||
return _cachedPassword;
|
||||
}
|
||||
|
||||
function buildSearchParams(
|
||||
@@ -116,32 +76,16 @@ export async function request<T>(
|
||||
init?: RequestInit,
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
const token = getSessionToken();
|
||||
const password = getAdminPassword();
|
||||
const url = path.startsWith("http") ? path : `${BE_API_URL}${path}`;
|
||||
const signal = AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
logger.debug("Request", { method: init?.method ?? "GET", url });
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Only set Content-Type for non-FormData bodies
|
||||
// FormData sets its own Content-Type (multipart/form-data with boundary)
|
||||
if (!(init?.body instanceof FormData)) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
// Prefer Bearer token (new auth method)
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
} else {
|
||||
// Fallback: X-Admin-Password (for backward compatibility)
|
||||
const password = getAdminPassword();
|
||||
if (password) {
|
||||
headers["X-Admin-Password"] = password;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(password ? { "X-Admin-Password": password } : {}),
|
||||
},
|
||||
signal,
|
||||
...init,
|
||||
});
|
||||
@@ -157,13 +101,6 @@ export async function request<T>(
|
||||
// ignore parse errors
|
||||
}
|
||||
logger.error("Request failed", { url, status: res.status, code, message });
|
||||
|
||||
// Auto-logout on 401: token expired / invalidated
|
||||
if (res.status === 401) {
|
||||
clearSessionToken();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
throw new ApiError(code, message, res.status);
|
||||
}
|
||||
|
||||
@@ -180,7 +117,6 @@ export function getAPIURL(): string {
|
||||
|
||||
export type {
|
||||
ActiveSpeaker,
|
||||
AdminSettings,
|
||||
AppConfig,
|
||||
Channel,
|
||||
ChatResponse,
|
||||
@@ -335,35 +271,13 @@ export function deleteRecording(id: string): Promise<void> {
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function login(password: string): Promise<{ ok: boolean; token?: string }> {
|
||||
return request<{ ok: boolean; token?: string }>("/api/auth/login", {
|
||||
export function login(password: string): Promise<{ ok: boolean }> {
|
||||
return request<{ ok: boolean }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side logout: increments token version, invalidating all sessions.
|
||||
* Call this before clearing local state so the token is properly revoked.
|
||||
*/
|
||||
export function logout(): Promise<{ ok: boolean }> {
|
||||
return request<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
|
||||
}
|
||||
// ─── Admin Settings ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getAdminSettings(): Promise<AdminSettings> {
|
||||
return request<AdminSettings>("/api/admin/settings");
|
||||
}
|
||||
|
||||
export function updateAdminSettings(
|
||||
patch: Partial<{ dashboardIsPublic: boolean }>,
|
||||
): Promise<AdminSettings> {
|
||||
return request<AdminSettings>("/api/admin/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Dashboard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getDashboardStats(): Promise<DashboardStats> {
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
Command,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
Moon,
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
Volume2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
} from "react";
|
||||
import type { MessageRecord } from "../api/client";
|
||||
import { request } from "../api/client";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Input } from "./index";
|
||||
|
||||
/* ─── Modal backdrop variants ──────────────────────────────────────────── */
|
||||
|
||||
const backdropVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1 },
|
||||
};
|
||||
|
||||
const modalVariants = {
|
||||
hidden: { opacity: 0, scale: 0.96, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: { type: "spring" as const, stiffness: 350, damping: 28 },
|
||||
},
|
||||
exit: { opacity: 0, scale: 0.96, y: 10, transition: { duration: 0.15 } },
|
||||
} as const;
|
||||
|
||||
/* ─── Types ────────────────────────────────────────────────────────────── */
|
||||
|
||||
type ModalMode = "search" | "shortcuts" | null;
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
mode: ModalMode;
|
||||
onClose: () => void;
|
||||
onNavigate: (tab: string) => void;
|
||||
onToggleTheme: () => void;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ["Ctrl", "K"], desc: "Open search" },
|
||||
{ keys: ["?"], desc: "Show keyboard shortcuts" },
|
||||
{ keys: ["Esc"], desc: "Close modal / cancel" },
|
||||
{ keys: ["Ctrl", "1"], desc: "Messages & Moderation" },
|
||||
{ keys: ["Ctrl", "2"], desc: "Voice & Media" },
|
||||
{ keys: ["Ctrl", "3"], desc: "Dashboard" },
|
||||
{ keys: ["Ctrl", "4"], desc: "Settings" },
|
||||
{ keys: ["Space"], desc: "Push-to-talk (when in voice)" },
|
||||
{ keys: ["T"], desc: "Toggle theme" },
|
||||
];
|
||||
|
||||
/* ─── Help panel ───────────────────────────────────────────────────────── */
|
||||
|
||||
function ShortcutsPanel() {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<HelpCircle className="h-4 w-4 text-primary" />
|
||||
Keyboard Shortcuts
|
||||
</h3>
|
||||
<div className="grid gap-1.5">
|
||||
{shortcuts.map((s) => (
|
||||
<div
|
||||
key={s.keys.join("+")}
|
||||
className="flex items-center justify-between rounded-lg px-2 py-1.5 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">{s.desc}</span>
|
||||
<kbd className="flex items-center gap-1">
|
||||
{s.keys.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="inline-flex h-6 min-w-[24px] items-center justify-center rounded-md border border-border bg-background px-1.5 text-xs font-mono text-foreground shadow-sm"
|
||||
>
|
||||
{k === "Ctrl" ? <Command className="h-3 w-3" /> : k}
|
||||
</span>
|
||||
))}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Quick actions ────────────────────────────────────────────────────── */
|
||||
|
||||
const quickActions = [
|
||||
{ id: "messages", label: "Go to Messages", icon: MessageSquare },
|
||||
{ id: "live", label: "Go to Voice & Media", icon: Volume2 },
|
||||
{ id: "dashboard", label: "Go to Dashboard", icon: FileText },
|
||||
{ id: "settings", label: "Open Settings", icon: Settings },
|
||||
{ id: "theme", label: "Toggle theme", icon: Sun },
|
||||
];
|
||||
|
||||
/* ─── Main component ───────────────────────────────────────────────────── */
|
||||
|
||||
export function CommandPalette({
|
||||
isOpen,
|
||||
mode,
|
||||
onClose,
|
||||
onNavigate,
|
||||
onToggleTheme,
|
||||
isDark,
|
||||
}: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
// Focus input when search mode opens
|
||||
useEffect(() => {
|
||||
if (isOpen && mode === "search") {
|
||||
// Small delay for the animation to settle
|
||||
const focusTimer = setTimeout(() => inputRef.current?.focus(), 50);
|
||||
return () => clearTimeout(focusTimer);
|
||||
}
|
||||
}, [isOpen, mode]);
|
||||
|
||||
// Reset state when closing
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setQuery("");
|
||||
setSearchResults([]);
|
||||
setActiveIndex(0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSearch = useCallback(async (q: string) => {
|
||||
setQuery(q);
|
||||
if (!q.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ q, limit: "10" });
|
||||
const data = await request<{ results: MessageRecord[] }>(
|
||||
`/api/analysis/search?${params}`,
|
||||
);
|
||||
setSearchResults(data.results || []);
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const executeAction = useCallback(
|
||||
(action: string) => {
|
||||
if (action === "theme") {
|
||||
onToggleTheme();
|
||||
} else if (action === "settings") {
|
||||
onNavigate("settings");
|
||||
} else if (action === "messages") {
|
||||
onNavigate("messages");
|
||||
} else if (action === "live") {
|
||||
onNavigate("live");
|
||||
} else if (action === "dashboard") {
|
||||
onNavigate("dashboard");
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[onNavigate, onToggleTheme, onClose],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i < searchResults.length - 1 ? i + 1 : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i > 0 ? i - 1 : searchResults.length - 1));
|
||||
} else if (e.key === "Enter" && searchResults.length > 0) {
|
||||
onClose();
|
||||
} else if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[searchResults.length, onClose],
|
||||
);
|
||||
|
||||
// Global keyboard listeners for search and help
|
||||
useEffect(() => {
|
||||
const handler = (e: globalThis.KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
)
|
||||
return;
|
||||
|
||||
// Ctrl+K — open search
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
// Don't toggle if already open — just close
|
||||
if (isOpen) {
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ? — show shortcuts (only when no modal is open)
|
||||
if (e.key === "?" && !isOpen) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape — close any modal
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Ctrl+1-4 — tab navigation
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const tabMap: Record<string, string> = {
|
||||
"1": "messages",
|
||||
"2": "live",
|
||||
"3": "dashboard",
|
||||
"4": "settings",
|
||||
};
|
||||
const tab = tabMap[e.key];
|
||||
if (tab) {
|
||||
e.preventDefault();
|
||||
onNavigate(tab);
|
||||
}
|
||||
}
|
||||
|
||||
// T — toggle theme (when no input focused)
|
||||
if (e.key === "t" && !e.ctrlKey && !e.metaKey && !isOpen) {
|
||||
e.preventDefault();
|
||||
onToggleTheme();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [isOpen, onClose, onNavigate, onToggleTheme]);
|
||||
|
||||
const showSearch = mode === "search";
|
||||
const showShortcuts = mode === "shortcuts";
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[9999] flex items-start justify-center pt-[12vh]"
|
||||
variants={backdropVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
className="relative w-full max-w-xl overflow-hidden rounded-2xl border border-border/50 bg-card shadow-2xl"
|
||||
variants={modalVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
>
|
||||
{/* Search header */}
|
||||
{showSearch && (
|
||||
<div className="flex items-center gap-3 border-b border-border/50 px-4 py-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search messages across all channels..."
|
||||
className="flex-1 border-0 bg-transparent p-0 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none"
|
||||
/>
|
||||
{isSearching && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
{!isSearching && query && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setSearchResults([]);
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<kbd className="shrink-0 hidden sm:inline-flex h-5 items-center rounded-md border border-border bg-background px-1.5 text-[10px] font-mono text-muted-foreground">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shortcuts header */}
|
||||
{showShortcuts && (
|
||||
<div className="flex items-center justify-between border-b border-border/50 px-4 py-3">
|
||||
<span className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<HelpCircle className="h-4 w-4 text-primary" />
|
||||
Keyboard Shortcuts
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search results */}
|
||||
{showSearch && (
|
||||
<div className="max-h-[320px] overflow-y-auto">
|
||||
{/* Quick actions */}
|
||||
{!query && (
|
||||
<div className="p-2">
|
||||
<p className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Quick actions
|
||||
</p>
|
||||
{quickActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
const isThemeAction = action.id === "theme";
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => executeAction(action.id)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-sm text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
isThemeAction && isDark
|
||||
? "text-amber-400"
|
||||
: isThemeAction
|
||||
? "text-indigo-400"
|
||||
: "text-primary",
|
||||
)}
|
||||
/>
|
||||
<span>{action.label}</span>
|
||||
{isThemeAction && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{isDark ? "→ Light" : "→ Dark"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{query && (
|
||||
<div className="p-2">
|
||||
{searchResults.length > 0 ? (
|
||||
<>
|
||||
<p className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Messages ({searchResults.length})
|
||||
</p>
|
||||
{searchResults.map((msg, i) => (
|
||||
<button
|
||||
key={msg.id}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-lg px-2 py-2 text-left transition-colors",
|
||||
i === activeIndex
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-foreground">
|
||||
{msg.content || "(no content)"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{msg.username || msg.user_id || "unknown"}
|
||||
{msg.ai_status === "flagged" && (
|
||||
<span className="ml-2 text-destructive">
|
||||
⚑ flagged
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||
{isSearching
|
||||
? "Searching..."
|
||||
: "No messages found matching your query."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search footer hint */}
|
||||
{!query && (
|
||||
<div className="border-t border-border/50 px-4 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Type to search messages — results are fetched from the
|
||||
server
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shortcuts content */}
|
||||
{showShortcuts && <ShortcutsPanel />}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { LayoutDashboard, MessageSquare, Radio, Settings } from "lucide-react";
|
||||
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
|
||||
import type { DashboardTab } from "../../entities/ui/types.js";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
@@ -7,7 +6,6 @@ const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
|
||||
{ id: "messages", label: "Messages", Icon: MessageSquare },
|
||||
{ id: "live", label: "Voice & Media", Icon: Radio },
|
||||
{ id: "dashboard", label: "Dashboard", Icon: LayoutDashboard },
|
||||
{ id: "settings" as const, label: "Admin", Icon: Settings },
|
||||
];
|
||||
|
||||
interface MobileTabBarProps {
|
||||
@@ -20,7 +18,7 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
||||
<nav
|
||||
aria-label="Main navigation"
|
||||
role="tablist"
|
||||
className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-card shadow-lg shadow-black/5 md:hidden"
|
||||
className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-card shadow-lg md:hidden"
|
||||
>
|
||||
{tabs.map(({ id, label, Icon }) => (
|
||||
<button
|
||||
@@ -31,26 +29,16 @@ export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
||||
type="button"
|
||||
onClick={() => onTabChange(id)}
|
||||
className={cn(
|
||||
"relative flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors",
|
||||
activeTab === id
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
"flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors",
|
||||
activeTab === id ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{activeTab === id && (
|
||||
<motion.div
|
||||
layoutId="tab-indicator"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
className="absolute -top-px left-1/4 right-1/4 h-0.5 rounded-full bg-primary"
|
||||
/>
|
||||
)}
|
||||
<Icon className={cn("h-5 w-5", activeTab === id && "drop-shadow-sm")} />
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="text-[10px]">{label}</span>
|
||||
{activeTab === id && (
|
||||
<motion.div
|
||||
layoutId="tab-dot"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
className="h-1 w-1 rounded-full bg-primary mt-0.5"
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-0.5 w-6 rounded-full bg-primary mx-auto mt-0.5"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
className?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Boundary component — catches JavaScript errors in its child tree,
|
||||
* logs them, and displays a fallback UI instead of crashing the whole app.
|
||||
*/
|
||||
export class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("[ErrorBoundary] Caught error:", error.message, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-xl border border-destructive/30 bg-destructive/5 p-8 text-center",
|
||||
this.props.className,
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<AlertTriangle className="mb-3 h-8 w-8 text-destructive" />
|
||||
<h3 className="mb-1 font-semibold text-foreground">
|
||||
{this.props.message || "Something went wrong"}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{this.state.error?.message || "An unexpected error occurred."}
|
||||
</p>
|
||||
<Button
|
||||
onClick={this.handleRetry}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MediaState } from "../../entities/media/types.js";
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
import { getSessionToken } from "../api/client.js";
|
||||
import type { ActiveSpeakerData } from "./events.js";
|
||||
|
||||
const logger = createLogger("socket");
|
||||
@@ -80,7 +79,6 @@ let _wsInstance: WebSocket | null = null;
|
||||
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _closed = false;
|
||||
let _reconnectAttempts = 0;
|
||||
let _heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
const _listeners = new Set<WsHandlers>();
|
||||
const _statusCallbacks = new Set<(s: WsStatus) => void>();
|
||||
|
||||
@@ -92,13 +90,7 @@ function doConnect(): WebSocket {
|
||||
const BE_WS_URL =
|
||||
import.meta.env.VITE_BE_WS_URL ||
|
||||
`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
|
||||
let url = BE_WS_URL.endsWith("/ws") ? BE_WS_URL : `${BE_WS_URL}/ws`;
|
||||
|
||||
// ⚠️ Token is NOT appended to the URL — sending it in the query string
|
||||
// would leak it into Nginx/Traefik access logs, browser history, and
|
||||
// Referer headers. Instead, the frontend sends an auth message as the
|
||||
// first WebSocket frame after connection.
|
||||
|
||||
const url = BE_WS_URL.endsWith("/ws") ? BE_WS_URL : `${BE_WS_URL}/ws`;
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
dispatchStatus("connecting");
|
||||
@@ -108,32 +100,12 @@ function doConnect(): WebSocket {
|
||||
_reconnectAttempts = 0;
|
||||
dispatchStatus("connected");
|
||||
logger.info("Connected");
|
||||
|
||||
// Send auth message — token is sent as the first WebSocket frame,
|
||||
// NOT in the URL query string, to avoid exposure in access logs.
|
||||
const sessionToken = getSessionToken();
|
||||
if (sessionToken) {
|
||||
ws.send(JSON.stringify({ type: "auth", token: sessionToken }));
|
||||
}
|
||||
|
||||
// Heartbeat — send a ping every 25s to keep the connection alive
|
||||
if (_heartbeatInterval) clearInterval(_heartbeatInterval);
|
||||
_heartbeatInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 25_000);
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
dispatchStatus("error");
|
||||
logger.error("WebSocket error");
|
||||
});
|
||||
ws.addEventListener("close", (event) => {
|
||||
// Clean up heartbeat on disconnect
|
||||
if (_heartbeatInterval) {
|
||||
clearInterval(_heartbeatInterval);
|
||||
_heartbeatInterval = null;
|
||||
}
|
||||
dispatchStatus("disconnected");
|
||||
logger.info("Disconnected", { code: event.code, reason: event.reason });
|
||||
if (!_closed && _listeners.size > 0) {
|
||||
@@ -366,20 +338,15 @@ export function useDashboardSocket(handlers: WsHandlers) {
|
||||
ensureConnected();
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
return () => {
|
||||
_listeners.delete(wrapper);
|
||||
_statusCallbacks.delete(setStatus);
|
||||
// Defer the close so that React Strict Mode double-invoke in dev
|
||||
// doesn't kill the socket that the remount immediately re-creates.
|
||||
const delayClose = setTimeout(() => {
|
||||
if (_listeners.size === 0) {
|
||||
_closed = true;
|
||||
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
||||
_wsInstance?.close();
|
||||
_wsInstance = null;
|
||||
}
|
||||
}, 100);
|
||||
delayClose.unref?.();
|
||||
if (_listeners.size === 0) {
|
||||
_closed = true;
|
||||
if (_reconnectTimer) clearTimeout(_reconnectTimer);
|
||||
_wsInstance?.close();
|
||||
_wsInstance = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
@layer base {
|
||||
/* ── Light theme (default) ───────────────────────────────────────────── */
|
||||
:root {
|
||||
--background: 1 0 0;
|
||||
--foreground: 0.141 0.005 285.823;
|
||||
@@ -25,49 +25,10 @@
|
||||
--primary-glow: 0.623 0.214 259.815 / 0.15;
|
||||
--accent-glow: 0.552 0.016 285.938 / 0.15;
|
||||
--card-shadow: 0.92 0.004 286.32 / 0.3;
|
||||
--particle-primary: 0.623 0.214 259.815 / 0.1;
|
||||
--particle-secondary: 0.552 0.016 285.938 / 0.1;
|
||||
--brand-gradient-from: var(--primary);
|
||||
--brand-gradient-to: 0.623 0.2 200;
|
||||
--scrollbar-track: 0.967 0.001 286.375;
|
||||
--scrollbar-thumb: 0.92 0.004 286.32;
|
||||
}
|
||||
|
||||
/* ── Dark theme ──────────────────────────────────────────────────────── */
|
||||
[data-theme="dark"] {
|
||||
--background: 0.147 0.004 285.823;
|
||||
--foreground: 0.92 0.004 286.32;
|
||||
--card: 0.162 0.008 286.034;
|
||||
--card-foreground: 0.92 0.004 286.32;
|
||||
--primary: 0.623 0.214 259.815;
|
||||
--primary-soft: 0.3 0.04 259.815;
|
||||
--primary-foreground: 0.97 0.014 254.604;
|
||||
--secondary: 0.2 0.008 286.034;
|
||||
--secondary-foreground: 0.85 0.008 286.034;
|
||||
--muted: 0.2 0.008 286.034;
|
||||
--muted-foreground: 0.6 0.016 285.938;
|
||||
--accent: 0.2 0.008 286.034;
|
||||
--accent-foreground: 0.85 0.008 286.034;
|
||||
--destructive: 0.577 0.245 27.325;
|
||||
--destructive-foreground: 0.97 0.014 254.604;
|
||||
--border: 0.25 0.008 286.034;
|
||||
--input: 0.25 0.008 286.034;
|
||||
--ring: 0.623 0.214 259.815;
|
||||
--primary-glow: 0.623 0.214 259.815 / 0.08;
|
||||
--accent-glow: 0.552 0.016 285.938 / 0.08;
|
||||
--card-shadow: 0 0 0 / 0.5;
|
||||
--particle-primary: 0.623 0.214 259.815 / 0.06;
|
||||
--particle-secondary: 0.552 0.016 285.938 / 0.06;
|
||||
--brand-gradient-from: var(--primary);
|
||||
--brand-gradient-to: 0.7 0.2 220;
|
||||
--scrollbar-track: 0.147 0.004 285.823;
|
||||
--scrollbar-thumb: 0.3 0.008 286.034;
|
||||
}
|
||||
|
||||
/* ── Base styles ─────────────────────────────────────────────────────── */
|
||||
[data-theme] {
|
||||
* {
|
||||
border-color: oklch(var(--border));
|
||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -75,7 +36,7 @@
|
||||
color: oklch(var(--foreground));
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-family: Outfit, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: Poppins, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -83,47 +44,22 @@
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ── Scrollbar styling ───────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: oklch(var(--scrollbar-track));
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(var(--scrollbar-thumb));
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* ── Focus ring consistency ──────────────────────────────────────────── */
|
||||
:focus-visible {
|
||||
outline: 2px solid oklch(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.glass-card {
|
||||
@apply backdrop-blur-sm rounded-xl;
|
||||
background-color: oklch(var(--card) / 0.7);
|
||||
border: 1px solid oklch(var(--border));
|
||||
@apply bg-white/70 backdrop-blur-sm border border-[oklch(0.92_0.004_286.32)] rounded-xl;
|
||||
}
|
||||
|
||||
.grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(oklch(var(--border) / 0.3) 1px, transparent 1px),
|
||||
linear-gradient(90deg, oklch(var(--border) / 0.3) 1px, transparent 1px);
|
||||
linear-gradient(oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px),
|
||||
linear-gradient(90deg, oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
@apply bg-clip-text text-transparent;
|
||||
background-image: linear-gradient(to right, oklch(var(--brand-gradient-from)), oklch(var(--brand-gradient-to)));
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
@@ -163,32 +99,23 @@
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 0.8; transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.animate-bar-pulse {
|
||||
animation: bar-pulse 0.4s ease-in-out infinite;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
oklch(var(--border) / 0.5) 0%,
|
||||
oklch(var(--muted)) 40%,
|
||||
oklch(var(--border) / 0.5) 80%,
|
||||
oklch(var(--border) / 0.7) 100%
|
||||
oklch(0.92 0.004 286.32 / 0.5) 0%,
|
||||
oklch(0.967 0.001 286.375) 40%,
|
||||
oklch(0.92 0.004 286.32 / 0.5) 80%,
|
||||
oklch(0.92 0.004 286.32 / 0.7) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-glow-pulse {
|
||||
animation: glow-pulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Reduced motion ──────────────────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import type { MessageRecord } from "../entities/message/types.js";
|
||||
import type { DashboardTab } from "../entities/ui/types.js";
|
||||
import type { VoiceStatus } from "../entities/voice/types.js";
|
||||
import type { ThemeMode } from "../hooks/useTheme";
|
||||
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
|
||||
import type { WsStatus } from "../shared/ws/socket";
|
||||
import { Header } from "./Header";
|
||||
@@ -14,30 +13,22 @@ interface DashboardLayoutProps {
|
||||
activeTab: DashboardTab;
|
||||
wsStatus: WsStatus;
|
||||
voiceStatus: VoiceStatus;
|
||||
themeMode: ThemeMode;
|
||||
isDark: boolean;
|
||||
onTabChange: (tab: DashboardTab) => void;
|
||||
onThemeToggle: () => void;
|
||||
children: ReactNode;
|
||||
recentMessages?: MessageRecord[];
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
notificationCount?: number;
|
||||
}
|
||||
|
||||
export function DashboardLayout({
|
||||
activeTab,
|
||||
wsStatus,
|
||||
voiceStatus,
|
||||
themeMode,
|
||||
isDark,
|
||||
onTabChange,
|
||||
onThemeToggle,
|
||||
children,
|
||||
recentMessages = [],
|
||||
guildId,
|
||||
channelId,
|
||||
notificationCount = 0,
|
||||
}: DashboardLayoutProps) {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-background text-foreground">
|
||||
@@ -55,29 +46,23 @@ export function DashboardLayout({
|
||||
recentMessages={recentMessages}
|
||||
guildId={guildId}
|
||||
channelId={channelId}
|
||||
notificationCount={notificationCount}
|
||||
/>
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<Header
|
||||
activeTab={activeTab}
|
||||
wsStatus={wsStatus}
|
||||
voiceStatus={voiceStatus}
|
||||
themeMode={themeMode}
|
||||
isDark={isDark}
|
||||
onThemeToggle={onThemeToggle}
|
||||
/>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.main
|
||||
key={activeTab}
|
||||
variants={fadeSlideUp}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="flex-1 overflow-auto p-4 md:p-6 lg:p-8 pb-16 md:pb-0"
|
||||
>
|
||||
{children}
|
||||
</motion.main>
|
||||
</AnimatePresence>
|
||||
<motion.main
|
||||
key={activeTab}
|
||||
variants={fadeSlideUp}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="flex-1 overflow-auto p-4 md:p-6 lg:p-8"
|
||||
>
|
||||
{children}
|
||||
</motion.main>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Moon, Sun, Wifi, WifiOff } from "lucide-react";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import type { DashboardTab } from "../entities/ui/types.js";
|
||||
import type { VoiceStatus } from "../entities/voice/types.js";
|
||||
import type { ThemeMode } from "../hooks/useTheme";
|
||||
import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
|
||||
import { cn } from "../shared/lib/utils";
|
||||
import { Badge } from "../shared/ui";
|
||||
@@ -12,23 +11,18 @@ const titles: Record<DashboardTab, string> = {
|
||||
messages: "Messages & Moderation",
|
||||
live: "Voice & Media",
|
||||
dashboard: "Dashboard",
|
||||
settings: "Admin Settings",
|
||||
};
|
||||
|
||||
const subtitles: Record<DashboardTab, string> = {
|
||||
messages: "Capture, analyse, and moderate Discord messages.",
|
||||
live: "Join voice channels, play media, stream audio, and browse recordings.",
|
||||
dashboard: "Server statistics, user profiles, and AI moderation overview.",
|
||||
settings: "Manage dashboard visibility, runtime configuration, and authentication.",
|
||||
};
|
||||
|
||||
interface HeaderProps {
|
||||
activeTab: DashboardTab;
|
||||
wsStatus: WsStatus;
|
||||
voiceStatus: VoiceStatus;
|
||||
themeMode: ThemeMode;
|
||||
isDark: boolean;
|
||||
onThemeToggle: () => void;
|
||||
}
|
||||
|
||||
/** Dot indicator colour for WS badge */
|
||||
@@ -69,7 +63,7 @@ function VoiceIndicator({ voiceStatus }: { voiceStatus: VoiceStatus }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({ activeTab, wsStatus, voiceStatus, themeMode, isDark, onThemeToggle }: HeaderProps) {
|
||||
export function Header({ activeTab, wsStatus, voiceStatus }: HeaderProps) {
|
||||
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 flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
@@ -98,22 +92,8 @@ export function Header({ activeTab, wsStatus, voiceStatus, themeMode, isDark, on
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Right: status badges + theme toggle */}
|
||||
{/* Right: status badges */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={onThemeToggle}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border bg-card/50 px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
title={`Switch to ${isDark ? "light" : "dark"} mode`}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-3.5 w-3.5 text-amber-400" />
|
||||
) : (
|
||||
<Moon className="h-3.5 w-3.5 text-indigo-400" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{isDark ? "Light" : "Dark"}</span>
|
||||
</button>
|
||||
|
||||
{/* WS Badge */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Bell,
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
Radio,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
|
||||
import type { MessageRecord } from "../entities/message/types.js";
|
||||
import type { DashboardTab } from "../entities/ui/types.js";
|
||||
import { useMascotChat } from "../shared/hooks/useMascotChat";
|
||||
@@ -13,16 +7,12 @@ import { cn } from "../shared/lib/utils";
|
||||
import { MascotChatbot } from "./mascot/MascotChatbot";
|
||||
import { MascotImage } from "./mascot/MascotImage";
|
||||
|
||||
const navItems: Array<{
|
||||
id: DashboardTab;
|
||||
label: string;
|
||||
icon: typeof Radio;
|
||||
}> = [
|
||||
{ id: "messages", label: "Messages & Moderation", icon: MessageSquare },
|
||||
{ id: "live", label: "Voice & Media", icon: Radio },
|
||||
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ id: "settings" as const, label: "Admin", icon: Settings },
|
||||
];
|
||||
const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Radio }> =
|
||||
[
|
||||
{ id: "messages", label: "Messages & Moderation", icon: MessageSquare },
|
||||
{ id: "live", label: "Voice & Media", icon: Radio },
|
||||
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: DashboardTab;
|
||||
@@ -31,7 +21,6 @@ interface SidebarProps {
|
||||
recentMessages?: MessageRecord[];
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
notificationCount?: number;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -41,7 +30,6 @@ export function Sidebar({
|
||||
recentMessages = [],
|
||||
guildId,
|
||||
channelId,
|
||||
notificationCount = 0,
|
||||
}: SidebarProps) {
|
||||
const mascotChat = useMascotChat({
|
||||
messageCount: recentMessages.length,
|
||||
@@ -58,7 +46,7 @@ export function Sidebar({
|
||||
<motion.nav
|
||||
className={cn(
|
||||
"relative hidden shrink-0 flex-col overflow-visible border-r border-border/50 bg-background/70 backdrop-blur-sm transition-all duration-300 md:flex",
|
||||
collapsed ? "w-16" : "w-56 lg:w-64",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
layout
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
@@ -74,10 +62,6 @@ export function Sidebar({
|
||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg"
|
||||
alt="IMPHNEN"
|
||||
className="h-8 w-8 rounded-xl"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Mascot image — only when expanded */}
|
||||
@@ -86,10 +70,6 @@ export function Sidebar({
|
||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
|
||||
alt="Mascot"
|
||||
className="mt-4 h-auto w-[140px] object-contain drop-shadow-md"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -115,21 +95,6 @@ export function Sidebar({
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
{!collapsed && item.id === "messages" &&
|
||||
notificationCount !== undefined &&
|
||||
notificationCount > 0 && (
|
||||
<span className="ml-auto flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
|
||||
{notificationCount > 99 ? "99+" : notificationCount}
|
||||
</span>
|
||||
)}
|
||||
{/* Collapsed badge — top-right dot */}
|
||||
{collapsed && item.id === "messages" &&
|
||||
notificationCount !== undefined &&
|
||||
notificationCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-destructive text-[7px] font-bold text-destructive-foreground">
|
||||
{notificationCount > 9 ? "N" : notificationCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -153,7 +118,7 @@ export function Sidebar({
|
||||
onClose={() => mascotChat.setIsOpen(false)}
|
||||
onSendMessage={mascotChat.handleSendMessage}
|
||||
mascotName="IMPHNEN Mascot"
|
||||
className="fixed bottom-[170px] left-[80px] z-[9999] md:bottom-4 md:left-4 md:right-auto"
|
||||
className="fixed bottom-[170px] left-[80px] z-[9999]"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -117,14 +117,14 @@ export function MascotChatbot({
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-primary to-primary/80 text-primary-foreground p-4 flex items-center justify-between">
|
||||
<div className="bg-gradient-to-r from-primary to-primary/80 text-white p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-foreground/20 flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">{mascotName}</h3>
|
||||
<p className="text-xs text-primary-foreground/80">
|
||||
<p className="text-xs text-white/80">
|
||||
{loading ? "Mengetik..." : "Online"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -134,7 +134,7 @@ export function MascotChatbot({
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
className="p-1.5 hover:bg-primary-foreground/20 rounded-lg transition-colors"
|
||||
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
|
||||
title={isMinimized ? "Maximize" : "Minimize"}
|
||||
>
|
||||
{isMinimized ? (
|
||||
@@ -149,7 +149,7 @@ export function MascotChatbot({
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
}}
|
||||
className="p-1.5 hover:bg-primary-foreground/20 rounded-lg transition-colors"
|
||||
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -176,10 +176,6 @@ export function MascotChatbot({
|
||||
src={mascotAvatar}
|
||||
alt={mascotName}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
@@ -204,10 +200,6 @@ export function MascotChatbot({
|
||||
src={mascotAvatar}
|
||||
alt={mascotName}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div className="bg-muted rounded-xl rounded-bl-none px-3 py-2">
|
||||
<div className="flex gap-1">
|
||||
|
||||
@@ -38,7 +38,6 @@ export function MascotImage({
|
||||
const sizeClass = sizeMap[size];
|
||||
const chatSizeClass = chatSizeMap[size];
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (showChat && chatMessage) {
|
||||
@@ -52,20 +51,13 @@ export function MascotImage({
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
{imgError ? (
|
||||
<div className={`flex items-center justify-center ${sizeClass} bg-muted/30 rounded-xl`}>
|
||||
<MessageCircle className="h-6 w-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
) : (
|
||||
<motion.img
|
||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
|
||||
alt="Mascot"
|
||||
className={`object-contain drop-shadow-md ${sizeClass} ${className}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
<motion.img
|
||||
src="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/apps/dimentorin/public/image/mascot-1.png"
|
||||
alt="Mascot"
|
||||
className={`object-contain drop-shadow-md ${sizeClass} ${className}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
/>
|
||||
|
||||
{/* Floating Chat Bubble */}
|
||||
{isVisible && chatMessage && (
|
||||
@@ -80,7 +72,7 @@ export function MascotImage({
|
||||
{/* Chat bubble */}
|
||||
<div className="bg-primary/90 text-primary-foreground rounded-xl px-4 py-2.5 shadow-lg backdrop-blur-sm border border-primary/30">
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageCircle className="h-4 w-4 shrink-0 mt-0.5 text-primary-foreground/80" />
|
||||
<MessageCircle className="h-4 w-4 shrink-0 mt-0.5 text-white/80" />
|
||||
<p className="text-xs leading-relaxed font-medium line-clamp-3">
|
||||
{chatMessage}
|
||||
</p>
|
||||
|
||||
@@ -20,16 +20,11 @@ export function ParticleBackground() {
|
||||
style={{ zIndex: -1 }}
|
||||
>
|
||||
{/* Top-right glow orb */}
|
||||
<div className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full blur-3xl animate-glow-pulse"
|
||||
style={{ backgroundColor: "oklch(var(--particle-primary, 0.623 0.214 259.815 / 0.1))" }}
|
||||
/>
|
||||
<div className="absolute -top-40 -right-40 h-[500px] w-[500px] rounded-full bg-primary/10 blur-3xl animate-glow-pulse" />
|
||||
{/* Bottom-left glow orb */}
|
||||
<div
|
||||
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full blur-3xl animate-glow-pulse"
|
||||
style={{
|
||||
backgroundColor: "oklch(var(--particle-secondary, 0.552 0.016 285.938 / 0.1))",
|
||||
animationDelay: "1.5s",
|
||||
}}
|
||||
className="absolute -bottom-40 -left-40 h-[400px] w-[400px] rounded-full bg-blue-400/10 blur-3xl animate-glow-pulse"
|
||||
style={{ animationDelay: "1.5s" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
@@ -6,15 +6,11 @@
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["astro/client"],
|
||||
"types": ["vite/client"],
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["src/*"]
|
||||
}
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*", "astro.config.mjs"]
|
||||
"include": ["src/**/*", "vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
checks: {
|
||||
pluginTimings: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
middlewareMode: false,
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
allowedHosts: [
|
||||
"imphnen.asepharyana.my.id",
|
||||
"imphnen.asepharyana.tech",
|
||||
"imphnen.asepharyana.web.id",
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user