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

View File

@@ -18,13 +18,15 @@ const crumbs = trailOf(article)
const published = article.published const published = article.published
--- ---
<header> <header class="article-header">
<Overline <Overline
crumbs={crumbs} crumbs={crumbs}
stamp={published ? toMilitaryDTG(published) : 'No publication date set'} stamp={published ? toMilitaryDTG(published) : 'No publication date set'}
datetime={published?.toISOString()} datetime={published?.toISOString()}
/> />
<div class="article-header__panel">
<Title title={article.title} subtitle={article.subtitle} /> <Title title={article.title} subtitle={article.subtitle} />
<Cover cover={article.summary.cover} />
<Meta article={article} /> <Meta article={article} />
<Cover cover={article.summary.cover} />
</div>
</header> </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' 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 { toMilitaryDTG } from '@/lib/content/dates'
import { getSite } from '@/lib/site' import { getSite } from '@/lib/site'
import type { Article } from '@/lib/site/types' import type { Article } from '@/lib/site/types'
@@ -11,40 +12,62 @@ const { article } = Astro.props
const site = await getSite() const site = await getSite()
const tags = article.tags.map((id) => site.tags.get(id)).filter((tag) => tag !== undefined) const tags = article.tags.map((id) => site.tags.get(id)).filter((tag) => tag !== undefined)
const date = article.updated ?? article.published const date = article.updated ?? article.published
const label = article.updated ? 'Updated' : 'Published' const dateLabel = article.updated ? 'Updated' : 'Published'
const machine = date?.toISOString()
const display = date && toMilitaryDTG(date)
--- ---
<dl class="meta"> <dl class="article-header__meta">
<div class="section"> <div class="article-header__cell">
<dt class="label">Author</dt> <dt class="article-header__label">
<dd class="item">Dave Damage</dd> <span class="article-header__index" aria-hidden="true">
</div> 02
{tags.length > 0 && ( </span>
<div class="section"> State
<dt class="label">Tags</dt> </dt>
<dd> <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) => ( {tags.map((tag) => (
<li class="tag"> <li>
<a href={tag.path} rel="tag"> <a class="article-header__tag" href={tag.path} rel="tag">
{tag.title} {tag.title}
</a> </a>
</li> </li>
))} ))}
</ul> </ul>
) : (
'None'
)}
</dd> </dd>
</div> </div>
)}
{display && (
<div class="section">
<dt class="label">{label}</dt>
<dd>
<time class="item" datetime={machine}>
{display}
</time>
</dd>
</div>
)}
</dl> </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 const { title, subtitle } = Astro.props
--- ---
<div class="wrapper content"> <div class="article-header__designation">
<h1 class="title">{title}</h1> <p class="article-header__label" aria-hidden="true">
{subtitle && <p class="subtitle">{subtitle}</p>} <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> </div>

View File

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

View File

@@ -1,5 +1,6 @@
import { fields } from '@keystatic/core' import { fields } from '@keystatic/core'
import { STAGE_OPTIONS } from '@/lib/constants/stage'
import { STATUS_OPTIONS } from '@/lib/constants/status' import { STATUS_OPTIONS } from '@/lib/constants/status'
import { SEGMENT, SEGMENT_MESSAGE, slugify } from '@/lib/content/slug' import { SEGMENT, SEGMENT_MESSAGE, slugify } from '@/lib/content/slug'
@@ -33,6 +34,13 @@ export const status = fields.select({
defaultValue: 'draft', 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({ export const noindex = fields.checkbox({
label: 'Hide from search engines', label: 'Hide from search engines',
}) })

View File

@@ -20,7 +20,7 @@ const meta = metaOf(article)
<BaseLayout {...meta}> <BaseLayout {...meta}>
<article class="article"> <article class="article">
<!--<ArticleHeader article={article} />--> <ArticleHeader article={article} />
<div class="frame"> <div class="frame">
<div class="rail"> <div class="rail">
<ToC headings={rendered.headings} /> <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)) { for (const { lines, error } of Markdoc.validate(node, config)) {
const level = LEVEL[error.level as keyof typeof LEVEL] 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) const tree = Markdoc.transform(node, config)
for (const { level, message } of state.problems) problems[level](collection, id, message) 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,7 +61,6 @@
.quote__source { .quote__source {
font-style: normal; font-style: normal;
} }
}
@media (--bp-desktop) { @media (--bp-desktop) {
.quote { .quote {
@@ -77,3 +76,4 @@
align-self: end; align-self: end;
} }
} }
}

View File

@@ -1,12 +1,3 @@
@import url("prose.css"); @import url("prose/index.css");
@import url("quote.css"); @import url("blocks/index.css");
@import url("lead.css"); @import url("post/index.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

@@ -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); line-height: var(--leading-prose-tight);
letter-spacing: var(--tracking--1); 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);
}