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:
@@ -18,6 +18,7 @@
|
||||
"react-bootstrap-icons": "^1.11.4",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react-vertical-timeline-component": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
45
ui/pnpm-lock.yaml
generated
45
ui/pnpm-lock.yaml
generated
@@ -32,6 +32,9 @@ importers:
|
||||
react-markdown:
|
||||
specifier: ^9.0.1
|
||||
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:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0(react@19.2.5)
|
||||
@@ -593,6 +596,10 @@ packages:
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
cookie@1.1.1:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
@@ -867,6 +874,23 @@ packages:
|
||||
'@types/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:
|
||||
resolution: {integrity: sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==}
|
||||
peerDependencies:
|
||||
@@ -899,6 +923,9 @@ packages:
|
||||
scheduler@0.27.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1378,6 +1405,8 @@ snapshots:
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
debug@4.4.3:
|
||||
@@ -1841,6 +1870,20 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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):
|
||||
dependencies:
|
||||
'@internationalized/date': 3.12.1
|
||||
@@ -1920,6 +1963,8 @@ snapshots:
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
body {
|
||||
background-color: #2c3e50;
|
||||
color: #ecf0f1;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -36,3 +37,11 @@ a.hot-pink {
|
||||
.vertical-timeline-element-content a {
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: 3rem;
|
||||
padding: 1rem 0;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
134
ui/src/App.tsx
134
ui/src/App.tsx
@@ -1,132 +1,24 @@
|
||||
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 { Routes, Route } from 'react-router-dom';
|
||||
|
||||
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://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' },
|
||||
];
|
||||
import { TimelineHome } from './pages/TimelineHome';
|
||||
import { CvPage } from './pages/CvPage';
|
||||
|
||||
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>
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/" element={<TimelineHome />} />
|
||||
<Route path="/cv" element={<CvPage />} />
|
||||
</Routes>
|
||||
<footer className="site-footer">
|
||||
no cookies are set or read by this site, which is why no consent banner
|
||||
is shown.
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
82
ui/src/api/cv.ts
Normal file
82
ui/src/api/cv.ts
Normal 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]);
|
||||
}
|
||||
22
ui/src/components/cv/CvHeader.tsx
Normal file
22
ui/src/components/cv/CvHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
ui/src/components/cv/CvSection.tsx
Normal file
66
ui/src/components/cv/CvSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
ui/src/components/cv/CvTimeline.tsx
Normal file
98
ui/src/components/cv/CvTimeline.tsx
Normal 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
91
ui/src/lib/cvDates.ts
Normal 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}`;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
|
||||
@@ -15,7 +16,9 @@ const queryClient = new QueryClient({
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
98
ui/src/pages/CvPage.css
Normal file
98
ui/src/pages/CvPage.css
Normal 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
108
ui/src/pages/CvPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
ui/src/pages/TimelineHome.tsx
Normal file
128
ui/src/pages/TimelineHome.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user