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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@
|
|||||||
/ui/node_modules
|
/ui/node_modules
|
||||||
/ui/dist
|
/ui/dist
|
||||||
/ui/.vite
|
/ui/.vite
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# rendered configs (templates committed, rendered output never)
|
# rendered configs (templates committed, rendered output never)
|
||||||
/asset/config/*.toml
|
/asset/config/*.toml
|
||||||
|
|||||||
13
ui/index.html
Normal file
13
ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>rob.tn</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
ui/package.json
Normal file
31
ui/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "moments-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"bootstrap": "^5.3.3",
|
||||||
|
"rc-slider": "^11.1.7",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-bootstrap": "^2.10.6",
|
||||||
|
"react-bootstrap-icons": "^1.11.4",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
|
"react-vertical-timeline-component": "^3.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@types/react-vertical-timeline-component": "^3.3.6",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||||
|
"typescript": "~5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
2027
ui/pnpm-lock.yaml
generated
Normal file
2027
ui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
ui/src/App.css
Normal file
38
ui/src/App.css
Normal 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
135
ui/src/App.tsx
Normal 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
84
ui/src/api/client.ts
Normal 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();
|
||||||
|
}
|
||||||
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}`;
|
||||||
|
}
|
||||||
56
ui/src/lib/icon.tsx
Normal file
56
ui/src/lib/icon.tsx
Normal 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
21
ui/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
21
ui/tsconfig.app.json
Normal file
21
ui/tsconfig.app.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
ui/tsconfig.json
Normal file
7
ui/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
15
ui/tsconfig.node.json
Normal file
15
ui/tsconfig.node.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
19
ui/vite.config.ts
Normal file
19
ui/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react-swc';
|
||||||
|
|
||||||
|
// In dev, the UI is served by Vite at :5173 and proxies `/api/*` to the
|
||||||
|
// moments-api binary at :8080 (default). In prod, nginx serves the static
|
||||||
|
// build and reverse-proxies the same `/api/*` to the API backend, so the
|
||||||
|
// frontend's URL shape is identical in both environments.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user