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 +
- - {formatBytes(r.size_bytes)} - -
- - {r.download_url ? ( - // eslint-disable-next-line jsx-a11y/media-has-caption -
)} ); } + +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 [];