SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
7.3 KB · 169 lines tsx
Raw Blame History
1"use client";23import type { EventDTO, WatchlistItem } from "@dci/core";4import { RefreshCw } from "lucide-react";5import Link from "next/link";6import { useCallback, useEffect, useState } from "react";7import { EventList } from "@/components/event-list";8import { WatchButton } from "@/components/intelligence/watch-button";9import { Button } from "@/components/ui/button";10import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";11import { SkeletonRows } from "@/components/ui/skeleton";12import { EmptyState, ErrorState } from "@/components/ui/states";13import { clientApiBase } from "@/lib/api";14import { fmtInt } from "@/lib/format";15import { ENTITY_TYPE_LABEL } from "@/lib/labels";16import { entityHref, routes } from "@/lib/routes";1718const PER_PAGE = 50;19const TYPE_ORDER: WatchlistItem["entityType"][] = ["operator", "metro", "country", "project", "facility"];2021/** Cookie-scoped call (credentials included) — apiGet has no credentials option. */22async function get<T>(path: string): Promise<{ ok: boolean; data: T | null; total: number; error: string | null }> {23  try {24    const res = await fetch(`${clientApiBase()}${path}`, { credentials: "include", headers: { accept: "application/json" }, cache: "no-store" });25    if (!res.ok) return { ok: false, data: null, total: 0, error: `HTTP ${res.status}` };26    const j = (await res.json()) as { data: T; meta?: { total?: number } };27    return { ok: true, data: j.data, total: j.meta?.total ?? 0, error: null };28  } catch (e) {29    return { ok: false, data: null, total: 0, error: e instanceof Error ? e.message : "network error" };30  }31}3233interface Fetched {34  ok: boolean;35  items: WatchlistItem[];36  feed: EventDTO[];37  feedTotal: number;38  error: string | null;39}4041/**42 * The watchlist is keyed by an httpOnly cookie on the API origin, so it is read in the browser with43 * credentials — the server never sees it. Items + feed load together; unfollow refreshes both.44 */45async function fetchAll(): Promise<Fetched> {46  const [a, b] = await Promise.all([get<WatchlistItem[]>("/watchlist"), get<EventDTO[]>(`/watchlist/feed?per_page=${PER_PAGE}`)]);47  return { ok: a.ok, items: a.data ?? [], feed: b.data ?? [], feedTotal: b.total || (b.data?.length ?? 0), error: a.ok ? null : a.error };48}4950export function WatchlistView() {51  const [state, setState] = useState<Fetched | null>(null);52  const [more, setMore] = useState(false);53  const [tick, setTick] = useState(0);54  const load = useCallback(() => setTick((t) => t + 1), []);5556  useEffect(() => {57    let alive = true;58    void fetchAll().then((s) => {59      if (alive) setState(s);60    });61    return () => {62      alive = false;63    };64  }, [tick]);6566  const loadMore = async () => {67    if (!state) return;68    setMore(true);69    const page = Math.floor(state.feed.length / PER_PAGE) + 1;70    const r = await get<EventDTO[]>(`/watchlist/feed?page=${page}&per_page=${PER_PAGE}`);71    setMore(false);72    if (r.ok && Array.isArray(r.data)) setState({ ...state, feed: [...state.feed, ...r.data.filter((e) => !state.feed.some((x) => x.id === e.id))] });73  };7475  if (!state) return <SkeletonRows rows={6} />;76  if (!state.ok) return <ErrorState compact title="Watchlist unavailable" description="The API did not respond to the watchlist call. Cookies must be allowed for the API origin." detail={state.error ?? undefined} action={<Button size="sm" onClick={load}>Retry</Button>} />;7778  const groups = TYPE_ORDER.map((t) => ({ type: t, items: state.items.filter((i) => i.entityType === t) })).filter((g) => g.items.length);7980  if (state.items.length === 0) {81    return (82      <EmptyState83        title="Nothing watched yet"84        description="Press “Watch” on an operator, market, country or project page and its changes will appear here. The list is private and cookie-based — no account, up to 200 items on this browser."85        action={86          <div className="flex flex-wrap justify-center gap-2">87            <Button size="sm" href={routes.operators()}>88              Operators89            </Button>90            <Button size="sm" href={routes.markets()}>91              Markets92            </Button>93            <Button size="sm" href={routes.countries()}>94              Countries95            </Button>96            <Button size="sm" href={routes.projects()}>97              Projects98            </Button>99          </div>100        }101      />102    );103  }104105  return (106    <div className="flex flex-col gap-8">107      <MetricRow cols={3}>108        <MetricCell>109          <Metric size="sm" value={fmtInt(state.items.length)} label="Watched entities" note="of 200 max" />110        </MetricCell>111        <MetricCell>112          <Metric size="sm" value={fmtInt(groups.length)} label="Entity types" />113        </MetricCell>114        <MetricCell>115          <Metric size="sm" value={fmtInt(state.feedTotal)} label="Changes in the feed" note="one row per announcement" />116        </MetricCell>117      </MetricRow>118119      <section aria-labelledby="watched-h">120        <div className="mb-2 flex items-baseline justify-between gap-3">121          <h2 id="watched-h" className="text-[15px] font-semibold text-ink">122            Watched123          </h2>124          <Button size="xs" variant="ghost" onClick={load} aria-label="Refresh watchlist">125            <RefreshCw className="size-3.5" aria-hidden /> Refresh126          </Button>127        </div>128        <div className="grid gap-4 md:grid-cols-2">129          {groups.map((g) => (130            <div key={g.type} className="rounded-sm border border-hair bg-surface">131              <div className="flex items-baseline justify-between border-b border-hair px-3 py-2">132                <h3 className="text-[13px] font-semibold text-ink">{ENTITY_TYPE_LABEL[g.type] ?? g.type}</h3>133                <span className="figure text-[11.5px] text-ink-3">{fmtInt(g.items.length)}</span>134              </div>135              <ul className="divide-y divide-hair">136                {g.items.map((it) => (137                  <li key={it.id} className="flex items-center justify-between gap-3 px-3 py-1.5">138                    <Link href={entityHref(it.entityType, it.slug, it.name)} className="min-w-0 truncate text-[13px] font-medium text-ink hover:text-accent hover:underline">139                      {it.name}140                    </Link>141                    <WatchButton entityType={it.entityType} slug={it.slug} entityId={it.entityId} name={it.name} compact size="xs" />142                  </li>143                ))}144              </ul>145            </div>146          ))}147        </div>148      </section>149150      <section aria-labelledby="feed-h">151        <div className="mb-2 flex items-baseline justify-between gap-3">152          <h2 id="feed-h" className="text-[15px] font-semibold text-ink">153            Watchlist feed154          </h2>155          <span className="text-[12px] text-ink-3">Newest first · deduplicated per announcement</span>156        </div>157        <EventList events={state.feed} groupByDay emptyTitle="No change yet for the watched entities" emptyDescription="Changes appear as connectors detect them." />158        {state.feed.length > 0 && state.feed.length < state.feedTotal && (159          <div className="flex justify-center py-4">160            <Button onClick={() => void loadMore()} disabled={more} size="md">161              <RefreshCw className="size-3.5" aria-hidden /> {more ? "Loading…" : "Load more"}162            </Button>163          </div>164        )}165      </section>166    </div>167  );168}169