This commit is contained in:
Adrian Altner 2026-03-30 14:16:43 +02:00
commit 017bf6e067
115 changed files with 19650 additions and 0 deletions

17
.claude/launch.json Normal file
View file

@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "Astro Dev",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev"],
"port": 4321
},
{
"name": "Astro Preview",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["preview"],
"port": 4321
}
]
}

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
.DS_Store
.git
.gitignore
.astro
dist
node_modules
.env
.env.local
.env.development
.env.development.local
.env.test
.env.test.local

9
.env.example Normal file
View file

@ -0,0 +1,9 @@
# Anthropic API key — used by scripts/vision.ts to generate photo sidecars
# Get yours at https://console.anthropic.com/settings/keys
ANTHROPIC_API_KEY=
# Optional: tune vision script behaviour
# VISION_CONCURRENCY=2 # parallel API requests (default: 2)
# VISION_MAX_RETRIES=8 # retries on rate limit (default: 8)
# VISION_BASE_BACKOFF_MS=1500 # initial backoff in ms (default: 1500)

19
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,19 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions-deps:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
patch-minor:
update-types: ["patch", "minor"]

47
.github/workflows/codeberg_mirror.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: 🪞 Mirror to Codeberg
on:
push:
branches: [main]
workflow_dispatch:
schedule:
- cron: "30 0 * * 0"
jobs:
codeberg:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.CODEBERG_SSH }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H codeberg.org >> ~/.ssh/known_hosts
cat <<'EOF' > ~/.ssh/config
Host codeberg.org
HostName codeberg.org
User git
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
EOF
- name: Verify SSH access
run: |
ssh -T git@codeberg.org || true
git ls-remote git@codeberg.org:adrian-altner/www.adrian-altner.com.git > /dev/null
- name: Mirror to Codeberg
run: |
git remote add mirror git@codeberg.org:adrian-altner/www.adrian-altner.com.git
# Remove previously mirrored remote-tracking refs (e.g. refs/remotes/origin/*).
while IFS= read -r ref; do
git push mirror ":${ref}"
done < <(git ls-remote --refs mirror 'refs/remotes/*' | awk '{print $2}')
# Mirror only real branches and tags.
git push --prune mirror \
+refs/heads/*:refs/heads/* \
+refs/tags/*:refs/tags/*

34
.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# local content hotfolder
src/content/*
# environment variables
.env
.env.local
.env.development
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
# Flickr upload tracking (local state, not for version control)
scripts/flickr-tracking.json
# Mastodon syndication tracking (persists on VPS, not version controlled)
syndicated.json

1
.husky/pre-commit Normal file
View file

@ -0,0 +1 @@
pnpm check:fix

8
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,8 @@
{
"recommendations": [
"astro-build.astro-vscode",
"deque-systems.vscode-axe-linter",
"biomejs.biome"
],
"unwantedRecommendations": ["esbenp.prettier-vscode"]
}

11
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

9
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"editor.defaultFormatter": "biomejs.biome",
"chat.disableAIFeatures": true
}

50
AGENTS.md Normal file
View file

@ -0,0 +1,50 @@
### 1. Plan Node Default
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
- If something goes sideways, STOP and re-plan immediately - don't keep pushing
- Use plan mode for verification steps, not just building
- Write detailed specs upfront to reduce ambiguity
### 2. Subagent Strategy
- Use subagents liberally to keep main context window clean
- Offload research, exploration, and parallel analysis to subagents
- For complex problems, throw more compute at it via subagents
- One tack per subagent for focused execution
### 3. Self-Improvement Loop
- After ANY correction from the user: update `tasks/lessons.md` with the pattern
- Write rules for yourself that prevent the same mistake
- Ruthlessly iterate on these lessons until mistake rate drops
- Review lessons at session start for relevant project
### 4. Verification Before Done
- Never mark a task complete without proving it works
- Diff behavior between main and your changes when relevant
- Ask yourself: "Would a staff engineer approve this?"
- Run tests, check logs, demonstrate correctness
### 5. Demand Elegance (Balanced)
- For non-trivial changes: pause and ask "is there a more elegant way?"
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
- Skip this for simple, obvious fixes - don't over-engineer
- Challenge your own work before presenting it
### 6. Autonomous Bug Fixing
- When given a bug report: just fix it. Don't ask for hand-holding
- Point at logs, errors, failing tests - then resolve them
- Zero context switching required from the user
- Go fix failing CI tests without being told how
## Task Management
1. **Plan First**: Write plan to `tasks/todo.md` with checkable items
2. **Verify Plan**: Check in before starting implementation
3. **Track Progress**: Mark items complete as you go
4. **Explain Changes**: High-level summary at each step
5. **Document Results**: Add review section to `tasks/todo.md`
6. **Capture Lessons**: Update `tasks/lessons.md` after corrections
## Core Principles
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
- **Minimat Impact**: Changes should only touch what's necessary. Avoid introducing bugs.

95
CLAUDE.md Normal file
View file

@ -0,0 +1,95 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm run dev # Start dev server (localhost:4321)
npm run build # astro check + build + copy-sw (use this to verify changes)
npm run check # Type check + Biome lint
npm run check:fix # Type check + Biome lint with auto-fix
npm run stylelint # Lint CSS/Astro styles
npm run stylelint:fix # Fix style issues
```
There are no automated tests. Verification is done via `npm run build` (0 errors required) and the preview MCP tools.
## Architecture
Astro 6 site running in SSR mode (Node.js standalone adapter) with static output for most routes. TypeScript strict mode. Path alias `@/*``src/*`.
**Formatter/linter:** Biome (not ESLint/Prettier). Run `check:fix` after larger edits.
### Content Collections (`src/content.config.ts`)
Five collections defined with Zod schemas:
| Collection | Path | Notes |
|---|---|---|
| `blog` | `src/content/blog/` | Posts with series support (`seriesParent`, `seriesOrder`), tags, category ref, syndication URLs |
| `categories` | `src/content/categories/` | Referenced by blog posts |
| `notes` | `src/content/notes/` | Short-form with optional cover image |
| `links` | `src/content/links/` | Curated external links |
| `collections_photos` | `src/content/photos/collections/` | Photo collections; photos stored as JPG + JSON sidecar files in `img/` subdirs |
### Key Routing Patterns
- `/blog/[...slug]` — Blog posts use the full content path as slug (e.g. `2026/03/01/post-name`)
- `/og/blog/[...slug].png` — OG images generated server-side via Satori (`src/lib/og.ts`)
- `/rss/blog.xml`, `/rss/notes.xml`, `/rss/links.xml`, `/rss/photos.xml` — Separate RSS feeds per content type
- `/photos/collections/[...slug]` — Nested photo collections with breadcrumb navigation
### Lib Utilities (`src/lib/`)
- `collections.ts` — Photo collection helpers: `collectionSlug()`, `buildCollectionPhotos()`, `buildBreadcrumbs()`, `getChildCollections()`
- `og.ts` — OG image generation using Satori (`buildArticleVNode`, `renderOgImage`)
- `webmentions.ts` — Fetch and filter webmentions from webmention.io
- `photo-albums.ts` — Photo album organisation utilities
### Scripts (`scripts/`)
- `mastodon-syndicate.js` — Scans `src/content/blog` and `src/content/notes` for posts without a `syndication` field, posts to Mastodon, writes the status URL back to frontmatter. Env vars: `MASTODON_BASE_URL`, `MASTODON_ACCESS_TOKEN`, `MASTODON_VISIBILITY`, `MASTODON_DRY_RUN`, `MASTODON_LIMIT`
- `publish-posts.sh` — Full deploy orchestration: rsync content to VPS → rebuild container → send webmentions → run mastodon-syndicate
### IndieWeb / Syndication
Blog posts support POSSE via `mastodon-syndicate.js`. After posting, the Mastodon status URL is written to frontmatter as `syndication: ["https://..."]`. The `SyndicationLinks` component reads this and renders `u-syndication` microformat links. Webmentions are fetched at build time from webmention.io and displayed via `WebMentions.astro`.
---
## Workflow
### 1. Plan First
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
- If something goes sideways, STOP and re-plan immediately - don't keep pushing
- Write plan to `tasks/todo.md` with checkable items (not just TodoWrite tool)
- Check in before starting implementation
### 2. Subagent Strategy
- Use subagents liberally to keep main context window clean
- Offload research, exploration, and parallel analysis to subagents
- For complex problems, throw more compute at it via subagents
### 3. Self-Improvement Loop
- After ANY correction from the user: update `tasks/lessons.md` with the pattern
- Review `tasks/lessons.md` at session start
- Write rules that prevent the same mistake from recurring
### 4. Verification Before Done
- Never mark a task complete without proving it works
- Run `npm run build` — must complete with 0 errors
- Use preview MCP tools to visually verify UI changes
### 5. Demand Elegance (Balanced)
- For non-trivial changes: pause and ask "is there a more elegant way?"
- Skip for simple, obvious fixes — don't over-engineer
### 6. Autonomous Bug Fixing
- When given a bug report: just fix it. Point at logs/errors, then resolve them.
## Core Principles
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
- **Minimal Impact**: Changes should only touch what's necessary.

36
Containerfile Normal file
View file

@ -0,0 +1,36 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build:production
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
RUN corepack enable
COPY --from=build --chown=node:node /app/package.json ./package.json
COPY --from=build --chown=node:node /app/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=build --chown=node:node /app/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 4321
CMD ["node", "dist/server/entry.mjs"]

32
LICENSE Normal file
View file

@ -0,0 +1,32 @@
MIT License
Copyright (c) 2026 Adrian Altner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## Content License
All content in `src/content/` and `public/` — including but not limited to
articles, notes, and photographs — is licensed under
[Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/).
You are free to share and adapt the content, as long as you give appropriate
credit and distribute your contributions under the same license.

67
README.md Normal file
View file

@ -0,0 +1,67 @@
# Astro Starter
An Astro starter project set up with pnpm, Biome, Stylelint, Husky, and Astro Sitemap, plus sensible VS Code defaults and recommended workspace extensions.
## Getting Started
1. Click "Use this template" and create a new repository.
2. In `astro.config.mjs`, update `site` from `https://mysite.com` to your site URL.
3. In `src/layouts/BaseLayout.astro`, update `siteName` to your site name.
4. Good to go!
## What's included
**Basics**
- A blank [Astro](https://astro.build/) project with TypeScript enabled
- `pnpm` package management plus `only-allow pnpm`
- Import aliases of `src` using `~` to enable using `import { Component } from '~/components'`
- Basic meta tags and open graph tags in `layouts/BaseLayout.astro`
- `initial-scale=1` on `viewport` to prevent default mobile zoom-out
- Site name displays after the page title (`Page Title · Site Name`) on pages other than the main index
- [smartypants](https://github.com/othree/smartypants.js) smart quotes for page titles and descriptions
**Styles**
- Josh Comeau's [reset.css](https://www.joshwcomeau.com/css/custom-css-reset/)
**Linting and Formatting**
- Linting and formatting with [Biome](https://biomejs.dev/)
- Style linting with [Stylelint](https://stylelint.io/) and [stylelint-config-astro](https://github.com/mattpfeffer/stylelint-config-astro)
**VS Code**
- Format on save and on paste
- Default formatter set to Astro
- Workplace recommendations for [Astro](https://marketplace.visualstudio.com/items?itemName=astro-build.astro-vscode), [axe Accessibility Linter](https://marketplace.visualstudio.com/items?itemName=deque-systems.vscode-axe-linter), and [Biome](https://marketplace.visualstudio.com/items?itemName=biomejs.biome)
**Package.json scripts**
- `check` — Run Astro check + Biome lint and format
- `check:fix` — Run Astro check + Biome lint and format + fix errors
- `stylelint` — Run Stylelint
- `stylelint:fix` — Run Stylelint and fix errors
**Vision script** (requires `ANTHROPIC_API_KEY` in `.env.local`)
Generate AI metadata (title, description, tags) + EXIF sidecars for a photo album:
```bash
node --env-file=.env.local --experimental-strip-types scripts/vision.ts src/content/photos/albums/<album>
```
Optional flags:
- `--refresh` — overwrite existing JSON sidecars
- `--concurrency=N` — parallel API calls (default: 2)
- `--retries=N` — max retries on rate limits (default: 8)
- `--backoff-ms=N` — base backoff in ms (default: 1500)
**Husky**
- `pnpm check` on pre-commit
**Dependabot**
- Weekly dependency update checks
- All GitHub Action updates grouped in a single pull request
- All patch or minor package updates grouped in a single pull request
- All major package updates created as individual pull requests
**Config**
- Link prefetching enabled
- Dev toolbar disabled
- [Astro Sitemap](https://docs.astro.build/en/guides/integrations-guide/sitemap/) installed

38
astro.config.mjs Normal file
View file

@ -0,0 +1,38 @@
import mdx from "@astrojs/mdx";
import node from "@astrojs/node";
import sitemap from "@astrojs/sitemap";
import { defineConfig } from "astro/config";
import EmbedPlugin from "astro-embed/integration";
import rehypeExternalLinks from "rehype-external-links";
import { remarkObsidianLinks } from "./src/lib/remark-obsidian-links.mjs";
export default defineConfig({
site: "https://adrian-altner.com",
devToolbar: {
enabled: false,
},
markdown: {
remarkPlugins: [remarkObsidianLinks],
rehypePlugins: [
[
rehypeExternalLinks,
{ target: "_blank", rel: ["noopener", "noreferrer"] },
],
],
},
integrations: [sitemap(), EmbedPlugin(), mdx()],
prefetch: true,
vite: {
ssr: {
noExternal: ["smartypants"],
},
},
adapter: node({
mode: "standalone",
}),
});

42
biome.jsonc Normal file
View file

@ -0,0 +1,42 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
"files": {
"includes": [
"**",
"!**/build",
"!**/dist",
"!**/node_modules",
"!**/playwright-report",
"!**/.vercel",
"!**/.netlify",
"!**/.vscode",
"!**/.astro",
"!scripts/flickr-tracking.json"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf"
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": { "enabled": true, "rules": { "recommended": true } },
"overrides": [
{
"includes": ["**/*.astro"],
"linter": {
"rules": {
"style": {
"useConst": "off",
"useImportType": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedImports": "off"
}
}
}
}
]
}

16
compose.yml Normal file
View file

@ -0,0 +1,16 @@
name: www-adrian-altner-com
services:
website:
build:
context: .
dockerfile: Containerfile
image: localhost/www.adrian-altner.com:latest
container_name: www.adrian-altner.com
ports:
- "4321:4321"
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 4321
restart: unless-stopped

81
package.json Normal file
View file

@ -0,0 +1,81 @@
{
"name": "@adrian-altner/adrian-altner.com",
"version": "0.0.1",
"title": "Adrian Altner",
"description": "The personal website of Adrian Altner",
"homepage": "https://adrian-altner.photo",
"author": {
"name": "Adrian Altner",
"url": "https://adrian-altner.com/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/adrian-altner/adrian-altner.com.git"
},
"bugs": {
"url": "https://github.com/adrian-altner/adrian-altner.com/issues"
},
"license": "MIT",
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"preinstall": "npx only-allow pnpm",
"prepare": "husky",
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build && node scripts/copy-sw.js",
"build:production": "astro build --mode production && node scripts/copy-sw.js",
"preview": "astro preview",
"astro": "astro",
"check": "astro check && biome check",
"check:fix": "astro check && biome check --write",
"stylelint": "stylelint '**/*.{astro,css}'",
"stylelint:fix": "stylelint '**/*.{astro,css}' --fix",
"generate:icons": "pwa-assets-generator"
},
"pnpm": {
"peerDependencyRules": {
"allowedVersions": {
"@vite-pwa/astro>astro": "*"
}
}
},
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"@astrojs/check": "^0.9.7",
"@astrojs/mdx": "^5.0.2",
"@astrojs/node": "^10.0.2",
"@astrojs/rss": "^4.0.17",
"@astrojs/sitemap": "^3.7.1",
"@fontsource/exo-2": "^5.2.8",
"astro": "^6.0.4",
"astro-embed": "^0.12.0",
"chart.js": "^4.5.1",
"consola": "^3.4.2",
"justified-layout": "^4.1.0",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"rehype-external-links": "^3.0.0",
"satori": "^0.26.0",
"smartypants": "^0.2.2",
"typescript": "^5.9.3"
},
"devDependencies": {
"@biomejs/biome": "^2.4.7",
"@types/justified-layout": "^4.1.4",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"@vite-pwa/assets-generator": "^1.0.2",
"dotenv": "^17.3.1",
"fast-glob": "^3.3.3",
"husky": "^9.1.7",
"sharp": "^0.34.5",
"stylelint": "^16.26.1",
"stylelint-config-astro": "^2.0.0",
"stylelint-config-standard": "^39.0.1",
"unist-util-visit": "^5.1.0",
"workbox-window": "^7.4.0"
}
}

6430
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- esbuild
- sharp

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
public/avatar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

9
pwa-assets.config.ts Normal file
View file

@ -0,0 +1,9 @@
import {
defineConfig,
minimal2023Preset,
} from "@vite-pwa/assets-generator/config";
export default defineConfig({
preset: minimal2023Preset,
images: ["public/favicon.svg"],
});

17
scripts/copy-sw.js Normal file
View file

@ -0,0 +1,17 @@
// Copies sw.js + workbox-*.js from dist/server/ to dist/client/ after build.
// @astrojs/node standalone only serves static files from dist/client/, but
// @vite-pwa/astro generates the service worker into dist/server/ during the
// SSR Vite build pass.
import { copyFile, readdir } from "node:fs/promises";
import { join } from "node:path";
const serverDir = "dist/server";
const clientDir = "dist/client";
const files = await readdir(serverDir).catch(() => []);
for (const file of files) {
if (file === "sw.js" || file.startsWith("workbox-")) {
await copyFile(join(serverDir, file), join(clientDir, file));
console.log(`[copy-sw] ${file} → dist/client/`);
}
}

39
scripts/new-note-mdx-prompt.sh Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Standalone wrapper für Obsidian Script Runner — neue Note mit Cover-Bild (MDX)
set -euo pipefail
VAULT='/Users/adrian/Obsidian/Web/adrian-altner-com'
TITLE=$(osascript \
-e 'Tell application "System Events" to display dialog "Note title (with cover):" default answer ""' \
-e 'text returned of result' 2>/dev/null) || exit 0
if [[ -z "$TITLE" ]]; then exit 0; fi
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ \+/-/g' | sed 's/^-\+//;s/-\+$//')
DATE_FOLDER=$(date +%Y/%m/%d)
PUBLISH_DATE=$(date +%Y-%m-%d)
DIR="$VAULT/content/notes/$DATE_FOLDER"
FILE="$DIR/$SLUG.mdx"
mkdir -p "$DIR"
if [[ -f "$FILE" ]]; then
osascript -e "display notification \"File already exists: $SLUG.mdx\" with title \"New Note\"" 2>/dev/null || true
exit 1
fi
cat > "$FILE" << EOF
---
title: "$TITLE"
publishDate: $PUBLISH_DATE
description: ""
cover: "./$SLUG.jpg"
coverAlt: ""
tags: []
draft: false
syndication:
---
EOF
echo "Created: $FILE"

37
scripts/new-note-prompt.sh Executable file
View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Standalone wrapper für Obsidian Script Runner — neue Note anlegen
set -euo pipefail
VAULT='/Users/adrian/Obsidian/Web/adrian-altner-com'
TITLE=$(osascript \
-e 'Tell application "System Events" to display dialog "Note title:" default answer ""' \
-e 'text returned of result' 2>/dev/null) || exit 0
if [[ -z "$TITLE" ]]; then exit 0; fi
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ \+/-/g' | sed 's/^-\+//;s/-\+$//')
DATE_FOLDER=$(date +%Y/%m/%d)
PUBLISH_DATE=$(date +%Y-%m-%d)
DIR="$VAULT/content/notes/$DATE_FOLDER"
FILE="$DIR/$SLUG.md"
mkdir -p "$DIR"
if [[ -f "$FILE" ]]; then
osascript -e "display notification \"File already exists: $SLUG.md\" with title \"New Note\"" 2>/dev/null || true
exit 1
fi
cat > "$FILE" << EOF
---
title: "$TITLE"
publishDate: $PUBLISH_DATE
description: ""
tags: []
draft: false
syndication:
---
EOF
echo "Created: $FILE"

66
scripts/new-note.sh Executable file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Usage: new-note.sh "Note Title" [--mdx]
# Creates a new note in the Obsidian vault with correct frontmatter and folder structure.
# Use --mdx for notes that need a cover image or custom components (creates .mdx file).
set -euo pipefail
VAULT='/Users/adrian/Obsidian/Web/adrian-altner-com'
if [[ -z "${1:-}" ]]; then
echo "Usage: new-note.sh \"Note Title\" [--mdx]" >&2
exit 1
fi
TITLE="$1"
MDX=false
if [[ "${2:-}" == "--mdx" ]]; then
MDX=true
fi
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ \+/-/g' | sed 's/^-\+//;s/-\+$//')
DATE_FOLDER=$(date +%Y/%m/%d)
PUBLISH_DATE=$(date +%Y-%m-%d)
DIR="$VAULT/content/notes/$DATE_FOLDER"
if $MDX; then
EXT="mdx"
else
EXT="md"
fi
FILE="$DIR/$SLUG.$EXT"
mkdir -p "$DIR"
if [[ -f "$FILE" ]]; then
echo "File already exists: $FILE" >&2
exit 1
fi
if $MDX; then
cat > "$FILE" << EOF
---
title: "$TITLE"
publishDate: $PUBLISH_DATE
description: ""
cover: "./$SLUG.jpg"
coverAlt: ""
tags: []
draft: false
syndication:
---
EOF
else
cat > "$FILE" << EOF
---
title: "$TITLE"
publishDate: $PUBLISH_DATE
description: ""
tags: []
draft: false
syndication:
---
EOF
fi
echo "Created: $FILE"

38
scripts/new-post-prompt.sh Executable file
View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Standalone wrapper für Obsidian Script Runner — neuen Blog-Post anlegen
set -euo pipefail
VAULT='/Users/adrian/Obsidian/Web/adrian-altner-com'
TITLE=$(osascript \
-e 'Tell application "System Events" to display dialog "Post title:" default answer ""' \
-e 'text returned of result' 2>/dev/null) || exit 0
if [[ -z "$TITLE" ]]; then exit 0; fi
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ \+/-/g' | sed 's/^-\+//;s/-\+$//')
DATE_FOLDER=$(date +%Y/%m/%d)
PUBLISH_DATE=$(date +%Y-%m-%dT%H:%M:%S%z)
DIR="$VAULT/content/blog/posts/$DATE_FOLDER"
FILE="$DIR/$SLUG.md"
mkdir -p "$DIR"
if [[ -f "$FILE" ]]; then
osascript -e "display notification \"File already exists: $SLUG.md\" with title \"New Post\"" 2>/dev/null || true
exit 1
fi
cat > "$FILE" << EOF
---
title: "$TITLE"
description: ""
publishDate: $PUBLISH_DATE
tags: []
category: general
draft: true
syndication:
---
EOF
echo "Created: $FILE"

39
scripts/new-post.sh Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Usage: new-post.sh "Post Title"
# Creates a new blog post in the Obsidian vault with correct frontmatter and folder structure.
set -euo pipefail
VAULT='/Users/adrian/Obsidian/Web/adrian-altner-com'
if [[ -z "${1:-}" ]]; then
echo "Usage: new-post.sh \"Post Title\"" >&2
exit 1
fi
TITLE="$1"
SLUG=$(echo "$TITLE" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9 -]//g' | sed 's/ \+/-/g' | sed 's/^-\+//;s/-\+$//')
DATE_FOLDER=$(date +%Y/%m/%d)
PUBLISH_DATE=$(date +%Y-%m-%dT%H:%M:%S%z)
DIR="$VAULT/content/blog/posts/$DATE_FOLDER"
FILE="$DIR/$SLUG.md"
mkdir -p "$DIR"
if [[ -f "$FILE" ]]; then
echo "File already exists: $FILE" >&2
exit 1
fi
cat > "$FILE" << EOF
---
title: "$TITLE"
description: ""
publishDate: $PUBLISH_DATE
tags: []
category: general
draft: true
syndication:
---
EOF
echo "Created: $FILE"

67
scripts/publish-all.sh Executable file
View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
VAULT_BLOG='/Users/adrian/Obsidian/Web/adrian-altner-com/content/blog'
VAULT_PHOTOS='/Users/adrian/Obsidian/Web/adrian-altner-com/content/photos'
VPS="${1:-hetzner}"
REMOTE_BRANCH="${2:-main}"
REMOTE_BASE='/opt/websites/www.adrian-altner.com'
REMOTE_BLOG="${REMOTE_BASE}/src/content/blog"
REMOTE_PHOTOS="${REMOTE_BASE}/src/content/photos"
# --- 1. Sync vault to VPS ---
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
git fetch --prune origin '$REMOTE_BRANCH'
git checkout '$REMOTE_BRANCH'
git reset --hard 'origin/$REMOTE_BRANCH'
git clean -fd -e .env -e .env.production
mkdir -p '$REMOTE_BLOG'
mkdir -p '$REMOTE_PHOTOS'
"
rsync -az --delete \
--include='*/' \
--include='*.md' \
--include='*.mdx' \
--exclude='*' \
"$VAULT_BLOG/" "$VPS:$REMOTE_BLOG/"
rsync -az --delete \
--include='*/' \
--include='*.md' \
--include='*.mdx' \
--include='*.jpg' \
--include='*.jpeg' \
--include='*.JPG' \
--include='*.JPEG' \
--include='*.json' \
--exclude='.DS_Store' \
--exclude='*' \
"$VAULT_PHOTOS/" "$VPS:$REMOTE_PHOTOS/"
# --- 2. Build + cleanup ---
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
podman-compose -f compose.yml up --build -d --force-recreate
podman image prune -af
podman builder prune -af
"
echo "Redeploy done via $VPS (branch: $REMOTE_BRANCH)."
# --- 3. Webmentions ---
WEBMENTION_APP_TOKEN="$(ssh "$VPS" "grep '^WEBMENTION_APP_TOKEN=' '$REMOTE_BASE/.env.production' | cut -d= -f2-" 2>/dev/null || true)"
if [[ -n "$WEBMENTION_APP_TOKEN" ]]; then
echo "Sending webmentions via webmention.app..."
for feed in rss/blog.xml rss/notes.xml rss/links.xml rss/photos.xml; do
curl -s -X POST "https://webmention.app/check?url=https://adrian-altner.com/${feed}&token=${WEBMENTION_APP_TOKEN}" \
| grep -o '"status":"[^"]*"' || true
done
echo "Webmentions triggered."
else
echo "No WEBMENTION_APP_TOKEN in .env.production — skipping webmentions."
fi

62
scripts/publish-blog.sh Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Usage: publish-blog.sh [vps-host] [branch]
# Can be called from any directory — no dependency on the repo being the working dir.
set -euo pipefail
VAULT_BLOG='/Users/adrian/Obsidian/Web/adrian-altner-com/content/blog'
VPS="${1:-hetzner}"
REMOTE_BRANCH="${2:-main}"
REMOTE_BASE='/opt/websites/www.adrian-altner.com'
REMOTE_BLOG="${REMOTE_BASE}/src/content/blog"
# --- 1. Sync vault to VPS ---
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
git fetch --prune origin '$REMOTE_BRANCH'
git checkout '$REMOTE_BRANCH'
git reset --hard 'origin/$REMOTE_BRANCH'
git clean -fd -e .env -e .env.production
mkdir -p '$REMOTE_BLOG'
"
rsync -az --delete \
--include='*/' \
--include='*.md' \
--include='*.mdx' \
--include='*.jpg' \
--include='*.jpeg' \
--include='*.JPG' \
--include='*.JPEG' \
--include='*.png' \
--include='*.PNG' \
--include='*.webp' \
--include='*.gif' \
--exclude='.DS_Store' \
--exclude='*' \
"$VAULT_BLOG/" "$VPS:$REMOTE_BLOG/"
# --- 2. Build + cleanup ---
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
podman-compose -f compose.yml up --build -d --force-recreate
podman image prune -af
podman builder prune -af
"
echo "Blog deploy done via $VPS (branch: $REMOTE_BRANCH)."
# --- 3. Webmentions ---
WEBMENTION_APP_TOKEN="$(ssh "$VPS" "grep '^WEBMENTION_APP_TOKEN=' '$REMOTE_BASE/.env.production' | cut -d= -f2-" 2>/dev/null || true)"
if [[ -n "$WEBMENTION_APP_TOKEN" ]]; then
echo "Sending webmentions..."
curl -s -X POST "https://webmention.app/check?url=https://adrian-altner.com/rss/blog.xml&token=${WEBMENTION_APP_TOKEN}" \
| grep -o '"status":"[^"]*"' || true
echo "Webmentions triggered."
else
echo "No WEBMENTION_APP_TOKEN in .env.production — skipping webmentions."
fi

52
scripts/publish-links.sh Executable file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Usage: publish-links.sh [vps-host] [branch]
# Can be called from any directory — no dependency on the repo being the working dir.
set -euo pipefail
VAULT_LINKS='/Users/adrian/Obsidian/Web/adrian-altner-com/content/links'
VPS="${1:-hetzner}"
REMOTE_BRANCH="${2:-main}"
REMOTE_BASE='/opt/websites/www.adrian-altner.com'
REMOTE_LINKS="${REMOTE_BASE}/src/content/links"
# --- 1. Sync vault to VPS ---
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
git fetch --prune origin '$REMOTE_BRANCH'
git checkout '$REMOTE_BRANCH'
git reset --hard 'origin/$REMOTE_BRANCH'
git clean -fd -e .env -e .env.production
mkdir -p '$REMOTE_LINKS'
"
rsync -az --delete \
--include='*/' \
--include='*.md' \
--include='*.mdx' \
--exclude='.DS_Store' \
--exclude='*' \
"$VAULT_LINKS/" "$VPS:$REMOTE_LINKS/"
# --- 2. Build + cleanup ---
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
podman-compose -f compose.yml up --build -d --force-recreate
podman image prune -af
podman builder prune -af
"
echo "Links deploy done via $VPS (branch: $REMOTE_BRANCH)."
# --- 3. Webmentions ---
WEBMENTION_APP_TOKEN="$(ssh "$VPS" "grep '^WEBMENTION_APP_TOKEN=' '$REMOTE_BASE/.env.production' | cut -d= -f2-" 2>/dev/null || true)"
if [[ -n "$WEBMENTION_APP_TOKEN" ]]; then
echo "Sending webmentions..."
curl -s -X POST "https://webmention.app/check?url=https://adrian-altner.com/rss/links.xml&token=${WEBMENTION_APP_TOKEN}" \
| grep -o '"status":"[^"]*"' || true
echo "Webmentions triggered."
else
echo "No WEBMENTION_APP_TOKEN in .env.production — skipping webmentions."
fi

58
scripts/publish-notes.sh Executable file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Usage: publish-notes.sh [vps-host] [branch]
# Can be called from any directory — no dependency on the repo being the working dir.
set -euo pipefail
VAULT_NOTES='/Users/adrian/Obsidian/Web/adrian-altner-com/content/notes'
VPS="${1:-hetzner}"
REMOTE_BRANCH="${2:-main}"
REMOTE_BASE='/opt/websites/www.adrian-altner.com'
REMOTE_NOTES="${REMOTE_BASE}/src/content/notes"
# --- 1. Sync vault to VPS ---
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
git fetch --prune origin '$REMOTE_BRANCH'
git checkout '$REMOTE_BRANCH'
git reset --hard 'origin/$REMOTE_BRANCH'
git clean -fd -e .env -e .env.production
mkdir -p '$REMOTE_NOTES'
"
rsync -az --delete \
--include='*/' \
--include='*.md' \
--include='*.mdx' \
--include='*.jpg' \
--include='*.jpeg' \
--include='*.JPG' \
--include='*.JPEG' \
--exclude='.DS_Store' \
--exclude='*' \
"$VAULT_NOTES/" "$VPS:$REMOTE_NOTES/"
# --- 2. Build + cleanup ---
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
podman-compose -f compose.yml up --build -d --force-recreate
podman image prune -af
podman builder prune -af
"
echo "Notes deploy done via $VPS (branch: $REMOTE_BRANCH)."
# --- 3. Webmentions ---
WEBMENTION_APP_TOKEN="$(ssh "$VPS" "grep '^WEBMENTION_APP_TOKEN=' '$REMOTE_BASE/.env.production' | cut -d= -f2-" 2>/dev/null || true)"
if [[ -n "$WEBMENTION_APP_TOKEN" ]]; then
echo "Sending webmentions..."
curl -s -X POST "https://webmention.app/check?url=https://adrian-altner.com/rss/notes.xml&token=${WEBMENTION_APP_TOKEN}" \
| grep -o '"status":"[^"]*"' || true
echo "Webmentions triggered."
else
echo "No WEBMENTION_APP_TOKEN in .env.production — skipping webmentions."
fi

