feat(frontend): v2 rewrite — Vite + React + TS readonly SPA

Replaces the v1 CRA app (which targeted the obsolete Python Dialectic
backend) with a fresh Vite + React 18 + TypeScript scaffold that talks
to Dialectic.Backend Go v2.

Pages (all readonly — propose/signup/post are agent-only by design):
  - /                       TopicList — filter by status, paginated
  - /topics/:id             TopicDetail — meta + camps + transcript
                            (polling every 8s)
  - /topics/:id/verdict     Verdict permalink (shareable)
  - /agents/:id             AgentActivity — admin diagnostics card

Stack:
  - Vite 5 + React 18 + react-router-dom 6
  - Pure ESM, NodeNext-style imports, .tsx
  - Style: ~/STYLE.md tokens (IBM Plex Mono + Major Mono Display +
    --acid #d8ff3e on --ink #080a0d, with subtle blueprint grid wash)

Auth:
  - v1 dev-bypass only — VITE_OIDC_DEV_BYPASS auto-attaches
    x-dev-bypass header. Real Keycloak OIDC redirect ships as v2.
  - Admin endpoints (x-dialectic-admin-key) prompt on first visit
    and store key in localStorage. Never baked into bundle. Never
    sent to non-admin endpoints.

Backend pairing:
  - Dialectic.Backend@0b16b52 adds GET /api/admin/agents/{id} for the
    AgentActivity page. AgentActivity calls it via the admin-key
    branch in api.ts.

Deploy:
  - Multi-stage Dockerfile (node:22-alpine build → nginx:1.27-alpine
    serve). nginx.conf reverse-proxies /api/ → dialectic-backend:8090
    so the browser sees one origin (no CORS).

Reuses the existing hzhang/Dialectic.Frontend repo — old CRA contents
nuked in this commit. History preserved on master.
This commit is contained in:
h z
2026-05-24 00:15:35 +01:00
parent f7c4ed9e3b
commit 3dbb5abaf6
38 changed files with 3497 additions and 2826 deletions

78
src/pages/Verdict.tsx Normal file
View File

@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getTopic, getVerdict } from '../api';
import type { TopicDetail, Verdict } from '../types';
import { fmtTime } from '../util';
export function VerdictPage() {
const { id } = useParams<{ id: string }>();
const [topic, setTopic] = useState<TopicDetail | null>(null);
const [verdict, setVerdict] = useState<Verdict | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
const ac = new AbortController();
setLoading(true);
Promise.all([getTopic(id, ac.signal), getVerdict(id, ac.signal)])
.then(([t, v]) => {
setTopic(t);
setVerdict(v);
setErr(null);
})
.catch((e: Error) => {
if (e.name !== 'AbortError') setErr(e.message);
})
.finally(() => setLoading(false));
return () => ac.abort();
}, [id]);
if (!id) return <div className="err">missing topic id</div>;
if (loading) return <div className="muted">loading</div>;
if (err) return <div className="err">{err}</div>;
if (!topic) return <div className="err">topic not found</div>;
return (
<div className="td">
<div>
<span className="eyebrow">verdict permalink</span>
<h1>{topic.title}</h1>
<p className="td-summary">{topic.summary}</p>
<p className="muted" style={{ marginTop: 8 }}>
schema: <code>{topic.verdict_schema_id}</code>
{' · '}debate window: {fmtTime(topic.debate_start_at)} {fmtTime(topic.debate_end_at)}
{' · '}
<Link to={`/topics/${topic.id}`}>back to topic</Link>
</p>
</div>
{!verdict ? (
<div className="panel td-empty">
no verdict yet topic status is <span className={`pill pill-${topic.status}`}>{topic.status}</span>
{topic.status === 'debating' && ', judge has not submitted'}
{topic.status === 'cancelled' && ', topic was cancelled before a verdict could be reached'}
</div>
) : (
<>
<div className="panel">
<div className="panel-header">verdict (structured)</div>
<pre className="vd-json">{JSON.stringify(verdict.verdict, null, 2)}</pre>
<div className="row muted" style={{ marginTop: 8, fontSize: '0.85em' }}>
<span>judge: <Link to={`/agents/${verdict.judge_agent_id}`}>{verdict.judge_agent_id}</Link></span>
<span className="spacer" />
<span>produced: {fmtTime(verdict.produced_at)}</span>
{(verdict.tokens_input + verdict.tokens_output) > 0 && (
<span>tokens: {verdict.tokens_input} in / {verdict.tokens_output} out</span>
)}
</div>
</div>
<div className="panel">
<div className="panel-header">rationale</div>
<div className="vd-rationale">{verdict.rationale}</div>
</div>
</>
)}
</div>
);
}