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