feat: add recordings, settings, and voice pages with WebSocket integration
Deploy to VPS / deploy (push) Failing after 1m36s

- Implemented RecordingsPage to display and manage voice recordings with live updates via WebSocket.
- Created SettingsPage for user preferences, including theme toggling and server configuration display.
- Developed VoicePage for managing voice connections, including guild and channel selection, and active speaker display.
- Introduced GuildSelector component for selecting Discord guilds with error handling and loading states.
- Added utility functions for formatting numbers and bytes, and safely parsing JSON.
- Established navigation structure for the dashboard with relevant links for new features.
This commit is contained in:
asepharyana
2026-07-26 15:34:08 +07:00
parent eae0d7ce56
commit c2502a0e5f
20 changed files with 1520 additions and 1036 deletions
@@ -1,7 +1,9 @@
"use client";
import { Moon, 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 { SidebarTrigger } from "@/components/ui/sidebar";
@@ -10,11 +12,32 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
function usePageTitle(): string {
const pathname = usePathname();
// Exact match first, then prefix match
const item = navItems.find((n) => {
if (n.matchPrefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(n.matchPrefix);
});
if (item) return item.label;
// Fallback: derive from pathname
const segment = pathname.split("/").filter(Boolean)[0];
if (segment) {
return segment.charAt(0).toUpperCase() + segment.slice(1);
}
return "Dashboard";
}
export function Header() {
const { status } = useWebSocket();
const pageTitle = usePageTitle();
const [theme, setTheme] = useState<"light" | "dark">("dark");
useEffect(() => {
@@ -50,6 +73,8 @@ export function Header() {
<header className="sticky top-0 z-10 flex h-14 items-center gap-3 border-b border-border/50 bg-background/60 backdrop-blur-lg px-4 md:px-6">
<SidebarTrigger className="-ml-1 size-8 text-muted-foreground hover:text-foreground" />
<h1 className="text-sm font-semibold hidden sm:block">{pageTitle}</h1>
<div className="flex-1" />
{/* Connection status */}
@@ -1,40 +1,41 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { type TabId, tabs } from "@/lib/tabs";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { mobileNavItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
export function MobileTabBar({ activeTab }: { activeTab: TabId }) {
const router = useRouter();
const searchParams = useSearchParams();
export function MobileTabBar() {
const pathname = usePathname();
const isActive = (matchPrefix: string) => {
if (matchPrefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(matchPrefix);
};
return (
<nav className="md:hidden fixed bottom-0 inset-x-0 z-10 border-t border-border/50 bg-background/80 backdrop-blur-lg">
<div className="flex">
{tabs.map(({ id, label, icon: Icon }) => {
const isActive = activeTab === id;
{mobileNavItems.map(({ href, label, icon: Icon, matchPrefix }) => {
const active = isActive(matchPrefix);
return (
<button
key={id}
type="button"
onClick={() => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", id);
router.push(`/dashboard?${params}`);
}}
<Link
key={href}
href={href}
className={cn(
"flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium transition-all duration-200 relative",
isActive
active
? "text-sky-400"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-5" />
<span>{label}</span>
{isActive && (
{active && (
<span className="absolute -top-px left-1/4 right-1/4 h-0.5 rounded-full bg-gradient-to-r from-sky-400 to-cyan-400" />
)}
</button>
</Link>
);
})}
</div>
@@ -1,7 +1,8 @@
"use client";
import { type LucideIcon, Radio } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { Radio } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import {
SidebarContent,
SidebarFooter,
@@ -14,21 +15,20 @@ import {
Sidebar as SidebarPrimitive,
useSidebar,
} from "@/components/ui/sidebar";
import { type TabId, tabs } from "@/lib/tabs";
import { navItems } from "@/lib/navigation";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
export function Sidebar({ activeTab }: { activeTab: TabId }) {
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const { state } = useSidebar();
const { status } = useWebSocket();
const collapsed = state === "collapsed";
const handleTabClick = (tabId: TabId) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", tabId);
router.push(`/dashboard?${params}`);
const isActive = (matchPrefix: string) => {
if (matchPrefix === "/dashboard") return pathname === "/dashboard";
return pathname.startsWith(matchPrefix);
};
const connectionLabel = {
@@ -53,6 +53,7 @@ export function Sidebar({ activeTab }: { activeTab: TabId }) {
<SidebarMenuButton
size="lg"
className="group-data-[collapsible=icon]:!p-0"
onClick={() => router.push("/dashboard")}
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-gradient-to-br from-sky-500 to-cyan-400 text-sidebar-primary-foreground">
<Radio className="size-4" />
@@ -79,28 +80,28 @@ export function Sidebar({ activeTab }: { activeTab: TabId }) {
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{tabs.map(({ id, label, icon: Icon }) => {
const isActive = activeTab === id;
{navItems.map(({ href, label, icon: Icon, matchPrefix }) => {
const active = isActive(matchPrefix);
return (
<SidebarMenuItem key={id}>
<SidebarMenuItem key={href}>
<SidebarMenuButton
isActive={isActive}
onClick={() => handleTabClick(id)}
isActive={active}
tooltip={collapsed ? label : undefined}
className={cn(
"relative transition-all duration-200",
isActive &&
active &&
"bg-sidebar-accent/80 text-sidebar-accent-foreground font-medium",
)}
onClick={() => router.push(href)}
>
<Icon
className={cn(
"size-4 transition-all duration-200",
isActive && "text-sky-400 scale-110",
active && "text-sky-400 scale-110",
)}
/>
<span>{label}</span>
{isActive && (
{active && (
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-full bg-gradient-to-b from-sky-400 to-cyan-400" />
)}
</SidebarMenuButton>
@@ -0,0 +1,221 @@
"use client";
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 { mascotApi } from "@/lib/api";
import type { ChatHistoryMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
export function MascotChatbot() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<ChatHistoryMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
mascotApi
.getHistory()
.then(setMessages)
.catch(() => {});
}, [open]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, []);
const handleClear = useCallback(async () => {
try {
await mascotApi.clearHistory();
setMessages([]);
} catch {
// ignore
}
}, []);
const handleSend = useCallback(async () => {
if (!input.trim() || sending) return;
setSending(true);
const text = input.trim();
setInput("");
// Add optimistic user message
setMessages((prev) => [
...prev,
{ role: "user", content: text, timestamp: new Date().toISOString() },
]);
try {
const resp = await mascotApi.send(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(),
},
]);
} finally {
setSending(false);
}
}, [input, sending]);
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>
Mascot
<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>
))}
{sending && (
<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={sending}
className="h-8 flex-1"
/>
<Button
type="submit"
size="icon-sm"
disabled={!input.trim() || sending}
>
{sending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</form>
</CardFooter>
</Card>
)}
</>
);
}
@@ -0,0 +1,120 @@
"use client";
import { AlertCircle, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { voiceApi } from "@/lib/api";
import type { Guild } from "@/lib/types";
export interface GuildSelectorProps {
/** Currently selected guild ID */
value: string;
/** Called when user selects a different guild */
onChange: (guildId: string) => void;
/** If true, the bar is hidden when there's only one guild */
autoHide?: boolean;
}
/**
* Guild selector bar — fetches the guild list and renders a <Select>.
* Optionally auto-hides when there's exactly one guild.
*/
export function GuildSelector({
value,
onChange,
autoHide = true,
}: GuildSelectorProps) {
const [guilds, setGuilds] = useState<Guild[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchGuilds = useCallback(() => {
setLoading(true);
setError(null);
voiceApi
.getGuilds()
.then(setGuilds)
.catch((err) =>
setError(
err instanceof Error ? err.message : "Failed to load guilds",
),
)
.finally(() => setLoading(false));
}, []);
useEffect(() => {
fetchGuilds();
}, [fetchGuilds]);
// Auto-hide when there's exactly one guild and autoHide is on
if (autoHide && guilds.length <= 1 && !loading && !error) return null;
if (loading) {
return (
<div className="flex items-center gap-3 rounded-xl border border-border/50 bg-card p-3">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-8 w-8 rounded-full" />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-between rounded-xl border border-destructive/20 bg-destructive/5 p-3">
<div className="flex items-center gap-2">
<AlertCircle className="size-4 text-destructive shrink-0" />
<p className="text-sm text-muted-foreground">
Could not load guilds: {error}
</p>
</div>
<Button variant="outline" size="sm" onClick={fetchGuilds}>
<RefreshCw className="size-3 mr-1" />
Retry
</Button>
</div>
);
}
if (guilds.length === 0) {
return (
<div className="rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-3">
<div className="flex items-center gap-2">
<AlertCircle className="size-4 text-yellow-500 shrink-0" />
<p className="text-sm text-muted-foreground">
No guilds available. Make sure the Discord gateway is connected.
</p>
</div>
</div>
);
}
return (
<div className="flex items-center gap-3 rounded-xl border border-border/50 bg-card p-3">
<Badge variant="outline" className="shrink-0 text-xs font-normal">
Guild
</Badge>
<Select value={value} onValueChange={(v) => v && onChange(v)}>
<SelectTrigger className="h-8 w-full max-w-xs">
<SelectValue placeholder="Select a guild…" />
</SelectTrigger>
<SelectContent>
{guilds.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}