From 41e67423b81029bf19a0bf4ac67fc72b68a4924a Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sun, 14 Jun 2026 15:16:18 +0700 Subject: [PATCH] chore(services): update components based on recent changes Changes: services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx | 258 +++++++++++----------------------------------------------------------------- services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx | 171 ++++++++------------------------------------------- services/frontend/src/features/dashboard/components/DashboardStats.tsx | 8 ++- services/frontend/src/features/dashboard/components/UserProfileDetail.tsx | 297 +++++++++++++--------------------------------------------------------------------------- services/frontend/src/features/dashboard/components/UserSummaryList.tsx | 204 +++++++++++------------------------------------------------- services/frontend/src/features/dashboard/hooks/useDashboard.ts | 203 +++++++++++++++--------------------------------------------- services/frontend/src/features/messages/components/MessageCard.tsx | 10 +-- services/frontend/src/shared/api/client.ts | 87 +++++++++++++++++++------- services/frontend/src/shared/hooks/useItemDetail.ts | 58 +++++++++++++++++ services/frontend/src/shared/hooks/usePaginatedList.ts | 105 +++++++++++++++++++++++++++++++ --- .../components/ChannelProfileDetail.tsx | 260 +++------------ .../components/ChannelSummaryList.tsx | 171 ++-------- .../dashboard/components/DashboardStats.tsx | 8 +- .../components/UserProfileDetail.tsx | 303 +++--------------- .../dashboard/components/UserSummaryList.tsx | 206 +++--------- .../features/dashboard/hooks/useDashboard.ts | 203 +++--------- .../messages/components/MessageCard.tsx | 10 +- services/frontend/src/shared/api/client.ts | 87 +++-- .../src/shared/hooks/useItemDetail.ts | 58 ++++ .../src/shared/hooks/usePaginatedList.ts | 105 ++++++ .../frontend/src/shared/ui/MobileTabBar.tsx | 14 +- services/frontend/src/shared/ui/badge.tsx | 11 +- services/frontend/src/shared/ui/button.tsx | 5 +- services/frontend/src/shared/ui/card.tsx | 1 + services/frontend/src/shared/ui/index.ts | 9 + services/frontend/src/shared/ui/input.tsx | 9 +- .../frontend/src/shared/ui/profile-detail.tsx | 206 ++++++++++++ services/frontend/src/shared/ui/select.tsx | 8 +- services/frontend/src/shared/ui/skeleton.tsx | 2 + .../frontend/src/shared/ui/status-badge.tsx | 52 +++ .../frontend/src/shared/ui/summary-list.tsx | 153 +++++++++ services/frontend/src/shared/ui/toast.tsx | 55 +++- services/frontend/src/shared/ws/events.ts | 82 +++-- services/frontend/src/shared/ws/socket.ts | 112 +++++-- services/frontend/tailwind.config.js | 5 - 25 files changed, 1090 insertions(+), 1045 deletions(-) create mode 100644 services/frontend/src/shared/hooks/useItemDetail.ts create mode 100644 services/frontend/src/shared/hooks/usePaginatedList.ts create mode 100644 services/frontend/src/shared/ui/profile-detail.tsx create mode 100644 services/frontend/src/shared/ui/status-badge.tsx create mode 100644 services/frontend/src/shared/ui/summary-list.tsx diff --git a/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx b/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx index c14886d..d817103 100644 --- a/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx +++ b/services/frontend/src/features/dashboard/components/ChannelProfileDetail.tsx @@ -1,16 +1,6 @@ -import { motion } from "framer-motion"; -import { AlertCircle, ArrowLeft, Hash, RefreshCw } from "lucide-react"; +import { Hash } from "lucide-react"; import type { DashboardChannelDetail } from "../../../shared/api/client"; -import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger"; -import { cn } from "../../../shared/lib/utils"; -import { - Badge, - Card, - CardContent, - CardHeader, - CardTitle, - Skeleton, -} from "../../../shared/ui"; +import { ProfileDetail } from "../../../shared/ui"; interface ChannelProfileDetailProps { detail: DashboardChannelDetail | null; @@ -27,218 +17,40 @@ export function ChannelProfileDetail({ onBack, onRefetch, }: ChannelProfileDetailProps) { + if (!detail && !loading && !error) return null; + return ( - - {/* Back button */} - - - - - {/* Loading */} - {loading && } - - {/* Error */} - {error && ( - - -

