feat(ui): scaffold vite + react 19 frontend

Replaces the CRA + React 16 + class-component frontend with the
shape from architecture/generic.md §4: vite + react + swc + ts,
served as static from nginx in prod, vite dev server in dev with
/api proxied to localhost:8080.

Layout:
  ui/
    package.json, vite.config.ts, tsconfig.{json,app,node}.json
    index.html
    src/
      main.tsx           — react root + react-query provider
      App.tsx            — header, filters, vertical timeline
      App.css            — dark backdrop, hot-pink links
      api/client.ts      — TS types mirroring moments-entities;
                            fetchEvents, fetchSources via /api/v1
      components/
        Filters.tsx      — source toggles, count slider, date range
        TimelineEntry.tsx — renders one TimelineItem with body
                             support for markdown, commits, links
      lib/icon.tsx       — TimelineIcon → react-bootstrap-icons map
                            + colour per icon

Stack: react 19, @tanstack/react-query 5, react-bootstrap 2 (on
bootstrap 5), react-vertical-timeline-component 3, rc-slider 11
(<Slider range /> replaces the removed v8 Range), react-markdown 9.

Dev proxy: /api/* → http://localhost:8080/* (rewrite strips /api).
Backend stays location-agnostic at /v1; ingress prefix is added
by nginx (and the dev proxy) so the same fetch shape works in
both environments.

Verified: tsc -b clean, vite build clean (417 KB js / 245 KB css
gzip 128 / 33), vite dev server serves the index. NOT verified
visually in a browser — that's a `pnpm run dev` away on roosta
once the api is up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 19:18:32 +03:00
parent 7772393598
commit b04afd83f9
15 changed files with 2656 additions and 0 deletions

84
ui/src/api/client.ts Normal file
View File

@@ -0,0 +1,84 @@
// Wire types mirror the moments-entities types serialised by the API.
// Hand-maintained for now; if drift becomes a problem, generate them
// from the Rust crate via ts-rs or specta.
export type Source = 'github' | 'gitea' | 'hg' | 'bugzilla';
export type TitleSegment =
| { kind: 'text'; text: string }
| { kind: 'link'; text: string; url: string };
export interface CommitSummary {
sha: string;
short_sha: string;
message: string;
url: string;
author: string | null;
}
export type TimelineBody =
| { kind: 'markdown'; text: string }
| { kind: 'commits'; commits: CommitSummary[] }
| { kind: 'links'; items: TitleSegment[] };
export type TimelineIcon =
| 'git-push'
| 'git-commit'
| 'git-merge'
| 'git-fork'
| 'git-branch-create'
| 'git-branch-delete'
| 'pull-request'
| 'issue'
| 'comment'
| 'star'
| 'release'
| 'bug'
| 'generic';
export interface TimelineItem {
id: string;
source: Source;
action: string;
occurred_at: string;
icon: TimelineIcon;
title: TitleSegment[];
subtitle: TitleSegment[] | null;
body: TimelineBody | null;
}
export interface SourceSummary {
source: Source;
count: number;
earliest: string | null;
latest: string | null;
}
export interface EventQuery {
from?: Date;
to?: Date;
sources?: Source[];
limit?: number;
}
const API_BASE = '/api/v1';
export async function fetchEvents(q: EventQuery): Promise<TimelineItem[]> {
const params = new URLSearchParams();
if (q.from) params.set('from', q.from.toISOString());
if (q.to) params.set('to', q.to.toISOString());
if (q.sources && q.sources.length > 0) {
params.set('source', q.sources.join(','));
}
if (q.limit) params.set('limit', String(q.limit));
const resp = await fetch(`${API_BASE}/events?${params}`);
if (!resp.ok) throw new Error(`events: HTTP ${resp.status}`);
return resp.json();
}
export async function fetchSources(): Promise<SourceSummary[]> {
const resp = await fetch(`${API_BASE}/sources`);
if (!resp.ok) throw new Error(`sources: HTTP ${resp.status}`);
return resp.json();
}