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:
asepharyana
2026-08-18 09:05:12 +07:00
parent 6bd6ddcdca
commit ff8a9c50ae
8 changed files with 250 additions and 142 deletions
@@ -6,23 +6,12 @@ import { useAmbient } from "@/components/ambient/ambient-context";
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
import { EmptyState, LoadingState, SectionHeader } from "@/components/shared";
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
import type { AiStatus } from "@/lib/types";
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";
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`;
}
import { aiTone } from "@/lib/ai-status";
import {
formatDuration,
getMessageChannelLabel,
renderMessageContent,
} from "@/lib/format";
export function AnalysisView() {
const [query, setQuery] = useState("");
@@ -109,7 +98,7 @@ export function AnalysisView() {
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
{m.ai_analysis_duration_ms &&
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}
</Badge>
)}
@@ -135,18 +124,33 @@ export function AnalysisView() {
}
/>
<div className="space-y-2">
{(reactors ?? []).slice(0, 6).map((r, i) => (
<div
key={r.user_id}
className="flex items-center gap-3 text-sm"
>
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<span className="flex-1 truncate text-ink">{r.username}</span>
<span className="mono text-xs text-signal">
+{r.net_count}
</span>
</div>
))}
{(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 text-sm"
>
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<span className="w-28 shrink-0 truncate text-ink-soft sm:w-40">
{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}
</span>
</div>
);
})}
{(reactors ?? []).length === 0 && (
<div className="py-4 text-center text-xs text-ink-faint">
No data
@@ -231,17 +231,27 @@ export function DashboardView({
<GlassPanel>
<SectionHeader eyebrow="engagement" title="Top reactors" />
<div className="space-y-2">
{(reactors ?? []).slice(0, 6).map((r, i) => (
<div key={r.user_id} className="flex items-center gap-3">
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<span className="flex-1 truncate text-sm text-ink">
{r.username}
</span>
<span className="mono text-xs text-signal">
+{formatNumber(r.net_count)}
</span>
</div>
))}
{(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">
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40">
{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">
+{formatNumber(r.net_count)}
</span>
</div>
);
})}
{(reactors ?? []).length === 0 && <EmptyHint />}
</div>
</GlassPanel>
@@ -7,6 +7,7 @@ import {
Repeat,
SkipForward,
Square,
Volume2,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
@@ -20,6 +21,7 @@ import {
useMediaStop,
useMediaWsSync,
} from "@/hooks";
import { formatDuration } from "@/lib/format";
import type { MediaState } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
@@ -38,6 +40,10 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const playing = media?.playing ?? false;
const current = media?.current ?? null;
const queueList = media?.queue ?? [];
const queueTotal = queueList.reduce(
(acc, it) => acc + (it.durationMs ?? 0),
0,
);
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
useEffect(() => {
@@ -88,11 +94,19 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
<h2 className="display text-balance text-2xl text-ink">
{current?.title ?? "Nothing queued"}
</h2>
{current?.source && (
<div className="mono mt-1 truncate text-xs text-ink-faint">
{current.source}
</div>
)}
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint">
{current?.source && (
<span className="mono truncate">{current.source}</span>
)}
{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">
<Button
variant="primary"
@@ -130,13 +144,29 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
</div>
</div>
<div className="mt-5 flex items-center gap-2">
<Input
placeholder="Paste a YouTube / music URL…"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onPlay()}
/>
<div className="mt-5 flex flex-wrap items-center gap-3">
<div className="relative min-w-0 flex-1">
<Input
placeholder="Paste a YouTube / music URL…"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onPlay()}
/>
</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>
@@ -146,7 +176,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
title="Queue"
action={
<span className="mono text-xs text-ink-faint">
{queueList.length} tracks
{queueList.length} track{queueList.length === 1 ? "" : "s"}
{queueTotal && ` · ${formatDuration(queueTotal)}`}
</span>
}
/>
@@ -172,7 +203,12 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
{item.source}
</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>
@@ -34,8 +34,11 @@ import {
useMessagesHasMore,
useMessagesWsSync,
} from "@/hooks";
import { aiTone } from "@/lib/ai-status";
import {
formatBytes,
formatDuration,
formatRelativeTime,
getMessageChannelLabel,
renderMessageContent,
safeParseJsonArray,
@@ -43,27 +46,6 @@ import {
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
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({
initialGuilds,
initialGuildId,
@@ -279,7 +261,7 @@ export function MessagesView({
{getMessageChannelLabel(m)}
</span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
{relTime(m.created_at)}
{formatRelativeTime(m.created_at)}
</span>
</div>
<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({
m,
attachments,
@@ -383,7 +359,7 @@ function MessageDetail({
<div>
<div className="font-semibold text-ink">{m.username}</div>
<div className="mono text-[0.65rem] text-ink-faint">
{getMessageChannelLabel(m)} · {relTime(m.created_at)}
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
</div>
</div>
<div className="ml-auto">
@@ -29,7 +29,7 @@ import {
SectionHeader,
} from "@/components/shared";
import { useModerationActions, useModerationStats } from "@/hooks";
import { formatNumber } from "@/lib/format";
import { formatNumber, formatRelativeTime } from "@/lib/format";
import type {
ModerationAction,
ModerationActionType,
@@ -247,12 +247,18 @@ function ActionRow({ a }: { a: ModerationAction }) {
</span>
<Badge tone={tone}>{a.status}</Badge>
<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>
</div>
{a.reason && (
<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 && (
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint">
{a.content}
@@ -1,10 +1,11 @@
"use client";
import { Download, Headphones, Trash2 } from "lucide-react";
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import {
Avatar,
Badge,
Button,
GlassCard,
GlassPanel,
@@ -21,7 +22,7 @@ import {
useRecordings,
useRecordingsWsSync,
} from "@/hooks";
import { formatBytes } from "@/lib/format";
import { formatBytes, formatRelativeTime } from "@/lib/format";
import type { VoiceRecording } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
@@ -75,64 +76,92 @@ export function RecordingsView({
/>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{(items ?? []).map((r) => (
<GlassCard key={r.id} className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Avatar src={r.avatar_url} name={r.username} size={38} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-ink">
{r.username}
{(items ?? []).map((r) => {
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">
<Avatar src={r.avatar_url} name={r.username} size={38} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-ink">
{r.username}
</div>
<div className="flex items-center gap-1.5 text-[0.65rem] text-ink-faint">
<Hash className="size-3" />
<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 className="mono text-[0.65rem] text-ink-faint">
{r.channel_name ?? "voice"} ·{" "}
{new Date(r.created_at).toLocaleString()}
{up && <Badge tone={up.tone}>{up.label}</Badge>}
<span className="mono text-[0.65rem] text-ink-faint">
{formatBytes(r.size_bytes)}
</span>
</div>
{r.download_url ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio
controls
src={r.download_url}
className="h-9 w-full"
preload="none"
aria-label={`Voice recording ${r.id}`}
/>
) : (
<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">
<Loader2 className="size-3.5 animate-spin" />
{r.upload_status === "pending"
? "Upload pending…"
: r.upload_error
? r.upload_error
: "Processing…"}
</div>
</div>
<span className="mono text-[0.65rem] text-ink-faint">
{formatBytes(r.size_bytes)}
</span>
</div>
{r.download_url ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<audio
controls
src={r.download_url}
className="h-9 w-full"
preload="none"
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">
Upload pending
</div>
)}
<div className="flex items-center gap-2">
{r.download_url && (
<a
href={r.download_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-[9px] border border-hairline px-2.5 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40"
>
<Download className="size-3.5" /> Download
</a>
)}
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => onDelete(r.id)}
disabled={del.isPending}
>
<Trash2 className="size-3.5" /> Delete
</Button>
</div>
</GlassCard>
))}
<div className="flex items-center gap-2">
{r.download_url && (
<a
href={r.download_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-[9px] border border-hairline px-2.5 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40"
>
<Download className="size-3.5" /> Download
</a>
)}
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => onDelete(r.id)}
disabled={del.isPending}
>
<Trash2 className="size-3.5" /> Delete
</Button>
</div>
</GlassCard>
);
})}
</div>
)}
</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" };
}
+13
View File
@@ -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";
}
+35 -1
View File
@@ -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[] {
if (!value) return [];