- {(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 */}
-
-
- SIZE: {formatBytes(r.size_bytes)}
-
-
+
+
+ {/* Actions & File Stats */}
+
+
+ SIZE: {formatBytes(r.size_bytes)}
+
+
+ {r.download_url && (
+
+ RAW
+
+ )}
+
+
-
- );
- })}
+ );
+ })}
+
+
+ {/* Infinite scroll sentinel & status footer */}
+
+ {loadMore.isPending ? (
+
+
+ LOADING EARLIER RECORDINGS...
+
+ ) : hasMore && loadedPages < MAX_OLDER_PAGES ? (
+
+ ↓ LOAD MORE RECORDINGS
+
+ ) : (
+
+ {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 },
);
});