Compare commits
99 Commits
f02a11afd2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
467f9a4e71
|
|||
|
8bd794bccb
|
|||
|
f52d6b4d4b
|
|||
|
9b5cfe5e49
|
|||
|
9fe9d95e36
|
|||
|
cd7912fa8f
|
|||
|
fb54f6907f
|
|||
|
94c8212078
|
|||
|
ac76bfba34
|
|||
|
0f6c50f854
|
|||
|
8a67f5fba8
|
|||
|
b49a299371
|
|||
|
f1c0304b14
|
|||
|
164fdb2916
|
|||
|
ae4adc4023
|
|||
|
17ccfa1b41
|
|||
|
dfc3142eeb
|
|||
|
fdea8416a0
|
|||
|
399d57217a
|
|||
|
0e70e30738
|
|||
|
1918d240db
|
|||
|
316a340494
|
|||
|
59b4630746
|
|||
|
8459327717
|
|||
|
a4c2efe4f3
|
|||
|
cb16b25444
|
|||
|
583d1b724c
|
|||
|
3fc539e6fb
|
|||
|
c2862f4c21
|
|||
|
38c3df8187
|
|||
|
530308033b
|
|||
|
c155fc33b4
|
|||
|
a2869c1d87
|
|||
|
a8902dcf11
|
|||
|
c9b2773de4
|
|||
|
909b02049d
|
|||
|
d4a8288ecf
|
|||
|
4114b0109f
|
|||
|
169f8798f3
|
|||
|
8f843833e0
|
|||
|
35872d731a
|
|||
|
2347a02fcb
|
|||
|
5540d37c72
|
|||
|
6483c55fce
|
|||
|
603240bf93
|
|||
|
53695b8244
|
|||
|
48d971c216
|
|||
|
bb1d494c48
|
|||
|
896e42f5d9
|
|||
|
7bede8cd64
|
|||
|
3b524e9c70
|
|||
|
59f40b9f05
|
|||
|
7b7fbed472
|
|||
|
e3ccd96c7b
|
|||
|
a1aa722512
|
|||
|
706dedbdc5
|
|||
|
2a8721fde0
|
|||
|
3331536ddd
|
|||
|
d957c746d5
|
|||
|
cfdd58fb18
|
|||
|
2c3886296c
|
|||
|
c082da700d
|
|||
|
fe08e06a02
|
|||
|
fd2d8a58d9
|
|||
|
f3d16d5187
|
|||
|
928774084f
|
|||
|
8c09b0b2f4
|
|||
|
5ac56db46c
|
|||
|
5fd8027d3e
|
|||
|
e87b8bd864
|
|||
|
eeed31aaf4
|
|||
|
e59e9679da
|
|||
|
685f4cc892
|
|||
|
60e651de72
|
|||
|
98417046bc
|
|||
|
244050fa75
|
|||
|
0814803fc7
|
|||
|
eb2ffac536
|
|||
|
9a97e41c28
|
|||
|
109ac8e13a
|
|||
|
6275b526d8
|
|||
|
6456804fc3
|
|||
|
585c03a11e
|
|||
|
0372c50084
|
|||
|
065c435d8b
|
|||
|
1bceabe967
|
|||
|
86c72c2dc3
|
|||
|
4623d7a1f7
|
|||
|
d59cc0a732
|
|||
|
ecbd24d7a4
|
|||
|
7e33ea833e
|
|||
|
b10634f831
|
|||
|
e85a2508e8
|
|||
|
096d6e2868
|
|||
|
68b078c146
|
|||
|
2af845bfc6
|
|||
|
6875e8762e
|
|||
|
4ed0251bb4
|
|||
|
6871e703e8
|
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Pre-commit: normalise hike track altitudes.
|
||||
#
|
||||
# Any added/modified src/content/hikes/<slug>/track.gpx is run through
|
||||
# scripts/fix-altitudes.ts (swisstopo swissALTI3D heights at each exact point)
|
||||
# and re-staged, so committed tracks always carry corrected elevation instead of
|
||||
# raw phone-GPS noise. Commits that don't touch a track.gpx are a fast no-op.
|
||||
#
|
||||
# Network failures degrade gracefully: fix-altitudes keeps a point's original
|
||||
# elevation when it can't resolve it, exits 0, and the commit proceeds.
|
||||
#
|
||||
# Caveat: a touched track.gpx is re-staged in full, so partial (`git add -p`)
|
||||
# staging of a track.gpx won't survive. These files are generated, so that's fine.
|
||||
set -euo pipefail
|
||||
|
||||
# Staged Added/Copied/Modified track.gpx paths, NUL-delimited so non-ASCII slug
|
||||
# dirs (e.g. "…pfäffikersee") come through as raw bytes, unquoted.
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do
|
||||
case "$f" in
|
||||
src/content/hikes/*/track.gpx) files+=("$f") ;;
|
||||
esac
|
||||
done < <(git diff --cached --name-only -z --diff-filter=ACM -- src/content/hikes)
|
||||
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Map each path to its <slug> (the directory under src/content/hikes/).
|
||||
slugs=()
|
||||
for f in "${files[@]}"; do
|
||||
s=${f#src/content/hikes/}
|
||||
slugs+=("${s%/track.gpx}")
|
||||
done
|
||||
|
||||
echo "[pre-commit] fix-altitudes: ${slugs[*]}"
|
||||
pnpm exec vite-node scripts/fix-altitudes.ts "${slugs[@]}"
|
||||
|
||||
# Re-stage so the corrected elevations are what actually gets committed.
|
||||
git add -- "${files[@]}"
|
||||
@@ -7,6 +7,7 @@ node_modules
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
.env_*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -15,6 +16,34 @@ data/usda/
|
||||
# Loyalty-card barcodes (regenerated by scripts/generate-loyalty-cards.ts from env)
|
||||
static/shopping/supercard.svg
|
||||
static/shopping/cumulus.svg
|
||||
# Hikes build outputs (regenerated by scripts/build-hikes.ts at prebuild)
|
||||
static/hikes/
|
||||
hikes-assets/
|
||||
src/lib/data/hikes.generated.ts
|
||||
# Tile-proxy build artefacts + secrets (the source tree itself is tracked).
|
||||
# The binary is dropped next to Cargo.toml by `make build`; its name happens
|
||||
# to collide with the directory it lives in, so the path is fully qualified
|
||||
# here to avoid the nested-gitignore quirk that previously hid the source.
|
||||
/tile-proxy/tile-proxy
|
||||
/tile-proxy/target/
|
||||
/tile-proxy/.env
|
||||
# Private image build outputs (regenerated by scripts/build-private-images.ts).
|
||||
# Sources are private + large, so they're ignored too — only the README is kept.
|
||||
private-assets/
|
||||
src/lib/data/privateImages.generated.ts
|
||||
src/lib/assets/private-images/*
|
||||
!src/lib/assets/private-images/README.md
|
||||
# Build-script disk caches (Swisstopo identify, BRouter responses, ...)
|
||||
scripts/.cache/
|
||||
# Loose working-tree scratch files (notes, photos, prototypes) that aren't
|
||||
# part of the committed source.
|
||||
/HIKES_PLAN.md
|
||||
/additional_apologetics.md
|
||||
/header_jellyfin.html
|
||||
/person-hiking.svg
|
||||
/PXL_*.jpg
|
||||
/PXL_*.MP.jpg
|
||||
src-tauri/icons/_safezone_template_*.png
|
||||
src-tauri/target/
|
||||
src-tauri/*.keystore
|
||||
# Android: ignore build output and caches, track source files
|
||||
@@ -22,3 +51,9 @@ src-tauri/gen/android/.gradle/
|
||||
src-tauri/gen/android/app/build/
|
||||
src-tauri/gen/android/buildSrc/.gradle/
|
||||
src-tauri/gen/android/buildSrc/build/
|
||||
|
||||
# Hike content: track the writing (index.svx), route (track.gpx) and icons,
|
||||
# but not the source photos (huge; re-encoded into static assets at build time).
|
||||
src/content/hikes/*/images/
|
||||
src/content/hikes/*/private/
|
||||
src/content/hikes/*/cover.*
|
||||
|
||||
@@ -173,7 +173,6 @@ Generated: 2025-11-18
|
||||
- `EditButton.svelte` - Edit button (floating)
|
||||
- `FavoriteButton.svelte` - Toggle favorite
|
||||
- `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category
|
||||
- `CardAdd.svelte` - Add recipe card placeholder
|
||||
- `FormSection.svelte` - Styled form section wrapper
|
||||
- `Header.svelte` - Page header
|
||||
- `UserHeader.svelte` - User-specific header
|
||||
@@ -190,7 +189,6 @@ Generated: 2025-11-18
|
||||
|
||||
#### Recipe-Specific Components
|
||||
- `Recipes.svelte` - Recipe list display
|
||||
- `RecipeEditor.svelte` - Recipe editing form
|
||||
- `RecipeNote.svelte` - Recipe notes display
|
||||
- `EditRecipe.svelte` - Edit recipe modal
|
||||
- `EditRecipeNote.svelte` - Edit recipe notes
|
||||
|
||||
@@ -24,10 +24,18 @@ Order = impact. Font items + app.html preload intentionally skipped.
|
||||
[x] BF graph (with trend line like weight graph) on /fitness/stats page. Emphasize relative changes, not absolute numbers in design (as we cannot trust those) (e.g., use start day of overview as 0% and then show +/- x % on the graph)
|
||||
[x] Workshop better names than "Measure" for the /fitness/measure route. It's about body data points (i.e., non-food related). What's a better, short name than "Measure" to capture the logging of weight, body composition, body part measurements, and period tracking?
|
||||
[x] on /fitness/stats/histoy/<part> for body measurement graphs, make the range reasonable. e.g., if we have 1 cm change, do not fill the entire y-height with 1 cm. Use reasonable padding for low ranges (i think we do something like htis already on the weight graph?)
|
||||
[ ] on /fitness/check-in, Make the Period ended button a lot more prominent in the period tracker component.
|
||||
[ ] swap heart emoji on recipe favorites to lucide icon
|
||||
[ ] coop and migros cards on shopping list for scanning
|
||||
[ ] login icon from lucide in header
|
||||
[x] on /fitness/check-in, Make the Period ended button a lot more prominent in the period tracker component.
|
||||
[x] swap heart emoji on recipe favorites to lucide icon
|
||||
[x] coop and migros cards on shopping list for scanning
|
||||
[x] login icon from lucide in header
|
||||
[ ] Investigate self-hosting BRouter
|
||||
[ ] Use the same color swisstopo map both for light and dark mode (currentyl only light mode)
|
||||
[ ] pre-compute required map tiles for all tiles on the route (and adjacent enough to be visibile by default on sane screen sizes) and create a fetch instruction for the server. (separate step: create a swiss-topo caching service which smoothly interpolates with non-switzerland service tiles for spots outside of switzerland)
|
||||
[ ] expand compatibility outside of switzerland with non-swiss topo map
|
||||
[ ] align design better with swizterland mobility
|
||||
[ ] allow for difficulty cardio, difficulty technique and T1-T6 labelling
|
||||
[ ] allow for Switzerland Mobility like hike icons (with alpine blue white blue, red white red, and yellow hiking shields as a fallback alternative)
|
||||
[ ] Add smoothing distance for elevation calculations on GPS-tracled workouts (3 meters? more?)
|
||||
|
||||
## Refactor Recipe Search Component
|
||||
|
||||
@@ -39,3 +47,45 @@ Refactor `src/lib/components/Search.svelte` to use the new `SearchInput.svelte`
|
||||
Files involved:
|
||||
- `src/lib/components/Search.svelte` - refactor to use SearchInput
|
||||
- `src/lib/components/SearchInput.svelte` - the reusable input component
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1. $app/stores → $app/state (biggest, most mechanical)
|
||||
Old: import { page } from '$app/stores' + $page.url.pathname
|
||||
New: import { page } from '$app/state' + page.url.pathname (no $, it's a rune now).
|
||||
Runes-based, smaller bundle (no store wrapper), cleaner SSR. Codebase has dozens of $app/stores imports — same kind
|
||||
of codemod-able migration as hrefs. Available since 2.12. $app/stores is deprecated.
|
||||
|
||||
2. Convert legacy stores to .svelte.ts rune state
|
||||
Files like $lib/stores/recipeTranslation.ts, $lib/stores/language.ts use writable(). Modern pattern: .svelte.ts files
|
||||
with $state() + exported getters/setters. Better TS inference, no $ prefix, no auto-subscription gotchas.
|
||||
|
||||
3. Remote functions for new API code ($app/server, since 2.27)
|
||||
Replaces hand-rolled +server.ts + client fetch with type-safe server functions called like normal funcs. Major
|
||||
refactor for existing /api/** (lots of files), so probably only adopt for new endpoints — not worth churning the
|
||||
existing ~80 API routes.
|
||||
|
||||
4. prerender = true audit
|
||||
Static-ish pages (faith catechesis, latin prayers, apologetics arguments) are great candidates. Skip-SSR for static
|
||||
content = faster cold loads + cheaper hosting. Currently nothing's prerendered — quick win where applicable.
|
||||
|
||||
5. @sveltejs/enhanced-img
|
||||
Transparent image optimization (responsive srcset, AVIF/WebP, blur placeholders) at build time. Recipe hero images
|
||||
and saint-day cards would benefit visibly. Drop-in via <enhanced:img src="...">.
|
||||
|
||||
6. {@attach} over use: (Svelte 5 attachments)
|
||||
Newer API for DOM-lifecycle hooks. Supports spread + library composition use: can't. Low urgency; only matters when
|
||||
writing new lifecycle code.
|
||||
|
||||
7. Shallow routing for modals/galleries
|
||||
pushState + <a> flow lets modals participate in history without full navigation. Useful if you ever add a
|
||||
recipe-image lightbox or apologetics-arg overlay. Net-new feature, not a migration.
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.57.6",
|
||||
"version": "1.96.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prepare": "git config core.hooksPath .githooks || true",
|
||||
"dev": "vite dev",
|
||||
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts",
|
||||
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts && pnpm exec vite-node scripts/build-hikes.ts && pnpm exec vite-node scripts/build-private-images.ts",
|
||||
"build": "vite build",
|
||||
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts && UV_THREADPOOL_SIZE=12 pnpm exec vite-node scripts/precompress.ts",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
@@ -22,12 +24,15 @@
|
||||
"test:e2e:docker:run": "docker run --rm --network host -v $(pwd):/app -w /app -e CI=true mcr.microsoft.com/playwright:v1.56.1-noble /bin/bash -c 'npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build && pnpm exec playwright test --project=chromium'",
|
||||
"deploy": "bash scripts/deploy.sh",
|
||||
"deploy:dry": "bash scripts/deploy.sh --dry-run",
|
||||
"photos:push": "bash scripts/hike-photos.sh push",
|
||||
"photos:pull": "bash scripts/hike-photos.sh pull",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/enhanced-img": "^0.10.4",
|
||||
"@sveltejs/kit": "^2.56.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tauri-apps/cli": "^2.10.1",
|
||||
@@ -39,6 +44,7 @@
|
||||
"@vitest/ui": "^4.1.2",
|
||||
"bwip-js": "^4.10.1",
|
||||
"jsdom": "^27.2.0",
|
||||
"mdsvex": "^0.12.7",
|
||||
"svelte": "^5.55.1",
|
||||
"svelte-check": "^4.4.6",
|
||||
"tslib": "^2.8.1",
|
||||
@@ -58,6 +64,7 @@
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"exifr": "^7.1.3",
|
||||
"file-type": "^19.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"mongoose": "^9.4.1",
|
||||
|
||||
@@ -38,6 +38,9 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
exifr:
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3
|
||||
file-type:
|
||||
specifier: ^19.0.0
|
||||
version: 19.6.0
|
||||
@@ -66,6 +69,9 @@ importers:
|
||||
'@sveltejs/adapter-auto':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))
|
||||
'@sveltejs/enhanced-img':
|
||||
specifier: ^0.10.4
|
||||
version: 0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(rollup@4.60.1)(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.56.1
|
||||
version: 2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
|
||||
@@ -99,6 +105,9 @@ importers:
|
||||
jsdom:
|
||||
specifier: ^27.2.0
|
||||
version: 27.2.0
|
||||
mdsvex:
|
||||
specifier: ^0.12.7
|
||||
version: 0.12.7(svelte@5.55.1)
|
||||
svelte:
|
||||
specifier: ^5.55.1
|
||||
version: 5.55.1
|
||||
@@ -919,6 +928,13 @@ packages:
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^2.4.0
|
||||
|
||||
'@sveltejs/enhanced-img@0.10.4':
|
||||
resolution: {integrity: sha512-Am5nmAKUo7Nboqq7Dhtfn7dcXA087d7gIz6Vecn1opB41aJ680+0q9U9KvEcMgduOyeiwckTIOQOx4Mmq9GcvA==}
|
||||
peerDependencies:
|
||||
'@sveltejs/vite-plugin-svelte': ^6.0.0 || ^7.0.0
|
||||
svelte: ^5.0.0
|
||||
vite: ^6.3.0 || >=7.0.0
|
||||
|
||||
'@sveltejs/kit@2.56.1':
|
||||
resolution: {integrity: sha512-9hDOl3yUh8UXWt+mN29dbcdrW0vNwPvMqi01y2Mw+ceErNIISh8MeEY7fXT2Dx1CjC/kfsVqrbxw7DifYr4hsg==}
|
||||
engines: {node: '>=18.13'}
|
||||
@@ -1079,6 +1095,9 @@ packages:
|
||||
'@types/leaflet@1.9.21':
|
||||
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
'@types/node-cron@3.0.11':
|
||||
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
|
||||
|
||||
@@ -1091,6 +1110,9 @@ packages:
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@2.0.11':
|
||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||
|
||||
'@types/webidl-conversions@7.0.0':
|
||||
resolution: {integrity: sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==}
|
||||
|
||||
@@ -1341,6 +1363,9 @@ packages:
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
exifr@7.1.3:
|
||||
resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1433,6 +1458,10 @@ packages:
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
imagetools-core@9.1.0:
|
||||
resolution: {integrity: sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
indent-string@4.0.0:
|
||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1585,6 +1614,11 @@ packages:
|
||||
mdn-data@2.12.2:
|
||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||
|
||||
mdsvex@0.12.7:
|
||||
resolution: {integrity: sha512-gx4bReLCUvq+MPErHXYeyX+TEq1hsS2KfiZtEOMNTcbibSouFy8AHc5h04KbGCl+g5tLuo4/lbgRVYRnc7bJZw==}
|
||||
peerDependencies:
|
||||
svelte: ^3.56.0 || ^4.0.0 || ^5.0.0-next.120
|
||||
|
||||
memory-pager@1.5.0:
|
||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||
|
||||
@@ -1738,6 +1772,13 @@ packages:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
|
||||
prism-svelte@0.4.7:
|
||||
resolution: {integrity: sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==}
|
||||
|
||||
prismjs@1.30.0:
|
||||
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
protobufjs@7.5.4:
|
||||
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1887,6 +1928,11 @@ packages:
|
||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||
typescript: '>=5.0.0'
|
||||
|
||||
svelte-parse-markup@0.1.5:
|
||||
resolution: {integrity: sha512-T6mqZrySltPCDwfKXWQ6zehipVLk4GWfH1zCMGgRtLlOIFPuw58ZxVYxVvotMJgJaurKi1i14viB2GIRKXeJTQ==}
|
||||
peerDependencies:
|
||||
svelte: ^3.0.0 || ^4.0.0 || ^5.0.0-next.1
|
||||
|
||||
svelte@5.55.1:
|
||||
resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1968,6 +2014,25 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
unist-util-is@4.1.0:
|
||||
resolution: {integrity: sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==}
|
||||
|
||||
unist-util-stringify-position@2.0.3:
|
||||
resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==}
|
||||
|
||||
unist-util-visit-parents@3.1.1:
|
||||
resolution: {integrity: sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==}
|
||||
|
||||
unist-util-visit@2.0.3:
|
||||
resolution: {integrity: sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==}
|
||||
|
||||
vfile-message@2.0.4:
|
||||
resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==}
|
||||
|
||||
vite-imagetools@9.0.3:
|
||||
resolution: {integrity: sha512-FwjApRNZyN+RucPW9Z9kf0dyzyi3r3zlDfrTnzHXNaYpmT3pZ5w//d6QkApy1iypbDm+3fq+Gwfv+PYA4j4uYw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
vite-node@6.0.0:
|
||||
resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -2702,6 +2767,19 @@ snapshots:
|
||||
'@sveltejs/kit': 2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
|
||||
rollup: 4.60.1
|
||||
|
||||
'@sveltejs/enhanced-img@0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(rollup@4.60.1)(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
|
||||
magic-string: 0.30.21
|
||||
sharp: 0.34.5
|
||||
svelte: 5.55.1
|
||||
svelte-parse-markup: 0.1.5(svelte@5.55.1)
|
||||
vite: 8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)
|
||||
vite-imagetools: 9.0.3(rollup@4.60.1)
|
||||
zimmerframe: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
|
||||
'@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
@@ -2847,6 +2925,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
|
||||
'@types/node-cron@3.0.11': {}
|
||||
|
||||
'@types/node@22.18.0':
|
||||
@@ -2857,6 +2939,8 @@ snapshots:
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/webidl-conversions@7.0.0': {}
|
||||
|
||||
'@types/whatwg-url@13.0.0':
|
||||
@@ -3096,6 +3180,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
exifr@7.1.3: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
@@ -3188,6 +3274,8 @@ snapshots:
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
imagetools-core@9.1.0: {}
|
||||
|
||||
indent-string@4.0.0: {}
|
||||
|
||||
ip@2.0.1:
|
||||
@@ -3321,6 +3409,16 @@ snapshots:
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
|
||||
mdsvex@0.12.7(svelte@5.55.1):
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/unist': 2.0.11
|
||||
prism-svelte: 0.4.7
|
||||
prismjs: 1.30.0
|
||||
svelte: 5.55.1
|
||||
unist-util-visit: 2.0.3
|
||||
vfile-message: 2.0.4
|
||||
|
||||
memory-pager@1.5.0: {}
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
@@ -3442,6 +3540,10 @@ snapshots:
|
||||
ansi-styles: 5.2.0
|
||||
react-is: 17.0.2
|
||||
|
||||
prism-svelte@0.4.7: {}
|
||||
|
||||
prismjs@1.30.0: {}
|
||||
|
||||
protobufjs@7.5.4:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
@@ -3670,6 +3772,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- picomatch
|
||||
|
||||
svelte-parse-markup@0.1.5(svelte@5.55.1):
|
||||
dependencies:
|
||||
svelte: 5.55.1
|
||||
|
||||
svelte@5.55.1:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -3752,6 +3858,36 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
unist-util-is@4.1.0: {}
|
||||
|
||||
unist-util-stringify-position@2.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
|
||||
unist-util-visit-parents@3.1.1:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
unist-util-is: 4.1.0
|
||||
|
||||
unist-util-visit@2.0.3:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
unist-util-is: 4.1.0
|
||||
unist-util-visit-parents: 3.1.1
|
||||
|
||||
vfile-message@2.0.4:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
unist-util-stringify-position: 2.0.3
|
||||
|
||||
vite-imagetools@9.0.3(rollup@4.60.1):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
|
||||
imagetools-core: 9.1.0
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
|
||||
vite-node@6.0.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0):
|
||||
dependencies:
|
||||
cac: 7.0.0
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Postbuild: turn each prerendered /errors/<status> route into a self-contained
|
||||
* HTML file at build/client/errors/<status>.html for nginx error_page use.
|
||||
*
|
||||
* - Inlines every <link rel="stylesheet"> by replacing it with <style>.
|
||||
* - Strips <script type="module"> and <link rel="modulepreload"> (csr=false,
|
||||
* so JS is dead weight and a missing-asset risk if upstream is dead).
|
||||
* - Leaves font/image URLs alone — nginx serves them from the same root.
|
||||
* - Emits matching .gz + .br for nginx gzip_static / brotli_static.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/build-error-page.ts
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { dirname, resolve, join, posix } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gzipSync, brotliCompressSync, constants as zlib } from 'node:zlib';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const PRERENDER_DIR = join(ROOT, 'build/prerendered/errors');
|
||||
const CLIENT = join(ROOT, 'build/client');
|
||||
const OUT_DIR = join(CLIENT, 'errors');
|
||||
|
||||
// Error pages may be served from arbitrary domains via nginx's default_server
|
||||
// catchall. Rewrite the home-link to an absolute canonical URL so clicking
|
||||
// the logo always lands on the real site.
|
||||
const CANONICAL_HOME = 'https://bocken.org/';
|
||||
|
||||
// Marker for idempotent script injection (so re-runs don't stack copies).
|
||||
const LANG_SCRIPT_MARKER = 'data-error-toggles';
|
||||
// Wires up language + theme toggles without Svelte hydration. Runs early
|
||||
// so <html data-lang="…"> is set before paint (avoids flash of both langs).
|
||||
// The icon inside the theme button is Svelte-reactive and stays at the
|
||||
// SSR-rendered shape; the actual theme cycle + persistence still works.
|
||||
const LANG_SCRIPT = `
|
||||
<script ${LANG_SCRIPT_MARKER}>
|
||||
(function(){try{
|
||||
var html=document.documentElement;
|
||||
var pref=localStorage.getItem('preferredLanguage');
|
||||
var lang=(pref==='en'||pref==='de')?pref:'de';
|
||||
html.setAttribute('data-lang',lang);
|
||||
var wire=function(){
|
||||
var langBtn=document.getElementById('lang-toggle');
|
||||
if(langBtn){
|
||||
var refresh=function(){
|
||||
var cur=html.getAttribute('data-lang')||'de';
|
||||
var next=cur==='de'?'en':'de';
|
||||
langBtn.textContent=next.toUpperCase();
|
||||
langBtn.setAttribute('aria-label',next==='en'?'Switch to English':'Auf Deutsch wechseln');
|
||||
};
|
||||
refresh();
|
||||
langBtn.addEventListener('click',function(){
|
||||
var cur=html.getAttribute('data-lang')||'de';
|
||||
var next=cur==='de'?'en':'de';
|
||||
html.setAttribute('data-lang',next);
|
||||
try{localStorage.setItem('preferredLanguage',next);}catch(_){}
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
var themeBtn=document.querySelector('button[aria-label^="Toggle theme"]');
|
||||
if(themeBtn){
|
||||
var CYCLE=['system','light','dark'];
|
||||
var getTheme=function(){
|
||||
var s=localStorage.getItem('theme');
|
||||
return (s==='light'||s==='dark')?s:'system';
|
||||
};
|
||||
var applyTheme=function(t){
|
||||
if(t==='system'){delete html.dataset.theme;try{localStorage.removeItem('theme');}catch(_){}}
|
||||
else{html.dataset.theme=t;try{localStorage.setItem('theme',t);}catch(_){}}
|
||||
themeBtn.setAttribute('aria-label','Toggle theme ('+t+')');
|
||||
themeBtn.setAttribute('title','Theme: '+t);
|
||||
};
|
||||
themeBtn.addEventListener('click',function(){
|
||||
var cur=getTheme();
|
||||
var next=CYCLE[(CYCLE.indexOf(cur)+1)%CYCLE.length];
|
||||
applyTheme(next);
|
||||
});
|
||||
}
|
||||
};
|
||||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wire);
|
||||
else wire();
|
||||
}catch(_){}})();
|
||||
</script>`;
|
||||
|
||||
if (!existsSync(PRERENDER_DIR)) {
|
||||
console.error(`[error-page] missing prerender dir: ${PRERENDER_DIR}`);
|
||||
console.error('[error-page] is /errors/[status=httpStatus]/+page.ts setting `prerender = true` with `entries()`?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
// Recursively collect every prerendered html under build/prerendered/errors,
|
||||
// so we pick up nested language variants (errors/en/<status>.html).
|
||||
function walk(dir: string, prefix = ''): { rel: string; abs: string }[] {
|
||||
const out: { rel: string; abs: string }[] = [];
|
||||
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
||||
const abs = join(dir, ent.name);
|
||||
const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
|
||||
if (ent.isDirectory()) out.push(...walk(abs, rel));
|
||||
else if (ent.isFile() && ent.name.endsWith('.html')) out.push({ rel, abs });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sources = walk(PRERENDER_DIR);
|
||||
if (sources.length === 0) {
|
||||
console.error(`[error-page] no .html files under ${PRERENDER_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve a possibly-relative href (../foo, ./foo, /foo) against the page's
|
||||
// path (e.g. /errors/503.html) into a path inside CLIENT.
|
||||
function resolveAsset(href: string, pagePath: string): string {
|
||||
const abs = posix.resolve(posix.dirname(pagePath), href); // e.g. /_app/immutable/assets/x.css
|
||||
return join(CLIENT, abs.replace(/^\//, ''));
|
||||
}
|
||||
|
||||
function inline(html: string, pagePath: string): string {
|
||||
// Inline <link rel="stylesheet"> regardless of attribute order.
|
||||
html = html.replace(/<link\b[^>]*>/g, (tag) => {
|
||||
if (!/\brel=["']stylesheet["']/.test(tag)) return tag;
|
||||
const m = tag.match(/\bhref=["']([^"']+)["']/);
|
||||
if (!m) return tag;
|
||||
const cssPath = resolveAsset(m[1], pagePath);
|
||||
if (!existsSync(cssPath)) {
|
||||
console.warn(`[error-page] stylesheet not found, leaving link tag: ${m[1]}`);
|
||||
return tag;
|
||||
}
|
||||
return `<style>${readFileSync(cssPath, 'utf8')}</style>`;
|
||||
});
|
||||
// Drop module preloads and module scripts — nothing should hydrate.
|
||||
html = html.replace(/<link[^>]*\brel=["']modulepreload["'][^>]*>\s*/g, '');
|
||||
html = html.replace(/<script[^>]*\btype=["']module["'][^>]*>[\s\S]*?<\/script>\s*/g, '');
|
||||
|
||||
// Point the brand/home link at the canonical site (the page may be served
|
||||
// from any domain when used as nginx's default_server fallback).
|
||||
html = html.replace(/<a\b[^>]*\bclass="[^"]*\bhome-link\b[^"]*"[^>]*>/g, (tag) =>
|
||||
tag.replace(/\bhref="[^"]*"/, `href="${CANONICAL_HOME}"`)
|
||||
);
|
||||
|
||||
// Inject the language-toggle bootstrap script just before </head> so
|
||||
// <html data-lang="…"> is set before the body paints (avoids flash of
|
||||
// both languages). Idempotent — if the marker is already present, skip.
|
||||
if (!html.includes(LANG_SCRIPT_MARKER)) {
|
||||
html = html.replace('</head>', `${LANG_SCRIPT}</head>`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
for (const { rel, abs } of sources) {
|
||||
const dst = join(OUT_DIR, rel);
|
||||
mkdirSync(dirname(dst), { recursive: true });
|
||||
const html = inline(readFileSync(abs, 'utf8'), `/errors/${rel}`);
|
||||
const buf = Buffer.from(html, 'utf8');
|
||||
writeFileSync(dst, buf);
|
||||
writeFileSync(`${dst}.gz`, gzipSync(buf, { level: 9 }));
|
||||
writeFileSync(`${dst}.br`, brotliCompressSync(buf, {
|
||||
params: { [zlib.BROTLI_PARAM_QUALITY]: 11 }
|
||||
}));
|
||||
console.log(`[error-page] wrote errors/${rel} (${(buf.length / 1024).toFixed(1)} kB) + .gz + .br`);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Build script for private (auth-gated) images rendered via `<Image private>`.
|
||||
*
|
||||
* Public images use @sveltejs/enhanced-img, which emits PUBLIC hashed assets
|
||||
* into the client bundle — fine for anything anyone may see. Private images
|
||||
* must not be publicly reachable, so they can't go through enhanced-img. This
|
||||
* script mirrors the hikes private pipeline instead:
|
||||
*
|
||||
* 1. Scan `src/lib/assets/private-images/` (recursively) for raster sources.
|
||||
* 2. Encode each into AVIF + WebP at multiple widths with sharp, named by
|
||||
* content hash, into `private-assets/` — a tree OUTSIDE the client bundle
|
||||
* and outside `/static`, so SvelteKit/Vite never serve it directly.
|
||||
* 3. Emit `src/lib/data/privateImages.generated.ts`: a manifest mapping each
|
||||
* source path to its responsive variant, with URLs under `/private-images/`
|
||||
* (the auth-gated endpoint at src/routes/private-images/[...file]/+server.ts).
|
||||
*
|
||||
* Deploy rsyncs `private-assets/` to the server, where nginx serves it only via
|
||||
* an `internal` location (`/protected-images/`) reachable through X-Accel-Redirect
|
||||
* from the endpoint — never publicly. In dev the endpoint streams from disk.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import sharp from 'sharp';
|
||||
import type { PrivateImageVariant } from '../src/types/images.js';
|
||||
|
||||
const ROOT = path.resolve(process.cwd());
|
||||
const SRC_DIR = path.join(ROOT, 'src', 'lib', 'assets', 'private-images');
|
||||
// Encoded output. Sibling of `hikes-assets/` and, like it, gitignored + rsynced
|
||||
// to the server by scripts/deploy.sh (never bundled, never under /static).
|
||||
const OUT_DIR = path.join(ROOT, 'private-assets');
|
||||
const MANIFEST_OUT = path.join(ROOT, 'src', 'lib', 'data', 'privateImages.generated.ts');
|
||||
|
||||
// Same responsive ladder + qualities as the hikes encoder, for consistency.
|
||||
const IMAGE_WIDTHS = [480, 960, 1600] as const;
|
||||
const AVIF_QUALITY = 55;
|
||||
const WEBP_QUALITY = 82;
|
||||
const RASTER_RE = /\.(jpe?g|png|webp|avif|tiff?|gif|heic|heif)$/i;
|
||||
// Sharp releases the JS thread while libvips runs, so a small pool ~linearly
|
||||
// speeds up encoding. Cap at 4 to avoid thrashing smaller boxes.
|
||||
const CONCURRENCY = Math.max(2, Math.min(os.cpus().length, 4));
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
worker: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (true) {
|
||||
const i = next++;
|
||||
if (i >= items.length) return;
|
||||
results[i] = await worker(items[i], i);
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
let out: string[] = [];
|
||||
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out = out.concat(await walk(full));
|
||||
else if (RASTER_RE.test(e.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function encode(
|
||||
srcPath: string
|
||||
): Promise<{ key: string; variant: PrivateImageVariant; outNames: string[] }> {
|
||||
const buffer = await fs.readFile(srcPath);
|
||||
// Content hash names the output files: an existing file is byte-identical, so
|
||||
// re-encodes are skipped and stale ones get swept. The source basename is
|
||||
// dropped so original filenames don't leak into the (guessable) URLs.
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 8);
|
||||
|
||||
const meta = await sharp(buffer).metadata();
|
||||
const intrinsicW = meta.width ?? IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];
|
||||
const intrinsicH = meta.height ?? 0;
|
||||
|
||||
let widths = IMAGE_WIDTHS.filter((w) => w <= intrinsicW);
|
||||
if (widths.length === 0) widths = [intrinsicW];
|
||||
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
type Job = { w: number; fmt: 'avif' | 'webp'; file: string; quality: number };
|
||||
const jobs: Job[] = [];
|
||||
const avif: string[] = [];
|
||||
const webp: string[] = [];
|
||||
const outNames: string[] = [];
|
||||
let largestWebp = '';
|
||||
|
||||
for (const w of widths) {
|
||||
const avifName = `${hash}.${w}.avif`;
|
||||
const webpName = `${hash}.${w}.webp`;
|
||||
jobs.push({ w, fmt: 'avif', file: path.join(OUT_DIR, avifName), quality: AVIF_QUALITY });
|
||||
jobs.push({ w, fmt: 'webp', file: path.join(OUT_DIR, webpName), quality: WEBP_QUALITY });
|
||||
avif.push(`/private-images/${avifName} ${w}w`);
|
||||
webp.push(`/private-images/${webpName} ${w}w`);
|
||||
largestWebp = `/private-images/${webpName}`;
|
||||
outNames.push(avifName, webpName);
|
||||
}
|
||||
|
||||
const presence = await Promise.all(jobs.map((j) => pathExists(j.file)));
|
||||
const pending = jobs.filter((_, i) => !presence[i]);
|
||||
await Promise.all(
|
||||
pending.map(async (j) => {
|
||||
const pipeline = sharp(buffer).rotate().resize({ width: j.w, withoutEnlargement: true });
|
||||
if (j.fmt === 'avif') await pipeline.avif({ quality: j.quality }).toFile(j.file);
|
||||
else await pipeline.webp({ quality: j.quality }).toFile(j.file);
|
||||
})
|
||||
);
|
||||
|
||||
const largestW = widths[widths.length - 1];
|
||||
const scale = largestW / intrinsicW;
|
||||
const height = Math.round((intrinsicH || largestW) * scale);
|
||||
// Manifest key: source path relative to SRC_DIR, forward-slashed, so a caller
|
||||
// writes <Image src="blog/cover.jpg" private />.
|
||||
const key = path.relative(SRC_DIR, srcPath).split(path.sep).join('/');
|
||||
|
||||
return {
|
||||
key,
|
||||
variant: {
|
||||
src: largestWebp,
|
||||
srcsetAvif: avif.join(', '),
|
||||
srcsetWebp: webp.join(', '),
|
||||
width: largestW,
|
||||
height
|
||||
},
|
||||
outNames
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files = await walk(SRC_DIR);
|
||||
if (files.length > 0) {
|
||||
console.log(`[build-private-images] encoding ${files.length} image(s) (concurrency=${CONCURRENCY})…`);
|
||||
}
|
||||
|
||||
const results = await runWithConcurrency(files, CONCURRENCY, (f) => encode(f));
|
||||
|
||||
const manifest: Record<string, PrivateImageVariant> = {};
|
||||
const keep = new Set<string>();
|
||||
for (const r of results) {
|
||||
manifest[r.key] = r.variant;
|
||||
for (const n of r.outNames) keep.add(n);
|
||||
}
|
||||
|
||||
// Sweep encodes from prior builds whose source was removed or changed.
|
||||
if (await pathExists(OUT_DIR)) {
|
||||
const existing = await fs.readdir(OUT_DIR);
|
||||
const orphans = existing.filter((f) => !keep.has(f));
|
||||
if (orphans.length > 0) {
|
||||
await Promise.all(orphans.map((f) => fs.unlink(path.join(OUT_DIR, f)).catch(() => {})));
|
||||
console.log(`[build-private-images] removed ${orphans.length} orphaned file(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(MANIFEST_OUT), { recursive: true });
|
||||
const banner =
|
||||
'// AUTO-GENERATED by scripts/build-private-images.ts — do not edit by hand.\n' +
|
||||
"import type { PrivateImageVariant } from '$types/images';\n\n";
|
||||
const body = `export const PRIVATE_IMAGES: Record<string, PrivateImageVariant> = ${JSON.stringify(
|
||||
manifest,
|
||||
null,
|
||||
2
|
||||
)};\n`;
|
||||
await fs.writeFile(MANIFEST_OUT, banner + body);
|
||||
|
||||
console.log(
|
||||
`[build-private-images] wrote ${Object.keys(manifest).length} entry(ies) to ${path.relative(ROOT, MANIFEST_OUT)}`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[build-private-images] Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build locally and rsync artifacts to the production server.
|
||||
# Avoids running pnpm / npm / any git-hosted prepare step on the server.
|
||||
#
|
||||
# Assumes:
|
||||
# - Local machine matches the server's arch + libc (linux-x64-glibc).
|
||||
# - Local Node major version matches the server's.
|
||||
# - Root SSH to $REMOTE works (key-based).
|
||||
#
|
||||
# Usage: scripts/deploy.sh [--dry-run]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="${REMOTE:-root@bocken.org}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/usr/share/webapps/homepage}"
|
||||
REMOTE_USER_GROUP="${REMOTE_USER_GROUP:-homepage:homepage}"
|
||||
SERVICE="${SERVICE:-homepage.service}"
|
||||
ERROR_PAGES_DIR="${ERROR_PAGES_DIR:-/var/www/errors}"
|
||||
ERROR_PAGES_OWNER="${ERROR_PAGES_OWNER:-http:http}"
|
||||
# Hike images live outside the Node app: nginx serves /hikes/<slug>/images/
|
||||
# directly from disk and gates /hikes/<slug>/private/ through Node via
|
||||
# X-Accel-Redirect. The build pipeline writes them to ./hikes-assets/ and we
|
||||
# rsync that tree to the path nginx serves from.
|
||||
HIKES_ASSETS_DIR="${HIKES_ASSETS_DIR:-/var/www/static/hikes}"
|
||||
HIKES_ASSETS_OWNER="${HIKES_ASSETS_OWNER:-http:http}"
|
||||
# Private (auth-gated) images for <Image private>. Built into ./private-assets/
|
||||
# and served by nginx ONLY via an `internal` location reached through the
|
||||
# endpoint's X-Accel-Redirect — add this once to the server's nginx config:
|
||||
# location /protected-images/ { internal; alias /var/www/static/private-images/; }
|
||||
PRIVATE_ASSETS_DIR="${PRIVATE_ASSETS_DIR:-/var/www/static/private-images}"
|
||||
PRIVATE_ASSETS_OWNER="${PRIVATE_ASSETS_OWNER:-http:http}"
|
||||
|
||||
DRY=""
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
DRY="--dry-run"
|
||||
echo ":: DRY RUN — no files will be transferred"
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo ":: Sanity-checking local/remote toolchain parity"
|
||||
local_node=$(node --version)
|
||||
remote_node=$(ssh "$REMOTE" 'node --version')
|
||||
if [[ "${local_node%%.*}" != "${remote_node%%.*}" ]]; then
|
||||
echo "!! Node major mismatch: local $local_node vs remote $remote_node"
|
||||
echo " Native modules (sharp, onnxruntime, bson) may break. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
echo " node $local_node (match)"
|
||||
|
||||
echo ":: Installing deps (frozen lockfile)"
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Build against production env, NOT the dev .env. SvelteKit's
|
||||
# `$env/static/private` (IMAGE_DIR, DB creds, …) is inlined at BUILD time, so a
|
||||
# build that picks up the dev .env ships dev values to prod — e.g. the relative
|
||||
# IMAGE_DIR="./imgs/" that resolves under the service's dist/ cwd instead of the
|
||||
# real served image dir. We export .env_prod into the environment; real env vars
|
||||
# take precedence over .env files in Vite/SvelteKit's env loading, so this wins
|
||||
# for the whole `pnpm build` lifecycle (prebuild vite-node scripts + build).
|
||||
PROD_ENV="${PROD_ENV:-.env_prod}"
|
||||
if [[ ! -f "$PROD_ENV" ]]; then
|
||||
echo "!! $PROD_ENV not found in $(pwd) — refusing to build with the dev .env."
|
||||
echo " Create $PROD_ENV with production values (IMAGE_DIR must be an"
|
||||
echo " ABSOLUTE path to the served recipe-image dir, DB creds, etc.)."
|
||||
exit 1
|
||||
fi
|
||||
echo ":: Building (env from $PROD_ENV)"
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "$PROD_ENV"
|
||||
set +a
|
||||
pnpm build
|
||||
|
||||
if [[ ! -d build ]]; then
|
||||
echo "!! build/ not produced — aborting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The server's systemd unit runs from $REMOTE_DIR/dist, so map build → dist.
|
||||
echo ":: Syncing build/ → $REMOTE:$REMOTE_DIR/dist/"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
build/ "$REMOTE:$REMOTE_DIR/dist/"
|
||||
|
||||
echo ":: Syncing node_modules/ → $REMOTE:$REMOTE_DIR/node_modules/"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
node_modules/ "$REMOTE:$REMOTE_DIR/node_modules/"
|
||||
|
||||
echo ":: Syncing static/ → $REMOTE:$REMOTE_DIR/static/"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
static/ "$REMOTE:$REMOTE_DIR/static/"
|
||||
|
||||
echo ":: Syncing package.json + pnpm-lock.yaml"
|
||||
rsync -az $DRY \
|
||||
package.json pnpm-lock.yaml "$REMOTE:$REMOTE_DIR/"
|
||||
|
||||
if [[ ! -d build/client/errors ]]; then
|
||||
echo "!! build/client/errors not produced — postbuild error-page step did not run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ":: Syncing error pages → $REMOTE:$ERROR_PAGES_DIR/"
|
||||
ssh "$REMOTE" "mkdir -p $ERROR_PAGES_DIR"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
build/client/errors/ "$REMOTE:$ERROR_PAGES_DIR/"
|
||||
|
||||
if [[ -d hikes-assets ]]; then
|
||||
echo ":: Syncing hikes-assets/ → $REMOTE:$HIKES_ASSETS_DIR/"
|
||||
ssh "$REMOTE" "mkdir -p $HIKES_ASSETS_DIR"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
hikes-assets/ "$REMOTE:$HIKES_ASSETS_DIR/"
|
||||
else
|
||||
echo ":: No hikes-assets/ dir — skipping nginx-served hike images sync"
|
||||
fi
|
||||
|
||||
if [[ -d private-assets ]]; then
|
||||
echo ":: Syncing private-assets/ → $REMOTE:$PRIVATE_ASSETS_DIR/"
|
||||
ssh "$REMOTE" "mkdir -p $PRIVATE_ASSETS_DIR"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
private-assets/ "$REMOTE:$PRIVATE_ASSETS_DIR/"
|
||||
else
|
||||
echo ":: No private-assets/ dir — skipping auth-gated image sync"
|
||||
fi
|
||||
|
||||
if [[ -n "$DRY" ]]; then
|
||||
echo ":: Dry run complete — no service restart"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ":: Fixing ownership on server"
|
||||
ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR && if [[ -d $HIKES_ASSETS_DIR ]]; then chown -R $HIKES_ASSETS_OWNER $HIKES_ASSETS_DIR; fi && if [[ -d $PRIVATE_ASSETS_DIR ]]; then chown -R $PRIVATE_ASSETS_OWNER $PRIVATE_ASSETS_DIR; fi"
|
||||
|
||||
echo ":: Restarting $SERVICE"
|
||||
ssh "$REMOTE" "systemctl restart $SERVICE"
|
||||
|
||||
echo ":: Verifying service is active"
|
||||
sleep 2
|
||||
if ssh "$REMOTE" "systemctl is-active --quiet $SERVICE"; then
|
||||
echo " $SERVICE is running"
|
||||
else
|
||||
echo "!! $SERVICE failed to start — check logs:"
|
||||
ssh "$REMOTE" "journalctl -u $SERVICE -n 30 --no-pager"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ":: Deploy complete"
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* One-shot fetch of the 26 Swiss cantonal coats of arms (Wappen) from
|
||||
* Wikimedia Commons into `static/cantons/<iso-code>.svg`. Files are
|
||||
* public-domain Swiss official insignia (PD-CH-coat-of-arms); we keep
|
||||
* the source filename in a header comment for traceability.
|
||||
*
|
||||
* Re-run with `pnpm exec vite-node scripts/download-cantons.ts` to refresh
|
||||
* any missing files. Existing files are left alone — the cantonal arms
|
||||
* don't change.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
type CantonEntry = {
|
||||
code: string; // ISO 3166-2:CH (lowercase for filename)
|
||||
commonsFile: string; // Commons filename WITHOUT the `File:` prefix
|
||||
};
|
||||
|
||||
// Names follow the "Wappen <German-name> matt.svg" convention used across
|
||||
// almost all cantons on Commons. The handful of exceptions (Basel-Stadt,
|
||||
// Basel-Landschaft, the two Appenzells) are spelt out explicitly. If a
|
||||
// fetch returns 404 the script logs the failure and continues so the
|
||||
// remaining cantons still land.
|
||||
const CANTONS: CantonEntry[] = [
|
||||
{ code: 'ag', commonsFile: 'Wappen Aargau matt.svg' },
|
||||
{ code: 'ai', commonsFile: 'Wappen Appenzell Innerrhoden matt.svg' },
|
||||
{ code: 'ar', commonsFile: 'Wappen Appenzell Ausserrhoden matt.svg' },
|
||||
{ code: 'be', commonsFile: 'Wappen Bern matt.svg' },
|
||||
{ code: 'bl', commonsFile: 'Wappen Basel-Landschaft matt.svg' },
|
||||
{ code: 'bs', commonsFile: 'Wappen Basel-Stadt matt.svg' },
|
||||
{ code: 'fr', commonsFile: 'Wappen Freiburg matt.svg' },
|
||||
{ code: 'ge', commonsFile: 'Wappen Genf matt.svg' },
|
||||
{ code: 'gl', commonsFile: 'Wappen Glarus matt.svg' },
|
||||
{ code: 'gr', commonsFile: 'Wappen Graubünden matt.svg' },
|
||||
{ code: 'ju', commonsFile: 'Wappen Jura matt.svg' },
|
||||
{ code: 'lu', commonsFile: 'Wappen Luzern matt.svg' },
|
||||
{ code: 'ne', commonsFile: 'Wappen Neuenburg matt.svg' },
|
||||
{ code: 'nw', commonsFile: 'Wappen Nidwalden matt.svg' },
|
||||
{ code: 'ow', commonsFile: 'Wappen Obwalden matt.svg' },
|
||||
{ code: 'sg', commonsFile: 'Wappen St. Gallen matt.svg' },
|
||||
{ code: 'sh', commonsFile: 'Wappen Schaffhausen matt.svg' },
|
||||
{ code: 'so', commonsFile: 'Wappen Solothurn matt.svg' },
|
||||
{ code: 'sz', commonsFile: 'Wappen Schwyz matt.svg' },
|
||||
{ code: 'tg', commonsFile: 'Wappen Thurgau matt.svg' },
|
||||
{ code: 'ti', commonsFile: 'Wappen Tessin matt.svg' },
|
||||
{ code: 'ur', commonsFile: 'Wappen Uri matt.svg' },
|
||||
{ code: 'vd', commonsFile: 'Wappen Waadt matt.svg' },
|
||||
{ code: 'vs', commonsFile: 'Wappen Wallis matt.svg' },
|
||||
{ code: 'zg', commonsFile: 'Wappen Zug matt.svg' },
|
||||
{ code: 'zh', commonsFile: 'Wappen Zürich matt.svg' }
|
||||
];
|
||||
|
||||
const OUT_DIR = path.resolve(process.cwd(), 'static', 'cantons');
|
||||
const UA = 'bocken-homepage cantons-downloader (https://bocken.org)';
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try { await fs.access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
/** Resolve a Commons `File:Foo.svg` to its actual upload.wikimedia.org URL
|
||||
* via the public API. Returns null on failure (typo in filename, etc.). */
|
||||
async function resolveCommonsUrl(file: string): Promise<string | null> {
|
||||
const url =
|
||||
'https://commons.wikimedia.org/w/api.php' +
|
||||
'?action=query&format=json&prop=imageinfo&iiprop=url' +
|
||||
'&titles=' + encodeURIComponent('File:' + file);
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as {
|
||||
query?: { pages?: Record<string, { imageinfo?: Array<{ url?: string }> }> };
|
||||
};
|
||||
const pages = json.query?.pages;
|
||||
if (!pages) return null;
|
||||
for (const page of Object.values(pages)) {
|
||||
const u = page.imageinfo?.[0]?.url;
|
||||
if (u) return u;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function downloadCanton(c: CantonEntry): Promise<'ok' | 'cached' | 'failed'> {
|
||||
const outPath = path.join(OUT_DIR, `${c.code}.svg`);
|
||||
if (await exists(outPath)) return 'cached';
|
||||
|
||||
const url = await resolveCommonsUrl(c.commonsFile);
|
||||
if (!url) {
|
||||
console.warn(`[cantons] ${c.code}: could not resolve Commons file "${c.commonsFile}"`);
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) {
|
||||
console.warn(`[cantons] ${c.code}: HTTP ${res.status} fetching ${url}`);
|
||||
return 'failed';
|
||||
}
|
||||
const body = await res.text();
|
||||
// Don't prepend anything: most of these files start with an `<?xml … ?>`
|
||||
// declaration, and that MUST be the very first thing in the file or
|
||||
// strict XML parsers (including browsers loading via `<img>`) reject
|
||||
// the document. Provenance is tracked in the CANTONS table above
|
||||
// instead — keep it out of the file bytes.
|
||||
await fs.writeFile(outPath, body);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
let ok = 0, cached = 0, failed = 0;
|
||||
for (const c of CANTONS) {
|
||||
const r = await downloadCanton(c);
|
||||
if (r === 'ok') ok++;
|
||||
else if (r === 'cached') cached++;
|
||||
else failed++;
|
||||
if (r === 'ok') console.log(`[cantons] ${c.code}: downloaded`);
|
||||
else if (r === 'cached') console.log(`[cantons] ${c.code}: cached`);
|
||||
}
|
||||
console.log(`[cantons] done — ${ok} downloaded, ${cached} cached, ${failed} failed`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[cantons] fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Re-derive track-point altitudes from a real terrain model.
|
||||
*
|
||||
* Phone GPS altitude is noisy (often ±10-20 m), which throws off the elevation
|
||||
* profile and the ascend/descend stats. This script keeps every point's exact
|
||||
* lat/lon and only rewrites its `<ele>`, sourcing the height from swisstopo's
|
||||
* swissALTI3D / DHM25 combined model (~0.5-2 m vertical accuracy) at that exact
|
||||
* coordinate.
|
||||
*
|
||||
* 1. Collect every `<wpt>` and `<trkpt>` in each `track.gpx`.
|
||||
* 2. Convert WGS84 → LV95 (swisstopo approximate formula, ~1 m horizontal —
|
||||
* negligible for an elevation lookup).
|
||||
* 3. Ask swisstopo for the height of each distinct point (one batched
|
||||
* `profile.json` POST per ~1000 points; per-point `height` as a fallback),
|
||||
* cached on disk so re-runs and shared points are free.
|
||||
* 4. Surgically replace each point's `<ele>` value, leaving coordinates,
|
||||
* timestamps, `<bocken:image>` extensions and all formatting untouched.
|
||||
*
|
||||
* swisstopo only covers Switzerland: points outside CH keep their original
|
||||
* elevation and are reported as skipped.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec vite-node scripts/fix-altitudes.ts [slug...] [--dry-run]
|
||||
* (no slug → every hike under src/content/hikes/)
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(process.cwd());
|
||||
const CONTENT_DIR = path.join(ROOT, 'src', 'content', 'hikes');
|
||||
const CACHE_DIR = path.join(ROOT, 'scripts', '.cache');
|
||||
const CACHE_FILE = path.join(CACHE_DIR, 'swisstopo-elevation.json');
|
||||
|
||||
const PROFILE_URL = 'https://api3.geo.admin.ch/rest/services/profile.json';
|
||||
const HEIGHT_URL = 'https://api3.geo.admin.ch/rest/services/height';
|
||||
// swisstopo's profile service handles a few thousand vertices per call; keep
|
||||
// chunks well under that so the POST body and response stay modest.
|
||||
const PROFILE_CHUNK = 1000;
|
||||
|
||||
// Matches a <wpt>/<trkpt> opening tag and its immediate <ele> child. The route
|
||||
// builder always writes `<ele>` as the first child (verified across every
|
||||
// track.gpx), so a single capture group around the value is enough to rewrite.
|
||||
const POINT_ELE_RE =
|
||||
/(<(?:wpt|trkpt)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>\s*<ele>)([^<]*)(<\/ele>)/g;
|
||||
|
||||
type Cache = Record<string, number>;
|
||||
|
||||
/** WGS84 (lat/lon, degrees) → CH1903+/LV95 (E, N), swisstopo approx formula. */
|
||||
function wgs84ToLV95(lat: number, lon: number): [number, number] {
|
||||
const phi = (lat * 3600 - 169028.66) / 10000;
|
||||
const lam = (lon * 3600 - 26782.5) / 10000;
|
||||
const E =
|
||||
2600072.37 +
|
||||
211455.93 * lam -
|
||||
10938.51 * lam * phi -
|
||||
0.36 * lam * phi * phi -
|
||||
44.54 * lam ** 3;
|
||||
const N =
|
||||
1200147.07 +
|
||||
308807.95 * phi +
|
||||
3745.25 * lam * lam +
|
||||
76.63 * phi * phi -
|
||||
194.56 * lam * lam * phi +
|
||||
119.79 * phi ** 3;
|
||||
return [Math.round(E * 100) / 100, Math.round(N * 100) / 100];
|
||||
}
|
||||
|
||||
const enKey = (E: number, N: number): string => `${E.toFixed(2)},${N.toFixed(2)}`;
|
||||
|
||||
async function loadCache(): Promise<Cache> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(CACHE_FILE, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCache(cache: Cache): Promise<void> {
|
||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||
await fs.writeFile(CACHE_FILE, JSON.stringify(cache));
|
||||
}
|
||||
|
||||
/** Batched height lookup. Returns a map of `enKey` → height for resolved points. */
|
||||
async function fetchProfile(coords: [number, number][]): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>();
|
||||
if (coords.length < 2) return out;
|
||||
const body = new URLSearchParams({
|
||||
geom: JSON.stringify({ type: 'LineString', coordinates: coords }),
|
||||
sr: '2056',
|
||||
distinct_points: 'true',
|
||||
nb_points: String(coords.length),
|
||||
offset: '0'
|
||||
});
|
||||
const res = await fetch(PROFILE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body
|
||||
});
|
||||
if (!res.ok) throw new Error(`profile.json HTTP ${res.status}`);
|
||||
const rows = (await res.json()) as Array<{
|
||||
alts?: Record<string, number | null>;
|
||||
easting: number;
|
||||
northing: number;
|
||||
}>;
|
||||
for (const r of rows) {
|
||||
const h = r.alts?.COMB ?? r.alts?.DTM2 ?? r.alts?.DTM25;
|
||||
if (typeof h === 'number') out.set(enKey(r.easting, r.northing), h);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Single-point fallback (also the only option for a 1-point chunk). */
|
||||
async function fetchHeight(E: number, N: number): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetch(`${HEIGHT_URL}?easting=${E}&northing=${N}&sr=2056`);
|
||||
if (!res.ok) return null;
|
||||
const j = (await res.json()) as { height?: string | number; success?: boolean };
|
||||
if (j.success === false) return null;
|
||||
const h = typeof j.height === 'string' ? parseFloat(j.height) : j.height;
|
||||
return typeof h === 'number' && Number.isFinite(h) ? h : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type PointKey = string; // `${latStr},${lonStr}` exactly as written in the file
|
||||
|
||||
async function fixTrack(slug: string, cache: Cache, dryRun: boolean): Promise<void> {
|
||||
const file = path.join(CONTENT_DIR, slug, 'track.gpx');
|
||||
let text: string;
|
||||
try {
|
||||
text = await fs.readFile(file, 'utf-8');
|
||||
} catch {
|
||||
console.warn(`[fix-altitudes] ${slug}: no track.gpx, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Distinct points, keyed by the exact lat/lon strings in the file so the
|
||||
// rewrite can match without any float round-tripping.
|
||||
const points = new Map<PointKey, { lat: number; lon: number; E: number; N: number }>();
|
||||
for (const m of text.matchAll(POINT_ELE_RE)) {
|
||||
const key = `${m[2]},${m[3]}`;
|
||||
if (!points.has(key)) {
|
||||
const lat = parseFloat(m[2]);
|
||||
const lon = parseFloat(m[3]);
|
||||
const [E, N] = wgs84ToLV95(lat, lon);
|
||||
points.set(key, { lat, lon, E, N });
|
||||
}
|
||||
}
|
||||
if (points.size === 0) {
|
||||
console.warn(`[fix-altitudes] ${slug}: no points found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve heights for any points not already cached.
|
||||
const uncached = [...points.values()].filter((p) => cache[enKey(p.E, p.N)] === undefined);
|
||||
if (uncached.length > 0) {
|
||||
for (let i = 0; i < uncached.length; i += PROFILE_CHUNK) {
|
||||
const chunk = uncached.slice(i, i + PROFILE_CHUNK);
|
||||
let resolved = new Map<string, number>();
|
||||
try {
|
||||
resolved = await fetchProfile(chunk.map((p) => [p.E, p.N] as [number, number]));
|
||||
} catch (err) {
|
||||
console.warn(`[fix-altitudes] ${slug}: profile batch failed (${String(err)}), falling back per-point`);
|
||||
}
|
||||
for (const p of chunk) {
|
||||
const k = enKey(p.E, p.N);
|
||||
let h = resolved.get(k);
|
||||
if (h === undefined) h = (await fetchHeight(p.E, p.N)) ?? undefined;
|
||||
if (h !== undefined) cache[k] = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite each <ele> in place; tally changes and out-of-CH skips.
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let maxDelta = 0;
|
||||
const fixed = text.replace(POINT_ELE_RE, (full, open, latStr, lonStr, oldEle, close) => {
|
||||
const p = points.get(`${latStr},${lonStr}`)!;
|
||||
const h = cache[enKey(p.E, p.N)];
|
||||
if (h === undefined) {
|
||||
skipped++;
|
||||
return full; // outside CH coverage — keep original elevation
|
||||
}
|
||||
const newEle = h.toFixed(1);
|
||||
const old = parseFloat(oldEle);
|
||||
if (Number.isFinite(old)) maxDelta = Math.max(maxDelta, Math.abs(h - old));
|
||||
if (newEle !== oldEle.trim()) updated++;
|
||||
return `${open}${newEle}${close}`;
|
||||
});
|
||||
|
||||
const summary =
|
||||
`${points.size} distinct pts · ${updated} ele rewritten · ` +
|
||||
`max Δ ${maxDelta.toFixed(1)} m` +
|
||||
(skipped > 0 ? ` · ${skipped} kept (outside CH)` : '');
|
||||
if (dryRun) {
|
||||
console.log(`[fix-altitudes] ${slug}: ${summary} (dry-run, not written)`);
|
||||
return;
|
||||
}
|
||||
if (fixed !== text) {
|
||||
await fs.writeFile(file, fixed);
|
||||
console.log(`[fix-altitudes] ${slug}: ${summary}`);
|
||||
} else {
|
||||
console.log(`[fix-altitudes] ${slug}: already up to date (${summary})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const slugArgs = args.filter((a) => !a.startsWith('--'));
|
||||
|
||||
let slugs = slugArgs;
|
||||
if (slugs.length === 0) {
|
||||
const entries = await fs.readdir(CONTENT_DIR, { withFileTypes: true });
|
||||
slugs = entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('TODO-'))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
const cache = await loadCache();
|
||||
for (const slug of slugs) {
|
||||
await fixTrack(slug, cache, dryRun);
|
||||
}
|
||||
await saveCache(cache);
|
||||
console.log(`[fix-altitudes] done (${slugs.length} track(s), cache: ${Object.keys(cache).length} pts)`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[fix-altitudes] Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Build-time generation of bilingual Bible quotes per HTTP error status.
|
||||
*
|
||||
* Looks up curated references in static/allioli.tsv (DE) + static/drb.tsv (EN)
|
||||
* via the existing bible reference parser, then writes the resolved verses to
|
||||
* src/lib/data/errorQuotes.json for the prerendered /errors/[status] pages.
|
||||
*
|
||||
* - Add or change a status by editing REFS below.
|
||||
* - Refs use the abbreviations defined in the TSVs (e.g. Mt 7,7 / Mt 7:7).
|
||||
* - Fails the build if any reference cannot be resolved.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/generate-error-quotes.ts
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { lookupReference } from '../src/lib/server/bible';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const ALLIOLI = join(ROOT, 'static/allioli.tsv');
|
||||
const DRB = join(ROOT, 'static/drb.tsv');
|
||||
const OUT = join(ROOT, 'src/lib/data/errorQuotes.json');
|
||||
|
||||
// Curated refs. Abbreviations must match the TSV's `abbreviation` column.
|
||||
const REFS: Record<number, { de: string; en: string }> = {
|
||||
401: { de: 'Mt 7,7', en: 'Mt 7:7' },
|
||||
403: { de: 'Mt 7,14', en: 'Mt 7:14' },
|
||||
404: { de: 'Mt 7,8', en: 'Mt 7:8' },
|
||||
500: { de: '2Kor 4,7', en: '2Cor 4:7' },
|
||||
502: { de: '1Mo 11,9', en: 'Gn 11:9' },
|
||||
503: { de: 'Ps 37,7', en: 'Ps 37:7' },
|
||||
504: { de: 'Jes 40,31', en: 'Is 40:31' }
|
||||
};
|
||||
|
||||
type ResolvedQuote = { text: string; reference: string };
|
||||
|
||||
function resolveOne(ref: string, tsv: string): ResolvedQuote {
|
||||
const result = lookupReference(ref, tsv);
|
||||
if (!result || result.verses.length === 0) {
|
||||
throw new Error(`could not resolve reference "${ref}" in ${tsv}`);
|
||||
}
|
||||
// Range refs join verses with a space. Display reference reuses the
|
||||
// original input so the UI keeps the canonical "Mt 7,7" / "Mt 7:7" form.
|
||||
const text = result.verses.map((v) => v.text).join(' ');
|
||||
return { text, reference: ref };
|
||||
}
|
||||
|
||||
const out: Record<string, { de: ResolvedQuote; en: ResolvedQuote }> = {};
|
||||
for (const [status, refs] of Object.entries(REFS)) {
|
||||
out[status] = {
|
||||
de: resolveOne(refs.de, ALLIOLI),
|
||||
en: resolveOne(refs.en, DRB)
|
||||
};
|
||||
console.log(`[error-quotes] ${status}: ${refs.de} / ${refs.en}`);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
||||
console.log(`[error-quotes] wrote ${OUT.replace(ROOT + '/', '')} (${Object.keys(out).length} statuses)`);
|
||||
@@ -2,15 +2,15 @@
|
||||
* Build-time generation of loyalty-card barcode SVGs.
|
||||
*
|
||||
* Reads card numbers from env vars and writes static/shopping/supercard.svg
|
||||
* + static/shopping/cumulus.svg. Skips cards whose env var is unset so the
|
||||
* site still builds in environments without secrets.
|
||||
* + static/shopping/cumulus.svg. Fails the build if any required env is
|
||||
* unset so deploys can't silently ship a broken UI.
|
||||
*
|
||||
* SHOPPING_COOP_SUPERCARD_NUMBER → Data Matrix (Coop Supercard)
|
||||
* SHOPPING_MIGROS_CUMULUS_NUMBER → Code 128 (Migros Cumulus)
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/generate-loyalty-cards.ts
|
||||
*/
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { toSVG } from 'bwip-js/node';
|
||||
@@ -37,15 +37,15 @@ const cards: CardSpec[] = [
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
for (const card of cards) {
|
||||
const value = process.env[card.envVar]?.trim();
|
||||
const outPath = resolve(OUT_DIR, card.filename);
|
||||
const missing = cards.filter((c) => !process.env[c.envVar]?.trim()).map((c) => c.envVar);
|
||||
if (missing.length) {
|
||||
console.error(`[loyalty-cards] missing required env: ${missing.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
try { rmSync(outPath); } catch { /* not present */ }
|
||||
console.log(`[loyalty-cards] ${card.envVar} not set — skipped ${card.filename}`);
|
||||
continue;
|
||||
}
|
||||
for (const card of cards) {
|
||||
const value = process.env[card.envVar]!.trim();
|
||||
const outPath = resolve(OUT_DIR, card.filename);
|
||||
|
||||
const svg = toSVG({
|
||||
bcid: card.bcid,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sync hike *source* photos to/from a backup server, keeping them out of git.
|
||||
#
|
||||
# The repo tracks each hike's index.svx + track.gpx — the manifest of which
|
||||
# images a hike uses (by content hash) and where they sit on the route. The
|
||||
# original JPEGs are large and live here instead of in git. `push` backs the
|
||||
# local photos up; `pull` restores them so any machine can run build-hikes and
|
||||
# reproduce the encoded static assets.
|
||||
#
|
||||
# Only photo files are transferred — images/, private/ and root cover.* —
|
||||
# mirroring the .gitignore rules; the text files stay in git and are skipped.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/hike-photos.sh push [--dry-run] [--delete]
|
||||
# scripts/hike-photos.sh pull [--dry-run] [--delete]
|
||||
#
|
||||
# --dry-run show what would transfer, change nothing
|
||||
# --delete mirror exactly (remove extra files on the destination) — careful
|
||||
#
|
||||
# Config (env vars, with defaults):
|
||||
# REMOTE SSH host (default root@bocken.org)
|
||||
# HIKE_PHOTOS_DIR remote dir for originals (default /var/backups/hike-photos)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="${REMOTE:-root@bocken.org}"
|
||||
HIKE_PHOTOS_DIR="${HIKE_PHOTOS_DIR:-/var/backups/hike-photos}"
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
LOCAL="src/content/hikes/"
|
||||
REMOTE_PATH="$REMOTE:$HIKE_PHOTOS_DIR/"
|
||||
|
||||
cmd="${1:-}"
|
||||
shift || true
|
||||
|
||||
EXTRA=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) EXTRA+=(--dry-run); echo ":: DRY RUN — nothing will be transferred" ;;
|
||||
--delete) EXTRA+=(--delete) ;;
|
||||
*) echo "!! Unknown option: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Transfer only the photo files: descend into each hike dir, take images/,
|
||||
# private/ and a root cover.*, drop everything else (index.svx, track.gpx,
|
||||
# icon.svg — those are versioned in git). Empty dirs (e.g. text-only TODO
|
||||
# drafts) are pruned so the backup stays clean.
|
||||
FILTERS=(
|
||||
--prune-empty-dirs
|
||||
--include='/*/'
|
||||
--include='/*/images/'
|
||||
--include='/*/images/**'
|
||||
--include='/*/private/'
|
||||
--include='/*/private/**'
|
||||
--include='/*/cover.*'
|
||||
--exclude='*'
|
||||
)
|
||||
|
||||
case "$cmd" in
|
||||
push)
|
||||
echo ":: Pushing hike photos → $REMOTE_PATH"
|
||||
ssh "$REMOTE" "mkdir -p '$HIKE_PHOTOS_DIR'"
|
||||
rsync -avh --info=progress2 "${EXTRA[@]}" "${FILTERS[@]}" "$LOCAL" "$REMOTE_PATH"
|
||||
;;
|
||||
pull)
|
||||
echo ":: Pulling hike photos ← $REMOTE_PATH"
|
||||
mkdir -p "$LOCAL"
|
||||
rsync -avh --info=progress2 "${EXTRA[@]}" "${FILTERS[@]}" "$REMOTE_PATH" "$LOCAL"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {push|pull} [--dry-run] [--delete]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ":: done"
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* One-time migration: convert legacy `season: number[]` (months 1–12) on every
|
||||
* Recipe document to the new `seasonRanges: SeasonRange[]` shape.
|
||||
*
|
||||
* Contiguous months are coalesced into a single range. A wrap across the year
|
||||
* boundary (e.g. months [11, 12, 1, 2]) merges into one wrapping range
|
||||
* Nov 1 → Feb 28; non-contiguous months stay as separate ranges.
|
||||
*
|
||||
* The legacy `season` field is then $unset.
|
||||
*
|
||||
* Run before deploying the new code path:
|
||||
* pnpm exec vite-node scripts/migrate-season-to-ranges.ts
|
||||
*
|
||||
* Idempotent: a recipe with no `season` field is left untouched.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const envPath = resolve(import.meta.dirname ?? '.', '..', '.env');
|
||||
const envText = readFileSync(envPath, 'utf-8');
|
||||
const mongoMatch = envText.match(/^MONGO_URL="?([^"\n]+)"?/m);
|
||||
if (!mongoMatch) { console.error('MONGO_URL not found in .env'); process.exit(1); }
|
||||
const MONGO_URL = mongoMatch[1];
|
||||
|
||||
const LAST_DAY = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
type FixedRange = { startM: number; endM: number };
|
||||
|
||||
/**
|
||||
* Coalesce a set of months (1–12) into contiguous ranges, merging the
|
||||
* year-boundary wrap if both Jan and Dec runs are present.
|
||||
*/
|
||||
function coalesceMonths(months: number[]): FixedRange[] {
|
||||
const sorted = [...new Set(months.filter(m => Number.isInteger(m) && m >= 1 && m <= 12))].sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return [];
|
||||
|
||||
const runs: FixedRange[] = [];
|
||||
let runStart = sorted[0];
|
||||
let runEnd = sorted[0];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i] === runEnd + 1) {
|
||||
runEnd = sorted[i];
|
||||
} else {
|
||||
runs.push({ startM: runStart, endM: runEnd });
|
||||
runStart = sorted[i];
|
||||
runEnd = sorted[i];
|
||||
}
|
||||
}
|
||||
runs.push({ startM: runStart, endM: runEnd });
|
||||
|
||||
// Merge the trailing-Dec run into the leading-Jan run so a winter span
|
||||
// like [11,12,1,2] becomes one wrapping Nov→Feb range instead of two.
|
||||
if (runs.length >= 2 && runs[0].startM === 1 && runs[runs.length - 1].endM === 12) {
|
||||
const wrapped = { startM: runs[runs.length - 1].startM, endM: runs[0].endM };
|
||||
return [wrapped, ...runs.slice(1, -1)];
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function rangeFromRun(run: FixedRange) {
|
||||
return {
|
||||
start: { kind: 'fixed', m: run.startM, d: 1 },
|
||||
end: { kind: 'fixed', m: run.endM, d: LAST_DAY[run.endM - 1] }
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mongoose.connect(MONGO_URL);
|
||||
const Recipe = mongoose.connection.collection('recipes');
|
||||
|
||||
const cursor = Recipe.find({ season: { $exists: true } });
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
while (await cursor.hasNext()) {
|
||||
const doc = await cursor.next() as any;
|
||||
if (!doc) break;
|
||||
|
||||
const months: number[] = Array.isArray(doc.season) ? doc.season : [];
|
||||
const runs = coalesceMonths(months);
|
||||
|
||||
if (runs.length === 0) {
|
||||
await Recipe.updateOne({ _id: doc._id }, { $unset: { season: '' } });
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const seasonRanges = runs.map(rangeFromRun);
|
||||
|
||||
await Recipe.updateOne(
|
||||
{ _id: doc._id },
|
||||
{ $set: { seasonRanges }, $unset: { season: '' } }
|
||||
);
|
||||
migrated++;
|
||||
if (migrated % 25 === 0) console.log(` migrated ${migrated}…`);
|
||||
}
|
||||
|
||||
console.log(`\nDone. Migrated: ${migrated}. Skipped (empty season): ${skipped}.`);
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Postbuild: precompress static build output for nginx `gzip_static` /
|
||||
* `brotli_static`.
|
||||
*
|
||||
* Replaces adapter-node's `precompress: true`, which brotli-q11 + gzips EVERY
|
||||
* file in build/client single-threaded — including ~90 MB of already-compressed
|
||||
* jpg/mp4/png/webp/woff2 (zero gain) and 20 MB+ text blobs at q11 (~30 s each).
|
||||
*
|
||||
* This version instead:
|
||||
* - only touches compressible text types (skips binaries entirely),
|
||||
* - tunes brotli quality down for large files (q11 is wildly slow past a few MB
|
||||
* for marginal ratio gains over q10/q9),
|
||||
* - runs gzip + brotli concurrently across the libuv threadpool,
|
||||
* - skips files that already have a .br/.gz sibling (e.g. the error pages the
|
||||
* build-error-page step emits), so it's idempotent.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/precompress.ts
|
||||
*/
|
||||
|
||||
// The async gzip/brotli calls run on libuv's threadpool. Its size must be set
|
||||
// before the pool is first used — by the time this module runs under vite-node
|
||||
// the pool is already up, so postbuild sets UV_THREADPOOL_SIZE on the command
|
||||
// line (the authoritative knob). This line is just a fallback default for
|
||||
// direct `vite-node scripts/precompress.ts` runs and won't override an
|
||||
// already-set value.
|
||||
import os from 'node:os';
|
||||
const CORES = Math.max(1, os.cpus().length);
|
||||
process.env.UV_THREADPOOL_SIZE ||= String(Math.min(CORES, 12));
|
||||
|
||||
import { readdir, readFile, writeFile, stat } from 'node:fs/promises';
|
||||
import { join, resolve, dirname, extname, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gzip, brotliCompress, constants as zlib } from 'node:zlib';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const gzipAsync = promisify(gzip);
|
||||
const brotliAsync = promisify(brotliCompress);
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TARGET_DIRS = ['build/client', 'build/prerendered'];
|
||||
|
||||
// Only these extensions are worth compressing; everything else (images, video,
|
||||
// fonts, archives) is already compressed and skipped.
|
||||
const COMPRESSIBLE = new Set([
|
||||
'.js', '.mjs', '.cjs', '.css', '.html', '.htm', '.json', '.map',
|
||||
'.svg', '.xml', '.txt', '.tsv', '.csv', '.wasm', '.webmanifest', '.ico'
|
||||
]);
|
||||
|
||||
// Server-side-only data that nonetheless lands in build/client and is read back
|
||||
// from disk server-side (never delivered to a browser). A .br/.gz sibling for
|
||||
// these is dead weight nginx never serves — and they're the largest, slowest
|
||||
// files in the tree, so skipping them is where almost all the time goes. They
|
||||
// must still exist UNCOMPRESSED for the server reads, so we skip rather than
|
||||
// remove them. Two kinds:
|
||||
// - bible TSVs: read via src/lib/server/staticAsset.ts → resolveStaticAsset
|
||||
// - ML embedding JSONs: `?url`-imported by $lib/server/{nutritionMatcher,
|
||||
// shoppingCategorizer}.ts and read via SvelteKit's read(); emitted into
|
||||
// _app/immutable/assets/ with a content hash (…Embeddings.<hash>.json).
|
||||
const SERVER_ONLY_NAMES = new Set(['allioli.tsv', 'drb.tsv']);
|
||||
const SERVER_ONLY_RE = /embeddings\.[^/]*\.json$/i;
|
||||
function isServerOnly(file: string): boolean {
|
||||
const base = basename(file);
|
||||
return SERVER_ONLY_NAMES.has(base) || SERVER_ONLY_RE.test(base);
|
||||
}
|
||||
|
||||
// Don't bother compressing tiny files — overhead/headers outweigh the savings.
|
||||
const MIN_BYTES = 1024;
|
||||
|
||||
/** Pick a brotli quality that balances ratio against time for large files. */
|
||||
function brotliQuality(size: number): number {
|
||||
if (size > 4 * 1024 * 1024) return 9; // >4 MB: q9 (q11 would take 30 s+)
|
||||
if (size > 1024 * 1024) return 10; // 1–4 MB
|
||||
return 11; // small files: max ratio, still fast
|
||||
}
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // dir doesn't exist (e.g. no prerendered output) — skip
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full);
|
||||
else if (entry.isFile()) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
for (const rel of TARGET_DIRS) {
|
||||
for await (const f of walk(join(ROOT, rel))) {
|
||||
const ext = extname(f).toLowerCase();
|
||||
if (!COMPRESSIBLE.has(ext)) continue;
|
||||
if (f.endsWith('.gz') || f.endsWith('.br')) continue;
|
||||
if (isServerOnly(f)) continue;
|
||||
files.push(f);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let saved = 0;
|
||||
let written = 0;
|
||||
|
||||
async function compressOne(file: string): Promise<void> {
|
||||
const buf = await readFile(file);
|
||||
if (buf.length < MIN_BYTES) return;
|
||||
|
||||
const jobs: Promise<void>[] = [];
|
||||
|
||||
if (!(await exists(file + '.gz'))) {
|
||||
jobs.push(
|
||||
gzipAsync(buf, { level: zlib.Z_BEST_COMPRESSION }).then(async (out) => {
|
||||
if (out.length < buf.length) {
|
||||
await writeFile(file + '.gz', out);
|
||||
written++;
|
||||
saved += buf.length - out.length;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await exists(file + '.br'))) {
|
||||
jobs.push(
|
||||
brotliAsync(buf, {
|
||||
params: {
|
||||
[zlib.BROTLI_PARAM_QUALITY]: brotliQuality(buf.length),
|
||||
[zlib.BROTLI_PARAM_SIZE_HINT]: buf.length
|
||||
}
|
||||
}).then(async (out) => {
|
||||
if (out.length < buf.length) {
|
||||
await writeFile(file + '.br', out);
|
||||
written++;
|
||||
saved += buf.length - out.length;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(jobs);
|
||||
}
|
||||
|
||||
/** Run `tasks` with at most `limit` in flight at once. */
|
||||
async function pool<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let i = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (i < items.length) {
|
||||
const idx = i++;
|
||||
await fn(items[idx]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const files = await collect();
|
||||
console.log(`[precompress] ${files.length} compressible files, ${CORES} cores`);
|
||||
await pool(files, CORES, compressOne);
|
||||
console.log(
|
||||
`[precompress] wrote ${written} files, saved ${(saved / 1048576).toFixed(1)} MB in ${(
|
||||
(Date.now() - t0) / 1000
|
||||
).toFixed(1)}s`
|
||||
);
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Build-time static hero-map renderer for individual hikes.
|
||||
*
|
||||
* Fetches the Swisstopo raster tiles covering each hike's bbox, composites
|
||||
* them into one PNG via sharp, draws the trail polyline + start/end markers
|
||||
* on top, and emits a single WebP. The result is served as `<img>` in the
|
||||
* detail page's hero so the user sees an exact replica of the live map
|
||||
* during the few hundred milliseconds it takes Leaflet to dynamic-import,
|
||||
* fetch tiles, and render — eliminating the perceived load delay.
|
||||
*
|
||||
* Tiles are content-cached on disk; rendered heroes are name-cached by
|
||||
* content hash so a re-build with unchanged GPX is a no-op.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const TILE_SIZE = 256;
|
||||
const TILE_CACHE_DIR = path.resolve(process.cwd(), 'scripts', '.cache', 'swisstopo-tiles');
|
||||
// Swisstopo serves the WMTS tiles from wmts10–wmts100. Spread across a
|
||||
// couple of sub-domains so we don't hammer a single origin during initial
|
||||
// build (browsers see different hosts; the disk cache makes follow-up
|
||||
// builds a non-event regardless).
|
||||
const SUBDOMAINS = ['wmts10', 'wmts20'] as const;
|
||||
const USER_AGENT = 'bocken-homepage build-hikes';
|
||||
|
||||
function tileUrl(sub: string, layer: string, z: number, x: number, y: number): string {
|
||||
return `https://${sub}.geo.admin.ch/1.0.0/${layer}/default/current/3857/${z}/${x}/${y}.jpeg`;
|
||||
}
|
||||
|
||||
/** Web Mercator: lng/lat → absolute pixel coordinate at a given zoom. */
|
||||
export function lngLatToPx(lng: number, lat: number, zoom: number): { x: number; y: number } {
|
||||
const n = 2 ** zoom;
|
||||
const x = ((lng + 180) / 360) * n * TILE_SIZE;
|
||||
const latRad = (lat * Math.PI) / 180;
|
||||
const y =
|
||||
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n * TILE_SIZE;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** `null` = network failure (we'll count it against the abort threshold).
|
||||
* `'blank'` = HTTP 4xx, i.e. the tile is intentionally not served — for
|
||||
* the Swisstopo Pixelkarte that means we're outside Switzerland's bbox.
|
||||
* The overview hero canvas extends into DE/IT/FR, so we treat blanks as
|
||||
* "OK, just nothing there" rather than failures. */
|
||||
type TileResult = Buffer | 'blank' | null;
|
||||
|
||||
async function fetchTile(
|
||||
layer: string,
|
||||
z: number,
|
||||
x: number,
|
||||
y: number
|
||||
): Promise<TileResult> {
|
||||
const key = `${layer.replace(/[^a-z0-9]/gi, '_')}_${z}_${x}_${y}.jpeg`;
|
||||
const cachePath = path.join(TILE_CACHE_DIR, key);
|
||||
try {
|
||||
return await fs.readFile(cachePath);
|
||||
} catch { /* miss */ }
|
||||
|
||||
const sub = SUBDOMAINS[(x + y) % SUBDOMAINS.length];
|
||||
try {
|
||||
const res = await fetch(tileUrl(sub, layer, z, x, y), {
|
||||
headers: { 'User-Agent': USER_AGENT }
|
||||
});
|
||||
if (!res.ok) {
|
||||
// 4xx means "we don't serve this tile" (out-of-bounds for the
|
||||
// Swiss data set). Anything else (5xx) is a real failure.
|
||||
if (res.status >= 400 && res.status < 500) return 'blank';
|
||||
if (process.env.STATIC_MAP_DEBUG) {
|
||||
console.warn(`[staticHikeMap] tile ${z}/${x}/${y} HTTP ${res.status}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await fs.mkdir(TILE_CACHE_DIR, { recursive: true });
|
||||
await fs.writeFile(cachePath, buf);
|
||||
return buf;
|
||||
} catch (err) {
|
||||
if (process.env.STATIC_MAP_DEBUG) {
|
||||
console.warn(`[staticHikeMap] tile ${z}/${x}/${y} error:`, err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeSvgNumber(n: number): string {
|
||||
// Keep SVG path compact but precise enough for 1600 px rendering.
|
||||
return n.toFixed(1);
|
||||
}
|
||||
|
||||
export interface RenderStaticMapPhotoMarker {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface StaticMapPose {
|
||||
zoom: number;
|
||||
centerLat: number;
|
||||
centerLng: number;
|
||||
/** Origin in zoom-pixel space — top-left of the output canvas. The
|
||||
* renderer needs it; the caller doesn't, but exposing it keeps the
|
||||
* `computePose` ↔ `renderStaticMap` interface stateless. */
|
||||
originX: number;
|
||||
originY: number;
|
||||
}
|
||||
|
||||
export interface ComputeStaticMapPoseOpts {
|
||||
bbox: [number, number, number, number];
|
||||
/** Canvas dimensions for centering / tile fetching. */
|
||||
width?: number;
|
||||
height?: number;
|
||||
paddingPx?: number;
|
||||
/** Reference dimensions used purely for zoom selection. Defaults to
|
||||
* `width × height` — but pass the expected *display* size (not the
|
||||
* rendered canvas size) when you want zoom to match Leaflet's
|
||||
* `fitBounds` at the user's viewport. The renderer still draws the
|
||||
* full `width × height` canvas around the chosen zoom, so wider
|
||||
* viewports get more context without the bbox being cropped on
|
||||
* smaller ones. */
|
||||
fitWidth?: number;
|
||||
fitHeight?: number;
|
||||
/** Upper bound on the zoom search — mirrors Leaflet's `fitBounds({ maxZoom })`.
|
||||
* Use this when the live map clamps its zoom so the static hero doesn't
|
||||
* land at a more detailed level than Leaflet will ever show. */
|
||||
maxZoom?: number;
|
||||
}
|
||||
|
||||
/** Pure-math pass: pick the zoom + centre + canvas origin that the static
|
||||
* renderer would use for these inputs. Identical for light- and dark-
|
||||
* themed renders, so callers can compute it once and re-use. */
|
||||
export function computeStaticMapPose(opts: ComputeStaticMapPoseOpts): StaticMapPose | null {
|
||||
const width = opts.width ?? 1600;
|
||||
const height = opts.height ?? 1000;
|
||||
const paddingPx = opts.paddingPx ?? 24;
|
||||
const fitWidth = opts.fitWidth ?? width;
|
||||
const fitHeight = opts.fitHeight ?? height;
|
||||
const maxZoom = opts.maxZoom ?? 18;
|
||||
|
||||
const [minLat, minLng, maxLat, maxLng] = opts.bbox;
|
||||
if (
|
||||
!Number.isFinite(minLat) || !Number.isFinite(minLng) ||
|
||||
!Number.isFinite(maxLat) || !Number.isFinite(maxLng)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const innerW = Math.max(1, fitWidth - 2 * paddingPx);
|
||||
const innerH = Math.max(1, fitHeight - 2 * paddingPx);
|
||||
|
||||
// Pick the highest integer zoom where the bbox fits inside the
|
||||
// reference inner rectangle. This mirrors Leaflet's `fitBounds`
|
||||
// integer-zoom search, so a viewport matching `fitWidth × fitHeight`
|
||||
// will choose the same zoom Leaflet does for the same bbox.
|
||||
let zoom = 7;
|
||||
for (let z = maxZoom; z >= 7; z--) {
|
||||
const tl = lngLatToPx(minLng, maxLat, z);
|
||||
const br = lngLatToPx(maxLng, minLat, z);
|
||||
if (br.x - tl.x <= innerW && br.y - tl.y <= innerH) {
|
||||
zoom = z;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const centerLat = (minLat + maxLat) / 2;
|
||||
const centerLng = (minLng + maxLng) / 2;
|
||||
const c = lngLatToPx(centerLng, centerLat, zoom);
|
||||
const originX = Math.round(c.x - width / 2);
|
||||
const originY = Math.round(c.y - height / 2);
|
||||
|
||||
return { zoom, centerLat, centerLng, originX, originY };
|
||||
}
|
||||
|
||||
export interface RenderStaticMapOpts {
|
||||
/** Pre-computed pose (zoom + centre + origin). Get this via
|
||||
* `computeStaticMapPose(...)`. Shared by light- and dark-themed
|
||||
* renders so both variants align perfectly. */
|
||||
pose: StaticMapPose;
|
||||
/** Track polyline as `[lat, lng]` tuples (any length). */
|
||||
polyline: Array<[number, number]>;
|
||||
color: string;
|
||||
outputPath: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Swisstopo WMTS layer ID. Defaults to the schematic Pixelkarte (the
|
||||
* same base layer Leaflet starts with on the detail page). */
|
||||
layer?: string;
|
||||
/** Optional image-point markers to burn into the SVG overlay alongside
|
||||
* the start/end dots. Pass only the points safe to render in a public-
|
||||
* facing image — private photos should be filtered out by the caller. */
|
||||
photoMarkers?: RenderStaticMapPhotoMarker[];
|
||||
/** Fill colour for the photo marker dots. Should match the live
|
||||
* HikePhoto marker styling (`--color-primary`). */
|
||||
photoMarkerColor?: string;
|
||||
/** Border colour for the photo marker dots — matches the live
|
||||
* `.hike-photo-marker .badge` `border-color: var(--color-surface)` so
|
||||
* the static blends in with the active theme's surface colour. */
|
||||
photoMarkerBorderColor?: string;
|
||||
/** Stroke colour of the Lucide `camera` icon inside the badge. Matches
|
||||
* the live badge's `color: var(--color-text-on-primary)` — white on
|
||||
* the light theme's mid-blue primary, dark on the dark theme's light-
|
||||
* blue primary. */
|
||||
photoMarkerIconColor?: string;
|
||||
}
|
||||
|
||||
/** Fetch every Swisstopo tile covering the canvas at the given pose, then
|
||||
* composite them into a single PNG buffer. Returns `null` when fewer than
|
||||
* half the tiles arrive (a patchy hero is worse than no hero). Shared by
|
||||
* `renderStaticMap` (per-hike hero) and `renderOverviewMap` (the /hikes
|
||||
* landing-page hero) so both pull the same tile cache and use the same
|
||||
* fallback colour. */
|
||||
async function composeBaseMap(
|
||||
pose: StaticMapPose,
|
||||
width: number,
|
||||
height: number,
|
||||
layer: string
|
||||
): Promise<Buffer | null> {
|
||||
const { zoom, originX, originY } = pose;
|
||||
|
||||
const minTileX = Math.floor(originX / TILE_SIZE);
|
||||
const maxTileX = Math.floor((originX + width - 1) / TILE_SIZE);
|
||||
const minTileY = Math.floor(originY / TILE_SIZE);
|
||||
const maxTileY = Math.floor((originY + height - 1) / TILE_SIZE);
|
||||
|
||||
// Parallel tile fetches — disk cache makes follow-up builds essentially
|
||||
// free, but the first build pulls ~6–20 tiles per per-hike hero and
|
||||
// considerably more for the overview hero.
|
||||
const tileJobs: Array<{ tx: number; ty: number; left: number; top: number }> = [];
|
||||
for (let ty = minTileY; ty <= maxTileY; ty++) {
|
||||
for (let tx = minTileX; tx <= maxTileX; tx++) {
|
||||
tileJobs.push({
|
||||
tx,
|
||||
ty,
|
||||
left: tx * TILE_SIZE - originX,
|
||||
top: ty * TILE_SIZE - originY
|
||||
});
|
||||
}
|
||||
}
|
||||
const tileBufs = await Promise.all(
|
||||
tileJobs.map(async (job) => ({
|
||||
job,
|
||||
buf: await fetchTile(layer, zoom, job.tx, job.ty)
|
||||
}))
|
||||
);
|
||||
|
||||
const composites: Array<{ input: Buffer; left: number; top: number }> = [];
|
||||
let networkFailures = 0;
|
||||
for (const { job, buf } of tileBufs) {
|
||||
if (buf === null) {
|
||||
networkFailures++;
|
||||
continue;
|
||||
}
|
||||
if (buf === 'blank') continue; // out-of-bounds, draw the fallback grey
|
||||
composites.push({ input: buf, left: job.left, top: job.top });
|
||||
}
|
||||
// Network-failure threshold (not "fewer than half present"): blank
|
||||
// out-of-bounds tiles are an expected outcome for the overview hero
|
||||
// that extends past Switzerland's edges, so they don't count against
|
||||
// the abort threshold.
|
||||
if (networkFailures > tileJobs.length / 2) return null;
|
||||
|
||||
// Tile composite is identical regardless of UI theme — we deliberately
|
||||
// don't invert the Pixelkarte for dark mode (its colour palette doesn't
|
||||
// survive a naive invert). Only the SVG overlay above changes per theme.
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: { r: 235, g: 235, b: 235 } }
|
||||
})
|
||||
.composite(composites)
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/** Render and write a single static hero map at the given pose. Returns
|
||||
* `false` on failure (zero tiles fetched, degenerate inputs). */
|
||||
export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolean> {
|
||||
const width = opts.width ?? 1600;
|
||||
const height = opts.height ?? 1000;
|
||||
const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe';
|
||||
const { zoom, originX, originY } = opts.pose;
|
||||
|
||||
if (opts.polyline.length < 2) return false;
|
||||
|
||||
const mapBuf = await composeBaseMap(opts.pose, width, height, layer);
|
||||
if (!mapBuf) return false;
|
||||
|
||||
// SVG overlay — polyline + photo markers + start/end dots.
|
||||
const pathParts: string[] = [];
|
||||
for (let i = 0; i < opts.polyline.length; i++) {
|
||||
const [lat, lng] = opts.polyline[i];
|
||||
const p = lngLatToPx(lng, lat, zoom);
|
||||
const px = p.x - originX;
|
||||
const py = p.y - originY;
|
||||
pathParts.push((i === 0 ? 'M' : 'L') + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
|
||||
}
|
||||
const start = opts.polyline[0];
|
||||
const end = opts.polyline[opts.polyline.length - 1];
|
||||
const startP = lngLatToPx(start[1], start[0], zoom);
|
||||
const endP = lngLatToPx(end[1], end[0], zoom);
|
||||
const sx = escapeSvgNumber(startP.x - originX);
|
||||
const sy = escapeSvgNumber(startP.y - originY);
|
||||
const ex = escapeSvgNumber(endP.x - originX);
|
||||
const ey = escapeSvgNumber(endP.y - originY);
|
||||
|
||||
const photoMarkerColor = opts.photoMarkerColor ?? '#5e81ac';
|
||||
const photoMarkerBorderColor = opts.photoMarkerBorderColor ?? '#eceff4';
|
||||
const photoMarkerIconColor = opts.photoMarkerIconColor ?? '#fff';
|
||||
// Match HikeMap's `.hike-photo-marker .badge` — 28 px Nord-blue circle
|
||||
// with a 2 px theme-surface border, holding a 14 px theme-on-primary
|
||||
// Lucide `camera` icon. The camera icon paths are the literal Lucide
|
||||
// source (lucide-camera).
|
||||
const photoMarkers = (opts.photoMarkers ?? [])
|
||||
.map((m) => {
|
||||
const p = lngLatToPx(m.lng, m.lat, zoom);
|
||||
const cx = escapeSvgNumber(p.x - originX);
|
||||
const cy = escapeSvgNumber(p.y - originY);
|
||||
return (
|
||||
`<g transform="translate(${cx} ${cy})">` +
|
||||
`<circle r="14" fill="${photoMarkerColor}" stroke="${photoMarkerBorderColor}" stroke-width="2"/>` +
|
||||
`<g transform="translate(-7 -7) scale(0.5833)" stroke="${photoMarkerIconColor}" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">` +
|
||||
`<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/>` +
|
||||
`<circle cx="12" cy="13" r="3"/>` +
|
||||
`</g>` +
|
||||
`</g>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
const overlay = Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
||||
`<path d="${pathParts.join(' ')}" fill="none" stroke="${opts.color}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.95"/>` +
|
||||
photoMarkers +
|
||||
`<circle cx="${sx}" cy="${sy}" r="9" fill="#a3be8c" stroke="#fff" stroke-width="3"/>` +
|
||||
`<circle cx="${ex}" cy="${ey}" r="9" fill="#bf616a" stroke="#fff" stroke-width="3"/>` +
|
||||
`</svg>`
|
||||
);
|
||||
|
||||
await sharp(mapBuf)
|
||||
.composite([{ input: overlay, left: 0, top: 0 }])
|
||||
.webp({ quality: 78 })
|
||||
.toFile(opts.outputPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overview hero (one image for the whole /hikes index page).
|
||||
// Same tile composite as `renderStaticMap`, but the overlay draws many
|
||||
// polylines (one per hike, coloured by SAC tier) and no per-route start /
|
||||
// end / photo markers — the map is a finder, not a detail view.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RenderOverviewPolyline {
|
||||
points: Array<[number, number]>;
|
||||
color: string;
|
||||
/** Indices where a new disconnected sub-path begins (multi-day stage gaps
|
||||
* >1 km), so the line isn't drawn across an overnight transfer. */
|
||||
breaks?: number[];
|
||||
}
|
||||
|
||||
export interface RenderOverviewMapOpts {
|
||||
pose: StaticMapPose;
|
||||
polylines: RenderOverviewPolyline[];
|
||||
outputPath: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
layer?: string;
|
||||
}
|
||||
|
||||
export async function renderOverviewMap(opts: RenderOverviewMapOpts): Promise<boolean> {
|
||||
const width = opts.width ?? 1600;
|
||||
const height = opts.height ?? 1000;
|
||||
const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe';
|
||||
const { zoom, originX, originY } = opts.pose;
|
||||
|
||||
const drawable = opts.polylines.filter((p) => p.points.length >= 2);
|
||||
if (drawable.length === 0) return false;
|
||||
|
||||
const mapBuf = await composeBaseMap(opts.pose, width, height, layer);
|
||||
if (!mapBuf) return false;
|
||||
|
||||
// One <path> per hike polyline. The overview map is rendered fairly
|
||||
// zoomed-out, so even ≤150-point preview polylines stay compact.
|
||||
const paths = drawable
|
||||
.map((line) => {
|
||||
const breakSet = new Set(line.breaks ?? []);
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < line.points.length; i++) {
|
||||
const [lat, lng] = line.points[i];
|
||||
const p = lngLatToPx(lng, lat, zoom);
|
||||
const px = p.x - originX;
|
||||
const py = p.y - originY;
|
||||
// Start a fresh sub-path at index 0 and at every stage break.
|
||||
const cmd = i === 0 || breakSet.has(i) ? 'M' : 'L';
|
||||
parts.push(cmd + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
|
||||
}
|
||||
return (
|
||||
`<path d="${parts.join(' ')}" fill="none" stroke="${line.color}" ` +
|
||||
`stroke-width="4" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.9"/>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
const overlay = Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
||||
paths +
|
||||
`</svg>`
|
||||
);
|
||||
|
||||
await sharp(mapBuf)
|
||||
.composite([{ input: overlay, left: 0, top: 0 }])
|
||||
.webp({ quality: 78 })
|
||||
.toFile(opts.outputPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bocken"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bocken"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 385 B |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 380 B |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 388 B |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 395 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 405 B |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 11 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
width="1024"
|
||||
height="1024"
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="g1">
|
||||
<rect
|
||||
style="fill:#2e3440;stroke-width:15;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="rect1"
|
||||
width="1024"
|
||||
height="1024"
|
||||
x="0"
|
||||
y="0" />
|
||||
<g
|
||||
id="g2"
|
||||
transform="matrix(6.5209236,0,0,6.5209236,362.8589,246.15055)">
|
||||
<g
|
||||
class="stroke"
|
||||
id="branches"
|
||||
transform="translate(-42.033271,-37.145192)"
|
||||
style="stroke:#d8dee9;stroke-opacity:1">
|
||||
<path
|
||||
d="m 65.113709,84.638921 c -0.346049,-9.794303 8.85917,-32.693347 8.85917,-32.693347"
|
||||
id="path1"
|
||||
style="fill:none;stroke:#d8dee9;stroke-width:3;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
d="m 65.108044,84.684262 c 0.346049,-9.794303 -8.85917,-32.693347 -8.85917,-32.693347"
|
||||
id="path2"
|
||||
style="fill:none;stroke:#d8dee9;stroke-width:3;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</g>
|
||||
<g
|
||||
class="leaf"
|
||||
id="g1-2"
|
||||
style="fill:#d8dee9;fill-opacity:1">
|
||||
<path
|
||||
d="M 0,0 C 6.633,-3.91 14.348,-4.302 20.992,-1.732 20.009,5.333 15.93,11.893 9.31,15.795 2.69,19.697 -5.025,20.088 -11.669,17.519 -10.7,10.462 -6.62,3.901 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,4.116564,13.543871)"
|
||||
id="path3"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="m 0,0 c -6.62,3.901 -14.335,4.293 -20.979,1.724 0.97,-7.058 5.049,-13.618 11.669,-17.519 6.633,-3.91 14.348,-4.301 20.992,-1.732 C 10.699,-10.462 6.62,-3.902 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,10.339434,19.278333)"
|
||||
id="path4"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="M 0,0 C 6.633,-3.909 14.348,-4.301 20.992,-1.731 20.009,5.333 15.93,11.894 9.31,15.795 2.69,19.697 -5.026,20.088 -11.669,17.52 -10.7,10.461 -6.62,3.902 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,10.903454,36.572256)"
|
||||
id="path5"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="M 0,0 C 6.644,-2.57 14.358,-2.178 20.992,1.732 27.612,5.633 31.691,12.194 32.661,19.25 26.017,21.82 18.302,21.429 11.682,17.527 5.062,13.625 0.982,7.065 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,32.871328,24.119748)"
|
||||
id="path6"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="M 0,0 C 6.62,3.901 10.699,10.461 11.669,17.519 5.025,20.088 -2.689,19.696 -9.31,15.795 -15.93,11.893 -20.009,5.333 -20.992,-1.732 -14.348,-4.301 -6.633,-3.91 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,35.741597,35.870171)"
|
||||
id="path7"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="m -27.40181,13.441787 c 6.644,-2.57 14.359,-2.178 20.9920004,1.731 6.62000002,3.902 10.699,10.461 11.669,17.519 -6.644,2.569 -14.359,2.178 -20.9790004,-1.724 -6.62,-3.901 -10.7,-10.462 -11.682,-17.526"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,43.12113,17.474745)"
|
||||
id="path8"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="m 0,0 c 1.271,7.579 -1.125,14.922 -5.904,20.205 -6.242,-3.433 -10.906,-9.591 -12.178,-17.169 -1.275,-7.594 1.123,-14.937 5.902,-20.22 C -5.936,-13.736 -1.273,-7.578 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,20.082753,7.127875)"
|
||||
id="path9"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="m 0,0 c 1.271,7.579 -1.125,14.922 -5.904,20.206 -6.242,-3.434 -10.906,-9.592 -12.178,-17.17 -1.275,-7.593 1.123,-14.937 5.902,-20.22 C -5.937,-13.736 -1.273,-7.578 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,26.963346,20.756878)"
|
||||
id="path10"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="M 0,0 C 4.779,5.283 7.176,12.627 5.901,20.22 4.629,27.798 -0.035,33.956 -6.277,37.39 -11.055,32.106 -13.453,24.763 -12.18,17.184 -10.908,9.606 -6.244,3.448 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,29.06985,14.051408)"
|
||||
id="path11"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
</g>
|
||||
<path
|
||||
class="fill"
|
||||
d="m 23.308833,63.179301 -3.288947,-3.831872 2.16535,-2.433461 1.123597,-1.262592 1.119364,1.257653 2.169936,2.4384 z M 37.853155,39.714993 c -0.02117,0.08396 -0.9652,3.0988 -3.220508,5.991225 -1.128536,1.453444 -2.574573,2.872317 -4.37515,3.93065 -1.617486,0.947914 -3.517195,1.617839 -5.820481,1.786467 l -1.128183,-1.267531 -1.127125,1.266825 C 19.838911,51.249415 17.912391,50.557971 16.276561,49.58254 13.551705,47.957293 11.640003,45.483263 10.434208,43.388115 9.8309581,42.34354 9.4048026,41.401624 9.134222,40.732051 8.9991081,40.397265 8.902447,40.131271 8.8414165,39.954529 8.8107248,39.865982 8.7892053,39.800013 8.7761526,39.75909 l -0.013053,-0.04233 -0.00212,-0.006 L 8.374688,38.405835 H 0.87287218 v 3.653366 H 5.7302693 c 0.5323417,1.327503 1.5515166,3.495323 3.2441444,5.720645 1.3409083,1.757539 3.1143223,3.553177 5.4257223,4.937477 1.423105,0.854428 3.055761,1.541992 4.884914,1.960034 l -1.365956,1.534936 -2.751314,3.091744 4.069998,4.741686 c -1.8415,0.426861 -3.481212,1.128536 -4.909256,1.995664 -3.439936,2.087739 -5.6744305,5.06095 -7.0735472,7.485944 -0.7094361,1.234017 -1.2043833,2.33292 -1.5250583,3.129845 H 0.87287218 v 3.653366 H 8.3746915 l 0.3869972,-1.306689 c 0.017992,-0.07479 0.9574388,-3.071988 3.1996943,-5.959122 1.120775,-1.448505 2.556934,-2.865966 4.343753,-3.928886 1.594908,-0.94615 3.464983,-1.623483 5.726994,-1.814336 l 1.276703,1.486959 1.276703,-1.486959 c 2.304697,0.193675 4.202641,0.89147 5.8166,1.865136 2.703336,1.631245 4.598811,4.097514 5.794022,6.182431 0.597605,1.039283 1.01988,1.975908 1.288344,2.640894 0.134056,0.33267 0.229658,0.597253 0.289983,0.772583 0.02999,0.08784 0.05151,0.153459 0.06456,0.194028 l 0.01305,0.04198 0.0014,0.0056 0.385939,1.306336 h 7.502877 V 76.657176 H 40.885985 C 40.357172,75.336729 39.347522,73.18549 37.673944,70.973573 36.344677,69.222384 34.586433,67.430273 32.294788,66.041387 30.865333,65.173554 29.224563,64.47082 27.3813,64.044312 L 31.450238,59.304037 28.698572,56.21194 27.33438,54.679121 c 1.829153,-0.417336 3.461456,-1.104195 4.884208,-1.957917 3.46957,-2.081036 5.72135,-5.0673 7.129639,-7.505347 0.716845,-1.244953 1.216025,-2.353733 1.538111,-3.156656 h 4.855986 v -3.653366 h -7.502877 z"
|
||||
id="path12"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"productName": "Bocken",
|
||||
"identifier": "org.bocken.app",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.3",
|
||||
"build": {
|
||||
"devUrl": "http://192.168.1.4:5173",
|
||||
"frontendDist": "https://bocken.org"
|
||||
|
||||
@@ -464,6 +464,99 @@ a:focus-visible {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HIKES TRANSITIONS
|
||||
Cards + filter fly in/out vertically, clicked card morphs into the hero
|
||||
map (cross-fade between thumbnail and map), and the whole below-map panel
|
||||
(an opaque sheet) slides up from the bottom. Page chrome under the hero
|
||||
cross-fades so nothing snaps in at transition end. Lives in app.css (not
|
||||
the page component) so the rules are still loaded on the OLD side of a
|
||||
nav AWAY from /hikes.
|
||||
============================================ */
|
||||
|
||||
@keyframes hikes-fly-up {
|
||||
from { transform: translateY(100vh); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
@keyframes hikes-fly-down {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(100vh); }
|
||||
}
|
||||
@keyframes hikes-root-fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes hikes-root-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
/* Only-child hike-fly-in pseudos (unpaired cards/filter on enter or exit):
|
||||
* kill UA's default fade, switch blend mode so the custom fly animation
|
||||
* shows clean motion against the rest of the page. */
|
||||
::view-transition-old(.hike-fly-in):only-child,
|
||||
::view-transition-new(.hike-fly-in):only-child {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
/* Paired (card ↔ hero): keep UA cross-fade so the card thumbnail dissolves
|
||||
* into the hero map — otherwise the new image would just cover the old one
|
||||
* and the thumbnail would vanish silently at t=0. Stretch the duration to
|
||||
* match the group so the fade ends exactly when the morph does. */
|
||||
::view-transition-old(.hike-fly-in):not(:only-child),
|
||||
::view-transition-new(.hike-fly-in):not(:only-child) {
|
||||
animation-duration: 550ms;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Group (the morphing bbox) timing. */
|
||||
::view-transition-group(.hike-fly-in) {
|
||||
animation-duration: 550ms;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Cards + filter rise from below the viewport on enter. */
|
||||
html.vt-enter-hikes::view-transition-new(.hike-fly-in):only-child {
|
||||
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
|
||||
/* Cards + filter drop off the bottom on exit. */
|
||||
html.vt-exit-hikes::view-transition-old(.hike-fly-in):only-child {
|
||||
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
|
||||
}
|
||||
|
||||
/* Everything below the hero map on a detail page — stage nav, photo strip,
|
||||
* metrics, tags, elevation chart, scroll area, meta footer — slides up from
|
||||
* the bottom on enter and back down on any exit, as one panel. The wrapper
|
||||
* carries `view-transition-name: hike-below-map` and an opaque background, so
|
||||
* the whole sheet (background included) moves; the hero map morphs separately
|
||||
* above, and the rest of the page chrome cross-fades via the root-pseudo rule. */
|
||||
html.vt-enter-hike-detail::view-transition-new(hike-below-map):only-child {
|
||||
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
html.vt-enter-hikes::view-transition-old(hike-below-map):only-child,
|
||||
html.vt-exit-hike-detail::view-transition-old(hike-below-map):only-child {
|
||||
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
|
||||
}
|
||||
|
||||
/* Cross-fade the rest of the page (root pseudo) during hike transitions so
|
||||
* the destination's chrome — metrics + content + footer on the detail page,
|
||||
* overview hero + credit on the index — phases in instead of snapping in
|
||||
* at the end of the morph. Overrides the global rule above; scope keeps
|
||||
* other routes' transitions on their existing instant-swap behavior. */
|
||||
html.vt-enter-hike-detail::view-transition-old(root),
|
||||
html.vt-enter-hikes::view-transition-old(root),
|
||||
html.vt-exit-hikes::view-transition-old(root),
|
||||
html.vt-exit-hike-detail::view-transition-old(root) {
|
||||
animation: hikes-root-fade-out 450ms ease-out both;
|
||||
}
|
||||
html.vt-enter-hike-detail::view-transition-new(root),
|
||||
html.vt-enter-hikes::view-transition-new(root),
|
||||
html.vt-exit-hikes::view-transition-new(root),
|
||||
html.vt-exit-hike-detail::view-transition-new(root) {
|
||||
animation: hikes-root-fade-in 450ms ease-out both;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RECIPE GRID
|
||||
Responsive card grid used across recipe pages
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="%lang%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Flims Gletschermühlen
|
||||
date: 2024-07-14
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [Graubünden, Flims, Sommer]
|
||||
seasons: 5-8
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
import Private from '$lib/components/Private.svelte';
|
||||
</script>
|
||||
|
||||
## Anreise
|
||||
|
||||
Start bei Bargis. Anreise am besten via Bus von Flims.
|
||||
<JourneyPlanner from="<current location>" to="Fidaz, Bargis" toFixed time="07:00" target="arrival"/>
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
<HikeImage idx={28} />
|
||||
<HikeImage idx={29} />
|
||||
<HikeImage idx={30} />
|
||||
<HikeImage idx={31} />
|
||||
<HikeImage idx={32} />
|
||||
<HikeImage idx={33} />
|
||||
<HikeImage idx={34} />
|
||||
<HikeImage idx={35} />
|
||||
<HikeImage idx={36} />
|
||||
<HikeImage idx={37} />
|
||||
<HikeImage idx={38} />
|
||||
<HikeImage idx={39} />
|
||||
<HikeImage idx={40} />
|
||||
<HikeImage idx={41} />
|
||||
<HikeImage idx={42} />
|
||||
<HikeImage idx={43} />
|
||||
<HikeImage idx={44} />
|
||||
<HikeImage idx={45} />
|
||||
<HikeImage idx={46} />
|
||||
<HikeImage idx={47} />
|
||||
<HikeImage idx={48} />
|
||||
<HikeImage idx={49} />
|
||||
<HikeImage idx={50} />
|
||||
<HikeImage idx={51} />
|
||||
<HikeImage idx={52} />
|
||||
<HikeImage idx={53} />
|
||||
<HikeImage idx={54} />
|
||||
<HikeImage idx={55} />
|
||||
<HikeImage idx={56} />
|
||||
<HikeImage idx={57} />
|
||||
<HikeImage idx={58} />
|
||||
<HikeImage idx={59} />
|
||||
<HikeImage idx={60} />
|
||||
<HikeImage idx={61} />
|
||||
<HikeImage idx={62} />
|
||||
<HikeImage idx={63} />
|
||||
<HikeImage idx={64} />
|
||||
<HikeImage idx={65} />
|
||||
<HikeImage idx={66} />
|
||||
<HikeImage idx={67} />
|
||||
<HikeImage idx={68} />
|
||||
<HikeImage idx={69} />
|
||||
<HikeImage idx={70} />
|
||||
<HikeImage idx={71} />
|
||||
<HikeImage idx={72} />
|
||||
<HikeImage idx={73} />
|
||||
<HikeImage idx={74} />
|
||||
<HikeImage idx={75} />
|
||||
|
||||
## Abreise
|
||||
Via Bus oder Auto wieder nach Hause. Wenn man nicht abgeholt wird wie wir, muss man noch etwas weiter laufen bis nach Trin.
|
||||
<JourneyPlanner from="Trin, Quadris" fromFixed to="<current location>" time="15:30" target="departure"/>
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Schlittelausflug Brün
|
||||
date: 2024-12-25
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [Graubünden, Flims, Winter, Schlitteln]
|
||||
seasons: 12-2
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
import Private from '$lib/components/Private.svelte';
|
||||
</script>
|
||||
|
||||
## Übersicht
|
||||
|
||||
Ein netter Ausflug zum Schlitteln, wenn man bereits in Flims ist.
|
||||
Aufstieg ca. 1 Stunde mit wunderschöner Winterlandschaft.
|
||||
|
||||
## Anreise
|
||||
|
||||
Start direkt in Brün. Eine Anreise mit Bus (Linie 404) ist möglich, ein direktes Anfahren mit Auto wäre jedoch zu empfehlen.
|
||||
Es empfiehlt sich ca. um 11 Uhr in Brün anzukommen, da durch die Nähe zum Piz Riein ausserhalb der Mittagszeit es schnell schattig werden kann.
|
||||
<JourneyPlanner from="<current location>" to="Valendas, Brün Dorf" toFixed time="11:00" target="arrival"/>
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
|
||||
<HikeImage src="PXL_20241225_121635285.jpg" alt="Anna auf dem Weg runter" private />
|
||||
<HikeImage src="PXL_20241225_122938851.jpg" alt="Wieder in Brün" private />
|
||||
<HikeImage src="PXL_20241225_122942649.jpg" alt="Wieder in Brün" />
|
||||
|
||||
## Abreise
|
||||
Via Bus (Linie 404) oder Auto.
|
||||
<JourneyPlanner from="Valendas, Brün Dorf" fromFixed to="<current location>" time="12:30" target="departure"/>
|
||||
@@ -0,0 +1,917 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Bocken Route Builder" xmlns="http://www.topografix.com/GPX/1/1" xmlns:bocken="https://bocken.org/gpx/v1">
|
||||
<wpt lat="46.778422" lon="9.30542">
|
||||
<ele>1289.9</ele>
|
||||
<time>2024-12-25T11:00:27.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="33736035" visibility="private"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.781754" lon="9.304902">
|
||||
<ele>1321.3</ele>
|
||||
<time>2024-12-25T11:02:57.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="b50be014"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.78252" lon="9.305407">
|
||||
<ele>1327.1</ele>
|
||||
<time>2024-12-25T11:04:19.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="be3138c8"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.780856" lon="9.307209">
|
||||
<ele>1356.5</ele>
|
||||
<time>2024-12-25T11:09:30.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="d4b01559"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.780487" lon="9.307823">
|
||||
<ele>1363.9</ele>
|
||||
<time>2024-12-25T11:11:11.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="64b8ebe0"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.781086" lon="9.310293">
|
||||
<ele>1444.3</ele>
|
||||
<time>2024-12-25T11:24:33.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="ace73886"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.781736" lon="9.310631">
|
||||
<ele>1453.9</ele>
|
||||
<time>2024-12-25T11:27:15.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="2e3de268"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.781776" lon="9.311906">
|
||||
<ele>1495.3</ele>
|
||||
<time>2024-12-25T11:33:44.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="e8cd91ea"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.77932" lon="9.314403">
|
||||
<ele>1540.4</ele>
|
||||
<time>2024-12-25T11:42:13.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="f03708bf"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.777217" lon="9.317179">
|
||||
<ele>1580.1</ele>
|
||||
<time>2024-12-25T11:50:16.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="0bf223b8" visibility="private"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.780061" lon="9.317093">
|
||||
<ele>1614.9</ele>
|
||||
<time>2024-12-25T12:00:12.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="b0be80dd"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<trk>
|
||||
<name>Etappe 1</name>
|
||||
<trkseg>
|
||||
<trkpt lat="46.778422" lon="9.30542">
|
||||
<ele>1289.9</ele>
|
||||
<time>2024-12-25T11:00:27.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778416" lon="9.305474">
|
||||
<ele>1290.1</ele>
|
||||
<time>2024-12-25T11:00:28.504Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778416" lon="9.305543">
|
||||
<ele>1290.3</ele>
|
||||
<time>2024-12-25T11:00:30.402Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77843" lon="9.305573">
|
||||
<ele>1290.3</ele>
|
||||
<time>2024-12-25T11:00:31.400Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778532" lon="9.305683">
|
||||
<ele>1291.2</ele>
|
||||
<time>2024-12-25T11:00:36.492Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778563" lon="9.305731">
|
||||
<ele>1291.5</ele>
|
||||
<time>2024-12-25T11:00:38.307Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778709" lon="9.305979">
|
||||
<ele>1293.7</ele>
|
||||
<time>2024-12-25T11:00:47.301Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778762" lon="9.306037">
|
||||
<ele>1294.3</ele>
|
||||
<time>2024-12-25T11:00:49.961Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778818" lon="9.306061">
|
||||
<ele>1294.8</ele>
|
||||
<time>2024-12-25T11:00:52.305Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778869" lon="9.306064">
|
||||
<ele>1295.2</ele>
|
||||
<time>2024-12-25T11:00:54.355Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779004" lon="9.306009">
|
||||
<ele>1296.5</ele>
|
||||
<time>2024-12-25T11:00:59.983Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779116" lon="9.305952">
|
||||
<ele>1297.1</ele>
|
||||
<time>2024-12-25T11:01:04.747Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779221" lon="9.305905">
|
||||
<ele>1297.3</ele>
|
||||
<time>2024-12-25T11:01:09.157Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779305" lon="9.305897">
|
||||
<ele>1297.5</ele>
|
||||
<time>2024-12-25T11:01:12.538Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779651" lon="9.305939">
|
||||
<ele>1301.6</ele>
|
||||
<time>2024-12-25T11:01:26.481Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779773" lon="9.305926">
|
||||
<ele>1303.0</ele>
|
||||
<time>2024-12-25T11:01:31.394Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779851" lon="9.305896">
|
||||
<ele>1304.2</ele>
|
||||
<time>2024-12-25T11:01:34.633Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779954" lon="9.305841">
|
||||
<ele>1305.4</ele>
|
||||
<time>2024-12-25T11:01:39.037Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780225" lon="9.30561">
|
||||
<ele>1309.0</ele>
|
||||
<time>2024-12-25T11:01:51.639Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780626" lon="9.305371">
|
||||
<ele>1313.5</ele>
|
||||
<time>2024-12-25T11:02:09.033Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780671" lon="9.305355">
|
||||
<ele>1314.0</ele>
|
||||
<time>2024-12-25T11:02:10.893Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780816" lon="9.305363">
|
||||
<ele>1314.8</ele>
|
||||
<time>2024-12-25T11:02:16.720Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780929" lon="9.30533">
|
||||
<ele>1315.3</ele>
|
||||
<time>2024-12-25T11:02:21.348Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780989" lon="9.305304">
|
||||
<ele>1315.5</ele>
|
||||
<time>2024-12-25T11:02:23.861Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781243" lon="9.305123">
|
||||
<ele>1316.5</ele>
|
||||
<time>2024-12-25T11:02:35.212Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781504" lon="9.304991">
|
||||
<ele>1318.9</ele>
|
||||
<time>2024-12-25T11:02:46.304Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781617" lon="9.30491">
|
||||
<ele>1320.1</ele>
|
||||
<time>2024-12-25T11:02:51.360Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781668" lon="9.304888">
|
||||
<ele>1320.6</ele>
|
||||
<time>2024-12-25T11:02:53.495Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781706" lon="9.304886">
|
||||
<ele>1320.9</ele>
|
||||
<time>2024-12-25T11:02:55.022Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781754" lon="9.304902">
|
||||
<ele>1321.3</ele>
|
||||
<time>2024-12-25T11:02:57.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781813" lon="9.304937">
|
||||
<ele>1321.8</ele>
|
||||
<time>2024-12-25T11:03:02.723Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78206" lon="9.305146">
|
||||
<ele>1323.5</ele>
|
||||
<time>2024-12-25T11:03:28.379Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782117" lon="9.305184">
|
||||
<ele>1323.9</ele>
|
||||
<time>2024-12-25T11:03:34.011Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782334" lon="9.305263">
|
||||
<ele>1325.3</ele>
|
||||
<time>2024-12-25T11:03:54.110Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78248" lon="9.305283">
|
||||
<ele>1326.1</ele>
|
||||
<time>2024-12-25T11:04:07.290Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782509" lon="9.305298">
|
||||
<ele>1326.4</ele>
|
||||
<time>2024-12-25T11:04:10.055Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782535" lon="9.30533">
|
||||
<ele>1326.6</ele>
|
||||
<time>2024-12-25T11:04:13.111Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782546" lon="9.305368">
|
||||
<ele>1326.8</ele>
|
||||
<time>2024-12-25T11:04:15.650Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78252" lon="9.305407">
|
||||
<ele>1327.1</ele>
|
||||
<time>2024-12-25T11:04:19.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782529" lon="9.305446">
|
||||
<ele>1327.2</ele>
|
||||
<time>2024-12-25T11:04:22.651Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782505" lon="9.305472">
|
||||
<ele>1327.5</ele>
|
||||
<time>2024-12-25T11:04:26.523Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782475" lon="9.305481">
|
||||
<ele>1327.8</ele>
|
||||
<time>2024-12-25T11:04:30.491Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782437" lon="9.305473">
|
||||
<ele>1327.9</ele>
|
||||
<time>2024-12-25T11:04:35.466Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782332" lon="9.305419">
|
||||
<ele>1328.5</ele>
|
||||
<time>2024-12-25T11:04:49.890Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782273" lon="9.305398">
|
||||
<ele>1328.9</ele>
|
||||
<time>2024-12-25T11:04:57.758Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782008" lon="9.305388">
|
||||
<ele>1331.5</ele>
|
||||
<time>2024-12-25T11:05:32.106Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781851" lon="9.30536">
|
||||
<ele>1333.6</ele>
|
||||
<time>2024-12-25T11:05:52.600Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781795" lon="9.305358">
|
||||
<ele>1334.5</ele>
|
||||
<time>2024-12-25T11:05:59.858Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781736" lon="9.305378">
|
||||
<ele>1335.4</ele>
|
||||
<time>2024-12-25T11:06:07.706Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781714" lon="9.305402">
|
||||
<ele>1335.8</ele>
|
||||
<time>2024-12-25T11:06:11.264Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781688" lon="9.30546">
|
||||
<ele>1336.6</ele>
|
||||
<time>2024-12-25T11:06:17.415Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781662" lon="9.305689">
|
||||
<ele>1338.7</ele>
|
||||
<time>2024-12-25T11:06:38.011Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781641" lon="9.305796">
|
||||
<ele>1339.7</ele>
|
||||
<time>2024-12-25T11:06:47.887Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781615" lon="9.305873">
|
||||
<ele>1340.4</ele>
|
||||
<time>2024-12-25T11:06:55.505Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781592" lon="9.305919">
|
||||
<ele>1341.0</ele>
|
||||
<time>2024-12-25T11:07:00.559Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781448" lon="9.306123">
|
||||
<ele>1343.6</ele>
|
||||
<time>2024-12-25T11:07:26.554Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781411" lon="9.306188">
|
||||
<ele>1344.4</ele>
|
||||
<time>2024-12-25T11:07:34.054Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781245" lon="9.306508">
|
||||
<ele>1348.1</ele>
|
||||
<time>2024-12-25T11:08:09.674Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781134" lon="9.306686">
|
||||
<ele>1350.4</ele>
|
||||
<time>2024-12-25T11:08:31.035Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781063" lon="9.306817">
|
||||
<ele>1351.9</ele>
|
||||
<time>2024-12-25T11:08:45.859Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780882" lon="9.307183">
|
||||
<ele>1356.1</ele>
|
||||
<time>2024-12-25T11:09:25.916Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780856" lon="9.307209">
|
||||
<ele>1356.5</ele>
|
||||
<time>2024-12-25T11:09:30.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780806" lon="9.307293">
|
||||
<ele>1357.5</ele>
|
||||
<time>2024-12-25T11:09:43.673Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780634" lon="9.307515">
|
||||
<ele>1360.3</ele>
|
||||
<time>2024-12-25T11:10:24.855Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780549" lon="9.307703">
|
||||
<ele>1362.4</ele>
|
||||
<time>2024-12-25T11:10:52.532Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780487" lon="9.307823">
|
||||
<ele>1363.9</ele>
|
||||
<time>2024-12-25T11:11:11.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780461" lon="9.30794">
|
||||
<ele>1365.1</ele>
|
||||
<time>2024-12-25T11:11:21.979Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780403" lon="9.308115">
|
||||
<ele>1366.9</ele>
|
||||
<time>2024-12-25T11:11:39.333Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780344" lon="9.308364">
|
||||
<ele>1368.9</ele>
|
||||
<time>2024-12-25T11:12:02.851Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780309" lon="9.308474">
|
||||
<ele>1370.0</ele>
|
||||
<time>2024-12-25T11:12:13.678Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780172" lon="9.308786">
|
||||
<ele>1373.6</ele>
|
||||
<time>2024-12-25T11:12:46.761Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78013" lon="9.308907">
|
||||
<ele>1374.7</ele>
|
||||
<time>2024-12-25T11:12:58.870Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780088" lon="9.309049">
|
||||
<ele>1376.1</ele>
|
||||
<time>2024-12-25T11:13:12.676Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780027" lon="9.309317">
|
||||
<ele>1378.7</ele>
|
||||
<time>2024-12-25T11:13:37.885Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779979" lon="9.30956">
|
||||
<ele>1381.0</ele>
|
||||
<time>2024-12-25T11:14:00.460Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779951" lon="9.309722">
|
||||
<ele>1382.5</ele>
|
||||
<time>2024-12-25T11:14:15.373Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779923" lon="9.309842">
|
||||
<ele>1383.7</ele>
|
||||
<time>2024-12-25T11:14:26.689Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779794" lon="9.310237">
|
||||
<ele>1388.0</ele>
|
||||
<time>2024-12-25T11:15:05.752Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779698" lon="9.310591">
|
||||
<ele>1391.1</ele>
|
||||
<time>2024-12-25T11:15:39.737Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77967" lon="9.310656">
|
||||
<ele>1391.9</ele>
|
||||
<time>2024-12-25T11:15:46.592Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779627" lon="9.310722">
|
||||
<ele>1392.6</ele>
|
||||
<time>2024-12-25T11:15:54.723Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779532" lon="9.3108">
|
||||
<ele>1394.3</ele>
|
||||
<time>2024-12-25T11:16:08.929Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779468" lon="9.310839">
|
||||
<ele>1395.0</ele>
|
||||
<time>2024-12-25T11:16:17.969Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779411" lon="9.310862">
|
||||
<ele>1395.8</ele>
|
||||
<time>2024-12-25T11:16:25.677Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779353" lon="9.310881">
|
||||
<ele>1396.5</ele>
|
||||
<time>2024-12-25T11:16:33.425Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778964" lon="9.310958">
|
||||
<ele>1402.1</ele>
|
||||
<time>2024-12-25T11:17:24.593Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778797" lon="9.311025">
|
||||
<ele>1404.1</ele>
|
||||
<time>2024-12-25T11:17:47.167Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778656" lon="9.311056">
|
||||
<ele>1405.3</ele>
|
||||
<time>2024-12-25T11:18:05.753Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778631" lon="9.311068">
|
||||
<ele>1405.4</ele>
|
||||
<time>2024-12-25T11:18:09.183Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778612" lon="9.311084">
|
||||
<ele>1405.6</ele>
|
||||
<time>2024-12-25T11:18:12.042Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778598" lon="9.311108">
|
||||
<ele>1405.8</ele>
|
||||
<time>2024-12-25T11:18:14.856Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778598" lon="9.31116">
|
||||
<ele>1406.2</ele>
|
||||
<time>2024-12-25T11:18:19.498Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778609" lon="9.311186">
|
||||
<ele>1406.3</ele>
|
||||
<time>2024-12-25T11:18:22.226Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778629" lon="9.31121">
|
||||
<ele>1406.4</ele>
|
||||
<time>2024-12-25T11:18:25.600Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778651" lon="9.311221">
|
||||
<ele>1406.6</ele>
|
||||
<time>2024-12-25T11:18:28.631Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77884" lon="9.311188">
|
||||
<ele>1408.2</ele>
|
||||
<time>2024-12-25T11:18:53.442Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778933" lon="9.3112">
|
||||
<ele>1409.4</ele>
|
||||
<time>2024-12-25T11:19:05.611Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778978" lon="9.311215">
|
||||
<ele>1409.9</ele>
|
||||
<time>2024-12-25T11:19:11.628Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779154" lon="9.311334">
|
||||
<ele>1412.5</ele>
|
||||
<time>2024-12-25T11:19:36.908Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779327" lon="9.311421">
|
||||
<ele>1415.2</ele>
|
||||
<time>2024-12-25T11:20:00.758Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779497" lon="9.311468">
|
||||
<ele>1417.4</ele>
|
||||
<time>2024-12-25T11:20:23.310Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779841" lon="9.311512">
|
||||
<ele>1422.3</ele>
|
||||
<time>2024-12-25T11:21:08.321Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779942" lon="9.311522">
|
||||
<ele>1423.7</ele>
|
||||
<time>2024-12-25T11:21:21.516Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780057" lon="9.311506">
|
||||
<ele>1425.2</ele>
|
||||
<time>2024-12-25T11:21:36.573Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780112" lon="9.311482">
|
||||
<ele>1426.0</ele>
|
||||
<time>2024-12-25T11:21:44.056Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780204" lon="9.311423">
|
||||
<ele>1427.5</ele>
|
||||
<time>2024-12-25T11:21:57.153Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78027" lon="9.311369">
|
||||
<ele>1428.7</ele>
|
||||
<time>2024-12-25T11:22:07.014Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78032" lon="9.311315">
|
||||
<ele>1429.5</ele>
|
||||
<time>2024-12-25T11:22:15.120Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780368" lon="9.311245">
|
||||
<ele>1430.4</ele>
|
||||
<time>2024-12-25T11:22:23.962Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780629" lon="9.310815">
|
||||
<ele>1435.7</ele>
|
||||
<time>2024-12-25T11:23:15.251Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780899" lon="9.310423">
|
||||
<ele>1441.3</ele>
|
||||
<time>2024-12-25T11:24:04.878Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780968" lon="9.310341">
|
||||
<ele>1442.6</ele>
|
||||
<time>2024-12-25T11:24:16.474Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781011" lon="9.310305">
|
||||
<ele>1443.2</ele>
|
||||
<time>2024-12-25T11:24:22.934Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781048" lon="9.310287">
|
||||
<ele>1443.8</ele>
|
||||
<time>2024-12-25T11:24:28.018Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781086" lon="9.310293">
|
||||
<ele>1444.3</ele>
|
||||
<time>2024-12-25T11:24:33.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78109" lon="9.310279">
|
||||
<ele>1444.6</ele>
|
||||
<time>2024-12-25T11:24:35.394Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781125" lon="9.310282">
|
||||
<ele>1444.8</ele>
|
||||
<time>2024-12-25T11:24:43.475Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78119" lon="9.310304">
|
||||
<ele>1445.8</ele>
|
||||
<time>2024-12-25T11:24:58.853Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781608" lon="9.310537">
|
||||
<ele>1451.9</ele>
|
||||
<time>2024-12-25T11:26:41.977Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781736" lon="9.310631">
|
||||
<ele>1453.9</ele>
|
||||
<time>2024-12-25T11:27:15.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781992" lon="9.310887">
|
||||
<ele>1458.1</ele>
|
||||
<time>2024-12-25T11:27:54.242Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782265" lon="9.311167">
|
||||
<ele>1463.0</ele>
|
||||
<time>2024-12-25T11:28:36.436Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782316" lon="9.311231">
|
||||
<ele>1463.9</ele>
|
||||
<time>2024-12-25T11:28:44.941Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782351" lon="9.311289">
|
||||
<ele>1464.6</ele>
|
||||
<time>2024-12-25T11:28:51.636Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782389" lon="9.311378">
|
||||
<ele>1465.7</ele>
|
||||
<time>2024-12-25T11:29:00.720Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782576" lon="9.311913">
|
||||
<ele>1471.2</ele>
|
||||
<time>2024-12-25T11:29:52.743Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782683" lon="9.312188">
|
||||
<ele>1474.0</ele>
|
||||
<time>2024-12-25T11:30:20.137Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782742" lon="9.312381">
|
||||
<ele>1475.9</ele>
|
||||
<time>2024-12-25T11:30:38.442Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782786" lon="9.312543">
|
||||
<ele>1477.2</ele>
|
||||
<time>2024-12-25T11:30:53.536Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782798" lon="9.312602">
|
||||
<ele>1477.6</ele>
|
||||
<time>2024-12-25T11:30:58.867Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782798" lon="9.312636">
|
||||
<ele>1477.8</ele>
|
||||
<time>2024-12-25T11:31:01.812Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782789" lon="9.312662">
|
||||
<ele>1477.9</ele>
|
||||
<time>2024-12-25T11:31:04.335Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782746" lon="9.312686">
|
||||
<ele>1478.3</ele>
|
||||
<time>2024-12-25T11:31:10.157Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782721" lon="9.312689">
|
||||
<ele>1478.7</ele>
|
||||
<time>2024-12-25T11:31:13.330Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782699" lon="9.312678">
|
||||
<ele>1478.9</ele>
|
||||
<time>2024-12-25T11:31:16.271Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782678" lon="9.312654">
|
||||
<ele>1479.2</ele>
|
||||
<time>2024-12-25T11:31:19.643Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78246" lon="9.312333">
|
||||
<ele>1483.2</ele>
|
||||
<time>2024-12-25T11:31:58.799Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782285" lon="9.312031">
|
||||
<ele>1487.3</ele>
|
||||
<time>2024-12-25T11:32:33.063Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782246" lon="9.311979">
|
||||
<ele>1488.2</ele>
|
||||
<time>2024-12-25T11:32:39.743Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782196" lon="9.31192">
|
||||
<ele>1489.2</ele>
|
||||
<time>2024-12-25T11:32:47.873Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782137" lon="9.311873">
|
||||
<ele>1490.0</ele>
|
||||
<time>2024-12-25T11:32:56.373Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782088" lon="9.311841">
|
||||
<ele>1490.7</ele>
|
||||
<time>2024-12-25T11:33:03.162Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782035" lon="9.311823">
|
||||
<ele>1491.5</ele>
|
||||
<time>2024-12-25T11:33:10.045Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781976" lon="9.311818">
|
||||
<ele>1492.1</ele>
|
||||
<time>2024-12-25T11:33:17.519Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78192" lon="9.311833">
|
||||
<ele>1492.8</ele>
|
||||
<time>2024-12-25T11:33:24.720Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781776" lon="9.311906">
|
||||
<ele>1495.3</ele>
|
||||
<time>2024-12-25T11:33:44.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781729" lon="9.311954">
|
||||
<ele>1496.1</ele>
|
||||
<time>2024-12-25T11:33:53.503Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781335" lon="9.312383">
|
||||
<ele>1503.6</ele>
|
||||
<time>2024-12-25T11:35:14.942Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781145" lon="9.312623">
|
||||
<ele>1507.2</ele>
|
||||
<time>2024-12-25T11:35:56.570Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781083" lon="9.312733">
|
||||
<ele>1508.8</ele>
|
||||
<time>2024-12-25T11:36:12.736Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781002" lon="9.31291">
|
||||
<ele>1511.1</ele>
|
||||
<time>2024-12-25T11:36:36.893Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78095" lon="9.312999">
|
||||
<ele>1512.4</ele>
|
||||
<time>2024-12-25T11:36:50.168Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780902" lon="9.31306">
|
||||
<ele>1513.2</ele>
|
||||
<time>2024-12-25T11:37:00.712Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78086" lon="9.31309">
|
||||
<ele>1514.1</ele>
|
||||
<time>2024-12-25T11:37:08.460Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780694" lon="9.31326">
|
||||
<ele>1517.2</ele>
|
||||
<time>2024-12-25T11:37:42.057Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780601" lon="9.313378">
|
||||
<ele>1519.0</ele>
|
||||
<time>2024-12-25T11:38:02.472Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78048" lon="9.313565">
|
||||
<ele>1521.6</ele>
|
||||
<time>2024-12-25T11:38:31.666Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780447" lon="9.313604">
|
||||
<ele>1522.2</ele>
|
||||
<time>2024-12-25T11:38:38.701Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780285" lon="9.313719">
|
||||
<ele>1524.7</ele>
|
||||
<time>2024-12-25T11:39:08.549Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780238" lon="9.313764">
|
||||
<ele>1525.6</ele>
|
||||
<time>2024-12-25T11:39:17.862Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780095" lon="9.313951">
|
||||
<ele>1528.0</ele>
|
||||
<time>2024-12-25T11:39:49.670Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780029" lon="9.314036">
|
||||
<ele>1529.3</ele>
|
||||
<time>2024-12-25T11:40:04.252Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779972" lon="9.314074">
|
||||
<ele>1530.3</ele>
|
||||
<time>2024-12-25T11:40:14.635Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77993" lon="9.314088">
|
||||
<ele>1530.8</ele>
|
||||
<time>2024-12-25T11:40:21.774Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77964" lon="9.314135">
|
||||
<ele>1535.1</ele>
|
||||
<time>2024-12-25T11:41:10.123Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779527" lon="9.31417">
|
||||
<ele>1536.7</ele>
|
||||
<time>2024-12-25T11:41:29.265Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779487" lon="9.314194">
|
||||
<ele>1537.3</ele>
|
||||
<time>2024-12-25T11:41:36.431Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779441" lon="9.314237">
|
||||
<ele>1538.0</ele>
|
||||
<time>2024-12-25T11:41:45.481Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779396" lon="9.314295">
|
||||
<ele>1539.0</ele>
|
||||
<time>2024-12-25T11:41:55.427Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77932" lon="9.314403">
|
||||
<ele>1540.4</ele>
|
||||
<time>2024-12-25T11:42:13.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779313" lon="9.314438">
|
||||
<ele>1540.8</ele>
|
||||
<time>2024-12-25T11:42:16.830Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77928" lon="9.314478">
|
||||
<ele>1541.3</ele>
|
||||
<time>2024-12-25T11:42:23.410Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779212" lon="9.314531">
|
||||
<ele>1542.4</ele>
|
||||
<time>2024-12-25T11:42:35.235Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779139" lon="9.314551">
|
||||
<ele>1543.4</ele>
|
||||
<time>2024-12-25T11:42:46.630Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778993" lon="9.314513">
|
||||
<ele>1545.5</ele>
|
||||
<time>2024-12-25T11:43:09.381Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778945" lon="9.314511">
|
||||
<ele>1546.2</ele>
|
||||
<time>2024-12-25T11:43:16.748Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778818" lon="9.314557">
|
||||
<ele>1547.9</ele>
|
||||
<time>2024-12-25T11:43:36.823Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778609" lon="9.314607">
|
||||
<ele>1550.6</ele>
|
||||
<time>2024-12-25T11:44:09.314Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77849" lon="9.314662">
|
||||
<ele>1552.3</ele>
|
||||
<time>2024-12-25T11:44:28.463Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778114" lon="9.314981">
|
||||
<ele>1558.2</ele>
|
||||
<time>2024-12-25T11:45:35.177Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778062" lon="9.315019">
|
||||
<ele>1558.7</ele>
|
||||
<time>2024-12-25T11:45:44.097Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777899" lon="9.315109">
|
||||
<ele>1561.1</ele>
|
||||
<time>2024-12-25T11:46:10.832Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777872" lon="9.315135">
|
||||
<ele>1561.6</ele>
|
||||
<time>2024-12-25T11:46:15.794Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777853" lon="9.315164">
|
||||
<ele>1562.0</ele>
|
||||
<time>2024-12-25T11:46:20.011Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77783" lon="9.315238">
|
||||
<ele>1562.8</ele>
|
||||
<time>2024-12-25T11:46:28.548Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777777" lon="9.315595">
|
||||
<ele>1566.2</ele>
|
||||
<time>2024-12-25T11:47:06.927Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777752" lon="9.315719">
|
||||
<ele>1567.4</ele>
|
||||
<time>2024-12-25T11:47:20.508Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777724" lon="9.315821">
|
||||
<ele>1568.2</ele>
|
||||
<time>2024-12-25T11:47:32.053Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777662" lon="9.315984">
|
||||
<ele>1569.9</ele>
|
||||
<time>2024-12-25T11:47:51.643Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777446" lon="9.316396">
|
||||
<ele>1573.9</ele>
|
||||
<time>2024-12-25T11:48:46.157Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777371" lon="9.316581">
|
||||
<ele>1575.4</ele>
|
||||
<time>2024-12-25T11:49:08.744Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777344" lon="9.316674">
|
||||
<ele>1576.2</ele>
|
||||
<time>2024-12-25T11:49:19.357Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777256" lon="9.317067">
|
||||
<ele>1579.0</ele>
|
||||
<time>2024-12-25T11:50:02.799Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777217" lon="9.317179">
|
||||
<ele>1580.1</ele>
|
||||
<time>2024-12-25T11:50:16.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777218" lon="9.317208">
|
||||
<ele>1580.3</ele>
|
||||
<time>2024-12-25T11:50:20.041Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77726" lon="9.31725">
|
||||
<ele>1579.6</ele>
|
||||
<time>2024-12-25T11:50:30.385Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777316" lon="9.317282">
|
||||
<ele>1578.8</ele>
|
||||
<time>2024-12-25T11:50:42.606Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777683" lon="9.317418">
|
||||
<ele>1580.2</ele>
|
||||
<time>2024-12-25T11:51:59.551Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777914" lon="9.317508">
|
||||
<ele>1583.2</ele>
|
||||
<time>2024-12-25T11:52:48.136Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778206" lon="9.317509">
|
||||
<ele>1590.3</ele>
|
||||
<time>2024-12-25T11:53:47.475Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778592" lon="9.317407">
|
||||
<ele>1598.0</ele>
|
||||
<time>2024-12-25T11:55:07.191Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77922" lon="9.317416">
|
||||
<ele>1607.9</ele>
|
||||
<time>2024-12-25T11:57:14.818Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779428" lon="9.317344">
|
||||
<ele>1609.4</ele>
|
||||
<time>2024-12-25T11:57:58.258Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77965" lon="9.317225">
|
||||
<ele>1612.7</ele>
|
||||
<time>2024-12-25T11:58:46.316Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77985" lon="9.31718">
|
||||
<ele>1613.6</ele>
|
||||
<time>2024-12-25T11:59:27.439Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780045" lon="9.317101">
|
||||
<ele>1614.9</ele>
|
||||
<time>2024-12-25T12:00:08.563Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780061" lon="9.317093">
|
||||
<ele>1614.9</ele>
|
||||
<time>2024-12-25T12:00:12.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780092" lon="9.317082">
|
||||
<ele>1614.9</ele>
|
||||
<time>2024-12-25T12:02:09.000Z</time>
|
||||
</trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Monte Generosa
|
||||
date: 2024-09-02
|
||||
author: Alexander
|
||||
difficulty: T4
|
||||
tags: [Tessin, Schweiz, Sommer, Schwierig]
|
||||
seasons: 5-9
|
||||
summary: Eine anspruchsvolle aber kurze Gipfelbesteigung
|
||||
heroAlt: Blick auf die Felswand, den schwierigsten Teil der Route
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Übersicht
|
||||
|
||||
Eine tiefe T4-Wanderung mit starkem Anstieg konsistent. Der letzte Aufstieg ist sehr exponiert, benötigt Hände und hat die Gefahr eines über 100 m Absturz. Nichts für Bergsteigeranfänger.
|
||||
Es empfiehlt sich, das Wetter explizit für Monte Generosa zu überprüfen.
|
||||
Wir wurden im schwierigsten Teil von Regen überrascht, was wohl zur nervenzerreibendsten Wanderung meines Lebens geführt hat.
|
||||
|
||||
## Anreise
|
||||
|
||||
Anreise via Bus (Linie 541) nach Rovio, Paese.
|
||||
<JourneyPlanner from="<current location>" to="Rovio, Paese" toFixed target="arrival" time="09:00" />
|
||||
|
||||
## Anfang
|
||||
|
||||
Man fängt in Rovio an und geniesst noch kurz die engen Gassen des Dorfes.
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
|
||||
Dann fängt der erste Waldteil an. Anfangs noch auf einem recht guten Waldweg. Später geht es über zu einem Pfad mit einem deutlich stärkeren Anstieg. Der Pfad ist sehr gut markiert.
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
|
||||
## Letzte Rast
|
||||
|
||||
Vor dem eigentlichen T4-Stück kann man sich noch gut bei einer kleinen Hütte ausruhen und die schöne Aussicht geniessen.
|
||||
Wie auf den Bildern zu sehen, waren hier bereits grössere Wolken voll um uns herum.
|
||||
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
|
||||
Blick auf die Steilwand, welche man links vom Bild besteigen wird.
|
||||
|
||||
<HikeImage idx={17} />
|
||||
|
||||
Wegen starkem Regen und Schwierigkeit der Strecke fehlen hier Bilder.
|
||||
|
||||
## Ankunft auf der Bergstation
|
||||
|
||||
Oben auf der Station angekommen, wurde das Wetter wieder besser und hat einen schönen Ausblick ermöglicht.
|
||||
Ein guter Punkt, um ein ordentliches Dessert zu geniessen.
|
||||
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
|
||||
## Heimreise
|
||||
|
||||
Von der Bergstation fährt eine Zahnradbahn nach Capolago-Riva S.Vitale. Von dort via Zug geht es schnell nach Lugano oder zu anderen Orten.
|
||||
<JourneyPlanner from="Generoso Vetta" to="<current location>" fromFixed target=departure time="14:30" />
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Spaziergang um den Pfäffikersee
|
||||
date: 2024-07-27
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [Zürich, See, Familie, Sommer]
|
||||
summary: Ein entspannter Spaziergang zum Juckerhof um den Pfäffikersee.
|
||||
heroAlt: Blick auf den Kirchturm Seegräben durch das Schilf
|
||||
seasons: 4-9
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Übersicht
|
||||
|
||||
Ein wunderschöner Spaziergang um den Pfäffikersee mit Ziel Juckerhof.
|
||||
Dort kann man ein kleines Picknick geniessen und auch z.B. selber Heidelbeeren pflücken.
|
||||
|
||||
## Anfahrt
|
||||
|
||||
Anreise via Zug nach Pfäffikon, ZH.
|
||||
<JourneyPlanner from="<current location>" to="Pfäffikon ZH, Bahnhof" toFixed time="09:00" target="arrival" />
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
|
||||
## Römisches Kastell
|
||||
|
||||
Auf dem Weg kommt man an einer Ruine einer römischen Festungsanlage vorbei.
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
|
||||
## Juckerhof
|
||||
|
||||
Nach ca. 1,5 Stunden ist das eigentliche Ziel des Spaziergangs erreicht: der Juckerhof.
|
||||
Hier kann man eine kleine Verpflegung sich direkt am Hof holen und auf den hübschen Wiesen geniessen.
|
||||
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
|
||||
### Heidelbeerpflücken
|
||||
|
||||
Es ist auch möglich selber Heidelbeeren zu pflücken und die hübsche Aussicht zu geniessen.
|
||||
<HikeImage src="PXL_20240727_113858845.jpg" />
|
||||
<HikeImage src="PXL_20240727_113854798.jpg" />
|
||||
<HikeImage src="PXL_20240727_113902155.jpg" private />
|
||||
|
||||
## Abreise
|
||||
|
||||
Via Zug von Aathal, 30 Minuten vom Juckerhof.
|
||||
<JourneyPlanner from="Aathal" to="<current location>" fromFixed time="14:00" target="departure" />
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: Wanderung durch das Verzascatal
|
||||
date: 2024-09-01
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [Tessin, Panorama, Familie, Spätsommer]
|
||||
summary: Eine schöne Aussichtstour durch das Tessin... wenn das Wetter hält
|
||||
heroAlt:
|
||||
seasons: 5-9
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Anfahrt
|
||||
|
||||
Anreise von Lugano via Bus nach Lavertezzo, Ai Poss.
|
||||
<JourneyPlanner from="<current location>" to="Lavertezzo, Ai Poss" toFixed time="09:00" target="arrival" />
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
<HikeImage idx={28} />
|
||||
<HikeImage idx={29} />
|
||||
<HikeImage idx={30} />
|
||||
<HikeImage idx={31} />
|
||||
<HikeImage idx={32} />
|
||||
<HikeImage idx={33} />
|
||||
<HikeImage idx={34} />
|
||||
<HikeImage idx={35} />
|
||||
<HikeImage idx={36} />
|
||||
<HikeImage idx={37} />
|
||||
<HikeImage idx={38} />
|
||||
<HikeImage idx={39} />
|
||||
<HikeImage idx={40} />
|
||||
<HikeImage idx={41} />
|
||||
<HikeImage idx={42} />
|
||||
<HikeImage idx={43} />
|
||||
<HikeImage idx={44} />
|
||||
<HikeImage idx={45} />
|
||||
<HikeImage idx={46} />
|
||||
<HikeImage idx={47} />
|
||||
|
||||
## Abreise
|
||||
|
||||
Via Bus zurück von Sonogno nach Lugano. Dort via Zug nach Hause.
|
||||
<JourneyPlanner from="Sonogno" to="<current location>" fromFixed time="12:00" target="departure" />
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,110 @@
|
||||
---
|
||||
title: Walenseewanderung
|
||||
date: 2024-04-14
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [St. Gallen, Walensee, Waldwanderung]
|
||||
seasons: 5-8
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
### Anreise
|
||||
|
||||
Anreise nach Amden, Dorf.
|
||||
|
||||
<JourneyPlanner from="<current location>" to="Amden, Dorf" toFixed time="08:30" target="arrival"/>
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
<HikeImage idx={28} />
|
||||
<HikeImage idx={29} />
|
||||
<HikeImage idx={30} />
|
||||
<HikeImage idx={31} />
|
||||
<HikeImage idx={32} />
|
||||
<HikeImage idx={33} />
|
||||
<HikeImage idx={34} />
|
||||
<HikeImage idx={35} />
|
||||
<HikeImage idx={36} />
|
||||
<HikeImage idx={37} />
|
||||
<HikeImage idx={38} />
|
||||
<HikeImage idx={39} />
|
||||
<HikeImage idx={40} />
|
||||
<HikeImage idx={41} />
|
||||
<HikeImage idx={42} />
|
||||
<HikeImage idx={43} />
|
||||
<HikeImage idx={44} />
|
||||
<HikeImage idx={45} />
|
||||
<HikeImage idx={46} />
|
||||
<HikeImage idx={47} />
|
||||
<HikeImage idx={48} />
|
||||
<HikeImage idx={49} />
|
||||
<HikeImage idx={50} />
|
||||
<HikeImage idx={51} />
|
||||
<HikeImage idx={52} />
|
||||
<HikeImage idx={53} />
|
||||
<HikeImage idx={54} />
|
||||
<HikeImage idx={55} />
|
||||
<HikeImage idx={56} />
|
||||
<HikeImage idx={57} />
|
||||
<HikeImage idx={58} />
|
||||
<HikeImage idx={59} />
|
||||
<HikeImage idx={60} />
|
||||
<HikeImage idx={61} />
|
||||
<HikeImage idx={62} />
|
||||
<HikeImage idx={63} />
|
||||
<HikeImage idx={64} />
|
||||
<HikeImage idx={65} />
|
||||
<HikeImage idx={66} />
|
||||
<HikeImage idx={67} />
|
||||
<HikeImage idx={68} />
|
||||
<HikeImage idx={69} />
|
||||
<HikeImage idx={70} />
|
||||
<HikeImage idx={71} />
|
||||
<HikeImage idx={72} />
|
||||
<HikeImage idx={73} />
|
||||
<HikeImage idx={74} />
|
||||
<HikeImage idx={75} />
|
||||
<HikeImage idx={76} />
|
||||
<HikeImage idx={77} />
|
||||
<HikeImage idx={78} />
|
||||
<HikeImage idx={79} />
|
||||
<HikeImage idx={80} />
|
||||
<HikeImage idx={81} />
|
||||
<HikeImage idx={82} />
|
||||
|
||||
## Heimreise
|
||||
|
||||
Via Schiff von Quinten nach Murg. Dort mit Zug nach Hause.
|
||||
<JourneyPlanner fromFixed from="Quinten" to="<current location>" time="14:30" target="departure"/>
|
||||
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: Klausenpasswanderung
|
||||
date: 2025-08-17
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [Zentralschweiz, Panorama, family, Spätsommer]
|
||||
summary: Eine schöne Aussichtstour durch die Zentralschweiz... wenn das Wetter hält
|
||||
heroAlt:
|
||||
seasons: 5-8
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Übersicht
|
||||
|
||||
Eine wunderschöne Panoramawanderung im Zentralschweizer Gebirge.
|
||||
Anspruch nicht sehr hoch, da grösstenteils flach oder gemütlich abwärts.
|
||||
Bei unserem Besuch hat das Wetter leider nicht ganz mitgespielt, weswegen grosse Teile voller Nebel waren.
|
||||
|
||||
## Anfahrt
|
||||
|
||||
Anreise via Altdorf, Uri und dann mit dem Bus auf die Klausen Passhöhe.
|
||||
<JourneyPlanner from="<current location>" to="Klausen Passhöhe" toFixed time="09:00" target="arrival" />
|
||||
|
||||
Die Wanderung hat wunderschön angefangen mit einer weiten Aussicht über das Tal.
|
||||
<HikeImage idx={0} />
|
||||
|
||||
Wir haben auch direkt einen Bergsalamander getroffen.
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
|
||||
Kurz bevor der Nebel aufkam hatten wir noch einen wunderschönen Ausblick auf den Gross Windgällen (rechts) und Gross Ruchen (links)
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
Leider verfolgte uns Nach 1.5 Stunden der Nebel, was die weitere Aussicht erschwerte.
|
||||
Nichtsdestotroz hat die Wanderun eine schönen Wanderweg bereitgestellt.
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
|
||||
Bei Mättental gab es einige Möglichkeiten Bergkäse direkt ab Hof zu kaufen. Ein nettes Mitbringsel von der Wanderung.
|
||||
<HikeImage idx={17} />
|
||||
Nach ca. 4 Stunden war der Nebel grösstenteils vorbei und wir konnten die schöne Alpenlandschaft geniessen.
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
<HikeImage idx={28} />
|
||||
Ankunft bei der Bergbahn Eggberge nach ca. 5 Stunden.
|
||||
<HikeImage idx={29} />
|
||||
|
||||
## Abreise
|
||||
|
||||
Via Bergbahn und Bus von Eggberge nach Altdorf. Dort via Zug nach Hause.
|
||||
<JourneyPlanner from="Bergbahn Eggberge" to="<current location>" fromFixed time="14:00" target="departure" />
|
||||
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Siebengipfelwanderung
|
||||
date: 2025-08-11
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [St. Gallen, Walensee, Almwanderung]
|
||||
seasons: 5-8
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
### Anreise
|
||||
|
||||
Anreise an Bahnhof Unterterzen, dann mit Gondeln hoch nach Flumserberg und via Gondel von Flumserberg auf den Maschgenkamm.
|
||||
|
||||
<JourneyPlanner from="<current location>" to="Maschgenkamm" toFixed time="08:30" target="arrival"/>
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
|
||||
## Gipfel 1: Ziger
|
||||
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
|
||||
## Gipfel 2: Leist
|
||||
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
|
||||
## Gipfel 3: Rainissalts
|
||||
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
|
||||
## Gipfel 4: Gulmen
|
||||
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
<HikeImage idx={28} />
|
||||
<HikeImage idx={29} />
|
||||
<HikeImage idx={30} />
|
||||
<HikeImage idx={31} />
|
||||
<HikeImage idx={32} />
|
||||
<HikeImage idx={33} />
|
||||
<HikeImage idx={34} />
|
||||
<HikeImage idx={35} />
|
||||
<HikeImage idx={36} />
|
||||
<HikeImage idx={37} />
|
||||
|
||||
## Gipfel 5: Cuncels
|
||||
|
||||
<HikeImage idx={38} />
|
||||
<HikeImage idx={39} />
|
||||
<HikeImage idx={40} />
|
||||
<HikeImage idx={41} />
|
||||
|
||||
## Gipfel 6: Chli Güslen
|
||||
|
||||
<HikeImage idx={42} />
|
||||
<HikeImage idx={43} />
|
||||
<HikeImage idx={44} />
|
||||
|
||||
## Gipfel 7: Gross Güslen
|
||||
|
||||
<HikeImage idx={45} />
|
||||
<HikeImage idx={46} />
|
||||
<HikeImage idx={47} />
|
||||
<HikeImage idx={48} />
|
||||
<HikeImage idx={49} />
|
||||
<HikeImage idx={50} />
|
||||
<HikeImage idx={51} />
|
||||
<HikeImage idx={52} />
|
||||
<HikeImage idx={53} />
|
||||
<HikeImage idx={54} />
|
||||
<HikeImage src="PXL_20240414_135254651.jpg" />
|
||||
|
||||
|
||||
## Heimreise
|
||||
|
||||
Vom Flumserberg mit der Gondel wieder runter nach Unterterzen. Von Unterterzen via Zug nach Hause.
|
||||
<JourneyPlanner fromFixed from="Flumserberg Tannenboden" to="<current location>" time="14:00" target="departure"/>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: Spaziergang Uetliberg
|
||||
date: 2025-08-07
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [Zürich, Spaziergang, Sommer, mittel]
|
||||
seasons: 4-9
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Anreise
|
||||
|
||||
Start direkt von der Tramstation Triemli.
|
||||
<JourneyPlanner from="<current location>" to="Zürich, Triemli" toFixed time="10:00" target="arrival"/>
|
||||
|
||||
Der erste Anstieg ist recht intensiv.
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
Danach geht es gemütlich dem Grat entlang. Ein entspannter Spaziergang bei schönem Wetter.
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
|
||||
|
||||
## Heimreise
|
||||
|
||||
Via Bus von Albispasshöhe nach Thalwil, dann nach Hause via Zug.
|
||||
<JourneyPlanner fromFixed from="Langnau a.A., Albispasshöhe" to="<current location>" time="15:00" target="departure"/>
|
||||
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: Muttertagswanderung Herisau Teufen
|
||||
date: 2026-05-09
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [appenzell, leicht, hügellandschaft, weiden]
|
||||
seasons: 4-9
|
||||
summary: Eine angenehme Wanderung über die Hügellandschaft von Appenzell
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
|
||||
### Übersicht
|
||||
|
||||
Eine nette Wanderung durch die hübsche Hügellandschaft des Appenzells. Man wandert entlang Feldwegen, Schotterstrassen, Teerstrassen und hier und da kurze Abschnitte auf Waldwegen.
|
||||
Auf dem Weg durchquert man zwei kleine Schluchten mit Flüssen.
|
||||
Ein netter Spaziergang, der keine besonderen Konditionsanforderungen über ein generelles Fitnessniveau hinaus stellt.
|
||||
|
||||
### Anreise
|
||||
|
||||
Die Wanderung startet direkt am Bahnhof Herisau und ist somit leicht erreichbar.
|
||||
<JourneyPlanner from="<current location>" to="Herisau" toFixed time="10:00" target="arrival"/>
|
||||
|
||||
|
||||
### Start
|
||||
|
||||
Schon schnell wurde klar, dass es ein wunderschöner Tag wird die grünenden Weiden des Appenzells zu erkunden.
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
|
||||
### Schlucht Nummer Eins
|
||||
|
||||
Nach circa einer Stunde sieht man prominent den Kirchturm von Hundwil vor sich.
|
||||
|
||||
<HikeImage idx={6} />
|
||||
|
||||
Davor geht es noch via der Alten Tobelbrücke über die Urnäsch.
|
||||
Die überdachte Brücke trägt an ihren Dachbalken christliche Zitate.
|
||||
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
|
||||
Die Urnäsch ist ein idealer Punkt für eine kurze erste Verpflegungspause.
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
|
||||
### Hundwil
|
||||
|
||||
Nach Schlucht Nummer Eins ist man bereits recht schnell in Hundwil und kann die wunderschöne Bauernlandschaft geniessen.
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
|
||||
Nach 2:30 Stunden geht der Wanderweg direkt geradeaus über eine Wiese, während der Feldweg rechts abbiegt. Hier aufpassen, der Karte zu folgen.
|
||||
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
|
||||
### Appenzeller Volkskundemuseum und Appenzeller Schaukäserei
|
||||
|
||||
In Stein angekommen, lohnt sich ein kleiner Abstecher in das Appenzeller Volkskundemuseum. Von Appenzeller Traditionen bis hin zu modernerer Kunst gibt es eine grosse Bandbreite an Appenzeller Kunst und Kultur.
|
||||
Im Keller gibt es eine grosse Ausstellung zur traditionellen Stickkunst des Appenzells sowie deren Industrialisierung und Automatisierung.
|
||||
|
||||
#### Das Berg-Häämetli fressende Ungeheuer
|
||||
|
||||
<HikeImage idx={22} />
|
||||
|
||||
Von den modernen Bildern hat mir besonders obiges Gemälde des Künstlers Willy Künzler gefallen.
|
||||
Wem die Botschaft der Politikerspinne, welche Hof (die im Appenzell üblichen Höfe werden auch (Berg)-<q>Häämetli</q> genannt) und Tier frisst und Golfplätze baut, noch zu unklar ist, dem hat Künzler auf der Seite des Bildes noch eine Hilfseinschrift hinterlassen:
|
||||
|
||||
> Wir wollen Bergbauern bleiben.
|
||||
> Nicht Folklore- und Schau-Bauern sein.
|
||||
> Nicht Golf-Handlanger werden.
|
||||
|
||||
Ein kurzer 30- bis 45-minütiger Abstecher in das Museum ist zu empfehlen.
|
||||
Wem das zu langweilig ist, dem kann die Appenzeller Schaukäserei direkt nebenan eventuell gefallen oder als Ort der Stärkung dienen.
|
||||
|
||||
|
||||
<HikeImage idx={23} />
|
||||
Stein hat auch sonst überzeugt: Es zeigt sich, dass man auch neu bauen kann, ohne <q>markant eckig</q> sein zu müssen. Ein schönes Häämetli, das sehr gut ins Appenzell passt.
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
|
||||
Auch alt kann überzeugen, mit schöner Obstwiese, verwittertem Holz und viel Detail im Holz.
|
||||
|
||||
### Schlucht Nummer Zwei
|
||||
|
||||
Danach geht es runter in Schlucht Nummer Zwei. Über Weide und Wald gelangt man dorthin, wo der Rotbach in die Sitter fliesst.
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
|
||||
|
||||
<HikeImage idx={28} />
|
||||
<HikeImage idx={29} />
|
||||
<HikeImage idx={30} />
|
||||
<HikeImage idx={31} />
|
||||
<HikeImage idx={32} />
|
||||
|
||||
<HikeImage idx={33} />
|
||||
|
||||
### Ehemaliges Kloster Wonnenstein
|
||||
|
||||
Nach Schlucht Nummer Zwei und einem Gefängnis kommt man an einem ehemaligen Nonnenkloster vorbei.
|
||||
Das Kloster wurde erst 2021 wegen fehlendem Nachwuchs aufgelöst.
|
||||
|
||||
Man fragt sich vielleicht, wie ein katholisches Kloster ins protestantische Appenzell <em>Ausserrhoden</em> passt — die Halbkantone sind ja historisch aus konfessionellen Gründen gespalten.
|
||||
|
||||
Darüber gibt das Kloster via Infotafeln Auskunft:
|
||||
Zwar war lange Zeit der Status des Klosters unklar, man hat sich jedoch einige Jahrzehnte nach den ursprünglichen Protestantenaufständen dazu einigen können, dass alles innerhalb der Klostermauern offiziell zu Appenzell <em>Innerrhoden</em> gehört. Somit ist das Kloster eine winzige Enklave Appenzell Innerrhodens innerhalb Appenzell Ausserrhoden.
|
||||
|
||||
|
||||
<HikeImage idx={34} />
|
||||
|
||||
Die Klosterkirche selbst ist erst vor kurzem restauriert worden, mit einem neuen Altar für <i>ad populum</i> Messen.
|
||||
Die drei Altäre dahinter haben alle einen Fokus auf die heilige Familie, ist die Kirche doch der heiligen Maria geweiht.
|
||||
Die Marienfigur des linken Altars könnte Schweizer Katholiken bekannt vorkommen.
|
||||
Es handelt sich hierbei um eine Replika der schwarzen Madonna aus Einsiedeln.
|
||||
Dementsprechend ist die Replika auch nach der fehlgeschlagenen Restauration der Einsiedler Madonna angefertigt worden, ist diese doch ursprünglich farbig bemalt gewesen.
|
||||
|
||||
|
||||
Der letzte Abschnitt vor Teufen verläuft teils neben der Autobahn, was nicht optimal ist. Gott sei Dank ist das Ganze aber nach 15 Minuten vorbei.
|
||||
<HikeImage idx={35} />
|
||||
|
||||
## Ankunft in Teufen
|
||||
|
||||
Teufen erreicht man über den treffend genannten Ort <q>Einsamkeit</q>, wo ein einzelnes wunderschönes Bauernhaus einen begrüsst.
|
||||
<HikeImage idx={36} />
|
||||
<HikeImage idx={37} />
|
||||
<HikeImage idx={38} />
|
||||
|
||||
## Heimreise
|
||||
|
||||
Von Teufen fährt halbstündlich ein Zug nach St. Gallen. Von dort die jeweilige Verbindung nach Hause.
|
||||
|
||||
<JourneyPlanner from="Teufen AR" to="<current location>" fromFixed time="16:30" target="departure"/>
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Einsiedeln – Spital – Unteriberg
|
||||
date: 2026-03-07
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [einsiedeln, schwyz, sommer, mittel]
|
||||
seasons: 4-9
|
||||
summary: Eine schnelle Gipfelwanderung in den Schwyzer Bergen
|
||||
heroAlt: Blick aus dem Sihltal Richtung Unteriberg
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Übersicht
|
||||
|
||||
Vom Einsiedler Sihlsee geht es über ein paar Almen auf den Spitalgipfel.
|
||||
Man hat eine schöne Aussicht auf das Schweizer Alpenmassiv, den grossen und kleinen Mythen Richtung Westen und natürich den Sihlsee hinter sich.
|
||||
Eine nette Wanderung welche man doch frühstens im Mai begehen sollte. März war definitiv zu früh und es lag noch in Teilen knietief Schnee, nicht nur an Nordhängen.
|
||||
|
||||
## Anreise
|
||||
|
||||
Anreise nach Einsiedeln. Dann 10 Minuten mit Buslinie 555 nach Gross, Ebenau.
|
||||
<JourneyPlanner from="<current location>" to="Gross, Ebenau" toFixed target="arrival" time="10:00" />
|
||||
|
||||
## Erster Anstieg
|
||||
|
||||
Von Gross ging es direkt los mit dem ersten richtigen Anstieg. Eine gute Aufwärmung für die restliche Wanderung.
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
|
||||
Früh Im Jahr sieht man schöne Almen, wenn auch etwas zu viel Schnee für den guten Geschmack.
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
|
||||
### Erster Hügel
|
||||
|
||||
Erster Hügel ist erklummen. Jetzt geht die Route für ein zwei Kilometer recht flach der Almen entlang.
|
||||
Derweil hat man einen wunderschöne Aussicht auf das schweizer Alpenmassiv und den Sihlsee.
|
||||
|
||||
|
||||
## Zweiter Hügel
|
||||
Jetzt geht es durch einen Wald zum zweiten Hügel, dem Spital. Die Bäume stehen hier und da etwas im Weg, es ist aber keine grössere Kraxelei um hoch zu kommen.
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
|
||||
Jetzt zum schlimmsten Abschnitt, zumindest wenn man zu früh im Jahr unterwegs ist: der letzte Hügel auf den Spital hoch hat noch bis tief ins Jahr Schnee und wer nicht sehr früh am Morgen dort ist droht immer wieder einzubrechen.
|
||||
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
|
||||
## Spital
|
||||
|
||||
Am Spital angekommen lohnt sich eine Pause und den Ausblick zu geniessen.
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
|
||||
## Abstieg
|
||||
Der Abstieg nach Unteriberg ist recht schnell, jedoch auch nicht schneefrei bis in den Mai.
|
||||
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
|
||||
## Heimreise
|
||||
|
||||
Angekommen in Unteriberg — Zeit für eine Pause vor der Rückfahrt.
|
||||
|
||||
<HikeImage idx={25} />
|
||||
|
||||
Von Unteriberg per Bus zurück nach Einsiedeln (Linie 555 oder 556) und weiter mit der
|
||||
S-Bahn ab Einsiedeln.
|
||||
<JourneyPlanner from="Unteriberg, Guggelstrasse" to="<current location>" fromFixed target=departure time="14:30" />
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Wanderung auf den Morgartenberg
|
||||
date: 2026-04-19
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [Schwyz, Zentralschweiz, kurz, leicht]
|
||||
seasons: 4-9
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Anreise
|
||||
|
||||
Start in Biberegg.
|
||||
<JourneyPlanner from="<current location>" to="Biberegg" toFixed time="10:00" target="arrival"/>
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
|
||||
<HikeImage src="PXL_20260419_090639481.jpg" private />
|
||||
<HikeImage src="PXL_20260419_090657549.jpg" private />
|
||||
|
||||
|
||||
## Heimreise
|
||||
|
||||
Rückreise ab Sattel, Schornen.
|
||||
<JourneyPlanner fromFixed from="Sattel, Schornen" to="<current location>" time="12:00" target="departure"/>
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: Wallfahrt Rheinau
|
||||
date: 2026-05-14
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [winterthur, rheinau, wallfahrt, pilgern]
|
||||
seasons: 4-9
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Von Winterthur über Feld und Wald zur ehemaligen Klosterkirche Rheinau
|
||||
|
||||
An Christi Himmelfahrt (14. Mai 2026) ging es auf eine 8-stündige Pilgerung mit anschliessendem Gottesdienst und Apéro riche mit Pater Ramm, organisiert durch die Christkönigjugend [(ckj.ch)](https://ckj.ch).
|
||||
Mit einem dann doch nicht so langsamen Tempo wurden die fast 26 km bis 14:30 Uhr zurückgelegt.
|
||||
|
||||
### Anreise
|
||||
|
||||
Start war direkt beim Bahnhof Winterthur, was die Anreise erleichtert.
|
||||
<JourneyPlanner from="<current location>" to="Winterthur, Hauptbahnhof" toFixed time="08:00" target="arrival"/>
|
||||
|
||||
### Route
|
||||
Kurz nach 8 Uhr ging es in Winterthur los. Nach ca. 30 Minuten durch die Stadt ging es über Feldwege und geteerte Fusswege Richtung Norden.
|
||||
Das Wetter hielt leider nicht lange, starker Regen erschwerte das Lesen von Liedtexten für Leute ohne Regenschirm.
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
|
||||
|
||||
Immer wieder gab es jedoch auch Pausen im Regen und man konnte das vibrante Frühlingsgrün von Feldern und Wald geniessen.
|
||||
Der starke Regen hat auch das Aufnehmen weiterer Fotos verhindert, weswegen erst am Ziel wieder viele Bilder zu sehen sind.
|
||||
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
|
||||
|
||||
## Ziel
|
||||
|
||||
Nach 8 Stunden war die Klosterkirche Rheinau hinter den Baumzipfeln zu erkennen.
|
||||
Der Rhein macht hier eine starke Rechtskurve, wodurch die Klosterinsel genau am Bogen des Rheins Richtung Osten zeigt.
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
|
||||
## Heimreise
|
||||
|
||||
Von der Klosterinsel Rheinau fährt stündlich ein Bus zum Bahnhof Marthalen.
|
||||
Dann geht es via Zug mit eventuellem Umsteigen in Winterthur nach Hause.
|
||||
|
||||
<JourneyPlanner fromFixed from="Rheinau, Unterstadt" to="<current location>" time="16:00" target="departure"/>
|
||||
@@ -1,12 +1,103 @@
|
||||
import type { Handle, HandleServerError, ServerInit } from "@sveltejs/kit"
|
||||
import { redirect } from "@sveltejs/kit"
|
||||
import { sequence } from "@sveltejs/kit/hooks"
|
||||
import { building } from "$app/environment"
|
||||
import * as auth from "./auth"
|
||||
import { initializeScheduler } from "./lib/server/scheduler"
|
||||
import { dbConnect } from "./utils/db"
|
||||
import { errorWithVerse, getRandomVerse } from "$lib/server/errorQuote"
|
||||
import { warmLiturgicalCache } from "$lib/server/liturgicalCalendar"
|
||||
|
||||
/** Map URL path to BCP 47 lang tag. Mirrors the [recipeLang] / [faithLang]
|
||||
* param matchers — keep in sync if new locale slugs are added.
|
||||
* @returns 'de' | 'en' | 'la'
|
||||
*/
|
||||
function langFromPath(pathname: string): 'de' | 'en' | 'la' {
|
||||
const first = pathname.split('/').filter(Boolean)[0] ?? '';
|
||||
if (first === 'recipes' || first === 'faith') return 'en';
|
||||
if (first === 'fides') return 'la';
|
||||
return 'de';
|
||||
}
|
||||
|
||||
async function htmlLang({ event, resolve }: Parameters<Handle>[0]) {
|
||||
const lang = langFromPath(event.url.pathname);
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('%lang%', lang),
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply headers to a response, transparently cloning it if the original
|
||||
* has immutable headers. Auth.js (and certain fetch error/redirect responses)
|
||||
* hand back frozen Headers, and a direct `.set()` on those throws
|
||||
* `TypeError: immutable` — which would mask the underlying error and 500
|
||||
* the request. Cloning preserves the body stream and status. */
|
||||
function applyHeaders(response: Response, entries: Array<[string, string]>): Response {
|
||||
try {
|
||||
for (const [k, v] of entries) response.headers.set(k, v);
|
||||
return response;
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) {
|
||||
const headers = new Headers(response.headers);
|
||||
for (const [k, v] of entries) headers.set(k, v);
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Routes that must never appear in search-engine indexes. Search-results pages
|
||||
* are thin/duplicate content; admin/edit/auth-walled pages have no public value
|
||||
* and shouldn't burn crawl budget. Sets X-Robots-Tag rather than per-page meta
|
||||
* so the rule lives in one place and also covers JSON/API responses.
|
||||
*/
|
||||
const NOINDEX_PATTERNS: RegExp[] = [
|
||||
/^\/api(\/|$)/,
|
||||
/^\/(rezepte|recipes)\/(search|admin|administration|add|edit|favorites|to-try)(\/|$)/,
|
||||
/^\/login$/,
|
||||
/^\/logout$/,
|
||||
/^\/register(\/|$)/,
|
||||
/^\/settings(\/|$)/,
|
||||
/^\/tasks(\/|$)/,
|
||||
/^\/fitness(\/|$)/,
|
||||
/^\/cospend(\/|$)/,
|
||||
/^\/expenses(\/|$)/,
|
||||
];
|
||||
|
||||
async function noindex({ event, resolve }: Parameters<Handle>[0]) {
|
||||
const response = await resolve(event);
|
||||
if (NOINDEX_PATTERNS.some((p) => p.test(event.url.pathname))) {
|
||||
return applyHeaders(response, [['X-Robots-Tag', 'noindex, nofollow']]);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Baseline security headers, set on every response.
|
||||
*
|
||||
* - X-Frame-Options + CSP frame-ancestors block this site from being
|
||||
* iframed onto attacker pages (clickjacking on /login, /cospend,
|
||||
* /fitness, etc.). Both directives are sent: modern browsers honour
|
||||
* frame-ancestors and ignore the legacy header; older ones (IE11) only
|
||||
* understand X-Frame-Options.
|
||||
* - Strict-Transport-Security tells browsers to refuse plain-HTTP for
|
||||
* bocken.org and any subdomain for one year, preventing protocol
|
||||
* downgrade. Browsers ignore the header on http:// loads, so dev on
|
||||
* localhost is unaffected. `preload` deliberately omitted — the HSTS
|
||||
* preload list is hard to leave; revisit only after a stable production
|
||||
* deployment.
|
||||
*/
|
||||
async function securityHeaders({ event, resolve }: Parameters<Handle>[0]) {
|
||||
const response = await resolve(event);
|
||||
return applyHeaders(response, [
|
||||
['X-Frame-Options', 'DENY'],
|
||||
['Content-Security-Policy', "frame-ancestors 'none'"],
|
||||
['Strict-Transport-Security', 'max-age=31536000; includeSubDomains']
|
||||
]);
|
||||
}
|
||||
|
||||
async function timing({ event, resolve }: Parameters<Handle>[0]) {
|
||||
const marks: Record<string, number> = {};
|
||||
event.locals.timing = {
|
||||
@@ -28,11 +119,19 @@ async function timing({ event, resolve }: Parameters<Handle>[0]) {
|
||||
const header = Object.entries(marks)
|
||||
.map(([k, v]) => `${k};dur=${v.toFixed(1)}`)
|
||||
.join(', ');
|
||||
response.headers.set('Server-Timing', header);
|
||||
return response;
|
||||
return applyHeaders(response, [['Server-Timing', header]]);
|
||||
}
|
||||
|
||||
export const init: ServerInit = async () => {
|
||||
// SvelteKit runs prerendering/analysis inside a worker_threads worker (see
|
||||
// @sveltejs/kit utils/fork.js) whose JS heap is capped well below the main
|
||||
// thread's. `init` fires there too, so warming the romcal cache during a
|
||||
// build exhausts that worker's heap → ERR_WORKER_OUT_OF_MEMORY and a failed
|
||||
// build. None of it is needed at build time: no prerendered route touches the
|
||||
// DB, and connecting to Mongo / starting the payment scheduler from a build
|
||||
// is undesirable regardless. Skip startup work while building.
|
||||
if (building) return;
|
||||
|
||||
console.log('🚀 Server starting - initializing database connection...');
|
||||
try {
|
||||
await dbConnect();
|
||||
@@ -128,8 +227,14 @@ async function authorization({ event, resolve }: Parameters<Handle>[0]) {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
/** Browser/crawler probes for these paths are routine 404s — not bugs.
|
||||
* Skip the noisy console.error so real errors stay visible. */
|
||||
const SILENT_404_PATHS = new Set(['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png']);
|
||||
|
||||
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
|
||||
console.error('Error occurred:', { error, status, message, url: event.url.pathname });
|
||||
if (!(status === 404 && SILENT_404_PATHS.has(event.url.pathname))) {
|
||||
console.error('Error occurred:', { error, status, message, url: event.url.pathname });
|
||||
}
|
||||
|
||||
const bibleQuote = await getRandomVerse(event.fetch, event.url.pathname);
|
||||
const isEnglish = event.url.pathname.startsWith('/faith/') || event.url.pathname.startsWith('/recipes/');
|
||||
@@ -143,6 +248,9 @@ export const handleError: HandleServerError = async ({ error, event, status, mes
|
||||
|
||||
export const handle: Handle = sequence(
|
||||
timing,
|
||||
htmlLang,
|
||||
noindex,
|
||||
securityHeaders,
|
||||
auth.handle,
|
||||
authorization
|
||||
);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Public responsive image assets
|
||||
|
||||
Drop public source images here, then render them with `$lib/components/Image.svelte`.
|
||||
|
||||
At build time `@sveltejs/enhanced-img` (vite-imagetools + sharp) processes every
|
||||
raster image in this folder into AVIF/WebP at multiple widths and strips EXIF.
|
||||
Output is a public, hashed, immutable build asset.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import Image from '$lib/components/Image.svelte';
|
||||
</script>
|
||||
|
||||
<!-- lazy by default; `src` is relative to this folder -->
|
||||
<Image src="hero.jpg" alt="…" />
|
||||
|
||||
<!-- above-the-fold / LCP image: load eagerly -->
|
||||
<Image src="hero.jpg" alt="…" lazy={false} />
|
||||
|
||||
<!-- full-width image: pass `sizes` so smaller screens fetch smaller files -->
|
||||
<Image src="banner.jpg" alt="…" sizes="min(1280px, 100vw)" />
|
||||
|
||||
<!-- subfolders work too -->
|
||||
<Image src="blog/cover.png" alt="…" />
|
||||
```
|
||||
|
||||
For **private, auth-gated** images use `<Image src="…" private />` and put the
|
||||
source in `../private-images/` instead — see that folder's README.
|
||||
|
||||
Notes:
|
||||
|
||||
- Provide images at ~2× the displayed size so HiDPI screens stay sharp;
|
||||
processing only ever scales **down**.
|
||||
- SVGs are not processed here — import them directly instead.
|
||||
- First build is slow (encoding); results are cached in
|
||||
`node_modules/.cache/imagetools`.
|
||||
- These sources are committed (they're public site assets).
|
||||
@@ -0,0 +1,45 @@
|
||||
# Private (auth-gated) image sources
|
||||
|
||||
Drop **private** source images here, then render them with
|
||||
`<Image src="…" private />` from `$lib/components/Image.svelte`.
|
||||
|
||||
These can't use `@sveltejs/enhanced-img` — its output is a public asset. Instead
|
||||
`scripts/build-private-images.ts` (runs at `prebuild`) encodes each image into
|
||||
AVIF/WebP at multiple widths into `private-assets/` (gitignored, outside the
|
||||
client bundle) and writes `src/lib/data/privateImages.generated.ts`. The bytes
|
||||
are served only through the auth-gated endpoint
|
||||
`src/routes/private-images/[...file]/+server.ts`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import Image from '$lib/components/Image.svelte';
|
||||
</script>
|
||||
|
||||
<!-- `src` is relative to THIS folder; shows a lock badge -->
|
||||
<Image src="receipt.jpg" private alt="…" />
|
||||
|
||||
<!-- gate rendering behind your own auth check too -->
|
||||
{#if data.session}
|
||||
<Image src="family/2024.jpg" private alt="…" sizes="min(1000px, 100vw)" />
|
||||
{/if}
|
||||
```
|
||||
|
||||
Setup / notes:
|
||||
|
||||
- **Dev:** run `pnpm exec vite-node scripts/build-private-images.ts` once (and
|
||||
after adding/changing images) so the manifest + `private-assets/` exist. You
|
||||
must be logged in for the gated endpoint to serve the bytes.
|
||||
- **Prod (one-time):** add an nginx `internal` location so the bytes are only
|
||||
reachable via the endpoint's `X-Accel-Redirect`:
|
||||
|
||||
```nginx
|
||||
location /protected-images/ {
|
||||
internal;
|
||||
alias /var/www/static/private-images/;
|
||||
}
|
||||
```
|
||||
|
||||
`scripts/deploy.sh` rsyncs `private-assets/` → `/var/www/static/private-images/`.
|
||||
- These source images are **gitignored** (private + large). Back them up
|
||||
separately.
|
||||
- SVGs are not processed here.
|
||||
@@ -0,0 +1,656 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { m } from '$lib/js/commonI18n';
|
||||
import type { CommonLang } from '$lib/js/commonI18n';
|
||||
|
||||
/**
|
||||
* Pill-styled date + (optional) time picker. Built to share the look of
|
||||
* `DatePicker.svelte` while operating on numeric `unix-ms` timestamps so it
|
||||
* can be dropped into any `wp.timestamp: number | null` shaped store without
|
||||
* an intermediate string conversion at every callsite.
|
||||
*
|
||||
* Features:
|
||||
* - `mode='date'` hides the time pill (useful for waypoints that don't
|
||||
* need a time anchor in their GPX export).
|
||||
* - `inheritedValue` lets a caller suggest a tentative default (e.g. the
|
||||
* nearest timestamped sibling's date). It's rendered in italic with a
|
||||
* dashed outline; an "Übernehmen" button commits it to the bound value.
|
||||
* User edits via the calendar, the time input, the day arrows, or any
|
||||
* nudge button also implicitly commit the inherited value.
|
||||
* - `nudgeMinutes` renders a row of ±N minute quick-adjust buttons. Only
|
||||
* shown when a value is set and `mode='datetime'`.
|
||||
* - `required` hides the clear button (e.g. first/last waypoint must keep
|
||||
* a timestamp for the export's interpolation to bind).
|
||||
*/
|
||||
interface Props {
|
||||
value: number | null | undefined;
|
||||
mode?: 'date' | 'datetime';
|
||||
inheritedValue?: number | null;
|
||||
nudgeMinutes?: number[];
|
||||
required?: boolean;
|
||||
lang?: CommonLang;
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
/** Optional extra CSS class on the outer wrapper. */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable<number | null | undefined>(null),
|
||||
mode = 'datetime',
|
||||
inheritedValue = null,
|
||||
nudgeMinutes = [],
|
||||
required = false,
|
||||
lang = 'de',
|
||||
min = null,
|
||||
max = null,
|
||||
class: extraClass = ''
|
||||
}: Props = $props();
|
||||
|
||||
const t = $derived(m[lang]);
|
||||
|
||||
let open = $state(false);
|
||||
let pickerRef = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||
|
||||
const weekdays = $derived(lang === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN);
|
||||
const months = $derived(lang === 'de' ? MONTHS_DE : MONTHS_EN);
|
||||
|
||||
// When the bound value is null but the caller supplied an inherited default,
|
||||
// the pill displays the inherited timestamp in "tentative" styling. The
|
||||
// `effective` getter is what every formatting/derived computation runs on.
|
||||
const effective = $derived<number | null>((value ?? inheritedValue) ?? null);
|
||||
const inheritedActive = $derived(value == null && inheritedValue != null);
|
||||
|
||||
function pad(n: number): string {
|
||||
return n.toString().padStart(2, '0');
|
||||
}
|
||||
function toDateStr(ts: number | null): string {
|
||||
if (ts == null) return '';
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
function toTimeStr(ts: number | null): string {
|
||||
if (ts == null) return '';
|
||||
const d = new Date(ts);
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const dateStr = $derived(toDateStr(effective));
|
||||
const timeStr = $derived(toTimeStr(effective));
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const dateLabel = $derived.by(() => {
|
||||
if (!dateStr) return t.select_date;
|
||||
if (dateStr === todayStr && lang in t) return t.today;
|
||||
const d = new Date(dateStr + 'T12:00:00');
|
||||
return d.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', {
|
||||
weekday: 'short', month: 'short', day: 'numeric'
|
||||
});
|
||||
});
|
||||
|
||||
// Calendar view month — independent of selected value, updated when `value`
|
||||
// or the dropdown opens to follow the relevant month.
|
||||
let viewYear = $state(new Date().getFullYear());
|
||||
let viewMonth = $state(new Date().getMonth());
|
||||
$effect(() => {
|
||||
const ref = effective ?? Date.now();
|
||||
const d = new Date(ref);
|
||||
viewYear = d.getFullYear();
|
||||
viewMonth = d.getMonth();
|
||||
});
|
||||
|
||||
function isDisabled(ts: number): boolean {
|
||||
if (min != null && ts < min) return true;
|
||||
if (max != null && ts > max) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function commit(ts: number | null) {
|
||||
if (ts != null && isDisabled(ts)) return;
|
||||
value = ts;
|
||||
}
|
||||
|
||||
function buildTimestamp(date: string, time: string): number | null {
|
||||
if (!date) return null;
|
||||
const [y, m, d] = date.split('-').map((n) => parseInt(n, 10));
|
||||
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null;
|
||||
let hh = 12, mm = 0;
|
||||
if (time) {
|
||||
const parts = time.split(':').map((n) => parseInt(n, 10));
|
||||
if (Number.isFinite(parts[0])) hh = parts[0];
|
||||
if (Number.isFinite(parts[1])) mm = parts[1];
|
||||
}
|
||||
return new Date(y, m - 1, d, hh, mm, 0, 0).getTime();
|
||||
}
|
||||
|
||||
function selectDay(date: string) {
|
||||
// When committing, preserve the time-of-day of the current effective
|
||||
// value so picking a new date doesn't reset to noon and discard the
|
||||
// user's already-tuned time.
|
||||
commit(buildTimestamp(date, mode === 'datetime' ? timeStr || '12:00' : '12:00'));
|
||||
open = false;
|
||||
}
|
||||
|
||||
function updateTimeInput(value: string) {
|
||||
if (!value) return;
|
||||
commit(buildTimestamp(dateStr || toDateStr(Date.now()), value));
|
||||
}
|
||||
|
||||
function navDay(delta: number) {
|
||||
const ref = effective ?? Date.now();
|
||||
const d = new Date(ref);
|
||||
d.setDate(d.getDate() + delta);
|
||||
commit(d.getTime());
|
||||
}
|
||||
|
||||
function navMonth(delta: number) {
|
||||
viewMonth += delta;
|
||||
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
|
||||
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
|
||||
}
|
||||
|
||||
function nudge(deltaMin: number) {
|
||||
const base = effective;
|
||||
if (base == null) return;
|
||||
commit(base + deltaMin * 60_000);
|
||||
}
|
||||
|
||||
function applyInherited() {
|
||||
if (inheritedValue == null) return;
|
||||
commit(inheritedValue);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
value = null;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function goNow() {
|
||||
commit(Date.now());
|
||||
open = false;
|
||||
}
|
||||
|
||||
const calendarDays = $derived.by(() => {
|
||||
const first = new Date(viewYear, viewMonth, 1);
|
||||
let startDay = first.getDay() - 1;
|
||||
if (startDay < 0) startDay = 6;
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const daysInPrevMonth = new Date(viewYear, viewMonth, 0).getDate();
|
||||
|
||||
type Day = {
|
||||
date: string;
|
||||
day: number;
|
||||
currentMonth: boolean;
|
||||
isToday: boolean;
|
||||
isSelected: boolean;
|
||||
disabled: boolean;
|
||||
};
|
||||
const days: Day[] = [];
|
||||
|
||||
const pushDay = (y: number, mo: number, d: number, currentMonth: boolean) => {
|
||||
const date = `${y}-${pad(mo + 1)}-${pad(d)}`;
|
||||
const ts = new Date(y, mo, d, 12, 0, 0, 0).getTime();
|
||||
days.push({
|
||||
date, day: d, currentMonth,
|
||||
isToday: date === todayStr,
|
||||
isSelected: date === dateStr && value != null,
|
||||
disabled: isDisabled(ts)
|
||||
});
|
||||
};
|
||||
|
||||
for (let i = startDay - 1; i >= 0; i--) {
|
||||
const d = daysInPrevMonth - i;
|
||||
const mo = viewMonth === 0 ? 11 : viewMonth - 1;
|
||||
const y = viewMonth === 0 ? viewYear - 1 : viewYear;
|
||||
pushDay(y, mo, d, false);
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
pushDay(viewYear, viewMonth, d, true);
|
||||
}
|
||||
const remaining = 7 - (days.length % 7);
|
||||
if (remaining < 7) {
|
||||
for (let d = 1; d <= remaining; d++) {
|
||||
const mo = viewMonth === 11 ? 0 : viewMonth + 1;
|
||||
const y = viewMonth === 11 ? viewYear + 1 : viewYear;
|
||||
pushDay(y, mo, d, false);
|
||||
}
|
||||
}
|
||||
return days;
|
||||
});
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (pickerRef && e.target instanceof Node && !pickerRef.contains(e.target)) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('pointerdown', handleClickOutside);
|
||||
return () => document.removeEventListener('pointerdown', handleClickOutside);
|
||||
}
|
||||
});
|
||||
|
||||
const negativeNudges = $derived(
|
||||
[...nudgeMinutes].filter((n) => n < 0).sort((a, b) => a - b)
|
||||
);
|
||||
const positiveNudges = $derived(
|
||||
[...nudgeMinutes].filter((n) => n > 0).sort((a, b) => a - b)
|
||||
);
|
||||
const showNudge = $derived(
|
||||
mode === 'datetime' && effective != null && nudgeMinutes.length > 0
|
||||
);
|
||||
const showClear = $derived(!required && value != null);
|
||||
</script>
|
||||
|
||||
<div class="dtp {extraClass}" bind:this={pickerRef}>
|
||||
<div class="dtp-pill" class:inherited={inheritedActive} class:empty={effective == null}>
|
||||
<button type="button" class="dtp-arrow" onclick={() => navDay(-1)} aria-label="-1d">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button type="button" class="dtp-display" onclick={() => (open = !open)}>
|
||||
<Calendar size={14} />
|
||||
<span class="dtp-date-label">{dateLabel}</span>
|
||||
</button>
|
||||
<button type="button" class="dtp-arrow" onclick={() => navDay(1)} aria-label="+1d">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
{#if mode === 'datetime'}
|
||||
{#if showNudge && negativeNudges.length > 0}
|
||||
<div class="dtp-nudge dtp-nudge-neg" role="group" aria-label={t.select_time}>
|
||||
{#each negativeNudges as delta (delta)}
|
||||
<button type="button" onclick={() => nudge(delta)}>
|
||||
−{Math.abs(delta)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<label class="dtp-time" title={t.select_time}>
|
||||
<Clock size={13} aria-hidden="true" />
|
||||
<input
|
||||
type="time"
|
||||
value={timeStr}
|
||||
onchange={(e) => updateTimeInput(e.currentTarget.value)}
|
||||
aria-label={t.select_time}
|
||||
/>
|
||||
</label>
|
||||
{#if showNudge && positiveNudges.length > 0}
|
||||
<div class="dtp-nudge dtp-nudge-pos" role="group" aria-label={t.select_time}>
|
||||
{#each positiveNudges as delta (delta)}
|
||||
<button type="button" onclick={() => nudge(delta)}>
|
||||
+{delta}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="dtp-dropdown" role="dialog" aria-label={t.select_date}>
|
||||
<div class="dtp-header">
|
||||
<button type="button" class="dtp-nav" onclick={() => navMonth(-1)} aria-label="<<">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span class="dtp-month-label">{months[viewMonth]} {viewYear}</span>
|
||||
<button type="button" class="dtp-nav" onclick={() => navMonth(1)} aria-label=">>">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="dtp-weekdays">
|
||||
{#each weekdays as wd (wd)}
|
||||
<span class="dtp-wd">{wd}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="dtp-grid">
|
||||
{#each calendarDays as day (day.date)}
|
||||
<button
|
||||
type="button"
|
||||
class="dtp-day"
|
||||
class:other-month={!day.currentMonth}
|
||||
class:today={day.isToday}
|
||||
class:selected={day.isSelected}
|
||||
class:disabled={day.disabled}
|
||||
disabled={day.disabled}
|
||||
onclick={() => selectDay(day.date)}
|
||||
>
|
||||
{day.day}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="dtp-today-btn" onclick={goNow}>
|
||||
{mode === 'datetime' ? t.now : t.today}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if inheritedActive}
|
||||
<button type="button" class="dtp-accept" onclick={applyInherited}>
|
||||
{t.apply_inherited}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if showClear}
|
||||
<button
|
||||
type="button"
|
||||
class="dtp-clear"
|
||||
onclick={clear}
|
||||
aria-label={t.clear}
|
||||
title={t.clear}
|
||||
>
|
||||
<X size={12} strokeWidth={2.25} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dtp {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
/* Reserve room above the pill for the absolutely-positioned clear
|
||||
* button so it doesn't visually crash into the row above. */
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.dtp-pill {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.dtp-pill.inherited {
|
||||
border-style: dashed;
|
||||
background: color-mix(in oklab, var(--color-bg-tertiary) 70%, transparent);
|
||||
}
|
||||
.dtp-pill.inherited .dtp-date-label,
|
||||
.dtp-pill.inherited .dtp-time input {
|
||||
color: var(--color-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
.dtp-pill.empty .dtp-date-label {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.dtp-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem 0.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-normal), background var(--transition-normal);
|
||||
}
|
||||
.dtp-arrow:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.dtp-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color var(--transition-normal), background var(--transition-normal);
|
||||
}
|
||||
.dtp-display:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.dtp-time {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0 0.55rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.dtp-time input {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-primary);
|
||||
min-width: 4.4em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dtp-time input::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Nudge clusters live INSIDE the pill, flanking the time input. They
|
||||
* share the pill's chrome (no extra borders, no rounded corners — the pill
|
||||
* itself clips them). */
|
||||
.dtp-nudge {
|
||||
display: inline-flex;
|
||||
}
|
||||
.dtp-nudge button {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
padding: 0 0.5rem;
|
||||
cursor: pointer;
|
||||
min-width: 2.2rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: color var(--transition-normal), background var(--transition-normal);
|
||||
}
|
||||
.dtp-nudge-neg {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
.dtp-nudge-neg button + button {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
.dtp-nudge-pos button + button {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
.dtp-nudge button:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Dropdown calendar — mirrors DatePicker.svelte */
|
||||
.dtp-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4rem);
|
||||
left: 0;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 0.6rem;
|
||||
z-index: 200;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.dtp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.dtp-month-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.dtp-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal), color var(--transition-normal);
|
||||
}
|
||||
.dtp-nav:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.dtp-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.dtp-wd {
|
||||
text-align: center;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.dtp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
.dtp-day {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal), color var(--transition-normal);
|
||||
}
|
||||
.dtp-day:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.dtp-day.other-month {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.dtp-day.disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.dtp-day.today {
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1.5px var(--color-primary);
|
||||
}
|
||||
.dtp-day.selected {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.dtp-day.selected:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.dtp-today-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.3rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal);
|
||||
}
|
||||
.dtp-today-btn:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.dtp-accept {
|
||||
appearance: none;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.dtp-accept:hover {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Clear button: top-right corner badge, mirrors close-X affordances on
|
||||
* dismissable chips elsewhere. Sits slightly outside the pill so it
|
||||
* doesn't crowd the date/time controls. */
|
||||
.dtp-clear {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
z-index: 2;
|
||||
}
|
||||
.dtp-clear:hover {
|
||||
color: var(--color-text-on-primary);
|
||||
background: var(--red);
|
||||
border-color: var(--red);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,7 @@ let {
|
||||
language_selector_mobile,
|
||||
language_selector_desktop,
|
||||
right_side,
|
||||
logo_overlay,
|
||||
children,
|
||||
fullSymbol = false
|
||||
}: {
|
||||
@@ -19,6 +20,7 @@ let {
|
||||
language_selector_mobile?: Snippet;
|
||||
language_selector_desktop?: Snippet;
|
||||
right_side?: Snippet;
|
||||
logo_overlay?: Snippet;
|
||||
children?: Snippet;
|
||||
fullSymbol?: boolean;
|
||||
} = $props();
|
||||
@@ -45,14 +47,18 @@ footer {
|
||||
═══════════════════════════════════════════ */
|
||||
nav {
|
||||
position: sticky;
|
||||
top: calc(12px + env(safe-area-inset-top, 0px));
|
||||
/* Without a safe-area inset (regular browser tabs), keep the comfortable
|
||||
12px gap. With an inset present (PWA / Tauri shell with notch / status
|
||||
bar), drop the extra 12px so the bar sits just 4px below the inset
|
||||
instead of stacking ~56px down. */
|
||||
top: max(12px, calc(env(safe-area-inset-top, 0px) + 4px));
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--header-h);
|
||||
gap: 0.4rem;
|
||||
padding: 0 0.8rem;
|
||||
margin: calc(12px + env(safe-area-inset-top, 0px)) auto 0;
|
||||
margin: max(12px, calc(env(safe-area-inset-top, 0px) + 4px)) auto 0;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - 1.5rem);
|
||||
border-radius: 100px;
|
||||
@@ -118,6 +124,12 @@ nav {
|
||||
/* ═══════════════════════════════════════════
|
||||
LOGO
|
||||
═══════════════════════════════════════════ */
|
||||
.logo-slot {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.home-link {
|
||||
view-transition-name: nav-logo;
|
||||
display: flex;
|
||||
@@ -330,7 +342,10 @@ nav {
|
||||
<div>
|
||||
|
||||
<nav class:no-links={!links}>
|
||||
<a href={resolve('/')} aria-label="Home" class="home-link" class:full={fullSymbol}><Symbol /></a>
|
||||
<div class="logo-slot">
|
||||
<a href={resolve('/')} aria-label="Home" class="home-link" class:full={fullSymbol}><Symbol /></a>
|
||||
{@render logo_overlay?.()}
|
||||
</div>
|
||||
{#if links}
|
||||
<div class="links-wrapper">
|
||||
{@render links()}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<script lang="ts" module>
|
||||
import type { Picture } from '@sveltejs/enhanced-img';
|
||||
|
||||
// Build-time map of every PUBLIC raster image under src/lib/assets/images/.
|
||||
// `query: { enhanced: true }` routes each match through @sveltejs/enhanced-img
|
||||
// (vite-imagetools + sharp), which generates AVIF/WebP at multiple widths and
|
||||
// returns a Picture that <enhanced:img> renders as a <picture>. Eager so the
|
||||
// lookup below stays synchronous. SVGs are excluded — enhanced-img only
|
||||
// supports them statically, and they need no rasterising anyway.
|
||||
const sources = import.meta.glob(
|
||||
'/src/lib/assets/images/**/*.{avif,gif,heif,jpeg,jpg,png,tiff,webp}',
|
||||
{ eager: true, query: { enhanced: true } }
|
||||
) as Record<string, { default: Picture }>;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { dev } from '$app/environment';
|
||||
import Lock from '@lucide/svelte/icons/lock';
|
||||
// PRIVATE images can't use enhanced-img (its output is public). They go
|
||||
// through the parallel sharp pipeline (scripts/build-private-images.ts) and
|
||||
// are served by the auth-gated /private-images/ endpoint. The manifest is
|
||||
// generated at prebuild; run `vite-node scripts/build-private-images.ts` once
|
||||
// for dev.
|
||||
import { PRIVATE_IMAGES } from '$lib/data/privateImages.generated';
|
||||
|
||||
interface Props {
|
||||
/** Path to the source image. Public: relative to src/lib/assets/images/.
|
||||
* Private: relative to src/lib/assets/private-images/. e.g. "hero.jpg"
|
||||
* or "blog/cover.png". A leading slash is tolerated. */
|
||||
src: string;
|
||||
/** Alt text. Always provide one for non-decorative images. */
|
||||
alt?: string;
|
||||
/** Lazy-load below the fold (default). Set false for above-the-fold /
|
||||
* LCP images, which should load eagerly. */
|
||||
lazy?: boolean;
|
||||
/** Auth-gate this image: served only to logged-in users via the
|
||||
* /private-images/ endpoint, with a lock badge. The bytes are never a
|
||||
* public asset. Render these behind your own auth check too — anonymous
|
||||
* viewers get a "locked" placeholder instead of the image. */
|
||||
private?: boolean;
|
||||
/** Responsive `sizes`. When set, smaller screens fetch smaller files;
|
||||
* omit for a plain 1x/2x pair (public) or the full ladder (private). */
|
||||
sizes?: string;
|
||||
/** Extra class(es) forwarded to the underlying <img>. */
|
||||
class?: string;
|
||||
/** Any other <img> attribute (width, height, fetchpriority, style, …). */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let {
|
||||
src,
|
||||
alt = '',
|
||||
lazy = true,
|
||||
private: isPrivate = false,
|
||||
sizes,
|
||||
class: className,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const key = $derived(src.replace(/^\/+/, ''));
|
||||
|
||||
// Public: enhanced-img Picture, looked up by root-relative glob key.
|
||||
const picture = $derived(isPrivate ? undefined : sources[`/src/lib/assets/images/${key}`]?.default);
|
||||
// Private: responsive variant with auth-gated /private-images/ URLs.
|
||||
const variant = $derived(isPrivate ? PRIVATE_IMAGES[key] : undefined);
|
||||
|
||||
// Anonymous viewers get a 401 from /private-images/; swap the broken image
|
||||
// for a locked placeholder when that happens.
|
||||
let locked = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!dev) return;
|
||||
if (isPrivate && !variant) {
|
||||
console.warn(
|
||||
`[Image] No private build-time asset for "${src}". Place it under ` +
|
||||
`src/lib/assets/private-images/ and re-run scripts/build-private-images.ts.`
|
||||
);
|
||||
} else if (!isPrivate && !picture) {
|
||||
console.warn(
|
||||
`[Image] No build-time asset for "${src}". ` +
|
||||
`Place it under src/lib/assets/images/ (path relative to that dir).`
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isPrivate}
|
||||
{#if variant}
|
||||
<span class="g-private-image" class:locked>
|
||||
<picture>
|
||||
<source type="image/avif" srcset={variant.srcsetAvif} {sizes} />
|
||||
<source type="image/webp" srcset={variant.srcsetWebp} {sizes} />
|
||||
<img
|
||||
src={variant.src}
|
||||
{alt}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
class={className}
|
||||
loading={lazy ? 'lazy' : 'eager'}
|
||||
decoding="async"
|
||||
onerror={() => (locked = true)}
|
||||
{...rest}
|
||||
/>
|
||||
</picture>
|
||||
<span class="g-private-badge" title="Privates Bild — nur für eingeloggte Benutzer sichtbar">
|
||||
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
|
||||
privat
|
||||
</span>
|
||||
{#if locked}
|
||||
<span class="g-private-locked">
|
||||
<Lock size={20} strokeWidth={2} aria-hidden="true" />
|
||||
Anmeldung erforderlich
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{:else if picture}
|
||||
<enhanced:img
|
||||
src={picture}
|
||||
{alt}
|
||||
{sizes}
|
||||
class={className}
|
||||
loading={lazy ? 'lazy' : 'eager'}
|
||||
decoding="async"
|
||||
{...rest}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* The colon in the tag name must be escaped in a selector. enhanced-img
|
||||
* rewrites this to target the generated <img>. */
|
||||
enhanced\:img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.g-private-image {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.g-private-image picture,
|
||||
.g-private-image img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Lock badge — mirrors HikeImage's `.private`. */
|
||||
.g-private-badge {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
left: 0.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgb(0 0 0 / 0.55);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Shown when the gated request 401s (anonymous viewer). */
|
||||
.g-private-image.locked img {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.g-private-locked {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { pwaStore } from '$lib/stores/pwa.svelte';
|
||||
import { m, type CommonLang } from '$lib/js/commonI18n';
|
||||
import Download from '@lucide/svelte/icons/download';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
let { lang = 'de' }: { lang?: string } = $props();
|
||||
|
||||
const DISMISS_KEY = 'bocken-offline-banner-dismissed';
|
||||
|
||||
let mounted = $state(false);
|
||||
let dismissed = $state(false);
|
||||
|
||||
const t = $derived(m[lang as CommonLang]);
|
||||
|
||||
onMount(async () => {
|
||||
dismissed = localStorage.getItem(DISMISS_KEY) === '1';
|
||||
mounted = true;
|
||||
await pwaStore.initialize();
|
||||
});
|
||||
|
||||
function dismiss() {
|
||||
dismissed = true;
|
||||
localStorage.setItem(DISMISS_KEY, '1');
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
await pwaStore.syncForOffline();
|
||||
}
|
||||
|
||||
const visible = $derived(
|
||||
mounted &&
|
||||
pwaStore.isStandalone &&
|
||||
!pwaStore.isOfflineAvailable &&
|
||||
(!dismissed || pwaStore.isSyncing)
|
||||
);
|
||||
|
||||
const progressPct = $derived.by(() => {
|
||||
const ip = pwaStore.syncProgress?.imageProgress;
|
||||
if (!ip || ip.total === 0) return 0;
|
||||
return Math.round((ip.completed / ip.total) * 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
max-width: 1200px;
|
||||
margin: 1rem auto;
|
||||
padding: 0.9rem 1.1rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.banner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: var(--green, var(--nord14));
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in oklab, var(--green, var(--nord14)) 18%, transparent);
|
||||
color: var(--green, var(--nord14));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copy {
|
||||
min-width: 0;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.body {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.15rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.95rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-on-primary);
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sync-btn:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
.sync-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 0.35rem;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
.dismiss-btn:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.progress {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.progress-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--green, var(--nord14));
|
||||
transition: width 200ms ease-out;
|
||||
}
|
||||
|
||||
.error {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.78rem;
|
||||
color: var(--red, var(--nord11));
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.banner {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.7rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
margin: 0.75rem auto;
|
||||
}
|
||||
.body {
|
||||
display: none;
|
||||
}
|
||||
.sync-btn {
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if visible}
|
||||
<aside class="banner" role="status" aria-live="polite">
|
||||
<div class="icon-wrap" aria-hidden="true">
|
||||
<Download size={20} strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<div class="copy">
|
||||
<div class="title">{t.offline_banner_title}</div>
|
||||
<div class="body">{t.offline_banner_body}</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
class="sync-btn"
|
||||
onclick={handleSync}
|
||||
disabled={pwaStore.isSyncing}
|
||||
type="button"
|
||||
>
|
||||
{pwaStore.isSyncing ? t.syncing : t.offline_banner_action}
|
||||
</button>
|
||||
{#if !pwaStore.isSyncing}
|
||||
<button
|
||||
class="dismiss-btn"
|
||||
onclick={dismiss}
|
||||
aria-label={t.dismiss}
|
||||
title={t.dismiss}
|
||||
type="button"
|
||||
>
|
||||
<X size={16} strokeWidth={2} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if pwaStore.isSyncing && pwaStore.syncProgress}
|
||||
<div class="progress">
|
||||
<div class="progress-text">
|
||||
{pwaStore.syncProgress.message}
|
||||
{#if pwaStore.syncProgress.imageProgress}
|
||||
· {progressPct}%
|
||||
{/if}
|
||||
</div>
|
||||
{#if pwaStore.syncProgress.imageProgress}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {progressPct}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pwaStore.error}
|
||||
<div class="error">{pwaStore.error}</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
@@ -1,257 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { pwaStore } from '$lib/stores/pwa.svelte';
|
||||
import { m, type CommonLang } from '$lib/js/commonI18n';
|
||||
|
||||
let { lang = 'de' }: { lang?: string } = $props();
|
||||
|
||||
let showTooltip = $state(false);
|
||||
let mounted = $state(false);
|
||||
|
||||
const t = $derived(m[lang as CommonLang]);
|
||||
const labels = $derived({
|
||||
syncForOffline: t.sync_for_offline,
|
||||
syncing: t.syncing,
|
||||
offlineReady: t.offline_ready,
|
||||
lastSync: t.last_sync,
|
||||
recipes: t.recipes_word,
|
||||
syncNow: t.sync_now,
|
||||
clearData: t.clear_offline_data
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
mounted = true;
|
||||
// Initialize PWA store (checks standalone mode, starts auto-sync if needed)
|
||||
await pwaStore.initialize();
|
||||
});
|
||||
|
||||
async function handleSync() {
|
||||
await pwaStore.syncForOffline();
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
await pwaStore.clearOfflineData();
|
||||
showTooltip = false;
|
||||
}
|
||||
|
||||
function formatDate(isoString: string | null): string {
|
||||
if (!isoString) return '';
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString(lang === 'en' ? 'en-US' : 'de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.offline-sync {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sync-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
transition: color 100ms;
|
||||
}
|
||||
|
||||
.sync-button:hover,
|
||||
.sync-button:focus {
|
||||
color: var(--nord8);
|
||||
}
|
||||
|
||||
.sync-button.syncing {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.sync-button.available {
|
||||
color: var(--nord14);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.sync-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
background: var(--nord0);
|
||||
border: 1px solid var(--nord3);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.875rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
.status.ready {
|
||||
color: var(--nord14);
|
||||
}
|
||||
|
||||
.tooltip-button {
|
||||
background: var(--nord3);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background 100ms;
|
||||
}
|
||||
|
||||
.tooltip-button:hover {
|
||||
background: var(--nord2);
|
||||
}
|
||||
|
||||
.tooltip-button.clear {
|
||||
background: var(--nord11);
|
||||
}
|
||||
|
||||
.tooltip-button.clear:hover {
|
||||
background: #c04040;
|
||||
}
|
||||
|
||||
.tooltip-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--nord3);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--nord14);
|
||||
transition: width 150ms ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if mounted && pwaStore.isStandalone}
|
||||
<div class="offline-sync">
|
||||
<button
|
||||
class="sync-button"
|
||||
class:syncing={pwaStore.isSyncing}
|
||||
class:available={pwaStore.isOfflineAvailable}
|
||||
onclick={() => showTooltip = !showTooltip}
|
||||
title={pwaStore.isOfflineAvailable ? labels.offlineReady : labels.syncForOffline}
|
||||
>
|
||||
<svg class="sync-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
{#if pwaStore.isOfflineAvailable}
|
||||
<!-- Checkmark icon when offline data is available -->
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
{:else}
|
||||
<!-- Download icon when no offline data -->
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showTooltip}
|
||||
<div class="tooltip">
|
||||
<div class="tooltip-content">
|
||||
{#if pwaStore.isOfflineAvailable}
|
||||
<div class="status ready">{labels.offlineReady}</div>
|
||||
<div class="meta">
|
||||
{pwaStore.recipeCount} {labels.recipes}
|
||||
{#if pwaStore.lastSyncDate}
|
||||
<br>{labels.lastSync}: {formatDate(pwaStore.lastSyncDate)}
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="tooltip-button"
|
||||
onclick={handleSync}
|
||||
disabled={pwaStore.isSyncing}
|
||||
>
|
||||
{pwaStore.isSyncing ? labels.syncing : labels.syncNow}
|
||||
</button>
|
||||
<button
|
||||
class="tooltip-button clear"
|
||||
onclick={handleClear}
|
||||
disabled={pwaStore.isSyncing}
|
||||
>
|
||||
{labels.clearData}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="status">{labels.syncForOffline}</div>
|
||||
<button
|
||||
class="tooltip-button"
|
||||
onclick={handleSync}
|
||||
disabled={pwaStore.isSyncing}
|
||||
>
|
||||
{pwaStore.isSyncing ? labels.syncing : labels.syncForOffline}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if pwaStore.isSyncing && pwaStore.syncProgress}
|
||||
<div class="progress-container">
|
||||
<div class="progress-text">{pwaStore.syncProgress.message}</div>
|
||||
{#if pwaStore.syncProgress.imageProgress}
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
style="width: {(pwaStore.syncProgress.imageProgress.completed / pwaStore.syncProgress.imageProgress.total) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pwaStore.error}
|
||||
<div class="status" style="color: var(--nord11);">
|
||||
{pwaStore.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,300 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { pwaStore } from '$lib/stores/pwa.svelte';
|
||||
import { m, type CommonLang } from '$lib/js/commonI18n';
|
||||
|
||||
let { lang = 'de' }: { lang?: string } = $props();
|
||||
|
||||
let mounted = $state(false);
|
||||
let open = $state(false);
|
||||
|
||||
const t = $derived(m[lang as CommonLang]);
|
||||
|
||||
onMount(async () => {
|
||||
mounted = true;
|
||||
await pwaStore.initialize();
|
||||
});
|
||||
|
||||
function closeOnOutsideClick(node: HTMLElement) {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!open) return;
|
||||
if (!node.contains(e.target as Node)) {
|
||||
open = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(lang === 'en' ? 'en-US' : 'de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
await pwaStore.syncForOffline();
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
await pwaStore.clearOfflineData();
|
||||
open = false;
|
||||
}
|
||||
|
||||
const visible = $derived(
|
||||
mounted && pwaStore.isStandalone && pwaStore.isOfflineAvailable
|
||||
);
|
||||
|
||||
const progressPct = $derived.by(() => {
|
||||
const ip = pwaStore.syncProgress?.imageProgress;
|
||||
if (!ip || ip.total === 0) return 0;
|
||||
return Math.round((ip.completed / ip.total) * 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pip {
|
||||
--size: 8px;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 3px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: background 150ms;
|
||||
}
|
||||
.pip:hover {
|
||||
background: var(--nav-hover-bg, rgba(255,255,255,0.12));
|
||||
}
|
||||
.dot {
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
background: var(--green, var(--nord14));
|
||||
box-shadow:
|
||||
0 0 0 2px color-mix(in oklab, var(--green, var(--nord14)) 70%, transparent),
|
||||
0 0 6px color-mix(in oklab, var(--green, var(--nord14)) 90%, transparent);
|
||||
transition: transform 150ms;
|
||||
}
|
||||
.pip:hover .dot {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
.dot.syncing {
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.55; transform: scale(0.85); }
|
||||
}
|
||||
|
||||
.popover {
|
||||
--menu-bg: rgba(46, 52, 64, 0.95);
|
||||
--menu-border: rgba(255,255,255,0.08);
|
||||
--menu-text: rgba(255,255,255,0.92);
|
||||
--menu-text-secondary: rgba(255,255,255,0.6);
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
left: -8px;
|
||||
min-width: 240px;
|
||||
background: var(--menu-bg);
|
||||
color: var(--menu-text);
|
||||
border: 1px solid var(--menu-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 0.95rem;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
z-index: 50;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.popover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: 0.7rem;
|
||||
border: 7px solid transparent;
|
||||
border-bottom-color: var(--menu-bg);
|
||||
border-top: 0;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.popover { --menu-bg: rgba(20, 20, 20, 0.95); }
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .popover {
|
||||
--menu-bg: rgba(20, 20, 20, 0.95);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme])) .popover {
|
||||
--menu-bg: rgba(255, 255, 255, 0.97);
|
||||
--menu-border: rgba(0,0,0,0.08);
|
||||
--menu-text: var(--color-text-primary);
|
||||
--menu-text-secondary: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .popover {
|
||||
--menu-bg: rgba(255, 255, 255, 0.97);
|
||||
--menu-border: rgba(0,0,0,0.08);
|
||||
--menu-text: var(--color-text-primary);
|
||||
--menu-text-secondary: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--green, var(--nord14));
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green, var(--nord14));
|
||||
}
|
||||
.meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--menu-text-secondary);
|
||||
margin-top: 0.35rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--menu-border);
|
||||
margin: 0.7rem 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.action {
|
||||
appearance: none;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--menu-text);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.action:hover:not(:disabled) {
|
||||
background: color-mix(in oklab, var(--menu-text) 10%, transparent);
|
||||
}
|
||||
.action.danger { color: var(--red, var(--nord11)); }
|
||||
.action:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.progress {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.progress-text {
|
||||
font-size: 0.72rem;
|
||||
color: var(--menu-text-secondary);
|
||||
}
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: color-mix(in oklab, var(--menu-text) 12%, transparent);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--green, var(--nord14));
|
||||
transition: width 200ms ease-out;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--red, var(--nord11));
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if visible}
|
||||
<div class="wrap" {@attach closeOnOutsideClick}>
|
||||
<button
|
||||
class="pip"
|
||||
onclick={() => open = !open}
|
||||
aria-label={t.offline_ready}
|
||||
aria-expanded={open}
|
||||
title={t.offline_ready}
|
||||
type="button"
|
||||
>
|
||||
<span class="dot" class:syncing={pwaStore.isSyncing}></span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="popover" role="dialog">
|
||||
<div class="status-row">
|
||||
<span class="status-dot"></span>
|
||||
{t.offline_ready}
|
||||
</div>
|
||||
<div class="meta">
|
||||
{pwaStore.recipeCount} {t.recipes_word}
|
||||
{#if pwaStore.lastSyncDate}
|
||||
<br />{t.last_sync}: {formatDate(pwaStore.lastSyncDate)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
class="action"
|
||||
onclick={handleSync}
|
||||
disabled={pwaStore.isSyncing}
|
||||
type="button"
|
||||
>
|
||||
{pwaStore.isSyncing ? t.syncing : t.sync_now}
|
||||
</button>
|
||||
<button
|
||||
class="action danger"
|
||||
onclick={handleClear}
|
||||
disabled={pwaStore.isSyncing}
|
||||
type="button"
|
||||
>
|
||||
{t.clear_offline_data}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if pwaStore.isSyncing && pwaStore.syncProgress}
|
||||
<div class="progress">
|
||||
<div class="progress-text">
|
||||
{pwaStore.syncProgress.message}
|
||||
{#if pwaStore.syncProgress.imageProgress}
|
||||
· {progressPct}%
|
||||
{/if}
|
||||
</div>
|
||||
{#if pwaStore.syncProgress.imageProgress}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {progressPct}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pwaStore.error}
|
||||
<div class="error">{pwaStore.error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import Lock from '@lucide/svelte/icons/lock';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** Show the small "privat" lock chip above the content (default true). */
|
||||
badge?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { badge = true, children }: Props = $props();
|
||||
|
||||
// Visible only to logged-in viewers. Pages that use this should be rendered
|
||||
// per request (e.g. the hike detail page is `prerender = false`) so the
|
||||
// session is live and, for anonymous visitors, the content is omitted from
|
||||
// the SSR HTML.
|
||||
//
|
||||
// NOTE: this is *cosmetic* gating, not byte-gating like a private image.
|
||||
// The prose is compiled into the page's JS chunk, which ships to every
|
||||
// visitor — a determined anonymous user can read it in the bundle. Use it
|
||||
// for "members-only" notes, never for secrets.
|
||||
const canSee = $derived(!!page.data.session?.user);
|
||||
</script>
|
||||
|
||||
{#if canSee}
|
||||
<div class="private-prose">
|
||||
{#if badge}
|
||||
<span class="badge" title="Privat — nur für eingeloggte Benutzer sichtbar">
|
||||
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
|
||||
privat
|
||||
</span>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.private-prose {
|
||||
position: relative;
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
/* Trim the first/last rendered block's margins so the box hugs its content. */
|
||||
.private-prose :global(> :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
.private-prose :global(> :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
type Alternate = { hreflang: string; href: string };
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
canonical?: string;
|
||||
ogImage?: string;
|
||||
ogImageAlt?: string;
|
||||
ogType?: 'website' | 'article';
|
||||
siteName?: string;
|
||||
lang?: 'de' | 'en' | 'la';
|
||||
alternates?: Alternate[];
|
||||
twitterCard?: 'summary' | 'summary_large_image';
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
ogImage,
|
||||
ogImageAlt,
|
||||
ogType = 'website',
|
||||
siteName = 'Bocken',
|
||||
lang,
|
||||
alternates = [],
|
||||
twitterCard = 'summary_large_image',
|
||||
}: Props = $props();
|
||||
|
||||
const localeMap = { de: 'de_DE', en: 'en_US', la: 'la' } as const;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
{#if description}<meta name="description" content={description} />{/if}
|
||||
{#if canonical}<link rel="canonical" href={canonical} />{/if}
|
||||
|
||||
<meta property="og:title" content={title} />
|
||||
{#if description}<meta property="og:description" content={description} />{/if}
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:site_name" content={siteName} />
|
||||
{#if canonical}<meta property="og:url" content={canonical} />{/if}
|
||||
{#if lang}<meta property="og:locale" content={localeMap[lang]} />{/if}
|
||||
{#if ogImage}
|
||||
<meta property="og:image" content={ogImage} />
|
||||
{#if ogImageAlt}<meta property="og:image:alt" content={ogImageAlt} />{/if}
|
||||
{/if}
|
||||
|
||||
<meta name="twitter:card" content={twitterCard} />
|
||||
<meta name="twitter:title" content={title} />
|
||||
{#if description}<meta name="twitter:description" content={description} />{/if}
|
||||
{#if ogImage}<meta name="twitter:image" content={ogImage} />{/if}
|
||||
|
||||
{#each alternates as a (a.hreflang)}
|
||||
<link rel="alternate" hreflang={a.hreflang} href={a.href} />
|
||||
{/each}
|
||||
</svelte:head>
|
||||
@@ -0,0 +1,326 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import { m } from '$lib/js/commonI18n';
|
||||
import type { CommonLang } from '$lib/js/commonI18n';
|
||||
|
||||
/**
|
||||
* Pill-styled time picker, sibling to `DatePicker.svelte` (date) and
|
||||
* `DateTimePicker.svelte` (combined). Operates on a plain `"HH:MM"` string so
|
||||
* it drops straight into 24-hour API params and `<input type="time">`-shaped
|
||||
* stores.
|
||||
*
|
||||
* - Chevron arrows nudge by `step` minutes (wrapping across the hour).
|
||||
* - The display opens a two-column hour / minute dropdown.
|
||||
* - Optional `min` / `max` (also `"HH:MM"`) disable out-of-range cells.
|
||||
*/
|
||||
interface Props {
|
||||
value?: string;
|
||||
/** Minute granularity for the dropdown + chevron nudges. */
|
||||
step?: number;
|
||||
min?: string;
|
||||
max?: string;
|
||||
lang?: CommonLang;
|
||||
/** Optional extra CSS class on the outer wrapper. */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
step = 5,
|
||||
min = '',
|
||||
max = '',
|
||||
lang = 'de',
|
||||
class: extraClass = ''
|
||||
}: Props = $props();
|
||||
|
||||
const t = $derived(m[lang]);
|
||||
|
||||
let open = $state(false);
|
||||
let pickerRef = $state<HTMLDivElement | null>(null);
|
||||
let hourCol = $state<HTMLDivElement | null>(null);
|
||||
let minCol = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function pad(n: number): string {
|
||||
return n.toString().padStart(2, '0');
|
||||
}
|
||||
function parse(v: string): { h: number; m: number } | null {
|
||||
const mt = /^(\d{1,2}):(\d{2})$/.exec(v ?? '');
|
||||
if (!mt) return null;
|
||||
const h = Number(mt[1]);
|
||||
const mm = Number(mt[2]);
|
||||
if (h < 0 || h > 23 || mm < 0 || mm > 59) return null;
|
||||
return { h, m: mm };
|
||||
}
|
||||
|
||||
const current = $derived(parse(value));
|
||||
const label = $derived(current ? `${pad(current.h)}:${pad(current.m)}` : t.select_time);
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
const minutes = $derived(
|
||||
Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step).filter((mm) => mm < 60)
|
||||
);
|
||||
|
||||
function outOfRange(time: string): boolean {
|
||||
if (min && time < min) return true;
|
||||
if (max && time > max) return true;
|
||||
return false;
|
||||
}
|
||||
function hourDisabled(h: number): boolean {
|
||||
for (let mm = 0; mm < 60; mm += step) {
|
||||
if (!outOfRange(`${pad(h)}:${pad(mm)}`)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function minuteDisabled(mm: number): boolean {
|
||||
const h = current?.h ?? -1;
|
||||
if (h < 0) return false;
|
||||
return outOfRange(`${pad(h)}:${pad(mm)}`);
|
||||
}
|
||||
|
||||
function commit(h: number, mm: number) {
|
||||
const next = `${pad(h)}:${pad(mm)}`;
|
||||
if (outOfRange(next)) return;
|
||||
value = next;
|
||||
}
|
||||
function selectHour(h: number) {
|
||||
commit(h, current?.m ?? 0);
|
||||
}
|
||||
function selectMinute(mm: number) {
|
||||
commit(current?.h ?? new Date().getHours(), mm);
|
||||
}
|
||||
function nudge(delta: number) {
|
||||
const base = current ?? { h: new Date().getHours(), m: 0 };
|
||||
let total = (base.h * 60 + base.m + delta) % (24 * 60);
|
||||
if (total < 0) total += 24 * 60;
|
||||
commit(Math.floor(total / 60), total % 60);
|
||||
}
|
||||
function setNow() {
|
||||
const d = new Date();
|
||||
let mm = Math.round(d.getMinutes() / step) * step;
|
||||
let h = d.getHours();
|
||||
if (mm >= 60) {
|
||||
mm = 0;
|
||||
h = (h + 1) % 24;
|
||||
}
|
||||
commit(h, mm);
|
||||
open = false;
|
||||
}
|
||||
|
||||
// Centre the selected cells when the dropdown opens.
|
||||
function centreCol(col: HTMLDivElement | null) {
|
||||
if (!col) return;
|
||||
const sel = col.querySelector<HTMLElement>('.tp-cell.selected');
|
||||
if (sel) col.scrollTop = sel.offsetTop - col.clientHeight / 2 + sel.clientHeight / 2;
|
||||
}
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
centreCol(hourCol);
|
||||
centreCol(minCol);
|
||||
}
|
||||
});
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (pickerRef && e.target instanceof Node && !pickerRef.contains(e.target)) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('pointerdown', handleClickOutside);
|
||||
return () => document.removeEventListener('pointerdown', handleClickOutside);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="tp {extraClass}" bind:this={pickerRef}>
|
||||
<div class="tp-pill" class:empty={current == null}>
|
||||
<button type="button" class="tp-arrow" onclick={() => nudge(-step)} aria-label="−{step} min">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button type="button" class="tp-display" onclick={() => (open = !open)} aria-label={t.select_time}>
|
||||
<Clock size={14} aria-hidden="true" />
|
||||
<span class="tp-label">{label}</span>
|
||||
</button>
|
||||
<button type="button" class="tp-arrow" onclick={() => nudge(step)} aria-label="+{step} min">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="tp-dropdown" role="dialog" aria-label={t.select_time}>
|
||||
<div class="tp-cols">
|
||||
<div class="tp-col" bind:this={hourCol} role="listbox" aria-label="Stunde">
|
||||
{#each hours as h (h)}
|
||||
<button
|
||||
type="button"
|
||||
class="tp-cell"
|
||||
class:selected={current?.h === h}
|
||||
disabled={hourDisabled(h)}
|
||||
onclick={() => selectHour(h)}
|
||||
>
|
||||
{pad(h)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tp-col" bind:this={minCol} role="listbox" aria-label="Minute">
|
||||
{#each minutes as mm (mm)}
|
||||
<button
|
||||
type="button"
|
||||
class="tp-cell"
|
||||
class:selected={current?.m === mm}
|
||||
disabled={minuteDisabled(mm)}
|
||||
onclick={() => selectMinute(mm)}
|
||||
>
|
||||
{pad(mm)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="tp-now" onclick={setNow}>{t.now}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tp {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tp-pill {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tp-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem 0.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-normal), background var(--transition-normal);
|
||||
}
|
||||
.tp-arrow:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.tp-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color var(--transition-normal), background var(--transition-normal);
|
||||
}
|
||||
.tp-display:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.tp-label {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tp-pill.empty .tp-label {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Dropdown — mirrors DatePicker / DateTimePicker chrome. */
|
||||
.tp-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4rem);
|
||||
left: 0;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 0.5rem;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.tp-cols {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tp-col {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 11rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.15rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tp-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.6rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal), color var(--transition-normal);
|
||||
}
|
||||
.tp-cell:hover:not(:disabled) {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.tp-cell:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.tp-cell.selected {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tp-cell.selected:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.tp-now {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.3rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal);
|
||||
}
|
||||
.tp-now:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
</style>
|
||||
@@ -5,10 +5,18 @@
|
||||
import { browser } from '$app/environment';
|
||||
import LogIn from '@lucide/svelte/icons/log-in';
|
||||
import { m, type CommonLang } from '$lib/js/commonI18n';
|
||||
import { pwaStore } from '$lib/stores/pwa.svelte';
|
||||
|
||||
let { user, recipeLang = 'rezepte', lang = 'de' } = $props();
|
||||
const t = $derived(m[lang as CommonLang]);
|
||||
|
||||
async function handleSync() {
|
||||
await pwaStore.syncForOffline();
|
||||
}
|
||||
async function handleClear() {
|
||||
await pwaStore.clearOfflineData();
|
||||
}
|
||||
|
||||
function toggle_options(){
|
||||
const el = document.querySelector("#options-wrap") as HTMLElement | null;
|
||||
if (el) el.hidden = !el.hidden;
|
||||
@@ -16,9 +24,9 @@
|
||||
|
||||
onMount( () => {
|
||||
document.addEventListener("click", (e: MouseEvent) => {
|
||||
const userButton = document.querySelector("#button");
|
||||
const userWrap = document.querySelector("#user-wrap");
|
||||
|
||||
if(userButton && !userButton.contains(e.target as Node)){
|
||||
if(userWrap && !userWrap.contains(e.target as Node)){
|
||||
const wrap = document.querySelector("#options-wrap") as HTMLElement | null;
|
||||
if (wrap) wrap.hidden = true;
|
||||
}
|
||||
@@ -49,8 +57,11 @@
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
button {
|
||||
.user-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
button.avatar {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
width: 1.8rem;
|
||||
@@ -138,6 +149,39 @@
|
||||
#options li:hover a {
|
||||
color: var(--menu-text-hover);
|
||||
}
|
||||
#options li button.menu-action {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: var(--menu-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
width: auto;
|
||||
}
|
||||
#options li:hover button.menu-action:not(:disabled) {
|
||||
color: var(--menu-text-hover);
|
||||
}
|
||||
#options li button.menu-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.menu-section-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.55;
|
||||
margin-top: 0.6rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
background: var(--menu-border, rgba(255,255,255,0.08));
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
h2 {
|
||||
margin-block: 0;
|
||||
font-size: 1.1rem;
|
||||
@@ -150,21 +194,60 @@
|
||||
</style>
|
||||
|
||||
{#if user}
|
||||
<button onclick={toggle_options} style="background-image: url(https://bocken.org/static/user/thumb/{user.nickname}.webp)" id=button>
|
||||
<div class="options-wrap" hidden id=options-wrap>
|
||||
<div id=options>
|
||||
<h2>{user.name}</h2>
|
||||
<p>({user.nickname})</p>
|
||||
<ul>
|
||||
{#if user.groups?.includes('rezepte_users')}
|
||||
<li><a href={resolve('/[recipeLang=recipeLang]/administration', { recipeLang })}>Administration</a></li>
|
||||
{/if}
|
||||
<li><a href="https://sso.bocken.org/if/user/#/settings" >Einstellungen</a></li>
|
||||
<li><a href={`${resolve('/logout')}?callbackUrl=${encodeURIComponent(getLogoutCallbackUrl(page.url.pathname))}`}>Log Out</a></li>
|
||||
</ul>
|
||||
<div class="user-wrap" id=user-wrap>
|
||||
<button
|
||||
class="avatar"
|
||||
onclick={toggle_options}
|
||||
style="background-image: url(https://bocken.org/static/user/thumb/{user.nickname}.webp)"
|
||||
id=button
|
||||
aria-label={user.name}
|
||||
></button>
|
||||
<div class="options-wrap" hidden id=options-wrap>
|
||||
<div id=options>
|
||||
<h2>{user.name}</h2>
|
||||
<p>({user.nickname})</p>
|
||||
<ul>
|
||||
{#if user.groups?.includes('rezepte_users')}
|
||||
<li><a href={resolve('/[recipeLang=recipeLang]/administration', { recipeLang })}>Administration</a></li>
|
||||
{/if}
|
||||
<li><a href="https://sso.bocken.org/if/user/#/settings" >Einstellungen</a></li>
|
||||
{#if pwaStore.isStandalone}
|
||||
<li class="menu-divider" aria-hidden="true"></li>
|
||||
<li class="menu-section-label">{t.offline_data}</li>
|
||||
{#if pwaStore.isOfflineAvailable}
|
||||
<li>
|
||||
<button
|
||||
class="menu-action"
|
||||
type="button"
|
||||
onclick={handleSync}
|
||||
disabled={pwaStore.isSyncing}
|
||||
>{pwaStore.isSyncing ? t.syncing : t.sync_now}</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class="menu-action"
|
||||
type="button"
|
||||
onclick={handleClear}
|
||||
disabled={pwaStore.isSyncing}
|
||||
>{t.clear_offline_data}</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<button
|
||||
class="menu-action"
|
||||
type="button"
|
||||
onclick={handleSync}
|
||||
disabled={pwaStore.isSyncing}
|
||||
>{pwaStore.isSyncing ? t.syncing : t.sync_for_offline}</button>
|
||||
</li>
|
||||
{/if}
|
||||
<li class="menu-divider" aria-hidden="true"></li>
|
||||
{/if}
|
||||
<li><a href={`${resolve('/logout')}?callbackUrl=${encodeURIComponent(getLogoutCallbackUrl(page.url.pathname))}`}>Log Out</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
class="entry login-link"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { getAngelusStreak, getCurrentTimeSlot, type TimeSlot } from '$lib/stores/angelusStreak.svelte';
|
||||
import StreakAura from '$lib/components/faith/StreakAura.svelte';
|
||||
import StreakInfoButton from '$lib/components/faith/StreakInfoButton.svelte';
|
||||
import Coffee from '@lucide/svelte/icons/coffee';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
@@ -91,6 +92,7 @@ async function pray() {
|
||||
</script>
|
||||
|
||||
<div class="angelus-streak">
|
||||
<StreakInfoButton {lang} />
|
||||
<div class="streak-display">
|
||||
<StreakAura value={displayStreak} {burst}>
|
||||
<span class="number">
|
||||
@@ -146,6 +148,8 @@ async function pray() {
|
||||
border-radius: 12px;
|
||||
width: fit-content;
|
||||
margin: 1.5rem auto;
|
||||
/* Anchor for the absolute-positioned StreakInfoButton pip */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.streak-display {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { getRosaryStreak } from '$lib/stores/rosaryStreak.svelte';
|
||||
import StreakAura from '$lib/components/faith/StreakAura.svelte';
|
||||
import StreakInfoButton from '$lib/components/faith/StreakInfoButton.svelte';
|
||||
import { m, type FaithLang } from '$lib/js/faithI18n';
|
||||
import { tick, onMount } from 'svelte';
|
||||
|
||||
@@ -42,6 +43,7 @@ async function pray() {
|
||||
</script>
|
||||
|
||||
<div class="streak-container" class:no-js-hidden={!isLoggedIn}>
|
||||
<StreakInfoButton {lang} />
|
||||
<div class="streak-display">
|
||||
<StreakAura value={displayLength} {burst} />
|
||||
<span class="streak-label">{dayLabel}</span>
|
||||
@@ -72,6 +74,8 @@ async function pray() {
|
||||
background: var(--nord1);
|
||||
border-radius: 12px;
|
||||
width: fit-content;
|
||||
/* Anchor for the absolute-positioned StreakInfoButton pip */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import type { FaithLang } from '$lib/js/faithI18n';
|
||||
|
||||
let { lang = 'de' }: { lang?: FaithLang } = $props();
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
function close() { open = false; }
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (open && e.key === 'Escape') close();
|
||||
}
|
||||
|
||||
const labels = $derived(
|
||||
lang === 'en'
|
||||
? { trigger: 'About this counter', close: 'Close', title: 'About this counter' }
|
||||
: lang === 'la'
|
||||
? { trigger: 'De numero hoc', close: 'Claudere', title: 'De numero hoc' }
|
||||
: { trigger: 'Über diese Zählung', close: 'Schliessen', title: 'Über diese Zählung' }
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<button
|
||||
class="info-btn"
|
||||
type="button"
|
||||
onclick={() => open = true}
|
||||
aria-label={labels.trigger}
|
||||
title={labels.trigger}
|
||||
>
|
||||
<Info size={14} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="info-backdrop" onclick={close} role="presentation">
|
||||
<div
|
||||
class="info-dialog"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="streak-info-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<button class="info-close" type="button" onclick={close} aria-label={labels.close}>
|
||||
<X size={16} strokeWidth={2} />
|
||||
</button>
|
||||
<h3 id="streak-info-title" class="info-title">{labels.title}</h3>
|
||||
{#if lang === 'en'}
|
||||
<p>
|
||||
This counter tracks <em>consistency</em>, not piety. The Church proposes regular rhythms of prayer (<abbr title="Catechism of the Catholic Church">CCC</abbr> 2698) — we are creatures of body and spirit (CCC 2702), and habit forms us. On weary days the count can be a small nudge to keep faithful to that routine.
|
||||
</p>
|
||||
<p>
|
||||
But the number itself is empty. Christ warns: <q>In praying do not heap up empty phrases as the Gentiles do; for they think that they will be heard for their many words</q> (Mt 6:7). What matters is <q>that the heart should be present to him to whom we are speaking</q> (CCC 2700). One prayer prayed with attention is worth more than thirty rushed to keep a count alive. And clinging to the streak as proof of one's piety only opens the door to the wounded pride the Catechism warns of (CCC 2728).
|
||||
</p>
|
||||
{:else if lang === 'la'}
|
||||
<p>
|
||||
Numerus iste <em>constantiam</em> metitur, non pietatem. Ecclesia rhythmos cotidianos orationis commendat (<abbr title="Catechismus Catholicae Ecclesiae">CCC</abbr> 2698) — homo enim corpus et spiritus est (CCC 2702), atque consuetudine formamur. Diebus laboriosis numerus parvulum incitamentum esse potest, ut consuetudini fideles maneamus.
|
||||
</p>
|
||||
<p>
|
||||
Numerus tamen ipse vacuus est. Christus monet: <q>Orantes nolite multum loqui, sicut ethnici; putant enim quod in multiloquio suo exaudiantur</q> (Mt 6,7). Quod refert est ut <q>cor adsit Ei cui loquimur</q> (CCC 2700). Una oratio attente fusa pluris est quam triginta praecipitanter recitatae ut numerus servetur. Qui autem numerum tenet ut testimonium pietatis suae, ostium aperit superbiae læsæ, quam Catechismus monet (CCC 2728).
|
||||
</p>
|
||||
{:else}
|
||||
<p>
|
||||
Diese Zählung misst <em>Beständigkeit</em>, nicht Frömmigkeit. Die Kirche empfiehlt regelmässige Gebetsrhythmen (<abbr title="Katechismus der Katholischen Kirche">KKK</abbr> 2698) — der Mensch ist Leib und Geist (KKK 2702), und Gewohnheit formt uns. An müden Tagen kann die Zahl ein kleiner Anstoss sein, in der Routine zu bleiben.
|
||||
</p>
|
||||
<p>
|
||||
Die Zahl selbst aber ist leer. Christus mahnt: <q>Plappert nicht wie die Heiden, die meinen, sie würden nur erhört, wenn sie viele Worte machen</q> (Mt 6,7). Worauf es ankommt, ist, <q>dass das Herz dem zugewandt ist, zu dem es spricht</q> (KKK 2700). Ein einziges aufmerksam gebetetes Gebet ist mehr wert als dreissig hastig durchgeleierte, nur um den Zähler zu retten. Und wer am Streak als Beweis seiner Frömmigkeit festhält, öffnet die Tür zum verletzten Stolz, vor dem der Katechismus warnt (KKK 2728).
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Tiny info pip in the corner — opens a modal that explains the
|
||||
streak counter is about habit, not piety. The parent container
|
||||
must be position: relative for this to anchor correctly. */
|
||||
.info-btn {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary, var(--nord4));
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
transition: opacity 150ms, background 150ms;
|
||||
z-index: 5;
|
||||
}
|
||||
.info-btn:hover,
|
||||
.info-btn:focus-visible {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme="dark"])) .info-btn:hover,
|
||||
:global(:root:not([data-theme="dark"])) .info-btn:focus-visible {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .info-btn:hover,
|
||||
:global(:root[data-theme="light"]) .info-btn:focus-visible {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.info-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
animation: streak-info-fade 150ms ease-out;
|
||||
}
|
||||
.info-dialog {
|
||||
position: relative;
|
||||
background: var(--color-surface, var(--nord1));
|
||||
border: 1px solid var(--color-border, var(--nord3));
|
||||
border-radius: 14px;
|
||||
padding: 1.5rem 1.5rem 1.25rem;
|
||||
max-width: 560px;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
animation: streak-info-scale 150ms ease-out;
|
||||
color: var(--color-text-primary, var(--nord6));
|
||||
}
|
||||
.info-close {
|
||||
position: absolute;
|
||||
top: 0.55rem;
|
||||
right: 0.55rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.85rem;
|
||||
height: 1.85rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms, background 150ms;
|
||||
}
|
||||
.info-close:hover,
|
||||
.info-close:focus-visible {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:global(:root:not([data-theme="dark"])) .info-close:hover,
|
||||
:global(:root:not([data-theme="dark"])) .info-close:focus-visible {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="light"]) .info-close:hover,
|
||||
:global(:root[data-theme="light"]) .info-close:focus-visible {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.info-title {
|
||||
margin: 0 2rem 0.85rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.info-dialog p {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.55;
|
||||
color: var(--color-text-secondary, var(--nord4));
|
||||
}
|
||||
.info-dialog p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.info-dialog q {
|
||||
font-style: italic;
|
||||
}
|
||||
.info-dialog abbr {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted currentColor;
|
||||
cursor: help;
|
||||
}
|
||||
@keyframes streak-info-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes streak-info-scale {
|
||||
from { opacity: 0; transform: scale(0.96); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
@@ -30,7 +30,7 @@
|
||||
<i>✻</i>
|
||||
In meiner Todesstunde rufe mich,
|
||||
<i>✻</i>
|
||||
Und heisse zur Dir kommen mich,
|
||||
Und heisse zu Dir kommen mich,
|
||||
<i>✻</i>
|
||||
Damit ich möge loben Dich
|
||||
<i>✻</i>
|
||||
@@ -53,10 +53,10 @@
|
||||
{/if}
|
||||
<p>
|
||||
{#if showLatin}
|
||||
<v lang=la >En ego, o bone et dulcíssime Jesu, ante contspéctum tuum génibusme provólvo ac máximo ánimi ardóre te oro atque obtéstor, ut meum in in cor vívidos fídei, spei et caritátis sensus, atque veram peccatórum meórum pœniténtiam, éaque emendándi firmíssimam voluntátem velis imprímere; dum magno ánimi afféctu et dolóre tua quinque vúlnera mecum ipse consídero ac mente contémplor, illud præ óculis habens, quod jam in ore ponébat tuo David Prophéta de te, o bone Jesu: Fodérunt manus meas et pedes meos; dinumeravérunt ómnio ossa mea (Ps. 21, 17-18)</v>
|
||||
<v lang=la >En ego, o bone et dulcíssime Jesu, ante conspéctum tuum génibus me provólvo ac máximo ánimi ardóre te oro atque obtéstor, ut meum in cor vívidos fídei, spei et caritátis sensus, atque veram peccatórum meórum pœniténtiam, éaque emendándi firmíssimam voluntátem velis imprímere; dum magno ánimi afféctu et dolóre tua quinque vúlnera mecum ipse consídero ac mente contémplor, illud præ óculis habens, quod jam in ore ponébat tuo David Prophéta de te, o bone Jesu: Fodérunt manus meas et pedes meos; dinumeravérunt ómnia ossa mea (Ps. 21, 17-18)</v>
|
||||
{/if}
|
||||
<v lang=de>
|
||||
Siehe, o gütiger und milder Jesus, ich werfe mich vor Deinen Augen auf die Knie. Inbrünstig bitte und beschwöre ich Dich: Präge meinem Herzen lebendige Gefühle des Glaubens, der Hoffung und der Liebe ein sowie wahre Reue über meine Sünden und den ganz festen Willen, mich zu bessern. Voll Liebe und Schmerz schaue ich Deine fünf Wunden und betrachte sie in meinem Geiste. Dabei halte ich mir vor Augen, was im Hinblick auf Dich, o guter Jesus, schon der Prophet David Dir in den Mund legte: «Sie haben Meine Hände und Meine Füsse durchbohrt; alle meine Gebeine haben sie gezählt.» (Ps. 21, 17-18)
|
||||
Siehe, o gütiger und milder Jesus, ich werfe mich vor Deinen Augen auf die Knie. Inbrünstig bitte und beschwöre ich Dich: Präge meinem Herzen lebendige Gefühle des Glaubens, der Hoffnung und der Liebe ein sowie wahre Reue über meine Sünden und den ganz festen Willen, mich zu bessern. Voll Liebe und Schmerz schaue ich Deine fünf Wunden und betrachte sie in meinem Geiste. Dabei halte ich mir vor Augen, was im Hinblick auf Dich, o guter Jesus, schon der Prophet David Dir in den Mund legte: «Sie haben Meine Hände und Meine Füsse durchbohrt; alle meine Gebeine haben sie gezählt.» (Ps. 21, 17-18)
|
||||
</v>
|
||||
|
||||
</p>
|
||||
|
||||