init
17
.claude/launch.json
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
||||||
|
pnpm check:fix
|
||||||
8
.vscode/extensions.json
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
3
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
- sharp
|
||||||
BIN
public/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
public/avatar.jpg
Normal file
|
After Width: | Height: | Size: 482 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 970 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/fonts/Exo2-Italic-VariableFont_wght.ttf
Normal file
BIN
public/fonts/Exo2-VariableFont_wght.ttf
Normal file
BIN
public/fonts/JetBrainsMono-Italic-VariableFont_wght.ttf
Normal file
BIN
public/fonts/JetBrainsMono-VariableFont_wght.ttf
Normal file
9
pwa-assets.config.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/assets/images/about.jpg
Normal file
|
After Width: | Height: | Size: 403 KiB |
BIN
src/assets/images/intro.jpg
Normal file
|
After Width: | Height: | Size: 482 KiB |
BIN
src/assets/images/me-bangkok.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
168
src/components/BlogPostItem.astro
Normal 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>
|
||||||
144
src/components/BlogPostSeries.astro
Normal 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
|
|
@ -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>
|
||||||
9
src/components/JsonLd.astro
Normal 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
|
|
@ -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>
|
||||||
423
src/components/PhotoDetail.astro
Normal 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>
|
||||||
97
src/components/PhotosSubNav.astro
Normal 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>
|
||||||
113
src/components/TableOfContents.astro
Normal 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>
|
||||||
241
src/components/WebMentions.astro
Normal 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>
|
||||||
167
src/components/links/LinkCard.astro
Normal 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>
|
||||||
397
src/components/links/LinksLayout.astro
Normal 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
|
|
@ -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,
|
||||||
|
};
|
||||||
129
src/layouts/BaseLayout.astro
Normal 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>
|
||||||
118
src/layouts/ProseLayout.astro
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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);
|
||||||
|
}
|
||||||
180
src/lib/remark-obsidian-links.mjs
Normal 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|alt text]] → 
|
||||||
|
*
|
||||||
|
* 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
|
|
@ -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
|
|
@ -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>
|
||||||
222
src/pages/archives/index.astro
Normal 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>
|
||||||
477
src/pages/blog/[...slug].astro
Normal 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>
|
||||||
142
src/pages/blog/category/[slug].astro
Normal 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
|
|
@ -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
|
|
@ -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 & 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
|
|
@ -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
|
|
@ -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>
|
||||||
90
src/pages/links/collection/[collection].astro
Normal 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>
|
||||||
118
src/pages/links/collection/index.astro
Normal 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>
|
||||||
50
src/pages/links/index.astro
Normal 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>
|
||||||
78
src/pages/links/tag/[tag].astro
Normal 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>
|
||||||
89
src/pages/links/tag/index.astro
Normal 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>
|
||||||
229
src/pages/notes/[...slug].astro
Normal 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
|
|
@ -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>
|
||||||
23
src/pages/og/blog/[...slug].png.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
32
src/pages/og/notes/[...slug].png.ts
Normal 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" } });
|
||||||
|
}
|
||||||
133
src/pages/og/photos/[...slug].png.ts
Normal 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" } });
|
||||||
|
}
|
||||||
653
src/pages/photos/collections/[...slug].astro
Normal 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>
|
||||||
165
src/pages/photos/collections/index.astro
Normal 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>
|
||||||
352
src/pages/photos/index.astro
Normal 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
|
|
@ -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:
|
||||||
|
'© <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a> © <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> © <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, """)}" 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>
|
||||||
343
src/pages/photos/stats.astro
Normal 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: "25–35mm", min: 25, max: 36 },
|
||||||
|
{ label: "36–50mm", min: 36, max: 51 },
|
||||||
|
{ label: "51–85mm", min: 51, max: 86 },
|
||||||
|
{ label: "86–135mm", 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>
|
||||||
340
src/pages/photos/tags/[slug].astro
Normal 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>
|
||||||
142
src/pages/photos/tags/index.astro
Normal 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
|
|
@ -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.
|
||||||
236
src/pages/projects/[slug].astro
Normal 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>
|
||||||
209
src/pages/projects/category/[slug].astro
Normal 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>
|
||||||
172
src/pages/projects/index.astro
Normal 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
|
|
@ -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
|
|
@ -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>",
|
||||||
|
});
|
||||||
|
}
|
||||||