Initial commit
Deploy / validate-dispatch (push) Successful in 43s
Deploy / scheduled-rebuild-dev (push) Skipped
Deploy / scheduled-rebuild-master (push) Skipped
Deploy / rollback-dev (push) Skipped
Deploy / rollback-master (push) Skipped
Deploy / changes (push) Successful in 13s
Deploy / secret-scan (push) Successful in 9s
Deploy / app-quality (push) Successful in 11m21s
Deploy / security-deps (push) Successful in 14s
Deploy / context (push) Failing after 1s
Deploy / app-image (push) Skipped
Deploy / deploy-dev (push) Skipped
Deploy / deploy-master (push) Skipped

This commit is contained in:
Per Hoener
2026-09-18 23:37:19 +02:00
commit 0d45331f5c
135 changed files with 20651 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Dependencies and build artifacts
node_modules
.next
out
# Logs & tmp
npm-debug.log*
*.log
tmp
coverage
# OS / editor noise
.DS_Store
.idea
.vscode
# VCS / metadata
.git
.gitignore
# Local env overrides
.env
.env.*
# Local tooling
.agents
.claude
.codex
.opencode
.playwright-mcp
.venv
frontend
backend
+34
View File
@@ -0,0 +1,34 @@
# Safe example configuration for the neutral website starter.
# Copy to .env.local for local development. Never commit real secrets.
# Public identity and contact values
APP_NAME=Example Website
APP_URL=https://example.com
COMPANY_NAME=Example Company
CONTACT_EMAIL=info@example.com
CONTACT_PHONE=+49 000 000000
CONTACT_ADDRESS=Example Street 1, 12345 Example City
PRIMARY_DOMAIN=example.com
# HTTPS ICS calendar URL (required at runtime, not during next build).
# Plain HTTP is accepted only for loopback test fixtures.
CALENDAR_URL=https://example.com/calendar.ics
# Runtime and reverse-proxy settings
CORS_ORIGIN=https://example.com
TRUSTED_PROXIES=["172.16.0.0/12"]
CALENDAR_CACHE_DURATION_MS=600000
CALENDAR_FETCH_TIMEOUT_MS=10000
DEFAULT_CALENDAR_TIMEZONE=Europe/Berlin
MAX_RECURRENCE_WINDOW_MONTHS=12
MAX_ICS_BYTES=524288
MAX_OCCURRENCES_PER_EVENT=500
ROUTINE_EVENT_TITLES=regeltermin
HOSTNAME=127.0.0.1
PORT=3000
# Docker/Traefik examples
APP_IMAGE_NAME=website-starter:local
TRAEFIK_STACK_NAME=website-starter
TRAEFIK_NETWORK_NAME=traefik-external
TRAEFIK_CERTRESOLVER=letsencrypt
+13
View File
@@ -0,0 +1,13 @@
public/images/**/*.jpg filter=lfs diff=lfs merge=lfs -text
public/images/**/*.jpeg filter=lfs diff=lfs merge=lfs -text
public/images/**/*.JPG filter=lfs diff=lfs merge=lfs -text
public/images/**/*.png filter=lfs diff=lfs merge=lfs -text
public/images/**/*.webp filter=lfs diff=lfs merge=lfs -text
public/images/**/*.avif filter=lfs diff=lfs merge=lfs -text
public/downloads/**/*.pdf filter=lfs diff=lfs merge=lfs -text
.media-source/**/*.jpg filter=lfs diff=lfs merge=lfs -text
.media-source/**/*.jpeg filter=lfs diff=lfs merge=lfs -text
.media-source/**/*.JPG filter=lfs diff=lfs merge=lfs -text
.media-source/**/*.JPEG filter=lfs diff=lfs merge=lfs -text
.media-source/**/*.png filter=lfs diff=lfs merge=lfs -text
.media-source/**/*.PNG filter=lfs diff=lfs merge=lfs -text
+127
View File
@@ -0,0 +1,127 @@
name: CI
permissions:
contents: read
concurrency:
group: website-starter-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches-ignore:
- dev
- master
pull_request:
branches:
- dev
- master
types:
- opened
- synchronize
- reopened
- ready_for_review
env:
NODE_VERSION_FILE: .node-version
TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969
jobs:
changes:
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 5
outputs:
app: ${{ steps.detect.outputs.app }}
security_deps: ${{ steps.detect.outputs.security_deps }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
fetch-depth: 0
- name: Detect changed areas
id: detect
env:
CHANGESET_BEFORE: ${{ github.event.before }}
CHANGESET_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: sh scripts/detect-ci-changes.sh
app:
needs: [changes]
if: needs.changes.outputs.app == 'true'
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 30
env:
APP_URL: https://example.com
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: ${{ env.NODE_VERSION_FILE }}
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Restore and update image cache
env:
IMAGE_CACHE_NAMESPACE: ${{ github.head_ref || github.ref_name }}
run: sh scripts/prepare-image-cache.sh
- name: Lint
run: npm run lint
- name: Test
run: npm run test
- name: Type check
run: npm run typecheck
- name: Build
env:
SKIP_IMAGE_OPTIMIZATION: true
run: npm run build
- name: E2E smoke test
env:
SKIP_IMAGE_OPTIMIZATION: true
run: npm run test:e2e
secret-scan:
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
- name: Scan repository for committed secrets
run: sh scripts/trivy-scan.sh secret-fs .
security-deps:
needs: [changes]
if: needs.changes.outputs.security_deps == 'true'
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
- name: Report app dependency vulnerabilities
run: sh scripts/trivy-scan.sh vuln-fs .
- name: Block high and critical app dependency vulnerabilities
env:
TRIVY_SEVERITY: HIGH,CRITICAL
TRIVY_EXIT_CODE: 1
run: sh scripts/trivy-scan.sh vuln-fs .
+627
View File
@@ -0,0 +1,627 @@
name: Deploy
permissions:
contents: read
concurrency:
group: website-starter-deploy-${{ github.event_name == 'schedule' && 'scheduled' || github.event.inputs.rollback_target || github.ref_name }}
cancel-in-progress: false
on:
push:
branches:
- dev
- master
workflow_dispatch:
inputs:
rollback_target:
description: "Rollback target: dev or master. Leave empty for a normal manual deploy."
required: false
app_image_tag:
description: "Next app image tag for rollback."
required: false
schedule:
- cron: "17 3 * * 1"
env:
DOCKER_BUILDKIT: 1
NODE_VERSION_FILE: .node-version
TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969
REGISTRY: ${{ vars.CONTAINER_REGISTRY }}
APP_IMAGE_REPOSITORY: ${{ vars.APP_IMAGE_REPOSITORY }}
APP_NAME: ${{ vars.APP_NAME }}
COMPANY_NAME: ${{ vars.COMPANY_NAME }}
CONTACT_EMAIL: ${{ vars.CONTACT_EMAIL }}
CONTACT_PHONE: ${{ vars.CONTACT_PHONE }}
CONTACT_ADDRESS: ${{ vars.CONTACT_ADDRESS }}
jobs:
validate-dispatch:
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 5
steps:
- name: Validate workflow dispatch inputs
env:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
ROLLBACK_TARGET: ${{ github.event.inputs.rollback_target }}
APP_IMAGE_TAG: ${{ github.event.inputs.app_image_tag }}
run: |
set -eu
if [ "$EVENT_NAME" != "workflow_dispatch" ]; then
exit 0
fi
fail() {
echo "$1" >&2
exit 1
}
validate_tag() {
key="$1"
value="$2"
case "$value" in
[A-Za-z0-9_]* ) ;;
*) fail "Invalid Docker image tag for $key." ;;
esac
case "$value" in
*[!A-Za-z0-9_.-]* ) fail "Invalid Docker image tag for $key." ;;
esac
if [ "${#value}" -gt 128 ]; then
fail "Docker image tag for $key exceeds 128 characters."
fi
}
case "$REF_NAME" in
dev|master) ;;
*) fail "Manual deployments must be dispatched from dev or master." ;;
esac
if [ -z "$ROLLBACK_TARGET" ]; then
if [ -n "$APP_IMAGE_TAG" ]; then
fail "Image tags require rollback_target."
fi
exit 0
fi
case "$ROLLBACK_TARGET" in
dev|master) ;;
*) fail "rollback_target must be dev or master." ;;
esac
if [ "$ROLLBACK_TARGET" != "$REF_NAME" ]; then
fail "rollback_target must match the dispatched branch."
fi
validate_tag APP_IMAGE_TAG "$APP_IMAGE_TAG"
changes:
if: github.event_name != 'schedule' && (github.event_name != 'workflow_dispatch' || github.event.inputs.rollback_target == '')
needs: [validate-dispatch]
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 5
outputs:
deploy: ${{ steps.detect.outputs.deploy }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
fetch-depth: 0
- name: Detect changed areas
id: detect
env:
CHANGESET_BEFORE: ${{ github.event.before }}
run: sh scripts/detect-ci-changes.sh
app-quality:
if: github.event_name != 'schedule' && needs.changes.outputs.deploy == 'true'
needs: [changes]
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 30
env:
APP_URL: https://example.com
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: ${{ env.NODE_VERSION_FILE }}
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Restore and update image cache
env:
IMAGE_CACHE_NAMESPACE: ${{ github.ref_name }}
run: sh scripts/prepare-image-cache.sh
- name: Lint
run: npm run lint
- name: Test
run: npm run test
- name: Type check
run: npm run typecheck
- name: Build
env:
SKIP_IMAGE_OPTIMIZATION: true
run: npm run build
- name: E2E smoke test
env:
SKIP_IMAGE_OPTIMIZATION: true
run: npm run test:e2e
secret-scan:
if: github.event_name != 'schedule' && needs.changes.outputs.deploy == 'true'
needs: [changes]
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
- name: Scan repository for committed secrets
run: sh scripts/trivy-scan.sh secret-fs .
security-deps:
if: github.event_name != 'schedule' && needs.changes.outputs.deploy == 'true'
needs: [changes]
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
- name: Report app dependency vulnerabilities
run: sh scripts/trivy-scan.sh vuln-fs .
- name: Block high and critical app dependency vulnerabilities
env:
TRIVY_SEVERITY: HIGH,CRITICAL
TRIVY_EXIT_CODE: 1
run: sh scripts/trivy-scan.sh vuln-fs .
context:
if: >
needs.changes.outputs.deploy == 'true' &&
(
(github.event_name == 'push' &&
github.event.pull_request == null &&
(github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/master')) ||
(github.event_name == 'workflow_dispatch' &&
github.event.pull_request == null &&
(github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/master'))
)
needs: [changes]
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 5
outputs:
environment_name: ${{ steps.resolve.outputs.environment_name }}
primary_domain: ${{ steps.resolve.outputs.primary_domain }}
app_url: ${{ steps.resolve.outputs.app_url }}
cors_origin: ${{ steps.resolve.outputs.cors_origin }}
deploy_stack_name: ${{ steps.resolve.outputs.deploy_stack_name }}
traefik_certresolver: ${{ steps.resolve.outputs.traefik_certresolver }}
traefik_network_name: ${{ steps.resolve.outputs.traefik_network_name }}
steps:
- name: Resolve deployment target
id: resolve
env:
REF_NAME: ${{ github.ref_name }}
DEV_PRIMARY_DOMAIN: ${{ vars.DEV_PRIMARY_DOMAIN }}
DEV_APP_URL: ${{ vars.DEV_APP_URL }}
DEV_CORS_ORIGIN: ${{ vars.DEV_CORS_ORIGIN }}
DEV_DEPLOY_STACK_NAME: ${{ vars.DEV_DEPLOY_STACK_NAME }}
DEV_TRAEFIK_CERTRESOLVER: ${{ secrets.DEV_TRAEFIK_CERTRESOLVER }}
DEV_TRAEFIK_NETWORK_NAME: ${{ vars.DEV_TRAEFIK_NETWORK_NAME }}
PROD_PRIMARY_DOMAIN: ${{ vars.PROD_PRIMARY_DOMAIN }}
PROD_APP_URL: ${{ vars.PROD_APP_URL }}
PROD_CORS_ORIGIN: ${{ vars.PROD_CORS_ORIGIN }}
PROD_DEPLOY_STACK_NAME: ${{ vars.PROD_DEPLOY_STACK_NAME }}
PROD_TRAEFIK_CERTRESOLVER: ${{ secrets.PROD_TRAEFIK_CERTRESOLVER }}
PROD_TRAEFIK_NETWORK_NAME: ${{ vars.PROD_TRAEFIK_NETWORK_NAME }}
run: |
set -eu
set_output() {
printf '%s=%s\n' "$1" "$2" >> "$GITHUB_OUTPUT"
}
require_value() {
key="$1"
value="$2"
if [ -z "$value" ]; then
echo "Missing required workflow configuration: $key" >&2
exit 1
fi
}
case "$REF_NAME" in
dev)
config_prefix="DEV"
environment_name="dev"
primary_domain="$DEV_PRIMARY_DOMAIN"
app_url="${DEV_APP_URL:-}"
cors_origin="${DEV_CORS_ORIGIN:-}"
deploy_stack_name="${DEV_DEPLOY_STACK_NAME:-website-starter-dev}"
traefik_certresolver="$DEV_TRAEFIK_CERTRESOLVER"
traefik_network_name="${DEV_TRAEFIK_NETWORK_NAME:-traefik-external}"
;;
master)
config_prefix="PROD"
environment_name="production"
primary_domain="$PROD_PRIMARY_DOMAIN"
app_url="${PROD_APP_URL:-}"
cors_origin="${PROD_CORS_ORIGIN:-}"
deploy_stack_name="${PROD_DEPLOY_STACK_NAME:-website-starter-prod}"
traefik_certresolver="$PROD_TRAEFIK_CERTRESOLVER"
traefik_network_name="${PROD_TRAEFIK_NETWORK_NAME:-traefik}"
;;
*)
echo "Branch $REF_NAME is not configured for deployment." >&2
exit 1
;;
esac
require_value "${config_prefix}_PRIMARY_DOMAIN" "$primary_domain"
require_value "${config_prefix}_TRAEFIK_CERTRESOLVER" "$traefik_certresolver"
if [ -z "$app_url" ]; then
app_url="https://$primary_domain"
fi
if [ -z "$cors_origin" ]; then
cors_origin="$app_url"
fi
set_output "environment_name" "$environment_name"
set_output "primary_domain" "$primary_domain"
set_output "app_url" "$app_url"
set_output "cors_origin" "$cors_origin"
set_output "deploy_stack_name" "$deploy_stack_name"
set_output "traefik_certresolver" "$traefik_certresolver"
set_output "traefik_network_name" "$traefik_network_name"
app-image:
if: github.event_name != 'schedule' && needs.changes.outputs.deploy == 'true'
needs: [changes, context, app-quality, secret-scan, security-deps]
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 30
env:
APP_URL: ${{ needs.context.outputs.app_url }}
APP_IMAGE_NAME: ${{ env.APP_IMAGE_REPOSITORY }}:${{ github.ref_name }}-${{ github.sha }}-${{ github.run_id }}
APP_BRANCH_IMAGE_NAME: ${{ env.APP_IMAGE_REPOSITORY }}:${{ github.ref_name }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version-file: ${{ env.NODE_VERSION_FILE }}
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Restore and update image cache
env:
IMAGE_CACHE_NAMESPACE: ${{ github.ref_name }}
run: sh scripts/prepare-image-cache.sh
- name: Build app runtime image
run: |
docker build --pull \
--build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--build-arg VCS_REF="${{ github.sha }}" \
--build-arg APP_URL="$APP_URL" \
--build-arg APP_NAME="${APP_NAME:-Example Website}" \
--build-arg COMPANY_NAME="${COMPANY_NAME:-Example Company}" \
--build-arg CONTACT_EMAIL="${CONTACT_EMAIL:-info@example.com}" \
--build-arg CONTACT_PHONE="${CONTACT_PHONE:-+49 000 000000}" \
--build-arg CONTACT_ADDRESS="${CONTACT_ADDRESS:-Example Street 1, 12345 Example City}" \
--build-arg SOURCE_URL="${{ github.server_url }}/${{ github.repository }}" \
--build-arg SKIP_IMAGE_OPTIMIZATION=true \
--build-arg IMAGE_CACHE_NAMESPACE="${{ github.ref_name }}" \
-f Dockerfile \
-t "$APP_IMAGE_NAME" \
-t "$APP_BRANCH_IMAGE_NAME" \
.
- name: Report app runtime image vulnerabilities
run: sh scripts/trivy-scan.sh vuln-image "$APP_IMAGE_NAME"
- name: Block high and critical app runtime vulnerabilities
env:
TRIVY_SEVERITY: HIGH,CRITICAL
TRIVY_EXIT_CODE: 1
run: sh scripts/trivy-scan.sh vuln-image "$APP_IMAGE_NAME"
- name: Push app runtime image
env:
REGISTRY_USERNAME: ${{ secrets.PACKAGES_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
run: |
set -eu
if [ -z "${REGISTRY_USERNAME:-}" ] || [ -z "${REGISTRY_TOKEN:-}" ]; then
echo "Missing required secrets: PACKAGES_USERNAME and PACKAGES_TOKEN" >&2
exit 1
fi
docker_config="$(mktemp -d)"
cleanup() {
DOCKER_CONFIG="$docker_config" docker logout "$REGISTRY" >/dev/null 2>&1 || true
rm -rf "$docker_config"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
export DOCKER_CONFIG="$docker_config"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
docker push "$APP_IMAGE_NAME"
docker push "$APP_BRANCH_IMAGE_NAME"
deploy-dev:
if: github.event_name != 'schedule' && needs.changes.outputs.deploy == 'true' && github.ref == 'refs/heads/dev'
needs: [changes, context, app-quality, app-image, secret-scan, security-deps]
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 30
env:
CALENDAR_URL: ${{ secrets.CALENDAR_URL }}
PRIMARY_DOMAIN: ${{ needs.context.outputs.primary_domain }}
APP_URL: ${{ needs.context.outputs.app_url }}
CORS_ORIGIN: ${{ needs.context.outputs.cors_origin }}
COMPOSE_PROJECT_NAME: ${{ needs.context.outputs.deploy_stack_name }}
TRAEFIK_STACK_NAME: ${{ needs.context.outputs.deploy_stack_name }}
TRAEFIK_CERTRESOLVER: ${{ needs.context.outputs.traefik_certresolver }}
TRAEFIK_NETWORK_NAME: ${{ needs.context.outputs.traefik_network_name }}
DEPLOY_TARGET: dev
APP_IMAGE_NAME: ${{ env.APP_IMAGE_REPOSITORY }}:dev-${{ github.sha }}-${{ github.run_id }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
- name: Pull app runtime image
env:
REGISTRY_USERNAME: ${{ secrets.PACKAGES_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
run: |
set -eu
if [ -z "${REGISTRY_USERNAME:-}" ] || [ -z "${REGISTRY_TOKEN:-}" ]; then
echo "Missing required secrets: PACKAGES_USERNAME and PACKAGES_TOKEN" >&2
exit 1
fi
docker_config="$(mktemp -d)"
cleanup() {
DOCKER_CONFIG="$docker_config" docker logout "$REGISTRY" >/dev/null 2>&1 || true
rm -rf "$docker_config"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
export DOCKER_CONFIG="$docker_config"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
docker pull "$APP_IMAGE_NAME"
- name: Validate runtime configuration
run: |
if [ -z "${CALENDAR_URL:-}" ]; then
echo "Missing required secret: CALENDAR_URL" >&2
exit 1
fi
- name: Print deployment target
run: |
echo "Environment: ${{ needs.context.outputs.environment_name }}"
echo "Site host: $PRIMARY_DOMAIN"
echo "Compose project: $COMPOSE_PROJECT_NAME"
- name: Deploy and verify app
run: sh scripts/deploy-and-verify.sh
deploy-master:
if: github.event_name != 'schedule' && needs.changes.outputs.deploy == 'true' && github.ref == 'refs/heads/master'
needs: [changes, context, app-quality, app-image, secret-scan, security-deps]
runs-on: ${{ vars.PROD_RUNNER || vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 30
env:
CALENDAR_URL: ${{ secrets.CALENDAR_URL }}
PRIMARY_DOMAIN: ${{ needs.context.outputs.primary_domain }}
APP_URL: ${{ needs.context.outputs.app_url }}
CORS_ORIGIN: ${{ needs.context.outputs.cors_origin }}
COMPOSE_PROJECT_NAME: ${{ needs.context.outputs.deploy_stack_name }}
TRAEFIK_STACK_NAME: ${{ needs.context.outputs.deploy_stack_name }}
TRAEFIK_CERTRESOLVER: ${{ needs.context.outputs.traefik_certresolver }}
TRAEFIK_NETWORK_NAME: ${{ needs.context.outputs.traefik_network_name }}
DEPLOY_TARGET: master
APP_IMAGE_NAME: ${{ env.APP_IMAGE_REPOSITORY }}:master-${{ github.sha }}-${{ github.run_id }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
- name: Pull app runtime image
env:
REGISTRY_USERNAME: ${{ secrets.PACKAGES_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
run: |
set -eu
if [ -z "${REGISTRY_USERNAME:-}" ] || [ -z "${REGISTRY_TOKEN:-}" ]; then
echo "Missing required secrets: PACKAGES_USERNAME and PACKAGES_TOKEN" >&2
exit 1
fi
docker_config="$(mktemp -d)"
cleanup() {
DOCKER_CONFIG="$docker_config" docker logout "$REGISTRY" >/dev/null 2>&1 || true
rm -rf "$docker_config"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
export DOCKER_CONFIG="$docker_config"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
docker pull "$APP_IMAGE_NAME"
- name: Validate runtime configuration
run: |
if [ -z "${CALENDAR_URL:-}" ]; then
echo "Missing required secret: CALENDAR_URL" >&2
exit 1
fi
- name: Print deployment target
run: |
echo "Environment: ${{ needs.context.outputs.environment_name }}"
echo "Site host: $PRIMARY_DOMAIN"
echo "Compose project: $COMPOSE_PROJECT_NAME"
- name: Deploy and verify app
run: sh scripts/deploy-and-verify.sh
scheduled-rebuild-dev:
if: github.event_name == 'schedule'
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 45
env:
DEPLOY_TARGET: dev
CALENDAR_URL: ${{ secrets.CALENDAR_URL }}
PRIMARY_DOMAIN: ${{ vars.DEV_PRIMARY_DOMAIN }}
APP_URL: ${{ vars.DEV_APP_URL }}
CORS_ORIGIN: ${{ vars.DEV_CORS_ORIGIN }}
COMPOSE_PROJECT_NAME: ${{ vars.DEV_DEPLOY_STACK_NAME }}
TRAEFIK_STACK_NAME: ${{ vars.DEV_DEPLOY_STACK_NAME }}
TRAEFIK_CERTRESOLVER: ${{ secrets.DEV_TRAEFIK_CERTRESOLVER }}
TRAEFIK_NETWORK_NAME: ${{ vars.DEV_TRAEFIK_NETWORK_NAME }}
steps:
- name: Checkout dev branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: dev
lfs: true
- name: Scan repository for committed secrets
run: sh scripts/trivy-scan.sh secret-fs .
- name: Report app dependency vulnerabilities
run: sh scripts/trivy-scan.sh vuln-fs .
- name: Block high and critical app dependency vulnerabilities
env:
TRIVY_SEVERITY: HIGH,CRITICAL
TRIVY_EXIT_CODE: 1
run: sh scripts/trivy-scan.sh vuln-fs .
- name: Rebuild latest base image and deploy dev
env:
REGISTRY_USERNAME: ${{ secrets.PACKAGES_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
run: sh scripts/scheduled-rebuild-deploy.sh
scheduled-rebuild-master:
if: github.event_name == 'schedule'
runs-on: ${{ vars.PROD_RUNNER || vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 45
env:
DEPLOY_TARGET: master
CALENDAR_URL: ${{ secrets.CALENDAR_URL }}
PRIMARY_DOMAIN: ${{ vars.PROD_PRIMARY_DOMAIN }}
APP_URL: ${{ vars.PROD_APP_URL }}
CORS_ORIGIN: ${{ vars.PROD_CORS_ORIGIN }}
COMPOSE_PROJECT_NAME: ${{ vars.PROD_DEPLOY_STACK_NAME }}
TRAEFIK_STACK_NAME: ${{ vars.PROD_DEPLOY_STACK_NAME }}
TRAEFIK_CERTRESOLVER: ${{ secrets.PROD_TRAEFIK_CERTRESOLVER }}
TRAEFIK_NETWORK_NAME: ${{ vars.PROD_TRAEFIK_NETWORK_NAME }}
steps:
- name: Checkout master branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: master
lfs: true
- name: Scan repository for committed secrets
run: sh scripts/trivy-scan.sh secret-fs .
- name: Report app dependency vulnerabilities
run: sh scripts/trivy-scan.sh vuln-fs .
- name: Block high and critical app dependency vulnerabilities
env:
TRIVY_SEVERITY: HIGH,CRITICAL
TRIVY_EXIT_CODE: 1
run: sh scripts/trivy-scan.sh vuln-fs .
- name: Rebuild latest base image and deploy master
env:
REGISTRY_USERNAME: ${{ secrets.PACKAGES_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
run: sh scripts/scheduled-rebuild-deploy.sh
rollback-dev:
if: github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_target == 'dev' && github.ref == 'refs/heads/dev'
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 30
env:
DEPLOY_TARGET: dev
CALENDAR_URL: ${{ secrets.CALENDAR_URL }}
PRIMARY_DOMAIN: ${{ vars.DEV_PRIMARY_DOMAIN }}
APP_URL: ${{ vars.DEV_APP_URL }}
CORS_ORIGIN: ${{ vars.DEV_CORS_ORIGIN }}
COMPOSE_PROJECT_NAME: ${{ vars.DEV_DEPLOY_STACK_NAME }}
TRAEFIK_STACK_NAME: ${{ vars.DEV_DEPLOY_STACK_NAME }}
TRAEFIK_CERTRESOLVER: ${{ secrets.DEV_TRAEFIK_CERTRESOLVER }}
TRAEFIK_NETWORK_NAME: ${{ vars.DEV_TRAEFIK_NETWORK_NAME }}
APP_IMAGE_TAG: ${{ github.event.inputs.app_image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
- name: Deploy rollback and verify app
env:
REGISTRY_USERNAME: ${{ secrets.PACKAGES_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
run: sh scripts/manual-rollback-deploy.sh
rollback-master:
if: github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_target == 'master' && github.ref == 'refs/heads/master'
runs-on: ${{ vars.PROD_RUNNER || vars.CI_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 30
env:
DEPLOY_TARGET: master
CALENDAR_URL: ${{ secrets.CALENDAR_URL }}
PRIMARY_DOMAIN: ${{ vars.PROD_PRIMARY_DOMAIN }}
APP_URL: ${{ vars.PROD_APP_URL }}
CORS_ORIGIN: ${{ vars.PROD_CORS_ORIGIN }}
COMPOSE_PROJECT_NAME: ${{ vars.PROD_DEPLOY_STACK_NAME }}
TRAEFIK_STACK_NAME: ${{ vars.PROD_DEPLOY_STACK_NAME }}
TRAEFIK_CERTRESOLVER: ${{ secrets.PROD_TRAEFIK_CERTRESOLVER }}
TRAEFIK_NETWORK_NAME: ${{ vars.PROD_TRAEFIK_NETWORK_NAME }}
APP_IMAGE_TAG: ${{ github.event.inputs.app_image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
lfs: true
- name: Deploy rollback and verify app
env:
REGISTRY_USERNAME: ${{ secrets.PACKAGES_USERNAME }}
REGISTRY_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
run: sh scripts/manual-rollback-deploy.sh
+37
View File
@@ -0,0 +1,37 @@
# Dependencies
node_modules/
# Obsolete local build artifacts left from the pre-root migration
frontend/
backend/
# Build outputs
dist/
.next/
out/
tsconfig.tsbuildinfo
.cache/
# OS/editor noise
.DS_Store
.idea/
.vscode/
*.log
yarn.lock
pnpm-lock.yaml
# Coverage / temp
coverage/
tmp/
.coverage
# Env files (nur .env.example tracken)
.env
.env.*
!.env.example
.claude
.agents
.codex
.opencode
.playwright-mcp
.venv
+3
View File
@@ -0,0 +1,3 @@
# Media sources
Store original project images in the matching subdirectories. The build pipeline derives optimized public variants from these files. Do not add production media, personal data, or unlicensed assets to the starter template.
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
24
+33
View File
@@ -0,0 +1,33 @@
# Repository Notes
## Next.js
This repository uses Next.js 16 with breaking changes relative to earlier versions. Read the relevant guide in `node_modules/next/dist/docs/` before changing framework code and heed deprecation notices.
## Shape
- The Next.js application lives at the repository root and uses the App Router, React 19, TypeScript, and `output: "standalone"`.
- Pages, `GET /api/events`, and health routes run in one Node.js process.
- Calendar data is fetched at runtime from an external ICS source.
- TSX content modules live under `src/trip-reports/` and are registered in `src/trip-reports/index.ts`.
## Commands
- Install dependencies exactly as CI does: `npm ci`.
- Checks: `npm run lint`, `npm run typecheck`, `npm run test`, `npm run build`.
- E2E smoke test: `npm run test:e2e`.
- Dev server: set `CALENDAR_URL`, then run `npm run dev`.
## Configuration and assets
- `.env.example` documents safe example settings.
- `CALENDAR_URL` is required at runtime but not during `next build`.
- `APP_URL` controls canonical URLs, sitemap, robots, host checks, and the default CORS origin.
- Original image sources live in `.media-source/`; generated variants live under `public/images/`.
- Use `npm run content:new` for new TSX content modules.
## CI and deployment
- Workflows live in `.gitea/workflows/`.
- CI runs lint, Vitest, typecheck, build, E2E, and security scans.
- Deploy builds and scans a configurable container image, deploys with Docker Compose, verifies app/API/health routes, and supports rollback.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+69
View File
@@ -0,0 +1,69 @@
# syntax=docker/dockerfile:1.7
FROM node:24-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG APP_NAME="Example Website"
ARG APP_URL="https://example.com"
ARG COMPANY_NAME="Example Company"
ARG CONTACT_EMAIL="info@example.com"
ARG CONTACT_PHONE="+49 000 000000"
ARG CONTACT_ADDRESS="Example Street 1, 12345 Example City"
ARG SKIP_IMAGE_OPTIMIZATION=false
ARG IMAGE_CACHE_NAMESPACE=shared
ENV APP_NAME=${APP_NAME} \
APP_URL=${APP_URL} \
COMPANY_NAME=${COMPANY_NAME} \
CONTACT_EMAIL=${CONTACT_EMAIL} \
CONTACT_PHONE=${CONTACT_PHONE} \
CONTACT_ADDRESS=${CONTACT_ADDRESS}
ENV NEXT_TELEMETRY_DISABLED=1
RUN --mount=type=cache,id=website-starter-image-assets-${IMAGE_CACHE_NAMESPACE},target=/image-cache,sharing=locked \
IMAGE_CACHE_ROOT=/image-cache \
SKIP_IMAGE_OPTIMIZATION=${SKIP_IMAGE_OPTIMIZATION} \
npm run build
FROM node:24-slim AS runtime
# Apply Debian security updates that may not yet be included in the Node base
# image. Clear package metadata in the same layer so it is not part of the
# runtime image.
# The server starts with Node directly and does not need npm at runtime. Removing
# npm also keeps its build-only dependency tree out of the production image.
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm \
/usr/local/bin/npm \
/usr/local/bin/npx
ARG BUILD_DATE=""
ARG VCS_REF=""
ARG SOURCE_URL="https://example.com/repository"
LABEL org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.revision="$VCS_REF" \
org.opencontainers.image.source="$SOURCE_URL" \
org.opencontainers.image.title="website-starter"
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
HOSTNAME=0.0.0.0 \
PORT=3000
WORKDIR /app
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
COPY --from=build --chown=node:node /app/public ./public
USER node
EXPOSE 3000
CMD ["node", "server.js"]
+143
View File
@@ -0,0 +1,143 @@
# Next.js Website Starter
Neutrale, wiederverwendbare technische Basis für eine neue Website. Das Repository enthält eine produktionsfähige Next.js-Anwendung mit Kalender-API, Bildpipeline, Tests, Containerisierung sowie CI/CD, aber bewusst keine fachlichen Inhalte oder produktiven Daten.
## Technologie-Stack
- Node.js 24 und npm
- Next.js 16 App Router mit React 19 und TypeScript
- Standalone-Serverausgabe für Docker
- ICS-Kalender mit `ical.js`, `rrule` und Temporal
- Zod für Runtime-Konfiguration
- Vitest sowie ein produktionsnaher E2E-Smoke-Test
- ESLint, Husky und lint-staged
- Sharp für responsive Bildvarianten und Metadatenbereinigung
- Docker Compose, Traefik und Trivy
- Gitea-Actions-kompatible Workflows unter `.gitea/workflows/`
Eine Datenbank und eine Authentifizierung sind nicht Bestandteil dieser Vorlage, weil das technische Ausgangsprojekt keine solchen Systeme verwendet.
## Struktur
- `src/app/`: Seiten, Metadaten-, API- und Health-Routen
- `src/components/`: wiederverwendbare Server- und Client-Komponenten
- `src/server/calendar/`: ICS-Abruf, Parser, Cache, Fehlerbehandlung und Readiness
- `src/trip-reports/`: registrierbare TSX-Beitragsmodule; im Starter leer
- `src/data/`: neutrale Datenregistries für Downloads, Galerien und Inhaltskarten
- `.media-source/`: Originalbilder für die Bildpipeline
- `public/`: generierte Bilder, lokale Schriften und öffentliche Dateien
- `scripts/`: Build-, Medien-, E2E-, Security- und Deploy-Hilfen
- `.gitea/workflows/`: CI, Deployment, Rollback und geplante Rebuilds
Die vorhandenen deutschsprachigen Routen bleiben aus Kompatibilitätsgründen erhalten: `/`, `/ueber-uns`, `/informationen`, `/kalender`, `/fahrten`, `/fahrten/[slug]`, `/bilder`, `/impressum` und `/datenschutz`.
## Lokale Installation
Voraussetzungen sind Node.js gemäß `.node-version`, npm und für Container-Workflows Docker.
```bash
npm ci
cp .env.example .env.local
npm run dev
```
Die Anwendung ist anschließend standardmäßig unter `http://localhost:3000` erreichbar. Für echte Kalenderdaten muss `CALENDAR_URL` auf eine erreichbare HTTPS-ICS-Datei zeigen.
## Environment-Variablen
Alle Werte in `.env.example` sind ungefährliche Beispiele.
| Variable | Zweck |
| --- | --- |
| `APP_NAME` | Öffentlicher Name der Website |
| `APP_URL` | Kanonische Basis-URL für SEO, Sitemap, Hostprüfung und CORS-Default |
| `COMPANY_NAME` | Betreiber-/Organisationsname |
| `CONTACT_EMAIL` | Öffentliche Kontaktadresse |
| `CONTACT_PHONE` | Öffentliche Telefonnummer |
| `CONTACT_ADDRESS` | Öffentliche Anschrift |
| `PRIMARY_DOMAIN` | Hostname für Traefik |
| `CALENDAR_URL` | HTTPS-URL des ICS-Kalenders; zur Laufzeit erforderlich |
| `CORS_ORIGIN` | Kommagetrennte erlaubte Origins; Standard ist `APP_URL` |
| `TRUSTED_PROXIES` | JSON-Liste oder CSV vertrauenswürdiger Proxy-Netze |
| `CALENDAR_CACHE_DURATION_MS` | Cache-Dauer für Kalenderdaten |
| `CALENDAR_FETCH_TIMEOUT_MS` | Timeout für den ICS-Abruf |
| `DEFAULT_CALENDAR_TIMEZONE` | IANA-Zeitzone für schwebende ICS-Zeitangaben |
| `MAX_RECURRENCE_WINDOW_MONTHS` | Maximales Vorschaufenster wiederkehrender Termine |
| `MAX_ICS_BYTES` | Maximale Größe der Kalenderdatei |
| `MAX_OCCURRENCES_PER_EVENT` | Obergrenze je wiederkehrendem Termin |
| `ROUTINE_EVENT_TITLES` | Titel regulärer Termine, die nicht hervorgehoben werden |
`CALENDAR_URL` ist ein Runtime-Secret und wird nicht als Docker-Build-Argument verwendet. Öffentliche Identitätswerte werden beim Container-Build gesetzt, da Next.js Metadaten statisch erzeugen kann, und zusätzlich zur Laufzeit an den Container übergeben.
## Befehle
```bash
npm run dev
npm run lint
npm run typecheck
npm run test
npm run build
npm run test:e2e
```
`npm run build` bereinigt öffentliche Dateien, erzeugt beziehungsweise entfernt Bildvarianten, entfernt Bildmetadaten, prüft PDF-Metadaten und führt `next build` aus. Der E2E-Test baut die Standalone-Ausgabe, startet eine lokale ICS-Fixture und prüft Seiten, API, ETag sowie Health-Routen.
Mit `npm run content:new` kann ein neues TSX-Beitragsmodul samt optionalen Originalbildern angelegt werden.
## Bilder und Dateien
Originalbilder gehören in die Unterverzeichnisse von `.media-source/`. `npm run images` erzeugt responsive JPEG-, WebP- und AVIF-Varianten in `public/images/`; `npm run images:prune` entfernt Varianten ohne Quelle. Neue Alt-Texte werden projektspezifisch in der Medienkonfiguration oder im erzeugten Manifest gepflegt.
Downloads werden in `public/downloads/` abgelegt und in `src/data/downloads.ts` registriert. Keine personenbezogenen, produktiven oder vertraulichen Dateien committen.
## Docker
Für einen lokalen Start über den vorhandenen Traefik-Stack:
```bash
CALENDAR_URL=https://example.com/calendar.ics docker compose up --build
```
Das externe Netzwerk heißt standardmäßig `traefik-external` und kann mit `TRAEFIK_NETWORK_NAME` geändert werden. Für einen isolierten Test ohne vorhandenes Traefik-Netz kann die Compose-Datei projektspezifisch überschrieben werden.
Das Runtime-Image läuft ohne Root-Rechte, mit read-only Dateisystem, entfernten Linux-Capabilities, Healthcheck und Ressourcenlimits.
## CI/CD und Deployment
Die Workflows bleiben vollständig erhalten:
- `.gitea/workflows/ci.yml` führt Lint, Tests, Typecheck, Build, E2E sowie Secret- und Schwachstellenscans aus.
- `.gitea/workflows/deploy.yml` baut und scannt Images, deployt `dev` und `master`, prüft die laufende Anwendung, unterstützt Rollbacks und führt wöchentliche Rebuilds aus aktuellen Basis-Images aus.
CI ist für den lokalen Start nicht technisch erforderlich. Für gemeinsame Branches und Deployments sollte sie aktiv bleiben, weil sie reproduzierbar Build, Tests, Secret-Scans und Container prüft. Auf einem warmen Runner ist für die reine App-Prüfung typischerweise mit etwa 25 Minuten zu rechnen; ein kalter Runner mit LFS-, Container- und Trivy-Downloads kann etwa 515 Minuten benötigen. Die Security-Jobs laufen parallel. Die tatsächliche Dauer hängt von Runner, Cache, Medienumfang und Netzwerk ab; die Job-Timeouts liegen je nach Aufgabe bei 545 Minuten.
Vor dem ersten Deployment sind mindestens folgende Repository-Variablen zu setzen:
- `CI_RUNNER`, optional `PROD_RUNNER`
- `CONTAINER_REGISTRY`
- `APP_IMAGE_REPOSITORY`
- `APP_NAME`, `COMPANY_NAME`, `CONTACT_EMAIL`, `CONTACT_PHONE`, `CONTACT_ADDRESS`
- `DEV_PRIMARY_DOMAIN`, `PROD_PRIMARY_DOMAIN`
- optional `DEV_APP_URL`, `PROD_APP_URL`, `DEV_CORS_ORIGIN`, `PROD_CORS_ORIGIN`
- optional Stack- und Netzwerkwerte `*_DEPLOY_STACK_NAME`, `*_TRAEFIK_NETWORK_NAME`
Benötigte Secrets:
- `CALENDAR_URL`
- `PACKAGES_USERNAME`, `PACKAGES_TOKEN`
- `DEV_TRAEFIK_CERTRESOLVER`, `PROD_TRAEFIK_CERTRESOLVER`
Runner benötigen Docker, Zugriff auf das Zielnetzwerk und bei produktiven Deployments Zugriff auf den Zielhost.
## Für ein neues Projekt anpassen
1. Alle Werte aus `.env.example` für die neue Installation festlegen.
2. Texte und Navigation in `src/app/` und `src/components/Header.tsx` ersetzen.
3. Rechtstexte fachlich und rechtlich erstellen lassen.
4. Neues Logo, Farbschema, Schriften und Medien einpflegen.
5. Kalenderquelle und Titel regulärer Termine konfigurieren.
6. Beitrags-, Download- und Galerie-Registries befüllen.
7. Registry, Runner, Domains, Traefik und Secrets in CI/CD konfigurieren.
8. Alle Qualitätsprüfungen und den finalen Secret-Scan ausführen.
Die Beispielwerte sind nicht für eine produktive Veröffentlichung bestimmt.
+75
View File
@@ -0,0 +1,75 @@
services:
app:
image: ${APP_IMAGE_NAME:-website-starter:local}
build:
context: .
args:
APP_NAME: ${APP_NAME:-Example Website}
APP_URL: ${APP_URL:-https://example.com}
COMPANY_NAME: ${COMPANY_NAME:-Example Company}
CONTACT_EMAIL: ${CONTACT_EMAIL:-info@example.com}
CONTACT_PHONE: ${CONTACT_PHONE:-+49 000 000000}
CONTACT_ADDRESS: ${CONTACT_ADDRESS:-Example Street 1, 12345 Example City}
environment:
CALENDAR_URL: ${CALENDAR_URL}
APP_NAME: ${APP_NAME:-Example Website}
APP_URL: ${APP_URL:-https://example.com}
COMPANY_NAME: ${COMPANY_NAME:-Example Company}
CONTACT_EMAIL: ${CONTACT_EMAIL:-info@example.com}
CONTACT_PHONE: ${CONTACT_PHONE:-+49 000 000000}
CONTACT_ADDRESS: ${CONTACT_ADDRESS:-Example Street 1, 12345 Example City}
CORS_ORIGIN: ${CORS_ORIGIN:-}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-["172.16.0.0/12"]}
HOSTNAME: 0.0.0.0
PORT: 3000
expose:
- "3000"
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:size=64m,mode=1777
networks:
- traefik-external
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=${TRAEFIK_NETWORK_NAME:-traefik-external}
- traefik.http.routers.${TRAEFIK_STACK_NAME:-website-starter}.rule=Host(`${PRIMARY_DOMAIN:-example.com}`)
- traefik.http.routers.${TRAEFIK_STACK_NAME:-website-starter}.entrypoints=websecure
- traefik.http.routers.${TRAEFIK_STACK_NAME:-website-starter}.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}
- traefik.http.routers.${TRAEFIK_STACK_NAME:-website-starter}.service=${TRAEFIK_STACK_NAME:-website-starter}
- traefik.http.routers.${TRAEFIK_STACK_NAME:-website-starter}-api.rule=Host(`${PRIMARY_DOMAIN:-example.com}`) && PathPrefix(`/api`)
- traefik.http.routers.${TRAEFIK_STACK_NAME:-website-starter}-api.entrypoints=websecure
- traefik.http.routers.${TRAEFIK_STACK_NAME:-website-starter}-api.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}
- traefik.http.routers.${TRAEFIK_STACK_NAME:-website-starter}-api.priority=100
- traefik.http.routers.${TRAEFIK_STACK_NAME:-website-starter}-api.service=${TRAEFIK_STACK_NAME:-website-starter}
- traefik.http.routers.${TRAEFIK_STACK_NAME:-website-starter}-api.middlewares=${TRAEFIK_STACK_NAME:-website-starter}-api-ratelimit
- traefik.http.middlewares.${TRAEFIK_STACK_NAME:-website-starter}-api-ratelimit.ratelimit.average=60
- traefik.http.middlewares.${TRAEFIK_STACK_NAME:-website-starter}-api-ratelimit.ratelimit.period=1m
- traefik.http.middlewares.${TRAEFIK_STACK_NAME:-website-starter}-api-ratelimit.ratelimit.burst=60
- traefik.http.services.${TRAEFIK_STACK_NAME:-website-starter}.loadbalancer.server.port=3000
- traefik.http.services.${TRAEFIK_STACK_NAME:-website-starter}.loadbalancer.passHostHeader=true
logging:
driver: json-file
options:
max-size: "10m"
max-file: "4"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health/live').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 512M
cpus: "0.75"
networks:
traefik-external:
external: true
name: ${TRAEFIK_NETWORK_NAME:-traefik-external}
+35
View File
@@ -0,0 +1,35 @@
import http from "node:http";
const port = Number(process.env.FIXTURE_PORT ?? 3101);
const start = new Date(Date.now() + 7 * 24 * 60 * 60 * 1_000);
const end = new Date(start.getTime() + 2 * 60 * 60 * 1_000);
function icsDate(date) {
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
const calendar = `BEGIN:VCALENDAR\r
VERSION:2.0\r
PRODID:-//Website Starter E2E//DE\r
BEGIN:VEVENT\r
UID:e2e-special-event\r
SUMMARY:E2E Beispieltermin\r
LOCATION:Beispielort\r
DTSTART:${icsDate(start)}\r
DTEND:${icsDate(end)}\r
END:VEVENT\r
END:VCALENDAR\r
`;
const server = http.createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/calendar; charset=utf-8" });
response.end(calendar);
});
server.listen(port, "127.0.0.1", () => {
console.log(`ICS fixture listening on http://127.0.0.1:${port}`);
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => server.close(() => process.exit(0)));
}
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
const baseUrl = process.env.E2E_BASE_URL ?? "http://127.0.0.1:3100";
async function readPage(pathname) {
const response = await fetch(`${baseUrl}${pathname}`);
assert.equal(response.status, 200);
return response.text();
}
describe("Next.js production server", () => {
it("renders the homepage with the main hero copy", async () => {
const html = await readPage("/");
assert.match(html, /Bereit für Ihre neue Website/);
assert.match(html, /E2E Beispieltermin/);
});
it("server-renders current calendar content", async () => {
const html = await readPage("/kalender/");
assert.match(html, /E2E Beispieltermin/);
assert.match(html, /Beispielort/);
});
it("renders the information page without client-side placeholders", async () => {
const html = await readPage("/informationen/");
assert.match(html, /Informationsbereiche/);
assert.doesNotMatch(html, /href="#"/);
});
it("serves the calendar API with cache validation", async () => {
const response = await fetch(`${baseUrl}/api/events`, { redirect: "manual" });
assert.equal(response.status, 200);
const events = await response.json();
assert.equal(events[0].title, "E2E Beispieltermin");
assert.match(response.headers.get("cache-control") ?? "", /stale-while-revalidate/);
const etag = response.headers.get("etag");
assert.ok(etag);
const cached = await fetch(`${baseUrl}/api/events`, {
headers: { "if-none-match": etag },
});
assert.equal(cached.status, 304);
});
it("reports liveness and calendar readiness", async () => {
const live = await fetch(`${baseUrl}/health/live`);
assert.equal(live.status, 200);
assert.deepEqual(await live.json(), { status: "ok" });
const ready = await fetch(`${baseUrl}/health/ready`);
assert.equal(ready.status, 200);
assert.deepEqual(await ready.json(), { status: "ok", reason: null });
});
});
+40
View File
@@ -0,0 +1,40 @@
import js from '@eslint/js'
import { defineConfig, globalIgnores } from 'eslint/config'
import nextVitals from 'eslint-config-next/core-web-vitals'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default defineConfig([
globalIgnores(['.astro', '.next', 'out', 'frontend/**', 'backend/**']),
...nextVitals,
{
files: ['eslint.config.js', 'scripts/**/*.mjs'],
extends: [js.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
],
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
'@next/next/no-img-element': 'off',
},
},
])
Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

@@ -0,0 +1,685 @@
# Codex-Masterprompt: Neue Website der Gemeinde Goldebek
Du arbeitest als Senior Full-Stack-Engineer, UX-Designer und Accessibility-Engineer. Deine Aufgabe ist, auf Basis dieses Repositories eine neue offizielle Website für die **Gemeinde Goldebek, Schleswig-Holstein** zu erstellen.
Die Website ersetzt inhaltlich die bisherige Jimdo-Seite. Sie soll **nicht** deren Gestaltung kopieren, sondern Inhalte und Funktionen sauber migrieren, aktualisieren und in eine moderne, langfristig pflegbare Struktur überführen.
## 0. Arbeitsweise
1. Untersuche zuerst das vorhandene Repository:
- Framework, Package Manager, Lockfile, Buildsystem, bestehende Komponenten, CI/CD und Deployment.
- Wenn bereits ein funktionierender Stack vorhanden ist, bleibe darin und passe dich ihm an.
- Wenn das Repository leer bzw. nur ein Grundgerüst ist, verwende eine aktuelle stabile Version von **Next.js mit App Router, TypeScript und Tailwind CSS**.
2. Entferne keine funktionierenden CI/CD-Pipelines oder Deployment-Konfigurationen.
3. Arbeite end-to-end: Informationsarchitektur, Datenmodell, Komponenten, Seiten, responsive Darstellung, Accessibility, SEO, Tests und Dokumentation.
4. Erfinde **keine** Telefonnummern, Namen, Termine, Gebühren, Satzungen, Öffnungszeiten, Rechtstexte oder Projektstände.
5. Alle dynamischen Daten müssen eine Quelle und `lastVerified` besitzen.
6. Wenn sich Quellen widersprechen, verwende die in `goldebek_data_2026-09-18.json` dokumentierte Entscheidung. Offene Punkte bleiben offen und werden nicht stillschweigend „gelöst“.
7. Übernimm keine privaten Gastgeberadressen aus alten Veranstaltungskalendern.
8. Übernimm keine fremden Fotos ohne geklärte Nutzungsrechte.
9. Verwende keine Tracker, externen Marketing-Skripte oder unnötige Third-Party-Einbindungen.
10. Liefere am Ende eine vollständig bau- und testbare Anwendung.
## 1. Verbindliche Datenbasis
Im Projekt liegt die Datei:
`goldebek_data_2026-09-18.json`
Behandle diese Datei als primären Content-Seed. Zusätzlich liegt eine ausführliche Recherche in:
`goldebek_recherche_2026-09-18.md`
Für Fakten mit `status: "needs-verification"` darfst du im öffentlichen Produktions-UI **keine ungeprüfte Behauptung** erzeugen. Solche Inhalte entweder:
- ausblenden,
- nur als redaktionellen TODO in einer Entwickler-/Preview-Ansicht markieren,
- oder auf die offizielle externe Quelle verlinken.
## 2. Zielbild
Die Website soll sich anfühlen wie ein **hochwertiges, modernes norddeutsches Gemeindeportal**:
- vertrauenswürdig
- unaufgeregt
- lokal
- freundlich
- offiziell
- sehr gut auf Mobilgeräten
- schnell
- barrierearm/barrierefrei
- redaktionell leicht pflegbar
Nicht erwünscht:
- generische „KI“-Optik
- übertriebene Gradients
- Glasmorphismus
- große animierte 3D-Effekte
- touristische Stockfoto-Ästhetik
- unnötige Carousels
- aggressive Animationen
- Marketing-Sprech
## 3. Visuelles System
Orientiere die Akzentfarben am Gemeindewappen:
- tiefes Blau
- natürliches Grün
- warmes Gold
- Weiß / Off-White / sehr helle neutrale Flächen
Wichtig:
- Das Wappen selbst niemals stilisieren, umfärben, nachzeichnen oder als dekoratives Pattern verfremden.
- Verwende nur eine von der Gemeinde freigegebene offizielle Wappengrafik.
- Falls aktuell keine freigegebene Datei im Repo liegt, nutze einen klar markierten Asset-Platzhalter im Code und dokumentiere ihn in `CONTENT_CHECKLIST.md`.
Visuelle Idee:
- ruhige horizontale oder leicht wellige Linien können dezent den Goldebeker Mühlenstrom zitieren
- moderne serifenlose Typografie
- klare Raster
- großzügiger Weißraum
- robuste Card-Komponenten
- dezente Schatten
- deutlich sichtbare Fokuszustände
- keine Informationen ausschließlich über Farbe vermitteln
Nutze möglichst Systemfonts oder lokal eingebundene freigegebene Fonts. Keine externen Google-Font-Aufrufe.
## 4. Informationsarchitektur
Erstelle folgende Hauptnavigation:
### Startseite
### Aktuelles
### Termine
### Gemeinde
- Über Goldebek
- Zahlen & Fakten
- Geschichte
- Wappen
- Dorfchronik
### Leben & Gemeinschaft
- Dörpshuus
- Spielplatz
- Vereine
- Freiwillige Feuerwehr
- Kulturausschuss
- Senioren
- Alltagshilfe Osterdörfer
### Politik & Verwaltung
- Bürgermeister & Gemeindevertretung
- Sitzungen & Protokolle
- Satzungen & Steuern
- Bauleitplanung
- Projekte & Entwicklung
### Service
- Ansprechpartner
- Mobilität
- Fahrbücherei
- Abfall & Abwasser
- Bildung / Kita / Schule
- Wichtige Links
### Kontakt
Footer:
- Impressum
- Datenschutz
- Erklärung zur Barrierefreiheit
- Amt Mittleres Nordfriesland
- Quellen-/Stand-Hinweis, wo sinnvoll
## 5. Startseite
### Hero
Eyebrow:
`Gemeinde Goldebek · Nordfriesland`
H1:
`Moin in Goldebek`
Einleitung:
`Goldebek liegt im nordöstlichen Nordfriesland zwischen Bredstedt und Wanderup. Landwirtschaft, Ehrenamt, Vereine und gemeinschaftliche Projekte prägen das Dorf.`
CTA:
- `Aktuelle Termine`
- `Goldebek entdecken`
### Faktenleiste
- 381 Einwohner Stand 31.03.2026
- 10,18 km²
- Kreis Nordfriesland
- PLZ 25862
### Danach
1. **Aktuelles** maximal drei aktuelle Meldungen
2. **Nächste Termine** die nächsten 35 bestätigten Termine
3. **Schnellzugriffe**
- Dörpshuus
- Gemeindevertretung
- Alltagshilfe
- Fahrbücherei
4. **Goldebek entdecken**
- Geschichte
- Mühlenstrom
- Dorfchronik
- Wappen
5. **Gemeinschaft**
- Vereine
- Feuerwehr
- Kulturausschuss
6. **Projekte & Entwicklung**
- Spielplatz
- Wärmeplanung
- Bauleitplanung
7. **Kontakt / Amt**
## 6. Inhalte, die bereits als verifiziert gelten
### Basisdaten
- Name: Goldebek
- Bundesland: Schleswig-Holstein
- Kreis: Nordfriesland
- Amt: Mittleres Nordfriesland
- AGS: 01054037
- PLZ: 25862
- Fläche: 10,18 km²
- Einwohner: 381 zum 31.03.2026
- Dichte: 37 Einwohner/km²
Siedlungsteile:
- Goldebek
- Heinsbek
- Kolonie
- Süderhuus
- Goldebekfeld
### Geschichte
- erste urkundliche Erwähnung: 1321
- Gemeinde seit 1934 selbstständig
- Mühle 1948 abgebrochen
- Namenswechsel Goldebeck → Goldebek mit Wirkung 01.11.1961
- Dörpshuus 2000 errichtet
- Dorfchronik Band 1: 1992
- Dorfchronik Band 2: April 2022
- Spielplatz am Langbarg: eröffnet 02.08.2026
### Bürgermeister
Peter Jessen
Am Mühlenstrom 14
25862 Goldebek
Telefon 04673/962229
Fax 04673/962688
### Gemeindevertretung
- Peter Jessen
- Hauke Jensen
- Volker Hansen
- Timo Jensen
- Catarina Tudsen
- Stefan Christiansen
- Rainer Bakker
- Finn Christiansen
- Nils Hörner
Achtung: Bei „Nils Hörner“ besteht eine Schreibweisen-Differenz zu einzelnen Sitzungsunterlagen. Im öffentlichen Seed die Amt-Schreibweise verwenden, aber in `CONTENT_CHECKLIST.md` als zu bestätigenden Punkt führen.
### Hebesätze / Hundesteuer
- Grundsteuer A: 595 v.H.
- Grundsteuer B: 517 v.H.
- Gewerbesteuer: 400 v.H.
- 1. Hund: 90 €/Jahr
- 2. Hund: 110 €/Jahr
- jeder weitere Hund: 110 €/Jahr
Mit Stand-Datum und Quelle darstellen.
### Dörpshuus
Adresse:
`Am Brodersberg 16, 25862 Goldebek`
Service-Telefon:
`0173 3826343`
Errichtet:
`2000`
Gebühren, zuletzt aus Ratsunterlagen 02.12.2025 bestätigt:
- Einwohner: 150 €
- Feuerwehr-Kameradschaftsraum: 60 €
- Auswärtige: 300 €
- Endreinigung: 70 €
- Fehlbenutzung Notausgang: 50 €
Kapazität laut Flyer April 2025:
- 80 Dörpshuus
- 20 Kameradschaftsraum
- maximal 100 insgesamt
Kaution NICHT veröffentlichen, solange der aktuelle Wert nicht bestätigt wurde.
### Spielplatz
- Name: Spielplatz am Langbarg
- eröffnet: 02.08.2026
- Projektvolumen: rund 145.000 €
- VR Bank Nord Stiftung: 10.000 € Spende
- Bürgerwindpark Veer Dörper: 500 € Spende
- Zweck: Treffpunkt für Kinder, Familien und Dorf
### Alltagshilfe Osterdörfer
Seit 01.01.2026 für:
- Goldebek
- Goldelund
- Joldelund
- Högel
- Kolkerheide
Beispiele:
- Einkäufe
- Fahrten zu Arztterminen
- kurze Kinderbetreuung
- kleine Alltagshilfen
Kein Ersatz für professionelle Pflege-, Handwerks-, Garten- oder Taxileistungen.
Kosten:
- 5 € je angefangener Stunde
- 0,50 €/km
Kontakt Goldebek:
Peter Jessen, 0151/70089673
### Mobilität
Aktuell verwendete ÖPNV-Linie:
`130`
Halte laut Betreiberseite:
- Goldebek Kreuzung
- Goldebek Dorfstraße
- Süderhuuser Straße 5
- Süderhuus
Keine Fahrzeiten in den Code kopieren. Link zur aktuellen Betreiberseite.
### Fahrbücherei
Haltestelle:
`Dorfstraße 12`
Zeit:
`15:5516:20`
Noch kommende Termine ab Recherche-Stand 18.09.2026:
- 09.10.2026
- 06.11.2026
- 04.12.2026
## 7. Events
Baue ein sauberes Event-System.
Erforderliche Eigenschaften:
```ts
type Event = {
id: string
slug: string
title: string
summary?: string
startDate: string
startTime?: string
endDate?: string
endTime?: string
timezone: "Europe/Berlin"
location?: string
organizer?: string
sourceUrl?: string
sourceName?: string
lastVerified: string
status: "verified" | "dynamic" | "needs-verification" | "archived"
privacyApproved?: boolean
}
```
Funktionen:
- kommende Termine
- Terminarchiv
- Detailseite
- ICS-Download pro Termin
- optional ein Gesamt-ICS-Feed
- schema.org/Event JSON-LD
- Datumsausgabe deutsch
- Zeitzone Europe/Berlin
- Wochentag immer aus dem Datum berechnen
- Vergangenes automatisch ins Archiv einordnen
- keine privaten Adressen ausgeben, wenn `privacyApproved !== true`
Seed die bestätigten Events aus `goldebek_data_2026-09-18.json`.
## 8. News
News-Datenmodell:
```ts
type NewsItem = {
id: string
slug: string
title: string
teaser: string
body: string
publishedAt: string
updatedAt?: string
image?: {
src: string
alt: string
copyright?: string
}
sourceUrl?: string
sourceName?: string
lastVerified: string
status: "published" | "draft" | "archived"
}
```
Lege als erste aktuelle Meldung den neuen Spielplatz an. Textlich nüchtern und lokal, nicht werblich überziehen.
## 9. Content-System
Ziel: keine Inhalte hart in React-Komponenten verstreuen.
Bevorzugt:
- `/content`
- `/data`
- JSON/YAML/MDX
- Schema-Validierung mit Zod
Beispielstruktur:
```text
content/
news/
pages/
events/
data/
municipality.json
council.json
services.json
clubs.json
sources.json
lib/
content/
dates/
validation/
```
Baue einen Validierungsschritt, z. B.:
`pnpm data:validate`
Prüfungen:
- doppelte Slugs
- ungültige ISO-Daten
- Event-Datum/Wochentag-Konsistenz
- fehlendes `lastVerified` bei dynamischen Daten
- fehlende Quelle bei politischen/administrativen Fakten
- abgelaufene `validUntil`-Einträge
- `needs-verification` darf nicht versehentlich als verifiziert angezeigt werden
- private Adressen nur mit Freigabeflag
## 10. Quellenkonzept
Pflege Quellen zentral in `data/sources.json`.
Jeder volatile oder verwaltungsbezogene Inhalt kann auf eine `sourceId` referenzieren.
Im öffentlichen UI:
- nicht jede Seite muss mit Quellen überladen sein
- bei Steuern, Fahrplänen, Verwaltung, Projekten und aktuellen Daten einen dezenten `Stand: …`-Hinweis und „Amtliche Quelle“ anbieten
- externe Links klar kennzeichnen
## 11. Bilder und Rechte
Die Recherche hat auf der Amt-Seite hochwertige Ortsbilder gefunden, die dort mit Fotografen-Copyright gekennzeichnet sind. Diese Bilder sind **nur Referenz**, nicht automatisch nutzbare Website-Assets.
Daher:
- keine Bilder von der Amt-Seite herunterladen oder kopieren
- keine Bild-URLs hotlinken
- im Projekt saubere Platzhalter/Asset-Slots vorsehen
- benötigte Motive in `CONTENT_CHECKLIST.md` aufführen:
- Luft-/Ortsansicht
- Dörpshuus
- Spielplatz
- Ortseingang/Wappenstein
- Landschaft/Mühlenstrom
- Vereinsleben
- Feuerwehr
- Streuobstwiese/Feste
Jedes Bildmodell soll `alt` und optional `copyright` enthalten.
## 12. Karte
Keine Google-Maps- oder externe OSM-Iframe-Einbindung standardmäßig laden.
Bevorzugt:
- kleine statische Lagekarte als eigenes Asset, wenn vorhanden, oder
- gut gestaltete Standortkarte mit Link `In OpenStreetMap öffnen`
So bleibt die Startseite ohne Third-Party-Requests datenschutzfreundlich.
## 13. Politik und Verwaltung
Die Website ist eine kommunale Informationsseite, keine politische Kampagnenseite.
Darstellung:
- neutral
- sachlich
- keine Rankings oder wertenden Beschreibungen
- keine parteipolitische Gestaltung
- Ratsmitglieder gleichartig darstellen
Sitzungen und Protokolle:
- eine übersichtliche Einstiegsseite
- möglichst auf die amtliche bzw. aktuelle Dokumentquelle verlinken
- keine veralteten Protokollkopien als „aktuell“ ausgeben
Satzungen:
- nur Titel und Link zur aktuellen Amt-Version
- keine alte PDF-Kopie im Repo pflegen, wenn das Amt die maßgebliche Fassung bereitstellt
Bauleitplanung:
- auf die aktuelle Seite des Amtes verlinken
- keine Statusaussage aus alten Ratsprotokollen ableiten
## 14. Barrierefreiheit
Ziel mindestens WCAG 2.2 AA und gute Praxis für kommunale Websites.
Pflicht:
- semantisches HTML
- Skip-Link
- vollständig tastaturbedienbar
- sichtbare Fokuszustände
- ausreichende Kontraste
- Form-Labels
- sinnvolle Überschriftenhierarchie
- `aria-*` nur dort, wo semantisches HTML nicht reicht
- `prefers-reduced-motion`
- Alt-Texte
- keine Auto-Play-Medien
- keine Informationen ausschließlich über Farbe
- Touch Targets ausreichend groß
- 200-%-Zoom ohne Funktionsverlust
- verständliche Linktexte
Erstelle eine Seite `Barrierefreiheit`, aber **erfinde keine rechtsverbindliche Erklärung**. Nutze einen klar markierten, nicht als final ausgegebenen Redaktionsplatzhalter.
## 15. Datenschutz
Standard:
- kein Google Analytics
- kein Meta Pixel
- keine Marketing-Cookies
- keine automatisch geladenen externen Karten/Videos
- keine Drittanbieter-Schriftarten
- kein Cookie-Banner nur „vorsichtshalber“
Falls später Analyse benötigt wird:
- Architektur für datenschutzfreundliche, ggf. selbst gehostete Lösung offen halten
- erst nach rechtlicher Freigabe aktivieren
Kontaktformular nur bauen, wenn ein sinnvoller Backend-/Mailweg und Spam-Schutz vorhanden sind. Sonst lieber gut gestaltete `mailto:`-/`tel:`-Kontakte.
## 16. SEO und strukturierte Daten
Implementieren:
- individuelle Titles/Descriptions
- Canonicals
- OpenGraph
- Sitemap
- robots.txt
- sprechende deutsche Slugs
- locale `de_DE`
- JSON-LD für Gemeinde/Organisation
- JSON-LD für Events
- Breadcrumbs
Keine Keyword-Spam-Texte.
## 17. Performance
Ziele:
- Lighthouse möglichst >95 in Performance/Accessibility/Best Practices/SEO
- sehr wenig Client-JavaScript
- Server Components / statische Seiten bevorzugen
- Bilder optimieren
- keine Layout Shifts
- keine unnötigen Animation-Libraries
- keine schweren Icon-Pakete, wenn wenige SVGs reichen
## 18. Suchfunktion
Wenn mit vertretbarem Aufwand möglich:
- kleine clientseitige Suche über Seiten, News, Termine und Vereine
- kein externer Suchdienst
- Suchindex beim Build generieren
Wenn es die Komplexität unnötig erhöht, sauber weglassen und dokumentieren.
## 19. Rechtliche Seiten
Erstelle Routen und Layout für:
- Impressum
- Datenschutz
- Barrierefreiheit
Aber:
- keine juristischen Inhalte erfinden
- im Entwicklungsstand klare Platzhalter
- in Produktionskonfiguration keine erfundenen Texte als final ausgeben
## 20. Migration alter URLs
Lege eine Redirect-Matrix an, soweit alte Pfade bekannt sind, z. B.:
```text
/die-gemeinde/ueber-uns/ -> /gemeinde/ueber-goldebek
/die-gemeinde/doerpshuus/ -> /leben/doerpshuus
/die-gemeinde/dorfchronik/ -> /gemeinde/dorfchronik
/die-gemeinde/kulturausschuss/ -> /leben/kulturausschuss
/termine/ -> /termine
/gemeindevertretung/ -> /politik/gemeindevertretung
/vereine/ -> /leben/vereine
```
Wenn das Hosting Redirects anders konfiguriert, passe die technische Umsetzung an den Stack an.
## 21. Tests und Qualitätschecks
Mindestens:
- TypeScript typecheck
- ESLint
- Produktions-Build
- Unit-Tests für Eventsortierung und Archivierung
- Unit-Test für Europe/Berlin-Datumslogik
- Unit-Test der Content-Schema-Validierung
- Link-Prüfung für interne Links
- Accessibility-Smoke-Test mit axe oder gleichwertig, wenn Stack geeignet
- keine Console-Errors
## 22. Dokumentation
Erstelle:
### `README.md`
- Setup
- lokale Entwicklung
- Build
- Deployment
- Content-Struktur
- neue News anlegen
- neuen Termin anlegen
- Ratsmitglieder aktualisieren
- Quelle/lastVerified pflegen
- Bild mit Copyright pflegen
- jährliche Updates
### `CONTENT_CHECKLIST.md`
Mindestens diese offenen Punkte:
- [ ] Heimatmuseum-Telefon bestätigen
- [ ] Schreibweise Nils Hörner/Höner bestätigen
- [ ] aktuelle Dörpshuus-Kaution bestätigen
- [ ] vollständige aktuelle Dörpshuus-Nutzungsbedingungen prüfen
- [ ] freigegebene Wappen-Datei bereitstellen
- [ ] Bildrechte / Originalbilder bereitstellen
- [ ] aktueller Abfall-Link
- [ ] aktueller Abwasser-Link
- [ ] B-Plan-5-/Baugrundstücksstatus bestätigen
- [ ] Windparkdaten nur bei gewünschter Veröffentlichung aktualisieren
- [ ] Rechtstexte bereitstellen/freigeben
- [ ] Fahrbücherei-Fahrplan für Folgejahr aktualisieren
- [ ] Veranstaltungskalender für Folgejahr aktualisieren
## 23. Wichtige Datenkonflikte niemals falsch übernehmen
1. **Einwohner:** 381 zum 31.03.2026 verwenden. Alte Zahl 350 verwerfen.
2. **Bus:** aktuelle Betreiberseite = Linie 130. Alte Angabe R125 nicht verwenden.
3. **Spielplatz:** seit 02.08.2026 eröffnet. Alte OEK-Aussage „kein Spielplatz“ ist überholt.
4. **Heimatmuseum:** Telefonnummer widersprüchlich. Nicht veröffentlichen.
5. **Nils Hörner/Höner:** Amt-Schreibweise verwenden und als Prüfpunkt dokumentieren.
6. **Dörpshuus-Kaution:** nicht veröffentlichen, bis bestätigt.
7. **Windpark:** keine statische Anlagenzahl wegen Repowering.
8. **Private Adventsadressen:** nicht ungeprüft migrieren.
## 24. Definition of Done
Die Aufgabe ist fertig, wenn:
- die neue Informationsarchitektur umgesetzt ist
- Startseite und alle Hauptbereiche existieren
- alle bestätigten Seed-Daten strukturiert vorliegen
- Events automatisch kommende/vergangene Termine unterscheiden
- Quellen- und Prüfstatus technisch abgebildet sind
- mobile und Desktopdarstellung hochwertig sind
- Wappen-/Bildrechte nicht verletzt werden
- keine ungeprüften Fakten erfunden wurden
- `typecheck`, `lint`, Tests und Produktions-Build erfolgreich laufen
- README und CONTENT_CHECKLIST vollständig sind
Arbeite jetzt das Repository vollständig durch und implementiere diese Website. Triff sinnvolle technische Detailentscheidungen selbstständig, ohne die inhaltlichen Sicherheitsregeln zu umgehen.
@@ -0,0 +1,822 @@
{
"meta": {
"researchDate": "2026-09-18",
"locale": "de-DE",
"timezone": "Europe/Berlin",
"purpose": "Seed-Daten für eine neue offizielle Website der Gemeinde Goldebek",
"rules": [
"Dynamische Daten nur mit lastVerified veröffentlichen.",
"Bei Konflikten haben amtliche und aktuellere Quellen Vorrang.",
"Keine fehlenden Daten erfinden.",
"Private Adressen aus Veranstaltungskalendern nicht ohne ausdrückliche Freigabe übernehmen.",
"Rechtstexte, Bildrechte und Wappennutzung vor Veröffentlichung durch die Gemeinde prüfen."
]
},
"municipality": {
"name": "Goldebek",
"state": "Schleswig-Holstein",
"district": "Nordfriesland",
"administration": "Amt Mittleres Nordfriesland",
"officialMunicipalityKey": "01054037",
"postalCode": "25862",
"areaKm2": 10.18,
"population": {
"value": 381,
"referenceDate": "2026-03-31",
"male": 194,
"female": 187,
"densityPerKm2": 37,
"sourceId": "statistikportal-goldebek"
},
"coordinatesApprox": {
"lat": 54.678611,
"lon": 9.144722,
"status": "approximate"
},
"settlementParts": [
"Goldebek",
"Heinsbek",
"Kolonie",
"Süderhuus",
"Goldebekfeld"
],
"locationSummary": "Goldebek liegt im nordöstlichen Teil des Kreises Nordfriesland an der Landesstraße 12 zwischen Bredstedt und Wanderup. Die Gemeinde liegt im Übergangsraum zur schleswigschen Geest und nahe der Kreisgrenze zu Schleswig-Flensburg.",
"landscapeAndWater": [
"Goldebeker Mühlenstrom",
"Linnau im nördlichen Gemeindegebiet bzw. Grenzbereich",
"ländlich und landwirtschaftlich geprägte Umgebung"
],
"nameMeaning": {
"text": "Nach der gemeindlichen Darstellung leitet sich der Name von 'Golde' im Sinn von unfruchtbarem/sandigem Boden und 'Bek' für Bach ab: sinngemäß 'Dorf am Bach auf unfruchtbarem Boden'.",
"sourceId": "amnf-goldebek"
}
},
"demographics": {
"referenceDate": "2024-12-31",
"population": 381,
"averageAge": 41.2,
"nationality": {
"german": 376,
"foreign": 5
},
"ageGroups": [
{
"range": "0-17",
"count": 85,
"percent": 22.3
},
{
"range": "18-24",
"count": 33,
"percent": 8.7
},
{
"range": "25-29",
"count": 18,
"percent": 4.7
},
{
"range": "30-49",
"count": 97,
"percent": 25.5
},
{
"range": "50-64",
"count": 81,
"percent": 21.3
},
{
"range": "65+",
"count": 67,
"percent": 17.6
}
],
"sourceId": "statistik-nord-goldebek"
},
"history": [
{
"year": 1321,
"title": "Erste urkundliche Erwähnung",
"text": "Goldebek wird erstmals 1321 erwähnt.",
"sourceId": "amnf-goldebek"
},
{
"date": "1934-04-01",
"title": "Selbstständige Gemeinde",
"text": "Goldebek wurde im Zuge der kommunalen Neuordnung 1934 selbstständige Gemeinde.",
"status": "verified-from-archive-source"
},
{
"year": 1948,
"title": "Mühle abgebrochen",
"text": "Die namens- und ortsbildprägende Mühle am Mühlenstrom wurde 1948 abgebrochen.",
"sourceId": "amnf-goldebek"
},
{
"date": "1961-11-01",
"title": "Namensänderung",
"text": "Der frühere Gemeindename Goldebeck wurde amtlich in Goldebek geändert.",
"status": "verified-from-official-statistical-publication"
},
{
"date": "1998-01-30",
"title": "Wappen",
"text": "Das Gemeindewappen wurde 1998 angenommen.",
"status": "verify-before-launch"
},
{
"year": 2000,
"title": "Dörpshuus",
"text": "Das Dörpshuus wurde 2000 mit erheblicher Eigenleistung aus der Dorfgemeinschaft errichtet.",
"sourceId": "legacy-doerpshuus"
},
{
"year": 2022,
"title": "Zweiter Band der Dorfchronik",
"text": "Der zweite Band der Dorfchronik erschien im April 2022. Der erste Band stammt aus dem Jahr 1992.",
"sourceId": "legacy-chronicle"
},
{
"date": "2026-08-02",
"title": "Neuer Spielplatz eröffnet",
"text": "Der neue Spielplatz am Langbarg wurde offiziell eröffnet.",
"sourceId": "playground-2026"
}
],
"coatOfArms": {
"blazon": "Von Blau und Grün durch einen goldenen Schrägwellenbalken erniedrigt geteilt. Oben eine silberne Windmühle mit hohem Sockelgeschoss, unten ein silberner Ochsenkopf.",
"meaning": [
"Blau und Grün stehen für Himmel und Landschaft.",
"Die Windmühle verweist auf Landwirtschaft und die frühere Mühle.",
"Der goldene Wellenbalken nimmt Bezug auf den Mühlenstrom und den Ortsnamen.",
"Der Ochsenkopf verweist auf die landwirtschaftliche Prägung."
],
"assetReference": "wappen-commons",
"usageRule": "Auf der Website nur die freigegebene offizielle Wappengrafik verwenden; nicht neu zeichnen, umfärben oder stilisieren."
},
"governance": {
"mayor": {
"name": "Peter Jessen",
"role": "Bürgermeister",
"publicAddress": "Am Mühlenstrom 14, 25862 Goldebek",
"phone": "04673/962229",
"fax": "04673/962688",
"sourceId": "amnf-goldebek",
"lastVerified": "2026-09-18"
},
"council": {
"members": [
"Peter Jessen",
"Hauke Jensen",
"Volker Hansen",
"Timo Jensen",
"Catarina Tudsen",
"Stefan Christiansen",
"Rainer Bakker",
"Finn Christiansen",
"Nils Hörner"
],
"sourceId": "amnf-goldebek",
"lastVerified": "2026-09-18",
"dataQualityNote": "In einzelnen lokalen Protokollen erscheint die Schreibweise 'Nils Höner'. Für die Website zunächst die aktuelle Amt-Seite mit 'Nils Hörner' verwenden und vor Livegang bestätigen."
},
"taxRates": {
"grundsteuerA": "595 v.H.",
"grundsteuerB": "517 v.H.",
"gewerbesteuer": "400 v.H.",
"dogTax": {
"firstDogEuroPerYear": 90,
"secondDogEuroPerYear": 110,
"eachAdditionalDogEuroPerYear": 110
},
"sourceId": "amnf-goldebek",
"lastVerified": "2026-09-18",
"publicationAdvice": "Als volatile Verwaltungsdaten mit Stand-Datum und Link zur amtlichen Quelle anzeigen."
},
"administrationContact": {
"name": "Amt Mittleres Nordfriesland",
"address": "Theodor-Storm-Straße 2, 25821 Bredstedt",
"phone": "04671/9192-0",
"fax": "04671/9192-93",
"email": "info@amnf.de",
"sourceId": "amnf-goldebek"
},
"statutes": [
"Hauptsatzung",
"Abwassergebührensatzung",
"Entschädigungssatzung",
"Erschließungsbeitragssatzung",
"Hundesteuersatzung",
"Feuerwehrgebührensatzung",
"Sondervermögen Feuerwehr",
"Satzung über Kostenerstattungsbeiträge gemäß BauGB",
"Wasseranschlusssatzung"
],
"publicationAdvice": "Satzungen und Bauleitplanung nicht als kopierte Dateien pflegen, sondern auf die jeweils gültige amtliche Quelle des Amtes verlinken."
},
"places": {
"doerpshuus": {
"name": "Dörpshuus Goldebek",
"address": "Am Brodersberg 16, 25862 Goldebek",
"built": 2000,
"summary": "Dorfgemeinschaftshaus für kulturelle und gemeinschaftliche Veranstaltungen; mit erheblicher Eigenleistung aus dem Dorf errichtet.",
"servicePhone": "0173 3826343",
"capacity": {
"doerpshuus": 80,
"fireBrigadeRoom": 20,
"total": 100,
"sourceStatus": "Flyer April 2025 vor Livegang nochmals prüfen"
},
"fees": {
"residentEuro": 150,
"fireBrigadeRoomEuro": 60,
"nonResidentEuro": 300,
"finalCleaningEuro": 70,
"misuseEmergencyExitEuro": 50,
"depositEuro": null,
"depositNote": "Im Flyer April 2025 standen 200 Euro Kaution; in der Ratsbeschluss-Zusammenfassung vom 02.12.2025 nicht erneut bestätigt. Vor Veröffentlichung prüfen."
},
"sourceId": "legacy-doerpshuus",
"lastVerifiedFees": "2025-12-02"
},
"playground": {
"name": "Spielplatz am Langbarg",
"opened": "2026-08-02",
"summary": "Neuer großer Spielplatz und Treffpunkt für Kinder, Familien und die Dorfgemeinschaft.",
"projectCostApproxEuro": 145000,
"fundingNotes": [
"VR Bank Nord Stiftung: 10.000 Euro Spende",
"Bürgerwindpark Veer Dörper: 500 Euro Spende"
],
"sourceId": "playground-2026"
},
"privateLocalMuseum": {
"name": "Privates Heimatmuseum",
"summary": "Sammlung von Haushalts- und landwirtschaftlichen Gegenständen aus etwa 1732 bis 1950.",
"contactPhone": null,
"verificationNeeded": "Amt-Seite nennt 04673-299, bisherige Gemeindeseite 04673-277. Keine Nummer veröffentlichen, bis die Gemeinde sie bestätigt.",
"sourceIds": [
"amnf-goldebek",
"legacy-about"
]
}
},
"community": {
"clubsAndGroups": [
{
"name": "TSV Goldebek",
"type": "Sportverein",
"url": "https://www.tsv-goldebek.de",
"sourceId": "tsv"
},
{
"name": "LandFrauenVerein Joldelund e.V.",
"type": "LandFrauen",
"url": "https://www.lfv-joldelund.de",
"coverage": "Joldelund, Goldelund, Högel, Goldebek und Kolkerheide",
"sourceId": "landfrauen"
},
{
"name": "Ringreiter- und Schützenverein Freie Bahn Goldebek von 1948",
"type": "Traditionsverein",
"summary": "Gegründet am 10.01.1948; pflegt unter anderem die Ringreitertradition.",
"sourceId": "legacy-clubs"
},
{
"name": "SoVD Goldebek-Goldelund-Joldelund",
"type": "Sozialverband",
"url": "https://www.sovd-nordfriesland.de",
"sourceId": "legacy-clubs"
},
{
"name": "Freiwillige Feuerwehr Goldebek",
"type": "Feuerwehr",
"summary": "Wichtiger Bestandteil des Gemeindelebens; Feuerwehrgerätehaus in Verbindung mit dem Dörpshuus.",
"contact": null,
"contactNote": "Keinen privaten Kontakt ohne aktuelle offizielle Freigabe veröffentlichen."
}
],
"cultureCommittee": {
"name": "Kulturausschuss Goldebek",
"chair": "Maik Tewes",
"phone": "0160 7697796",
"summary": "Plant und organisiert gemeinschaftliche Veranstaltungen und Aktionen in Goldebek.",
"sourceId": "legacy-culture",
"privacyRule": "Keine private Wohnanschrift übernehmen."
},
"recurringTraditions": [
{
"name": "Seniorennachmittag",
"summary": "Regelmäßiger Treff für Seniorinnen und Senioren im Dörpshuus, häufig am Monatsende.",
"rule": "Keine starre Wiederholungsregel programmieren; konkrete Termine aus dem Jahreskalender übernehmen."
},
{
"name": "Biikebrennen",
"typicalDate": "21.02.",
"typicalLocation": "Silleruper Weg",
"summary": "Traditionelles Biikebrennen mit Bewirtung; jährliche Details neu pflegen."
},
{
"name": "Lebendiger Adventskalender",
"summary": "Dezember-Aktion mit wechselnden Gastgebern. Private Gastgeberadressen erst nach ausdrücklicher Freigabe veröffentlichen."
},
{
"name": "Dorfflohmarkt",
"summary": "Wiederkehrende Dorfveranstaltung; konkrete Jahresdaten separat pflegen."
},
{
"name": "Streuobstwiesenfest",
"summary": "Wiederkehrende Veranstaltung; konkrete Jahresdaten separat pflegen."
},
{
"name": "Erntefest",
"summary": "Herbstliche Dorfveranstaltung im Dörpshuus; konkrete Jahresdaten separat pflegen."
}
]
},
"services": {
"mobility": {
"publicBus": {
"line": "130",
"operatorSourceId": "bus-130",
"directionSummary": "Verbindung im Korridor BredstedtJoldelundGoldebekLindewittGroßenwieheFlensburg.",
"stopsObserved": [
"Goldebek Kreuzung",
"Goldebek Dorfstraße",
"Süderhuuser Straße 5",
"Süderhuus"
],
"rule": "Keine Abfahrtszeiten hart codieren; direkt auf den aktuellen Betreiberfahrplan verlinken."
},
"communityBus": {
"summary": "Lokaler Gemeindebus für Joldelund und Goldebek laut bisheriger Gemeindeseite etwa alle 14 Tage donnerstags sowie nach Absprache.",
"contact": "Ernst Thomsen, 0174 9804582",
"status": "needs-annual-verification",
"sourceId": "legacy-events"
},
"cyclingMeetup": {
"summary": "Radfahrtreff laut bisheriger Gemeindeseite dienstags um 14:00 Uhr am Dörpshuus.",
"status": "needs-annual-verification",
"sourceId": "legacy-events"
}
},
"mobileLibrary": {
"stop": "Dorfstraße 12, Goldebek",
"time": "15:5516:20",
"remainingDates2026": [
"2026-10-09",
"2026-11-06",
"2026-12-04"
],
"sourceId": "fahrbuecherei-2026",
"rule": "Jahresfahrplan als austauschbare Datendatei pflegen."
},
"everydayHelp": {
"name": "Alltagshilfe Osterdörfer",
"since": "2026-01-01",
"coverage": [
"Goldebek",
"Goldelund",
"Joldelund",
"Högel",
"Kolkerheide"
],
"summary": "Niedrigschwellige Nachbarschaftshilfe, z. B. Einkäufe, Fahrten zu Arztterminen, kurze Kinderbetreuung und kleine Alltagshilfen. Kein Ersatz für professionelle Pflege-, Handwerks-, Garten- oder Taxileistungen.",
"fees": {
"euroPerStartedHour": 5,
"euroPerKm": 0.5
},
"goldebekContact": {
"name": "Peter Jessen",
"phone": "0151/70089673"
},
"sourceId": "alltagshilfe",
"lastVerified": "2026-09-18"
},
"education": {
"summary": "Goldebek selbst verfügt nach dem Ortsentwicklungskonzept nicht über eigene Kita oder Grundschule. Die nächstgelegenen Angebote liegen insbesondere in Joldelund.",
"recommendedLinks": [
"https://www.joldelund.de/seite/175858/bildung-in-joldelund.html",
"https://www.amnf.de/leben-erleben/bildung-familie/kindergaerten"
],
"status": "link-to-current-sources-rather-than-copy-contact-data"
}
},
"events": {
"timezone": "Europe/Berlin",
"upcomingSeedAsOf2026-09-18": [
{
"id": "2026-09-20-landfrauen-jubilaeum",
"date": "2026-09-20",
"time": "11:00",
"title": "70-jähriges Jubiläum LandFrauenVerein Joldelund e.V.",
"location": "Dörpshuus Goldebek",
"sourceId": "legacy-events",
"status": "public-calendar"
},
{
"id": "2026-09-28-seniorennachmittag",
"date": "2026-09-28",
"time": "14:00",
"title": "Seniorennachmittag",
"location": "Dörpshuus Goldebek",
"status": "public-calendar"
},
{
"id": "2026-10-04-erntedank",
"date": "2026-10-04",
"time": "11:00",
"title": "Erntedankgottesdienst",
"location": "Goldebek",
"status": "public-calendar"
},
{
"id": "2026-10-09-fahrbuecherei",
"date": "2026-10-09",
"time": "15:55",
"endTime": "16:20",
"title": "Fahrbücherei",
"location": "Dorfstraße 12",
"sourceId": "fahrbuecherei-2026",
"status": "official-schedule"
},
{
"id": "2026-10-17-erntefest",
"date": "2026-10-17",
"time": "19:30",
"title": "Erntefest",
"location": "Dörpshuus Goldebek",
"status": "public-calendar"
},
{
"id": "2026-10-26-seniorennachmittag",
"date": "2026-10-26",
"time": "14:00",
"title": "Seniorennachmittag",
"location": "Dörpshuus Goldebek",
"status": "public-calendar"
},
{
"id": "2026-11-06-fahrbuecherei",
"date": "2026-11-06",
"time": "15:55",
"endTime": "16:20",
"title": "Fahrbücherei",
"location": "Dorfstraße 12",
"sourceId": "fahrbuecherei-2026",
"status": "official-schedule"
},
{
"id": "2026-11-15-kranzniederlegung",
"date": "2026-11-15",
"time": "14:30",
"title": "Kranzniederlegung",
"location": "Ehrenmal Goldebek",
"status": "public-calendar"
},
{
"id": "2026-11-30-seniorennachmittag",
"date": "2026-11-30",
"time": "14:00",
"title": "Seniorennachmittag",
"location": "Dörpshuus Goldebek",
"status": "public-calendar"
},
{
"id": "2026-12-04-fahrbuecherei",
"date": "2026-12-04",
"time": "15:55",
"endTime": "16:20",
"title": "Fahrbücherei",
"location": "Dorfstraße 12",
"sourceId": "fahrbuecherei-2026",
"status": "official-schedule"
},
{
"id": "2026-12-06-nikolaus-ff",
"date": "2026-12-06",
"time": null,
"title": "Nikolaus kommt mit der Freiwilligen Feuerwehr",
"location": "Goldebek",
"status": "public-calendar"
},
{
"id": "2026-12-14-seniorennachmittag",
"date": "2026-12-14",
"time": "14:00",
"title": "Seniorennachmittag",
"location": "Dörpshuus Goldebek",
"status": "public-calendar"
}
],
"ingestionRules": [
"Vergangene Termine automatisch in ein Archiv verschieben.",
"Wochentag programmatisch aus dem ISO-Datum berechnen und widersprüchliche Angaben melden.",
"Termine mit Fragezeichen oder unbestätigten Angaben nicht als verifiziert veröffentlichen.",
"Private Gastgeberadressen nur mit privacyApproved=true ausgeben.",
"Kalender-Export als ICS anbieten.",
"Event-Markup nach schema.org/Event erzeugen."
]
},
"projects": [
{
"id": "spielplatz-langbarg",
"title": "Spielplatz am Langbarg",
"status": "completed",
"completed": "2026-08-02",
"summary": "Aus einem Bürgerwunsch seit 2022 entstand ein neuer Spielplatz; Baubeginn war im Juni 2026, Eröffnung am 02.08.2026.",
"sourceId": "playground-2026"
},
{
"id": "waermeplanung-osterdorfer",
"title": "Gemeinsame kommunale Wärmeplanung",
"status": "in-progress",
"summary": "Goldebek beteiligt sich gemeinsam mit Goldelund, Joldelund und Kolkerheide an einer kommunalen Wärmeplanung unter Koordination des Amtes Mittleres Nordfriesland.",
"target": "Prozess laut Ratsunterlagen voraussichtlich bis Juni 2028",
"lastVerified": "2026-04-21",
"publicationRule": "Fortschritt vor Veröffentlichung erneut prüfen."
},
{
"id": "windenergie",
"title": "Windenergie und Bürgerwindpark Veer Dörper",
"status": "dynamic",
"summary": "Goldebek ist Teil des Bürgerwindparks Veer Dörper. In der Region laufen Planungen und Repowering-Projekte.",
"sourceId": "veer-doerper",
"publicationRule": "Keine aktuelle Zahl von Anlagen oder Leistung ohne erneute Prüfung nennen."
},
{
"id": "baugebiet-bplan5",
"title": "Wohnbauentwicklung / Bebauungsplan Nr. 5",
"status": "needs-current-verification",
"summary": "In Ratsunterlagen 2025/2026 werden Erschließung und Vergabekriterien für Baugrundstücke behandelt.",
"publicationRule": "Nur mit aktuellem Stand des Amtes veröffentlichen; keine Verfügbarkeit von Grundstücken aus alten Protokollen ableiten."
}
],
"contentIdeas": {
"homepageHero": {
"eyebrow": "Gemeinde Goldebek · Nordfriesland",
"headline": "Moin in Goldebek",
"body": "Goldebek liegt im nordöstlichen Nordfriesland zwischen Bredstedt und Wanderup. Landwirtschaft, Ehrenamt, Vereine und gemeinschaftliche Projekte prägen das Dorf.",
"primaryCta": "Aktuelle Termine",
"secondaryCta": "Goldebek entdecken"
},
"quickFacts": [
{
"label": "Einwohner",
"value": "381",
"note": "Stand 31.03.2026"
},
{
"label": "Fläche",
"value": "10,18 km²"
},
{
"label": "Kreis",
"value": "Nordfriesland"
},
{
"label": "PLZ",
"value": "25862"
}
],
"suggestedHomepageSections": [
"Aktuelles",
"Nächste Termine",
"Schnellzugriffe: Dörpshuus, Gemeindevertretung, Alltagshilfe, Fahrbücherei",
"Goldebek in Zahlen",
"Gemeinschaft und Vereine",
"Goldebek entdecken: Geschichte, Mühlenstrom, Dorfchronik",
"Projekte und Entwicklung",
"Kontakt und Amt Mittleres Nordfriesland"
]
},
"dataQuality": [
{
"severity": "high",
"topic": "Einwohnerzahl",
"issue": "Die Amt-Seite nennt im Fließtext 350 Einwohner, das offizielle Statistikportal 381 zum 31.03.2026.",
"decision": "381 mit Stichtag verwenden; 350 nicht übernehmen."
},
{
"severity": "high",
"topic": "ÖPNV",
"issue": "Ältere Quellen nennen Buslinie R125, die aktuelle Betreiberseite Linie 130.",
"decision": "Aktuell Linie 130 nennen und nur auf den Live-Fahrplan verlinken."
},
{
"severity": "medium",
"topic": "Heimatmuseum Telefon",
"issue": "Amt-Seite und bisherige Gemeindeseite nennen unterschiedliche Telefonnummern.",
"decision": "Telefonnummer bis zur Bestätigung nicht veröffentlichen."
},
{
"severity": "medium",
"topic": "Name Ratsmitglied",
"issue": "Amt-Seite: Nils Hörner; einzelne lokale Protokolle: Nils Höner.",
"decision": "Amt-Schreibweise verwenden und vor Livegang bestätigen."
},
{
"severity": "medium",
"topic": "Dörpshuus Kaution",
"issue": "Flyer April 2025 nennt 200 Euro Kaution; Ratsbeschluss Dezember 2025 bestätigt andere Gebühren, erwähnt die Kaution in der Kurzfassung aber nicht.",
"decision": "Kaution vor Livegang prüfen."
},
{
"severity": "high",
"topic": "Spielplatz",
"issue": "Das OEK 2022 beschreibt noch einen fehlenden Spielplatz.",
"decision": "Diese Aussage ist überholt; der neue Spielplatz wurde am 02.08.2026 eröffnet."
},
{
"severity": "medium",
"topic": "Windpark",
"issue": "Bestandszahlen verändern sich durch Repowering.",
"decision": "Keine Anlagenzahl als statischen Fakt auf der Website führen."
},
{
"severity": "medium",
"topic": "Veranstaltungskalender",
"issue": "Altseite/Jahreskalender enthalten vereinzelt widersprüchliche Wochentage, Fragezeichen und private Gastgeberadressen.",
"decision": "Programmgesteuerte Plausibilitätsprüfung + redaktionelle Freigabe."
}
],
"sources": [
{
"id": "amnf-goldebek",
"title": "Amt Mittleres Nordfriesland Gemeinde Goldebek",
"url": "https://www.amnf.de/gemeinden-stadt/goldebek",
"type": "official",
"last_checked": "2026-09-18",
"notes": "Gemeindebeschreibung, Bürgermeister, Gemeindevertretung, Hebesätze, Satzungen. Die dort genannte Einwohnerzahl 350 ist erkennbar älter als die amtliche Statistik vom 31.03.2026."
},
{
"id": "statistikportal-goldebek",
"title": "Statistikportal Gemeindeverzeichnis Goldebek",
"url": "https://www.statistikportal.de/de/gemeindeverzeichnis/01054037",
"type": "official-statistics",
"last_checked": "2026-09-18",
"notes": "Bevölkerungsstand 31.03.2026, Fläche, Dichte, AGS, PLZ."
},
{
"id": "statistik-nord-goldebek",
"title": "Statistikamt Nord Regionaldaten Goldebek",
"url": "https://region.statistik-nord.de/detail/0010000000000000000/1/0/641/",
"type": "official-statistics",
"last_checked": "2026-09-18",
"notes": "Bevölkerungsstruktur zum 31.12.2024."
},
{
"id": "legacy-home",
"title": "Bisherige Gemeindeseite Goldebek",
"url": "https://goldebek.jimdosite.com/",
"type": "municipal-legacy",
"last_checked": "2026-09-18",
"notes": "Bestehende Inhalte, Nachrichten, Termine und Navigationsstruktur. Einzelne Inhalte sind veraltet und müssen gegen aktuelle Quellen geprüft werden."
},
{
"id": "legacy-about",
"title": "Bisherige Gemeindeseite Über uns",
"url": "https://goldebek.jimdosite.com/die-gemeinde/ueber-uns/",
"type": "municipal-legacy",
"last_checked": "2026-09-18"
},
{
"id": "legacy-doerpshuus",
"title": "Bisherige Gemeindeseite Dörpshuus",
"url": "https://goldebek.jimdosite.com/die-gemeinde/doerpshuus/",
"type": "municipal-legacy",
"last_checked": "2026-09-18"
},
{
"id": "legacy-chronicle",
"title": "Bisherige Gemeindeseite Dorfchronik",
"url": "https://goldebek.jimdosite.com/die-gemeinde/dorfchronik/",
"type": "municipal-legacy",
"last_checked": "2026-09-18"
},
{
"id": "legacy-culture",
"title": "Bisherige Gemeindeseite Kulturausschuss",
"url": "https://goldebek.jimdosite.com/die-gemeinde/kulturausschuss/",
"type": "municipal-legacy",
"last_checked": "2026-09-18"
},
{
"id": "legacy-events",
"title": "Bisherige Gemeindeseite Termine",
"url": "https://goldebek.jimdosite.com/termine/",
"type": "municipal-legacy",
"last_checked": "2026-09-18"
},
{
"id": "legacy-council",
"title": "Bisherige Gemeindeseite Gemeindevertretung",
"url": "https://goldebek.jimdosite.com/gemeindevertretung/",
"type": "municipal-legacy",
"last_checked": "2026-09-18",
"notes": "Nicht als alleinige Quelle für die aktuelle Besetzung verwenden."
},
{
"id": "legacy-minutes",
"title": "Bisherige Gemeindeseite Sitzungstermine und Protokolle",
"url": "https://goldebek.jimdosite.com/gemeindevertretung/sitzungstermine-und-protokolle/",
"type": "municipal-legacy",
"last_checked": "2026-09-18"
},
{
"id": "legacy-clubs",
"title": "Bisherige Gemeindeseite Vereine",
"url": "https://goldebek.jimdosite.com/vereine/",
"type": "municipal-legacy",
"last_checked": "2026-09-18"
},
{
"id": "amnf-planning",
"title": "Amt Mittleres Nordfriesland Bauleitplanung Goldebek",
"url": "https://www.amnf.de/amt-verwaltung/bauen-wohnen/verfahren-bauleitplanung/goldebek",
"type": "official",
"last_checked": "2026-09-18"
},
{
"id": "oek",
"title": "Ortsentwicklungskonzept Goldebek und Högel",
"url": "https://www.amnf.de/fileadmin/download/gemeinden/goldebek/abschlussbereicht_oek___goldebek_u_hoegel.pdf",
"type": "official-planning",
"last_checked": "2026-09-18",
"notes": "Planungsstand 2022. Aussagen zu Infrastruktur müssen auf Aktualität geprüft werden; insbesondere der inzwischen eröffnete Spielplatz."
},
{
"id": "playground-2026",
"title": "Amt Mittleres Nordfriesland Neuer Spielplatz in Goldebek",
"url": "https://www.amnf.de/aktuelles/aktuelle-meldungen/details/news/neuer-spielplatz-in-goldebek-ein-ort-zum-toben-begegnen-und-wachsen",
"type": "official-news",
"last_checked": "2026-09-18"
},
{
"id": "fahrbuecherei-2026",
"title": "Fahrbücherei 5 Fahrplan 2026",
"url": "https://www.fahrbuecherei5.de/fileadmin/fahrbuechereien/fahrbuecherei-5/PDF_Dokumente/Fahrplan_2026.pdf",
"type": "official-service",
"last_checked": "2026-09-18"
},
{
"id": "bus-130",
"title": "Norddeutsche Verkehrsbetriebe Linie 130",
"url": "https://www.norddeutsche-verkehrsbetriebe.de/de/fuer-unsere-fahr-gaeste/nordfriesland-mitte/linie/linie-130",
"type": "operator",
"last_checked": "2026-09-18",
"notes": "Aktuelle Betreiberseite nennt Linie 130. Ältere Quellen nennen R125; diese Nummer nicht übernehmen."
},
{
"id": "veer-doerper",
"title": "Bürgerwindpark Veer Dörper",
"url": "https://veer-doerper.de/",
"type": "project",
"last_checked": "2026-09-18",
"notes": "Windparkdaten sind wegen Repowering dynamisch. Keine aktuelle Anlagenzahl ohne erneute Prüfung veröffentlichen."
},
{
"id": "alltagshilfe",
"title": "Gemeinde Goldelund Alltagshilfe Osterdörfer",
"url": "https://www.goldelund.de/verzeichnis/visitenkarte.php?mandat=174283",
"type": "municipal-service",
"last_checked": "2026-09-18"
},
{
"id": "tsv",
"title": "TSV Goldebek",
"url": "https://www.tsv-goldebek.de",
"type": "club",
"last_checked": "2026-09-18"
},
{
"id": "landfrauen",
"title": "LandFrauenVerein Joldelund e.V.",
"url": "https://www.lfv-joldelund.de",
"type": "club",
"last_checked": "2026-09-18"
},
{
"id": "wappen-commons",
"title": "Wikimedia Commons Wappen Goldebek",
"url": "https://commons.wikimedia.org/wiki/File:DEU_Goldebek_COA.svg",
"type": "asset-reference",
"last_checked": "2026-09-18",
"notes": "Urheberrechtliche Kennzeichnung beachten; zusätzlich können kommunal-/wappenrechtliche Nutzungsregeln gelten. Für die offizielle Website Freigabe/Verwendung mit Gemeinde abstimmen."
},
{
"id": "joldelund-education",
"title": "Gemeinde Joldelund Bildung",
"url": "https://www.joldelund.de/seite/175858/bildung-in-joldelund.html",
"type": "municipal-service",
"last_checked": "2026-09-18"
},
{
"id": "amnf-kindergarten",
"title": "Amt Mittleres Nordfriesland Kindergärten",
"url": "https://www.amnf.de/leben-erleben/bildung-familie/kindergaerten",
"type": "official-service",
"last_checked": "2026-09-18"
}
]
}
@@ -0,0 +1,431 @@
# Recherche-Datenpaket: Gemeinde Goldebek, Schleswig-Holstein
**Recherche-Stand:** 2026-09-18
**Zweck:** Inhaltliche Grundlage für eine neue offizielle Website der Gemeinde Goldebek.
**Grundsatz:** Bei dynamischen Daten immer Stichtag und Quelle mitführen. Alte Angaben werden nicht ungeprüft übernommen.
## 1. Kurzprofil
- **Gemeinde:** Goldebek
- **Bundesland:** Schleswig-Holstein
- **Kreis:** Nordfriesland
- **Amt:** Mittleres Nordfriesland
- **Amtlicher Gemeindeschlüssel:** 01054037
- **PLZ:** 25862
- **Fläche:** 10,18 km²
- **Einwohner:** 381 zum 31.03.2026
- **Einwohnerdichte:** 37 Einwohner/km²
- **Ortsteile/Siedlungsteile:** Goldebek, Heinsbek, Kolonie, Süderhuus, Goldebekfeld
- **Lage:** an der L 12 zwischen Bredstedt und Wanderup, im nordöstlichen Nordfriesland
- **Gewässer/Landschaft:** Goldebeker Mühlenstrom; Linnau im nördlichen Gemeindegebiet/Grenzbereich; ländlich-landwirtschaftliche Prägung
Wichtig: Der Fließtext auf der Amt-Seite nennt noch etwa 350 Einwohner. Für die neue Website ist die amtliche Statistik mit **381 Einwohnern zum 31.03.2026** maßgeblich.
## 2. Bevölkerung
Statistikamt Nord, Stand 31.12.2024:
| Merkmal | Wert |
|---|---:|
| Einwohner | 381 |
| männlich | 194 |
| weiblich | 187 |
| Durchschnittsalter | 41,2 Jahre |
| deutsche Staatsangehörigkeit | 376 |
| ausländische Staatsangehörigkeit | 5 |
| 017 Jahre | 85 / 22,3 % |
| 1824 Jahre | 33 / 8,7 % |
| 2529 Jahre | 18 / 4,7 % |
| 3049 Jahre | 97 / 25,5 % |
| 5064 Jahre | 81 / 21,3 % |
| 65+ | 67 / 17,6 % |
Diese Werte sollten nicht als „live“ bezeichnet werden, sondern immer mit Stichtag erscheinen.
## 3. Geschichte und Identität
- Erste urkundliche Erwähnung: **1321**.
- Nach der gemeindlichen Darstellung setzt sich der Ortsname aus „Golde“ für unfruchtbaren/sandigen Boden und „Bek“ für Bach zusammen.
- Der Goldebeker Mühlenstrom ist namens- und ortsgeschichtlich relevant.
- Die frühere Mühle wurde **1948** abgebrochen.
- Goldebek wurde **1934** selbstständige Gemeinde.
- Der frühere Gemeindename **Goldebeck** wurde mit Wirkung vom **01.11.1961** in **Goldebek** geändert.
- Das Dörpshuus wurde **2000** mit erheblicher Eigenleistung aus der Dorfgemeinschaft errichtet.
- Die erste Dorfchronik erschien **1992**, ein zweiter Band im **April 2022**.
- Der neue Spielplatz am Langbarg wurde am **02.08.2026** eröffnet.
### Wappen
Blasonierung:
> Von Blau und Grün durch einen goldenen Schrägwellenbalken erniedrigt geteilt. Oben eine silberne Windmühle mit hohem Sockelgeschoss, unten ein silberner Ochsenkopf.
Für das Webdesign können die Wappenfarben als Inspiration dienen: tiefes Blau, Grün, warmes Gold und Weiß/Silber. Das Wappen selbst darf **nicht neu gezeichnet, umgefärbt oder stilisiert** werden. Vor dem Livegang ist die gewünschte offizielle Grafik und deren Nutzung mit der Gemeinde abzustimmen.
## 4. Gemeindevertretung und Verwaltung
### Bürgermeister
**Peter Jessen**
Am Mühlenstrom 14
25862 Goldebek
Telefon: 04673/962229
Fax: 04673/962688
Quelle: Amt Mittleres Nordfriesland, geprüft am 2026-09-18.
### Gemeindevertretung
Aktuelle Liste laut Amt Mittleres Nordfriesland:
1. Peter Jessen
2. Hauke Jensen
3. Volker Hansen
4. Timo Jensen
5. Catarina Tudsen
6. Stefan Christiansen
7. Rainer Bakker
8. Finn Christiansen
9. Nils Hörner
**Datenhinweis:** In einzelnen lokalen Sitzungsunterlagen erscheint „Nils Höner“. Für die Migration zunächst die Amt-Schreibweise „Nils Hörner“ verwenden und vor Veröffentlichung bestätigen.
### Hebesätze / Hundesteuer
- Grundsteuer A: 595 v.H.
- Grundsteuer B: 517 v.H.
- Gewerbesteuer: 400 v.H.
- Hundesteuer: 1. Hund 90 €/Jahr; 2. Hund 110 €/Jahr; jeder weitere Hund 110 €/Jahr
Diese Werte auf der Website nur mit „Stand“ und Link zur amtlichen Quelle anzeigen.
### Amt
Amt Mittleres Nordfriesland
Theodor-Storm-Straße 2
25821 Bredstedt
Telefon 04671/9192-0
Fax 04671/9192-93
E-Mail info@amnf.de
Satzungen, Bauleitplanung und andere rechtsverbindliche Dokumente sollten **nicht redundant kopiert**, sondern möglichst direkt auf die jeweils aktuelle amtliche Quelle verlinkt werden.
## 5. Dörpshuus
**Adresse:** Am Brodersberg 16, 25862 Goldebek
**Service-Telefon laut bestehender Seite:** 0173 3826343
**Baujahr:** 2000
**Funktion:** Dorfgemeinschaftshaus, Veranstaltungs- und Begegnungsort.
Kapazität laut Flyer April 2025:
- Dörpshuus: 80 Personen
- Feuerwehr-Kameradschaftsraum: 20 Personen
- zusammen maximal 100 Personen
Gebühren, zuletzt aus Ratsunterlagen vom 02.12.2025 bestätigt:
- Einwohner/Einwohnerinnen: 150 €
- Feuerwehr-Kameradschaftsraum: 60 €
- Auswärtige: 300 €
- Endreinigung: 70 €
- Fehlbenutzung Notausgang: 50 €
**Offener Punkt:** Ein älterer Flyer nennt 200 € Kaution; dieser Betrag muss vor Veröffentlichung noch einmal bestätigt werden.
## 6. Spielplatz am Langbarg
Der neue Spielplatz wurde am **02.08.2026** eröffnet. Der Wunsch nach einem neuen Spielplatz wurde bereits Ende 2022 diskutiert. Die Auswahl der Spielgeräte wurde unter Beteiligung der Dorfgemeinschaft vorbereitet. Baubeginn war Anfang Juni 2026, die TÜV-Abnahme erfolgte Ende Juni.
Projektvolumen laut Amt: rund **145.000 €**. Genannte Einzelposten:
- ca. 7.000 € Rodung/Wurzeln/Erdabfuhr
- ca. 40.000 € Spielgeräte
- ca. 55.000 € Erdarbeiten/Aufbau
- ca. 12.000 € Zaun
Spenden:
- VR Bank Nord Stiftung: 10.000 €
- Bürgerwindpark Veer Dörper: 500 €
Für die Startseite eignet sich der Spielplatz als aktueller positiver Gemeindebaustein.
## 7. Gemeinschaft, Vereine und Gruppen
### Vereine / Organisationen
- **TSV Goldebek** Sportverein https://www.tsv-goldebek.de
- **LandFrauenVerein Joldelund e.V.** für Joldelund, Goldelund, Högel, Goldebek und Kolkerheide https://www.lfv-joldelund.de
- **Ringreiter- und Schützenverein „Freie Bahn Goldebek“ von 1948** gegründet am 10.01.1948
- **SoVD Goldebek-Goldelund-Joldelund** https://www.sovd-nordfriesland.de
- **Freiwillige Feuerwehr Goldebek** wichtiger Bestandteil des Gemeindelebens
### Kulturausschuss
Der Kulturausschuss plant und organisiert Veranstaltungen und Aktionen der Dorfgemeinschaft.
Öffentlich genannter Ansprechpartner auf der bisherigen Gemeindeseite:
- Maik Tewes
- Telefon 0160 7697796
Private Wohnanschriften aus Altseiten nicht ungeprüft übernehmen.
### Wiederkehrende Formate
- Seniorennachmittage
- Biikebrennen
- Lebendiger Adventskalender
- Dorfflohmarkt
- Streuobstwiesenfest
- Erntefest
- Ringreiten / traditionelle Vereinsveranstaltungen
Für diese Formate nur die Beschreibung dauerhaft pflegen. Datum, Uhrzeit und Ansprechpartner werden jedes Jahr als separate Events eingetragen.
## 8. Termine Seed ab 18.09.2026
| Datum | Uhrzeit | Termin | Ort |
|---|---|---|---|
| 20.09.2026 | 11:00 | 70-jähriges Jubiläum LandFrauenVerein Joldelund e.V. | Dörpshuus Goldebek |
| 28.09.2026 | 14:00 | Seniorennachmittag | Dörpshuus |
| 04.10.2026 | 11:00 | Erntedankgottesdienst | Goldebek |
| 09.10.2026 | 15:5516:20 | Fahrbücherei | Dorfstraße 12 |
| 17.10.2026 | 19:30 | Erntefest | Dörpshuus |
| 26.10.2026 | 14:00 | Seniorennachmittag | Dörpshuus |
| 06.11.2026 | 15:5516:20 | Fahrbücherei | Dorfstraße 12 |
| 15.11.2026 | 14:30 | Kranzniederlegung | Ehrenmal |
| 30.11.2026 | 14:00 | Seniorennachmittag | Dörpshuus |
| 04.12.2026 | 15:5516:20 | Fahrbücherei | Dorfstraße 12 |
| 06.12.2026 | offen | Nikolaus kommt mit der Freiwilligen Feuerwehr | Goldebek |
| 14.12.2026 | 14:00 | Seniorennachmittag | Dörpshuus |
Ein Termin mit unsicherer Datumsangabe im Jahreskalender wurde bewusst nicht übernommen. Ebenso keine privaten Gastgeberadressen aus Adventsveranstaltungen.
## 9. Mobilität und Service
### ÖPNV
Die aktuelle Betreiberseite führt Goldebek unter **Linie 130**. Beobachtete Halte:
- Goldebek Kreuzung
- Goldebek Dorfstraße
- Süderhuuser Straße 5
- Süderhuus
Die Verbindung liegt im Korridor Bredstedt Joldelund Goldebek Lindewitt Großenwiehe Flensburg.
**Wichtig:** Ältere Unterlagen nennen noch „R125“. Auf der neuen Website keine alte Liniennummer übernehmen und keine Fahrzeiten fest einbauen. Immer zum aktuellen Betreiberfahrplan verlinken.
### Fahrbücherei
Haltestelle Goldebek: **Dorfstraße 12**, 15:5516:20 Uhr.
Noch ausstehende Termine 2026: 09.10., 06.11., 04.12.
### Gemeindebus / Radfahrtreff
Laut bisheriger Gemeindeseite:
- Gemeindebus Joldelund/Goldebek etwa alle 14 Tage donnerstags und nach Absprache
- Anmeldung: Ernst Thomsen, 0174 9804582
- Radfahrtreff dienstags 14:00 Uhr am Dörpshuus
Diese Angaben sind **jährlich zu verifizieren**.
### Alltagshilfe Osterdörfer
Seit 01.01.2026 für Goldebek, Goldelund, Joldelund, Högel und Kolkerheide.
Beispiele:
- Einkäufe
- Fahrten zu Arztterminen
- kurze Kinderbetreuung
- kleine Alltagshilfen
Kein Ersatz für professionelle Pflege-, Handwerks-, Garten- oder Taxileistungen.
Kosten:
- 5 € je angefangener Stunde
- 0,50 € je Kilometer
Kontakt Goldebek:
- Peter Jessen
- 0151/70089673
## 10. Bildung und Versorgung
Nach dem OEK 2022 gibt es in Goldebek selbst keine eigene Kita oder Grundschule. Naheliegende Angebote befinden sich insbesondere in Joldelund. Da Ansprechpartner und Trägerschaften dynamisch sind, sollte die Goldebeker Website zu den aktuellen offiziellen Seiten verlinken und nicht alle Kontaktdaten kopieren.
Empfohlene Links:
- https://www.joldelund.de/seite/175858/bildung-in-joldelund.html
- https://www.amnf.de/leben-erleben/bildung-familie/kindergaerten
## 11. Projekte und Entwicklung
### Kommunale Wärmeplanung
Ratsunterlagen vom 21.04.2026 dokumentieren die Beteiligung Goldebeks an einer gemeinsamen Wärmeplanung mit Goldelund, Joldelund und Kolkerheide. Koordination: Amt Mittleres Nordfriesland. Genannter Planungshorizont: bis voraussichtlich Juni 2028.
### Windenergie / Bürgerwindpark
Goldebek ist Teil des Bürgerwindparks **Veer Dörper** mit umliegenden Gemeinden. Wegen laufenden Repowerings keine statische Zahl von Windenergieanlagen oder installierter Leistung auf der Website festschreiben.
### Bauleitplanung / Wohnbauentwicklung
Aktuelle Verfahren und der Stand des Bebauungsplans Nr. 5 sollten ausschließlich über die amtliche Bauleitplanungsquelle bzw. nach redaktioneller Bestätigung veröffentlicht werden. Aus alten Protokollen nicht auf aktuell verfügbare Grundstücke schließen.
## 12. Empfohlene Informationsarchitektur
1. **Startseite**
2. **Aktuelles**
3. **Termine**
4. **Gemeinde**
- Über Goldebek
- Zahlen & Fakten
- Geschichte
- Wappen
- Dorfchronik
5. **Leben & Gemeinschaft**
- Dörpshuus
- Spielplatz
- Vereine
- Feuerwehr
- Kulturausschuss
- Senioren
- Alltagshilfe
6. **Politik & Verwaltung**
- Bürgermeister & Gemeindevertretung
- Sitzungen & Protokolle
- Satzungen & Steuern
- Bauleitplanung
- Projekte & Entwicklung
7. **Service**
- Ansprechpartner
- Mobilität
- Fahrbücherei
- Abfall & Abwasser
- Bildung / Kita / Schule
- Wichtige Links
8. **Kontakt**
9. **Rechtliches**
- Impressum
- Datenschutz
- Erklärung zur Barrierefreiheit
## 13. Startseiten-Konzept
Hero:
- Eyebrow: „Gemeinde Goldebek · Nordfriesland“
- H1: **„Moin in Goldebek“**
- Text: „Goldebek liegt im nordöstlichen Nordfriesland zwischen Bredstedt und Wanderup. Landwirtschaft, Ehrenamt, Vereine und gemeinschaftliche Projekte prägen das Dorf.“
- CTA 1: „Aktuelle Termine“
- CTA 2: „Goldebek entdecken“
Direkt darunter vier Fakten:
- 381 Einwohner · Stand 31.03.2026
- 10,18 km²
- Kreis Nordfriesland
- PLZ 25862
Danach:
1. Aktuelles
2. Nächste Termine
3. Schnellzugriffe
4. Gemeinschaft & Vereine
5. Goldebek entdecken
6. Projekte & Entwicklung
7. Kontakt / Amt
## 14. Gestaltung
Nicht wie eine generische Tourismus- oder KI-Seite. Ziel: **modernes norddeutsches Gemeindeportal**.
Designrichtung:
- viel Weißraum
- klare Typografie
- ruhige, robuste Karten/Flächen
- Wappenfarben als Akzente: Blau, Grün, Gold
- dezente Topografie-/Wellenform als grafischer Bezug auf den Mühlenstrom
- hochwertige, echte Ortsfotografie statt Stockbilder
- kaum Animation; nur funktionale Mikrointeraktionen
- starke mobile Darstellung
- offizielle, aber warme Tonalität
### Bilder
Die Fotos auf der Amt-Seite eignen sich als visuelle Referenz, sind dort aber mit Fotografen-Copyright gekennzeichnet. **Nicht automatisch kopieren.** Für die Website sollte die Gemeinde freigegebene Originalbilder liefern.
Gewünschte Motive:
- Orts-/Luftaufnahme
- Dörpshuus
- Spielplatz
- Dorf-/Landschaftssituation
- Mühlenstrom / Natur
- Feuerwehr
- Vereine / Gemeinschaft
- Streuobstwiese / Feste
- Ortseingang / Wappenstein
## 15. Technische Inhaltsregeln
Jeder volatile Datensatz sollte mindestens haben:
```ts
type ContentMeta = {
sourceUrl: string;
sourceName: string;
sourceDate?: string;
lastVerified: string;
status: "verified" | "dynamic" | "needs-verification" | "archived";
privacyApproved?: boolean;
validFrom?: string;
validUntil?: string;
}
```
Termine zusätzlich:
- ISO-Datum
- Start-/Endzeit
- Zeitzone Europe/Berlin
- Ort
- Veranstalter
- Quell-URL
- Event-Status
- optional ICS-Export
- schema.org/Event
## 16. Offene Punkte vor Livegang
1. Telefonnummer des privaten Heimatmuseums bestätigen.
2. Schreibweise „Nils Hörner/Höner“ bestätigen.
3. Dörpshuus-Kaution und vollständige aktuelle Nutzungsbedingungen bestätigen.
4. Bildrechte / Gemeinde-Fotopaket beschaffen.
5. Wappen-Datei und Nutzungsfreigabe bestätigen.
6. Aktuellen Abfall-/Abwasser-Link festlegen.
7. Aktuellen Stand B-Plan 5 / Baugrundstücke bestätigen.
8. Aktuellen Stand Bürgerwindpark/Repowering nur bei Bedarf nachrecherchieren.
9. Rechtstexte für Impressum, Datenschutz und Barrierefreiheit juristisch/administrativ freigeben.
10. Veranstaltungskalender nach 2026 redaktionell pflegen.
11. Fahrbücherei-Fahrplan jährlich austauschen.
12. Busfahrplan nur live verlinken, nicht kopieren.
## 17. Quellenregister
- **Amt Mittleres Nordfriesland Gemeinde Goldebek** https://www.amnf.de/gemeinden-stadt/goldebek — Gemeindebeschreibung, Bürgermeister, Gemeindevertretung, Hebesätze, Satzungen. Die dort genannte Einwohnerzahl 350 ist erkennbar älter als die amtliche Statistik vom 31.03.2026.
- **Statistikportal Gemeindeverzeichnis Goldebek** https://www.statistikportal.de/de/gemeindeverzeichnis/01054037 — Bevölkerungsstand 31.03.2026, Fläche, Dichte, AGS, PLZ.
- **Statistikamt Nord Regionaldaten Goldebek** https://region.statistik-nord.de/detail/0010000000000000000/1/0/641/ — Bevölkerungsstruktur zum 31.12.2024.
- **Bisherige Gemeindeseite Goldebek** https://goldebek.jimdosite.com/ — Bestehende Inhalte, Nachrichten, Termine und Navigationsstruktur. Einzelne Inhalte sind veraltet und müssen gegen aktuelle Quellen geprüft werden.
- **Bisherige Gemeindeseite Über uns** https://goldebek.jimdosite.com/die-gemeinde/ueber-uns/
- **Bisherige Gemeindeseite Dörpshuus** https://goldebek.jimdosite.com/die-gemeinde/doerpshuus/
- **Bisherige Gemeindeseite Dorfchronik** https://goldebek.jimdosite.com/die-gemeinde/dorfchronik/
- **Bisherige Gemeindeseite Kulturausschuss** https://goldebek.jimdosite.com/die-gemeinde/kulturausschuss/
- **Bisherige Gemeindeseite Termine** https://goldebek.jimdosite.com/termine/
- **Bisherige Gemeindeseite Gemeindevertretung** https://goldebek.jimdosite.com/gemeindevertretung/ — Nicht als alleinige Quelle für die aktuelle Besetzung verwenden.
- **Bisherige Gemeindeseite Sitzungstermine und Protokolle** https://goldebek.jimdosite.com/gemeindevertretung/sitzungstermine-und-protokolle/
- **Bisherige Gemeindeseite Vereine** https://goldebek.jimdosite.com/vereine/
- **Amt Mittleres Nordfriesland Bauleitplanung Goldebek** https://www.amnf.de/amt-verwaltung/bauen-wohnen/verfahren-bauleitplanung/goldebek
- **Ortsentwicklungskonzept Goldebek und Högel** https://www.amnf.de/fileadmin/download/gemeinden/goldebek/abschlussbereicht_oek___goldebek_u_hoegel.pdf — Planungsstand 2022. Aussagen zu Infrastruktur müssen auf Aktualität geprüft werden; insbesondere der inzwischen eröffnete Spielplatz.
- **Amt Mittleres Nordfriesland Neuer Spielplatz in Goldebek** https://www.amnf.de/aktuelles/aktuelle-meldungen/details/news/neuer-spielplatz-in-goldebek-ein-ort-zum-toben-begegnen-und-wachsen
- **Fahrbücherei 5 Fahrplan 2026** https://www.fahrbuecherei5.de/fileadmin/fahrbuechereien/fahrbuecherei-5/PDF_Dokumente/Fahrplan_2026.pdf
- **Norddeutsche Verkehrsbetriebe Linie 130** https://www.norddeutsche-verkehrsbetriebe.de/de/fuer-unsere-fahr-gaeste/nordfriesland-mitte/linie/linie-130 — Aktuelle Betreiberseite nennt Linie 130. Ältere Quellen nennen R125; diese Nummer nicht übernehmen.
- **Bürgerwindpark Veer Dörper** https://veer-doerper.de/ — Windparkdaten sind wegen Repowering dynamisch. Keine aktuelle Anlagenzahl ohne erneute Prüfung veröffentlichen.
- **Gemeinde Goldelund Alltagshilfe Osterdörfer** https://www.goldelund.de/verzeichnis/visitenkarte.php?mandat=174283
- **TSV Goldebek** https://www.tsv-goldebek.de
- **LandFrauenVerein Joldelund e.V.** https://www.lfv-joldelund.de
- **Wikimedia Commons Wappen Goldebek** https://commons.wikimedia.org/wiki/File:DEU_Goldebek_COA.svg — Urheberrechtliche Kennzeichnung beachten; zusätzlich können kommunal-/wappenrechtliche Nutzungsregeln gelten. Für die offizielle Website Freigabe/Verwendung mit Gemeinde abstimmen.
- **Gemeinde Joldelund Bildung** https://www.joldelund.de/seite/175858/bildung-in-joldelund.html
- **Amt Mittleres Nordfriesland Kindergärten** https://www.amnf.de/leben-erleben/bildung-familie/kindergaerten
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+46
View File
@@ -0,0 +1,46 @@
import type { NextConfig } from "next";
const securityHeaders = [
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "geolocation=()" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Resource-Policy", value: "same-site" },
];
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
experimental: {
// TypeScript 5 provides the compiler API and avoids CLI output-capture issues
// in restricted build environments.
useTypeScriptCli: false,
},
turbopack: {
root: process.cwd(),
},
async headers() {
return [
{ source: "/(.*)", headers: securityHeaders },
{
source: "/downloads/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=604800" },
{ key: "X-Robots-Tag", value: "noindex, noarchive" },
],
},
{
source: "/images/:path*",
headers: [{ key: "Cache-Control", value: "public, max-age=2592000" }],
},
{
source: "/fonts/:path*",
headers: [{ key: "Cache-Control", value: "public, max-age=2592000" }],
},
];
},
};
export default nextConfig;
+7704
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
{
"name": "nextjs-website-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"prepare": "husky",
"clean:public": "node scripts/clean-public.mjs",
"images": "node scripts/optimize-images.mjs",
"images:prune": "node scripts/optimize-images.mjs --prune",
"images:strip-metadata": "node scripts/strip-image-metadata.mjs",
"images:check-metadata": "node scripts/strip-image-metadata.mjs --check",
"downloads:check-metadata": "node scripts/check-download-metadata.mjs",
"media:migrate": "node scripts/migrate-media-sources.mjs",
"post:new": "node scripts/new-trip-report.mjs",
"dev": "next dev",
"build": "npm run clean:public && if [ \"${SKIP_IMAGE_OPTIMIZATION:-false}\" != true ]; then npm run images:prune; fi && npm run images:strip-metadata && npm run downloads:check-metadata && next build",
"build:with-images": "npm run build",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "node scripts/run-e2e.mjs",
"typecheck": "tsc --noEmit",
"preview": "next start",
"content:new": "npm run post:new"
},
"dependencies": {
"@js-temporal/polyfill": "^0.5.1",
"ical.js": "^2.2.1",
"next": "16.3.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"rrule": "^2.8.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@types/node": "^24.13.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.3",
"eslint-config-next": "16.3.4",
"globals": "^17.3.0",
"husky": "^9.1.0",
"lint-staged": "^16.1.0",
"sharp": "^0.35.4",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.0",
"vitest": "^4.1.2"
},
"overrides": {
"esbuild": "0.28.1"
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
"npm exec eslint -- --config eslint.config.js --fix"
],
"scripts/**/*.mjs": [
"npm exec eslint -- --config eslint.config.js --fix"
],
"eslint.config.js": [
"npm exec eslint -- --config eslint.config.js --fix"
]
}
}
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 675" role="img" aria-labelledby="title description">
<title id="title">Bildplatzhalter</title>
<desc id="description">Neutraler graublauer Platzhalter für ein späteres Projektbild</desc>
<rect width="1200" height="675" fill="#e2e8f0" />
<path d="M0 540 280 300l190 170 190-220 540 425H0Z" fill="#94a3b8" />
<circle cx="920" cy="170" r="72" fill="#2563eb" />
</svg>

After

Width:  |  Height:  |  Size: 435 B

@@ -0,0 +1 @@
[]
+1
View File
@@ -0,0 +1 @@
[]
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title description">
<title id="title">Neutrales Logo</title>
<desc id="description">Abstrakter Platzhalter aus einem Kreis und einem Quadrat</desc>
<circle cx="32" cy="32" r="29" fill="#2563eb" />
<rect x="20" y="20" width="24" height="24" rx="4" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 368 B

+30
View File
@@ -0,0 +1,30 @@
import fs from "node:fs";
import path from "node:path";
const DOWNLOADS_DIR = path.resolve(import.meta.dirname, "../public/downloads");
const PDF_METADATA_PATTERN =
/\/(?:Title|Author|Subject|Keywords|Creator|CreationDate|ModDate)\s*\(|<xmp:(?:CreatorTool|CreateDate|ModifyDate)>|<dc:(?:creator|title)>|<pdf:Keywords>/i;
function collectPdfs(directory) {
if (!fs.existsSync(directory)) return [];
return fs
.readdirSync(directory, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".pdf"))
.map((entry) => path.join(directory, entry.name));
}
const files = collectPdfs(DOWNLOADS_DIR);
const filesWithMetadata = files.filter((file) =>
PDF_METADATA_PATTERN.test(fs.readFileSync(file, "latin1")),
);
for (const file of filesWithMetadata) {
console.error(`[downloads:check-metadata] PDF metadata found: ${file}`);
}
if (filesWithMetadata.length > 0) {
process.exitCode = 1;
} else {
console.log(`[downloads:check-metadata] ${files.length} PDFs checked; no document metadata found.`);
}
+23
View File
@@ -0,0 +1,23 @@
import fs from "node:fs";
import path from "node:path";
const PUBLIC_DIR = path.resolve(import.meta.dirname, "../public");
function cleanDotfiles(directory) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
cleanDotfiles(fullPath);
continue;
}
if (entry.isFile() && entry.name === ".DS_Store") {
fs.rmSync(fullPath);
}
}
}
if (fs.existsSync(PUBLIC_DIR)) {
cleanDotfiles(PUBLIC_DIR);
}
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
set -eu
previous_image=""
app_container_id=""
wait_for_health() {
attempt=1
while [ "$attempt" -le 40 ]; do
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$app_container_id" 2>/dev/null || echo unknown)"
[ "$status" = healthy ] && return 0
[ "$status" = unhealthy ] && return 1
sleep 3
attempt=$((attempt + 1))
done
return 1
}
verify_routes() {
docker exec -i "$app_container_id" node --input-type=module - <<'NODE'
const request = (path, init) => fetch(`http://127.0.0.1:3000${path}`, { signal: AbortSignal.timeout(20000), ...init });
for (const [path, text] of [["/", "Bereit für Ihre neue Website"], ["/kalender/", "Kalender"], ["/robots.txt", "Sitemap:"]]) {
const response = await request(path);
if (!response.ok || !(await response.text()).includes(text)) throw new Error(`${path} failed`);
}
const events = await request("/api/events");
const etag = events.headers.get("etag");
if (!events.ok || !etag || !Array.isArray(await events.json())) throw new Error("events failed");
if ((await request("/api/events", { headers: { "If-None-Match": etag } })).status !== 304) throw new Error("ETag failed");
for (const path of ["/health/live", "/health/ready"]) if (!(await request(path)).ok) throw new Error(`${path} failed`);
NODE
}
current_id="$(docker compose ps -q app 2>/dev/null || true)"
if [ -n "$current_id" ]; then previous_image="$(docker inspect --format '{{.Config.Image}}' "$current_id" 2>/dev/null || true)"; fi
if ! docker compose up -d --no-build --remove-orphans app; then exit 1; fi
app_container_id="$(docker compose ps -q app)"
if ! wait_for_health || ! verify_routes; then
docker compose ps || true
docker compose logs app || true
if [ -n "$previous_image" ]; then APP_IMAGE_NAME="$previous_image" docker compose up -d --no-build --remove-orphans app; fi
exit 1
fi
+83
View File
@@ -0,0 +1,83 @@
#!/bin/sh
set -eu
set_output() {
key="$1"
value="$2"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT"
fi
printf '%s=%s\n' "$key" "$value"
}
set_all() {
value="$1"
set_output app "$value"
set_output security_deps "$value"
set_output deploy "$value"
}
event_name="${GITHUB_EVENT_NAME:-}"
if [ "$event_name" = "schedule" ] || [ "$event_name" = "workflow_dispatch" ]; then
echo "Running all jobs for $event_name."
set_all true
exit 0
fi
head_sha="${GITHUB_SHA:-HEAD}"
base_sha="${CHANGESET_PR_BASE_SHA:-}"
before_sha="${CHANGESET_BEFORE:-}"
zero_sha="0000000000000000000000000000000000000000"
if [ -z "$base_sha" ] && [ -n "$before_sha" ] && [ "$before_sha" != "$zero_sha" ]; then
base_sha="$before_sha"
fi
if [ -z "$base_sha" ]; then
echo "No reliable base commit found; running all jobs."
set_all true
exit 0
fi
if ! git cat-file -e "$base_sha^{commit}" 2>/dev/null; then
git fetch --no-tags --depth=1 origin "$base_sha" || true
fi
if ! git cat-file -e "$base_sha^{commit}" 2>/dev/null; then
echo "Base commit $base_sha is unavailable; running all jobs."
set_all true
exit 0
fi
changed_files="$(git diff --name-only "$base_sha" "$head_sha")"
if [ -z "$changed_files" ]; then
echo "No changed files detected."
set_all false
exit 0
fi
app=false
security_deps=false
deploy=false
echo "Changed files:"
printf '%s\n' "$changed_files"
for path in $changed_files; do
case "$path" in
*)
app=true
deploy=true
;;
esac
case "$path" in
.gitea/workflows/*|docker-compose*.yml|scripts/trivy-scan.sh|Dockerfile*|package.json|package-lock.json)
security_deps=true
;;
esac
done
set_output app "$app"
set_output security_deps "$security_deps"
set_output deploy "$deploy"
+100
View File
@@ -0,0 +1,100 @@
#!/bin/sh
set -eu
require_value() {
key="$1"
value="$2"
if [ -z "$value" ]; then
echo "Missing required rollback configuration: $key" >&2
exit 1
fi
}
validate_tag() {
key="$1"
value="$2"
case "$value" in
[A-Za-z0-9_]* ) ;;
*)
echo "Invalid Docker image tag for $key" >&2
exit 1
;;
esac
case "$value" in
*[!A-Za-z0-9_.-]* )
echo "Invalid Docker image tag for $key" >&2
exit 1
;;
esac
if [ "${#value}" -gt 128 ]; then
echo "Docker image tag for $key exceeds 128 characters" >&2
exit 1
fi
}
require_value "DEPLOY_TARGET" "${DEPLOY_TARGET:-}"
require_value "CALENDAR_URL" "${CALENDAR_URL:-}"
require_value "PRIMARY_DOMAIN" "${PRIMARY_DOMAIN:-}"
require_value "TRAEFIK_CERTRESOLVER" "${TRAEFIK_CERTRESOLVER:-}"
require_value "REGISTRY" "${REGISTRY:-}"
require_value "REGISTRY_USERNAME" "${REGISTRY_USERNAME:-}"
require_value "REGISTRY_TOKEN" "${REGISTRY_TOKEN:-}"
case "$DEPLOY_TARGET" in
dev)
default_stack_name="website-starter-dev"
default_traefik_network="traefik-external"
;;
master|production)
default_stack_name="website-starter-prod"
default_traefik_network="traefik"
;;
*)
echo "Unsupported rollback target: $DEPLOY_TARGET" >&2
exit 1
;;
esac
require_value "APP_IMAGE_REPOSITORY" "${APP_IMAGE_REPOSITORY:-}"
require_value "APP_IMAGE_TAG" "${APP_IMAGE_TAG:-}"
validate_tag "APP_IMAGE_TAG" "$APP_IMAGE_TAG"
APP_IMAGE_NAME="$APP_IMAGE_REPOSITORY:$APP_IMAGE_TAG"
APP_URL="${APP_URL:-https://$PRIMARY_DOMAIN}"
CORS_ORIGIN="${CORS_ORIGIN:-$APP_URL}"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$default_stack_name}"
TRAEFIK_STACK_NAME="${TRAEFIK_STACK_NAME:-$COMPOSE_PROJECT_NAME}"
TRAEFIK_NETWORK_NAME="${TRAEFIK_NETWORK_NAME:-$default_traefik_network}"
export CALENDAR_URL
export PRIMARY_DOMAIN
export APP_URL
export CORS_ORIGIN
export COMPOSE_PROJECT_NAME
export TRAEFIK_STACK_NAME
export TRAEFIK_CERTRESOLVER
export TRAEFIK_NETWORK_NAME
export APP_IMAGE_NAME
docker_config="$(mktemp -d)"
cleanup_registry_credentials() {
DOCKER_CONFIG="$docker_config" docker logout "$REGISTRY" >/dev/null 2>&1 || true
rm -rf "$docker_config"
}
trap cleanup_registry_credentials EXIT
trap 'exit 1' HUP INT TERM
export DOCKER_CONFIG="$docker_config"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
docker pull "$APP_IMAGE_NAME"
docker logout "$REGISTRY" >/dev/null
rm -rf "$docker_config"
trap - EXIT HUP INT TERM
unset DOCKER_CONFIG REGISTRY_USERNAME REGISTRY_TOKEN
echo "Rollback target: $DEPLOY_TARGET"
echo "Site host: $PRIMARY_DOMAIN"
echo "Compose project: $COMPOSE_PROJECT_NAME"
echo "App image: $APP_IMAGE_NAME"
sh scripts/deploy-and-verify.sh
+35
View File
@@ -0,0 +1,35 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..");
const legacyRoot = path.join(ROOT, "assets");
const mediaRoot = path.join(ROOT, ".media-source");
const directories = [
"original-content",
"original-slider",
"original-secondary-slider",
"original-herosections",
];
fs.mkdirSync(mediaRoot, { recursive: true });
for (const directory of directories) {
const legacyDir = path.join(legacyRoot, directory);
const targetDir = path.join(mediaRoot, directory);
if (!fs.existsSync(legacyDir)) {
console.log(`[media:migrate] ${directory} bereits ausgelagert oder nicht vorhanden.`);
continue;
}
if (fs.existsSync(targetDir)) {
console.log(`[media:migrate] ${directory} bereits unter .media-source vorhanden, überspringe.`);
continue;
}
fs.renameSync(legacyDir, targetDir);
console.log(`[media:migrate] ${directory} → .media-source/${directory}`);
}
+171
View File
@@ -0,0 +1,171 @@
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { stdin as input, stdout as output } from "node:process";
import { fileURLToPath } from "node:url";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const reportsRoot = path.join(root, "src", "trip-reports");
const registryPath = path.join(reportsRoot, "index.ts");
const imageSourceRoot = path.join(root, ".media-source", "original-content");
const supportedImageExtensions = new Set([".jpg", ".jpeg", ".png"]);
const prompt = readline.createInterface({ input, output });
const lines = prompt[Symbol.asyncIterator]();
function slugify(value) {
return value
.replace(/ß/g, "ss")
.replace(/[äÄ]/g, "ae")
.replace(/[öÖ]/g, "oe")
.replace(/[üÜ]/g, "ue")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function componentName(slug) {
const name = slug.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
return /^\d/.test(name) ? `Report${name}` : name;
}
async function ask(label, fallback = "") {
output.write(`${label}${fallback ? ` [${fallback}]` : ""}: `);
const answer = await lines.next();
if (answer.done) throw new Error("Eingabe wurde unerwartet beendet.");
return answer.value.trim() || fallback;
}
async function askRequired(label, fallback = "") {
while (true) {
const answer = await ask(label, fallback);
if (answer) return answer;
console.log("Bitte einen Wert eingeben.");
}
}
async function askBoolean(label, fallback) {
const answer = (await ask(`${label} (${fallback ? "J/n" : "j/N"})`)).toLowerCase();
if (!answer) return fallback;
if (["j", "ja", "y", "yes"].includes(answer)) return true;
if (["n", "nein", "no"].includes(answer)) return false;
throw new Error("Bitte mit ja oder nein antworten.");
}
function parseDate(value) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("Datum muss YYYY-MM-DD entsprechen.");
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) {
throw new Error("Das Datum ist ungueltig.");
}
return date;
}
async function collectImages(year) {
const raw = await ask("Bildpfade, kommagetrennt (optional)");
if (!raw) return [];
const images = [];
for (const entry of raw.split(",").map((value) => value.trim()).filter(Boolean)) {
const sourcePath = path.resolve(process.cwd(), entry);
if (!fs.statSync(sourcePath, { throwIfNoEntry: false })?.isFile()) {
throw new Error(`Bild nicht gefunden: ${sourcePath}`);
}
const extension = path.extname(sourcePath).toLowerCase();
if (!supportedImageExtensions.has(extension)) throw new Error(`Nicht unterstuetztes Bildformat: ${sourcePath}`);
const fileName = path.basename(sourcePath);
const alt = await askRequired(`Alt-Text fuer ${fileName}`);
const caption = await ask(`Bildunterschrift fuer ${fileName}`, alt);
images.push({
sourcePath,
destinationPath: path.join(imageSourceRoot, String(year), fileName),
publicPath: `/images/content/${year}/${fileName}`,
alt,
caption,
});
}
return images;
}
function createReport({ slug, title, author, date, published, excerpt, images }) {
const name = componentName(slug);
const imageMarkup = images.map((image) =>
` <TripImage src=${JSON.stringify(image.publicPath)} alt=${JSON.stringify(image.alt)} caption=${JSON.stringify(image.caption)} />`,
);
const imports = images.length ? 'import TripImage from "@/components/TripImage";\n' : "";
const body = [" <p>Berichtstext hier schreiben.</p>", ...imageMarkup].join("\n");
return `${imports}import type { TripReportMetadata } from "./types";
export const metadata = {
slug: ${JSON.stringify(slug)},
title: ${JSON.stringify(title)},
author: ${JSON.stringify(author)},
createdAt: ${JSON.stringify(date.toISOString())},
updatedAt: ${JSON.stringify(date.toISOString())},
published: ${published},
excerpt: ${JSON.stringify(excerpt)},
plainText: ${JSON.stringify(excerpt)},
} satisfies TripReportMetadata;
export default function ${name}() {
return (
<>
${body}
</>
);
}
`;
}
function registerReport(registry, slug) {
const name = componentName(slug);
const importMarker = 'import type { TripReportModule } from "./types";';
const listMarker = "const tripReports: readonly TripReportModule[] = [";
if (!registry.includes(importMarker) || !registry.includes(listMarker)) {
throw new Error("Die Beitrags-Registry hat ein unbekanntes Format.");
}
const importLine = `import ${name}, { metadata as ${name.charAt(0).toLowerCase() + name.slice(1)}Metadata } from "./${slug}";\n`;
const entry = `\n { metadata: ${name.charAt(0).toLowerCase() + name.slice(1)}Metadata, Component: ${name} },`;
return registry
.replace(importMarker, `${importLine}${importMarker}`)
.replace(listMarker, `${listMarker}${entry}`);
}
async function main() {
console.log("Neuen TSX-Beitrag anlegen\n");
const title = await askRequired("Titel");
const author = await askRequired("Autor");
const date = parseDate(await ask("Berichtsdatum (YYYY-MM-DD)", new Date().toISOString().slice(0, 10)));
const year = date.getUTCFullYear();
const suggestedSlug = `${slugify(title)}-${year}`.replace(new RegExp(`-${year}-${year}$`), `-${year}`);
const slug = await askRequired("Slug", suggestedSlug);
if (slugify(slug) !== slug) throw new Error(`Ungueltiger Slug. Vorschlag: ${slugify(slug)}`);
const excerpt = await askRequired("Kurzbeschreibung fuer Uebersicht und SEO");
const published = await askBoolean("Direkt veroeffentlichen?", true);
const images = await collectImages(year);
const reportPath = path.join(reportsRoot, `${slug}.tsx`);
if (fs.existsSync(reportPath)) throw new Error(`Beitrag existiert bereits: ${reportPath}`);
for (const image of images) {
if (fs.existsSync(image.destinationPath)) throw new Error(`Zielbild existiert bereits: ${image.destinationPath}`);
}
const registry = fs.readFileSync(registryPath, "utf8");
const nextRegistry = registerReport(registry, slug);
fs.mkdirSync(path.join(imageSourceRoot, String(year)), { recursive: true });
for (const image of images) fs.copyFileSync(image.sourcePath, image.destinationPath, fs.constants.COPYFILE_EXCL);
fs.writeFileSync(reportPath, createReport({ slug, title, author, date, published, excerpt, images }), { flag: "wx" });
fs.writeFileSync(registryPath, nextRegistry);
console.log(`\nBeitrag angelegt: ${path.relative(process.cwd(), reportPath)}`);
console.log("Naechster Schritt: Berichtstext in der TSX-Datei ausarbeiten.");
}
try {
await main();
} catch (error) {
console.error(`\nFehler: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
} finally {
prompt.close();
}
+641
View File
@@ -0,0 +1,641 @@
import sharp from 'sharp';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const IMAGE_CACHE_ROOT = process.env.IMAGE_CACHE_ROOT?.trim();
const CACHE_ROOT = IMAGE_CACHE_ROOT
? path.resolve(IMAGE_CACHE_ROOT, 'manifests')
: path.join(ROOT, '.cache', 'image-manifests');
const CACHED_ORIGINALS_ROOT = IMAGE_CACHE_ROOT
? path.resolve(IMAGE_CACHE_ROOT, 'originals')
: null;
const CACHED_OUTPUTS_ROOT = IMAGE_CACHE_ROOT
? path.resolve(IMAGE_CACHE_ROOT, 'optimized')
: null;
const IMAGE_PIPELINE_VERSION = 1;
const PUBLIC_IMAGES_ROOT = path.join(ROOT, 'public/images');
const DEFAULT_MEDIA_ROOT = path.join(ROOT, '.media-source');
const LEGACY_MEDIA_ROOT = path.join(ROOT, 'assets');
// Optional project-specific alt texts keyed by source image name.
const altTexts = {};
const quality = { jpeg: 85, webp: 80, avif: 50, png: 9 };
const supportedExts = new Set(['.jpg', '.jpeg', '.png']);
const SHOULD_PRUNE = process.argv.includes('--prune');
const REQUIRE_IMAGE_SOURCES = process.env.IMAGES_SOURCE_REQUIRED === 'true';
function resolveSourceDir(relativePath) {
const envRoot = process.env.MEDIA_SOURCE_ROOT?.trim();
const candidates = [
envRoot ? path.resolve(ROOT, envRoot, relativePath) : null,
path.join(DEFAULT_MEDIA_ROOT, relativePath),
path.join(LEGACY_MEDIA_ROOT, relativePath),
].filter(Boolean);
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return path.join(DEFAULT_MEDIA_ROOT, relativePath);
}
/**
* Pipeline-Konfigurationen
*/
const pipelines = [
{
name: 'content',
sourceDir: resolveSourceDir('original-content'),
outputDir: path.join(PUBLIC_IMAGES_ROOT, 'content'),
outputRelativeDir: 'content',
recursive: true,
sizes: [
{ suffix: '-sm', width: 800, height: 600 },
{ suffix: '-md', width: 1000, height: 750 },
],
formats: ['original', 'webp', 'avif'],
deleteOriginal: false,
generateJson: false,
},
{
name: 'slider',
sourceDir: resolveSourceDir('original-slider'),
outputDir: path.join(PUBLIC_IMAGES_ROOT, 'slider'),
outputRelativeDir: 'slider',
recursive: false,
sizes: [
{ suffix: '-sm', width: 640, height: 480 },
{ suffix: '-md', width: 1280, height: 960 },
{ suffix: '', width: 1920, height: 1440 },
],
formats: ['jpg', 'webp', 'avif'],
deleteOriginal: false,
generateJson: true,
jsonFileName: 'slider-images.json',
},
{
name: 'secondary-slider',
sourceDir: resolveSourceDir('original-secondary-slider'),
outputDir: path.join(PUBLIC_IMAGES_ROOT, 'secondary-slider'),
outputRelativeDir: 'secondary-slider',
recursive: false,
sizes: [
{ suffix: '-sm', width: 640, height: 480 },
{ suffix: '-md', width: 1280, height: 960 },
{ suffix: '', width: 1920, height: 1440 },
],
formats: ['jpg', 'webp', 'avif'],
deleteOriginal: false,
generateJson: true,
jsonFileName: 'slider-images.json',
},
{
name: 'herosections',
sourceDir: resolveSourceDir('original-herosections'),
outputDir: path.join(PUBLIC_IMAGES_ROOT, 'backgrounds'),
outputRelativeDir: 'backgrounds',
recursive: false,
sizes: [
{ suffix: '', width: 1920, height: 1440 },
{ suffix: '-mobile', width: 1024, height: 768 },
],
formats: ['jpg', 'webp', 'avif'],
deleteOriginal: false,
generateJson: false,
},
];
function isVariantFile(fileName, sizeSuffixes) {
const ext = path.extname(fileName).toLowerCase();
const base = path.basename(fileName, path.extname(fileName)).toLowerCase();
if (ext === '.webp' || ext === '.avif') return true;
if (base.endsWith('-temp')) return true;
return sizeSuffixes.some((s) => s && base.endsWith(s));
}
function collectImages(dirPath, recursive, sizeSuffixes) {
const collected = [];
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory() && recursive) {
collected.push(...collectImages(fullPath, true, sizeSuffixes));
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!supportedExts.has(ext)) continue;
if (isVariantFile(entry.name, sizeSuffixes)) continue;
collected.push(fullPath);
}
return collected;
}
function collectBaseImageRecords(dirPath, recursive, sizeSuffixes) {
if (!fs.existsSync(dirPath)) return [];
return collectImages(dirPath, recursive, sizeSuffixes)
.map((filePath) => ({
name: path.basename(filePath, path.extname(filePath)),
ext: path.extname(filePath),
relDir: path.relative(dirPath, path.dirname(filePath)),
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
function collectOutputFileNames(dirPath) {
if (!fs.existsSync(dirPath)) return [];
return fs.readdirSync(dirPath, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name);
}
function readJsonManifest(manifestPath) {
try {
if (!fs.existsSync(manifestPath)) return [];
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function getBaseOutputRecords(outputFileNames, sizeSuffixes) {
return outputFileNames
.filter((fileName) => {
const ext = path.extname(fileName).toLowerCase();
return supportedExts.has(ext) && !isVariantFile(fileName, sizeSuffixes);
})
.map((fileName) => ({
name: path.basename(fileName, path.extname(fileName)),
ext: path.extname(fileName),
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
function manifestKeyForRecord(record) {
return record.relDir ? `${record.relDir}/${record.name}` : record.name;
}
function stripGeneratedSuffix(baseName, sizeSuffixes) {
const sortedSuffixes = [...sizeSuffixes]
.filter(Boolean)
.sort((a, b) => b.length - a.length);
for (const suffix of sortedSuffixes) {
if (baseName.endsWith(suffix)) {
return baseName.slice(0, -suffix.length);
}
}
return baseName;
}
function collectGeneratedOutputFiles(dirPath, rootDir = dirPath) {
if (!fs.existsSync(dirPath)) return [];
const files = [];
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
files.push(...collectGeneratedOutputFiles(fullPath, rootDir));
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!new Set([...supportedExts, '.webp', '.avif']).has(ext)) continue;
files.push({
path: fullPath,
fileName: entry.name,
relDir: path.relative(rootDir, path.dirname(fullPath)),
});
}
return files;
}
function removeEmptyDirs(dirPath, rootDir) {
if (!fs.existsSync(dirPath) || dirPath === rootDir) return;
if (fs.readdirSync(dirPath).length > 0) return;
fs.rmdirSync(dirPath);
removeEmptyDirs(path.dirname(dirPath), rootDir);
}
function pruneGeneratedAssets(config, sourceRecords, sizeSuffixes) {
const sourceKeys = new Set(sourceRecords.map(manifestKeyForRecord));
let removed = 0;
for (const outputFile of collectGeneratedOutputFiles(config.outputDir)) {
const baseName = stripGeneratedSuffix(
path.basename(outputFile.fileName, path.extname(outputFile.fileName)),
sizeSuffixes,
);
const key = outputFile.relDir ? `${outputFile.relDir}/${baseName}` : baseName;
if (sourceKeys.has(key)) continue;
fs.rmSync(outputFile.path);
removeEmptyDirs(path.dirname(outputFile.path), config.outputDir);
removed++;
}
if (removed > 0) {
console.log(`[${config.name}] ${removed} verwaiste Bilddatei(en) entfernt`);
}
}
export function buildImageManifestEntries({
existingManifest = [],
outputFileNames = [],
sourceRecords = [],
sizeSuffixes = [],
altTextsByName = altTexts,
sourceIsComplete = false,
} = {}) {
const outputRecords = getBaseOutputRecords(outputFileNames, sizeSuffixes);
const outputNames = new Set(outputRecords.map(manifestKeyForRecord));
const sourceNames = new Set(sourceRecords.map(manifestKeyForRecord));
const sourceByName = new Map(sourceRecords.map((record) => [manifestKeyForRecord(record), record]));
const outputByName = new Map(outputRecords.map((record) => [manifestKeyForRecord(record), record]));
const webpNames = new Set(outputFileNames
.filter((fileName) => path.extname(fileName).toLowerCase() === '.webp')
.map((fileName) => path.basename(fileName, path.extname(fileName)).replace(/-(sm|md|mobile)$/i, '')));
const avifNames = new Set(outputFileNames
.filter((fileName) => path.extname(fileName).toLowerCase() === '.avif')
.map((fileName) => path.basename(fileName, path.extname(fileName)).replace(/-(sm|md|mobile)$/i, '')));
const entries = new Map();
const addEntry = (record, existing = {}) => {
if (!record?.name || !record?.ext) return;
const key = manifestKeyForRecord(record);
entries.set(key, {
name: record.name,
ext: record.ext,
alt: existing.alt ?? altTextsByName[record.name],
webp: webpNames.has(record.name),
avif: avifNames.has(record.name),
});
};
for (const item of existingManifest) {
if (!item?.name) continue;
const key = manifestKeyForRecord(item);
if (sourceIsComplete && !sourceNames.has(key)) continue;
if (!sourceIsComplete && !outputNames.has(key) && !sourceNames.has(key)) continue;
const record = sourceByName.get(key) ?? outputByName.get(key) ?? item;
addEntry(record, item);
}
const defaultRecords = sourceIsComplete || existingManifest.length > 0 ? sourceRecords : outputRecords;
for (const record of defaultRecords) {
if (!entries.has(manifestKeyForRecord(record))) {
addEntry(record);
}
}
return [...entries.values()]
.filter((entry) => entry.name && entry.ext)
.sort((a, b) => a.name.localeCompare(b.name));
}
async function collectOutputMetadata(outputDir, outputFileNames) {
const metadata = new Map();
await Promise.all(outputFileNames.map(async (fileName) => {
const ext = path.extname(fileName).toLowerCase();
if (![...supportedExts, '.webp', '.avif'].includes(ext)) return;
const dimensions = await sharp(path.join(outputDir, fileName)).metadata();
if (!dimensions.width || !dimensions.height) return;
metadata.set(fileName, {
width: dimensions.width,
height: dimensions.height,
});
}));
return metadata;
}
export function addImageManifestMetadata({
entries = [],
outputMetadata = new Map(),
sizes = [],
fallbackExtension = '.jpg',
} = {}) {
return entries.flatMap((entry) => {
const variantsForExtension = (extension) => sizes.flatMap(({ suffix }) => {
const file = `${entry.name}${suffix}${extension}`;
const dimensions = outputMetadata.get(file);
return dimensions ? [{ file, ...dimensions }] : [];
});
const fallback = variantsForExtension(fallbackExtension);
const base = fallback.find((variant) => variant.file === `${entry.name}${fallbackExtension}`)
?? fallback.at(-1);
if (!base) return [];
return [{
...entry,
width: base.width,
height: base.height,
variants: {
fallback,
webp: variantsForExtension('.webp'),
avif: variantsForExtension('.avif'),
},
}];
});
}
async function writeImageManifest(config, outDir, sourceRecords, sizeSuffixes, sourceIsComplete) {
const jsonName = config.jsonFileName || 'images.json';
const jsonPath = path.join(outDir, jsonName);
const outputFileNames = collectOutputFileNames(outDir);
const entries = buildImageManifestEntries({
existingManifest: readJsonManifest(jsonPath),
outputFileNames,
sourceRecords,
sizeSuffixes,
sourceIsComplete,
});
const fallbackExtension = config.formats.includes('original')
? entries[0]?.ext
: '.jpg';
const outputMetadata = await collectOutputMetadata(outDir, outputFileNames);
const imagesList = addImageManifestMetadata({
entries,
outputMetadata,
sizes: config.sizes,
fallbackExtension,
});
fs.writeFileSync(jsonPath, JSON.stringify(imagesList, null, 2));
}
function fileHash(filePath) {
return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex');
}
function getEncoder(extLower) {
if (extLower === '.png') {
return (pipeline) => pipeline.png({ compressionLevel: quality.png, adaptiveFiltering: true });
}
return (pipeline) => pipeline.jpeg({ quality: quality.jpeg, progressive: true, mozjpeg: true });
}
function getFormatPriority(format) {
if (format === 'original' || format === 'jpg') return 0;
if (format === 'webp') return 1;
if (format === 'avif') return 2;
return 3;
}
function replaceFile(fromPath, toPath) {
if (fs.existsSync(toPath)) {
fs.rmSync(toPath);
}
fs.renameSync(fromPath, toPath);
}
function copyFileWithParents(sourcePath, destinationPath) {
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
fs.copyFileSync(sourcePath, destinationPath);
}
function pruneCachedOriginals(config, sourceRecords) {
if (!CACHED_ORIGINALS_ROOT) return;
const cachedDir = path.join(CACHED_ORIGINALS_ROOT, config.name);
const sourceKeys = new Set(sourceRecords.map(manifestKeyForRecord));
for (const cachedFile of collectGeneratedOutputFiles(cachedDir)) {
const baseName = path.basename(cachedFile.fileName, path.extname(cachedFile.fileName));
const key = cachedFile.relDir ? `${cachedFile.relDir}/${baseName}` : baseName;
if (sourceKeys.has(key)) continue;
fs.rmSync(cachedFile.path);
removeEmptyDirs(path.dirname(cachedFile.path), cachedDir);
}
}
function mirrorCachedOutput(config, outDir) {
if (!CACHED_OUTPUTS_ROOT) return;
// These directories contain only generated variants. Replacing them makes a
// removed Git source disappear from the image that is being built as well.
fs.rmSync(config.outputDir, { recursive: true, force: true });
fs.mkdirSync(path.dirname(config.outputDir), { recursive: true });
fs.cpSync(outDir, config.outputDir, { recursive: true });
}
function hasFallbackVariants(config, fileOutDir, baseName, ext) {
const fallbackFormat = config.formats.includes('original') ? 'original' : 'jpg';
const fallbackExt = fallbackFormat === 'original' ? ext : '.jpg';
return config.sizes.every((size) =>
fs.existsSync(path.join(fileOutDir, `${baseName}${size.suffix}${fallbackExt}`))
);
}
async function runPipeline(config) {
if (!fs.existsSync(config.sourceDir)) {
const outDir = config.outputDir ?? config.sourceDir;
const hasGeneratedAssets = fs.existsSync(outDir);
if (REQUIRE_IMAGE_SOURCES || !hasGeneratedAssets) {
throw new Error(`[${config.name}] Quellverzeichnis fehlt: ${config.sourceDir}`);
}
console.warn(
`[${config.name}] Quellverzeichnis fehlt (${config.sourceDir}). ` +
`Verwende vorhandene optimierte Assets in ${outDir}.`
);
return;
}
const outDir = CACHED_OUTPUTS_ROOT
? path.join(CACHED_OUTPUTS_ROOT, config.outputRelativeDir)
: (config.outputDir ?? config.sourceDir);
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
if (!fs.existsSync(CACHE_ROOT)) {
fs.mkdirSync(CACHE_ROOT, { recursive: true });
}
// Manifest for hash caching outside the public asset tree
const manifestPath = path.join(CACHE_ROOT, `${config.name}.json`);
let manifest = {};
try {
if (fs.existsSync(manifestPath)) {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
}
} catch { manifest = {}; }
const sizeSuffixes = config.sizes.map((s) => s.suffix);
const files = collectImages(config.sourceDir, config.recursive, sizeSuffixes);
const sourceRecords = collectBaseImageRecords(config.sourceDir, config.recursive, sizeSuffixes);
const sourceIsComplete = fs.existsSync(config.sourceDir);
const outputConfig = { ...config, outputDir: outDir };
let manifestChanged = false;
if (SHOULD_PRUNE && sourceIsComplete) {
pruneGeneratedAssets(outputConfig, sourceRecords, sizeSuffixes);
pruneCachedOriginals(config, sourceRecords);
for (const key of Object.keys(manifest)) {
if (!sourceRecords.some((record) => manifestKeyForRecord(record) === key)) {
delete manifest[key];
manifestChanged = true;
}
}
}
if (files.length === 0) {
console.log(`[${config.name}] Keine Bilder gefunden.`);
if (config.generateJson) {
await writeImageManifest(config, outDir, sourceRecords, sizeSuffixes, sourceIsComplete);
}
if (manifestChanged) {
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
}
mirrorCachedOutput(config, outDir);
return;
}
console.log(`[${config.name}] ${files.length} Bilder gefunden`);
let processed = 0;
for (const inputPath of files) {
const file = path.basename(inputPath);
const ext = path.extname(file);
const extLower = ext.toLowerCase();
const baseName = path.basename(file, ext);
const relDir = path.relative(config.sourceDir, path.dirname(inputPath));
const fileOutDir = relDir ? path.join(outDir, relDir) : outDir;
const hash = fileHash(inputPath);
const manifestKey = relDir ? `${relDir}/${baseName}` : baseName;
if (CACHED_ORIGINALS_ROOT) {
const cachedOriginalPath = path.join(CACHED_ORIGINALS_ROOT, config.name, relDir, file);
if (manifest[manifestKey]?.hash !== hash || !fs.existsSync(cachedOriginalPath)) {
copyFileWithParents(inputPath, cachedOriginalPath);
}
}
if (
manifest[manifestKey]?.hash === hash &&
manifest[manifestKey]?.optimized &&
manifest[manifestKey]?.version === IMAGE_PIPELINE_VERSION &&
hasFallbackVariants(config, fileOutDir, baseName, ext)
) {
continue;
}
if (!fs.existsSync(fileOutDir)) {
fs.mkdirSync(fileOutDir, { recursive: true });
}
let totalVariantSize = 0;
const originalSize = fs.statSync(inputPath).size;
const orderedFormats = [...config.formats].sort((left, right) => getFormatPriority(left) - getFormatPriority(right));
for (const size of config.sizes) {
const resizeOpts = { width: size.width, height: size.height, fit: 'inside', withoutEnlargement: true };
let fallbackVariantSize = Number.POSITIVE_INFINITY;
for (const format of orderedFormats) {
let outputPath;
let pipeline = sharp(inputPath).rotate().resize(resizeOpts);
if (format === 'original') {
outputPath = path.join(fileOutDir, `${baseName}${size.suffix}${ext}`);
pipeline = getEncoder(extLower)(pipeline);
} else if (format === 'jpg') {
outputPath = path.join(fileOutDir, `${baseName}${size.suffix}.jpg`);
pipeline = pipeline.jpeg({ quality: quality.jpeg, progressive: true, mozjpeg: true });
} else if (format === 'webp') {
outputPath = path.join(fileOutDir, `${baseName}${size.suffix}.webp`);
pipeline = pipeline.webp({ quality: quality.webp });
} else if (format === 'avif') {
outputPath = path.join(fileOutDir, `${baseName}${size.suffix}.avif`);
pipeline = pipeline.avif({ quality: quality.avif });
}
const tempPath = `${outputPath}.tmp`;
if (fs.existsSync(tempPath)) {
fs.rmSync(tempPath);
}
await pipeline.toFile(tempPath);
const candidateSize = fs.statSync(tempPath).size;
const existingSize = fs.existsSync(outputPath) ? fs.statSync(outputPath).size : null;
const isDerivedVariant = format === 'webp' || format === 'avif';
if (isDerivedVariant && Number.isFinite(fallbackVariantSize) && candidateSize >= fallbackVariantSize) {
fs.rmSync(tempPath);
if (existingSize !== null && existingSize < fallbackVariantSize) {
totalVariantSize += existingSize;
} else if (existingSize !== null && SHOULD_PRUNE) {
fs.rmSync(outputPath);
}
continue;
}
if (existingSize !== null && existingSize <= candidateSize) {
fs.rmSync(tempPath);
totalVariantSize += existingSize;
if (format === 'original' || format === 'jpg') {
fallbackVariantSize = existingSize;
}
continue;
}
replaceFile(tempPath, outputPath);
totalVariantSize += candidateSize;
if (format === 'original' || format === 'jpg') {
fallbackVariantSize = candidateSize;
}
}
}
manifest[manifestKey] = {
hash,
optimized: true,
originalSize,
version: IMAGE_PIPELINE_VERSION,
timestamp: new Date().toISOString(),
};
manifestChanged = true;
if (config.deleteOriginal) {
fs.rmSync(inputPath);
}
if (totalVariantSize < originalSize) {
const saving = (((originalSize - totalVariantSize) / originalSize) * 100).toFixed(1);
console.log(` ${file}${saving}% Ersparnis`);
} else {
console.log(` ${file} → Varianten aktualisiert`);
}
processed++;
}
// Generate JSON manifest if needed
if (config.generateJson) {
await writeImageManifest(config, outDir, sourceRecords, sizeSuffixes, sourceIsComplete);
}
if (manifestChanged) {
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
}
mirrorCachedOutput(config, outDir);
if (processed > 0) {
console.log(`[${config.name}] ${processed} Bild(er) optimiert`);
} else {
console.log(`[${config.name}] Alle Bilder aktuell`);
}
}
// Run all pipelines
if (path.resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
for (const pipeline of pipelines) {
await runPipeline(pipeline);
}
}
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
set -eu
# The runner's Docker volume survives individual checkouts. It contains a
# copy of the Git originals, optimized variants, and the hash manifests used
# by optimize-images.mjs.
cache_namespace=$(printf '%s' "${IMAGE_CACHE_NAMESPACE:-shared}" | tr -c 'A-Za-z0-9_.-' '-')
case "$cache_namespace" in
''|*[!A-Za-z0-9_.-]*)
echo "IMAGE_CACHE_NAMESPACE may only contain letters, digits, ., _ and -" >&2
exit 1
;;
esac
cache_volume="website-starter-image-cache-${cache_namespace}"
docker volume create "$cache_volume" >/dev/null
app_dir="$(pwd)"
run_image_prune() {
docker run --rm "$@" \
--mount "type=volume,src=$cache_volume,dst=/image-cache" \
--workdir "$app_dir" \
--env IMAGE_CACHE_ROOT=/image-cache \
--env HOST_UID="$(id -u)" \
--env HOST_GID="$(id -g)" \
node:24-slim \
sh -ec '
npm run images:prune
chown -R "$HOST_UID:$HOST_GID" /image-cache "$PWD/public/images/content" "$PWD/public/images/slider" "$PWD/public/images/secondary-slider" "$PWD/public/images/backgrounds"
'
}
# In Gitea Actions, the Docker daemon cannot bind-mount the job container's
# workspace path. Share its existing workspace volume instead.
if docker inspect "$(hostname)" >/dev/null 2>&1; then
run_image_prune --volumes-from "$(hostname)"
else
run_image_prune --mount "type=bind,src=$app_dir,dst=$app_dir"
fi
+86
View File
@@ -0,0 +1,86 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
const root = path.resolve(import.meta.dirname, "..");
const appPort = Number(process.env.E2E_PORT ?? 3100);
const fixturePort = Number(process.env.FIXTURE_PORT ?? 3101);
const baseUrl = `http://127.0.0.1:${appPort}`;
const children = [];
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd: root, env: process.env, stdio: "inherit", ...options });
child.once("error", reject);
child.once("exit", (code) => code === 0
? resolve()
: reject(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`)));
});
}
function start(command, args, env = {}) {
const child = spawn(command, args, {
cwd: root,
env: { ...process.env, ...env },
stdio: "inherit",
});
children.push(child);
return child;
}
async function waitFor(url) {
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {
// The process is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${url}`);
}
async function stopChildren() {
await Promise.all(children.map((child) => new Promise((resolve) => {
if (child.exitCode !== null) return resolve();
child.once("exit", resolve);
child.kill("SIGTERM");
setTimeout(() => {
if (child.exitCode === null) child.kill("SIGKILL");
}, 3_000).unref();
})));
}
async function prepareStandaloneServer() {
const standaloneRoot = path.join(root, ".next", "standalone");
await fs.rm(path.join(standaloneRoot, "public"), { recursive: true, force: true });
await fs.rm(path.join(standaloneRoot, ".next", "static"), { recursive: true, force: true });
await fs.cp(path.join(root, "public"), path.join(standaloneRoot, "public"), { recursive: true });
await fs.cp(
path.join(root, ".next", "static"),
path.join(standaloneRoot, ".next", "static"),
{ recursive: true },
);
}
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
try {
await run(npm, ["run", "build"]);
await prepareStandaloneServer();
start(process.execPath, ["e2e/fixture-server.mjs"], { FIXTURE_PORT: String(fixturePort) });
start(process.execPath, [".next/standalone/server.js"], {
CALENDAR_URL: `http://127.0.0.1:${fixturePort}/calendar.ics`,
APP_URL: baseUrl,
CORS_ORIGIN: baseUrl,
PORT: String(appPort),
HOSTNAME: "127.0.0.1",
});
await waitFor(`${baseUrl}/api/events`);
await run(process.execPath, ["--test", "e2e/smoke.e2e.mjs"], {
env: { ...process.env, E2E_BASE_URL: baseUrl },
});
} finally {
await stopChildren();
}
+103
View File
@@ -0,0 +1,103 @@
#!/bin/sh
set -eu
require_value() {
key="$1"
value="$2"
if [ -z "$value" ]; then
echo "Missing required workflow configuration: $key" >&2
exit 1
fi
}
require_value "DEPLOY_TARGET" "${DEPLOY_TARGET:-}"
require_value "CALENDAR_URL" "${CALENDAR_URL:-}"
require_value "PRIMARY_DOMAIN" "${PRIMARY_DOMAIN:-}"
require_value "TRAEFIK_CERTRESOLVER" "${TRAEFIK_CERTRESOLVER:-}"
require_value "REGISTRY" "${REGISTRY:-}"
require_value "APP_IMAGE_REPOSITORY" "${APP_IMAGE_REPOSITORY:-}"
require_value "REGISTRY_USERNAME" "${REGISTRY_USERNAME:-}"
require_value "REGISTRY_TOKEN" "${REGISTRY_TOKEN:-}"
APP_URL="${APP_URL:-https://$PRIMARY_DOMAIN}"
CORS_ORIGIN="${CORS_ORIGIN:-$APP_URL}"
case "$DEPLOY_TARGET" in
dev)
default_stack_name="website-starter-dev"
default_traefik_network="traefik-external"
;;
master|production)
default_stack_name="website-starter-prod"
default_traefik_network="traefik"
;;
*)
echo "Unsupported deployment target: $DEPLOY_TARGET" >&2
exit 1
;;
esac
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$default_stack_name}"
TRAEFIK_STACK_NAME="${TRAEFIK_STACK_NAME:-$COMPOSE_PROJECT_NAME}"
TRAEFIK_NETWORK_NAME="${TRAEFIK_NETWORK_NAME:-$default_traefik_network}"
TRIVY_IMAGE="${TRIVY_IMAGE:-aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969}"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
DOCKER_BUILDKIT="${DOCKER_BUILDKIT:-1}"
revision="$(git rev-parse --short=12 HEAD)"
build_timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
APP_IMAGE_TAG="scheduled-$DEPLOY_TARGET-$revision-$build_timestamp"
APP_IMAGE_NAME="$APP_IMAGE_REPOSITORY:$APP_IMAGE_TAG"
export CALENDAR_URL
export PRIMARY_DOMAIN
export APP_URL
export CORS_ORIGIN
export COMPOSE_PROJECT_NAME
export TRAEFIK_STACK_NAME
export TRAEFIK_CERTRESOLVER
export TRAEFIK_NETWORK_NAME
export APP_IMAGE_NAME
export TRIVY_IMAGE
export DOCKER_BUILDKIT
export DEPLOY_MODE=app
echo "Scheduled rebuild target: $DEPLOY_TARGET"
echo "Git revision: $revision"
echo "App image: $APP_IMAGE_NAME"
docker build --pull \
--build-arg BUILD_DATE="$BUILD_DATE" \
--build-arg VCS_REF="$revision" \
--build-arg APP_URL="$APP_URL" \
--build-arg APP_NAME="${APP_NAME:-Example Website}" \
--build-arg COMPANY_NAME="${COMPANY_NAME:-Example Company}" \
--build-arg CONTACT_EMAIL="${CONTACT_EMAIL:-info@example.com}" \
--build-arg CONTACT_PHONE="${CONTACT_PHONE:-+49 000 000000}" \
--build-arg CONTACT_ADDRESS="${CONTACT_ADDRESS:-Example Street 1, 12345 Example City}" \
-f Dockerfile \
-t "$APP_IMAGE_NAME" \
.
echo "Reporting app runtime image vulnerabilities..."
sh scripts/trivy-scan.sh vuln-image "$APP_IMAGE_NAME"
echo "Blocking app critical runtime vulnerabilities..."
TRIVY_SEVERITY=CRITICAL TRIVY_EXIT_CODE=1 sh scripts/trivy-scan.sh vuln-image "$APP_IMAGE_NAME"
docker_config="$(mktemp -d)"
cleanup_registry_credentials() {
DOCKER_CONFIG="$docker_config" docker logout "$REGISTRY" >/dev/null 2>&1 || true
rm -rf "$docker_config"
}
trap cleanup_registry_credentials EXIT
trap 'exit 1' HUP INT TERM
export DOCKER_CONFIG="$docker_config"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
docker push "$APP_IMAGE_NAME"
docker logout "$REGISTRY" >/dev/null
rm -rf "$docker_config"
trap - EXIT HUP INT TERM
unset DOCKER_CONFIG REGISTRY_USERNAME REGISTRY_TOKEN
sh scripts/deploy-and-verify.sh
+83
View File
@@ -0,0 +1,83 @@
import fs from "node:fs";
import path from "node:path";
import sharp from "sharp";
const PUBLIC_IMAGES_DIR = path.resolve(import.meta.dirname, "../public/images");
const SUPPORTED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]);
const METADATA_FIELDS = ["exif", "icc", "iptc", "xmp"];
const CHECK_ONLY = process.argv.includes("--check");
function collectImages(directory) {
if (!fs.existsSync(directory)) return [];
const files = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...collectImages(fullPath));
continue;
}
if (entry.isFile() && SUPPORTED_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
files.push(fullPath);
}
}
return files;
}
function getEncoder(pipeline, extension) {
if (extension === ".png") {
return pipeline.png({ compressionLevel: 9, adaptiveFiltering: true });
}
if (extension === ".webp") {
return pipeline.webp({ quality: 80 });
}
if (extension === ".avif") {
return pipeline.avif({ quality: 50 });
}
return pipeline.jpeg({ quality: 85, progressive: true, mozjpeg: true });
}
async function hasMetadata(filePath) {
const metadata = await sharp(filePath).metadata();
return METADATA_FIELDS.some((field) => Boolean(metadata[field]));
}
async function stripMetadata(filePath) {
const extension = path.extname(filePath).toLowerCase();
const tempPath = `${filePath}.tmp`;
if (fs.existsSync(tempPath)) {
fs.rmSync(tempPath);
}
const pipeline = sharp(filePath).rotate();
await getEncoder(pipeline, extension).toFile(tempPath);
fs.renameSync(tempPath, filePath);
}
const files = collectImages(PUBLIC_IMAGES_DIR);
const filesWithMetadata = [];
for (const file of files) {
if (await hasMetadata(file)) {
filesWithMetadata.push(file);
}
}
if (CHECK_ONLY) {
for (const file of filesWithMetadata) {
console.error(`[images:check-metadata] Metadata found: ${file}`);
}
if (filesWithMetadata.length > 0) {
process.exitCode = 1;
} else {
console.log(`[images:check-metadata] ${files.length} images checked; no metadata blocks found.`);
}
} else {
for (const file of filesWithMetadata) {
await stripMetadata(file);
}
console.log(
`[images:strip-metadata] ${filesWithMetadata.length} of ${files.length} images rewritten.`,
);
}
+123
View File
@@ -0,0 +1,123 @@
#!/bin/sh
set -eu
TRIVY_IMAGE="${TRIVY_IMAGE:-aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969}"
TRIVY_CACHE_DIR="${TRIVY_CACHE_DIR:-.cache/trivy}"
TRIVY_SEVERITY="${TRIVY_SEVERITY:-HIGH,CRITICAL}"
TRIVY_EXIT_CODE="${TRIVY_EXIT_CODE:-0}"
TRIVY_IGNORE_UNFIXED="${TRIVY_IGNORE_UNFIXED:-true}"
TRIVY_IMAGE_SOURCE="${TRIVY_IMAGE_SOURCE:-docker}"
usage() {
echo "Usage: $0 secret-fs|vuln-fs|vuln-image <target>" >&2
}
if [ "$#" -ne 2 ]; then
usage
exit 2
fi
mode="$1"
target="$2"
mkdir -p "$TRIVY_CACHE_DIR"
common_args="--cache-dir /trivy-cache"
ignore_unfixed_arg=""
if [ "$TRIVY_IGNORE_UNFIXED" = "true" ]; then
ignore_unfixed_arg="--ignore-unfixed"
fi
case "$mode" in
secret-fs)
container_id="$(docker create \
-v "$(pwd)/$TRIVY_CACHE_DIR:/trivy-cache" \
"$TRIVY_IMAGE" \
fs \
$common_args \
--scanners secret \
--exit-code 1 \
--skip-dirs /work/.git \
--skip-dirs /work/.agents \
--skip-dirs /work/.cache \
--skip-dirs /work/.codex \
--skip-dirs /work/.opencode \
--skip-dirs /work/.venv \
--skip-dirs /work/frontend \
--skip-dirs /work/node_modules \
--skip-dirs /work/.next \
/work)"
trap 'docker rm -f "$container_id" >/dev/null 2>&1 || true' EXIT
docker cp "$target" "$container_id:/work"
docker start -a "$container_id"
;;
vuln-fs)
container_id="$(docker create \
-v "$(pwd)/$TRIVY_CACHE_DIR:/trivy-cache" \
"$TRIVY_IMAGE" \
fs \
$common_args \
--scanners vuln \
--severity "$TRIVY_SEVERITY" \
$ignore_unfixed_arg \
--exit-code "$TRIVY_EXIT_CODE" \
--skip-dirs /work/.git \
--skip-dirs /work/.agents \
--skip-dirs /work/.cache \
--skip-dirs /work/.codex \
--skip-dirs /work/.opencode \
--skip-dirs /work/.venv \
--skip-dirs /work/frontend \
--skip-dirs /work/node_modules \
--skip-dirs /work/.next \
"/work/$target")"
trap 'docker rm -f "$container_id" >/dev/null 2>&1 || true' EXIT
docker cp . "$container_id:/work"
docker start -a "$container_id"
;;
vuln-image)
case "$TRIVY_IMAGE_SOURCE" in
docker)
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$(pwd)/$TRIVY_CACHE_DIR:/trivy-cache" \
"$TRIVY_IMAGE" \
image \
$common_args \
--scanners vuln \
--severity "$TRIVY_SEVERITY" \
$ignore_unfixed_arg \
--exit-code "$TRIVY_EXIT_CODE" \
"$target"
;;
remote)
docker_config_dir="${DOCKER_CONFIG:-$HOME/.docker}"
if [ ! -f "$docker_config_dir/config.json" ]; then
echo "Docker registry credentials not found: $docker_config_dir/config.json" >&2
exit 1
fi
docker run --rm \
-e DOCKER_CONFIG=/root/.docker \
-v "$docker_config_dir/config.json:/root/.docker/config.json:ro" \
-v "$(pwd)/$TRIVY_CACHE_DIR:/trivy-cache" \
"$TRIVY_IMAGE" \
image \
$common_args \
--image-src remote \
--scanners vuln \
--severity "$TRIVY_SEVERITY" \
$ignore_unfixed_arg \
--exit-code "$TRIVY_EXIT_CODE" \
"$target"
;;
*)
echo "Unsupported TRIVY_IMAGE_SOURCE: $TRIVY_IMAGE_SOURCE (expected docker or remote)" >&2
exit 2
;;
esac
;;
*)
usage
exit 2
;;
esac
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect } from "vitest";
import { getEffectiveEventEnd, formatEventDateRange } from "../utils/eventFormat";
import type { CalendarEvent } from "../types";
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: "1",
title: "Test Event",
description: null,
location: null,
start: "2025-06-15T14:00:00+02:00",
end: "2025-06-15T18:00:00+02:00",
allDay: false,
timeZone: "Europe/Berlin",
special: false,
...overrides,
};
}
describe("getEffectiveEventEnd", () => {
it("returns end date unchanged for timed events", () => {
const event = makeEvent();
const end = getEffectiveEventEnd(event);
expect(end).toEqual(new Date("2025-06-15T18:00:00+02:00"));
});
it("sets allDay event end to 23:59:59.999", () => {
const event = makeEvent({
allDay: true,
start: "2025-06-15T00:00:00+02:00",
end: "2025-06-16T00:00:00+02:00",
});
const end = getEffectiveEventEnd(event);
expect(end.getHours()).toBe(23);
expect(end.getMinutes()).toBe(59);
expect(end.getSeconds()).toBe(59);
expect(end.getMilliseconds()).toBe(999);
});
});
describe("formatEventDateRange", () => {
it("formats same-day timed event with time range", () => {
const event = makeEvent();
const result = formatEventDateRange(event);
expect(result).toContain("15. Juni 2025");
expect(result).toContain("14:00");
expect(result).toContain("18:00");
});
it("formats single all-day event without time", () => {
const event = makeEvent({
allDay: true,
start: "2025-06-15T00:00:00+02:00",
end: "2025-06-15T00:00:00+02:00",
});
const result = formatEventDateRange(event);
expect(result).toContain("15. Juni 2025");
expect(result).not.toContain(":");
});
it("formats multi-day all-day event as date range", () => {
const event = makeEvent({
allDay: true,
start: "2025-08-01T00:00:00+02:00",
end: "2025-08-08T00:00:00+02:00",
});
const result = formatEventDateRange(event);
expect(result).toContain("1. August 2025");
expect(result).toContain("7. August 2025");
expect(result).toContain("");
});
it("formats multi-day timed event with both dates and times", () => {
const event = makeEvent({
start: "2025-08-01T10:00:00+02:00",
end: "2025-08-03T16:00:00+02:00",
});
const result = formatEventDateRange(event);
expect(result).toContain("1. August 2025");
expect(result).toContain("3. August 2025");
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
getCalendarGroups,
getSpecialEvents,
} from "../utils/eventsRender";
import type { CalendarEvent } from "../types";
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(10, 0, 0, 0);
const tomorrowEnd = new Date(tomorrow);
tomorrowEnd.setHours(12, 0, 0, 0);
const dayAfterTomorrow = new Date(tomorrow);
dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 1);
dayAfterTomorrow.setHours(17, 0, 0, 0);
const dayAfterTomorrowEnd = new Date(dayAfterTomorrow);
dayAfterTomorrowEnd.setHours(19, 0, 0, 0);
const events: CalendarEvent[] = [
{
id: "1",
title: "Regeltermin",
description: null,
location: "Beispielort",
start: dayAfterTomorrow.toISOString(),
end: dayAfterTomorrowEnd.toISOString(),
allDay: false,
timeZone: "Europe/Berlin",
special: false,
},
{
id: "2",
title: "Sondertermin",
description: null,
location: "Beispielstadt",
start: tomorrow.toISOString(),
end: tomorrowEnd.toISOString(),
allDay: false,
timeZone: "Europe/Berlin",
special: true,
},
];
describe("eventsRender helpers", () => {
it("returns only special events in hero mode", () => {
const specialEvents = getSpecialEvents(events);
expect(specialEvents).toHaveLength(1);
expect(specialEvents[0]?.title).toBe("Sondertermin");
});
it("groups upcoming events for the calendar view", () => {
const groups = getCalendarGroups(events);
expect(groups).toHaveLength(1);
expect(groups[0]?.events).toHaveLength(2);
expect(groups[0]?.events[0]?.title).toBe("Sondertermin");
expect(groups[0]?.events[1]?.title).toBe("Regeltermin");
});
it("groups midnight events in their declared time zone", () => {
const event: CalendarEvent = {
id: "midnight",
title: "Mitternachtstermin",
description: null,
location: null,
start: "2026-03-31T22:30:00.000Z",
end: "2026-04-01T00:30:00.000Z",
allDay: false,
timeZone: "Europe/Berlin",
special: true,
};
const groups = getCalendarGroups([event], new Date("2026-03-01T00:00:00.000Z"));
expect(groups[0]?.label).toBe("April 2026");
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { serializeJsonLd } from "../utils/jsonLd";
describe("serializeJsonLd", () => {
it("escapes characters that can break out of a script context", () => {
const serialized = serializeJsonLd({
text: '</script><script>alert("x")</script>&',
lineSeparator: "\u2028",
paragraphSeparator: "\u2029",
});
expect(serialized).not.toContain("</script>");
expect(serialized).not.toContain("<script>");
expect(serialized).toContain("\\u003C/script\\u003E");
expect(serialized).toContain("\\u0026");
expect(serialized).toContain("\\u2028");
expect(serialized).toContain("\\u2029");
});
});
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
addImageManifestMetadata,
buildImageManifestEntries,
} from "../../scripts/optimize-images.mjs";
const sizeSuffixes = ["-sm", "-md", ""];
describe("optimize-images manifest helpers", () => {
it("removes existing manifest entries when the tracked source image was deleted", () => {
const manifest = buildImageManifestEntries({
existingManifest: [
{ name: "old-image", ext: ".jpg", alt: "Existing alt", webp: true, avif: true },
],
outputFileNames: [
"old-image.jpg",
"old-image.webp",
"old-image.avif",
"new-image.jpg",
"new-image.webp",
],
sourceRecords: [{ name: "new-image", ext: ".jpg" }],
sizeSuffixes,
sourceIsComplete: true,
});
expect(manifest).toEqual([
{ name: "new-image", ext: ".jpg", alt: undefined, webp: true, avif: false },
]);
});
it("keeps existing public entries only when sources are unavailable", () => {
const manifest = buildImageManifestEntries({
existingManifest: [{ name: "visible", ext: ".jpg" }],
outputFileNames: ["visible.jpg", "hidden.jpg"],
sourceRecords: [],
sizeSuffixes,
sourceIsComplete: false,
});
expect(manifest.map((item) => item.name)).toEqual(["visible"]);
});
it("discovers public images when no manifest exists yet", () => {
const manifest = buildImageManifestEntries({
existingManifest: [],
outputFileNames: ["first.jpg", "first-sm.jpg", "first.webp", "second.png"],
sourceRecords: [],
sizeSuffixes,
});
expect(manifest).toEqual([
{ name: "first", ext: ".jpg", alt: undefined, webp: true, avif: false },
{ name: "second", ext: ".png", alt: undefined, webp: false, avif: false },
]);
});
it("adds intrinsic dimensions and only the generated responsive variants", () => {
const manifest = addImageManifestMetadata({
entries: [{ name: "camp", ext: ".jpg", alt: "Camp" }],
outputMetadata: new Map([
["camp-sm.jpg", { width: 640, height: 427 }],
["camp-md.jpg", { width: 1280, height: 853 }],
["camp.jpg", { width: 1920, height: 1280 }],
["camp-sm.webp", { width: 640, height: 427 }],
["camp.webp", { width: 1920, height: 1280 }],
]),
sizes: [
{ suffix: "-sm", width: 640, height: 480 },
{ suffix: "-md", width: 1280, height: 960 },
{ suffix: "", width: 1920, height: 1440 },
],
});
expect(manifest).toEqual([{
name: "camp",
ext: ".jpg",
alt: "Camp",
width: 1920,
height: 1280,
variants: {
fallback: [
{ file: "camp-sm.jpg", width: 640, height: 427 },
{ file: "camp-md.jpg", width: 1280, height: 853 },
{ file: "camp.jpg", width: 1920, height: 1280 },
],
webp: [
{ file: "camp-sm.webp", width: 640, height: 427 },
{ file: "camp.webp", width: 1920, height: 1280 },
],
avif: [],
},
}]);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import {
sortPostsByCreatedDate,
extractPlainText,
createExcerpt,
} from "../utils/postUtils";
import type { PostMetadata } from "../types";
function makePost(overrides: Partial<PostMetadata> = {}): PostMetadata {
return {
id: "test",
title: "Test",
author: "Author",
createdAt: new Date("2025-01-01T00:00:00Z"),
updatedAt: new Date("2025-01-02T00:00:00Z"),
published: true,
...overrides,
};
}
describe("sortPostsByCreatedDate", () => {
it("sorts newest first", () => {
const posts = [
makePost({ id: "old", createdAt: new Date("2024-01-01T00:00:00Z") }),
makePost({ id: "new", createdAt: new Date("2025-12-01T00:00:00Z") }),
makePost({ id: "mid", createdAt: new Date("2025-06-01T00:00:00Z") }),
];
const sorted = sortPostsByCreatedDate(posts);
expect(sorted.map((p) => p.id)).toEqual(["new", "mid", "old"]);
});
it("does not mutate the original array", () => {
const posts = [
makePost({ id: "b", createdAt: new Date("2024-01-01T00:00:00Z") }),
makePost({ id: "a", createdAt: new Date("2025-01-01T00:00:00Z") }),
];
const sorted = sortPostsByCreatedDate(posts);
expect(sorted).not.toBe(posts);
expect(posts[0].id).toBe("b");
});
});
describe("extractPlainText", () => {
it("removes HTML tags", () => {
expect(extractPlainText("<p>Hello <b>world</b></p>")).toBe("Hello world");
});
it("removes markdown headings and bold", () => {
expect(extractPlainText("## Heading\n**bold text**")).toBe("Heading bold text");
});
it("extracts link text", () => {
expect(extractPlainText("[click here](https://example.com)")).toBe("click here");
});
it("normalizes whitespace", () => {
expect(extractPlainText(" too much space ")).toBe("too much space");
});
});
describe("createExcerpt", () => {
it("returns full text when shorter than maxLength", () => {
expect(createExcerpt("short", 100)).toBe("short");
});
it("truncates and adds ellipsis", () => {
const result = createExcerpt("This is a longer text that needs truncation", 20);
expect(result.length).toBeLessThanOrEqual(20);
expect(result).toMatch(/\.\.\.$/);
});
it("returns text unchanged at exact maxLength", () => {
expect(createExcerpt("12345", 5)).toBe("12345");
});
});
+69
View File
@@ -0,0 +1,69 @@
import { buildEventsEtag } from "../../../server/calendar/events-transformer";
import { getEventsService } from "../../../server/calendar/events-service";
import { handleRoute } from "../../../server/calendar/http";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function parseBoolean(value: string | null): boolean | null {
if (value === null) return false;
const normalized = value.toLowerCase();
if (["true", "1", "on", "yes"].includes(normalized)) return true;
if (["false", "0", "off", "no"].includes(normalized)) return false;
return null;
}
function ifNoneMatchMatches(headerValue: string | null, etag: string): boolean {
if (!headerValue) return false;
return headerValue.split(",").some((candidate) => {
let normalized = candidate.trim();
if (normalized === "*") return true;
if (normalized.startsWith("W/")) normalized = normalized.slice(2).trim();
return normalized.replace(/^"|"$/g, "") === etag;
});
}
export async function GET(request: Request): Promise<Response> {
return handleRoute(request, async (config) => {
const specialValues = new URL(request.url).searchParams.getAll("special");
const special = parseBoolean(specialValues.at(-1) ?? null);
if (special === null) {
return Response.json(
{ detail: "Request validation failed", error: "validation_error" },
{ status: 422 },
);
}
const service = getEventsService();
const events = special ? await service.getSpecialEvents() : await service.getEvents();
const etag = special ? buildEventsEtag(events, true) : service.getCachedEtag() ?? buildEventsEtag(events);
const cacheSeconds = Math.floor(config.calendarCacheDurationMs / 1_000);
const headers = new Headers({
"Cache-Control": `public, max-age=${cacheSeconds}, stale-while-revalidate=300`,
ETag: `"${etag}"`,
});
if (ifNoneMatchMatches(request.headers.get("if-none-match"), etag)) {
return new Response(null, { status: 304, headers });
}
return Response.json(events, { headers });
});
}
export async function OPTIONS(request: Request): Promise<Response> {
return handleRoute(request, (config) => {
const origin = request.headers.get("origin");
const requestedMethod = request.headers.get("access-control-request-method");
if (!origin || !requestedMethod) {
return Response.json({ detail: "Method Not Allowed", error: "http_error" }, { status: 405 });
}
const failures: string[] = [];
if (!config.corsOrigins.includes(origin)) failures.push("origin");
if (requestedMethod !== "GET") failures.push("method");
if (failures.length > 0) {
return new Response(`Disallowed CORS ${failures.join(", ")}`, { status: 400 });
}
return new Response("OK", { status: 200 });
});
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import PageEffects from "../../components/PageEffects";
import PhotoSlider from "../../components/PhotoSlider";
import { galleryYears } from "../../data/galleryYears";
import { createPageMetadata } from "../../lib/metadata";
export const metadata: Metadata = createPageMetadata({ title: "Bilder", description: "Neutrale Bildergalerie.", path: "/bilder" });
export default function PicturesPage() {
return (
<div className="about-v2">
<header className="about-header about-header--hero-camp2"><div className="about-header__overlay" /><div className="about-header__content"><h1>Bilder</h1><p>Platzhalter für Bilder und externe Galerien.</p></div></header>
<section className="about-block reveal"><div className="about-block__inner"><PhotoSlider /></div></section>
<section className="about-block about-block--alt reveal"><div className="about-block__inner">
<div className="about-block__head"><span className="about-eyebrow">Galerien</span><h2>Galerieübersicht</h2></div>
{galleryYears.length ? <div className="bilder-grid">{galleryYears.map((year) => <article className="bilder-card" key={year.year}><h2>{year.year}</h2><ul className="bilder-link-list">{year.items.map((item) => <li key={item.label}>{item.link ? <a href={item.link} target="_blank" rel="noopener noreferrer">{item.label}</a> : <span>{item.label}</span>}</li>)}</ul></article>)}</div> : <p className="muted">Noch keine Galerien hinterlegt.</p>}
</div></section>
<PageEffects once={false} />
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { createPageMetadata } from "../../lib/metadata";
import { COMPANY_NAME, CONTACT_ADDRESS, CONTACT_EMAIL, SITE_URL } from "../../lib/site";
export const metadata: Metadata = createPageMetadata({ title: "Datenschutz", description: "Platzhalter für Datenschutzhinweise.", path: "/datenschutz", noindex: true });
export default function PrivacyPage() {
return (
<section className="container legal-page">
<header className="page-header"><h1>Datenschutz</h1><p>Neutrale Vorlage vor Veröffentlichung rechtlich prüfen und vervollständigen.</p></header>
<div className="legal-content">
<h2>Verantwortliche Stelle</h2>
<p>{COMPANY_NAME}<br />{CONTACT_ADDRESS}<br />E-Mail: <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a></p>
<h2>Geltungsbereich</h2>
<p>Diese Platzhalterseite bezieht sich auf <a href={SITE_URL}>{SITE_URL}</a>.</p>
<h2>Technischer Betrieb</h2>
<p>Beim Abruf der Website können technisch erforderliche Zugriffsdaten verarbeitet werden. Passen Sie diesen Abschnitt an Hosting, Formulare, Cookies, eingebundene Dienste und tatsächliche Verarbeitungen an.</p>
<h2>Hinweis</h2>
<p>Dieser Text ist keine vollständige Datenschutzerklärung und keine Rechtsberatung.</p>
</div>
</section>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { getAllTripReports, getTripReport } from "@/trip-reports";
import JsonLd from "../../../components/JsonLd";
import { createPageMetadata } from "../../../lib/metadata";
import { COMPANY_NAME, SITE_URL } from "../../../lib/site";
import { createExcerpt } from "../../../utils/postUtils";
interface TripPageProps { params: Promise<{ slug: string }>; }
export const dynamicParams = false;
export async function generateStaticParams() { return (await getAllTripReports()).filter((post) => post.metadata.published).map((post) => ({ slug: post.metadata.slug })); }
export async function generateMetadata({ params }: TripPageProps): Promise<Metadata> { const { slug } = await params; const post = await getTripReport(slug); return post?.metadata.published ? createPageMetadata({ title: post.metadata.title, description: createExcerpt(post.metadata.plainText, 160), path: `/fahrten/${post.metadata.slug}`, type: "article" }) : {}; }
export default async function TripPage({ params }: TripPageProps) {
const { slug } = await params;
const post = await getTripReport(slug);
if (!post?.metadata.published) notFound();
const ReportContent = post.Component;
const jsonLd = { "@context": "https://schema.org", "@type": "BlogPosting", headline: post.metadata.title, dateModified: new Date(post.metadata.updatedAt).toISOString(), author: { "@type": "Person", name: post.metadata.author }, publisher: { "@type": "Organization", name: COMPANY_NAME }, mainEntityOfPage: `${SITE_URL}/fahrten/${post.metadata.slug}`, articleBody: post.metadata.plainText };
return <div className="container page-spacing post-detail-page"><JsonLd data={jsonLd} /><Link className="back-link" href="/fahrten">&larr; Zur Beitragsübersicht</Link><article className="post-card post-detail-card"><header><h1>{post.metadata.title}</h1><p className="post-meta">Von {post.metadata.author}</p></header><div className="markdown-content"><ReportContent /></div></article></div>;
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getAllTripReports } from "@/trip-reports";
import JsonLd from "../../components/JsonLd";
import PageEffects from "../../components/PageEffects";
import { createPageMetadata } from "../../lib/metadata";
import { SITE_URL } from "../../lib/site";
import { createExcerpt } from "../../utils/postUtils";
export const metadata: Metadata = createPageMetadata({ title: "Beiträge", description: "Übersicht veröffentlichter Beiträge.", path: "/fahrten" });
export default async function TripsPage() {
const posts = (await getAllTripReports()).filter((post) => post.metadata.published).sort((a, b) => new Date(b.metadata.createdAt).getTime() - new Date(a.metadata.createdAt).getTime());
const collectionJsonLd = { "@context": "https://schema.org", "@type": "CollectionPage", name: "Beiträge", url: `${SITE_URL}/fahrten`, hasPart: posts.slice(0, 20).map((post) => ({ "@type": "BlogPosting", headline: post.metadata.title, url: `${SITE_URL}/fahrten/${post.metadata.slug}`, author: { "@type": "Person", name: post.metadata.author } })) };
return (
<div className="about-v2">
<JsonLd data={collectionJsonLd} />
<header className="about-header about-header--hero-camp1"><div className="about-header__overlay" /><div className="about-header__content"><h1>Beiträge</h1><p>Platzhalter für redaktionelle Inhalte.</p></div></header>
<section className="about-block reveal"><div className="about-block__inner">
{posts.length ? <div className="post-grid post-grid--v2">{posts.map((post) => <Link href={`/fahrten/${post.metadata.slug}`} className="post-card-link" key={post.metadata.slug}><article className="post-card post-preview-card"><header><h3>{post.metadata.title}</h3><p className="post-meta">Von {post.metadata.author}</p></header><p className="post-preview-text">{createExcerpt(post.metadata.plainText, 240)}</p><span className="post-preview-link">Weiterlesen</span></article></Link>)}</div> : <p className="about-block__empty">Noch keine Beiträge veröffentlicht.</p>}
</div></section>
<PageEffects threshold={0} rootMargin="0px" />
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { handleRoute } from "../../../server/calendar/http";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request): Promise<Response> {
return handleRoute(request, () => Response.json({ status: "ok" }), {
httpOnlyConfig: true,
});
}
+9
View File
@@ -0,0 +1,9 @@
import { handleRoute } from "../../../server/calendar/http";
import { readinessResponse } from "../../../server/calendar/readiness";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request): Promise<Response> {
return handleRoute(request, readinessResponse, { httpOnlyConfig: true });
}
+9
View File
@@ -0,0 +1,9 @@
import { handleRoute } from "../../server/calendar/http";
import { readinessResponse } from "../../server/calendar/readiness";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request): Promise<Response> {
return handleRoute(request, readinessResponse, { httpOnlyConfig: true });
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { createPageMetadata } from "../../lib/metadata";
import { COMPANY_NAME, CONTACT_ADDRESS, CONTACT_EMAIL, CONTACT_PHONE, SITE_URL, toPhoneUrl } from "../../lib/site";
export const metadata: Metadata = createPageMetadata({ title: "Impressum", description: "Platzhalter für rechtliche Anbieterangaben.", path: "/impressum", noindex: true });
export default function LegalNoticePage() {
return (
<section className="container legal-page">
<header className="page-header"><h1>Impressum</h1><p>Neutrale Vorlage vor Veröffentlichung rechtlich prüfen und vervollständigen.</p></header>
<div className="legal-content">
<h2>Diensteanbieter</h2>
<p><b>{COMPANY_NAME}</b><br />{CONTACT_ADDRESS}</p>
<h2>Kontakt</h2>
<p>Telefon: <a href={toPhoneUrl(CONTACT_PHONE)}>{CONTACT_PHONE}</a><br />E-Mail: <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a><br />Website: <a href={SITE_URL}>{SITE_URL}</a></p>
<h2>Hinweis</h2>
<p>Dieser Text ist nur ein technischer Platzhalter und keine Rechtsberatung. Ergänzen Sie alle für Ihr Projekt erforderlichen Pflichtangaben.</p>
</div>
</section>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { Fragment } from "react";
import PacklisteIcon from "../../components/PacklisteIcon";
import PageEffects from "../../components/PageEffects";
import RichTextParagraph from "../../components/RichTextParagraph";
import { downloads } from "../../data/downloads";
import { packlisteBasics, packlisteIntro, packlisteItems, packlisteTips } from "../../data/packliste";
import { createPageMetadata } from "../../lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Informationen",
description: "Neutrale Seite für Informationen und Downloads.",
path: "/informationen",
});
function getFileIcon(filename: string): string {
const extension = filename.toLowerCase().split(".").pop() || "";
if (extension === "pdf") return "\u{1F4C4}";
if (["doc", "docx", "txt"].includes(extension)) return "\u{1F4DD}";
return "\u{1F4CE}";
}
export default function InformationPage() {
return (
<div className="about-v2 about-v2--info">
<header className="about-header about-header--hero-camp">
<div className="about-header__overlay" />
<div className="about-header__content"><h1>Informationen</h1><p>Platzhalter für strukturierte Informationen und Dokumente.</p></div>
</header>
<section className="about-block reveal">
<div className="about-block__inner">
<div className="about-block__head"><span className="about-eyebrow">Bausteine</span><h2>Informationsbereiche</h2></div>
<div className="packliste">
<div className="packliste__intro">{packlisteIntro.map((paragraph) => <RichTextParagraph text={paragraph} key={paragraph} />)}</div>
<div className="packliste__callout">
<div className="packliste__callout-icon" aria-hidden="true"><PacklisteIcon name={packlisteBasics.icon} size={20} /></div>
<div className="packliste__callout-body"><h4>{packlisteBasics.title}</h4>{packlisteBasics.paragraphs.map((paragraph) => <RichTextParagraph text={paragraph} key={paragraph} />)}</div>
</div>
<div className="packliste__grid">
{packlisteItems.map((item) => (
<article className="packliste__item" key={item.title}>
<div className="packliste__item-header"><span className="packliste__item-icon" aria-hidden="true"><PacklisteIcon name={item.icon} size={18} /></span><h4>{item.title}</h4></div>
{item.paragraphs.map((paragraph, index) => (
<Fragment key={paragraph}><RichTextParagraph text={paragraph} />{index === 0 && item.bullets?.length ? <ul>{item.bullets.map((bullet) => <li key={bullet}>{bullet}</li>)}</ul> : null}</Fragment>
))}
</article>
))}
</div>
<div className="packliste__tips"><h4>{packlisteTips.title}</h4>{packlisteTips.paragraphs.map((paragraph) => <RichTextParagraph text={paragraph} key={paragraph} />)}</div>
</div>
</div>
</section>
<section className="about-block about-block--alt reveal">
<div className="about-block__inner">
<div className="about-block__head"><span className="about-eyebrow">Dateien</span><h2>Downloads</h2></div>
{downloads.length ? <div className="download-grid">{downloads.map((download) => (
<a href={download.download_url} download className="download-card" title={`Download: ${download.name}`} key={download.download_url}>
<div className="download-header"><span className="download-icon">{getFileIcon(download.name)}</span><h3 className="download-name">{download.name}</h3></div>
<p className="download-description">{download.description}</p>
</a>
))}</div> : <p className="muted">Noch keine Downloads hinterlegt.</p>}
</div>
</section>
<PageEffects threshold={0} rootMargin="0px" />
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import EventsView from "../../components/EventsView";
import PageEffects from "../../components/PageEffects";
import { createPageMetadata } from "../../lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Kalender",
description: "Kommende Termine im Kalenderüberblick.",
path: "/kalender",
});
export const dynamic = "force-dynamic";
export default function CalendarPage() {
return (
<div className="about-v2">
<header className="about-header about-header--hero-camp2">
<div className="about-header__overlay" />
<div className="about-header__content"><h1>Kalender</h1><p>Termine aus der konfigurierten Kalenderquelle.</p></div>
</header>
<section className="about-block reveal"><div className="about-block__inner"><EventsView mode="calendar" /></div></section>
<PageEffects once={false} />
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata, Viewport } from "next";
import { connection } from "next/server";
import type { ReactNode } from "react";
import Footer from "../components/Footer";
import Header from "../components/Header";
import ScrollToTop from "../components/ScrollToTop";
import { SITE_NAME, SITE_URL } from "../lib/site";
import "../styles/base.css";
import "../styles/layout.css";
import "../styles/components.css";
import "../styles/hero.css";
import "../styles/about.css";
import "../styles/calendar.css";
import "../styles/packliste.css";
import "../styles/post.css";
import "../styles/PhotoSlider.css";
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: SITE_NAME,
template: `%s | ${SITE_NAME}`,
},
icons: { icon: "/logo.svg" },
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
};
export default async function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
await connection();
return (
<html lang="de">
<head>
<link rel="preload" href="/fonts/Montserrat-Regular.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
<link rel="preload" href="/fonts/Poppins-Bold.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
</head>
<body>
<a href="#main-content" className="skip-link">Zum Inhalt springen</a>
<div className="app-shell">
<Header siteName={SITE_NAME} />
<main id="main-content">{children}</main>
<Footer />
</div>
<ScrollToTop />
</body>
</html>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import Link from "next/link";
import { createPageMetadata } from "../lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Seite nicht gefunden",
description: "Die angeforderte Seite wurde nicht gefunden.",
path: "/404",
noindex: true,
});
export default function NotFound() {
return (
<section className="container page-spacing">
<h1>Seite nicht gefunden</h1>
<p className="muted">Diese Seite existiert nicht oder wurde verschoben.</p>
<Link className="back-link" href="/">&larr; Zur Startseite</Link>
</section>
);
}
+73
View File
@@ -0,0 +1,73 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getAllTripReports } from "@/trip-reports";
import EventsView from "../components/EventsView";
import JsonLd from "../components/JsonLd";
import { createPageMetadata } from "../lib/metadata";
import { COMPANY_NAME, SITE_NAME, SITE_URL } from "../lib/site";
import { createExcerpt } from "../utils/postUtils";
const homeMetadata = createPageMetadata({
title: "Startseite",
description: "Neutrales Starter-Projekt für eine neue Website.",
path: "/",
});
export const metadata: Metadata = {
...homeMetadata,
title: { absolute: SITE_NAME },
};
export const dynamic = "force-dynamic";
export default async function HomePage() {
const posts = (await getAllTripReports())
.filter((post) => post.metadata.published)
.sort((a, b) => new Date(b.metadata.createdAt).getTime() - new Date(a.metadata.createdAt).getTime())
.slice(0, 3);
const jsonLd = [
{ "@context": "https://schema.org", "@type": "Organization", name: COMPANY_NAME, url: SITE_URL },
{ "@context": "https://schema.org", "@type": "WebSite", name: SITE_NAME, url: SITE_URL },
];
return (
<>
<JsonLd data={jsonLd} />
<section className="hero">
<div className="container hero-content">
<div>
<h1>Bereit für Ihre neue Website</h1>
<p>Diese neutrale Startseite enthält bewusst nur minimale Platzhalter. Inhalte, Navigation und Gestaltung können projektspezifisch ergänzt werden.</p>
<a className="cta" href="#start">Projekt starten</a>
</div>
<div className="hero-card">
<h2>Nächste Termine</h2>
<EventsView mode="special" />
</div>
</div>
</section>
<section id="start" className="container group-info">
<div className="group-info-card">
<h2>Neutraler Ausgangspunkt</h2>
<p>Die technische Infrastruktur ist eingerichtet. Ersetzen Sie diesen Abschnitt durch den Einstieg Ihrer neuen Website.</p>
</div>
</section>
<section className="container">
<h2>Aktuelle Beiträge</h2>
{posts.length ? (
<div className="post-grid">
{posts.map((post) => (
<Link href={`/fahrten/${post.metadata.slug}`} className="post-card-link" key={post.metadata.slug}>
<article className="post-card post-preview-card">
<header><h3>{post.metadata.title}</h3><p className="post-meta">Von {post.metadata.author}</p></header>
<p className="post-preview-text">{createExcerpt(post.metadata.plainText, 220)}</p>
<span className="post-preview-link">Weiterlesen</span>
</article>
</Link>
))}
</div>
) : <p className="muted">Noch keine Beiträge veröffentlicht.</p>}
</section>
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "../lib/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api/", "/health", "/downloads/"],
},
sitemap: `${SITE_URL}/sitemap-index.xml`,
};
}
+17
View File
@@ -0,0 +1,17 @@
import { SITE_URL } from "../../lib/site";
export const dynamic = "force-static";
export function GET(): Response {
const body = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
` <sitemap><loc>${SITE_URL}/sitemap.xml</loc></sitemap>`,
"</sitemapindex>",
"",
].join("\n");
return new Response(body, {
headers: { "Content-Type": "application/xml; charset=utf-8" },
});
}
+25
View File
@@ -0,0 +1,25 @@
import type { MetadataRoute } from "next";
import { getAllTripReports } from "@/trip-reports";
import { SITE_URL } from "../lib/site";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticPaths = [
"",
"/ueber-uns",
"/informationen",
"/kalender",
"/fahrten",
"/bilder",
];
const staticEntries: MetadataRoute.Sitemap = staticPaths.map((path) => ({
url: `${SITE_URL}${path}`,
}));
const tripEntries: MetadataRoute.Sitemap = (await getAllTripReports())
.filter((post) => post.metadata.published)
.map((post) => ({
url: `${SITE_URL}/fahrten/${post.metadata.slug}`,
lastModified: new Date(post.metadata.updatedAt),
}));
return [...staticEntries, ...tripEntries];
}
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import PageEffects from "../../components/PageEffects";
import PhotoSlider from "../../components/PhotoSlider";
import { createPageMetadata } from "../../lib/metadata";
export const metadata: Metadata = createPageMetadata({
title: "Über uns",
description: "Neutrale Inhaltsseite für die Vorstellung einer Organisation.",
path: "/ueber-uns",
});
export default function AboutPage() {
return (
<div className="about-v2">
<header className="about-header about-header--hero-camp1">
<div className="about-header__overlay" />
<div className="about-header__content"><h1>Über uns</h1><p>Platzhalter für eine kurze Vorstellung.</p></div>
</header>
<section className="about-block reveal">
<div className="about-block__inner about-block__inner--narrow">
<span className="about-eyebrow">Organisation</span>
<h2>Ihre Geschichte</h2>
<p>Ergänzen Sie hier die Geschichte, Werte und Ziele Ihres neuen Projekts.</p>
</div>
</section>
<section className="about-block about-block--alt reveal">
<div className="about-block__inner">
<div className="about-block__head"><span className="about-eyebrow">Medien</span><h2>Bilder</h2></div>
<PhotoSlider imageDir="secondary-slider" label="Beispielgalerie" />
</div>
</section>
<PageEffects threshold={0} rootMargin="0px" />
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
interface ContactLinkProps {
href: string;
children: string;
}
export default function ContactLink({ href, children }: ContactLinkProps) {
return <a href={href}>{children}</a>;
}
+29
View File
@@ -0,0 +1,29 @@
import { getEventsService } from "../server/calendar/events-service";
import type { CalendarEvent } from "../types";
import EventsViewClient from "./EventsViewClient";
interface EventsViewProps {
mode: "special" | "calendar";
}
export default async function EventsView({ mode }: EventsViewProps) {
let initialEvents: CalendarEvent[] | null = null;
try {
const service = getEventsService();
initialEvents = mode === "special"
? await service.getSpecialEvents()
: await service.getEvents();
} catch {
// The page remains available while the calendar source is unavailable.
}
return (
<EventsViewClient
mode={mode}
initialEvents={initialEvents}
referenceDate={new Date().toISOString()}
fixedReferenceDate={false}
/>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import type { CalendarEvent } from "../types";
import { formatEventDateRange } from "../utils/eventFormat";
import { getCalendarGroups, getSpecialEvents } from "../utils/eventsRender";
interface EventsViewClientProps {
mode: "special" | "calendar";
initialEvents: CalendarEvent[] | null;
referenceDate: string;
fixedReferenceDate: boolean;
}
const REQUEST_TIMEOUT_MS = 10_000;
async function fetchEvents(
specialOnly: boolean,
): Promise<CalendarEvent[]> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const path = specialOnly ? "/api/events?special=true" : "/api/events";
try {
const response = await fetch(path, { signal: controller.signal });
if (!response.ok) {
const message = await response.text();
throw new Error(message || response.statusText);
}
return (await response.json()) as CalendarEvent[];
} finally {
window.clearTimeout(timeout);
}
}
function SpecialEvents({
events,
now,
}: {
events: CalendarEvent[];
now: Date;
}) {
const specialEvents = getSpecialEvents(events, now);
if (!specialEvents.length) {
return <p className="muted">Aktuell sind keine besonderen Termine geplant.</p>;
}
return (
<div className="hero-special-events">
<ul>
{specialEvents.map((event) => (
<li key={event.id}>
<span className="group-name">{event.title}</span>
<span>{new Intl.DateTimeFormat("de-DE", {
dateStyle: "long",
timeZone: event.timeZone,
}).format(new Date(event.start))}</span>
</li>
))}
</ul>
<p className="calendar-note">
Alle Termine mit weiteren Details findest Du im <Link href="/kalender">Kalender</Link>.
</p>
</div>
);
}
function CalendarEvents({
events,
now,
}: {
events: CalendarEvent[];
now: Date;
}) {
const groups = getCalendarGroups(events, now);
if (!groups.length) {
return (
<p className="muted">
In den nächsten 12 Monaten sind noch keine Termine geplant.
</p>
);
}
return (
<div className="calendar-table">
{groups.map((group) => (
<section className="calendar-table-month" aria-label={group.label} key={group.label}>
<h2 className="calendar-month-heading">{group.label}</h2>
<div className="calendar-table-grid" role="table">
<div className="calendar-table-row calendar-table-row-head" role="row">
<span className="calendar-table-title" role="columnheader">Termin</span>
<span className="calendar-table-date" role="columnheader">Datum &amp; Uhrzeit</span>
</div>
{group.events.map((event) => (
<div className="calendar-table-row" role="row" key={event.id}>
<span className="calendar-table-title" role="cell">
<span className="calendar-event-title">
{event.special ? <span className="calendar-highlight"></span> : null}
{event.special ? " " : null}
{event.title}
</span>
{event.location ? <span className="calendar-event-location">, {event.location}</span> : null}
</span>
<span className="calendar-table-date" role="cell">{formatEventDateRange(event)}</span>
</div>
))}
</div>
</section>
))}
</div>
);
}
export default function EventsViewClient({
mode,
initialEvents,
referenceDate,
fixedReferenceDate,
}: EventsViewClientProps) {
const [events, setEvents] = useState<CalendarEvent[] | null>(initialEvents);
const [error, setError] = useState<string | null>(null);
const [renderReferenceDate, setRenderReferenceDate] = useState(referenceDate);
const now = new Date(renderReferenceDate);
useEffect(() => {
if (initialEvents !== null) return;
const request = { active: true };
void fetchEvents(mode === "special")
.then((freshEvents) => {
if (!request.active) return;
setEvents(freshEvents);
if (!fixedReferenceDate) setRenderReferenceDate(new Date().toISOString());
setError(null);
})
.catch((cause: unknown) => {
if (!request.active) return;
const message =
cause instanceof Error ? cause.message : "Konnte Termine nicht laden.";
setError(
initialEvents !== null
? "Aktualisierung fehlgeschlagen, zeige letzte bekannte Daten."
: message,
);
});
return () => {
request.active = false;
};
}, [fixedReferenceDate, initialEvents, mode]);
return (
<div className="events-view">
{events === null && !error ? <p role="status">Lade Termine...</p> : null}
{error ? <p className="error" role="alert">{error}</p> : null}
{events !== null ? (
mode === "special" ? <SpecialEvents events={events} now={now} /> : <CalendarEvents events={events} now={now} />
) : null}
<noscript>
<p className="muted">
Die Termine werden serverseitig geladen und können bei Problemen mit der
Kalenderquelle vorübergehend veraltet sein.
</p>
</noscript>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import Link from "next/link";
import {
COMPANY_NAME,
CONTACT_ADDRESS,
CONTACT_EMAIL,
CONTACT_PHONE,
toPhoneUrl,
} from "../lib/site";
import ContactLink from "./ContactLink";
export default function Footer() {
return (
<footer className="app-footer">
<div className="container footer-content">
<div className="footer-top">
<div className="footer-column footer-contact">
<h2 className="footer-heading">Kontakt</h2>
<address>
<div className="footer-contact-item">
<svg className="footer-contact-icon" viewBox="0 0 24 24" role="presentation" aria-hidden="true">
<path fill="currentColor" d="M12 2a7 7 0 0 0-7 7c0 5.25 7 13 7 13s7-7.75 7-13a7 7 0 0 0-7-7Zm0 9.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5Z" />
</svg>
<span>{CONTACT_ADDRESS}</span>
</div>
<div className="footer-contact-item">
<svg className="footer-contact-icon" viewBox="0 0 24 24" role="presentation" aria-hidden="true">
<path fill="currentColor" d="M20 4H4a1 1 0 0 0-1 1v1.22l9 5.4 9-5.4V5a1 1 0 0 0-1-1Zm1 5.26-9 5.4-9-5.4V19a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V9.26Z" />
</svg>
<ContactLink href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</ContactLink>
</div>
<div className="footer-contact-item">
<svg className="footer-contact-icon" viewBox="0 0 24 24" role="presentation" aria-hidden="true">
<path fill="currentColor" d="M6.62 10.79a15.91 15.91 0 0 0 6.59 6.59l1.89-1.89a1 1 0 0 1 1-.24 12.18 12.18 0 0 0 3.83.61 1 1 0 0 1 1 1v3.18a1 1 0 0 1-1 1A17 17 0 0 1 3 7a1 1 0 0 1 1-1h3.18a1 1 0 0 1 1 1 12.31 12.31 0 0 0 .61 3.83 1 1 0 0 1-.24 1Z" />
</svg>
<ContactLink href={toPhoneUrl(CONTACT_PHONE)}>{CONTACT_PHONE}</ContactLink>
</div>
</address>
</div>
<nav className="footer-column footer-links" aria-label="Seitennavigation">
<h2 className="footer-heading">Links</h2>
<ul>
<li><Link href="/ueber-uns">Über uns</Link></li>
<li><Link href="/informationen">Informationen</Link></li>
<li><Link href="/kalender">Kalender</Link></li>
<li><Link href="/bilder">Bilder</Link></li>
</ul>
</nav>
</div>
<div className="footer-bottom">
<span>&copy; {new Date().getFullYear()} {COMPANY_NAME}</span>
<nav className="footer-legal" aria-label="Rechtliches">
<Link href="/datenschutz">Datenschutz</Link>
<Link href="/impressum">Impressum</Link>
</nav>
</div>
</div>
</footer>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
const navLinks = [
{ href: "/", label: "Start" },
{ href: "/ueber-uns", label: "Über uns" },
{ href: "/informationen", label: "Informationen" },
{ href: "/kalender", label: "Kalender" },
{ href: "/fahrten", label: "Beiträge" },
{ href: "/bilder", label: "Bilder" },
];
export default function Header({ siteName }: { siteName: string }) {
const pathname = usePathname() ?? "/";
const [openPath, setOpenPath] = useState<string | null>(null);
const open = openPath === pathname;
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpenPath(null);
};
document.addEventListener("keydown", closeOnEscape);
return () => document.removeEventListener("keydown", closeOnEscape);
}, []);
const isActive = (href: string) =>
href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<header className="app-header">
<div className="container header-content">
<Link href="/" className="brand">
<img
src="/logo.svg"
alt={`${siteName} Logo`}
className="brand-logo"
/>
<span>{siteName}</span>
</Link>
<button
className="nav-toggle"
type="button"
aria-expanded={open}
aria-controls="primary-navigation"
aria-label={open ? "Menü schließen" : "Menü öffnen"}
data-open={String(open)}
onClick={() => setOpenPath(open ? null : pathname)}
>
<span className="nav-toggle-bars" aria-hidden="true">
<span />
<span />
<span />
</span>
</button>
<nav
className="nav-links"
id="primary-navigation"
data-open={String(open)}
>
{navLinks.map(({ href, label }) => {
const active = isActive(href);
return (
<Link
key={href}
href={href}
aria-current={active ? "page" : undefined}
className={active ? "active" : undefined}
onClick={() => setOpenPath(null)}
>
{label}
</Link>
);
})}
</nav>
</div>
</header>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { headers } from "next/headers";
import { serializeJsonLd } from "../utils/jsonLd";
interface JsonLdProps {
data: Record<string, unknown> | Record<string, unknown>[];
}
export default async function JsonLd({ data }: JsonLdProps) {
const entries = Array.isArray(data) ? data : [data];
const nonce = (await headers()).get("x-nonce") ?? undefined;
return entries.map((entry, index) => (
<script
key={index}
nonce={nonce}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(entry) }}
/>
));
}
+34
View File
@@ -0,0 +1,34 @@
import type { PacklisteIconName } from "../data/packliste";
interface PacklisteIconProps {
name: PacklisteIconName;
size?: number;
}
export default function PacklisteIcon({ name, size = 20 }: PacklisteIconProps) {
if (name === "star") {
return (
<svg width={size} height={size} viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M10 2L12.09 7.26L18 8.27L13.82 12.14L14.94 18.02L10 15.27L5.06 18.02L6.18 12.14L2 8.27L7.91 7.26L10 2Z" fill="currentColor" />
</svg>
);
}
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{name === "document" ? <><path d="M2 4v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8l-6-6H4c-1.1 0-2 .9-2 2z" /><path d="M14 2v6h6" /></> : null}
{name === "mat" ? <><rect x="2" y="6" width="20" height="12" rx="2" /><path d="M2 12h20" /></> : null}
{name === "meal-bag" ? <><path d="M18 8h1a4 4 0 010 8h-1" /><path d="M2 8h16v9a4 4 0 01-4 4H6a4 4 0 01-4-4V8z" /><line x1="6" y1="1" x2="6" y2="4" /><line x1="10" y1="1" x2="10" y2="4" /><line x1="14" y1="1" x2="14" y2="4" /></> : null}
{name === "wash" ? <path d="M12 2.69l5.66 5.66a8 8 0 11-11.31 0z" /> : null}
{name === "clothes" ? <path d="M20.38 3.46L16 2 12 3.46 8 2 3.62 3.46a2 2 0 00-1.34 1.89v13.3a2 2 0 002.68 1.89L8 19.54l4 1.46 4-1.46 3.04 1.1a2 2 0 002.68-1.89V5.35a2 2 0 00-1.34-1.89z" /> : null}
{name === "boots" ? <><path d="M2 22l1-1h18l1 1" /><path d="M6 18V2l4 2 4-2 4 2v16" /></> : null}
{name === "rain" ? <><path d="M16 13v8" /><path d="M12 21h8" /><path d="M20 16.58A5 5 0 0018 7h-1.26A8 8 0 104 15.25" /></> : null}
{name === "flashlight" ? <><line x1="9" y1="18" x2="15" y2="18" /><line x1="10" y1="22" x2="14" y2="22" /><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0018 8 6 6 0 006 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 018.91 14" /></> : null}
{name === "hat" ? <><circle cx="12" cy="12" r="5" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" /><line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /><line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" /><line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" /></> : null}
{name === "first-aid" ? <path d="M22 12h-4l-3 9L9 3l-3 9H2" /> : null}
{name === "book" ? <><path d="M4 19.5A2.5 2.5 0 016.5 17H20" /><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" /></> : null}
{name === "laundry" ? <><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z" /><line x1="3" y1="6" x2="21" y2="6" /><path d="M16 10a4 4 0 01-8 0" /></> : null}
{name === "note" ? <><rect x="2" y="2" width="20" height="20" rx="5" ry="5" /><line x1="16" y1="8" x2="8" y2="8" /><line x1="16" y1="12" x2="8" y2="12" /><line x1="10" y1="16" x2="8" y2="16" /></> : null}
</svg>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
interface PageEffectsProps {
threshold?: number;
rootMargin?: string;
once?: boolean;
}
export default function PageEffects({
threshold = 0.1,
rootMargin = "0px 0px -40px 0px",
once = true,
}: PageEffectsProps) {
useEffect(() => {
const elements = document.querySelectorAll(".reveal");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("revealed");
if (once) observer.unobserve(entry.target);
});
},
{ threshold, rootMargin },
);
elements.forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, [once, rootMargin, threshold]);
return null;
}
+26
View File
@@ -0,0 +1,26 @@
import { getSliderImages } from "../lib/photo-slider";
import PhotoSliderCarousel from "./PhotoSliderCarousel";
interface PhotoSliderProps {
imageDir?: string;
label?: string;
autoplayMs?: number;
}
export default async function PhotoSlider({
imageDir = "slider",
label = "Bildergalerie",
autoplayMs = 5000,
}: PhotoSliderProps) {
const images = await getSliderImages(imageDir);
const sliderSlug = imageDir.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
return (
<PhotoSliderCarousel
images={images}
label={label}
autoplayMs={images.length > 1 ? autoplayMs : 0}
sliderId={`photo-slider-${sliderSlug}`}
/>
);
}
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { SliderImage } from "../lib/photo-slider";
interface PhotoSliderCarouselProps {
images: SliderImage[];
label: string;
autoplayMs: number;
sliderId: string;
}
function getNearestSlideIndex(
scrollLeft: number,
slideWidth: number,
slideCount: number,
) {
if (slideCount <= 1) return 0;
return Math.max(
0,
Math.min(slideCount - 1, Math.round(scrollLeft / Math.max(slideWidth, 1))),
);
}
export default function PhotoSliderCarousel({
images,
label,
autoplayMs,
sliderId,
}: PhotoSliderCarouselProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<number | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [paused, setPaused] = useState(false);
const hasControls = images.length > 1;
const viewportId = `${sliderId}-viewport`;
const goTo = (nextIndex: number, behavior: ScrollBehavior = "smooth") => {
const viewport = viewportRef.current;
if (!viewport || !images.length) return;
const index = (nextIndex + images.length) % images.length;
setActiveIndex(index);
viewport.scrollTo({ left: viewport.clientWidth * index, behavior });
};
const goBy = (offset: number) => {
const viewport = viewportRef.current;
if (!viewport) return;
const current = getNearestSlideIndex(
viewport.scrollLeft,
viewport.clientWidth,
images.length,
);
goTo(current + offset);
};
useEffect(() => {
if (!hasControls || paused || autoplayMs < 1000) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timeout = window.setTimeout(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const index = (activeIndex + 1) % images.length;
setActiveIndex(index);
viewport.scrollTo({ left: viewport.clientWidth * index, behavior: "smooth" });
}, autoplayMs);
return () => window.clearTimeout(timeout);
}, [activeIndex, autoplayMs, hasControls, images.length, paused]);
useEffect(() => {
const handleResize = () => {
const viewport = viewportRef.current;
if (!viewport) return;
viewport.scrollTo({ left: viewport.clientWidth * activeIndex, behavior: "auto" });
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [activeIndex]);
useEffect(() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
}, []);
if (!images.length) {
return <div className="slider-empty">Keine Slider-Bilder gefunden.</div>;
}
return (
<div
className="slider-container"
data-enhanced="true"
aria-label={label}
aria-roledescription="carousel"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setPaused(false);
}}
>
<div className="slider-stage">
<div
className="slider-viewport"
id={viewportId}
ref={viewportRef}
tabIndex={0}
onScroll={(event) => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
const viewport = event.currentTarget;
frameRef.current = requestAnimationFrame(() => {
setActiveIndex(
getNearestSlideIndex(
viewport.scrollLeft,
viewport.clientWidth,
images.length,
),
);
});
}}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
goBy(-1);
} else if (event.key === "ArrowRight") {
event.preventDefault();
goBy(1);
} else if (event.key === "Home") {
event.preventDefault();
goTo(0);
} else if (event.key === "End") {
event.preventDefault();
goTo(images.length - 1);
}
}}
>
{images.map((image, index) => (
<figure
className="slider-slide"
aria-hidden={index !== activeIndex}
key={image.id}
>
<picture className="slider-picture">
{image.srcSetAvif ? (
<source srcSet={image.srcSetAvif} sizes="(max-width: 768px) 100vw, min(100vw, 960px)" type="image/avif" />
) : null}
{image.srcSetWebp ? (
<source srcSet={image.srcSetWebp} sizes="(max-width: 768px) 100vw, min(100vw, 960px)" type="image/webp" />
) : null}
<img
src={image.src}
srcSet={image.srcSet}
sizes="(max-width: 768px) 100vw, min(100vw, 960px)"
alt={image.alt}
width={image.width}
height={image.height}
loading={index === 0 ? "eager" : "lazy"}
fetchPriority={index === 0 ? "high" : "auto"}
draggable="false"
/>
</picture>
</figure>
))}
</div>
{hasControls ? (
<>
<button className="slider-nav slider-nav--prev" type="button" aria-controls={viewportId} aria-label="Vorheriges Bild" onClick={() => goBy(-1)}>
<span aria-hidden="true">&#8249;</span>
</button>
<button className="slider-nav slider-nav--next" type="button" aria-controls={viewportId} aria-label="Nächstes Bild" onClick={() => goBy(1)}>
<span aria-hidden="true">&#8250;</span>
</button>
</>
) : null}
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
interface RichTextParagraphProps {
text: string;
className?: string;
}
type Segment = { type: "text" | "strong"; value: string };
function parseStrongSegments(value: string): Segment[] {
const segments: Segment[] = [];
const pattern = /<strong>(.*?)<\/strong>/g;
let lastIndex = 0;
for (const match of value.matchAll(pattern)) {
const matchIndex = match.index ?? 0;
if (matchIndex > lastIndex) {
segments.push({ type: "text", value: value.slice(lastIndex, matchIndex) });
}
segments.push({ type: "strong", value: match[1] ?? "" });
lastIndex = matchIndex + match[0].length;
}
if (lastIndex < value.length) {
segments.push({ type: "text", value: value.slice(lastIndex) });
}
return segments;
}
export default function RichTextParagraph({ text, className }: RichTextParagraphProps) {
return (
<p className={className}>
{parseStrongSegments(text).map((segment, index) =>
segment.type === "strong" ? (
<strong key={index}>{segment.value}</strong>
) : (
segment.value
),
)}
</p>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { usePathname } from "next/navigation";
import { useLayoutEffect } from "react";
export default function ScrollToTop() {
const pathname = usePathname();
useLayoutEffect(() => {
const root = document.documentElement;
const previousScrollBehavior = root.style.scrollBehavior;
root.style.scrollBehavior = "auto";
window.scrollTo({ top: 0, left: 0 });
root.style.scrollBehavior = previousScrollBehavior;
}, [pathname]);
return null;
}
+88
View File
@@ -0,0 +1,88 @@
import fs from "node:fs";
import path from "node:path";
interface TripImageProps {
src: string;
alt: string;
caption?: string;
}
const RESPONSIVE_SIZES = [
{ suffix: "-sm", width: 800 },
{ suffix: "-md", width: 1000 },
];
const IMAGE_SIZES = "(max-width: 840px) 100vw, 680px";
const directoryEntries = new Map<string, string[]>();
function resolveAssetPath(assetPath: string): string | undefined {
const relativePath = assetPath.replace(/^\/+/, "");
if (!relativePath || relativePath.split("/").includes("..")) return undefined;
const directory = path.dirname(relativePath);
const absoluteDirectory = path.join(process.cwd(), "public", directory);
let entries = directoryEntries.get(absoluteDirectory);
if (!entries) {
try {
entries = fs.readdirSync(absoluteDirectory);
directoryEntries.set(absoluteDirectory, entries);
} catch {
return undefined;
}
}
const requestedName = path.basename(relativePath);
const fileName =
entries.find((entry) => entry === requestedName) ??
entries.find((entry) => entry.toLowerCase() === requestedName.toLowerCase());
if (!fileName) return undefined;
const publicDirectory = directory === "." ? "" : `${directory}/`;
return `/${publicDirectory}${fileName}`;
}
function buildSrcSet(basePath: string, extension: string): string | undefined {
const entries = RESPONSIVE_SIZES.flatMap(({ suffix, width }) => {
const assetPath = `${basePath}${suffix}${extension}`;
const resolvedPath = resolveAssetPath(assetPath);
return resolvedPath ? [`${resolvedPath} ${width}w`] : [];
});
return entries.length ? entries.join(", ") : undefined;
}
export default function TripImage({ src, alt, caption }: TripImageProps) {
const match = src.match(/^(.*?)(\.[a-zA-Z0-9]+)$/);
const basePath = match?.[1];
const extension = match?.[2];
const fallbackSrcSet =
basePath && extension ? buildSrcSet(basePath, extension) : undefined;
const avifSrcSet = basePath ? buildSrcSet(basePath, ".avif") : undefined;
const webpSrcSet = basePath ? buildSrcSet(basePath, ".webp") : undefined;
const fallbackSrc =
basePath && extension
? [...RESPONSIVE_SIZES]
.reverse()
.map(({ suffix }) => `${basePath}${suffix}${extension}`)
.map(resolveAssetPath)
.find((assetPath) => assetPath !== undefined) ?? src
: src;
return (
<figure className="post-figure">
<picture>
{avifSrcSet ? <source type="image/avif" srcSet={avifSrcSet} sizes={IMAGE_SIZES} /> : null}
{webpSrcSet ? <source type="image/webp" srcSet={webpSrcSet} sizes={IMAGE_SIZES} /> : null}
<img
src={fallbackSrc}
srcSet={fallbackSrcSet}
sizes={fallbackSrcSet ? IMAGE_SIZES : undefined}
alt={alt}
className="post-image"
loading="lazy"
decoding="async"
/>
</picture>
{caption ? <figcaption className="post-caption">{caption}</figcaption> : null}
</figure>
);
}
+4
View File
@@ -0,0 +1,4 @@
import type { Download } from "../types";
// Add project-specific files from public/downloads when needed.
export const downloads: Download[] = [];
+7
View File
@@ -0,0 +1,7 @@
export interface GalleryYear {
year: string;
items: Array<{ label: string; link?: string }>;
}
// Add external galleries only after checking access and privacy requirements.
export const galleryYears: GalleryYear[] = [];
+44
View File
@@ -0,0 +1,44 @@
export type PacklisteIconName =
| "star" | "document" | "mat" | "meal-bag" | "wash" | "clothes"
| "boots" | "rain" | "flashlight" | "hat" | "first-aid" | "book"
| "laundry" | "note";
export interface PacklisteSection {
title: string;
icon: PacklisteIconName;
paragraphs: string[];
bullets?: string[];
variant?: "note";
}
export const packlisteIntro = [
"Dieser Bereich demonstriert wiederverwendbare Informationskarten.",
"Ersetzen Sie die Beispiele durch Inhalte des neuen Projekts.",
];
export const packlisteBasics: PacklisteSection = {
title: "Wichtiger Hinweis",
icon: "star",
paragraphs: ["Konfigurieren und prüfen Sie alle Angaben vor der Veröffentlichung."],
};
export const packlisteItems: PacklisteSection[] = [
{
title: "Beispielabschnitt",
icon: "document",
paragraphs: ["Ein kurzer neutraler Platzhalter für strukturierte Inhalte."],
bullets: ["Erster Punkt", "Zweiter Punkt"],
},
];
export const packlisteDigitalPause: PacklisteSection = {
title: "Zusätzlicher Hinweis",
icon: "note",
variant: "note",
paragraphs: ["Optionaler hervorgehobener Inhaltsbereich."],
};
export const packlisteTips = {
title: "Nächste Schritte",
paragraphs: ["Ersetzen Sie Beispieltexte, Dateien und Verweise durch geprüfte Projektinhalte."],
};
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { SITE_NAME, toAbsoluteUrl } from "./site";
const DEFAULT_OG_IMAGE = "/logo.svg";
interface PageMetadataOptions {
title: string;
description: string;
path: string;
type?: "website" | "article";
image?: string;
noindex?: boolean;
}
export function createPageMetadata({
title,
description,
path,
type = "website",
image = DEFAULT_OG_IMAGE,
noindex = false,
}: PageMetadataOptions): Metadata {
const canonicalUrl = toAbsoluteUrl(path);
const imageUrl = toAbsoluteUrl(image);
return {
title,
description,
robots: { index: !noindex, follow: true },
alternates: { canonical: canonicalUrl },
openGraph: {
siteName: SITE_NAME,
title,
description,
type,
url: canonicalUrl,
images: [imageUrl],
},
twitter: {
card: "summary",
title,
description,
images: [imageUrl],
},
};
}
+123
View File
@@ -0,0 +1,123 @@
import secondarySliderManifest from "../../public/images/secondary-slider/slider-images.json";
import sliderManifest from "../../public/images/slider/slider-images.json";
interface SliderImageVariant {
file: string;
width: number;
height: number;
}
interface SliderImageMeta {
name: string;
alt?: string;
width: number;
height: number;
variants: {
fallback: SliderImageVariant[];
webp: SliderImageVariant[];
avif: SliderImageVariant[];
};
}
export interface SliderImage {
id: string;
src: string;
alt: string;
srcSet: string;
srcSetWebp?: string;
srcSetAvif?: string;
width?: number;
height?: number;
}
const IMAGE_MANIFESTS: Record<string, unknown> = {
"secondary-slider": secondarySliderManifest,
slider: sliderManifest,
};
function normalizeImageDirectory(imageDir: string): string {
const normalized = imageDir.replace(/^\/+|\/+$/g, "");
if (
!normalized ||
normalized.split("/").some((segment) => !segment || segment === "..")
) {
throw new Error(`Invalid PhotoSlider imageDir: ${imageDir}`);
}
return normalized;
}
function isVariant(value: unknown): value is SliderImageVariant {
if (!value || typeof value !== "object") return false;
const variant = value as Partial<SliderImageVariant>;
return (
typeof variant.file === "string" &&
Number.isInteger(variant.width) &&
Number.isInteger(variant.height)
);
}
function isSliderImageMeta(value: unknown): value is SliderImageMeta {
if (!value || typeof value !== "object") return false;
const item = value as Partial<SliderImageMeta>;
return (
typeof item.name === "string" &&
Number.isInteger(item.width) &&
Number.isInteger(item.height) &&
!!item.variants &&
[item.variants.fallback, item.variants.webp, item.variants.avif].every(
(variants) => Array.isArray(variants) && variants.every(isVariant),
) &&
item.variants.fallback.length > 0
);
}
function defaultAltText(name: string): string {
const normalizedName = name.replace(/[-_]+/g, " ").trim();
return normalizedName
? `Bild: ${normalizedName}`
: "Galeriebild";
}
function buildSrcSet(
imageDir: string,
variants: SliderImageVariant[],
): string | undefined {
if (!variants.length) return undefined;
return variants
.map(({ file, width }) => `/images/${imageDir}/${file} ${width}w`)
.join(", ");
}
export async function getSliderImages(imageDir: string): Promise<SliderImage[]> {
const normalizedImageDir = normalizeImageDirectory(imageDir);
const manifest = IMAGE_MANIFESTS[normalizedImageDir];
if (!Array.isArray(manifest)) return [];
const sliderSlug = normalizedImageDir
.replace(/[^a-z0-9]+/gi, "-")
.toLowerCase();
return manifest.filter(isSliderImageMeta).map((item, index) => {
const fallbackFile = item.variants.fallback.find(
({ width, height }) => width === item.width && height === item.height,
)?.file ?? item.variants.fallback.at(-1)!.file;
const publicImagePath = `/images/${normalizedImageDir}/${fallbackFile}`;
if (!item.alt?.trim()) {
console.warn(
`[PhotoSlider] Missing alt text for ${normalizedImageDir}/${fallbackFile}; using fallback text.`,
);
}
return {
id: `${sliderSlug}-${index + 1}`,
src: publicImagePath,
alt: item.alt?.trim() || defaultAltText(item.name),
srcSet: buildSrcSet(normalizedImageDir, item.variants.fallback) ?? publicImagePath,
srcSetWebp: buildSrcSet(normalizedImageDir, item.variants.webp),
srcSetAvif: buildSrcSet(normalizedImageDir, item.variants.avif),
width: item.width,
height: item.height,
};
});
}

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