SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
11.8 KB · 236 lines tsx
Raw Blame History
1'use client';2import { Bell, Trash2 } from 'lucide-react';3import Link from 'next/link';4import { useCallback, useEffect, useState } from 'react';5import { EventList } from '@/components/events/event-row';6import { EventTypeBadge } from '@/components/ui/badges';7import { LiveAgo } from '@/components/ui/live';8import { Empty, Note, Section } from '@/components/ui/section';9import { SkeletonRows } from '@/components/ui/skeleton';10import { ownerApi } from '@/lib/client-api';11import { EVENT_TYPES } from '@/lib/event-styles';12import { readWatched, setOwnerToken, useOwnerToken, writeWatched } from '@/lib/owner';13import { routes } from '@/lib/site';14import type { Alert, AlertDelivery, WatchlistPayload } from '@/lib/types';15import { CompanyTable } from './company-table';1617/** /watchlist: watched companies with their latest events, alert rules and recent deliveries — all keyed by the owner token. */18export function WatchlistClient() {19  const token = useOwnerToken(true);20  const [data, setData] = useState<WatchlistPayload | null>(null);21  const [alerts, setAlerts] = useState<Alert[]>([]);22  const [deliveries, setDeliveries] = useState<AlertDelivery[]>([]);23  const [error, setError] = useState<string | null>(null);24  const [loading, setLoading] = useState(true);25  const [showToken, setShowToken] = useState(false);26  const [importDraft, setImportDraft] = useState('');27  const [form, setForm] = useState<{ name: string; company: string; event_types: string[]; min_importance: string; channel: 'web' | 'webhook'; target: string }>({ name: '', company: '', event_types: [], min_importance: '', channel: 'web', target: '' });2829  const load = useCallback(async () => {30    if (!token) return;31    const api = ownerApi(token);32    setLoading(true);33    try {34      const [w, a, d] = await Promise.all([api.watchlist(), api.alerts().catch(() => ({ items: [] })), api.deliveries(30).catch(() => ({ items: [] }))]);35      setData(w);36      setAlerts(a.items ?? []);37      setDeliveries(d.items ?? []);38      setError(null);39      // reconcile the local mirror with the server truth40      writeWatched(w.items.map((c) => c.slug));41    } catch (e) {42      setError((e as Error).message);43      // fall back to the local mirror for names only44      if (!data) setData({ items: [], events: [] });45    } finally {46      setLoading(false);47    }48    // eslint-disable-next-line react-hooks/exhaustive-deps49  }, [token]);50  useEffect(() => {51    load();52  }, [load]);5354  const remove = async (slug: string) => {55    if (!token) return;56    writeWatched(readWatched().filter((s) => s !== slug));57    setData((d) => (d ? { ...d, items: d.items.filter((c) => c.slug !== slug) } : d));58    try {59      await ownerApi(token).unwatch(slug);60    } catch {61      /* reload will reconcile */62    }63  };64  const createAlert = async (e: React.FormEvent) => {65    e.preventDefault();66    if (!token || !form.name.trim()) return;67    try {68      const a = await ownerApi(token).createAlert({ name: form.name.trim(), company: form.company || undefined, condition: { event_types: form.event_types.length ? form.event_types : undefined, min_importance: form.min_importance ? Number(form.min_importance) : undefined }, channel: form.channel, target: form.channel === 'webhook' ? form.target : undefined });69      setAlerts((l) => [...l, a]);70      setForm({ name: '', company: '', event_types: [], min_importance: '', channel: 'web', target: '' });71    } catch (err) {72      setError((err as Error).message);73    }74  };75  const deleteAlert = async (id: string) => {76    if (!token) return;77    setAlerts((l) => l.filter((a) => a.id !== id));78    try {79      await ownerApi(token).deleteAlert(id);80    } catch {81      /* ignore */82    }83  };8485  const localOnly = readWatched();86  return (87    <div data-watchlist>88      {error && (89        <p className="mb-4 border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-warning" role="status">90          Watchlist service: {error}. Your locally saved list ({localOnly.length}) is kept and will sync when the API answers.91        </p>92      )}93      <Section eyebrow="Watched companies" title={data ? `${data.items.length} ${data.items.length === 1 ? 'company' : 'companies'}` : 'Loading…'} hairline={false} action={{ href: routes.companies(), label: 'Find companies' }}>94        {loading && !data ? (95          <SkeletonRows />96        ) : data && data.items.length ? (97          <>98            <CompanyTable items={data.items} />99            <ul className="mt-2 flex flex-wrap gap-1.5">100              {data.items.map((c) => (101                <li key={c.slug}>102                  <button type="button" onClick={() => remove(c.slug)} className="chip-btn" aria-label={`Stop watching ${c.display_name}`}>103                    <Trash2 className="size-3" aria-hidden /> {c.display_name}104                  </button>105                </li>106              ))}107            </ul>108            {data.items.length >= 2 && (109              <p className="mt-3 text-sm">110                <Link href={routes.compare(data.items.slice(0, 6).map((c) => c.slug))} className="link">111                  Compare watched companies →112                </Link>113              </p>114            )}115          </>116        ) : (117          <Empty title="Your watchlist is empty.">118            Use the <span className="font-medium text-ink-2">Watch</span> button on any company page. No account is needed — the list is tied to a token stored in this browser.119          </Empty>120        )}121      </Section>122123      <Section eyebrow="Latest events" title="Across watched companies">124        {data ? <EventList events={data.events} variant="table" emptyLabel="No events yet for the companies you watch." /> : <SkeletonRows />}125      </Section>126127      <Section eyebrow="Alerts" title="Rules" lede="Get notified in the web feed or by webhook when watched companies produce matching events. Rules are evaluated on the server against new structured events.">128        <form onSubmit={createAlert} className="grid gap-2 border border-rule p-3 md:grid-cols-[1.5fr_1fr_1fr_1fr_auto]" data-alert-form>129          <input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Rule name (required)" className="field" aria-label="Rule name" required />130          <select value={form.company} onChange={(e) => setForm({ ...form, company: e.target.value })} className="field" aria-label="Company">131            <option value="">Any watched company</option>132            {(data?.items ?? []).map((c) => (133              <option key={c.slug} value={c.slug}>134                {c.display_name}135              </option>136            ))}137          </select>138          <select value={form.min_importance} onChange={(e) => setForm({ ...form, min_importance: e.target.value })} className="field" aria-label="Minimum importance">139            <option value="">Any importance</option>140            <option value="0.5">Importance ≥ 50</option>141            <option value="0.7">Importance ≥ 70</option>142            <option value="0.85">Importance ≥ 85</option>143          </select>144          <select value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value as 'web' | 'webhook' })} className="field" aria-label="Channel">145            <option value="web">Web</option>146            <option value="webhook">Webhook</option>147          </select>148          <button type="submit" className="btn btn-primary">149            <Bell className="size-4" aria-hidden /> Add rule150          </button>151          {form.channel === 'webhook' && <input value={form.target} onChange={(e) => setForm({ ...form, target: e.target.value })} placeholder="https://your-endpoint.example/hook" className="field md:col-span-5" aria-label="Webhook URL" type="url" required />}152          <div className="flex flex-wrap gap-1 md:col-span-5">153            {EVENT_TYPES.map((t) => {154              const on = form.event_types.includes(t);155              return (156                <button key={t} type="button" onClick={() => setForm({ ...form, event_types: on ? form.event_types.filter((x) => x !== t) : [...form.event_types, t] })} className="chip-btn" data-on={on} aria-pressed={on}>157                  {t}158                </button>159              );160            })}161            <span className="self-center text-[11px] text-ink-3">no selection = all types</span>162          </div>163        </form>164        {alerts.length > 0 ? (165          <ul className="mt-4 divide-y divide-rule border-y border-rule text-sm">166            {alerts.map((a) => (167              <li key={a.id} className="flex flex-wrap items-center gap-2 py-2">168                <span className="font-medium text-ink">{a.name}</span>169                <span className="text-xs text-ink-3">{a.company ? `company ${a.company}` : 'any watched company'}</span>170                {(a.condition.event_types ?? []).map((t) => (171                  <EventTypeBadge key={t} type={t} small />172                ))}173                {a.condition.min_importance !== undefined && <span className="tnum text-xs text-ink-3">importance ≥ {Math.round(a.condition.min_importance * 100)}</span>}174                <span className="mono text-[11px] text-ink-3">{a.channel}</span>175                <button type="button" onClick={() => deleteAlert(a.id)} className="ml-auto flex size-9 items-center justify-center text-ink-3 hover:text-danger" aria-label={`Delete rule ${a.name}`}>176                  <Trash2 className="size-4" aria-hidden />177                </button>178              </li>179            ))}180          </ul>181        ) : (182          <Note className="mt-3">No rules yet.</Note>183        )}184        {deliveries.length > 0 && (185          <div className="mt-6">186            <p className="eyebrow mb-1">Recent deliveries</p>187            <ul className="divide-y divide-rule border-y border-rule text-sm">188              {deliveries.map((d) => (189                <li key={d.id} className="flex flex-wrap items-center gap-2 py-2">190                  <span className="text-xs text-ink-3">{d.alert_name ?? d.alert_id}</span>191                  {d.event ? (192                    <Link href={routes.event(d.event.id)} className="min-w-0 flex-1 truncate hover:text-accent">193                      {d.event.title}194                    </Link>195                  ) : (196                    <span className="mono text-xs">{d.event_id}</span>197                  )}198                  <span className="mono text-[11px] text-ink-3">199                    {d.channel} · {d.status}200                  </span>201                  <LiveAgo at={d.delivered_at} tick={30000} className="text-xs text-ink-3" />202                </li>203              ))}204            </ul>205          </div>206        )}207      </Section>208209      <Section eyebrow="Your token" title="No account, one token">210        <p className="text-sm text-ink-2">Watchlists and alerts belong to a random token generated by this browser and stored in localStorage; the server keeps only a hash. Clearing site data loses the list — copy the token to move it to another device.</p>211        <div className="mt-3 flex flex-wrap items-center gap-2">212          <button type="button" onClick={() => setShowToken((v) => !v)} className="btn btn-sm">213            {showToken ? 'Hide token' : 'Show token'}214          </button>215          {showToken && token && <code className="mono break-all border border-rule bg-surface-2 px-2 py-1 text-xs">{token}</code>}216        </div>217        <form218          className="mt-3 flex max-w-lg gap-2"219          onSubmit={(e) => {220            e.preventDefault();221            if (setOwnerToken(importDraft)) {222              setImportDraft('');223              load();224            }225          }}226        >227          <input value={importDraft} onChange={(e) => setImportDraft(e.target.value)} placeholder="Paste a token from another device" className="field flex-1 text-xs" aria-label="Import token" />228          <button type="submit" className="btn btn-sm" disabled={importDraft.trim().length < 24}>229            Import230          </button>231        </form>232      </Section>233    </div>234  );235}236