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:
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user