Restructure routes: / and /dash show a project overview dashboard, /activity hosts the existing timeline, /cv remains. Shared Layout component provides consistent nav header and footer across all routes. New /v1/projects endpoint aggregates per-repo activity stats (commits, issues, PRs, date range) from existing event data via SQL. Dashboard ranks projects by weighted recency + volume score and renders a card grid. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
108 lines
3.0 KiB
TypeScript
108 lines
3.0 KiB
TypeScript
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 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 (
|
|
<>
|
|
<CvHeader />
|
|
<div className="d-flex align-items-center gap-2">
|
|
<Spinner animation="border" role="status" size="sm" />
|
|
<span>loading cv…</span>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<CvHeader />
|
|
<Alert variant="warning">cv unavailable: no sections in config</Alert>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<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>
|
|
</>
|
|
);
|
|
}
|