feat: migrate frontend to Astro + expand AI moderation + backend admin/runtime config

Frontend:
- migrate from Vite to Astro (astro.config.mjs, pages/, layouts/)
- add admin panel, settings page, command palette, error boundary
- refactor App.tsx, MascotChatbot, Sidebar, Header, DashboardLayout
- update API client, WebSocket, auth, dashboard features

Backend:
- add admin module and config routes
- refactor middlewares, Redis connection, WebSocket server/bridge
- add runtime config loader

Discord Gateway:
- refactor AI moderation: circuit breaker, concurrency limiter, fallback processor
- add media analysis client, Seaxng search, user profile learner
- add new drizzle migration

Shared:
- extend database schema, add new config fields
This commit is contained in:
asepharyana
2026-07-02 00:02:41 +07:00
parent d5c22a3959
commit d59b59a7a7
91 changed files with 11165 additions and 674 deletions
@@ -0,0 +1,295 @@
import { motion } from "framer-motion";
import {
Eye,
EyeOff,
Globe,
Lock,
RefreshCw,
Save,
Settings,
Shield,
} from "lucide-react";
import { useEffect, useState } from "react";
import type { AdminSettings } from "../../shared/api/client";
import {
getAdminSettings,
updateAdminSettings,
clearSessionToken,
logout,
} from "../../shared/api/client";
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../shared/ui";
export function AdminPanel() {
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleLogout = async () => {
// Call server-side logout to increment token version
try {
await logout();
} catch {
// Even if server call fails, still clear local state for security
}
// Clear local token and legacy password
clearSessionToken();
localStorage.removeItem("admin-password");
window.location.reload();
};
const fetchSettings = async () => {
setLoading(true);
setError(null);
try {
const data = await getAdminSettings();
setSettings(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load settings");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSettings();
}, []);
const handleTogglePublic = async () => {
if (!settings) return;
const newValue = !settings.dashboardIsPublic;
setSaving(true);
setError(null);
setSuccess(null);
try {
const updated = await updateAdminSettings({
dashboardIsPublic: newValue,
});
setSettings(updated);
setSuccess(
newValue
? "Dashboard is now public — accessible without password."
: "Dashboard is now private — admin password required.",
);
setTimeout(() => setSuccess(null), 4000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update settings");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-primary">Admin Settings</CardTitle>
<CardDescription>Loading settings...</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
</CardContent>
</Card>
);
}
if (error && !settings) {
return (
<Card>
<CardHeader>
<CardTitle className="text-primary">Admin Settings</CardTitle>
<CardDescription className="text-destructive">{error}</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={fetchSettings} variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" /> Retry
</Button>
</CardContent>
</Card>
);
}
const isPublic = settings?.dashboardIsPublic ?? false;
return (
<motion.div variants={cardStagger} initial="initial" animate="animate">
<motion.div variants={cardItem}>
<Card className="border-primary/20">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-primary">
<Settings className="h-5 w-5" />
Admin Settings
</CardTitle>
<CardDescription>
Manage dashboard visibility and runtime configuration.
</CardDescription>
</div>
<Button
onClick={fetchSettings}
variant="ghost"
size="sm"
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* ── Success / Error messages ── */}
{success && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-600 dark:text-emerald-400">
{success}
</div>
)}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{/* ── Dashboard Visibility ── */}
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
{isPublic ? (
<Globe className="h-4 w-4 text-emerald-500" />
) : (
<Lock className="h-4 w-4 text-amber-500" />
)}
<h3 className="font-semibold">
Dashboard Visibility:{" "}
<span
className={
isPublic ? "text-emerald-500" : "text-amber-500"
}
>
{isPublic ? "Public" : "Private"}
</span>
</h3>
</div>
<p className="text-sm text-muted-foreground">
{isPublic
? "Anyone can view the dashboard without a password. Admin password is still required for management actions."
: "Admin password is required to access any part of the dashboard."}
</p>
</div>
<Button
onClick={handleTogglePublic}
disabled={saving}
variant={isPublic ? "outline" : "default"}
size="sm"
className="shrink-0"
>
{saving ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Saving...
</>
) : isPublic ? (
<>
<Lock className="mr-2 h-4 w-4" />
Make Private
</>
) : (
<>
<Eye className="mr-2 h-4 w-4" />
Make Public
</>
)}
</Button>
</div>
{/* ── Status indicators ── */}
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-lg bg-muted/50 px-3 py-2">
<p className="text-xs text-muted-foreground">Runtime</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span
className={`inline-block h-2 w-2 rounded-full ${
isPublic ? "bg-emerald-400" : "bg-amber-400"
}`}
/>
<span className="text-sm font-medium">
{isPublic ? "Public" : "Private"}
</span>
</div>
</div>
<div className="rounded-lg bg-muted/50 px-3 py-2">
<p className="text-xs text-muted-foreground">
Env Default
</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span
className={`inline-block h-2 w-2 rounded-full ${
settings?.envDashboardIsPublic
? "bg-emerald-400"
: "bg-amber-400"
}`}
/>
<span className="text-sm font-medium">
{settings?.envDashboardIsPublic ? "Public" : "Private"}
</span>
</div>
</div>
</div>
</div>
{/* ── Logout ── */}
<div className="flex justify-end">
<Button
onClick={handleLogout}
variant="outline"
size="sm"
className="text-muted-foreground"
>
<Lock className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
{/* ── Info card ── */}
<div className="rounded-xl border border-border/50 bg-muted/30 p-4">
<div className="flex items-start gap-3">
<Shield className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="space-y-1 text-xs text-muted-foreground">
<p>
<strong>Admin password</strong> is configured via the
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
ADMIN_PASSWORD
</code>
environment variable. For security, it cannot be changed
through this panel update it in your deployment
configuration and restart the service.
</p>
<p className="mt-2">
Runtime settings are persisted across restarts in the
<code className="mx-1 rounded bg-muted px-1 py-0.5 font-mono text-[10px]">
data/settings.json
</code>
file. Changes take effect immediately, no restart needed.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
);
}
+96 -15
View File
@@ -1,7 +1,7 @@
import { motion } from "framer-motion";
import { Lock } from "lucide-react";
import { useState } from "react";
import { login } from "../../shared/api/client.js";
import { Lock, Unlock, Shield, WifiOff, RefreshCw } from "lucide-react";
import { useState, useCallback } from "react";
import { login, setSessionToken } from "../../shared/api/client.js";
import {
Button,
Card,
@@ -14,67 +14,148 @@ import {
interface AuthOverlayProps {
onAuthenticated: () => void;
isPublic: boolean;
configError?: string | null;
onRetryConfig?: () => void;
}
export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
export function AuthOverlay({
onAuthenticated,
isPublic,
configError,
onRetryConfig,
}: 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 {
await login(password);
localStorage.setItem("admin-password", password);
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");
onAuthenticated();
} catch {
setError("Invalid password");
} 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",
);
} 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 items-center justify-center p-4"
className="flex min-h-screen items-center justify-center p-4"
>
<Card className="w-full max-w-md border-primary/30 shadow-lg shadow-primary/10">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex items-center justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Lock className="h-6 w-6" />
{isPublic ? (
<Shield className="h-6 w-6" />
) : (
<Lock className="h-6 w-6" />
)}
</div>
</div>
<CardTitle>Admin Access Required</CardTitle>
<CardTitle>
{isPublic ? "Admin Authentication" : "Admin Access Required"}
</CardTitle>
<CardDescription>
Enter the admin password to access Voice and Media controls.
{isPublic
? "Enter the admin password to manage settings and perform administrative actions."
: "Enter the admin password to access the dashboard."}
</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 password"
placeholder="Enter admin password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
{error && <p className="text-xs text-destructive">{error}</p>}
{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>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={loading || !password}
>
{loading ? "Authenticating..." : "Unlock Controls"}
{loading ? "Authenticating..." : "Unlock"}
</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,4 +1,6 @@
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";
@@ -87,6 +89,10 @@ 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">
@@ -120,6 +126,10 @@ 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-sky-200 bg-white">
<div key={rec.id} className="rounded-xl border border-border bg-card">
<div className="flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Mic className="h-5 w-5" />
@@ -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-white shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
className="group overflow-hidden rounded-xl border border-primary/20 bg-card shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
>
<div className="relative aspect-video overflow-hidden">
{image.kind === "sticker" ? (
@@ -42,6 +42,10 @@ 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;
@@ -80,13 +84,13 @@ function parseStringList(value?: string | null): string[] {
function severityColor(severity: string) {
switch (severity) {
case "critical":
return "bg-red-100 text-red-700 border-red-200";
return "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800";
case "high":
return "bg-orange-100 text-orange-700 border-orange-200";
return "bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 border-orange-200 dark:border-orange-800";
case "medium":
return "bg-yellow-100 text-yellow-700 border-yellow-200";
return "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800";
case "low":
return "bg-blue-100 text-blue-700 border-blue-200";
return "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800";
default:
return "bg-muted text-muted-foreground border-border";
}
@@ -333,6 +337,10 @@ 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">
@@ -360,6 +368,10 @@ 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>
))}
@@ -380,7 +392,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-black"
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-muted"
preload="metadata"
/>
))}
@@ -409,8 +421,8 @@ function MessageRow({
<div
className={`rounded-lg border-l-[3px] px-3 py-2 ${
aiStatus === "flagged"
? "border-l-pink-400 bg-pink-50/40"
: "border-l-emerald-400 bg-emerald-50/40"
? "border-l-pink-400 dark:border-l-pink-600 bg-pink-50/40 dark:bg-pink-950/30"
: "border-l-emerald-400 dark:border-l-emerald-600 bg-emerald-50/40 dark:bg-emerald-950/30"
}`}
>
<div className="flex items-start gap-2 text-[11px]">
@@ -431,7 +443,7 @@ function MessageRow({
{/* AI Error */}
{message.ai_error ? (
<div className="rounded-lg bg-pink-50/40 px-3 py-2 text-[12px] text-pink-600">
<div className="rounded-lg bg-pink-50/40 dark:bg-pink-950/30 px-3 py-2 text-[12px] text-pink-600 dark:text-pink-400">
AI error: {message.ai_error}
</div>
) : null}
@@ -451,7 +463,7 @@ function MessageRow({
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
</Button>
{aiStatus === "error" && (
<span className="text-[11px] text-pink-600/70">
<span className="text-[11px] text-pink-600/70 dark:text-pink-400/70">
Click to retry analysis
</span>
)}
@@ -483,7 +495,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 opacity-60" : "border-border"
firstMsg.deleted_at ? "border-red-200 dark:border-red-900/50 opacity-60" : "border-border"
}`}
>
<div className="flex gap-3 p-4">
@@ -495,6 +507,10 @@ 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">
@@ -0,0 +1,426 @@
import { motion } from "framer-motion";
import {
Bell,
BellOff,
Moon,
Palette,
Sun,
Monitor,
Settings,
Shield,
Globe,
Lock,
Volume2,
VolumeX,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ThemeMode } from "../../hooks/useTheme";
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
import { Card, CardContent, CardHeader, CardTitle, Button } from "../../shared/ui";
import {
getAdminSettings,
updateAdminSettings,
clearSessionToken,
} from "../../shared/api/client";
import type { AdminSettings as AdminSettingsType } from "../../entities/ui/types";
/* ─── Storage keys ─────────────────────────────────────────────────────── */
const NOTIF_ENABLED_KEY = "bete-notif-enabled";
const NOTIF_SOUND_KEY = "bete-notif-sound";
/* ─── Types ────────────────────────────────────────────────────────────── */
interface NotificationPrefs {
enabled: boolean;
sound: boolean;
}
function loadNotifPrefs(): NotificationPrefs {
try {
const raw = localStorage.getItem(NOTIF_ENABLED_KEY);
const soundRaw = localStorage.getItem(NOTIF_SOUND_KEY);
return {
enabled: raw !== "false", // default true
sound: soundRaw !== "false", // default true
};
} catch {
return { enabled: true, sound: true };
}
}
/* ─── Props ────────────────────────────────────────────────────────────── */
interface SettingsPanelProps {
themeMode: ThemeMode;
isDark: boolean;
onThemeModeChange: (mode: ThemeMode) => void;
}
/* ─── Component ────────────────────────────────────────────────────────── */
export function SettingsPanel({
themeMode,
isDark,
onThemeModeChange,
}: SettingsPanelProps) {
const [notifPrefs, setNotifPrefs] = useState<NotificationPrefs>(loadNotifPrefs);
const [adminSettings, setAdminSettings] = useState<AdminSettingsType | null>(null);
const [adminSaving, setAdminSaving] = useState(false);
const [adminError, setAdminError] = useState<string | null>(null);
const [adminSuccess, setAdminSuccess] = useState<string | null>(null);
const adminSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load admin settings on mount
useEffect(() => {
getAdminSettings()
.then(setAdminSettings)
.catch(() => {
// Not authenticated — ignore
});
}, []);
const handleTogglePublic = async () => {
if (!adminSettings) return;
const newValue = !adminSettings.dashboardIsPublic;
setAdminSaving(true);
setAdminError(null);
setAdminSuccess(null);
// Clear any existing auto-clear timer
if (adminSuccessTimerRef.current) {
clearTimeout(adminSuccessTimerRef.current);
}
try {
const updated = await updateAdminSettings({ dashboardIsPublic: newValue });
setAdminSettings(updated);
setAdminSuccess(
newValue
? "Dashboard is now public — accessible without password."
: "Dashboard is now private — admin password required.",
);
// Auto-clear success message after 4s
adminSuccessTimerRef.current = setTimeout(() => setAdminSuccess(null), 4000);
} catch (err) {
setAdminError(err instanceof Error ? err.message : "Failed to update");
} finally {
setAdminSaving(false);
}
};
const handleLogout = () => {
clearSessionToken();
window.location.reload();
};
const updateNotif = useCallback(
(patch: Partial<NotificationPrefs>) => {
setNotifPrefs((prev) => {
const next = { ...prev, ...patch };
try {
localStorage.setItem(NOTIF_ENABLED_KEY, String(next.enabled));
localStorage.setItem(NOTIF_SOUND_KEY, String(next.sound));
} catch {
/* quota */
}
// Dispatch event so other components can react
window.dispatchEvent(
new CustomEvent("notif_prefs_changed", { detail: next }),
);
return next;
});
},
[],
);
const themeOptions: Array<{
value: ThemeMode;
label: string;
icon: typeof Sun;
desc: string;
}> = [
{
value: "light",
label: "Light",
icon: Sun,
desc: "Always use light theme",
},
{
value: "dark",
label: "Dark",
icon: Moon,
desc: "Always use dark theme",
},
{
value: "system",
label: "System",
icon: Monitor,
desc: "Follow system preference",
},
];
return (
<motion.div
className="mx-auto max-w-2xl space-y-6"
variants={cardStagger}
initial="initial"
animate="animate"
>
{/* ── Theme section ────────────────────────────────────────────── */}
<motion.div variants={cardItem}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-primary">
<Palette className="h-5 w-5" />
Theme
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{themeOptions.map((opt) => {
const Icon = opt.icon;
const isActive = themeMode === opt.value;
return (
<button
key={opt.value}
onClick={() => onThemeModeChange(opt.value)}
className={`
flex flex-col items-center gap-2 rounded-xl border-2 p-4 text-center transition-all
${
isActive
? "border-primary bg-primary/5 text-primary"
: "border-border text-muted-foreground hover:border-primary/40 hover:text-foreground"
}
`}
>
<Icon
className={`h-6 w-6 ${
opt.value === "dark" && !isActive
? "text-indigo-400"
: opt.value === "light" && !isActive
? "text-amber-500"
: ""
}`}
/>
<span className="text-sm font-semibold">{opt.label}</span>
<span className="text-xs">{opt.desc}</span>
{isActive && (
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-primary" />
)}
</button>
);
})}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Current: <span className="font-medium text-foreground capitalize">{isDark ? "Dark" : "Light"}</span>
{themeMode === "system" && " (follows system)"}
</p>
</CardContent>
</Card>
</motion.div>
{/* ── Notifications section ────────────────────────────────────── */}
<motion.div variants={cardItem}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-primary">
<Bell className="h-5 w-5" />
Notifications
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Toggle — enable/disable all notifs */}
<label className="flex items-center justify-between rounded-lg border border-border p-3 cursor-pointer hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3">
{notifPrefs.enabled ? (
<Bell className="h-5 w-5 text-primary" />
) : (
<BellOff className="h-5 w-5 text-muted-foreground" />
)}
<div>
<p className="text-sm font-medium text-foreground">
Moderation alerts
</p>
<p className="text-xs text-muted-foreground">
Show toast when a message is flagged by AI
</p>
</div>
</div>
<button
role="switch"
aria-checked={notifPrefs.enabled}
onClick={() => updateNotif({ enabled: !notifPrefs.enabled })}
className={`
relative h-6 w-11 rounded-full transition-colors
${notifPrefs.enabled ? "bg-primary" : "bg-muted"}
`}
>
<span
className={`
absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow-sm transition-transform
${notifPrefs.enabled ? "translate-x-5" : "translate-x-0"}
`}
/>
</button>
</label>
{/* Toggle — sound */}
<label className="flex items-center justify-between rounded-lg border border-border p-3 cursor-pointer hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3">
{notifPrefs.sound ? (
<Volume2 className="h-5 w-5 text-primary" />
) : (
<VolumeX className="h-5 w-5 text-muted-foreground" />
)}
<div>
<p className="text-sm font-medium text-foreground">
Sound effects
</p>
<p className="text-xs text-muted-foreground">
Play a sound when new moderation alerts arrive
</p>
</div>
</div>
<button
role="switch"
aria-checked={notifPrefs.sound}
onClick={() => updateNotif({ sound: !notifPrefs.sound })}
className={`
relative h-6 w-11 rounded-full transition-colors
${notifPrefs.sound ? "bg-primary" : "bg-muted"}
`}
>
<span
className={`
absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow-sm transition-transform
${notifPrefs.sound ? "translate-x-5" : "translate-x-0"}
`}
/>
</button>
</label>
</CardContent>
</Card>
</motion.div>
{/* ── Admin section ────────────────────────────────────────────── */}
<motion.div variants={cardItem}>
<Card className="border-primary/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-primary">
<Settings className="h-5 w-5" />
Admin Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Dashboard visibility toggle */}
<div className="rounded-lg border border-border p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
{adminSettings?.dashboardIsPublic ? (
<Globe className="mt-0.5 h-5 w-5 text-emerald-500 dark:text-emerald-400 shrink-0" />
) : (
<Lock className="mt-0.5 h-5 w-5 text-amber-500 dark:text-amber-400 shrink-0" />
)}
<div>
<p className="text-sm font-medium text-foreground">
Dashboard Visibility:{" "}
<span className={adminSettings?.dashboardIsPublic ? "text-emerald-500 dark:text-emerald-400" : "text-amber-500 dark:text-amber-400"}>
{adminSettings?.dashboardIsPublic ? "Public" : "Private"}
</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{adminSettings?.dashboardIsPublic
? "Anyone can view the dashboard. Admin password still required for management."
: "Admin password required to access the dashboard."}
</p>
</div>
</div>
<Button
onClick={handleTogglePublic}
disabled={adminSaving}
variant={adminSettings?.dashboardIsPublic ? "outline" : "default"}
size="sm"
className="shrink-0"
>
{adminSaving ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : adminSettings?.dashboardIsPublic ? (
"Make Private"
) : (
"Make Public"
)}
</Button>
</div>
{adminError && (
<p className="mt-2 text-xs text-destructive">{adminError}</p>
)}
{adminSuccess && (
<p className="mt-2 text-xs text-emerald-500 dark:text-emerald-400">{adminSuccess}</p>
)}
</div>
{/* Status indicators */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-muted/50 px-3 py-2">
<p className="text-xs text-muted-foreground">Runtime</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`inline-block h-2 w-2 rounded-full ${adminSettings?.dashboardIsPublic ? "bg-emerald-400 dark:bg-emerald-500" : "bg-amber-400 dark:bg-amber-500"}`} />
<span className="text-sm font-medium">{adminSettings?.dashboardIsPublic ? "Public" : "Private"}</span>
</div>
</div>
<div className="rounded-lg bg-muted/50 px-3 py-2">
<p className="text-xs text-muted-foreground">Env Default</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`inline-block h-2 w-2 rounded-full ${adminSettings?.envDashboardIsPublic ? "bg-emerald-400 dark:bg-emerald-500" : "bg-amber-400 dark:bg-amber-500"}`} />
<span className="text-sm font-medium">{adminSettings?.envDashboardIsPublic ? "Public" : "Private"}</span>
</div>
</div>
</div>
{/* Logout */}
<div className="flex justify-end border-t border-border pt-4">
<Button
onClick={handleLogout}
variant="outline"
size="sm"
className="text-muted-foreground"
>
<Lock className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
{/* Info */}
<div className="rounded-lg bg-muted/30 px-3 py-2">
<div className="flex items-start gap-2">
<Shield className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
Admin password is set via the <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">ADMIN_PASSWORD</code> env var.
Runtime settings are persisted in <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">data/settings.json</code>.
</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* ── About section ────────────────────────────────────────────── */}
<motion.div variants={cardItem}>
<Card>
<CardHeader>
<CardTitle className="text-muted-foreground">About</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Bete Dashboard v1.0 Discord AI Moderation & Voice Recording
System.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Theme settings are saved locally. Notification preferences are
persisted across sessions.
</p>
</CardContent>
</Card>
</motion.div>
</motion.div>
);
}