57
scripts/publish-photos.sh Executable file
View file

@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Usage: publish-photos.sh [vps-host] [branch]
# Can be called from any directory — no dependency on the repo being the working dir.
set -euo pipefail
VAULT_PHOTOS='/Users/adrian/Obsidian/Web/adrian-altner-com/content/photos'
VPS="${1:-hetzner}"
REMOTE_BRANCH="${2:-main}"
REMOTE_BASE='/opt/websites/www.adrian-altner.com'
REMOTE_PHOTOS="${REMOTE_BASE}/src/content/photos"
# --- 1. Sync vault to VPS ---
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
git fetch --prune origin '$REMOTE_BRANCH'
git checkout '$REMOTE_BRANCH'
git reset --hard 'origin/$REMOTE_BRANCH'
git clean -fd -e .env -e .env.production
mkdir -p '$REMOTE_PHOTOS'
"
rsync -az --delete \
--include='*/' \
--include='*.md' \
--include='*.mdx' \
--include='*.jpg' \
--include='*.jpeg' \
--include='*.JPG' \
--include='*.JPEG' \
--include='*.json' \
--exclude='.DS_Store' \
--exclude='*' \
"$VAULT_PHOTOS/" "$VPS:$REMOTE_PHOTOS/"
# --- 2. Build + cleanup ---
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
podman-compose -f compose.yml up --build -d --force-recreate
podman image prune -af
podman builder prune -af
"
echo "Photos deploy done via $VPS (branch: $REMOTE_BRANCH)."
# --- 3. Webmentions ---
WEBMENTION_APP_TOKEN="$(ssh "$VPS" "grep '^WEBMENTION_APP_TOKEN=' '$REMOTE_BASE/.env.production' | cut -d= -f2-" 2>/dev/null || true)"
if [[ -n "$WEBMENTION_APP_TOKEN" ]]; then
echo "Sending webmentions..."
curl -s -X POST "https://webmention.app/check?url=https://adrian-altner.com/rss/photos.xml&token=${WEBMENTION_APP_TOKEN}" \
| grep -o '"status":"[^"]*"' || true
echo "Webmentions triggered."
else
echo "No WEBMENTION_APP_TOKEN in .env.production — skipping webmentions."
fi

49
scripts/publish-projects.sh Executable file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Usage: publish-projects.sh [vps-host] [branch]
# Can be called from any directory — no dependency on the repo being the working dir.
set -euo pipefail
VAULT_PROJECTS='/Users/adrian/Obsidian/Web/adrian-altner-com/content/projects'
VPS="${1:-hetzner}"
REMOTE_BRANCH="${2:-main}"
REMOTE_BASE='/opt/websites/www.adrian-altner.com'
REMOTE_PROJECTS="${REMOTE_BASE}/src/content/projects"
# --- 1. Sync vault to VPS ---
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
git fetch --prune origin '$REMOTE_BRANCH'
git checkout '$REMOTE_BRANCH'
git reset --hard 'origin/$REMOTE_BRANCH'
git clean -fd -e .env -e .env.production
mkdir -p '$REMOTE_PROJECTS'
"
rsync -az --delete \
--include='*/' \
--include='*.md' \
--include='*.mdx' \
--include='*.jpg' \
--include='*.jpeg' \
--include='*.JPG' \
--include='*.JPEG' \
--include='*.png' \
--include='*.PNG' \
--include='*.webp' \
--include='*.gif' \
--exclude='.DS_Store' \
--exclude='*' \
"$VAULT_PROJECTS/" "$VPS:$REMOTE_PROJECTS/"
# --- 2. Build + cleanup ---
ssh "$VPS" "
set -euo pipefail
cd '$REMOTE_BASE'
podman-compose -f compose.yml up --build -d --force-recreate
podman image prune -af
podman builder prune -af
"
echo "Projects deploy done via $VPS (branch: $REMOTE_BRANCH)."

23
scripts/squash-history.sh Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# squash-history.sh — Replaces entire git history with a single "init" commit.
# WARNING: Destructive and irreversible. Force-pushes to remote.
set -euo pipefail
COMMIT_MSG="${1:-init}"
REMOTE="${2:-origin}"
BRANCH="main"
TEMP="temp-squash-$$"
echo "⚠️ This will destroy all git history and force-push to $REMOTE/$BRANCH."
read -r -p "Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
git checkout --orphan "$TEMP"
git add -A
git commit -m "$COMMIT_MSG"
git branch -D "$BRANCH"
git branch -m "$TEMP" "$BRANCH"
git push --force "$REMOTE" "$BRANCH"
echo "Done. $(git log --oneline)"

87
scripts/vision.spec.ts Normal file
View file

