diff --git a/ui/package.json b/ui/package.json index 153fbd1..c43dce7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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": { diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index ac47957..47d0658 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -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: {} diff --git a/ui/src/App.css b/ui/src/App.css index 64441d9..6eeab1e 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -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; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 56be828..51e0841 100644 --- a/ui/src/App.tsx +++ b/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>({ - 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(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 ( - - - -

hi, i'm rob

- - - {externalLinks.map((el) => ( - - {el.alt} - - ))} - -
- - -

- i rarely say anything that warrants capital letters. if you're here - to see my resume, please go to{' '} - - https://rob.tn/cv - - . a peek into the projects i'm working on is below. -

- -
- - - setEnabledSources((prev) => ({ ...prev, [s]: on })) - } - rangeMin={RANGE_MIN} - rangeMax={RANGE_MAX} - rangeValue={rangeValue} - onRangeChange={setRangeValue} - limit={limit} - onLimitChange={setLimit} - summaries={sourcesQ.data} - /> - - - -

- {eventsQ.isLoading - ? 'loading…' - : eventsQ.isError - ? `error: ${(eventsQ.error as Error).message}` - : `showing ${events.length} ${events.length === 1 ? 'activity' : 'activities'}`} -

- - {events.map((item) => ( - - ))} - - -
-
+ <> + + } /> + } /> + +
+ no cookies are set or read by this site, which is why no consent banner + is shown. +
+ ); } diff --git a/ui/src/api/cv.ts b/ui/src/api/cv.ts new file mode 100644 index 0000000..50c5a7a --- /dev/null +++ b/ui/src/api/cv.ts @@ -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; +} + +export interface CvData { + config: CvConfig; + files: Record; +} + +export async function fetchCv(): Promise { + 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]); +} diff --git a/ui/src/components/cv/CvHeader.tsx b/ui/src/components/cv/CvHeader.tsx new file mode 100644 index 0000000..da2b1af --- /dev/null +++ b/ui/src/components/cv/CvHeader.tsx @@ -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 ( +
+ rob +
+

curriculum vitae

+ + ← timeline + +
+
+ ); +} diff --git a/ui/src/components/cv/CvSection.tsx b/ui/src/components/cv/CvSection.tsx new file mode 100644 index 0000000..ecab308 --- /dev/null +++ b/ui/src/components/cv/CvSection.tsx @@ -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` -> `
`) 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.show_section_name &&

{section.name}

} + {files.map((file) => { + const content = section.show_section_name + ? file.content + : splitPipes(file.content); + if (section.show_section_name) { + return ( +
+ + + {content} + + +
+ ); + } + return ( +
+ {content} +
+ ); + })} +
+ ); +} diff --git a/ui/src/components/cv/CvTimeline.tsx b/ui/src/components/cv/CvTimeline.tsx new file mode 100644 index 0000000..f7afb9d --- /dev/null +++ b/ui/src/components/cv/CvTimeline.tsx @@ -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 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( + + {m[1]} + , + ); + 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 ( +
+

timeline

+ + {elements.map(({ section, file }) => { + const parsed = parseEntryHeader(file.content); + const anchor = `#${entryAnchorId(section.name, file.content)}`; + return ( + + ) : undefined + } + > +

+ {renderInlineLinks(parsed.titleMd)} +

+ {parsed.locationRoleMd && ( +

+ {renderInlineLinks(parsed.locationRoleMd)} +

+ )} + + → details + +
+ ); + })} +
+
+ ); +} diff --git a/ui/src/lib/cvDates.ts b/ui/src/lib/cvDates.ts new file mode 100644 index 0000000..e765f81 --- /dev/null +++ b/ui/src/lib/cvDates.ts @@ -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 = { + 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}`; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index eea9825..d198950 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -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( - + + + , ); diff --git a/ui/src/pages/CvPage.css b/ui/src/pages/CvPage.css new file mode 100644 index 0000000..636d02a --- /dev/null +++ b/ui/src/pages/CvPage.css @@ -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; + } +} diff --git a/ui/src/pages/CvPage.tsx b/ui/src/pages/CvPage.tsx new file mode 100644 index 0000000..ba3479e --- /dev/null +++ b/ui/src/pages/CvPage.tsx @@ -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 ( + + +
+ + loading cv… +
+
+ ); + } + + 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 ( + + + + cv unavailable +

+ {msg} + {rateHint} +

+ +
+
+ ); + } + + 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 ( + + + cv unavailable: no sections in config + + ); + } + + return ( + + + + + {bodySections.map((section) => ( + + ))} + + + {navSections.map((section) => ( + + ))} + + + + + ); +} diff --git a/ui/src/pages/TimelineHome.tsx b/ui/src/pages/TimelineHome.tsx new file mode 100644 index 0000000..1d1f339 --- /dev/null +++ b/ui/src/pages/TimelineHome.tsx @@ -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>({ + 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(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 ( + + + +

hi, i'm rob

+ + + {externalLinks.map((el) => ( + + {el.alt} + + ))} + +
+ + +

+ i rarely say anything that warrants capital letters. if you're here + to see my resume, please go to{' '} + + /cv + + . a peek into the projects i'm working on is below. +

+ +
+ + + setEnabledSources((prev) => ({ ...prev, [s]: on })) + } + rangeMin={RANGE_MIN} + rangeMax={RANGE_MAX} + rangeValue={rangeValue} + onRangeChange={setRangeValue} + limit={limit} + onLimitChange={setLimit} + summaries={sourcesQ.data} + /> + + + +

+ {eventsQ.isLoading + ? 'loading…' + : eventsQ.isError + ? `error: ${(eventsQ.error as Error).message}` + : `showing ${events.length} ${events.length === 1 ? 'activity' : 'activities'}`} +

+ + {events.map((item) => ( + + ))} + + +
+
+ ); +}