HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
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