@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExifMetadata,
ImageMetadataSuggestion,
VisionAIResult,
} from "./vision.ts";
import { getImagesToProcess, mergeMetaAndVisionData } from "./vision.ts";
const FINAL: ImageMetadataSuggestion = {
id: "2R9A2805",
title: [
"Blossom and Buzz",
"Spring's Gentle Awakening",
"Cherry Blossom Haven",
"Nature's Delicate Balance",
"A Bee's Spring Feast",
],
image: "./2R9A2805.jpg",
alt: "Close-up of vibrant pink cherry blossoms on a branch with a honeybee collecting nectar. The bee's wings are slightly blurred, capturing its motion as it works. The background is a soft, dreamy pink hue, complementing the sharp details of the blossoms and the bee.",
location: "48 deg 8' 37.56\" N, 11 deg 34' 13.32\" E",
date: "2024-03-17",
tags: ["nature", "cherryblossom", "bee", "spring", "floral"],
exif: {
camera: "Canon EOS R6m2",
lens: "RF70-200mm F2.8 L IS USM",
aperture: "2.8",
iso: "125",
focal_length: "200.0",
shutter_speed: "1/1000",
},
};
const VISION_DATA: VisionAIResult = {
title_ideas: [
"Blossom and Buzz",
"Spring's Gentle Awakening",
"Cherry Blossom Haven",
"Nature's Delicate Balance",
"A Bee's Spring Feast",
],
description:
"Close-up of vibrant pink cherry blossoms on a branch with a honeybee collecting nectar. The bee's wings are slightly blurred, capturing its motion as it works. The background is a soft, dreamy pink hue, complementing the sharp details of the blossoms and the bee.",
tags: ["nature", "cherryblossom", "bee", "spring", "floral"],
};
const EXIF_DATA: ExifMetadata = {
SourceFile: "/Users/flori/Sites/flori-dev/src/content/grid/2R9A2805.jpg",
FileName: "2R9A2805.jpg",
Model: "Canon EOS R6m2",
ExposureTime: "1/1000",
FNumber: 2.8,
ISO: 125,
DateTimeOriginal: "2024:03:17 15:06:16",
FocalLength: "200.0 mm",
LensModel: "RF70-200mm F2.8 L IS USM",
GPSPosition: "48 deg 8' 37.56\" N, 11 deg 34' 13.32\" E",
};
async function main() {
const tempRoot = await mkdtemp(join(tmpdir(), "vision-photos-"));
try {
assert.deepEqual(mergeMetaAndVisionData(EXIF_DATA, VISION_DATA), FINAL);
const albumDirectory = join(tempRoot, "chiang-mai");
const missingImage = join(albumDirectory, "2025-10-06-121017.jpg");
const completeImage = join(albumDirectory, "2025-10-06-121212.jpg");
await mkdir(albumDirectory, { recursive: true });
await writeFile(missingImage, "");
await writeFile(completeImage, "");
await writeFile(join(albumDirectory, "2025-10-06-121212.json"), "{}");
assert.deepEqual(await getImagesToProcess(tempRoot), [missingImage]);
assert.deepEqual(await getImagesToProcess(tempRoot, { refresh: true }), [
missingImage,
completeImage,
]);
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
}
await main();

522
scripts/vision.ts Normal file
View file

@ -0,0 +1,522 @@
#!/usr/bin/env -S node --experimental-strip-types
import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import { relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import Anthropic from "@anthropic-ai/sdk";
import { consola } from "consola";
import {
getImagesMissingMetadata,
getMetadataPathForImage,
getPhotoAbsolutePath,
getPhotoDirectories,
PHOTOS_DIRECTORY,
} from "../src/lib/photo-albums.ts";
const execFileAsync = promisify(execFile);
/**
* Define the directory where the images are located.
*/
const PHOTOS_DIR = PHOTOS_DIRECTORY;
/**
* Instantiate the Anthropic client.
*/
let anthropic: Anthropic | undefined;
function getAnthropicClient(): Anthropic {
anthropic ??= new Anthropic({ maxRetries: 0 });
return anthropic;
}
function assertRequiredEnvironment(): void {
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error(
"Missing ANTHROPIC_API_KEY. `pnpm run vision` loads `.env.local` automatically. If you run the script directly, use `node --env-file=.env.local --experimental-strip-types scripts/vision.ts`.",
);
}
}
/**
* Represents the metadata of an image in the Exif format.
*/
export interface ExifMetadata {
SourceFile: string;
FileName: string;
Model: string;
FNumber: number;
FocalLength: string;
ExposureTime: string;
ISO: number;
DateTimeOriginal: string;
LensModel: string;
GPSPosition?: string;
GPSLatitude?: string;
GPSLongitude?: string;
}
/**
* Represents the result of the AI analysis.
*/
export interface VisionAIResult {
title_ideas: string[];
description: string;
tags: string[];
}
/**
* Represents the final metadata suggestion for an image.
*/
export interface ImageMetadataSuggestion {
id: string;
title: string[];
image: string;
alt: string;
location: string;
date: string;
tags: string[];
exif: {
camera: string;
lens: string;
aperture: string;
iso: string;
focal_length: string;
shutter_speed: string;
};
}
interface VisionCliOptions {
refresh: boolean;
photosDirectory?: string;
visionConcurrency: number;
visionMaxRetries: number;
visionBaseBackoffMs: number;
}
function parseCliOptions(argv: string[]): VisionCliOptions {
const getNumericOption = (name: string, fallback: number): number => {
const prefix = `--${name}=`;
const rawValue = argv
.find((arg) => arg.startsWith(prefix))
?.slice(prefix.length);
const parsed = Number.parseInt(rawValue ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const envConcurrency = Number.parseInt(
process.env.VISION_CONCURRENCY ?? "",
10,
);
const envMaxRetries = Number.parseInt(
process.env.VISION_MAX_RETRIES ?? "",
10,
);
const envBaseBackoffMs = Number.parseInt(
process.env.VISION_BASE_BACKOFF_MS ?? "",
10,
);
const nonFlagArgs = argv.filter((arg) => !arg.startsWith("--"));
return {
refresh: argv.includes("--refresh"),
photosDirectory: resolve(nonFlagArgs[0] ?? PHOTOS_DIR),
visionConcurrency: getNumericOption(
"concurrency",
Number.isFinite(envConcurrency) && envConcurrency > 0
? envConcurrency
: 2,
),
visionMaxRetries: getNumericOption(
"retries",
Number.isFinite(envMaxRetries) && envMaxRetries > 0 ? envMaxRetries : 8,
),
visionBaseBackoffMs: getNumericOption(
"backoff-ms",
Number.isFinite(envBaseBackoffMs) && envBaseBackoffMs > 0
? envBaseBackoffMs
: 1500,
),
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRateLimitError(error: unknown): boolean {
return error instanceof Anthropic.RateLimitError;
}
function extractRetryAfterMs(error: unknown): number | null {
if (!(error instanceof Anthropic.RateLimitError)) {
return null;
}
const retryAfter = error.headers?.get("retry-after");
if (retryAfter) {
const seconds = Number.parseFloat(retryAfter);
if (Number.isFinite(seconds) && seconds > 0) {
return Math.ceil(seconds * 1000);
}
}
return null;
}
async function mapWithConcurrency<T, R>(
values: T[],
concurrency: number,
mapper: (value: T, index: number) => Promise<R>,
): Promise<R[]> {
if (values.length === 0) {
return [];
}
const results: R[] = new Array(values.length);
const workerCount = Math.max(1, Math.min(concurrency, values.length));
let cursor = 0;
const workers = Array.from({ length: workerCount }, async () => {
while (true) {
const currentIndex = cursor;
cursor += 1;
if (currentIndex >= values.length) {
return;
}
const value = values[currentIndex];
if (typeof value === "undefined") {
continue;
}
results[currentIndex] = await mapper(value, currentIndex);
}
});
await Promise.all(workers);
return results;
}
/**
* Get all images that don't have a JSON file and therefore need to be processed.
*/
export async function getImagesToProcess(
photosDirectory = PHOTOS_DIR,
options: Pick<VisionCliOptions, "refresh"> = { refresh: false },
): Promise<string[]> {
const relativeImagePaths = options.refresh
? (await getPhotoDirectories(photosDirectory)).flatMap(
(directory) => directory.imagePaths,
)
: await getImagesMissingMetadata(photosDirectory);
consola.info(
options.refresh
? `Refreshing ${relativeImagePaths.length} ${relativeImagePaths.length === 1 ? "image" : "images"} with metadata sidecars`
: `Found ${relativeImagePaths.length} ${relativeImagePaths.length === 1 ? "image" : "images"} without metadata`,
);
return relativeImagePaths.map((imagePath) =>
getPhotoAbsolutePath(imagePath, photosDirectory),
);
}
/**
* Extracts the EXIF metadata from an image file.
* @param imagePath - The path to the image file.
*
* @returns A promise that resolves to the extracted EXIF metadata.
*/
export async function extractExifMetadata(
imagePath: string,
): Promise<ExifMetadata> {
/// Check if `exiftool` is installed.
try {
await execFileAsync("exiftool", ["--version"]);
} catch (_error) {
consola.error(
"exiftool is not installed. Please run `brew install exiftool`.",
);
process.exit(1);
}
/// Extract the metadata
const { stdout } = await execFileAsync("exiftool", ["-j", imagePath]);
const output = JSON.parse(stdout) as ExifMetadata[];
if (!output[0]) {
throw new Error(`No EXIF metadata found for ${imagePath}.`);
}
return output[0];
}
/**
* Encodes an image file to base64.
* @param imagePath - The path to the image file.
* @returns A Promise that resolves to the base64 encoded image.
*/
async function base64EncodeImage(imagePath: string): Promise<string> {
const buffer = await readFile(imagePath);
return buffer.toString("base64");
}
const VISION_TOOL = {
name: "vision_response",
description: "Return the vision analysis of the image.",
input_schema: {
type: "object" as const,
additionalProperties: false,
properties: {
title_ideas: { type: "array", items: { type: "string" } },
description: { type: "string" },
tags: { type: "array", items: { type: "string" } },
},
required: ["title_ideas", "description", "tags"],
},
};
/**
* Generates image description, title suggestions and tags using AI.
*
* @param metadata - The metadata of the image.
* @returns A Promise that resolves to a VisionAIResult object containing the generated image description, title suggestions, and tags.
*/
async function generateImageDescriptionTitleSuggestionsAndTags(
metadata: ExifMetadata,
options: Pick<VisionCliOptions, "visionMaxRetries" | "visionBaseBackoffMs">,
): Promise<VisionAIResult> {
/// Base64 encode the image in order to pass it to the API
const encodedImage = await base64EncodeImage(metadata.SourceFile);
const prompt =
"Create an accurate and detailed description of this image that would also work as an alt text. The alt text should not contain words like image, photograph, illustration or such. Describe the scene as it is. Also come up with 5 title suggestions for this image. At last suggest 5 tags that suit the image description. These tags should be single words only. Identify the main subject or theme and make sure to put the according tag first. Return the description, the title suggestions and tags.";
let lastError: unknown;
for (let attempt = 0; attempt <= options.visionMaxRetries; attempt += 1) {
try {
const response = await getAnthropicClient().messages.create({
model: "claude-opus-4-6",
max_tokens: 2048,
tools: [VISION_TOOL],
tool_choice: { type: "tool", name: "vision_response" },
messages: [
{
role: "user",
content: [
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: encodedImage,
},
},
{ type: "text", text: prompt },
],
},
],
});
const toolUseBlock = response.content.find((b) => b.type === "tool_use");
if (!toolUseBlock || toolUseBlock.type !== "tool_use") {
throw new Error(
`No tool use response from AI for ${metadata.SourceFile}.`,
);
}
const parsedResponse = toolUseBlock.input as VisionAIResult;
if (
parsedResponse.title_ideas.length === 0 ||
parsedResponse.description.length === 0 ||
parsedResponse.tags.length === 0
) {
throw new Error(
`Incomplete vision response for ${metadata.SourceFile}.`,
);
}
return parsedResponse;
} catch (error) {
lastError = error;
if (!isRateLimitError(error) || attempt >= options.visionMaxRetries) {
break;
}
const retryAfterMs = extractRetryAfterMs(error);
const exponentialBackoffMs = options.visionBaseBackoffMs * 2 ** attempt;
const jitterMs = Math.floor(Math.random() * 350);
const waitMs =
Math.max(retryAfterMs ?? 0, exponentialBackoffMs) + jitterMs;
const relativeSourcePath = relative(process.cwd(), metadata.SourceFile);
const nextAttempt = attempt + 1;
consola.warn(
`Rate limit for ${relativeSourcePath}. Retry ${nextAttempt}/${options.visionMaxRetries} in ${Math.ceil(waitMs / 1000)}s...`,
);
await sleep(waitMs);
}
}
throw lastError;
}
function ensureVisionCanRun(imagesToProcess: string[]): void {
if (imagesToProcess.length === 0) {
return;
}
assertRequiredEnvironment();
}
function getLocationFromExif(exifData: ExifMetadata): string {
if (exifData.GPSPosition) {
return exifData.GPSPosition;
}
if (exifData.GPSLatitude && exifData.GPSLongitude) {
return `${exifData.GPSLatitude}, ${exifData.GPSLongitude}`;
}
return "";
}
/**
* Merges the metadata from EXIF data and vision data to create an ImageMetadataSuggestion object.
* @param exifData - The EXIF metadata of the image.
* @param visionData - The vision AI result data of the image.
* @returns The merged ImageMetadataSuggestion object.
*/
export function mergeMetaAndVisionData(
exifData: ExifMetadata,
visionData: VisionAIResult,
): ImageMetadataSuggestion {
const [date] = exifData.DateTimeOriginal.split(" ");
if (!date) {
throw new Error(`Missing original date for ${exifData.SourceFile}.`);
}
return {
id: exifData.FileName.replace(".jpg", ""),
title: visionData.title_ideas,
image: `./${exifData.FileName}`,
alt: visionData.description,
location: getLocationFromExif(exifData),
date: date.replaceAll(":", "-"),
tags: visionData.tags,
exif: {
camera: exifData.Model,
lens: exifData.LensModel,
aperture: exifData.FNumber.toString(),
iso: exifData.ISO.toString(),
focal_length: exifData.FocalLength.replace(" mm", ""),
shutter_speed: exifData.ExposureTime,
},
};
}
/**
* Writes the given image metadata to a JSON file.
* @param imageMetadata - The image metadata to be written.
* @returns A Promise that resolves when the JSON file is written successfully.
*/
async function writeToJsonFile(
imageMetadata: ImageMetadataSuggestion,
imagePath: string,
photosDirectory: string,
): Promise<void> {
const relativeImagePath = relative(photosDirectory, imagePath);
const jsonPath = getMetadataPathForImage(relativeImagePath, photosDirectory);
const json = JSON.stringify(imageMetadata, null, 2);
await writeFile(jsonPath, json);
}
/**
* Main.
*/
async function main() {
consola.start("Checking for images to process...");
const cliOptions = parseCliOptions(process.argv.slice(2));
const photosDirectory = cliOptions.photosDirectory ?? PHOTOS_DIR;
/// Load all images that don't have a JSON file.
const images = await getImagesToProcess(photosDirectory, cliOptions);
if (images.length === 0) {
consola.success(
cliOptions.refresh
? "No images found to refresh."
: "No images require metadata.",
);
return;
}
consola.info(
`Vision settings: concurrency=${cliOptions.visionConcurrency}, retries=${cliOptions.visionMaxRetries}, backoff=${cliOptions.visionBaseBackoffMs}ms`,
);
ensureVisionCanRun(images);
/// Extract EXIF metadata from these images.
const exifData = await mapWithConcurrency(
images,
8,
async (imagePath, index) => {
consola.info(`Extracting EXIF ${index + 1}/${images.length}...`);
return await extractExifMetadata(imagePath);
},
);
/// Determine the image description, title suggestions and tags for each image with AI.
const visionData = await mapWithConcurrency(
exifData,
cliOptions.visionConcurrency,
async (exifEntry, index) => {
consola.info(`Generating AI metadata ${index + 1}/${exifData.length}...`);
return await generateImageDescriptionTitleSuggestionsAndTags(
exifEntry,
cliOptions,
);
},
);
/// Merge the EXIF and Vision data to create the final metadata suggestion.
const imageData = exifData.map((e, i) => {
const currentVisionData = visionData[i];
if (!currentVisionData) {
throw new Error(`Missing vision data for ${e.SourceFile}.`);
}
return mergeMetaAndVisionData(e, currentVisionData);
});
/// Write the metadata to JSON files.
await mapWithConcurrency(imageData, 8, async (imageMetadata, index) => {
const sourceFile = exifData[index]?.SourceFile;
if (!sourceFile) {
throw new Error(`Missing source file for ${imageMetadata.id}.`);
}
await writeToJsonFile(imageMetadata, sourceFile, photosDirectory);
consola.info(`Wrote metadata ${index + 1}/${imageData.length}.`);
});
consola.success("All images processed successfully.");
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
await main();
} catch (error) {
consola.error(error);
process.exit(1);
}
}

BIN
src/assets/icons/swipe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
src/assets/images/about.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

BIN
src/assets/images/intro.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -0,0 +1,168 @@
---
import type { CollectionEntry } from "astro:content";
interface Props {
post: CollectionEntry<"blog">;
categoryTitleMap?: Map<string, string>;
seriesCountMap?: Map<string, number>;
activeTagId?: string;
}
const { post, categoryTitleMap, seriesCountMap, activeTagId } = Astro.props;
function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function formatDate(date: Date) {
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
const showCategory = categoryTitleMap && post.data.category;
const hasMeta =
showCategory || post.data.tags.length > 0 || !!post.data.seriesParent;
---
<li class="item">
<a href={`/blog/${post.id}`} class="item__link">
<h2 class="item__title">{post.data.title}</h2>
<span class="item__date">{formatDate(post.data.publishDate)}</span>
</a>
{post.data.description && <p class="item__desc">{post.data.description}</p>}
{
hasMeta && (
<div class="item__meta">
{post.data.seriesParent && (
<a href={`/blog/${post.data.seriesParent}`} class="series-badge">
Series with {seriesCountMap?.get(post.data.seriesParent) ?? "?"} Parts
</a>
)}
{showCategory && (
<a
href={`/blog/category/${post.data.category!.id}`}
class="category"
>
in{" "}
{categoryTitleMap!.get(post.data.category!.id) ??
post.data.category!.id}
</a>
)}
{post.data.tags.map((t) => (
<a
href={`/tags/${t}`}
class={`tag${t === activeTagId ? " tag--active" : ""}`}
>
{capitalize(t)}
</a>
))}
</div>
)
}
</li>
<style>
.item {
border-bottom: 1px solid #e0e0de;
padding-bottom: 2rem;
}
.item:last-child {
border-bottom: none;
}
.item__link {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
text-decoration: none;
color: inherit;
margin-bottom: 0.4rem;
}
.item__link:hover .item__title {
color: var(--accent);
}
.item__title {
font-weight: 500;
margin: 0;
transition: color 0.15s;
}
.item__date {
font-size: 0.85rem;
color: #888;
white-space: nowrap;
flex-shrink: 0;
}
.item__desc {
font-size: 0.95rem;
color: #555;
line-height: 1.5;
margin-bottom: 0.75rem;
}
.item__meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.series-badge {
font-size: 0.75rem;
color: #888;
text-decoration: none;
padding-right: 0.5rem;
margin-right: 0.1rem;
border-right: 1px solid #ccc;
}
.series-badge:hover {
color: var(--accent);
}
.category {
font-size: 0.75rem;
color: #888;
text-decoration: none;
padding-right: 0.5rem;
margin-right: 0.1rem;
border-right: 1px solid #ccc;
}
.category:hover {
color: var(--accent);
}
.tag {
font-size: 0.75rem;
color: #666;
background: #e8e8e6;
padding: 0.15em 0.55em;
border-radius: 3px;
text-decoration: none;
}
.tag:hover {
background: var(--accent);
color: #fff;
}
.tag--active {
background: var(--accent);
color: #fff;
}
@media (max-width: 640px) {
.item__link {
flex-direction: column;
gap: 0.2rem;
}
}
</style>

View file

@ -0,0 +1,144 @@
---
import type { CollectionEntry } from "astro:content";
interface Props {
children: CollectionEntry<"blog">[];
currentId?: string;
parent?: CollectionEntry<"blog">;
sidebar?: boolean;
}
const { children, currentId, parent, sidebar = false } = Astro.props;
---
<aside class:list={["series", { "series--sidebar": sidebar }]}>
<p class="series__heading">Parts in Series:</p>
<ol class="series__list">
{
parent &&
(parent.id === currentId ? (
<li class="series__item">
<span
class="series__link series__link--current"
aria-current="page"
>
{parent.data.title}
</span>
</li>
) : (
<li class="series__item">
<a href={`/blog/${parent.id}`} class="series__link">
{parent.data.title}
</a>
</li>
))
}
{
children.map((child) =>
child.id === currentId ? (
<li class="series__item">
<span
class="series__link series__link--current"
aria-current="page"
>
{child.data.title}
</span>
</li>
) : (
<li class="series__item">
<a href={`/blog/${child.id}`} class="series__link">
{child.data.title}
</a>
</li>
),
)
}
</ol>
</aside>
<style>
/* ── Inline variant (bottom of article) ── */
.series {
margin-top: 3rem;
padding: 1.25rem 1.5rem;
border-top: 2px solid #111;
background: #eeeeed;
border-radius: 4px;
margin-bottom: 1rem;
}
.series__heading {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #888;
margin-bottom: 0.75rem;
}
/* ── Sidebar variant ── */
.series--sidebar {
margin-top: 1.5rem;
padding: 0;
background: none;
border-radius: 4px;
background: #eeeeed;
border-top: 2px solid #111;
padding: 0.75rem 0.75rem;
}
.series__parent {
display: block;
font-size: 0.8rem;
color: #555;
text-decoration: none;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.series__parent:hover {
color: var(--accent);
}
.series__list {
padding-left: 1.25rem;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.series--sidebar .series__list {
padding-left: 1.1rem;
}
.series__item {
font-size: 0.9rem;
}
.series--sidebar .series__item {
font-size: 0.8rem;
}
.series__link {
color: #333;
text-decoration: none;
text-underline-offset: 3px;
line-height: 1.4;
display: block;
}
.series__link:hover {
color: var(--accent);
text-decoration: underline;
}
.series__link--current {
color: var(--accent);
font-weight: 500;
}
.series__link--current:hover {
text-decoration: none;
}
</style>

182
src/components/Footer.astro Normal file
View file

@ -0,0 +1,182 @@
---
interface Props {
dark?: boolean | undefined;
}
const { dark } = Astro.props;
---
<footer class={`footer${dark ? " footer--dark" : ""}`}>
<div class="footer__inner">
<p class="footer__license">
© 2026 Adrian Altner · <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener noreferrer">CC BY-SA 4.0</a> · <a href="/imprint">Imprint</a> · <a href="/privacy-policy">Privacy Policy</a> · <a href="/contact">Contact</a>
</p>
<div class="footer__icons">
<a href="https://mastodon.social/@altner" aria-label="Mastodon" target="_blank" rel="me noopener noreferrer">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
aria-hidden="true"
>
<path d="M23.193 7.879c0-5.206-3.411-6.732-3.411-6.732C18.062.357 15.108.025 12.041 0h-.076c-3.068.025-6.02.357-7.74 1.147 0 0-3.413 1.526-3.413 6.732 0 1.192-.023 2.618.015 4.129.124 5.092.934 10.11 5.641 11.355 2.17.574 4.034.695 5.535.612 2.722-.15 4.25-.972 4.25-.972l-.09-1.975s-1.945.613-4.129.539c-2.165-.074-4.449-.233-4.799-2.891a5.499 5.499 0 0 1-.048-.745s2.125.52 4.817.643c1.647.076 3.19-.096 4.758-.283 3.007-.359 5.625-2.212 5.954-3.905.517-2.665.475-6.507.475-6.507zm-4.024 6.709h-2.497V8.469c0-1.29-.543-1.944-1.629-1.944-1.2 0-1.801.776-1.801 2.312v3.349h-2.483v-3.35c0-1.536-.601-2.312-1.802-2.312-1.086 0-1.629.655-1.629 1.944v6.119H4.831V8.284c0-1.29.328-2.313.988-3.07.68-.758 1.569-1.146 2.674-1.146 1.278 0 2.246.491 2.886 1.474l.622 1.043.623-1.043c.64-.983 1.608-1.474 2.886-1.474 1.104 0 1.994.388 2.674 1.146.66.757.987 1.78.987 3.07v6.304z"/>
</svg>
</a>
<a href="https://www.instagram.com/adrian.altner/" aria-label="Instagram" target="_blank" rel="noopener noreferrer">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect><path
d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path><line
x1="17.5"
y1="6.5"
x2="17.51"
y2="6.5"></line>
</svg>
</a>
<a href="https://www.linkedin.com/in/adrian-altner/" aria-label="LinkedIn" target="_blank" rel="noopener noreferrer">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"
></path><rect x="2" y="9" width="4" height="12"></rect><circle
cx="4"
cy="4"
r="2"></circle>
</svg>
</a>
<a href="https://codeberg.org/adrian-altner" aria-label="Codeberg" target="_blank" rel="noopener noreferrer">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
aria-hidden="true"
>
<path d="M11.999.747A11.974 11.974 0 0 0 0 12.75c0 2.254.635 4.465 1.833 6.376L11.837 6.19c.072-.092.251-.092.323 0l4.178 5.402h-2.992l.065.239h3.113l.882 1.138h-3.674l.103.374h3.86l.777 1.003h-4.358l.135.483h4.593l.695.894h-5.038l.165.589h5.326l.609.785h-5.717l.182.65h6.038l.562.727h-6.397l.183.65h6.717A12.003 12.003 0 0 0 24 12.75 11.977 11.977 0 0 0 11.999.747zm3.654 19.104.182.65h5.326c.173-.204.353-.433.513-.65zm.385 1.377.18.65h3.563c.233-.198.485-.428.712-.65zm.383 1.377.182.648h1.203c.356-.204.685-.412 1.042-.648z"/>
</svg>
</a>
<a href="https://github.com/adrian-altner" aria-label="GitHub" target="_blank" rel="noopener noreferrer">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"
></path>
</svg>
</a>
<a href="/rss.xml" aria-label="RSS">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M4 11a9 9 0 0 1 9 9"></path><path d="M4 4a16 16 0 0 1 16 16"
></path><circle cx="5" cy="19" r="1"></circle>
</svg>
</a>
</div>
</div>
</footer>
<style>
.footer {
width: 100%;
display: block;
}
.footer--dark {
background: #111;
color: #f5f5f3;
}
.footer--dark .footer__inner a {
color: #f5f5f3;
}
.footer__inner {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 1.75rem;
padding: 2.5rem 2.5rem;
max-width: var(--width-shell);
margin-inline: auto;
}
.footer__icons {
display: flex;
gap: 1.75rem;
}
.footer__inner a {
color: #111;
opacity: 0.75;
display: flex;
align-items: center;
}
.footer__license {
font-size: 0.8rem;
opacity: 0.75;
margin-right: auto;
line-height: 1.5;
}
.footer__license a {
display: inline;
color: inherit;
opacity: 1;
text-decoration: underline;
}
.footer__inner a:hover {
opacity: 1;
}
@media (max-width: 640px) {
.footer__inner {
flex-direction: column-reverse;
align-items: flex-start;
gap: 0.75rem;
padding: 1.5rem;
}
.footer__license {
font-size: 0.65rem;
}
}
</style>

View file

@ -0,0 +1,9 @@
---
interface Props {
schema: Record<string, unknown>;
}
const { schema } = Astro.props;
---
<script is:inline type="application/ld+json" set:html={JSON.stringify(schema)} />

251
src/components/Nav.astro Normal file
View file

@ -0,0 +1,251 @@
---
interface Props {
title?: string;
desktopHidden?: boolean;
hidden?: boolean;
}
const { title, desktopHidden, hidden } = Astro.props;
const pathname = Astro.url.pathname;
---
<!-- Desktop nav -->
<nav
class={`nav${title ? "" : " nav--end"}${desktopHidden ? " nav--desktop-hidden" : ""}`}
aria-label="Main navigation"
>
<div class="nav__inner">
{title && <a href="/" class="nav__site-name">{title}</a>}
<div class="nav__links">
<a href="/blog" class={pathname.startsWith("/blog") ? "is-active" : ""}>Blog</a>
<a href="/photos" class={pathname.startsWith("/photos") ? "is-active" : ""}>Photos</a>
<a href="/notes" class={pathname.startsWith("/notes") ? "is-active" : ""}>Notes</a>
<a href="/links" class={pathname.startsWith("/links") ? "is-active" : ""}>Links</a>
<a href="/archives" class={pathname.startsWith("/archives") ? "is-active" : ""}>Archives</a>
<a href="/about" class={pathname.startsWith("/about") ? "is-active" : ""}>About</a>
</div>
</div>
</nav>
<!-- Mobile top bar -->
<div class={`mobile-bar${hidden ? " mobile-bar--hidden" : ""}`}>
<button
class="mobile-bar__btn"
id="menu-open"
aria-expanded="false"
aria-controls="mobile-menu"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
aria-hidden="true"
>
<line x1="3" y1="6" x2="21" y2="6"></line><line
x1="3"
y1="12"
x2="21"
y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
Menu
</button>
</div>
<!-- Mobile menu overlay -->
<div class="mobile-menu" id="mobile-menu" aria-hidden="true">
<div class="mobile-menu__bar">
<button class="mobile-bar__btn" id="menu-close" tabindex="-1">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18"></line><line
x1="6"
y1="6"
x2="18"
y2="18"></line>
</svg>
Close
</button>
</div>
<nav class="mobile-menu__nav" aria-label="Mobile navigation">
<a href="/blog" tabindex="-1">Blog</a>
<a href="/photos" tabindex="-1">Photos</a>
<a href="/notes" tabindex="-1">Notes</a>
<a href="/links" tabindex="-1">Links</a>
<a href="/archives" tabindex="-1">Archives</a>
<a href="/about" tabindex="-1">About</a>
</nav>
</div>
<script>
const openBtn = document.getElementById("menu-open") as HTMLButtonElement | null;
const closeBtn = document.getElementById("menu-close") as HTMLButtonElement | null;
const menu = document.getElementById("mobile-menu");
const focusable = menu
? [closeBtn, ...Array.from(menu.querySelectorAll<HTMLElement>("a"))].filter(Boolean) as HTMLElement[]
: [];
openBtn?.addEventListener("click", () => {
menu?.classList.add("is-open");
menu?.setAttribute("aria-hidden", "false");
openBtn.setAttribute("aria-expanded", "true");
focusable.forEach((el) => el.setAttribute("tabindex", "0"));
closeBtn?.focus();
});
closeBtn?.addEventListener("click", () => {
menu?.classList.remove("is-open");
menu?.setAttribute("aria-hidden", "true");
openBtn?.setAttribute("aria-expanded", "false");
focusable.forEach((el) => el.setAttribute("tabindex", "-1"));
openBtn?.focus();
});
</script>
<style>
/* ── Desktop nav ── */
.nav {
display: block;
}
.nav--end .nav__inner {
justify-content: flex-end;
}
.nav__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 28px;
max-width: var(--width-shell);
margin-inline: auto;
padding: 40px;
min-height: 116px;
}
.nav__site-name {
font-size: var(--text-md);
font-weight: 700;
color: inherit;
text-decoration: none;
white-space: nowrap;
margin-right: auto;
}
.nav__links {
display: flex;
align-items: center;
gap: 28px;
}
.nav__links a {
font-size: var(--text-sm);
font-weight: 500;
color: inherit;
text-decoration: none;
}
.nav a.is-active {
color: var(--accent);
font-weight: 700;
opacity: 1;
}
/* ── Mobile top bar (hidden on desktop) ── */
.mobile-bar {
display: none;
background: #c0405e;
}
.mobile-bar--hidden {
display: none !important;
}
.mobile-bar__btn {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: #fff;
font-size: var(--text-sm);
font-weight: 500;
font-family: inherit;
cursor: pointer;
padding: 14px 24px;
width: 100%;
justify-content: center;
}
/* ── Mobile menu overlay ── */
.mobile-menu {
display: none;
position: fixed;
inset: 0;
z-index: 100;
flex-direction: column;
background: #2b2b2b;
color: #e0e0e0;
transform: translateY(-100%);
transition: transform 0.25s ease;
}
.mobile-menu.is-open {
transform: translateY(0);
}
.mobile-menu__bar {
background: #c0405e;
flex-shrink: 0;
}
.mobile-menu__nav {
display: flex;
flex-direction: column;
padding: 0 24px;
}
.mobile-menu__nav a {
color: inherit;
text-decoration: none;
font-size: var(--text-sm);
font-weight: 400;
padding: 16px 0;
border-bottom: 1px dashed #444;
}
.mobile-menu__nav a:last-child {
border-bottom: none;
}
@media (min-width: 641px) {
.nav--desktop-hidden {
display: none;
}
}
/* ── Mobile breakpoint ── */
@media (max-width: 640px) {
.nav {
display: none;
}
.mobile-bar {
display: block;
}
.mobile-menu {
display: flex;
}
}
</style>

View file

@ -0,0 +1,423 @@
---
import { Image } from "astro:assets";
import type { ImageMetadata } from "astro";
import swipeIcon from "@/assets/icons/swipe.png";
type PhotoSidecar = {
id: string;
title: string[];
alt: string;
location: string;
date: string;
tags: string[];
exif: {
camera: string;
lens: string;
aperture: string;
iso: string;
focal_length: string;
shutter_speed: string;
};
};
interface Props {
sidecar: PhotoSidecar;
image: ImageMetadata;
prevHref: string | null;
nextHref: string | null;
backHref: string;
backLabel: string;
canonicalUrl: string;
}
const {
sidecar,
image,
prevHref,
nextHref,
backHref,
backLabel,
canonicalUrl,
} = Astro.props;
---
<main class="viewer h-entry">
<a href={canonicalUrl} class="u-url" aria-hidden="true" tabindex="-1" style="display:none">Permalink</a>
<span class="p-author h-card" style="display:none"><img class="u-photo" src="https://adrian-altner.com/avatar.jpg" alt="Adrian Altner" /><span class="p-name">Adrian Altner</span><a class="u-url" href="https://adrian-altner.com">adrian-altner.com</a></span>
<header class="topbar">
<a href={backHref} class="back-btn">← {backLabel}</a>
</header>
<div class="photo-area">
<figure class="figure">
<div class="photo-frame">
{
prevHref ? (
<a href={prevHref} class="arrow arrow--prev" aria-label="Previous photo"></a>
) : (
<span class="arrow arrow--prev arrow--disabled" aria-hidden="true"></span>
)
}
<div class="photo-wrap">
<Image
src={image}
alt={sidecar.alt}
width={image.width}
height={image.height}
class="photo u-photo"
/>
<button class="info-btn" aria-label="Photo info" aria-expanded="false">ⓘ</button>
<div class="infobar" role="region" aria-label="Photo info">
<div class="infobar__primary">
<h1 class="title p-name">{sidecar.title[0]}</h1>
<time class="date dt-published" datetime={new Date(sidecar.date).toISOString()}>{sidecar.date}</time>
</div>
<div class="infobar__secondary">
<span class="exif-line">ƒ{sidecar.exif.aperture} · {sidecar.exif.shutter_speed}s · ISO {sidecar.exif.iso} · {sidecar.exif.focal_length}mm</span>
{
sidecar.tags.length > 0 && (
<div class="tags">
{sidecar.tags.map((tag) => (
<a
href={`/photos/tags/${tag.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")}`}
class="tag"
>
{tag}
</a>
))}
</div>
)
}
</div>
</div>
</div>
{
nextHref ? (
<a href={nextHref} class="arrow arrow--next" aria-label="Next photo"></a>
) : (
<span class="arrow arrow--next arrow--disabled" aria-hidden="true"></span>
)
}
</div>
</figure>
</div>
<div class="bottombar" aria-hidden="true">
<Image src={swipeIcon} alt="" class="swipe-hint" width={28} height={28} />
</div>
</main>
<script>
const prev = document.querySelector<HTMLAnchorElement>('.arrow--prev[href]');
const next = document.querySelector<HTMLAnchorElement>('.arrow--next[href]');
const infoBtn = document.querySelector<HTMLButtonElement>('.info-btn');
const infobar = document.querySelector<HTMLElement>('.infobar');
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' && prev) {
e.preventDefault();
prev.click();
} else if (e.key === 'ArrowRight' && next) {
e.preventDefault();
next.click();
} else if (e.key === 'Escape') {
closeInfo();
}
});
let touchStartX = 0;
document.addEventListener('touchstart', (e) => {
touchStartX = e.touches[0]!.clientX;
}, { passive: true });
document.addEventListener('touchend', (e) => {
const dx = e.changedTouches[0]!.clientX - touchStartX;
if (Math.abs(dx) < 40) return;
if (dx > 0 && prev) prev.click();
else if (dx < 0 && next) next.click();
}, { passive: true });
function openInfo() {
infobar?.classList.add('is-open');
infoBtn?.setAttribute('aria-expanded', 'true');
}
function closeInfo() {
infobar?.classList.remove('is-open');
infoBtn?.setAttribute('aria-expanded', 'false');
}
infoBtn?.addEventListener('click', (e) => {
e.stopPropagation();
infobar?.classList.contains('is-open') ? closeInfo() : openInfo();
});
document.querySelector('.photo-area')?.addEventListener('click', (e) => {
if (infobar?.classList.contains('is-open') && !(e.target as Element).closest('.infobar')) {
closeInfo();
}
});
</script>
<style>
.viewer {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100dvh;
background: #111;
color: #f5f5f3;
overflow: hidden;
position: relative;
}
.topbar {
display: flex;
align-items: center;
padding: 0.6rem 1.25rem;
flex-shrink: 0;
}
@keyframes swipe-reveal {
0% { opacity: 0; transform: translateX(0); }
10% { opacity: 0.75; transform: translateX(0); }
35% { opacity: 0.75; transform: translateX(-28px); }
65% { opacity: 0.75; transform: translateX(28px); }
90% { opacity: 0.75; transform: translateX(0); }
100% { opacity: 0; transform: translateX(0); }
}
.swipe-hint {
display: none;
width: 28px;
height: 28px;
filter: invert(1);
opacity: 0;
}
.back-btn {
font-size: var(--text-xs);
color: #888;
text-decoration: none;
transition: color 0.2s;
}
.back-btn:hover {
color: #f5f5f3;
}
.bottombar {
display: flex;
align-items: center;
justify-content: center;
padding: 0.6rem 1.25rem;
font-size: var(--text-xs);
flex-shrink: 0;
}
@media (max-width: 640px) {
.bottombar {
padding-top: 2rem;
padding-bottom: 2.5rem;
}
.photo {
max-height: calc(100dvh - 120px);
}
.swipe-hint {
display: block;
animation: swipe-reveal 5s ease forwards;
animation-delay: 0.5s;
}
}
.photo-area {
display: grid;
grid-template-columns: 1fr;
align-items: center;
min-height: 0;
position: relative;
}
.figure {
margin: 0;
min-width: 0;
min-height: 0;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.photo-frame {
display: flex;
width: max-content;
max-width: 100%;
align-items: center;
}
.photo-wrap {
position: relative;
overflow: hidden;
min-width: 0;
display: flex;
}
.photo {
display: block;
/* topbar + bottombar = 96px */
max-height: calc(100dvh - 96px);
max-width: 100%;
width: auto;
height: auto;
object-fit: contain;
border-radius: 2px;
}
.arrow {
font-size: 2.5rem;
line-height: 1;
color: #fff;
opacity: 0.45;
text-decoration: none;
padding: 1rem 1.25rem;
transition: opacity 0.2s;
user-select: none;
-webkit-tap-highlight-color: transparent;
display: flex;
align-items: center;
align-self: stretch;
flex-shrink: 0;
}
.arrow:not(.arrow--disabled):hover {
opacity: 1;
}
.arrow--disabled {
opacity: 0.1;
pointer-events: none;
}
.info-btn {
position: absolute;
bottom: 0.75rem;
right: 0.75rem;
background: none;
border: none;
color: #fff;
opacity: 0.45;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
transition: opacity 0.2s;
-webkit-tap-highlight-color: transparent;
z-index: 2;
}
.info-btn:hover,
.info-btn[aria-expanded="true"] {
opacity: 1;
}
.infobar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem 1.5rem;
padding: 2rem 1.25rem 1rem;
background: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0) 100%);
transform: translateY(100%);
transition: transform 0.25s ease;
z-index: 1;
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
}
.infobar.is-open {
transform: translateY(0);
}
.infobar__primary {
display: flex;
align-items: baseline;
gap: 0.75rem;
flex-shrink: 0;
}
.title {
font-size: var(--text-sm);
font-weight: 400;
margin: 0;
}
.date {
font-size: var(--text-xs);
color: #f5f5f3;
}
.infobar__secondary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1rem;
}
.exif-line {
font-size: var(--text-xs);
color: #f5f5f3;
letter-spacing: 0.01em;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.tag {
font-size: var(--text-xs);
color: #f5f5f3;
border: 1px solid rgba(255,255,255,0.3);
border-radius: 3px;
padding: 0.15rem 0.5rem;
text-decoration: none;
transition: color 0.2s, border-color 0.2s;
}
.tag:hover {
color: #f5f5f3;
border-color: #555;
}
@media (max-width: 640px) {
.photo-frame {
width: 100%;
}
.photo-wrap {
width: 100%;
}
.arrow {
display: none;
}
.infobar {
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem 1rem 1rem;
}
.infobar__secondary {
gap: 0.4rem 0.75rem;
}
}
</style>

View file

@ -0,0 +1,97 @@
---
const pathname = Astro.url.pathname;
const links = [
{
href: "/photos",
label: "Stream",
active: pathname === "/photos" || pathname === "/photos/",
},
{
href: "/photos/collections",
label: "Collections",
active: pathname.startsWith("/photos/collections"),
},
{
href: "/photos/tags",
label: "Tags",
active: pathname.startsWith("/photos/tags"),
},
{
href: "/photos/map",
label: "Map",
active: pathname.startsWith("/photos/map"),
},
{
href: "/photos/stats",
label: "Stats",
active: pathname.startsWith("/photos/stats"),
},
];
---
<div class="photos-nav-wrapper">
<div class="photos-header">
<a href="/" class="back-link">← Back home</a>
<h1 class="photos-title">Photos</h1>
</div>
<nav class="sub-nav" aria-label="Photos navigation">
{
links.map(({ href, label, active }) => (
<a href={href} class={`sub-nav__link${active ? " is-active" : ""}`}>
{label}
</a>
))
}
</nav>
</div>
<style>
.photos-header {
margin-bottom: 1rem;
}
.back-link {
font-size: 0.8rem;
font-weight: 500;
color: #666;
text-decoration: none;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.back-link:hover {
color: #f5f5f3;
}
.photos-title {
font-size: 2rem;
font-weight: 700;
color: #f5f5f3;
margin: 0.25rem 0 0;
}
.sub-nav {
display: flex;
gap: 1.5rem;
margin-bottom: 1.25rem;
}
.sub-nav__link {
font-size: 0.85rem;
font-weight: 500;
color: #666;
text-decoration: none;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.sub-nav__link:hover {
color: #f5f5f3;
}
.sub-nav__link.is-active {
color: #f5f5f3;
}
</style>

View file

@ -0,0 +1,113 @@
---
interface Heading {
depth: number;
slug: string;
text: string;
}
interface Props {
headings: Heading[];
}
const { headings } = Astro.props;
---
<nav class="toc">
<p class="toc__label">Table of Content</p>
<ol class="toc__list">
{headings.map((h) => (
<li class={`toc__item toc__item--h${h.depth}`}>
<a href={`#${h.slug}`} class="toc__link">{h.text}</a>
</li>
))}
</ol>
</nav>
<style>
.toc {
/* sticky is handled by the parent .toc-sidebar */
}
.toc__label {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #888;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-top: 2px solid #111;
padding-top: 0.75rem;
}
.toc__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.toc__item--h3 {
padding-left: 0.85rem;
}
.toc__link {
font-size: 0.8rem;
color: #666;
text-decoration: none;
line-height: 1.4;
display: block;
}
.toc__link:hover {
color: var(--accent);
}
.toc__link--active {
color: #111;
font-weight: 500;
}
</style>
<script>
const links = document.querySelectorAll<HTMLAnchorElement>(".toc__link");
const headingIds = [...links].map((a) => a.getAttribute("href")?.slice(1) ?? "");
const headingEls = headingIds
.map((id) => document.getElementById(id))
.filter(Boolean) as HTMLElement[];
function update() {
const nearBottom =
window.scrollY + window.innerHeight >= document.body.scrollHeight - 50;
let activeId = headingIds[0] ?? "";
if (nearBottom) {
activeId = headingIds[headingIds.length - 1] ?? activeId;
} else {
const threshold = window.scrollY + window.innerHeight * 0.25;
for (const el of headingEls) {
if (el.offsetTop <= threshold) activeId = el.id;
}
}
links.forEach((a) => {
const href = a.getAttribute("href")?.slice(1);
a.classList.toggle("toc__link--active", href === activeId);
});
}
window.addEventListener("scroll", update, { passive: true });
update();
links.forEach((a) => {
a.addEventListener("click", (e) => {
e.preventDefault();
const id = a.getAttribute("href")?.slice(1);
document.getElementById(id ?? "")?.scrollIntoView({ behavior: "smooth" });
});
});
</script>

View file

@ -0,0 +1,241 @@
---
import { getFilteredWebmentions, type Mention } from "@/lib/webmentions";
interface Props {
url: string;
}
const { url } = Astro.props;
// Build-time fetch for instant initial render
let mentions: Mention[] = [];
try {
mentions = await getFilteredWebmentions(url);
} catch {
// Unavailable at build time — client-side will handle it
}
const likes = mentions.filter((m) => m["wm-property"] === "like-of");
const reposts = mentions.filter((m) => m["wm-property"] === "repost-of");
const replies = mentions.filter(
(m) =>
m["wm-property"] === "in-reply-to" || m["wm-property"] === "mention-of",
);
---
{mentions.length > 0 && (
<section class="webmentions">
<h2 class="webmentions__heading">Webmentions</h2>
{(likes.length > 0 || reposts.length > 0) && (
<div class="webmentions__reactions">
{likes.length > 0 && (
<div class="webmentions__group">
<span class="webmentions__label">{likes.length} {likes.length === 1 ? "Like" : "Likes"}</span>
<div class="webmentions__avatars">
{likes.map((m) => (
<a href={m.author?.url ?? m.url} target="_blank" rel="noopener noreferrer"
title={m.author?.name ?? "Anonymous"} class="webmentions__avatar-link">
{m.author?.photo ? (
<img src={m.author.photo} alt={m.author.name ?? ""} width="32" height="32"
class="webmentions__avatar" loading="lazy" />
) : (
<span class="webmentions__avatar webmentions__avatar--fallback" aria-hidden="true">
{(m.author?.name ?? "?")[0]?.toUpperCase()}
</span>
)}
</a>
))}
</div>
</div>
)}
{reposts.length > 0 && (
<div class="webmentions__group">
<span class="webmentions__label">{reposts.length} {reposts.length === 1 ? "Repost" : "Reposts"}</span>
<div class="webmentions__avatars">
{reposts.map((m) => (
<a href={m.author?.url ?? m.url} target="_blank" rel="noopener noreferrer"
title={m.author?.name ?? "Anonymous"} class="webmentions__avatar-link">
{m.author?.photo ? (
<img src={m.author.photo} alt={m.author.name ?? ""} width="32" height="32"
class="webmentions__avatar" loading="lazy" />
) : (
<span class="webmentions__avatar webmentions__avatar--fallback" aria-hidden="true">
{(m.author?.name ?? "?")[0]?.toUpperCase()}
</span>
)}
</a>
))}
</div>
</div>
)}
</div>
)}
{replies.length > 0 && (
<div class="webmentions__replies">
<span class="webmentions__label">{replies.length} {replies.length === 1 ? "Reply" : "Replies"}</span>
{replies.map((m) => (
<article class="webmentions__reply">
<header class="webmentions__reply-header">
{m.author?.photo && (
<img src={m.author.photo} alt={m.author.name ?? ""} width="36" height="36"
class="webmentions__avatar" loading="lazy" />
)}
<div class="webmentions__reply-meta">
<a href={m.author?.url ?? m.url} target="_blank" rel="noopener noreferrer"
class="webmentions__reply-author">{m.author?.name ?? "Anonymous"}</a>
{m.published && (
<time class="webmentions__reply-date" datetime={m.published}>
{new Date(m.published).toLocaleDateString("en-US", {
year: "numeric", month: "short", day: "numeric",
})}
</time>
)}
</div>
</header>
{m.content?.text && <p class="webmentions__reply-content">{m.content.text}</p>}
<a href={m.url} target="_blank" rel="noopener noreferrer"
class="webmentions__reply-source">View original</a>
</article>
))}
</div>
)}
</section>
)}
{mentions.length === 0 && (
<section class="webmentions" data-empty></section>
)}
<style is:global>
.webmentions {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid #e0e0de;
}
.webmentions:empty,
.webmentions[data-empty] {
display: none;
}
.webmentions__heading {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #888;
margin-bottom: 1.25rem;
}
.webmentions__reactions {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.webmentions__group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.webmentions__label {
font-size: 0.75rem;
color: #888;
}
.webmentions__avatars {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.webmentions__avatar-link {
display: block;
line-height: 0;
}
.webmentions__avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
display: block;
}
.webmentions__avatar--fallback {
background: #e0e0de;
color: #555;
font-size: 0.8rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.webmentions__replies {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.webmentions__reply {
padding: 0.875rem 0;
border-bottom: 1px solid #e0e0de;
}
.webmentions__reply:first-of-type {
border-top: 1px solid #e0e0de;
margin-top: 0.5rem;
}
.webmentions__reply-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.webmentions__reply-meta {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.webmentions__reply-author {
font-size: 0.875rem;
font-weight: 600;
color: #111;
text-decoration: none;
}
.webmentions__reply-author:hover {
color: var(--accent);
}
.webmentions__reply-date {
font-size: 0.75rem;
color: #999;
}
.webmentions__reply-content {
font-size: 0.9rem;
color: #444;
line-height: 1.6;
margin: 0 0 0.5rem;
}
.webmentions__reply-source {
font-size: 0.75rem;
color: #999;
text-decoration: none;
}
.webmentions__reply-source:hover {
color: var(--accent);
}
</style>

View file

@ -0,0 +1,167 @@
---
import type { AnyLink } from "@/lib/collections";
import {
collectionColor,
formatDate,
getDomain,
getFaviconUrl,
} from "@/lib/links";
export interface Props {
link: AnyLink;
}
const { link } = Astro.props;
const { title, url, description, publishDate, tags, collection } = link.data;
const domain = getDomain(url);
const faviconUrl = getFaviconUrl(url);
---
<li class="link-item">
<a href={url} class="link-item__link" target="_blank" rel="noopener noreferrer">
<div class="link-item__main">
<div class="link-item__title-row">
<img
class="link-item__favicon"
src={faviconUrl}
alt=""
width="14"
height="14"
loading="lazy"
onerror="this.style.display='none'"
/>
<span class="link-item__title">{title}</span>
</div>
<span class="link-item__domain">{domain}</span>
{description && <p class="link-item__desc">{description}</p>}
</div>
<div class="link-item__meta">
<time class="link-item__date" datetime={publishDate.toISOString()}>
{formatDate(publishDate)}
</time>
</div>
</a>
{(collection || tags.length > 0) && (
<div class="link-item__tags">
{collection && (
<span class="link-item__collection" style={`background: ${collectionColor(collection)}18; color: ${collectionColor(collection)}`}>
{collection}
</span>
)}
{tags.map((tag) => (
<a href={`/links/tag/${encodeURIComponent(tag.toLowerCase())}`} class="link-item__tag">
#{tag}
</a>
))}
</div>
)}
</li>
<style>
.link-item {
list-style: none;
border-bottom: 1px solid #e8e8e6;
padding: 0.75rem 0;
}
.link-item:last-child {
border-bottom: none;
}
.link-item__link {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
text-decoration: none;
color: inherit;
}
.link-item__link::after {
content: none;
}
.link-item__link:hover .link-item__title {
color: var(--accent);
}
.link-item__main {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.link-item__title-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.link-item__favicon {
width: 14px;
height: 14px;
flex-shrink: 0;
opacity: 0.8;
}
.link-item__title {
font-size: 0.9rem;
font-weight: 500;
color: #111;
transition: color 0.1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.link-item__desc {
font-size: 0.8rem;
color: #777;
line-height: 1.4;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.link-item__meta {
flex-shrink: 0;
}
.link-item__domain {
font-size: 0.72rem;
color: #aaa;
white-space: nowrap;
}
.link-item__date {
font-size: 0.72rem;
color: #bbb;
white-space: nowrap;
}
.link-item__tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.35rem;
}
.link-item__collection {
font-size: 0.68rem;
font-weight: 500;
padding: 0.1em 0.5em;
border-radius: 3px;
}
.link-item__tag {
font-size: 0.68rem;
color: #999;
text-decoration: none;
}
.link-item__tag:hover {
color: var(--accent);
}
</style>

View file

@ -0,0 +1,397 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro";
import type { AnyLink } from "@/lib/collections";
import {
collectionColor,
filterByCollection,
filterByTag,
getUniqueCollections,
getUniqueTags,
} from "@/lib/links";
export interface Props {
title: string;
description: string;
links: AnyLink[];
}
const { title, description, links } = Astro.props;
const collections = getUniqueCollections(links);
const tags = getUniqueTags(links);
const pathname = Astro.url.pathname.replace(/\/$/, "");
function isActive(href: string): boolean {
return pathname === href.replace(/\/$/, "");
}
---
<BaseLayout {title} {description} navHidden={true} footerHidden={true}>
<div class="links-app">
<aside class="sidebar">
<div class="sidebar__brand">
<a href="/" class="sidebar__back">← Back home</a>
</div>
<div class="sidebar__collapsible">
<nav class="sidebar__nav">
<a
href="/links"
class:list={[
"sidebar__item",
{ "sidebar__item--active": isActive("/links") },
]}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
All Links
<span class="sidebar__count">{links.length}</span>
</a>
</nav>
{
collections.length > 0 && (
<div class="sidebar__section">
<a
href="/links/collection"
class:list={[
"sidebar__section-title",
{
"sidebar__section-title--active":
isActive("/links/collection"),
},
]}
>
Collections
</a>
{collections.map((col) => (
<a
href={`/links/collection/${encodeURIComponent(col.toLowerCase())}`}
class:list={[
"sidebar__item",
{
"sidebar__item--active": isActive(
`/links/collection/${col.toLowerCase()}`,
),
},
]}
>
<span
class="sidebar__folder"
style={`background: ${collectionColor(col)}`}
aria-hidden="true"
>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="white"
stroke="none"
aria-hidden="true"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</span>
{col}
<span class="sidebar__count">
{filterByCollection(links, col).length}
</span>
</a>
))}
</div>
)
}
{
tags.length > 0 && (
<div class="sidebar__section">
<a
href="/links/tag"
class:list={[
"sidebar__section-title",
{ "sidebar__section-title--active": isActive("/links/tag") },
]}
>
Tags
</a>
{tags.map((tag) => (
<a
href={`/links/tag/${encodeURIComponent(tag.toLowerCase())}`}
class:list={[
"sidebar__item",
{
"sidebar__item--active": isActive(
`/links/tag/${tag.toLowerCase()}`,
),
},
]}
>
<span class="sidebar__hash" aria-hidden="true">
#
</span>
{tag}
<span class="sidebar__count">
{filterByTag(links, tag).length}
</span>
</a>
))}
</div>
)
}
</div>
</aside>
<main class="links-main">
<div class="mobile-header">
<a href="/" class="mobile-header__back">← Back home</a>
<nav class="mobile-subnav">
<a
href="/links"
class:list={[
"mobile-subnav__item",
{ "mobile-subnav__item--active": isActive("/links") },
]}>All Links</a
>
<a
href="/links/collection"
class:list={[
"mobile-subnav__item",
{ "mobile-subnav__item--active": isActive("/links/collection") },
]}>Collections</a
>
<a
href="/links/tag"
class:list={[
"mobile-subnav__item",
{ "mobile-subnav__item--active": isActive("/links/tag") },
]}>Tags</a
>
</nav>
</div>
<div class="links-content">
<slot />
</div>
</main>
</div>
</BaseLayout>
<style>
.links-app {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 100dvh;
background: #f5f5f3;
}
/* Sidebar */
.sidebar {
background: #fff;
border-right: 1px solid #e0e0de;
display: flex;
flex-direction: column;
padding: 0;
position: sticky;
top: 0;
height: 100dvh;
overflow-y: auto;
}
.sidebar__brand {
padding: 1rem;
border-bottom: 1px solid #e0e0de;
}
.sidebar__nav {
padding: 0.5rem 0.5rem 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.sidebar__section {
padding: 0.75rem 0.5rem 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.sidebar__section-title {
font-size: 0.7rem;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0 0.5rem 0.25rem;
text-decoration: none;
}
.sidebar__section-title:hover,
.sidebar__section-title--active {
color: #555;
}
.sidebar__item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.5rem;
border-radius: 6px;
font-size: 0.875rem;
color: #444;
text-decoration: none;
transition:
background 0.1s,
color 0.1s;
}
.sidebar__item:hover {
background: #f0f0ee;
color: #111;
}
.sidebar__item--active {
background: #eee;
color: #111;
font-weight: 500;
}
.sidebar__folder {
width: 20px;
height: 20px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.sidebar__hash {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 0.8rem;
color: #999;
font-weight: 600;
}
.sidebar__count {
margin-left: auto;
font-size: 0.75rem;
color: #aaa;
font-variant-numeric: tabular-nums;
}
.sidebar__back {
font-size: 0.8rem;
font-weight: 500;
color: #666;
text-decoration: none;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.sidebar__back:hover {
color: var(--accent);
}
/* Main content */
.links-main {
padding: 2rem 2.5rem;
min-width: 0;
max-width: 680px;
}
.mobile-header {
display: none;
}
.links-content {
height: 100%;
}
@media (max-width: 768px) {
.links-app {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
.links-main {
padding: 0;
max-width: 100%;
}
.links-content {
padding: 1.25rem 1rem;
}
.mobile-header {
display: block;
background: #fff;
border-bottom: 1px solid #e0e0de;
position: sticky;
top: 0;
z-index: 10;
}
.mobile-header__back {
display: block;
font-size: 0.72rem;
color: #666;
text-decoration: none;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0.6rem 1rem 0;
}
.mobile-header__back:hover {
color: var(--accent);
}
.mobile-subnav {
display: flex;
}
.mobile-subnav__item {
flex: 1;
text-align: center;
padding: 0.6rem 0.5rem;
font-size: 0.8rem;
font-weight: 500;
color: #888;
text-decoration: none;
border-bottom: 2px solid transparent;
transition: color 0.1s;
}
.mobile-subnav__item:hover {
color: #111;
}
.mobile-subnav__item--active {
color: #111;
border-bottom-color: #111;
}
}
</style>

148
src/content.config.ts Normal file
View file

@ -0,0 +1,148 @@
import { defineCollection, reference } from "astro:content";
import { file, glob } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog/posts" }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
publishDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
category: reference("categories").default({
collection: "categories",
id: "general",
}),
draft: z.boolean().default(false),
seriesParent: z.string().optional(),
seriesOrder: z.number().optional(),
cover: image().optional(),
coverAlt: z.string().optional(),
}),
});
const categories = defineCollection({
loader: glob({
pattern: "**/*.{md,mdx}",
base: "./src/content/blog/categories",
}),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
order: z.number().int().default(99),
cover: image().optional(),
coverAlt: z.string().optional(),
}),
});
const notes = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/notes" }),
schema: ({ image }) =>
z.object({
title: z.string(),
publishDate: z.coerce.date(),
description: z.string().optional(),
cover: image().optional(),
coverAlt: z.string().optional(),
draft: z.boolean().default(false),
tags: z.array(z.string()).default([]),
}),
});
const links_json = defineCollection({
loader: file("./src/content/links/links.json", {
parser: (text) => {
const entries = JSON.parse(text) as Array<Record<string, unknown>>;
return entries.map((entry) => {
if (!entry.id && !entry.slug) {
const date = (
String(entry.publishDate ?? "undated").split("T")[0] ?? "undated"
).replace(/-/g, "/");
const slug = String(entry.title ?? "link")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
return { ...entry, id: `json/${date}/${slug}` };
}
return entry;
});
},
}),
schema: z.object({
title: z.string(),
url: z.url(),
description: z.string().optional(),
publishDate: z.coerce.date(),
via: z.string().optional(),
tags: z.array(z.string()).default([]),
collection: z.string().optional(),
}),
});
const projects_categories = defineCollection({
loader: glob({
pattern: "**/*.{md,mdx}",
base: "./src/content/projects/categories",
}),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
order: z.number().int().default(99),
cover: image().optional(),
coverAlt: z.string().optional(),
}),
});
const projects = defineCollection({
loader: glob({
pattern: "**/*.{md,mdx}",
base: "./src/content/projects/project",
}),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
publishDate: z.coerce.date(),
draft: z.boolean().default(false),
url: z.url().optional(),
github: z.url().optional(),
tags: z.array(z.string()).default([]),
category: reference("projects_categories").default({
collection: "projects_categories",
id: "general",
}),
cover: image().optional(),
coverAlt: z.string().optional(),
}),
});
const collections_photos = defineCollection({
loader: glob({
pattern: "**/index.{md,mdx}",
base: "./src/content/photos/collections",
}),
schema: z.object({
title: z.string(),
description: z.string(),
location: z.string().optional(),
publishDate: z.coerce.date().optional(),
draft: z.boolean().default(false),
coverImage: z.string().optional(),
order: z.number().int().default(0),
set: z.boolean().default(false),
}),
});
export const collections = {
blog,
categories,
notes,
links_json,
collections_photos,
projects,
projects_categories,
};

View file

@ -0,0 +1,129 @@
---
import "@/styles/reset.css";
import "@/styles/global.css";
import smartypants from "smartypants";
import Footer from "@/components/Footer.astro";
import JsonLd from "@/components/JsonLd.astro";
import Nav from "@/components/Nav.astro";
const siteName = "Adrian Altner";
const siteUrl = "https://adrian-altner.com";
export interface Props {
title: string;
description: string;
image?:
| {
src: string;
alt: string;
}
| undefined;
navDesktopHidden?: boolean;
navHidden?: boolean;
footerHidden?: boolean;
footerDark?: boolean;
}
const {
title,
description,
image,
navDesktopHidden,
navHidden,
footerHidden,
footerDark,
} = Astro.props;
function smartypantsText(str: string): string {
return smartypants(str, 1).replace(/&#(\d+);/g, (_, code) =>
String.fromCharCode(Number(code)),
);
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const pageTitle = Astro.url.pathname === "/" ? title : `${title} · ${siteName}`;
const isHomepage = Astro.url.pathname === "/";
const websiteSchema = {
"@context": "https://schema.org",
"@type": "WebSite",
name: siteName,
url: siteUrl,
};
const personSchema = {
"@context": "https://schema.org",
"@type": "Person",
name: siteName,
url: siteUrl,
jobTitle: "Photographer & Software Developer",
sameAs: [
"https://www.instagram.com/adrian.altner/",
"https://www.linkedin.com/in/adrian-altner/",
"https://github.com/adrian-altner",
],
};
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<meta name="theme-color" content="#111111" />
<link rel="canonical" href={canonicalURL} />
<link rel="me" href="https://mastodon.social/@altner" />
<link rel="me" href="https://github.com/adrian-altner" />
<link rel="me" href="https://www.instagram.com/adrian.altner/" />
<link rel="me" href="https://www.linkedin.com/in/adrian-altner/" />
<meta name="fediverse:creator" content="@altner@mastodon.social" />
<link rel="webmention" href="https://webmention.io/adrian-altner.com/webmention" />
<link rel="pingback" href="https://webmention.io/adrian-altner.com/xmlrpc" />
<link rel="alternate" type="application/rss+xml" title="Adrian Altner" href="/rss.xml" />
<link rel="alternate" type="application/rss+xml" title="Adrian Altner — Blog" href="/rss/blog.xml" />
<link rel="alternate" type="application/rss+xml" title="Adrian Altner — Notes" href="/rss/notes.xml" />
<link rel="alternate" type="application/rss+xml" title="Adrian Altner — Links" href="/rss/links.xml" />
<link rel="alternate" type="application/rss+xml" title="Adrian Altner — Photos" href="/rss/photos.xml" />
<link rel="preload" href="/fonts/Exo2-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin />
<link rel="preload" href="/fonts/Exo2-Italic-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin />
<link rel="preload" href="/fonts/JetBrainsMono-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin />
<link rel="preload" href="/fonts/JetBrainsMono-Italic-VariableFont_wght.ttf" as="font" type="font/ttf" crossorigin />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<title set:html={smartypants(pageTitle, 1)} />
<meta name="description" content={smartypantsText(description)} />
<meta property="og:title" content={smartypantsText(title)} />
<meta property="og:type" content="website" />
<meta property="og:description" content={smartypantsText(description)} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content={siteName} />
{
image ? (
<>
<meta property="og:image" content={image.src} />
<meta property="og:image:alt" content={image.alt} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={image.src} />
</>
) : (
<meta name="twitter:card" content="summary" />
)
}
<JsonLd schema={websiteSchema} />
{isHomepage && <JsonLd schema={personSchema} />}
<slot name="head" />
</head>
<body>
{navHidden
? <Nav hidden={true} desktopHidden={true} />
: navDesktopHidden
? <Nav desktopHidden={true} />
: <Nav title={siteName} />
}
<slot />
{!footerHidden && <Footer dark={footerDark} />}
</body>
</html>

View file

@ -0,0 +1,118 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro";
const { frontmatter } = Astro.props;
---
<BaseLayout title={frontmatter.title} description={frontmatter.description}>
<main class="main">
<a href="/" class="back">← Home</a>
<div class="content">
<slot />
<script>
document.querySelectorAll('[data-obf]').forEach(el => {
el.textContent = atob((el as HTMLElement).dataset.obf!);
});
document.querySelectorAll('[data-obf-href]').forEach(el => {
(el as HTMLAnchorElement).href = atob((el as HTMLElement).dataset.obfHref!);
});
</script>
</div>
</main>
</BaseLayout>
<style>
.main {
max-width: var(--width-prose);
margin: 0 auto;
padding: 2rem 2rem 4rem;
width: 100%;
}
.back {
display: inline-block;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: #555;
text-decoration: none;
}
.back:hover {
color: var(--accent);
}
.content {
font-size: 1rem;
line-height: 1.75;
color: #333;
}
.content :global(h1) {
font-size: 1.75rem;
font-weight: 400;
letter-spacing: -0.02em;
margin-bottom: 2rem;
color: #111;
}
.content :global(h2) {
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #888;
margin: 2rem 0 0.75rem;
padding-bottom: 0.4rem;
border-bottom: 2px solid #111;
}
.content :global(h3) {
font-size: 1rem;
font-weight: 600;
margin: 1.5rem 0 0.5rem;
color: #111;
}
.content :global(p) {
margin-bottom: 1.25rem;
}
.content :global(ul),
.content :global(ol) {
margin-bottom: 1.25rem;
padding-left: 1.5rem;
}
.content :global(li) {
margin-bottom: 0.4rem;
}
.content :global(strong) {
font-weight: 600;
color: #111;
}
.content :global(a) {
color: var(--accent);
text-underline-offset: 3px;
}
.content :global(code) {
font-family: monospace;
font-size: 0.9rem;
background: #eeeeed;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.content :global(.r) {
direction: rtl;
unicode-bidi: bidi-override;
}
.content :global(hr) {
border: none;
border-top: 1px solid #ddd;
margin: 2rem 0;
}
</style>

137
src/lib/collections.ts Normal file
View file

@ -0,0 +1,137 @@
import { type CollectionEntry, getCollection } from "astro:content";
import type { ImageMetadata } from "astro";
export type AnyLink = CollectionEntry<"links_json">;
export async function getLinks(): Promise<AnyLink[]> {
const links = await getCollection("links_json");
return links.sort(
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf(),
);
}
export type PhotoSidecar = {
id: string;
title: string[];
alt: string;
location: string;
date: string;
tags: string[];
exif: {
camera: string;
lens: string;
aperture: string;
iso: string;
focal_length: string;
shutter_speed: string;
};
};
export type LoadedPhoto = {
sidecar: PhotoSidecar;
image: ImageMetadata;
};
export type Breadcrumb = {
label: string;
href: string;
};
/**
* Extract the clean slug from a collection entry id.
* e.g. "travels/asia/chiang-mai/index.md" "travels/asia/chiang-mai"
* "index.md" ""
*/
export function collectionSlug(
entry: CollectionEntry<"collections_photos">,
): string {
return entry.id.replace(/\/index\.mdx?$/, "").replace(/^index\.mdx?$/, "");
}
/**
* Load and sort photos from a specific collection's img/ directory.
* Only loads photos directly in `collections/<slug>/img/` (not recursive).
*/
export function buildCollectionPhotos(
sidecars: Record<string, PhotoSidecar>,
imageModules: Record<string, { default: ImageMetadata }>,
slug: string,
): LoadedPhoto[] {
const prefix = slug
? `/src/content/photos/collections/${slug}/img/`
: "/src/content/photos/collections/img/";
return Object.entries(sidecars)
.filter(([p]) => {
// Must be in exactly this collection's img/ dir (no deeper nesting)
if (!p.startsWith(prefix)) return false;
const remainder = p.slice(prefix.length);
return !remainder.includes("/");
})
.map(([jsonPath, sidecar]) => {
const imgPath = jsonPath.replace(/\.json$/, ".jpg");
const image = imageModules[imgPath]?.default;
if (!image) return null;
return { sidecar, image };
})
.filter((p): p is LoadedPhoto => p !== null)
.sort(
(a, b) =>
new Date(a.sidecar.date).getTime() - new Date(b.sidecar.date).getTime(),
);
}
/**
* Build breadcrumb trail from root to the given slug.
* e.g. slug "travels/asia/chiang-mai" [
* { label: "Collections", href: "/photos/collections" },
* { label: "Travels", href: "/photos/collections/travels" },
* { label: "Asia", href: "/photos/collections/travels/asia" },
* { label: "Chiang Mai", href: "/photos/collections/travels/asia/chiang-mai" },
* ]
*/
export function buildBreadcrumbs(
slug: string,
allCollections: CollectionEntry<"collections_photos">[],
): Breadcrumb[] {
const crumbs: Breadcrumb[] = [
{ label: "Collections", href: "/photos/collections" },
];
if (!slug) return crumbs;
const segments = slug.split("/");
for (let i = 0; i < segments.length; i++) {
const partialSlug = segments.slice(0, i + 1).join("/");
const entry = allCollections.find((c) => collectionSlug(c) === partialSlug);
crumbs.push({
label: entry?.data.title ?? segments[i] ?? partialSlug,
href: `/photos/collections/${partialSlug}`,
});
}
return crumbs;
}
/**
* Get immediate child collections of a given parent slug.
*/
export function getChildCollections(
parentSlug: string,
allCollections: CollectionEntry<"collections_photos">[],
): CollectionEntry<"collections_photos">[] {
const depth = parentSlug ? parentSlug.split("/").length : 0;
return allCollections
.filter((c) => {
const cs = collectionSlug(c);
if (!cs) return false;
if (parentSlug && !cs.startsWith(`${parentSlug}/`)) return false;
if (!parentSlug && cs.includes("/")) return false;
return cs.split("/").length === depth + 1;
})
.sort(
(a, b) =>
a.data.order - b.data.order || a.data.title.localeCompare(b.data.title),
);
}

68
src/lib/links.ts Normal file
View file

@ -0,0 +1,68 @@
import type { AnyLink } from "@/lib/collections";
const COLLECTION_COLORS = [
"#e45c5c",
"#e4885c",
"#d4c24a",
"#5cb85c",
"#5ca8e4",
"#8e5ce4",
"#e45ca8",
"#5ce4d4",
];
export function collectionColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
}
return COLLECTION_COLORS[hash % COLLECTION_COLORS.length] as string;
}
export function getDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url;
}
}
export function getFaviconUrl(url: string): string {
const domain = getDomain(url);
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
}
export function getUniqueCollections(links: AnyLink[]): string[] {
const seen = new Set<string>();
for (const link of links) {
const col = link.data.collection;
if (col) seen.add(col);
}
return Array.from(seen).sort();
}
export function getUniqueTags(links: AnyLink[]): string[] {
const seen = new Set<string>();
for (const link of links) {
for (const tag of link.data.tags) {
seen.add(tag);
}
}
return Array.from(seen).sort();
}
export function filterByCollection(links: AnyLink[], col: string): AnyLink[] {
return links.filter((l) => l.data.collection === col);
}
export function filterByTag(links: AnyLink[], tag: string): AnyLink[] {
return links.filter((l) => l.data.tags.includes(tag));
}
export function formatDate(date: Date): string {
return date.toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}

