diff --git a/src/components/ArticleList.astro b/src/components/ArticleList.astro new file mode 100644 index 0000000..791686e --- /dev/null +++ b/src/components/ArticleList.astro @@ -0,0 +1,53 @@ +--- +import type { CollectionEntry } from 'astro:content'; +import EntryThumbnail from './EntryThumbnail.astro'; +import TagList from './TagList.astro'; +import { ARTICLE_THUMBNAIL, articlePath, formatDate, formatDateShort } from '../lib/site'; + +interface Props { + posts: CollectionEntry<'posts'>[]; + showYear?: boolean; + tagLimit?: number; + // Opt-in: eagerly load the first thumbnail. Only set when the list is + // reliably above the fold (home, tag pages). Lists below substantial + // content (related, archives by year, 404) should leave this off. + eagerFirstThumbnail?: boolean; +} + +const { posts, showYear = true, tagLimit = 3, eagerFirstThumbnail = false } = Astro.props; +--- + +
    + { + posts.map((post, index) => { + const href = articlePath(post); + return ( +
  1. + + + +
  2. + ); + }) + } +
diff --git a/src/components/AtAGlance.astro b/src/components/AtAGlance.astro new file mode 100644 index 0000000..c5417ea --- /dev/null +++ b/src/components/AtAGlance.astro @@ -0,0 +1,50 @@ +--- +import type { CollectionEntry } from 'astro:content'; +import ProjectLinks from './ProjectLinks.astro'; + +type Link = CollectionEntry<'projects'>['data']['links'][number]; + +interface Props { + role?: string; + projectPeriod?: string; + stack?: string[]; + scale?: string; + outcome?: string; + links?: Link[]; + headingId: string; +} + +const { + role, + projectPeriod, + stack = [], + scale, + outcome, + links = [], + headingId, +} = Astro.props; + +const rows: Array<[string, string]> = []; +if (role) rows.push(['Role', role]); +if (projectPeriod) rows.push(['Period', projectPeriod]); +if (stack.length > 0) rows.push(['Stack', stack.join(', ')]); +if (scale) rows.push(['Scale', scale]); +if (outcome) rows.push(['Outcome', outcome]); +--- + +{ + rows.length > 0 && ( + + ) +} diff --git a/src/components/Breadcrumbs.astro b/src/components/Breadcrumbs.astro new file mode 100644 index 0000000..ba039af --- /dev/null +++ b/src/components/Breadcrumbs.astro @@ -0,0 +1,33 @@ +--- +interface Crumb { + href?: string; + label: string; +} + +interface Props { + items: Crumb[]; +} + +const { items } = Astro.props; +const lastIndex = items.length - 1; +--- + + diff --git a/src/components/EntryThumbnail.astro b/src/components/EntryThumbnail.astro new file mode 100644 index 0000000..90eae9e --- /dev/null +++ b/src/components/EntryThumbnail.astro @@ -0,0 +1,56 @@ +--- +import type { ImageMetadata } from 'astro'; +import { Picture } from 'astro:assets'; + +interface Props { + src: ImageMetadata; + alt: string; + href?: string; + class?: string; + widths: number[]; + sizes: string; + loading?: 'lazy' | 'eager'; + fetchpriority?: 'high' | 'low' | 'auto'; + ariaLabel?: string; + // When the listing already has a focusable, screen-reader-visible title + // link, the thumbnail link is visually duplicative. We keep it clickable + // for pointer users but drop it from the tab order. The link still needs + // a name because some assistive tech exposes non-tabbable links. + decorative?: boolean; +} + +const { + src, + alt, + href, + class: extraClass, + widths, + sizes, + loading = 'lazy', + fetchpriority, + ariaLabel, + decorative = true, +} = Astro.props; + +const Tag = href ? 'a' : 'div'; +const isDecorativeLink = Boolean(href) && decorative; +--- + + + + diff --git a/src/components/Footer.astro b/src/components/Footer.astro new file mode 100644 index 0000000..45f2c8f --- /dev/null +++ b/src/components/Footer.astro @@ -0,0 +1,32 @@ +--- +import { navItems, site } from '../lib/site'; + +const year = new Date().getFullYear(); + +// Footer shows all nav items except Home (which is implicit via the site title). +const footerNavItems = navItems.filter((item) => item.href !== '/'); +--- + + diff --git a/src/components/Header.astro b/src/components/Header.astro new file mode 100644 index 0000000..115432c --- /dev/null +++ b/src/components/Header.astro @@ -0,0 +1,128 @@ +--- +import { navItems, site } from '../lib/site'; + +const currentPath = Astro.url.pathname; +const current = + currentPath === '/' || currentPath.endsWith('/') || /\.[^/]+$/.test(currentPath) + ? currentPath + : `${currentPath}/`; + +// Exact match for the current page; section match (descendant URLs) for +// ancestor links. `aria-current="page"` is reserved for the exact page, +// `"true"` indicates an ancestor section. +function currentState(href: string): 'page' | 'true' | undefined { + if (current === href) return 'page'; + if (href !== '/' && current.startsWith(href)) return 'true'; + return undefined; +} + +// Header shows nav items except Home and footer-only entries. RSS lives as a +// dedicated icon link to the right of the nav. +const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.footerOnly); +--- + + + + + + + diff --git a/src/components/PostMedia.astro b/src/components/PostMedia.astro new file mode 100644 index 0000000..2a5f425 --- /dev/null +++ b/src/components/PostMedia.astro @@ -0,0 +1,30 @@ +--- +import type { CollectionEntry } from 'astro:content'; +import PostMediaFigure from './PostMediaFigure.astro'; + +type MediaItem = CollectionEntry<'posts'>['data']['media'][number]; + +interface Props { + items: MediaItem[]; +} + +const { items } = Astro.props; + +// Wrap in a gallery `