feat: rewrite dashboard layout with top nav, hidden sidebar, mascot, mini-player
This commit is contained in:
@@ -2,17 +2,20 @@
|
|||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { TopNav } from "@/components/layout/top-nav";
|
||||||
import { Chatbot } from "@/components/chatbot/chatbot";
|
|
||||||
import { AppHeader } from "@/components/layout/app-header";
|
|
||||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
|
||||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||||
import { WsProvider } from "@/lib/ws/context";
|
import { WsProvider } from "@/lib/ws/context";
|
||||||
|
import { MascotProvider } from "@/components/mascot/mascot-context";
|
||||||
|
import { MascotContainer } from "@/components/mascot/mascot-container";
|
||||||
|
import { MiniPlayer } from "@/components/media/mini-player";
|
||||||
|
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
|
||||||
|
import { HiddenSidebar } from "@/components/layout/hidden-sidebar";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 15_000,
|
staleTime: 10_000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
@@ -24,28 +27,38 @@ export default function DashboardLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const [guildId, setGuildId] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<WsProvider>
|
<WsProvider>
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<MediaPlayerProvider>
|
||||||
<AppSidebar />
|
<MascotProvider>
|
||||||
<div className="flex flex-1 flex-col min-w-0">
|
<div className="min-h-screen bg-canvas">
|
||||||
<AppHeader />
|
<TopNav />
|
||||||
<main className="flex-1 overflow-y-auto p-4 md:p-6 pb-20 md:pb-6">
|
<HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} />
|
||||||
<Suspense
|
|
||||||
fallback={
|
{/* Sub-nav space — filled per-page */}
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="pt-11">
|
||||||
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<main className="p-4 md:p-6 pb-24 md:pb-6 max-w-[1600px] mx-auto">
|
||||||
</div>
|
<Suspense
|
||||||
}
|
fallback={
|
||||||
>
|
<div className="flex h-[60vh] items-center justify-center">
|
||||||
{children}
|
<div className="size-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||||
</Suspense>
|
</div>
|
||||||
</main>
|
}
|
||||||
</div>
|
>
|
||||||
<MobileNav />
|
{children}
|
||||||
</div>
|
</Suspense>
|
||||||
<Chatbot />
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MobileNav />
|
||||||
|
<MiniPlayer />
|
||||||
|
<MascotContainer />
|
||||||
|
</div>
|
||||||
|
</MascotProvider>
|
||||||
|
</MediaPlayerProvider>
|
||||||
</WsProvider>
|
</WsProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
Bot,
|
|
||||||
Loader2,
|
|
||||||
MessageCircle,
|
|
||||||
Send,
|
|
||||||
Sparkles,
|
|
||||||
Trash2,
|
|
||||||
User,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { chatbotApi } from "@/lib/api";
|
|
||||||
import type { ChatHistoryMessage } from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function Chatbot() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [messages, setMessages] = useState<ChatHistoryMessage[]>([]);
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
const { data: historyMessages = [] } = useQuery({
|
|
||||||
queryKey: ["chatbot-history"],
|
|
||||||
queryFn: () => chatbotApi.getHistory(),
|
|
||||||
enabled: open,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (historyMessages.length > 0) setMessages(historyMessages);
|
|
||||||
}, [historyMessages]);
|
|
||||||
|
|
||||||
const sendMut = useMutation({
|
|
||||||
mutationFn: (text: string) => chatbotApi.send(text),
|
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["chatbot-history"] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const clearMut = useMutation({
|
|
||||||
mutationFn: () => chatbotApi.clearHistory(),
|
|
||||||
onSuccess: () => qc.setQueryData(["chatbot-history"], []),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
|
||||||
clearMut.mutate();
|
|
||||||
setMessages([]);
|
|
||||||
}, [clearMut]);
|
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
|
||||||
if (!input.trim() || sendMut.isPending) return;
|
|
||||||
const text = input.trim();
|
|
||||||
setInput("");
|
|
||||||
|
|
||||||
// Add optimistic user message
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ role: "user", content: text, timestamp: new Date().toISOString() },
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await sendMut.mutateAsync(text);
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: resp.response,
|
|
||||||
timestamp: resp.timestamp,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} catch {
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: "Sorry, I couldn't process that request.",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}, [input, sendMut]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Toggle button */}
|
|
||||||
<Button
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
size="icon"
|
|
||||||
aria-label={open ? "Close chat" : "Open chat"}
|
|
||||||
className={cn(
|
|
||||||
"fixed bottom-4 right-4 z-50 size-12 rounded-full shadow-lg transition-all duration-200",
|
|
||||||
open && "scale-90 opacity-80 hover:scale-100 hover:opacity-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Chat panel */}
|
|
||||||
{open && (
|
|
||||||
<Card className="fixed bottom-20 right-4 z-50 w-80 sm:w-96 shadow-xl border-border/50 animate-fade-in-up">
|
|
||||||
<CardHeader className="border-b border-border/50 bg-gradient-to-r from-primary/5 to-primary/[0.02]">
|
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<div className="flex size-6 items-center justify-center rounded-full bg-primary/10">
|
|
||||||
<Bot className="size-3.5 text-primary" />
|
|
||||||
</div>
|
|
||||||
Chatbot
|
|
||||||
<Sparkles className="size-3 text-primary/60 ml-0.5" />
|
|
||||||
<div className="flex-1" />
|
|
||||||
{messages.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
onClick={handleClear}
|
|
||||||
title="Clear history"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<ScrollArea className="h-80">
|
|
||||||
<div ref={scrollRef} className="space-y-3 p-3">
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<p className="text-center text-xs text-muted-foreground py-12">
|
|
||||||
Ask me anything about the server!
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<div
|
|
||||||
key={msg.timestamp + msg.role}
|
|
||||||
className={cn(
|
|
||||||
"flex items-start gap-2",
|
|
||||||
msg.role === "user" && "flex-row-reverse",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Avatar className="size-6 shrink-0">
|
|
||||||
<AvatarFallback className="text-[10px] bg-muted">
|
|
||||||
{msg.role === "user" ? (
|
|
||||||
<User className="size-3" />
|
|
||||||
) : (
|
|
||||||
<Bot className="size-3" />
|
|
||||||
)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-xl px-3 py-2 text-sm max-w-[80%] leading-relaxed",
|
|
||||||
msg.role === "user"
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted/70",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{msg.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{sendMut.isPending && (
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Avatar className="size-6 shrink-0">
|
|
||||||
<AvatarFallback className="text-[10px] bg-muted">
|
|
||||||
<Bot className="size-3" />
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="rounded-xl bg-muted/70 px-3 py-2">
|
|
||||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter className="border-t border-border/50 p-3">
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}}
|
|
||||||
className="flex w-full gap-2"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Ask the mascot…"
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
disabled={sendMut.isPending}
|
|
||||||
className="h-8 flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="icon-sm"
|
|
||||||
disabled={!input.trim() || sendMut.isPending}
|
|
||||||
>
|
|
||||||
{sendMut.isPending ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Moon, PanelLeft, Sun } from "lucide-react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { isActivePath, navItems } from "@/lib/navigation";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
|
||||||
|
|
||||||
export function AppHeader() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const { status } = useWebSocket();
|
|
||||||
const [theme, setTheme] = useState<"light" | "dark">("dark");
|
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
|
||||||
|
|
||||||
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 pageTitle =
|
|
||||||
navItems
|
|
||||||
.filter((n) => isActivePath(pathname, n.matchPrefix))
|
|
||||||
.map((n) => n.label)
|
|
||||||
.at(0) ?? "Dashboard";
|
|
||||||
|
|
||||||
const statusVariant =
|
|
||||||
status === "connected"
|
|
||||||
? "default"
|
|
||||||
: status === "connecting"
|
|
||||||
? "secondary"
|
|
||||||
: "destructive";
|
|
||||||
|
|
||||||
const statusLabel =
|
|
||||||
status === "connected"
|
|
||||||
? "Connected"
|
|
||||||
: status === "connecting"
|
|
||||||
? "Connecting"
|
|
||||||
: "Disconnected";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<header className="flex h-14 items-center gap-3 border-b border-border/50 bg-background/70 backdrop-blur-xl px-4 shrink-0 shadow-[0_1px_0_0_oklch(0.62_0.17_215_/_0.06)]">
|
|
||||||
{/* Mobile menu button */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="md:hidden size-8 -ml-1 text-muted-foreground"
|
|
||||||
onClick={() => setMobileOpen(!mobileOpen)}
|
|
||||||
aria-label="Toggle menu"
|
|
||||||
>
|
|
||||||
<PanelLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<h1 className="text-base font-semibold tracking-tight">{pageTitle}</h1>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
<Badge
|
|
||||||
variant={statusVariant}
|
|
||||||
className="gap-1.5 px-2.5 py-1 cursor-default select-none text-xs"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"size-1.5 rounded-full",
|
|
||||||
status === "connected" &&
|
|
||||||
"bg-emerald-500 shadow-[0_0_6px] shadow-emerald-500/60",
|
|
||||||
status === "connecting" && "bg-amber-400 animate-pulse",
|
|
||||||
status === "disconnected" && "bg-destructive",
|
|
||||||
status === "error" && "bg-destructive",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="hidden sm:inline">{statusLabel}</span>
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
className="size-8 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Sun
|
|
||||||
className={cn(
|
|
||||||
"size-4 transition-all absolute",
|
|
||||||
theme === "dark"
|
|
||||||
? "opacity-0 rotate-90 scale-75"
|
|
||||||
: "opacity-100 rotate-0 scale-100",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Moon
|
|
||||||
className={cn(
|
|
||||||
"size-4 transition-all absolute",
|
|
||||||
theme === "dark"
|
|
||||||
? "opacity-100 rotate-0 scale-100"
|
|
||||||
: "opacity-0 -rotate-90 scale-75",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Mobile overlay menu */}
|
|
||||||
{mobileOpen && (
|
|
||||||
<div className="fixed inset-0 z-50 md:hidden">
|
|
||||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: overlay backdrop */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") setMobileOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<aside className="absolute left-0 top-0 bottom-0 w-64 bg-sidebar border-r border-sidebar-border p-2 space-y-0.5">
|
|
||||||
<div className="flex h-14 items-center gap-3 px-3 mb-1 border-b border-sidebar-border/50">
|
|
||||||
<div className="flex size-7 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-500 to-teal-400 text-white text-xs font-bold">
|
|
||||||
D
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-bold text-gradient">
|
|
||||||
Discord Automod
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
|
|
||||||
const active = isActivePath(pathname, matchPrefix);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={href}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
window.location.href = href;
|
|
||||||
setMobileOpen(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-all text-left",
|
|
||||||
active
|
|
||||||
? "bg-sidebar-accent/80 text-sidebar-accent-foreground font-medium"
|
|
||||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/40 hover:text-sidebar-foreground/90",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={cn("size-4 shrink-0", active && "text-cyan-400")}
|
|
||||||
/>
|
|
||||||
<span>{label}</span>
|
|
||||||
{active && (
|
|
||||||
<div className="ml-auto w-1 h-5 rounded-full bg-gradient-to-b from-cyan-400 to-teal-500" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { isActivePath, navItems } from "@/lib/navigation";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
|
||||||
|
|
||||||
export function AppSidebar() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
|
||||||
const { status } = useWebSocket();
|
|
||||||
|
|
||||||
const connectionDot = {
|
|
||||||
connected:
|
|
||||||
"bg-emerald-500 shadow-[0_0_8px] shadow-emerald-500/60 animate-pulse",
|
|
||||||
connecting: "bg-amber-400 animate-pulse",
|
|
||||||
disconnected: "bg-destructive",
|
|
||||||
error: "bg-destructive",
|
|
||||||
}[status];
|
|
||||||
|
|
||||||
const connectionLabel = {
|
|
||||||
connected: "Connected",
|
|
||||||
connecting: "Connecting",
|
|
||||||
disconnected: "Disconnected",
|
|
||||||
error: "Error",
|
|
||||||
}[status];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="hidden md:flex md:w-64 flex-col border-r border-border/50 bg-sidebar shrink-0">
|
|
||||||
{/* Brand */}
|
|
||||||
<div className="flex h-14 items-center gap-3 border-b border-sidebar-border/50 px-4 shrink-0">
|
|
||||||
<div className="flex size-8 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-500 to-teal-400 text-white text-xs font-bold shadow-lg shadow-cyan-500/20">
|
|
||||||
D
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-bold tracking-tight">
|
|
||||||
<span className="text-gradient">Discord Automod</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-muted-foreground/60 tracking-widest uppercase leading-none">
|
|
||||||
Monitor
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nav */}
|
|
||||||
<nav className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
|
||||||
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
|
|
||||||
const active = isActivePath(pathname, matchPrefix);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={href}
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push(href)}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all duration-150 text-left group relative",
|
|
||||||
active
|
|
||||||
? "bg-sidebar-accent/80 text-sidebar-accent-foreground font-medium"
|
|
||||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/40 hover:text-sidebar-foreground/90",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={cn(
|
|
||||||
"size-4 shrink-0 transition-all",
|
|
||||||
active && "text-cyan-400",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{label}</span>
|
|
||||||
{active && (
|
|
||||||
<div className="ml-auto w-1 h-5 rounded-full bg-gradient-to-b from-cyan-400 to-teal-500 shadow-[0_0_8px] shadow-cyan-400/60" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Connection status */}
|
|
||||||
<div className="border-t border-sidebar-border/50 p-3 shrink-0">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span className="relative flex size-2 shrink-0">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute inline-flex size-full rounded-full opacity-75",
|
|
||||||
connectionDot,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex size-2 rounded-full",
|
|
||||||
status === "connected" ? "bg-emerald-500" : connectionDot,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground/70 truncate font-medium tracking-wide">
|
|
||||||
{connectionLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user