diff --git a/web/src/app/dashboard/audit-log/page.tsx b/web/src/app/dashboard/audit-log/page.tsx index 4071b313..54ef51ce 100644 --- a/web/src/app/dashboard/audit-log/page.tsx +++ b/web/src/app/dashboard/audit-log/page.tsx @@ -3,6 +3,8 @@ import { ChevronDown, ChevronRight, ClipboardList, RefreshCw, Search, X } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; +import { EmptyState } from '@/components/dashboard/empty-state'; +import { PageHeader } from '@/components/dashboard/page-header'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ErrorBoundary } from '@/components/ui/error-boundary'; @@ -268,48 +270,69 @@ export default function AuditLogPage() { return (
- {/* Header */} -
-
-

- - Audit Log -

-

- Track all admin actions and configuration changes. -

-
- - -
+ + + Refresh + + } + /> {/* No guild selected */} {!guildId && ( -
-

- Select a server from the sidebar to view the audit log. -

-
+ )} {/* Content */} {guildId && ( <> +
+
+

+ Total Entries +

+

+ {total.toLocaleString()} +

+
+
+

+ Active Filters +

+

+ {[actionFilter, debouncedUserSearch, startDate, endDate].filter(Boolean).length} +

+
+
+

+ Expanded Rows +

+

+ {expandedRows.size} +

+
+
+ {/* Filters */} -
-
+
+
setUserSearch(e.target.value)} @@ -334,7 +357,7 @@ export default function AuditLogPage() { setOffset(0); }} > - + @@ -347,29 +370,27 @@ export default function AuditLogPage() { -
- { - setStartDate(e.target.value); - setOffset(0); - }} - aria-label="Start date filter" - /> - - { - setEndDate(e.target.value); - setOffset(0); - }} - aria-label="End date filter" - /> -
+ { + setStartDate(e.target.value); + setOffset(0); + }} + aria-label="Start date filter" + /> + + { + setEndDate(e.target.value); + setOffset(0); + }} + aria-label="End date filter" + /> {total > 0 && ( @@ -392,7 +413,7 @@ export default function AuditLogPage() { {loading && entries.length === 0 ? ( ) : entries.length > 0 ? ( -
+
@@ -445,8 +466,8 @@ export default function AuditLogPage() { {isExpanded && entry.details && ( - -
+                              
+                                
                                   {JSON.stringify(entry.details, null, 2)}
                                 
@@ -459,18 +480,24 @@ export default function AuditLogPage() {
) : ( -
-

- {actionFilter || debouncedUserSearch || startDate || endDate - ? 'No audit entries match your filters.' - : 'No audit log entries found.'} -

-
+ )} {/* Pagination */} {totalPages > 1 && ( -
+
Page {currentPage} of {totalPages} diff --git a/web/src/app/dashboard/conversations/conversations-client.tsx b/web/src/app/dashboard/conversations/conversations-client.tsx new file mode 100644 index 00000000..bc2a9c49 --- /dev/null +++ b/web/src/app/dashboard/conversations/conversations-client.tsx @@ -0,0 +1,507 @@ +'use client'; + +import { Hash, MessageSquare, RefreshCw, Search, X } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { EmptyState } from '@/components/dashboard/empty-state'; +import { PageHeader } from '@/components/dashboard/page-header'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useGuildSelection } from '@/hooks/use-guild-selection'; + +function ConversationsSkeleton() { + return ( +
+ + + + Channel + Participants + Messages + Duration + Preview + Date + + + + {Array.from({ length: 8 }).map((_, i) => ( + + + + + + + + + + + + + + + + + + + + + ))} + +
+
+ ); +} + +interface Participant { + username: string; + role: string; +} + +interface ConversationSummary { + id: number; + channelId: string; + channelName: string; + participants: Participant[]; + messageCount: number; + firstMessageAt: string; + lastMessageAt: string; + preview: string; +} + +interface ConversationsApiResponse { + conversations: ConversationSummary[]; + total: number; + page: number; +} + +interface Channel { + id: string; + name: string; + type: number; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatDuration(first: string, last: string): string { + const ms = new Date(last).getTime() - new Date(first).getTime(); + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + return `${hours}h ${mins % 60}m`; +} + +/** Number of conversations per page */ +const PAGE_SIZE = 25; + +/** + * Conversation list page with search, filters, and pagination. + */ +export default function ConversationsClient() { + const router = useRouter(); + + const [conversations, setConversations] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [search, setSearch] = useState(''); + const [channelFilter, setChannelFilter] = useState(''); + const [channels, setChannels] = useState([]); + + // Debounce search + const searchTimerRef = useRef>(undefined); + const [debouncedSearch, setDebouncedSearch] = useState(''); + + const abortControllerRef = useRef(null); + const requestIdRef = useRef(0); + + useEffect(() => { + clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 300); + return () => clearTimeout(searchTimerRef.current); + }, [search]); + + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const onGuildChange = useCallback(() => { + setConversations([]); + setTotal(0); + setPage(1); + setError(null); + setChannels([]); + }, []); + + const guildId = useGuildSelection({ onGuildChange }); + + const onUnauthorized = useCallback(() => router.replace('/login'), [router]); + + // Fetch channels for filter dropdown + useEffect(() => { + if (!guildId) return; + void (async () => { + try { + const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/channels`); + if (res.ok) { + const data = (await res.json()) as Channel[]; + // Only show text channels (type 0) + setChannels(data.filter((ch) => ch.type === 0)); + } + } catch { + // Non-critical — channels filter just won't populate + } + })(); + }, [guildId]); + + // Fetch conversations + const fetchConversations = useCallback( + async (opts: { guildId: string; search: string; channel: string; page: number }) => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + const requestId = ++requestIdRef.current; + + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + params.set('page', String(opts.page)); + params.set('limit', String(PAGE_SIZE)); + if (opts.search) params.set('search', opts.search); + if (opts.channel) params.set('channel', opts.channel); + + const res = await fetch( + `/api/guilds/${encodeURIComponent(opts.guildId)}/conversations?${params.toString()}`, + { signal: controller.signal }, + ); + + if (requestId !== requestIdRef.current) return; + + if (res.status === 401) { + onUnauthorized(); + return; + } + if (!res.ok) { + throw new Error(`Failed to fetch conversations (${res.status})`); + } + + const data = (await res.json()) as ConversationsApiResponse; + setConversations(data.conversations); + setTotal(data.total); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + if (requestId !== requestIdRef.current) return; + setError(err instanceof Error ? err.message : 'Failed to fetch conversations'); + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + } + } + }, + [onUnauthorized], + ); + + useEffect(() => { + if (!guildId) return; + void fetchConversations({ + guildId, + search: debouncedSearch, + channel: channelFilter, + page, + }); + }, [guildId, debouncedSearch, channelFilter, page, fetchConversations]); + + const handleRefresh = useCallback(() => { + if (!guildId) return; + void fetchConversations({ + guildId, + search: debouncedSearch, + channel: channelFilter, + page, + }); + }, [guildId, fetchConversations, debouncedSearch, channelFilter, page]); + + const handleRowClick = useCallback( + (conversationId: number) => { + if (!guildId) return; + router.push( + `/dashboard/conversations/${conversationId}?guildId=${encodeURIComponent(guildId)}`, + ); + }, + [router, guildId], + ); + + const handleClearSearch = useCallback(() => { + setSearch(''); + setDebouncedSearch(''); + setPage(1); + }, []); + + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+ + + Refresh + + } + /> + + {/* No guild selected */} + {!guildId && ( + + )} + + {/* Content */} + {guildId && ( + <> +
+
+

+ Total Conversations +

+

+ {total.toLocaleString()} +

+
+
+

+ Text Channels +

+

+ {channels.length.toLocaleString()} +

+
+
+

+ Page Window +

+

+ {page} of {Math.max(1, totalPages)} +

+
+
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + aria-label="Search conversations" + /> + {search && ( + + )} +
+ + + + {total > 0 && ( + + {total.toLocaleString()} {total === 1 ? 'conversation' : 'conversations'} + + )} +
+ + {/* Error */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Table */} + {loading && conversations.length === 0 ? ( + + ) : conversations.length > 0 ? ( +
+ + + + Channel + Participants + Messages + Duration + Preview + Date + + + + {conversations.map((convo) => ( + handleRowClick(convo.id)} + > + +
+ + {convo.channelName} +
+
+ +
+ {convo.participants.slice(0, 3).map((p) => ( +
+ {p.username.slice(0, 2).toUpperCase()} +
+ ))} + {convo.participants.length > 3 && ( +
+ +{convo.participants.length - 3} +
+ )} +
+
+ + {convo.messageCount} + + + {formatDuration(convo.firstMessageAt, convo.lastMessageAt)} + + + {convo.preview} + + + {formatDate(convo.firstMessageAt)} + +
+ ))} +
+
+
+ ) : ( + + )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} + + )} +
+ ); +} diff --git a/web/src/app/dashboard/conversations/page.tsx b/web/src/app/dashboard/conversations/page.tsx index 6fc5add1..ad60447d 100644 --- a/web/src/app/dashboard/conversations/page.tsx +++ b/web/src/app/dashboard/conversations/page.tsx @@ -1,488 +1,17 @@ -'use client'; - -import { Hash, MessageSquare, RefreshCw, Search, X } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; +import type { Metadata } from 'next'; import { ErrorBoundary } from '@/components/ui/error-boundary'; -import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Skeleton } from '@/components/ui/skeleton'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { useGuildSelection } from '@/hooks/use-guild-selection'; +import { createPageMetadata } from '@/lib/page-titles'; +import ConversationsClient from './conversations-client'; -function ConversationsSkeleton() { - return ( -
- - - - Channel - Participants - Messages - Duration - Preview - Date - - - - {Array.from({ length: 8 }).map((_, i) => ( - - - - - - - - - - - - - - - - - - - - - ))} - -
-
- ); -} +export const metadata: Metadata = createPageMetadata( + 'Conversations', + 'Browse, search, and replay AI conversations.', +); -interface Participant { - username: string; - role: string; -} - -interface ConversationSummary { - id: number; - channelId: string; - channelName: string; - participants: Participant[]; - messageCount: number; - firstMessageAt: string; - lastMessageAt: string; - preview: string; -} - -interface ConversationsApiResponse { - conversations: ConversationSummary[]; - total: number; - page: number; -} - -interface Channel { - id: string; - name: string; - type: number; -} - -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -} - -function formatDuration(first: string, last: string): string { - const ms = new Date(last).getTime() - new Date(first).getTime(); - const seconds = Math.round(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const mins = Math.floor(seconds / 60); - if (mins < 60) return `${mins}m`; - const hours = Math.floor(mins / 60); - return `${hours}h ${mins % 60}m`; -} - -/** Number of conversations per page */ -const PAGE_SIZE = 25; - -/** - * Conversation list page with search, filters, and pagination. - */ export default function ConversationsPage() { - const router = useRouter(); - - const [conversations, setConversations] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const [search, setSearch] = useState(''); - const [channelFilter, setChannelFilter] = useState(''); - const [channels, setChannels] = useState([]); - - // Debounce search - const searchTimerRef = useRef>(undefined); - const [debouncedSearch, setDebouncedSearch] = useState(''); - - const abortControllerRef = useRef(null); - const requestIdRef = useRef(0); - - useEffect(() => { - clearTimeout(searchTimerRef.current); - searchTimerRef.current = setTimeout(() => { - setDebouncedSearch(search); - setPage(1); - }, 300); - return () => clearTimeout(searchTimerRef.current); - }, [search]); - - useEffect(() => { - return () => { - abortControllerRef.current?.abort(); - }; - }, []); - - const onGuildChange = useCallback(() => { - setConversations([]); - setTotal(0); - setPage(1); - setError(null); - setChannels([]); - }, []); - - const guildId = useGuildSelection({ onGuildChange }); - - const onUnauthorized = useCallback(() => router.replace('/login'), [router]); - - // Fetch channels for filter dropdown - useEffect(() => { - if (!guildId) return; - void (async () => { - try { - const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/channels`); - if (res.ok) { - const data = (await res.json()) as Channel[]; - // Only show text channels (type 0) - setChannels(data.filter((ch) => ch.type === 0)); - } - } catch { - // Non-critical — channels filter just won't populate - } - })(); - }, [guildId]); - - // Fetch conversations - const fetchConversations = useCallback( - async (opts: { guildId: string; search: string; channel: string; page: number }) => { - abortControllerRef.current?.abort(); - const controller = new AbortController(); - abortControllerRef.current = controller; - const requestId = ++requestIdRef.current; - - setLoading(true); - setError(null); - - try { - const params = new URLSearchParams(); - params.set('page', String(opts.page)); - params.set('limit', String(PAGE_SIZE)); - if (opts.search) params.set('search', opts.search); - if (opts.channel) params.set('channel', opts.channel); - - const res = await fetch( - `/api/guilds/${encodeURIComponent(opts.guildId)}/conversations?${params.toString()}`, - { signal: controller.signal }, - ); - - if (requestId !== requestIdRef.current) return; - - if (res.status === 401) { - onUnauthorized(); - return; - } - if (!res.ok) { - throw new Error(`Failed to fetch conversations (${res.status})`); - } - - const data = (await res.json()) as ConversationsApiResponse; - setConversations(data.conversations); - setTotal(data.total); - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return; - if (requestId !== requestIdRef.current) return; - setError(err instanceof Error ? err.message : 'Failed to fetch conversations'); - } finally { - if (requestId === requestIdRef.current) { - setLoading(false); - } - } - }, - [onUnauthorized], - ); - - useEffect(() => { - if (!guildId) return; - void fetchConversations({ - guildId, - search: debouncedSearch, - channel: channelFilter, - page, - }); - }, [guildId, debouncedSearch, channelFilter, page, fetchConversations]); - - const handleRefresh = useCallback(() => { - if (!guildId) return; - void fetchConversations({ - guildId, - search: debouncedSearch, - channel: channelFilter, - page, - }); - }, [guildId, fetchConversations, debouncedSearch, channelFilter, page]); - - const handleRowClick = useCallback( - (conversationId: number) => { - if (!guildId) return; - router.push( - `/dashboard/conversations/${conversationId}?guildId=${encodeURIComponent(guildId)}`, - ); - }, - [router, guildId], - ); - - const handleClearSearch = useCallback(() => { - setSearch(''); - setDebouncedSearch(''); - setPage(1); - }, []); - - const totalPages = Math.ceil(total / PAGE_SIZE); - return ( -
- {/* Header */} -
-
-

- - Conversations -

-

Browse, search, and replay AI conversations.

-
- - -
- - {/* No guild selected */} - {!guildId && ( -
-

- Select a server from the sidebar to view conversations. -

-
- )} - - {/* Content */} - {guildId && ( - <> - {/* Filters */} -
-
- - setSearch(e.target.value)} - aria-label="Search conversations" - /> - {search && ( - - )} -
- - - - {total > 0 && ( - - {total.toLocaleString()} {total === 1 ? 'conversation' : 'conversations'} - - )} -
- - {/* Error */} - {error && ( -
- Error: {error} -
- )} - - {/* Table */} - {loading && conversations.length === 0 ? ( - - ) : conversations.length > 0 ? ( -
- - - - Channel - Participants - Messages - Duration - Preview - Date - - - - {conversations.map((convo) => ( - handleRowClick(convo.id)} - tabIndex={0} - role="button" - aria-label={`View conversation in ${convo.channelName}`} - onKeyDown={(e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleRowClick(convo.id); - } - }} - > - -
- - {convo.channelName} -
-
- -
- {convo.participants.slice(0, 3).map((p) => ( -
- {p.username.slice(0, 2).toUpperCase()} -
- ))} - {convo.participants.length > 3 && ( -
- +{convo.participants.length - 3} -
- )} -
-
- - {convo.messageCount} - - - {formatDuration(convo.firstMessageAt, convo.lastMessageAt)} - - - {convo.preview} - - - {formatDate(convo.firstMessageAt)} - -
- ))} -
-
-
- ) : ( -
-

- {debouncedSearch || channelFilter - ? 'No conversations match your filters.' - : 'No conversations found.'} -

-
- )} - - {/* Pagination */} - {totalPages > 1 && ( -
- - Page {page} of {totalPages} - -
- - -
-
- )} - - )} -
+
); } diff --git a/web/src/app/dashboard/logs/page.tsx b/web/src/app/dashboard/logs/page.tsx index 278c1ed5..35d11331 100644 --- a/web/src/app/dashboard/logs/page.tsx +++ b/web/src/app/dashboard/logs/page.tsx @@ -1,8 +1,10 @@ 'use client'; +import { ScrollText } from 'lucide-react'; import { HealthSection } from '@/components/dashboard/health-section'; import { LogFilters } from '@/components/dashboard/log-filters'; import { LogViewer } from '@/components/dashboard/log-viewer'; +import { PageHeader } from '@/components/dashboard/page-header'; import { ErrorBoundary } from '@/components/ui/error-boundary'; import { useGuildSelection } from '@/hooks/use-guild-selection'; import { useLogStream } from '@/lib/log-ws'; @@ -23,25 +25,31 @@ export default function LogsPage() { return ( -
+
+ + {/* Health cards + restart history */} {/* Log stream section */} -
+
-

Log Stream

+

Log Stream

Real-time logs from the bot API

-
+
-
+
); diff --git a/web/src/app/dashboard/members/members-client.tsx b/web/src/app/dashboard/members/members-client.tsx new file mode 100644 index 00000000..a78d4d34 --- /dev/null +++ b/web/src/app/dashboard/members/members-client.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { RefreshCw, Search, Users, X } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef } from 'react'; +import { EmptyState } from '@/components/dashboard/empty-state'; +import { MemberTable } from '@/components/dashboard/member-table'; +import { PageHeader } from '@/components/dashboard/page-header'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useGuildSelection } from '@/hooks/use-guild-selection'; +import { useMembersStore } from '@/stores/members-store'; + +/** + * Renders the Members page with search, sorting, pagination, and a member list table. + * + * Displays a searchable and sortable list of guild members, supports cursor-based + * pagination, refreshing, row navigation to a member detail page, and shows totals + * and errors. If the API responds with an unauthorized status, navigates to `/login`. + */ +export default function MembersClient() { + const router = useRouter(); + + const { + members, + nextAfter, + total, + filteredTotal, + loading, + error, + search, + debouncedSearch, + sortColumn, + sortOrder, + setSearch, + setDebouncedSearch, + setSortColumn, + setSortOrder, + resetPagination, + resetAll, + fetchMembers, + } = useMembersStore(); + + // Debounce search + const searchTimerRef = useRef>(undefined); + // AbortController for cancelling in-flight requests + const abortRef = useRef(null); + + useEffect(() => { + clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + setDebouncedSearch(search); + }, 300); + return () => clearTimeout(searchTimerRef.current); + }, [search, setDebouncedSearch]); + + // Abort in-flight request on unmount + useEffect(() => { + return () => { + abortRef.current?.abort(); + }; + }, []); + + const onGuildChange = useCallback(() => { + abortRef.current?.abort(); + resetAll(); + }, [resetAll]); + + const guildId = useGuildSelection({ onGuildChange }); + + const onUnauthorized = useCallback(() => router.replace('/login'), [router]); + + // Fetch helper that manages abort controller and unauthorized redirect + const runFetch = useCallback( + async (opts: Parameters[0]) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + const result = await fetchMembers({ ...opts, signal: controller.signal }); + if (result === 'unauthorized') onUnauthorized(); + }, + [fetchMembers, onUnauthorized], + ); + + // Fetch on guild/search/sort change + useEffect(() => { + if (!guildId) return; + void runFetch({ + guildId, + search: debouncedSearch, + sortColumn, + sortOrder, + after: null, + append: false, + }); + }, [guildId, debouncedSearch, sortColumn, sortOrder, runFetch]); + + const handleSort = useCallback( + (col: typeof sortColumn) => { + if (col === sortColumn) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortColumn(col); + setSortOrder('desc'); + } + resetPagination(); + }, + [sortColumn, sortOrder, setSortColumn, setSortOrder, resetPagination], + ); + + const handleLoadMore = useCallback(() => { + if (!guildId || !nextAfter || loading) return; + void runFetch({ + guildId, + search: debouncedSearch, + sortColumn, + sortOrder, + after: nextAfter, + append: true, + }); + }, [guildId, nextAfter, loading, runFetch, debouncedSearch, sortColumn, sortOrder]); + + const handleRefresh = useCallback(() => { + if (!guildId) return; + resetPagination(); + void runFetch({ + guildId, + search: debouncedSearch, + sortColumn, + sortOrder, + after: null, + append: false, + }); + }, [guildId, runFetch, debouncedSearch, sortColumn, sortOrder, resetPagination]); + + const handleRowClick = useCallback( + (userId: string) => { + if (!guildId) return; + router.push(`/dashboard/members/${userId}?guildId=${encodeURIComponent(guildId)}`); + }, + [router, guildId], + ); + + const handleClearSearch = useCallback(() => { + setSearch(''); + setDebouncedSearch(''); + }, [setSearch, setDebouncedSearch]); + + return ( +
+ + + Refresh + + } + /> + + {/* No guild selected */} + {!guildId && ( + + )} + + {/* Content */} + {guildId && ( + <> +
+
+

+ Total Members +

+

+ {total.toLocaleString()} +

+
+
+

+ Filtered Results +

+

+ {(filteredTotal ?? total).toLocaleString()} +

+
+
+

+ Sort Strategy +

+

+ {sortColumn} · {sortOrder} +

+
+
+ + {/* Search + stats bar */} +
+
+ + setSearch(e.target.value)} + aria-label="Search members" + /> + {search && ( + + )} +
+ {total > 0 && ( + + {filteredTotal !== null && filteredTotal !== total + ? `${filteredTotal.toLocaleString()} of ${total.toLocaleString()} members` + : `${total.toLocaleString()} ${total === 1 ? 'member' : 'members'}`} + + )} +
+ + {/* Error */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Table */} + + + )} +
+ ); +} diff --git a/web/src/app/dashboard/members/page.tsx b/web/src/app/dashboard/members/page.tsx index 419a4b0d..078d165b 100644 --- a/web/src/app/dashboard/members/page.tsx +++ b/web/src/app/dashboard/members/page.tsx @@ -1,244 +1,17 @@ -'use client'; - -import { RefreshCw, Search, Users, X } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useRef } from 'react'; -import { MemberTable } from '@/components/dashboard/member-table'; -import { Button } from '@/components/ui/button'; +import type { Metadata } from 'next'; import { ErrorBoundary } from '@/components/ui/error-boundary'; -import { Input } from '@/components/ui/input'; -import { useGuildSelection } from '@/hooks/use-guild-selection'; -import { useMembersStore } from '@/stores/members-store'; - -/** - * Renders the Members page with search, sorting, pagination, and a member list table. - * - * Displays a searchable and sortable list of guild members, supports cursor-based - * pagination, refreshing, row navigation to a member detail page, and shows totals - * and errors. If the API responds with an unauthorized status, navigates to `/login`. - */ -export default function MembersPage() { - const router = useRouter(); - - const { - members, - nextAfter, - total, - filteredTotal, - loading, - error, - search, - debouncedSearch, - sortColumn, - sortOrder, - setSearch, - setDebouncedSearch, - setSortColumn, - setSortOrder, - resetPagination, - resetAll, - fetchMembers, - } = useMembersStore(); - - // Debounce search - const searchTimerRef = useRef>(undefined); - // AbortController for cancelling in-flight requests - const abortRef = useRef(null); - - useEffect(() => { - clearTimeout(searchTimerRef.current); - searchTimerRef.current = setTimeout(() => { - setDebouncedSearch(search); - }, 300); - return () => clearTimeout(searchTimerRef.current); - }, [search, setDebouncedSearch]); - - // Abort in-flight request on unmount - useEffect(() => { - return () => { - abortRef.current?.abort(); - }; - }, []); +import { createPageMetadata } from '@/lib/page-titles'; +import MembersClient from './members-client'; - const onGuildChange = useCallback(() => { - abortRef.current?.abort(); - resetAll(); - }, [resetAll]); - - const guildId = useGuildSelection({ onGuildChange }); - - const onUnauthorized = useCallback(() => router.replace('/login'), [router]); - - // Fetch helper that manages abort controller and unauthorized redirect - const runFetch = useCallback( - async (opts: Parameters[0]) => { - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; - const result = await fetchMembers({ ...opts, signal: controller.signal }); - if (result === 'unauthorized') onUnauthorized(); - }, - [fetchMembers, onUnauthorized], - ); - - // Fetch on guild/search/sort change - useEffect(() => { - if (!guildId) return; - void runFetch({ - guildId, - search: debouncedSearch, - sortColumn, - sortOrder, - after: null, - append: false, - }); - }, [guildId, debouncedSearch, sortColumn, sortOrder, runFetch]); - - const handleSort = useCallback( - (col: typeof sortColumn) => { - if (col === sortColumn) { - setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); - } else { - setSortColumn(col); - setSortOrder('desc'); - } - resetPagination(); - }, - [sortColumn, sortOrder, setSortColumn, setSortOrder, resetPagination], - ); - - const handleLoadMore = useCallback(() => { - if (!guildId || !nextAfter || loading) return; - void runFetch({ - guildId, - search: debouncedSearch, - sortColumn, - sortOrder, - after: nextAfter, - append: true, - }); - }, [guildId, nextAfter, loading, runFetch, debouncedSearch, sortColumn, sortOrder]); - - const handleRefresh = useCallback(() => { - if (!guildId) return; - resetPagination(); - void runFetch({ - guildId, - search: debouncedSearch, - sortColumn, - sortOrder, - after: null, - append: false, - }); - }, [guildId, runFetch, debouncedSearch, sortColumn, sortOrder, resetPagination]); - - const handleRowClick = useCallback( - (userId: string) => { - if (!guildId) return; - router.push(`/dashboard/members/${userId}?guildId=${encodeURIComponent(guildId)}`); - }, - [router, guildId], - ); - - const handleClearSearch = useCallback(() => { - setSearch(''); - setDebouncedSearch(''); - }, [setSearch, setDebouncedSearch]); +export const metadata: Metadata = createPageMetadata( + 'Members', + 'View member activity, XP, levels, and moderation history.', +); +export default function MembersPage() { return ( -
- {/* Header */} -
-
-

- - Members -

-

- View member activity, XP, levels, and moderation history. -

-
- - -
- - {/* No guild selected */} - {!guildId && ( -
-

- Select a server from the sidebar to view members. -

-
- )} - - {/* Content */} - {guildId && ( - <> - {/* Search + stats bar */} -
-
- - setSearch(e.target.value)} - aria-label="Search members" - /> - {search && ( - - )} -
- {total > 0 && ( - - {filteredTotal !== null && filteredTotal !== total - ? `${filteredTotal.toLocaleString()} of ${total.toLocaleString()} members` - : `${total.toLocaleString()} ${total === 1 ? 'member' : 'members'}`} - - )} -
- - {/* Error */} - {error && ( -
- Error: {error} -
- )} - - {/* Table */} - - - )} -
+
); } diff --git a/web/src/app/dashboard/moderation/moderation-client.tsx b/web/src/app/dashboard/moderation/moderation-client.tsx new file mode 100644 index 00000000..62f82c85 --- /dev/null +++ b/web/src/app/dashboard/moderation/moderation-client.tsx @@ -0,0 +1,311 @@ +'use client'; + +import { RefreshCw, Search, Shield, X } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef } from 'react'; +import { CaseTable } from '@/components/dashboard/case-table'; +import { EmptyState } from '@/components/dashboard/empty-state'; +import { ModerationStats } from '@/components/dashboard/moderation-stats'; +import { PageHeader } from '@/components/dashboard/page-header'; +import { Button } from '@/components/ui/button'; +import { ErrorBoundary } from '@/components/ui/error-boundary'; +import { Input } from '@/components/ui/input'; +import { useGuildSelection } from '@/hooks/use-guild-selection'; +import { useModerationStore } from '@/stores/moderation-store'; + +export default function ModerationClient() { + const router = useRouter(); + + const { + page, + sortDesc, + actionFilter, + userSearch, + userHistoryInput, + lookupUserId, + userHistoryPage, + casesData, + casesLoading, + casesError, + stats, + statsLoading, + statsError, + userHistoryData, + userHistoryLoading, + userHistoryError, + setPage, + toggleSortDesc, + setActionFilter, + setUserSearch, + setUserHistoryInput, + setLookupUserId, + setUserHistoryPage, + clearFilters, + clearUserHistory, + resetOnGuildChange, + fetchStats, + fetchCases, + fetchUserHistory, + } = useModerationStore(); + + const abortRefreshRef = useRef(null); + + const onGuildChange = useCallback(() => { + resetOnGuildChange(); + }, [resetOnGuildChange]); + + const guildId = useGuildSelection({ onGuildChange }); + + const onUnauthorized = useCallback(() => router.replace('/login'), [router]); + + useEffect(() => { + if (!guildId) return; + const controller = new AbortController(); + void (async () => { + const result = await fetchStats(guildId, { signal: controller.signal }); + if (result === 'unauthorized') onUnauthorized(); + })(); + return () => controller.abort(); + }, [guildId, fetchStats, onUnauthorized]); + + // page, actionFilter, userSearch, sortDesc are read inside fetchCases via get() + // but must appear in deps so the effect re-fires when they change. + // biome-ignore lint/correctness/useExhaustiveDependencies: filter deps trigger refetch + useEffect(() => { + if (!guildId) return; + const controller = new AbortController(); + void (async () => { + const result = await fetchCases(guildId, { signal: controller.signal }); + if (result === 'unauthorized') onUnauthorized(); + })(); + return () => controller.abort(); + }, [guildId, page, actionFilter, userSearch, sortDesc, fetchCases, onUnauthorized]); + + useEffect(() => { + if (!guildId || !lookupUserId) return; + const controller = new AbortController(); + void (async () => { + const result = await fetchUserHistory(guildId, lookupUserId, userHistoryPage, { + signal: controller.signal, + }); + if (result === 'unauthorized') onUnauthorized(); + })(); + return () => controller.abort(); + }, [guildId, lookupUserId, userHistoryPage, fetchUserHistory, onUnauthorized]); + + useEffect(() => { + return () => { + abortRefreshRef.current?.abort(); + }; + }, []); + + const handleRefresh = useCallback(() => { + if (!guildId) return; + abortRefreshRef.current?.abort(); + const controller = new AbortController(); + abortRefreshRef.current = controller; + const { signal } = controller; + void (async () => { + const [statsResult, casesResult] = await Promise.all([ + fetchStats(guildId, { signal }), + fetchCases(guildId, { signal }), + ]); + if (lookupUserId) { + const historyResult = await fetchUserHistory(guildId, lookupUserId, userHistoryPage, { + signal, + }); + if (historyResult === 'unauthorized') { + onUnauthorized(); + return; + } + } + if (statsResult === 'unauthorized' || casesResult === 'unauthorized') { + onUnauthorized(); + } + })(); + }, [ + guildId, + lookupUserId, + userHistoryPage, + fetchStats, + fetchCases, + fetchUserHistory, + onUnauthorized, + ]); + + const handleUserHistorySearch = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = userHistoryInput.trim(); + if (!trimmed || !guildId) return; + setLookupUserId(trimmed); + setUserHistoryPage(1); + }, + [guildId, userHistoryInput, setLookupUserId, setUserHistoryPage], + ); + + const handleClearUserHistory = useCallback(() => { + clearUserHistory(); + }, [clearUserHistory]); + + return ( +
+ + + Refresh + + } + /> + + {/* No guild selected */} + {!guildId && ( + + )} + + {/* Content */} + {guildId && ( + <> + {/* Stats */} + + + + +
+ {/* Cases */} +
+
+

Cases

+

+ Review, filter, and audit moderator actions in one place. +

+
+ +
+ + {/* User History Lookup */} +
+
+

User History Lookup

+

+ Look up a single user's full moderation timeline. +

+
+ +
+
+ + setUserHistoryInput(e.target.value)} + aria-label="User ID for history lookup" + /> +
+ + {lookupUserId && ( + + )} +
+ + {lookupUserId ? ( +
+

+ History for{' '} + + {lookupUserId} + + {userHistoryData && ( + <> + {' '} + — {userHistoryData.total}{' '} + {userHistoryData.total === 1 ? 'case' : 'cases'} total + + )} +

+ + setUserHistoryPage(pg)} + onSortToggle={() => {}} + onActionFilterChange={() => {}} + onUserSearchChange={() => {}} + onClearFilters={() => {}} + /> +
+ ) : ( + + )} +
+
+ + )} +
+ ); +} diff --git a/web/src/app/dashboard/moderation/page.tsx b/web/src/app/dashboard/moderation/page.tsx index 26a61e46..caf4dc06 100644 --- a/web/src/app/dashboard/moderation/page.tsx +++ b/web/src/app/dashboard/moderation/page.tsx @@ -1,290 +1,17 @@ -'use client'; - -import { RefreshCw, Search, Shield, X } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useRef } from 'react'; -import { CaseTable } from '@/components/dashboard/case-table'; -import { ModerationStats } from '@/components/dashboard/moderation-stats'; -import { Button } from '@/components/ui/button'; +import type { Metadata } from 'next'; import { ErrorBoundary } from '@/components/ui/error-boundary'; -import { Input } from '@/components/ui/input'; -import { useGuildSelection } from '@/hooks/use-guild-selection'; -import { useModerationStore } from '@/stores/moderation-store'; - -export default function ModerationPage() { - const router = useRouter(); - - const { - page, - sortDesc, - actionFilter, - userSearch, - userHistoryInput, - lookupUserId, - userHistoryPage, - casesData, - casesLoading, - casesError, - stats, - statsLoading, - statsError, - userHistoryData, - userHistoryLoading, - userHistoryError, - setPage, - toggleSortDesc, - setActionFilter, - setUserSearch, - setUserHistoryInput, - setLookupUserId, - setUserHistoryPage, - clearFilters, - clearUserHistory, - resetOnGuildChange, - fetchStats, - fetchCases, - fetchUserHistory, - } = useModerationStore(); - - const abortRefreshRef = useRef(null); - - const onGuildChange = useCallback(() => { - resetOnGuildChange(); - }, [resetOnGuildChange]); - - const guildId = useGuildSelection({ onGuildChange }); - - const onUnauthorized = useCallback(() => router.replace('/login'), [router]); - - useEffect(() => { - if (!guildId) return; - const controller = new AbortController(); - void (async () => { - const result = await fetchStats(guildId, { signal: controller.signal }); - if (result === 'unauthorized') onUnauthorized(); - })(); - return () => controller.abort(); - }, [guildId, fetchStats, onUnauthorized]); - - // page, actionFilter, userSearch, sortDesc are read inside fetchCases via get() - // but must appear in deps so the effect re-fires when they change. - // biome-ignore lint/correctness/useExhaustiveDependencies: filter deps trigger refetch - useEffect(() => { - if (!guildId) return; - const controller = new AbortController(); - void (async () => { - const result = await fetchCases(guildId, { signal: controller.signal }); - if (result === 'unauthorized') onUnauthorized(); - })(); - return () => controller.abort(); - }, [guildId, page, actionFilter, userSearch, sortDesc, fetchCases, onUnauthorized]); - - useEffect(() => { - if (!guildId || !lookupUserId) return; - const controller = new AbortController(); - void (async () => { - const result = await fetchUserHistory(guildId, lookupUserId, userHistoryPage, { - signal: controller.signal, - }); - if (result === 'unauthorized') onUnauthorized(); - })(); - return () => controller.abort(); - }, [guildId, lookupUserId, userHistoryPage, fetchUserHistory, onUnauthorized]); - - useEffect(() => { - return () => { - abortRefreshRef.current?.abort(); - }; - }, []); +import { createPageMetadata } from '@/lib/page-titles'; +import ModerationClient from './moderation-client'; - const handleRefresh = useCallback(() => { - if (!guildId) return; - abortRefreshRef.current?.abort(); - const controller = new AbortController(); - abortRefreshRef.current = controller; - const { signal } = controller; - void (async () => { - const [statsResult, casesResult] = await Promise.all([ - fetchStats(guildId, { signal }), - fetchCases(guildId, { signal }), - ]); - if (lookupUserId) { - const historyResult = await fetchUserHistory(guildId, lookupUserId, userHistoryPage, { - signal, - }); - if (historyResult === 'unauthorized') { - onUnauthorized(); - return; - } - } - if (statsResult === 'unauthorized' || casesResult === 'unauthorized') { - onUnauthorized(); - } - })(); - }, [ - guildId, - lookupUserId, - userHistoryPage, - fetchStats, - fetchCases, - fetchUserHistory, - onUnauthorized, - ]); - - const handleUserHistorySearch = useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - const trimmed = userHistoryInput.trim(); - if (!trimmed || !guildId) return; - setLookupUserId(trimmed); - setUserHistoryPage(1); - }, - [guildId, userHistoryInput, setLookupUserId, setUserHistoryPage], - ); - - const handleClearUserHistory = useCallback(() => { - clearUserHistory(); - }, [clearUserHistory]); +export const metadata: Metadata = createPageMetadata( + 'Moderation', + 'Review cases, track activity, and audit your moderation team.', +); +export default function ModerationPage() { return ( -
-
-
-

- - Moderation -

-

- Review cases, track activity, and audit your moderation team. -

-
- - -
- - {!guildId && ( -
-

- Select a server from the sidebar to view moderation data. -

-
- )} - - {guildId && ( - <> - - - - -
-

Cases

- -
- -
-

User History Lookup

-

- Search for a user's complete moderation history by their Discord user ID. -

- -
-
- - setUserHistoryInput(e.target.value)} - aria-label="User ID for history lookup" - /> -
- - {lookupUserId && ( - - )} -
- - {lookupUserId && ( -
-

- History for{' '} - {lookupUserId} - {userHistoryData && ( - <> - {' '} - — {userHistoryData.total}{' '} - {userHistoryData.total === 1 ? 'case' : 'cases'} total - - )} -

- - setUserHistoryPage(pg)} - onSortToggle={() => {}} - onActionFilterChange={() => {}} - onUserSearchChange={() => {}} - onClearFilters={() => {}} - /> -
- )} -
- - )} -
+
); } diff --git a/web/src/app/dashboard/tickets/page.tsx b/web/src/app/dashboard/tickets/page.tsx index 618259b7..67244a90 100644 --- a/web/src/app/dashboard/tickets/page.tsx +++ b/web/src/app/dashboard/tickets/page.tsx @@ -1,480 +1,17 @@ -'use client'; - -import { RefreshCw, Search, Ticket, X } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; +import type { Metadata } from 'next'; import { ErrorBoundary } from '@/components/ui/error-boundary'; -import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Skeleton } from '@/components/ui/skeleton'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { useGuildSelection } from '@/hooks/use-guild-selection'; +import { createPageMetadata } from '@/lib/page-titles'; +import TicketsClient from './tickets-client'; -function TicketsSkeleton() { - return ( -
- - - - ID - Topic - User - Status - Created - Closed - - - - {Array.from({ length: 8 }).map((_, i) => ( - - - - - - - - - - - - - - - - - - - - - ))} - -
-
- ); -} - -interface TicketSummary { - id: number; - guild_id: string; - user_id: string; - topic: string | null; - status: string; - thread_id: string; - channel_id: string | null; - closed_by: string | null; - close_reason: string | null; - created_at: string; - closed_at: string | null; -} - -interface TicketsApiResponse { - tickets: TicketSummary[]; - total: number; - page: number; - limit: number; -} - -interface TicketStats { - openCount: number; - avgResolutionSeconds: number; - ticketsThisWeek: number; -} - -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -} - -function formatDuration(seconds: number): string { - if (seconds === 0) return 'N/A'; - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - if (hours >= 24) { - const days = Math.floor(hours / 24); - return `${days}d ${hours % 24}h`; - } - if (hours > 0) return `${hours}h ${mins}m`; - return `${mins}m`; -} - -const PAGE_SIZE = 25; +export const metadata: Metadata = createPageMetadata( + 'Tickets', + 'Manage support tickets and view transcripts.', +); export default function TicketsPage() { - const router = useRouter(); - - const [tickets, setTickets] = useState([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const [statusFilter, setStatusFilter] = useState(''); - const [search, setSearch] = useState(''); - const searchTimerRef = useRef>(undefined); - const [debouncedSearch, setDebouncedSearch] = useState(''); - - const [stats, setStats] = useState(null); - - const abortControllerRef = useRef(null); - const requestIdRef = useRef(0); - - useEffect(() => { - clearTimeout(searchTimerRef.current); - searchTimerRef.current = setTimeout(() => { - setDebouncedSearch(search); - setPage(1); - }, 300); - return () => clearTimeout(searchTimerRef.current); - }, [search]); - - useEffect(() => { - return () => { - abortControllerRef.current?.abort(); - }; - }, []); - - const onGuildChange = useCallback(() => { - setTickets([]); - setTotal(0); - setPage(1); - setError(null); - setStats(null); - }, []); - - const guildId = useGuildSelection({ onGuildChange }); - - const onUnauthorized = useCallback(() => router.replace('/login'), [router]); - - // Fetch stats - useEffect(() => { - if (!guildId) return; - const controller = new AbortController(); - void (async () => { - try { - const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/tickets/stats`, { - signal: controller.signal, - }); - if (res.ok) { - const data = (await res.json()) as TicketStats; - setStats(data); - } - } catch { - // Non-critical (includes AbortError) - } - })(); - return () => controller.abort(); - }, [guildId]); - - // Fetch tickets - const fetchTickets = useCallback( - async (opts: { guildId: string; status: string; user: string; page: number }) => { - abortControllerRef.current?.abort(); - const controller = new AbortController(); - abortControllerRef.current = controller; - const requestId = ++requestIdRef.current; - - setLoading(true); - setError(null); - - try { - const params = new URLSearchParams(); - params.set('page', String(opts.page)); - params.set('limit', String(PAGE_SIZE)); - if (opts.status) params.set('status', opts.status); - if (opts.user) params.set('user', opts.user); - - const res = await fetch( - `/api/guilds/${encodeURIComponent(opts.guildId)}/tickets?${params.toString()}`, - { signal: controller.signal }, - ); - - if (requestId !== requestIdRef.current) return; - - if (res.status === 401) { - onUnauthorized(); - return; - } - if (!res.ok) { - throw new Error(`Failed to fetch tickets (${res.status})`); - } - - const data = (await res.json()) as TicketsApiResponse; - setTickets(data.tickets); - setTotal(data.total); - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return; - if (requestId !== requestIdRef.current) return; - setError(err instanceof Error ? err.message : 'Failed to fetch tickets'); - } finally { - if (requestId === requestIdRef.current) { - setLoading(false); - } - } - }, - [onUnauthorized], - ); - - useEffect(() => { - if (!guildId) return; - void fetchTickets({ - guildId, - status: statusFilter, - user: debouncedSearch, - page, - }); - }, [guildId, statusFilter, debouncedSearch, page, fetchTickets]); - - const handleRefresh = useCallback(() => { - if (!guildId) return; - void fetchTickets({ - guildId, - status: statusFilter, - user: debouncedSearch, - page, - }); - }, [guildId, fetchTickets, statusFilter, debouncedSearch, page]); - - const handleRowClick = useCallback( - (ticketId: number) => { - if (!guildId) return; - router.push(`/dashboard/tickets/${ticketId}?guildId=${encodeURIComponent(guildId)}`); - }, - [router, guildId], - ); - - const handleClearSearch = useCallback(() => { - setSearch(''); - setDebouncedSearch(''); - setPage(1); - }, []); - - const totalPages = Math.ceil(total / PAGE_SIZE); - return ( -
- {/* Header */} -
-
-

- - Tickets -

-

Manage support tickets and view transcripts.

-
- - -
- - {/* Stats Cards */} - {stats && ( -
-
-
Open Tickets
-
{stats.openCount}
-
-
-
Avg Resolution
-
- {formatDuration(stats.avgResolutionSeconds)} -
-
-
-
This Week
-
{stats.ticketsThisWeek}
-
-
- )} - - {/* No guild selected */} - {!guildId && ( -
-

- Select a server from the sidebar to view tickets. -

-
- )} - - {/* Content */} - {guildId && ( - <> - {/* Filters */} -
-
- - setSearch(e.target.value)} - aria-label="Search tickets by user" - /> - {search && ( - - )} -
- - - - {total > 0 && ( - - {total.toLocaleString()} {total === 1 ? 'ticket' : 'tickets'} - - )} -
- - {/* Error */} - {error && ( -
- Error: {error} -
- )} - - {/* Table */} - {loading && tickets.length === 0 ? ( - - ) : tickets.length > 0 ? ( -
- - - - ID - Topic - User - Status - Created - Closed - - - - {tickets.map((ticket) => ( - handleRowClick(ticket.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleRowClick(ticket.id); - } - }} - > - #{ticket.id} - - {ticket.topic || ( - No topic - )} - - {ticket.user_id} - - - {ticket.status === 'open' ? '🟢 Open' : '🔒 Closed'} - - - - {formatDate(ticket.created_at)} - - - {ticket.closed_at ? formatDate(ticket.closed_at) : '—'} - - - ))} - -
-
- ) : ( -
-

- {statusFilter || debouncedSearch - ? 'No tickets match your filters.' - : 'No tickets found.'} -

-
- )} - - {/* Pagination */} - {totalPages > 1 && ( -
- - Page {page} of {totalPages} - -
- - -
-
- )} - - )} -
+
); } diff --git a/web/src/app/dashboard/tickets/tickets-client.tsx b/web/src/app/dashboard/tickets/tickets-client.tsx new file mode 100644 index 00000000..e1d5e69a --- /dev/null +++ b/web/src/app/dashboard/tickets/tickets-client.tsx @@ -0,0 +1,493 @@ +'use client'; + +import { RefreshCw, Search, Ticket, X } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { EmptyState } from '@/components/dashboard/empty-state'; +import { PageHeader } from '@/components/dashboard/page-header'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useGuildSelection } from '@/hooks/use-guild-selection'; + +function TicketsSkeleton() { + return ( +
+ + + + ID + Topic + User + Status + Created + Closed + + + + {Array.from({ length: 8 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: skeleton placeholders have no stable identity + + + + + + + + + + + + + + + + + + + + + ))} + +
+
+ ); +} + +interface TicketSummary { + id: number; + guild_id: string; + user_id: string; + topic: string | null; + status: string; + thread_id: string; + channel_id: string | null; + closed_by: string | null; + close_reason: string | null; + created_at: string; + closed_at: string | null; +} + +interface TicketsApiResponse { + tickets: TicketSummary[]; + total: number; + page: number; + limit: number; +} + +interface TicketStats { + openCount: number; + avgResolutionSeconds: number; + ticketsThisWeek: number; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatDuration(seconds: number): string { + if (seconds === 0) return 'N/A'; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (hours >= 24) { + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h`; + } + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +const PAGE_SIZE = 25; + +export default function TicketsClient() { + const router = useRouter(); + + const [tickets, setTickets] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [statusFilter, setStatusFilter] = useState(''); + const [search, setSearch] = useState(''); + const searchTimerRef = useRef>(undefined); + const [debouncedSearch, setDebouncedSearch] = useState(''); + + const [stats, setStats] = useState(null); + + const abortControllerRef = useRef(null); + const requestIdRef = useRef(0); + + useEffect(() => { + clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 300); + return () => clearTimeout(searchTimerRef.current); + }, [search]); + + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const onGuildChange = useCallback(() => { + setTickets([]); + setTotal(0); + setPage(1); + setError(null); + setStats(null); + }, []); + + const guildId = useGuildSelection({ onGuildChange }); + + const onUnauthorized = useCallback(() => router.replace('/login'), [router]); + + // Fetch stats — extracted as a reusable callback + const fetchStats = useCallback(async (targetGuildId: string, signal?: AbortSignal) => { + try { + const res = await fetch( + `/api/guilds/${encodeURIComponent(targetGuildId)}/tickets/stats`, + signal ? { signal } : undefined, + ); + if (res.ok) { + const data = (await res.json()) as TicketStats; + setStats(data); + } + } catch { + // Non-critical (includes AbortError) + } + }, []); + + // Fetch stats on guild change + useEffect(() => { + if (!guildId) return; + const controller = new AbortController(); + void fetchStats(guildId, controller.signal); + return () => controller.abort(); + }, [guildId, fetchStats]); + + // Fetch tickets + const fetchTickets = useCallback( + async (opts: { guildId: string; status: string; user: string; page: number }) => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + const requestId = ++requestIdRef.current; + + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + params.set('page', String(opts.page)); + params.set('limit', String(PAGE_SIZE)); + if (opts.status) params.set('status', opts.status); + if (opts.user) params.set('user', opts.user); + + const res = await fetch( + `/api/guilds/${encodeURIComponent(opts.guildId)}/tickets?${params.toString()}`, + { signal: controller.signal }, + ); + + if (requestId !== requestIdRef.current) return; + + if (res.status === 401) { + onUnauthorized(); + return; + } + if (!res.ok) { + throw new Error(`Failed to fetch tickets (${res.status})`); + } + + const data = (await res.json()) as TicketsApiResponse; + setTickets(data.tickets); + setTotal(data.total); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + if (requestId !== requestIdRef.current) return; + setError(err instanceof Error ? err.message : 'Failed to fetch tickets'); + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + } + } + }, + [onUnauthorized], + ); + + useEffect(() => { + if (!guildId) return; + void fetchTickets({ + guildId, + status: statusFilter, + user: debouncedSearch, + page, + }); + }, [guildId, statusFilter, debouncedSearch, page, fetchTickets]); + + const handleRefresh = useCallback(() => { + if (!guildId) return; + void fetchStats(guildId); + void fetchTickets({ + guildId, + status: statusFilter, + user: debouncedSearch, + page, + }); + }, [guildId, fetchStats, fetchTickets, statusFilter, debouncedSearch, page]); + + const handleRowClick = useCallback( + (ticketId: number) => { + if (!guildId) return; + router.push(`/dashboard/tickets/${ticketId}?guildId=${encodeURIComponent(guildId)}`); + }, + [router, guildId], + ); + + const handleClearSearch = useCallback(() => { + setSearch(''); + setDebouncedSearch(''); + setPage(1); + }, []); + + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+ + + Refresh + + } + /> + + {/* Stats Cards */} + {stats && ( +
+
+
+ Open Tickets +
+
+ {stats.openCount} +
+
+
+
+ Avg Resolution +
+
+ {formatDuration(stats.avgResolutionSeconds)} +
+
+
+
+ This Week +
+
+ {stats.ticketsThisWeek} +
+
+
+ )} + + {/* No guild selected */} + {!guildId && ( + + )} + + {/* Content */} + {guildId && ( + <> + {/* Filters */} +
+
+ + setSearch(e.target.value)} + aria-label="Search tickets by user" + /> + {search && ( + + )} +
+ + + + {total > 0 && ( + + {total.toLocaleString()} {total === 1 ? 'ticket' : 'tickets'} + + )} +
+ + {/* Error */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Table */} + {loading && tickets.length === 0 ? ( + + ) : tickets.length > 0 ? ( +
+ + + + ID + Topic + User + Status + Created + Closed + + + + {tickets.map((ticket) => ( + handleRowClick(ticket.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleRowClick(ticket.id); + } + }} + > + #{ticket.id} + + {ticket.topic || ( + No topic + )} + + {ticket.user_id} + + + {ticket.status === 'open' ? 'Open' : 'Closed'} + + + + {formatDate(ticket.created_at)} + + + {ticket.closed_at ? formatDate(ticket.closed_at) : '—'} + + + ))} + +
+
+ ) : ( + + )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} + + )} +
+ ); +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 0d683a59..ccd00bb0 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -180,6 +180,89 @@ opacity: 0.03; } +/* ─── Dashboard canvas & panels ───────────────────────────────────────────── */ + +.dashboard-canvas { + position: relative; + isolation: isolate; +} + +.dashboard-canvas::before { + content: ''; + position: absolute; + inset: 0; + z-index: -2; + background: + radial-gradient(1200px 600px at 8% -10%, hsl(var(--primary) / 0.14), transparent 65%), + radial-gradient(900px 500px at 95% 0%, hsl(var(--secondary) / 0.1), transparent 60%), + linear-gradient(180deg, hsl(var(--background)), hsl(var(--background))); +} + +.dark .dashboard-canvas::before { + background: + radial-gradient(1200px 600px at 8% -10%, hsl(var(--primary) / 0.16), transparent 65%), + radial-gradient(900px 500px at 95% 0%, hsl(var(--secondary) / 0.08), transparent 60%), + linear-gradient(180deg, hsl(var(--background)), hsl(var(--background))); +} + +.dashboard-grid { + background-image: + linear-gradient(hsl(var(--foreground) / 0.03) 1px, transparent 1px), + linear-gradient(90deg, hsl(var(--foreground) / 0.03) 1px, transparent 1px); + background-size: 36px 36px; +} + +.dark .dashboard-grid { + background-image: + linear-gradient(hsl(var(--foreground) / 0.02) 1px, transparent 1px), + linear-gradient(90deg, hsl(var(--foreground) / 0.02) 1px, transparent 1px); +} + +.dashboard-panel { + border: 1px solid hsl(var(--border) / 0.7); + background: linear-gradient( + 180deg, + hsl(var(--card) / 0.94) 0%, + hsl(var(--card) / 0.9) 100% + ); + box-shadow: + 0 1px 0 hsl(var(--foreground) / 0.03), + 0 18px 30px -24px hsl(var(--foreground) / 0.28); +} + +.dark .dashboard-panel { + border: 1px solid hsl(var(--border) / 0.8); + background: linear-gradient( + 180deg, + hsl(var(--card) / 0.9) 0%, + hsl(var(--card) / 0.84) 100% + ); + box-shadow: + 0 1px 0 hsl(var(--foreground) / 0.06), + 0 20px 36px -28px hsl(0 0% 0% / 0.8); +} + +.dashboard-chip { + border: 1px solid hsl(var(--border) / 0.9); + background: hsl(var(--background) / 0.88); + backdrop-filter: blur(8px); +} + +.dashboard-fade-in { + animation: dashboard-fade-in 260ms ease-out; +} + +@keyframes dashboard-fade-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + /* ─── Floating nav island ─────────────────────────────────────────────────── */ .nav-island { diff --git a/web/src/components/dashboard/analytics-dashboard.tsx b/web/src/components/dashboard/analytics-dashboard.tsx index b5e991c1..1abb4fe1 100644 --- a/web/src/components/dashboard/analytics-dashboard.tsx +++ b/web/src/components/dashboard/analytics-dashboard.tsx @@ -34,6 +34,7 @@ import { } from 'recharts'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { EmptyState } from './empty-state'; import { useChartTheme } from '@/hooks/use-chart-theme'; import { useGuildSelection } from '@/hooks/use-guild-selection'; import { exportAnalyticsPdf } from '@/lib/analytics-pdf'; @@ -104,6 +105,19 @@ function formatDeltaPercent(deltaPercent: number | null): string { return `${deltaPercent > 0 ? '+' : ''}${deltaPercent.toFixed(1)}%`; } +function hexToRgba(hex: string, alpha: number): string { + const normalized = hex.replace('#', ''); + if (normalized.length !== 6) { + return `rgba(88, 101, 242, ${alpha})`; + } + + const r = Number.parseInt(normalized.slice(0, 2), 16); + const g = Number.parseInt(normalized.slice(2, 4), 16); + const b = Number.parseInt(normalized.slice(4, 6), 16); + + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + export function AnalyticsDashboard() { const [now] = useState(() => new Date()); const chart = useChartTheme(); @@ -278,6 +292,12 @@ export function AnalyticsDashboard() { ); const topChannels = analytics?.topChannels ?? analytics?.channelActivity ?? []; + const hasMessageVolumeData = (analytics?.messageVolume?.length ?? 0) > 0; + const hasModelUsageData = modelUsageData.length > 0; + const hasTokenUsageData = + (analytics?.aiUsage.tokens.prompt ?? 0) > 0 || (analytics?.aiUsage.tokens.completion ?? 0) > 0; + const hasTopChannelsData = topChannels.length > 0; + const canShowNoDataStates = !loading && analytics !== null; const kpiCards = useMemo( () => [ @@ -643,86 +663,141 @@ export function AnalyticsDashboard() {
-
- +
+ Message volume Messages and AI requests over the selected range. -
- - - - - - - - - - - -
+ {hasMessageVolumeData ? ( +
+ + + + + + + + + + + +
+ ) : canShowNoDataStates ? ( + + ) : ( +