SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
15.6 KB · 320 lines tsx
Raw Blame History
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