diff --git a/services/frontend/src/app/(dashboard)/layout.tsx b/services/frontend/src/app/(dashboard)/layout.tsx index 8d8e7ad..cc6c082 100644 --- a/services/frontend/src/app/(dashboard)/layout.tsx +++ b/services/frontend/src/app/(dashboard)/layout.tsx @@ -1,19 +1,15 @@ "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Suspense } from "react"; +import { Suspense, useEffect, useState } from "react"; import { TopNav } from "@/components/layout/top-nav"; import { MobileNav } from "@/components/layout/mobile-nav"; -import { WsProvider } from "@/lib/ws/context"; -import { MascotProvider } from "@/components/mascot/mascot-context"; +import { WsProvider, useWebSocket } from "@/lib/ws/context"; +import { MascotProvider, useMascot } 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"; -import { useWebSocket } from "@/lib/ws/context"; -import { useMascot } from "@/components/mascot/mascot-context"; -import { useEffect } from "react"; const queryClient = new QueryClient({ defaultOptions: { @@ -41,7 +37,10 @@ function MascotExpressionSync() { setExpression("listening"); }); - return () => { unsub1(); unsub2(); }; + return () => { + unsub1(); + unsub2(); + }; }, [ws, setExpression]); return null; @@ -59,10 +58,10 @@ export default function DashboardLayout({ +
setGuildId(g ?? "")} /> - {/* Sub-nav space — filled per-page */}
diff --git a/services/frontend/src/app/(dashboard)/messages/page.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx index b08d0f5..12f2805 100644 --- a/services/frontend/src/app/(dashboard)/messages/page.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -2,10 +2,11 @@ import { useCallback, useEffect, useState } from "react"; import { useSearchParams, useRouter } from "next/navigation"; -import { Search, Flag, Image, Loader2, RefreshCw } from "lucide-react"; +import { Flag, Image, Loader2, RefreshCw, Search } from "lucide-react"; import { MessageList } from "@/components/messages/message-list"; -import { MessageDetail } from "@/components/messages/message-detail"; +import { MessageDetailView } from "@/components/messages/message-detail-view"; import { SearchOverlay } from "@/components/messages/search-overlay"; +import { extractFirstImage } from "@/components/messages/message-card"; import { SubNav } from "@/components/layout/sub-nav"; import { ErrorState, LoadingSkeleton } from "@/components/shared"; import { GlassCard } from "@/components/glass/card"; @@ -31,9 +32,10 @@ import { useReview, useTextChannels, } from "@/hooks"; +import type { MessageRecord } from "@/lib/types"; +import { cn } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; import { GuildSelector } from "@/components/shared/guild-selector"; -import { cn } from "@/lib/utils"; type MessagesTab = "all" | "images" | "review"; @@ -41,9 +43,15 @@ export default function MessagesPage() { const router = useRouter(); const searchParams = useSearchParams(); const [guildId, setGuildId] = useState(searchParams.get("guild") || ""); - const [selectedChannel, setSelectedChannel] = useState(searchParams.get("channel") || ""); - const [detailId, setDetailId] = useState(searchParams.get("selected")); - const [tab, setTab] = useState((searchParams.get("tab") as MessagesTab) || "all"); + const [selectedChannel, setSelectedChannel] = useState( + searchParams.get("channel") || "", + ); + const [detailId, setDetailId] = useState( + searchParams.get("selected"), + ); + const [tab, setTab] = useState( + (searchParams.get("tab") as MessagesTab) || "all", + ); const [searchOpen, setSearchOpen] = useState(false); const ws = useWebSocket(); @@ -64,7 +72,7 @@ export default function MessagesPage() { useMessagesWsSync(ws, guildId); - // Sync to URL + // Sync state to URL useEffect(() => { const params = new URLSearchParams(); if (guildId) params.set("guild", guildId); @@ -74,7 +82,7 @@ export default function MessagesPage() { router.replace(`/messages?${params.toString()}`, { scroll: false }); }, [guildId, selectedChannel, detailId, tab, router]); - // Global Cmd+K + // Global Cmd+K search trigger useEffect(() => { const handleKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { @@ -95,8 +103,14 @@ export default function MessagesPage() { }); }, [cursorData, loadMoreMut, guildId, selectedChannel]); + const handleGuildChange = useCallback((g: string) => { + setGuildId(g); + setSelectedChannel(""); + setDetailId(null); + }, []); + const subNavTabs = [ - { id: "all", label: "All" }, + { id: "all", label: "All", icon: null }, { id: "images", label: "Images", icon: }, { id: "review", label: "Review", icon: }, ]; @@ -105,11 +119,14 @@ export default function MessagesPage() { return (
- {/* Controls bar */} + {/* ── Controls bar ── */}
- { setGuildId(g ?? ""); setSelectedChannel(""); }} /> + {channels.length > 0 && ( - setSelectedChannel(v ?? "")} + > @@ -124,105 +141,183 @@ export default function MessagesPage() { -
- setTab(t as MessagesTab)} /> + {/* ── Sub navigation ── */} + setTab(t as MessagesTab)} + /> - {/* Split pane */} + {/* ── Split pane ── */} {error ? ( ) : isLoading ? ( ) : (
- {/* Left pane — message list */} -
+ {/* Left pane */} +
{tab === "all" && ( - <> - - {cursorData?.hasMore && ( -
- -
- )} - + reanalyzeMut.mutate(id)} + hasMore={cursorData?.hasMore} + onLoadMore={handleLoadMore} + isLoadingMore={loadMoreMut.isPending} + /> )} {tab === "images" && ( )} {tab === "review" && ( - reanalyzeMut.mutate(id)} /> + )}
- {/* Right pane — detail */} + {/* Right pane — message detail */} {detailId && ( -
+
{detailLoading ? ( ) : detailMessage ? ( - setDetailId(null)} - /> +
+ + +
) : null}
)}
)} - {/* Search overlay */} - setSearchOpen(false)} onSelect={setDetailId} /> + {/* ── Search overlay ── */} + setSearchOpen(false)} + onSelect={(id) => { + setDetailId(id); + setTab("all"); + }} + />
); } -// Inline ImageGrid and ReviewList -function ImageGrid({ items, onSelect }: { items: any[]; onSelect: (id: string) => void }) { +// ── Inline ImageGrid (glass-styled) ──────────────── + +function ImageGrid({ + items, + onSelect, +}: { + items: MessageRecord[]; + onSelect: (id: string) => void; +}) { return (
- {items.map((item: any) => ( - - ))} + {items.map((item) => { + const imgUrl = extractFirstImage(item.metadata); + return ( + + ); + })} {items.length === 0 && ( -
No images
+
+ No images +
)}
); } -function ReviewList({ items, onSelect, onReanalyze }: { items: any[]; onSelect: (id: string) => void; onReanalyze: (id: string) => void }) { +// ── Inline ReviewList (glass-styled) ──────────────── + +function ReviewList({ + items, + onSelect, +}: { + items: MessageRecord[]; + onSelect: (id: string) => void; +}) { return (
- {items.map((item: any) => ( - onSelect(item.message_id)}> + {items.map((item) => ( + onSelect(item.id)} + >
- -
-

{item.content || item.id}

+ +
+

+ {item.content || item.id} +

))} {items.length === 0 && ( -
No flagged messages
+
+ No flagged messages +
)}
); diff --git a/services/frontend/src/app/(dashboard)/recordings/page.tsx b/services/frontend/src/app/(dashboard)/recordings/page.tsx index 4bdc9df..a68a795 100644 --- a/services/frontend/src/app/(dashboard)/recordings/page.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/page.tsx @@ -6,26 +6,27 @@ import { RecordingPlayer } from "@/components/recordings/recording-player"; import { SubNav } from "@/components/layout/sub-nav"; import { ErrorState, LoadingSkeleton } from "@/components/shared"; import { useRecordings } from "@/hooks"; -import { useWebSocket } from "@/lib/ws/context"; +import type { VoiceRecording } from "@/lib/types"; + +type RecordingsTab = "library" | "stats"; type RecordingsTab = "library" | "stats"; export default function RecordingsPage() { - const ws = useWebSocket(); const { data: recordings, isLoading, error, refetch } = useRecordings(); const [playingId, setPlayingId] = useState(null); const [tab, setTab] = useState("library"); const currentTrack = playingId && recordings - ? recordings.find((r: any) => r.id === playingId) + ? recordings.find((r: VoiceRecording) => r.id === playingId) : null; return (
setTab(t as RecordingsTab)} @@ -39,7 +40,7 @@ export default function RecordingsPage() { ) : (
- {(recordings ?? []).map((rec: any) => ( + {(recordings ?? []).map((rec: VoiceRecording) => ( Recording stats coming soon
)} - setPlayingId(null)} /> + setPlayingId(null)} />
); } diff --git a/services/frontend/src/app/(dashboard)/voice/page.tsx b/services/frontend/src/app/(dashboard)/voice/page.tsx index f476441..df72b76 100644 --- a/services/frontend/src/app/(dashboard)/voice/page.tsx +++ b/services/frontend/src/app/(dashboard)/voice/page.tsx @@ -7,7 +7,15 @@ import { MicControl } from "@/components/voice/mic-control"; import { VoiceActivityTimeline } from "@/components/voice/activity-timeline"; import { SubNav } from "@/components/layout/sub-nav"; import { useWebSocket } from "@/lib/ws/context"; -import { useGuilds, useMicTransmit, useSpeakers, useVoiceChannels, useVoiceConnect, useVoiceDisconnect, useVoiceStatus } from "@/hooks"; +import { + useGuilds, + useMicTransmit, + useSpeakers, + useVoiceChannels, + useVoiceConnect, + useVoiceDisconnect, + useVoiceStatus, +} from "@/hooks"; type VoiceTab = "connection" | "activity"; @@ -43,6 +51,15 @@ export default function VoicePage() { [micMut], ); + const handleGuildChange = useCallback((guildId: string | null) => { + if (!guildId) { + setSelectedGuild(""); + setSelectedChannel(""); + return; + } + setSelectedGuild(guildId); + }, []); + const activeSpeakers = speakers.filter((s) => s.speaking); const connected = voiceStatus?.connected ?? false; @@ -50,8 +67,8 @@ export default function VoicePage() {
setTab(t as VoiceTab)} @@ -64,7 +81,7 @@ export default function VoicePage() { voiceChannels={voiceChannels} selectedGuild={selectedGuild} selectedChannel={selectedChannel} - onGuildChange={(g) => { setSelectedGuild(g ?? ""); setSelectedChannel(""); }} + onGuildChange={handleGuildChange} onChannelChange={(v) => setSelectedChannel(v ?? "")} onConnect={() => connectMut.mutate({ guildId: selectedGuild, channelId: selectedChannel })} onDisconnect={() => disconnectMut.mutate(undefined)} diff --git a/services/frontend/src/app/globals.css b/services/frontend/src/app/globals.css index 59d6188..15bf7e0 100644 --- a/services/frontend/src/app/globals.css +++ b/services/frontend/src/app/globals.css @@ -43,6 +43,9 @@ --color-accent: var(--color-primary); --color-accent-foreground: var(--color-primary-foreground); + /* Ring / focus outline for shadcn outline-ring utility */ + --color-ring: var(--color-primary); + /* Radius */ --radius-card: 16px; --radius-panel: 12px; @@ -86,25 +89,111 @@ radial-gradient(ellipse 50% 40% at 80% 80%, oklch(0.65 0.2 280 / 0.04), transparent); background-size: 24px 24px, 100% 100%, 100% 100%; } + html { + @apply font-sans scroll-smooth; + } + + /* Custom selection color */ + ::selection { + background: oklch(0.62 0.17 215 / 0.4); + color: inherit; + } + + /* Scrollbar styling */ + ::-webkit-scrollbar { + width: 6px; + height: 6px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: oklch(1 0 0 / 0.1); + border-radius: 999px; + } + ::-webkit-scrollbar-thumb:hover { + background: oklch(1 0 0 / 0.2); + } ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.1); border-radius: 999px; } ::-webkit-scrollbar-thumb:hover { background: oklch(1 0 0 / 0.2); } } +/* ── Animations ──────────────────────────── */ + @keyframes pulse-ring { - 0% { transform: scale(0.8); opacity: 1; } - 100% { transform: scale(2.5); opacity: 0; } -} -@keyframes fade-in-up { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } -} -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } + 0% { + transform: scale(0.8); + opacity: 1; + } + 100% { + transform: scale(2.5); + opacity: 0; + } } -.animate-fade-in-up { animation: fade-in-up 0.3s ease-out forwards; } -.animate-pulse-ring { animation: pulse-ring 1.5s ease-out infinite; } -.animate-shimmer { background: linear-gradient(90deg, transparent, oklch(0.62 0.17 215 / 0.08), transparent); background-size: 200% 100%; animation: shimmer 1.5s infinite; } +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.animate-fade-in-up { + animation: fade-in-up 0.3s ease-out forwards; +} + +.animate-pulse-ring { + animation: pulse-ring 1.5s ease-out infinite; +} + +.animate-shimmer { + background: linear-gradient(90deg, transparent, oklch(0.62 0.17 215 / 0.08), transparent); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + +/* ── Utility classes ─────────────────────── */ + +/* Gradient text */ +.text-gradient { + background: linear-gradient(135deg, oklch(0.62 0.17 215), oklch(0.6 0.15 195), oklch(0.65 0.12 185)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* Gradient border (via pseudo-element trick) */ +.gradient-border { + position: relative; +} +.gradient-border::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: linear-gradient(135deg, oklch(0.62 0.17 215), oklch(0.6 0.15 195), oklch(0.65 0.12 185)); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; +} + +/* Live pulse ring (compat alias) */ +.live-pulse-ring { + animation: pulse-ring 1.5s ease-out infinite; +} diff --git a/services/frontend/src/components/dashboard/activity-heatmap.tsx b/services/frontend/src/components/dashboard/activity-heatmap.tsx index 2249dce..bb25768 100644 --- a/services/frontend/src/components/dashboard/activity-heatmap.tsx +++ b/services/frontend/src/components/dashboard/activity-heatmap.tsx @@ -27,7 +27,7 @@ export function ActivityHeatmap({ data = {} }: ActivityHeatmapProps) {
Activity - hour x day + hour × day
@@ -46,7 +46,7 @@ export function ActivityHeatmap({ data = {} }: ActivityHeatmapProps) {
))}
diff --git a/services/frontend/src/components/layout/hidden-sidebar.tsx b/services/frontend/src/components/layout/hidden-sidebar.tsx index 54edadf..5e39723 100644 --- a/services/frontend/src/components/layout/hidden-sidebar.tsx +++ b/services/frontend/src/components/layout/hidden-sidebar.tsx @@ -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 */}
+
{tabs.map((tab) => ( -
+
); } diff --git a/services/frontend/src/components/mascot/index.ts b/services/frontend/src/components/mascot/index.ts index 19fb542..361e7df 100644 --- a/services/frontend/src/components/mascot/index.ts +++ b/services/frontend/src/components/mascot/index.ts @@ -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"; diff --git a/services/frontend/src/components/mascot/mascot-canvas.tsx b/services/frontend/src/components/mascot/mascot-canvas.tsx index 222fa66..b037c56 100644 --- a/services/frontend/src/components/mascot/mascot-canvas.tsx +++ b/services/frontend/src/components/mascot/mascot-canvas.tsx @@ -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 ( diff --git a/services/frontend/src/components/mascot/mascot-container.tsx b/services/frontend/src/components/mascot/mascot-container.tsx index 32d0e4f..1144bbe 100644 --- a/services/frontend/src/components/mascot/mascot-container.tsx +++ b/services/frontend/src/components/mascot/mascot-container.tsx @@ -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(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 (
{minimized ? ( ) : ( <> @@ -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} > - Mascot + + Mascot +
- -
@@ -72,8 +99,12 @@ export function MascotContainer() {
{/* Chat panel (expandable) */} -
- +
+
)} diff --git a/services/frontend/src/components/mascot/mascot-context.tsx b/services/frontend/src/components/mascot/mascot-context.tsx index f3c3c51..7a87938 100644 --- a/services/frontend/src/components/mascot/mascot-context.tsx +++ b/services/frontend/src/components/mascot/mascot-context.tsx @@ -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(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; + clearMessages: () => Promise; + isTyping: boolean; +} + +const MascotContext = createContext(null); export function MascotProvider({ children }: { children: ReactNode }) { const [expression, setExpression] = useState("idle"); const [minimized, setMinimized] = useState(true); const [chatOpen, setChatOpen] = useState(false); - const [chatHistory, setChatHistory] = useState<{ role: "user" | "assistant"; text: string }[]>([]); + const [messages, setMessages] = useState([]); + 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 ( {children} ); } -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; } diff --git a/services/frontend/src/components/media/mini-player.tsx b/services/frontend/src/components/media/mini-player.tsx index ff59d5f..0e2a737 100644 --- a/services/frontend/src/components/media/mini-player.tsx +++ b/services/frontend/src/components/media/mini-player.tsx @@ -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 ( -
-
-
- +
+ {/* Track info */} +
+
+ {playing ? ( + + ) : ( + + )}
-
-

{currentTrack.title}

- {currentTrack.artist && ( -

{currentTrack.artist}

+
+

+ {current?.title ?? "Unknown track"} +

+ {queue.length > 0 && ( +

+ {queue.length > 1 + ? `${queue.length} in queue` + : "1 in queue"} +

)}
-
-
- - + + {/* Controls */} +
+ {playing && ( + + )} + {current && ( + + )} +
+ + {/* Volume */} +
+ 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" />
diff --git a/services/frontend/src/components/messages/message-list.tsx b/services/frontend/src/components/messages/message-list.tsx index 322dd78..a7161cb 100644 --- a/services/frontend/src/components/messages/message-list.tsx +++ b/services/frontend/src/components/messages/message-list.tsx @@ -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 ( -
- {messages.length === 0 ? ( -
- No messages + <> + {messages.map((msg) => ( + onReanalyze?.(id)} + /> + ))} + {hasMore && ( +
+
- ) : ( - messages.map((msg) => ( - - )) )} -
+ ); } diff --git a/services/frontend/src/components/messages/search-overlay.tsx b/services/frontend/src/components/messages/search-overlay.tsx index 575201a..154fa18 100644 --- a/services/frontend/src/components/messages/search-overlay.tsx +++ b/services/frontend/src/components/messages/search-overlay.tsx @@ -16,11 +16,11 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) { const [query, setQuery] = useState(""); const inputRef = useRef(null); - const { data: results } = useQuery<{ results: MessageRecord[] }>({ + const { data: results } = useQuery({ 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 */}
- {!results || results.results.length === 0 ? ( + {!results || results.length === 0 ? (
{query.length < 2 ? "Type at least 2 characters" : "No results found"}
) : ( - results.results.map((msg: MessageRecord) => ( + results.map((msg) => (
diff --git a/services/frontend/src/components/recordings/recording-player.tsx b/services/frontend/src/components/recordings/recording-player.tsx index 4db50e6..643cc76 100644 --- a/services/frontend/src/components/recordings/recording-player.tsx +++ b/services/frontend/src/components/recordings/recording-player.tsx @@ -5,7 +5,7 @@ import { GlassPanel } from "@/components/glass/panel"; import { X } from "lucide-react"; interface RecordingPlayerProps { - url?: string | null; + url?: string; onClose: () => void; } diff --git a/services/frontend/src/components/shared/empty-state.tsx b/services/frontend/src/components/shared/empty-state.tsx index d692d22..2bb2d68 100644 --- a/services/frontend/src/components/shared/empty-state.tsx +++ b/services/frontend/src/components/shared/empty-state.tsx @@ -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 ( - +

{title}

{description}

diff --git a/services/frontend/src/components/shared/loading-skeleton.tsx b/services/frontend/src/components/shared/loading-skeleton.tsx index 8320acd..81019ec 100644 --- a/services/frontend/src/components/shared/loading-skeleton.tsx +++ b/services/frontend/src/components/shared/loading-skeleton.tsx @@ -33,7 +33,7 @@ export function LoadingSkeleton({ if (columns) { return ( -
+
{items}
); diff --git a/services/frontend/src/components/voice/activity-timeline.tsx b/services/frontend/src/components/voice/activity-timeline.tsx index 1389784..c658657 100644 --- a/services/frontend/src/components/voice/activity-timeline.tsx +++ b/services/frontend/src/components/voice/activity-timeline.tsx @@ -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 (
- Voice Activity + + Voice Activity +
- - + + `${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"]} + /> + -
diff --git a/services/frontend/src/components/voice/connection-card.tsx b/services/frontend/src/components/voice/connection-card.tsx index 9f0bbec..51cf2f8 100644 --- a/services/frontend/src/components/voice/connection-card.tsx +++ b/services/frontend/src/components/voice/connection-card.tsx @@ -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 (
- - - + + +
Voice Connection @@ -48,7 +64,9 @@ export function VoiceConnectionCard({
{connected ? ( - + ) : (
- { + onGuildChange(v); + onChannelChange(""); + }} + > {guilds.map((g) => ( - {g.name} + + {g.name} + ))} - {voiceChannels.map((c) => ( - {c.name} + + {c.name} + ))} diff --git a/services/frontend/src/components/voice/mic-control.tsx b/services/frontend/src/components/voice/mic-control.tsx index 9750728..7bbe4d0 100644 --- a/services/frontend/src/components/voice/mic-control.tsx +++ b/services/frontend/src/components/voice/mic-control.tsx @@ -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 (
diff --git a/services/frontend/src/components/voice/speaker-waveform.tsx b/services/frontend/src/components/voice/speaker-waveform.tsx index c50f897..cfee868 100644 --- a/services/frontend/src/components/voice/speaker-waveform.tsx +++ b/services/frontend/src/components/voice/speaker-waveform.tsx @@ -56,11 +56,20 @@ export function SpeakerWaveform({ speakers }: SpeakerWaveformProps) {
{speakers.map((s) => (
- {s.username} + + {s.username} +
))}
- + ); } diff --git a/services/frontend/src/lib/hooks/use-media-player.tsx b/services/frontend/src/lib/hooks/use-media-player.tsx index a88aa97..d89c83b 100644 --- a/services/frontend/src/lib/hooks/use-media-player.tsx +++ b/services/frontend/src/lib/hooks/use-media-player.tsx @@ -1,77 +1,130 @@ "use client"; -import { createContext, useContext, useState, type ReactNode } from "react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { useWebSocket } from "@/lib/ws/context"; +import { mediaApi } from "@/lib/api"; +import type { MediaState, MediaItem } from "@/lib/types"; -interface Track { - id: string; - title: string; - artist?: string; - duration?: number; -} - -interface MediaPlayerState { - currentTrack: Track | null; - queue: Track[]; +interface MediaPlayerContextValue { + /** Current play state */ playing: boolean; + /** Current track, or null */ + current: MediaItem | null; + /** Upcoming queue */ + queue: MediaItem[]; + /** Current volume [0-1] */ volume: number; -} + /** True while a mutation is in flight */ + pending: boolean; -interface MediaPlayerContextType extends MediaPlayerState { - play: (track: Track) => void; + /** Skip to next track */ skip: () => void; + /** Stop playback */ stop: () => void; - setVolume: (v: number) => void; - addToQueue: (track: Track) => void; - removeFromQueue: (id: string) => void; + /** Set volume [0-1] */ + setVolume: (vol: number) => void; + /** Queue a URL for playback */ + queueUrl: (url: string) => void; } -const MediaPlayerContext = createContext(null); +const MediaPlayerContext = createContext(null); export function MediaPlayerProvider({ children }: { children: ReactNode }) { - const [state, setState] = useState({ - currentTrack: null, - queue: [], + const ws = useWebSocket(); + const [state, setState] = useState({ playing: false, - volume: 75, + musicVolume: 0.5, + current: null, + queue: [], }); + const [pending, setPending] = useState(false); + const fetched = useRef(false); - const play = (track: Track) => { - setState((prev) => ({ ...prev, currentTrack: track, playing: true })); - }; - - const skip = () => { - setState((prev) => { - if (prev.queue.length === 0) return { ...prev, currentTrack: null, playing: false }; - const [next, ...rest] = prev.queue; - return { ...prev, currentTrack: next, queue: rest }; + // Fetch initial state + useEffect(() => { + if (fetched.current) return; + fetched.current = true; + mediaApi.getStatus().then((data) => { + if (data) setState(data as MediaState); + }).catch(() => { + // API not yet available }); - }; + }, []); - const stop = () => { - setState((prev) => ({ ...prev, currentTrack: null, playing: false })); - }; + // Subscribe to live media_state events via WS + useEffect(() => { + const unsub = ws.on("media_state", (data) => { + setState(data as unknown as MediaState); + }); + return unsub; + }, [ws]); - const setVolume = (volume: number) => { - setState((prev) => ({ ...prev, volume })); - }; + const skip = useCallback(() => { + setPending(true); + mediaApi.skip().then((data) => { + if (data) setState(data as MediaState); + }).catch(() => { + // ignore + }).finally(() => setPending(false)); + }, []); - const addToQueue = (track: Track) => { - setState((prev) => ({ ...prev, queue: [...prev.queue, track] })); - }; + const stop = useCallback(() => { + setPending(true); + mediaApi.stop().then((data) => { + if (data) setState(data as MediaState); + }).catch(() => { + // ignore + }).finally(() => setPending(false)); + }, []); - const removeFromQueue = (id: string) => { - setState((prev) => ({ ...prev, queue: prev.queue.filter((t) => t.id !== id) })); - }; + const setVolume = useCallback((vol: number) => { + mediaApi.volume(vol).then((data) => { + if (data) setState(data as MediaState); + }).catch(() => { + // ignore + }); + }, []); + + const queueUrl = useCallback((url: string) => { + setPending(true); + mediaApi.queue(url, "music").then((data) => { + if (data) setState(data as MediaState); + }).catch(() => { + // ignore + }).finally(() => setPending(false)); + }, []); return ( - + {children} ); } -export function useMediaPlayer() { +export function useMediaPlayer(): MediaPlayerContextValue { const ctx = useContext(MediaPlayerContext); - if (!ctx) throw new Error("useMediaPlayer must be used within MediaPlayerProvider"); + if (!ctx) { + throw new Error("useMediaPlayer must be used within a MediaPlayerProvider"); + } return ctx; }