Refactored ArticleHeader

This commit is contained in:
2026-09-30 12:24:27 +02:00
parent 9aa43737d2
commit 90b5390795
30 changed files with 398 additions and 71 deletions

View File

@@ -11,8 +11,6 @@ type Props = {
size?: FigureSize
effect?: ImageEffect
ratio?: AspectRatio
eager?: boolean
class?: string
}
const LAYOUT: Record<FigureSize, { widths: number[]; sizes: string }> = {
@@ -25,11 +23,9 @@ const {
alt,
caption,
credit,
eager,
size = 'inline',
effect = 'none',
ratio = 'original',
class: className,
} = Astro.props
---
@@ -39,10 +35,9 @@ const {
`figure--${size}`,
effect !== 'none' && `effect--${effect}`,
ratio !== 'original' && `ratio--${ratio}`,
className,
]}
>
<ContentImage src={image} alt={alt} eager={eager} {...LAYOUT[size]} />
<ContentImage src={image} alt={alt} {...LAYOUT[size]} />
{(caption || credit) && (
<figcaption class:list={['figure__caption', size !== 'inline' && 'dark']}>
{caption && <span class="figure__text">{caption}</span>}

View File

@@ -18,13 +18,15 @@ const crumbs = trailOf(article)
const published = article.published
---
<header>
<header class="article-header">
<Overline
crumbs={crumbs}
stamp={published ? toMilitaryDTG(published) : 'No publication date set'}
datetime={published?.toISOString()}
/>
<div class="article-header__panel">
<Title title={article.title} subtitle={article.subtitle} />
<Cover cover={article.summary.cover} />
<Meta article={article} />
<Cover cover={article.summary.cover} />
</div>
</header>

View File

@@ -1,10 +1,34 @@
---
import Figure from '@/components/media/Figure.astro'
import ContentImage from '@/components/media/ContentImage.astro'
import type { Article } from '@/lib/site/types'
type Props = { cover: Article['summary']['cover'] }
type Props = {
cover: Article['summary']['cover']
}
const { image, ...rest } = Astro.props.cover
const { image, alt, caption, credit, effect, ratio } = Astro.props.cover
---
{image && <Figure image={image} {...rest} size="wide" eager />}
{image && (
<figure
class:list={[
'article-header__cover',
effect !== 'none' && `effect--${effect}`,
ratio !== 'original' && `ratio--${ratio}`,
]}
>
<ContentImage
src={image}
alt={alt}
widths={[800, 1600, 2400]}
sizes="(min-width: 80rem) 74rem, 100vw"
eager
/>
{(caption || credit) && (
<figcaption class="article-header__caption dark">
{caption && <span>{caption}</span>}
{credit && <span class="article-header__credit">{credit}</span>}
</figcaption>
)}
</figure>
)}

View File

@@ -1,4 +1,5 @@
---
import StageBadge from '@/components/post/Header/StageBadge.astro'
import { toMilitaryDTG } from '@/lib/content/dates'
import { getSite } from '@/lib/site'
import type { Article } from '@/lib/site/types'
@@ -11,40 +12,62 @@ const { article } = Astro.props
const site = await getSite()
const tags = article.tags.map((id) => site.tags.get(id)).filter((tag) => tag !== undefined)
const date = article.updated ?? article.published
const label = article.updated ? 'Updated' : 'Published'
const machine = date?.toISOString()
const display = date && toMilitaryDTG(date)
const dateLabel = article.updated ? 'Updated' : 'Published'
---
<dl class="meta">
<div class="section">
<dt class="label">Author</dt>
<dd class="item">Dave Damage</dd>
</div>
{tags.length > 0 && (
<div class="section">
<dt class="label">Tags</dt>
<dl class="article-header__meta">
<div class="article-header__cell">
<dt class="article-header__label">
<span class="article-header__index" aria-hidden="true">
02
</span>
State
</dt>
<dd>
<ul class="tags">
<StageBadge stage={article.stage} />
</dd>
</div>
<div class="article-header__cell">
<dt class="article-header__label">
<span class="article-header__index" aria-hidden="true">
03
</span>
{dateLabel}
</dt>
<dd class="article-header__value">
{date ? <time datetime={date.toISOString()}>{toMilitaryDTG(date)}</time> : 'Undated'}
</dd>
</div>
<div class="article-header__cell">
<dt class="article-header__label">
<span class="article-header__index" aria-hidden="true">
04
</span>
Author
</dt>
<dd class="article-header__value">Dave Damage</dd>
</div>
<div class="article-header__cell">
<dt class="article-header__label">
<span class="article-header__index" aria-hidden="true">
05
</span>
Tags
</dt>
<dd class="article-header__value">
{tags.length > 0 ? (
<ul class="article-header__tags">
{tags.map((tag) => (
<li class="tag">
<a href={tag.path} rel="tag">
<li>
<a class="article-header__tag" href={tag.path} rel="tag">
{tag.title}
</a>
</li>
))}
</ul>
) : (
'None'
)}
</dd>
</div>
)}
{display && (
<div class="section">
<dt class="label">{label}</dt>
<dd>
<time class="item" datetime={machine}>
{display}
</time>
</dd>
</div>
)}
</dl>

View File

@@ -0,0 +1,13 @@
---
import { STAGE_OPTIONS, type Stage } from '@/lib/constants/stage'
type props = {
stage: Stage
}
const { stage } = Astro.props
const label = STAGE_OPTIONS.find((option) => option.value === stage)?.label ?? stage
---
<span class:list={['stage-badge', `stage-badge--${stage}`]}>{label}</span>

View File

@@ -7,7 +7,11 @@ type Props = {
const { title, subtitle } = Astro.props
---
<div class="wrapper content">
<h1 class="title">{title}</h1>
{subtitle && <p class="subtitle">{subtitle}</p>}
<div class="article-header__designation">
<p class="article-header__label" aria-hidden="true">
<span class="article-header__index">01</span>
Designation
</p>
<h1 class="article-header__title">{title}</h1>
{subtitle && <p class="article-header__subtitle">{subtitle}</p>}
</div>

View File

@@ -1,6 +1,6 @@
import { collection, fields } from '@keystatic/core'
import { base } from '@/keystatic/fields/content/base'
import { base, stage } from '@/keystatic/fields/content/base'
import { content } from '@/keystatic/fields/content/content'
import { dates } from '@/keystatic/fields/content/dates'
import { articleRoute } from '@/keystatic/fields/content/route'
@@ -15,9 +15,10 @@ export const articles = collection({
contentField: 'content',
},
entryLayout: 'content',
columns: ['title', 'status'],
columns: ['title', 'status', 'stage'],
schema: {
...base,
stage,
route: articleRoute,
summary: articleSummary(ARTICLES),
dates,

View File

@@ -1,5 +1,6 @@
import { fields } from '@keystatic/core'
import { STAGE_OPTIONS } from '@/lib/constants/stage'
import { STATUS_OPTIONS } from '@/lib/constants/status'
import { SEGMENT, SEGMENT_MESSAGE, slugify } from '@/lib/content/slug'
@@ -33,6 +34,13 @@ export const status = fields.select({
defaultValue: 'draft',
})
export const stage = fields.select({
label: 'Stage',
description: 'How finished the text is. Separate from status, which controls visibility',
options: STAGE_OPTIONS,
defaultValue: 'stub',
})
export const noindex = fields.checkbox({
label: 'Hide from search engines',
})

View File

@@ -20,7 +20,7 @@ const meta = metaOf(article)
<BaseLayout {...meta}>
<article class="article">
<!--<ArticleHeader article={article} />-->
<ArticleHeader article={article} />
<div class="frame">
<div class="rail">
<ToC headings={rendered.headings} />

View File

@@ -0,0 +1,20 @@
export const STAGE_OPTIONS = [
{
label: 'Stub',
value: 'stub',
},
{
label: 'Developing',
value: 'developing',
},
{
label: 'Complete',
value: 'complete',
},
{
label: 'Ongoing',
value: 'ongoing',
},
] as const
export type Stage = (typeof STAGE_OPTIONS)[number]['value']

View File

@@ -35,7 +35,7 @@ export async function render(
for (const { lines, error } of Markdoc.validate(node, config)) {
const level = LEVEL[error.level as keyof typeof LEVEL]
if (level) problems[level](collection, id, `Text line ${lines[0] + 1}: ${error.message}`)
if (level) problems[level](collection, id, `Content line ${lines[0] + 1}: ${error.message}`)
}
const tree = Markdoc.transform(node, config)
for (const { level, message } of state.problems) problems[level](collection, id, message)

View File

@@ -0,0 +1,11 @@
@import url("quote.css");
@import url("lead.css");
@import url("callout.css");
@import url("reveal.css");
@import url("table-wrapper.css");
@import url("footnote.css");
@import url("sidenote.css");
@import url("figure.css");
@import url("code.css");
@import url("terms.css");
@import url("spoiler.css");

View File

@@ -61,9 +61,8 @@
.quote__source {
font-style: normal;
}
}
@media (--bp-desktop) {
@media (--bp-desktop) {
.quote {
@mixin me calc(-1 * (var(--layout-gutter) + var(--layout-margin-width)));
@@ -76,4 +75,5 @@
gap: var(--size-2);
align-self: end;
}
}
}

View File

@@ -1,12 +1,3 @@
@import url("prose.css");
@import url("quote.css");
@import url("lead.css");
@import url("callout.css");
@import url("reveal.css");
@import url("table-wrapper.css");
@import url("footnote.css");
@import url("sidenote.css");
@import url("figure.css");
@import url("code.css");
@import url("terms.css");
@import url("spoiler.css");
@import url("prose/index.css");
@import url("blocks/index.css");
@import url("post/index.css");

View File

@@ -0,0 +1,171 @@
@layer components.blocks {
.article-header__panel {
@mixin mx auto;
@mixin mt var(--size-10);
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--border-card);
width: min(100% - 2 * var(--layout-page-margin), var(--layout-wrapper-width));
border: var(--border-card) solid var(--color-border-strong);
background-color: var(--color-border-strong);
@media (--bp-desktop) {
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
}
}
.article-header__designation {
@mixin pa var(--size-9);
display: flex;
flex-direction: column;
gap: var(--size-10);
background-color: var(--color-surface-elevated-1);
}
.article-header__label {
@mixin typo_eyebrow;
@mixin ma var(--size-0);
color: var(--color-text-secondary);
}
.article-header__index {
&::after {
content: "⟫" / "";
margin-inline: var(--size-2);
font-family: var(--font-body);
}
}
.article-header__title {
@mixin mt auto;
@mixin mb var(--size-0);
font-family: var(--font-display);
font-size: clamp(var(--type-9), 5vw, var(--size-14));
font-weight: var(--font-weight-display);
line-height: 1;
color: var(--color-text-primary);
letter-spacing: var(--tracking-display);
}
.article-header__subtitle {
@mixin ma var(--size-0);
font-family: var(--font-header);
font-size: var(--type-1);
font-weight: var(--font-weight-body);
line-height: var(--leading-1);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: var(--tracking-subtitle);
}
.article-header__meta {
@mixin ma var(--size-0);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--border-card);
}
.article-header__cell {
@mixin pa var(--size-9);
display: flex;
flex-direction: column;
gap: var(--size-8);
background-color: var(--color-surface-elevated-1);
& dd {
@mixin ma var(--size-0);
}
}
.article-header__value {
font-family: var(--font-mono);
font-size: var(--type-0);
line-height: var(--leading-0);
color: var(--color-text-primary);
text-transform: uppercase;
& time {
font-weight: var(--font-weight-emphasis);
}
}
.article-header__tags {
@mixin ma var(--size-0);
@mixin pa var(--size-0);
display: flex;
flex-direction: column;
gap: var(--size-2);
align-items: flex-start;
list-style: none;
}
.article-header__tag {
@mixin py var(--size-1);
@mixin px var(--size-3);
display: block;
font-size: var(--type--2);
font-weight: var(--font-weight-emphasis);
line-height: var(--leading--2);
color: var(--color-text-inverse);
letter-spacing: var(--tracking-tag);
background-color: var(--color-surface-inverse);
transition: background-color var(--motion-fast) var(--ease-out);
&:hover {
color: var(--color-text-on-accent);
background-color: var(--color-primary);
}
&:focus-visible {
@mixin focus-ring;
}
}
.article-header__cover {
@mixin ma var(--size-0);
grid-column: 1 / -1;
& img {
display: block;
aspect-ratio: var(--figure-ratio, auto);
width: 100%;
height: auto;
object-fit: cover;
}
}
.article-header__caption {
@mixin typo_code;
@mixin py var(--size-4);
@mixin px var(--size-9);
display: flex;
flex-wrap: wrap;
gap: var(--size-2) var(--size-6);
justify-content: center;
color: var(--color-text-primary);
text-align: center;
background-color: var(--color-surface-base);
}
.article-header__credit {
color: var(--color-text-secondary);
}
}

View File

@@ -0,0 +1,2 @@
@import url("article-header.css");
@import url("stage-badge.css");

View File

@@ -0,0 +1,52 @@
@layer components.blocks {
.stage-badge {
display: inline-flex;
gap: var(--size-3);
align-items: baseline;
font-family: var(--font-mono);
font-size: var(--type--1);
line-height: 1;
color: var(--color-text-primary);
text-transform: uppercase;
letter-spacing: var(--tracking-tag);
}
.stage-badge--stub,
.stage-badge--developing {
@mixin py var(--size-2);
@mixin px var(--size-3);
color: var(--color-text-on-accent);
background-color: var(--color-tertiary);
&::before {
content: "";
width: 1cap;
height: 1cap;
background-color: currentcolor;
}
}
.stage-badge--stub {
&::before {
clip-path: polygon(
evenodd,
50% 0,
100% 100%,
0 100%,
50% 0,
50% 36%,
21% 86%,
79% 86%,
50% 36%
);
}
}
.stage-badge--developing {
&::before {
clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
}
}

View File

@@ -0,0 +1 @@
@import url("prose.css");

View File

@@ -99,3 +99,12 @@
line-height: var(--leading-prose-tight);
letter-spacing: var(--tracking--1);
}
@define-mixin typo_eyebrow {
font-family: var(--font-mono);
font-size: var(--type--3);
font-weight: var(--font-weight-body);
line-height: var(--leading--3);
text-transform: uppercase;
letter-spacing: var(--tracking-eyebrow);
}