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

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