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:
@@ -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,9 +229,10 @@ 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 =
|
||||||
? (target[0]?.username ?? "speaker").replace(/[^\w.-]+/g, "_")
|
userId !== ALL
|
||||||
: "all";
|
? (target[0]?.username ?? "speaker").replace(/[^\w.-]+/g, "_")
|
||||||
|
: "all";
|
||||||
downloadBlob(wav, `gmw-rec-${who}-${target.length}clips.wav`);
|
downloadBlob(wav, `gmw-rec-${who}-${target.length}clips.wav`);
|
||||||
toast({
|
toast({
|
||||||
title: `Exported ${target.length} clips as one WAV`,
|
title: `Exported ${target.length} clips as one 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,234 +310,436 @@ export function RecordingsView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter + Export toolbar */}
|
{tab === "leaderboard" ? (
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<Leaderboard
|
||||||
<div className="flex items-center gap-2">
|
summary={summary.data}
|
||||||
<Users className="size-3.5 text-ink-faint" />
|
isLoading={summary.isLoading}
|
||||||
<Select
|
onShowSpeaker={(uid) => {
|
||||||
value={filterUserId ?? ALL_USERS}
|
setUserId(uid);
|
||||||
onChange={(v) => {
|
setTab("deck");
|
||||||
setFilterUserId(v === ALL_USERS ? null : v);
|
setLoadedPages(0);
|
||||||
setLoadedPages(0);
|
}}
|
||||||
}}
|
|
||||||
options={userOptions}
|
|
||||||
placeholder="Filter by speaker"
|
|
||||||
size="sm"
|
|
||||||
className="w-48"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onExportAll}
|
|
||||||
disabled={exportingAll || exportableCount === 0}
|
|
||||||
title="Concatenate the visible (filtered) recordings into one WAV for Audacity"
|
|
||||||
>
|
|
||||||
{exportingAll ? (
|
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Files className="size-3.5" />
|
|
||||||
)}
|
|
||||||
{exportingAll ? "RENDERING..." : `EXPORT WAV (${exportableCount})`}
|
|
||||||
</Button>
|
|
||||||
{filterUserId && (
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setFilterUserId(null)}
|
|
||||||
>
|
|
||||||
CLEAR FILTER
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GlassPanel>
|
|
||||||
<SectionHeader
|
|
||||||
eyebrow="acoustic buffer"
|
|
||||||
title="Voice Capture Tape Deck"
|
|
||||||
action={
|
|
||||||
<span className="mono text-xs text-[#8a8f98]">
|
|
||||||
{totalRecordings} clips loaded {hasMore ? "· more available" : ""}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{totalRecordings === 0 ? (
|
) : (
|
||||||
<EmptyState
|
<>
|
||||||
icon={<Headphones className="size-7" />}
|
{/* Filter + Search + Export toolbar */}
|
||||||
title={
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
filterUserId
|
<div className="relative min-w-0 flex-1 basis-56">
|
||||||
? "No recordings for this speaker"
|
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-ink-faint" />
|
||||||
: "Tape deck is empty"
|
<Input
|
||||||
}
|
value={q}
|
||||||
description={
|
onChange={(e) => setQ(e.target.value)}
|
||||||
filterUserId
|
placeholder="Search transcriptions / speakers…"
|
||||||
? "This speaker has no captured transmissions (or the filter is stale)."
|
className="pl-8"
|
||||||
: "Voice transmissions captured in connected channels will be archived here."
|
/>
|
||||||
}
|
</div>
|
||||||
/>
|
<Select
|
||||||
) : (
|
value={userId}
|
||||||
<div
|
onChange={(v) => {
|
||||||
ref={scrollRef}
|
setUserId(v);
|
||||||
className="mt-4 max-h-[calc(100vh-220px)] overflow-y-auto pr-1"
|
setLoadedPages(0);
|
||||||
>
|
}}
|
||||||
<div
|
options={userOptions}
|
||||||
ref={deckRef}
|
placeholder="Speaker"
|
||||||
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
size="sm"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onExportAll}
|
||||||
|
disabled={exportingAll || exportableCount === 0}
|
||||||
|
title="Concatenate the visible (filtered) recordings into one WAV for Audacity"
|
||||||
>
|
>
|
||||||
{(items ?? []).map((r) => {
|
{exportingAll ? (
|
||||||
const up = uploadStatus(r);
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
const isPlaying = playingId === r.id;
|
) : (
|
||||||
const isExporting = exportingIds.has(r.id);
|
<Files className="size-3.5" />
|
||||||
return (
|
)}
|
||||||
<div
|
{exportingAll
|
||||||
key={r.id}
|
? "RENDERING..."
|
||||||
className={`recording-deck-card hud-card flex flex-col justify-between p-4 transition-all duration-200 ${
|
: `EXPORT WAV (${exportableCount})`}
|
||||||
isPlaying
|
</Button>
|
||||||
? "border-signal/50 bg-signal/10 shadow-[0_0_24px_-10px_var(--color-signal-glow)]"
|
{(Object.keys(activeFilter).length > 0 ||
|
||||||
: ""
|
channelId !== ALL ||
|
||||||
}`}
|
userId !== ALL) && (
|
||||||
>
|
<Button
|
||||||
<div>
|
variant="subtle"
|
||||||
{/* Header info */}
|
size="sm"
|
||||||
<div className="flex items-center gap-3 border-b border-hairline pb-3">
|
onClick={() => {
|
||||||
<Avatar
|
setQ("");
|
||||||
src={r.avatar_url}
|
setChannelId(ALL);
|
||||||
name={r.username}
|
setUserId(ALL);
|
||||||
size={36}
|
setStartDate("");
|
||||||
/>
|
setEndDate("");
|
||||||
<div className="min-w-0 flex-1">
|
setLoadedPages(0);
|
||||||
<div className="truncate text-xs font-semibold text-ink">
|
}}
|
||||||
{r.username}
|
>
|
||||||
|
RESET
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GlassPanel>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="acoustic buffer"
|
||||||
|
title="Voice Capture Tape Deck"
|
||||||
|
action={
|
||||||
|
<span className="mono text-xs text-[#8a8f98]">
|
||||||
|
{totalRecordings} clips loaded{" "}
|
||||||
|
{hasMore ? "· more available" : ""}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{totalRecordings === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Headphones className="size-7" />}
|
||||||
|
title={
|
||||||
|
Object.keys(activeFilter).length > 0
|
||||||
|
? "No recordings match these filters"
|
||||||
|
: "Tape deck is empty"
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
Object.keys(activeFilter).length > 0
|
||||||
|
? "Try clearing the search or filters, or pick a different speaker/channel."
|
||||||
|
: "Voice transmissions captured in connected channels will be archived here."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="mt-4 max-h-[calc(100vh-260px)] overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<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;
|
||||||
|
const isExporting = exportingIds.has(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 suppressHydrationWarning>
|
||||||
|
{formatRelativeTime(r.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isPlaying && <NowPlayingChip />}
|
||||||
|
{up && !isPlaying && (
|
||||||
|
<Badge
|
||||||
|
tone={up.tone}
|
||||||
|
className="font-mono text-[9px]"
|
||||||
|
>
|
||||||
|
{up.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 font-mono text-[10px] text-ink-faint">
|
|
||||||
<Hash className="size-2.5 text-signal" />
|
{/* Transcription snippet */}
|
||||||
<span className="truncate">
|
<div className="py-2">
|
||||||
{r.channel_name ?? "voice"}
|
<TranscriptionSnippet text={r.transcription} />
|
||||||
</span>
|
</div>
|
||||||
<span>·</span>
|
|
||||||
<span suppressHydrationWarning>
|
{/* Audio Player Scrub */}
|
||||||
{formatRelativeTime(r.created_at)}
|
<div className="my-1">
|
||||||
</span>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
{isPlaying && <NowPlayingChip />}
|
|
||||||
{up && !isPlaying && (
|
|
||||||
<Badge
|
|
||||||
tone={up.tone}
|
|
||||||
className="font-mono text-[9px]"
|
|
||||||
>
|
|
||||||
{up.label}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Audio Player Scrub */}
|
{/* Actions & File Stats */}
|
||||||
<div className="my-3">
|
<div className="flex items-center justify-between border-t border-hairline pt-2.5">
|
||||||
{r.download_url ? (
|
<span className="font-mono text-[10px] text-ink-faint">
|
||||||
<RecordingAudioPlayer
|
SIZE: {formatBytes(r.size_bytes)}
|
||||||
src={r.download_url}
|
</span>
|
||||||
label={`Voice recording by ${r.username}`}
|
<div className="flex items-center gap-2">
|
||||||
onPlayStateChange={(active) =>
|
{r.download_url && (
|
||||||
setPlayingId((prev) => {
|
<>
|
||||||
if (active) return r.id;
|
<button
|
||||||
return prev === r.id ? null : prev;
|
type="button"
|
||||||
})
|
onClick={() => onExportOne(r)}
|
||||||
}
|
disabled={isExporting}
|
||||||
/>
|
className="inline-flex items-center gap-1 rounded-md border border-signal/40 bg-signal/10 px-2 py-1 font-mono text-[10px] text-signal transition-colors hover:bg-signal/20"
|
||||||
) : (
|
>
|
||||||
<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">
|
{isExporting ? (
|
||||||
<Loader2 className="size-3.5 animate-spin text-signal" />
|
<Loader2 className="size-3 animate-spin" />
|
||||||
{r.upload_status === "pending"
|
) : (
|
||||||
? "UPLOAD_PENDING..."
|
<FileAudio className="size-3" />
|
||||||
: r.upload_error
|
)}
|
||||||
? r.upload_error
|
{isExporting ? "WAV..." : "WAV"}
|
||||||
: "SYNTHESIZING_PCM..."}
|
</button>
|
||||||
</div>
|
<a
|
||||||
)}
|
href={r.download_url}
|
||||||
</div>
|
target="_blank"
|
||||||
</div>
|
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"
|
||||||
{/* Actions & File Stats */}
|
>
|
||||||
<div className="flex items-center justify-between border-t border-hairline pt-2.5">
|
<Download className="size-3 text-signal" />{" "}
|
||||||
<span className="font-mono text-[10px] text-ink-faint">
|
RAW
|
||||||
SIZE: {formatBytes(r.size_bytes)}
|
</a>
|
||||||
</span>
|
</>
|
||||||
<div className="flex items-center gap-2">
|
)}
|
||||||
{r.download_url && (
|
|
||||||
<>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onExportOne(r)}
|
onClick={() => onDelete(r.id)}
|
||||||
disabled={isExporting}
|
disabled={del.isPending}
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-signal/40 bg-signal/10 px-2 py-1 font-mono text-[10px] text-signal transition-colors hover:bg-signal/20"
|
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"
|
||||||
>
|
>
|
||||||
{isExporting ? (
|
<Trash2 className="size-3" /> PURGE
|
||||||
<Loader2 className="size-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<FileAudio className="size-3" />
|
|
||||||
)}
|
|
||||||
{isExporting ? "WAV..." : "WAV"}
|
|
||||||
</button>
|
</button>
|
||||||
<a
|
</div>
|
||||||
href={r.download_url}
|
</div>
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3" /> PURGE
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
})}
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Infinite scroll sentinel & status footer */}
|
{/* Infinite scroll sentinel & status footer */}
|
||||||
<div ref={sentinelRef} className="py-4 text-center">
|
<div ref={sentinelRef} className="py-4 text-center">
|
||||||
{loadMore.isPending ? (
|
{loadMore.isPending ? (
|
||||||
<span className="flex items-center justify-center gap-2 font-mono text-xs text-ink-muted">
|
<span className="flex items-center justify-center gap-2 font-mono text-xs text-ink-muted">
|
||||||
<Loader2 className="size-4 animate-spin text-signal" />
|
<Loader2 className="size-4 animate-spin text-signal" />
|
||||||
LOADING EARLIER RECORDINGS...
|
LOADING EARLIER RECORDINGS...
|
||||||
<span className="typing-dots">
|
<span className="typing-dots">
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : hasMore && loadedPages < MAX_OLDER_PAGES ? (
|
) : hasMore && loadedPages < MAX_OLDER_PAGES ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={loadOlder}
|
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"
|
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
|
↓ LOAD MORE RECORDINGS
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-mono text-[10px] text-ink-faint">
|
<span className="font-mono text-[10px] text-ink-faint">
|
||||||
{loadedPages >= MAX_OLDER_PAGES
|
{loadedPages >= MAX_OLDER_PAGES
|
||||||
? `CAPPED AT ${MAX_OLDER_PAGES} PAGES`
|
? `CAPPED AT ${MAX_OLDER_PAGES} PAGES`
|
||||||
: "ARCHIVE END REACHED"}
|
: "ARCHIVE END REACHED"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,28 +89,30 @@ 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),
|
||||||
await mutate(
|
limit: 50,
|
||||||
recordingsKey(userId),
|
cursor,
|
||||||
(old: PaginatedRecordings | undefined): PaginatedRecordings => {
|
});
|
||||||
if (!old) return result;
|
await mutate(
|
||||||
const existingIds = new Set(old.items.map((r) => r.id));
|
recordingsKey(filter),
|
||||||
const newUnique = result.items.filter((r) => !existingIds.has(r.id));
|
(old: PaginatedRecordings | undefined): PaginatedRecordings => {
|
||||||
return {
|
if (!old) return result;
|
||||||
items: [...old.items, ...newUnique],
|
const existingIds = new Set(old.items.map((r) => r.id));
|
||||||
nextCursor: result.nextCursor,
|
const newUnique = result.items.filter((r) => !existingIds.has(r.id));
|
||||||
hasMore: result.hasMore,
|
return {
|
||||||
};
|
items: [...old.items, ...newUnique],
|
||||||
},
|
nextCursor: result.nextCursor,
|
||||||
{ revalidate: false },
|
hasMore: result.hasMore,
|
||||||
);
|
};
|
||||||
return result;
|
},
|
||||||
},
|
{ revalidate: false },
|
||||||
);
|
);
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user