Files
Cozy-Den/src/pages/blog/[...slug].astro
T
Latte 7e3299e3bd
CI / ci (push) Successful in 29s
CI / ci (pull_request) Successful in 29s
Docker / docker (pull_request) Successful in 17s
Add blog utilities and tag pages
Introduce formatBlogDate, slugifyTag and getTagHref in src/lib/blog.ts
and
a reading time helper getReadingTime in src/lib/readingTime.ts. Update
blog index and post pages to display ISO dates, reading times and link
tags to /blog/tag/<slug>. Add a tag listing page at
src/pages/blog/tag/[tag].astro with styles and static paths generation
2026-03-07 17:46:15 +01:00

716 lines
20 KiB
Plaintext

---
import BaseLayout from "../../layouts/BaseLayout.astro";
import { getCollection, type CollectionEntry } from "astro:content";
import { formatBlogDate, getTagHref } from "../../lib/blog";
import { getReadingTime } from "../../lib/readingTime";
type BlogPost = CollectionEntry<"blog">;
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.slug },
props: {
post,
seriesPosts: post.data.series
? posts
.filter(
(candidate) =>
candidate.data.series?.name ===
post.data.series?.name,
)
.sort((a, b) => {
const partDifference =
(a.data.series?.part ?? Number.MAX_SAFE_INTEGER) -
(b.data.series?.part ?? Number.MAX_SAFE_INTEGER);
if (partDifference !== 0) return partDifference;
return (
a.data.pubDate.valueOf() -
b.data.pubDate.valueOf()
);
})
: [],
relatedPosts: posts
.filter((candidate) => candidate.slug !== post.slug)
.map((candidate) => ({
post: candidate,
score:
(candidate.data.series?.name &&
candidate.data.series.name === post.data.series?.name
? 10
: 0) +
candidate.data.tags.filter((tag) =>
post.data.tags.includes(tag),
).length,
}))
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return (
b.post.data.pubDate.valueOf() -
a.post.data.pubDate.valueOf()
);
})
.filter((candidate) => candidate.score > 0)
.slice(0, 2)
.map((candidate) => candidate.post),
},
}));
}
const {
post,
seriesPosts = [],
relatedPosts = [],
} = Astro.props as {
post: BlogPost;
seriesPosts: BlogPost[];
relatedPosts: BlogPost[];
};
const { Content } = await post.render();
const readingTime = getReadingTime(post.body);
---
<BaseLayout
title={`${post.data.title} - Hidden Den Cafe`}
description={post.data.description}
>
<div class="matrix-bg" aria-hidden="true"></div>
<main class="main">
<div class="container">
<header class="header fade-in">
{
post.data.series && (
<p class="series-label">
{post.data.series.name} #{post.data.series.part}
</p>
)
}
<h1 class="title">{post.data.title}</h1>
<div class="meta-row">
<time
class="date"
datetime={formatBlogDate(post.data.pubDate)}
>
{formatBlogDate(post.data.pubDate)}
</time>
<span class="meta-sep" aria-hidden="true">·</span>
<span class="reading-time">{readingTime.text}</span>
</div>
{
post.data.tags.length > 0 && (
<div
class="tag-list"
aria-label={`${post.data.title} tags`}
>
{post.data.tags.map((tag) => (
<a class="tag" href={getTagHref(tag)}>
{tag}
</a>
))}
</div>
)
}
<div class="divider">==============================</div>
</header>
<article class="content fade-in">
<Content />
</article>
{
post.data.series && (
<section
class="series fade-in"
aria-labelledby="series-posts"
>
<div class="series-head">
<h2 id="series-posts">{post.data.series.name}</h2>
<a href="/blog" class="series-link">
Browse the blog
</a>
</div>
<p class="series-note">
This post is part of an ongoing series about
building warm, intentional places on the web.
</p>
<ol class="series-list">
{seriesPosts.map((seriesPost) => (
<li
class:list={[
"series-item",
{
current:
seriesPost.slug === post.slug,
},
]}
>
{seriesPost.slug === post.slug ? (
<span class="series-current">
#{seriesPost.data.series?.part}{" "}
{seriesPost.data.title}
</span>
) : (
<a
href={`/blog/${seriesPost.slug}`}
class="series-entry"
>
<span class="series-part">
#{seriesPost.data.series?.part}
</span>
<span>{seriesPost.data.title}</span>
</a>
)}
</li>
))}
</ol>
<p class="series-footnote">
Future Coffee & Code entries will show up here as
they are written.
</p>
</section>
)
}
{
relatedPosts.length > 0 && (
<section
class="related fade-in"
aria-labelledby="related-posts"
>
<div class="related-head">
<h2 id="related-posts">Related Reading</h2>
<a href="/blog" class="related-link">
Back to blog
</a>
</div>
<ul class="related-list">
{relatedPosts.map((relatedPost) => (
<li class="related-item">
<a
href={`/blog/${relatedPost.slug}`}
class="related-card"
>
<div class="related-meta">
<span class="related-date">
{formatBlogDate(
relatedPost.data.pubDate,
)}
</span>
<span
class="related-sep"
aria-hidden="true"
>
·
</span>
<span class="related-reading-time">
{
getReadingTime(
relatedPost.body,
).text
}
</span>
</div>
<h3>{relatedPost.data.title}</h3>
<p>{relatedPost.data.description}</p>
</a>
</li>
))}
</ul>
</section>
)
}
<footer class="footer fade-in">
<p>Made with love by Latte</p>
</footer>
</div>
</main>
</BaseLayout>
<style>
.matrix-bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
opacity: 0.03;
background:
linear-gradient(var(--color-accent) 1px, transparent 1px),
linear-gradient(90deg, var(--color-accent) 1px, transparent 1px);
background-size: 50px 50px;
animation: grid-move 20s linear infinite;
}
@keyframes grid-move {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(50px, 50px);
}
}
.main {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-lg) var(--space-md);
padding-top: calc(var(--space-lg) + 3rem);
}
.container {
max-width: 700px;
width: 100%;
background: var(--color-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-surface);
border-radius: 8px;
padding: var(--space-xl);
}
.header {
margin-bottom: var(--space-lg);
}
.series-label {
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.78rem;
margin-bottom: var(--space-xs);
}
.divider {
color: var(--color-surface);
text-align: center;
font-size: 0.75rem;
margin: var(--space-md) 0;
user-select: none;
}
.title {
font-size: 1.6rem;
font-weight: 700;
letter-spacing: 1px;
margin-bottom: var(--space-xs);
}
.date {
color: var(--color-text-dim);
font-size: 0.85rem;
}
.meta-row {
display: flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-text-dim);
font-size: 0.85rem;
}
.meta-sep {
color: var(--color-surface);
}
.reading-time {
color: var(--color-accent);
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
.tag {
font-size: 0.72rem;
color: var(--color-accent);
border: 1px solid var(--color-surface);
padding: 1px 6px;
border-radius: 999px;
text-decoration: none;
}
.tag:hover {
color: var(--color-accent-bright);
border-color: color-mix(in srgb, var(--color-accent) 45%, transparent);
}
.content {
color: var(--color-text);
line-height: 1.8;
}
.content :global(h2) {
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--color-accent);
margin: var(--space-lg) 0 var(--space-sm);
}
.content :global(h3) {
font-size: 1rem;
color: var(--color-accent-bright);
margin: var(--space-md) 0 var(--space-xs);
}
.content :global(p) {
margin-bottom: var(--space-md);
color: var(--color-text-dim);
}
.content :global(a) {
color: var(--color-blue);
}
.content :global(a:hover) {
color: var(--color-accent-bright);
}
.content :global(code) {
font-family: inherit;
background: var(--color-bg);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85em;
color: var(--color-green);
}
.content :global(pre) {
background: var(--color-bg);
border: 1px solid var(--color-surface);
border-radius: 6px;
padding: var(--space-md);
overflow-x: auto;
margin-bottom: var(--space-md);
}
.content :global(pre code) {
background: none;
padding: 0;
font-size: 0.85rem;
}
.content :global(ul),
.content :global(ol) {
padding-left: var(--space-lg);
margin-bottom: var(--space-md);
color: var(--color-text-dim);
}
.content :global(li) {
margin-bottom: var(--space-xs);
}
.content :global(blockquote) {
border-left: 3px solid var(--color-accent);
padding-left: var(--space-md);
margin: var(--space-md) 0;
color: var(--color-text-dim);
font-style: italic;
}
.content :global(hr) {
border: none;
border-top: 1px solid var(--color-surface);
margin: var(--space-lg) 0;
}
.content :global(img) {
max-width: 100%;
border-radius: 6px;
margin: var(--space-md) 0;
border: 1px solid var(--color-surface);
display: block;
}
.content :global(figure) {
margin: var(--space-md) 0;
}
.content :global(figcaption) {
font-size: 0.8rem;
color: var(--color-text-dim);
text-align: center;
margin-top: var(--space-xs);
}
.series {
margin-top: var(--space-xl);
padding: var(--space-lg);
border-radius: 8px;
border: 1px solid
color-mix(in srgb, var(--color-accent) 28%, transparent);
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--color-accent) 9%, transparent),
transparent 70%
),
color-mix(in srgb, var(--color-bg-light) 84%, transparent);
}
.series-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.series-head h2 {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--color-accent);
}
.series-link {
color: var(--color-blue);
font-size: 0.85rem;
white-space: nowrap;
}
.series-link:hover {
color: var(--color-accent-bright);
}
.series-note,
.series-footnote {
color: var(--color-text-dim);
line-height: 1.7;
}
.series-note {
margin-bottom: var(--space-md);
}
.series-footnote {
margin-top: var(--space-md);
font-size: 0.9rem;
}
.series-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding-left: 1.25rem;
}
.series-item {
color: var(--color-text-dim);
}
.series-entry,
.series-current {
display: inline-flex;
flex-wrap: wrap;
gap: var(--space-xs);
line-height: 1.6;
}
.series-entry {
color: var(--color-blue);
}
.series-entry:hover {
color: var(--color-accent-bright);
}
.series-current {
color: var(--color-text);
}
.series-part {
color: var(--color-accent);
}
.related {
margin-top: var(--space-xl);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-surface);
}
.related-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.related-head h2 {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--color-accent);
}
.related-link {
color: var(--color-blue);
font-size: 0.85rem;
white-space: nowrap;
}
.related-link:hover {
color: var(--color-accent-bright);
}
.related-list {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.related-card {
display: block;
padding: var(--space-md);
border-radius: 8px;
background: color-mix(in srgb, var(--color-bg-light) 88%, transparent);
border: 1px solid
color-mix(in srgb, var(--color-surface) 70%, transparent);
transition:
border-color 0.2s ease,
transform 0.2s ease,
box-shadow 0.2s ease;
}
.related-card:hover {
border-color: color-mix(in srgb, var(--color-accent) 55%, transparent);
transform: translateY(-2px);
box-shadow: 0 0 0 1px
color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.related-date {
color: var(--color-text-dim);
font-size: 0.8rem;
}
.related-meta {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-bottom: var(--space-xs);
}
.related-sep {
color: var(--color-surface);
}
.related-reading-time {
color: var(--color-accent);
font-size: 0.78rem;
}
.related-card h3 {
font-size: 1rem;
color: var(--color-accent-bright);
margin-bottom: var(--space-xs);
}
.related-card p {
color: var(--color-text-dim);
line-height: 1.7;
}
.footer {
margin-top: var(--space-xl);
text-align: center;
}
.footer p {
color: var(--color-text-dim);
font-size: 0.85rem;
}
.fade-in {
animation: fadeIn 0.6s ease-out forwards;
opacity: 0;
}
.fade-in:nth-child(1) {
animation-delay: 0.1s;
}
.fade-in:nth-child(2) {
animation-delay: 0.2s;
}
.fade-in:nth-child(3) {
animation-delay: 0.3s;
}
.fade-in:nth-child(4) {
animation-delay: 0.4s;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 600px) {
.container {
padding: var(--space-md);
}
.title {
font-size: 1.3rem;
}
.divider {
font-size: 0.6rem;
}
.related-head {
flex-direction: column;
align-items: flex-start;
}
.series-head {
flex-direction: column;
align-items: flex-start;
}
.meta-row,
.related-meta {
flex-wrap: wrap;
}
}
@media (prefers-reduced-motion: reduce) {
.matrix-bg {
animation: none;
}
.fade-in {
animation: none;
opacity: 1;
}
.related-card {
transition: none;
}
.related-card:hover {
transform: none;
box-shadow: none;
}
}
</style>