Merge branch 'worktree-neo-surveillance-redesign'

This commit is contained in:
Developer
2026-07-28 14:32:00 +07:00
25 changed files with 891 additions and 291 deletions
@@ -27,7 +27,7 @@ export function ActivityHeatmap({ data = {} }: ActivityHeatmapProps) {
<GlassCard variant="base">
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">Activity</span>
<span className="text-[10px] text-text-secondary/40">hour x day</span>
<span className="text-[10px] text-text-secondary/40">hour × day</span>
</div>
<div className="overflow-x-auto">
<div className="flex gap-0.5 min-w-[400px]">
@@ -46,7 +46,7 @@ export function ActivityHeatmap({ data = {} }: ActivityHeatmapProps) {
<div
key={`${day}-${hour}`}
className={cn("size-3 rounded-sm transition-colors", getIntensity(day, hour))}
title={`${day} ${hour}:00 - ${data[`${day}-${hour}`] || 0}`}
title={`${day} ${hour}:00 ${data[`${day}-${hour}`] || 0}`}
/>
))}
<div className="h-3 flex items-center justify-center text-[8px] text-text-secondary/30 font-mono">
@@ -5,7 +5,7 @@ import { GuildSelector } from "@/components/shared/guild-selector";
interface HiddenSidebarProps {
guildId: string;
onGuildChange: (guildId: string | null) => void;
onGuildChange: (guildId: string) => void;
}
export function HiddenSidebar({ guildId, onGuildChange }: HiddenSidebarProps) {
@@ -24,6 +24,7 @@ export function HiddenSidebar({ guildId, onGuildChange }: HiddenSidebarProps) {
return (
<>
{/* Hotspot trigger */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: transparent mouse detection zone, not interactive content */}
<div
className="fixed left-0 top-0 bottom-0 w-1 z-50"
onMouseEnter={handleMouseEnter}
@@ -31,6 +32,8 @@ export function HiddenSidebar({ guildId, onGuildChange }: HiddenSidebarProps) {
{/* Sidebar */}
<div
role="region"
aria-label="Guild selector sidebar"
className={`fixed left-0 top-0 bottom-0 z-40 w-56 glass-intense border-r border-glass-border transition-transform duration-150 ease-out ${
visible ? "translate-x-0" : "-translate-x-full"
}`}
@@ -15,9 +15,19 @@ interface SubNavProps {
className?: string;
}
export function SubNav({ tabs, activeTab, onTabChange, className }: SubNavProps) {
export function SubNav({
tabs,
activeTab,
onTabChange,
className,
}: SubNavProps) {
return (
<div className={cn("flex items-center gap-1 px-1 py-1 glass rounded-[var(--radius-panel)] w-fit", className)}>
<div
className={cn(
"flex items-center gap-1 px-1 py-1 glass rounded-[var(--radius-panel)] w-fit",
className,
)}
>
{tabs.map((tab) => (
<button
key={tab.id}
@@ -1,55 +1,90 @@
"use client";
import { useRef, useEffect } from "react";
import { Send } from "lucide-react";
import { useState } from "react";
import { useMascot } from "./mascot-context";
export function ChatPanel() {
const { chatHistory, addChat, setExpression } = useMascot();
const [input, setInput] = useState("");
interface ChatPanelProps {
inputRef?: React.RefObject<HTMLInputElement | null>;
}
const handleSend = () => {
if (!input.trim()) return;
addChat("user", input);
setExpression("listening");
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
const { messages, sendMessage, isTyping } = useMascot();
const listRef = useRef<HTMLDivElement>(null);
const internalInputRef = useRef<HTMLInputElement>(null);
const inputRef = externalInputRef ?? internalInputRef;
// Simulated bot response — replace with actual mascot-chat API call
setTimeout(() => {
addChat("assistant", "I'm monitoring this server for you!");
setExpression("happy");
}, 800);
// Auto-scroll to bottom on new messages
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages, isTyping]);
setInput("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const input = inputRef.current;
if (!input || !input.value.trim()) return;
sendMessage(input.value);
input.value = "";
};
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto px-2 py-1 space-y-1">
{chatHistory.slice(-6).map((msg, i) => (
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<span className={`text-[10px] px-2 py-1 rounded-lg max-w-[85%] ${
msg.role === "user"
? "bg-primary/20 text-text-primary"
: "glass text-text-secondary"
}`}>
{msg.text}
{/* Chat messages */}
<div ref={listRef} className="flex-1 overflow-y-auto px-2 py-1 space-y-1">
{messages.length === 0 && (
<div className="flex items-center justify-center h-full">
<p className="text-[10px] text-text-secondary/40">Ask mascot anything</p>
</div>
)}
{messages.slice(-8).map((msg, i) => (
<div
key={`${msg.timestamp}-${i}`}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<span
className={`text-[10px] px-2 py-1 rounded-lg max-w-[85%] leading-relaxed ${
msg.role === "user"
? "bg-primary/20 text-text-primary"
: "glass text-text-secondary"
}`}
>
{msg.content}
</span>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="glass rounded-lg px-2 py-1">
<span className="inline-flex gap-0.5">
<span className="size-1 rounded-full bg-text-secondary animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="size-1 rounded-full bg-text-secondary animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="size-1 rounded-full bg-text-secondary animate-bounce" style={{ animationDelay: "300ms" }} />
</span>
</div>
</div>
)}
</div>
<div className="flex items-center gap-1 px-2 py-1 border-t border-glass-border">
{/* Input bar */}
<form onSubmit={handleSubmit} className="flex items-center gap-1 px-2 py-1.5 border-t border-glass-border shrink-0">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Ask mascot..."
className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none"
disabled={isTyping}
/>
<button type="button" onClick={handleSend} className="size-5 flex items-center justify-center">
<button
type="submit"
className="size-5 flex items-center justify-center disabled:opacity-40"
disabled={isTyping}
aria-label="Send message"
>
<Send className="size-3 text-primary" />
</button>
</div>
</form>
</div>
);
}
@@ -1,3 +1,4 @@
export { MascotProvider } from "./mascot-context";
export { MascotProvider, useMascot } from "./mascot-context";
export { MascotContainer } from "./mascot-container";
export { useMascot } from "./mascot-context";
export { MascotCanvas } from "./mascot-canvas";
export { ChatPanel } from "./chat-panel";
@@ -112,6 +112,11 @@ export function MascotCanvas() {
ctx.arc(w / 2, 75, 6, 0.1, Math.PI - 0.1);
ctx.stroke();
}
// Breathing animation — subtle canvas shift
const breath = Math.sin(Date.now() / 1000) * 1.5;
// Applied via CSS transform on container instead
}, [expression]);
return (
@@ -1,28 +1,38 @@
"use client";
import { MessageCircle, X, Minimize2, Maximize2 } from "lucide-react";
import { useRef, useState, useCallback, useEffect } from "react";
import { Bot, MessageCircle, Minimize2 } from "lucide-react";
import { useMascot } from "./mascot-context";
import { MascotCanvas } from "./mascot-canvas";
import { ChatPanel } from "./chat-panel";
import { useState } from "react";
export function MascotContainer() {
const { minimized, setMinimized, chatOpen, setChatOpen } = useMascot();
const [position, setPosition] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const inputRef = useRef<HTMLInputElement>(null);
const handleMouseDown = (e: React.MouseEvent) => {
const handleMouseDown = useCallback((e: React.MouseEvent) => {
setDragging(true);
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
};
}, [position]);
const handleMouseMove = (e: React.MouseEvent) => {
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!dragging) return;
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
};
}, [dragging, dragStart]);
const handleMouseUp = () => setDragging(false);
const handleMouseUp = useCallback(() => setDragging(false), []);
// Focus input when chat opens
useEffect(() => {
if (chatOpen) {
// Small delay for the animation
const id = setTimeout(() => inputRef.current?.focus(), 150);
return () => clearTimeout(id);
}
}, [chatOpen]);
return (
<div
@@ -35,9 +45,9 @@ export function MascotContainer() {
{/* Main mascot bubble */}
<div
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
minimized ? "w-16 h-16 cursor-pointer" : "w-[200px]"
minimized ? "w-14 h-14 cursor-pointer" : "w-[220px]"
}`}
style={{ height: minimized ? 64 : 280 }}
style={{ height: minimized ? 56 : 320 }}
>
{minimized ? (
<button
@@ -45,8 +55,9 @@ export function MascotContainer() {
onClick={() => setMinimized(false)}
className="w-full h-full flex items-center justify-center"
onMouseDown={handleMouseDown}
aria-label="Open mascot"
>
<MessageCircle className="size-6 text-primary" />
<Bot className="size-6 text-primary" />
</button>
) : (
<>
@@ -55,13 +66,29 @@ export function MascotContainer() {
className="flex items-center justify-between px-3 py-1.5 border-b border-glass-border cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown}
>
<span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">Mascot</span>
<span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">
Mascot
</span>
<div className="flex items-center gap-1">
<button type="button" onClick={() => setChatOpen(!chatOpen)}>
<button
type="button"
onClick={() => setChatOpen(!chatOpen)}
className="size-5 flex items-center justify-center rounded hover:bg-glass-bg transition-colors"
aria-label={chatOpen ? "Close chat" : "Open chat"}
>
<MessageCircle className="size-3 text-text-secondary/60 hover:text-text-primary" />
</button>
<button type="button" onClick={() => setMinimized(true)}>
<Minimize2 className="size-3 text-text-secondary/60 hover:text-text-primary" />
<button
type="button"
onClick={() => setMinimized(true)}
className="size-5 flex items-center justify-center rounded hover:bg-glass-bg transition-colors"
aria-label="Minimize mascot"
>
{minimized ? (
<Bot className="size-3 text-text-secondary/60 hover:text-text-primary" />
) : (
<Minimize2 className="size-3 text-text-secondary/60 hover:text-text-primary" />
)}
</button>
</div>
</div>
@@ -72,8 +99,12 @@ export function MascotContainer() {
</div>
{/* Chat panel (expandable) */}
<div className={`transition-all duration-200 overflow-hidden ${chatOpen ? "h-[120px]" : "h-0"}`}>
<ChatPanel />
<div
className={`transition-all duration-200 overflow-hidden ${
chatOpen ? "h-[130px]" : "h-0"
}`}
>
<ChatPanel inputRef={inputRef} />
</div>
</>
)}
@@ -1,43 +1,161 @@
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { chatbotApi } from "@/lib/api";
import type { ChatHistoryMessage } from "@/lib/types";
type MascotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking";
export type MascotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking";
interface MascotContextType {
expression: MascotExpression;
minimized: boolean;
chatOpen: boolean;
chatHistory: { role: "user" | "assistant"; text: string }[];
setExpression: (expr: MascotExpression) => void;
setMinimized: (v: boolean) => void;
setChatOpen: (v: boolean) => void;
addChat: (role: "user" | "assistant", text: string) => void;
interface MascotMessage {
role: "user" | "assistant";
content: string;
timestamp: string;
}
const MascotContext = createContext<MascotContextType | null>(null);
interface MascotContextValue {
/** Expression the mascot avatar should display */
expression: MascotExpression;
setExpression: (expr: MascotExpression) => void;
/** Whether the enlarged bubble is minimized to a small icon */
minimized: boolean;
setMinimized: (v: boolean) => void;
/** Whether the chat panel inside the bubble is open */
chatOpen: boolean;
setChatOpen: (v: boolean) => void;
/**
* @deprecated Use `minimized` / `setMinimized` instead.
* Legacy toggle alias kept for compatibility.
*/
isOpen: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
/** Chat messages with real API backend */
messages: MascotMessage[];
sendMessage: (content: string) => Promise<void>;
clearMessages: () => Promise<void>;
isTyping: boolean;
}
const MascotContext = createContext<MascotContextValue | null>(null);
export function MascotProvider({ children }: { children: ReactNode }) {
const [expression, setExpression] = useState<MascotExpression>("idle");
const [minimized, setMinimized] = useState(true);
const [chatOpen, setChatOpen] = useState(false);
const [chatHistory, setChatHistory] = useState<{ role: "user" | "assistant"; text: string }[]>([]);
const [messages, setMessages] = useState<MascotMessage[]>([]);
const [isTyping, setIsTyping] = useState(false);
const historyFetched = useRef(false);
const addChat = (role: "user" | "assistant", text: string) => {
setChatHistory((prev) => [...prev, { role, text }]);
};
// Derived legacy state
const isOpen = !minimized;
const setOpen = useCallback((open: boolean) => {
setMinimized(!open);
}, []);
const toggle = useCallback(() => {
setMinimized((prev) => !prev);
}, []);
// Load chat history on first mount
useEffect(() => {
if (historyFetched.current) return;
historyFetched.current = true;
chatbotApi.getHistory().then((history) => {
const mapped = (history ?? []).map((msg: ChatHistoryMessage) => ({
role: msg.role as "user" | "assistant",
content: msg.content,
timestamp: msg.timestamp,
}));
setMessages(mapped);
}).catch(() => {
// API may not be available yet — silently ignore
});
}, []);
const sendMessage = useCallback(async (content: string) => {
if (!content.trim()) return;
const userMsg: MascotMessage = {
role: "user",
content: content.trim(),
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
setExpression("listening");
setIsTyping(true);
try {
const res = await chatbotApi.send(content.trim());
const botMsg: MascotMessage = {
role: "assistant",
content: res.response,
timestamp: res.timestamp ?? new Date().toISOString(),
};
setMessages((prev) => [...prev, botMsg]);
setExpression("happy");
} catch {
const errorMsg: MascotMessage = {
role: "assistant",
content: "Sorry, I couldn't process that request. Please try again.",
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, errorMsg]);
setExpression("sad");
} finally {
setIsTyping(false);
}
}, []);
const clearMessages = useCallback(async () => {
try {
await chatbotApi.clearHistory();
} catch {
// Best-effort clear
}
setMessages([]);
}, []);
return (
<MascotContext.Provider
value={{ expression, minimized, chatOpen, chatHistory, setExpression, setMinimized, setChatOpen, addChat }}
value={{
expression,
setExpression,
minimized,
setMinimized,
chatOpen,
setChatOpen,
isOpen,
setOpen,
toggle,
messages,
sendMessage,
clearMessages,
isTyping,
}}
>
{children}
</MascotContext.Provider>
);
}
export function useMascot() {
export function useMascot(): MascotContextValue {
const ctx = useContext(MascotContext);
if (!ctx) throw new Error("useMascot must be used within MascotProvider");
if (!ctx) {
throw new Error("useMascot must be used within a MascotProvider");
}
return ctx;
}
@@ -1,41 +1,79 @@
"use client";
import { Play, SkipForward, Volume2, X } from "lucide-react";
import { Disc3, Music, Play, SkipForward, Square, Volume2 } from "lucide-react";
import { useMediaPlayer } from "@/lib/hooks/use-media-player";
export function MiniPlayer() {
const { currentTrack, playing, volume, skip, stop, setVolume } = useMediaPlayer();
const { playing, current, queue, volume, pending, skip, stop, setVolume } =
useMediaPlayer();
if (!currentTrack) return null;
// Nothing to show if no track is playing and nothing is queued
if (!current && queue.length === 0) return null;
return (
<div className="fixed bottom-16 md:bottom-4 left-4 z-30 glass-elevated rounded-[var(--radius-card)] p-3 w-64 shadow-2xl">
<div className="flex items-center gap-2 mb-2">
<div className="size-6 flex items-center justify-center rounded bg-primary/20">
<Play className="size-3 text-primary" />
<div className="fixed bottom-0 left-0 right-0 z-40 h-14 glass-intense border-t border-glass-border flex items-center gap-3 px-4 md:px-6">
{/* Track info */}
<div className="flex items-center gap-2.5 min-w-0 flex-1 max-w-[280px]">
<div className="size-8 rounded-md bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/10 flex items-center justify-center shrink-0">
{playing ? (
<Disc3 className="size-4 text-primary animate-spin" style={{ animationDuration: "4s" }} />
) : (
<Music className="size-4 text-text-secondary" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-text-primary truncate">{currentTrack.title}</p>
{currentTrack.artist && (
<p className="text-[10px] text-text-secondary/50 truncate">{currentTrack.artist}</p>
<div className="min-w-0">
<p className="text-xs font-medium text-text-primary truncate">
{current?.title ?? "Unknown track"}
</p>
{queue.length > 0 && (
<p className="text-[10px] text-text-secondary/60">
{queue.length > 1
? `${queue.length} in queue`
: "1 in queue"}
</p>
)}
</div>
<button type="button" onClick={stop} className="size-5 flex items-center justify-center hover:bg-glass-bg rounded">
<X className="size-3 text-text-secondary/60" />
</button>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={skip} className="size-6 flex items-center justify-center hover:bg-glass-bg rounded">
<SkipForward className="size-3 text-text-secondary/60" />
</button>
<Volume2 className="size-3 text-text-secondary/40" />
{/* Controls */}
<div className="flex items-center gap-1 shrink-0">
{playing && (
<button
type="button"
onClick={stop}
disabled={pending}
className="size-8 flex items-center justify-center rounded-md text-text-secondary hover:text-destructive hover:bg-glass-bg transition-colors disabled:opacity-40"
aria-label="Stop"
>
<Square className="size-3.5" />
</button>
)}
{current && (
<button
type="button"
onClick={skip}
disabled={pending || queue.length === 0}
className="size-8 flex items-center justify-center rounded-md text-text-secondary hover:text-text-primary hover:bg-glass-bg transition-colors disabled:opacity-40"
aria-label="Skip"
>
<SkipForward className="size-3.5" />
</button>
)}
</div>
{/* Volume */}
<div className="flex items-center gap-2 shrink-0 ml-2">
<Volume2 className="size-3.5 text-text-secondary/60" />
<input
type="range"
min={0}
max={100}
min="0"
max="1"
step="0.05"
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
className="flex-1 h-1 appearance-none bg-glass-border rounded-full accent-primary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary"
className="w-20 h-1 appearance-none rounded-full bg-glass-bg accent-primary cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary"
aria-label="Volume"
/>
</div>
</div>
@@ -1,31 +1,53 @@
"use client";
import { Loader2 } from "lucide-react";
import { MessageCard } from "./message-card";
import { Button } from "@/components/ui/button";
import type { MessageRecord } from "@/lib/types";
interface MessageListProps {
messages: MessageRecord[];
selectedId?: string | null;
selectedId: string | null;
onSelect: (id: string) => void;
onReanalyze?: (id: string) => void;
hasMore?: boolean;
onLoadMore?: () => void;
isLoadingMore?: boolean;
}
export function MessageList({ messages, selectedId, onSelect }: MessageListProps) {
export function MessageList({
messages,
selectedId: _selectedId,
onSelect,
onReanalyze,
hasMore,
onLoadMore,
isLoadingMore,
}: MessageListProps) {
return (
<div className="space-y-1.5 overflow-y-auto max-h-[calc(100vh-200px)] pr-1">
{messages.length === 0 ? (
<div className="flex items-center justify-center py-12 text-text-secondary/40 text-sm">
No messages
<>
{messages.map((msg) => (
<MessageCard
key={msg.id}
message={msg}
onClick={onSelect}
onReanalyze={(id) => onReanalyze?.(id)}
/>
))}
{hasMore && (
<div className="flex justify-center py-4">
<Button
variant="outline"
size="sm"
onClick={onLoadMore}
disabled={isLoadingMore}
className="text-xs glass"
>
{isLoadingMore && <Loader2 className="size-3 animate-spin mr-1" />}
Load more
</Button>
</div>
) : (
messages.map((msg) => (
<MessageCard
key={msg.id}
message={msg}
selected={selectedId === msg.id}
onClick={onSelect}
/>
))
)}
</div>
</>
);
}
@@ -16,11 +16,11 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const { data: results } = useQuery<{ results: MessageRecord[] }>({
const { data: results } = useQuery<MessageRecord[]>({
queryKey: ["messages-search", query],
queryFn: async () => {
const res = await messagesApi.search(query, 20);
return res;
return res.results;
},
enabled: query.length >= 2,
});
@@ -37,7 +37,7 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
const handleKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
onClose();
onClose(); // this is called when Cmd+K is pressed globally — toggle
}
if (e.key === "Escape") onClose();
};
@@ -69,12 +69,12 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
{/* Results */}
<div className="max-h-80 overflow-y-auto p-2 space-y-1">
{!results || results.results.length === 0 ? (
{!results || results.length === 0 ? (
<div className="py-8 text-center text-xs text-text-secondary/40">
{query.length < 2 ? "Type at least 2 characters" : "No results found"}
</div>
) : (
results.results.map((msg: MessageRecord) => (
results.map((msg) => (
<button
key={msg.id}
type="button"
@@ -10,6 +10,10 @@ interface RecordingCardProps {
}
export function RecordingCard({ recording, onPlay }: RecordingCardProps) {
const durationStr = recording.duration_bytes
? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}`
: "--:--";
return (
<GlassCard variant="interactive" className="p-4" onClick={() => onPlay(recording.id)}>
<div className="flex items-start gap-3">
@@ -39,9 +43,7 @@ export function RecordingCard({ recording, onPlay }: RecordingCardProps) {
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] font-mono text-text-secondary/60">
{recording.duration_bytes ? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}` : "--:--"}
</span>
<span className="text-[10px] font-mono text-text-secondary/60">{durationStr}</span>
<span className="text-[10px] text-text-secondary/40">{new Date(recording.created_at).toLocaleString()}</span>
</div>
</div>
@@ -5,7 +5,7 @@ import { GlassPanel } from "@/components/glass/panel";
import { X } from "lucide-react";
interface RecordingPlayerProps {
url?: string | null;
url?: string;
onClose: () => void;
}
@@ -1,20 +1,23 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { Inbox } from "lucide-react";
import { GlassPanel } from "@/components/glass/panel";
interface EmptyStateProps {
icon?: LucideIcon;
title?: string;
description?: string;
}
export function EmptyState({
icon: Icon = Inbox,
title = "No data yet",
description = "Nothing to display here yet.",
}: EmptyStateProps) {
return (
<GlassPanel dense className="flex flex-col items-center gap-2 py-12">
<Inbox className="size-8 text-text-secondary/20" />
<Icon className="size-8 text-text-secondary/20" />
<p className="text-sm text-text-secondary/60">{title}</p>
<p className="text-xs text-text-secondary/40">{description}</p>
</GlassPanel>
@@ -33,7 +33,7 @@ export function LoadingSkeleton({
if (columns) {
return (
<div className={`grid grid-cols-1 md:grid-cols-${Math.min(columns, 6)} gap-3`}>
<div className={`grid grid-cols-1 md:grid-cols-${columns} gap-3`}>
{items}
</div>
);
@@ -1,28 +1,59 @@
"use client";
import { GlassCard } from "@/components/glass/card";
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import {
Bar,
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
interface VoiceActivityTimelineProps {
interface ActivityTimelineProps {
data?: { user: string; duration: number }[];
}
export function VoiceActivityTimeline({ data = [] }: VoiceActivityTimelineProps) {
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
return (
<GlassCard variant="base">
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">Voice Activity</span>
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
Voice Activity
</span>
</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical">
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} />
<YAxis type="category" dataKey="user" axisLine={false} tickLine={false} tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} width={80} />
<XAxis
type="number"
axisLine={false}
tickLine={false}
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
/>
<YAxis
type="category"
dataKey="user"
axisLine={false}
tickLine={false}
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
width={80}
/>
<Tooltip
contentStyle={{ background: "oklch(0.11 0.02 245 / 0.9)", border: "1px solid oklch(1 0 0 / 0.08)", borderRadius: 8, fontSize: 12, color: "oklch(0.93 0.01 245)" }}
formatter={(value) => `${Number(value) / 60}m`}
contentStyle={{
background: "oklch(0.11 0.02 245 / 0.9)",
border: "1px solid oklch(1 0 0 / 0.08)",
borderRadius: 8,
fontSize: 12,
color: "oklch(0.93 0.01 245)",
}}
formatter={(value) => [`${(Number(value) / 60).toFixed(1)}m`, "Duration"]}
/>
<Bar
dataKey="duration"
fill="var(--color-primary)"
radius={[0, 4, 4, 0]}
/>
<Bar dataKey="duration" fill="var(--color-primary)" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
@@ -2,14 +2,21 @@
import { GlassCard } from "@/components/glass/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Channel, Guild } from "@/lib/types";
import { cn } from "@/lib/utils";
interface ConnectionCardProps {
connected: boolean;
activeChannelName?: string | null;
guilds: { id: string; name: string }[];
voiceChannels: { id: string; name: string }[];
guilds: Guild[];
voiceChannels: Channel[];
selectedGuild: string;
selectedChannel: string;
onGuildChange: (guildId: string | null) => void;
@@ -20,25 +27,34 @@ interface ConnectionCardProps {
}
export function VoiceConnectionCard({
connected, activeChannelName, guilds, voiceChannels,
selectedGuild, selectedChannel,
onGuildChange, onChannelChange, onConnect, onDisconnect, connecting,
connected,
activeChannelName,
guilds,
voiceChannels,
selectedGuild,
selectedChannel,
onGuildChange,
onChannelChange,
onConnect,
onDisconnect,
connecting,
}: ConnectionCardProps) {
return (
<GlassCard variant={connected ? "elevated" : "base"}>
<div className="flex items-center gap-3 mb-4">
<span className={cn(
"relative flex size-3",
connected && "text-emerald-500",
)}>
<span className={cn(
"absolute inline-flex size-full rounded-full opacity-75",
connected ? "bg-emerald-500 animate-pulse-ring" : "bg-destructive",
)} />
<span className={cn(
"relative inline-flex size-3 rounded-full",
connected ? "bg-emerald-500" : "bg-destructive",
)} />
<span className={cn("relative flex size-3", connected && "text-emerald-500")}>
<span
className={cn(
"absolute inline-flex size-full rounded-full opacity-75",
connected ? "bg-emerald-500 animate-pulse-ring" : "bg-destructive",
)}
/>
<span
className={cn(
"relative inline-flex size-3 rounded-full",
connected ? "bg-emerald-500" : "bg-destructive",
)}
/>
</span>
<div>
<span className="text-sm font-semibold text-text-primary">Voice Connection</span>
@@ -48,7 +64,9 @@ export function VoiceConnectionCard({
</div>
<div className="ml-auto flex items-center gap-2">
{connected ? (
<Button size="sm" variant="destructive" onClick={onDisconnect}>Disconnect</Button>
<Button size="sm" variant="destructive" onClick={onDisconnect}>
Disconnect
</Button>
) : (
<Button size="sm" onClick={onConnect} disabled={!selectedGuild || !selectedChannel || connecting}>
{connecting ? "Connecting..." : "Connect"}
@@ -58,23 +76,37 @@ export function VoiceConnectionCard({
</div>
<div className="grid grid-cols-2 gap-3">
<Select value={selectedGuild} onValueChange={(v) => { onGuildChange(v ?? null); onChannelChange(""); }}>
<Select
value={selectedGuild}
onValueChange={(v) => {
onGuildChange(v);
onChannelChange("");
}}
>
<SelectTrigger className="h-8 glass border-glass-border text-xs">
<SelectValue placeholder="Select guild" />
</SelectTrigger>
<SelectContent>
{guilds.map((g) => (
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedChannel} onValueChange={onChannelChange} disabled={!selectedGuild}>
<Select
value={selectedChannel}
onValueChange={onChannelChange}
disabled={!selectedGuild}
>
<SelectTrigger className="h-8 glass border-glass-border text-xs">
<SelectValue placeholder="Select channel" />
</SelectTrigger>
<SelectContent>
{voiceChannels.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -12,7 +12,13 @@ interface MicControlProps {
onVolumeChange: (v: number) => void;
}
export function MicControl({ connected, active, onToggle, volume, onVolumeChange }: MicControlProps) {
export function MicControl({
connected,
active,
onToggle,
volume,
onVolumeChange,
}: MicControlProps) {
return (
<GlassCard variant="base">
<div className="flex items-center gap-3">
@@ -56,11 +56,20 @@ export function SpeakerWaveform({ speakers }: SpeakerWaveformProps) {
<div className="space-y-1">
{speakers.map((s) => (
<div key={s.userId} className="flex items-center gap-2 text-xs">
<span className={s.speaking ? "text-primary font-medium" : "text-text-secondary/60"}>{s.username}</span>
<span
className={s.speaking ? "text-primary font-medium" : "text-text-secondary/60"}
>
{s.username}
</span>
</div>
))}
</div>
<canvas ref={canvasRef} width={400} height={speakers.length * 30} className="w-full h-auto mt-2 rounded" />
<canvas
ref={canvasRef}
width={400}
height={speakers.length * 30}
className="w-full h-auto mt-2 rounded"
/>
</GlassPanel>
);
}