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

38
ui/src/App.css Normal file
View File

@@ -0,0 +1,38 @@
body {
background-color: #2c3e50;
color: #ecf0f1;
}
.container {
color: #ecf0f1;
}
a {
color: #ff4081;
text-decoration: none;
}
a:hover {
color: #ff80ab;
text-decoration: underline;
}
.hot-pink,
a.hot-pink {
color: #ff4081;
}
/* react-vertical-timeline-component date label sits in the gutter — readable
against the dark backdrop. */
.vertical-timeline-element-date {
color: #ecf0f1 !important;
opacity: 0.8;
}
.vertical-timeline-element-content {
color: #2c3e50;
}
.vertical-timeline-element-content a {
color: #1565c0;
}

135
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,135 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Col from 'react-bootstrap/Col';
import Container from 'react-bootstrap/Container';
import Row from 'react-bootstrap/Row';
import { VerticalTimeline } from 'react-vertical-timeline-component';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'rc-slider/assets/index.css';
import 'react-vertical-timeline-component/style.min.css';
import './App.css';
import { fetchEvents, fetchSources, type Source } from './api/client';
import { Filters } from './components/Filters';
import { TimelineEntry } from './components/TimelineEntry';
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
const RANGE_MAX = Date.now();
const externalLinks: { url: string; alt: string }[] = [
{ url: 'https://instagram.com/rob_thij', alt: 'instagram' },
{ url: 'https://www.facebook.com/rob.thijssen', alt: 'facebook' },
{ url: 'https://linkedin.com/in/thijssen/', alt: 'linkedin' },
{ url: 'https://stackoverflow.com/users/68115/grenade', alt: 'stackoverflow' },
{ url: 'https://github.com/grenade', alt: 'github' },
{ url: 'https://git.lair.cafe/grenade', alt: 'gitea' },
{ url: 'https://steelhorseadventures.com', alt: 'steel horse adventures' },
];
export default function App() {
const [enabledSources, setEnabledSources] = useState<Record<Source, boolean>>({
github: true,
gitea: true,
hg: true,
bugzilla: true,
});
const [rangeValue, setRangeValue] = useState<[number, number]>(() => {
const now = Date.now();
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
return [thirtyDaysAgo, now];
});
const [limit, setLimit] = useState<number>(100);
const sourcesQ = useQuery({
queryKey: ['sources'],
queryFn: fetchSources,
refetchInterval: 60_000,
});
const activeSources = useMemo(
() =>
(Object.keys(enabledSources) as Source[]).filter((s) => enabledSources[s]),
[enabledSources],
);
const eventsQ = useQuery({
queryKey: ['events', rangeValue, activeSources, limit],
queryFn: () =>
fetchEvents({
from: new Date(rangeValue[0]),
to: new Date(rangeValue[1]),
sources: activeSources,
limit,
}),
refetchInterval: 60_000,
});
const events = eventsQ.data ?? [];
return (
<Container className="py-4">
<Row className="mb-3">
<Col>
<h1>hi, i'm rob</h1>
</Col>
<Col className="d-flex flex-wrap gap-3 justify-content-end align-items-center">
{externalLinks.map((el) => (
<a
key={el.url}
href={el.url}
title={el.alt}
target="_blank"
rel="noopener noreferrer"
>
{el.alt}
</a>
))}
</Col>
</Row>
<Row className="mb-4">
<Col>
<p>
i rarely say anything that warrants capital letters. if you're here
to see my resume, please go to{' '}
<a className="hot-pink" href="https://rob.tn/cv/">
https://rob.tn/cv
</a>
. a peek into the projects i'm working on is below.
</p>
</Col>
</Row>
<Filters
enabledSources={enabledSources}
onSourceToggle={(s, on) =>
setEnabledSources((prev) => ({ ...prev, [s]: on }))
}
rangeMin={RANGE_MIN}
rangeMax={RANGE_MAX}
rangeValue={rangeValue}
onRangeChange={setRangeValue}
limit={limit}
onLimitChange={setLimit}
summaries={sourcesQ.data}
/>
<Row>
<Col>
<p className="text-center" style={{ fontSize: '85%' }}>
{eventsQ.isLoading
? 'loading'
: eventsQ.isError
? `error: ${(eventsQ.error as Error).message}`
: `showing ${events.length} ${events.length === 1 ? 'activity' : 'activities'}`}
</p>
<VerticalTimeline>
{events.map((item) => (
<TimelineEntry key={item.id} item={item} />
))}
</VerticalTimeline>
</Col>
</Row>
</Container>
);
}

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();
}

View File