610
src/lib/og.ts Normal file
View file

@ -0,0 +1,610 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import satori from "satori";
import sharp from "sharp";
const fontRegular = readFileSync(
join(
process.cwd(),
"node_modules/@fontsource/exo-2/files/exo-2-latin-400-normal.woff",
),
);
const fontBold = readFileSync(
join(
process.cwd(),
"node_modules/@fontsource/exo-2/files/exo-2-latin-700-normal.woff",
),
);
export async function renderOgImage(
vNode: Parameters<typeof satori>[0],
): Promise<ArrayBuffer> {
const svg = await satori(vNode, {
width: 1200,
height: 630,
fonts: [
{
name: "Exo 2",
data: fontRegular.buffer,
weight: 400,
style: "normal",
},
{
name: "Exo 2",
data: fontBold.buffer,
weight: 700,
style: "normal",
},
],
});
const buf = await sharp(Buffer.from(svg)).png().toBuffer();
return buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength,
) as ArrayBuffer;
}
/**
* Crop + resize a source image file to exactly 1200×630 (centered) and
* return it as a base64 JPEG data URI ready for Satori backgroundImage.
*/
export async function imageToOgDataUri(sourcePath: string): Promise<string> {
const buf = await sharp(readFileSync(sourcePath))
.resize(1200, 630, { fit: "cover", position: "centre" })
.jpeg({ quality: 85 })
.toBuffer();
return `data:image/jpeg;base64,${buf.toString("base64")}`;
}
// Colors
const BG = "#111111";
const FG = "#f5f5f3";
const ACCENT = "#e8587a";
function truncate(str: string, max: number): string {
return str.length > max ? `${str.slice(0, max - 1)}` : str;
}
export type ArticleOgProps = {
title: string;
description: string;
tags?: string[];
};
export function buildArticleVNode(
props: ArticleOgProps,
): Parameters<typeof satori>[0] {
const title = truncate(props.title, 70);
const description = truncate(props.description, 120);
return {
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
width: "1200px",
height: "630px",
backgroundColor: BG,
color: FG,
fontFamily: "Exo 2",
},
children: [
// Top accent bar
{
type: "div",
props: {
style: {
display: "flex",
width: "1200px",
height: "6px",
backgroundColor: ACCENT,
},
},
},
// Content
{
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
flex: "1",
paddingTop: "64px",
paddingRight: "80px",
paddingBottom: "48px",
paddingLeft: "80px",
justifyContent: "space-between",
},
children: [
// Title + description
{
type: "div",
props: {
style: { display: "flex", flexDirection: "column" },
children: [
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "18px",
color: ACCENT,
letterSpacing: "2px",
marginBottom: "20px",
textTransform: "uppercase",
},
children: "Article",
},
},
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "56px",
fontWeight: 700,
color: FG,
lineHeight: "1.1",
marginBottom: "20px",
},
children: title,
},
},
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "24px",
color: "rgba(245,245,243,0.65)",
lineHeight: "1.4",
},
children: description,
},
},
],
},
},
// Footer
{
type: "div",
props: {
style: {
display: "flex",
justifyContent: "flex-end",
borderTop: "1px solid #333333",
paddingTop: "20px",
},
children: [
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "22px",
fontWeight: 700,
color: FG,
},
children: "Adrian Altner",
},
},
],
},
},
],
},
},
],
},
};
}
export type CollectionOgProps = {
title: string;
description: string;
location?: string | undefined;
coverDataUri?: string | undefined;
};
export function buildCollectionVNode(
props: CollectionOgProps,
): Parameters<typeof satori>[0] {
const title = truncate(props.title, 60);
const hasImage = !!props.coverDataUri;
if (hasImage) {
return {
type: "div",
props: {
style: {
display: "flex",
width: "1200px",
height: "630px",
backgroundImage: `url(${props.coverDataUri})`,
backgroundSize: "cover",
backgroundPosition: "center",
fontFamily: "Exo 2",
position: "relative",
},
children: [
// Gradient overlay (full card, darker at bottom)
{
type: "div",
props: {
style: {
display: "flex",
position: "absolute",
top: "0px",
left: "0px",
width: "1200px",
height: "630px",
backgroundImage:
"linear-gradient(to top, rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.4) 50%, rgba(0,0,0,0.1) 100%)",
},
},
},
// Top accent bar
{
type: "div",
props: {
style: {
display: "flex",
position: "absolute",
top: "0px",
left: "0px",
width: "1200px",
height: "6px",
backgroundColor: ACCENT,
},
},
},
// Bottom content
{
type: "div",
props: {
style: {
display: "flex",
position: "absolute",
bottom: "0px",
left: "0px",
right: "0px",
paddingTop: "32px",
paddingRight: "64px",
paddingBottom: "48px",
paddingLeft: "64px",
flexDirection: "column",
},
children: [
// Location label
...(props.location
? [
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "18px",
color: ACCENT,
letterSpacing: "2px",
textTransform: "uppercase",
marginBottom: "12px",
},
children: truncate(props.location, 50),
},
},
]
: []),
// Title
{
type: "div",
props: {
style: {
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
},
children: [
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "64px",
fontWeight: 700,
color: "#ffffff",
lineHeight: "1.05",
},
children: title,
},
},
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "20px",
fontWeight: 700,
color: "rgba(255,255,255,0.7)",
letterSpacing: "1px",
paddingBottom: "8px",
},
children: "Adrian Altner",
},
},
],
},
},
],
},
},
],
},
};
}
// Text-only fallback (no cover photo)
return {
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
width: "1200px",
height: "630px",
backgroundColor: BG,
color: FG,
fontFamily: "Exo 2",
},
children: [
{
type: "div",
props: {
style: {
display: "flex",
width: "1200px",
height: "6px",
backgroundColor: ACCENT,
},
},
},
{
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
flex: "1",
paddingTop: "64px",
paddingRight: "80px",
paddingBottom: "48px",
paddingLeft: "80px",
justifyContent: "space-between",
},
children: [
{
type: "div",
props: {
style: { display: "flex", flexDirection: "column" },
children: [
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "18px",
color: ACCENT,
letterSpacing: "2px",
marginBottom: "20px",
textTransform: "uppercase",
},
children: "Collection",
},
},
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "64px",
fontWeight: 700,
color: FG,
},
children: title,
},
},
],
},
},
{
type: "div",
props: {
style: {
display: "flex",
justifyContent: "flex-end",
borderTop: "1px solid #333333",
paddingTop: "20px",
},
children: [
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "22px",
fontWeight: 700,
color: FG,
},
children: "Adrian Altner",
},
},
],
},
},
],
},
},
],
},
};
}
export type NoteOgProps = {
coverDataUri?: string | undefined;
};
export function buildNoteVNode(
props: NoteOgProps,
): Parameters<typeof satori>[0] {
if (props.coverDataUri) {
return {
type: "div",
props: {
style: {
display: "flex",
width: "1200px",
height: "630px",
backgroundImage: `url(${props.coverDataUri})`,
backgroundSize: "cover",
backgroundPosition: "center",
},
},
};
}
return {
type: "div",
props: {
style: {
display: "flex",
width: "1200px",
height: "630px",
backgroundColor: BG,
},
},
};
}
export type PhotoOgProps = {
title: string;
alt: string;
date: string;
location?: string | undefined;
photoDataUri: string;
};
export function buildPhotoVNode(
props: PhotoOgProps,
): Parameters<typeof satori>[0] {
const title = truncate(props.title, 70);
const dateFormatted = new Date(props.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
return {
type: "div",
props: {
style: {
display: "flex",
width: "1200px",
height: "630px",
backgroundImage: `url(${props.photoDataUri})`,
backgroundSize: "cover",
fontFamily: "Exo 2",
position: "relative",
},
children: [
// Gradient overlay (darker at bottom for text legibility)
{
type: "div",
props: {
style: {
display: "flex",
position: "absolute",
top: "0px",
left: "0px",
width: "1200px",
height: "630px",
backgroundImage:
"linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.3) 50%, rgba(0,0,0,0.05) 100%)",
},
},
},
// Bottom content
{
type: "div",
props: {
style: {
display: "flex",
position: "absolute",
bottom: "0px",
left: "0px",
right: "0px",
paddingTop: "32px",
paddingRight: "64px",
paddingBottom: "48px",
paddingLeft: "64px",
flexDirection: "column",
},
children: [
// Title
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "52px",
fontWeight: 700,
color: "#ffffff",
lineHeight: "1.05",
marginBottom: "16px",
},
children: title,
},
},
// Date left, site name right
{
type: "div",
props: {
style: {
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
},
children: [
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "18px",
color: "rgba(255,255,255,0.6)",
},
children: dateFormatted,
},
},
{
type: "div",
props: {
style: {
display: "flex",
fontSize: "20px",
fontWeight: 700,
color: "rgba(255,255,255,0.7)",
letterSpacing: "1px",
},
children: "Adrian Altner",
},
},
],
},
},
],
},
},
],
},
};
}

77
src/lib/photo-albums.ts Normal file
View file

@ -0,0 +1,77 @@
import { access, readdir } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export const PHOTOS_DIRECTORY = join(__dirname, "../content/photos/albums");
export interface PhotoDirectory {
name: string;
imagePaths: string[];
}
async function findJpgFiles(dir: string, baseDir: string): Promise<string[]> {
const results: string[] = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
const nested = await findJpgFiles(fullPath, baseDir);
results.push(...nested);
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".jpg")) {
results.push(relative(baseDir, fullPath));
}
}
return results;
}
export async function getPhotoDirectories(
photosDirectory: string,
): Promise<PhotoDirectory[]> {
const entries = await readdir(photosDirectory, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory());
const result: PhotoDirectory[] = [];
for (const dir of dirs) {
const dirPath = join(photosDirectory, dir.name);
const imagePaths = await findJpgFiles(dirPath, photosDirectory);
if (imagePaths.length > 0) {
result.push({ name: dir.name, imagePaths });
}
}
return result;
}
export async function getImagesMissingMetadata(
photosDirectory: string,
): Promise<string[]> {
const allImagePaths = await findJpgFiles(photosDirectory, photosDirectory);
const missing: string[] = [];
for (const imagePath of allImagePaths) {
const jsonPath = getMetadataPathForImage(imagePath, photosDirectory);
try {
await access(jsonPath);
} catch {
missing.push(imagePath);
}
}
return missing;
}
export function getMetadataPathForImage(
relativeImagePath: string,
photosDirectory: string,
): string {
const jsonRelativePath = relativeImagePath.replace(/\.jpg$/i, ".json");
return join(photosDirectory, jsonRelativePath);
}
export function getPhotoAbsolutePath(
relativeImagePath: string,
photosDirectory: string,
): string {
return join(photosDirectory, relativeImagePath);
}

View file

@ -0,0 +1,180 @@
/**
* Remark plugin that converts Obsidian-style [[wiki-links]] to standard
* Markdown links by resolving filenames against the content directory.
*
* Supported syntax:
* [[filename]] [filename](/url/to/filename/)
* [[filename|display text]] [display text](/url/to/filename/)
* [[filename#heading]] heading fragment is preserved in the URL
* ![[image.jpg]] ![image.jpg](./relative/path/image.jpg)
* ![[image.jpg|alt text]] ![alt text](./relative/path/image.jpg)
*
* Content sources and their URL prefixes:
* src/content/blog/posts /blog/{slug}/
* src/content/notes /notes/{slug}/
* src/content/projects/project /projects/{slug}/
*/
import { readdirSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { SKIP, visit } from "unist-util-visit";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SOURCES = [
{
base: resolve(__dirname, "../content/blog/posts"),
urlPrefix: "/blog",
},
{
base: resolve(__dirname, "../content/notes"),
urlPrefix: "/notes",
},
{
base: resolve(__dirname, "../content/projects/project"),
urlPrefix: "/projects",
},
];
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|svg|avif)$/i;
function buildFileIndex() {
const map = new Map();
for (const { base, urlPrefix } of SOURCES) {
let files;
try {
files = readdirSync(base, { recursive: true });
} catch {
continue; // directory may not exist yet
}
for (const file of files) {
if (!/\.(md|mdx)$/.test(file)) continue;
const slug = file.replace(/\.(md|mdx)$/, "").replace(/\\/g, "/");
const filename = slug.split("/").pop();
// First match wins — filename must be unique across collections
if (!map.has(filename)) {
map.set(filename, `${urlPrefix}/${slug}/`);
}
}
}
return map;
}
function buildImageIndex() {
const map = new Map();
for (const { base } of SOURCES) {
let files;
try {
files = readdirSync(base, { recursive: true });
} catch {
continue;
}
for (const file of files) {
if (!IMAGE_EXT_RE.test(file)) continue;
const filename = file.replace(/\\/g, "/").split("/").pop();
if (!map.has(filename)) {
map.set(filename, resolve(base, file));
}
}
}
return map;
}
// Build once per process, not per file
const fileIndex = buildFileIndex();
const imageIndex = buildImageIndex();
// Matches optional leading ! and [[...]] content
const WIKI_LINK_RE = /(!?)\[\[([^\]]+)\]\]/g;
export function remarkObsidianLinks() {
return (tree, file) => {
visit(tree, "text", (node, index, parent) => {
if (!WIKI_LINK_RE.test(node.value)) return;
WIKI_LINK_RE.lastIndex = 0;
const nodes = [];
let last = 0;
let match = WIKI_LINK_RE.exec(node.value);
while (match !== null) {
if (match.index > last) {
nodes.push({
type: "text",
value: node.value.slice(last, match.index),
});
}
const embed = match[1] === "!";
const inner = match[2];
const pipeIdx = inner.indexOf("|");
const ref = pipeIdx === -1 ? inner : inner.slice(0, pipeIdx);
const label =
pipeIdx === -1
? ref.split("#")[0].trim()
: inner.slice(pipeIdx + 1).trim();
const [filename, heading] = ref.trim().split("#");
const trimmedFilename = filename.trim();
// Image embed: ![[image.ext]]
if (embed && IMAGE_EXT_RE.test(trimmedFilename)) {
const absImagePath = imageIndex.get(trimmedFilename);
if (absImagePath && file?.path) {
const relPath =
"./" +
relative(dirname(file.path), absImagePath).replace(/\\/g, "/");
nodes.push({
type: "image",
url: relPath,
alt: label || trimmedFilename,
title: null,
});
} else {
// Fallback: emit as link
nodes.push({
type: "link",
url: `#${trimmedFilename}`,
title: null,
children: [{ type: "text", value: label || trimmedFilename }],
});
}
} else {
// Regular wiki-link
const base = fileIndex.get(trimmedFilename);
const url = base
? heading
? `${base}#${heading.trim()}`
: base
: `#${trimmedFilename}`;
nodes.push({
type: "link",
url,
title: null,
data: {
hName: "a",
hProperties: {
href: url,
target: "_blank",
rel: "noopener noreferrer",
},
hChildren: [{ type: "text", value: label }],
},
children: [{ type: "text", value: label }],
});
}
last = match.index + match[0].length;
match = WIKI_LINK_RE.exec(node.value);
}
if (last < node.value.length) {
nodes.push({ type: "text", value: node.value.slice(last) });
}
parent.children.splice(index, 1, ...nodes);
return [SKIP, index + nodes.length];
});
};
}

364
src/lib/webmentions.ts Normal file
View file

