Merge branch 'worktree-neo-surveillance-redesign'
This commit is contained in:
@@ -1,19 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 { TopNav } from "@/components/layout/top-nav";
|
||||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||||
import { WsProvider } from "@/lib/ws/context";
|
import { WsProvider, useWebSocket } from "@/lib/ws/context";
|
||||||
import { MascotProvider } from "@/components/mascot/mascot-context";
|
import { MascotProvider, useMascot } from "@/components/mascot/mascot-context";
|
||||||
import { MascotContainer } from "@/components/mascot/mascot-container";
|
import { MascotContainer } from "@/components/mascot/mascot-container";
|
||||||
import { MiniPlayer } from "@/components/media/mini-player";
|
import { MiniPlayer } from "@/components/media/mini-player";
|
||||||
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
|
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
|
||||||
import { HiddenSidebar } from "@/components/layout/hidden-sidebar";
|
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({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -41,7 +37,10 @@ function MascotExpressionSync() {
|
|||||||
setExpression("listening");
|
setExpression("listening");
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => { unsub1(); unsub2(); };
|
return () => {
|
||||||
|
unsub1();
|
||||||
|
unsub2();
|
||||||
|
};
|
||||||
}, [ws, setExpression]);
|
}, [ws, setExpression]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -59,10 +58,10 @@ export default function DashboardLayout({
|
|||||||
<WsProvider>
|
<WsProvider>
|
||||||
<MediaPlayerProvider>
|
<MediaPlayerProvider>
|
||||||
<MascotProvider>
|
<MascotProvider>
|
||||||
|
<MascotExpressionSync />
|
||||||
<div className="min-h-screen bg-canvas">
|
<div className="min-h-screen bg-canvas">
|
||||||
<TopNav />
|
<TopNav />
|
||||||
<HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} />
|
<HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} />
|
||||||
<MascotExpressionSync />
|
|
||||||
|
|
||||||
{/* Sub-nav space — filled per-page */}
|
{/* Sub-nav space — filled per-page */}
|
||||||
<div className="pt-11">
|
<div className="pt-11">
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
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 { 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 { SearchOverlay } from "@/components/messages/search-overlay";
|
||||||
|
import { extractFirstImage } from "@/components/messages/message-card";
|
||||||
import { SubNav } from "@/components/layout/sub-nav";
|
import { SubNav } from "@/components/layout/sub-nav";
|
||||||
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
||||||
import { GlassCard } from "@/components/glass/card";
|
import { GlassCard } from "@/components/glass/card";
|
||||||
@@ -31,9 +32,10 @@ import {
|
|||||||
useReview,
|
useReview,
|
||||||
useTextChannels,
|
useTextChannels,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
|
import type { MessageRecord } from "@/lib/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
import { GuildSelector } from "@/components/shared/guild-selector";
|
import { GuildSelector } from "@/components/shared/guild-selector";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type MessagesTab = "all" | "images" | "review";
|
type MessagesTab = "all" | "images" | "review";
|
||||||
|
|
||||||
@@ -41,9 +43,15 @@ export default function MessagesPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [guildId, setGuildId] = useState(searchParams.get("guild") || "");
|
const [guildId, setGuildId] = useState(searchParams.get("guild") || "");
|
||||||
const [selectedChannel, setSelectedChannel] = useState(searchParams.get("channel") || "");
|
const [selectedChannel, setSelectedChannel] = useState(
|
||||||
const [detailId, setDetailId] = useState<string | null>(searchParams.get("selected"));
|
searchParams.get("channel") || "",
|
||||||
const [tab, setTab] = useState<MessagesTab>((searchParams.get("tab") as MessagesTab) || "all");
|
);
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(
|
||||||
|
searchParams.get("selected"),
|
||||||
|
);
|
||||||
|
const [tab, setTab] = useState<MessagesTab>(
|
||||||
|
(searchParams.get("tab") as MessagesTab) || "all",
|
||||||
|
);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
@@ -64,7 +72,7 @@ export default function MessagesPage() {
|
|||||||
|
|
||||||
useMessagesWsSync(ws, guildId);
|
useMessagesWsSync(ws, guildId);
|
||||||
|
|
||||||
// Sync to URL
|
// Sync state to URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (guildId) params.set("guild", guildId);
|
if (guildId) params.set("guild", guildId);
|
||||||
@@ -74,7 +82,7 @@ export default function MessagesPage() {
|
|||||||
router.replace(`/messages?${params.toString()}`, { scroll: false });
|
router.replace(`/messages?${params.toString()}`, { scroll: false });
|
||||||
}, [guildId, selectedChannel, detailId, tab, router]);
|
}, [guildId, selectedChannel, detailId, tab, router]);
|
||||||
|
|
||||||
// Global Cmd+K
|
// Global Cmd+K search trigger
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
@@ -95,8 +103,14 @@ export default function MessagesPage() {
|
|||||||
});
|
});
|
||||||
}, [cursorData, loadMoreMut, guildId, selectedChannel]);
|
}, [cursorData, loadMoreMut, guildId, selectedChannel]);
|
||||||
|
|
||||||
|
const handleGuildChange = useCallback((g: string) => {
|
||||||
|
setGuildId(g);
|
||||||
|
setSelectedChannel("");
|
||||||
|
setDetailId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const subNavTabs = [
|
const subNavTabs = [
|
||||||
{ id: "all", label: "All" },
|
{ id: "all", label: "All", icon: null },
|
||||||
{ id: "images", label: "Images", icon: <Image className="size-3" /> },
|
{ id: "images", label: "Images", icon: <Image className="size-3" /> },
|
||||||
{ id: "review", label: "Review", icon: <Flag className="size-3" /> },
|
{ id: "review", label: "Review", icon: <Flag className="size-3" /> },
|
||||||
];
|
];
|
||||||
@@ -105,11 +119,14 @@ export default function MessagesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in-up space-y-4">
|
<div className="animate-fade-in-up space-y-4">
|
||||||
{/* Controls bar */}
|
{/* ── Controls bar ── */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<GuildSelector value={guildId} onChange={(g) => { setGuildId(g ?? ""); setSelectedChannel(""); }} />
|
<GuildSelector value={guildId} onChange={handleGuildChange} />
|
||||||
{channels.length > 0 && (
|
{channels.length > 0 && (
|
||||||
<Select value={selectedChannel} onValueChange={(v) => setSelectedChannel(v ?? "")}>
|
<Select
|
||||||
|
value={selectedChannel}
|
||||||
|
onValueChange={(v) => setSelectedChannel(v ?? "")}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-8 w-40 glass border-glass-border text-xs">
|
<SelectTrigger className="h-8 w-40 glass border-glass-border text-xs">
|
||||||
<SelectValue placeholder="All channels" />
|
<SelectValue placeholder="All channels" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -124,105 +141,183 @@ export default function MessagesPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => setSearchOpen(true)}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs text-text-secondary/60 hover:text-text-primary glass hover:glass-elevated transition-all ml-auto"
|
className="ml-auto flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-text-secondary/60 hover:text-text-primary glass hover:glass-elevated transition-all"
|
||||||
>
|
>
|
||||||
<Search className="size-3.5" />
|
<Search className="size-3.5" />
|
||||||
Search
|
Search
|
||||||
<span className="text-[10px] text-text-secondary/30 font-mono hidden sm:inline">⌘K</span>
|
<span className="hidden font-mono text-[10px] text-text-secondary/30 sm:inline">
|
||||||
|
⌘K
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<Button variant="outline" size="sm" onClick={() => reanalyzeBatchMut.mutate(guildId)} className="h-8 text-xs">
|
<Button
|
||||||
<RefreshCw className="size-3 mr-1" /> Reanalyze
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => reanalyzeBatchMut.mutate(guildId)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1 size-3" /> Reanalyze
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubNav tabs={subNavTabs} activeTab={tab} onTabChange={(t) => setTab(t as MessagesTab)} />
|
{/* ── Sub navigation ── */}
|
||||||
|
<SubNav
|
||||||
|
tabs={subNavTabs}
|
||||||
|
activeTab={tab}
|
||||||
|
onTabChange={(t) => setTab(t as MessagesTab)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Split pane */}
|
{/* ── Split pane ── */}
|
||||||
{error ? (
|
{error ? (
|
||||||
<ErrorState message={error.message} onRetry={refetch} />
|
<ErrorState message={error.message} onRetry={refetch} />
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<LoadingSkeleton count={6} height="h-20" />
|
<LoadingSkeleton count={6} height="h-20" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{/* Left pane — message list */}
|
{/* Left pane */}
|
||||||
<div className={cn("space-y-2", detailId ? "w-1/2 lg:w-2/5" : "w-full")}>
|
<div
|
||||||
{tab === "all" && (
|
className={cn(
|
||||||
<>
|
"space-y-2",
|
||||||
<MessageList messages={currentMessages} selectedId={detailId} onSelect={setDetailId} />
|
detailId ? "w-1/2 lg:w-2/5" : "w-full",
|
||||||
{cursorData?.hasMore && (
|
|
||||||
<div className="flex justify-center py-4">
|
|
||||||
<Button variant="outline" size="sm" onClick={handleLoadMore} disabled={loadMoreMut.isPending} className="text-xs glass">
|
|
||||||
{loadMoreMut.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
|
|
||||||
Load more
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
>
|
||||||
|
{tab === "all" && (
|
||||||
|
<MessageList
|
||||||
|
messages={currentMessages}
|
||||||
|
selectedId={detailId}
|
||||||
|
onSelect={setDetailId}
|
||||||
|
onReanalyze={(id) => reanalyzeMut.mutate(id)}
|
||||||
|
hasMore={cursorData?.hasMore}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
isLoadingMore={loadMoreMut.isPending}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "images" && (
|
{tab === "images" && (
|
||||||
<ImageGrid items={images ?? []} onSelect={setDetailId} />
|
<ImageGrid items={images ?? []} onSelect={setDetailId} />
|
||||||
)}
|
)}
|
||||||
{tab === "review" && (
|
{tab === "review" && (
|
||||||
<ReviewList items={reviews ?? []} onSelect={setDetailId} onReanalyze={(id: string) => reanalyzeMut.mutate(id)} />
|
<ReviewList
|
||||||
|
items={reviews ?? []}
|
||||||
|
onSelect={setDetailId}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right pane — detail */}
|
{/* Right pane — message detail */}
|
||||||
{detailId && (
|
{detailId && (
|
||||||
<div className="hidden md:block w-1/2 lg:w-3/5 sticky top-16 self-start">
|
<div className="sticky top-16 hidden w-1/2 self-start md:block lg:w-3/5">
|
||||||
{detailLoading ? (
|
{detailLoading ? (
|
||||||
<GlassPanel dense className="flex items-center justify-center py-12">
|
<GlassPanel dense className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="size-5 animate-spin text-text-secondary/60" />
|
<Loader2 className="size-5 animate-spin text-text-secondary/60" />
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
) : detailMessage ? (
|
) : detailMessage ? (
|
||||||
<MessageDetail
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDetailId(null)}
|
||||||
|
className="text-xs text-text-secondary/60 hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
← Back to list
|
||||||
|
</button>
|
||||||
|
<MessageDetailView
|
||||||
message={detailMessage}
|
message={detailMessage}
|
||||||
attachments={detailAttachments}
|
attachments={detailAttachments}
|
||||||
onBack={() => setDetailId(null)}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search overlay */}
|
{/* ── Search overlay ── */}
|
||||||
<SearchOverlay open={searchOpen} onClose={() => setSearchOpen(false)} onSelect={setDetailId} />
|
<SearchOverlay
|
||||||
|
open={searchOpen}
|
||||||
|
onClose={() => setSearchOpen(false)}
|
||||||
|
onSelect={(id) => {
|
||||||
|
setDetailId(id);
|
||||||
|
setTab("all");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline ImageGrid and ReviewList
|
// ── Inline ImageGrid (glass-styled) ────────────────
|
||||||
function ImageGrid({ items, onSelect }: { items: any[]; onSelect: (id: string) => void }) {
|
|
||||||
|
function ImageGrid({
|
||||||
|
items,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
items: MessageRecord[];
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{items.map((item: any) => (
|
{items.map((item) => {
|
||||||
<button key={item.id} type="button" onClick={() => onSelect(item.message_id)} className="glass rounded-lg overflow-hidden hover:scale-[1.02] transition-transform">
|
const imgUrl = extractFirstImage(item.metadata);
|
||||||
<img src={item.uploaded_url || item.discord_url} alt="" className="w-full h-24 object-cover" loading="lazy" />
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
className="glass overflow-hidden rounded-lg transition-transform hover:scale-[1.02]"
|
||||||
|
>
|
||||||
|
{imgUrl ? (
|
||||||
|
<img
|
||||||
|
src={imgUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-24 w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-24 w-full items-center justify-center text-xs text-text-secondary/40">
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<div className="col-span-3 py-12 text-center text-xs text-text-secondary/40">No images</div>
|
<div className="col-span-3 py-12 text-center text-xs text-text-secondary/40">
|
||||||
|
No images
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{items.map((item: any) => (
|
{items.map((item) => (
|
||||||
<GlassCard key={item.id} variant="danger" className="p-3 cursor-pointer" onClick={() => onSelect(item.message_id)}>
|
<GlassCard
|
||||||
|
key={item.id}
|
||||||
|
variant="danger"
|
||||||
|
className="cursor-pointer p-3"
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Flag className="size-3.5 text-accent-purple mt-0.5 shrink-0" />
|
<Flag className="mt-0.5 size-3.5 shrink-0 text-accent-purple" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs text-text-secondary line-clamp-2">{item.content || item.id}</p>
|
<p className="line-clamp-2 text-xs text-text-secondary">
|
||||||
|
{item.content || item.id}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<div className="py-12 text-center text-xs text-text-secondary/40">No flagged messages</div>
|
<div className="py-12 text-center text-xs text-text-secondary/40">
|
||||||
|
No flagged messages
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,26 +6,27 @@ import { RecordingPlayer } from "@/components/recordings/recording-player";
|
|||||||
import { SubNav } from "@/components/layout/sub-nav";
|
import { SubNav } from "@/components/layout/sub-nav";
|
||||||
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
||||||
import { useRecordings } from "@/hooks";
|
import { useRecordings } from "@/hooks";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import type { VoiceRecording } from "@/lib/types";
|
||||||
|
|
||||||
|
type RecordingsTab = "library" | "stats";
|
||||||
|
|
||||||
type RecordingsTab = "library" | "stats";
|
type RecordingsTab = "library" | "stats";
|
||||||
|
|
||||||
export default function RecordingsPage() {
|
export default function RecordingsPage() {
|
||||||
const ws = useWebSocket();
|
|
||||||
const { data: recordings, isLoading, error, refetch } = useRecordings();
|
const { data: recordings, isLoading, error, refetch } = useRecordings();
|
||||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||||
const [tab, setTab] = useState<RecordingsTab>("library");
|
const [tab, setTab] = useState<RecordingsTab>("library");
|
||||||
|
|
||||||
const currentTrack = playingId && recordings
|
const currentTrack = playingId && recordings
|
||||||
? recordings.find((r: any) => r.id === playingId)
|
? recordings.find((r: VoiceRecording) => r.id === playingId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in-up">
|
<div className="space-y-4 animate-fade-in-up">
|
||||||
<SubNav
|
<SubNav
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: "library", label: "Library" },
|
{ id: "library", label: "Library", icon: undefined },
|
||||||
{ id: "stats", label: "Stats" },
|
{ id: "stats", label: "Stats", icon: undefined },
|
||||||
]}
|
]}
|
||||||
activeTab={tab}
|
activeTab={tab}
|
||||||
onTabChange={(t) => setTab(t as RecordingsTab)}
|
onTabChange={(t) => setTab(t as RecordingsTab)}
|
||||||
@@ -39,7 +40,7 @@ export default function RecordingsPage() {
|
|||||||
<LoadingSkeleton count={4} height="h-28" />
|
<LoadingSkeleton count={4} height="h-28" />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(recordings ?? []).map((rec: any) => (
|
{(recordings ?? []).map((rec: VoiceRecording) => (
|
||||||
<RecordingCard
|
<RecordingCard
|
||||||
key={rec.id}
|
key={rec.id}
|
||||||
recording={rec}
|
recording={rec}
|
||||||
@@ -58,7 +59,7 @@ export default function RecordingsPage() {
|
|||||||
<div className="py-12 text-center text-sm text-text-secondary/40">Recording stats coming soon</div>
|
<div className="py-12 text-center text-sm text-text-secondary/40">Recording stats coming soon</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RecordingPlayer url={currentTrack?.download_url} onClose={() => setPlayingId(null)} />
|
<RecordingPlayer url={currentTrack?.download_url ?? undefined} onClose={() => setPlayingId(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ import { MicControl } from "@/components/voice/mic-control";
|
|||||||
import { VoiceActivityTimeline } from "@/components/voice/activity-timeline";
|
import { VoiceActivityTimeline } from "@/components/voice/activity-timeline";
|
||||||
import { SubNav } from "@/components/layout/sub-nav";
|
import { SubNav } from "@/components/layout/sub-nav";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
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";
|
type VoiceTab = "connection" | "activity";
|
||||||
|
|
||||||
@@ -43,6 +51,15 @@ export default function VoicePage() {
|
|||||||
[micMut],
|
[micMut],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleGuildChange = useCallback((guildId: string | null) => {
|
||||||
|
if (!guildId) {
|
||||||
|
setSelectedGuild("");
|
||||||
|
setSelectedChannel("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedGuild(guildId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const activeSpeakers = speakers.filter((s) => s.speaking);
|
const activeSpeakers = speakers.filter((s) => s.speaking);
|
||||||
const connected = voiceStatus?.connected ?? false;
|
const connected = voiceStatus?.connected ?? false;
|
||||||
|
|
||||||
@@ -50,8 +67,8 @@ export default function VoicePage() {
|
|||||||
<div className="space-y-4 animate-fade-in-up">
|
<div className="space-y-4 animate-fade-in-up">
|
||||||
<SubNav
|
<SubNav
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: "connection", label: "Connection" },
|
{ id: "connection", label: "Connection", icon: undefined },
|
||||||
{ id: "activity", label: "Activity" },
|
{ id: "activity", label: "Activity", icon: undefined },
|
||||||
]}
|
]}
|
||||||
activeTab={tab}
|
activeTab={tab}
|
||||||
onTabChange={(t) => setTab(t as VoiceTab)}
|
onTabChange={(t) => setTab(t as VoiceTab)}
|
||||||
@@ -64,7 +81,7 @@ export default function VoicePage() {
|
|||||||
voiceChannels={voiceChannels}
|
voiceChannels={voiceChannels}
|
||||||
selectedGuild={selectedGuild}
|
selectedGuild={selectedGuild}
|
||||||
selectedChannel={selectedChannel}
|
selectedChannel={selectedChannel}
|
||||||
onGuildChange={(g) => { setSelectedGuild(g ?? ""); setSelectedChannel(""); }}
|
onGuildChange={handleGuildChange}
|
||||||
onChannelChange={(v) => setSelectedChannel(v ?? "")}
|
onChannelChange={(v) => setSelectedChannel(v ?? "")}
|
||||||
onConnect={() => connectMut.mutate({ guildId: selectedGuild, channelId: selectedChannel })}
|
onConnect={() => connectMut.mutate({ guildId: selectedGuild, channelId: selectedChannel })}
|
||||||
onDisconnect={() => disconnectMut.mutate(undefined)}
|
onDisconnect={() => disconnectMut.mutate(undefined)}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@
|
|||||||
--color-accent: var(--color-primary);
|
--color-accent: var(--color-primary);
|
||||||
--color-accent-foreground: var(--color-primary-foreground);
|
--color-accent-foreground: var(--color-primary-foreground);
|
||||||
|
|
||||||
|
/* Ring / focus outline for shadcn outline-ring utility */
|
||||||
|
--color-ring: var(--color-primary);
|
||||||
|
|
||||||
/* Radius */
|
/* Radius */
|
||||||
--radius-card: 16px;
|
--radius-card: 16px;
|
||||||
--radius-panel: 12px;
|
--radius-panel: 12px;
|
||||||
@@ -86,25 +89,111 @@
|
|||||||
radial-gradient(ellipse 50% 40% at 80% 80%, oklch(0.65 0.2 280 / 0.04), transparent);
|
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%;
|
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 { width: 6px; height: 6px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.1); border-radius: 999px; }
|
::-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-thumb:hover { background: oklch(1 0 0 / 0.2); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Animations ──────────────────────────── */
|
||||||
|
|
||||||
@keyframes pulse-ring {
|
@keyframes pulse-ring {
|
||||||
0% { transform: scale(0.8); opacity: 1; }
|
0% {
|
||||||
100% { transform: scale(2.5); opacity: 0; }
|
transform: scale(0.8);
|
||||||
}
|
opacity: 1;
|
||||||
@keyframes fade-in-up {
|
}
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
100% {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
transform: scale(2.5);
|
||||||
}
|
opacity: 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; }
|
@keyframes fade-in-up {
|
||||||
.animate-pulse-ring { animation: pulse-ring 1.5s ease-out infinite; }
|
from {
|
||||||
.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; }
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function ActivityHeatmap({ data = {} }: ActivityHeatmapProps) {
|
|||||||
<GlassCard variant="base">
|
<GlassCard variant="base">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<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-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>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="flex gap-0.5 min-w-[400px]">
|
<div className="flex gap-0.5 min-w-[400px]">
|
||||||
@@ -46,7 +46,7 @@ export function ActivityHeatmap({ data = {} }: ActivityHeatmapProps) {
|
|||||||
<div
|
<div
|
||||||
key={`${day}-${hour}`}
|
key={`${day}-${hour}`}
|
||||||
className={cn("size-3 rounded-sm transition-colors", getIntensity(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">
|
<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 {
|
interface HiddenSidebarProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
onGuildChange: (guildId: string | null) => void;
|
onGuildChange: (guildId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HiddenSidebar({ guildId, onGuildChange }: HiddenSidebarProps) {
|
export function HiddenSidebar({ guildId, onGuildChange }: HiddenSidebarProps) {
|
||||||
@@ -24,6 +24,7 @@ export function HiddenSidebar({ guildId, onGuildChange }: HiddenSidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hotspot trigger */}
|
{/* Hotspot trigger */}
|
||||||
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: transparent mouse detection zone, not interactive content */}
|
||||||
<div
|
<div
|
||||||
className="fixed left-0 top-0 bottom-0 w-1 z-50"
|
className="fixed left-0 top-0 bottom-0 w-1 z-50"
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
@@ -31,6 +32,8 @@ export function HiddenSidebar({ guildId, onGuildChange }: HiddenSidebarProps) {
|
|||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div
|
<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 ${
|
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"
|
visible ? "translate-x-0" : "-translate-x-full"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -15,9 +15,19 @@ interface SubNavProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubNav({ tabs, activeTab, onTabChange, className }: SubNavProps) {
|
export function SubNav({
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
className,
|
||||||
|
}: SubNavProps) {
|
||||||
return (
|
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) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
|
|||||||
@@ -1,55 +1,90 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
import { Send } from "lucide-react";
|
import { Send } from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
import { useMascot } from "./mascot-context";
|
import { useMascot } from "./mascot-context";
|
||||||
|
|
||||||
export function ChatPanel() {
|
interface ChatPanelProps {
|
||||||
const { chatHistory, addChat, setExpression } = useMascot();
|
inputRef?: React.RefObject<HTMLInputElement | null>;
|
||||||
const [input, setInput] = useState("");
|
}
|
||||||
|
|
||||||
const handleSend = () => {
|
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||||
if (!input.trim()) return;
|
const { messages, sendMessage, isTyping } = useMascot();
|
||||||
addChat("user", input);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
setExpression("listening");
|
const internalInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const inputRef = externalInputRef ?? internalInputRef;
|
||||||
|
|
||||||
// Simulated bot response — replace with actual mascot-chat API call
|
// Auto-scroll to bottom on new messages
|
||||||
setTimeout(() => {
|
useEffect(() => {
|
||||||
addChat("assistant", "I'm monitoring this server for you!");
|
if (listRef.current) {
|
||||||
setExpression("happy");
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||||
}, 800);
|
}
|
||||||
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 overflow-y-auto px-2 py-1 space-y-1">
|
{/* Chat messages */}
|
||||||
{chatHistory.slice(-6).map((msg, i) => (
|
<div ref={listRef} className="flex-1 overflow-y-auto px-2 py-1 space-y-1">
|
||||||
<div key={i} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
{messages.length === 0 && (
|
||||||
<span className={`text-[10px] px-2 py-1 rounded-lg max-w-[85%] ${
|
<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"
|
msg.role === "user"
|
||||||
? "bg-primary/20 text-text-primary"
|
? "bg-primary/20 text-text-primary"
|
||||||
: "glass text-text-secondary"
|
: "glass text-text-secondary"
|
||||||
}`}>
|
}`}
|
||||||
{msg.text}
|
>
|
||||||
|
{msg.content}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 className="flex items-center gap-1 px-2 py-1 border-t border-glass-border">
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input bar */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center gap-1 px-2 py-1.5 border-t border-glass-border shrink-0">
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
|
||||||
placeholder="Ask mascot..."
|
placeholder="Ask mascot..."
|
||||||
className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none"
|
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" />
|
<Send className="size-3 text-primary" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { MascotProvider } from "./mascot-context";
|
export { MascotProvider, useMascot } from "./mascot-context";
|
||||||
export { MascotContainer } from "./mascot-container";
|
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.arc(w / 2, 75, 6, 0.1, Math.PI - 0.1);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Breathing animation — subtle canvas shift
|
||||||
|
const breath = Math.sin(Date.now() / 1000) * 1.5;
|
||||||
|
// Applied via CSS transform on container instead
|
||||||
|
|
||||||
}, [expression]);
|
}, [expression]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,28 +1,38 @@
|
|||||||
"use client";
|
"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 { useMascot } from "./mascot-context";
|
||||||
import { MascotCanvas } from "./mascot-canvas";
|
import { MascotCanvas } from "./mascot-canvas";
|
||||||
import { ChatPanel } from "./chat-panel";
|
import { ChatPanel } from "./chat-panel";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export function MascotContainer() {
|
export function MascotContainer() {
|
||||||
const { minimized, setMinimized, chatOpen, setChatOpen } = useMascot();
|
const { minimized, setMinimized, chatOpen, setChatOpen } = useMascot();
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
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);
|
setDragging(true);
|
||||||
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
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;
|
if (!dragging) return;
|
||||||
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -35,9 +45,9 @@ export function MascotContainer() {
|
|||||||
{/* Main mascot bubble */}
|
{/* Main mascot bubble */}
|
||||||
<div
|
<div
|
||||||
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
|
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 ? (
|
{minimized ? (
|
||||||
<button
|
<button
|
||||||
@@ -45,8 +55,9 @@ export function MascotContainer() {
|
|||||||
onClick={() => setMinimized(false)}
|
onClick={() => setMinimized(false)}
|
||||||
className="w-full h-full flex items-center justify-center"
|
className="w-full h-full flex items-center justify-center"
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
aria-label="Open mascot"
|
||||||
>
|
>
|
||||||
<MessageCircle className="size-6 text-primary" />
|
<Bot className="size-6 text-primary" />
|
||||||
</button>
|
</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"
|
className="flex items-center justify-between px-3 py-1.5 border-b border-glass-border cursor-grab active:cursor-grabbing"
|
||||||
onMouseDown={handleMouseDown}
|
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">
|
<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" />
|
<MessageCircle className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setMinimized(true)}>
|
<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" />
|
<Minimize2 className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,8 +99,12 @@ export function MascotContainer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat panel (expandable) */}
|
{/* Chat panel (expandable) */}
|
||||||
<div className={`transition-all duration-200 overflow-hidden ${chatOpen ? "h-[120px]" : "h-0"}`}>
|
<div
|
||||||
<ChatPanel />
|
className={`transition-all duration-200 overflow-hidden ${
|
||||||
|
chatOpen ? "h-[130px]" : "h-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChatPanel inputRef={inputRef} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,43 +1,161 @@
|
|||||||
"use client";
|
"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 {
|
interface MascotMessage {
|
||||||
expression: MascotExpression;
|
role: "user" | "assistant";
|
||||||
minimized: boolean;
|
content: string;
|
||||||
chatOpen: boolean;
|
timestamp: string;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }) {
|
export function MascotProvider({ children }: { children: ReactNode }) {
|
||||||
const [expression, setExpression] = useState<MascotExpression>("idle");
|
const [expression, setExpression] = useState<MascotExpression>("idle");
|
||||||
const [minimized, setMinimized] = useState(true);
|
const [minimized, setMinimized] = useState(true);
|
||||||
const [chatOpen, setChatOpen] = useState(false);
|
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) => {
|
// Derived legacy state
|
||||||
setChatHistory((prev) => [...prev, { role, text }]);
|
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 (
|
return (
|
||||||
<MascotContext.Provider
|
<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}
|
{children}
|
||||||
</MascotContext.Provider>
|
</MascotContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMascot() {
|
export function useMascot(): MascotContextValue {
|
||||||
const ctx = useContext(MascotContext);
|
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;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,79 @@
|
|||||||
"use client";
|
"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";
|
import { useMediaPlayer } from "@/lib/hooks/use-media-player";
|
||||||
|
|
||||||
export function MiniPlayer() {
|
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 (
|
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="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">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
{/* Track info */}
|
||||||
<div className="size-6 flex items-center justify-center rounded bg-primary/20">
|
<div className="flex items-center gap-2.5 min-w-0 flex-1 max-w-[280px]">
|
||||||
<Play className="size-3 text-primary" />
|
<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">
|
||||||
</div>
|
{playing ? (
|
||||||
<div className="flex-1 min-w-0">
|
<Disc3 className="size-4 text-primary animate-spin" style={{ animationDuration: "4s" }} />
|
||||||
<p className="text-xs font-medium text-text-primary truncate">{currentTrack.title}</p>
|
) : (
|
||||||
{currentTrack.artist && (
|
<Music className="size-4 text-text-secondary" />
|
||||||
<p className="text-[10px] text-text-secondary/50 truncate">{currentTrack.artist}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={stop} className="size-5 flex items-center justify-center hover:bg-glass-bg rounded">
|
<div className="min-w-0">
|
||||||
<X className="size-3 text-text-secondary/60" />
|
<p className="text-xs font-medium text-text-primary truncate">
|
||||||
</button>
|
{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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<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" />
|
{/* 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>
|
</button>
|
||||||
<Volume2 className="size-3 text-text-secondary/40" />
|
)}
|
||||||
|
{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
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min="0"
|
||||||
max={100}
|
max="1"
|
||||||
|
step="0.05"
|
||||||
value={volume}
|
value={volume}
|
||||||
onChange={(e) => setVolume(Number(e.target.value))}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +1,53 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
import { MessageCard } from "./message-card";
|
import { MessageCard } from "./message-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import type { MessageRecord } from "@/lib/types";
|
import type { MessageRecord } from "@/lib/types";
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: MessageRecord[];
|
messages: MessageRecord[];
|
||||||
selectedId?: string | null;
|
selectedId: string | null;
|
||||||
onSelect: (id: string) => void;
|
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 (
|
return (
|
||||||
<div className="space-y-1.5 overflow-y-auto max-h-[calc(100vh-200px)] pr-1">
|
<>
|
||||||
{messages.length === 0 ? (
|
{messages.map((msg) => (
|
||||||
<div className="flex items-center justify-center py-12 text-text-secondary/40 text-sm">
|
|
||||||
No messages
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
messages.map((msg) => (
|
|
||||||
<MessageCard
|
<MessageCard
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
message={msg}
|
message={msg}
|
||||||
selected={selectedId === msg.id}
|
|
||||||
onClick={onSelect}
|
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>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { data: results } = useQuery<{ results: MessageRecord[] }>({
|
const { data: results } = useQuery<MessageRecord[]>({
|
||||||
queryKey: ["messages-search", query],
|
queryKey: ["messages-search", query],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await messagesApi.search(query, 20);
|
const res = await messagesApi.search(query, 20);
|
||||||
return res;
|
return res.results;
|
||||||
},
|
},
|
||||||
enabled: query.length >= 2,
|
enabled: query.length >= 2,
|
||||||
});
|
});
|
||||||
@@ -37,7 +37,7 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
|
|||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onClose();
|
onClose(); // this is called when Cmd+K is pressed globally — toggle
|
||||||
}
|
}
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
};
|
};
|
||||||
@@ -69,12 +69,12 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
|
|||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
<div className="max-h-80 overflow-y-auto p-2 space-y-1">
|
<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">
|
<div className="py-8 text-center text-xs text-text-secondary/40">
|
||||||
{query.length < 2 ? "Type at least 2 characters" : "No results found"}
|
{query.length < 2 ? "Type at least 2 characters" : "No results found"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
results.results.map((msg: MessageRecord) => (
|
results.map((msg) => (
|
||||||
<button
|
<button
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ interface RecordingCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RecordingCard({ recording, onPlay }: 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 (
|
return (
|
||||||
<GlassCard variant="interactive" className="p-4" onClick={() => onPlay(recording.id)}>
|
<GlassCard variant="interactive" className="p-4" onClick={() => onPlay(recording.id)}>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -39,9 +43,7 @@ export function RecordingCard({ recording, onPlay }: RecordingCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] font-mono text-text-secondary/60">
|
<span className="text-[10px] font-mono text-text-secondary/60">{durationStr}</span>
|
||||||
{recording.duration_bytes ? `${Math.floor(recording.duration_bytes / 60)}:${String(recording.duration_bytes % 60).padStart(2, "0")}` : "--:--"}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-text-secondary/40">{new Date(recording.created_at).toLocaleString()}</span>
|
<span className="text-[10px] text-text-secondary/40">{new Date(recording.created_at).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { GlassPanel } from "@/components/glass/panel";
|
|||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
interface RecordingPlayerProps {
|
interface RecordingPlayerProps {
|
||||||
url?: string | null;
|
url?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { Inbox } from "lucide-react";
|
import { Inbox } from "lucide-react";
|
||||||
import { GlassPanel } from "@/components/glass/panel";
|
import { GlassPanel } from "@/components/glass/panel";
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
|
icon?: LucideIcon;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
|
icon: Icon = Inbox,
|
||||||
title = "No data yet",
|
title = "No data yet",
|
||||||
description = "Nothing to display here yet.",
|
description = "Nothing to display here yet.",
|
||||||
}: EmptyStateProps) {
|
}: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<GlassPanel dense className="flex flex-col items-center gap-2 py-12">
|
<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-sm text-text-secondary/60">{title}</p>
|
||||||
<p className="text-xs text-text-secondary/40">{description}</p>
|
<p className="text-xs text-text-secondary/40">{description}</p>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function LoadingSkeleton({
|
|||||||
|
|
||||||
if (columns) {
|
if (columns) {
|
||||||
return (
|
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}
|
{items}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,28 +1,59 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { GlassCard } from "@/components/glass/card";
|
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 }[];
|
data?: { user: string; duration: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VoiceActivityTimeline({ data = [] }: VoiceActivityTimelineProps) {
|
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
|
||||||
return (
|
return (
|
||||||
<GlassCard variant="base">
|
<GlassCard variant="base">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<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>
|
||||||
<div className="h-40">
|
<div className="h-40">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={data} layout="vertical">
|
<BarChart data={data} layout="vertical">
|
||||||
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} />
|
<XAxis
|
||||||
<YAxis type="category" dataKey="user" axisLine={false} tickLine={false} tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} width={80} />
|
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
|
<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)" }}
|
contentStyle={{
|
||||||
formatter={(value) => `${Number(value) / 60}m`}
|
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>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,14 +2,21 @@
|
|||||||
|
|
||||||
import { GlassCard } from "@/components/glass/card";
|
import { GlassCard } from "@/components/glass/card";
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ConnectionCardProps {
|
interface ConnectionCardProps {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
activeChannelName?: string | null;
|
activeChannelName?: string | null;
|
||||||
guilds: { id: string; name: string }[];
|
guilds: Guild[];
|
||||||
voiceChannels: { id: string; name: string }[];
|
voiceChannels: Channel[];
|
||||||
selectedGuild: string;
|
selectedGuild: string;
|
||||||
selectedChannel: string;
|
selectedChannel: string;
|
||||||
onGuildChange: (guildId: string | null) => void;
|
onGuildChange: (guildId: string | null) => void;
|
||||||
@@ -20,25 +27,34 @@ interface ConnectionCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VoiceConnectionCard({
|
export function VoiceConnectionCard({
|
||||||
connected, activeChannelName, guilds, voiceChannels,
|
connected,
|
||||||
selectedGuild, selectedChannel,
|
activeChannelName,
|
||||||
onGuildChange, onChannelChange, onConnect, onDisconnect, connecting,
|
guilds,
|
||||||
|
voiceChannels,
|
||||||
|
selectedGuild,
|
||||||
|
selectedChannel,
|
||||||
|
onGuildChange,
|
||||||
|
onChannelChange,
|
||||||
|
onConnect,
|
||||||
|
onDisconnect,
|
||||||
|
connecting,
|
||||||
}: ConnectionCardProps) {
|
}: ConnectionCardProps) {
|
||||||
return (
|
return (
|
||||||
<GlassCard variant={connected ? "elevated" : "base"}>
|
<GlassCard variant={connected ? "elevated" : "base"}>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<span className={cn(
|
<span className={cn("relative flex size-3", connected && "text-emerald-500")}>
|
||||||
"relative flex size-3",
|
<span
|
||||||
connected && "text-emerald-500",
|
className={cn(
|
||||||
)}>
|
|
||||||
<span className={cn(
|
|
||||||
"absolute inline-flex size-full rounded-full opacity-75",
|
"absolute inline-flex size-full rounded-full opacity-75",
|
||||||
connected ? "bg-emerald-500 animate-pulse-ring" : "bg-destructive",
|
connected ? "bg-emerald-500 animate-pulse-ring" : "bg-destructive",
|
||||||
)} />
|
)}
|
||||||
<span className={cn(
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
"relative inline-flex size-3 rounded-full",
|
"relative inline-flex size-3 rounded-full",
|
||||||
connected ? "bg-emerald-500" : "bg-destructive",
|
connected ? "bg-emerald-500" : "bg-destructive",
|
||||||
)} />
|
)}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-semibold text-text-primary">Voice Connection</span>
|
<span className="text-sm font-semibold text-text-primary">Voice Connection</span>
|
||||||
@@ -48,7 +64,9 @@ export function VoiceConnectionCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{connected ? (
|
{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}>
|
<Button size="sm" onClick={onConnect} disabled={!selectedGuild || !selectedChannel || connecting}>
|
||||||
{connecting ? "Connecting..." : "Connect"}
|
{connecting ? "Connecting..." : "Connect"}
|
||||||
@@ -58,23 +76,37 @@ export function VoiceConnectionCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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">
|
<SelectTrigger className="h-8 glass border-glass-border text-xs">
|
||||||
<SelectValue placeholder="Select guild" />
|
<SelectValue placeholder="Select guild" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{guilds.map((g) => (
|
{guilds.map((g) => (
|
||||||
<SelectItem key={g.id} value={g.id}>{g.name}</SelectItem>
|
<SelectItem key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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">
|
<SelectTrigger className="h-8 glass border-glass-border text-xs">
|
||||||
<SelectValue placeholder="Select channel" />
|
<SelectValue placeholder="Select channel" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{voiceChannels.map((c) => (
|
{voiceChannels.map((c) => (
|
||||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ interface MicControlProps {
|
|||||||
onVolumeChange: (v: number) => void;
|
onVolumeChange: (v: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MicControl({ connected, active, onToggle, volume, onVolumeChange }: MicControlProps) {
|
export function MicControl({
|
||||||
|
connected,
|
||||||
|
active,
|
||||||
|
onToggle,
|
||||||
|
volume,
|
||||||
|
onVolumeChange,
|
||||||
|
}: MicControlProps) {
|
||||||
return (
|
return (
|
||||||
<GlassCard variant="base">
|
<GlassCard variant="base">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -56,11 +56,20 @@ export function SpeakerWaveform({ speakers }: SpeakerWaveformProps) {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{speakers.map((s) => (
|
{speakers.map((s) => (
|
||||||
<div key={s.userId} className="flex items-center gap-2 text-xs">
|
<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>
|
||||||
))}
|
))}
|
||||||
</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>
|
</GlassPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,130 @@
|
|||||||
"use client";
|
"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 {
|
interface MediaPlayerContextValue {
|
||||||
id: string;
|
/** Current play state */
|
||||||
title: string;
|
|
||||||
artist?: string;
|
|
||||||
duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaPlayerState {
|
|
||||||
currentTrack: Track | null;
|
|
||||||
queue: Track[];
|
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
/** Current track, or null */
|
||||||
|
current: MediaItem | null;
|
||||||
|
/** Upcoming queue */
|
||||||
|
queue: MediaItem[];
|
||||||
|
/** Current volume [0-1] */
|
||||||
volume: number;
|
volume: number;
|
||||||
}
|
/** True while a mutation is in flight */
|
||||||
|
pending: boolean;
|
||||||
|
|
||||||
interface MediaPlayerContextType extends MediaPlayerState {
|
/** Skip to next track */
|
||||||
play: (track: Track) => void;
|
|
||||||
skip: () => void;
|
skip: () => void;
|
||||||
|
/** Stop playback */
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
setVolume: (v: number) => void;
|
/** Set volume [0-1] */
|
||||||
addToQueue: (track: Track) => void;
|
setVolume: (vol: number) => void;
|
||||||
removeFromQueue: (id: string) => void;
|
/** Queue a URL for playback */
|
||||||
|
queueUrl: (url: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MediaPlayerContext = createContext<MediaPlayerContextType | null>(null);
|
const MediaPlayerContext = createContext<MediaPlayerContextValue | null>(null);
|
||||||
|
|
||||||
export function MediaPlayerProvider({ children }: { children: ReactNode }) {
|
export function MediaPlayerProvider({ children }: { children: ReactNode }) {
|
||||||
const [state, setState] = useState<MediaPlayerState>({
|
const ws = useWebSocket();
|
||||||
currentTrack: null,
|
const [state, setState] = useState<MediaState>({
|
||||||
queue: [],
|
|
||||||
playing: false,
|
playing: false,
|
||||||
volume: 75,
|
musicVolume: 0.5,
|
||||||
|
current: null,
|
||||||
|
queue: [],
|
||||||
});
|
});
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const fetched = useRef(false);
|
||||||
|
|
||||||
const play = (track: Track) => {
|
// Fetch initial state
|
||||||
setState((prev) => ({ ...prev, currentTrack: track, playing: true }));
|
useEffect(() => {
|
||||||
};
|
if (fetched.current) return;
|
||||||
|
fetched.current = true;
|
||||||
const skip = () => {
|
mediaApi.getStatus().then((data) => {
|
||||||
setState((prev) => {
|
if (data) setState(data as MediaState);
|
||||||
if (prev.queue.length === 0) return { ...prev, currentTrack: null, playing: false };
|
}).catch(() => {
|
||||||
const [next, ...rest] = prev.queue;
|
// API not yet available
|
||||||
return { ...prev, currentTrack: next, queue: rest };
|
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const stop = () => {
|
// Subscribe to live media_state events via WS
|
||||||
setState((prev) => ({ ...prev, currentTrack: null, playing: false }));
|
useEffect(() => {
|
||||||
};
|
const unsub = ws.on("media_state", (data) => {
|
||||||
|
setState(data as unknown as MediaState);
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [ws]);
|
||||||
|
|
||||||
const setVolume = (volume: number) => {
|
const skip = useCallback(() => {
|
||||||
setState((prev) => ({ ...prev, volume }));
|
setPending(true);
|
||||||
};
|
mediaApi.skip().then((data) => {
|
||||||
|
if (data) setState(data as MediaState);
|
||||||
|
}).catch(() => {
|
||||||
|
// ignore
|
||||||
|
}).finally(() => setPending(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const addToQueue = (track: Track) => {
|
const stop = useCallback(() => {
|
||||||
setState((prev) => ({ ...prev, queue: [...prev.queue, track] }));
|
setPending(true);
|
||||||
};
|
mediaApi.stop().then((data) => {
|
||||||
|
if (data) setState(data as MediaState);
|
||||||
|
}).catch(() => {
|
||||||
|
// ignore
|
||||||
|
}).finally(() => setPending(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const removeFromQueue = (id: string) => {
|
const setVolume = useCallback((vol: number) => {
|
||||||
setState((prev) => ({ ...prev, queue: prev.queue.filter((t) => t.id !== id) }));
|
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 (
|
return (
|
||||||
<MediaPlayerContext.Provider value={{ ...state, play, skip, stop, setVolume, addToQueue, removeFromQueue }}>
|
<MediaPlayerContext.Provider
|
||||||
|
value={{
|
||||||
|
playing: state.playing,
|
||||||
|
current: state.current,
|
||||||
|
queue: state.queue,
|
||||||
|
volume: state.musicVolume,
|
||||||
|
pending,
|
||||||
|
skip,
|
||||||
|
stop,
|
||||||
|
setVolume,
|
||||||
|
queueUrl,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</MediaPlayerContext.Provider>
|
</MediaPlayerContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMediaPlayer() {
|
export function useMediaPlayer(): MediaPlayerContextValue {
|
||||||
const ctx = useContext(MediaPlayerContext);
|
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;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user