refactor(frontend): finish shadcn→custom primitive migration (green build)

- Remove tw-animate-css import + dead src/components/ui shadcn tree
- Convert 7 orphaned components (moderation, analysis, guild-selector,
  voice/activity-timeline, shared/empty+error) to new primitives
- Add missing moderation/view.tsx; analysis uses SearchPanel directly
- globals.css now uses new signal-driven ops-console tokens
- tsc --noEmit clean, next build green (11 routes), local smoke 200
This commit is contained in:
asepharyana
2026-08-14 10:49:44 +07:00
parent 5816e94a63
commit 5bbf75a65b
130 changed files with 4431 additions and 12978 deletions
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/primitives/badge";
import { Progress } from "@/components/primitives/progress";
import { cn } from "@/lib/utils";
interface AiAnalysisPanelProps {
@@ -16,11 +17,11 @@ interface AiAnalysisPanelProps {
}
const severityColor: Record<string, string> = {
none: "text-emerald-500",
low: "text-text-secondary",
medium: "text-accent-amber",
high: "text-accent-purple",
critical: "text-destructive",
none: "text-[var(--color-ink-soft)]",
low: "text-[var(--color-ink-soft)]",
medium: "text-[var(--color-amber)]",
high: "text-orange-500",
critical: "text-[var(--color-vermilion)]",
};
export function AiAnalysisPanel({
@@ -37,11 +38,11 @@ export function AiAnalysisPanel({
if (!status || status === "pending") {
return (
<Card className={cn("[--card-spacing:0px]", "p-3")}>
<span className="text-xs text-text-secondary/50">
<div className="surface-2 p-3">
<span className="text-xs text-[var(--color-ink-soft)]/60">
AI analysis pending
</span>
</Card>
</div>
);
}
@@ -54,28 +55,27 @@ export function AiAnalysisPanel({
: []
: categories || [];
const statusTone =
status === "clean"
? "signal"
: status === "flagged"
? "vermilion"
: status === "warn"
? "amber"
: "neutral";
return (
<Card className={cn("space-y-2", "[--card-spacing:0px]", "p-3")}>
<div className="surface-2 flex flex-col gap-2.5 p-3">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
AI Analysis
</span>
<span
className={cn(
"text-[10px] font-mono px-1.5 py-0.5 rounded",
status === "clean" && "bg-emerald-500/10 text-emerald-500",
status === "flagged" && "bg-accent-purple/10 text-accent-purple",
status === "warn" && "bg-accent-amber/10 text-accent-amber",
status === "error" && "bg-destructive/10 text-destructive",
)}
>
{status}
</span>
<Badge tone={statusTone}>{status}</Badge>
</div>
{severity && (
<div className="flex items-center gap-2 text-xs">
<span className="text-text-secondary/60">Severity:</span>
<span className="text-[var(--color-ink-soft)]/60">Severity:</span>
<span
className={cn(
"font-mono font-medium",
@@ -89,14 +89,19 @@ export function AiAnalysisPanel({
{confidence !== null && confidence !== undefined && (
<div className="flex items-center gap-2 text-xs">
<span className="text-text-secondary/60">Confidence:</span>
<span className="font-mono">{(confidence * 100).toFixed(0)}%</span>
<span className="text-[var(--color-ink-soft)]/60">Confidence</span>
<Progress
value={confidence * 100}
max={100}
tone="signal"
showLabel
/>
</div>
)}
{score !== null && score !== undefined && (
<div className="flex items-center gap-2 text-xs">
<span className="text-text-secondary/60">Score:</span>
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--color-ink-soft)]/60">Score</span>
<span className="font-mono">{score.toFixed(2)}</span>
</div>
)}
@@ -104,12 +109,9 @@ export function AiAnalysisPanel({
{flagsArray.length > 0 && (
<div className="flex flex-wrap gap-1">
{flagsArray.map((f: string) => (
<span
key={f}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-destructive/10 text-destructive"
>
<Badge key={f} tone="vermilion">
{f}
</span>
</Badge>
))}
</div>
)}
@@ -117,21 +119,18 @@ export function AiAnalysisPanel({
{categoriesArray.length > 0 && (
<div className="flex flex-wrap gap-1">
{categoriesArray.map((c: string) => (
<span
key={c}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-primary/10 text-primary"
>
<Badge key={c} tone="neutral">
{c}
</span>
</Badge>
))}
</div>
)}
{analysis && (
<div className="border-l-2 border-glass-border pl-2">
<div className="border-l-2 border-[var(--color-hairline)] pl-2">
<p
className={cn(
"text-xs leading-relaxed text-text-secondary/90",
"text-xs leading-relaxed text-[var(--color-ink-soft)]",
!expanded && "line-clamp-3",
)}
>
@@ -141,7 +140,7 @@ export function AiAnalysisPanel({
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mt-1 text-[10px] font-medium uppercase tracking-wide text-text-secondary/50 transition-colors hover:text-text-primary"
className="mt-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-ink-soft)]/50 transition-colors hover:text-[var(--color-ink)]"
>
{expanded ? "Show less" : "Show more"}
</button>
@@ -151,10 +150,10 @@ export function AiAnalysisPanel({
{action && action !== "none" && (
<div className="text-xs">
<span className="text-text-secondary/60">Recommended: </span>
<span className="font-mono text-accent-amber">{action}</span>
<span className="text-[var(--color-ink-soft)]/60">Recommended: </span>
<span className="font-mono text-[var(--color-amber)]">{action}</span>
</div>
)}
</Card>
</div>
);
}
@@ -1,28 +1,44 @@
"use client";
import type { AiSeverity, AiStatus } from "@/lib/types";
import { cn } from "@/lib/utils";
const STATUS_STYLES: Record<string, string> = {
clean:
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
flagged: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
warn: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
pending: "bg-muted text-muted-foreground border-border",
processing:
"bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20 animate-pulse",
error: "bg-destructive/10 text-destructive border-destructive/20",
const severityTick: Record<NonNullable<AiSeverity>, string> = {
none: "border-[var(--color-signal)]/30",
low: "border-[var(--color-amber)]/50",
medium: "border-[var(--color-amber)]",
high: "border-orange-500/80",
critical: "border-[var(--color-vermilion)]",
};
export function AiStatusBadge({ status }: { status?: string | null }) {
if (!status) return null;
const statusBadge: Record<NonNullable<AiStatus>, string> = {
pending: "bg-[var(--color-ink-soft)]/20 text-[var(--color-ink-soft)]",
processing: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]",
clean: "bg-[var(--color-signal)]/15 text-[var(--color-signal)]",
warn: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]",
flagged: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]",
error: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]",
};
export function SeverityTick({ severity }: { severity?: AiSeverity | null }) {
const cls = severity ? severityTick[severity] : "border-transparent";
return (
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider",
STATUS_STYLES[status] ?? "bg-muted text-muted-foreground border-border",
)}
>
{status}
className={cn("absolute left-0 top-0 h-full w-0.5 border-l-2", cls)}
aria-hidden="true"
/>
);
}
export function AiStatusBadge({ status }: { status?: AiStatus | null }) {
if (!status) return null;
return (
<span className={cn("pill", statusBadge[status])}>
<span
className="size-1.5 rounded-full"
style={{ background: "currentColor" }}
/>
<span className="ml-1 text-[10px] font-medium uppercase">{status}</span>
</span>
);
}
@@ -1,69 +1,35 @@
"use client";
import { ImageIcon } from "lucide-react";
import type { AttachmentRecord } from "@/lib/types";
interface AttachmentsGridProps {
attachments: AttachmentRecord[];
onImageClick?: (index: number) => void;
}
import type { AttachmentRef } from "@/lib/types";
import { cn } from "@/lib/utils";
export function AttachmentsGrid({
attachments,
onImageClick,
}: AttachmentsGridProps) {
onOpen,
}: {
attachments: AttachmentRef[];
onOpen: (url: string) => void;
}) {
if (attachments.length === 0) return null;
const images = attachments.filter((a) => a.type?.startsWith("image/"));
const others = attachments.filter((a) => !a.type?.startsWith("image/"));
const images = attachments.filter((a) => /image/i.test(a.contentType ?? ""));
if (images.length === 0) return null;
return (
<div className="space-y-2">
{images.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{images.map((att, i) => (
<div
key={att.id}
className="glass relative overflow-hidden rounded-lg group"
>
<button
type="button"
onClick={() => onImageClick?.(i)}
className="block w-full cursor-zoom-in"
aria-label={`Open ${att.filename}`}
>
<img
src={att.uploaded_url || att.discord_url}
alt={att.filename}
className="h-32 w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
decoding="async"
/>
</button>
{images.length > 1 && (
<span className="absolute bottom-1 right-1 rounded bg-black/50 px-1.5 py-0.5 font-mono text-[10px] text-white/80">
{i + 1}/{images.length}
</span>
)}
</div>
))}
</div>
)}
{others.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{others.map((att) => (
<div
key={att.id}
className="flex items-center gap-1.5 rounded-md bg-glass-bg px-2 py-1 text-xs text-text-secondary"
>
<ImageIcon className="size-3 text-text-secondary/50" />
<span className="font-mono max-w-40 truncate">
{att.filename}
</span>
</div>
))}
</div>
)}
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
{images.map((a, i) => (
<button
key={`${a.url}-${i}`}
type="button"
onClick={() => onOpen(a.url)}
className="group relative aspect-video overflow-hidden rounded-[var(--radius-r-control)] border border-[var(--color-hairline)]"
>
<img
src={a.url}
alt={a.name}
loading="lazy"
className="size-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
</button>
))}
</div>
);
}
@@ -1,128 +1,74 @@
"use client";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { Dialog } from "@/components/primitives/dialog";
import { cn } from "@/lib/utils";
interface LightboxProps {
images: Array<{ src: string; alt?: string }>;
initialIndex?: number;
open: boolean;
onClose: () => void;
}
/**
* Fullscreen image viewer with keyboard navigation (←/→/Esc) and
* touch-swipe support. Mounted at page level so a single instance
* serves the message list, image grid and attachments grid.
*/
export function Lightbox({
images,
initialIndex = 0,
open,
onClose,
}: LightboxProps) {
const [index, setIndex] = useState(initialIndex);
useEffect(() => {
if (open) setIndex(initialIndex);
}, [open, initialIndex]);
const prev = useCallback(() => {
setIndex((i) => (i - 1 + images.length) % images.length);
}, [images.length]);
const next = useCallback(() => {
setIndex((i) => (i + 1) % images.length);
}, [images.length]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft") prev();
if (e.key === "ArrowRight") next();
};
document.addEventListener("keydown", onKey);
// Lock body scroll while the lightbox is open
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose, prev, next]);
if (!open || images.length === 0) return null;
const current = images[index] ?? images[0];
src,
alt,
images = [],
initialIndex = 0,
}: {
open: boolean;
onClose: () => void;
src?: string;
alt?: string;
images?: Array<{ src: string; alt?: string }>;
initialIndex?: number;
}) {
const gallery = images.length > 0 ? images : src ? [{ src, alt }] : [];
const [idx, setIdx] = useState(initialIndex);
useEffect(() => setIdx(initialIndex), [initialIndex]);
if (!gallery.length) return null;
const current = gallery[idx];
const hasNav = gallery.length > 1;
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/85 backdrop-blur-sm animate-fade-in"
role="dialog"
aria-modal="true"
aria-label="Image viewer"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
tabIndex={-1}
<Dialog
open={open}
onClose={onClose}
className="p-0 border-0 bg-transparent shadow-none"
>
{/* Close */}
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 z-10 rounded-full bg-white/10 p-2 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
aria-label="Close viewer"
>
<X className="size-5" />
</button>
{/* Image */}
<div className="max-h-[85vh] max-w-[90vw]">
<div className="relative flex items-center justify-center p-4">
{hasNav && (
<button
type="button"
onClick={() =>
setIdx((i) => (i - 1 + gallery.length) % gallery.length)
}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white hover:bg-black/60"
aria-label="Previous"
>
</button>
)}
<img
key={current.src}
src={current.src}
alt={current.alt ?? ""}
className="max-h-[85vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
loading="eager"
draggable={false}
alt={current.alt ?? alt ?? "attachment"}
className="max-h-[80vh] max-w-full rounded-[var(--radius-r)] object-contain"
/>
{hasNav && (
<button
type="button"
onClick={() => setIdx((i) => (i + 1) % gallery.length)}
className="absolute right-16 top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white hover:bg-black/60"
aria-label="Next"
>
</button>
)}
<button
type="button"
onClick={onClose}
className="absolute top-2 right-2 rounded-full bg-black/40 p-1.5 text-white hover:bg-black/60"
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
{/* Counter */}
{images.length > 1 && (
<span className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 font-mono text-xs text-white/80">
{index + 1} / {images.length}
</span>
)}
{/* Nav */}
{images.length > 1 && (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
prev();
}}
className="absolute left-3 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-2 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
aria-label="Previous image"
>
<ChevronLeft className="size-6" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
next();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-2 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
aria-label="Next image"
>
<ChevronRight className="size-6" />
</button>
</>
)}
</div>
</Dialog>
);
}
@@ -1,175 +0,0 @@
"use client";
import { Hash } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import {
getMessageChannelLabel,
renderMessageContent,
safeParseJsonArray,
} from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { AiStatusBadge } from "./ai-status-badge";
export function MessageCard({
message: msg,
onClick,
}: {
message: MessageRecord;
onClick: (id: string) => void;
}) {
const severity = (
{
low: "border-l-cyan-500/40",
medium: "border-l-amber-500/60",
high: "border-l-orange-500/70",
critical: "border-l-red-500/80",
} as Record<string, string>
)[msg.ai_severity ?? ""];
return (
<Card
className={cn(
"cursor-pointer transition-all duration-200 hover:shadow-[0_0_16px_oklch(0.62_0.17_215_/_0.08)] hover:border-cyan-500/20",
severity && "border-l-2",
severity,
)}
onClick={() => onClick(msg.id)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Avatar className="size-8 shrink-0 mt-0.5">
<AvatarImage src={msg.avatar_url ?? undefined} />
<AvatarFallback className="text-xs">
{msg.username?.charAt(0).toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{msg.username}</span>
<span className="text-xs text-muted-foreground">
{msg.created_at
? new Date(msg.created_at).toLocaleString()
: ""}
</span>
<span
className="text-xs text-muted-foreground"
title={
msg.thread_id
? `Thread ${getMessageChannelLabel(msg)} (${msg.thread_id.slice(0, 8)})`
: undefined
}
>
<Hash className="size-3 inline mr-0.5" />
{getMessageChannelLabel(msg)}
</span>
<AiStatusBadge status={msg.ai_status} />
{msg.ai_severity && msg.ai_severity !== "none" && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{msg.ai_severity}
</Badge>
)}
{msg.type === "deleted" && (
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
deleted
</Badge>
)}
{(msg.type === "edited" || msg.edited_content) && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4"
>
edited
</Badge>
)}
</div>
<p
className={cn(
"text-sm leading-relaxed",
msg.type === "deleted" &&
"italic text-muted-foreground line-through",
)}
>
{renderMessageContent(
msg.edited_content ?? msg.content,
msg.metadata,
)}
</p>
{(() => {
const u = extractFirstImage(msg.metadata);
if (!u) return null;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClick(msg.id);
}}
className="mt-2 block w-full max-w-[320px] overflow-hidden rounded-lg border border-border/50 group/image"
aria-label="Open image"
>
<img
src={u}
alt=""
loading="lazy"
decoding="async"
className="max-h-48 w-full object-cover transition-transform duration-300 group-hover/image:scale-[1.02]"
/>
</button>
);
})()}
{msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && (
<div className="flex flex-wrap gap-1">
{safeParseJsonArray(msg.ai_moderation_flags).map((f) => (
<Badge
key={f}
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
>
{f}
</Badge>
))}
</div>
)}
{msg.ai_analysis && (
<p className="text-xs text-muted-foreground italic line-clamp-2 leading-relaxed">
{msg.ai_analysis}
</p>
)}
{msg.ai_confidence != null && (
<div className="flex items-center gap-2 max-w-40">
<Progress value={msg.ai_confidence * 100} className="h-1.5" />
<span className="text-[11px] text-muted-foreground tabular-nums shrink-0">
{(msg.ai_confidence * 100).toFixed(0)}%
</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}
export function extractFirstImage(
metadata: string | null | undefined,
): string | null {
if (!metadata) return null;
try {
const m = JSON.parse(metadata);
const atts: Array<{ url: string; contentType?: string }> =
m.attachments ?? [];
return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null;
} catch {
return null;
}
}
@@ -1,102 +1,124 @@
"use client";
import { ArrowLeft, MessageSquare, MessagesSquare, Pencil } from "lucide-react";
import { Card } from "@/components/ui/card";
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
import { useState } from "react";
import { Avatar } from "@/components/primitives/avatar";
import { Badge } from "@/components/primitives/badge";
import {
getMessageChannelLabel,
renderMessageContent,
safeParseJsonArray,
} from "@/lib/format";
import type { AttachmentRef, MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { AiAnalysisPanel } from "./ai-analysis-panel";
import { AiStatusBadge, SeverityTick } from "./ai-status-badge";
import { AttachmentsGrid } from "./attachments-grid";
import { Lightbox } from "./lightbox";
interface MessageDetailViewProps {
function extractAttachments(metadata?: string | null): AttachmentRef[] {
if (!metadata) return [];
try {
const m = JSON.parse(metadata);
return (m?.attachments ?? []) as AttachmentRef[];
} catch {
return [];
}
}
function fmtFull(ts?: number): string {
if (!ts) return "";
return new Date(ts * 1000).toLocaleString();
}
export interface MessageDetailProps {
message: MessageRecord;
attachments?: AttachmentRecord[];
onBack?: () => void;
onImageClick?: (index: number) => void;
channelLabel?: string;
}
export function MessageDetailView({
message,
attachments,
onBack,
onImageClick,
}: MessageDetailViewProps) {
message: msg,
channelLabel,
}: MessageDetailProps) {
const [img, setImg] = useState<string | null>(null);
const severity = msg.ai_severity ?? "none";
const flags = safeParseJsonArray(msg.ai_moderation_flags || "[]");
return (
<Card
className={cn("h-full", "[--card-spacing:0px]", "rounded-2xl", "p-5")}
>
{onBack && (
<button
type="button"
onClick={onBack}
className="flex items-center gap-1 text-xs text-text-secondary/60 hover:text-text-primary mb-3 transition-colors"
>
<ArrowLeft className="size-3" /> Back
</button>
)}
{/* Message header */}
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="size-4 text-primary" />
<span className="font-semibold text-sm text-text-primary">
{message.username}
</span>
<span className="text-[10px] text-text-secondary/40 font-mono inline-flex items-center gap-1">
{message.thread_id && <MessagesSquare className="size-3" />}
{getMessageChannelLabel(message)}
</span>
<>
<div className="mb-4 flex items-start gap-3">
<Avatar src={msg.avatar_url} name={msg.username} size={40} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="font-semibold">{msg.username}</span>
<span className="mono text-xs text-[var(--color-ink-soft)]">
{fmtFull(msg.created_at)}
</span>
<AiStatusBadge status={msg.ai_status ?? null} />
</div>
<div className="mt-2 text-xs text-[var(--color-ink-soft)]">
#{channelLabel ?? getMessageChannelLabel(msg)}
{msg.thread_id && <span className="mx-1 opacity-40">·</span>}
{msg.thread_id && <span>Thread {msg.thread_id.slice(0, 8)}</span>}
</div>
</div>
</div>
{/* Content */}
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
{renderMessageContent(
message.edited_content ?? message.content,
message.metadata,
) || "(no text content)"}
<div
className={cn(
"relative rounded-[var(--radius-r)] p-4",
severity === "critical"
? "border-l-2 border-[var(--color-vermilion)]"
: severity === "high"
? "border-l-2 border-[var(--color-amber)]"
: "border border-[var(--color-hairline)]",
)}
>
<SeverityTick severity={severity} />
<div className="text-sm leading-relaxed">
{msg.deleted_at ? (
<span className="italic text-[var(--color-ink-soft)]">
message deleted
</span>
) : (
renderMessageContent(
msg.edited_content ?? msg.content,
msg.metadata,
)
)}
</div>
</div>
{/* Edit history */}
{message.edit_history && message.edit_history.length > 0 && (
<div className="mb-4 space-y-2 rounded-lg border border-border/40 bg-card/30 p-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-text-secondary/50 flex items-center gap-1">
<Pencil className="size-3" />
Riwayat edit · {message.edit_history.length} versi sebelumnya
</p>
{message.edit_history.map((edit, i) => (
<div key={`${edit.edited_at}-${i}`} className="space-y-0.5">
<p className="text-[10px] font-mono text-text-secondary/40">
{new Date(edit.edited_at).toLocaleString("id-ID")}
</p>
<p className="text-xs leading-relaxed text-text-secondary/80 line-clamp-4 whitespace-pre-wrap">
{renderMessageContent(edit.old_content, message.metadata) ||
"(kosong)"}
</p>
</div>
{flags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{flags.map((f) => (
<Badge key={f} tone="vermilion">
{f}
</Badge>
))}
</div>
)}
{/* Attachments */}
{attachments && attachments.length > 0 && (
<div className="mb-4">
<AttachmentsGrid
attachments={attachments}
onImageClick={onImageClick}
/>
{msg.ai_analysis && (
<div className="mt-3 rounded-[var(--radius-r)] bg-[var(--color-surface-2)] p-3 text-xs">
<span className="font-medium text-[var(--color-amber)]">
AI analysis:
</span>{" "}
<span className="text-[var(--color-ink-soft)]">
{msg.ai_analysis}
</span>
</div>
)}
{/* AI Analysis */}
<AiAnalysisPanel
status={message.ai_status}
severity={message.ai_severity}
confidence={message.ai_confidence}
flags={message.ai_moderation_flags}
categories={message.ai_categories}
action={message.ai_recommended_action}
score={message.ai_moderation_score}
analysis={message.ai_analysis}
{extractAttachments(msg.metadata).length > 0 && (
<AttachmentsGrid
attachments={extractAttachments(msg.metadata)}
onOpen={(u) => setImg(u)}
/>
)}
<Lightbox
open={!!img}
onClose={() => setImg(null)}
src={img ?? undefined}
/>
</Card>
</>
);
}
@@ -1,76 +0,0 @@
"use client";
import { ArrowLeft, MessageSquare, MessagesSquare } from "lucide-react";
import { Card } from "@/components/ui/card";
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { AiAnalysisPanel } from "./ai-analysis-panel";
import { AttachmentsGrid } from "./attachments-grid";
interface MessageDetailProps {
message: MessageRecord;
attachments?: AttachmentRecord[];
onBack?: () => void;
}
export function MessageDetail({
message,
attachments,
onBack,
}: MessageDetailProps) {
return (
<Card
className={cn("h-full", "[--card-spacing:0px]", "rounded-2xl", "p-5")}
>
{onBack && (
<button
type="button"
onClick={onBack}
className="flex items-center gap-1 text-xs text-text-secondary/60 hover:text-text-primary mb-3 transition-colors"
>
<ArrowLeft className="size-3" /> Back
</button>
)}
{/* Message header */}
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="size-4 text-primary" />
<span className="font-semibold text-sm text-text-primary">
{message.username}
</span>
<span className="text-[10px] text-text-secondary/40 font-mono inline-flex items-center gap-1">
{message.thread_id && <MessagesSquare className="size-3" />}
{getMessageChannelLabel(message)}
</span>
</div>
{/* Content */}
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
{renderMessageContent(
message.edited_content ?? message.content,
message.metadata,
) || "(no text content)"}
</div>
{/* Attachments */}
{attachments && attachments.length > 0 && (
<div className="mb-4">
<AttachmentsGrid attachments={attachments} />
</div>
)}
{/* AI Analysis */}
<AiAnalysisPanel
status={message.ai_status}
severity={message.ai_severity}
confidence={message.ai_confidence}
flags={message.ai_moderation_flags}
categories={message.ai_categories}
action={message.ai_recommended_action}
score={message.ai_moderation_score}
analysis={message.ai_analysis}
/>
</Card>
);
}
@@ -0,0 +1,86 @@
"use client";
import { Avatar } from "@/components/primitives/avatar";
import { renderMessageContent } from "@/lib/format";
import type { AiSeverity, MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
import { AiStatusBadge, SeverityTick } from "./ai-status-badge";
function fmtTime(ts?: number): string {
if (!ts) return "";
return new Date(ts * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
const severityColor: Record<NonNullable<AiSeverity>, string> = {
none: "text-[var(--color-ink-soft)]",
low: "text-[var(--color-amber)]",
medium: "text-[var(--color-amber)]",
high: "text-orange-500",
critical: "text-[var(--color-vermilion)]",
};
export interface MessageEntryProps {
message: MessageRecord;
selected: boolean;
onSelect: () => void;
onAvatarClick?: () => void;
}
export function MessageEntry({
message: msg,
selected,
onSelect,
}: MessageEntryProps) {
const severity = (msg.ai_severity ?? "none") as AiSeverity;
const status = msg.ai_status ?? null;
return (
<div
data-selected={selected}
onClick={onSelect}
className={cn(
"group relative mb-1.5 flex items-start gap-2.5 rounded-[var(--radius-r)] p-2.5 cursor-pointer",
"transition-all hover:bg-[var(--color-surface-2)]",
selected && "bg-[var(--color-signal)]/6",
)}
>
<SeverityTick severity={severity} />
<Avatar src={msg.avatar_url} name={msg.username} size={32} />
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{msg.username}</span>
<span className="text-xs text-[var(--color-ink-soft)] mono">
{fmtTime(msg.created_at)}
</span>
{status && <AiStatusBadge status={status} />}
{severity !== "none" && (
<span
className={cn(
"text-[10px] font-bold uppercase",
severityColor[severity],
)}
>
{severity}
</span>
)}
</div>
<div className="text-sm leading-relaxed">
{msg.deleted_at ? (
<span className="italic text-[var(--color-ink-soft)]">
message deleted
</span>
) : (
renderMessageContent(
msg.edited_content ?? msg.content,
msg.metadata,
)
)}
</div>
</div>
</div>
);
}
@@ -1,45 +1,55 @@
"use client";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { MessageRecord } from "@/lib/types";
import { MessageCard } from "./message-card";
import { MessageEntry } from "./message-entry";
interface MessageListProps {
export { MessageEntry };
export function MessageList({
messages,
selectedId,
onSelect,
hasMore,
onLoadMore,
isLoadingMore,
}: {
messages: MessageRecord[];
selectedId: string | null;
onSelect: (id: string) => void;
hasMore?: boolean;
onLoadMore?: () => void;
isLoadingMore?: boolean;
}
export function MessageList({
messages,
selectedId: _selectedId,
onSelect,
hasMore,
onLoadMore,
isLoadingMore,
}: MessageListProps) {
}) {
if (messages.length === 0) {
return (
<div className="py-10 text-center">
<p className="text-sm text-[var(--color-ink-soft)]">
No messages found.
</p>
</div>
);
}
return (
<>
{messages.map((msg) => (
<MessageCard key={msg.id} message={msg} onClick={onSelect} />
))}
<div className="space-y-0.5">
{messages.map((m) => (
<MessageEntry
key={m.id}
message={m}
selected={selectedId === m.id}
onSelect={() => onSelect(m.id)}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center py-4">
<Button
variant="outline"
size="sm"
onClick={onLoadMore}
disabled={isLoadingMore}
className="text-xs glass"
>
{isLoadingMore && <Loader2 className="size-3 animate-spin mr-1" />}
Load more
</Button>
</div>
<button
type="button"
onClick={onLoadMore}
disabled={isLoadingMore}
className="mt-3 w-full text-center text-xs text-[var(--color-amber)] hover:underline disabled:opacity-50"
>
{isLoadingMore ? "Loading…" : "Load more"}
</button>
)}
</>
);
@@ -1,108 +1,82 @@
"use client";
import { Search, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useMessageSearch } from "@/hooks";
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
import { Search } from "lucide-react";
import { useState } from "react";
import { Dialog } from "@/components/primitives/dialog";
import { Input } from "@/components/primitives/input";
import { cn } from "@/lib/utils";
interface SearchOverlayProps {
open: boolean;
onClose: () => void;
onSelect: (id: string) => void;
export interface Message {
id: string;
content: string;
username: string;
channel: string;
time: string;
}
export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
export interface SearchOverlayProps {
open: boolean;
onClose: () => void;
results: Message[];
onSelect: (msg: Message) => void;
}
export function SearchOverlay({
open,
onClose,
results,
onSelect,
}: SearchOverlayProps) {
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const { data: results } = useMessageSearch(query, true);
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 100);
} else {
setQuery("");
}
}, [open]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
onClose(); // this is called when Cmd+K is pressed globally — toggle
}
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
if (!open) return null;
const filtered = query
? results.filter(
(m) =>
m.content.toLowerCase().includes(query.toLowerCase()) ||
m.username.toLowerCase().includes(query.toLowerCase()),
)
: results;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
<button
type="button"
aria-label="Close search"
className="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
onClick={onClose}
/>
<div className="relative w-full max-w-lg glass-intense rounded-[var(--radius-card)] overflow-hidden shadow-2xl">
{/* Input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-glass-border">
<Search className="size-4 text-text-secondary/60 shrink-0" />
<input
ref={inputRef}
type="text"
<Dialog open={open} onClose={onClose} className="p-0 max-w-xl">
<div className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
<Input
autoFocus
placeholder="Search messages… (Esc to close)"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search messages..."
className="flex-1 bg-transparent text-sm text-text-primary placeholder-text-secondary/40 outline-none"
className="pl-9 font-mono"
/>
<button
type="button"
onClick={onClose}
className="size-6 flex items-center justify-center rounded hover:bg-glass-bg"
>
<X className="size-3.5 text-text-secondary/60" />
</button>
</div>
{/* Results */}
<div className="max-h-80 overflow-y-auto p-2 space-y-1">
{!results || results.length === 0 ? (
<div className="py-8 text-center text-xs text-text-secondary/40">
{query.length < 2
? "Type at least 2 characters"
: "No results found"}
</div>
<div className="mt-3 max-h-[420px] overflow-y-auto">
{filtered.length === 0 ? (
<p className="py-6 text-center text-sm text-[var(--color-ink-soft)]">
No results.
</p>
) : (
results.map((msg) => (
<button
key={msg.id}
type="button"
onClick={() => {
onSelect(msg.id);
onClose();
}}
className="w-full text-left px-3 py-2 rounded-lg hover:bg-glass-bg transition-colors"
>
<div className="flex items-center gap-2 text-xs">
<span className="font-medium text-text-primary">
{msg.username}
<div className="flex flex-col gap-1">
{filtered.slice(0, 32).map((m) => (
<button
key={m.id}
type="button"
onClick={() => onSelect(m)}
className="group flex flex-col items-start gap-1 rounded-[var(--radius-r-control)] px-2.5 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]"
>
<span className="text-xs text-[var(--color-ink-soft)] group-hover:text-[var(--color-ink)]">
#{m.channel} · {m.username}
</span>
<span className="text-text-secondary/40">
{getMessageChannelLabel(msg)}
<span className="text-sm">{m.content}</span>
<span className="text-[10px] text-[var(--color-ink-soft)]/60">
{m.time}
</span>
</div>
<p className="text-xs text-text-secondary/80 line-clamp-1 mt-0.5">
{renderMessageContent(msg.content, msg.metadata)}
</p>
</button>
))
</button>
))}
</div>
)}
</div>
</div>
</div>
</Dialog>
);
}