@ -0,0 +1,364 @@
interface Author {
name?: string;
photo?: string;
url?: string;
}
export interface Mention {
"wm-property": string;
"wm-id": number;
url: string;
author?: Author;
published?: string;
content?: { text?: string; html?: string };
}
const WEBMENTION_IO_API = "https://webmention.io/api/mentions.jf2";
const SOCIAL_PROPERTIES = new Set([
"like-of",
"repost-of",
"in-reply-to",
"mention-of",
]);
const mentionsCache = new Map<
string,
{ expiresAt: number; mentions: Mention[] }
>();
const sourceExistsCache = new Map<
string,
{ expiresAt: number; exists: boolean }
>();
const MENTIONS_CACHE_TTL_MS = 60_000;
const SOURCE_EXISTS_TRUE_CACHE_TTL_MS = 10 * 60_000;
const SOURCE_EXISTS_FALSE_CACHE_TTL_MS = 60_000;
const MAX_CONCURRENCY = 6;
const FETCH_TIMEOUT_MS = 8_000;
type MastodonInteractionCheck = {
kind: "mastodon";
origin: string;
statusId: string;
actorId: string;
action: "favourited_by" | "reblogged_by";
};
type MastodonStatusRef = {
kind: "mastodon";
origin: string;
statusId: string;
};
function now() {
return Date.now();
}
function cacheSourceExists(
sourceUrl: string,
exists: boolean,
ttlMs = exists
? SOURCE_EXISTS_TRUE_CACHE_TTL_MS
: SOURCE_EXISTS_FALSE_CACHE_TTL_MS,
) {
sourceExistsCache.set(sourceUrl, {
exists,
expiresAt: now() + ttlMs,
});
}
function getHostname(rawUrl: string) {
try {
return new URL(rawUrl).hostname.toLowerCase();
} catch {
return "";
}
}
function isLikelySocialSource(url: string) {
const host = getHostname(url);
if (!host) return false;
if (host.includes("mastodon")) return true;
if (host.endsWith(".social")) return true;
return false;
}
function shouldValidateSource(mention: Mention) {
if (!mention.url) return false;
if (!SOCIAL_PROPERTIES.has(mention["wm-property"])) return false;
return isLikelySocialSource(mention.url);
}
function withTimeout(ms: number) {
return AbortSignal.timeout(ms);
}
function getLinkHeaderNextUrl(linkHeader: string | null) {
if (!linkHeader) return null;
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/i);
return match?.[1] ?? null;
}
function parseMastodonInteraction(
sourceUrl: string,
): MastodonInteractionCheck | null {
try {
const parsed = new URL(sourceUrl);
const fragment = decodeURIComponent(parsed.hash.replace(/^#/, ""));
if (!fragment) return null;
const likeMatch = fragment.match(/^(?:favorited|favourited)-by-(\d+)$/i);
const repostMatch = fragment.match(/^reblogged-by-(\d+)$/i);
if (!likeMatch && !repostMatch) return null;
const statusIdMatch = parsed.pathname.match(/\/(\d+)\/?$/);
const statusId = statusIdMatch?.[1];
if (!statusId) return null;
const likedActorId = likeMatch?.[1];
const repostActorId = repostMatch?.[1];
if (likedActorId) {
return {
kind: "mastodon",
origin: parsed.origin,
statusId,
actorId: likedActorId,
action: "favourited_by",
};
}
if (!repostActorId) return null;
return {
kind: "mastodon",
origin: parsed.origin,
statusId,
actorId: repostActorId,
action: "reblogged_by",
};
} catch {
return null;
}
}
function parseSocialInteraction(sourceUrl: string) {
return parseMastodonInteraction(sourceUrl);
}
function parseMastodonStatusRef(sourceUrl: string): MastodonStatusRef | null {
try {
const parsed = new URL(sourceUrl);
const statusIdMatch = parsed.pathname.match(/\/(\d+)\/?$/);
const statusId = statusIdMatch?.[1];
if (!statusId) return null;
if (parsed.hash && parsed.hash.length > 1) return null;
const host = parsed.hostname.toLowerCase();
if (!host.includes("mastodon") && !host.endsWith(".social")) return null;
return {
kind: "mastodon",
origin: parsed.origin,
statusId,
};
} catch {
return null;
}
}
function parseSocialPostRef(sourceUrl: string): MastodonStatusRef | null {
return parseMastodonStatusRef(sourceUrl);
}
async function checkMastodonInteractionExists(
interaction: MastodonInteractionCheck,
) {
let nextUrl: string | null =
`${interaction.origin}/api/v1/statuses/${interaction.statusId}/${interaction.action}?limit=80`;
for (let page = 0; page < 20 && nextUrl; page++) {
const res = await fetch(nextUrl, {
signal: withTimeout(FETCH_TIMEOUT_MS),
headers: {
"User-Agent": "adrian-altner.com webmentions verifier",
},
});
if (res.status === 404 || res.status === 410) return false;
if (!res.ok) return true;
const accounts = await res.json();
if (!Array.isArray(accounts)) return true;
if (
accounts.some(
(account) => String(account?.id ?? "") === interaction.actorId,
)
) {
return true;
}
nextUrl = getLinkHeaderNextUrl(res.headers.get("link"));
}
return !!nextUrl;
}
async function checkMastodonStatusExists(status: MastodonStatusRef) {
const res = await fetch(
`${status.origin}/api/v1/statuses/${status.statusId}`,
{
signal: withTimeout(FETCH_TIMEOUT_MS),
headers: {
"User-Agent": "adrian-altner.com webmentions verifier",
},
},
);
if (res.status === 404 || res.status === 410) return false;
if (!res.ok) return true;
return true;
}
async function socialPostStillExists(postRef: MastodonStatusRef) {
return checkMastodonStatusExists(postRef);
}
async function socialInteractionStillExists(
interaction: MastodonInteractionCheck,
) {
return checkMastodonInteractionExists(interaction);
}
async function sourceStillExists(sourceUrl: string) {
const cached = sourceExistsCache.get(sourceUrl);
if (cached && cached.expiresAt > now()) return cached.exists;
try {
const interaction = parseSocialInteraction(sourceUrl);
if (interaction) {
const exists = await socialInteractionStillExists(interaction);
cacheSourceExists(sourceUrl, exists);
return exists;
}
const postRef = parseSocialPostRef(sourceUrl);
if (postRef) {
const exists = await socialPostStillExists(postRef);
cacheSourceExists(sourceUrl, exists);
return exists;
}
const res = await fetch(sourceUrl, {
redirect: "follow",
signal: withTimeout(FETCH_TIMEOUT_MS),
headers: {
"User-Agent": "adrian-altner.com webmentions verifier",
},
});
const statusGone = res.status === 404 || res.status === 410;
if (statusGone) {
cacheSourceExists(sourceUrl, false);
return false;
}
if (!res.ok) {
// Fail open on transient upstream errors.
cacheSourceExists(sourceUrl, true, 60_000);
return true;
}
const host = getHostname(sourceUrl);
const contentType = res.headers.get("content-type") ?? "";
const isHtml = contentType.includes("text/html");
if (isHtml && host.includes("mastodon")) {
const body = (await res.text()).toLowerCase();
const isDeletedMastodon =
host.includes("mastodon") &&
(body.includes("status not found") ||
body.includes("this status does not exist"));
if (isDeletedMastodon) {
cacheSourceExists(sourceUrl, false);
return false;
}
}
cacheSourceExists(sourceUrl, true);
return true;
} catch {
// Fail open on network/runtime problems.
cacheSourceExists(sourceUrl, true, 60_000);
return true;
}
}
async function runWithConcurrencyLimit<T, R>(
items: T[],
limit: number,
worker: (item: T) => Promise<R>,
) {
const output = new Array<R | undefined>(items.length);
let nextIndex = 0;
async function runWorker() {
while (nextIndex < items.length) {
const index = nextIndex++;
const item = items[index];
if (item === undefined) continue;
output[index] = await worker(item);
}
}
const workers = Array.from(
{ length: Math.max(1, Math.min(limit, items.length)) },
() => runWorker(),
);
await Promise.all(workers);
return output as R[];
}
async function fetchRawWebmentions(target: string) {
const apiUrl = `${WEBMENTION_IO_API}?target=${encodeURIComponent(target)}&per-page=100`;
const res = await fetch(apiUrl, {
signal: withTimeout(FETCH_TIMEOUT_MS),
headers: {
"User-Agent": "adrian-altner.com webmentions fetcher",
},
});
if (!res.ok) return [];
const data = await res.json();
return (data.children ?? []) as Mention[];
}
export async function getFilteredWebmentions(target: string) {
const cached = mentionsCache.get(target);
if (cached && cached.expiresAt > now()) {
return cached.mentions;
}
const mentions = await fetchRawWebmentions(target);
if (mentions.length === 0) {
mentionsCache.set(target, {
mentions: [],
expiresAt: now() + MENTIONS_CACHE_TTL_MS,
});
return [];
}
const checks = await runWithConcurrencyLimit(
mentions,
MAX_CONCURRENCY,
async (mention) => {
if (!shouldValidateSource(mention)) return true;
return sourceStillExists(mention.url);
},
);
const filtered = mentions.filter((_, idx) => checks[idx]);
mentionsCache.set(target, {
mentions: filtered,
expiresAt: now() + MENTIONS_CACHE_TTL_MS,
});
return filtered;
}

249
src/pages/about.astro Normal file
View file

@ -0,0 +1,249 @@
---
import { Image } from "astro:assets";
import { getCollection } from "astro:content";
import meBangkok from "@/assets/images/me-bangkok.jpg";
import JsonLd from "@/components/JsonLd.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
const recentProjects = (
await getCollection("projects", ({ data }) => !data.draft)
)
.sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf())
.slice(0, 3);
const profilePageSchema = {
"@context": "https://schema.org",
"@type": "ProfilePage",
url: "https://adrian-altner.com/about",
mainEntity: {
"@type": "Person",
name: "Adrian Altner",
url: "https://adrian-altner.com",
jobTitle: "Key Account Manager & Amateur Photographer",
sameAs: [
"https://www.instagram.com/adrian.altner/",
"https://www.linkedin.com/in/adrian-altner/",
"https://github.com/adrian-altner",
],
},
};
---
<BaseLayout title="About" description="About Adrian Altner, his passion for the web and photography, and how to get in touch.">
<JsonLd schema={profilePageSchema} slot="head" />
<div class="page">
<main class="main">
<a href="/" class="back">← Home</a>
<div class="about">
<div class="about__photo-col">
<Image
src={meBangkok}
alt="Adrian Altner in Bangkok"
class="about__photo"
width={480}
height={640}
/>
</div>
<div class="about__content">
<h1 class="about__heading">About me</h1>
<p class="about__tagline">Adrian Altner</p>
<p class="about__bio">
I've been passionate about the web since 1997. By day, I work as a Key Account
Manager in IT distribution. I'm self-taught by nature, and in my spare time I
build things for fun and teach myself whatever sparks my curiosity.
</p>
<p class="about__bio">
Photography is one of my main hobbies. I usually shoot while traveling and on
photowalks, drawn to street scenes, architecture, and the kind of light that never
waits. This site is where those interests come together.
</p>
</div>
</div>
<section class="projects">
<h2 class="projects__heading"><a href="/projects">Projects</a></h2>
<ul class="projects__list">
{recentProjects.map((p) => (
<li class="projects__item">
<a href={`/projects/${p.id}`} class="projects__title">{p.data.title}</a>
<p class="projects__desc">{p.data.description}</p>
</li>
))}
</ul>
</section>
</main>
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #f5f5f3;
color: #111;
}
.main {
max-width: var(--width-content);
margin: 0 auto;
padding: 2rem 2rem 4rem;
width: 100%;
}
.back {
display: inline-block;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: #555;
text-decoration: none;
}
.back:hover {
color: var(--accent);
}
/* ── Two-column layout ── */
.about {
display: grid;
grid-template-columns: 48% 52%;
gap: 0;
}
/* ── Photo column ── */
.about__photo-col {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.about__photo {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
display: block;
}
/* ── Content column ── */
.about__content {
padding: 3rem 2.5rem;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 560px;
}
.about__heading {
font-size: clamp(1.75rem, 3vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1;
text-transform: uppercase;
color: #1a1a2e;
margin-bottom: 0.75rem;
}
.about__tagline {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--accent);
margin-bottom: 2rem;
}
.about__bio {
font-size: 0.975rem;
line-height: 1.8;
color: #444;
margin-bottom: 1rem;
}
/* ── Projects ── */
.projects {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid #e0e0de;
}
.projects__heading {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #888;
margin-bottom: 1rem;
}
.projects__heading a {
color: inherit;
text-decoration: none;
}
.projects__heading a:hover {
color: #111;
}
.projects__list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
}
.projects__item {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.75rem 0;
border-bottom: 1px solid #e0e0de;
}
.projects__item:first-child {
border-top: 1px solid #e0e0de;
}
.projects__title {
font-size: 0.875rem;
font-weight: 500;
color: #111;
text-decoration: none;
line-height: 1.3;
}
.projects__title:hover {
color: var(--accent);
}
.projects__desc {
font-size: 0.875rem;
color: #666;
line-height: 1.4;
margin: 0;
}
/* ── Mobile ── */
@media (max-width: 600px) {
.about {
grid-template-columns: 1fr;
}
.about__photo-col {
height: 520px;
}
.about__photo {
object-fit: cover;
object-position: center 85%;
}
.about__content {
padding: 2rem 1.5rem 3rem;
}
}
</style>

View file

@ -0,0 +1,222 @@
---
import { getCollection } from "astro:content";
import BaseLayout from "@/layouts/BaseLayout.astro";
import { collectionSlug, getLinks } from "@/lib/collections";
const posts = (await getCollection("blog", ({ data }) => !data.draft)).map(
(p) => ({
title: p.data.title,
date: p.data.publishDate,
url: `/blog/${p.id}`,
type: "post" as const,
}),
);
const notes = (await getCollection("notes", ({ data }) => !data.draft)).map(
(n) => ({
title: n.data.title,
date: n.data.publishDate,
url: `/notes/${n.id}`,
type: "note" as const,
}),
);
const photoSets = (
await getCollection(
"collections_photos",
({ data }) => data.set && !data.draft && !!data.publishDate,
)
).map((c) => ({
title: c.data.title,
date: c.data.publishDate as Date,
url: `/photos/collections/${collectionSlug(c)}`,
type: "photos" as const,
}));
const links = (await getLinks()).map((l) => ({
title: l.data.title,
date: l.data.publishDate,
url: `/links/${l.id}`,
type: "link" as const,
}));
const all = [...posts, ...notes, ...photoSets, ...links].sort(
(a, b) => b.date.valueOf() - a.date.valueOf(),
);
// Group by year
const byYear = new Map<number, typeof all>();
for (const entry of all) {
const year = entry.date.getFullYear();
if (!byYear.has(year)) byYear.set(year, []);
byYear.get(year)?.push(entry);
}
const years = [...byYear.keys()].sort((a, b) => b - a);
function formatDate(date: Date) {
return date.toLocaleDateString("de-DE", { month: "2-digit", day: "2-digit" });
}
const typeLabel: Record<string, string> = {
post: "Article",
note: "Note",
photos: "Photo Collection",
link: "Link",
};
---
<BaseLayout
title="Archives"
description="Chronologisches Archiv aller Posts und Notizen von Adrian Altner."
>
<div class="page">
<main class="main">
<a href="/" class="back">← Home</a>
<h1 class="heading">Archives</h1>
{
years.length === 0 ? (
<p class="empty">Noch keine Inhalte vorhanden.</p>
) : (
years.map((year) => (
<section class="year">
<h2 class="year__heading">{year}</h2>
<ul class="list">
{byYear.get(year)!.map((entry) => (
<li class="item">
<time class="item__date" datetime={entry.date.toISOString()}>
{formatDate(entry.date)}
</time>
<a href={entry.url} class="item__title">
{entry.title}
</a>
<span class={`item__type item__type--${entry.type}`}>
{typeLabel[entry.type]}
</span>
</li>
))}
</ul>
</section>
))
)
}
</main>
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #f5f5f3;
color: #111;
}
.main {
max-width: var(--width-prose);
margin: 0 auto;
padding: 2rem 2rem 4rem;
width: 100%;
}
.back {
display: inline-block;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: #555;
text-decoration: none;
}
.back:hover {
color: var(--accent);
}
.heading {
font-size: 1.75rem;
font-weight: 400;
letter-spacing: -0.02em;
margin-bottom: 2.5rem;
}
.empty {
color: #888;
font-size: 0.95rem;
}
.year {
margin-bottom: 2.5rem;
}
.year__heading {
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #888;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #111;
}
.list {
list-style: none;
padding: 0;
}
.item {
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 0.55rem 0;
border-bottom: 1px solid #e8e8e6;
}
.item__date {
font-size: 0.8rem;
color: #888;
white-space: nowrap;
flex-shrink: 0;
width: 3.5rem;
}
.item__title {
font-size: 0.95rem;
color: #111;
text-decoration: none;
flex: 1;
}
.item__title:hover {
color: var(--accent);
}
.item__type {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0.15rem 0.4rem;
border-radius: 3px;
flex-shrink: 0;
}
.item__type--post {
background: #e8e8e6;
color: #555;
}
.item__type--note {
background: #e8f0e8;
color: #4a7a4a;
}
.item__type--photos {
background: #e8eef5;
color: #3a5f8a;
}
.item__type--link {
background: #f5ece8;
color: #8a4a3a;
}
</style>

View file

