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.5 KB · 130 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { useMemo } from "react";5import type { EventDTO } from "@dci/core";6import { Badge, Button, EmptyState, EventBadge, RelativeTime, SkeletonRows } from "@/components/ui";7import { cn } from "@/components/ui/cn";8import { fmtInt, fmtValue } from "@/lib/format";9import { EVENT_LABEL } from "@/lib/labels";10import { entityHref } from "@/lib/routes";11import { ErrorNote, ExtLink, Input, Pager, RefreshButton, ReviewBadge, Segmented, Select } from "./primitives";12import { AdminPageHeader } from "./shell";13import { useToast } from "./toast";14import { useAdminMutation, useAdminQuery } from "./use-admin-query";15import { useUrlState } from "./use-url-state";1617const REVIEW = ["all", "pending", "approved", "auto"] as const;18type Review = (typeof REVIEW)[number];19const PER_PAGE = 50;2021export function EventsReview() {22  const [params, setParams] = useUrlState();23  const review = (REVIEW.includes(params.review as Review) ? params.review : "all") as Review;24  const page = Math.max(1, Number(params.page ?? 1) || 1);25  const type = params.type ?? "";26  const minSig = params.min_significance ?? "";27  const q = useAdminQuery<EventDTO[]>("v1/events", { query: { type: type || undefined, min_significance: minSig || undefined, page, per_page: PER_PAGE }, refreshMs: 60_000 });28  const { busy, run } = useAdminMutation();29  const toast = useToast();30  const total = Number(q.meta?.total ?? 0);31  const rows = useMemo(() => (q.data ?? []).filter((e) => review === "all" || e.reviewStatus === review), [q.data, review]);32  const counts = useMemo(() => {33    const c: Record<string, number> = {};34    for (const e of q.data ?? []) c[e.reviewStatus] = (c[e.reviewStatus] ?? 0) + 1;35    return c;36  }, [q.data]);3738  const setStatus = async (e: EventDTO, reviewStatus: "approved" | "rejected" | "pending") => {39    const prev = e.reviewStatus;40    q.mutate((list) => list?.map((x) => (x.id === e.id ? { ...x, reviewStatus } : x)) ?? list);41    const r = await run<{ id: string; reviewStatus: string }>(`${e.id}:${reviewStatus}`, `events/${encodeURIComponent(e.id)}`, { method: "PATCH", body: { reviewStatus } });42    if (r.ok) toast({ tone: "ok", title: `Event ${reviewStatus}`, description: e.title });43    else {44      q.mutate((list) => list?.map((x) => (x.id === e.id ? { ...x, reviewStatus: prev } : x)) ?? list);45      toast({ tone: "danger", title: "Review not saved", description: r.error });46    }47  };4849  return (50    <div>51      <AdminPageHeader title="Events review" description="Recent change-feed events with their review state. Rejected events disappear from the public feed (and from this list, which reads the public endpoint)." aside={<RefreshButton onClick={() => void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} />} />52      <ErrorNote error={q.error && !q.data ? q.error : null} onRetry={() => void q.refresh()} className="mb-3" />53      <div className="mb-3 flex flex-col gap-2 lg:flex-row lg:items-center">54        <Segmented label="Review" value={review} onChange={(v) => setParams({ review: v === "all" ? null : v })} options={REVIEW.map((r) => ({ value: r, label: r === "all" ? "All (page)" : r[0]!.toUpperCase() + r.slice(1), count: r === "all" ? (q.data?.length ?? null) : (counts[r] ?? 0) }))} />55        <div className="flex flex-1 gap-2">56          <Select value={type} onChange={(e) => setParams({ type: e.target.value || null, page: null })} className="sm:w-[220px]" aria-label="Event type">57            <option value="">All event types</option>58            {Object.entries(EVENT_LABEL).map(([k, v]) => (59              <option key={k} value={k}>60                {v}61              </option>62            ))}63          </Select>64          <Input type="number" min={0} max={100} value={minSig} onChange={(e) => setParams({ min_significance: e.target.value || null, page: null })} placeholder="min significance" className="w-[150px]" mono />65        </div>66      </div>6768      {q.loading && !q.data ? (69        <SkeletonRows rows={10} />70      ) : rows.length === 0 ? (71        <EmptyState compact title="No events" description={review !== "all" ? "No events with this review state on the current page." : undefined} />72      ) : (73        <div className="rounded-sm border border-hair bg-surface">74          <ul className="divide-y divide-hair">75            {rows.map((e) => (76              <li key={e.id} className="flex flex-col gap-2 px-3 py-2.5 sm:flex-row sm:items-start">77                <div className="flex w-full shrink-0 flex-wrap items-center gap-1.5 sm:w-[210px] sm:flex-col sm:items-start">78                  <EventBadge type={e.eventType} size="xs" />79                  <ReviewBadge status={e.reviewStatus} />80                  <span className="text-[11.5px] text-ink-3">81                    <RelativeTime iso={e.detectedAt} />82                  </span>83                  <span className={cn("figure text-[11.5px]", e.significance >= 60 ? "text-accent" : "text-ink-4")}>sig {e.significance}</span>84                </div>85                <div className="min-w-0 flex-1 text-[13px]">86                  <div className="font-medium text-ink">{e.title}</div>87                  {e.summary && <p className="mt-0.5 line-clamp-2 text-[12.5px] text-ink-2">{e.summary}</p>}88                  <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-ink-3">89                    {e.entity && (90                      <Link href={entityHref(e.entityType, e.entity.slug, e.entity.name)} target="_blank" className="text-ink-2 hover:text-accent hover:underline">91                        {e.entity.name}92                      </Link>93                    )}94                    {e.operator && <span>{e.operator.name}</span>}95                    {e.countryIso2 && <Badge size="xs">{e.countryIso2}</Badge>}96                    <span>{e.sourceName}</span>97                    <ExtLink href={e.url} />98                    {(e.oldValue !== null && e.oldValue !== undefined) || (e.newValue !== null && e.newValue !== undefined) ? (99                      <span className="figure">100                        {e.oldValue !== null && e.oldValue !== undefined && <span className="text-danger">{fmtValue(e.oldValue)}</span>}101                        {e.oldValue !== null && e.oldValue !== undefined && " → "}102                        <span className="text-st-operational">{fmtValue(e.newValue)}</span>103                      </span>104                    ) : null}105                  </div>106                </div>107                <div className="flex shrink-0 gap-1">108                  <Button size="xs" variant={e.reviewStatus === "approved" ? "primary" : "secondary"} onClick={() => void setStatus(e, "approved")} disabled={busy !== null || e.reviewStatus === "approved"}>109                    Approve110                  </Button>111                  <Button size="xs" variant="ghost" onClick={() => void setStatus(e, "pending")} disabled={busy !== null || e.reviewStatus === "pending"}>112                    Pending113                  </Button>114                  <Button size="xs" variant="danger" onClick={() => void setStatus(e, "rejected")} disabled={busy !== null}>115                    Reject116                  </Button>117                </div>118              </li>119            ))}120          </ul>121          <div className="px-3">122            <Pager page={page} perPage={PER_PAGE} total={total} onPage={(p) => setParams({ page: p === 1 ? null : p })} />123          </div>124        </div>125      )}126      <p className="mt-2 text-[11.5px] text-ink-4">{fmtInt(total)} events match the type / significance filters. The review filter applies to the current page.</p>127    </div>128  );129}130