From e12b1302ffbe85080f268b5603aeee184a41ceeb Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 30 Aug 2026 13:35:33 +0700 Subject: [PATCH] feat(frontend): show transcription + keyword search + channel/date filters + speaker leaderboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cards now render transcription snippet (expandable); no-transcript placeholder - toolbar: search box (transcription+username ILIKE), speaker filter (existing), channel filter, start/end date range, RESET - new DECK/LEADERBOARD tabs: leaderboard = per-speaker clips, est duration, transcribed words (useRecordingsSummary → recordings.summary); click a row jumps the deck to that speaker's clips - hooks: filter-driven SWR cache key (fields serialized), representative key matcher for delete/WS-sync across all filter views; useRecordingsSummary hook - recordingsApi.list generalized to RecordingListParams (q/startDate/endDate) --- .../src/app/(dashboard)/recordings/view.tsx | 705 ++++++++++++------ services/frontend/src/hooks/index.ts | 2 + services/frontend/src/hooks/use-recordings.ts | 126 +++- services/frontend/src/lib/api/recordings.ts | 36 +- services/frontend/src/lib/types/recording.ts | 13 + 5 files changed, 609 insertions(+), 273 deletions(-) diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx index bd75d85c..ed1f5d19 100644 --- a/services/frontend/src/app/(dashboard)/recordings/view.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx @@ -7,6 +7,9 @@ import { Hash, Headphones, Loader2, + MessagesSquare, + Mic, + Search, Trash2, Users, } from "lucide-react"; @@ -18,6 +21,7 @@ import { Button, GlassCard, GlassPanel, + Input, Select, type SelectOption, Skeleton, @@ -29,9 +33,11 @@ import { RecordingAudioPlayer, } from "@/components/voice/recording-audio-player"; import { + type RecordingsFilter, useDeleteRecording, useLoadMoreRecordings, useRecordings, + useRecordingsSummary, useRecordingsWsSync, } from "@/hooks"; import { useStaggerReveal } from "@/hooks/use-gsap-animation"; @@ -41,11 +47,17 @@ import { decodeAudio, downloadBlob, } from "@/lib/audio/wav"; -import { formatBytes, formatRelativeTime } from "@/lib/format"; -import type { PaginatedRecordings, VoiceRecording } from "@/lib/types"; +import { formatBytes, formatDuration, formatRelativeTime } from "@/lib/format"; +import type { + PaginatedRecordings, + SpeakerSummary, + VoiceRecording, +} from "@/lib/types"; import { useWebSocket } from "@/lib/ws/context"; -const ALL_USERS = "__all__"; +const ALL = "__all__"; + +type Tab = "deck" | "leaderboard"; export function RecordingsView({ initialPage, @@ -53,7 +65,24 @@ export function RecordingsView({ initialPage?: PaginatedRecordings; }) { const ws = useWebSocket(); - const [filterUserId, setFilterUserId] = useState(null); + const [tab, setTab] = useState("deck"); + const [q, setQ] = useState(""); + const [channelId, setChannelId] = useState(ALL); + const [userId, setUserId] = useState(ALL); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + + const activeFilter: RecordingsFilter = useMemo(() => { + const f: RecordingsFilter = {}; + if (channelId !== ALL) f.channelId = channelId; + if (userId !== ALL) f.userId = userId; + const sq = q.trim(); + if (sq) f.q = sq; + if (startDate) f.startDate = new Date(`${startDate}T00:00:00`).getTime(); + if (endDate) f.endDate = new Date(`${endDate}T23:59:59`).getTime(); + return f; + }, [channelId, userId, q, startDate, endDate]); + const { data: items, isLoading, @@ -61,9 +90,10 @@ export function RecordingsView({ nextCursor, hasMore, mutate, - } = useRecordings(initialPage, filterUserId ?? undefined); - const loadMore = useLoadMoreRecordings(filterUserId ?? undefined); + } = useRecordings(initialPage, activeFilter); + const loadMore = useLoadMoreRecordings(activeFilter); const del = useDeleteRecording(); + const summary = useRecordingsSummary(); useRecordingsWsSync(ws); const ambient = useAmbient(); const [playingId, setPlayingId] = useState(null); @@ -87,17 +117,28 @@ export function RecordingsView({ ambient.set("signal", 0.3, "recordings"); }, [ambient]); - // Distinct speakers visible in the current (filtered) list, for the dropdown. + // Distinct speakers/channels across the current (filtered) list. const userOptions = useMemo(() => { const map = new Map(); for (const r of items ?? []) { if (r.user_id && !map.has(r.user_id)) map.set(r.user_id, r.username); } - const opts: SelectOption[] = [ - { value: ALL_USERS, label: "All speakers" }, + return [ + { value: ALL, label: "All speakers" }, + ...[...map.entries()].map(([value, label]) => ({ value, label })), + ]; + }, [items]); + + const channelOptions = useMemo(() => { + const map = new Map(); + for (const r of items ?? []) { + if (r.channel_id && !map.has(r.channel_id)) + map.set(r.channel_id, r.channel_name ?? r.channel_id); + } + return [ + { value: ALL, label: "All channels" }, ...[...map.entries()].map(([value, label]) => ({ value, label })), ]; - return opts; }, [items]); const loadOlder = useCallback(async () => { @@ -188,9 +229,10 @@ export function RecordingsView({ const merged = concatAudioBuffers(buffers); if (!merged) throw new Error("No audio decoded"); const wav = audioBufferToWav(merged); - const who = filterUserId - ? (target[0]?.username ?? "speaker").replace(/[^\w.-]+/g, "_") - : "all"; + const who = + userId !== ALL + ? (target[0]?.username ?? "speaker").replace(/[^\w.-]+/g, "_") + : "all"; downloadBlob(wav, `gmw-rec-${who}-${target.length}clips.wav`); toast({ title: `Exported ${target.length} clips as one WAV`, @@ -246,6 +288,17 @@ export function RecordingsView({ Tape Deck · Captured Audio Archive +
+ setTab("deck")}> + DECK + + setTab("leaderboard")} + > + LEADERBOARD + +
STATUS:
- {/* Filter + Export toolbar */} -
-
- - setQ(e.target.value)} + placeholder="Search transcriptions / speakers…" + className="pl-8" + /> +
+ { + setChannelId(v); + setLoadedPages(0); + }} + options={channelOptions} + placeholder="Channel" + size="sm" + className="w-40" + /> + setStartDate(e.target.value)} + aria-label="From date" + className="w-36" + /> + setEndDate(e.target.value)} + aria-label="To date" + className="w-36" + /> + + {(Object.keys(activeFilter).length > 0 || + channelId !== ALL || + userId !== ALL) && ( + + )} +
+ + + + {totalRecordings} clips loaded{" "} + {hasMore ? "· more available" : ""} + + } + /> + {totalRecordings === 0 ? ( + } + title={ + Object.keys(activeFilter).length > 0 + ? "No recordings match these filters" + : "Tape deck is empty" + } + description={ + Object.keys(activeFilter).length > 0 + ? "Try clearing the search or filters, or pick a different speaker/channel." + : "Voice transmissions captured in connected channels will be archived here." + } + /> + ) : ( +
+
+ {(items ?? []).map((r) => { + const up = uploadStatus(r); + const isPlaying = playingId === r.id; + const isExporting = exportingIds.has(r.id); + return ( +
+
+ {/* Header info */} +
+ +
+
+ {r.username} +
+
+ + + {r.channel_name ?? "voice"} + + · + + {formatRelativeTime(r.created_at)} + +
+
+ {isPlaying && } + {up && !isPlaying && ( + + {up.label} + + )}
-
- - - {r.channel_name ?? "voice"} - - · - - {formatRelativeTime(r.created_at)} - + + {/* Transcription snippet */} +
+ +
+ + {/* Audio Player Scrub */} +
+ {r.download_url ? ( + + setPlayingId((prev) => { + if (active) return r.id; + return prev === r.id ? null : prev; + }) + } + /> + ) : ( +
+ + {r.upload_status === "pending" + ? "UPLOAD_PENDING..." + : r.upload_error + ? r.upload_error + : "SYNTHESIZING_PCM..."} +
+ )}
- {isPlaying && } - {up && !isPlaying && ( - - {up.label} - - )} -
- {/* Audio Player Scrub */} -
- {r.download_url ? ( - - setPlayingId((prev) => { - if (active) return r.id; - return prev === r.id ? null : prev; - }) - } - /> - ) : ( -
- - {r.upload_status === "pending" - ? "UPLOAD_PENDING..." - : r.upload_error - ? r.upload_error - : "SYNTHESIZING_PCM..."} -
- )} -
-
- - {/* Actions & File Stats */} -
- - SIZE: {formatBytes(r.size_bytes)} - -
- {r.download_url && ( - <> + {/* Actions & File Stats */} +
+ + SIZE: {formatBytes(r.size_bytes)} + +
+ {r.download_url && ( + <> + + + {" "} + RAW + + + )} - - RAW - - - )} - +
+
-
-
- ); - })} -
+ ); + })} + - {/* Infinite scroll sentinel & status footer */} -
- {loadMore.isPending ? ( - - - LOADING EARLIER RECORDINGS... - - - - - - - ) : hasMore && loadedPages < MAX_OLDER_PAGES ? ( - - ) : ( - - {loadedPages >= MAX_OLDER_PAGES - ? `CAPPED AT ${MAX_OLDER_PAGES} PAGES` - : "ARCHIVE END REACHED"} - - )} -
- - )} -
+ {/* Infinite scroll sentinel & status footer */} +
+ {loadMore.isPending ? ( + + + LOADING EARLIER RECORDINGS... + + + + + + + ) : hasMore && loadedPages < MAX_OLDER_PAGES ? ( + + ) : ( + + {loadedPages >= MAX_OLDER_PAGES + ? `CAPPED AT ${MAX_OLDER_PAGES} PAGES` + : "ARCHIVE END REACHED"} + + )} +
+ + )} + + + )} ); } +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function TranscriptionSnippet({ text }: { text?: string | null }) { + const [open, setOpen] = useState(false); + if (!text) { + return ( +
+ no transcription +
+ ); + } + const collapsed = !open && text.length > 160; + return ( +
+

+ {text} +

+ {collapsed && ( + + )} +
+ ); +} + +function Leaderboard({ + summary, + isLoading, + onShowSpeaker, +}: { + summary: SpeakerSummary[] | undefined; + isLoading: boolean; + onShowSpeaker: (userId: string, username: string) => void; +}) { + if (isLoading && !summary) + return ( + + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ ); + if (!summary || summary.length === 0) + return ( + + } + title="No speakers yet" + description="Leaderboard populates as voice is captured and transcribed." + /> + + ); + + const maxClips = Math.max(...summary.map((s) => s.clips), 1); + + return ( + + + {summary.length} speakers + + } + /> +
+ {summary.map((s, idx) => ( + + ))} +
+
+ ); +} + function uploadStatus( r: VoiceRecording, ): { tone: "neutral" | "amber" | "vermilion"; label: string } | null { diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index 938c6dc8..59ded156 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -47,9 +47,11 @@ export { useTopFlaggedDomains, } from "./use-moderation"; export { + type RecordingsFilter, useDeleteRecording, useLoadMoreRecordings, useRecordings, + useRecordingsSummary, useRecordingsWsSync, } from "./use-recordings"; export { diff --git a/services/frontend/src/hooks/use-recordings.ts b/services/frontend/src/hooks/use-recordings.ts index 4e2d09d7..ebdcbbcc 100644 --- a/services/frontend/src/hooks/use-recordings.ts +++ b/services/frontend/src/hooks/use-recordings.ts @@ -1,44 +1,85 @@ import { useEffect } from "react"; import useSWR, { useSWRConfig } from "swr"; import { useAction } from "@/hooks/use-action"; -import { recordingsApi } from "@/lib/api"; -import type { PaginatedRecordings, VoiceRecording } from "@/lib/types"; +import { type RecordingListParams, recordingsApi } from "@/lib/api/recordings"; +import type { + PaginatedRecordings, + SpeakerSummary, + VoiceRecording, +} from "@/lib/types"; import type { WsHook } from "@/lib/ws-hook"; const RECORDINGS_PREFIX = "recordings"; +const RECORDINGS_SUMMARY_KEY = ["recordings", "summary"] as const; -function recordingsKey(userId?: string): [string, string] { - return [RECORDINGS_PREFIX, userId ?? "all"]; +export interface RecordingsFilter { + channelId?: string; + userId?: string; + /** keyword search */ + q?: string; + startDate?: number; + endDate?: number; } -/** Revalidate/mutate every recording cache (all filters + global list). */ -function isRecordingsKey(key: unknown): boolean { +function recordingsKey(filter: RecordingsFilter = {}): unknown[] { + return [ + RECORDINGS_PREFIX, + "list", + filter.channelId ?? "", + filter.userId ?? "", + filter.q ?? "", + filter.startDate ?? "", + filter.endDate ?? "", + ]; +} + +/** Match every recordings-list cache key so mutation/sync hits all filters. */ +function isRecordingsListKey(key: unknown): boolean { return ( Array.isArray(key) && - key.length === 2 && + key.length === 7 && key[0] === RECORDINGS_PREFIX && - typeof key[1] === "string" + key[1] === "list" ); } +function baseParams(filter: RecordingsFilter = {}): RecordingListParams { + return { + channelId: filter.channelId, + userId: filter.userId, + q: filter.q, + startDate: filter.startDate, + endDate: filter.endDate, + }; +} + export function useRecordingsPage( initialPage?: PaginatedRecordings, - userId?: string, + filter: RecordingsFilter = {}, ) { return useSWR( - recordingsKey(userId), - () => recordingsApi.list(50, undefined, userId), + recordingsKey(filter), + () => recordingsApi.list({ ...baseParams(filter), limit: 50 }), // fallbackData only applies to the unfiltered "all" view; a filtered cache // must not be pre-seeded with unfiltered rows (would flash wrong data). - { fallbackData: userId ? undefined : initialPage }, + { + fallbackData: + filter.channelId || + filter.userId || + filter.q || + filter.startDate || + filter.endDate + ? undefined + : initialPage, + }, ); } export function useRecordings( initialPage?: PaginatedRecordings, - userId?: string, + filter: RecordingsFilter = {}, ) { - const page = useRecordingsPage(initialPage, userId); + const page = useRecordingsPage(initialPage, filter); return { ...page, data: page.data?.items, @@ -48,28 +89,30 @@ export function useRecordings( }; } -export function useLoadMoreRecordings(userId?: string) { +export function useLoadMoreRecordings(filter: RecordingsFilter = {}) { const { mutate } = useSWRConfig(); - return useAction( - async ({ channelId, cursor }: { channelId?: string; cursor: string }) => { - const result = await recordingsApi.list(50, channelId, userId, cursor); - await mutate( - recordingsKey(userId), - (old: PaginatedRecordings | undefined): PaginatedRecordings => { - if (!old) return result; - const existingIds = new Set(old.items.map((r) => r.id)); - const newUnique = result.items.filter((r) => !existingIds.has(r.id)); - return { - items: [...old.items, ...newUnique], - nextCursor: result.nextCursor, - hasMore: result.hasMore, - }; - }, - { revalidate: false }, - ); - return result; - }, - ); + return useAction(async ({ cursor }: { cursor: string }) => { + const result = await recordingsApi.list({ + ...baseParams(filter), + limit: 50, + cursor, + }); + await mutate( + recordingsKey(filter), + (old: PaginatedRecordings | undefined): PaginatedRecordings => { + if (!old) return result; + const existingIds = new Set(old.items.map((r) => r.id)); + const newUnique = result.items.filter((r) => !existingIds.has(r.id)); + return { + items: [...old.items, ...newUnique], + nextCursor: result.nextCursor, + hasMore: result.hasMore, + }; + }, + { revalidate: false }, + ); + return result; + }); } export function useDeleteRecording() { @@ -78,7 +121,7 @@ export function useDeleteRecording() { onSuccess: (_, id) => { // A deleted recording disappears from every filter view. void mutate( - (key: unknown) => isRecordingsKey(key), + (key: unknown) => isRecordingsListKey(key), ( old: PaginatedRecordings | undefined, ): PaginatedRecordings | undefined => { @@ -90,6 +133,8 @@ export function useDeleteRecording() { }, { revalidate: false }, ); + // Leaderboard totals shifted — refresh. + void mutate(RECORDINGS_SUMMARY_KEY); }, }); } @@ -101,7 +146,7 @@ export function useRecordingsWsSync(ws: WsHook) { const rec = data as VoiceRecording; // A fresh recording should appear in every filter view it belongs to. void mutate( - (key: unknown) => isRecordingsKey(key), + (key: unknown) => isRecordingsListKey(key), (old: PaginatedRecordings | undefined): PaginatedRecordings => { if (!old) return { items: [rec], nextCursor: null, hasMore: false }; if (old.items.some((r) => r.id === rec.id)) return old; @@ -112,7 +157,14 @@ export function useRecordingsWsSync(ws: WsHook) { }, { revalidate: false }, ); + void mutate(RECORDINGS_SUMMARY_KEY); }); return unsub; }, [ws, mutate]); } + +export function useRecordingsSummary() { + return useSWR(RECORDINGS_SUMMARY_KEY, () => + recordingsApi.summary(), + ); +} diff --git a/services/frontend/src/lib/api/recordings.ts b/services/frontend/src/lib/api/recordings.ts index 647f97e3..e38b89aa 100644 --- a/services/frontend/src/lib/api/recordings.ts +++ b/services/frontend/src/lib/api/recordings.ts @@ -1,20 +1,34 @@ import { orpc } from "@/lib/orpc/client"; -import type { PaginatedRecordings } from "@/lib/types"; +import type { PaginatedRecordings, SpeakerSummary } from "@/lib/types"; + +export interface RecordingListParams { + limit?: number; + channelId?: string; + userId?: string; + cursor?: string; + /** keyword search against transcription + username */ + q?: string; + /** created_at lower bound (epoch ms) */ + startDate?: number; + /** created_at upper bound (epoch ms) */ + endDate?: number; +} export const recordingsApi = { - list: ( - limit?: number, - channelId?: string, - userId?: string, - cursor?: string, - ) => + list: (params: RecordingListParams = {}) => orpc.recordings.list({ - limit, - channelId, - userId, - cursor, + limit: params.limit, + channelId: params.channelId, + userId: params.userId, + cursor: params.cursor, + q: params.q, + startDate: params.startDate, + endDate: params.endDate, }) as unknown as Promise, delete: (id: string) => orpc.recordings.delete({ id }) as unknown as Promise<{ ok: boolean }>, + + summary: () => + orpc.recordings.summary({}) as unknown as Promise, }; diff --git a/services/frontend/src/lib/types/recording.ts b/services/frontend/src/lib/types/recording.ts index 26126f74..9a755fdb 100644 --- a/services/frontend/src/lib/types/recording.ts +++ b/services/frontend/src/lib/types/recording.ts @@ -13,6 +13,7 @@ export interface VoiceRecording { upload_error?: string | null; created_at: number; uploaded_at?: number | null; + transcription?: string | null; } export interface PaginatedRecordings { @@ -20,3 +21,15 @@ export interface PaginatedRecordings { nextCursor: string | null; hasMore: boolean; } + +/** recordings.summary — per-speaker leaderboard. */ +export interface SpeakerSummary { + user_id: string; + username: string; + avatar_url: string | null; + clips: number; + est_duration_s: number; + words: number; + transcribed: number; + last_at: number; +}