@ -0,0 +1,477 @@
---
import { Image } from "astro:assets";
import { getCollection, render } from "astro:content";
import BlogPostSeries from "@/components/BlogPostSeries.astro";
import JsonLd from "@/components/JsonLd.astro";
import TableOfContents from "@/components/TableOfContents.astro";
import WebMentions from "@/components/WebMentions.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
export async function getStaticPaths() {
const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
(a, b) => a.data.publishDate.valueOf() - b.data.publishDate.valueOf(),
);
return posts.map((post, i) => {
const prev = posts[i - 1];
const next = posts[i + 1];
return {
params: { slug: post.id },
props: {
post,
prevPost: prev ? { id: prev.id, title: prev.data.title } : null,
nextPost: next ? { id: next.id, title: next.data.title } : null,
},
};
});
}
const { post, prevPost, nextPost } = Astro.props;
const { Content, headings } = await render(post);
const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3);
function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
const allPosts = await getCollection("blog");
const seriesChildren = allPosts
.filter((a) => a.data.seriesParent === post.id && !a.data.draft)
.sort((a, b) => (a.data.seriesOrder ?? 99) - (b.data.seriesOrder ?? 99));
// For child posts: find parent + siblings
const seriesParentPost = post.data.seriesParent
? allPosts.find((a) => a.id === post.data.seriesParent)
: undefined;
const seriesSiblings = seriesParentPost
? allPosts
.filter(
(a) => a.data.seriesParent === seriesParentPost.id && !a.data.draft,
)
.sort((a, b) => (a.data.seriesOrder ?? 99) - (b.data.seriesOrder ?? 99))
: [];
// Series data for sidebar + bottom block
const hasSeries = seriesChildren.length > 0 || seriesSiblings.length > 0;
function formatDate(date: Date) {
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
const ogImageUrl = new URL(`/og/blog/${post.id}.png`, Astro.site).toString();
const postUrl = new URL(`/blog/${post.id}/`, Astro.site).toString();
const postSchema = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.data.title,
description: post.data.description,
datePublished: post.data.publishDate.toISOString(),
image: ogImageUrl,
url: postUrl,
author: {
"@type": "Person",
name: "Adrian Altner",
url: "https://adrian-altner.com",
},
};
---
<BaseLayout
title={post.data.title}
description={post.data.description}
image={{ src: ogImageUrl, alt: post.data.title }}
>
<JsonLd schema={postSchema} slot="head" />
<div class="page">
<div class="main">
<a href="/blog" class="back">← Back to Blog</a>
<div class="content">
<article class="h-entry">
<a
href={postUrl}
class="u-url"
aria-hidden="true"
tabindex="-1"
style="display:none">Permalink</a
>
<span class="p-author h-card" style="display:none"
><img
class="u-photo"
src="https://adrian-altner.com/avatar.jpg"
alt="Adrian Altner"
/><span class="p-name">Adrian Altner</span><a
class="u-url"
href="https://adrian-altner.com">adrian-altner.com</a
></span
>
<header class="post-header">
<h1 class="post-title p-name">{post.data.title}</h1>
<div class="post-meta">
<time
class="dt-published"
datetime={post.data.publishDate.toISOString()}
>
{formatDate(post.data.publishDate)}
</time>
{
post.data.tags.length > 0 && (
<div class="post-tags">
{post.data.tags.map((tag) => (
<a href={`/tags/${tag}`} class="tag">
{capitalize(tag)}
</a>
))}
</div>
)
}
</div>
</header>
{post.data.cover && (
<Image class="post-cover" src={post.data.cover} alt={post.data.coverAlt ?? ""} />
)}
<div class="prose e-content">
<Content />
</div>
{
seriesChildren.length > 0 && (
<BlogPostSeries
children={seriesChildren}
currentId={post.id}
parent={post}
/>
)
}
{
seriesParentPost && seriesSiblings.length > 0 && (
<BlogPostSeries
children={seriesSiblings}
currentId={post.id}
parent={seriesParentPost}
/>
)
}
<WebMentions url={postUrl} />
</article>
{
(tocHeadings.length > 1 || hasSeries) && (
<aside class="toc-sidebar">
{tocHeadings.length > 1 && (
<TableOfContents headings={tocHeadings} />
)}
{seriesChildren.length > 0 && (
<BlogPostSeries
children={seriesChildren}
currentId={post.id}
parent={post}
sidebar={true}
/>
)}
{seriesParentPost && seriesSiblings.length > 0 && (
<BlogPostSeries
children={seriesSiblings}
currentId={post.id}
parent={seriesParentPost}
sidebar={true}
/>
)}
</aside>
)
}
</div>
{
(prevPost || nextPost) && (
<nav class="post-pagination" aria-label="Post navigation">
<div class="pagination-prev">
{nextPost && (
<a href={`/blog/${nextPost.id}`} class="pagination-link">
<span class="pagination-label">← Newer</span>
<span class="pagination-title">{nextPost.title}</span>
</a>
)}
</div>
<div class="pagination-next">
{prevPost && (
<a
href={`/blog/${prevPost.id}`}
class="pagination-link pagination-link--next"
>
<span class="pagination-label">Older →</span>
<span class="pagination-title">{prevPost.title}</span>
</a>
)}
</div>
</nav>
)
}
<a href="/blog" class="back">← Back to Blog</a>
</div>
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #f5f5f3;
color: #111;
}
.main {
max-width: var(--width-content);
margin: 0 auto;
padding: 2rem 2rem 4rem;
width: 100%;
}
.content {
display: grid;
grid-template-columns: 1fr var(--width-sidebar-toc);
gap: 3rem;
align-items: start;
}
.toc-sidebar {
min-width: 0;
position: sticky;
top: 2rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
}
@media (max-width: 900px) {
.content {
grid-template-columns: 1fr;
}
.toc-sidebar {
display: none;
}
}
article {
min-width: 0;
max-width: var(--width-prose);
}
.post-header {
margin-bottom: 2.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #e0e0de;
}
.post-title {
font-weight: 400;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
.post-meta {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.post-meta time {
font-size: 0.875rem;
color: #888;
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.tag {
font-size: 0.75rem;
color: var(--accent);
text-decoration: none;
border: 1px solid var(--accent);
border-radius: 3px;
padding: 0.15rem 0.5rem;
opacity: 0.8;
}
.tag:hover {
opacity: 1;
}
.post-cover {
width: 100%;
height: auto;
border-radius: 6px;
display: block;
margin-bottom: 2rem;
}
.prose {
font-size: 1rem;
line-height: 1.7;
color: #333;
}
.prose :global(h2) {
font-size: var(--text-md);
font-weight: 500;
margin: 2rem 0 0.75rem;
color: #111;
}
.prose :global(h3) {
font-size: var(--text-sm);
font-weight: 500;
margin: 1.5rem 0 0.5rem;
color: #111;
}
.prose :global(p) {
margin-bottom: 1.25rem;
}
.prose :global(a) {
color: var(--accent);
text-underline-offset: 3px;
}
.prose :global(blockquote) {
border-left: 3px solid var(--accent);
margin: 1.5rem 0;
padding: 0.25rem 0 0.25rem 1.25rem;
color: #555;
font-style: italic;
}
.prose :global(code) {
font-family: ui-monospace, monospace;
font-size: 0.875em;
background: #e8e8e6;
padding: 0.1em 0.35em;
border-radius: 3px;
}
.prose :global(pre) {
background: #2b2b2b;
color: #e0e0e0;
padding: 1.25rem;
border-radius: 6px;
overflow-x: auto;
margin: 1.5rem 0;
}
.prose :global(pre code) {
background: none;
padding: 0;
font-size: 0.875rem;
}
.prose :global(table) {
width: 100%;
border-collapse: collapse;
margin: 1.75rem 0;
font-size: 0.95rem;
line-height: 1.6;
}
.prose :global(thead) {
border-bottom: 2px solid #d7d7d2;
}
.prose :global(tbody tr) {
border-bottom: 1px solid #e0e0de;
}
.prose :global(tbody tr:last-child) {
border-bottom: none;
}
.prose :global(th),
.prose :global(td) {
padding: 0.8rem 1rem 0.8rem 0;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
.prose :global(th:last-child),
.prose :global(td:last-child) {
padding-right: 0;
}
.prose :global(th) {
font-weight: 600;
color: #111;
}
.prose :global(ul),
.prose :global(ol) {
padding-left: 1.5rem;
margin-bottom: 1.25rem;
}
.prose :global(li) {
margin-bottom: 0.4rem;
}
.back {
display: inline-block;
font-size: 0.9rem;
color: #555;
text-decoration: none;
border-bottom: 1px solid #ccc;
padding-bottom: 1px;
margin-bottom: 1.5rem;
}
.back:hover {
color: var(--accent);
border-color: var(--accent);
}
.post-pagination {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
max-width: var(--width-prose);
margin: 1rem 0 2rem;
padding-top: 2rem;
border-top: 1px solid #e0e0de;
}
.pagination-next {
text-align: right;
}
.pagination-link {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-decoration: none;
color: inherit;
}
.pagination-link--next {
align-items: flex-end;
}
.pagination-label {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--accent);
}
.pagination-title {
font-size: 0.9rem;
color: #333;
line-height: 1.4;
}
.pagination-link:hover .pagination-title {
color: var(--accent);
}
</style>

View file

@ -0,0 +1,142 @@
---
import { Image } from "astro:assets";
import { getCollection, render } from "astro:content";
import BlogPostItem from "@/components/BlogPostItem.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
export async function getStaticPaths() {
const categories = await getCollection("categories");
const allPosts = await getCollection("blog", ({ data }) => !data.draft);
return categories.map((category) => ({
params: { slug: category.id },
props: {
category,
posts: allPosts
.filter((p) => p.data.category?.id === category.id)
.sort(
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf(),
),
},
}));
}
const { category, posts } = Astro.props;
const { Content } = await render(category);
const allPosts = await getCollection("blog", ({ data }) => !data.draft);
const seriesCountMap = new Map<string, number>();
for (const post of allPosts) {
if (post.data.seriesParent) {
seriesCountMap.set(
post.data.seriesParent,
(seriesCountMap.get(post.data.seriesParent) ?? 1) + 1,
);
}
}
---
<BaseLayout title={category.data.title} description={category.data.description}>
<div class="page">
<main class="main">
<a href="/blog" class="back">← Blog</a>
<p class="eyebrow">Category</p>
<h1 class="heading">{category.data.title}</h1>
{category.data.cover && (
<Image class="category-cover" src={category.data.cover} alt={category.data.coverAlt ?? ""} />
)}
<div class="category-intro">
<Content />
</div>
<p class="count">{posts.length} article{posts.length !== 1 ? "s" : ""}</p>
<ul class="list">
{posts.map((post) => (
<BlogPostItem post={post} seriesCountMap={seriesCountMap} />
))}
</ul>
</main>
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #f5f5f3;
color: #111;
}
.main {
max-width: var(--width-prose);
margin: 0 auto;
padding: 2rem 2rem 4rem;
width: 100%;
}
.back {
display: inline-block;
font-size: 0.9rem;
color: #555;
text-decoration: none;
border-bottom: 1px solid #ccc;
padding-bottom: 1px;
margin-bottom: 1.5rem;
}
.back:hover {
color: var(--accent);
border-color: var(--accent);
}
.eyebrow {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.4rem;
}
.heading {
font-weight: 400;
letter-spacing: -0.02em;
margin-bottom: 1rem;
}
.category-cover {
width: 100%;
height: auto;
border-radius: 6px;
display: block;
margin-bottom: 1.5rem;
}
.category-intro {
font-size: 0.95rem;
line-height: 1.6;
color: #555;
margin-bottom: 0.5rem;
}
.count {
font-size: 0.875rem;
color: #888;
margin-bottom: 2.5rem;
}
.list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
@media (max-width: 640px) {
.item__link {
flex-direction: column;
gap: 0.2rem;
}
}
</style>

283
src/pages/blog/index.astro Normal file
View file

@ -0,0 +1,283 @@
---
import { getCollection } from "astro:content";
import BlogPostItem from "@/components/BlogPostItem.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
const articles = (await getCollection("blog", ({ data }) => !data.draft)).sort(
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf(),
);
const allCategories = await getCollection("categories");
const categoryCounts = new Map<string, number>();
for (const post of articles) {
if (post.data.category) {
categoryCounts.set(
post.data.category.id,
(categoryCounts.get(post.data.category.id) ?? 0) + 1,
);
}
}
const categories = allCategories
.map((cat) => ({ ...cat, count: categoryCounts.get(cat.id) ?? 0 }))
.sort(
(a, b) =>
a.data.order - b.data.order || a.data.title.localeCompare(b.data.title),
);
const categoryTitleMap = new Map(
allCategories.map((c) => [c.id, c.data.title]),
);
const seriesCountMap = new Map<string, number>();
for (const post of articles) {
if (post.data.seriesParent) {
seriesCountMap.set(
post.data.seriesParent,
(seriesCountMap.get(post.data.seriesParent) ?? 1) + 1,
);
}
}
// Aggregate tags with counts
function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
const tagCounts = new Map<string, number>();
for (const post of articles) {
for (const tag of post.data.tags) {
tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
}
}
const tags = [...tagCounts.entries()]
.sort((a, b) => b[1] - a[1])
.map(([id, count]) => ({ id, title: capitalize(id), count }));
---
<BaseLayout title="Blog" description="Blog by Adrian Altner.">
<div class="page">
<div class="main">
<a href="/" class="back">← Home</a>
<div class="content">
<main>
<h1 class="heading">Blog</h1>
<ul class="list">
{
articles.map((post) => (
<BlogPostItem
post={post}
categoryTitleMap={categoryTitleMap}
seriesCountMap={seriesCountMap}
/>
))
}
</ul>
</main>
<aside class="sidebar">
{
categories.length > 0 && (
<section class="sidebar__section">
<h2 class="sidebar__heading">Categories</h2>
<ul class="sidebar__list">
{categories.map((cat) => (
<li>
<a
href={`/blog/category/${cat.id}`}
class="sidebar__link"
>
{cat.data.title}
<span class="sidebar__tag-count">{cat.count}</span>
</a>
</li>
))}
</ul>
</section>
)
}
{
tags.length > 0 && (
<section class="sidebar__section">
<h2 class="sidebar__heading">Tags</h2>
<div class="sidebar__tags">
{tags.map(({ id, title, count }) => (
<a href={`/tags/${id}`} class="sidebar__tag">
{title}
<span class="sidebar__tag-count">{count}</span>
</a>
))}
</div>
<a href="/tags" class="sidebar__link-all">
View All
</a>
</section>
)
}
</aside>
</div>
</div>
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #f5f5f3;
color: #111;
}
.main {
max-width: var(--width-content);
margin: 0 auto;
padding: 2rem 2rem 4rem;
width: 100%;
}
.content {
display: grid;
grid-template-columns: 1fr var(--width-sidebar);
gap: 4rem;
align-items: start;
}
.back {
display: inline-block;
font-size: 0.9rem;
color: #555;
text-decoration: none;
border-bottom: 1px solid #ccc;
padding-bottom: 1px;
margin-bottom: 1.5rem;
}
.back:hover {
color: var(--accent);
border-color: var(--accent);
}
/* ── Post list ── */
.heading {
font-weight: 400;
letter-spacing: -0.02em;
margin-bottom: 2.5rem;
}
.list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 2rem;
}
/* ── Sidebar ── */
.sidebar {
position: sticky;
top: 2rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.sidebar__section {
border-top: 2px solid #111;
padding-top: 0.75rem;
}
.sidebar__heading {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #888;
margin-bottom: 0.75rem;
}
.sidebar__list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.sidebar__link {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
color: #333;
text-decoration: none;
}
.sidebar__link-all {
display: block;
font-size: 0.9rem;
color: #333;
text-decoration: none;
margin-top: 1rem;
}
.sidebar__tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.sidebar__tag {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: #444;
text-decoration: none;
background: #e8e8e6;
border-radius: 3px;
padding: 0.2rem 0.5rem;
transition:
background 0.15s,
color 0.15s;
}
.sidebar__tag:hover {
background: var(--accent);
color: #fff;
}
.sidebar__tag-count {
font-size: 0.7rem;
color: #888;
}
.sidebar__tag:hover .sidebar__tag-count {
color: rgba(255, 255, 255, 0.75);
}
/* ── Mobile ── */
@media (max-width: 768px) {
.content {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
flex-direction: row;
flex-wrap: wrap;
gap: 1.5rem;
border-top: 1px solid #e0e0de;
padding-top: 2rem;
}
.sidebar__section {
flex: 1;
min-width: 140px;
}
.item__link {
flex-direction: column;
gap: 0.2rem;
}
}
</style>

221
src/pages/contact.astro Normal file
View file

@ -0,0 +1,221 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro";
---
<BaseLayout title="Contact" description="Get in touch with Adrian Altner.">
<div class="page">
<main class="main">
<a href="/" class="back">← Home</a>
<div class="content">
<h1>Contact</h1>
<h3>Notes &amp; Feedback</h3>
<p>Feedback and corrections are always welcome. I read everything, but rarely reply — please don't be discouraged if you don't hear back.</p>
<hr />
<h2>How to reach me</h2>
<div class="cards">
<div class="card">
<div class="card__icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
</svg>
</div>
<div class="card__body">
<p class="card__label">Email</p>
<a class="card__value r" data-obf="bW9je3RvZH1yZW50bGEtbmFpcmRhe3RhfXllaA==" data-obf-href="bWFpbHRvOmhleUBhZHJpYW4tYWx0bmVyLmNvbQ=="></a>
</div>
</div>
<div class="card card--placeholder">
<div class="card__icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.15 12a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.05 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 21 17z"></path>
</svg>
</div>
<div class="card__body">
<p class="card__label">Signal</p>
<p class="card__value card__value--muted">Coming soon.</p>
</div>
</div>
</div>
<hr />
<div class="note">
<strong>Please note:</strong> Unsolicited press releases, advertising, and SEO pitches will be deleted without a response.
</div>
</div>
</main>
</div>
</BaseLayout>
<script>
document.querySelectorAll('[data-obf]').forEach(el => {
el.textContent = atob((el as HTMLElement).dataset.obf!);
});
document.querySelectorAll('[data-obf-href]').forEach(el => {
(el as HTMLAnchorElement).href = atob((el as HTMLElement).dataset.obfHref!);
});
</script>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #f5f5f3;
color: #111;
}
.main {
max-width: var(--width-prose);
margin: 0 auto;
padding: 2rem 2rem 4rem;
width: 100%;
}
.back {
display: inline-block;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: #555;
text-decoration: none;
}
.back:hover {
color: var(--accent);
}
.content {
font-size: 1rem;
line-height: 1.75;
color: #333;
}
h1 {
font-size: 1.75rem;
font-weight: 400;
letter-spacing: -0.02em;
margin-bottom: 2rem;
color: #111;
}
h2 {
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #888;
margin: 2rem 0 1rem;
padding-bottom: 0.4rem;
border-bottom: 2px solid #111;
}
h3 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.5rem;
color: #111;
}
p {
margin-bottom: 1.25rem;
}
hr {
border: none;
border-top: 1px solid #ddd;
margin: 2rem 0;
}
.cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem 1.25rem;
background: #fff;
border: 1px solid #e5e5e3;
border-radius: 8px;
}
.card--placeholder {
opacity: 0.5;
}
.card__icon {
flex-shrink: 0;
margin-top: 0.2rem;
color: #555;
}
.card__body {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.card__label {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #888;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card__value {
font-size: clamp(0.8rem, 3.5vw, 1rem);
color: var(--accent);
text-underline-offset: 3px;
margin: 0;
white-space: nowrap;
}
.card__value--muted {
color: #888;
font-style: italic;
}
.badge {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
background: #eeeeed;
color: #888;
padding: 0.15rem 0.45rem;
border-radius: 999px;
}
.r {
direction: rtl;
unicode-bidi: bidi-override;
}
.note {
font-size: 0.9rem;
color: #555;
background: #eeeeed;
padding: 0.9rem 1.25rem;
border-radius: 6px;
line-height: 1.6;
}
.note strong {
color: #111;
}
</style>

38
src/pages/imprint.md Normal file
View file

@ -0,0 +1,38 @@
---
layout: ../layouts/ProseLayout.astro
title: Imprint
description: Legal notice for adrian-altner.com.
---
# Imprint
## Information pursuant to Section 5 DDG
<span class="r" data-obf="cmVudGxBIG5haXJkQQ=="></span><br>
<span class="r" data-obf="MTMgLnJ0Uy1kcmFobm9lTC1mbG9kdVI="></span><br>
<span class="r" data-obf="bmVkc2VyRCA3OTAxMA=="></span><br>
<span class="r" data-obf="eW5hbXJlRw=="></span>
**Phone:** <a class="r" data-obf="MDI0MDM1ODcgNjUxIDk0Kw==" data-obf-href="dGVsOis0OTE1Njc4NTMwNDIw"></a>
**Email:** <a class="r" data-obf="bW9je3RvZH1yZW50bGEtbmFpcmRhe3RhfXllaA==" data-obf-href="bWFpbHRvOmhleUBhZHJpYW4tYWx0bmVyLmNvbQ=="></a>
---
## Responsible for Editorial Content pursuant to Section 18 para. 2 MStV
<span class="r" data-obf="cmVudGxBIG5haXJkQQ=="></span><br>
<span class="r" data-obf="MTMgLnJ0Uy1kcmFobm9lTC1mbG9kdVI="></span><br>
<span class="r" data-obf="bmVkc2VyRCA3OTAxMA=="></span><br>
<span class="r" data-obf="eW5hbXJlRw=="></span>
---
## Content License
Unless otherwise noted, content published on this website is licensed under the [Creative Commons Attribution-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-sa/4.0/) license.
---
## Privacy
Information on the processing of personal data can be found in the [Privacy Policy](/privacy-policy).

447
src/pages/index.astro Normal file
View file

@ -0,0 +1,447 @@
---
import { Image } from "astro:assets";
import { getCollection } from "astro:content";
import type { ImageMetadata } from "astro";
import profilePicture from "@/assets/images/intro.jpg";
import BaseLayout from "@/layouts/BaseLayout.astro";
import {
buildCollectionPhotos,
collectionSlug,
type PhotoSidecar,
} from "@/lib/collections";
const articles = (await getCollection("blog", ({ data }) => !data.draft))
.sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf())
.slice(0, 3);
const notes = (await getCollection("notes", ({ data }) => !data.draft))
.sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf())
.slice(0, 3);
const allCollections = await getCollection(
"collections_photos",
({ data }) => !data.draft,
);
const sidecars = import.meta.glob<PhotoSidecar>(
"/src/content/photos/collections/**/*.json",
{ eager: true },
);
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/photos/collections/**/*.jpg",
{ eager: true },
);
const photoEntries = allCollections
.flatMap((col) => {
const slug = collectionSlug(col);
return buildCollectionPhotos(sidecars, imageModules, slug).map((p) => ({
image: p.image,
href: `/photos/collections/${slug}/${p.sidecar.id}`,
date: p.sidecar.date,
}));
})
.sort((a, b) => a.date.localeCompare(b.date))
.slice(-6);
function formatDate(date: Date) {
return date.toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
---
<BaseLayout
title="Adrian Altner"
description="Longtime web enthusiast with a strong interest in technology who enjoys photography, travel, and learning new things in his spare time."
navDesktopHidden={true}
>
<div class="page">
<!-- Hero -->
<main class="hero">
<div class="hero__card h-card">
<div class="hero__photo-wrap">
<Image
src={profilePicture}
alt="Adrian Altner"
class="hero__photo u-photo"
width={200}
height={200}
/>
</div>
<div class="hero__text">
<h1 class="hero__name">
<a href="https://adrian-altner.com" class="u-url p-name"
>Adrian Altner</a>
</h1>
<p class="hero__intro p-note">
I'm a longtime <strong>web enthusiast</strong> with a strong interest
in
<strong>technology</strong>. In my spare time, I enjoy photography,
traveling, and learning new things on my own.
</p>
</div>
</div>
<div class="hero__divider" aria-hidden="true"></div>
<nav class="hero__nav" aria-label="Main navigation">
<a href="/blog">Blog</a>
<a href="/photos">Photos</a>
<a href="/notes">Notes</a>
<a href="/links">Links</a>
<a href="/archives">Archives</a>
<a href="/about">About</a>
</nav>
</main>
<!-- Content columns -->
<section class="content-grid">
<div class="col">
<h2 class="col__heading"><a href="/blog">Blog</a></h2>
<ul class="preview-list">
{
articles.map((a) => (
<li class="preview-item">
<time
class="preview-item__date"
datetime={a.data.publishDate.toISOString()}
>
{formatDate(a.data.publishDate)}
</time>
<a href={`/blog/${a.id}`} class="preview-item__title">
{a.data.title}
</a>
<p class="preview-item__desc">{a.data.description}</p>
</li>
))
}
</ul>
</div>
<div class="col">
<h2 class="col__heading"><a href="/notes">Notes</a></h2>
<ul class="preview-list">
{
notes.map((n) => (
<li class="preview-item">
<time
class="preview-item__date"
datetime={n.data.publishDate.toISOString()}
>
{formatDate(n.data.publishDate)}
</time>
<a href={`/notes/${n.id}`} class="preview-item__title">
{n.data.title}
</a>
{n.data.description && (
<p class="preview-item__desc">{n.data.description}</p>
)}
</li>
))
}
</ul>
</div>
</section>
<!-- Photo stream -->
{
photoEntries.length > 0 && (
<section class="photo-stream">
<h2 class="photo-stream__heading">
Last photos from <a href="/photos/collections">my collections</a>
</h2>
{photoEntries.map((entry) => (
<a href={entry.href} class="photo-stream__item">
<Image
src={entry.image}
alt=""
class="photo-stream__img"
width={400}
height={300}
/>
</a>
))}
</section>
)
}
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-height: 100dvh;
color: #111;
}
/* ── Hero ── */
.hero {
max-width: var(--width-hero);
margin: 0 auto;
width: 100%;
padding: 5rem 2rem 6rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0;
}
.hero__divider {
width: 1px;
align-self: stretch;
background: #e0e0de;
margin: 0 2rem;
flex-shrink: 0;
}
.hero__card {
display: flex;
align-items: center;
max-width: 60ch;
gap: 1.5rem;
}
.hero__nav {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.4rem 0;
flex-shrink: 0;
}
.hero__nav a {
font-size: var(--text-xs);
font-weight: 500;
color: #111;
text-decoration: none;
padding: 0.3rem 0.75rem;
border-radius: 4px;
text-align: left;
transition: background 0.15s;
}
.hero__nav a:hover {
background: #e8e8e6;
}
.hero__photo-wrap {
width: 160px;
height: 160px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.hero__photo {
width: 156px;
height: 156px;
object-fit: cover;
transform: scale(1.25);
}
.hero__name a {
color: inherit;
text-decoration: none;
}
.hero__name {
font-size: var(--text-xl);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.1;
margin-bottom: 0.5rem;
}
.hero__intro {
font-size: var(--text-xs);
line-height: 1.35;
color: #444;
max-width: 46ch;
}
.hero__intro strong {
font-weight: 700;
color: #111;
}
/* ── Content grid ── */
.content-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
max-width: var(--width-hero);
margin: 0 auto;
padding: 0 2rem 3rem;
width: 100%;
}
.col__heading {
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #888;
margin-bottom: 1rem;
}
.col__heading a {
color: inherit;
text-decoration: none;
}
.col__heading a:hover {
color: #111;
}
.preview-list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.preview-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.75rem 0;
border-bottom: 1px solid #e0e0de;
}
.preview-item:first-child {
border-top: 1px solid #e0e0de;
}
.preview-item__date {
color: #999;
}
.preview-item__title {
font-size: var(--text-xs);
font-weight: 500;
color: #111;
text-decoration: none;
line-height: 1.3;
}
.preview-item__title:hover {
color: var(--accent);
}
.preview-item__desc {
color: #666;
line-height: 1.4;
margin: 0;
}
/* ── Photo stream ── */
.photo-stream {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
max-width: var(--width-hero);
margin: 0 auto;
padding: 0 2rem 4rem;
width: 100%;
}
.photo-stream__heading {
grid-column: 1 / -1;
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #888;
margin-bottom: 0.25rem;
}
.photo-stream__heading a {
color: inherit;
text-decoration: underline;
}
.photo-stream__heading a:hover {
color: var(--accent);
}
.photo-stream__item {
display: block;
aspect-ratio: 4 / 3;
overflow: hidden;
cursor: pointer;
}
.photo-stream__item:hover .photo-stream__img {
transform: scale(1.04);
transition: transform 0.3s ease;
}
.photo-stream__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
/* ── Mobile breakpoint ── */
@media (max-width: 640px) {
.hero {
flex-direction: column;
align-items: flex-start;
padding-top: 2rem;
padding-bottom: 3rem;
}
/* flex-wrap: photo + name centered in row 1, intro wraps to row 2 */
.hero__card {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.25rem 1rem;
width: 100%;
}
.hero__text {
display: contents;
}
.hero__photo-wrap {
width: 96px;
height: 96px;
flex-shrink: 0;
}
.hero__photo {
width: 92px;
height: 92px;
}
.hero__name {
min-width: 0;
overflow-wrap: break-word;
margin-bottom: 0;
}
.hero__intro {
flex-basis: 100%;
margin-top: 0.75rem;
text-align: center;
}
.hero__nav {
display: none;
}
.content-grid {
grid-template-columns: 1fr;
gap: 2rem;
}
.photo-stream {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View file

@ -0,0 +1,90 @@
---
import LinkCard from "@/components/links/LinkCard.astro";
import LinksLayout from "@/components/links/LinksLayout.astro";
import { getLinks } from "@/lib/collections";
import {
collectionColor,
filterByCollection,
getUniqueCollections,
} from "@/lib/links";
export async function getStaticPaths() {
const links = await getLinks();
const collections = getUniqueCollections(links);
return collections.map((col) => ({
params: { collection: col.toLowerCase() },
props: { col, links },
}));
}
const { col, links } = Astro.props;
const colLinks = filterByCollection(links, col);
const color = collectionColor(col);
---
<LinksLayout title={col} description={`Links in der Collection "${col}".`} {links}>
<div class="page">
<header class="page__header">
<span class="page__folder" style={`background: ${color}`} aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 24 24" fill="white" stroke="none">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</span>
<h1 class="page__title">{col}</h1>
<span class="page__count">{colLinks.length}</span>
</header>
{colLinks.length === 0 ? (
<p class="empty">Keine Links in dieser Collection.</p>
) : (
<ul class="link-list">
{colLinks.map((link) => <LinkCard {link} />)}
</ul>
)}
</div>
</LinksLayout>
<style>
.page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page__header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.page__folder {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.page__title {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: -0.02em;
color: #111;
}
.page__count {
font-size: 0.875rem;
color: #999;
}
.empty {
color: #888;
font-size: 0.95rem;
}
.link-list {
margin: 0;
padding: 0;
}
</style>

View file

@ -0,0 +1,118 @@
---
import LinksLayout from "@/components/links/LinksLayout.astro";
import { getLinks } from "@/lib/collections";
import {
collectionColor,
filterByCollection,
getUniqueCollections,
} from "@/lib/links";
const links = await getLinks();
const collections = getUniqueCollections(links).sort((a, b) =>
a.localeCompare(b),
);
---
<LinksLayout title="Collections" description="Alle Link-Collections." {links}>
<div class="page">
<header class="page__header">
<h1 class="page__title">Collections</h1>
<span class="page__count">{collections.length}</span>
</header>
<ul class="col-list">
{collections.map((col) => (
<li>
<a href={`/links/collection/${encodeURIComponent(col.toLowerCase())}`} class="col-item">
<div class="col-item__left">
<span class="col-item__folder" style={`background: ${collectionColor(col)}`} aria-hidden="true">
<svg width="10" height="10" viewBox="0 0 24 24" fill="white" stroke="none">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</span>
<span class="col-item__name">{col}</span>
</div>
<span class="col-item__count">{filterByCollection(links, col).length}</span>
</a>
</li>
))}
</ul>
</div>
</LinksLayout>
<style>
.page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page__header {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.page__title {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: -0.02em;
color: #111;
}
.page__count {
font-size: 0.875rem;
color: #999;
}
.col-list {
list-style: none;
margin: 0;
padding: 0;
}
.col-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0;
border-bottom: 1px solid #e8e8e6;
text-decoration: none;
color: inherit;
}
.col-list li:last-child .col-item {
border-bottom: none;
}
.col-item:hover .col-item__name {
color: var(--accent);
}
.col-item__left {
display: flex;
align-items: center;
gap: 0.6rem;
}
.col-item__folder {
width: 20px;
height: 20px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.col-item__name {
font-size: 0.9rem;
color: #444;
transition: color 0.1s;
}
.col-item__count {
font-size: 0.75rem;
color: #bbb;
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -0,0 +1,50 @@
---
import LinkCard from "@/components/links/LinkCard.astro";
import LinksLayout from "@/components/links/LinksLayout.astro";
import { getLinks } from "@/lib/collections";
const links = await getLinks();
---
<LinksLayout title="All Links" description="Alle kuratierten Links von Adrian Altner." {links}>
<div class="page">
<header class="page__header">
<h1 class="page__title">All Links</h1>
<span class="page__count">{links.length}</span>
</header>
<ul class="link-list">
{links.map((link) => <LinkCard {link} />)}
</ul>
</div>
</LinksLayout>
<style>
.page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page__header {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.page__title {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: -0.02em;
color: #111;
}
.page__count {
font-size: 0.875rem;
color: #999;
}
.link-list {
margin: 0;
padding: 0;
}
</style>

View file

@ -0,0 +1,78 @@
---
import LinkCard from "@/components/links/LinkCard.astro";
import LinksLayout from "@/components/links/LinksLayout.astro";
import { getLinks } from "@/lib/collections";
import { filterByTag, getUniqueTags } from "@/lib/links";
export async function getStaticPaths() {
const links = await getLinks();
const tags = getUniqueTags(links);
return tags.map((tag) => ({
params: { tag: tag.toLowerCase() },
props: { tag, links },
}));
}
const { tag, links } = Astro.props;
const tagLinks = filterByTag(links, tag);
---
<LinksLayout title={`#${tag}`} description={`Links mit Tag "${tag}".`} {links}>
<div class="page">
<header class="page__header">
<span class="page__hash" aria-hidden="true">#</span>
<h1 class="page__title">{tag}</h1>
<span class="page__count">{tagLinks.length}</span>
</header>
{tagLinks.length === 0 ? (
<p class="empty">Keine Links mit diesem Tag.</p>
) : (
<ul class="link-list">
{tagLinks.map((link) => <LinkCard {link} />)}
</ul>
)}
</div>
</LinksLayout>
<style>
.page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page__header {
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.page__hash {
font-size: 1.4rem;
font-weight: 600;
color: #aaa;
}
.page__title {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: -0.02em;
color: #111;
}
.page__count {
font-size: 0.875rem;
color: #999;
margin-left: 0.4rem;
}
.empty {
color: #888;
font-size: 0.95rem;
}
.link-list {
margin: 0;
padding: 0;
}
</style>

View file

@ -0,0 +1,89 @@
---
import LinksLayout from "@/components/links/LinksLayout.astro";
import { getLinks } from "@/lib/collections";
import { filterByTag, getUniqueTags } from "@/lib/links";
const links = await getLinks();
const tags = getUniqueTags(links).sort((a, b) => a.localeCompare(b));
---
<LinksLayout title="Tags" description="Alle Link-Tags." {links}>
<div class="page">
<header class="page__header">
<h1 class="page__title">Tags</h1>
<span class="page__count">{tags.length}</span>
</header>
<ul class="tag-list">
{tags.map((tag) => (
<li>
<a href={`/links/tag/${encodeURIComponent(tag.toLowerCase())}`} class="tag-item">
<span class="tag-item__name">#{tag}</span>
<span class="tag-item__count">{filterByTag(links, tag).length}</span>
</a>
</li>
))}
</ul>
</div>
</LinksLayout>
<style>
.page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page__header {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.page__title {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: -0.02em;
color: #111;
}
.page__count {
font-size: 0.875rem;
color: #999;
}
.tag-list {
list-style: none;
margin: 0;
padding: 0;
}
.tag-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0;
border-bottom: 1px solid #e8e8e6;
text-decoration: none;
color: inherit;
}
.tag-list li:last-child .tag-item {
border-bottom: none;
}
.tag-item:hover .tag-item__name {
color: var(--accent);
}
.tag-item__name {
font-size: 0.9rem;
color: #444;
transition: color 0.1s;
}
.tag-item__count {
font-size: 0.75rem;
color: #bbb;
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -0,0 +1,229 @@
---
import { Image } from "astro:assets";
import { getCollection, render } from "astro:content";
import WebMentions from "@/components/WebMentions.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
export async function getStaticPaths() {
const notes = await getCollection("notes", ({ data }) => !data.draft);
return notes.map((note) => ({
params: { slug: note.id },
props: { note },
}));
}
const { note } = Astro.props;
const { Content } = await render(note);
const noteUrl = new URL(`/notes/${note.id}/`, Astro.site).toString();
const ogImageUrl = new URL(`/og/notes/${note.id}.png`, Astro.site).toString();
function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function formatDate(date: Date) {
return date.toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
});
}
---
<BaseLayout
title={note.data.title}
description={note.data.description ?? note.data.title}
image={{ src: ogImageUrl, alt: note.data.title }}
>
<div class="page">
<main class="main">
<a href="/notes" class="back">← Notes</a>
<article class="h-entry">
<a href={noteUrl} class="u-url" aria-hidden="true" tabindex="-1" style="display:none">Permalink</a>
<span class="p-author h-card" style="display:none"><img class="u-photo" src="https://adrian-altner.com/avatar.jpg" alt="Adrian Altner" /><span class="p-name">Adrian Altner</span><a class="u-url" href="https://adrian-altner.com">adrian-altner.com</a></span>
<header class="note-header">
<h1 class="note-title p-name">{note.data.title}</h1>
<time
class="note-date dt-published"
datetime={note.data.publishDate.toISOString()}
>
{formatDate(note.data.publishDate)}
</time>
{
note.data.tags.length > 0 && (
<div class="note-tags">
{note.data.tags.map((tag) => (
<a href={`/tags/${tag}`} class="tag">
{capitalize(tag)}
</a>
))}
</div>
)
}
</header>
{note.data.cover && (
<Image class="note-cover" src={note.data.cover} alt={note.data.coverAlt ?? ""} />
)}
<div class="prose e-content">
<Content />
</div>
<WebMentions url={noteUrl} />
</article>
<a href="/notes" class="back">← Notes</a>
</main>
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #f5f5f3;
color: #111;
}
.main {
max-width: var(--width-prose);
margin: 0 auto;
padding: 2rem 2rem 4rem;
width: 100%;
}
.note-header {
margin-bottom: 2rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid #e0e0de;
}
.note-title {
font-size: clamp(1.5rem, 4vw, 2.25rem);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.2;
margin-bottom: 0.5rem;
}
.note-date {
font-size: 0.875rem;
color: #888;
}
.note-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.75rem;
}
.tag {
font-size: 0.75rem;
color: #666;
background: #e8e8e6;
padding: 0.15em 0.55em;
border-radius: 3px;
text-decoration: none;
}
.tag:hover {
background: var(--accent);
color: #fff;
}
.note-cover {
width: 100%;
height: auto;
border-radius: 6px;
display: block;
margin-bottom: 2rem;
}
.prose {
font-size: 1rem;
line-height: 1.7;
color: #333;
}
.prose :global(h2) {
font-size: 1.4rem;
font-weight: 500;
margin: 2rem 0 0.75rem;
color: #111;
}
.prose :global(h3) {
font-size: 1.15rem;
font-weight: 500;
margin: 1.5rem 0 0.5rem;
color: #111;
}
.prose :global(p) {
margin-bottom: 1.25rem;
}
.prose :global(a) {
color: var(--accent);
text-underline-offset: 3px;
}
.prose :global(blockquote) {
border-left: 3px solid var(--accent);
margin: 1.5rem 0;
padding: 0.25rem 0 0.25rem 1.25rem;
color: #555;
font-style: italic;
}
.prose :global(code) {
font-family: ui-monospace, monospace;
font-size: 0.875em;
background: #e8e8e6;
padding: 0.1em 0.35em;
border-radius: 3px;
}
.prose :global(pre) {
background: #2b2b2b;
color: #e0e0e0;
padding: 1.25rem;
border-radius: 6px;
overflow-x: auto;
margin: 1.5rem 0;
}
.prose :global(pre code) {
background: none;
padding: 0;
font-size: 0.875rem;
}
.prose :global(ul),
.prose :global(ol) {
padding-left: 1.5rem;
margin-bottom: 1.25rem;
}
.prose :global(li) {
margin-bottom: 0.4rem;
}
.back {
display: inline-block;
font-size: 0.9rem;
color: #555;
text-decoration: none;
border-bottom: 1px solid #ccc;
padding-bottom: 1px;
margin-bottom: 1.5rem;
}
article + .back {
margin-bottom: 0;
margin-top: 1rem;
}
.back:hover {
color: var(--accent);
border-color: var(--accent);
}
</style>

275
src/pages/notes/index.astro Normal file
View file

@ -0,0 +1,275 @@
---
import { Image } from "astro:assets";
import { getCollection, render } from "astro:content";
import BaseLayout from "@/layouts/BaseLayout.astro";
const notes = (await getCollection("notes", ({ data }) => !data.draft)).sort(
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf(),
);
function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
const renderedNotes = await Promise.all(
notes.map(async (note) => ({ note, Content: (await render(note)).Content })),
);
function formatDate(date: Date) {
return date.toLocaleDateString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
---
<BaseLayout title="Notes" description="Kurze Notizen von Adrian Altner.">
<div class="page">
<main class="main">
<a href="/" class="back">← Home</a>
<h1 class="heading">Notes</h1>
{
renderedNotes.length === 0 ? (
<p class="empty">Noch keine Notizen vorhanden.</p>
) : (
<ul class="list">
{renderedNotes.map(({ note, Content }) => (
<li class="item">
<time
class="item__date"
datetime={note.data.publishDate.toISOString()}
>
{formatDate(note.data.publishDate)}
</time>
<a href={`/notes/${note.id}`} class="item__title">
{note.data.title}
</a>
{note.data.cover && (
<a href={`/notes/${note.id}`} class="item__cover">
<Image
src={note.data.cover}
alt={note.data.coverAlt ?? ""}
/>
</a>
)}
<div class="item__body prose">
<Content />
</div>
{note.data.tags.length > 0 && (
<div class="item__tags">
{note.data.tags.map((tag) => (
<a href={`/tags/${tag}`} class="tag">
{capitalize(tag)}
</a>
))}
</div>
)}
</li>
))}
</ul>
)
}
</main>
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #f5f5f3;
color: #111;
}
.main {
max-width: var(--width-prose);
margin: 0 auto;
padding: 2rem 2rem 4rem;
width: 100%;
}
.heading {
font-size: 1.75rem;
font-weight: 400;
letter-spacing: -0.02em;
margin-bottom: 2.5rem;
}
.empty {
color: #888;
font-size: 0.95rem;
}
.back {
display: inline-block;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: #555;
text-decoration: none;
}
.back:hover {
color: var(--accent);
}
.list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
}
.item {
display: grid;
grid-template-columns: 7rem 1fr;
gap: 0.25rem 1.25rem;
padding: 1rem 0;
border-bottom: 1px solid #e0e0de;
align-items: baseline;
}
.item:first-child {
border-top: 1px solid #e0e0de;
}
.item__date {
font-size: 0.8rem;
color: #888;
white-space: nowrap;
padding-top: 0.15rem;
}
.item__title {
font-size: 1rem;
font-weight: 500;
color: #111;
text-decoration: none;
}
.item__title:hover {
color: var(--accent);
}
.item__cover {
grid-column: 1 / -1;
margin-top: 0.75rem;
display: block;
}
.item__cover img {
width: 100%;
height: auto;
border-radius: 4px;
display: block;
}
.item__body {
grid-column: 1 / -1;
margin-top: 0.75rem;
}
.prose {
font-size: 1rem;
line-height: 1.7;
color: #333;
}
.prose :global(h2) {
font-size: 1.4rem;
font-weight: 500;
margin: 2rem 0 0.75rem;
color: #111;
}
.prose :global(h3) {
font-size: 1.15rem;
font-weight: 500;
margin: 1.5rem 0 0.5rem;
color: #111;
}
.prose :global(p) {
margin-bottom: 1.25rem;
}
.prose :global(p:last-child) {
margin-bottom: 0;
}
.prose :global(a) {
color: var(--accent);
text-underline-offset: 3px;
}
.prose :global(blockquote) {
border-left: 3px solid var(--accent);
margin: 1.5rem 0;
padding: 0.25rem 0 0.25rem 1.25rem;
color: #555;
font-style: italic;
}
.prose :global(code) {
font-family: ui-monospace, monospace;
font-size: 0.875em;
background: #e8e8e6;
padding: 0.1em 0.35em;
border-radius: 3px;
}
.prose :global(pre) {
background: #2b2b2b;
color: #e0e0e0;
padding: 1.25rem;
border-radius: 6px;
overflow-x: auto;
margin: 1.5rem 0;
}
.prose :global(pre code) {
background: none;
padding: 0;
font-size: 0.875rem;
}
.prose :global(ul),
.prose :global(ol) {
padding-left: 1.5rem;
margin-bottom: 1.25rem;
}
.prose :global(li) {
margin-bottom: 0.4rem;
}
.item__tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 2rem;
grid-column: 1 / -1;
}
.tag {
font-size: 0.75rem;
color: #666;
background: #e8e8e6;
padding: 0.15em 0.55em;
border-radius: 3px;
text-decoration: none;
}
.tag:hover {
background: var(--accent);
color: #fff;
}
@media (max-width: 480px) {
.item {
grid-template-columns: 1fr;
gap: 0.2rem;
}
}
</style>

View file

@ -0,0 +1,23 @@
import { getCollection } from "astro:content";
import { buildArticleVNode, renderOgImage } from "@/lib/og";
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { title: post.data.title, description: post.data.description },
}));
}
export async function GET({
props,
}: {
props: { title: string; description: string };
}) {
const buffer = await renderOgImage(
buildArticleVNode({ title: props.title, description: props.description }),
);
return new Response(buffer, {
headers: { "Content-Type": "image/png" },
});
}

View file

@ -0,0 +1,32 @@
import { getCollection } from "astro:content";
import { join } from "node:path";
import type { APIContext, ImageMetadata } from "astro";
import { buildNoteVNode, imageToOgDataUri, renderOgImage } from "@/lib/og";
export async function getStaticPaths() {
const notes = await getCollection("notes", ({ data }) => !data.draft);
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/notes/**/*.{jpg,jpeg,png,webp}",
{ eager: true },
);
return notes.map((note) => {
let coverPath: string | null = null;
if (note.data.cover) {
const entry = Object.entries(imageModules).find(
([, mod]) => mod.default.src === note.data.cover?.src,
);
coverPath = entry?.[0] ?? null;
}
return { params: { slug: note.id }, props: { coverPath } };
});
}
export async function GET({ props }: APIContext) {
const coverDataUri = props.coverPath
? await imageToOgDataUri(join(process.cwd(), props.coverPath))
: undefined;
const buffer = await renderOgImage(buildNoteVNode({ coverDataUri }));
return new Response(buffer, { headers: { "Content-Type": "image/png" } });
}

View file

@ -0,0 +1,133 @@
import { getCollection } from "astro:content";
import { join } from "node:path";
import type { APIContext, ImageMetadata } from "astro";
import {
buildCollectionPhotos,
collectionSlug,
type PhotoSidecar,
} from "@/lib/collections";
import {
buildCollectionVNode,
buildPhotoVNode,
imageToOgDataUri,
renderOgImage,
} from "@/lib/og";
type CollectionPathProps = {
type: "collection";
title: string;
description: string;
location?: string;
coverPath: string | null;
};
type PhotoPathProps = {
type: "photo";
title: string;
alt: string;
date: string;
location: string;
photoPath: string;
};
type PathProps = CollectionPathProps | PhotoPathProps;
export async function getStaticPaths() {
const allCollections = await getCollection(
"collections_photos",
({ data }) => !data.draft,
);
const sidecars = import.meta.glob<PhotoSidecar>(
"/src/content/photos/collections/**/*.json",
{ eager: true },
);
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/photos/collections/**/*.jpg",
{ eager: true },
);
const paths: { params: { slug: string }; props: PathProps }[] = [];
for (const col of allCollections) {
const slug = collectionSlug(col);
if (!slug) continue; // skip root collection index
// Cover: first JPG in this collection's own img/ dir (sorted alphabetically)
const imgPrefix = `/src/content/photos/collections/${slug}/img/`;
const coverEntry = Object.entries(imageModules)
.filter(
([p]) =>
p.startsWith(imgPrefix) && !p.slice(imgPrefix.length).includes("/"),
)
.sort(([a], [b]) => a.localeCompare(b))[0];
const coverPath = coverEntry?.[0] ?? null;
paths.push({
params: { slug },
props: {
type: "collection",
title: col.data.title,
description: col.data.description,
...(col.data.location !== undefined
? { location: col.data.location }
: {}),
coverPath,
} satisfies CollectionPathProps,
});
// Individual photo paths
const photos = buildCollectionPhotos(sidecars, imageModules, slug);
for (const photo of photos) {
const photoPath = `/src/content/photos/collections/${slug}/img/${photo.sidecar.id}.jpg`;
paths.push({
params: { slug: `${slug}/${photo.sidecar.id}` },
props: {
type: "photo",
title: photo.sidecar.title[0] ?? photo.sidecar.id,
alt: photo.sidecar.alt,
date: photo.sidecar.date,
location: photo.sidecar.location,
photoPath,
} satisfies PhotoPathProps,
});
}
}
return paths;
}
export async function GET({ props }: APIContext) {
const typedProps = props as PathProps;
const cwd = process.cwd();
if (typedProps.type === "collection") {
const coverDataUri = typedProps.coverPath
? await imageToOgDataUri(join(cwd, typedProps.coverPath))
: undefined;
const buffer = await renderOgImage(
buildCollectionVNode({
title: typedProps.title,
description: typedProps.description,
...(typedProps.location !== undefined
? { location: typedProps.location }
: {}),
coverDataUri,
}),
);
return new Response(buffer, { headers: { "Content-Type": "image/png" } });
}
// type === "photo"
const photoDataUri = await imageToOgDataUri(join(cwd, typedProps.photoPath));
const buffer = await renderOgImage(
buildPhotoVNode({
title: typedProps.title,
alt: typedProps.alt,
date: typedProps.date,
location: typedProps.location,
photoDataUri,
}),
);
return new Response(buffer, { headers: { "Content-Type": "image/png" } });
}

View file

