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