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 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 <RecordingsView initialItems={recordings?.items} />;
return <RecordingsView initialPage={recordings} />;
}
@@ -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<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", {
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({
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>STATUS:</span>
<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>
</div>
</div>
@@ -113,7 +163,7 @@ export function RecordingsView({
title="Voice Capture Tape Deck"
action={
<span className="mono text-xs text-[#8a8f98]">
{totalRecordings} clips archived
{totalRecordings} clips loaded {hasMore ? "· more available" : ""}
</span>
}
/>
@@ -125,101 +175,137 @@ export function RecordingsView({
/>
) : (
<div
ref={deckRef}
className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
ref={scrollRef}
className="mt-4 max-h-[calc(100vh-220px)] overflow-y-auto pr-1"
>
{(items ?? []).map((r) => {
const up = uploadStatus(r);
const isPlaying = playingId === r.id;
return (
<div
key={r.id}
className={`recording-deck-card hud-card flex flex-col justify-between p-4 transition-all duration-200 ${
isPlaying
? "border-signal/50 bg-signal/10 shadow-[0_0_24px_-10px_var(--color-signal-glow)]"
: ""
}`}
>
<div>
{/* Header info */}
<div className="flex items-center gap-3 border-b border-hairline pb-3">
<Avatar src={r.avatar_url} name={r.username} size={36} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-ink">
{r.username}
</div>
<div className="flex items-center gap-1.5 font-mono text-[10px] text-ink-faint">
<Hash className="size-2.5 text-signal" />
<span className="truncate">
{r.channel_name ?? "voice"}
</span>
<span>·</span>
<span>{formatRelativeTime(r.created_at)}</span>
</div>
</div>
{isPlaying && <NowPlayingChip />}
{up && !isPlaying && (
<Badge tone={up.tone} className="font-mono text-[9px]">
{up.label}
</Badge>
)}
</div>
{/* Audio Player Scrub */}
<div className="my-3">
{r.download_url ? (
<RecordingAudioPlayer
src={r.download_url}
label={`Voice recording by ${r.username}`}
onPlayStateChange={(active) =>
setPlayingId((prev) => {
if (active) return r.id;
return prev === r.id ? null : prev;
})
}
<div
ref={deckRef}
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
>
{(items ?? []).map((r) => {
const up = uploadStatus(r);
const isPlaying = playingId === r.id;
return (
<div
key={r.id}
className={`recording-deck-card hud-card flex flex-col justify-between p-4 transition-all duration-200 ${
isPlaying
? "border-signal/50 bg-signal/10 shadow-[0_0_24px_-10px_var(--color-signal-glow)]"
: ""
}`}
>
<div>
{/* Header info */}
<div className="flex items-center gap-3 border-b border-hairline pb-3">
<Avatar
src={r.avatar_url}
name={r.username}
size={36}
/>
) : (
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 px-3 py-2 font-mono text-[11px] text-ink-muted">
<Loader2 className="size-3.5 animate-spin text-signal" />
{r.upload_status === "pending"
? "UPLOAD_PENDING..."
: r.upload_error
? r.upload_error
: "SYNTHESIZING_PCM..."}
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-ink">
{r.username}
</div>
<div className="flex items-center gap-1.5 font-mono text-[10px] text-ink-faint">
<Hash className="size-2.5 text-signal" />
<span className="truncate">
{r.channel_name ?? "voice"}
</span>
<span>·</span>
<span>{formatRelativeTime(r.created_at)}</span>
</div>
</div>
)}
</div>
</div>
{isPlaying && <NowPlayingChip />}
{up && !isPlaying && (
<Badge
tone={up.tone}
className="font-mono text-[9px]"
>
{up.label}
</Badge>
)}
</div>
{/* Actions & File Stats */}
<div className="flex items-center justify-between border-t border-hairline pt-2.5">
<span className="font-mono text-[10px] text-ink-faint">
SIZE: {formatBytes(r.size_bytes)}
</span>
<div className="flex items-center gap-2">
{r.download_url && (
<a
href={r.download_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-md border border-hairline bg-surface-2 px-2 py-1 font-mono text-[10px] text-ink-soft transition-colors hover:bg-surface hover:text-ink"
{/* Audio Player Scrub */}
<div className="my-3">
{r.download_url ? (
<RecordingAudioPlayer
src={r.download_url}
label={`Voice recording by ${r.username}`}
onPlayStateChange={(active) =>
setPlayingId((prev) => {
if (active) return r.id;
return prev === r.id ? null : prev;
})
}
/>
) : (
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 px-3 py-2 font-mono text-[11px] text-ink-muted">
<Loader2 className="size-3.5 animate-spin text-signal" />
{r.upload_status === "pending"
? "UPLOAD_PENDING..."
: r.upload_error
? r.upload_error
: "SYNTHESIZING_PCM..."}
</div>
)}
</div>
</div>
{/* Actions & File Stats */}
<div className="flex items-center justify-between border-t border-hairline pt-2.5">
<span className="font-mono text-[10px] text-ink-faint">
SIZE: {formatBytes(r.size_bytes)}
</span>
<div className="flex items-center gap-2">
{r.download_url && (
<a
href={r.download_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-md border border-hairline bg-surface-2 px-2 py-1 font-mono text-[10px] text-ink-soft transition-colors hover:bg-surface hover:text-ink"
>
<Download className="size-3 text-signal" /> RAW
</a>
)}
<button
type="button"
onClick={() => onDelete(r.id)}
disabled={del.isPending}
className="inline-flex items-center gap-1 rounded-md border border-vermilion/30 bg-vermilion/10 px-2 py-1 font-mono text-[10px] text-vermilion transition-colors hover:bg-vermilion/20"
>
<Download className="size-3 text-signal" /> RAW
</a>
)}
<button
type="button"
onClick={() => onDelete(r.id)}
disabled={del.isPending}
className="inline-flex items-center gap-1 rounded-md border border-vermilion/30 bg-vermilion/10 px-2 py-1 font-mono text-[10px] text-vermilion transition-colors hover:bg-vermilion/20"
>
<Trash2 className="size-3" /> PURGE
</button>
<Trash2 className="size-3" /> PURGE
</button>
</div>
</div>
</div>
</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>
+1
View File
@@ -48,6 +48,7 @@ export {
} from "./use-moderation";
export {
useDeleteRecording,
useLoadMoreRecordings,
useRecordings,
useRecordingsWsSync,
} from "./use-recordings";
+69 -10
View File
@@ -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<VoiceRecording[]>(
export function useRecordingsPage(initialPage?: PaginatedRecordings) {
return useSWR<PaginatedRecordings>(
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 },
);
});