{error}

- -
- )} - - {detail && !error && ( - <> - {/* Channel header */} - - - -
-
- -
- -
-

- #{detail.channel_name ?? detail.channel_id} -

-

- {detail.channel_id} -

- -
- {detail.flagged_count > 0 && ( - - {detail.flagged_count} flagged - - )} - {detail.culture_summary && ( - AI Profile - )} -
-
-
- - {/* Culture summary */} - {detail.culture_summary && ( -
-

- AI Channel Summary -

-

- {detail.culture_summary} -

- {detail.last_analyzed_at && ( -

- Last analyzed:{" "} - {new Date(detail.last_analyzed_at).toLocaleString()} -

- )} -
- )} -
-
-
- - {/* Stats grid */} - - - -

- {detail.total_messages.toLocaleString()} -

-

- Total Messages -

-
-
- - -

- {detail.clean_count.toLocaleString()} -

-

Clean

-
-
- - -

0 - ? "text-destructive" - : "text-muted-foreground", - )} - > - {detail.flagged_count.toLocaleString()} -

-

Flagged

-
-
-
- - {/* Recent messages */} - - - - Recent Messages - - - {detail.recent_messages.length === 0 ? ( -

- No messages found. -

- ) : ( -
- {detail.recent_messages.map((msg) => ( -
-
-

- {msg.content} -

- {msg.ai_status && ( - - {msg.ai_status} - - )} -
-

- {msg.username ?? "unknown"} ·{" "} - {new Date(msg.created_at).toLocaleString()} -

-
- ))} -
- )} -
-
-
- - )} -
- ); -} - -function DetailSkeleton() { - return ( -
- - -
- -
- - -
- - -
-
-
- -
-
-
- {Array.from({ length: 3 }).map((_, i) => ( - - - - - - - ))} -
-
+ } + title={detail ? `#${detail.channel_name ?? detail.channel_id}` : ""} + subtitle={detail?.channel_id} + summaryLabel="AI Channel Summary" + summaryText={detail?.culture_summary ?? undefined} + lastAnalyzedLabel={ + detail?.last_analyzed_at + ? `Last analyzed: ${new Date(detail.last_analyzed_at).toLocaleString()}` + : undefined + } + stats={{ + totalLabel: "Total Messages", + totalValue: detail?.total_messages ?? 0, + cleanLabel: "Clean", + cleanValue: detail?.clean_count ?? 0, + flaggedLabel: "Flagged", + flaggedValue: detail?.flagged_count ?? 0, + }} + messages={ + detail?.recent_messages.map((msg) => ({ + id: msg.id, + content: msg.content, + created_at: new Date(msg.created_at).toISOString(), + ai_status: msg.ai_status, + })) ?? [] + } + /> ); } diff --git a/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx b/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx index d74bc46..e306a67 100644 --- a/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx +++ b/services/frontend/src/features/dashboard/components/ChannelSummaryList.tsx @@ -1,9 +1,7 @@ -import { motion } from "framer-motion"; -import { AlertCircle, Hash, Loader2, RefreshCw, Search } from "lucide-react"; +import { Hash } from "lucide-react"; import type { DashboardChannel } from "../../../shared/api/client"; -import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger"; -import { cn } from "../../../shared/lib/utils"; -import { Card, CardContent, Input, Skeleton } from "../../../shared/ui"; +import { SummaryList } from "../../../shared/ui"; +import type { SummaryItem } from "../../../shared/ui"; interface ChannelSummaryListProps { channels: DashboardChannel[]; @@ -28,148 +26,31 @@ export function ChannelSummaryList({ onRefetch, onSelectChannel, }: ChannelSummaryListProps) { + const items: SummaryItem[] = channels.map((ch) => ({ + id: ch.channel_id, + label: `#${ch.channel_name ?? ch.channel_id}`, + subtitle: ch.flagged_count > 0 ? `${ch.flagged_count} flagged` : undefined, + summaryText: ch.culture_summary ?? `${ch.total_messages} messages`, + onClick: () => onSelectChannel(ch.channel_id), + })); + return ( - - {/* Search bar */} - -
- - onSearchChange(e.target.value)} - /> + ( +
+
- - - {/* Error state */} - {error && ( - - -

{error}

- -
)} - - {/* Loading state */} - {loading && channels.length === 0 && !error && } - - {/* Empty state */} - {!loading && !error && channels.length === 0 && ( - - -

No channels found.

-
- )} - - {/* Channel cards */} - {channels.length > 0 && ( - - {channels.map((ch) => ( - - ))} - - )} - - {/* Load more */} - {hasMore && ( - - - - )} - - ); -} - -function ChannelListSkeleton() { - return ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - -
- -
- - - -
-
-
-
- ))} -
+ emptyMessage="No channels found." + /> ); } diff --git a/services/frontend/src/features/dashboard/components/DashboardStats.tsx b/services/frontend/src/features/dashboard/components/DashboardStats.tsx index 5e0b7e2..a63b4cc 100644 --- a/services/frontend/src/features/dashboard/components/DashboardStats.tsx +++ b/services/frontend/src/features/dashboard/components/DashboardStats.tsx @@ -17,6 +17,7 @@ import { CardHeader, CardTitle, Skeleton, + StatusBadge, } from "../../../shared/ui"; import { useDashboardStats } from "../hooks/useDashboard"; import { useUIState } from "../../../shared/hooks/useUIState"; @@ -189,7 +190,12 @@ export function DashboardStatsContent() { Moderation Queue -
+
+ + + +
+

{stats.moderation_overview.pending} diff --git a/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx b/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx index af1e9d5..0058e6c 100644 --- a/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx +++ b/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx @@ -1,20 +1,6 @@ -import { motion } from "framer-motion"; -import { AlertCircle, ArrowLeft, RefreshCw, User } from "lucide-react"; +import { User } from "lucide-react"; import type { DashboardUserDetail } from "../../../shared/api/client"; -import { - cardItem, - cardStagger, - fadeSlideUp, -} from "../../../shared/hooks/useFramerStagger"; -import { cn } from "../../../shared/lib/utils"; -import { - Badge, - Card, - CardContent, - CardHeader, - CardTitle, - Skeleton, -} from "../../../shared/ui"; +import { ProfileDetail } from "../../../shared/ui"; interface UserProfileDetailProps { detail: DashboardUserDetail | null; @@ -31,247 +17,50 @@ export function UserProfileDetail({ onBack, onRefetch, }: UserProfileDetailProps) { + if (!detail && !loading && !error) return null; + + const icon = detail?.avatar_url ? ( + + ) : ( + + ); + return ( - - {/* Back button */} - - - - - {/* Loading */} - {loading && } - - {/* Error */} - {error && ( - - -

{error}

- - - )} - - {detail && !error && ( - <> - {/* Profile header */} - - - -
- {/* Avatar */} -
- {detail.avatar_url ? ( - {detail.username - ) : ( -
- -
- )} -
- - {/* Info */} -
-

- {detail.username ?? detail.user_id} -

-

- {detail.user_id} -

- -
- {detail.trust_score !== null && ( - = 80 - ? "success" - : detail.trust_score >= 50 - ? "warning" - : "destructive" - } - > - Trust: {detail.trust_score} - - )} - {detail.total_infractions !== null && - detail.total_infractions > 0 && ( - - {detail.total_infractions} infractions - - )} - {detail.clean_message_streak !== null && - detail.clean_message_streak > 0 && ( - - Streak: {detail.clean_message_streak} - - )} -
-
-
- - {/* Profile summary */} - {detail.profile_summary && ( -
-

- AI Profile Summary -

-

- {detail.profile_summary} -

- {detail.last_analyzed_at && ( -

- Last analyzed:{" "} - {new Date(detail.last_analyzed_at).toLocaleString()} -

- )} -
- )} -
-
-
- - {/* Stats grid */} - - - -

- {detail.total_messages.toLocaleString()} -

-

- Total Messages -

-
-
- - -

- {detail.clean_count.toLocaleString()} -

-

Clean

-
-
- - -

0 - ? "text-destructive" - : "text-muted-foreground", - )} - > - {detail.flagged_count.toLocaleString()} -

-

Flagged

-
-
-
- - {/* Recent messages */} - - - - Recent Messages - - - {detail.recent_messages.length === 0 ? ( -

- No messages found. -

- ) : ( -
- {detail.recent_messages.map((msg) => ( -
-
-

- {msg.content} -

- {msg.ai_status && ( - - {msg.ai_status} - - )} -
-

- #{msg.channel_id} ·{" "} - {new Date(msg.created_at).toLocaleString()} -

-
- ))} -
- )} -
-
-
- - )} - - ); -} - -function DetailSkeleton() { - return ( -
- - -
- -
- - -
- - -
-
-
- -
-
-
- {Array.from({ length: 3 }).map((_, i) => ( - - - - - - - ))} -
-
+ ({ + id: msg.id, + content: msg.content, + created_at: new Date(msg.created_at).toISOString(), + ai_status: msg.ai_status, + })) ?? [] + } + /> ); } diff --git a/services/frontend/src/features/dashboard/components/UserSummaryList.tsx b/services/frontend/src/features/dashboard/components/UserSummaryList.tsx index e6abc4f..06cfdd9 100644 --- a/services/frontend/src/features/dashboard/components/UserSummaryList.tsx +++ b/services/frontend/src/features/dashboard/components/UserSummaryList.tsx @@ -1,16 +1,7 @@ -import { motion } from "framer-motion"; -import { AlertCircle, Loader2, RefreshCw, Search, User } from "lucide-react"; +import { User } from "lucide-react"; import type { DashboardUser } from "../../../shared/api/client"; -import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger"; -import { cn } from "../../../shared/lib/utils"; -import { - Card, - CardContent, - CardHeader, - CardTitle, - Input, - Skeleton, -} from "../../../shared/ui"; +import { SummaryList } from "../../../shared/ui"; +import type { SummaryItem } from "../../../shared/ui"; interface UserSummaryListProps { users: DashboardUser[]; @@ -35,163 +26,42 @@ export function UserSummaryList({ onRefetch, onSelectUser, }: UserSummaryListProps) { + const items: SummaryItem[] = users.map((u) => ({ + id: u.user_id, + label: u.username ?? u.user_id, + subtitle: u.trust_score !== null ? `Trust: ${u.trust_score}` : undefined, + summaryText: u.profile_summary ?? `${u.total_messages} messages`, + onClick: () => onSelectUser(u.user_id), + })); + + const avatarMap = new Map(users.map((u) => [u.user_id, u.avatar_url])); + return ( - - {/* Search bar */} - -
- - onSearchChange(e.target.value)} + { + const avatarUrl = avatarMap.get(item.id); + return avatarUrl ? ( + -
-
- - {/* Error state */} - {error && ( - - -

{error}

- -
- )} - - {/* Loading state */} - {loading && users.length === 0 && !error && } - - {/* Empty state */} - {!loading && !error && users.length === 0 && ( - - -

No users found.

-
- )} - - {/* User cards */} - {users.length > 0 && ( - - {users.map((u) => ( - - ))} - - )} - - {/* Load more */} - {hasMore && ( - - - - )} -
- ); -} - -function UserListSkeleton() { - return ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - -
- -
- - - -
-
-
-
- ))} -
+ ) : ( +
+ +
+ ); + }} + emptyMessage="No users found." + /> ); } diff --git a/services/frontend/src/features/dashboard/hooks/useDashboard.ts b/services/frontend/src/features/dashboard/hooks/useDashboard.ts index 6bab453..05074f2 100644 --- a/services/frontend/src/features/dashboard/hooks/useDashboard.ts +++ b/services/frontend/src/features/dashboard/hooks/useDashboard.ts @@ -11,6 +11,8 @@ import { listDashboardChannels, listDashboardUsers, } from "../../../shared/api/client"; +import { useItemDetail } from "../../../shared/hooks/useItemDetail"; +import { usePaginatedList } from "../../../shared/hooks/usePaginatedList"; const logger = console; @@ -48,58 +50,25 @@ export function useDashboardStats() { * Fetch paginated user list with optional search. */ export function useDashboardUsers() { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [nextCursor, setNextCursor] = useState(null); - const [search, setSearch] = useState(""); - - const fetchUsers = useCallback( - async (cursor?: string) => { - setLoading(true); - setError(null); - try { - const result = await listDashboardUsers({ - limit: 20, - cursor, - search: search || undefined, - }); - if (cursor) { - setUsers((prev) => [...prev, ...result.data]); - } else { - setUsers(result.data); - } - setNextCursor(result.nextCursor); - } catch (e) { - const msg = e instanceof Error ? e.message : "Failed to load users"; - setError(msg); - logger.error("[useDashboardUsers]", msg); - } finally { - setLoading(false); - } - }, - [search], + const paginated = usePaginatedList( + (params) => + listDashboardUsers({ + limit: params.limit, + search: params.search, + cursor: params.cursor, + }).then((r) => ({ data: r.data, nextCursor: r.nextCursor })), + "", ); - useEffect(() => { - fetchUsers().catch(() => undefined); - }, [fetchUsers]); - - const loadMore = useCallback(() => { - if (nextCursor && !loading) { - fetchUsers(nextCursor).catch(() => undefined); - } - }, [nextCursor, loading, fetchUsers]); - return { - users, - loading, - error, - search, - setSearch, - loadMore, - hasMore: !!nextCursor, - refetch: () => fetchUsers().catch(() => undefined), + users: paginated.data, + loading: paginated.loading, + error: paginated.error, + search: paginated.search, + setSearch: paginated.setSearch, + loadMore: paginated.loadMore, + hasMore: paginated.hasMore, + refetch: paginated.refetch, }; } @@ -107,92 +76,40 @@ export function useDashboardUsers() { * Fetch a single user detail by userId. */ export function useDashboardUserDetail(userId: string | null) { - const [detail, setDetail] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const { data, loading, error, refetch } = useItemDetail( + (_guildId, entityId) => getDashboardUserDetail(entityId), + "", + userId, + "user", + ); - const fetch = useCallback(async () => { - if (!userId) return; - setLoading(true); - setError(null); - try { - const data = await getDashboardUserDetail(userId); - if (!data) { - setError("User not found"); - return; - } - setDetail(data); - } catch (e) { - const msg = e instanceof Error ? e.message : "Failed to load user detail"; - setError(msg); - logger.error("[useDashboardUserDetail]", msg); - } finally { - setLoading(false); - } - }, [userId]); - - useEffect(() => { - fetch().catch(() => undefined); - }, [fetch]); - - return { detail, loading, error, refetch: fetch }; + return { detail: data, loading, error, refetch }; } /** * Fetch paginated channel list with optional search. */ export function useDashboardChannels() { - const [channels, setChannels] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [nextCursor, setNextCursor] = useState(null); - const [search, setSearch] = useState(""); - - const fetchChannels = useCallback( - async (cursor?: string) => { - setLoading(true); - setError(null); - try { - const result = await listDashboardChannels({ - limit: 20, - search: search || undefined, - }); - if (cursor) { - setChannels((prev) => [...prev, ...result.data]); - } else { - setChannels(result.data); - } - setNextCursor(result.nextCursor); - } catch (e) { - const msg = e instanceof Error ? e.message : "Failed to load channels"; - setError(msg); - logger.error("[useDashboardChannels]", msg); - } finally { - setLoading(false); - } - }, - [search], + const paginated = usePaginatedList( + (params) => + listDashboardChannels({ + limit: params.limit, + search: params.search, + guild_id: params.guildId, + cursor: params.cursor, + }).then((r) => ({ data: r.data, nextCursor: r.nextCursor })), + "", ); - useEffect(() => { - fetchChannels().catch(() => undefined); - }, [fetchChannels]); - - const loadMore = useCallback(() => { - if (nextCursor && !loading) { - fetchChannels(nextCursor).catch(() => undefined); - } - }, [nextCursor, loading, fetchChannels]); - return { - channels, - loading, - error, - search, - setSearch, - loadMore, - hasMore: !!nextCursor, - refetch: () => fetchChannels().catch(() => undefined), + channels: paginated.data, + loading: paginated.loading, + error: paginated.error, + search: paginated.search, + setSearch: paginated.setSearch, + loadMore: paginated.loadMore, + hasMore: paginated.hasMore, + refetch: paginated.refetch, }; } @@ -200,34 +117,12 @@ export function useDashboardChannels() { * Fetch a single channel detail by channelId. */ export function useDashboardChannelDetail(channelId: string | null) { - const [detail, setDetail] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const { data, loading, error, refetch } = useItemDetail( + (_guildId, entityId) => getDashboardChannelDetail(entityId), + "", + channelId, + "channel", + ); - const fetch = useCallback(async () => { - if (!channelId) return; - setLoading(true); - setError(null); - try { - const data = await getDashboardChannelDetail(channelId); - if (!data) { - setError("Channel not found"); - return; - } - setDetail(data); - } catch (e) { - const msg = - e instanceof Error ? e.message : "Failed to load channel detail"; - setError(msg); - logger.error("[useDashboardChannelDetail]", msg); - } finally { - setLoading(false); - } - }, [channelId]); - - useEffect(() => { - fetch().catch(() => undefined); - }, [fetch]); - - return { detail, loading, error, refetch: fetch }; + return { detail: data, loading, error, refetch }; } diff --git a/services/frontend/src/features/messages/components/MessageCard.tsx b/services/frontend/src/features/messages/components/MessageCard.tsx index 5d684ea..919b88d 100644 --- a/services/frontend/src/features/messages/components/MessageCard.tsx +++ b/services/frontend/src/features/messages/components/MessageCard.tsx @@ -11,7 +11,7 @@ import { import { Fragment, useMemo, useState } from "react"; import { parseMetadata } from "../../../entities/message/types"; import type { MessageRecord } from "../../../shared/api/client"; -import { Badge, Button, Skeleton } from "../../../shared/ui"; +import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui"; const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g; @@ -194,15 +194,11 @@ function MessageRow({ )}
- + {aiStatus === "clean" && } {aiStatus === "flagged" && } {aiStatus === "error" && } - {aiStatus} - + {message.ai_severity && message.ai_severity !== "none" && ( (path: string, init?: RequestInit): Promise { - const password = localStorage.getItem("admin-password"); +// Cache admin password in memory — read from localStorage once on first call +let _cachedPassword: string | null = null; + +function getAdminPassword(): string | null { + if (_cachedPassword === null) { + _cachedPassword = localStorage.getItem("admin-password"); + } + return _cachedPassword; +} + +function buildSearchParams( + params: Record, +): URLSearchParams { + const sp = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value != null && value !== "") { + sp.set(key, String(value)); + } + } + return sp; +} + +export async function request( + path: string, + init?: RequestInit, + timeoutMs?: number, +): Promise { + const password = getAdminPassword(); const url = path.startsWith("http") ? path : `${BE_API_URL}${path}`; + const signal = AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS); logger.debug("Request", { method: init?.method ?? "GET", url }); const res = await fetch(url, { @@ -29,6 +58,7 @@ export async function request(path: string, init?: RequestInit): Promise { "Content-Type": "application/json", ...(password ? { "X-Admin-Password": password } : {}), }, + signal, ...init, }); @@ -144,11 +174,11 @@ export function listMessages(params: { limit?: number; cursor?: string; }): Promise> { - const sp = new URLSearchParams({ + const sp = buildSearchParams({ guildId: params.guildId, - limit: String(params.limit ?? 100), - ...(params.channelId && { channelId: params.channelId }), - ...(params.cursor && { cursor: params.cursor }), + limit: params.limit ?? 100, + channelId: params.channelId, + cursor: params.cursor, }); return request>(`/api/messages?${sp}`); } @@ -263,16 +293,21 @@ export interface VoiceRecording { uploaded_at: number | null; } +export interface VoiceRecordingListResponse { + items: VoiceRecording[]; + nextCursor: string | null; + hasMore: boolean; +} + export function listRecordings(params?: { limit?: number; cursor?: string; -}): Promise<{ items: VoiceRecording[]; nextCursor: string | null; hasMore: boolean }> { - const sp = new URLSearchParams(); - sp.set("limit", String(params?.limit ?? 50)); - if (params?.cursor) sp.set("cursor", params.cursor); - return request<{ items: VoiceRecording[]; nextCursor: string | null; hasMore: boolean }>( - `/api/recordings?${sp}`, - ); +}): Promise { + const sp = buildSearchParams({ + limit: params?.limit ?? 50, + cursor: params?.cursor, + }); + return request(`/api/recordings?${sp}`); } export function deleteRecording(id: string): Promise { @@ -346,10 +381,11 @@ export function getDashboardStats(): Promise { export function listDashboardUsers( params: { limit?: number; cursor?: string; search?: string } = {}, ): Promise<{ data: DashboardUser[]; nextCursor: string | null }> { - const sp = new URLSearchParams(); - if (params.limit) sp.set("limit", String(params.limit)); - if (params.cursor) sp.set("cursor", params.cursor); - if (params.search) sp.set("search", params.search); + const sp = buildSearchParams({ + limit: params.limit, + cursor: params.cursor, + search: params.search, + }); return request<{ data: DashboardUser[]; nextCursor: string | null }>( `/api/dashboard/users?${sp}`, ); @@ -387,12 +423,19 @@ export interface DashboardChannelDetail extends DashboardChannel { } export function listDashboardChannels( - params: { limit?: number; search?: string; guild_id?: string } = {}, + params: { + limit?: number; + search?: string; + guild_id?: string; + cursor?: string; + } = {}, ): Promise<{ data: DashboardChannel[]; nextCursor: string | null }> { - const sp = new URLSearchParams(); - if (params.limit) sp.set("limit", String(params.limit)); - if (params.search) sp.set("search", params.search); - if (params.guild_id) sp.set("guild_id", params.guild_id); + const sp = buildSearchParams({ + limit: params.limit, + search: params.search, + guild_id: params.guild_id, + cursor: params.cursor, + }); return request<{ data: DashboardChannel[]; nextCursor: string | null }>( `/api/dashboard/channels?${sp}`, ); diff --git a/services/frontend/src/shared/hooks/useItemDetail.ts b/services/frontend/src/shared/hooks/useItemDetail.ts new file mode 100644 index 0000000..a14425c --- /dev/null +++ b/services/frontend/src/shared/hooks/useItemDetail.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * Generic hook for fetching a single item by ID. + * Automatically refetches when guildId or entityId changes. + */ +export function useItemDetail( + fetchFn: (guildId: string, entityId: string) => Promise, + guildId: string, + entityId: string | null, + entityName = "item", +) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchFnRef = useRef(fetchFn); + fetchFnRef.current = fetchFn; + const guildIdRef = useRef(guildId); + guildIdRef.current = guildId; + const fetchIdRef = useRef(0); + + const fetch = useCallback(async () => { + if (!entityId) return; + const id = ++fetchIdRef.current; + + setLoading(true); + setError(null); + try { + const result = await fetchFnRef.current(guildIdRef.current, entityId); + if (id !== fetchIdRef.current) return; + if (!result) { + setError(`${entityName} not found`); + return; + } + setData(result); + } catch (e) { + if (id !== fetchIdRef.current) return; + const msg = + e instanceof Error ? e.message : `Failed to load ${entityName}`; + setError(msg); + } finally { + if (id === fetchIdRef.current) { + setLoading(false); + } + } + }, [entityId, entityName]); + + useEffect(() => { + fetch().catch(() => undefined); + }, [fetch]); + + const refetch = useCallback(() => { + fetch().catch(() => undefined); + }, [fetch]); + + return { data, loading, error, refetch }; +} diff --git a/services/frontend/src/shared/hooks/usePaginatedList.ts b/services/frontend/src/shared/hooks/usePaginatedList.ts new file mode 100644 index 0000000..05b844c --- /dev/null +++ b/services/frontend/src/shared/hooks/usePaginatedList.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface PaginatedResult { + data: T[]; + nextCursor: string | null; +} + +export interface UsePaginatedListParams { + limit: number; + search?: string; + cursor?: string; + guildId?: string; +} + +/** + * Generic hook for fetching a paginated list with cursor-based pagination, + * search support, and automatic refetch when search/guildId changes. + */ +export function usePaginatedList( + fetchFn: (params: UsePaginatedListParams) => Promise>, + guildId = "", + initialSearch = "", +) { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [nextCursor, setNextCursor] = useState(null); + const [search, setSearch] = useState(initialSearch); + + // Stable refs so fetch() can be a stable callback that reads latest values + const fetchFnRef = useRef(fetchFn); + fetchFnRef.current = fetchFn; + const guildIdRef = useRef(guildId); + guildIdRef.current = guildId; + const searchRef = useRef(search); + searchRef.current = search; + const fetchIdRef = useRef(0); + + const fetch = useCallback(async (cursor?: string) => { + const id = ++fetchIdRef.current; + + if (cursor) { + setLoadingMore(true); + } else { + setLoading(true); + setData([]); + } + setError(null); + + try { + const result = await fetchFnRef.current({ + limit: 20, + search: searchRef.current || undefined, + cursor, + guildId: guildIdRef.current || undefined, + }); + if (id !== fetchIdRef.current) return; + + if (cursor) { + setData((prev) => [...prev, ...result.data]); + } else { + setData(result.data); + } + setNextCursor(result.nextCursor); + } catch (e) { + if (id !== fetchIdRef.current) return; + const msg = e instanceof Error ? e.message : "Failed to load data"; + setError(msg); + } finally { + if (id === fetchIdRef.current) { + setLoading(false); + setLoadingMore(false); + } + } + }, []); + + // Re-fetch on mount, search change, or guildId change + useEffect(() => { + fetch().catch(() => undefined); + }, [search, guildId, fetch]); + + const loadMore = useCallback(() => { + if (nextCursor && !loading && !loadingMore) { + fetch(nextCursor).catch(() => undefined); + } + }, [nextCursor, loading, loadingMore, fetch]); + + const refetch = useCallback(() => { + fetch().catch(() => undefined); + }, [fetch]); + + return { + data, + loading, + loadingMore, + error, + nextCursor, + search, + setSearch, + hasMore: !!nextCursor, + loadMore, + refetch, + }; +} diff --git a/services/frontend/src/shared/ui/MobileTabBar.tsx b/services/frontend/src/shared/ui/MobileTabBar.tsx index 96d2578..6be0f90 100644 --- a/services/frontend/src/shared/ui/MobileTabBar.tsx +++ b/services/frontend/src/shared/ui/MobileTabBar.tsx @@ -15,10 +15,17 @@ interface MobileTabBarProps { export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) { return ( -