diff --git a/services/frontend/src/app/(dashboard)/analysis/view.tsx b/services/frontend/src/app/(dashboard)/analysis/view.tsx
index 7e3f6a8..8cad2c8 100644
--- a/services/frontend/src/app/(dashboard)/analysis/view.tsx
+++ b/services/frontend/src/app/(dashboard)/analysis/view.tsx
@@ -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() {
{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}
)}
@@ -135,18 +124,33 @@ export function AnalysisView() {
}
/>
- {(reactors ?? []).slice(0, 6).map((r, i) => (
-
- {i + 1}
- {r.username}
-
- +{r.net_count}
-
-
- ))}
+ {(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 (
+
+
{i + 1}
+
+ {r.username}
+
+
+
+ +{r.net_count}
+
+
+ );
+ })}
{(reactors ?? []).length === 0 && (
No data
diff --git a/services/frontend/src/app/(dashboard)/dashboard/view.tsx b/services/frontend/src/app/(dashboard)/dashboard/view.tsx
index 7301a93..ffeb9d8 100644
--- a/services/frontend/src/app/(dashboard)/dashboard/view.tsx
+++ b/services/frontend/src/app/(dashboard)/dashboard/view.tsx
@@ -231,17 +231,27 @@ export function DashboardView({
- {(reactors ?? []).slice(0, 6).map((r, i) => (
-
- {i + 1}
-
- {r.username}
-
-
- +{formatNumber(r.net_count)}
-
-
- ))}
+ {(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 (
+
+
{i + 1}
+
+ {r.username}
+
+
+
+ +{formatNumber(r.net_count)}
+
+
+ );
+ })}
{(reactors ?? []).length === 0 &&
}
diff --git a/services/frontend/src/app/(dashboard)/media/view.tsx b/services/frontend/src/app/(dashboard)/media/view.tsx
index d4269e0..5713174 100644
--- a/services/frontend/src/app/(dashboard)/media/view.tsx
+++ b/services/frontend/src/app/(dashboard)/media/view.tsx
@@ -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 }) {
{current?.title ?? "Nothing queued"}
- {current?.source && (
-
- {current.source}
-
- )}
+
+ {current?.source && (
+ {current.source}
+ )}
+ {current?.mode && (
+ {current.mode}
+ )}
+ {formatDuration(current?.durationMs) && (
+
+ {formatDuration(current?.durationMs)}
+
+ )}
+
-
-
setUrl(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && onPlay()}
- />
+
+
+ setUrl(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && onPlay()}
+ />
+
+
+
+
+
+ {Math.round((media?.musicVolume ?? 0) * 100)}%
+
+
@@ -146,7 +176,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
title="Queue"
action={
- {queueList.length} tracks
+ {queueList.length} track{queueList.length === 1 ? "" : "s"}
+ {queueTotal && ` · ${formatDuration(queueTotal)}`}
}
/>
@@ -172,7 +203,12 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
{item.source}
-
{item.mode ?? "music"}
+
{item.mode ?? "music"}
+ {formatDuration(item.durationMs) && (
+
+ {formatDuration(item.durationMs)}
+
+ )}
))}
diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx
index ecf374d..637f530 100644
--- a/services/frontend/src/app/(dashboard)/messages/view.tsx
+++ b/services/frontend/src/app/(dashboard)/messages/view.tsx
@@ -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)}
- {relTime(m.created_at)}
+ {formatRelativeTime(m.created_at)}
@@ -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({
{m.username}
- {getMessageChannelLabel(m)} · {relTime(m.created_at)}
+ {getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
diff --git a/services/frontend/src/app/(dashboard)/moderation/view.tsx b/services/frontend/src/app/(dashboard)/moderation/view.tsx
index c53614c..b93796e 100644
--- a/services/frontend/src/app/(dashboard)/moderation/view.tsx
+++ b/services/frontend/src/app/(dashboard)/moderation/view.tsx
@@ -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 }) {
{a.status}
- {a.created_at ? new Date(a.created_at).toLocaleString() : "—"}
+ {formatRelativeTime(a.created_at)}
{a.reason && (
“{a.reason}”
)}
+ {a.executed_by && (
+
+ by {a.executed_by}
+ {a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""}
+
+ )}
{a.content && (
{a.content}
diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx
index 76b3ebc..0e48061 100644
--- a/services/frontend/src/app/(dashboard)/recordings/view.tsx
+++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx
@@ -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({
/>
) : (
- {(items ?? []).map((r) => (
-
-
-
-
-
- {r.username}
+ {(items ?? []).map((r) => {
+ const up = uploadStatus(r);
+ return (
+
+
+
+
+
+ {r.username}
+
+
+
+
+ {r.channel_name ?? "voice"}
+
+ ·
+
+ {formatRelativeTime(r.created_at)}
+
+
-
- {r.channel_name ?? "voice"} ·{" "}
- {new Date(r.created_at).toLocaleString()}
+ {up && {up.label} }
+
+ {formatBytes(r.size_bytes)}
+
+
+
+ {r.download_url ? (
+ // eslint-disable-next-line jsx-a11y/media-has-caption
+
+ ) : (
+
+
+ {r.upload_status === "pending"
+ ? "Upload pending…"
+ : r.upload_error
+ ? r.upload_error
+ : "Processing…"}
-
-
- {formatBytes(r.size_bytes)}
-
-
-
- {r.download_url ? (
- // eslint-disable-next-line jsx-a11y/media-has-caption
-
- ) : (
-
- Upload pending…
-
- )}
-
-
- {r.download_url && (
-
- Download
-
)}
-
onDelete(r.id)}
- disabled={del.isPending}
- >
- Delete
-
-
-
- ))}
+
+
+ {r.download_url && (
+
+ Download
+
+ )}
+
onDelete(r.id)}
+ disabled={del.isPending}
+ >
+ Delete
+
+
+
+ );
+ })}
)}
);
}
+
+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" };
+}
diff --git a/services/frontend/src/lib/ai-status.ts b/services/frontend/src/lib/ai-status.ts
new file mode 100644
index 0000000..72a9c74
--- /dev/null
+++ b/services/frontend/src/lib/ai-status.ts
@@ -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";
+}
diff --git a/services/frontend/src/lib/format.ts b/services/frontend/src/lib/format.ts
index b26cb30..f845171 100644
--- a/services/frontend/src/lib/format.ts
+++ b/services/frontend/src/lib/format.ts
@@ -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 [];