feat(ui): add /cv route, site-wide lowercase, no-cookies footer

reproduces the legacy cv (previously at grenade.github.io/cv) as a
react-router /cv route, fetched at runtime from the same gist. moves
the lowercase aesthetic from per-element overrides to a single body-
level rule so a future toggle can flip it from one place. adds a small
site-wide footer noting why no cookie consent banner is shown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 17:22:44 +03:00
parent 8867ff5df3
commit 4c8a663288
13 changed files with 765 additions and 122 deletions

View File

@@ -18,6 +18,7 @@
"react-bootstrap-icons": "^1.11.4", "react-bootstrap-icons": "^1.11.4",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-router-dom": "^7.14.2",
"react-vertical-timeline-component": "^3.6.0" "react-vertical-timeline-component": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {

45
ui/pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
react-markdown: react-markdown:
specifier: ^9.0.1 specifier: ^9.0.1
version: 9.1.0(@types/react@19.2.14)(react@19.2.5) version: 9.1.0(@types/react@19.2.14)(react@19.2.5)
react-router-dom:
specifier: ^7.14.2
version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-vertical-timeline-component: react-vertical-timeline-component:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0(react@19.2.5) version: 3.6.0(react@19.2.5)
@@ -593,6 +596,10 @@ packages:
comma-separated-tokens@2.0.3: comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -867,6 +874,23 @@ packages:
'@types/react': '>=18' '@types/react': '>=18'
react: '>=18' react: '>=18'
react-router-dom@7.14.2:
resolution: {integrity: sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react-router@7.14.2:
resolution: {integrity: sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react-stately@3.46.0: react-stately@3.46.0:
resolution: {integrity: sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==} resolution: {integrity: sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==}
peerDependencies: peerDependencies:
@@ -899,6 +923,9 @@ packages:
scheduler@0.27.0: scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1378,6 +1405,8 @@ snapshots:
comma-separated-tokens@2.0.3: {} comma-separated-tokens@2.0.3: {}
cookie@1.1.1: {}
csstype@3.2.3: {} csstype@3.2.3: {}
debug@4.4.3: debug@4.4.3:
@@ -1841,6 +1870,20 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
react-router-dom@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-router: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
cookie: 1.1.1
react: 19.2.5
set-cookie-parser: 2.7.2
optionalDependencies:
react-dom: 19.2.5(react@19.2.5)
react-stately@3.46.0(react@19.2.5): react-stately@3.46.0(react@19.2.5):
dependencies: dependencies:
'@internationalized/date': 3.12.1 '@internationalized/date': 3.12.1
@@ -1920,6 +1963,8 @@ snapshots:
scheduler@0.27.0: {} scheduler@0.27.0: {}
set-cookie-parser@2.7.2: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {} space-separated-tokens@2.0.2: {}

View File

@@ -1,6 +1,7 @@
body { body {
background-color: #2c3e50; background-color: #2c3e50;
color: #ecf0f1; color: #ecf0f1;
text-transform: lowercase;
} }
.container { .container {
@@ -36,3 +37,11 @@ a.hot-pink {
.vertical-timeline-element-content a { .vertical-timeline-element-content a {
color: #1565c0; color: #1565c0;
} }
.site-footer {
margin-top: 3rem;
padding: 1rem 0;
font-size: 0.75rem;
opacity: 0.6;
text-align: center;
}

View File

@@ -1,132 +1,24 @@
import { useMemo, useState } from 'react'; import { Routes, Route } from 'react-router-dom';
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 'bootstrap/dist/css/bootstrap.min.css';
import 'rc-slider/assets/index.css'; import 'rc-slider/assets/index.css';
import 'react-vertical-timeline-component/style.min.css'; import 'react-vertical-timeline-component/style.min.css';
import './App.css'; import './App.css';
import { fetchEvents, fetchSources, type Source } from './api/client'; import { TimelineHome } from './pages/TimelineHome';
import { Filters } from './components/Filters'; import { CvPage } from './pages/CvPage';
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://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' },
];
export default function App() { 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 ( return (
<Container className="py-4"> <>
<Row className="mb-3"> <Routes>
<Col> <Route path="/" element={<TimelineHome />} />
<h1>hi, i'm rob</h1> <Route path="/cv" element={<CvPage />} />
</Col> </Routes>
<Col className="d-flex flex-wrap gap-3 justify-content-end align-items-center"> <footer className="site-footer">
{externalLinks.map((el) => ( no cookies are set or read by this site, which is why no consent banner
<a is shown.
key={el.url} </footer>
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>
); );
} }

82
ui/src/api/cv.ts Normal file
View File

@@ -0,0 +1,82 @@
// Fetches the CV gist at runtime and returns the parsed config + file
// list. The legacy implementation (cv/src/App.js) hits the same endpoint
// and relies entirely on the inline `content` field — no per-file raw_url
// fetches. We do the same: one request, dedup'd via TanStack Query.
const GIST_OWNER = 'grenade';
const GIST_ID = '8e487477663c8e57c7bf31e8371f454a';
const GIST_API_URL = `https://api.github.com/gists/${GIST_ID}`;
const GIST_RAW_BASE = `https://gist.githubusercontent.com/${GIST_OWNER}/${GIST_ID}/raw`;
export const CV_PHOTO_URL = `${GIST_RAW_BASE}/rob.png`;
const CONFIG_FILENAME = 'cv-config.json';
export type SectionPlacement = 'body' | 'nav';
export type SortDirection = 'ascending' | 'descending';
export interface CvSectionConfig {
name: string;
filename_prefix: string;
order: number;
show_section_name: boolean;
placement: SectionPlacement;
sort?: {
on: 'filename';
direction: SortDirection;
};
}
export interface CvConfig {
sections: CvSectionConfig[];
}
export interface GistFile {
filename: string;
type: string;
language: string | null;
raw_url: string;
size: number;
truncated: boolean;
content: string;
}
interface GistResponse {
files: Record<string, GistFile>;
}
export interface CvData {
config: CvConfig;
files: Record<string, GistFile>;
}
export async function fetchCv(): Promise<CvData> {
const resp = await fetch(GIST_API_URL);
if (!resp.ok) {
throw new Error(`gist: HTTP ${resp.status} ${resp.statusText}`);
}
const gist = (await resp.json()) as GistResponse;
const cfgFile = gist.files[CONFIG_FILENAME];
if (!cfgFile) {
throw new Error(`gist: missing ${CONFIG_FILENAME}`);
}
const config = JSON.parse(cfgFile.content) as CvConfig;
return { config, files: gist.files };
}
// Pick out the gist files whose names start with the given prefix, applying
// the section's sort order. Mirrors the legacy filter at cv/src/App.js:67-68.
export function filesForSection(
data: CvData,
section: CvSectionConfig,
): GistFile[] {
const matches = Object.keys(data.files)
.filter((name) => name.startsWith(section.filename_prefix))
.sort();
if (section.sort?.direction === 'descending') {
matches.reverse();
}
return matches.map((name) => data.files[name]);
}

View File

@@ -0,0 +1,22 @@
import { Link } from 'react-router-dom';
import Image from 'react-bootstrap/Image';
import { CV_PHOTO_URL } from '../../api/cv';
export function CvHeader() {
return (
<div className="cv-header d-flex flex-column flex-md-row align-items-md-center gap-3 mb-4">
<Image
src={CV_PHOTO_URL}
alt="rob"
roundedCircle
className="cv-photo"
/>
<div className="flex-grow-1">
<h1 className="mb-1">curriculum vitae</h1>
<Link to="/" className="hot-pink">
timeline
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import ReactMarkdown from 'react-markdown';
import Card from 'react-bootstrap/Card';
import { type CvSectionConfig, type GistFile } from '../../api/cv';
import { entryAnchorId } from '../../lib/cvDates';
interface Props {
section: CvSectionConfig;
files: GistFile[];
}
// Pipe-delimited fields (e.g. "email | phone | github, linkedin" in the
// contact section) become one paragraph per field, so each lands on its own
// line with a paragraph gap. Within each pipe-segment, comma-separated values
// are stacked with a soft line break (markdown ` \n` -> `<br/>`) so multiple
// emails / phones / urls each get their own line at a tighter spacing.
function splitPipes(content: string): string {
return content
.split('\n')
.map((line) => {
if (!line.includes(' | ')) return line;
return line
.split(' | ')
.map((segment) =>
segment.includes(', ') ? segment.split(', ').join(' \n') : segment,
)
.join('\n\n');
})
.join('\n');
}
// Renders a single section. Each .md file becomes its own block. When
// `show_section_name` is true (e.g. experience, education) the entries are
// wrapped in cards and given anchor ids so the timeline sidebar can deep-link
// to them; otherwise (e.g. summary, contact) they render as flat markdown.
export function CvSection({ section, files }: Props) {
return (
<section id={section.name} className="cv-section mb-4">
{section.show_section_name && <h2 className="cv-section-name">{section.name}</h2>}
{files.map((file) => {
const content = section.show_section_name
? file.content
: splitPipes(file.content);
if (section.show_section_name) {
return (
<div
key={file.filename}
id={entryAnchorId(section.name, file.content)}
className="cv-entry mb-3"
>
<Card className="cv-card">
<Card.Body>
<ReactMarkdown>{content}</ReactMarkdown>
</Card.Body>
</Card>
</div>
);
}
return (
<div key={file.filename} className="cv-entry">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
);
})}
</section>
);
}

View File

@@ -0,0 +1,98 @@
import type { ReactNode } from 'react';
import {
VerticalTimeline,
VerticalTimelineElement,
} from 'react-vertical-timeline-component';
import { type CvData, filesForSection } from '../../api/cv';
import { entryAnchorId, parseEntryHeader } from '../../lib/cvDates';
interface Props {
data: CvData;
}
// Tiny inline parser: turns "[text](url)" segments into <a> elements while
// leaving surrounding text alone. Used so the timeline title renders the
// company name as plain text and the linked website as an external link
// (matching how a markdown parser would render the same source).
function renderInlineLinks(text: string): ReactNode[] {
const parts: ReactNode[] = [];
const re = /\[([^\]]+)\]\(([^)]+)\)/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m.index > last) parts.push(text.slice(last, m.index));
parts.push(
<a key={m.index} href={m[2]} target="_blank" rel="noopener noreferrer">
{m[1]}
</a>,
);
last = m.index + m[0].length;
}
if (last < text.length) parts.push(text.slice(last));
return parts;
}
// Sidebar timeline rendered from every body section that has `show_section_name`
// (i.e. timeline-eligible sections — experience and education in the current
// gist). Each element offers a small "→" link to the matching anchor in the
// body; the title and subtitle preserve any inline markdown links so they
// behave as proper external anchors.
export function CvTimeline({ data }: Props) {
const elements = data.config.sections
.filter((s) => s.placement === 'body' && s.show_section_name)
.flatMap((section) =>
filesForSection(data, section).map((file) => ({ section, file })),
);
if (elements.length === 0) return null;
return (
<div className="cv-timeline">
<h2 className="cv-section-name">timeline</h2>
<VerticalTimeline layout="1-column-left" lineColor="#ecf0f1">
{elements.map(({ section, file }) => {
const parsed = parseEntryHeader(file.content);
const anchor = `#${entryAnchorId(section.name, file.content)}`;
return (
<VerticalTimelineElement
key={file.filename}
date={parsed.interval.replace(/\s*\([^)]*\)/g, '')}
iconStyle={
parsed.iconUrl
? { background: '#ffffff', boxShadow: 'none' }
: { background: '#ff4081', color: '#ffffff' }
}
icon={
parsed.iconUrl ? (
<img
src={parsed.iconUrl}
alt={parsed.title}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
borderRadius: '50%',
padding: 4,
}}
/>
) : undefined
}
>
<h3 className="vertical-timeline-element-title">
{renderInlineLinks(parsed.titleMd)}
</h3>
{parsed.locationRoleMd && (
<h4 className="vertical-timeline-element-subtitle">
{renderInlineLinks(parsed.locationRoleMd)}
</h4>
)}
<a href={anchor} className="cv-timeline-anchor">
details
</a>
</VerticalTimelineElement>
);
})}
</VerticalTimeline>
</div>
);
}

91
ui/src/lib/cvDates.ts Normal file
View File

@@ -0,0 +1,91 @@
// Normalizes the date interval line of a CV entry. The legacy implementation
// at cv/src/App.js:139 chained .replace() calls per month name; this collapses
// that into a single regex pass.
const MONTH_ABBREV: Record<string, string> = {
january: 'jan',
february: 'feb',
march: 'mar',
april: 'apr',
// may stays "may"
june: 'jun',
july: 'jul',
august: 'aug',
september: 'sep',
october: 'oct',
november: 'nov',
december: 'dec',
};
const MONTH_RE = new RegExp(
`\\b(${Object.keys(MONTH_ABBREV).join('|')})\\b`,
'gi',
);
// Strip leading/trailing markdown # / whitespace, abbreviate months. Casing
// is left to the body-level `text-transform: lowercase` so a future toggle can
// flip it from a single place.
export function normalizeInterval(line: string): string {
return line
.replace(/^[\s#]+|[\s#]+$/g, '')
.replace(MONTH_RE, (m) => MONTH_ABBREV[m.toLowerCase()] ?? m);
}
// Strip markdown link syntax (e.g. "[text](url)") down to just the text.
export function stripMdLinks(s: string): string {
return s.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
}
// Parse the title / location-role / interval triple out of a CV entry's
// markdown content. If the first line embeds a PNG URL (icon-style entry),
// indices shift down by one.
export interface ParsedHeader {
// Plain text, markdown links stripped — for alt= attributes and similar.
title: string;
locationRole: string;
// Markdown source with leading #s/whitespace stripped — for inline rendering
// so [text](url) links render as proper anchors.
titleMd: string;
locationRoleMd: string;
iconUrl: string | null;
interval: string;
}
export function parseEntryHeader(content: string): ParsedHeader {
const lines = content.split('\n');
const firstLine = lines[0] ?? '';
const hasIcon = firstLine.includes('.png');
const iconUrl = hasIcon
? (firstLine.match(/https:[^ )\]]+\.png/)?.[0] ?? null)
: null;
const titleLine = hasIcon ? (lines[1] ?? '') : firstLine;
const locRoleLine = hasIcon ? (lines[2] ?? '') : (lines[1] ?? '');
const intervalLine = hasIcon ? (lines[3] ?? '') : (lines[2] ?? '');
const titleMd = titleLine.replace(/^[\s#]+|[\s#]+$/g, '');
const locationRoleMd = locRoleLine.replace(/^[\s#]+|[\s#]+$/g, '');
return {
title: stripMdLinks(titleMd),
locationRole: stripMdLinks(locationRoleMd),
titleMd,
locationRoleMd,
iconUrl,
interval: normalizeInterval(intervalLine),
};
}
// Anchor id for an entry: combines the section name and a slug of the title
// line. Mirrors the legacy id format at cv/src/App.js:71.
export function entryAnchorId(sectionName: string, content: string): string {
const lines = content.split('\n');
const firstLine = lines[0] ?? '';
const titleLine = firstLine.includes('.png') ? (lines[1] ?? '') : firstLine;
const slug = stripMdLinks(titleLine.replace(/^[\s#]+|[\s#]+$/g, ''))
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
return `${sectionName}-${slug}`;
}

View File

@@ -1,5 +1,6 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App'; import App from './App';
@@ -15,7 +16,9 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<App /> <BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>,
); );

98
ui/src/pages/CvPage.css Normal file
View File

@@ -0,0 +1,98 @@
.cv-photo {
width: 96px;
height: 96px;
object-fit: cover;
}
.cv-section-name {
margin-bottom: 0.75rem;
border-bottom: 1px solid rgba(236, 241, 241, 0.2);
padding-bottom: 0.25rem;
}
.cv-card {
background-color: #34495e;
color: #ecf0f1;
border: 1px solid rgba(236, 241, 241, 0.1);
}
.cv-card a {
color: #ff80ab;
}
.cv-card a:hover {
color: #ff4081;
}
.cv-card img {
max-width: 96px;
max-height: 48px;
background-color: #ffffff;
padding: 4px;
border-radius: 4px;
margin-bottom: 0.75rem;
}
.cv-card h3 {
font-size: 1.25rem;
}
.cv-card h4 {
font-size: 1.05rem;
opacity: 0.9;
}
.cv-card h5 {
font-size: 0.95rem;
opacity: 0.75;
font-style: italic;
}
.cv-timeline .vertical-timeline-element-content {
background-color: #34495e;
color: #ecf0f1;
box-shadow: 0 3px 0 #ff4081;
}
.cv-timeline .vertical-timeline-element-content a {
color: #ff80ab;
}
.cv-timeline .vertical-timeline-element-content a:hover {
color: #ff4081;
}
.cv-timeline h3.vertical-timeline-element-title {
font-size: 0.95rem;
margin: 0;
}
.cv-timeline h4.vertical-timeline-element-subtitle {
font-size: 0.8rem;
opacity: 0.85;
margin: 0.15rem 0 0.4rem;
font-weight: normal;
}
.cv-timeline .cv-timeline-anchor {
font-size: 0.75rem;
opacity: 0.85;
float: right;
margin-left: 0.75rem;
}
.cv-timeline .vertical-timeline-element-content::before {
border-right-color: #34495e;
border-left-color: #34495e;
}
.cv-timeline .vertical-timeline-element-date {
color: #ecf0f1 !important;
opacity: 0.8;
}
@media (max-width: 991px) {
.cv-timeline {
margin-top: 2rem;
}
}

108
ui/src/pages/CvPage.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import Alert from 'react-bootstrap/Alert';
import Col from 'react-bootstrap/Col';
import Container from 'react-bootstrap/Container';
import Row from 'react-bootstrap/Row';
import Spinner from 'react-bootstrap/Spinner';
import { fetchCv, filesForSection } from '../api/cv';
import { CvHeader } from '../components/cv/CvHeader';
import { CvSection } from '../components/cv/CvSection';
import { CvTimeline } from '../components/cv/CvTimeline';
import './CvPage.css';
export function CvPage() {
const { hash } = useLocation();
const cvQ = useQuery({
queryKey: ['cv-gist'],
queryFn: fetchCv,
staleTime: 5 * 60_000,
});
// Scroll to the anchored entry once the gist resolves and the section
// body has rendered its ids. Re-runs if the user changes the hash while
// already on the page.
useEffect(() => {
if (!cvQ.data || !hash) return;
const target = document.getElementById(hash.slice(1));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, [cvQ.data, hash]);
if (cvQ.isLoading) {
return (
<Container className="py-4">
<CvHeader />
<div className="d-flex align-items-center gap-2">
<Spinner animation="border" role="status" size="sm" />
<span>loading cv</span>
</div>
</Container>
);
}
if (cvQ.isError) {
const msg = (cvQ.error as Error).message;
const rateHint = /403|rate limit/i.test(msg)
? ' (github limits unauthenticated requests to 60/hour per ip — try again shortly)'
: '';
return (
<Container className="py-4">
<CvHeader />
<Alert variant="danger">
<Alert.Heading>cv unavailable</Alert.Heading>
<p className="mb-2">
{msg}
{rateHint}
</p>
<button className="btn btn-outline-light" onClick={() => cvQ.refetch()}>
retry
</button>
</Alert>
</Container>
);
}
const data = cvQ.data!;
const bodySections = data.config.sections.filter((s) => s.placement === 'body');
const navSections = data.config.sections.filter((s) => s.placement === 'nav');
if (bodySections.length === 0 && navSections.length === 0) {
return (
<Container className="py-4">
<CvHeader />
<Alert variant="warning">cv unavailable: no sections in config</Alert>
</Container>
);
}
return (
<Container className="py-4">
<CvHeader />
<Row>
<Col lg={9} className="cv-body">
{bodySections.map((section) => (
<CvSection
key={section.name}
section={section}
files={filesForSection(data, section)}
/>
))}
</Col>
<Col lg={3} className="cv-sidebar">
{navSections.map((section) => (
<CvSection
key={section.name}
section={section}
files={filesForSection(data, section)}
/>
))}
<CvTimeline data={data} />
</Col>
</Row>
</Container>
);
}

View File

@@ -0,0 +1,128 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
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 { 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://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' },
];
export function TimelineHome() {
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{' '}
<Link className="hot-pink" to="/cv">
/cv
</Link>
. 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>
);
}