2026-08-15 17:53:48 +07:00
|
|
|
"use client";
|
|
|
|
|
|
2026-08-24 16:32:16 +07:00
|
|
|
import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
|
|
|
|
|
import { useEffect, useState } from "react";
|
2026-08-15 17:53:48 +07:00
|
|
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
2026-08-24 16:32:16 +07:00
|
|
|
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
|
|
|
|
|
import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
|
|
|
|
|
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
|
2026-08-18 09:05:12 +07:00
|
|
|
import { aiTone } from "@/lib/ai-status";
|
|
|
|
|
import {
|
|
|
|
|
formatDuration,
|
|
|
|
|
getMessageChannelLabel,
|
|
|
|
|
renderMessageContent,
|
|
|
|
|
} from "@/lib/format";
|
2026-08-16 00:11:00 +07:00
|
|
|
|
2026-08-15 17:53:48 +07:00
|
|
|
export function AnalysisView() {
|
|
|
|
|
const [query, setQuery] = useState("");
|
|
|
|
|
const search = useMessageSearch(query, query.trim().length >= 2);
|
2026-08-24 16:32:16 +07:00
|
|
|
const { data: reactors } = useTopReactors();
|
2026-08-15 17:53:48 +07:00
|
|
|
const { data: channels } = useChannels();
|
|
|
|
|
const ambient = useAmbient();
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-08-15 20:03:55 +07:00
|
|
|
ambient.set(
|
|
|
|
|
query ? "amber" : "signal",
|
|
|
|
|
0.3,
|
|
|
|
|
query ? "analyzing" : "search",
|
|
|
|
|
);
|
2026-08-15 17:53:48 +07:00
|
|
|
}, [query, ambient]);
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-24 16:32:16 +07:00
|
|
|
<div className="space-y-5">
|
|
|
|
|
<GlassPanel glow className="relative overflow-hidden">
|
|
|
|
|
<div className="scan-line absolute inset-x-0 top-0" />
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Sparkles className="size-5 text-signal" />
|
|
|
|
|
<div>
|
|
|
|
|
<div className="eyebrow">Semantic search</div>
|
|
|
|
|
<h2 className="display text-balance text-2xl text-ink">
|
|
|
|
|
Search the archive
|
|
|
|
|
</h2>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="relative mt-4">
|
|
|
|
|
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-ink-faint" />
|
|
|
|
|
<Input
|
|
|
|
|
className="h-12 pl-12 text-base"
|
|
|
|
|
placeholder="Find messages, patterns, flags…"
|
2026-08-15 17:53:48 +07:00
|
|
|
value={query}
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-08-24 16:32:16 +07:00
|
|
|
{query.trim().length > 0 && query.trim().length < 2 && (
|
|
|
|
|
<div className="mono mt-2 text-xs text-ink-faint">
|
|
|
|
|
Type at least 2 characters…
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</GlassPanel>
|
2026-08-15 17:53:48 +07:00
|
|
|
|
2026-08-24 16:32:16 +07:00
|
|
|
<div className="grid gap-5 lg:grid-cols-5">
|
|
|
|
|
<GlassPanel className="lg:col-span-3">
|
|
|
|
|
<SectionHeader
|
|
|
|
|
eyebrow="results"
|
|
|
|
|
title="Matches"
|
|
|
|
|
action={
|
|
|
|
|
<span className="mono text-xs text-ink-faint">
|
|
|
|
|
{(search.data ?? []).length}
|
|
|
|
|
</span>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
{query.trim().length >= 2 && search.isLoading && (
|
|
|
|
|
<SkeletonRows rows={6} />
|
|
|
|
|
)}
|
|
|
|
|
{(search.data ?? []).length === 0 ? (
|
2026-08-15 20:03:55 +07:00
|
|
|
<EmptyState
|
2026-08-24 16:32:16 +07:00
|
|
|
icon={<Search className="size-7" />}
|
2026-08-15 20:03:55 +07:00
|
|
|
title="No matches yet"
|
|
|
|
|
description="Run a search to surface messages across the guild."
|
|
|
|
|
/>
|
2026-08-15 17:53:48 +07:00
|
|
|
) : (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
{(search.data ?? []).map((m) => (
|
2026-08-15 20:03:55 +07:00
|
|
|
<div
|
|
|
|
|
key={m.id}
|
2026-08-24 16:32:16 +07:00
|
|
|
className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3"
|
2026-08-15 20:03:55 +07:00
|
|
|
>
|
2026-08-24 16:32:16 +07:00
|
|
|
<Avatar src={m.avatar_url} name={m.username} size={32} />
|
2026-08-15 17:53:48 +07:00
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-08-24 16:32:16 +07:00
|
|
|
<span className="text-sm font-semibold text-ink">
|
2026-08-15 20:03:55 +07:00
|
|
|
{m.username}
|
|
|
|
|
</span>
|
2026-08-24 16:32:16 +07:00
|
|
|
<span className="mono text-[0.65rem] text-ink-faint">
|
2026-08-15 20:03:55 +07:00
|
|
|
{getMessageChannelLabel(m)}
|
|
|
|
|
</span>
|
2026-08-24 16:32:16 +07:00
|
|
|
{m.ai_status && (
|
2026-08-15 20:03:55 +07:00
|
|
|
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
2026-08-16 00:11:00 +07:00
|
|
|
{m.ai_analysis_duration_ms &&
|
|
|
|
|
m.ai_analysis_duration_ms > 0
|
2026-08-18 09:05:12 +07:00
|
|
|
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
|
2026-08-16 00:11:00 +07:00
|
|
|
: m.ai_status}
|
2026-08-15 20:03:55 +07:00
|
|
|
</Badge>
|
2026-08-24 16:32:16 +07:00
|
|
|
)}
|
2026-08-15 20:03:55 +07:00
|
|
|
</div>
|
2026-08-24 16:32:16 +07:00
|
|
|
<div className="mt-0.5 text-sm text-ink-soft">
|
2026-08-15 20:03:55 +07:00
|
|
|
{renderMessageContent(m.content, m.metadata) || "(embed)"}
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-08-24 16:32:16 +07:00
|
|
|
</GlassPanel>
|
2026-08-24 15:29:21 +07:00
|
|
|
|
2026-08-24 16:32:16 +07:00
|
|
|
<div className="space-y-5 lg:col-span-2">
|
|
|
|
|
<GlassPanel>
|
|
|
|
|
<SectionHeader
|
|
|
|
|
eyebrow="culture"
|
|
|
|
|
title={
|
|
|
|
|
<span className="flex items-center gap-2">
|
|
|
|
|
<TrendingUp className="size-4 text-signal" /> Top reactors
|
|
|
|
|
</span>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{(reactors ?? []).slice(0, 6).map((r, i) => {
|
|
|
|
|
const maxNet = reactors?.[0]?.net_count || 1;
|
|
|
|
|
const pct = Math.max(
|
|
|
|
|
4,
|
|
|
|
|
Math.round((r.net_count / maxNet) * 100),
|
|
|
|
|
);
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={r.user_id}
|
|
|
|
|
className="flex items-center gap-3 text-sm"
|
|
|
|
|
>
|
|
|
|
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
|
|
|
|
<span className="w-28 shrink-0 truncate text-ink-soft sm:w-40">
|
|
|
|
|
{r.username}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
|
|
|
|
|
<div
|
|
|
|
|
className="h-full rounded-full bg-signal/70"
|
|
|
|
|
style={{ width: `${pct}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="mono w-12 text-right text-xs text-signal">
|
|
|
|
|
+{r.net_count}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{(reactors ?? []).length === 0 && (
|
|
|
|
|
<div className="py-4 text-center text-xs text-ink-faint">
|
|
|
|
|
No data
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</GlassPanel>
|
|
|
|
|
|
|
|
|
|
<GlassPanel>
|
|
|
|
|
<SectionHeader
|
|
|
|
|
eyebrow="channels"
|
|
|
|
|
title={
|
|
|
|
|
<span className="flex items-center gap-2">
|
|
|
|
|
<Hash className="size-4 text-signal" /> Top channels
|
|
|
|
|
</span>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{(channels ?? []).slice(0, 6).map((c) => (
|
|
|
|
|
<div
|
|
|
|
|
key={c.channel_id}
|
|
|
|
|
className="flex items-center gap-3 text-sm"
|
|
|
|
|
>
|
|
|
|
|
<span className="flex-1 truncate text-ink-soft">
|
|
|
|
|
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="mono text-xs text-ink-faint">
|
|
|
|
|
{c.total_messages}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
{(channels ?? []).length === 0 && (
|
|
|
|
|
<div className="py-4 text-center text-xs text-ink-faint">
|
|
|
|
|
No data
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</GlassPanel>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-08-15 17:53:48 +07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|