Merge pull request 'dev' (#60) from dev into main
CI / ci (push) Successful in 32s
Docker / docker (push) Successful in 23s

Reviewed-on: #60
This commit was merged in pull request #60.
This commit is contained in:
2026-03-08 09:41:14 +00:00
54 changed files with 4665 additions and 177 deletions
+21
View File
@@ -0,0 +1,21 @@
# Required: random secret used to salt IP hashes and sign sessions
# Generate with: openssl rand -hex 32
SECRET_KEY=replace_me_with_a_random_secret
# Required: admin login token for /admin/login
ADMIN_SECRET_TOKEN=replace_me_with_a_long_random_token
# Optional: force cookie secure behavior (`true` or `false`)
# Leave unset for automatic behavior based on NODE_ENV
# COOKIE_SECURE=
# Database path (Docker mounts /data as a named volume)
DB_PATH=/data/guestbook.db
# Server binding
HOST=0.0.0.0
PORT=3000
# --- Development overrides ---
# For local dev (npm run dev), override with:
# COOKIE_SECURE=false
# DB_PATH=./data/guestbook.db
+6 -3
View File
@@ -5,7 +5,7 @@
#
# Detection logic:
# 1. Python: if requirements.txt exists → install deps, lint, test.
# 2. Node/JS: if package.json exists → npm ci, lint, test, build.
# 2. Node/JS: if package.json exists → install deps, lint, test, build.
# 3. Neither detected → print a message and exit 0 (never fail).
#
# Controlled by .ci/config.env:
@@ -194,11 +194,14 @@ jobs:
if: env.HAS_NODE == 'true'
uses: actions/setup-node@v4
with:
node-version: "lts/*"
# Keep CI on Node 20 to match runtime/Docker and better-sqlite3 compatibility.
node-version: "20.x"
- name: Install Node dependencies
if: env.HAS_NODE == 'true'
run: npm ci
run: |
# Lockfile is currently not authoritative; use install to refresh dependency tree.
npm install
# -----------------------------------------------------------------------
# Step 9: Node.js — Lint (only if "lint" script exists in package.json)
+6
View File
@@ -62,3 +62,9 @@ Thumbs.db
!.env.example
*.pem
*.key
# ---- Guestbook SQLite database (use Docker volume in production) ----
data/
*.db
*.db-wal
*.db-shm
+1
View File
@@ -0,0 +1 @@
20
+169
View File
@@ -0,0 +1,169 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Quick Reference
**Project:** Cozy Den - Personal landing page for hiddenden.cafe
**Owner:** Latte (gay furry developer, values self-hosting and privacy)
**Tech Stack:** Astro 4.x, TypeScript, Vanilla CSS, Docker + Nginx
**Aesthetic:** Warm coffee/cappuccino theme, cozy hidden den vibes
**Deployment:** Docker containers pushed to Gitea registry at git.hiddenden.cafe
## Core Design Principles
1. **Cozy Aesthetic** - Warm colors, coffee/cappuccino theme, hidden den vibes
2. **Self-Hosted** - Everything runs on personal infrastructure (homelab/VPS)
3. **Privacy First** - No tracking, no external dependencies
4. **Lightweight** - Static HTML/CSS, minimal JavaScript
5. **Docker-Ready** - Easy deployment via containers
## File Structure
```
cozy-den/
├── src/
│ ├── layouts/
│ │ └── BaseLayout.astro # Base layout + global styles
│ └── pages/
│ ├── index.astro # Main landing page
│ └── 404.astro # Custom 404 page
├── public/
│ ├── favicon.svg # Coffee emoji favicon
│ └── robots.txt # Search engine directives
├── astro.config.mjs # Astro config with sitemap
├── package.json # Dependencies (Astro 4.x, @astrojs/sitemap)
├── Dockerfile # Multi-stage: Node builder + Nginx
├── docker-compose.yml # Local container orchestration
└── nginx.conf # Production web server config
```
## Architecture Notes
This is a simple static site following standard Astro conventions:
- Layouts in `src/layouts/` for reusable page templates
- Pages in `src/pages/` (routes automatically based on filename)
- All content is on a single page (`index.astro`) with multiple sections
- Custom 404 page with cozy theming
- No client-side JavaScript - pure static HTML/CSS output
- CSS custom properties centralized in `BaseLayout.astro` for theming
- Accessibility improvements with ARIA labels and semantic HTML
## Commands
```bash
# Development
npm install # Install dependencies
npm run dev # Start dev server at http://localhost:4321
npm run build # Build for production (runs astro check + astro build)
npm run preview # Preview production build
# Docker
docker build -t cozy-den .
docker run -d -p 3000:80 --name cozy-den cozy-den
docker-compose up -d
# Deployment to Gitea registry
docker tag cozy-den git.hiddenden.cafe/mats/cozy-den:latest
docker login git.hiddenden.cafe
docker push git.hiddenden.cafe/mats/cozy-den:latest
```
## Color System
All colors use CSS custom properties in `BaseLayout.astro`:
```css
--color-bg: #1a1410 /* Dark background (deep coffee) */
--color-bg-light: #2a1f18 /* Lighter background for cards */
--color-text: #f4e9d8 /* Cream text */
--color-text-dim: #c4b5a0 /* Dimmed text */
--color-accent: #d4a574 /* Warm accent (coffee with cream) */
--color-accent-bright: #e8bf8e /* Brighter accent for highlights */
--color-warm: #8b6f47 /* Warm brown for borders/accents */
```
**To change theme:** Edit these variables. All components update automatically.
## Common Modification Patterns
### Adding a Section
```astro
<section class="section new-section">
<div class="container">
<div class="card fade-in">
<h2>Section Title</h2>
<p>Content</p>
</div>
</div>
</section>
```
### Adding a Service
```astro
<div class="service-item">
<h3><a href="https://service.hiddenden.cafe">🔧 Service Name</a></h3>
<p>Description of the service</p>
</div>
```
### Adding a New Page
Create new `.astro` file in `src/pages/`:
```astro
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="New Page">
<div class="container">
<h1>New Page</h1>
</div>
</BaseLayout>
```
Note: Pages route based on filename (e.g., `about.astro``/about`)
## Implementation Guidelines
**DO:**
- Maintain cozy, warm aesthetic (coffee/cappuccino theme)
- Keep site lightweight - static HTML/CSS only, no JavaScript runtime
- Use CSS custom properties for all colors (defined in `src/layouts/BaseLayout.astro`)
- Use `.fade-in` class for animations, `.card` class for consistent card styling
- Test production builds and Docker builds after changes
- Ensure responsive design works on mobile
- Follow standard Astro structure (layouts in `src/layouts/`, pages in `src/pages/`)
**DON'T:**
- Add tracking or external dependencies (privacy-first approach)
- Add client-side JavaScript unless absolutely necessary
- Break the coffee/warm color theme
- Create sterile or corporate design elements
## Astro-Specific Notes
- Frontmatter (code between `---`) runs at build time only
- `<style>` tags are scoped by default; use `<style is:global>` for global styles (see `src/layouts/BaseLayout.astro`)
- Site generates static HTML at build time - no JavaScript runtime
- Sitemap integration configured in `astro.config.mjs` via `@astrojs/sitemap`
- Custom 404 page at `src/pages/404.astro` with warm, themed styling
## Context & Preferences
- **Owner:** Latte (gay furry developer who values self-hosting, privacy, and open-source)
- **Deployment:** All deployments via Docker to personal Gitea registry (git.hiddenden.cafe)
- **Design Philosophy:** Warm, personal, cozy aesthetic over corporate/sterile design
- **Technical Background:** Owner typically uses Python/Flask, learning Astro with this project
## Troubleshooting
**Build fails:** Check TypeScript config, ensure Node 18+, run `astro check`
**Styles not applying:** Verify CSS variables are in `BaseLayout.astro`, check if you need `is:global`
**Docker build fails:** Ensure `package.json` and `package-lock.json` exist
**Changes not showing:** Hard refresh browser, restart dev server, or clear `.astro` cache
## Related Documentation
- **PROJECT_CONTEXT.md** - Design principles and project philosophy
- **DEVELOPMENT.md** - Detailed developer documentation
- **TODO.md** - Current tasks and future feature ideas
- **README.md** - User-facing setup and deployment guide
+9 -6
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Project:** Cozy Den - Personal landing page for hiddenden.cafe
**Owner:** Latte (gay furry developer, values self-hosting and privacy)
**Tech Stack:** Astro 4.x, TypeScript, Vanilla CSS, Docker + Nginx
**Tech Stack:** Astro 4.x (hybrid SSR), TypeScript, Vanilla CSS, SQLite, Docker + Node.js
**Aesthetic:** Warm coffee/cappuccino theme, cozy hidden den vibes
**Deployment:** Docker containers pushed to Gitea registry at git.hiddenden.cafe
@@ -40,14 +40,17 @@ cozy-den/
## Architecture Notes
This is a simple static site following standard Astro conventions:
Astro **hybrid SSR** site — most pages are statically pre-rendered, but guestbook and admin pages are server-rendered:
- Layouts in `src/layouts/` for reusable page templates
- Pages in `src/pages/` (routes automatically based on filename)
- All content is on a single page (`index.astro`) with multiple sections
- Custom 404 page with cozy theming
- No client-side JavaScript - pure static HTML/CSS output
- Server-side lib code in `src/lib/` (db, auth, guestbook, spam)
- API routes in `src/pages/api/` for form handling and admin actions
- CSS custom properties centralized in `BaseLayout.astro` for theming
- Accessibility improvements with ARIA labels and semantic HTML
- `output: 'hybrid'` + `@astrojs/node` adapter — Node.js standalone server in production
- SQLite database (better-sqlite3) for guestbook entries and admin sessions
- Docker runtime is now Node.js (not Nginx); see `docs/guestbook.md` for setup
**Guestbook:** See `docs/guestbook.md` for full setup, token login, and deployment notes.
## Commands
+31 -15
View File
@@ -1,28 +1,44 @@
# Stage 1: Build the Astro app
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
# Install build dependencies for native modules (e.g. better-sqlite3)
RUN apk add --no-cache python3 make g++
COPY package*.json ./
RUN npm install
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the site
RUN npm run build
# Production stage
FROM nginx:alpine
# Stage 2: Install production dependencies only
FROM node:20-alpine AS deps
# Copy built files to nginx
COPY --from=builder /app/dist /usr/share/nginx/html
WORKDIR /app
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
RUN apk add --no-cache python3 make g++
EXPOSE 80
COPY package*.json ./
RUN npm install --omit=dev
CMD ["nginx", "-g", "daemon off;"]
# Stage 3: Runtime image
FROM node:20-alpine AS runtime
WORKDIR /app
# Data directory for SQLite database
RUN mkdir -p /data
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
ENV DB_PATH=/data/guestbook.db
EXPOSE 3000
CMD ["node", "dist/server/entry.mjs"]
+5
View File
@@ -1,9 +1,14 @@
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import node from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
site: 'https://hiddenden.cafe',
output: 'hybrid',
adapter: node({
mode: 'standalone',
}),
integrations: [
sitemap({
changefreq: 'weekly',
+12 -3
View File
@@ -1,11 +1,20 @@
version: '3.8'
services:
cozy-den:
build: .
container_name: cozy-den
ports:
- "3000:80"
- "3000:3000"
restart: unless-stopped
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=3000
- DB_PATH=/data/guestbook.db
- SECRET_KEY=${SECRET_KEY}
- ADMIN_SECRET_TOKEN=${ADMIN_SECRET_TOKEN}
- COOKIE_SECURE=${COOKIE_SECURE:-true}
volumes:
- guestbook_data:/data
volumes:
guestbook_data:
+113
View File
@@ -0,0 +1,113 @@
# Guestbook — Implementation Notes
## Architecture summary
The guestbook extends the Astro site with **hybrid SSR** mode using the `@astrojs/node` standalone adapter.
- Existing content pages remain static.
- Guestbook and admin pages are server-rendered (`export const prerender = false`).
- API routes handle submissions, moderation, and token-based admin login.
- SQLite (`better-sqlite3`) stores entries, sessions, rate-limit data, and audit logs.
## Relevant files
```
src/
lib/
db.ts — SQLite singleton + schema
guestbook.ts — Entry CRUD, pagination, moderation reads
auth.ts — Session management + cookie policy
spam.ts — Validation + heuristic spam scoring
pages/
guestbook.astro — Public guestbook page
admin/
index.astro — Moderation portal (session-gated)
login.astro — Token login form
pages/api/
guestbook/submit.ts — POST: public guestbook submission
admin/token-login.ts — POST: token authentication + session creation
admin/moderate.ts — POST: approve / reject / spam
admin/logout.ts — POST: end admin session
layouts/
AdminLayout.astro — Minimal admin UI layout
```
## Environment variables
Copy `.env.example` to `.env` and set:
| Variable | Required | Description |
|---|---|---|
| `SECRET_KEY` | **Yes** | Random secret for IP-hash salting and session-related values |
| `ADMIN_SECRET_TOKEN` | **Yes** | Shared secret token for `/admin/login` |
| `COOKIE_SECURE` | No | Force secure cookies (`true`/`false`). If unset, `NODE_ENV=production` => secure cookies |
| `DB_PATH` | No | SQLite path (default: `./data/guestbook.db`) |
| `PORT` | No | Server port (default: `3000`) |
| `HOST` | No | Bind host (default: `0.0.0.0`) |
Generate secrets:
```bash
openssl rand -hex 32 # SECRET_KEY
openssl rand -hex 32 # ADMIN_SECRET_TOKEN
```
## Admin setup
1. Set `ADMIN_SECRET_TOKEN` in your environment.
2. Open `/admin/login`.
3. Enter token.
4. After success, you are redirected to `/admin`.
If token is missing, `/admin/login` shows a configuration warning and login is disabled.
## Local development
```bash
npm install
cp .env.example .env
# set at minimum:
# SECRET_KEY=...
# ADMIN_SECRET_TOKEN=...
# DB_PATH=./data/guestbook.db
# COOKIE_SECURE=false # for local http
npm run dev
# guestbook: http://localhost:4321/guestbook
# admin: http://localhost:4321/admin/login
```
## Docker deployment
```bash
docker compose up -d --build
docker compose logs -f cozy-den
```
The `guestbook_data` Docker volume persists the SQLite database.
## Moderation flow
1. Visitor submits message at `/guestbook`.
2. Entry is saved as `pending`.
3. Admin logs in at `/admin/login` with token.
4. Admin approves/rejects/marks spam in `/admin`.
5. Approved entries are shown publicly.
## Privacy decisions
- IP addresses are never stored directly.
- A truncated salted hash is stored only for rate limiting.
- No tracking scripts or third-party analytics.
- Admin session cookie is `httpOnly` and `SameSite=Strict`.
- User content is stored as plain text (HTML stripped server-side).
## Database tables
| Table | Purpose |
|---|---|
| `guestbook_entries` | Submissions + moderation status |
| `admin_sessions` | Active admin sessions |
| `rate_limit` | Submission throttling by IP hash |
| `audit_log` | Moderation actions |
+18 -23
View File
@@ -1,36 +1,31 @@
# nginx.conf — reverse proxy in front of the Astro Node.js server
# If you run cozy-den behind your own reverse proxy (Caddy, Nginx, etc.),
# this file is for reference / the docker-compose nginx service pattern.
#
# The primary server is now the Node.js process (dist/server/entry.mjs).
# Point your reverse proxy to http://cozy-den:3000 (or localhost:3000).
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
absolute_redirect off;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json image/svg+xml;
server_name hiddenden.cafe;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
}
# Main location
# Proxy to Node.js Astro server
location / {
try_files $uri $uri/ =404;
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
}
# Custom error pages
error_page 404 /404.html;
}
+11 -1
View File
@@ -2,6 +2,9 @@
"name": "cozy-den",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=20 <24"
},
"scripts": {
"dev": "astro dev",
"start": "astro dev",
@@ -11,6 +14,13 @@
},
"dependencies": {
"astro": "^4.16.18",
"@astrojs/sitemap": "^3.2.2"
"@astrojs/sitemap": "^3.2.2",
"@astrojs/node": "^8.3.4",
"better-sqlite3": "^9.4.3",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.10",
"@types/uuid": "^9.0.7"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+29 -13
View File
@@ -5,7 +5,9 @@ const links = [
{ href: "/", label: "home" },
{ href: "/about", label: "about" },
{ href: "/projects", label: "projects" },
{ href: "/now", label: "now" },
{ href: "/blog", label: "blog" },
{ href: "/guestbook", label: "guestbook" },
];
function isActive(href: string, current: string): boolean {
@@ -16,18 +18,31 @@ function isActive(href: string, current: string): boolean {
<nav class="nav" aria-label="Main navigation">
<div class="nav-inner">
{links.map((link, i) => (
<>
{i > 0 && <span class="nav-sep" aria-hidden="true">·</span>}
<a
href={link.href}
class:list={["nav-link", { active: isActive(link.href, currentPath) }]}
aria-current={isActive(link.href, currentPath) ? "page" : undefined}
>
~/{link.label}
</a>
</>
))}
{
links.map((link, i) => (
<>
{i > 0 && (
<span class="nav-sep" aria-hidden="true">
·
</span>
)}
<a
href={link.href}
class:list={[
"nav-link",
{ active: isActive(link.href, currentPath) },
]}
aria-current={
isActive(link.href, currentPath)
? "page"
: undefined
}
>
~/{link.label}
</a>
</>
))
}
</div>
</nav>
@@ -39,7 +54,7 @@ function isActive(href: string, current: string): boolean {
right: 0;
z-index: 100;
padding: var(--space-sm) var(--space-md);
background: rgba(30, 30, 46, 0.85);
background: var(--color-glass-nav);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--color-surface);
}
@@ -48,6 +63,7 @@ function isActive(href: string, current: string): boolean {
max-width: 700px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--space-xs);
+135
View File
@@ -0,0 +1,135 @@
---
title: "After the Silence"
description: "Reflections on what remains after a meaningful relationship ends, and how love can transform without disappearing."
pubDate: 2026-03-04
tags: ["love", "reflection", "healing", "relationships", "personal"]
category: "reflection"
featuredEssay: true
---
When a relationship ends, the world does not become quiet immediately.
At first there is noise.
Questions that circle endlessly.
Memories that appear without warning.
Moments where the absence of someone feels louder than their presence ever was.
But eventually something changes.
The storm that once lived inside your mind slowly settles.
And what remains is something different.
A silence that is not empty - but reflective.
---
## The Quiet After Love
The silence that follows a meaningful relationship is unlike any other.
It is not the silence of indifference.
Nor the silence of forgetting.
It is the quiet that appears when two lives that once moved together begin to move separately.
Routines shift.
Spaces feel different.
The future you once imagined slowly dissolves into something undefined.
At first, that silence can feel unsettling.
But over time it becomes something else.
A place where reflection becomes possible.
---
## Love Does Not Always Disappear
One of the more confusing realizations after a relationship ends is that love does not always vanish with it.
Even when distance becomes necessary, care can remain.
This can feel contradictory at first.
We often expect emotional closure to mean the disappearance of feeling.
But meaningful connections rarely dissolve that neatly.
Sometimes love simply changes form.
It moves from something shared into something carried quietly.
And that does not make it less real.
---
## The Quiet Transformation
Time has a way of reshaping emotional intensity.
What once felt overwhelming slowly becomes more understandable.
Moments that once caused pain begin to look different when viewed from a distance.
Not because the past changes.
But because you do.
Perspective grows.
The urgency fades.
And what once felt like chaos becomes something you can hold with calm understanding.
This transformation rarely happens suddenly.
It arrives slowly, through reflection, distance, and the quiet work of healing.
---
## Carrying Without Clinging
There is a difference between remembering someone and remaining attached to them.
Clinging keeps you in the past.
Carrying allows you to move forward.
When you carry something, you acknowledge its importance without letting it control your direction.
You recognize the place it had in your life.
You respect the impact it left.
But you also accept that some chapters belong where they ended.
In that sense, remembering can become an act of peace rather than longing.
---
## What Remains
When the emotional storm has passed, something meaningful remains.
Not the loss.
Not the confusion.
But the understanding.
You understand more about the kind of connection that matters to you.
You understand more about your own capacity to care deeply.
And perhaps most importantly, you understand that love itself was never the mistake.
Even when relationships end, the love that existed still shapes who we become.
It leaves behind lessons, perspective, and a deeper awareness of what it means to connect with another human being.
And sometimes, what remains after the silence is not emptiness.
But growth.
@@ -0,0 +1,106 @@
---
title: "Coffee & Code #1 - Building Quiet Corners on the Internet"
description: "Why personal websites still matter, and why I want Hidden Den to feel more like a quiet cafe than another loud platform."
pubDate: 2026-03-07
tags: ["coffee-and-code", "internet", "technology", "philosophy", "devlog", "personal website"]
category: "internet"
featuredEssay: true
readingOrder: 2
series:
name: "Coffee & Code"
part: 1
---
The modern internet is loud in a way that can be hard to notice until you step away from it.
Most of the biggest platforms are built around movement. Refresh. Scroll. React. Return. Every surface is tuned to keep your attention warm and your focus fragmented. Even when you are technically resting, you are still being pulled. Still being measured. Still being asked, in one way or another, to perform your presence.
That kind of internet leaves very little room for quiet.
It leaves very little room for thought that takes time to form, for work that grows slowly, or for spaces that are allowed to exist without proving their usefulness to an algorithm.
## The Modern Internet Is Loud
I do not think the problem is only that modern platforms are busy.
The deeper problem is that they are designed to turn human attention into a resource that can be managed, shaped, and extracted. Feeds are personalized to keep you looking. Interfaces are optimized for response. Identity becomes flattened into profiles, metrics, and whatever version of you performs best in public.
That pressure changes the feeling of being online.
Instead of visiting places, we get funneled through systems. Instead of inhabiting a space, we are circulated through streams. The internet starts to feel less like a landscape and more like a series of endless hallways built to keep us moving.
There is a kind of fatigue that comes from that.
Not only from seeing too much, but from rarely feeling settled anywhere.
## A Different Kind Of Website
Hidden Den exists because I wanted something smaller and more intentional than that.
Not a brand. Not a content machine. Not a personal site designed to act like a polished advertisement for myself.
I wanted a place that feels inhabited.
A place where writing, projects, experiments, infrastructure notes, and personal reflections can sit beside each other without needing to become a strategy. A place that can grow at the pace of real life. A place that belongs to me in a practical sense, not just an aesthetic one.
That matters.
Ownership changes the emotional texture of a website. When the space is actually yours, you can shape it around care instead of platform expectations. You can decide what belongs there, what does not, and how fast it should evolve.
## The Beauty Of Personal Websites
One of the things I still love about personal websites is how specific they are allowed to be.
They do not have to speak in a universal tone. They do not have to flatten themselves into a single recognizable format. They can be weird, warm, sparse, dense, messy, elegant, practical, intimate, technical, playful, or all of those at once.
Older parts of the web had more of that energy. People made pages because they wanted a place to put something they cared about. A collection. A diary. A project log. A cluster of links. A strange design choice that only made sense to them.
That kind of specificity is harder to find now, not because people stopped wanting it, but because so much online identity was centralized into a few massive platforms. Instead of building places, most people were encouraged to maintain profiles.
Profiles are convenient.
But they are not the same thing as a home.
## Slow Creation
One of the nicest things about building a personal site is that it does not have to move at platform speed.
I can write when I have something worth saying. I can redesign a section because it feels better, not because a dashboard says engagement dipped. I can let a page sit unfinished until I understand what it wants to become.
That slower rhythm creates a healthier relationship with making things.
It gives room for reflection before publishing. Room for taste. Room for experimenting without every small step needing to become a public performance. It also makes the work feel more durable. Less disposable. Less trapped in the constant churn of whatever the current feed wants to reward.
Slow creation is not laziness.
It is often how care looks when it is allowed to breathe.
## The Cafe Metaphor
The cafe part of Hidden Den is not only visual, even if I do love the warm colors and the feeling of a quiet corner with a drink nearby.
It is also a way of thinking about what I want this site to be.
A good cafe gives you a certain kind of permission. You can arrive as you are. You can stay with your thoughts for a while. You can read, write, work, rest, or just exist without being rushed into spectacle.
That is the atmosphere I want here.
Not silence in the sense of emptiness, but quiet in the sense of breathing room.
Something warm. Something human-scaled. Something that feels made for people rather than extracted from them.
## Why It Matters
I do not think small personal sites are going to replace massive platforms, and I do not need them to.
What matters is that they still exist at all.
They remind us that the internet can be made of places instead of only systems. They remind us that technology does not have to feel hostile to be serious. They remind us that identity online can still be expressive, self-directed, and specific.
Most of all, they remind us that a quieter web is still worth building.
That is part of what I want Hidden Den to be: a small, steady corner of the internet where code, writing, privacy, warmth, and personal presence can live together without needing to become louder than they are.
More entries in Coffee & Code will probably keep circling around that same idea from different angles.
This one is just the first cup.
+8 -4
View File
@@ -1,8 +1,12 @@
---
---
title: "Welcome to the Den"
date: 2026-03-04
description: "First proper post. Why I built this site, what it runs on, and what to expect."
draft: true
pubDate: 2026-03-01
tags: ["self-hosting", "privacy", "personal-web", "infrastructure"]
category: "building"
featuredEssay: true
readingOrder: 1
draft: false
---
So I finally got around to setting up a proper blog. Welcome.
@@ -11,7 +15,7 @@ So I finally got around to setting up a proper blog. Welcome.
I wanted a place to write that wasn't owned by a corporation. No Medium, no Substack, no algorithm deciding who sees what. Just markdown files on my own server, served by nginx from a Docker container I control.
That's the whole point of the den owning your own space on the internet.
That's the whole point of the den - owning your own space on the internet.
## What It Runs On
+14 -13
View File
@@ -1,7 +1,8 @@
---
---
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."
description: "A reflection on a first love - what it meant, what it cost, and why distance was the most loving thing left."
pubDate: 2026-03-01
tags: ["love", "reflection", "healing", "relationships", "personal"]
---
*by LATTE*
@@ -16,7 +17,7 @@ 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
It's a story about something that stayed real...
even after it stopped being reachable.
---
@@ -25,7 +26,7 @@ even after it stopped being reachable.
We were friends first.
Maybe that's what made it so deep. It didn't explode into existence it grew. Slowly. Safely.
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.
@@ -75,7 +76,7 @@ Silence.
"I don't feel it anymore."
And something in me went very quiet and very loud at the same time.
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
@@ -98,7 +99,7 @@ But no longer together.
A body that says yes.
Words that say no.
That contradiction doesn't just hurt emotionally it destabilizes you.
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.
@@ -199,7 +200,7 @@ That's architecture.
## On Being Replaced
Yes he found someone new quickly.
Yes - he found someone new quickly.
That hurt.
@@ -222,7 +223,7 @@ Something ending does not mean it was nothing.
## For You
If you ever read this
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.
@@ -243,7 +244,7 @@ Trying. Failing. Adjusting.
Discovering what intimacy meant.
Discovering what we meant.
You weren't just someone who entered my life
You weren't just someone who entered my life -
you were part of my becoming.
That matters.
@@ -262,7 +263,7 @@ 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.
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.
@@ -295,7 +296,7 @@ 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:
And yes - I'm going to be a little playful about it:
You're on my website. Do you get that?
@@ -333,4 +334,4 @@ to be worthy of being held.
The wolf in me was never meant to become smaller.
Only to find the right pack.
LATTE
- LATTE
@@ -0,0 +1,141 @@
---
title: "Things I Learned From Loving Deeply"
description: "Reflections on intimacy, trust, and the quiet ways meaningful love can continue shaping who we become."
pubDate: 2026-03-07
tags: ["love", "relationships", "reflection", "emotional growth", "intimacy"]
category: "personal"
featuredEssay: true
readingOrder: 3
---
Some relationships change you in quiet but permanent ways.
Not every love becomes a lifelong story.
Some arrive, reshape parts of you, and then continue in another direction.
But even when they end, they leave behind something real.
Looking back, I realize that loving someone deeply taught me more about connection, vulnerability, and myself than I expected.
Not because everything went perfectly.
But because the love itself was real.
---
## Love Is Often Quiet
One of the first things I learned is that love rarely lives in dramatic moments.
It lives in the quiet ones.
Sitting together without needing to fill the silence.
Sharing a bed, a room, a routine.
Feeling calm simply because another person is there.
Those moments may look small from the outside.
But inside them is something profound: the feeling of being safe in someone else's presence.
Real love often looks ordinary.
And that is exactly what makes it meaningful.
---
## Intimacy Requires Trust
True intimacy asks for something difficult.
It asks you to allow another person to see parts of yourself that you normally protect.
Your fears.
Your uncertainties.
The parts of you that still feel unfinished.
Trust is not just about loyalty.
It is about emotional safety - the quiet understanding that someone will treat your vulnerability with care.
When that happens, connection becomes deeper than attraction.
It becomes a place where two people can actually exist as themselves.
---
## Loving Deeply Reveals Who You Are
Another lesson I learned is that loving someone deeply reveals something important about yourself.
It shows you the depth of your own capacity.
Your ability to care.
Your willingness to stay present.
Your strength in offering patience, warmth, and loyalty.
For a long time it can feel as if love is something another person gives you.
But eventually you realize something different.
The love you gave was always yours.
And that capacity does not disappear simply because a relationship ends.
---
## Love and Loss Can Exist Together
One of the harder truths about relationships is that love alone is not always enough to keep two lives moving in the same direction.
People grow.
People struggle.
People reach limits in ways neither person expected.
Sometimes relationships end not because the love was false, but because something deeper stopped aligning.
That realization can hurt.
But it also reveals an important truth:
A relationship ending does not erase the love that once existed.
Both things can be true at the same time.
---
## Love Does Not Need Access To Remain Real
When someone leaves your life, the connection does not instantly disappear.
At first that can feel confusing.
You can still care about someone while knowing they are no longer part of your life.
You can still wish them well while recognizing that distance is necessary.
Eventually you learn something quiet but important:
Love does not always need proximity to remain meaningful.
Sometimes the healthiest form of care is letting a chapter remain where it ended.
---
## Carrying The Lessons Forward
Healing after a deep relationship is not about pretending it never happened.
It is about integrating what it taught you.
Understanding the kind of connection that allows you to thrive.
Recognizing the parts of yourself that deserve to be met with the same care you offer others.
And perhaps the most reassuring realization is this:
The ability to love deeply was never the mistake.
It was always the strength.
The story does not end when a relationship does.
It simply becomes part of the person you are becoming.
+11
View File
@@ -0,0 +1,11 @@
---
date: 2026-03-07T21:30:00Z
title: Late-night systems
draft: false
---
There is a very specific kind of peace in checking on a quiet system late at
night and finding that everything is simply humming along.
No alerts. No drama. Just warm coffee, a few container logs, and the feeling
that the machines are behaving themselves for once.
@@ -0,0 +1,11 @@
---
date: 2026-03-05T20:40:00Z
title: Learning by breaking
draft: false
---
One of the nicest things about a homelab is that it gives you permission to
learn by breaking things in a place that still feels like yours.
Rebuild, retry, take notes, improve the backup story, and understand the system
a little better than you did the night before.
+12
View File
@@ -0,0 +1,12 @@
---
date: 2026-03-06T23:10:00Z
title: Privacy as care
draft: false
---
Privacy is often framed like fear, but most of the time I experience it more as
care.
Care for my own data. Care for other people's attention. Care for building
systems that do not assume they are entitled to watch everyone who walks
through the door.
@@ -0,0 +1,12 @@
---
date: 2026-03-07T18:05:00Z
title: Small sites still matter
draft: false
---
I still think small personal websites matter because they let a person sound
like themselves again.
Not every thought needs to be flattened into a post that fights for reach. Some
things are better when they can just quietly exist on a page someone made on
purpose.
+40 -9
View File
@@ -1,13 +1,44 @@
import { z, defineCollection } from 'astro:content';
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),
}),
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
category: z.string().optional(),
featuredEssay: z.boolean().optional().default(false),
readingOrder: z.number().int().positive().optional(),
series: z
.object({
name: z.string(),
part: z.number().int().positive(),
})
.optional(),
draft: z.boolean().optional().default(false),
}),
});
export const collections = { blog };
const coffee = defineCollection({
type: "content",
schema: z.object({
title: z.string().optional(),
date: z.coerce.date(),
draft: z.boolean().optional().default(false),
}),
});
const links = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
url: z.string().url(),
description: z.string(),
category: z.string().optional(),
date: z.coerce.date().optional(),
draft: z.boolean().optional().default(false),
}),
});
export const collections = { blog, coffee, links };
+8
View File
@@ -0,0 +1,8 @@
---
title: "32-Bit Cafe"
url: "https://32bit.cafe"
description: "A warm small-web corner full of personal pages, guides, and gentle encouragement to make something of your own on the internet."
category: "inspiration"
date: 2026-03-06
draft: false
---
+8
View File
@@ -0,0 +1,8 @@
---
title: "IndieWeb"
url: "https://indieweb.org"
description: "A lot of the values behind Hidden Den rhyme with this community: personal sites, ownership, and a web made of real places instead of only platforms."
category: "blogs"
date: 2026-03-07
draft: false
---
+8
View File
@@ -0,0 +1,8 @@
---
title: "Low-tech Magazine"
url: "https://solar.lowtechmagazine.com"
description: "Thoughtful writing about technology, infrastructure, energy, and limits. It is one of those rare places that feels both technical and deeply reflective."
category: "essays"
date: 2026-03-04
draft: false
---
+8
View File
@@ -0,0 +1,8 @@
---
title: "Privacy Guides"
url: "https://www.privacyguides.org"
description: "One of the more practical places for thinking about privacy tools without drifting too far into paranoia or purity spirals."
category: "tools"
date: 2026-03-05
draft: false
---
+8
View File
@@ -0,0 +1,8 @@
---
title: "The Marginalian"
url: "https://www.themarginalian.org"
description: "A long-running archive of essays, ideas, and literary wandering. Good when I want to remember that thoughtful writing can still feel generous and alive."
category: "essays"
date: 2026-03-03
draft: false
---
+25
View File
@@ -0,0 +1,25 @@
Living snapshot of what I am building, learning, and focusing on right now.
Last updated: 2026-03-06
## Current Projects
- **GuardDen** - tightening moderation and security workflows for community spaces.
- **loyal_companion** - active work-in-progress with feature and stability passes.
- **Cozy Den** - improving structure and adding pages that are easier to maintain over time.
## Learning Right Now
- Better Astro content organization for small personal sites.
- Cleaner Docker + nginx deployment routines for self-hosted services.
- Practical writing habits for short, regular project updates.
## Focus Areas
- Privacy-first self-hosting with minimal external dependencies.
- Building lightweight tools that stay understandable and maintainable.
- Sustainable progress: consistent small steps instead of burnout sprints.
## Next Check-In
I try to refresh this page every few weeks so it stays useful and current.
+200
View File
@@ -0,0 +1,200 @@
---
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title} — Cozy Den Admin</title>
<meta name="robots" content="noindex, nofollow" />
</head>
<body>
<header>
<div class="header-inner">
<span class="site-name">~/admin</span>
<nav>
<a href="/admin">moderation</a>
<span aria-hidden="true">·</span>
<a href="/guestbook">public view</a>
<span aria-hidden="true">·</span>
<form method="post" action="/api/admin/logout" style="display:inline">
<button type="submit" class="logout-btn">logout</button>
</form>
</nav>
</div>
</header>
<main>
<slot />
</main>
</body>
</html>
<style is:global>
:root {
--color-bg: #1e1e2e;
--color-bg-light: #313244;
--color-surface: #45475a;
--color-text: #cdd6f4;
--color-text-dim: #a6adc8;
--color-accent: #cba6f7;
--color-accent-bright: #f5c2e7;
--color-warm: #f38ba8;
--color-green: #a6e3a1;
--color-peach: #fab387;
--font-body: "JetBrains Mono", "Fira Code", monospace;
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 1.5rem;
--space-lg: 2rem;
}
@media (prefers-color-scheme: light) {
:root {
--color-bg: #f6efe6;
--color-bg-light: #efe1cf;
--color-surface: #d8c2a8;
--color-text: #35251a;
--color-text-dim: #6b5442;
--color-accent: #8b5e3c;
--color-accent-bright: #a16c45;
--color-warm: #b6794f;
--color-green: #3f7c47;
--color-peach: #b5693e;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html {
font-family: var(--font-body);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
}
body { min-height: 100vh; line-height: 1.6; }
a {
color: var(--color-accent-bright);
text-decoration: none;
}
a:hover { text-decoration: underline; }
a:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
header {
background: var(--color-bg-light);
border-bottom: 1px solid var(--color-surface);
padding: var(--space-xs) var(--space-md);
}
.header-inner {
max-width: 900px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
flex-wrap: wrap;
}
.site-name {
color: var(--color-accent);
font-weight: bold;
}
nav {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.logout-btn {
background: none;
border: none;
color: var(--color-warm);
font-family: inherit;
font-size: inherit;
cursor: pointer;
padding: 0;
text-decoration: none;
}
.logout-btn:hover { text-decoration: underline; }
main {
max-width: 900px;
margin: 0 auto;
padding: var(--space-lg) var(--space-md);
}
h1, h2, h3 { color: var(--color-accent-bright); line-height: 1.2; }
h1 { font-size: 1.4rem; margin-bottom: var(--space-md); }
h2 { font-size: 1.1rem; margin-bottom: var(--space-sm); }
.card {
background: var(--color-bg-light);
border: 1px solid var(--color-surface);
border-radius: 6px;
padding: var(--space-md);
margin-bottom: var(--space-sm);
}
.btn {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
border: 1px solid currentColor;
background: none;
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
text-decoration: none;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.8; }
.btn-approve { color: var(--color-green); }
.btn-reject { color: var(--color-text-dim); }
.btn-spam { color: var(--color-warm); }
.btn-primary { color: var(--color-accent); }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 0.78rem;
font-weight: bold;
}
.badge-pending { background: var(--color-peach); color: #1e1e2e; }
.badge-approved { background: var(--color-green); color: #1e1e2e; }
.badge-rejected { background: var(--color-surface); color: var(--color-text-dim); }
.badge-spam { background: var(--color-warm); color: #1e1e2e; }
.meta { color: var(--color-text-dim); font-size: 0.82rem; }
.message-text { white-space: pre-wrap; word-break: break-word; }
.section-gap { margin-top: var(--space-lg); }
.alert {
padding: var(--space-sm);
border-radius: 4px;
margin-bottom: var(--space-md);
border: 1px solid;
}
.alert-error { border-color: var(--color-warm); color: var(--color-warm); }
.alert-success { border-color: var(--color-green); color: var(--color-green); }
input, textarea, select {
background: var(--color-bg);
border: 1px solid var(--color-surface);
border-radius: 4px;
color: var(--color-text);
font-family: inherit;
font-size: inherit;
padding: 4px 8px;
}
input:focus, textarea:focus { outline: 2px solid var(--color-accent); outline-offset: 2px; }
</style>
+34 -2
View File
@@ -54,8 +54,18 @@ const fullOgImage = new URL(ogImage, Astro.site).href;
name="keywords"
content="self-hosted, privacy, open-source, furry, developer, cozy, hidden den"
/>
<meta name="theme-color" content="#cba6f7" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#1e1e2e" />
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content="#1e1e2e"
/>
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content="#f6efe6"
/>
<meta name="color-scheme" content="dark light" />
<!-- Favicons -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
@@ -101,6 +111,9 @@ const fullOgImage = new URL(ogImage, Astro.site).href;
--color-blue: #89b4fa;
--color-green: #a6e3a1;
--color-peach: #fab387;
--color-glass: rgba(30, 30, 46, 0.8);
--color-glass-nav: rgba(30, 30, 46, 0.85);
color-scheme: dark;
/* Spacing */
--space-xs: 0.5rem;
@@ -113,6 +126,25 @@ const fullOgImage = new URL(ogImage, Astro.site).href;
--font-body: "JetBrains Mono", "Fira Code", "SF Mono", Consolas, monospace;
}
@media (prefers-color-scheme: light) {
:root {
--color-bg: #f6efe6;
--color-bg-light: #efe1cf;
--color-surface: #d8c2a8;
--color-text: #35251a;
--color-text-dim: #6b5442;
--color-accent: #8b5e3c;
--color-accent-bright: #a16c45;
--color-warm: #b6794f;
--color-blue: #326b8f;
--color-green: #3f7c47;
--color-peach: #b5693e;
--color-glass: rgba(246, 239, 230, 0.82);
--color-glass-nav: rgba(246, 239, 230, 0.88);
color-scheme: light;
}
}
* {
margin: 0;
padding: 0;
+73
View File
@@ -0,0 +1,73 @@
import { createHash, randomBytes } from 'node:crypto';
import db from './db';
function getSecretKey(): string {
const key = process.env.SECRET_KEY;
if (!key) throw new Error('SECRET_KEY environment variable is required');
return key;
}
export function hashIP(ip: string): string {
return createHash('sha256').update(getSecretKey() + ':' + ip).digest('hex').slice(0, 16);
}
export function generateId(): string {
return randomBytes(32).toString('hex');
}
export interface AdminSession {
id: string;
user_id: string;
created_at: string;
expires_at: string;
}
export function createSession(userId: string): string {
const sessionId = generateId();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
db.prepare(
`INSERT INTO admin_sessions (id, user_id, expires_at) VALUES (?, ?, ?)`
).run(sessionId, userId, expiresAt);
return sessionId;
}
export function getSession(sessionId: string): AdminSession | undefined {
return db
.prepare(
`SELECT * FROM admin_sessions
WHERE id = ? AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
)
.get(sessionId) as AdminSession | undefined;
}
export function deleteSession(sessionId: string): void {
db.prepare(`DELETE FROM admin_sessions WHERE id = ?`).run(sessionId);
}
export function cleanExpiredSessions(): void {
db.prepare(
`DELETE FROM admin_sessions WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`
).run();
}
export const SESSION_COOKIE = 'admin_session';
function shouldUseSecureCookies(): boolean {
const secureOverride = process.env.COOKIE_SECURE?.trim().toLowerCase();
if (secureOverride === 'true') return true;
if (secureOverride === 'false') return false;
return process.env.NODE_ENV === 'production';
}
export function sessionCookieOptions(maxAge: number) {
return {
httpOnly: true,
secure: shouldUseSecureCookies(),
sameSite: 'strict' as const,
path: '/',
maxAge,
};
}
+17
View File
@@ -0,0 +1,17 @@
export function formatBlogDate(date: Date) {
return date.toISOString().split("T")[0];
}
export function slugifyTag(tag: string) {
return tag
.toLowerCase()
.trim()
.replace(/&/g, " and ")
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
}
export function getTagHref(tag: string) {
return `/blog/tag/${slugifyTag(tag)}`;
}
+57
View File
@@ -0,0 +1,57 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const dbPath = resolve(process.env.DB_PATH ?? './data/guestbook.db');
// Ensure directory exists
mkdirSync(dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
// WAL mode improves concurrent read performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS guestbook_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
message TEXT NOT NULL,
website TEXT,
ip_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
moderated_at TEXT,
moderation_note TEXT
);
CREATE TABLE IF NOT EXISTS admin_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rate_limit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
entry_id INTEGER,
admin_session TEXT,
note TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_entries_status ON guestbook_entries(status);
CREATE INDEX IF NOT EXISTS idx_entries_created ON guestbook_entries(created_at);
CREATE INDEX IF NOT EXISTS idx_rate_limit_ip ON rate_limit(ip_hash, created_at);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON admin_sessions(expires_at);
`);
export default db;
+125
View File
@@ -0,0 +1,125 @@
import db from './db';
export interface GuestbookEntry {
id: number;
display_name: string;
message: string;
website: string | null;
ip_hash: string | null;
status: 'pending' | 'approved' | 'rejected' | 'spam';
created_at: string;
moderated_at: string | null;
moderation_note: string | null;
}
export interface SubmitData {
display_name: string;
message: string;
website: string | null;
ip_hash: string | null;
}
const PAGE_SIZE = 20;
export function getApprovedEntries(page = 1): { entries: GuestbookEntry[]; total: number; hasMore: boolean } {
const offset = (page - 1) * PAGE_SIZE;
const entries = db
.prepare(
`SELECT id, display_name, message, website, created_at
FROM guestbook_entries
WHERE status = 'approved'
ORDER BY created_at DESC
LIMIT ? OFFSET ?`
)
.all(PAGE_SIZE, offset) as GuestbookEntry[];
const { total } = db
.prepare(`SELECT COUNT(*) as total FROM guestbook_entries WHERE status = 'approved'`)
.get() as { total: number };
return { entries, total, hasMore: offset + PAGE_SIZE < total };
}
export function getPendingEntries(): GuestbookEntry[] {
return db
.prepare(
`SELECT * FROM guestbook_entries WHERE status = 'pending' ORDER BY created_at ASC`
)
.all() as GuestbookEntry[];
}
export function getRecentModerated(limit = 30): GuestbookEntry[] {
return db
.prepare(
`SELECT * FROM guestbook_entries
WHERE status IN ('approved', 'rejected', 'spam')
ORDER BY moderated_at DESC
LIMIT ?`
)
.all(limit) as GuestbookEntry[];
}
export function submitEntry(data: SubmitData): number {
const result = db
.prepare(
`INSERT INTO guestbook_entries (display_name, message, website, ip_hash)
VALUES (?, ?, ?, ?)`
)
.run(data.display_name, data.message, data.website, data.ip_hash);
return result.lastInsertRowid as number;
}
export function moderateEntry(
id: number,
status: 'approved' | 'rejected' | 'spam',
note: string | null,
sessionId: string
): boolean {
const result = db
.prepare(
`UPDATE guestbook_entries
SET status = ?, moderated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), moderation_note = ?
WHERE id = ? AND status = 'pending'`
)
.run(status, note, id);
if (result.changes > 0) {
db.prepare(
`INSERT INTO audit_log (action, entry_id, admin_session, note) VALUES (?, ?, ?, ?)`
).run(`moderate:${status}`, id, sessionId, note);
}
return result.changes > 0;
}
export function checkRateLimit(ipHash: string): boolean {
// Clean up old entries (>1 hour)
db.prepare(
`DELETE FROM rate_limit WHERE created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 hour')`
).run();
const { count } = db
.prepare(`SELECT COUNT(*) as count FROM rate_limit WHERE ip_hash = ?`)
.get(ipHash) as { count: number };
// Allow max 3 submissions per hour per IP hash
return count < 3;
}
export function recordSubmission(ipHash: string): void {
db.prepare(`INSERT INTO rate_limit (ip_hash) VALUES (?)`).run(ipHash);
}
export function isDuplicateSubmission(displayName: string, message: string): boolean {
// Check for exact duplicate in last 24 hours (regardless of status)
const row = db
.prepare(
`SELECT id FROM guestbook_entries
WHERE display_name = ? AND message = ?
AND created_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 day')`
)
.get(displayName, message);
return row !== undefined;
}
+19
View File
@@ -0,0 +1,19 @@
export function getReadingTime(content: string) {
const normalized = content
.replace(/```[\s\S]*?```/g, " ")
.replace(/`[^`]*`/g, " ")
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/[#>*_~-]/g, " ")
.replace(/\s+/g, " ")
.trim();
const wordCount = normalized ? normalized.split(" ").length : 0;
const minutes = Math.max(1, Math.ceil(wordCount / 200));
return {
minutes,
text: `${minutes} min read`,
};
}
+120
View File
@@ -0,0 +1,120 @@
// Lightweight spam detection and input sanitization
const MAX_NAME_LENGTH = 60;
const MAX_MESSAGE_LENGTH = 1000;
const MAX_WEBSITE_LENGTH = 200;
// Patterns that strongly suggest spam
const SPAM_PATTERNS = [
/\b(viagra|cialis|casino|poker|lottery|bitcoin|crypto|investment|forex)\b/i,
/\b(click here|buy now|free money|earn \$|make money)\b/i,
/(https?:\/\/[^\s]{0,10}){3,}/i, // 3+ URLs in message
];
// Basic URL validation for the optional website field
const SAFE_URL_PATTERN = /^https?:\/\/[a-z0-9-]+(\.[a-z0-9-]+)+(\/[^\s]*)?$/i;
export interface ValidationError {
field: string;
message: string;
}
export function sanitizeText(input: string): string {
// Normalize whitespace: collapse runs of spaces/tabs, normalize line endings
return input
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
export function stripHtml(input: string): string {
// Remove anything that looks like an HTML tag
return input.replace(/<[^>]*>/g, '').replace(/&[a-z#0-9]+;/gi, (match) => {
const entities: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
};
return entities[match] ?? match;
});
}
export function validateWebsite(url: string): string | null {
if (!url || url.trim() === '') return null;
const cleaned = url.trim();
if (cleaned.length > MAX_WEBSITE_LENGTH) return null;
if (!SAFE_URL_PATTERN.test(cleaned)) return null;
// Block localhost and private ranges in URLs
if (/localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\./i.test(cleaned)) return null;
return cleaned;
}
export function validateEntry(data: {
display_name: string;
message: string;
website: string;
consent: string;
honeypot: string;
}): ValidationError[] {
const errors: ValidationError[] = [];
// Honeypot check: must be empty (bots fill it in)
if (data.honeypot && data.honeypot.trim() !== '') {
errors.push({ field: 'honeypot', message: 'Spam detected' });
return errors;
}
// Consent required
if (!data.consent || data.consent !== 'yes') {
errors.push({ field: 'consent', message: 'You must consent to public posting' });
}
// Display name validation
const name = stripHtml(sanitizeText(data.display_name));
if (!name || name.length < 1) {
errors.push({ field: 'display_name', message: 'Please enter a display name' });
} else if (name.length > MAX_NAME_LENGTH) {
errors.push({ field: 'display_name', message: `Name must be ${MAX_NAME_LENGTH} characters or less` });
}
// Message validation
const message = stripHtml(sanitizeText(data.message));
if (!message || message.length < 1) {
errors.push({ field: 'message', message: 'Please enter a message' });
} else if (message.length > MAX_MESSAGE_LENGTH) {
errors.push({ field: 'message', message: `Message must be ${MAX_MESSAGE_LENGTH} characters or less` });
}
return errors;
}
export function scoreSpam(data: { display_name: string; message: string; website: string | null }): number {
let score = 0;
const combined = `${data.display_name} ${data.message} ${data.website ?? ''}`;
for (const pattern of SPAM_PATTERNS) {
if (pattern.test(combined)) score += 30;
}
// Lots of URLs in message is suspicious
const urlCount = (data.message.match(/https?:\/\//gi) ?? []).length;
if (urlCount >= 2) score += 20 * urlCount;
// All-caps message is a mild spam signal
const upperRatio = (data.message.match(/[A-Z]/g) ?? []).length / Math.max(data.message.length, 1);
if (upperRatio > 0.6 && data.message.length > 10) score += 15;
return score;
}
export function isLikelySpam(data: { display_name: string; message: string; website: string | null }): boolean {
return scoreSpam(data) >= 50;
}
+1 -1
View File
@@ -66,7 +66,7 @@ import BaseLayout from "../layouts/BaseLayout.astro";
.container {
max-width: 500px;
width: 100%;
background: rgba(30, 30, 46, 0.8);
background: var(--color-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-surface);
border-radius: 8px;
+244 -33
View File
@@ -4,67 +4,192 @@ import BaseLayout from "../layouts/BaseLayout.astro";
<BaseLayout
title="About — Hidden Den Cafe"
description="About Latte — IT wizard, self-hosting advocate, privacy enthusiast, and the person behind Hidden Den Cafe."
description="About Latte and Hidden Den: a personal introduction, the values behind the site, and why this quiet corner of the internet exists."
>
<div class="matrix-bg" aria-hidden="true"></div>
<main class="main">
<div class="container">
<header class="header fade-in">
<p class="eyebrow">Latte / Hidden Den</p>
<h1 class="title">About</h1>
<div class="divider">══════════════════════════════</div>
</header>
<section class="section fade-in">
<h2>The Den</h2>
<p class="desc">
Hidden Den Cafe is my little corner of the internet — self-hosted,
self-maintained, and free from corporate nonsense. No trackers, no ads,
no data harvesting. Just a cozy space that I built and control.
<p class="lead">
Hi, I'm Latte. Hidden Den exists because I wanted a personal place on
the internet that feels the way I want technology to feel: calm,
understandable, warm, and fully mine.
</p>
</section>
</header>
<section class="section fade-in">
<h2>Who I Am</h2>
<p class="desc">
I'm <span class="highlight">Latte</span> an IT wizard with a homelab,
a love for privacy, and a deep distrust of companies that treat your data
like their product. I believe in owning your infrastructure, running your
own services, and keeping things under your own roof.
I'm <span class="highlight">Latte</span> - an IT professional, a
developer, and someone who spends a lot of time close to systems. A
lot of my day-to-day thinking is shaped by infrastructure, maintenance,
deployment, networking, and the quiet work required to keep things
reliable. I like understanding how things fit together, not just using
them from a distance.
</p>
<p class="desc">
I'm a gay furry developer who builds things because I want them to exist —
not because some product manager told me to. My stack leans toward Python
and self-hosted tooling, but I'm always exploring new things.
I run a homelab because I enjoy learning by building, breaking,
fixing, and gradually improving the systems I rely on. I tend to
prefer tools I can audit, services I can migrate, setups I can back
up, and infrastructure I can replace without begging a platform to keep
my life intact.
</p>
</section>
<section class="section fade-in">
<h2>The Homelab</h2>
<h2>The Person Behind The Stack</h2>
<p class="desc">
Most of what runs here lives on my own hardware — Gitea for code, Docker
for deployment, nginx for serving. Where physical infra doesn't make sense,
I rent VPS capacity from OVH and Play.hosting. For work, the Microsoft 365
ecosystem does what it needs to do.
The technical side is real, but it is not the whole story. I'm also a
gay furry developer with a soft spot for cozy cafe aesthetics, warm
tones, coffee culture, quiet spaces, and slow building. I am much less
interested in performing some polished hacker persona than I am in
making a space that feels thoughtful, lived in, and unmistakably human.
</p>
<p class="desc">
The goal isn't purity — it's control. Keep data minimal, choose providers
you understand, avoid surveillance-adjacent platforms. Self-host what you
can; rent infra where it's practical; use what you need without pretending
you don't.
Hidden Den reflects that mix. It is technical, but not sterile. It is
personal, but not performative. It is a place where infrastructure,
writing, projects, experiments, and internet philosophy can sit next to
warmth, identity, and the kinds of details that make a site feel like
someone actually lives there.
</p>
<div class="callout">
<p>
More cozy tech wizard than cyberpunk hacker.
</p>
</div>
</section>
<section class="section fade-in">
<h2>How I Tend To Build</h2>
<div class="value-grid">
<article class="value-card">
<h3>Understand It</h3>
<p>
I am most comfortable with systems I can inspect and reason
about. If I do not understand the tradeoffs, the failure modes,
or the path out, I do not feel like I really own the tool.
</p>
</article>
<article class="value-card">
<h3>Keep It Durable</h3>
<p>
I prefer setups that can be backed up, migrated, repaired, and
replaced. Durable systems are not always flashy, but they age
better and make better foundations for real life.
</p>
</article>
<article class="value-card">
<h3>Leave Room For Care</h3>
<p>
I care about interfaces and environments that feel intentional.
Warmth matters to me. I do not think technical spaces need to be
cold to be serious.
</p>
</article>
<article class="value-card">
<h3>Stay Practical</h3>
<p>
I do not treat purity as a goal. I self-host a lot because it
teaches me things and gives me control, but I still care about
workable systems more than ideological posturing.
</p>
</article>
</div>
<p class="uses-link">
Curious about the actual tools and setup? <a href="/uses">See what I use →</a>
</p>
</section>
<section class="section fade-in">
<h2>Ethos</h2>
<ul class="values">
<li><span class="value-key">privacy:</span> Your data is yours. Period.</li>
<li><span class="value-key">self-hosting:</span> If you can run it yourself, you should.</li>
<li><span class="value-key">open source:</span> Knowledge should be shared.</li>
<li><span class="value-key">small web:</span> The internet is better when it's personal.</li>
<h2>Why Hidden Den Exists</h2>
<p class="desc">
This site is not a portfolio, a startup brand, or a personal marketing
project. It exists because I wanted a real personal website again: a
place for writing, projects, experiments, infrastructure notes, and the
kinds of ideas that do not fit neatly into social platforms.
</p>
<p class="desc">
I wanted something quieter than a feed and more honest than a polished
personal brand. Hidden Den gives me room to publish on my own terms and
let the site grow slowly, in the shape that actually suits me.
</p>
</section>
<section class="section fade-in">
<h2>Why This Matters To Me</h2>
<p class="desc">
Working with infrastructure changes how you see the internet. It reveals
the parts most people never notice: who owns the platform, where the data
goes, what happens when the service changes, and how little control people
often have over the spaces they depend on. That matters to me both
technically and personally.
</p>
<p class="desc">
I care about technology that feels intentional instead of engineered for
surveillance, lock-in, or endless engagement. I want the tools around me
to be legible. I want the places I spend time in to respect the people
using them. Hidden Den is one small attempt to build that kind of space.
</p>
</section>
<section class="section fade-in">
<h2>On The Internet I Want</h2>
<p class="desc">
Too much of the modern web is optimized for extraction: attention,
behavior, identity, and dependence. I prefer a smaller internet made of
personal sites, weird projects, community infrastructure, and spaces
that are allowed to be specific. Not everything needs to become a
platform, and not every page needs to be a funnel.
</p>
<p class="desc">
I still believe the web is better when more people make places that feel
like their own. Places with taste. Places with personality. Places that
are maintained because someone cares about them, not because they have
been optimized against a dashboard.
</p>
</section>
<section class="section fade-in">
<h2>Privacy-First, In Practice</h2>
<p class="desc">
Privacy is part of the philosophy here, but it is also part of the
implementation. Hidden Den avoids trackers, ads, invasive analytics, and
unnecessary third-party dependencies. Static pages are not a compromise
for me. They are often the cleaner solution.
</p>
<p class="desc">
The same goes for the infrastructure behind the site. I prefer systems I
can audit, migrate, back up, and replace. People should be able to visit
a personal website without quietly being turned into a behavioral profile.
</p>
<ul class="values compact">
<li><span class="value-key">no trackers:</span> Visitors are guests, not telemetry.</li>
<li><span class="value-key">minimal dependencies:</span> Fewer external systems means fewer leaks.</li>
<li><span class="value-key">self-hosting bias:</span> Control matters when the tradeoff is reasonable.</li>
<li><span class="value-key">human scale:</span> The site is built to feel inhabited, not optimized.</li>
</ul>
</section>
<section class="section fade-in">
<h2>What I Want This Place To Be</h2>
<p class="desc">
Hidden Den is meant to feel like a quiet corner of the internet: warm,
thoughtful, technical, and personal. A place where I can share what I am
building and thinking about without flattening myself into a bio, a
brand, or a feed.
</p>
<p class="desc">
If this page does its job, it should feel clear that there is a real
person behind the site. Someone who likes systems and infrastructure,
cares about privacy, prefers warm light over neon, and still thinks the
internet is worth building on carefully.
</p>
</section>
<footer class="footer fade-in">
<p>Made with love by Latte</p>
</footer>
@@ -105,7 +230,7 @@ import BaseLayout from "../layouts/BaseLayout.astro";
.container {
max-width: 700px;
width: 100%;
background: rgba(30, 30, 46, 0.8);
background: var(--color-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-surface);
border-radius: 8px;
@@ -117,6 +242,14 @@ import BaseLayout from "../layouts/BaseLayout.astro";
margin-bottom: var(--space-lg);
}
.eyebrow {
color: var(--color-text-dim);
text-transform: uppercase;
letter-spacing: 0.24em;
font-size: 0.75rem;
margin-bottom: var(--space-sm);
}
.title {
font-size: 2rem;
font-weight: 700;
@@ -131,6 +264,13 @@ import BaseLayout from "../layouts/BaseLayout.astro";
user-select: none;
}
.lead {
color: var(--color-text);
line-height: 1.8;
max-width: 36rem;
margin: 0 auto;
}
.section {
margin: var(--space-lg) 0;
}
@@ -153,11 +293,56 @@ import BaseLayout from "../layouts/BaseLayout.astro";
margin-bottom: 0;
}
.callout {
margin-top: var(--space-md);
padding: var(--space-md);
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--color-accent) 10%, transparent),
transparent 70%
),
color-mix(in srgb, var(--color-bg-light) 84%, transparent);
border-radius: 8px;
}
.callout p {
color: var(--color-text);
line-height: 1.7;
}
.highlight {
color: var(--color-accent-bright);
font-weight: 700;
}
.value-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-md);
}
.value-card {
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);
}
.value-card h3 {
color: var(--color-accent-bright);
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.12em;
margin-bottom: var(--space-xs);
}
.value-card p {
color: var(--color-text-dim);
line-height: 1.7;
}
.values {
list-style: none;
display: flex;
@@ -175,6 +360,24 @@ import BaseLayout from "../layouts/BaseLayout.astro";
font-weight: 600;
}
.uses-link {
margin-top: var(--space-md);
font-size: 0.875rem;
color: var(--color-text-dim);
}
.uses-link a {
color: var(--color-accent);
}
.uses-link a:hover {
color: var(--color-accent-bright);
}
.compact {
margin-top: var(--space-md);
}
.footer {
margin-top: var(--space-xl);
text-align: center;
@@ -221,6 +424,14 @@ import BaseLayout from "../layouts/BaseLayout.astro";
.divider {
font-size: 0.6rem;
}
.lead {
font-size: 0.95rem;
}
.value-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
+282
View File
@@ -0,0 +1,282 @@
---
export const prerender = false;
import AdminLayout from '../../layouts/AdminLayout.astro';
import { getSession, SESSION_COOKIE, cleanExpiredSessions } from '../../lib/auth';
import { getPendingEntries, getRecentModerated } from '../../lib/guestbook';
const sessionId = Astro.cookies.get(SESSION_COOKIE)?.value;
const session = sessionId ? getSession(sessionId) : undefined;
if (!session) {
return Astro.redirect('/admin/login');
}
// Periodic cleanup
cleanExpiredSessions();
const pending = getPendingEntries();
const recent = getRecentModerated(20);
const errorParam = Astro.url.searchParams.get('error');
---
<AdminLayout title="Moderation">
<h1>guestbook moderation</h1>
{errorParam && (
<div class="alert alert-error" role="alert">
{errorParam === 'invalid' ? 'Invalid action.' : errorParam}
</div>
)}
<!-- Pending entries -->
<section aria-labelledby="pending-heading">
<h2 id="pending-heading">
Pending
{pending.length > 0 && <span class="count-badge">{pending.length}</span>}
</h2>
{pending.length === 0 ? (
<div class="card empty">
<p>No pending messages. All clear.</p>
</div>
) : (
<div class="entry-list">
{pending.map((entry) => (
<article class="card entry-card">
<div class="entry-meta meta">
<span class="badge badge-pending">pending</span>
<span>id #{entry.id}</span>
<time datetime={entry.created_at}>{entry.created_at.replace('T', ' ').replace('Z', ' UTC')}</time>
{entry.ip_hash && <span title="Truncated IP hash (privacy-safe)">ip: {entry.ip_hash}</span>}
</div>
<div class="entry-fields">
<div class="entry-field">
<span class="field-label">name:</span>
<span class="field-value">{entry.display_name}</span>
</div>
{entry.website && (
<div class="entry-field">
<span class="field-label">website:</span>
<a
href={entry.website}
class="field-value"
rel="noopener noreferrer nofollow"
target="_blank"
>
{entry.website}
</a>
</div>
)}
<div class="entry-field">
<span class="field-label">message:</span>
</div>
<blockquote class="message-text">{entry.message}</blockquote>
</div>
<form method="post" action="/api/admin/moderate" class="action-form">
<input type="hidden" name="id" value={entry.id} />
<div class="note-field">
<label for={`note-${entry.id}`} class="meta">
Note (optional):
</label>
<input
type="text"
id={`note-${entry.id}`}
name="note"
maxlength="200"
placeholder="internal note..."
/>
</div>
<div class="action-btns">
<button type="submit" name="action" value="approve" class="btn btn-approve">
approve
</button>
<button type="submit" name="action" value="reject" class="btn btn-reject">
reject
</button>
<button type="submit" name="action" value="spam" class="btn btn-spam">
spam
</button>
</div>
</form>
</article>
))}
</div>
)}
</section>
<!-- Recently moderated -->
{recent.length > 0 && (
<section class="section-gap" aria-labelledby="recent-heading">
<h2 id="recent-heading">Recently moderated</h2>
<div class="entry-list">
{recent.map((entry) => (
<article class="card entry-card entry-card--compact">
<div class="entry-meta meta">
<span class:list={['badge', `badge-${entry.status}`]}>{entry.status}</span>
<span>id #{entry.id}</span>
<time datetime={entry.created_at}>{entry.created_at.slice(0, 10)}</time>
<span class="entry-name">{entry.display_name}</span>
</div>
<p class="message-text compact-msg">{entry.message}</p>
{entry.moderation_note && (
<p class="meta mod-note">note: {entry.moderation_note}</p>
)}
</article>
))}
</div>
</section>
)}
</AdminLayout>
<style>
h1 {
margin-bottom: var(--space-md);
}
h2 {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-bottom: var(--space-sm);
}
.count-badge {
background: var(--color-peach);
color: #1e1e2e;
font-size: 0.75rem;
font-weight: bold;
padding: 1px 7px;
border-radius: 10px;
}
.entry-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.entry-card {
border-left: 3px solid var(--color-surface);
}
.entry-meta {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
margin-bottom: var(--space-xs);
font-size: 0.8rem;
}
.entry-fields {
margin-bottom: var(--space-sm);
}
.entry-field {
display: flex;
gap: 6px;
font-size: 0.88rem;
margin-bottom: 4px;
}
.field-label {
color: var(--color-text-dim);
flex-shrink: 0;
min-width: 5em;
}
.field-value {
word-break: break-all;
}
.message-text {
white-space: pre-wrap;
word-break: break-word;
font-size: 0.88rem;
padding: var(--space-xs) var(--space-sm);
border-left: 2px solid var(--color-surface);
margin: var(--space-xs) 0;
font-style: normal;
}
.action-form {
display: flex;
flex-direction: column;
gap: var(--space-xs);
border-top: 1px solid var(--color-surface);
padding-top: var(--space-sm);
margin-top: var(--space-xs);
}
.note-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.note-field input {
max-width: 400px;
font-size: 0.82rem;
}
.action-btns {
display: flex;
gap: var(--space-xs);
flex-wrap: wrap;
}
.btn {
padding: 4px 14px;
border-radius: 4px;
border: 1px solid currentColor;
background: none;
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.75; }
.btn-approve { color: var(--color-green); }
.btn-reject { color: var(--color-text-dim); }
.btn-spam { color: var(--color-warm); }
.entry-card--compact {
padding: var(--space-xs) var(--space-sm);
}
.compact-msg {
font-size: 0.85rem;
color: var(--color-text-dim);
white-space: pre-wrap;
word-break: break-word;
max-height: 3em;
overflow: hidden;
text-overflow: ellipsis;
}
.mod-note {
font-size: 0.78rem;
color: var(--color-text-dim);
font-style: italic;
}
.entry-name {
color: var(--color-text);
}
.empty {
color: var(--color-text-dim);
font-size: 0.88rem;
padding: var(--space-md);
}
.section-gap {
margin-top: var(--space-xl);
}
</style>
+108
View File
@@ -0,0 +1,108 @@
---
export const prerender = false;
import AdminLayout from '../../layouts/AdminLayout.astro';
import { getSession, SESSION_COOKIE } from '../../lib/auth';
// Redirect if already logged in
const sessionId = Astro.cookies.get(SESSION_COOKIE)?.value;
const session = sessionId ? getSession(sessionId) : undefined;
if (session) {
return Astro.redirect('/admin');
}
const tokenAuthEnabled = Boolean(process.env.ADMIN_SECRET_TOKEN?.trim());
const tokenError = Astro.url.searchParams.get('tokenError') === '1';
---
<AdminLayout title="Login">
<div class="login-wrap">
<h1>admin access</h1>
{tokenAuthEnabled ? (
<div class="card">
<h2>Token login</h2>
<p class="info-text">
Enter your admin token to access moderation.
</p>
{tokenError && (
<p class="warning-text">Invalid token. Try again.</p>
)}
<form method="post" action="/api/admin/token-login" class="token-form">
<label for="token-input" class="token-label">Admin token</label>
<input
id="token-input"
name="token"
type="password"
autocomplete="current-password"
required
class="token-input"
/>
<button type="submit" class="btn btn-primary">Sign in with token</button>
</form>
</div>
) : (
<div class="card">
<h2>Token not configured</h2>
<p class="warning-text">
<code>ADMIN_SECRET_TOKEN</code> is not set. Configure it in your environment, then reload this page.
</p>
</div>
)}
</div>
<style>
.login-wrap {
max-width: 480px;
margin: 0 auto;
}
h1 {
margin-bottom: var(--space-md);
}
.info-text {
color: var(--color-text-dim);
font-size: 0.88rem;
margin-bottom: var(--space-sm);
}
.warning-text {
color: var(--color-peach);
font-size: 0.82rem;
margin-bottom: var(--space-md);
padding: var(--space-xs) var(--space-sm);
border-left: 2px solid var(--color-peach);
}
.btn {
display: inline-block;
padding: 8px 20px;
border-radius: 4px;
border: 1px solid currentColor;
background: none;
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.8; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary { color: var(--color-accent); }
.token-form {
display: grid;
gap: var(--space-xs);
}
.token-label {
color: var(--color-text-dim);
font-size: 0.82rem;
}
.token-input {
width: 100%;
max-width: 360px;
}
</style>
</AdminLayout>
+14
View File
@@ -0,0 +1,14 @@
import type { APIRoute } from 'astro';
import { deleteSession, SESSION_COOKIE } from '../../../lib/auth';
export const prerender = false;
export const POST: APIRoute = async ({ cookies }) => {
const sessionId = cookies.get(SESSION_COOKIE)?.value;
if (sessionId) {
deleteSession(sessionId);
cookies.delete(SESSION_COOKIE, { path: '/' });
}
return new Response(null, { status: 303, headers: { Location: '/admin/login' } });
};
+39
View File
@@ -0,0 +1,39 @@
import type { APIRoute } from 'astro';
import { moderateEntry } from '../../../lib/guestbook';
import { getSession, SESSION_COOKIE } from '../../../lib/auth';
export const prerender = false;
export const POST: APIRoute = async ({ request, cookies }) => {
const sessionId = cookies.get(SESSION_COOKIE)?.value;
const session = sessionId ? getSession(sessionId) : undefined;
if (!session) {
return new Response(null, { status: 303, headers: { Location: '/admin/login' } });
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return new Response(null, { status: 303, headers: { Location: '/admin' } });
}
const id = parseInt(String(formData.get('id') ?? ''), 10);
const action = String(formData.get('action') ?? '');
const note = String(formData.get('note') ?? '').trim() || null;
if (isNaN(id) || !['approve', 'reject', 'spam'].includes(action)) {
return new Response(null, { status: 303, headers: { Location: '/admin?error=invalid' } });
}
const statusMap: Record<string, 'approved' | 'rejected' | 'spam'> = {
approve: 'approved',
reject: 'rejected',
spam: 'spam',
};
moderateEntry(id, statusMap[action], note, session.id);
return new Response(null, { status: 303, headers: { Location: '/admin' } });
};
+37
View File
@@ -0,0 +1,37 @@
import type { APIRoute } from 'astro';
import { timingSafeEqual } from 'node:crypto';
import { createSession, SESSION_COOKIE, sessionCookieOptions } from '../../../lib/auth';
export const prerender = false;
function tokenMatches(input: string, expected: string): boolean {
const a = Buffer.from(input);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export const POST: APIRoute = async ({ request, cookies }) => {
const expectedToken = process.env.ADMIN_SECRET_TOKEN?.trim();
if (!expectedToken) {
return new Response(null, { status: 404 });
}
const formData = await request.formData();
const token = String(formData.get('token') ?? '').trim();
if (!tokenMatches(token, expectedToken)) {
return new Response(null, {
status: 303,
headers: { Location: '/admin/login?tokenError=1' },
});
}
const sessionId = createSession('admin');
cookies.set(SESSION_COOKIE, sessionId, sessionCookieOptions(24 * 60 * 60));
return new Response(null, {
status: 303,
headers: { Location: '/admin' },
});
};
+83
View File
@@ -0,0 +1,83 @@
import type { APIRoute } from 'astro';
import {
submitEntry,
checkRateLimit,
recordSubmission,
isDuplicateSubmission,
moderateEntry,
} from '../../../lib/guestbook';
import { hashIP } from '../../../lib/auth';
import { validateEntry, sanitizeText, stripHtml, validateWebsite, isLikelySpam } from '../../../lib/spam';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
// Parse form data
let formData: FormData;
try {
formData = await request.formData();
} catch {
return redirect('/guestbook?error=invalid');
}
const raw = {
display_name: String(formData.get('display_name') ?? ''),
message: String(formData.get('message') ?? ''),
website: String(formData.get('website') ?? ''),
consent: String(formData.get('consent') ?? ''),
honeypot: String(formData.get('address') ?? ''), // hidden honeypot field named "address"
};
// Validate
const errors = validateEntry(raw);
if (errors.some((e) => e.field === 'honeypot')) {
// Silent reject for bots: appear successful
return redirect('/guestbook?submitted=true');
}
if (errors.length > 0) {
const msg = encodeURIComponent(errors[0].message);
return redirect(`/guestbook?error=${msg}`);
}
// Sanitize
const display_name = stripHtml(sanitizeText(raw.display_name));
const message = stripHtml(sanitizeText(raw.message));
const website = validateWebsite(raw.website);
// Rate limit by salted IP hash
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.headers.get('x-real-ip')
?? 'unknown';
const ipHash = hashIP(ip);
if (!checkRateLimit(ipHash)) {
return redirect('/guestbook?error=rate_limit');
}
// Duplicate check
if (isDuplicateSubmission(display_name, message)) {
return redirect('/guestbook?submitted=true'); // silent dedupe
}
// Heuristic spam scoring: auto-mark as spam if score is high
const spamEntry = isLikelySpam({ display_name, message, website });
recordSubmission(ipHash);
const id = submitEntry({ display_name, message, website, ip_hash: ipHash });
// If auto-detected as spam, immediately mark it
if (spamEntry) {
moderateEntry(id, 'spam', 'auto-detected', 'system');
return redirect('/guestbook?submitted=true'); // still appear successful
}
return redirect('/guestbook?submitted=true');
};
function redirect(location: string): Response {
return new Response(null, {
status: 303,
headers: { Location: location },
});
}
+467 -17
View File
@@ -1,25 +1,81 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro";
import { getCollection } from "astro:content";
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 },
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 } = Astro.props;
const { Content } = await post.render();
const {
post,
seriesPosts = [],
relatedPosts = [],
} = Astro.props as {
post: BlogPost;
seriesPosts: BlogPost[];
relatedPosts: BlogPost[];
};
function formatDate(date: Date) {
return date.toISOString().split("T")[0];
}
const { Content } = await post.render();
const readingTime = getReadingTime(post.body);
---
<BaseLayout
title={`${post.data.title} Hidden Den Cafe`}
title={`${post.data.title} - Hidden Den Cafe`}
description={post.data.description}
>
<div class="matrix-bg" aria-hidden="true"></div>
@@ -27,15 +83,148 @@ function formatDate(date: Date) {
<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>
<p class="date">{formatDate(post.data.date)}</p>
<div class="divider">══════════════════════════════</div>
<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>
@@ -60,8 +249,12 @@ function formatDate(date: Date) {
}
@keyframes grid-move {
0% { transform: translate(0, 0); }
100% { transform: translate(50px, 50px); }
0% {
transform: translate(0, 0);
}
100% {
transform: translate(50px, 50px);
}
}
.main {
@@ -76,7 +269,7 @@ function formatDate(date: Date) {
.container {
max-width: 700px;
width: 100%;
background: rgba(30, 30, 46, 0.8);
background: var(--color-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-surface);
border-radius: 8px;
@@ -87,6 +280,14 @@ function formatDate(date: Date) {
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;
@@ -107,7 +308,43 @@ function formatDate(date: Date) {
font-size: 0.85rem;
}
/* Prose styles for markdown content */
.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;
@@ -208,6 +445,186 @@ function formatDate(date: Date) {
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;
@@ -223,9 +640,18 @@ function formatDate(date: Date) {
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(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 {
@@ -250,6 +676,21 @@ function formatDate(date: Date) {
.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) {
@@ -261,5 +702,14 @@ function formatDate(date: Date) {
animation: none;
opacity: 1;
}
.related-card {
transition: none;
}
.related-card:hover {
transform: none;
box-shadow: none;
}
}
</style>
+116 -32
View File
@@ -1,13 +1,17 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro";
import { getCollection } from "astro:content";
import { formatBlogDate, getTagHref } from "../../lib/blog";
import { getReadingTime } from "../../lib/readingTime";
const posts = (await getCollection("blog", ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
function formatDate(date: Date) {
return date.toISOString().split("T")[0];
}
const previewPosts = posts.map((post) => ({
...post,
readingTime: getReadingTime(post.body),
}));
---
<BaseLayout
@@ -23,25 +27,63 @@ function formatDate(date: Date) {
<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>
)}
{
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">
{previewPosts.map((post) => (
<li class="post-item">
<div class="post-meta-column">
<time
class="post-date"
datetime={formatBlogDate(
post.data.pubDate,
)}
>
{formatBlogDate(post.data.pubDate)}
</time>
<span class="post-reading-time">
{post.readingTime.text}
</span>
</div>
<div class="post-info">
<a
href={`/blog/${post.slug}`}
class="post-title"
>
{post.data.title}
</a>
<p class="post-desc">
{post.data.description}
</p>
{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>
</li>
))}
</ul>
</section>
)
}
<footer class="footer fade-in">
<p>Made with love by Latte</p>
@@ -67,8 +109,12 @@ function formatDate(date: Date) {
}
@keyframes grid-move {
0% { transform: translate(0, 0); }
100% { transform: translate(50px, 50px); }
0% {
transform: translate(0, 0);
}
100% {
transform: translate(50px, 50px);
}
}
.main {
@@ -83,7 +129,7 @@ function formatDate(date: Date) {
.container {
max-width: 700px;
width: 100%;
background: rgba(30, 30, 46, 0.8);
background: var(--color-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-surface);
border-radius: 8px;
@@ -126,12 +172,24 @@ function formatDate(date: Date) {
align-items: flex-start;
}
.post-meta-column {
min-width: 110px;
display: flex;
flex-direction: column;
gap: 2px;
}
.post-date {
color: var(--color-text-dim);
font-size: 0.8rem;
white-space: nowrap;
padding-top: 2px;
min-width: 90px;
}
.post-reading-time {
color: var(--color-accent);
font-size: 0.72rem;
white-space: nowrap;
}
.post-info {
@@ -156,6 +214,26 @@ function formatDate(date: Date) {
line-height: 1.6;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.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);
}
.empty {
color: var(--color-text-dim);
font-style: italic;
@@ -176,9 +254,15 @@ function formatDate(date: Date) {
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(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 {
@@ -209,7 +293,7 @@ function formatDate(date: Date) {
gap: var(--space-xs);
}
.post-date {
.post-meta-column {
min-width: unset;
}
}
+395
View File
@@ -0,0 +1,395 @@
---
import BaseLayout from "../../../layouts/BaseLayout.astro";
import { getCollection, type CollectionEntry } from "astro:content";
import { formatBlogDate, getTagHref, slugifyTag } from "../../../lib/blog";
import { getReadingTime } from "../../../lib/readingTime";
type BlogPost = CollectionEntry<"blog">;
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
const tagMap = new Map<string, { tag: string; posts: BlogPost[] }>();
for (const post of posts) {
for (const tag of post.data.tags) {
const slug = slugifyTag(tag);
const existing = tagMap.get(slug);
if (existing) {
existing.posts.push(post);
} else {
tagMap.set(slug, { tag, posts: [post] });
}
}
}
return [...tagMap.entries()].map(([slug, entry]) => ({
params: { tag: slug },
props: {
tag: entry.tag,
posts: entry.posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
),
},
}));
}
const { tag, posts } = Astro.props as {
tag: string;
posts: BlogPost[];
};
const previewPosts = posts.map((post) => ({
...post,
readingTime: getReadingTime(post.body),
}));
---
<BaseLayout
title={`Tag: ${tag} - Hidden Den Cafe`}
description={`Blog posts tagged ${tag} on Hidden Den.`}
>
<div class="matrix-bg" aria-hidden="true"></div>
<main class="main">
<div class="container">
<header class="header fade-in">
<p class="eyebrow">Blog tag</p>
<h1 class="title">{tag}</h1>
<div class="divider">==============================</div>
<p class="lead">
Posts filed under <span class="highlight">{tag}</span>. A
small cluster of related thoughts from the den.
</p>
</header>
<section class="section fade-in" aria-labelledby="tag-posts">
<div class="section-head">
<h2 id="tag-posts">Posts</h2>
<a class="section-link" href="/blog">Back to blog</a>
</div>
<ul class="post-list">
{previewPosts.map((post) => (
<li class="post-item">
<div class="post-meta-column">
<time
class="post-date"
datetime={formatBlogDate(post.data.pubDate)}
>
{formatBlogDate(post.data.pubDate)}
</time>
<span class="post-reading-time">
{post.readingTime.text}
</span>
</div>
<div class="post-info">
<a
href={`/blog/${post.slug}`}
class="post-title"
>
{post.data.title}
</a>
<p class="post-desc">{post.data.description}</p>
{post.data.tags.length > 0 && (
<div
class="tag-list"
aria-label={`${post.data.title} tags`}
>
{post.data.tags.map((entryTag) => (
<a
class:list={[
"tag",
{
active:
entryTag === tag,
},
]}
href={getTagHref(entryTag)}
>
{entryTag}
</a>
))}
</div>
)}
</div>
</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 {
text-align: center;
margin-bottom: var(--space-lg);
}
.eyebrow {
color: var(--color-text-dim);
text-transform: uppercase;
letter-spacing: 0.24em;
font-size: 0.75rem;
margin-bottom: var(--space-sm);
}
.title {
font-size: 2rem;
font-weight: 700;
letter-spacing: 2px;
}
.divider {
color: var(--color-surface);
text-align: center;
font-size: 0.75rem;
margin: var(--space-md) 0;
user-select: none;
}
.lead {
color: var(--color-text-dim);
line-height: 1.8;
max-width: 38rem;
margin: 0 auto;
}
.highlight {
color: var(--color-accent-bright);
font-weight: 700;
}
.section {
margin: var(--space-md) 0;
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.section-head h2 {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--color-accent);
}
.section-link {
color: var(--color-blue);
font-size: 0.85rem;
white-space: nowrap;
}
.section-link:hover {
color: var(--color-accent-bright);
}
.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-meta-column {
min-width: 110px;
display: flex;
flex-direction: column;
gap: 2px;
}
.post-date {
color: var(--color-text-dim);
font-size: 0.8rem;
white-space: nowrap;
padding-top: 2px;
}
.post-reading-time {
color: var(--color-accent);
font-size: 0.72rem;
white-space: nowrap;
}
.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;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.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,
.tag.active {
color: var(--color-accent-bright);
border-color: color-mix(in srgb, var(--color-accent) 45%, transparent);
}
.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;
}
@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;
}
.lead {
font-size: 0.95rem;
}
.section-head {
flex-direction: column;
align-items: flex-start;
}
.post-item {
flex-direction: column;
gap: var(--space-xs);
}
.post-meta-column {
min-width: unset;
}
}
@media (prefers-reduced-motion: reduce) {
.matrix-bg {
animation: none;
}
.fade-in {
animation: none;
opacity: 1;
}
}
</style>
+526
View File
@@ -0,0 +1,526 @@
---
export const prerender = false;
import BaseLayout from '../layouts/BaseLayout.astro';
import { getApprovedEntries } from '../lib/guestbook';
const pageParam = parseInt(Astro.url.searchParams.get('page') ?? '1', 10);
const page = isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
const { entries, total, hasMore } = getApprovedEntries(page);
const submitted = Astro.url.searchParams.get('submitted') === 'true';
const errorParam = Astro.url.searchParams.get('error');
const errorMessages: Record<string, string> = {
rate_limit: 'You\'ve submitted too many messages recently. Please wait a while before trying again.',
invalid: 'Your submission could not be processed. Please check all fields and try again.',
};
const errorMsg = errorParam ? (errorMessages[errorParam] ?? decodeURIComponent(errorParam)) : null;
---
<BaseLayout
title="Guestbook — Hidden Den Cafe"
description="Leave a message in the guestbook for Hidden Den Cafe."
>
<div class="container fade-in">
<header class="page-header">
<h1>~/guestbook</h1>
<p class="subtitle">
Leave a note. Say hello. This is a quiet corner of the internet — be kind.
</p>
</header>
<!-- Submission form -->
<section class="card form-section" aria-label="Leave a message">
<details class="compose" open={submitted || Boolean(errorMsg)}>
<summary class="compose-toggle">
<span class="compose-toggle-text">Leave a message</span>
</summary>
<div class="compose-body">
<p class="form-note">
Messages are reviewed before being published. Please don't include personal or
sensitive information — this is a public guestbook.
</p>
{submitted && (
<div class="alert alert-success" role="alert">
Thank you for your message! It will appear here once reviewed.
</div>
)}
{errorMsg && (
<div class="alert alert-error" role="alert">
{errorMsg}
</div>
)}
<form
method="post"
action="/api/guestbook/submit"
class="guestbook-form"
novalidate
>
<!-- Honeypot field: hidden from real users, filled by bots -->
<div class="hp-field" aria-hidden="true">
<label for="address">Address</label>
<input
type="text"
id="address"
name="address"
tabindex="-1"
autocomplete="off"
/>
</div>
<div class="field">
<label for="display_name">
Name or nickname <span class="required" aria-label="required">*</span>
</label>
<input
type="text"
id="display_name"
name="display_name"
maxlength="60"
required
autocomplete="nickname"
placeholder="your name or alias"
/>
</div>
<div class="field">
<label for="message">
Message <span class="required" aria-label="required">*</span>
</label>
<textarea
id="message"
name="message"
maxlength="1000"
required
rows="4"
placeholder="say hello, share a thought, leave a trail..."
></textarea>
<span class="field-hint">Plain text only. Max 1000 characters.</span>
</div>
<div class="field">
<label for="website">
Website <span class="optional">(optional)</span>
</label>
<input
type="url"
id="website"
name="website"
maxlength="200"
autocomplete="url"
placeholder="https://your-site.example"
/>
<span class="field-hint">Only https:// links. Leave blank if you don't have one.</span>
</div>
<div class="field consent-field">
<label class="consent-label">
<input
type="checkbox"
name="consent"
value="yes"
required
/>
<span>
I understand this message may be published publicly on this guestbook.
<span class="required" aria-label="required">*</span>
</span>
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn-submit">send message</button>
</div>
</form>
</div>
</details>
</section>
<!-- Approved entries -->
<section class="entries-section" aria-labelledby="entries-heading">
<h2 id="entries-heading">
Messages
{total > 0 && <span class="entry-count">({total})</span>}
</h2>
{entries.length === 0 ? (
<div class="empty-state card">
<p>No messages yet. Be the first to leave a note!</p>
</div>
) : (
<div class="entries-list">
{entries.map((entry) => (
<article class="entry card">
<header class="entry-header">
<span class="entry-name">
{entry.website ? (
<a
href={entry.website}
rel="noopener noreferrer nofollow"
target="_blank"
>
{entry.display_name}
</a>
) : (
entry.display_name
)}
</span>
<time
class="entry-date"
datetime={entry.created_at}
title={entry.created_at}
>
{new Date(entry.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
</header>
<p class="entry-message">{entry.message}</p>
</article>
))}
</div>
)}
<!-- Pagination -->
{(page > 1 || hasMore) && (
<nav class="pagination" aria-label="Guestbook pagination">
{page > 1 && (
<a href={`/guestbook?page=${page - 1}`} class="page-link">
← newer
</a>
)}
<span class="page-info">page {page}</span>
{hasMore && (
<a href={`/guestbook?page=${page + 1}`} class="page-link">
older →
</a>
)}
</nav>
)}
</section>
</div>
</BaseLayout>
<style>
.container {
max-width: 700px;
margin: 0 auto;
padding: calc(4rem + var(--space-lg)) var(--space-md) var(--space-xl);
}
.page-header {
margin-bottom: var(--space-lg);
}
h1 {
font-size: 1.6rem;
color: var(--color-accent-bright);
margin-bottom: var(--space-xs);
}
h2 {
font-size: 1.1rem;
color: var(--color-accent-bright);
margin-bottom: var(--space-sm);
}
.subtitle {
color: var(--color-text-dim);
font-size: 0.9rem;
}
/* Form */
.form-section {
background: var(--color-bg-light);
border: 1px solid var(--color-surface);
border-radius: 8px;
padding: var(--space-md);
margin-bottom: var(--space-lg);
}
.form-note {
color: var(--color-text-dim);
font-size: 0.85rem;
margin-bottom: var(--space-md);
}
.compose {
width: 100%;
}
.compose-toggle {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
color: var(--color-accent-bright);
font-size: 1.1rem;
font-weight: bold;
margin-bottom: var(--space-sm);
user-select: none;
}
.compose-toggle::-webkit-details-marker {
display: none;
}
.compose-toggle:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: 4px;
}
.compose-toggle::before {
content: '+';
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.2em;
height: 1.2em;
border: 1px solid var(--color-accent);
border-radius: 4px;
color: var(--color-accent);
font-weight: normal;
line-height: 1;
}
.compose[open] .compose-toggle::before {
content: '';
}
.compose-body {
margin-top: var(--space-sm);
}
.guestbook-form {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
/* Honeypot: visually hidden but accessible-compatible */
.hp-field {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
label {
font-size: 0.88rem;
color: var(--color-text-dim);
}
.required {
color: var(--color-warm);
margin-left: 2px;
}
.optional {
color: var(--color-surface);
font-size: 0.82rem;
}
input[type="text"],
input[type="url"],
textarea {
background: var(--color-bg);
border: 1px solid var(--color-surface);
border-radius: 4px;
color: var(--color-text);
font-family: var(--font-body);
font-size: 0.9rem;
padding: 8px 10px;
width: 100%;
transition: border-color 0.15s;
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--color-accent);
}
textarea {
resize: vertical;
min-height: 100px;
}
.field-hint {
font-size: 0.78rem;
color: var(--color-text-dim);
}
.consent-field {
margin-top: var(--space-xs);
}
.consent-label {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
font-size: 0.88rem;
color: var(--color-text-dim);
}
.consent-label input[type="checkbox"] {
margin-top: 3px;
flex-shrink: 0;
accent-color: var(--color-accent);
width: auto;
}
.form-actions {
margin-top: var(--space-xs);
}
.btn-submit {
background: var(--color-accent);
color: var(--color-bg);
border: none;
border-radius: 4px;
padding: 8px 20px;
font-family: var(--font-body);
font-size: 0.9rem;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-submit:hover {
opacity: 0.85;
}
.btn-submit:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Alerts */
.alert {
padding: var(--space-sm);
border-radius: 4px;
margin-bottom: var(--space-sm);
border: 1px solid;
font-size: 0.88rem;
}
.alert-success {
border-color: var(--color-green);
color: var(--color-green);
background: color-mix(in srgb, var(--color-green) 10%, transparent);
}
.alert-error {
border-color: var(--color-warm);
color: var(--color-warm);
background: color-mix(in srgb, var(--color-warm) 10%, transparent);
}
/* Entries */
.entries-section {
margin-top: var(--space-lg);
}
.entry-count {
color: var(--color-text-dim);
font-size: 0.9rem;
font-weight: normal;
}
.entries-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.entry {
background: var(--color-bg-light);
border: 1px solid var(--color-surface);
border-radius: 6px;
padding: var(--space-sm) var(--space-md);
}
.entry-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: 6px;
flex-wrap: wrap;
}
.entry-name {
color: var(--color-accent-bright);
font-weight: bold;
font-size: 0.92rem;
}
.entry-name a {
color: var(--color-accent-bright);
}
.entry-date {
color: var(--color-text-dim);
font-size: 0.78rem;
white-space: nowrap;
}
.entry-message {
color: var(--color-text);
font-size: 0.9rem;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
}
.empty-state {
background: var(--color-bg-light);
border: 1px solid var(--color-surface);
border-radius: 6px;
padding: var(--space-md);
color: var(--color-text-dim);
font-size: 0.9rem;
text-align: center;
}
/* Pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-md);
margin-top: var(--space-lg);
font-size: 0.88rem;
}
.page-link {
color: var(--color-accent);
}
.page-info {
color: var(--color-text-dim);
}
@media (max-width: 600px) {
.container {
padding-top: calc(3.5rem + var(--space-lg));
}
}
</style>
+1 -1
View File
@@ -210,7 +210,7 @@ if (now < new Date(now.getFullYear(), 6, 8)) age--;
.container {
max-width: 700px;
width: 100%;
background: rgba(30, 30, 46, 0.8);
background: var(--color-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-surface);
border-radius: 8px;
+152
View File
@@ -0,0 +1,152 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import NowContent from "../data/now.md";
---
<BaseLayout
title="Now - Hidden Den Cafe"
description="A living snapshot of what Latte is building, learning, and focusing on right now."
>
<div class="matrix-bg" aria-hidden="true"></div>
<main class="main">
<article class="now-card fade-in">
<header class="header">
<h1 class="title">Now</h1>
<div class="divider">══════════════════════════════</div>
</header>
<NowContent />
</article>
</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: flex-start;
justify-content: center;
padding: var(--space-lg) var(--space-md);
padding-top: calc(var(--space-lg) + 3rem);
}
.now-card {
width: 100%;
max-width: 700px;
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);
}
.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;
}
.now-card :global(h2) {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 2px;
margin-top: var(--space-lg);
margin-bottom: var(--space-sm);
color: var(--color-accent);
}
.now-card :global(p),
.now-card :global(li) {
color: var(--color-text-dim);
line-height: 1.7;
}
.now-card :global(p) {
margin-bottom: var(--space-sm);
}
.now-card :global(ul) {
margin-left: 1.2rem;
display: grid;
gap: var(--space-xs);
}
.now-card :global(strong) {
color: var(--color-accent-bright);
font-weight: 700;
}
.fade-in {
animation: fadeIn 0.6s ease-out forwards;
opacity: 0;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 600px) {
.now-card {
padding: var(--space-md);
}
.title {
font-size: 1.5rem;
}
.divider {
font-size: 0.6rem;
}
}
@media (prefers-reduced-motion: reduce) {
.matrix-bg {
animation: none;
}
.fade-in {
animation: none;
opacity: 1;
}
}
</style>
+1 -1
View File
@@ -126,7 +126,7 @@ function getPrimaryLink(project: Project): string | undefined {
.container {
max-width: 700px;
width: 100%;
background: rgba(30, 30, 46, 0.8);
background: var(--color-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-surface);
border-radius: 8px;
+568
View File
@@ -0,0 +1,568 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout
title="Uses — Hidden Den Cafe"
description="The tools, hardware, software, and infrastructure Mats uses to build, self-host, and explore technology."
>
<div class="matrix-bg" aria-hidden="true"></div>
<main class="main">
<div class="container">
<header class="header fade-in">
<h1 class="title">Uses</h1>
<div class="divider">══════════════════════════════</div>
<p class="intro">
This page is a window into the workshop behind Hidden Den Cafe.
It is less about a perfect spec sheet and more about the tools I
actually reach for to write code, run infrastructure, and keep
exploring how much of the internet I can build on my own terms.
Some of it lives on my desk, some of it lives in the homelab,
and some of it lives in carefully chosen external systems.
</p>
</header>
<section class="section fade-in">
<h2>Personal Computing Environment</h2>
<div class="uses-list">
<div class="use-item">
<h3>Windows + Ubuntu on WSL2</h3>
<p>
My day-to-day machine is a Windows workstation with an Ubuntu
environment inside WSL2. It gives me a practical desktop setup
while keeping most of my actual work in a Linux shell where I
can script, build, and debug with less friction.
</p>
</div>
<div class="use-item">
<h3>Terminal + Bash</h3>
<p>
Bash is still where a lot of real work happens for me. If a task
can be expressed as a clean command, script, or container build,
that is usually the route I prefer because it stays close to the
system instead of hiding it behind layers of UI.
</p>
</div>
<div class="use-item">
<h3>Zed</h3>
<p>
<a href="https://zed.dev" target="_blank" rel="noopener noreferrer">Zed</a>
is my primary editor. I like it because it is extremely fast,
modern without feeling bloated, and built for the kind of
collaborative and AI-assisted development workflow I spend a lot
of time experimenting with.
</p>
</div>
<div class="use-item">
<h3>Fast tools, low friction</h3>
<p>
I am happiest when tools respond immediately and get out of the
way. Markdown, plain text configs, and lightweight utilities
matter to me because they keep the system inspectable and make it
easier to move from idea to experiment without losing momentum.
</p>
</div>
</div>
</section>
<section class="section fade-in">
<h2>Development Tools</h2>
<div class="uses-list">
<div class="use-item">
<h3>Python first, Astro on purpose</h3>
<p>
A lot of my background is in Python and Flask-style thinking:
simple services, clear behavior, and tooling I can reason about.
Cozy Den is also where I am learning Astro because it fits the
small-web, static-first approach I want for personal sites.
</p>
</div>
<div class="use-item">
<h3>Git + self-hosted Gitea</h3>
<p>
Version control lives on my own Gitea instance at
<a href="https://git.hiddenden.cafe" target="_blank" rel="noopener noreferrer">git.hiddenden.cafe</a>.
I use it both as a code forge and as part of the deployment path.
</p>
</div>
<div class="use-item">
<h3>Docker</h3>
<p>
Containers are the default way I package and move projects. I
like being able to build something once, understand its runtime,
and ship the same artifact to the machines that need it.
</p>
</div>
<div class="use-item">
<h3>TypeScript, Markdown, and plain files</h3>
<p>
I use typed configs where they help, Markdown where writing
should stay lightweight, and plain files wherever possible so the
project remains durable without a huge stack around it.
</p>
</div>
<div class="use-item">
<h3>Editor-driven exploration</h3>
<p>
A lot of experimentation starts in the editor: quick prototypes,
note fragments, config drafts, and partial implementations. I
prefer tools that make it easy to move between writing, coding,
terminal work, and AI-assisted iteration in one place.
</p>
</div>
</div>
</section>
<section class="section fade-in">
<h2>AI Tools</h2>
<div class="uses-list">
<div class="use-item">
<h3>Model mix, not model worship</h3>
<p>
I actively experiment with multiple AI systems, including OpenAI,
OpenAI Business, Claude, Mistral, Ollama, OpenRouter, and
Microsoft Foundry. Different tools are better at different kinds
of work, so I treat them as instruments rather than as a single
source of truth.
</p>
</div>
<div class="use-item">
<h3>Development and drafting support</h3>
<p>
AI is useful for implementation support, debugging odd edge cases,
brainstorming approaches, writing rough drafts, and pressure-testing
architectural ideas. It helps me move faster, but it does not get
to replace judgment, taste, or authorship.
</p>
</div>
<div class="use-item">
<h3>Local and hosted experimentation</h3>
<p>
Some experiments belong in cloud APIs, some belong in local model
runtimes. I am interested in both, especially where privacy,
controllability, and cost start to matter.
</p>
</div>
<div class="use-item">
<h3>Human ideas stay human</h3>
<p>
The point is not to automate away authorship. The point is to
extend what I can build, test, and think through while keeping
the taste, priorities, and final decisions my own.
</p>
</div>
</div>
</section>
<section class="section fade-in">
<h2>Infrastructure &amp; Homelab</h2>
<div class="uses-list">
<div class="use-item">
<h3>Proxmox cluster</h3>
<p>
A lot of the lab side of my work is built around a Proxmox-based
cluster. It acts as a workshop environment where services and
ideas get built, tested, broken, rebuilt, and slowly turned into
something stable enough to keep.
</p>
</div>
<div class="use-item">
<h3>Multiple nodes, mixed workloads</h3>
<p>
The homelab is not a single-box setup. It spans multiple nodes
running both experimental and production services, which makes it
useful for trying things out without forcing every idea directly
into the same environment.
</p>
</div>
<div class="use-item">
<h3>Self-hosted services</h3>
<p>
Gitea is part of that stack already, and more services tend to
follow the same philosophy: if I can run it myself without making
life worse, I would rather understand the system than outsource
it by default.
</p>
</div>
<div class="use-item">
<h3>Persistent storage</h3>
<p>
Persistent data matters, so storage gets treated as infrastructure,
not an afterthought. That includes self-hosted storage with tools
like OpenMediaVault and the kind of planning that keeps the lab
useful as it grows rather than turning into a graveyard of disks.
</p>
</div>
<div class="use-item">
<h3>Networking foundation</h3>
<p>
The network is built around a UniFi UDM Pro Max, which acts as the
core router and network controller for the environment. I want the
network to be stable first, then segmented where useful, with
secure remote access and reliable routing between local services
and the systems that need to be reachable from the outside.
</p>
</div>
<div class="use-item">
<h3>Tailscale for remote reachability</h3>
<p>
Tailscale is part of the connective tissue that makes the lab feel
usable from anywhere. I like tools that make remote access simple
without turning the whole setup into a networking puzzle.
</p>
</div>
</div>
</section>
<section class="section fade-in">
<h2>Identity &amp; Cloud Services</h2>
<div class="uses-list">
<div class="use-item">
<h3>Microsoft Entra</h3>
<p>
Entra is part of how I think about identity and access in the
broader environment. It is useful where centralized identity,
policy, and account management make sense.
</p>
</div>
<div class="use-item">
<h3>Microsoft Intune</h3>
<p>
Intune fits into the device and policy side of the stack. I do not
see that as a replacement for understanding systems directly, but
it is practical where managed devices and consistent policy matter.
</p>
</div>
<div class="use-item">
<h3>Microsoft Azure</h3>
<p>
Azure is part of the cloud experimentation side of the workshop:
useful for testing ideas, running services that do not belong at
home, and understanding how managed infrastructure behaves in the
real world.
</p>
</div>
<div class="use-item">
<h3>Homelab first, cloud where it helps</h3>
<p>
These Microsoft services complement the homelab rather than
replacing it. I still prefer running and understanding systems
directly whenever possible, but I am not interested in pretending
cloud tooling has no value when it clearly does.
</p>
</div>
</div>
</section>
<section class="section fade-in">
<h2>External Hosting</h2>
<div class="uses-list">
<div class="use-item">
<h3>OVH</h3>
<p>
OVH is part of the external VPS layer I use when a service needs
public accessibility, geographic separation, or a home outside the
local lab.
</p>
</div>
<div class="use-item">
<h3>VPS.play.hosting</h3>
<p>
VPS.play.hosting fills a similar role for additional external
services. I like having more than one place to run things when the
goal is resilience rather than putting every dependency in one box.
</p>
</div>
<div class="use-item">
<h3>Hybrid infrastructure</h3>
<p>
Not everything belongs in the homelab, and not everything belongs
in the cloud. The useful middle ground is a hybrid setup where
local infrastructure and external VPS systems complement each other
instead of competing.
</p>
</div>
</div>
</section>
<section class="section fade-in">
<h2>Security &amp; Identity</h2>
<div class="uses-list">
<div class="use-item">
<h3>Hardware-backed authentication</h3>
<p>
I prefer security that is boring and strong. Hardware keys such as
YubiKeys make more sense to me than pretending a password alone is
enough for important accounts.
</p>
</div>
<div class="use-item">
<h3>GPG and identity hygiene</h3>
<p>
Cryptographic identity is part of the workflow here. I publish my
keys, use GPG where it is useful, and try to keep trust anchored in
things I can verify instead of platforms asking to be trusted.
</p>
</div>
<div class="use-item">
<h3>Password managers and compartmentalization</h3>
<p>
Good security is mostly consistency. Unique secrets, sensible
separation between roles and systems, and fewer scattered accounts
beat dramatic security theater every time.
</p>
</div>
<div class="use-item">
<h3>Privacy as a design constraint</h3>
<p>
I do not chase privacy because it sounds noble. I care about it
because it changes architecture choices: fewer unnecessary
dependencies, less telemetry, tighter control over where data ends
up, and a better understanding of what the system is doing.
</p>
</div>
</div>
</section>
<section class="section fade-in">
<h2>Website Stack</h2>
<div class="uses-list">
<div class="use-item">
<h3>Astro</h3>
<p>
Cozy Den itself is built with Astro because static HTML is still
one of the nicest ways to publish a personal site.
</p>
</div>
<div class="use-item">
<h3>Vanilla CSS + TypeScript</h3>
<p>
Styling stays close to the markup, and the TypeScript that exists
is there to make content and structure safer rather than heavier.
</p>
</div>
<div class="use-item">
<h3>Docker + nginx</h3>
<p>
The site builds in a container and ships as static files served by
nginx, which keeps deployment small, repeatable, and easy to audit.
</p>
</div>
<div class="use-item">
<h3>Gitea-driven deployment path</h3>
<p>
Source, registry, and release flow all stay close to my own
infrastructure instead of depending on a hosted publishing platform.
</p>
</div>
<div class="use-item">
<h3>Secondary to the workshop</h3>
<p>
The site stack matters, but it is only one part of the broader
ecosystem. Cozy Den exists because of the surrounding tools,
machines, services, and habits that make building on my own terms
possible.
</p>
</div>
</div>
</section>
<section class="section fade-in">
<p class="closing-note">
This page is a living snapshot of the tools and systems behind Cozy Den.
It changes from time to time as experiments evolve and the workshop grows.
</p>
</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: 760px;
width: 100%;
background: var(--color-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-surface);
border-radius: 8px;
padding: var(--space-xl);
}
.header {
text-align: center;
margin-bottom: var(--space-lg);
}
.title {
font-size: 2rem;
font-weight: 700;
letter-spacing: 2px;
}
.divider {
color: var(--color-surface);
text-align: center;
font-size: 0.75rem;
margin: var(--space-md) 0;
user-select: none;
}
.intro {
max-width: 60ch;
margin: 0 auto;
color: var(--color-text-dim);
line-height: 1.7;
}
.section {
margin: var(--space-xl) 0;
}
.section h2 {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: var(--space-md);
color: var(--color-accent);
}
.uses-list {
display: grid;
gap: var(--space-md);
}
.use-item {
padding: var(--space-md);
border: 1px solid var(--color-surface);
border-radius: 6px;
background: var(--color-bg-light);
}
.use-item h3 {
font-size: 1rem;
color: var(--color-accent-bright);
margin-bottom: var(--space-xs);
}
.use-item p {
color: var(--color-text-dim);
line-height: 1.7;
}
.use-item code {
font-family: inherit;
background: var(--color-bg);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85em;
color: var(--color-green);
}
.footer {
margin-top: var(--space-xl);
text-align: center;
}
.closing-note {
color: var(--color-text-dim);
line-height: 1.7;
text-align: center;
max-width: 62ch;
margin: 0 auto;
}
.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; }
.fade-in:nth-child(5) { animation-delay: 0.5s; }
.fade-in:nth-child(6) { animation-delay: 0.6s; }
.fade-in:nth-child(7) { animation-delay: 0.7s; }
.fade-in:nth-child(8) { animation-delay: 0.8s; }
.fade-in:nth-child(9) { animation-delay: 0.9s; }
.fade-in:nth-child(10) { animation-delay: 1s; }
@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;
}
.use-item {
padding: var(--space-sm);
}
}
@media (prefers-reduced-motion: reduce) {
.matrix-bg {
animation: none;
}
.fade-in {
animation: none;
opacity: 1;
}
}
</style>