SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
5.6 KB · 116 lines tsx
Raw Blame History
1'use client';2import { Star, X } from 'lucide-react';3import Link from 'next/link';4import { useEffect, useMemo, useState } from 'react';5import { ChangeRow } from '@/components/changes/change-row';6import { EntityBadge } from '@/components/ui/badges';7import { EmptyState } from '@/components/ui/unavailable';8import { clientApi } from '@/lib/client-api';9import { routes } from '@/lib/site';10import type { ChangeEvent } from '@/lib/types';11import { useWatchlist, WATCH_EVENT_TYPES, WATCH_MAX } from '@/lib/watchlist';1213type Feed = { status: 'idle' | 'loading' | 'ready'; events: ChangeEvent[]; failed: string[] };1415/**16 * `/watchlist` body: the watched entities (remove buttons) and their merged event feed — `/changes?entity=<slug>&limit=20`17 * per item (≤ 12 items), merged, sorted by `effective_at ?? observed_at`, filtered to release / price / deprecation /18 * benchmark / provider / licence event types. Nothing is stored server-side.19 */20export function WatchlistView() {21  const wl = useWatchlist();22  const [feed, setFeed] = useState<Feed>({ status: 'idle', events: [], failed: [] });23  const slugs = useMemo(() => wl.items.map((i) => i.slug).slice(0, WATCH_MAX), [wl.items]);24  const key = slugs.join(',');2526  useEffect(() => {27    if (!wl.ready) return;28    if (!slugs.length) {29      setFeed({ status: 'ready', events: [], failed: [] });30      return;31    }32    const ctrl = new AbortController();33    setFeed((f) => ({ ...f, status: 'loading' }));34    (async () => {35      const results = await Promise.all(slugs.map((s) => clientApi.entityChanges(s, 20, ctrl.signal).then((p) => ({ s, items: p.items ?? [] })).catch((e) => ({ s, items: null as ChangeEvent[] | null, e }))));36      if (ctrl.signal.aborted) return;37      const seen = new Set<string>();38      const events: ChangeEvent[] = [];39      const failed: string[] = [];40      for (const r of results) {41        if (!r.items) {42          failed.push(r.s);43          continue;44        }45        for (const e of r.items) {46          if (!WATCH_EVENT_TYPES.has(e.event_type) || seen.has(e.id)) continue;47          seen.add(e.id);48          events.push(e);49        }50      }51      events.sort((a, b) => new Date(b.effective_at ?? b.observed_at).getTime() - new Date(a.effective_at ?? a.observed_at).getTime());52      setFeed({ status: 'ready', events, failed });53    })();54    return () => ctrl.abort();55    // eslint-disable-next-line react-hooks/exhaustive-deps56  }, [key, wl.ready]);5758  if (!wl.ready) return <p className="py-8 text-sm text-ink-3">Loading your watchlist…</p>;59  if (!wl.items.length)60    return (61      <EmptyState title="Nothing watched yet">62        Press <span className="inline-flex items-center gap-1 border border-rule px-1.5 py-0.5 text-xs text-ink-2"><Star className="size-3" aria-hidden /> Watch</span> on any model, organization, provider or benchmark page. The list lives in this browser only — no account.{' '}63        <Link href={routes.models()} className="link">Browse models →</Link>64      </EmptyState>65    );6667  return (68    <div className="grid gap-10 lg:grid-cols-[18rem_minmax(0,1fr)]">69      <div>70        <div className="flex items-baseline justify-between">71          <p className="eyebrow">Watching · {wl.items.length}/{WATCH_MAX}</p>72          <button type="button" onClick={wl.clear} className="text-xs text-ink-3 hover:text-danger">73            Clear all74          </button>75        </div>76        <ul className="mt-2 border-t border-rule" data-watchlist-items>77          {wl.items.map((i) => (78            <li key={i.slug} className="flex items-center gap-2 border-b border-rule py-2">79              <EntityBadge type={i.entity_type} small />80              <Link href={routes.entity(i)} className="min-w-0 flex-1 truncate text-sm font-medium text-ink hover:text-accent">81                {i.name}82              </Link>83              {i.org && <span className="hidden truncate text-xs text-ink-3 xl:block">{i.org}</span>}84              <button type="button" onClick={() => wl.remove(i.slug)} className="flex size-9 shrink-0 items-center justify-center text-ink-3 hover:text-danger" aria-label={`Stop watching ${i.name}`}>85                <X className="size-4" aria-hidden />86              </button>87            </li>88          ))}89        </ul>90        {wl.items.length > 1 && (91          <p className="mt-3 text-xs">92            <Link href={routes.compare(wl.items.filter((i) => i.entity_type === wl.items[0]?.entity_type).slice(0, 6).map((i) => i.slug))} className="link">93              Compare the watched {wl.items[0]?.entity_type}s →94            </Link>95          </p>96        )}97      </div>98      <div>99        <p className="eyebrow">Relevant events · releases, prices, deprecations, benchmarks, providers, licences</p>100        {feed.status === 'loading' && !feed.events.length && <p className="mt-3 text-sm text-ink-3">Loading events…</p>}101        {feed.failed.length > 0 && <p className="mt-2 text-xs text-warning">Events unavailable for {feed.failed.length} {feed.failed.length === 1 ? 'entity' : 'entities'} (API did not answer).</p>}102        {feed.status === 'ready' && !feed.events.length && feed.failed.length < slugs.length && (103          <p className="mt-3 border-y border-rule py-8 text-center text-sm text-ink-3">No release, price, deprecation, benchmark, provider or licence events recorded yet for the watched entities.</p>104        )}105        {feed.events.length > 0 && (106          <ul className="mt-2 border-t border-rule" data-watchlist-feed>107            {feed.events.slice(0, 80).map((e) => (108              <ChangeRow key={e.id} e={e} showDate live={false} />109            ))}110          </ul>111        )}112      </div>113    </div>114  );115}116