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:
98
ui/src/components/Filters.tsx
Normal file
98
ui/src/components/Filters.tsx
Normal 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();
|
||||
}
|
||||
90
ui/src/components/TimelineEntry.tsx
Normal file
90
ui/src/components/TimelineEntry.tsx
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user