@ -0,0 +1,653 @@
---
import { Image } from "astro:assets";
import { getCollection } from "astro:content";
import type { ImageMetadata } from "astro";
import PhotoDetail from "@/components/PhotoDetail.astro";
import PhotosSubNav from "@/components/PhotosSubNav.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
import type { PhotoSidecar } from "@/lib/collections";
import {
buildBreadcrumbs,
buildCollectionPhotos,
collectionSlug,
getChildCollections,
} from "@/lib/collections";
type CollectionProps = {
type: "collection";
slug: string;
title: string;
description: string;
location?: string;
hasContent: boolean;
childSlugs: string[];
childTitles: Record<string, string>;
childLocations: Record<string, string | undefined>;
photoCount: number;
breadcrumbs: { label: string; href: string }[];
};
type PhotoProps = {
type: "photo";
sidecar: PhotoSidecar;
image: ImageMetadata;
collectionSlug: string;
collectionTitle: string;
prevHref: string | null;
nextHref: string | null;
breadcrumbs: { label: string; href: string }[];
};
export async function getStaticPaths() {
const allCollections = await getCollection(
"collections_photos",
({ data }) => !data.draft,
);
const sidecars = import.meta.glob<PhotoSidecar>(
"/src/content/photos/collections/**/*.json",
{ eager: true },
);
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/photos/collections/**/*.jpg",
{ eager: true },
);
const paths: {
params: { slug: string | undefined };
props: CollectionProps | PhotoProps;
}[] = [];
// Build a global flat photo list (sorted by collection slug) for cross-collection nav
const sortedCollections = [...allCollections].sort((a, b) =>
collectionSlug(a).localeCompare(collectionSlug(b)),
);
const globalPhotos: { cSlug: string; photoId: string }[] = [];
const collectionPhotosMap = new Map<
string,
{ sidecar: PhotoSidecar; image: ImageMetadata }[]
>();
for (const col of sortedCollections) {
const slug = collectionSlug(col);
const photos = buildCollectionPhotos(sidecars, imageModules, slug);
collectionPhotosMap.set(slug, photos);
for (const photo of photos) {
globalPhotos.push({ cSlug: slug, photoId: photo.sidecar.id });
}
}
for (const col of allCollections) {
const slug = collectionSlug(col);
const children = getChildCollections(slug, allCollections);
const photos = collectionPhotosMap.get(slug) ?? [];
const breadcrumbs = buildBreadcrumbs(slug, allCollections);
// Collection index path
paths.push({
params: { slug: slug || undefined },
props: {
type: "collection",
slug,
title: col.data.title,
description: col.data.description,
...(col.data.location !== undefined
? { location: col.data.location }
: {}),
hasContent: false, // render() called at page level
childSlugs: children.map((c) => collectionSlug(c)),
childTitles: Object.fromEntries(
children.map((c) => [collectionSlug(c), c.data.title]),
),
childLocations: Object.fromEntries(
children.map((c) => [collectionSlug(c), c.data.location]),
),
photoCount: photos.length,
breadcrumbs,
} satisfies CollectionProps,
});
// Photo detail paths — use global prev/next for cross-collection navigation
photos.forEach((photo) => {
const gi = globalPhotos.findIndex(
(p) => p.cSlug === slug && p.photoId === photo.sidecar.id,
);
const prevG = globalPhotos[gi - 1];
const nextG = globalPhotos[gi + 1];
paths.push({
params: { slug: `${slug}/${photo.sidecar.id}` },
props: {
type: "photo",
sidecar: photo.sidecar,
image: photo.image,
collectionSlug: slug,
collectionTitle: col.data.title,
prevHref: prevG
? `/photos/collections/${prevG.cSlug}/${prevG.photoId}`
: null,
nextHref: nextG
? `/photos/collections/${nextG.cSlug}/${nextG.photoId}`
: null,
breadcrumbs: [
...breadcrumbs,
{
label: photo.sidecar.title[0] ?? photo.sidecar.id,
href: `/photos/collections/${slug}/${photo.sidecar.id}`,
},
],
} satisfies PhotoProps,
});
});
}
return paths;
}
const props = Astro.props as CollectionProps | PhotoProps;
// For collection pages: load photos + child covers at render time
const sidecars = import.meta.glob<PhotoSidecar>(
"/src/content/photos/collections/**/*.json",
{ eager: true },
);
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/photos/collections/**/*.jpg",
{ eager: true },
);
let photos: { sidecar: PhotoSidecar; image: ImageMetadata }[] = [];
let childCovers: Record<string, ImageMetadata | null> = {};
if (props.type === "collection") {
photos = buildCollectionPhotos(sidecars, imageModules, props.slug);
// Pick first image per child collection as cover
for (const childSlug of props.childSlugs) {
const prefix = `/src/content/photos/collections/${childSlug}/`;
const cover = Object.entries(imageModules)
.filter(([p]) => p.startsWith(prefix))
.sort(([a], [b]) => a.localeCompare(b))[0];
childCovers[childSlug] = cover?.[1]?.default ?? null;
}
}
const BATCH_SIZE = 15;
---
{
props.type === "photo" ? (
<BaseLayout
title={props.sidecar.title[0] ?? props.sidecar.id}
description={props.sidecar.alt}
image={{
src: new URL(
`/og/photos/${props.collectionSlug}/${props.sidecar.id}.png`,
Astro.site,
).toString(),
alt: props.sidecar.alt,
}}
navHidden={true}
footerHidden={true}
>
<PhotoDetail
sidecar={props.sidecar}
image={props.image}
prevHref={props.prevHref}
nextHref={props.nextHref}
backHref={`/photos/collections/${props.collectionSlug}`}
backLabel={props.collectionTitle}
canonicalUrl={new URL(
`/photos/collections/${props.collectionSlug}/${props.sidecar.id}`,
Astro.site,
).toString()}
/>
</BaseLayout>
) : (
<BaseLayout
title={props.title}
description={props.description}
navDesktopHidden={true}
footerDark={true}
image={
props.slug
? {
src: new URL(
`/og/photos/${props.slug}.png`,
Astro.site,
).toString(),
alt: props.title,
}
: undefined
}
>
<div class="page">
<main class="main">
<PhotosSubNav />
<nav class="breadcrumb" aria-label="Breadcrumb">
{props.breadcrumbs.map((crumb, i) => (
<>
{i > 0 && <span aria-hidden="true">/</span>}
{i < props.breadcrumbs.length - 1 ? (
<a href={crumb.href}>{crumb.label}</a>
) : (
<span aria-current="page">{crumb.label}</span>
)}
</>
))}
</nav>
<header class="collection-header">
<h1 class="collection-title">{props.title}</h1>
{props.location && (
<p class="collection-location">{props.location}</p>
)}
{props.description && (
<p class="collection-description">{props.description}</p>
)}
</header>
{props.childSlugs.length > 0 && (
<section class="sub-collections">
<ul class="collection-grid" role="list">
{props.childSlugs.map((childSlug) => {
const cover = childCovers[childSlug];
const title = props.childTitles[childSlug] ?? childSlug;
const location = props.childLocations[childSlug];
return (
<li class="collection-card">
<a
href={`/photos/collections/${childSlug}`}
class="collection-link"
>
{cover && (
<div class="collection-cover">
<Image
src={cover}
alt={title}
width={600}
height={400}
class="collection-img"
/>
</div>
)}
<div class="collection-meta">
<span class="collection-name">{title}</span>
{location && (
<span class="collection-loc">{location}</span>
)}
</div>
</a>
</li>
);
})}
</ul>
</section>
)}
{photos.length > 0 && (
<>
<div class="photo-grid h-feed" id="photo-grid">
{photos.map((photo, i) => {
const ar = photo.image.width / photo.image.height;
return (
<div
class="photo-item h-entry"
data-ar={ar}
data-batch={Math.floor(i / BATCH_SIZE)}
data-hidden={i >= BATCH_SIZE ? "true" : undefined}
>
<a
href={`/photos/collections/${props.slug}/${photo.sidecar.id}`}
class="photo-link u-url"
>
<Image
src={photo.image}
alt={photo.sidecar.alt}
width={800}
height={Math.round(800 / ar)}
loading="lazy"
class="photo-img u-photo"
/>
</a>
<time
class="dt-published"
datetime={new Date(photo.sidecar.date).toISOString()}
style="display:none"
>
{photo.sidecar.date}
</time>
</div>
);
})}
</div>
<div id="sentinel" aria-hidden="true" />
<div id="load-status" class="load-status" aria-live="polite" />
</>
)}
</main>
</div>
</BaseLayout>
)
}
<script>
import justifiedLayout from "justified-layout";
const grid = document.getElementById("photo-grid");
if (grid) {
const BATCH_SIZE = 15;
const TARGET_ROW_HEIGHT = 280;
const BOX_SPACING = 8;
const allItems = Array.from(
grid.querySelectorAll<HTMLElement>(".photo-item"),
);
const sentinel = document.getElementById("sentinel");
const status = document.getElementById("load-status");
let currentBatch = 0;
const totalBatches =
Math.ceil(
allItems.filter((el) => el.hasAttribute("data-hidden")).length /
BATCH_SIZE,
) + 1;
function applyLayout() {
if (window.innerWidth <= 640) {
grid!.style.height = "";
grid!.style.position = "";
for (const item of allItems) {
if (!item.hasAttribute("data-hidden")) item.style.cssText = "";
}
return;
}
const visibleItems = allItems.filter(
(el) => !el.hasAttribute("data-hidden"),
);
const ratios = visibleItems.map((el) => Number(el.dataset.ar));
const result = justifiedLayout(ratios, {
containerWidth: grid!.offsetWidth,
targetRowHeight: TARGET_ROW_HEIGHT,
boxSpacing: BOX_SPACING,
containerPadding: 0,
});
grid!.style.position = "relative";
grid!.style.height = `${result.containerHeight}px`;
for (let i = 0; i < visibleItems.length; i++) {
const box = result.boxes[i];
const item = visibleItems[i];
if (!box || !item) continue;
item.style.position = "absolute";
item.style.top = `${box.top}px`;
item.style.left = `${box.left}px`;
item.style.width = `${box.width}px`;
item.style.height = `${box.height}px`;
}
}
function revealNextBatch() {
currentBatch++;
const toReveal = allItems.filter(
(el) =>
el.hasAttribute("data-hidden") &&
Number(el.dataset.batch) === currentBatch,
);
if (toReveal.length === 0) {
sentinel?.remove();
return;
}
if (status) status.textContent = "Loading more photos…";
for (const el of toReveal) {
el.removeAttribute("data-hidden");
el.classList.add("is-visible");
}
applyLayout();
if (currentBatch >= totalBatches) sentinel?.remove();
if (status) status.textContent = "";
}
let resizeTimer: ReturnType<typeof setTimeout>;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(applyLayout, 150);
});
applyLayout();
if (sentinel) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) revealNextBatch();
},
{ rootMargin: "400px" },
);
observer.observe(sentinel);
}
}
</script>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #111;
color: #f5f5f3;
}
.page.dark {
display: flex;
flex-direction: column;
}
.main {
max-width: var(--width-wide);
margin: 0 auto;
padding: 1.5rem;
width: 100%;
}
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #666;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.breadcrumb a {
color: #888;
text-decoration: none;
}
.breadcrumb a:hover {
color: #f5f5f3;
}
/* Collection header */
.collection-header {
margin-bottom: 1.5rem;
}
.collection-title {
font-size: 1.5rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.collection-location {
font-size: 0.85rem;
color: #888;
margin: 0;
}
.collection-description {
margin-top: 0.5rem;
font-size: 0.9rem;
color: #aaa;
line-height: 1.6;
max-width: 60ch;
}
/* Sub-collections grid */
.sub-collections {
margin-bottom: 2rem;
}
.collection-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
list-style: none;
padding: 0;
margin: 0;
}
.collection-card {
border-radius: 2px;
overflow: hidden;
}
.collection-link {
display: block;
text-decoration: none;
color: inherit;
}
.collection-cover {
aspect-ratio: 3 / 2;
overflow: hidden;
}
.collection-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
.collection-link:hover .collection-img {
transform: scale(1.03);
}
.collection-meta {
padding: 0.6rem 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.collection-name {
font-size: 0.95rem;
font-weight: 500;
}
.collection-loc {
font-size: 0.8rem;
color: #888;
}
/* Photo grid */
.photo-grid {
position: relative;
}
.photo-item {
border-radius: 2px;
overflow: hidden;
}
.photo-item[data-hidden] {
display: none;
}
.photo-item.is-visible {
animation: fadeIn 0.4s ease forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.photo-link {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
}
.photo-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
.photo-link:hover .photo-img {
transform: scale(1.03);
}
#sentinel {
height: 1px;
margin-top: 2rem;
}
.load-status {
text-align: center;
padding: 1rem;
font-size: 0.85rem;
color: #666;
min-height: 2rem;
}
.page :global(.footer a) {
color: #f5f5f3;
}
@media (max-width: 640px) {
.main {
padding: 0.25rem 0.75rem;
}
.collection-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.photo-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: auto !important;
position: static !important;
}
.photo-item {
position: static !important;
width: 100% !important;
height: auto !important;
}
.photo-img {
height: auto;
object-fit: initial;
}
}
</style>

View file

@ -0,0 +1,165 @@
---
import { Image } from "astro:assets";
import { getCollection } from "astro:content";
import type { ImageMetadata } from "astro";
import PhotosSubNav from "@/components/PhotosSubNav.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
import { collectionSlug, getChildCollections } from "@/lib/collections";
const allCollections = await getCollection(
"collections_photos",
({ data }) => !data.draft,
);
// Top-level collections only (no slash in slug)
const topLevel = getChildCollections("", allCollections);
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/photos/collections/**/*.jpg",
{ eager: true },
);
// Pick first image per top-level collection as cover
const covers = Object.fromEntries(
topLevel.map((col) => {
const slug = collectionSlug(col);
const prefix = `/src/content/photos/collections/${slug}/`;
const cover = Object.entries(imageModules)
.filter(([p]) => p.startsWith(prefix))
.sort(([a], [b]) => a.localeCompare(b))[0];
return [slug, cover?.[1]?.default ?? null];
}),
);
---
<BaseLayout
title="Collections"
description="Photo collections by Adrian Altner — travel and street photography."
navDesktopHidden={true}
footerDark={true}
>
<div class="page">
<main class="main">
<PhotosSubNav />
<ul class="collection-grid" role="list">
{
topLevel.map((col) => {
const slug = collectionSlug(col);
const cover = covers[slug];
return (
<li class="collection-card">
<a href={`/photos/collections/${slug}`} class="collection-link">
{cover && (
<div class="collection-cover">
<Image
src={cover}
alt={col.data.title}
width={600}
height={400}
class="collection-img"
/>
</div>
)}
<div class="collection-meta">
<span class="collection-title">{col.data.title}</span>
{col.data.location && (
<span class="collection-location">
{col.data.location}
</span>
)}
</div>
</a>
</li>
);
})
}
</ul>
</main>
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #111;
color: #f5f5f3;
}
.main {
max-width: var(--width-wide);
margin: 0 auto;
padding: 1.5rem;
width: 100%;
}
.collection-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
list-style: none;
padding: 0;
margin: 0;
}
.collection-card {
border-radius: 2px;
overflow: hidden;
}
.collection-link {
display: block;
text-decoration: none;
color: inherit;
}
.collection-cover {
aspect-ratio: 3 / 2;
overflow: hidden;
}
.collection-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
.collection-link:hover .collection-img {
transform: scale(1.03);
}
.collection-meta {
padding: 0.6rem 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.collection-title {
font-size: 0.95rem;
font-weight: 500;
}
.collection-location {
font-size: 0.8rem;
color: #888;
}
.page :global(.footer a) {
color: #f5f5f3;
}
@media (max-width: 640px) {
.main {
padding: 0.75rem;
}
.collection-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
}
</style>

View file

@ -0,0 +1,352 @@
---
import { Image } from "astro:assets";
import type { ImageMetadata } from "astro";
import JsonLd from "@/components/JsonLd.astro";
import PhotosSubNav from "@/components/PhotosSubNav.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
const collectionPageSchema = {
"@context": "https://schema.org",
"@type": "CollectionPage",
name: "Photography by Adrian Altner",
description:
"A photostream of travel and street photography by Adrian Altner.",
url: "https://adrian-altner.com/photos",
author: {
"@type": "Person",
name: "Adrian Altner",
url: "https://adrian-altner.com",
},
};
type PhotoSidecar = {
id: string;
title: string[];
alt: string;
location: string;
date: string;
tags: string[];
exif: {
camera: string;
lens: string;
aperture: string;
iso: string;
focal_length: string;
shutter_speed: string;
};
};
const sidecars = import.meta.glob<PhotoSidecar>(
"/src/content/photos/collections/**/*.json",
{ eager: true },
);
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/photos/collections/**/*.jpg",
{ eager: true },
);
const photos = Object.entries(sidecars)
.map(([jsonPath, sidecar]) => {
const imgPath = jsonPath.replace(".json", ".jpg");
const imageModule = imageModules[imgPath];
const collectionSlug = jsonPath
.replace("/src/content/photos/collections/", "")
.replace(/\/img\/[^/]+\.json$/, "");
return { sidecar, image: imageModule?.default, collectionSlug };
})
.filter(
(
p,
): p is {
sidecar: PhotoSidecar;
image: ImageMetadata;
collectionSlug: string;
} => !!p.image,
)
.sort(
(a, b) =>
new Date(b.sidecar.date).getTime() - new Date(a.sidecar.date).getTime(),
);
const BATCH_SIZE = 15;
---
<BaseLayout
title="Photography"
description="A photostream of travel and street photography by Adrian Altner."
navDesktopHidden={true}
footerDark={true}
>
<JsonLd schema={collectionPageSchema} slot="head" />
<div class="page">
<main class="main">
<PhotosSubNav />
<div class="photo-grid" id="photo-grid">
{
photos.map((photo, i) => {
const ar = photo.image.width / photo.image.height;
return (
<div
class="photo-item"
data-ar={ar}
data-batch={Math.floor(i / BATCH_SIZE)}
data-hidden={i >= BATCH_SIZE ? "true" : undefined}
>
<a href={`/photos/collections/${photo.collectionSlug}/${photo.sidecar.id}`} class="photo-link">
<Image
src={photo.image}
alt={photo.sidecar.alt}
width={800}
height={Math.round(800 / ar)}
loading="lazy"
class="photo-img"
/>
</a>
</div>
);
})
}
</div>
<div id="sentinel" aria-hidden="true"></div>
<div id="load-status" class="load-status" aria-live="polite"></div>
</main>
</div>
</BaseLayout>
<script>
import justifiedLayout from "justified-layout";
const BATCH_SIZE = 15;
const TARGET_ROW_HEIGHT = 280;
const BOX_SPACING = 8;
const grid = document.getElementById("photo-grid")!;
const allItems = Array.from(
grid.querySelectorAll<HTMLElement>(".photo-item"),
);
const sentinel = document.getElementById("sentinel");
const status = document.getElementById("load-status");
let currentBatch = 0;
const totalBatches =
Math.ceil(
allItems.filter((el) => el.hasAttribute("data-hidden")).length /
BATCH_SIZE,
) + 1;
function applyLayout() {
if (window.innerWidth <= 640) {
grid.style.height = "";
grid.style.position = "";
for (const item of allItems) {
if (!item.hasAttribute("data-hidden")) {
item.style.cssText = "";
}
}
return;
}
const visibleItems = allItems.filter(
(el) => !el.hasAttribute("data-hidden"),
);
const ratios = visibleItems.map((el) => Number(el.dataset.ar));
const result = justifiedLayout(ratios, {
containerWidth: grid.offsetWidth,
targetRowHeight: TARGET_ROW_HEIGHT,
boxSpacing: BOX_SPACING,
containerPadding: 0,
});
grid.style.position = "relative";
grid.style.height = `${result.containerHeight}px`;
for (let i = 0; i < visibleItems.length; i++) {
const box = result.boxes[i];
const item = visibleItems[i];
if (!box || !item) continue;
item.style.position = "absolute";
item.style.top = `${box.top}px`;
item.style.left = `${box.left}px`;
item.style.width = `${box.width}px`;
item.style.height = `${box.height}px`;
}
}
function revealNextBatch() {
currentBatch++;
const toReveal = allItems.filter(
(el) =>
el.hasAttribute("data-hidden") &&
Number(el.dataset.batch) === currentBatch,
);
if (toReveal.length === 0) {
sentinel?.remove();
return;
}
if (status) status.textContent = "Loading more photos…";
for (const el of toReveal) {
el.removeAttribute("data-hidden");
el.classList.add("is-visible");
}
applyLayout();
if (currentBatch >= totalBatches) {
sentinel?.remove();
}
if (status) status.textContent = "";
}
let resizeTimer: ReturnType<typeof setTimeout>;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(applyLayout, 150);
});
applyLayout();
if (sentinel) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
revealNextBatch();
}
},
{ rootMargin: "400px" },
);
observer.observe(sentinel);
}
</script>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #111;
color: #f5f5f3;
}
.main {
max-width: var(--width-wide);
margin: 0 auto;
padding: 1.5rem;
width: 100%;
}
/* ── Justified layout (positioned by JS) ── */
.photo-grid {
position: relative;
}
.photo-item {
border-radius: 2px;
overflow: hidden;
transition: opacity 0.4s ease;
}
.photo-item[data-hidden] {
display: none;
}
.photo-item.is-visible {
animation: fadeIn 0.4s ease forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Photo card ── */
.photo-link {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
}
.photo-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
.photo-link:hover .photo-img {
transform: scale(1.03);
}
/* ── Sentinel / status ── */
#sentinel {
height: 1px;
margin-top: 2rem;
}
.load-status {
text-align: center;
padding: 1rem;
font-size: 0.85rem;
color: #666;
min-height: 2rem;
}
/* ── Nav / Footer override for dark bg ── */
.page :global(.nav),
.page :global(.mobile-bar) {
background: #111;
color: #000;
}
.page :global(.nav a),
.page :global(.nav__icon-btn) {
color: #000;
}
.page :global(.footer a) {
color: #f5f5f3;
}
/* ── Mobile: simple column, natural aspect ratios ── */
@media (max-width: 640px) {
.main {
padding: 0.25rem;
}
.photo-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: auto !important;
position: static !important;
}
.photo-item {
position: static !important;
width: 100% !important;
height: auto !important;
top: auto !important;
left: auto !important;
}
.photo-img {
height: auto;
object-fit: initial;
}
}
</style>

353
src/pages/photos/map.astro Normal file
View file

@ -0,0 +1,353 @@
---
import { getImage } from "astro:assets";
import type { ImageMetadata } from "astro";
import PhotosSubNav from "@/components/PhotosSubNav.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
type PhotoSidecar = {
id: string;
title: string[];
image: string;
alt: string;
location: string;
date: string;
tags: string[];
exif: {
camera: string;
lens: string;
aperture: string;
iso: string;
focal_length: string;
shutter_speed: string;
};
};
type MarkerData = {
id: string;
collectionSlug: string;
lat: number;
lng: number;
thumbSrc: string;
thumbWidth: number;
thumbHeight: number;
alt: string;
title: string;
};
function parseDMS(dms: string): { lat: number; lng: number } | null {
const re =
/(\d+)\s*deg\s*(\d+)'\s*([\d.]+)"\s*([NS])\s*,\s*(\d+)\s*deg\s*(\d+)'\s*([\d.]+)"\s*([EW])/;
const m = dms.match(re);
if (!m) return null;
const [, d1, mi1, s1, dir1, d2, mi2, s2, dir2] = m;
if (!d1 || !mi1 || !s1 || !dir1 || !d2 || !mi2 || !s2 || !dir2) return null;
const toDD = (d: string, min: string, sec: string, dir: string): number => {
const dd = Number(d) + Number(min) / 60 + Number(sec) / 3600;
return dir === "S" || dir === "W" ? -dd : dd;
};
return {
lat: toDD(d1, mi1, s1, dir1),
lng: toDD(d2, mi2, s2, dir2),
};
}
const sidecars = import.meta.glob<PhotoSidecar>(
"/src/content/photos/collections/**/*.json",
{ eager: true },
);
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/photos/collections/**/*.jpg",
{ eager: true },
);
const photos = Object.entries(sidecars)
.map(([jsonPath, sidecar]) => {
const imgPath = jsonPath.replace(".json", ".jpg");
const imageModule = imageModules[imgPath];
const collectionSlug = jsonPath
.replace("/src/content/photos/collections/", "")
.replace(/\/img\/.*$/, "");
return { sidecar, image: imageModule?.default, collectionSlug };
})
.filter(
(
p,
): p is {
sidecar: PhotoSidecar;
image: ImageMetadata;
collectionSlug: string;
} => !!p.image,
);
const markers: MarkerData[] = (
await Promise.all(
photos.map(async ({ sidecar, image, collectionSlug }) => {
const coords = parseDMS(sidecar.location);
if (!coords) return null;
const thumb = await getImage({ src: image, width: 240, format: "jpeg" });
return {
id: sidecar.id,
collectionSlug,
lat: coords.lat,
lng: coords.lng,
thumbSrc: thumb.src,
thumbWidth: thumb.attributes.width as number,
thumbHeight: thumb.attributes.height as number,
alt: sidecar.alt,
title: sidecar.title[0] ?? sidecar.id,
} satisfies MarkerData;
}),
)
).filter((m): m is MarkerData => m !== null);
const markersJson = JSON.stringify(markers);
---
<BaseLayout
title="Map"
description="All photo locations mapped — travel and street photography by Adrian Altner."
navDesktopHidden={true}
footerDark={true}
>
<div class="page">
<main class="main">
<div class="nav-area">
<PhotosSubNav />
</div>
<div id="map" class="map-container"></div>
</main>
</div>
<script
is:inline
type="application/json"
id="map-data"
set:html={markersJson}
/>
</BaseLayout>
<script>
import "leaflet/dist/leaflet.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
import L from "leaflet";
import "leaflet.markercluster";
type MarkerData = {
id: string;
collectionSlug: string;
lat: number;
lng: number;
thumbSrc: string;
thumbWidth: number;
thumbHeight: number;
alt: string;
title: string;
};
const raw = document.getElementById("map-data")?.textContent ?? "[]";
const markers: MarkerData[] = JSON.parse(raw);
const map = L.map("map", { zoomControl: true });
L.tileLayer("https://tiles-eu.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png", {
attribution:
'&copy; <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>',
maxZoom: 20,
opacity: 0.7,
}).addTo(map);
const cluster = L.markerClusterGroup({
iconCreateFunction: (c) =>
L.divIcon({
html: `<div class="cluster-icon">${c.getChildCount()}</div>`,
className: "",
iconSize: [36, 36],
}),
showCoverageOnHover: false,
maxClusterRadius: 60,
});
for (const m of markers) {
const popup = `<div class="map-popup">
<a href="/photos/collections/${m.collectionSlug}/${m.id}" class="map-popup__link">
<img src="${m.thumbSrc}" width="${m.thumbWidth}" height="${m.thumbHeight}" alt="${m.alt.replace(/"/g, "&quot;")}" loading="lazy" class="map-popup__img" />
<p class="map-popup__title">${m.title}</p>
</a>
</div>`;
L.circleMarker([m.lat, m.lng], {
radius: 7,
color: "#e8587a",
fillColor: "#e8587a",
fillOpacity: 0.85,
weight: 2,
})
.bindPopup(popup, { maxWidth: 240 })
.addTo(cluster);
}
cluster.addTo(map);
if (markers.length > 0) {
const bounds = L.latLngBounds(markers.map((m) => [m.lat, m.lng]));
map.fitBounds(bounds, { padding: [40, 40] });
} else {
map.setView([10, 105], 5);
}
</script>
<style>
.page {
display: flex;
flex-direction: column;
height: 100dvh;
background: #111;
color: #f5f5f3;
}
.main {
flex: 1;
width: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.nav-area {
padding: 1.5rem 1.5rem 0;
}
/* ── Map ── */
.map-container {
flex: 1;
min-height: 0;
border-radius: 2px;
overflow: hidden;
}
/* ── Leaflet dark overrides ── */
:global(.leaflet-popup-content-wrapper) {
background: #1e1e1e !important;
color: #f5f5f3 !important;
border-radius: 4px !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6) !important;
padding: 0 !important;
}
:global(.leaflet-popup-content) {
margin: 0 !important;
width: auto !important;
}
:global(.leaflet-popup-tip) {
background: #1e1e1e !important;
}
:global(.leaflet-popup-close-button) {
color: #888 !important;
top: 6px !important;
right: 8px !important;
}
:global(.leaflet-popup-close-button:hover) {
color: #f5f5f3 !important;
}
:global(.leaflet-control-zoom a) {
background: #1e1e1e;
color: #f5f5f3;
border-color: #333;
}
:global(.leaflet-control-zoom a:hover) {
background: #2a2a2a;
color: #fff;
}
:global(.leaflet-bar) {
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
:global(.leaflet-control-attribution) {
background: rgba(17, 17, 17, 0.7);
color: #555;
font-size: 0.6rem;
padding: 1px 4px;
}
:global(.leaflet-control-attribution a) {
color: #666;
}
/* ── Cluster icon ── */
:global(.cluster-icon) {
width: 36px;
height: 36px;
border-radius: 50%;
background: #e8587a;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: 600;
font-family: "Exo 2", sans-serif;
box-shadow: 0 2px 8px rgba(232, 88, 122, 0.4);
}
/* ── Popup content ── */
:global(.map-popup) {
width: 220px;
}
:global(.map-popup__link) {
display: block;
text-decoration: none;
color: inherit;
}
:global(.map-popup__img) {
display: block;
width: 100%;
height: 148px;
object-fit: cover;
border-radius: 4px 4px 0 0;
}
:global(.map-popup__title) {
font-size: 0.78rem;
line-height: 1.4;
padding: 0.5rem 0.6rem 0.6rem;
color: #f5f5f3 !important;
margin: 0;
}
:global(.map-popup__link:hover .map-popup__title) {
color: #f5f5f3;
}
/* ── Nav / Footer override for dark bg ── */
.page :global(.nav),
.page :global(.mobile-bar) {
background: #111;
color: #000;
}
.page :global(.nav a),
.page :global(.nav__icon-btn) {
color: #000;
}
.page :global(.footer a) {
color: #f5f5f3;
}
/* ── Mobile ── */
@media (max-width: 640px) {
.nav-area {
padding: 0.5rem 0.5rem 0;
}
}
</style>

View file

@ -0,0 +1,343 @@
---
import PhotosSubNav from "@/components/PhotosSubNav.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
type PhotoSidecar = {
id: string;
exif: {
camera: string;
lens: string;
aperture: string;
iso: string;
focal_length: string;
shutter_speed: string;
};
};
const sidecars = import.meta.glob<PhotoSidecar>(
"/src/content/photos/collections/**/*.json",
{ eager: true },
);
function countBy(values: string[]): { labels: string[]; data: number[] } {
const counts: Record<string, number> = {};
for (const v of values) {
counts[v] = (counts[v] ?? 0) + 1;
}
const labels = Object.keys(counts).sort(
(a, b) => (counts[b] ?? 0) - (counts[a] ?? 0),
);
return { labels, data: labels.map((l) => counts[l] ?? 0) };
}
function bucket(
values: number[],
buckets: { label: string; min: number; max: number }[],
): { labels: string[]; data: number[] } {
const counts: Record<string, number> = {};
for (const b of buckets) counts[b.label] = 0;
for (const v of values) {
const b = buckets.find((b) => v >= b.min && v < b.max);
if (b) counts[b.label] = (counts[b.label] ?? 0) + 1;
}
const labels = buckets.map((b) => b.label);
return { labels, data: labels.map((l) => counts[l] ?? 0) };
}
function parseShutter(s: string): number {
if (s.includes("/")) {
const [num, den] = s.split("/").map(Number);
return (num ?? 1) / (den ?? 1);
}
return Number(s) || 0;
}
const exifs = Object.values(sidecars)
.map((s) => s.exif)
.filter(Boolean);
const totalPhotos = exifs.length;
const camera = countBy(exifs.map((e) => e.camera ?? "Unknown"));
function normalizeLens(lens: string): string {
return lens.replace(/^Fujifilm Fujinon /, "").trim();
}
const lens = countBy(exifs.map((e) => normalizeLens(e.lens ?? "Unknown")));
const apertures = exifs
.map((e) => parseFloat(e.aperture))
.filter((n) => !isNaN(n));
const apertureUnique = [...new Set(apertures)].sort((a, b) => a - b);
const apertureCounts: Record<string, number> = {};
for (const v of apertures) {
const k = `f/${v}`;
apertureCounts[k] = (apertureCounts[k] ?? 0) + 1;
}
const aperture = {
labels: apertureUnique.map((v) => `f/${v}`),
data: apertureUnique.map((v) => apertureCounts[`f/${v}`] ?? 0),
};
const isoBuckets = [
{ label: "≤ 200", min: 0, max: 201 },
{ label: "400", min: 201, max: 600 },
{ label: "800", min: 600, max: 1200 },
{ label: "1600", min: 1200, max: 2400 },
{ label: "3200", min: 2400, max: 4800 },
{ label: "≥ 6400", min: 4800, max: Infinity },
];
const iso = bucket(
exifs.map((e) => parseInt(e.iso)).filter((n) => !isNaN(n)),
isoBuckets,
);
const focalBuckets = [
{ label: "≤ 24mm", min: 0, max: 25 },
{ label: "2535mm", min: 25, max: 36 },
{ label: "3650mm", min: 36, max: 51 },
{ label: "5185mm", min: 51, max: 86 },
{ label: "86135mm", min: 86, max: 136 },
{ label: "> 135mm", min: 136, max: Infinity },
];
const focal = bucket(
exifs.map((e) => parseFloat(e.focal_length)).filter((n) => !isNaN(n)),
focalBuckets,
);
const shutterBuckets = [
{ label: "≥ 1s", min: 1, max: Infinity },
{ label: "1/2 1s", min: 0.5, max: 1 },
{ label: "1/30 1/2", min: 1 / 30, max: 0.5 },
{ label: "1/125 1/30", min: 1 / 125, max: 1 / 30 },
{ label: "1/500 1/125", min: 1 / 500, max: 1 / 125 },
{ label: "< 1/500", min: 0, max: 1 / 500 },
];
const shutter = bucket(
exifs.map((e) => parseShutter(e.shutter_speed)).filter((n) => n > 0),
shutterBuckets,
);
const metrics = JSON.stringify({ camera, lens, aperture, iso, focal, shutter });
---
<BaseLayout
title="Photo Stats"
description="Metrics and statistics from my photo collection."
navDesktopHidden={true}
footerDark={true}
>
<div class="page">
<main class="main">
<PhotosSubNav />
<div class="stats-content" data-metrics={metrics}>
<p class="total">{totalPhotos} photos total</p>
<div class="chart-grid chart-grid--top">
<section class="chart-section">
<h2 class="section-title">Camera</h2>
<div class="chart-wrap chart-wrap--tall">
<canvas id="chart-camera" aria-label="Photos per camera"></canvas>
</div>
</section>
<section class="chart-section">
<h2 class="section-title">Lens</h2>
<div class="chart-wrap chart-wrap--tall">
<canvas id="chart-lens" aria-label="Photos per lens"></canvas>
</div>
</section>
</div>
<div class="chart-grid">
<section class="chart-section">
<h2 class="section-title">Aperture</h2>
<div class="chart-wrap">
<canvas id="chart-aperture" aria-label="Aperture distribution"></canvas>
</div>
</section>
<section class="chart-section">
<h2 class="section-title">ISO</h2>
<div class="chart-wrap">
<canvas id="chart-iso" aria-label="ISO distribution"></canvas>
</div>
</section>
<section class="chart-section">
<h2 class="section-title">Focal Length</h2>
<div class="chart-wrap">
<canvas id="chart-focal" aria-label="Focal length distribution"></canvas>
</div>
</section>
<section class="chart-section">
<h2 class="section-title">Shutter Speed</h2>
<div class="chart-wrap">
<canvas id="chart-shutter" aria-label="Shutter speed distribution"></canvas>
</div>
</section>
</div>
</div>
</main>
</div>
</BaseLayout>
<script>
import Chart from "chart.js/auto";
const container = document.querySelector<HTMLElement>(".stats-content");
if (!container) throw new Error("No stats container");
const metrics = JSON.parse(container.dataset.metrics ?? "{}");
function neon(hex: string, alpha = 0.25) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
const COLORS = ["#39ff14", "#ff2d78", "#00e5ff", "#ffaa00", "#bf00ff", "#ff6b00"];
function makeChart(
id: string,
labels: string[],
data: number[],
horizontal = false,
colorIndex = 0,
) {
const hex = COLORS[colorIndex % COLORS.length] ?? "#ffffff";
const canvas = document.getElementById(id) as HTMLCanvasElement | null;
if (!canvas) return;
if (horizontal) {
const wrap = canvas.parentElement as HTMLElement | null;
if (wrap) wrap.style.height = `${labels.length * 44 + 40}px`;
}
new Chart(canvas, {
type: "bar",
data: {
labels,
datasets: [
{
label: "Photos",
data,
backgroundColor: neon(hex, 0.2),
borderColor: neon(hex, 0.9),
borderWidth: 1,
borderRadius: 2,
},
],
},
options: {
indexAxis: horizontal ? "y" : "x",
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: { label: (ctx) => ` ${horizontal ? ctx.parsed.x : ctx.parsed.y} photos` },
},
},
scales: {
x: {
ticks: { color: "#999", font: { size: 11 } },
grid: { color: horizontal ? "rgba(255,255,255,0.06)" : "none" },
border: { color: "rgba(255,255,255,0.1)" },
},
y: {
ticks: { color: "#f5f5f3", font: { size: 12 } },
grid: { color: horizontal ? "none" : "rgba(255,255,255,0.06)" },
border: { color: "rgba(255,255,255,0.1)" },
},
},
},
});
}
makeChart("chart-camera", metrics.camera.labels, metrics.camera.data, true, 0);
makeChart("chart-lens", metrics.lens.labels, metrics.lens.data, true, 1);
makeChart("chart-aperture",metrics.aperture.labels,metrics.aperture.data,false,2);
makeChart("chart-iso", metrics.iso.labels, metrics.iso.data, false,3);
makeChart("chart-focal", metrics.focal.labels, metrics.focal.data, false,4);
makeChart("chart-shutter", metrics.shutter.labels, metrics.shutter.data, false,5);
</script>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #111;
color: #f5f5f3;
}
.main {
max-width: var(--width-wide);
margin: 0 auto;
padding: 1.5rem;
width: 100%;
}
.stats-content {
padding-top: 1rem;
}
.total {
font-size: 0.85rem;
color: #555;
margin: 0 0 2rem;
}
.section-title {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.07em;
text-transform: uppercase;
color: #666;
margin: 0 0 0.75rem;
}
.chart-section {
margin-bottom: 2.5rem;
}
.chart-wrap {
position: relative;
height: 220px;
}
.chart-wrap--tall {
height: 140px;
}
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem 3rem;
}
.chart-grid--top {
margin-bottom: 2.5rem;
}
@media (max-width: 640px) {
.chart-grid {
grid-template-columns: 1fr;
}
}
.page :global(.nav),
.page :global(.mobile-bar) {
background: #111;
color: #000;
}
.page :global(.nav a),
.page :global(.nav__icon-btn) {
color: #000;
}
.page :global(.footer a) {
color: #f5f5f3;
}
</style>

