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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user