Compare commits

...
2 Commits
Author SHA1 Message Date
asepharyana 903e7c4aeb feat(fe): remove Settings page from dashboard
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 3m7s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m28s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m28s
Hapus halaman /settings + nav item Settings (TopNav/HiddenSidebar
via navItems). GlassDivider ikut dihapus (cuma dipakai settings).
useConfig tetap (dipakai guild-selector).

Verifikasi: tsc PASS, biome 0 error, next build PASS (10 halaman
static, settings hilang).
2026-08-01 11:13:47 +07:00
asepharyana 0771e62223 feat(fe): remove Live tab from dashboard
Hapus tab Live + komponennya (LiveStream, ModQueue) dari halaman
dashboard — tab Stats/Users/Channels tetap. useReview tetap dipakai
messages page (review tab), jadi hook tidak dihapus.

Verifikasi: tsc PASS, biome 0 error, next build PASS.
2026-08-01 11:10:36 +07:00
7 changed files with 2 additions and 414 deletions
@@ -10,38 +10,21 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { ChannelsSection } from "@/components/dashboard/channels-section"; import { ChannelsSection } from "@/components/dashboard/channels-section";
import { LiveStream } from "@/components/dashboard/live-stream";
import type { ModQueueItem } from "@/components/dashboard/mod-queue";
import { ModQueue } from "@/components/dashboard/mod-queue";
import { StatCard } from "@/components/dashboard/stat-card"; import { StatCard } from "@/components/dashboard/stat-card";
import { TopChannelsChart } from "@/components/dashboard/top-channels-chart"; import { TopChannelsChart } from "@/components/dashboard/top-channels-chart";
import { UsersSection } from "@/components/dashboard/users-section"; import { UsersSection } from "@/components/dashboard/users-section";
import { SubNav } from "@/components/layout/sub-nav"; import { SubNav } from "@/components/layout/sub-nav";
import { ErrorState, LoadingSkeleton } from "@/components/shared"; import { ErrorState, LoadingSkeleton } from "@/components/shared";
import { useReview, useStats } from "@/hooks"; import { useStats } from "@/hooks";
type DashboardTab = "stats" | "live" | "users" | "channels"; type DashboardTab = "stats" | "users" | "channels";
export default function DashboardPage() { export default function DashboardPage() {
const [tab, setTab] = useState<DashboardTab>("stats"); const [tab, setTab] = useState<DashboardTab>("stats");
const { data: stats, isLoading, error, mutate: refetch } = useStats(); const { data: stats, isLoading, error, mutate: refetch } = useStats();
const { data: review = [] } = useReview();
const modQueueItems: ModQueueItem[] = review.slice(0, 10).map((msg) => ({
id: msg.id,
content: msg.content || msg.id,
username: msg.username,
metadata: msg.metadata ?? null,
severity:
msg.ai_severity && msg.ai_severity !== "none"
? (msg.ai_severity as ModQueueItem["severity"])
: "medium",
reason: msg.ai_analysis ?? "AI moderation flag",
}));
const subNavTabs = [ const subNavTabs = [
{ id: "stats", label: "Stats", icon: <Hash className="size-3" /> }, { id: "stats", label: "Stats", icon: <Hash className="size-3" /> },
{ id: "live", label: "Live", icon: <Sparkles className="size-3" /> },
{ id: "users", label: "Users", icon: <Users className="size-3" /> }, { id: "users", label: "Users", icon: <Users className="size-3" /> },
{ id: "channels", label: "Channels", icon: <Hash className="size-3" /> }, { id: "channels", label: "Channels", icon: <Hash className="size-3" /> },
]; ];
@@ -108,13 +91,6 @@ export default function DashboardPage() {
</div> </div>
)} )}
{tab === "live" && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<LiveStream />
<ModQueue items={modQueueItems} />
</div>
)}
{tab === "users" && <UsersSection />} {tab === "users" && <UsersSection />}
{tab === "channels" && <ChannelsSection />} {tab === "channels" && <ChannelsSection />}
@@ -1,188 +0,0 @@
"use client";
import { Moon, Server, Shield, Sun, Wifi } from "lucide-react";
import { useEffect, useState } from "react";
import { GlassCard } from "@/components/glass/card";
import { GlassDivider } from "@/components/glass/divider";
import { SubNav } from "@/components/layout/sub-nav";
import { LoadingSkeleton } from "@/components/shared";
import { useConfig } from "@/hooks";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
type SettingsTab = "connection" | "appearance" | "config" | "about";
export default function SettingsPage() {
const { status } = useWebSocket();
const { data: config, isLoading: configLoading } = useConfig();
const [theme, setTheme] = useState<"light" | "dark">("dark");
const [tab, setTab] = useState<SettingsTab>("connection");
useEffect(() => {
const stored = localStorage.getItem("theme") as "light" | "dark" | null;
if (stored) setTheme(stored);
}, []);
const toggleTheme = () => {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
localStorage.setItem("theme", next);
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(next);
};
const statusDot = {
connected:
"bg-emerald-500 shadow-[0_0_8px] shadow-emerald-500/60 animate-pulse",
connecting: "bg-accent-amber animate-pulse",
disconnected: "bg-destructive",
error: "bg-destructive",
}[status];
const statusLabel = {
connected: "Connected",
connecting: "Connecting",
disconnected: "Disconnected",
error: "Error",
}[status];
return (
<div className="space-y-4 animate-fade-in-up max-w-2xl">
<SubNav
tabs={[
{
id: "connection",
label: "Connection",
icon: <Wifi className="size-3" />,
},
{
id: "appearance",
label: "Appearance",
icon: <Sun className="size-3" />,
},
{
id: "config",
label: "Config",
icon: <Server className="size-3" />,
},
{ id: "about", label: "About", icon: <Shield className="size-3" /> },
]}
activeTab={tab}
onTabChange={(t) => setTab(t as SettingsTab)}
/>
{tab === "connection" && (
<GlassCard variant="base">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-semibold text-text-primary">
WebSocket
</span>
</div>
<div className="flex items-center gap-2">
<span className={cn("size-2 rounded-full", statusDot)} />
<span className="text-xs font-mono text-text-secondary">
{statusLabel}
</span>
</div>
</div>
</GlassCard>
)}
{tab === "appearance" && (
<GlassCard variant="base">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{theme === "dark" ? (
<Moon className="size-4 text-primary" />
) : (
<Sun className="size-4 text-primary" />
)}
<span className="text-sm font-semibold text-text-primary">
Theme
</span>
</div>
<button
type="button"
onClick={toggleTheme}
className="flex items-center gap-2 px-3 py-1.5 rounded-md glass hover:glass-elevated transition-all text-xs"
>
<span className="font-mono">{theme}</span>
</button>
</div>
</GlassCard>
)}
{tab === "config" && (
<GlassCard variant="base">
<div className="space-y-3">
{configLoading ? (
<LoadingSkeleton count={6} height="h-6" />
) : config ? (
<>
<ConfigRow
label="Monitor Guild"
value={config.monitorGuildId || "Not configured"}
/>
<GlassDivider />
<ConfigRow
label="Voice Guild"
value={config.voiceGuildId || "Not configured"}
/>
<GlassDivider />
<ConfigRow
label="Voice Channel"
value={config.voiceChannelId || "Not configured"}
/>
<GlassDivider />
<ConfigRow
label="AI Analysis"
value={config.aiAnalysisEnabled ? "Enabled" : "Disabled"}
/>
<GlassDivider />
<ConfigRow
label="Auto-Delete Flagged"
value={
config.autoDeleteFlaggedEnabled ? "Enabled" : "Disabled"
}
/>
</>
) : (
<p className="text-xs text-text-secondary/60">
Unable to load config.
</p>
)}
</div>
</GlassCard>
)}
{tab === "about" && (
<GlassCard variant="base">
<div className="space-y-2">
<h2 className="text-base font-bold text-primary">
Discord Automod
</h2>
<p className="text-xs text-text-secondary/80 leading-relaxed">
AI-powered message moderation, voice recording, and real-time
monitoring for Discord communities.
</p>
<div className="text-[10px] font-mono text-text-secondary/40 mt-4">
v0.1.0
</div>
</div>
</GlassCard>
)}
</div>
);
}
function ConfigRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between py-0.5">
<span className="text-xs text-text-secondary">{label}</span>
<span className="text-[11px] font-mono text-text-primary/80 max-w-[240px] truncate text-right">
{value}
</span>
</div>
);
}
@@ -1,97 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { GlassCard } from "@/components/glass/card";
import { renderMessageContent } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
interface LiveMessage {
id: string;
content: string;
username: string;
channelName?: string;
timestamp: string;
flagged?: boolean;
metadata?: string | null;
}
export function LiveStream() {
const [messages, setMessages] = useState<LiveMessage[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const ws = useWebSocket();
useEffect(() => {
const unsub = ws.on("message_created", (data: MessageRecord) => {
// channel name lives inside the metadata JSON (channel.channelName)
let channelName: string | undefined;
try {
const meta =
typeof data.metadata === "string"
? JSON.parse(data.metadata)
: data.metadata;
channelName = meta?.channel?.channelName;
} catch {
channelName = undefined;
}
const msg: LiveMessage = {
id: data.id,
content: data.content || "(attachment)",
username: data.username || "unknown",
channelName,
timestamp: new Date().toLocaleTimeString(),
flagged: data.ai_status === "flagged" || data.ai_status === "warn",
metadata: data.metadata ?? null,
};
setMessages((prev) => [msg, ...prev].slice(0, 50));
});
return () => unsub();
}, [ws]);
return (
<GlassCard variant="base" className="p-0 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-glass-border">
<span className="relative flex size-2">
<span className="absolute inline-flex size-full rounded-full bg-emerald-500 opacity-75 animate-pulse-ring" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
</span>
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
Live Stream
</span>
</div>
<div
ref={scrollRef}
className="overflow-y-auto max-h-[320px] space-y-0.5 p-2"
>
{messages.length === 0 ? (
<div className="flex items-center justify-center py-8 text-text-secondary/40 text-xs">
Waiting for messages...
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={cn(
"flex items-start gap-2 px-3 py-2 rounded-md text-sm transition-all",
msg.flagged
? "bg-destructive/5 border-l-2 border-destructive/40"
: "hover:bg-glass-bg",
)}
>
<span className="font-medium text-xs shrink-0 text-primary">
{msg.username}
</span>
<span className="text-xs text-text-secondary truncate flex-1">
{renderMessageContent(msg.content, msg.metadata)}
</span>
<span className="text-[10px] text-text-secondary/40 shrink-0">
{msg.timestamp}
</span>
</div>
))
)}
</div>
</GlassCard>
);
}
@@ -1,86 +0,0 @@
"use client";
import { AlertCircle, Check, Trash2 } from "lucide-react";
import { GlassCard } from "@/components/glass/card";
import { renderMessageContent } from "@/lib/format";
import { cn } from "@/lib/utils";
export interface ModQueueItem {
id: string;
content: string;
username: string;
severity: "low" | "medium" | "high" | "critical";
reason: string;
metadata?: string | null;
}
export function ModQueue({ items = [] }: { items?: ModQueueItem[] }) {
const severityColor = {
low: "text-accent-amber border-accent-amber/30",
medium: "text-accent-purple border-accent-purple/30",
high: "text-destructive border-destructive/40",
critical: "text-destructive border-destructive/60 bg-destructive/10",
};
return (
<GlassCard variant="base" className="p-0 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-glass-border">
<AlertCircle className="size-3.5 text-accent-purple" />
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
Mod Queue
</span>
{items.length > 0 && (
<span className="ml-auto text-[10px] font-mono text-accent-amber">
{items.length} pending
</span>
)}
</div>
<div className="overflow-y-auto max-h-[320px] space-y-1 p-2">
{items.length === 0 ? (
<div className="flex items-center justify-center py-8 text-text-secondary/40 text-xs">
No flagged messages
</div>
) : (
items.map((item) => (
<div
key={item.id}
className={cn(
"px-3 py-2 rounded-md border-l-2 text-sm space-y-1 hover:bg-glass-bg transition-colors",
severityColor[item.severity],
)}
>
<div className="flex items-center gap-2">
<span className="font-medium text-xs text-text-primary">
{item.username}
</span>
<span className="text-[10px] font-mono uppercase text-text-secondary/60">
{item.severity}
</span>
</div>
<p className="text-xs text-text-secondary line-clamp-1">
{renderMessageContent(item.content, item.metadata)}
</p>
<p className="text-[10px] text-text-secondary/50">
{item.reason}
</p>
<div className="flex gap-1 pt-1">
<button
type="button"
className="size-6 flex items-center justify-center rounded bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/20 text-xs"
>
<Check className="size-3" />
</button>
<button
type="button"
className="size-6 flex items-center justify-center rounded bg-destructive/10 text-destructive hover:bg-destructive/20 text-xs"
>
<Trash2 className="size-3" />
</button>
</div>
</div>
))
)}
</div>
</GlassCard>
);
}
@@ -1,9 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
export function GlassDivider({ className }: { className?: string }) {
return (
<div className={cn("h-px bg-gradient-to-r from-transparent via-oklch(1 0 0 / 0.08) to-transparent", className)} />
);
}
@@ -1,3 +1,2 @@
export { GlassCard } from "./card"; export { GlassCard } from "./card";
export { GlassPanel } from "./panel"; export { GlassPanel } from "./panel";
export { GlassDivider } from "./divider";
-7
View File
@@ -6,7 +6,6 @@ import {
Mic, Mic,
Music, Music,
Search, Search,
Settings,
} from "lucide-react"; } from "lucide-react";
export interface NavItem { export interface NavItem {
@@ -57,12 +56,6 @@ export const navItems: NavItem[] = [
icon: Search, icon: Search,
matchPrefix: "/analysis", matchPrefix: "/analysis",
}, },
{
href: "/settings",
label: "Settings",
icon: Settings,
matchPrefix: "/settings",
},
]; ];
/** /**