View file

@ -0,0 +1,340 @@
---
import { Image } from "astro:assets";
import type { ImageMetadata } from "astro";
import PhotosSubNav from "@/components/PhotosSubNav.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
import type { PhotoSidecar } from "@/lib/collections";
type TagPhoto = {
sidecar: PhotoSidecar;
image: ImageMetadata;
collectionSlug: string;
};
export async function getStaticPaths() {
const sidecars = import.meta.glob<PhotoSidecar>(
"/src/content/photos/collections/**/*.json",
{ eager: true },
);
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/photos/collections/**/*.jpg",
{ eager: true },
);
function toSlug(tag: string): string {
return tag
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}
const tagMap = new Map<string, { label: string; photos: TagPhoto[] }>();
for (const [jsonPath, sidecar] of Object.entries(sidecars)) {
const imgPath = jsonPath.replace(/\.json$/, ".jpg");
const image = imageModules[imgPath]?.default;
if (!image) continue;
const collectionSlug = jsonPath
.replace("/src/content/photos/collections/", "")
.replace(/\/img\/[^/]+\.json$/, "");
for (const tag of sidecar.tags) {
const slug = toSlug(tag);
if (!tagMap.has(slug)) tagMap.set(slug, { label: tag, photos: [] });
tagMap.get(slug)?.photos.push({ sidecar, image, collectionSlug });
}
}
return [...tagMap.entries()].map(([slug, { label, photos }]) => ({
params: { slug },
props: {
label,
photos: photos.sort(
(a, b) =>
new Date(b.sidecar.date).getTime() -
new Date(a.sidecar.date).getTime(),
),
},
}));
}
const { label, photos } = Astro.props as { label: string; photos: TagPhoto[] };
const BATCH_SIZE = 15;
---
<BaseLayout
title={`Tag: ${label}`}
description={`Photos tagged with "${label}" by Adrian Altner.`}
navDesktopHidden={true}
footerDark={true}
>
<div class="page">
<main class="main">
<PhotosSubNav />
<header class="tag-header">
<h1 class="tag-title">{label}</h1>
<p class="tag-count">
{photos.length}
{photos.length === 1 ? "photo" : "photos"}
</p>
</header>
<div class="photo-grid" id="photo-grid">
{
photos.map((photo, i) => {
const ar = photo.image.width / photo.image.height;
return (
<div
class="photo-item"
data-ar={ar}
data-batch={Math.floor(i / BATCH_SIZE)}
data-hidden={i >= BATCH_SIZE ? "true" : undefined}
>
<a
href={`/photos/collections/${photo.collectionSlug}/${photo.sidecar.id}`}
class="photo-link"
>
<Image
src={photo.image}
alt={photo.sidecar.alt}
width={800}
height={Math.round(800 / ar)}
loading="lazy"
class="photo-img"
/>
</a>
</div>
);
})
}
</div>
<div id="sentinel" aria-hidden="true"></div>
<div id="load-status" class="load-status" aria-live="polite"></div>
</main>
</div>
</BaseLayout>
<script>
import justifiedLayout from "justified-layout";
const grid = document.getElementById("photo-grid");
if (grid) {
const BATCH_SIZE = 15;
const TARGET_ROW_HEIGHT = 280;
const BOX_SPACING = 8;
const allItems = Array.from(
grid.querySelectorAll<HTMLElement>(".photo-item"),
);
const sentinel = document.getElementById("sentinel");
const status = document.getElementById("load-status");
let currentBatch = 0;
const totalBatches =
Math.ceil(
allItems.filter((el) => el.hasAttribute("data-hidden")).length /
BATCH_SIZE,
) + 1;
function applyLayout() {
if (window.innerWidth <= 640) {
grid!.style.height = "";
grid!.style.position = "";
for (const item of allItems) {
if (!item.hasAttribute("data-hidden")) item.style.cssText = "";
}
return;
}
const visibleItems = allItems.filter(
(el) => !el.hasAttribute("data-hidden"),
);
const ratios = visibleItems.map((el) => Number(el.dataset.ar));
const result = justifiedLayout(ratios, {
containerWidth: grid!.offsetWidth,
targetRowHeight: TARGET_ROW_HEIGHT,
boxSpacing: BOX_SPACING,
containerPadding: 0,
});
grid!.style.position = "relative";
grid!.style.height = `${result.containerHeight}px`;
for (let i = 0; i < visibleItems.length; i++) {
const box = result.boxes[i];
const item = visibleItems[i];
if (!box || !item) continue;
item.style.position = "absolute";
item.style.top = `${box.top}px`;
item.style.left = `${box.left}px`;
item.style.width = `${box.width}px`;
item.style.height = `${box.height}px`;
}
}
function revealNextBatch() {
currentBatch++;
const toReveal = allItems.filter(
(el) =>
el.hasAttribute("data-hidden") &&
Number(el.dataset.batch) === currentBatch,
);
if (toReveal.length === 0) {
sentinel?.remove();
return;
}
if (status) status.textContent = "Loading more photos…";
for (const el of toReveal) {
el.removeAttribute("data-hidden");
el.classList.add("is-visible");
}
applyLayout();
if (currentBatch >= totalBatches) sentinel?.remove();
if (status) status.textContent = "";
}
let resizeTimer: ReturnType<typeof setTimeout>;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(applyLayout, 150);
});
applyLayout();
if (sentinel) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) revealNextBatch();
},
{ rootMargin: "400px" },
);
observer.observe(sentinel);
}
}
</script>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #111;
color: #f5f5f3;
}
.main {
max-width: var(--width-wide);
margin: 0 auto;
padding: 1.5rem;
width: 100%;
}
.tag-header {
margin-bottom: 1.5rem;
}
.tag-title {
font-size: 1.5rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.tag-count {
font-size: 0.85rem;
color: #888;
margin: 0;
}
.photo-grid {
position: relative;
}
.photo-item {
border-radius: 2px;
overflow: hidden;
}
.photo-item[data-hidden] {
display: none;
}
.photo-item.is-visible {
animation: fadeIn 0.4s ease forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.photo-link {
display: block;
width: 100%;
height: 100%;
overflow: hidden;
}
.photo-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
.photo-link:hover .photo-img {
transform: scale(1.03);
}
#sentinel {
height: 1px;
margin-top: 2rem;
}
.load-status {
text-align: center;
padding: 1rem;
font-size: 0.85rem;
color: #666;
min-height: 2rem;
}
.page :global(.footer a) {
color: #f5f5f3;
}
@media (max-width: 640px) {
.main {
padding: 0.25rem 0.75rem;
}
.photo-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: auto !important;
position: static !important;
}
.photo-item {
position: static !important;
width: 100% !important;
height: auto !important;
}
.photo-img {
height: auto;
object-fit: initial;
}
}
</style>

View file

@ -0,0 +1,142 @@
---
import PhotosSubNav from "@/components/PhotosSubNav.astro";
import BaseLayout from "@/layouts/BaseLayout.astro";
import type { PhotoSidecar } from "@/lib/collections";
function tagToSlug(tag: string): string {
return tag
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}
const sidecars = import.meta.glob<PhotoSidecar>(
"/src/content/photos/collections/**/*.json",
{ eager: true },
);
const tagMap = new Map<string, { label: string; count: number }>();
for (const sidecar of Object.values(sidecars)) {
for (const tag of sidecar.tags) {
const slug = tagToSlug(tag);
const entry = tagMap.get(slug);
if (entry) {
entry.count++;
} else {
tagMap.set(slug, { label: tag, count: 1 });
}
}
}
const tags = [...tagMap.entries()]
.map(([slug, { label, count }]) => ({ slug, label, count }))
.sort((a, b) => a.label.localeCompare(b.label));
---
<BaseLayout
title="Tags"
description="Browse photos by tag — travel and street photography by Adrian Altner."
navDesktopHidden={true}
footerDark={true}
>
<div class="page">
<main class="main">
<PhotosSubNav />
<header class="page-header">
<h1 class="page-title">Tags</h1>
<p class="page-meta">{tags.length} tags</p>
</header>
<ul class="tag-list" role="list">
{
tags.map(({ slug, label, count }) => (
<li>
<a href={`/photos/tags/${slug}`} class="tag-pill">
{label}
<span class="tag-count">{count}</span>
</a>
</li>
))
}
</ul>
</main>
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #111;
color: #f5f5f3;
}
.main {
max-width: var(--width-wide);
margin: 0 auto;
padding: 1.5rem;
width: 100%;
}
.page-header {
margin-bottom: 1.5rem;
}
.page-title {
font-size: 1.5rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.page-meta {
font-size: 0.85rem;
color: #888;
margin: 0;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #aaa;
border: 1px solid #333;
border-radius: 3px;
padding: 0.3rem 0.65rem;
text-decoration: none;
transition:
color 0.2s,
border-color 0.2s;
}
.tag-pill:hover {
color: #f5f5f3;
border-color: #555;
}
.tag-count {
font-size: 0.75rem;
color: #555;
}
.page :global(.footer a) {
color: #f5f5f3;
}
@media (max-width: 640px) {
.main {
padding: 0.75rem;
}
}
</style>

119
src/pages/privacy-policy.md Normal file
View file

@ -0,0 +1,119 @@
---
layout: ../layouts/ProseLayout.astro
title: Privacy Policy
description: Privacy policy and data protection information for adrian-altner.com.
---
# Privacy Policy
*Last updated: March 2026 (added: hosting provider, interactive map)*
---
## 1. Controller
The controller responsible for data processing on this website within the meaning of the General Data Protection Regulation (GDPR) is:
<strong><span class="r" data-obf="cmVudGxBIG5haXJkQQ=="></span></strong><br>
**Email:** <a class="r" data-obf="bW9je3RvZH1yZW50bGEtbmFpcmRhe3RhfXllaA==" data-obf-href="bWFpbHRvOmhleUBhZHJpYW4tYWx0bmVyLmNvbQ=="></a>
---
## 2. General Information on Data Processing
We take the protection of your personal data very seriously. We treat your personal data confidentially and in accordance with the statutory data protection regulations and this privacy policy.
As a rule, it is possible to use this website without providing personal data. If personal data (e.g. name or email address) is collected on this website, this is done on a voluntary basis where possible. This data will not be passed on to third parties without your explicit consent.
---
## 3. Data Collection When Visiting This Website
**Server Log Files**
When you visit this website, our hosting provider automatically collects and stores information in server log files that your browser transmits to us. This includes:
- Your IP address
- Date and time of the request
- Content of the request (specific page)
- Access status / HTTP status code
- Amount of data transferred
- Website from which the request originated (referrer)
- Browser type and version
- Operating system
This data is not merged with other data sources. The legal basis for this processing is Art. 6(1)(f) GDPR (legitimate interest). Our legitimate interest lies in the technically error-free presentation and optimisation of this website.
The server log files are stored for a maximum of 7 days and then deleted, unless further retention is required for security purposes.
---
## 4. Hosting
This website is hosted by **Hetzner Online GmbH**, Industriestr. 25, 91710 Gunzenhausen, Germany (<https://www.hetzner.com>). All data processing takes place on servers within the European Union, ensuring a high level of data protection in accordance with GDPR.
When you visit this website, Hetzner may process technical connection data (e.g. IP address) as part of server operations. We have concluded a data processing agreement (DPA) with Hetzner in accordance with Art. 28 GDPR.
---
## 5. Interactive Map (Stadia Maps)
The photo map page (`/photos/map`) uses map tiles provided by **Stadia Maps** (Stadia Maps, LLC, <https://stadiamaps.com>). When you visit this page, your browser loads map tiles directly from Stadia Maps servers located in the European Union (`tiles-eu.stadiamaps.com`). This causes your IP address and browser information to be transmitted to Stadia Maps.
Stadia Maps does not track users across websites and processes data in accordance with GDPR. For details, see their privacy policy: <https://stadiamaps.com/privacy-policy/>. A Data Processing Addendum (DPA) is available at <https://stadiamaps.com/legal/data-processing-addendum/>.
The legal basis for this processing is Art. 6(1)(f) GDPR (legitimate interest in providing an interactive map of photo locations). Map tile requests are only made when you actively visit the map page.
The map data is provided by **OpenStreetMap** contributors (<https://www.openstreetmap.org/copyright>, ODbL license) and processed into vector tiles by **OpenMapTiles** (<https://openmaptiles.org>).
---
## 6. Cookies
This website does not use cookies, tracking technologies, or any analytics tools. No information is stored on your device when you visit this website.
---
## 7. Contact
If you contact us by email, the data you provide (your email address and the content of your message) will be stored for the purpose of processing your inquiry and in case of follow-up questions. This data will not be passed on to third parties without your consent.
The legal basis for processing this data is Art. 6(1)(f) GDPR (legitimate interest in responding to your inquiry) or, where your inquiry aims at concluding a contract, Art. 6(1)(b) GDPR.
You may object to the storage of your data at any time. In this case, the conversation cannot be continued. All personal data stored in the course of the contact will be deleted.
---
## 8. Your Rights
You have the following rights regarding your personal data:
- **Right of access** (Art. 15 GDPR) you may request information about the personal data we process about you.
- **Right to rectification** (Art. 16 GDPR) you may request correction of inaccurate or incomplete data.
- **Right to erasure** (Art. 17 GDPR) you may request deletion of your data under certain conditions.
- **Right to restriction of processing** (Art. 18 GDPR) you may request that processing of your data be restricted.
- **Right to data portability** (Art. 20 GDPR) you may request to receive your data in a structured, machine-readable format.
- **Right to object** (Art. 21 GDPR) you may object to processing based on legitimate interests at any time.
To exercise any of these rights, please contact us at the address provided above.
---
## 9. Right to Lodge a Complaint
You have the right to lodge a complaint with a data protection supervisory authority. The supervisory authority competent for you depends on your place of residence or the location of the alleged infringement.
In Germany, the competent authority is typically the data protection authority of the federal state in which you reside. A list of all German supervisory authorities is available at:
<https://www.bfdi.bund.de/DE/Service/Anschriften/Laender/Laender-node.html>
---
## 10. Data Security
This website uses SSL/TLS encryption for security reasons and to protect the transmission of confidential content. You can recognise an encrypted connection by the padlock icon in your browser address bar and by the `https://` prefix.
---
## 11. Changes to This Privacy Policy
We reserve the right to update this privacy policy to reflect changes in legal requirements or changes to our services. The current version of the privacy policy is always available on this page. Please check this page regularly.

View file

@ -0,0 +1,236 @@
---
import { Image } from "astro:assets";
import { getCollection, render } from "astro:content";
import BaseLayout from "@/layouts/BaseLayout.astro";
export async function getStaticPaths() {
const projects = await getCollection("projects", ({ data }) => !data.draft);
return projects.map((project) => ({
params: { slug: project.id },
props: { project },
}));
}
const { project } = Astro.props;
const { Content } = await render(project);
function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function formatDate(date: Date) {
return date.toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
});
}
---
<BaseLayout title={project.data.title} description={project.data.description}>
<div class="page">
<main class="main">
<a href="/projects" class="back">← Projects</a>
<article>
<header class="project-header">
<h1 class="project-title">{project.data.title}</h1>
<time class="project-date" datetime={project.data.publishDate.toISOString()}>
{formatDate(project.data.publishDate)}
</time>
<div class="project-links">
{project.data.url && (
<a href={project.data.url} class="project-link" target="_blank" rel="noopener noreferrer">
Live ↗
</a>
)}
{project.data.github && (
<a href={project.data.github} class="project-link" target="_blank" rel="noopener noreferrer">
GitHub ↗
</a>
)}
</div>
{project.data.tags.length > 0 && (
<div class="project-tags">
{project.data.tags.map((tag) => (
<span class="tag">{capitalize(tag)}</span>
))}
</div>
)}
</header>
{project.data.cover && (
<Image class="project-cover" src={project.data.cover} alt={project.data.coverAlt ?? ""} />
)}
<div class="prose">
<Content />
</div>
</article>
<a href="/projects" class="back">← Projects</a>
</main>
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #f5f5f3;
color: #111;
}
.main {
max-width: var(--width-prose);
margin: 0 auto;
padding: 2rem 2rem 4rem;
width: 100%;
}
.back {
display: inline-block;
font-size: 0.9rem;
color: #555;
text-decoration: none;
border-bottom: 1px solid #ccc;
padding-bottom: 1px;
margin-bottom: 1.5rem;
}
.back:hover {
color: var(--accent);
border-color: var(--accent);
}
article + .back {
margin-bottom: 0;
margin-top: 1rem;
}
.project-header {
margin-bottom: 2rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid #e0e0de;
}
.project-title {
font-size: clamp(1.5rem, 4vw, 2.25rem);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.2;
margin-bottom: 0.5rem;
}
.project-date {
font-size: 0.875rem;
color: #888;
display: block;
}
.project-links {
display: flex;
gap: 1rem;
margin-top: 0.75rem;
}
.project-link {
font-size: 0.875rem;
color: var(--accent);
text-decoration: none;
}
.project-link:hover {
text-decoration: underline;
}
.project-tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.75rem;
}
.tag {
font-size: 0.75rem;
color: #666;
background: #e8e8e6;
padding: 0.15em 0.55em;
border-radius: 3px;
}
.project-cover {
width: 100%;
height: auto;
border-radius: 6px;
display: block;
margin-bottom: 2rem;
}
.prose {
font-size: 1rem;
line-height: 1.7;
color: #333;
}
.prose :global(h2) {
font-size: 1.4rem;
font-weight: 500;
margin: 2rem 0 0.75rem;
color: #111;
}
.prose :global(h3) {
font-size: 1.15rem;
font-weight: 500;
margin: 1.5rem 0 0.5rem;
color: #111;
}
.prose :global(p) {
margin-bottom: 1.25rem;
}
.prose :global(a) {
color: var(--accent);
text-underline-offset: 3px;
}
.prose :global(blockquote) {
border-left: 3px solid var(--accent);
margin: 1.5rem 0;
padding: 0.25rem 0 0.25rem 1.25rem;
color: #555;
font-style: italic;
}
.prose :global(code) {
font-family: ui-monospace, monospace;
font-size: 0.875em;
background: #e8e8e6;
padding: 0.1em 0.35em;
border-radius: 3px;
}
.prose :global(pre) {
background: #2b2b2b;
color: #e0e0e0;
padding: 1.25rem;
border-radius: 6px;
overflow-x: auto;
margin: 1.5rem 0;
}
.prose :global(pre code) {
background: none;
padding: 0;
font-size: 0.875rem;
}
.prose :global(ul),
.prose :global(ol) {
padding-left: 1.5rem;
margin-bottom: 1.25rem;
}
.prose :global(li) {
margin-bottom: 0.4rem;
}
</style>

View file

@ -0,0 +1,209 @@
---
import { Image } from "astro:assets";
import { getCollection } from "astro:content";
import BaseLayout from "@/layouts/BaseLayout.astro";
export async function getStaticPaths() {
const categories = await getCollection("projects_categories");
return categories.map((category) => ({
params: { slug: category.id },
props: { category },
}));
}
const { category } = Astro.props;
const projects = (
await getCollection(
"projects",
({ data }) => !data.draft && data.category?.id === category.id,
)
).sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf());
function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
---
<BaseLayout
title={`Projects: ${category.data.title}`}
description={category.data.description}
>
<div class="page">
<main class="main">
<a href="/projects" class="back">← Projects</a>
<h1 class="heading">{category.data.title}</h1>
{category.data.description && (
<p class="description">{category.data.description}</p>
)}
{category.data.cover && (
<Image class="category-cover" src={category.data.cover} alt={category.data.coverAlt ?? ""} />
)}
{
projects.length === 0 ? (
<p class="empty">Keine Projekte in dieser Kategorie.</p>
) : (
<ul class="list">
{projects.map((project) => (
<li class="item">
<div class="item__meta">
<a href={`/projects/${project.id}`} class="item__title">
{project.data.title}
</a>
<div class="item__links">
{project.data.url && (
<a href={project.data.url} class="item__link" target="_blank" rel="noopener noreferrer">
Live ↗
</a>
)}
{project.data.github && (
<a href={project.data.github} class="item__link" target="_blank" rel="noopener noreferrer">
GitHub ↗
</a>
)}
</div>
</div>
{project.data.description && (
<p class="item__description">{project.data.description}</p>
)}
{project.data.tags.length > 0 && (
<div class="item__tags">
{project.data.tags.map((tag) => (
<span class="tag">{capitalize(tag)}</span>
))}
</div>
)}
</li>
))}
</ul>
)
}
</main>
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #f5f5f3;
color: #111;
}
.main {
max-width: var(--width-prose);
margin: 0 auto;
padding: 2rem 2rem 4rem;
width: 100%;
}
.heading {
font-size: 1.75rem;
font-weight: 400;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
.description {
color: #555;
font-size: 0.95rem;
margin-bottom: 2rem;
}
.category-cover {
width: 100%;
height: auto;
border-radius: 6px;
display: block;
margin-bottom: 2rem;
}
.empty {
color: #888;
font-size: 0.95rem;
}
.back {
display: inline-block;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: #555;
text-decoration: none;
}
.back:hover {
color: var(--accent);
}
.list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
}
.item {
padding: 1.25rem 0;
border-bottom: 1px solid #e0e0de;
}
.item:first-child {
border-top: 1px solid #e0e0de;
}
.item__meta {
display: flex;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
}
.item__title {
font-size: 1rem;
font-weight: 500;
color: #111;
text-decoration: none;
}
.item__title:hover {
color: var(--accent);
}
.item__links {
display: flex;
gap: 0.75rem;
}
.item__link {
font-size: 0.8rem;
color: #888;
text-decoration: none;
}
.item__link:hover {
color: var(--accent);
}
.item__description {
font-size: 0.9rem;
color: #555;
margin-top: 0.4rem;
line-height: 1.5;
}
.item__tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.75rem;
}
.tag {
font-size: 0.75rem;
color: #666;
background: #e8e8e6;
padding: 0.15em 0.55em;
border-radius: 3px;
}
</style>

View file

@ -0,0 +1,172 @@
---
import { getCollection } from "astro:content";
import BaseLayout from "@/layouts/BaseLayout.astro";
const projects = (
await getCollection("projects", ({ data }) => !data.draft)
).sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf());
function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
---
<BaseLayout title="Projects" description="Projekte von Adrian Altner.">
<div class="page">
<main class="main">
<a href="/" class="back">← Home</a>
<h1 class="heading">Projects</h1>
{
projects.length === 0 ? (
<p class="empty">Noch keine Projekte vorhanden.</p>
) : (
<ul class="list">
{projects.map((project) => (
<li class="item">
<div class="item__meta">
<a href={`/projects/${project.id}`} class="item__title">
{project.data.title}
</a>
<div class="item__links">
{project.data.url && (
<a href={project.data.url} class="item__link" target="_blank" rel="noopener noreferrer">
Live ↗
</a>
)}
{project.data.github && (
<a href={project.data.github} class="item__link" target="_blank" rel="noopener noreferrer">
GitHub ↗
</a>
)}
</div>
</div>
{project.data.description && (
<p class="item__description">{project.data.description}</p>
)}
{project.data.tags.length > 0 && (
<div class="item__tags">
{project.data.tags.map((tag) => (
<span class="tag">{capitalize(tag)}</span>
))}
</div>
)}
</li>
))}
</ul>
)
}
</main>
</div>
</BaseLayout>
<style>
.page {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
background: #f5f5f3;
color: #111;
}
.main {
max-width: var(--width-prose);
margin: 0 auto;
padding: 2rem 2rem 4rem;
width: 100%;
}
.heading {
font-size: 1.75rem;
font-weight: 400;
letter-spacing: -0.02em;
margin-bottom: 2.5rem;
}
.empty {
color: #888;
font-size: 0.95rem;
}
.back {
display: inline-block;
margin-bottom: 1.5rem;
font-size: 0.9rem;
color: #555;
text-decoration: none;
}
.back:hover {
color: var(--accent);
}
.list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
}
.item {
padding: 1.25rem 0;
border-bottom: 1px solid #e0e0de;
}
.item:first-child {
border-top: 1px solid #e0e0de;
}
.item__meta {
display: flex;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
}
.item__title {
font-size: 1rem;
font-weight: 500;
color: #111;
text-decoration: none;
}
.item__title:hover {
color: var(--accent);
}
.item__links {
display: flex;
gap: 0.75rem;
}
.item__link {
font-size: 0.8rem;
color: #888;
text-decoration: none;
}
.item__link:hover {
color: var(--accent);
}
.item__description {
font-size: 0.9rem;
color: #555;
margin-top: 0.4rem;
line-height: 1.5;
}
.item__tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.75rem;
}
.tag {
font-size: 0.75rem;
color: #666;
background: #e8e8e6;
padding: 0.15em 0.55em;
border-radius: 3px;
}
</style>

71
src/pages/rss.xml.ts Normal file
View file

@ -0,0 +1,71 @@
import { getCollection } from "astro:content";
import rss from "@astrojs/rss";
import type { APIContext, ImageMetadata } from "astro";
import type { PhotoSidecar } from "@/lib/collections";
import { buildCollectionPhotos, collectionSlug } from "@/lib/collections";
export async function GET(context: APIContext) {
const articles = await getCollection("blog", ({ data }) => !data.draft);
const notes = await getCollection("notes", ({ data }) => !data.draft);
const links = await getCollection("links_json");
const allCollections = await getCollection(
"collections_photos",
({ data }) => !data.draft,
);
const sidecars = import.meta.glob<PhotoSidecar>(
"/src/content/photos/collections/**/*.json",
{ eager: true },
);
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
"/src/content/photos/collections/**/*.jpg",
{ eager: true },
);
const articleItems = articles.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.publishDate,
link: `/blog/${post.id}/`,
}));
const noteItems = notes.map((note) => ({
title: note.data.title,
description: note.data.description ?? note.data.title,
pubDate: note.data.publishDate,
link: `/notes/${note.id}/`,
}));
const linkItems = links.map((link) => ({
title: link.data.title,
description: link.data.description ?? link.data.title,
pubDate: link.data.publishDate,
link: `/links/${link.id}/`,
}));
const photoItems = allCollections.flatMap((col) => {
const slug = collectionSlug(col);
return buildCollectionPhotos(sidecars, imageModules, slug).map((p) => ({
title: p.sidecar.title[0] ?? p.sidecar.id,
description: p.sidecar.alt,
pubDate: new Date(p.sidecar.date),
link: `/photos/collections/${slug}/${p.sidecar.id}/`,
}));
});
const items = [
...articleItems,
...noteItems,
...linkItems,
...photoItems,
].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf());
return rss({
title: "Adrian Altner",
description:
"Passionate photographer and avid traveler, bridging a deep technological curiosity with a keen eye for the latest digital innovations.",
site: context.site ?? new URL("https://adrian-altner.com"),
items,
customData: "<language>en-us</language>",
});
}

24
src/pages/rss/blog.xml.ts Normal file
View file

@ -0,0 +1,24 @@
import { getCollection } from "astro:content";
import rss from "@astrojs/rss";
import type { APIContext } from "astro";
export async function GET(context: APIContext) {
const articles = await getCollection("blog", ({ data }) => !data.draft);
const items = articles
.sort((a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf())
.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.publishDate,
link: `/blog/${post.id}/`,
}));
return rss({
title: "Adrian Altner — Blog",
description: "Articles on technology, web development, and photography.",
site: context.site ?? new URL("https://adrian-altner.com"),
items,
customData: "<language>en-us</language>",
});
}

Some files were not shown because too many files have changed in this diff Show more