@@ -0,0 +1,98 @@
import Col from 'react-bootstrap/Col';
import Form from 'react-bootstrap/Form';
import Row from 'react-bootstrap/Row';
import Slider from 'rc-slider';
import type { Source, SourceSummary } from '../api/client';
const ALL_SOURCES: Source[] = ['github', 'gitea', 'hg', 'bugzilla'];
interface Props {
enabledSources: Record<Source, boolean>;
onSourceToggle: (s: Source, on: boolean) => void;
rangeMin: number;
rangeMax: number;
rangeValue: [number, number];
onRangeChange: (v: [number, number]) => void;
limit: number;
onLimitChange: (n: number) => void;
summaries: SourceSummary[] | undefined;
}
export function Filters({
enabledSources,
onSourceToggle,
rangeMin,
rangeMax,
rangeValue,
onRangeChange,
limit,
onLimitChange,
summaries,
}: Props) {
const summaryFor = (src: Source) => summaries?.find((s) => s.source === src);
return (
<>
<Row className="mb-3">
<Col md={6}>
{ALL_SOURCES.map((src) => {
const sum = summaryFor(src);
const label = sum ? `${src} (${sum.count})` : src;
return (
<Form.Check
key={src}
type="switch"
id={`source-${src}`}
label={label}
checked={enabledSources[src]}
disabled={!sum || sum.count === 0}
onChange={(e) => onSourceToggle(src, e.target.checked)}
/>
);
})}
</Col>
<Col md={6}>
<label style={{ fontSize: '70%' }}>
number of activities to display: {limit}
</label>
<Slider
value={limit}
min={10}
max={1000}
onChange={(v) => onLimitChange(Array.isArray(v) ? v[0] : v)}
/>
</Col>
</Row>
<Row className="mb-3">
<Col>
<Slider
range
allowCross={false}
value={rangeValue}
min={rangeMin}
max={rangeMax}
onChange={(v) => {
if (Array.isArray(v) && v.length === 2) {
onRangeChange([v[0], v[1]]);
}
}}
/>
<p className="text-center" style={{ fontSize: '85%' }}>
<em>{formatDate(rangeValue[0])}</em> to{' '}
<em>{formatDate(rangeValue[1])}</em>
</p>
</Col>
</Row>
</>
);
}
function formatDate(ts: number): string {
return new Date(ts)
.toLocaleDateString('en-GB', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
.toLowerCase();
}

View File

@@ -0,0 +1,90 @@
import ReactMarkdown from 'react-markdown';
import { VerticalTimelineElement } from 'react-vertical-timeline-component';
import type { TimelineBody, TimelineItem, TitleSegment } from '../api/client';
import { colorFor, iconFor } from '../lib/icon';
interface Props {
item: TimelineItem;
}
export function TimelineEntry({ item }: Props) {
const Icon = iconFor(item.icon);
const date = formatDate(item.occurred_at);
return (
<VerticalTimelineElement
date={date}
iconStyle={{ background: colorFor(item.icon), color: '#fff' }}
icon={<Icon />}
>
<h4 className="vertical-timeline-element-title">
{renderSegments(item.title)}
</h4>
{item.subtitle && (
<h5 className="vertical-timeline-element-subtitle">
{renderSegments(item.subtitle)}
</h5>
)}
{item.body && <Body body={item.body} />}
</VerticalTimelineElement>
);
}
function Body({ body }: { body: TimelineBody }) {
switch (body.kind) {
case 'markdown':
return <ReactMarkdown>{body.text}</ReactMarkdown>;
case 'commits':
return (
<ul style={{ listStyle: 'none', paddingLeft: 0 }}>
{body.commits.map((c) => (
<li key={c.sha}>
<a href={c.url} target="_blank" rel="noopener noreferrer">
<code>{c.short_sha}</code>
</a>{' '}
{c.message}
</li>
))}
</ul>
);
case 'links':
return (
<ul>
{body.items.map((seg, i) => (
<li key={i}>{renderSegment(seg, i)}</li>
))}
</ul>
);
}
}
function renderSegments(segments: TitleSegment[]) {
return segments.map((seg, i) => renderSegment(seg, i));
}
function renderSegment(seg: TitleSegment, i: number) {
if (seg.kind === 'link') {
return (
<a key={i} href={seg.url} target="_blank" rel="noopener noreferrer">
{seg.text}
</a>
);
}
return <span key={i}>{seg.text}</span>;
}
function formatDate(iso: string): string {
const d = new Date(iso);
const date = d
.toLocaleDateString('en-GB', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
.toLowerCase();
const time = d
.toLocaleTimeString('en-GB', { timeZoneName: 'short' })
.toLowerCase();
return `${date}${time}`;
}

56
ui/src/lib/icon.tsx Normal file
View File

@@ -0,0 +1,56 @@
import {
ArrowLeftRight,
ArrowUpCircle,
ArrowsAngleContract,
Bug,
ChatLeft,
CodeSquare,
DashCircle,
Diagram3,
ExclamationCircle,
PlusCircle,
StarFill,
Tag,
Wrench,
} from 'react-bootstrap-icons';
import type { TimelineIcon } from '../api/client';
const map: Record<TimelineIcon, typeof Wrench> = {
'git-push': ArrowUpCircle,
'git-commit': CodeSquare,
'git-merge': ArrowsAngleContract,
'git-fork': Diagram3,
'git-branch-create': PlusCircle,
'git-branch-delete': DashCircle,
'pull-request': ArrowLeftRight,
issue: ExclamationCircle,
comment: ChatLeft,
star: StarFill,
release: Tag,
bug: Bug,
generic: Wrench,
};
const colors: Record<TimelineIcon, string> = {
'git-push': '#2e7d32',
'git-commit': '#1565c0',
'git-merge': '#6a1b9a',
'git-fork': '#1565c0',
'git-branch-create': '#2e7d32',
'git-branch-delete': '#c62828',
'pull-request': '#1565c0',
issue: '#ef6c00',
comment: '#1565c0',
star: '#f9a825',
release: '#6a1b9a',
bug: '#c62828',
generic: '#546e7a',
};
export function iconFor(name: TimelineIcon) {
return map[name] ?? Wrench;
}
export function colorFor(name: TimelineIcon) {
return colors[name] ?? colors.generic;
}

21
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);