feat(frontend): show transcription + keyword search + channel/date filters + speaker leaderboard

- 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)
This commit is contained in:
asepharyana committed 2026-08-30 13:35:33 +07:00
1 parent 22c09ecd28
commit e12b1302ff
5 files changed
+401 -65

No files matched your search

@@ -7,6 +7,9 @@ import {
Hash, Hash,
Headphones, Headphones,
Loader2, Loader2,
MessagesSquare,
Mic,
Search,
Trash2, Trash2,
Users, Users,
} from "lucide-react"; } from "lucide-react";
@@ -18,6 +21,7 @@ import {
Button, Button,
GlassCard, GlassCard,
GlassPanel, GlassPanel,
Input,
Select, Select,
type SelectOption, type SelectOption,
Skeleton, Skeleton,
@@ -29,9 +33,11 @@ import {
RecordingAudioPlayer, RecordingAudioPlayer,
} from "@/components/voice/recording-audio-player"; } from "@/components/voice/recording-audio-player";
import { import {
type RecordingsFilter,
useDeleteRecording, useDeleteRecording,
useLoadMoreRecordings, useLoadMoreRecordings,
useRecordings, useRecordings,
useRecordingsSummary,
useRecordingsWsSync, useRecordingsWsSync,
} from "@/hooks"; } from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation"; import { useStaggerReveal } from "@/hooks/use-gsap-animation";
@@ -41,11 +47,17 @@ import {
decodeAudio, decodeAudio,
downloadBlob, downloadBlob,
} from "@/lib/audio/wav"; } from "@/lib/audio/wav";
import { formatBytes, formatRelativeTime } from "@/lib/format"; import { formatBytes, formatDuration, formatRelativeTime } from "@/lib/format";
import type { PaginatedRecordings, VoiceRecording } from "@/lib/types"; import type {
PaginatedRecordings,
SpeakerSummary,
VoiceRecording,
} from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
const ALL_USERS = "__all__"; const ALL = "__all__";
type Tab = "deck" | "leaderboard";
export function RecordingsView({ export function RecordingsView({
initialPage, initialPage,
@@ -53,7 +65,24 @@ export function RecordingsView({
initialPage?: PaginatedRecordings; initialPage?: PaginatedRecordings;
}) { }) {
const ws = useWebSocket(); const ws = useWebSocket();
const [filterUserId, setFilterUserId] = useState<string | null>(null); const [tab, setTab] = useState<Tab>("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 { const {
data: items, data: items,
isLoading, isLoading,
@@ -61,9 +90,10 @@ export function RecordingsView({
nextCursor, nextCursor,
hasMore, hasMore,
mutate, mutate,
} = useRecordings(initialPage, filterUserId ?? undefined); } = useRecordings(initialPage, activeFilter);
const loadMore = useLoadMoreRecordings(filterUserId ?? undefined); const loadMore = useLoadMoreRecordings(activeFilter);
const del = useDeleteRecording(); const del = useDeleteRecording();
const summary = useRecordingsSummary();
useRecordingsWsSync(ws); useRecordingsWsSync(ws);
const ambient = useAmbient(); const ambient = useAmbient();
const [playingId, setPlayingId] = useState<string | null>(null); const [playingId, setPlayingId] = useState<string | null>(null);
@@ -87,17 +117,28 @@ export function RecordingsView({
ambient.set("signal", 0.3, "recordings"); ambient.set("signal", 0.3, "recordings");
}, [ambient]); }, [ambient]);
// Distinct speakers visible in the current (filtered) list, for the dropdown. // Distinct speakers/channels across the current (filtered) list.
const userOptions = useMemo<SelectOption[]>(() => { const userOptions = useMemo<SelectOption[]>(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
for (const r of items ?? []) { for (const r of items ?? []) {
if (r.user_id && !map.has(r.user_id)) map.set(r.user_id, r.username); if (r.user_id && !map.has(r.user_id)) map.set(r.user_id, r.username);
} }
const opts: SelectOption[] = [ return [
{ value: ALL_USERS, label: "All speakers" }, { value: ALL, label: "All speakers" },
...[...map.entries()].map(([value, label]) => ({ value, label })),
];
}, [items]);
const channelOptions = useMemo<SelectOption[]>(() => {
const map = new Map<string, string>();
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 })), ...[...map.entries()].map(([value, label]) => ({ value, label })),
]; ];
return opts;
}, [items]); }, [items]);
const loadOlder = useCallback(async () => { const loadOlder = useCallback(async () => {
@@ -188,7 +229,8 @@ export function RecordingsView({
const merged = concatAudioBuffers(buffers); const merged = concatAudioBuffers(buffers);
if (!merged) throw new Error("No audio decoded"); if (!merged) throw new Error("No audio decoded");
const wav = audioBufferToWav(merged); const wav = audioBufferToWav(merged);
const who = filterUserId const who =
userId !== ALL
? (target[0]?.username ?? "speaker").replace(/[^\w.-]+/g, "_") ? (target[0]?.username ?? "speaker").replace(/[^\w.-]+/g, "_")
: "all"; : "all";
downloadBlob(wav, `gmw-rec-${who}-${target.length}clips.wav`); downloadBlob(wav, `gmw-rec-${who}-${target.length}clips.wav`);
@@ -246,6 +288,17 @@ export function RecordingsView({
Tape Deck · Captured Audio Archive Tape Deck · Captured Audio Archive
</h1> </h1>
</div> </div>
<div className="flex items-center gap-1.5">
<TabButton active={tab === "deck"} onClick={() => setTab("deck")}>
<MessagesSquare className="size-3.5" /> DECK
</TabButton>
<TabButton
active={tab === "leaderboard"}
onClick={() => setTab("leaderboard")}
>
<Users className="size-3.5" /> LEADERBOARD
</TabButton>
</div>
<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 <span
@@ -257,22 +310,65 @@ export function RecordingsView({
</div> </div>
</div> </div>
{/* Filter + Export toolbar */} {tab === "leaderboard" ? (
<Leaderboard
summary={summary.data}
isLoading={summary.isLoading}
onShowSpeaker={(uid) => {
setUserId(uid);
setTab("deck");
setLoadedPages(0);
}}
/>
) : (
<>
{/* Filter + Search + Export toolbar */}
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
<div className="flex items-center gap-2"> <div className="relative min-w-0 flex-1 basis-56">
<Users className="size-3.5 text-ink-faint" /> <Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-ink-faint" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search transcriptions / speakers…"
className="pl-8"
/>
</div>
<Select <Select
value={filterUserId ?? ALL_USERS} value={userId}
onChange={(v) => { onChange={(v) => {
setFilterUserId(v === ALL_USERS ? null : v); setUserId(v);
setLoadedPages(0); setLoadedPages(0);
}} }}
options={userOptions} options={userOptions}
placeholder="Filter by speaker" placeholder="Speaker"
size="sm" size="sm"
className="w-48" className="w-40"
/>
<Select
value={channelId}
onChange={(v) => {
setChannelId(v);
setLoadedPages(0);
}}
options={channelOptions}
placeholder="Channel"
size="sm"
className="w-40"
/>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
aria-label="From date"
className="w-36"
/>
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
aria-label="To date"
className="w-36"
/> />
</div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -285,15 +381,26 @@ export function RecordingsView({
) : ( ) : (
<Files className="size-3.5" /> <Files className="size-3.5" />
)} )}
{exportingAll ? "RENDERING..." : `EXPORT WAV (${exportableCount})`} {exportingAll
? "RENDERING..."
: `EXPORT WAV (${exportableCount})`}
</Button> </Button>
{filterUserId && ( {(Object.keys(activeFilter).length > 0 ||
channelId !== ALL ||
userId !== ALL) && (
<Button <Button
variant="subtle" variant="subtle"
size="sm" size="sm"
onClick={() => setFilterUserId(null)} onClick={() => {
setQ("");
setChannelId(ALL);
setUserId(ALL);
setStartDate("");
setEndDate("");
setLoadedPages(0);
}}
> >
CLEAR FILTER RESET
</Button> </Button>
)} )}
</div> </div>
@@ -304,7 +411,8 @@ 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 loaded {hasMore ? "· more available" : ""} {totalRecordings} clips loaded{" "}
{hasMore ? "· more available" : ""}
</span> </span>
} }
/> />
@@ -312,20 +420,20 @@ export function RecordingsView({
<EmptyState <EmptyState
icon={<Headphones className="size-7" />} icon={<Headphones className="size-7" />}
title={ title={
filterUserId Object.keys(activeFilter).length > 0
? "No recordings for this speaker" ? "No recordings match these filters"
: "Tape deck is empty" : "Tape deck is empty"
} }
description={ description={
filterUserId Object.keys(activeFilter).length > 0
? "This speaker has no captured transmissions (or the filter is stale)." ? "Try clearing the search or filters, or pick a different speaker/channel."
: "Voice transmissions captured in connected channels will be archived here." : "Voice transmissions captured in connected channels will be archived here."
} }
/> />
) : ( ) : (
<div <div
ref={scrollRef} ref={scrollRef}
className="mt-4 max-h-[calc(100vh-220px)] overflow-y-auto pr-1" className="mt-4 max-h-[calc(100vh-260px)] overflow-y-auto pr-1"
> >
<div <div
ref={deckRef} ref={deckRef}
@@ -378,8 +486,13 @@ export function RecordingsView({
)} )}
</div> </div>
{/* Transcription snippet */}
<div className="py-2">
<TranscriptionSnippet text={r.transcription} />
</div>
{/* Audio Player Scrub */} {/* Audio Player Scrub */}
<div className="my-3"> <div className="my-1">
{r.download_url ? ( {r.download_url ? (
<RecordingAudioPlayer <RecordingAudioPlayer
src={r.download_url} src={r.download_url}
@@ -431,7 +544,8 @@ export function RecordingsView({
rel="noreferrer" 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" 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 <Download className="size-3 text-signal" />{" "}
RAW
</a> </a>
</> </>
)} )}
@@ -481,10 +595,151 @@ export function RecordingsView({
</div> </div>
)} )}
</GlassPanel> </GlassPanel>
</>
)}
</div> </div>
); );
} }
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 font-mono text-[10px] transition-colors ${
active
? "border-signal/40 bg-signal/15 text-signal"
: "border-hairline bg-surface-2 text-ink-soft hover:bg-surface hover:text-ink"
}`}
>
{children}
</button>
);
}
function TranscriptionSnippet({ text }: { text?: string | null }) {
const [open, setOpen] = useState(false);
if (!text) {
return (
<div className="h-10 rounded-[6px] border border-dashed border-hairline bg-surface-2/40 px-3 py-2 font-mono text-[10px] text-ink-faint">
no transcription
</div>
);
}
const collapsed = !open && text.length > 160;
return (
<div className="rounded-[6px] border border-hairline bg-surface-2 px-3 py-2">
<p
className={`font-mono text-[11px] leading-relaxed text-ink-soft ${
collapsed ? "line-clamp-3" : ""
}`}
>
{text}
</p>
{collapsed && (
<button
type="button"
onClick={() => setOpen(true)}
className="mt-1 font-mono text-[10px] text-signal hover:underline"
>
READ FULL TRANSCRIPT
</button>
)}
</div>
);
}
function Leaderboard({
summary,
isLoading,
onShowSpeaker,
}: {
summary: SpeakerSummary[] | undefined;
isLoading: boolean;
onShowSpeaker: (userId: string, username: string) => void;
}) {
if (isLoading && !summary)
return (
<GlassPanel>
<Skeleton className="h-4 w-40" />
<div className="mt-3 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</GlassPanel>
);
if (!summary || summary.length === 0)
return (
<GlassPanel>
<EmptyState
icon={<Mic className="size-7" />}
title="No speakers yet"
description="Leaderboard populates as voice is captured and transcribed."
/>
</GlassPanel>
);
const maxClips = Math.max(...summary.map((s) => s.clips), 1);
return (
<GlassPanel>
<SectionHeader
eyebrow="speech analytics"
title="Speaker Leaderboard"
action={
<span className="mono text-xs text-[#8a8f98]">
{summary.length} speakers
</span>
}
/>
<div className="mt-4 space-y-1.5">
{summary.map((s, idx) => (
<button
key={s.user_id}
type="button"
onClick={() => onShowSpeaker(s.user_id, s.username)}
className="group flex w-full items-center gap-3 rounded-lg border border-hairline bg-surface-2/50 px-3 py-2.5 text-left transition-colors hover:border-signal/40 hover:bg-surface"
>
<span className="w-6 shrink-0 text-center font-mono text-xs text-ink-faint">
{idx + 1}
</span>
<Avatar src={s.avatar_url} name={s.username} size={32} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-ink">
{s.username}
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-surface">
<div
className="h-full rounded-full bg-signal"
style={{ width: `${(s.clips / maxClips) * 100}%` }}
/>
</div>
</div>
<div className="shrink-0 text-right font-mono text-[10px] text-ink-soft">
<div className="flex items-center justify-end gap-1">
<Mic className="size-3 text-signal" /> {s.clips}
</div>
<div>{formatDuration(s.est_duration_s * 1000)}</div>
<div className="text-ink-faint">
{s.transcribed > 0 ? `${s.words} words` : "no transcript"}
</div>
</div>
</button>
))}
</div>
</GlassPanel>
);
}
function uploadStatus( function uploadStatus(
r: VoiceRecording, r: VoiceRecording,
): { tone: "neutral" | "amber" | "vermilion"; label: string } | null { ): { tone: "neutral" | "amber" | "vermilion"; label: string } | null {
+2
View File
@@ -47,9 +47,11 @@ export {
useTopFlaggedDomains, useTopFlaggedDomains,
} from "./use-moderation"; } from "./use-moderation";
export { export {
type RecordingsFilter,
useDeleteRecording, useDeleteRecording,
useLoadMoreRecordings, useLoadMoreRecordings,
useRecordings, useRecordings,
useRecordingsSummary,
useRecordingsWsSync, useRecordingsWsSync,
} from "./use-recordings"; } from "./use-recordings";
export { export {
+75 -23
View File
@@ -1,44 +1,85 @@
import { useEffect } from "react"; 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 { type RecordingListParams, recordingsApi } from "@/lib/api/recordings";
import type { PaginatedRecordings, VoiceRecording } from "@/lib/types"; import type {
PaginatedRecordings,
SpeakerSummary,
VoiceRecording,
} from "@/lib/types";
import type { WsHook } from "@/lib/ws-hook"; import type { WsHook } from "@/lib/ws-hook";
const RECORDINGS_PREFIX = "recordings"; const RECORDINGS_PREFIX = "recordings";
const RECORDINGS_SUMMARY_KEY = ["recordings", "summary"] as const;
function recordingsKey(userId?: string): [string, string] { export interface RecordingsFilter {
return [RECORDINGS_PREFIX, userId ?? "all"]; channelId?: string;
userId?: string;
/** keyword search */
q?: string;
startDate?: number;
endDate?: number;
} }
/** Revalidate/mutate every recording cache (all filters + global list). */ function recordingsKey(filter: RecordingsFilter = {}): unknown[] {
function isRecordingsKey(key: unknown): boolean { 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 ( return (
Array.isArray(key) && Array.isArray(key) &&
key.length === 2 && key.length === 7 &&
key[0] === RECORDINGS_PREFIX && 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( export function useRecordingsPage(
initialPage?: PaginatedRecordings, initialPage?: PaginatedRecordings,
userId?: string, filter: RecordingsFilter = {},
) { ) {
return useSWR<PaginatedRecordings>( return useSWR<PaginatedRecordings>(
recordingsKey(userId), recordingsKey(filter),
() => recordingsApi.list(50, undefined, userId), () => recordingsApi.list({ ...baseParams(filter), limit: 50 }),
// fallbackData only applies to the unfiltered "all" view; a filtered cache // fallbackData only applies to the unfiltered "all" view; a filtered cache
// must not be pre-seeded with unfiltered rows (would flash wrong data). // 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( export function useRecordings(
initialPage?: PaginatedRecordings, initialPage?: PaginatedRecordings,
userId?: string, filter: RecordingsFilter = {},
) { ) {
const page = useRecordingsPage(initialPage, userId); const page = useRecordingsPage(initialPage, filter);
return { return {
...page, ...page,
data: page.data?.items, data: page.data?.items,
@@ -48,13 +89,16 @@ export function useRecordings(
}; };
} }
export function useLoadMoreRecordings(userId?: string) { export function useLoadMoreRecordings(filter: RecordingsFilter = {}) {
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
return useAction( return useAction(async ({ cursor }: { cursor: string }) => {
async ({ channelId, cursor }: { channelId?: string; cursor: string }) => { const result = await recordingsApi.list({
const result = await recordingsApi.list(50, channelId, userId, cursor); ...baseParams(filter),
limit: 50,
cursor,
});
await mutate( await mutate(
recordingsKey(userId), recordingsKey(filter),
(old: PaginatedRecordings | undefined): PaginatedRecordings => { (old: PaginatedRecordings | undefined): PaginatedRecordings => {
if (!old) return result; if (!old) return result;
const existingIds = new Set(old.items.map((r) => r.id)); const existingIds = new Set(old.items.map((r) => r.id));
@@ -68,8 +112,7 @@ export function useLoadMoreRecordings(userId?: string) {
{ revalidate: false }, { revalidate: false },
); );
return result; return result;
}, });
);
} }
export function useDeleteRecording() { export function useDeleteRecording() {
@@ -78,7 +121,7 @@ export function useDeleteRecording() {
onSuccess: (_, id) => { onSuccess: (_, id) => {
// A deleted recording disappears from every filter view. // A deleted recording disappears from every filter view.
void mutate( void mutate(
(key: unknown) => isRecordingsKey(key), (key: unknown) => isRecordingsListKey(key),
( (
old: PaginatedRecordings | undefined, old: PaginatedRecordings | undefined,
): PaginatedRecordings | undefined => { ): PaginatedRecordings | undefined => {
@@ -90,6 +133,8 @@ export function useDeleteRecording() {
}, },
{ revalidate: false }, { 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; const rec = data as VoiceRecording;
// A fresh recording should appear in every filter view it belongs to. // A fresh recording should appear in every filter view it belongs to.
void mutate( void mutate(
(key: unknown) => isRecordingsKey(key), (key: unknown) => isRecordingsListKey(key),
(old: PaginatedRecordings | undefined): PaginatedRecordings => { (old: PaginatedRecordings | undefined): PaginatedRecordings => {
if (!old) return { items: [rec], nextCursor: null, hasMore: false }; if (!old) return { items: [rec], nextCursor: null, hasMore: false };
if (old.items.some((r) => r.id === rec.id)) return old; if (old.items.some((r) => r.id === rec.id)) return old;
@@ -112,7 +157,14 @@ export function useRecordingsWsSync(ws: WsHook) {
}, },
{ revalidate: false }, { revalidate: false },
); );
void mutate(RECORDINGS_SUMMARY_KEY);
}); });
return unsub; return unsub;
}, [ws, mutate]); }, [ws, mutate]);
} }
export function useRecordingsSummary() {
return useSWR<SpeakerSummary[]>(RECORDINGS_SUMMARY_KEY, () =>
recordingsApi.summary(),
);
}
+25 -11
View File
@@ -1,20 +1,34 @@
import { orpc } from "@/lib/orpc/client"; 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 = { export const recordingsApi = {
list: ( list: (params: RecordingListParams = {}) =>
limit?: number,
channelId?: string,
userId?: string,
cursor?: string,
) =>
orpc.recordings.list({ orpc.recordings.list({
limit, limit: params.limit,
channelId, channelId: params.channelId,
userId, userId: params.userId,
cursor, cursor: params.cursor,
q: params.q,
startDate: params.startDate,
endDate: params.endDate,
}) as unknown as Promise<PaginatedRecordings>, }) as unknown as Promise<PaginatedRecordings>,
delete: (id: string) => delete: (id: string) =>
orpc.recordings.delete({ id }) as unknown as Promise<{ ok: boolean }>, orpc.recordings.delete({ id }) as unknown as Promise<{ ok: boolean }>,
summary: () =>
orpc.recordings.summary({}) as unknown as Promise<SpeakerSummary[]>,
}; };
@@ -13,6 +13,7 @@ export interface VoiceRecording {
upload_error?: string | null; upload_error?: string | null;
created_at: number; created_at: number;
uploaded_at?: number | null; uploaded_at?: number | null;
transcription?: string | null;
} }
export interface PaginatedRecordings { export interface PaginatedRecordings {
@@ -20,3 +21,15 @@ export interface PaginatedRecordings {
nextCursor: string | null; nextCursor: string | null;
hasMore: boolean; 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;
}