diff --git a/services/frontend/src/app/(dashboard)/recordings/page.tsx b/services/frontend/src/app/(dashboard)/recordings/page.tsx index 95b2da6d..fe2f8b8e 100644 --- a/services/frontend/src/app/(dashboard)/recordings/page.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/page.tsx @@ -1,16 +1,15 @@ import { getRecordings } from "@/lib/api/server"; +import type { PaginatedRecordings } from "@/lib/types"; import { RecordingsView } from "./view"; export const dynamic = "force-dynamic"; export default async function RecordingsPage() { - let recordings: - | import("@/lib/types/recording").PaginatedRecordings - | undefined; + let recordings: PaginatedRecordings | undefined; try { recordings = await getRecordings(50); } catch { /* client hooks surface errors */ } - return ; + return ; } diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx index aa0286f6..d59500af 100644 --- a/services/frontend/src/app/(dashboard)/recordings/view.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx @@ -1,7 +1,7 @@ "use client"; import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; import { Avatar, @@ -18,36 +18,86 @@ import { } from "@/components/voice/recording-audio-player"; import { useDeleteRecording, + useLoadMoreRecordings, useRecordings, useRecordingsWsSync, } from "@/hooks"; import { useStaggerReveal } from "@/hooks/use-gsap-animation"; import { formatBytes, formatRelativeTime } from "@/lib/format"; -import type { VoiceRecording } from "@/lib/types"; +import type { PaginatedRecordings, VoiceRecording } from "@/lib/types"; import { useWebSocket } from "@/lib/ws/context"; export function RecordingsView({ - initialItems, + initialPage, }: { - initialItems?: VoiceRecording[]; + initialPage?: PaginatedRecordings; }) { const ws = useWebSocket(); - const { data: items, isLoading, error, mutate } = useRecordings(initialItems); + const { + data: items, + isLoading, + error, + nextCursor, + hasMore, + mutate, + } = useRecordings(initialPage); + const loadMore = useLoadMoreRecordings(); const del = useDeleteRecording(); useRecordingsWsSync(ws); const ambient = useAmbient(); const [playingId, setPlayingId] = useState(null); + // Maximum older pages to prevent infinite runaway memory usage + const MAX_OLDER_PAGES = 10; + const [loadedPages, setLoadedPages] = useState(0); + + const scrollRef = useRef(null); + const sentinelRef = useRef(null); + const deckRef = useStaggerReveal(".recording-deck-card", { stagger: 0.04, y: 10, - dependencies: [items], + dependencies: [items?.length === 0], }); useEffect(() => { ambient.set("signal", 0.3, "recordings"); }, [ambient]); + const loadOlder = useCallback(async () => { + if ( + !hasMore || + !nextCursor || + loadMore.isPending || + loadedPages >= MAX_OLDER_PAGES + ) + return; + try { + await loadMore.mutateAsync({ cursor: nextCursor }); + setLoadedPages((n) => n + 1); + } catch { + // client error handling in hook/action + } + }, [hasMore, nextCursor, loadMore, loadedPages]); + + // Infinite scroll trigger via IntersectionObserver on sentinel at the bottom of the list + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + void loadOlder(); + } + }, + { root: scrollRef.current, rootMargin: "200px" }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [loadOlder]); + const onDelete = async (id: string) => { try { await del.mutateAsync(id); @@ -102,7 +152,7 @@ export function RecordingsView({
STATUS: - {totalRecordings} CLIPS_ONLINE + {totalRecordings} CLIPS_LOADED
@@ -113,7 +163,7 @@ export function RecordingsView({ title="Voice Capture Tape Deck" action={ - {totalRecordings} clips archived + {totalRecordings} clips loaded {hasMore ? "· more available" : ""} } /> @@ -125,101 +175,137 @@ export function RecordingsView({ /> ) : (
- {(items ?? []).map((r) => { - const up = uploadStatus(r); - const isPlaying = playingId === r.id; - return ( -
-
- {/* Header info */} -
- -
-
- {r.username} -
-
- - - {r.channel_name ?? "voice"} - - · - {formatRelativeTime(r.created_at)} -
-
- {isPlaying && } - {up && !isPlaying && ( - - {up.label} - - )} -
- - {/* Audio Player Scrub */} -
- {r.download_url ? ( - - setPlayingId((prev) => { - if (active) return r.id; - return prev === r.id ? null : prev; - }) - } +
+ {(items ?? []).map((r) => { + const up = uploadStatus(r); + const isPlaying = playingId === r.id; + return ( +
+
+ {/* Header info */} +
+ - ) : ( -
- - {r.upload_status === "pending" - ? "UPLOAD_PENDING..." - : r.upload_error - ? r.upload_error - : "SYNTHESIZING_PCM..."} +
+
+ {r.username} +
+
+ + + {r.channel_name ?? "voice"} + + · + {formatRelativeTime(r.created_at)} +
- )} -
-
+ {isPlaying && } + {up && !isPlaying && ( + + {up.label} + + )} +
- {/* Actions & File Stats */} - + + {/* Actions & File Stats */} +
+ + SIZE: {formatBytes(r.size_bytes)} + +
+ {r.download_url && ( + + RAW + + )} + + PURGE + +
-
- ); - })} + ); + })} +
+ + {/* 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"} + + )} +
)} diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index 3b2afc2c..938c6dc8 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -48,6 +48,7 @@ export { } from "./use-moderation"; export { useDeleteRecording, + useLoadMoreRecordings, useRecordings, useRecordingsWsSync, } from "./use-recordings"; diff --git a/services/frontend/src/hooks/use-recordings.ts b/services/frontend/src/hooks/use-recordings.ts index 7a9f2ee9..d51393be 100644 --- a/services/frontend/src/hooks/use-recordings.ts +++ b/services/frontend/src/hooks/use-recordings.ts @@ -2,27 +2,79 @@ import { useEffect } from "react"; import useSWR, { useSWRConfig } from "swr"; import { useAction } from "@/hooks/use-action"; import { recordingsApi } from "@/lib/api"; -import type { VoiceRecording } from "@/lib/types"; +import type { PaginatedRecordings, VoiceRecording } from "@/lib/types"; import type { WsHook } from "@/lib/ws-hook"; const RECORDINGS_KEY = ["recordings"] as const; -export function useRecordings(initialData?: VoiceRecording[]) { - return useSWR( +export function useRecordingsPage(initialPage?: PaginatedRecordings) { + return useSWR( RECORDINGS_KEY, - async () => { - const res = await recordingsApi.list(50); - return res.items; + () => recordingsApi.list(50), + { fallbackData: initialPage }, + ); +} + +export function useRecordings(initialPage?: PaginatedRecordings) { + const page = useRecordingsPage(initialPage); + return { + ...page, + data: page.data?.items, + nextCursor: page.data?.nextCursor ?? null, + hasMore: page.data?.hasMore ?? false, + refetch: () => page.mutate(), + }; +} + +export function useLoadMoreRecordings() { + const { mutate } = useSWRConfig(); + return useAction( + async ({ + channelId, + userId, + cursor, + }: { + channelId?: string; + userId?: string; + cursor: string; + }) => { + const result = await recordingsApi.list(50, channelId, userId, cursor); + await mutate( + RECORDINGS_KEY, + (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; }, - { fallbackData: initialData }, ); } export function useDeleteRecording() { const { mutate } = useSWRConfig(); return useAction((id: string) => recordingsApi.delete(id), { - onSuccess: () => { - void mutate(RECORDINGS_KEY); + onSuccess: (_, id) => { + void mutate( + RECORDINGS_KEY, + ( + old: PaginatedRecordings | undefined, + ): PaginatedRecordings | undefined => { + if (!old) return old; + return { + ...old, + items: old.items.filter((r) => r.id !== id), + }; + }, + { revalidate: false }, + ); }, }); } @@ -34,7 +86,14 @@ export function useRecordingsWsSync(ws: WsHook) { const rec = data as VoiceRecording; void mutate( RECORDINGS_KEY, - (old: VoiceRecording[] | undefined) => (old ? [rec, ...old] : [rec]), + (old: PaginatedRecordings | undefined): PaginatedRecordings => { + if (!old) return { items: [rec], nextCursor: null, hasMore: false }; + if (old.items.some((r) => r.id === rec.id)) return old; + return { + ...old, + items: [rec, ...old.items], + }; + }, { revalidate: false }, ); });