spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
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