feat(frontend): revamp channels into signal-map roster with GSAP stagger

This commit is contained in:
asepharyana
2026-08-25 23:17:39 +07:00
parent cab451f0e5
commit 2aa12b9ec5
4 changed files with 195 additions and 74 deletions
@@ -12,7 +12,23 @@ export function ChannelsView({
}) {
const { data: cultures } = useChannelCultures(100, initialCultures);
return (
<div className="space-y-5">
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-3">
<div className="relative flex size-3 items-center justify-center">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-signal opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-signal" />
</div>
<h1 className="font-mono text-xs font-semibold tracking-widest text-ink uppercase">
CHANNEL ROSTER · SIGNAL_MAP
</h1>
</div>
<div className="inline-flex items-center gap-1.5 rounded-sm bg-surface px-2 py-0.5 font-mono text-[11px] text-ink-soft">
<span className="text-ink-faint">NODES:</span>
<span className="font-bold text-signal">{cultures?.length ?? 0}</span>
</div>
</div>
{cultures ? (
<ChannelCultureGlossary cultures={cultures} />
) : (
@@ -1,21 +1,60 @@
"use client";
import { Radio, Signal } from "lucide-react";
import { useMemo } from "react";
import { GlassPanel } from "@/components/primitives";
import { SectionHeader } from "@/components/shared";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { downloadCsv } from "@/lib/csv";
import { formatRelativeTime } from "@/lib/format";
import type { ChannelCultureRow } from "@/lib/types";
/**
* Deterministic pseudo signal-strength (0..1) derived from recency + summary
* richness — purely cosmetic telemetry framing, no backend field for this.
*/
function deriveSignalStrength(c: ChannelCultureRow): number {
let score = 0.25;
if (c.culture_summary) {
score += Math.min(0.45, c.culture_summary.length / 400);
}
if (c.last_analyzed_at) {
const ageMs = Date.now() - c.last_analyzed_at;
const days = ageMs / (1000 * 60 * 60 * 24);
score += Math.max(0, 0.3 - days * 0.02);
}
return Math.max(0.08, Math.min(1, score));
}
export function ChannelCultureGlossary({
cultures,
}: {
cultures: ChannelCultureRow[];
}) {
const ranked = useMemo(
() =>
cultures
.map((c) => ({ c, signal: deriveSignalStrength(c) }))
.sort((a, b) => b.signal - a.signal),
[cultures],
);
const hudRef = useStaggerReveal<HTMLDivElement>(".channel-node", {
stagger: 0.045,
y: 14,
dependencies: [cultures],
});
return (
<GlassPanel className="lg:col-span-3">
<SectionHeader
eyebrow="culture"
title="Channel Culture Glossary"
eyebrow="knowledge · roster"
title={
<span className="flex items-center gap-2">
<Radio className="size-4 text-signal" />
Channel Culture Glossary
</span>
}
action={
cultures.length > 0 ? (
<button
@@ -30,38 +69,55 @@ export function ChannelCultureGlossary({
})),
)
}
className="flex items-center gap-1.5 text-xs text-ink-soft hover:text-ink"
className="flex items-center gap-1.5 font-mono text-[11px] text-ink-soft hover:text-ink"
>
CSV
EXPORT_CSV
</button>
) : null
}
/>
{cultures.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-faint">
No channel cultures captured yet.
<p className="py-6 text-center font-mono text-xs text-ink-faint">
NO CHANNEL TELEMETRY CAPTURED YET
</p>
) : (
<div className="space-y-3">
{cultures.map((c) => (
<div key={c.channel_id} className="text-sm">
<div className="flex items-baseline justify-between">
<span className="font-medium text-ink">
{c.channel_name ?? c.channel_id}
<div ref={hudRef} className="space-y-2">
{ranked.map(({ c, signal }) => (
<div
key={c.channel_id}
className="channel-node group relative overflow-hidden rounded-md border border-hairline bg-surface/40 p-3 backdrop-blur-md transition-colors hover:border-signal/40"
>
<div className="flex items-baseline justify-between gap-3">
<span className="truncate font-mono text-xs font-semibold text-ink">
#{c.channel_name ?? c.channel_id.slice(0, 8)}
</span>
{c.last_analyzed_at && (
<span className="text-xs text-ink-faint">
{formatRelativeTime(c.last_analyzed_at)}
</span>
)}
<div className="flex items-center gap-2 shrink-0">
<Signal className="size-3 text-ink-faint" />
{c.last_analyzed_at && (
<span className="font-mono text-[10px] text-ink-faint">
{formatRelativeTime(c.last_analyzed_at)}
</span>
)}
</div>
</div>
{c.culture_summary ? (
<p className="mt-1 text-ink-faint">{c.culture_summary}</p>
<p className="mt-1.5 line-clamp-2 text-[13px] text-ink-soft">
{c.culture_summary}
</p>
) : (
<span className="text-xs text-ink-faint">
<span className="mt-1.5 block font-mono text-[11px] text-ink-faint">
(no summary captured)
</span>
)}
{/* Signal strength readout bar */}
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-canvas-2">
<div
className="h-full rounded-full bg-signal transition-[width] duration-700 ease-out"
style={{ width: `${Math.round(signal * 100)}%` }}
/>
</div>
</div>
))}
</div>