Add blog via Astro content collections
CI / ci (push) Successful in 26s
CI / ci (pull_request) Successful in 27s
Docker / docker (pull_request) Successful in 15s
Enterprise AI Code Review / ai-review (pull_request) Successful in 1m53s
Security / security (pull_request) Successful in 6s

Introduce blog support: content collection schema, listing and post
routes, and a sample Markdown post. Update docs and TODO; add blog
assets dir and adjust color variables in docs. Also set
absolute_redirect off in nginx.conf for container routing.
This commit is contained in:
2026-03-02 19:13:30 +01:00
parent 7fd3a59c3a
commit 077cc06d75
9 changed files with 947 additions and 30 deletions
+336
View File
@@ -0,0 +1,336 @@
---
title: "Love Without Access"
date: 2026-03-01
description: "A reflection on a first love — what it meant, what it cost, and why distance was the most loving thing left."
---
*by LATTE*
There was a time when I thought love was mostly about intensity.
Waking up next to someone and feeling like the world aligned.
Skin against skin.
Breathing in sync.
Power and surrender that were, underneath it all, just different shapes of trust.
This isn't a story about blame.
And it's not a story about anger.
It's a story about something that stayed real…
even after it stopped being reachable.
---
## How It Began
We were friends first.
Maybe that's what made it so deep. It didn't explode into existence — it grew. Slowly. Safely.
From gaming together to talking for hours.
From talking to tension.
From tension to touch.
And then, somehow, we became a home.
Not the perfect kind.
Not the easy kind.
But the kind your body recognizes.
---
## What We Were
From the outside, we looked like a relationship.
On the inside, we were a system of trust.
I was the one who brought structure.
Who checked in.
Who asked, "Are you still okay?"
Who carried responsibility for safety.
What some people would call dominance
was, for me, care.
And for him, surrender wasn't weakness.
It was relief.
But what I only understood later was this:
Closeness regulated me.
Distance regulated him.
And that difference doesn't show up at the start.
You only feel it when life gets heavy and love gets real.
---
## The Fracture
There wasn't an explosion. No dramatic collapse.
There was fatigue.
Doubt.
Silence.
"I don't feel it anymore."
And something in me went very quiet — and very loud — at the same time.
Because I could still feel myself holding on.
And carrying someone who no longer carries back
exhausts you in a way no one sees.
---
## The After That Kept Me Stuck
What made it hardest wasn't just the ending.
It was the ambiguity after.
Still sleeping next to each other.
Still laughing.
Still touching.
But no longer together.
A body that says yes.
Words that say no.
That contradiction doesn't just hurt emotionally — it destabilizes you.
Hope becomes a reflex.
And every time hope collapses, you fracture a little with it.
I tried to understand. I tried to fix.
I thought if I could articulate my love clearly enough, we could find safety again.
But love is not code you can debug
when one of you has already logged out.
---
## The Part Where Safety Changed
And then there was the part people don't like to talk about:
When something private stops being safe.
When the inner room gets opened.
I'm not writing this to accuse you.
I'm writing this because it matters.
Because trust isn't just "did you mean well."
Trust is "did I feel protected."
And after a certain point, I didn't.
That's when I understood something important:
Even love needs a locked door
when the inside of you keeps getting exposed.
---
## What It Did to Me
There were days my body shook.
Days where seeing your name online
made my chest tighten.
Where I felt cold,
then numb,
then flooded.
I don't write that for drama.
I write it because endings are not abstract.
When attachment breaks,
the body reacts.
I wasn't weak.
I was grieving a nervous system bond.
And at some point, I understood something else:
Loving you was real.
But staying in reach of you
was slowly undoing me.
So I chose distance.
Not because I stopped loving you.
But because I had to protect myself.
No access wasn't punishment.
It was survival.
---
## What I Understand Now
Some people are not cruel.
Not cold.
Not heartless.
They're overwhelmed.
And when overwhelm becomes someone's default response to emotional depth,
distance becomes their survival strategy.
For a long time, I wondered if I was "too much."
Too intense. Too deep. Too present.
Now I know:
I wasn't too much.
We were mismatched in emotional capacity.
I wanted co-regulation.
He needed self-regulation.
That's not villain and victim.
That's architecture.
---
## On Being Replaced
Yes — he found someone new quickly.
That hurt.
Not because he doesn't deserve happiness.
But because what we had felt quiet and private,
and what came after looked public and bright.
I questioned whether I was replaceable.
Whether I had simply been a phase.
But love isn't a competition.
What we had lasted years.
It was layered. It was real.
It mattered.
Something ending does not mean it was nothing.
---
## For You
If you ever read this —
I want you to know that what we had was real to me.
Not experimental. Not temporary. Not a placeholder.
Real.
You weren't just someone I loved.
You were my first love.
My first true one.
The first person I chose fully.
The first person I built a life around.
The first person I learned love with.
We were figuring things out together.
Trying. Failing. Adjusting.
Discovering what intimacy meant.
Discovering what we meant.
You weren't just someone who entered my life —
you were part of my becoming.
That matters.
Not in a way that traps either of us.
But in a way that leaves a mark.
And I need to say this clearly:
I was never angry at you.
Not truly. Not even in the hardest moments.
I was hurt.
I was overwhelmed.
I was trying to hold something I didn't yet know how to let go of.
But even then, I wasn't against you.
I saw you as someone struggling — not someone malicious.
There were moments when you softened completely with me.
Moments where you rested your full weight without guarding yourself.
Those moments were real.
You were there.
You chose me then.
And I don't erase that.
Here is the honest truth:
The love didn't vanish.
Access did.
And choosing no access
was the hardest loving decision I've ever made.
Because access requires safety.
And safety requires consistency.
And consistency requires a kind of staying that you couldn't give.
So no, I'm not angry that you couldn't stay.
I still love you.
The love I had doesn't disappear just because access is gone.
It changes shape.
It becomes quieter.
It becomes something I carry instead of something I reach for.
And yes — I'm going to be a little playful about it:
You're on my website. Do you get that?
Not as a spectacle.
Not as a wound.
As a chapter.
I don't delete chapters.
I archive them properly.
You were my first true love.
The first person I learned how to love with.
That doesn't give you access anymore.
But it does give you permanence.
That love still exists.
It just no longer has a door.
---
## For Me
I'm still learning.
That giving doesn't have to be my identity.
That caring doesn't mean abandoning myself.
That intensity isn't a flaw.
I don't need to shrink
to be worthy of being held.
The wolf in me was never meant to become smaller.
Only to find the right pack.
— LATTE
+13
View File
@@ -0,0 +1,13 @@
import { z, defineCollection } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
date: z.coerce.date(),
description: z.string(),
draft: z.boolean().optional().default(false),
}),
});
export const collections = { blog };
+280
View File
@@ -0,0 +1,280 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro";
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
function formatDate(date: Date) {
return date.toISOString().split("T")[0];
}
---
<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">
<p class="back"><a href="/blog">← back to blog</a></p>
<div class="divider">══════════════════════════════</div>
<h1 class="title">{post.data.title}</h1>
<p class="date">{formatDate(post.data.date)}</p>
<div class="divider">══════════════════════════════</div>
</header>
<article class="content fade-in">
<Content />
</article>
<footer class="footer fade-in">
<div class="divider">══════════════════════════════</div>
<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);
}
.container {
max-width: 700px;
width: 100%;
background: rgba(30, 30, 46, 0.8);
backdrop-filter: blur(10px);
border: 1px solid var(--color-surface);
border-radius: 8px;
padding: var(--space-xl);
}
.header {
margin-bottom: var(--space-lg);
}
.back {
margin-bottom: var(--space-sm);
font-size: 0.85rem;
}
.back a {
color: var(--color-text-dim);
}
.back a:hover {
color: var(--color-accent);
}
.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;
}
/* Prose styles for markdown content */
.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);
}
.footer {
margin-top: var(--space-lg);
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; }
@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;
}
}
@media (prefers-reduced-motion: reduce) {
.matrix-bg {
animation: none;
}
.fade-in {
animation: none;
opacity: 1;
}
}
</style>
+241
View File
@@ -0,0 +1,241 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro";
import { getCollection } from "astro:content";
const posts = (await getCollection("blog", ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
function formatDate(date: Date) {
return date.toISOString().split("T")[0];
}
---
<BaseLayout
title="Blog — Hidden Den Cafe"
description="Latte's blog. Thoughts, technical things, and whatever else is on my mind."
>
<div class="matrix-bg" aria-hidden="true"></div>
<main class="main">
<div class="container">
<header class="header fade-in">
<p class="back"><a href="/">← back to home</a></p>
<h1 class="title">Blog</h1>
<div class="divider">══════════════════════════════</div>
</header>
{posts.length === 0 ? (
<section class="section fade-in">
<p class="empty">No posts yet. Will get around to it.</p>
</section>
) : (
<section class="section fade-in">
<ul class="post-list">
{posts.map((post) => (
<li class="post-item">
<span class="post-date">{formatDate(post.data.date)}</span>
<div class="post-info">
<a href={`/blog/${post.slug}`} class="post-title">{post.data.title}</a>
<p class="post-desc">{post.data.description}</p>
</div>
</li>
))}
</ul>
</section>
)}
<footer class="footer fade-in">
<div class="divider">══════════════════════════════</div>
<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);
}
.container {
max-width: 700px;
width: 100%;
background: rgba(30, 30, 46, 0.8);
backdrop-filter: blur(10px);
border: 1px solid var(--color-surface);
border-radius: 8px;
padding: var(--space-xl);
}
.header {
margin-bottom: var(--space-lg);
}
.back {
margin-bottom: var(--space-sm);
font-size: 0.85rem;
}
.back a {
color: var(--color-text-dim);
}
.back a:hover {
color: var(--color-accent);
}
.title {
font-size: 2rem;
font-weight: 700;
letter-spacing: 2px;
text-align: center;
}
.divider {
color: var(--color-surface);
text-align: center;
font-size: 0.75rem;
margin: var(--space-md) 0;
user-select: none;
}
.section {
margin: var(--space-md) 0;
}
.post-list {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.post-item {
display: flex;
gap: var(--space-md);
align-items: flex-start;
}
.post-date {
color: var(--color-text-dim);
font-size: 0.8rem;
white-space: nowrap;
padding-top: 2px;
min-width: 90px;
}
.post-info {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.post-title {
color: var(--color-blue);
font-size: 1rem;
font-weight: 700;
}
.post-title:hover {
color: var(--color-accent-bright);
}
.post-desc {
color: var(--color-text-dim);
font-size: 0.85rem;
line-height: 1.6;
}
.empty {
color: var(--color-text-dim);
font-style: italic;
}
.footer {
margin-top: var(--space-lg);
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; }
@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.5rem;
}
.divider {
font-size: 0.6rem;
}
.post-item {
flex-direction: column;
gap: var(--space-xs);
}
.post-date {
min-width: unset;
}
}
@media (prefers-reduced-motion: reduce) {
.matrix-bg {
animation: none;
}
.fade-in {
animation: none;
opacity: 1;
}
}
</style>
+10
View File
@@ -111,6 +111,16 @@ if (now < new Date(now.getFullYear(), 6, 8)) age--;
</div>
</section>
<div class="divider">══════════════════════════════</div>
<!-- Blog -->
<section class="section fade-in">
<h2>Blog</h2>
<ul class="links">
<li><a href="/blog">blog</a> — thoughts and things</li>
</ul>
</section>
<!-- Footer -->
<footer class="footer fade-in">
<div class="divider">══════════════════════════════</div>