SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
5.6 KB · 130 lines tsx
Raw Blame History
1'use client';23import { useRouter } from 'next/navigation';4import { useState, useTransition } from 'react';5import { cn } from '@/lib/cn';6import type { ReviewDecision } from './types';78/**9 * Admin mutations. Each button POSTs to a Next route handler (same origin, session cookie) which forwards to FastAPI with the10 * server-side token, then refreshes the server components. Errors are shown inline — never swallowed.11 */12async function post(path: string, body?: unknown): Promise<{ ok: true; data: Record<string, unknown> } | { ok: false; error: string }> {13  try {14    const res = await fetch(path, { method: 'POST', headers: body !== undefined ? { 'content-type': 'application/json' } : undefined, body: body !== undefined ? JSON.stringify(body) : undefined });15    const json = (await res.json().catch(() => ({}))) as Record<string, unknown> & { error?: { detail?: string; title?: string } };16    if (!res.ok) return { ok: false, error: json.error?.detail || json.error?.title || `HTTP ${res.status}` };17    return { ok: true, data: json };18  } catch (e) {19    return { ok: false, error: (e as Error).message };20  }21}2223const btn = 'inline-flex min-h-9 items-center justify-center rounded-md border px-3 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50';24const btnNeutral = 'border-rule text-ink-2 hover:bg-plane-2 hover:text-ink';25const btnAccent = 'border-accent/40 bg-accent-soft text-accent hover:bg-accent/20';26const btnDanger = 'border-danger/40 bg-danger-soft text-danger hover:bg-danger/20';2728export function RunNowButton({ name, className }: { name: string; className?: string }) {29  const router = useRouter();30  const [pending, start] = useTransition();31  const [msg, setMsg] = useState<{ tone: 'ok' | 'err'; text: string } | null>(null);32  return (33    <span className={cn('inline-flex flex-wrap items-center gap-2', className)}>34      <button35        type="button"36        disabled={pending}37        className={cn(btn, btnAccent)}38        onClick={() =>39          start(async () => {40            setMsg(null);41            const r = await post(`/api/admin/connectors/${encodeURIComponent(name)}/run`);42            if (r.ok) {43              const queued = r.data.queued === true;44              setMsg({ tone: queued ? 'ok' : 'err', text: queued ? 'queued' : String(r.data.note ?? 'not queued') });45              router.refresh();46            } else setMsg({ tone: 'err', text: r.error });47          })48        }49      >50        {pending ? 'Queuing…' : 'Run now'}51      </button>52      {msg && <span className={cn('text-xs', msg.tone === 'ok' ? 'text-active' : 'text-danger')}>{msg.text}</span>}53    </span>54  );55}5657export function EnabledToggle({ name, enabled, className }: { name: string; enabled: boolean; className?: string }) {58  const router = useRouter();59  const [pending, start] = useTransition();60  const [err, setErr] = useState<string | null>(null);61  return (62    <span className={cn('inline-flex flex-wrap items-center gap-2', className)}>63      <button64        type="button"65        role="switch"66        aria-checked={enabled}67        disabled={pending}68        className={cn(btn, enabled ? btnNeutral : btnAccent)}69        onClick={() =>70          start(async () => {71            setErr(null);72            const r = await post(`/api/admin/connectors/${encodeURIComponent(name)}/enabled`, { enabled: !enabled });73            if (r.ok) router.refresh();74            else setErr(r.error);75          })76        }77      >78        {pending ? '…' : enabled ? 'Disable' : 'Enable'}79      </button>80      {err && <span className="text-xs text-danger">{err}</span>}81    </span>82  );83}8485const DECISION_LABELS: Record<ReviewDecision, string> = { merged: 'Merge B into A', kept_separate: 'Keep separate', dismissed: 'Dismiss' };8687export function ReviewDecisionButtons({ id, canMerge, className }: { id: number; canMerge: boolean; className?: string }) {88  const router = useRouter();89  const [pending, start] = useTransition();90  const [msg, setMsg] = useState<{ tone: 'ok' | 'err'; text: string } | null>(null);91  const [confirmMerge, setConfirmMerge] = useState(false);92  const decide = (decision: ReviewDecision) =>93    start(async () => {94      setMsg(null);95      const r = await post(`/api/admin/review/${id}`, { decision });96      if (r.ok) {97        setMsg({ tone: 'ok', text: `recorded: ${decision.replace('_', ' ')}` });98        setConfirmMerge(false);99        router.refresh();100      } else setMsg({ tone: 'err', text: r.error });101    });102  return (103    <div className={cn('flex flex-wrap items-center gap-2', className)}>104      {canMerge &&105        (confirmMerge ? (106          <>107            <button type="button" disabled={pending} className={cn(btn, btnDanger)} onClick={() => decide('merged')}>108              Confirm merge (audited)109            </button>110            <button type="button" disabled={pending} className={cn(btn, btnNeutral)} onClick={() => setConfirmMerge(false)}>111              Cancel112            </button>113          </>114        ) : (115          <button type="button" disabled={pending} className={cn(btn, btnAccent)} onClick={() => setConfirmMerge(true)}>116            {DECISION_LABELS.merged}117          </button>118        ))}119      <button type="button" disabled={pending} className={cn(btn, btnNeutral)} onClick={() => decide('kept_separate')}>120        {DECISION_LABELS.kept_separate}121      </button>122      <button type="button" disabled={pending} className={cn(btn, btnNeutral)} onClick={() => decide('dismissed')}>123        {DECISION_LABELS.dismissed}124      </button>125      {pending && <span className="text-xs text-ink-3">Saving…</span>}126      {msg && <span className={cn('text-xs', msg.tone === 'ok' ? 'text-active' : 'text-danger')}>{msg.text}</span>}127    </div>128  );129}130