SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
4.2 KB · 113 lines tsx
Raw Blame History
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