spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
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