"use client"; import { Plus, Trash2, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { COUNTRIES, EVENT_TYPES } from "@websensor/core/client"; import { EventRow } from "@/components/event-row"; import { LiveDot } from "@/components/live-feed"; import { Chip, Empty, Flag, Panel, SkeletonRows, type Tone } from "@/components/ui"; import { liveToEvent, type EventItem, type LiveEvent, type SearchResult, type Watchlist } from "@/lib/api"; import { CHANNEL_KEYS, GROUP_LABELS, hostOf, typeLabel } from "@/lib/format"; import { ownerFetch, publicFetch } from "@/lib/owner"; import { useLive } from "@/lib/use-live"; type Kind = "entity" | "source" | "keyword" | "category" | "url" | "event_type" | "country" | "group"; type Item = { kind: Kind; value: string }; const KIND_TONE: Record = { entity: "signal", source: "info", category: "ok", keyword: "default", url: "warn", event_type: "high", country: "default", group: "silent" }; const KIND_LABEL: Record = { entity: "entity", source: "source", keyword: "keyword", category: "category", url: "url", event_type: "type", country: "country", group: "group" }; function itemLabel(i: { kind: string; value: string }): string { switch (i.kind) { case "entity": return i.value.replace(/^(org|prd|ent)_/, ""); case "url": return i.value.length > 48 ? `${hostOf(i.value)}…${i.value.slice(-18)}` : i.value; case "event_type": return EVENT_TYPES[i.value]?.label ?? typeLabel(i.value); case "group": return GROUP_LABELS[i.value] ?? i.value; case "country": return COUNTRIES[i.value]?.name ?? i.value; default: return i.value; } } export function Watchlists() { const [lists, setLists] = useState(null); const [active, setActive] = useState(null); /** Keyed by watchlist id: switching lists shows a loading state without a synchronous reset. */ const [feed, setFeed] = useState<{ id: string; items: EventItem[] } | null>(null); const events = feed && feed.id === active ? feed.items : null; const [error, setError] = useState(null); const [name, setName] = useState(""); const [q, setQ] = useState(""); const [url, setUrl] = useState(""); const [results, setResults] = useState(null); const [typeSel, setTypeSel] = useState(""); const [countrySel, setCountrySel] = useState(""); const load = useCallback( () => ownerFetch<{ items: Watchlist[] }>("/api/v1/watchlists") .then((r) => { setLists(r.items); setActive((cur) => cur ?? r.items[0]?.id ?? null); }) .catch((e: Error) => { setError(e.message); setLists([]); }), [], ); useEffect(() => { void load(); }, [load]); useEffect(() => { if (!active) return; const id = active; let cancelled = false; ownerFetch<{ items: EventItem[] }>(`/api/v1/watchlists/${id}/events?limit=60`) .then((r) => { if (!cancelled) setFeed({ id, items: r.items }); }) .catch(() => { if (!cancelled) setFeed({ id, items: [] }); }); return () => { cancelled = true; }; }, [active, lists]); useEffect(() => { const t = setTimeout(() => { if (q.trim().length < 2) { setResults(null); return; } publicFetch(`/api/v1/search?q=${encodeURIComponent(q.trim())}&limit=8`).then(setResults).catch(() => setResults(null)); }, 250); return () => clearTimeout(t); }, [q]); const channels = useMemo(() => (active ? [`watchlist:${active}`] : []), [active]); const status = useLive(channels, (e: LiveEvent) => setFeed((prev) => (!prev || prev.id !== active || prev.items.some((x) => x.id === e.id) ? prev : { id: prev.id, items: [liveToEvent(e), ...prev.items].slice(0, 200) }))); const current = lists?.find((l) => l.id === active) ?? null; const create = async (): Promise => { try { const wl = await ownerFetch("/api/v1/watchlists", { method: "POST", body: JSON.stringify({ name: name.trim() || "My watchlist", items: [] }) }); setName(""); await load(); setActive(wl.id); } catch (e) { setError((e as Error).message); } }; const remove = async (id: string): Promise => { await ownerFetch(`/api/v1/watchlists/${id}`, { method: "DELETE" }).catch((e: Error) => setError(e.message)); setActive(null); await load(); }; const setItems = async (items: Item[]): Promise => { if (!current) return; try { await ownerFetch(`/api/v1/watchlists/${current.id}`, { method: "PUT", body: JSON.stringify({ items: items.map((i) => ({ kind: i.kind, value: i.value })) }) }); setError(null); } catch (e) { setError((e as Error).message); } await load(); }; const currentItems = (): Item[] => (current?.items ?? []).map((i) => ({ kind: i.kind as Kind, value: i.value })); const add = (it: Item): void => { if (!current) return; const items = currentItems(); if (!items.some((i) => i.kind === it.kind && i.value === it.value)) void setItems([...items, it]); setQ(""); }; const del = (it: { kind: string; value: string }): void => { if (!current) return; void setItems(currentItems().filter((i) => !(i.kind === it.kind && i.value === it.value))); }; const addUrl = (): void => { const v = url.trim(); if (!/^https?:\/\//i.test(v)) { setError("URL must start with http:// or https://"); return; } add({ kind: "url", value: v }); setUrl(""); }; const selectCls = "h-8 min-w-0 flex-1 rounded-md border border-line bg-panel px-2 text-[12.5px]"; const addBtn = "inline-flex h-8 shrink-0 items-center gap-1 rounded-md border border-line bg-panel-2 px-2 text-[12px] hover:border-line-strong disabled:opacity-50"; return (
{current ? current.name : "Events"} {active && }} dense> {!current ? ( {lists && lists.length === 0 ? "Create a watchlist to start following entities, sources, URLs, types, countries or groups." : "Select a watchlist."} ) : events === null ? ( ) : events.length ? ( events.map((e) => ) ) : ( {current.items.length === 0 ? "This watchlist is empty — add items on the left." : "No events match this watchlist yet — new ones will appear live."} )}
); }