TypeScript 55.4%
Python 43.2%
SQL 1.2%
1"use client";23import { Plus, Trash2, X } from "lucide-react";4import { useCallback, useEffect, useMemo, useState } from "react";5import { COUNTRIES, EVENT_TYPES } from "@websensor/core/client";6import { EventRow } from "@/components/event-row";7import { LiveDot } from "@/components/live-feed";8import { Chip, Empty, Flag, Panel, SkeletonRows, type Tone } from "@/components/ui";9import { liveToEvent, type EventItem, type LiveEvent, type SearchResult, type Watchlist } from "@/lib/api";10import { CHANNEL_KEYS, GROUP_LABELS, hostOf, typeLabel } from "@/lib/format";11import { ownerFetch, publicFetch } from "@/lib/owner";12import { useLive } from "@/lib/use-live";1314type Kind = "entity" | "source" | "keyword" | "category" | "url" | "event_type" | "country" | "group";15type Item = { kind: Kind; value: string };1617const KIND_TONE: Record<Kind, Tone> = { entity: "signal", source: "info", category: "ok", keyword: "default", url: "warn", event_type: "high", country: "default", group: "silent" };18const KIND_LABEL: Record<Kind, string> = { entity: "entity", source: "source", keyword: "keyword", category: "category", url: "url", event_type: "type", country: "country", group: "group" };1920function itemLabel(i: { kind: string; value: string }): string {21 switch (i.kind) {22 case "entity":23 return i.value.replace(/^(org|prd|ent)_/, "");24 case "url":25 return i.value.length > 48 ? `${hostOf(i.value)}…${i.value.slice(-18)}` : i.value;26 case "event_type":27 return EVENT_TYPES[i.value]?.label ?? typeLabel(i.value);28 case "group":29 return GROUP_LABELS[i.value] ?? i.value;30 case "country":31 return COUNTRIES[i.value]?.name ?? i.value;32 default:33 return i.value;34 }35}3637export function Watchlists() {38 const [lists, setLists] = useState<Watchlist[] | null>(null);39 const [active, setActive] = useState<string | null>(null);40 /** Keyed by watchlist id: switching lists shows a loading state without a synchronous reset. */41 const [feed, setFeed] = useState<{ id: string; items: EventItem[] } | null>(null);42 const events = feed && feed.id === active ? feed.items : null;43 const [error, setError] = useState<string | null>(null);44 const [name, setName] = useState("");45 const [q, setQ] = useState("");46 const [url, setUrl] = useState("");47 const [results, setResults] = useState<SearchResult | null>(null);48 const [typeSel, setTypeSel] = useState("");49 const [countrySel, setCountrySel] = useState("");5051 const load = useCallback(52 () =>53 ownerFetch<{ items: Watchlist[] }>("/api/v1/watchlists")54 .then((r) => {55 setLists(r.items);56 setActive((cur) => cur ?? r.items[0]?.id ?? null);57 })58 .catch((e: Error) => {59 setError(e.message);60 setLists([]);61 }),62 [],63 );64 useEffect(() => {65 void load();66 }, [load]);6768 useEffect(() => {69 if (!active) return;70 const id = active;71 let cancelled = false;72 ownerFetch<{ items: EventItem[] }>(`/api/v1/watchlists/${id}/events?limit=60`)73 .then((r) => {74 if (!cancelled) setFeed({ id, items: r.items });75 })76 .catch(() => {77 if (!cancelled) setFeed({ id, items: [] });78 });79 return () => {80 cancelled = true;81 };82 }, [active, lists]);8384 useEffect(() => {85 const t = setTimeout(() => {86 if (q.trim().length < 2) {87 setResults(null);88 return;89 }90 publicFetch<SearchResult>(`/api/v1/search?q=${encodeURIComponent(q.trim())}&limit=8`).then(setResults).catch(() => setResults(null));91 }, 250);92 return () => clearTimeout(t);93 }, [q]);9495 const channels = useMemo(() => (active ? [`watchlist:${active}`] : []), [active]);96 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) })));9798 const current = lists?.find((l) => l.id === active) ?? null;99100 const create = async (): Promise<void> => {101 try {102 const wl = await ownerFetch<Watchlist>("/api/v1/watchlists", { method: "POST", body: JSON.stringify({ name: name.trim() || "My watchlist", items: [] }) });103 setName("");104 await load();105 setActive(wl.id);106 } catch (e) {107 setError((e as Error).message);108 }109 };110 const remove = async (id: string): Promise<void> => {111 await ownerFetch(`/api/v1/watchlists/${id}`, { method: "DELETE" }).catch((e: Error) => setError(e.message));112 setActive(null);113 await load();114 };115 const setItems = async (items: Item[]): Promise<void> => {116 if (!current) return;117 try {118 await ownerFetch(`/api/v1/watchlists/${current.id}`, { method: "PUT", body: JSON.stringify({ items: items.map((i) => ({ kind: i.kind, value: i.value })) }) });119 setError(null);120 } catch (e) {121 setError((e as Error).message);122 }123 await load();124 };125 const currentItems = (): Item[] => (current?.items ?? []).map((i) => ({ kind: i.kind as Kind, value: i.value }));126 const add = (it: Item): void => {127 if (!current) return;128 const items = currentItems();129 if (!items.some((i) => i.kind === it.kind && i.value === it.value)) void setItems([...items, it]);130 setQ("");131 };132 const del = (it: { kind: string; value: string }): void => {133 if (!current) return;134 void setItems(currentItems().filter((i) => !(i.kind === it.kind && i.value === it.value)));135 };136 const addUrl = (): void => {137 const v = url.trim();138 if (!/^https?:\/\//i.test(v)) {139 setError("URL must start with http:// or https://");140 return;141 }142 add({ kind: "url", value: v });143 setUrl("");144 };145146 const selectCls = "h-8 min-w-0 flex-1 rounded-md border border-line bg-panel px-2 text-[12.5px]";147 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";148149 return (150 <div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">151 <aside className="flex min-w-0 flex-col gap-4">152 <Panel title="Your watchlists" dense>153 {lists === null ? (154 <SkeletonRows rows={2} />155 ) : (156 <ul className="divide-y divide-line">157 {lists.map((l) => (158 <li key={l.id} className={`flex items-center justify-between gap-2 px-3 py-1.5 text-[13px] ${l.id === active ? "bg-panel-2" : ""}`}>159 <button type="button" aria-pressed={l.id === active} onClick={() => setActive(l.id)} className="min-w-0 flex-1 truncate text-left hover:underline">160 {l.name} <span className="font-mono text-[11px] text-fg-subtle">· {l.items.length}</span>161 </button>162 <button type="button" aria-label={`Delete watchlist ${l.name}`} onClick={() => remove(l.id)} className="text-fg-subtle hover:text-danger"><Trash2 className="size-3.5" /></button>163 </li>164 ))}165 {lists.length === 0 && <Empty>No watchlist yet — create one below.</Empty>}166 </ul>167 )}168 <form169 className="flex gap-1 border-t border-line p-2"170 onSubmit={(e) => {171 e.preventDefault();172 void create();173 }}174 >175 <input value={name} onChange={(e) => setName(e.target.value)} placeholder="New watchlist name" aria-label="New watchlist name" className={selectCls} />176 <button type="submit" className={addBtn}><Plus className="size-3.5" /> Add</button>177 </form>178 {error && <p className="px-3 pb-2 text-[11px] text-danger">{error}</p>}179 </Panel>180181 {current && (182 <Panel title={<span>Items <span className="font-mono text-fg-subtle">{current.items.length}</span></span>}>183 <div className="mb-3 flex flex-wrap gap-1">184 {current.items.map((i) => (185 <Chip key={`${i.kind}:${i.value}`} tone={KIND_TONE[i.kind as Kind] ?? "default"} title={`${i.kind}: ${i.value}`}>186 <span className="font-mono text-[9.5px] uppercase tracking-wider opacity-70">{KIND_LABEL[i.kind as Kind] ?? i.kind}</span>187 {i.kind === "country" && <Flag code={i.value} />}188 <span className="max-w-[14rem] truncate">{itemLabel(i)}</span>189 <button type="button" aria-label={`Remove ${i.kind} ${i.value}`} onClick={() => del(i)} className="ml-0.5 hover:text-danger"><X className="size-3" /></button>190 </Chip>191 ))}192 {current.items.length === 0 && <span className="text-[12px] text-fg-subtle">Empty — add entities, sources, keywords, categories, URLs, event types, countries or groups.</span>}193 </div>194195 <div className="flex flex-col gap-3 text-[12.5px]">196 <div>197 <div className="label mb-1">Entity · source · keyword</div>198 <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search entities / sources, or type a keyword…" aria-label="Search entities, sources or keyword" className="h-8 w-full rounded-md border border-line bg-panel px-2 text-[12.5px]" />199 {q.trim().length >= 2 && (200 <div className="mt-1 max-h-64 overflow-auto rounded-md border border-line bg-panel text-[12.5px]">201 <button type="button" onClick={() => add({ kind: "keyword", value: q.trim().toLowerCase() })} className="block w-full px-2 py-1.5 text-left hover:bg-panel-2"><span className="label mr-1 !text-[9.5px]">keyword</span> “{q.trim()}”</button>202 {results?.entities.map((e) => (203 <button key={e.id} type="button" onClick={() => add({ kind: "entity", value: e.id })} className="block w-full truncate px-2 py-1.5 text-left hover:bg-panel-2"><span className="label mr-1 !text-[9.5px] !text-signal">entity</span> {e.name} <span className="text-fg-subtle">{e.type}</span></button>204 ))}205 {results?.sources.map((s) => (206 <button key={s.id} type="button" onClick={() => add({ kind: "source", value: s.id })} className="block w-full truncate px-2 py-1.5 text-left hover:bg-panel-2"><span className="label mr-1 !text-[9.5px] !text-info">source</span> {s.name} <span className="text-fg-subtle">{s.domain}</span></button>207 ))}208 {results && !results.entities.length && !results.sources.length && <div className="px-2 py-1.5 text-[11.5px] text-fg-subtle">No entity or source matches — add as a keyword.</div>}209 </div>210 )}211 </div>212213 <div>214 <div className="label mb-1">URL</div>215 <form216 className="flex gap-1"217 onSubmit={(e) => {218 e.preventDefault();219 addUrl();220 }}221 >222 <input type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com/pricing" aria-label="URL to watch" className={`${selectCls} font-mono text-[12px]`} />223 <button type="submit" disabled={!url.trim()} className={addBtn}><Plus className="size-3.5" /></button>224 </form>225 <p className="mt-1 text-[11px] text-fg-subtle">Matches events whose monitored URL equals this address. To watch a page WebSensor does not cover yet, create a <a href="/monitors" className="text-info hover:underline">custom monitor</a>.</p>226 </div>227228 <div>229 <div className="label mb-1">Event type</div>230 <div className="flex gap-1">231 <select value={typeSel} onChange={(e) => setTypeSel(e.target.value)} aria-label="Event type" className={selectCls}>232 <option value="">Choose an event type…</option>233 {Object.entries(EVENT_TYPES).map(([k, v]) => (234 <option key={k} value={k}>{v.label}</option>235 ))}236 </select>237 <button238 type="button"239 disabled={!typeSel}240 onClick={() => {241 if (typeSel) add({ kind: "event_type", value: typeSel });242 setTypeSel("");243 }}244 className={addBtn}245 >246 <Plus className="size-3.5" />247 </button>248 </div>249 </div>250251 <div>252 <div className="label mb-1">Country</div>253 <div className="flex gap-1">254 <select value={countrySel} onChange={(e) => setCountrySel(e.target.value)} aria-label="Country" className={selectCls}>255 <option value="">Choose a country…</option>256 {Object.entries(COUNTRIES).map(([code, c]) => (257 <option key={code} value={code}>{c.flag} {c.name} ({code})</option>258 ))}259 </select>260 <button261 type="button"262 disabled={!countrySel}263 onClick={() => {264 if (countrySel) add({ kind: "country", value: countrySel });265 setCountrySel("");266 }}267 className={addBtn}268 >269 <Plus className="size-3.5" />270 </button>271 </div>272 </div>273274 <div>275 <div className="label mb-1">Group</div>276 <div className="flex flex-wrap gap-1">277 {Object.entries(GROUP_LABELS).map(([k, v]) => {278 const on = current.items.some((i) => i.kind === "group" && i.value === k);279 return (280 <button key={k} type="button" aria-pressed={on} disabled={on} onClick={() => add({ kind: "group", value: k })} className={`rounded-sm border px-1.5 py-px text-[10.5px] leading-4 ${on ? "border-silent/40 bg-silent-soft text-silent" : "border-line text-fg-muted hover:text-fg"}`}>281 {on ? "" : "+ "}{v}282 </button>283 );284 })}285 </div>286 </div>287288 <div>289 <div className="label mb-1">Category</div>290 <div className="flex flex-wrap gap-1">291 {CHANNEL_KEYS.map((c) => {292 const on = current.items.some((i) => i.kind === "category" && i.value === c);293 return (294 <button key={c} type="button" aria-pressed={on} disabled={on} onClick={() => add({ kind: "category", value: c })} className={`rounded-sm border px-1.5 py-px text-[10.5px] leading-4 ${on ? "border-ok/40 bg-ok/10 text-ok" : "border-line text-fg-muted hover:text-fg"}`}>295 {on ? "" : "+ "}{c}296 </button>297 );298 })}299 </div>300 </div>301 </div>302 </Panel>303 )}304 </aside>305306 <Panel title={<span className="flex items-center gap-3">{current ? current.name : "Events"} {active && <LiveDot status={status} />}</span>} dense>307 {!current ? (308 <Empty>{lists && lists.length === 0 ? "Create a watchlist to start following entities, sources, URLs, types, countries or groups." : "Select a watchlist."}</Empty>309 ) : events === null ? (310 <SkeletonRows rows={6} />311 ) : events.length ? (312 events.map((e) => <EventRow key={e.id} ev={e} showDate />)313 ) : (314 <Empty>{current.items.length === 0 ? "This watchlist is empty — add items on the left." : "No events match this watchlist yet — new ones will appear live."}</Empty>315 )}316 </Panel>317 </div>318 );319}320