spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import type { WatchlistItem } from "@dci/core";4import { Bookmark, BookmarkCheck, Loader2 } from "lucide-react";5import Link from "next/link";6import { useCallback, useEffect, useState } from "react";7import { Button } from "@/components/ui/button";8import { cn } from "@/components/ui/cn";9import { clientApiBase } from "@/lib/api";10import { routes } from "@/lib/routes";1112export type WatchEntityType = WatchlistItem["entityType"];1314interface Props {15 entityType: WatchEntityType;16 slug: string;17 entityId?: string;18 name: string;19 size?: "xs" | "sm" | "md";20 className?: string;21 /** compact icon-only rendering (tables) */22 compact?: boolean;23}2425interface Envelope<T> {26 data: T;27}2829async function readJson<T>(res: Response): Promise<T | null> {30 try {31 const j = (await res.json()) as Envelope<T> | T;32 return j && typeof j === "object" && "data" in (j as object) ? (j as Envelope<T>).data : (j as T);33 } catch {34 return null;35 }36}3738/** Module-level cache of the watchlist so several buttons on a page share one GET. */39let cache: Promise<WatchlistItem[]> | null = null;40const listeners = new Set<() => void>();41function invalidate() {42 cache = null;43 for (const l of listeners) l();44}45function loadList(): Promise<WatchlistItem[]> {46 if (!cache) {47 cache = fetch(`${clientApiBase()}/watchlist`, { credentials: "include", headers: { accept: "application/json" } })48 .then(async (r) => (r.ok ? ((await readJson<WatchlistItem[]>(r)) ?? []) : []))49 .catch(() => []);50 }51 return cache;52}5354/**55 * Follow / unfollow an operator, market, country, project or facility. State lives in the private,56 * cookie-scoped watchlist (POST / DELETE /api/v1/watchlist, credentials included). No account needed.57 */58export function WatchButton({ entityType, slug, entityId, name, size = "sm", className, compact }: Props) {59 const [item, setItem] = useState<WatchlistItem | null | undefined>(undefined);60 const [busy, setBusy] = useState(false);61 const [error, setError] = useState<string | null>(null);6263 const refresh = useCallback(() => {64 void loadList().then((list) => setItem(list.find((w) => w.entityType === entityType && (w.slug === slug || (entityId && w.entityId === entityId))) ?? null));65 }, [entityType, slug, entityId]);6667 useEffect(() => {68 refresh();69 listeners.add(refresh);70 return () => {71 listeners.delete(refresh);72 };73 }, [refresh]);7475 const toggle = async () => {76 if (busy) return;77 setBusy(true);78 setError(null);79 try {80 if (item) {81 const r = await fetch(`${clientApiBase()}/watchlist/${encodeURIComponent(item.id)}`, { method: "DELETE", credentials: "include" });82 if (!r.ok && r.status !== 404) throw new Error(`HTTP ${r.status}`);83 } else {84 const r = await fetch(`${clientApiBase()}/watchlist`, { method: "POST", credentials: "include", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ entityType, slug, ...(entityId ? { entityId } : {}) }) });85 if (r.status === 409) throw new Error("watchlist full (200 items)");86 if (!r.ok) throw new Error(`HTTP ${r.status}`);87 }88 invalidate();89 } catch (e) {90 setError(e instanceof Error ? e.message : "failed");91 } finally {92 setBusy(false);93 }94 };9596 const watching = Boolean(item);97 const label = watching ? `Stop watching ${name}` : `Watch ${name}`;98 return (99 <span className={cn("inline-flex items-center gap-2", className)}>100 <Button size={size} variant={watching ? "primary" : "secondary"} onClick={() => void toggle()} disabled={busy || item === undefined} aria-pressed={watching} aria-label={label} title={label}>101 {busy ? <Loader2 className="size-3.5 animate-spin" aria-hidden /> : watching ? <BookmarkCheck className="size-3.5" strokeWidth={1.75} aria-hidden /> : <Bookmark className="size-3.5" strokeWidth={1.75} aria-hidden />}102 {!compact && (watching ? "Watching" : "Watch")}103 </Button>104 {watching && !compact && (105 <Link href={routes.watchlist()} className="hidden text-[11.5px] text-ink-3 hover:text-ink sm:inline">106 Watchlist →107 </Link>108 )}109 {error && <span className="text-[11px] text-danger">{error}</span>}110 </span>111 );112}113