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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user