refactor: update recordings handling with pagination support and improve infinite scroll functionality

This commit is contained in:
asepharyana
2026-08-26 17:05:41 +07:00
parent b640079cb6
commit 0c3a82ad77
4 changed files with 255 additions and 110 deletions
@@ -1,16 +1,15 @@
import { getRecordings } from "@/lib/api/server"; import { getRecordings } from "@/lib/api/server";
import type { PaginatedRecordings } from "@/lib/types";
import { RecordingsView } from "./view"; import { RecordingsView } from "./view";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function RecordingsPage() { export default async function RecordingsPage() {
let recordings: let recordings: PaginatedRecordings | undefined;
| import("@/lib/types/recording").PaginatedRecordings
| undefined;
try { try {
recordings = await getRecordings(50); recordings = await getRecordings(50);
} catch { } catch {
/* client hooks surface errors */ /* client hooks surface errors */
} }
return <RecordingsView initialItems={recordings?.items} />; return <RecordingsView initialPage={recordings} />;
} }
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react"; 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 { useAmbient } from "@/components/ambient/ambient-context";
import { import {
Avatar, Avatar,
@@ -18,36 +18,86 @@ import {
} from "@/components/voice/recording-audio-player"; } from "@/components/voice/recording-audio-player";
import { import {
useDeleteRecording, useDeleteRecording,
useLoadMoreRecordings,
useRecordings, useRecordings,
useRecordingsWsSync, useRecordingsWsSync,
} from "@/hooks"; } from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation"; import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { formatBytes, formatRelativeTime } from "@/lib/format"; 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"; import { useWebSocket } from "@/lib/ws/context";
export function RecordingsView({ export function RecordingsView({
initialItems, initialPage,
}: { }: {
initialItems?: VoiceRecording[]; initialPage?: PaginatedRecordings;
}) { }) {
const ws = useWebSocket(); 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(); const del = useDeleteRecording();
useRecordingsWsSync(ws); useRecordingsWsSync(ws);
const ambient = useAmbient(); const ambient = useAmbient();
const [playingId, setPlayingId] = useState<string | null>(null); const [playingId, setPlayingId] = useState<string | null>(null);
// Maximum older pages to prevent infinite runaway memory usage
const MAX_OLDER_PAGES = 10;
const [loadedPages, setLoadedPages] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const deckRef = useStaggerReveal<HTMLDivElement>(".recording-deck-card", { const deckRef = useStaggerReveal<HTMLDivElement>(".recording-deck-card", {
stagger: 0.04, stagger: 0.04,
y: 10, y: 10,
dependencies: [items], dependencies: [items?.length === 0],
}); });
useEffect(() => { useEffect(() => {
ambient.set("signal", 0.3, "recordings"); ambient.set("signal", 0.3, "recordings");
}, [ambient]); }, [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) => { const onDelete = async (id: string) => {
try { try {
await del.mutateAsync(id); await del.mutateAsync(id);
@@ -102,7 +152,7 @@ export function RecordingsView({
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted"> <div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>STATUS:</span> <span>STATUS:</span>
<span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30"> <span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30">
{totalRecordings} CLIPS_ONLINE {totalRecordings} CLIPS_LOADED
</span> </span>
</div> </div>
</div> </div>
@@ -113,7 +163,7 @@ export function RecordingsView({
title="Voice Capture Tape Deck" title="Voice Capture Tape Deck"
action={ action={
<span className="mono text-xs text-[#8a8f98]"> <span className="mono text-xs text-[#8a8f98]">
{totalRecordings} clips archived {totalRecordings} clips loaded {hasMore ? "· more available" : ""}
</span> </span>
} }
/> />
@@ -124,9 +174,13 @@ export function RecordingsView({
description="Voice transmissions captured in connected channels will be archived here." description="Voice transmissions captured in connected channels will be archived here."
/> />
) : ( ) : (
<div
ref={scrollRef}
className="mt-4 max-h-[calc(100vh-220px)] overflow-y-auto pr-1"
>
<div <div
ref={deckRef} ref={deckRef}
className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3" className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
> >
{(items ?? []).map((r) => { {(items ?? []).map((r) => {
const up = uploadStatus(r); const up = uploadStatus(r);
@@ -143,7 +197,11 @@ export function RecordingsView({
<div> <div>
{/* Header info */} {/* Header info */}
<div className="flex items-center gap-3 border-b border-hairline pb-3"> <div className="flex items-center gap-3 border-b border-hairline pb-3">
<Avatar src={r.avatar_url} name={r.username} size={36} /> <Avatar
src={r.avatar_url}
name={r.username}
size={36}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-ink"> <div className="truncate text-xs font-semibold text-ink">
{r.username} {r.username}
@@ -159,7 +217,10 @@ export function RecordingsView({
</div> </div>
{isPlaying && <NowPlayingChip />} {isPlaying && <NowPlayingChip />}
{up && !isPlaying && ( {up && !isPlaying && (
<Badge tone={up.tone} className="font-mono text-[9px]"> <Badge
tone={up.tone}
className="font-mono text-[9px]"
>
{up.label} {up.label}
</Badge> </Badge>
)} )}
@@ -221,6 +282,31 @@ export function RecordingsView({
); );
})} })}
</div> </div>
{/* Infinite scroll sentinel & status footer */}
<div ref={sentinelRef} className="py-4 text-center">
{loadMore.isPending ? (
<span className="flex items-center justify-center gap-2 font-mono text-xs text-ink-muted">
<Loader2 className="size-4 animate-spin text-signal" />
LOADING EARLIER RECORDINGS...
</span>
) : hasMore && loadedPages < MAX_OLDER_PAGES ? (
<button
type="button"
onClick={loadOlder}
className="rounded-md border border-hairline bg-surface-2 px-3 py-1.5 font-mono text-xs text-ink-muted transition-colors hover:bg-surface hover:text-ink"
>
LOAD MORE RECORDINGS
</button>
) : (
<span className="font-mono text-[10px] text-ink-faint">
{loadedPages >= MAX_OLDER_PAGES
? `CAPPED AT ${MAX_OLDER_PAGES} PAGES`
: "ARCHIVE END REACHED"}
</span>
)}
</div>
</div>
)} )}
</GlassPanel> </GlassPanel>
</div> </div>
+1
View File
@@ -48,6 +48,7 @@ export {
} from "./use-moderation"; } from "./use-moderation";
export { export {
useDeleteRecording, useDeleteRecording,
useLoadMoreRecordings,
useRecordings, useRecordings,
useRecordingsWsSync, useRecordingsWsSync,
} from "./use-recordings"; } from "./use-recordings";
+69 -10
View File
@@ -2,27 +2,79 @@ import { useEffect } from "react";
import useSWR, { useSWRConfig } from "swr"; import useSWR, { useSWRConfig } from "swr";
import { useAction } from "@/hooks/use-action"; import { useAction } from "@/hooks/use-action";
import { recordingsApi } from "@/lib/api"; 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"; import type { WsHook } from "@/lib/ws-hook";
const RECORDINGS_KEY = ["recordings"] as const; const RECORDINGS_KEY = ["recordings"] as const;
export function useRecordings(initialData?: VoiceRecording[]) { export function useRecordingsPage(initialPage?: PaginatedRecordings) {
return useSWR<VoiceRecording[]>( return useSWR<PaginatedRecordings>(
RECORDINGS_KEY, RECORDINGS_KEY,
async () => { () => recordingsApi.list(50),
const res = await recordingsApi.list(50); { fallbackData: initialPage },
return res.items; );
}
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() { export function useDeleteRecording() {
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
return useAction((id: string) => recordingsApi.delete(id), { return useAction((id: string) => recordingsApi.delete(id), {
onSuccess: () => { onSuccess: (_, id) => {
void mutate(RECORDINGS_KEY); 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; const rec = data as VoiceRecording;
void mutate( void mutate(
RECORDINGS_KEY, 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 }, { revalidate: false },
); );
}); });