feat(frontend): polish remaining views + centralize time/status helpers
- Add formatDuration + formatRelativeTime to lib/format.ts and aiTone to lib/ai-status.ts; dedupe duplicated helpers in messages/analysis views. - Recordings: upload-status badge (pending/processing/failed), channel chip, relative time, hover lift, surface upload errors. - Media: mode pill + duration on now-playing, volume meter, per-track duration and queue total in header. - Moderation: relative timestamps in action rows + executor hint. - Analysis & Dashboard: rank bars for top reactors for scannable comparison.
This commit is contained in:
@@ -6,23 +6,12 @@ import { useAmbient } from "@/components/ambient/ambient-context";
|
|||||||
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
|
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
|
||||||
import { EmptyState, LoadingState, SectionHeader } from "@/components/shared";
|
import { EmptyState, LoadingState, SectionHeader } from "@/components/shared";
|
||||||
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
|
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
|
||||||
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
import type { AiStatus } from "@/lib/types";
|
import {
|
||||||
|
formatDuration,
|
||||||
function aiTone(
|
getMessageChannelLabel,
|
||||||
s?: AiStatus | null,
|
renderMessageContent,
|
||||||
): "signal" | "amber" | "vermilion" | "neutral" {
|
} from "@/lib/format";
|
||||||
if (s === "clean") return "signal";
|
|
||||||
if (s === "warn") return "amber";
|
|
||||||
if (s === "flagged" || s === "error") return "vermilion";
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Human-readable analysis duration, e.g. 850ms / 1.2s / 3.4s. */
|
|
||||||
function formatAnalysisDuration(ms: number): string {
|
|
||||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
||||||
return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AnalysisView() {
|
export function AnalysisView() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -109,7 +98,7 @@ export function AnalysisView() {
|
|||||||
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
||||||
{m.ai_analysis_duration_ms &&
|
{m.ai_analysis_duration_ms &&
|
||||||
m.ai_analysis_duration_ms > 0
|
m.ai_analysis_duration_ms > 0
|
||||||
? `${m.ai_status} · ${formatAnalysisDuration(m.ai_analysis_duration_ms)}`
|
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
|
||||||
: m.ai_status}
|
: m.ai_status}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -135,18 +124,33 @@ export function AnalysisView() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(reactors ?? []).slice(0, 6).map((r, i) => (
|
{(reactors ?? []).slice(0, 6).map((r, i) => {
|
||||||
|
const maxNet = reactors?.[0]?.net_count || 1;
|
||||||
|
const pct = Math.max(
|
||||||
|
4,
|
||||||
|
Math.round((r.net_count / maxNet) * 100),
|
||||||
|
);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={r.user_id}
|
key={r.user_id}
|
||||||
className="flex items-center gap-3 text-sm"
|
className="flex items-center gap-3 text-sm"
|
||||||
>
|
>
|
||||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||||
<span className="flex-1 truncate text-ink">{r.username}</span>
|
<span className="w-28 shrink-0 truncate text-ink-soft sm:w-40">
|
||||||
<span className="mono text-xs text-signal">
|
{r.username}
|
||||||
|
</span>
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-signal/70"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="mono w-12 text-right text-xs text-signal">
|
||||||
+{r.net_count}
|
+{r.net_count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{(reactors ?? []).length === 0 && (
|
{(reactors ?? []).length === 0 && (
|
||||||
<div className="py-4 text-center text-xs text-ink-faint">
|
<div className="py-4 text-center text-xs text-ink-faint">
|
||||||
No data
|
No data
|
||||||
|
|||||||
@@ -231,17 +231,27 @@ export function DashboardView({
|
|||||||
<GlassPanel>
|
<GlassPanel>
|
||||||
<SectionHeader eyebrow="engagement" title="Top reactors" />
|
<SectionHeader eyebrow="engagement" title="Top reactors" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(reactors ?? []).slice(0, 6).map((r, i) => (
|
{(reactors ?? []).slice(0, 6).map((r, i) => {
|
||||||
|
const maxNet = reactors?.[0]?.net_count || 1;
|
||||||
|
const pct = Math.max(4, Math.round((r.net_count / maxNet) * 100));
|
||||||
|
return (
|
||||||
<div key={r.user_id} className="flex items-center gap-3">
|
<div key={r.user_id} className="flex items-center gap-3">
|
||||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||||
<span className="flex-1 truncate text-sm text-ink">
|
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40">
|
||||||
{r.username}
|
{r.username}
|
||||||
</span>
|
</span>
|
||||||
<span className="mono text-xs text-signal">
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-signal/70"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="mono w-12 text-right text-xs text-signal">
|
||||||
+{formatNumber(r.net_count)}
|
+{formatNumber(r.net_count)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{(reactors ?? []).length === 0 && <EmptyHint />}
|
{(reactors ?? []).length === 0 && <EmptyHint />}
|
||||||
</div>
|
</div>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Repeat,
|
Repeat,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
Square,
|
Square,
|
||||||
|
Volume2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
useMediaStop,
|
useMediaStop,
|
||||||
useMediaWsSync,
|
useMediaWsSync,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
|
import { formatDuration } from "@/lib/format";
|
||||||
import type { MediaState } from "@/lib/types";
|
import type { MediaState } from "@/lib/types";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
@@ -38,6 +40,10 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
const playing = media?.playing ?? false;
|
const playing = media?.playing ?? false;
|
||||||
const current = media?.current ?? null;
|
const current = media?.current ?? null;
|
||||||
const queueList = media?.queue ?? [];
|
const queueList = media?.queue ?? [];
|
||||||
|
const queueTotal = queueList.reduce(
|
||||||
|
(acc, it) => acc + (it.durationMs ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,11 +94,19 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
<h2 className="display text-balance text-2xl text-ink">
|
<h2 className="display text-balance text-2xl text-ink">
|
||||||
{current?.title ?? "Nothing queued"}
|
{current?.title ?? "Nothing queued"}
|
||||||
</h2>
|
</h2>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint">
|
||||||
{current?.source && (
|
{current?.source && (
|
||||||
<div className="mono mt-1 truncate text-xs text-ink-faint">
|
<span className="mono truncate">{current.source}</span>
|
||||||
{current.source}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
{current?.mode && (
|
||||||
|
<span className="pill capitalize">{current.mode}</span>
|
||||||
|
)}
|
||||||
|
{formatDuration(current?.durationMs) && (
|
||||||
|
<span className="mono">
|
||||||
|
{formatDuration(current?.durationMs)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -130,7 +144,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center gap-2">
|
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Paste a YouTube / music URL…"
|
placeholder="Paste a YouTube / music URL…"
|
||||||
value={url}
|
value={url}
|
||||||
@@ -138,6 +153,21 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
onKeyDown={(e) => e.key === "Enter" && onPlay()}
|
onKeyDown={(e) => e.key === "Enter" && onPlay()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5">
|
||||||
|
<Volume2 className="size-4 text-ink-faint" />
|
||||||
|
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-signal/70"
|
||||||
|
style={{
|
||||||
|
width: `${Math.round((media?.musicVolume ?? 0) * 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="mono w-9 text-right text-[0.65rem] text-ink-faint">
|
||||||
|
{Math.round((media?.musicVolume ?? 0) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
|
|
||||||
<GlassPanel>
|
<GlassPanel>
|
||||||
@@ -146,7 +176,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
title="Queue"
|
title="Queue"
|
||||||
action={
|
action={
|
||||||
<span className="mono text-xs text-ink-faint">
|
<span className="mono text-xs text-ink-faint">
|
||||||
{queueList.length} tracks
|
{queueList.length} track{queueList.length === 1 ? "" : "s"}
|
||||||
|
{queueTotal && ` · ${formatDuration(queueTotal)}`}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -172,7 +203,12 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
{item.source}
|
{item.source}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="pill">{item.mode ?? "music"}</span>
|
<span className="pill capitalize">{item.mode ?? "music"}</span>
|
||||||
|
{formatDuration(item.durationMs) && (
|
||||||
|
<span className="mono w-10 text-right text-[0.65rem] text-ink-faint">
|
||||||
|
{formatDuration(item.durationMs)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ import {
|
|||||||
useMessagesHasMore,
|
useMessagesHasMore,
|
||||||
useMessagesWsSync,
|
useMessagesWsSync,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
|
import { aiTone } from "@/lib/ai-status";
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
|
formatDuration,
|
||||||
|
formatRelativeTime,
|
||||||
getMessageChannelLabel,
|
getMessageChannelLabel,
|
||||||
renderMessageContent,
|
renderMessageContent,
|
||||||
safeParseJsonArray,
|
safeParseJsonArray,
|
||||||
@@ -43,27 +46,6 @@ import {
|
|||||||
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
|
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
function relTime(ts?: number | null) {
|
|
||||||
if (!ts) return "";
|
|
||||||
const d = Date.now() - ts;
|
|
||||||
const m = Math.floor(d / 60000);
|
|
||||||
if (m < 1) return "just now";
|
|
||||||
if (m < 60) return `${m}m`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h`;
|
|
||||||
return `${Math.floor(h / 24)}d`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aiTone(
|
|
||||||
s?: AiStatus | null,
|
|
||||||
): "signal" | "amber" | "vermilion" | "neutral" {
|
|
||||||
if (s === "clean") return "signal";
|
|
||||||
if (s === "warn") return "amber";
|
|
||||||
if (s === "flagged" || s === "error") return "vermilion";
|
|
||||||
if (s === "processing" || s === "pending") return "neutral";
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessagesView({
|
export function MessagesView({
|
||||||
initialGuilds,
|
initialGuilds,
|
||||||
initialGuildId,
|
initialGuildId,
|
||||||
@@ -279,7 +261,7 @@ export function MessagesView({
|
|||||||
{getMessageChannelLabel(m)}
|
{getMessageChannelLabel(m)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||||
{relTime(m.created_at)}
|
{formatRelativeTime(m.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
||||||
@@ -361,12 +343,6 @@ function AiBadge({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human-readable analysis duration, e.g. 850ms / 1.2s / 3.4s. */
|
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
||||||
return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageDetail({
|
function MessageDetail({
|
||||||
m,
|
m,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -383,7 +359,7 @@ function MessageDetail({
|
|||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-ink">{m.username}</div>
|
<div className="font-semibold text-ink">{m.username}</div>
|
||||||
<div className="mono text-[0.65rem] text-ink-faint">
|
<div className="mono text-[0.65rem] text-ink-faint">
|
||||||
{getMessageChannelLabel(m)} · {relTime(m.created_at)}
|
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
SectionHeader,
|
SectionHeader,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { useModerationActions, useModerationStats } from "@/hooks";
|
import { useModerationActions, useModerationStats } from "@/hooks";
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
||||||
import type {
|
import type {
|
||||||
ModerationAction,
|
ModerationAction,
|
||||||
ModerationActionType,
|
ModerationActionType,
|
||||||
@@ -247,12 +247,18 @@ function ActionRow({ a }: { a: ModerationAction }) {
|
|||||||
</span>
|
</span>
|
||||||
<Badge tone={tone}>{a.status}</Badge>
|
<Badge tone={tone}>{a.status}</Badge>
|
||||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||||
{a.created_at ? new Date(a.created_at).toLocaleString() : "—"}
|
{formatRelativeTime(a.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{a.reason && (
|
{a.reason && (
|
||||||
<div className="mt-0.5 text-xs text-ink-soft">“{a.reason}”</div>
|
<div className="mt-0.5 text-xs text-ink-soft">“{a.reason}”</div>
|
||||||
)}
|
)}
|
||||||
|
{a.executed_by && (
|
||||||
|
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
|
||||||
|
by {a.executed_by}
|
||||||
|
{a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{a.content && (
|
{a.content && (
|
||||||
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint">
|
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint">
|
||||||
{a.content}
|
{a.content}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Download, Headphones, Trash2 } from "lucide-react";
|
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
GlassCard,
|
GlassCard,
|
||||||
GlassPanel,
|
GlassPanel,
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
useRecordings,
|
useRecordings,
|
||||||
useRecordingsWsSync,
|
useRecordingsWsSync,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { formatBytes } from "@/lib/format";
|
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
||||||
import type { VoiceRecording } from "@/lib/types";
|
import type { VoiceRecording } from "@/lib/types";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
@@ -75,19 +76,31 @@ export function RecordingsView({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{(items ?? []).map((r) => (
|
{(items ?? []).map((r) => {
|
||||||
<GlassCard key={r.id} className="flex flex-col gap-3">
|
const up = uploadStatus(r);
|
||||||
|
return (
|
||||||
|
<GlassCard
|
||||||
|
key={r.id}
|
||||||
|
className="flex flex-col gap-3 transition-colors hover:bg-white/[0.06]"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-semibold text-ink">
|
<div className="truncate text-sm font-semibold text-ink">
|
||||||
{r.username}
|
{r.username}
|
||||||
</div>
|
</div>
|
||||||
<div className="mono text-[0.65rem] text-ink-faint">
|
<div className="flex items-center gap-1.5 text-[0.65rem] text-ink-faint">
|
||||||
{r.channel_name ?? "voice"} ·{" "}
|
<Hash className="size-3" />
|
||||||
{new Date(r.created_at).toLocaleString()}
|
<span className="truncate">
|
||||||
|
{r.channel_name ?? "voice"}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-faint/60">·</span>
|
||||||
|
<span className="mono">
|
||||||
|
{formatRelativeTime(r.created_at)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{up && <Badge tone={up.tone}>{up.label}</Badge>}
|
||||||
<span className="mono text-[0.65rem] text-ink-faint">
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
{formatBytes(r.size_bytes)}
|
{formatBytes(r.size_bytes)}
|
||||||
</span>
|
</span>
|
||||||
@@ -103,8 +116,13 @@ export function RecordingsView({
|
|||||||
aria-label={`Voice recording ${r.id}`}
|
aria-label={`Voice recording ${r.id}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
|
<div className="flex items-center gap-1.5 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
|
||||||
Upload pending…
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
{r.upload_status === "pending"
|
||||||
|
? "Upload pending…"
|
||||||
|
: r.upload_error
|
||||||
|
? r.upload_error
|
||||||
|
: "Processing…"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -130,9 +148,20 @@ export function RecordingsView({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uploadStatus(
|
||||||
|
r: VoiceRecording,
|
||||||
|
): { tone: "neutral" | "amber" | "vermilion"; label: string } | null {
|
||||||
|
if (r.download_url) return null;
|
||||||
|
if (r.upload_status === "error" || r.upload_error)
|
||||||
|
return { tone: "vermilion", label: "failed" };
|
||||||
|
if (r.upload_status === "pending") return { tone: "amber", label: "pending" };
|
||||||
|
return { tone: "amber", label: "processing" };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { AiStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
export type AiTone = "signal" | "amber" | "vermilion" | "neutral";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an AI analysis status to a design-system tone.
|
||||||
|
*/
|
||||||
|
export function aiTone(s?: AiStatus | null): AiTone {
|
||||||
|
if (s === "clean") return "signal";
|
||||||
|
if (s === "warn") return "amber";
|
||||||
|
if (s === "flagged" || s === "error") return "vermilion";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
@@ -17,7 +17,41 @@ export function formatBytes(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely parse a JSON string into an array.
|
* Human-readable duration, e.g. 850ms / 1.2s / 3m 12s.
|
||||||
|
*/
|
||||||
|
export function formatDuration(ms: number | null | undefined): string {
|
||||||
|
if (!ms || ms <= 0) return "";
|
||||||
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||||
|
const totalS = Math.floor(ms / 1000);
|
||||||
|
if (totalS < 60) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
const m = Math.floor(totalS / 60);
|
||||||
|
const s = totalS % 60;
|
||||||
|
return s ? `${m}m ${s}s` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact relative time from an epoch-ms timestamp.
|
||||||
|
* e.g. "just now" / "5m" / "3h" / "2d" / "Apr 3".
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(ts?: number | null): string {
|
||||||
|
if (!ts) return "";
|
||||||
|
const diff = Date.now() - ts;
|
||||||
|
if (diff < 0) return new Date(ts).toLocaleDateString();
|
||||||
|
const m = Math.floor(diff / 60000);
|
||||||
|
if (m < 1) return "just now";
|
||||||
|
if (m < 60) return `${m}m`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
if (d < 7) return `${d}d`;
|
||||||
|
return new Date(ts).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse JSON string into array — safe.
|
||||||
*/
|
*/
|
||||||
export function safeParseJsonArray(value: string | null | undefined): string[] {
|
export function safeParseJsonArray(value: string | null | undefined): string[] {
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
|
|||||||
Reference in New Issue
Block a user