dev #60
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -62,3 +62,9 @@ Thumbs.db
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# ---- Guestbook SQLite database (use Docker volume in production) ----
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
@@ -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
|
||||
---
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
};
|
||||
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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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' } });
|
||||
};
|
||||
@@ -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' } });
|
||||
};
|
||||
@@ -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' },
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
Reference in New Issue
Block a user