diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..b6bc592 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,4 @@ +PUBLIC_API_BASE_URL=http://localhost:8080 + +# Cloudflare Turnstile (site key - public, safe to expose) +PUBLIC_TURNSTILE_SITE_KEY= diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/web/.pre-commit-config.yaml b/web/.pre-commit-config.yaml new file mode 100644 index 0000000..6042a3a --- /dev/null +++ b/web/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + exclude: ^deploy/helm/templates/ + - id: check-json + exclude: ^tsconfig\.json$ + - id: check-merge-conflict + - id: no-commit-to-branch + args: ['--branch', 'main'] + + - repo: local + hooks: + - id: prettier + name: prettier + entry: pnpm run format:check + language: system + pass_filenames: false + stages: [pre-commit] + + - id: eslint + name: eslint + entry: pnpm run lint + language: system + pass_filenames: false + stages: [pre-commit] + + - id: svelte-check + name: svelte-check + entry: pnpm run check + language: system + pass_filenames: false + stages: [pre-commit] diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..19dd9ed --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,5 @@ +build/ +.svelte-kit/ +node_modules/ +pnpm-lock.yaml +deploy/ diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 0000000..8bc6e86 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,8 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/web/.woodpecker.yml b/web/.woodpecker.yml new file mode 100644 index 0000000..94e95d2 --- /dev/null +++ b/web/.woodpecker.yml @@ -0,0 +1,57 @@ +when: + - event: [push, pull_request] + +steps: + lint: + image: node:25-alpine + environment: + PUBLIC_API_BASE_URL: https://api.marktvogt.de + commands: + - npm install -g pnpm@10 + - pnpm install --frozen-lockfile + - pnpm run format:check + - pnpm run lint + - pnpm run check + when: + - event: [push, pull_request] + + # Build and push immutable SHA-tagged image on main branch only + docker: + image: woodpeckerci/plugin-docker-buildx + settings: + repo: registry.itsh.dev/vikingowl/marktvogt.de/web + tags: + - '${CI_COMMIT_SHA:0:8}' + dockerfile: Dockerfile + registry: registry.itsh.dev + username: + from_secret: registry_user + password: + from_secret: registry_password + build_args: + - PUBLIC_API_BASE_URL=https://api.marktvogt.de + - PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAACjLCV-78Ql1oTPz + when: + - event: push + branch: main + + # Deploy via Helm using the exact SHA tag just pushed + deploy: + image: alpine/helm:4.1 + environment: + KUBECONFIG_DATA: + from_secret: kubeconfig + commands: + - mkdir -p ~/.kube + - echo "$KUBECONFIG_DATA" > ~/.kube/config + - chmod 600 ~/.kube/config + - | + helm upgrade --install marktvogt-web ./deploy/helm/ \ + --namespace tenant-2 \ + --set image.tag="${CI_COMMIT_SHA:0:8}" \ + --rollback-on-failure \ + --wait=watcher \ + --timeout 5m + when: + - event: push + branch: main diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..02ca18f --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,48 @@ +FROM node:25-alpine AS deps + +RUN npm install -g pnpm@10 +WORKDIR /app + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod + +# ───────────────────────────────────────────── +FROM node:25-alpine AS builder + +RUN npm install -g pnpm@10 +WORKDIR /app + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . + +# PUBLIC_API_BASE_URL is baked at build time if using $env/static/public. +# If using $env/dynamic/public, remove the ARG/ENV below and pass it at runtime. +ARG PUBLIC_API_BASE_URL=https://api.marktvogt.de +ENV PUBLIC_API_BASE_URL=$PUBLIC_API_BASE_URL + +ARG PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA +ENV PUBLIC_TURNSTILE_SITE_KEY=$PUBLIC_TURNSTILE_SITE_KEY + +RUN pnpm run build + +# ───────────────────────────────────────────── +FROM node:25-alpine + +WORKDIR /app + +COPY --from=builder /app/build ./build +COPY --from=deps /app/node_modules ./node_modules +COPY --from=builder /app/package.json . + +# node:25-alpine already ships nobody at UID 65534 — matches podSecurityContext.runAsUser +USER nobody:nobody + +# ORIGIN is required by adapter-node for CSRF protection. +# Must match the public-facing URL exactly (set via k8s ConfigMap). +ENV PORT=3000 HOST=0.0.0.0 NODE_ENV=production + +EXPOSE 3000 + +CMD ["node", "build"] diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..5edb743 --- /dev/null +++ b/web/README.md @@ -0,0 +1,3 @@ +# Marktvogt Web + +SvelteKit + Tailwind 4 diff --git a/web/deploy/helm/Chart.yaml b/web/deploy/helm/Chart.yaml new file mode 100644 index 0000000..3a447c6 --- /dev/null +++ b/web/deploy/helm/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: marktvogt-web +description: Marktvogt.de – SvelteKit SSR frontend +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/web/deploy/helm/templates/_helpers.tpl b/web/deploy/helm/templates/_helpers.tpl new file mode 100644 index 0000000..cc0631b --- /dev/null +++ b/web/deploy/helm/templates/_helpers.tpl @@ -0,0 +1,28 @@ +{{- define "marktvogt-web.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "marktvogt-web.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "marktvogt-web.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }} +{{ include "marktvogt-web.selectorLabels" . }} +app.kubernetes.io/version: {{ .Values.image.tag | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "marktvogt-web.selectorLabels" -}} +app.kubernetes.io/name: {{ include "marktvogt-web.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/web/deploy/helm/templates/configmap.yaml b/web/deploy/helm/templates/configmap.yaml new file mode 100644 index 0000000..0e70693 --- /dev/null +++ b/web/deploy/helm/templates/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "marktvogt-web.fullname" . }}-config + namespace: {{ .Release.Namespace }} + labels: + {{- include "marktvogt-web.labels" . | nindent 4 }} +data: + {{- toYaml .Values.config | nindent 2 }} diff --git a/web/deploy/helm/templates/deployment.yaml b/web/deploy/helm/templates/deployment.yaml new file mode 100644 index 0000000..dfaa3d9 --- /dev/null +++ b/web/deploy/helm/templates/deployment.yaml @@ -0,0 +1,86 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "marktvogt-web.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "marktvogt-web.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + {{- include "marktvogt-web.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + labels: + {{- include "marktvogt-web.selectorLabels" . | nindent 8 }} + spec: + serviceAccountName: {{ include "marktvogt-web.fullname" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: web + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "marktvogt-web.fullname" . }}-config + # Node.js may write to /tmp; mount an emptyDir so readOnlyRootFilesystem works + volumeMounts: + - name: tmp + mountPath: /tmp + startupProbe: + httpGet: + path: /healthz + port: http + failureThreshold: 15 + periodSeconds: 2 + livenessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 10 + timeoutSeconds: 3 + resources: + {{- toYaml .Values.resources | nindent 12 }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + volumes: + - name: tmp + emptyDir: {} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/web/deploy/helm/templates/hpa.yaml b/web/deploy/helm/templates/hpa.yaml new file mode 100644 index 0000000..5cb7c00 --- /dev/null +++ b/web/deploy/helm/templates/hpa.yaml @@ -0,0 +1,23 @@ +{{- if .Values.autoscaling.enabled -}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "marktvogt-web.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "marktvogt-web.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "marktvogt-web.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/web/deploy/helm/templates/httproute-redirect.yaml b/web/deploy/helm/templates/httproute-redirect.yaml new file mode 100644 index 0000000..8bf006c --- /dev/null +++ b/web/deploy/helm/templates/httproute-redirect.yaml @@ -0,0 +1,24 @@ +{{- if .Values.httpRoute.enabled -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "marktvogt-web.fullname" . }}-redirect + namespace: {{ .Release.Namespace }} + labels: + {{- include "marktvogt-web.labels" . | nindent 4 }} +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: {{ .Values.httpRoute.gatewayName }} + namespace: {{ .Values.httpRoute.gatewayNamespace }} + sectionName: http + hostnames: + - {{ .Values.httpRoute.hostname | quote }} + rules: + - filters: + - type: RequestRedirect + requestRedirect: + scheme: https + statusCode: 301 +{{- end }} diff --git a/web/deploy/helm/templates/httproute.yaml b/web/deploy/helm/templates/httproute.yaml new file mode 100644 index 0000000..46b2951 --- /dev/null +++ b/web/deploy/helm/templates/httproute.yaml @@ -0,0 +1,26 @@ +{{- if .Values.httpRoute.enabled -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "marktvogt-web.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "marktvogt-web.labels" . | nindent 4 }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: {{ .Values.httpRoute.gatewayName }} + namespace: {{ .Values.httpRoute.gatewayNamespace }} + sectionName: {{ .Values.httpRoute.sectionName }} + hostnames: + - {{ .Values.httpRoute.hostname | quote }} + rules: + - backendRefs: + - name: {{ include "marktvogt-web.fullname" . }} + port: {{ .Values.service.port }} +{{- end }} diff --git a/web/deploy/helm/templates/pdb.yaml b/web/deploy/helm/templates/pdb.yaml new file mode 100644 index 0000000..cb7ae8f --- /dev/null +++ b/web/deploy/helm/templates/pdb.yaml @@ -0,0 +1,14 @@ +{{- if .Values.pdb.enabled -}} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "marktvogt-web.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "marktvogt-web.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.pdb.minAvailable }} + selector: + matchLabels: + {{- include "marktvogt-web.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/web/deploy/helm/templates/service.yaml b/web/deploy/helm/templates/service.yaml new file mode 100644 index 0000000..9c2506e --- /dev/null +++ b/web/deploy/helm/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "marktvogt-web.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "marktvogt-web.labels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + {{- include "marktvogt-web.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP diff --git a/web/deploy/helm/templates/serviceaccount.yaml b/web/deploy/helm/templates/serviceaccount.yaml new file mode 100644 index 0000000..fe0c477 --- /dev/null +++ b/web/deploy/helm/templates/serviceaccount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "marktvogt-web.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "marktvogt-web.labels" . | nindent 4 }} +automountServiceAccountToken: false diff --git a/web/deploy/helm/values.yaml b/web/deploy/helm/values.yaml new file mode 100644 index 0000000..cfdbe45 --- /dev/null +++ b/web/deploy/helm/values.yaml @@ -0,0 +1,70 @@ +image: + repository: registry.itsh.dev/vikingowl/marktvogt.de/web + tag: "latest" + pullPolicy: IfNotPresent + +imagePullSecrets: + - name: itsh-registry + +nameOverride: "" +fullnameOverride: "" + +replicaCount: 1 + +service: + port: 80 + targetPort: 3000 + +httpRoute: + enabled: true + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + hostname: marktvogt.de + gatewayName: default + gatewayNamespace: nginx-gateway + sectionName: https-marktvogt-de + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 100m + memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 80 + +pdb: + enabled: false + minAvailable: 1 + +podSecurityContext: + runAsNonRoot: true + runAsUser: 65534 + fsGroup: 65534 + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + +config: + # Required by adapter-node — must match the public-facing origin exactly + ORIGIN: "https://marktvogt.de" + NODE_ENV: production + PORT: "3000" + HOST: "0.0.0.0" + # Cloudflare Turnstile — read at runtime via $env/dynamic/public + PUBLIC_TURNSTILE_SITE_KEY: "0x4AAAAAACjLCV-78Ql1oTPz" + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..b32fad6 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,46 @@ +import js from '@eslint/js'; +import ts from 'typescript-eslint'; +import svelte from 'eslint-plugin-svelte'; +import prettier from 'eslint-config-prettier'; +import globals from 'globals'; + +export default ts.config( + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs['flat/recommended'], + prettier, + ...svelte.configs['flat/prettier'], + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node + } + } + }, + { + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' } + ] + } + }, + { + files: ['**/*.svelte'], + languageOptions: { + parserOptions: { + parser: ts.parser + } + }, + rules: { + 'svelte/no-at-html-tags': 'off', + 'svelte/no-navigation-without-resolve': 'off', + 'svelte/require-each-key': 'off', + 'svelte/prefer-svelte-reactivity': 'off' + } + }, + { + ignores: ['build/', '.svelte-kit/', 'node_modules/', 'deploy/'] + } +); diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..0c0f160 --- /dev/null +++ b/web/package.json @@ -0,0 +1,43 @@ +{ + "name": "marktvogt-web", + "private": true, + "packageManager": "pnpm@10.33.0", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "lint:fix": "eslint --fix ." + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@sveltejs/adapter-node": "^5.5.3", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.0.0", + "@types/leaflet": "^1.9.0", + "eslint": "^10.0.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.15.0", + "globals": "^17.3.0", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.0", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.49.2", + "svelte-check": "^4.3.6", + "tailwindcss": "^4.0.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.56.0", + "vite": "^7.3.1" + }, + "dependencies": { + "leaflet": "^1.9.0" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..14b169e --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,2520 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + leaflet: + specifier: ^1.9.0 + version: 1.9.4 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.1.0(jiti@2.6.1)) + '@sveltejs/adapter-node': + specifier: ^5.5.3 + version: 5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))) + '@sveltejs/kit': + specifier: ^2.50.2 + version: 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + '@sveltejs/vite-plugin-svelte': + specifier: ^6.2.4 + version: 6.2.4(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + '@types/leaflet': + specifier: ^1.9.0 + version: 1.9.21 + eslint: + specifier: ^10.0.1 + version: 10.1.0(jiti@2.6.1) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.1.0(jiti@2.6.1)) + eslint-plugin-svelte: + specifier: ^3.15.0 + version: 3.16.0(eslint@10.1.0(jiti@2.6.1))(svelte@5.55.1) + globals: + specifier: ^17.3.0 + version: 17.4.0 + prettier: + specifier: ^3.8.1 + version: 3.8.1 + prettier-plugin-svelte: + specifier: ^3.5.0 + version: 3.5.1(prettier@3.8.1)(svelte@5.55.1) + prettier-plugin-tailwindcss: + specifier: ^0.7.2 + version: 0.7.2(prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.55.1))(prettier@3.8.1) + svelte: + specifier: ^5.49.2 + version: 5.55.1 + svelte-check: + specifier: ^4.3.6 + version: 4.4.6(picomatch@4.0.4)(svelte@5.55.1)(typescript@5.9.3) + tailwindcss: + specifier: ^4.0.0 + version: 4.2.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.56.0 + version: 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: ^7.3.1 + version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + +packages: + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.3': + resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.3': + resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.1.1': + resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.3': + resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/plugin-commonjs@29.0.2': + resolution: {integrity: sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==} + engines: {node: '>=16.0.0 || 14 >= 14.17'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-json@6.1.0': + resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-node@5.5.4': + resolution: {integrity: sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==} + peerDependencies: + '@sveltejs/kit': ^2.4.0 + + '@sveltejs/kit@2.55.0': + resolution: {integrity: sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2': + resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@sveltejs/vite-plugin-svelte@6.2.4': + resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.2': + resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/leaflet@1.9.21': + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.58.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.6.4: + resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-svelte@3.16.0: + resolution: {integrity: sha512-DJXxqpYZUxcE0SfYo8EJzV2ZC+zAD7fJp1n1HwcEMRR1cOEUYvjT9GuzJeNghMjgb7uxuK3IJAzI+x6zzUxO5A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.1 || ^9.0.0 || ^10.0.0 + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.1.0: + resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrap@2.2.4: + resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globals@17.4.0: + resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss-load-config@3.1.4: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-svelte@3.5.1: + resolution: {integrity: sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==} + peerDependencies: + prettier: ^3.0.0 + svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 + + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svelte-check@4.4.6: + resolution: {integrity: sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte-eslint-parser@1.6.0: + resolution: {integrity: sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.30.3} + peerDependencies: + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + + svelte@5.55.1: + resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==} + engines: {node: '>=18'} + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))': + dependencies: + eslint: 10.1.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.3': + dependencies: + '@eslint/object-schema': 3.0.3 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.3': + dependencies: + '@eslint/core': 1.1.1 + + '@eslint/core@1.1.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.1.0(jiti@2.6.1))': + optionalDependencies: + eslint: 10.1.0(jiti@2.6.1) + + '@eslint/object-schema@3.0.3': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.1.1 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@polka/url@1.0.0-next.29': {} + + '@rollup/plugin-commonjs@29.0.2(rollup@4.60.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + commondir: 1.0.1 + estree-walker: 2.0.2 + fdir: 6.5.0(picomatch@4.0.4) + is-reference: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.1 + + '@rollup/plugin-json@6.1.0(rollup@4.60.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + optionalDependencies: + rollup: 4.60.1 + + '@rollup/plugin-node-resolve@16.0.3(rollup@4.60.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.11 + optionalDependencies: + rollup: 4.60.1 + + '@rollup/pluginutils@5.3.0(rollup@4.60.1)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.1 + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)))': + dependencies: + '@rollup/plugin-commonjs': 29.0.2(rollup@4.60.1) + '@rollup/plugin-json': 6.1.0(rollup@4.60.1) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.1) + '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + rollup: 4.60.1 + + '@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + '@types/cookie': 0.6.0 + acorn: 8.16.0 + cookie: 0.6.0 + devalue: 5.6.4 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.1.0 + sirv: 3.0.2 + svelte: 5.55.1 + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + optionalDependencies: + typescript: 5.9.3 + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': + dependencies: + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + obug: 2.1.1 + svelte: 5.55.1 + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.1 + svelte: 5.55.1 + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + vitefu: 1.1.3(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/vite@4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + + '@types/cookie@0.6.0': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/json-schema@7.0.15': {} + + '@types/leaflet@1.9.21': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/resolve@1.20.2': {} + + '@types/trusted-types@2.0.7': {} + + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + eslint: 10.1.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + eslint: 10.1.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.1.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.58.0': {} + + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + eslint: 10.1.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + aria-query@5.3.1: {} + + axobject-query@4.1.0: {} + + balanced-match@4.0.4: {} + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + commondir@1.0.1: {} + + cookie@0.6.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + detect-libc@2.1.2: {} + + devalue@5.6.4: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.6.1)): + dependencies: + eslint: 10.1.0(jiti@2.6.1) + + eslint-plugin-svelte@3.16.0(eslint@10.1.0(jiti@2.6.1))(svelte@5.55.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@jridgewell/sourcemap-codec': 1.5.5 + eslint: 10.1.0(jiti@2.6.1) + esutils: 2.0.3 + globals: 16.5.0 + known-css-properties: 0.37.0 + postcss: 8.5.8 + postcss-load-config: 3.1.4(postcss@8.5.8) + postcss-safe-parser: 7.0.1(postcss@8.5.8) + semver: 7.7.4 + svelte-eslint-parser: 1.6.0(svelte@5.55.1) + optionalDependencies: + svelte: 5.55.1 + transitivePeerDependencies: + - ts-node + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.1.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.3 + '@eslint/config-helpers': 0.5.3 + '@eslint/core': 1.1.1 + '@eslint/plugin-kit': 0.6.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + esm-env@1.2.2: {} + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrap@2.2.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.58.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@16.5.0: {} + + globals@17.4.0: {} + + graceful-fs@4.2.11: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-module@1.0.0: {} + + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.8 + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + isexe@2.0.0: {} + + jiti@2.6.1: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@4.1.5: {} + + known-css-properties@0.37.0: {} + + leaflet@1.9.4: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lilconfig@2.1.0: {} + + locate-character@3.0.0: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + obug@2.1.1: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss-load-config@3.1.4(postcss@8.5.8): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.3 + optionalDependencies: + postcss: 8.5.8 + + postcss-safe-parser@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + postcss-scss@4.0.9(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.55.1): + dependencies: + prettier: 3.8.1 + svelte: 5.55.1 + + prettier-plugin-tailwindcss@0.7.2(prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.55.1))(prettier@3.8.1): + dependencies: + prettier: 3.8.1 + optionalDependencies: + prettier-plugin-svelte: 3.5.1(prettier@3.8.1)(svelte@5.55.1) + + prettier@3.8.1: {} + + punycode@2.3.1: {} + + readdirp@4.1.2: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + semver@7.7.4: {} + + set-cookie-parser@3.1.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + supports-preserve-symlinks-flag@1.0.0: {} + + svelte-check@4.4.6(picomatch@4.0.4)(svelte@5.55.1)(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.55.1 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte-eslint-parser@1.6.0(svelte@5.55.1): + dependencies: + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + postcss: 8.5.8 + postcss-scss: 4.0.9(postcss@8.5.8) + postcss-selector-parser: 7.1.1 + semver: 7.7.4 + optionalDependencies: + svelte: 5.55.1 + + svelte@5.55.1: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.6.4 + esm-env: 1.2.2 + esrap: 2.2.4 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + totalist@3.0.1: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.1.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + + vitefu@1.1.3(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)): + optionalDependencies: + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yaml@1.10.3: {} + + yocto-queue@0.1.0: {} + + zimmerframe@1.1.4: {} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..fa9170c --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,180 @@ +@import 'tailwindcss'; + +/* ── Fonts ───────────────────────────────────────────────── */ + +@font-face { + font-family: 'MedievalSharp'; + src: url('/fonts/medievalsharp-400.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Crimson Pro'; + src: url('/fonts/crimsonpro-400.woff2') format('woff2'); + font-weight: 400 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Crimson Pro'; + src: url('/fonts/crimsonpro-400i.woff2') format('woff2'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +/* ── Dark mode variant: .dark class only (JS resolves system preference) ── */ + +@custom-variant dark (&:is(.dark *)); + +/* ── Theme tokens ────────────────────────────────────────── */ + +@theme { + /* Forest green primary */ + --color-primary-50: oklch(0.97 0.02 150); + --color-primary-100: oklch(0.93 0.04 150); + --color-primary-200: oklch(0.86 0.08 150); + --color-primary-300: oklch(0.77 0.13 150); + --color-primary-400: oklch(0.67 0.16 150); + --color-primary-500: oklch(0.55 0.16 150); + --color-primary-600: oklch(0.47 0.14 150); + --color-primary-700: oklch(0.4 0.12 150); + --color-primary-800: oklch(0.33 0.09 150); + --color-primary-900: oklch(0.27 0.07 150); + --color-primary-950: oklch(0.18 0.05 150); + + /* Gold / amber accent */ + --color-accent-50: oklch(0.98 0.02 75); + --color-accent-100: oklch(0.94 0.06 70); + --color-accent-200: oklch(0.88 0.11 65); + --color-accent-300: oklch(0.82 0.15 60); + --color-accent-400: oklch(0.75 0.16 58); + --color-accent-500: oklch(0.68 0.16 55); + --color-accent-600: oklch(0.58 0.14 55); + --color-accent-700: oklch(0.48 0.11 55); + --color-accent-800: oklch(0.4 0.08 55); + --color-accent-900: oklch(0.32 0.06 55); + --color-accent-950: oklch(0.22 0.04 55); + + /* Warm stone neutrals (constant scale — dark mode uses dark: utilities) */ + --color-stone-50: oklch(0.98 0.005 70); + --color-stone-100: oklch(0.96 0.008 60); + --color-stone-200: oklch(0.92 0.01 55); + --color-stone-300: oklch(0.87 0.012 50); + --color-stone-400: oklch(0.71 0.013 55); + --color-stone-500: oklch(0.56 0.013 58); + --color-stone-600: oklch(0.45 0.012 58); + --color-stone-700: oklch(0.38 0.011 55); + --color-stone-800: oklch(0.32 0.01 50); + --color-stone-900: oklch(0.25 0.008 45); + --color-stone-950: oklch(0.17 0.006 40); + + /* Semantic surface colors (overridden in .dark via CSS vars) */ + --color-parchment: oklch(0.96 0.012 70); + --color-vellum: oklch(0.99 0.006 70); + + /* Danger / brick red */ + --color-danger-50: oklch(0.97 0.015 25); + --color-danger-100: oklch(0.93 0.04 25); + --color-danger-200: oklch(0.87 0.08 25); + --color-danger-300: oklch(0.78 0.12 25); + --color-danger-400: oklch(0.68 0.15 25); + --color-danger-500: oklch(0.58 0.16 25); + --color-danger-600: oklch(0.5 0.15 25); + --color-danger-700: oklch(0.42 0.12 25); + --color-danger-800: oklch(0.35 0.09 25); + --color-danger-900: oklch(0.28 0.06 25); + --color-danger-950: oklch(0.2 0.04 25); + + /* Typography */ + --font-heading: 'MedievalSharp', cursive; + --font-sans: 'Crimson Pro', 'Georgia', serif; +} + +/* ── Dark surface overrides (.dark class set by JS) ──────── */ + +:root.dark { + color-scheme: dark; + --color-parchment: oklch(0.14 0.007 20); + --color-vellum: oklch(0.19 0.009 18); +} + +/* ── Base layer ──────────────────────────────────────────── */ + +@layer base { + body { + @apply bg-parchment text-stone-800 antialiased; + } + + .dark body { + @apply text-stone-200; + } + + /* Skip-to-content link (visible on focus only) */ + .skip-link { + @apply bg-primary-700 absolute -top-full left-4 z-50 rounded-b-lg px-4 py-2 text-sm font-medium text-white; + @apply focus:ring-primary-400 focus:top-0 focus:ring-2 focus:outline-none; + } + + h1, + h2, + h3, + h4 { + font-family: var(--font-heading); + } + + h1 { + @apply relative pb-3; + } + + h1::after { + content: ''; + @apply bg-accent-400 absolute bottom-0 left-0 h-0.5 w-16; + } + + h1:is([class*='text-center'])::after { + @apply left-1/2 -translate-x-1/2; + } + + /* ── Shared form control styles ───────────────────────── */ + + input:where( + :not([type='hidden']):not([type='checkbox']):not([type='radio']):not([type='submit']):not( + [type='button'] + ):not([type='reset']) + ), + textarea { + @apply bg-vellum block w-full rounded-lg border border-stone-300 px-3 py-2 text-sm text-stone-900 shadow-sm transition-colors; + @apply placeholder-stone-400; + @apply focus:border-primary-500 focus:ring-primary-500 focus:ring-2 focus:outline-none; + } + + .dark + input:where( + :not([type='hidden']):not([type='checkbox']):not([type='radio']):not([type='submit']):not( + [type='button'] + ):not([type='reset']) + ), + .dark textarea { + @apply border-stone-600 bg-stone-800 text-stone-100 placeholder-stone-500; + } + + /* Error state */ + input[aria-invalid='true'], + textarea[aria-invalid='true'] { + @apply border-danger-400 text-danger-900 placeholder-danger-300; + } + + .dark input[aria-invalid='true'], + .dark textarea[aria-invalid='true'] { + @apply border-danger-500 text-danger-200; + } + + /* Focus ring offset matches background */ + *:focus-visible { + --tw-ring-offset-color: var(--color-parchment); + } +} diff --git a/web/src/app.d.ts b/web/src/app.d.ts new file mode 100644 index 0000000..83a2120 --- /dev/null +++ b/web/src/app.d.ts @@ -0,0 +1,18 @@ +import type { ProfileData } from '$lib/api/types.js'; + +declare global { + namespace App { + interface Error { + code?: string; + message: string; + } + interface Locals { + user: ProfileData | null; + } + interface PageData { + user: ProfileData | null; + } + } +} + +export {}; diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..6bbb9c9 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts new file mode 100644 index 0000000..5b0b81f --- /dev/null +++ b/web/src/hooks.server.ts @@ -0,0 +1,65 @@ +import type { Handle } from '@sveltejs/kit'; +import { apiFetch } from '$lib/api/client.js'; +import { refreshTokens } from '$lib/api/client.server.js'; +import type { ProfileData } from '$lib/api/types.js'; + +export const handle: Handle = async ({ event, resolve }) => { + // Lightweight health endpoint for k8s probes — bypass auth and SSR. + if (event.url.pathname === '/healthz') { + return new Response('ok', { status: 200 }); + } + + const accessToken = event.cookies.get('access_token'); + const sessionToken = event.cookies.get('session_token'); + + event.locals.user = null; + + if (accessToken) { + try { + const res = await apiFetch('/users/me', { + headers: { Authorization: `Bearer ${accessToken}` }, + fetch: event.fetch + }); + event.locals.user = res.data; + } catch { + // Access token expired — try refresh + if (sessionToken) { + const refreshed = await refreshTokens(event.cookies, event.fetch); + if (refreshed) { + const newToken = event.cookies.get('access_token'); + if (newToken) { + try { + const res = await apiFetch('/users/me', { + headers: { Authorization: `Bearer ${newToken}` }, + fetch: event.fetch + }); + event.locals.user = res.data; + } catch { + // Token invalid even after refresh + } + } + } + } + } + } else if (sessionToken) { + const refreshed = await refreshTokens(event.cookies, event.fetch); + if (refreshed) { + const newToken = event.cookies.get('access_token'); + if (newToken) { + try { + const res = await apiFetch('/users/me', { + headers: { Authorization: `Bearer ${newToken}` }, + fetch: event.fetch + }); + event.locals.user = res.data; + } catch { + // Failed after refresh + } + } + } + } + + return resolve(event, { + preload: ({ type }) => type === 'font' || type === 'js' || type === 'css' + }); +}; diff --git a/web/src/lib/api/client.server.ts b/web/src/lib/api/client.server.ts new file mode 100644 index 0000000..539e9f7 --- /dev/null +++ b/web/src/lib/api/client.server.ts @@ -0,0 +1,45 @@ +import type { Cookies } from '@sveltejs/kit'; +import { apiFetch } from './client.js'; +import type { ApiResponse, AuthData } from './types.js'; +import { setAuthCookies } from '$lib/auth/cookies.js'; + +export async function serverFetch( + path: string, + cookies: Cookies, + init?: RequestInit & { fetch?: typeof globalThis.fetch } +): Promise> { + const accessToken = cookies.get('access_token'); + const headers: Record = {}; + + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } + + return apiFetch(path, { + ...init, + headers: { + ...headers, + ...init?.headers + } + }); +} + +export async function refreshTokens( + cookies: Cookies, + fetchFn: typeof globalThis.fetch +): Promise { + const sessionToken = cookies.get('session_token'); + if (!sessionToken) return false; + + try { + const res = await apiFetch('/auth/refresh', { + method: 'POST', + body: JSON.stringify({ session_token: sessionToken }), + fetch: fetchFn + }); + setAuthCookies(cookies, res.data); + return true; + } catch { + return false; + } +} diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts new file mode 100644 index 0000000..8eb097b --- /dev/null +++ b/web/src/lib/api/client.ts @@ -0,0 +1,54 @@ +import { PUBLIC_API_BASE_URL } from '$env/static/public'; +import type { ApiError, ApiResponse } from './types.js'; + +const API_BASE = `${PUBLIC_API_BASE_URL}/api/v1`; + +export class ApiClientError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string + ) { + super(message); + this.name = 'ApiClientError'; + } +} + +export async function apiFetch( + path: string, + init?: RequestInit & { fetch?: typeof globalThis.fetch } +): Promise> { + const fetchFn = init?.fetch ?? globalThis.fetch; + const { fetch: _, ...restInit } = init ?? {}; + + const res = await fetchFn(`${API_BASE}${path}`, { + headers: { + 'Content-Type': 'application/json', + ...restInit.headers + }, + ...restInit + }); + + const body = await res.json(); + + if (!res.ok) { + const err = body.error as ApiError | undefined; + throw new ApiClientError( + res.status, + err?.code ?? 'unknown', + err?.message ?? `Request failed with status ${res.status}` + ); + } + + return body as ApiResponse; +} + +export function buildSearchQuery(params: Record): string { + const sp = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== '') { + sp.set(key, String(value)); + } + } + return sp.toString(); +} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts new file mode 100644 index 0000000..98dd3cd --- /dev/null +++ b/web/src/lib/api/types.ts @@ -0,0 +1,243 @@ +// API response envelope +export interface ApiResponse { + data: T; + meta?: PaginationMeta; + error?: ApiError; +} + +export interface ApiError { + code: string; + message: string; +} + +export interface PaginationMeta { + page: number; + per_page: number; + total: number; + total_pages: number; +} + +// Market types +export interface MarketSummary { + id: string; + series_id?: string; + slug: string; + name: string; + city: string; + state: string; + zip: string; + country: string; + latitude: number; + longitude: number; + start_date: string; + end_date: string; + image_url: string; + organizer_name: string; + distance?: number; // meters, only in geo queries + edition_count?: number; +} + +export interface MarketDetail { + id: string; + slug: string; + name: string; + description: string; + street: string; + city: string; + state: string; + zip: string; + country: string; + latitude: number; + longitude: number; + start_date: string; + end_date: string; + opening_hours: OpeningHoursEntry[] | null; + admission_info: AdmissionInfo | null; + website: string; + organizer_name: string; + image_url: string; +} + +export interface OpeningHoursEntry { + day: string; + open: string; + close: string; +} + +export interface AdmissionInfo { + adult_cents: number; + child_cents: number; + reduced_cents: number; + free_under_age: number; + notes: string; +} + +export interface EditionBrief { + year: number; + start_date: string; + end_date: string; +} + +// Auth types +export interface AuthData { + access_token: string; + session_token: string; + expires_in: number; +} + +export interface TOTPSetupData { + secret: string; + url: string; +} + +export interface MessageData { + message: string; +} + +// User types +export interface ProfileData { + id: string; + email: string; + email_verified: boolean; + display_name: string; + avatar_url: string; + role: string; + has_password: boolean; + created_at: string; +} + +// Admin types +export type EditionStatus = + | 'rumored' + | 'confirmed' + | 'active' + | 'completed' + | 'cancelled' + | 'archived'; + +// Keep backward compat alias +export type MarketStatus = EditionStatus; + +export interface AdminMarketSummary { + id: string; + series_id: string; + year: number; + slug: string; + name: string; + city: string; + state: string; + status: EditionStatus; + start_date: string; + end_date: string; + organizer_name: string; + submitter_name: string; + created_at: string; +} + +export interface AdminMarketDetail { + id: string; + series_id: string; + year: number; + slug: string; + series_name: string; + name: string; + description: string; + street: string; + city: string; + state: string; + zip: string; + country: string; + latitude: number; + longitude: number; + start_date: string; + end_date: string; + opening_hours: OpeningHoursEntry[] | null; + admission_info: AdmissionInfo | null; + website: string; + organizer_name: string; + image_url: string; + sources: string[] | null; + status: EditionStatus; + submitter_email?: string; + submitter_name: string; + admin_notes: string; + reviewed_at?: string; + reviewed_by?: string; + created_at: string; + updated_at: string; +} + +export interface SubmitMarketRequest { + name: string; + description: string; + city: string; + state: string; + zip: string; + country: string; + start_date: string; + end_date: string; + website: string; + organizer_name: string; + submitter_email: string; + submitter_name: string; + turnstile_token: string; +} + +// AI Research types +export interface ResearchResult { + suggestions: FieldSuggestion[]; + sources: string[]; +} + +export interface FieldSuggestion { + field: string; + current_value: unknown; + suggested_value: unknown; + confidence: 'high' | 'medium' | 'low'; + reason: string; +} + +// Duplicate detection +export interface DuplicateMarket { + id: string; + name: string; + city: string; + start_date: string; + end_date: string; + similarity: number; +} + +// Series types +export interface SeriesSummary { + id: string; + slug: string; + name: string; + city: string; + country: string; +} + +export interface AdminSeriesGroup { + series_id: string; + slug: string; + series_name: string; + city: string; + editions: AdminMarketSummary[]; +} + +export interface SeriesEditionsResponse { + series: SeriesSummary; + editions: AdminMarketSummary[]; +} + +// Search params (mirrors backend SearchParams) +export interface MarketSearchParams { + lat?: number; + lon?: number; + radius?: number; + q?: string; + from?: string; + to?: string; + sort?: 'distance' | 'date' | 'name'; + page?: number; + per_page?: number; +} diff --git a/web/src/lib/assets/favicon.svg b/web/src/lib/assets/favicon.svg new file mode 100644 index 0000000..fc8ed39 --- /dev/null +++ b/web/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo diff --git a/web/src/lib/auth/cookies.ts b/web/src/lib/auth/cookies.ts new file mode 100644 index 0000000..ade877c --- /dev/null +++ b/web/src/lib/auth/cookies.ts @@ -0,0 +1,26 @@ +import { dev } from '$app/environment'; +import type { Cookies } from '@sveltejs/kit'; +import type { AuthData } from '$lib/api/types.js'; + +const COOKIE_OPTS = { + path: '/', + httpOnly: true, + secure: !dev, + sameSite: 'lax' as const +}; + +export function setAuthCookies(cookies: Cookies, auth: AuthData): void { + cookies.set('access_token', auth.access_token, { + ...COOKIE_OPTS, + maxAge: auth.expires_in + }); + cookies.set('session_token', auth.session_token, { + ...COOKIE_OPTS, + maxAge: 30 * 24 * 60 * 60 // 30 days + }); +} + +export function clearAuthCookies(cookies: Cookies): void { + cookies.delete('access_token', { path: '/' }); + cookies.delete('session_token', { path: '/' }); +} diff --git a/web/src/lib/auth/guard.ts b/web/src/lib/auth/guard.ts new file mode 100644 index 0000000..84e6003 --- /dev/null +++ b/web/src/lib/auth/guard.ts @@ -0,0 +1,17 @@ +import { error, redirect } from '@sveltejs/kit'; +import type { ServerLoadEvent } from '@sveltejs/kit'; + +export function requireAuth(event: ServerLoadEvent): void { + if (!event.locals.user) { + redirect(302, `/auth/anmelden?redirect=${encodeURIComponent(event.url.pathname)}`); + } +} + +export function requireAdmin(event: ServerLoadEvent): void { + if (!event.locals.user) { + redirect(302, `/auth/anmelden?redirect=${encodeURIComponent(event.url.pathname)}`); + } + if (event.locals.user.role !== 'admin') { + error(403, 'Zugriff verweigert'); + } +} diff --git a/web/src/lib/components/admin/MarketForm.svelte b/web/src/lib/components/admin/MarketForm.svelte new file mode 100644 index 0000000..014dfaa --- /dev/null +++ b/web/src/lib/components/admin/MarketForm.svelte @@ -0,0 +1,552 @@ + + +{#if error} + +{/if} + +
+
+ Allgemein + + + +
+ + +
+
+ +
+ Standort + + + +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ + {#if geocodeError} + {geocodeError} + {/if} +
+
+ +
+ Zeitraum + +
+ + +
+
+ +
+ Weitere Infos + + + + + + +
+ +
+ Öffnungszeiten + + {#each hours as row, i} +
+
+ + +
+ { + row.open = e.currentTarget.value; + }} + /> + { + row.close = e.currentTarget.value; + }} + /> + +
+ {/each} + + + + +
+ +
+ Eintrittspreise + +
+
+ + { + admission.adult_cents = Math.round(parseFloat(e.currentTarget.value || '0') * 100); + }} + class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 + text-sm shadow-sm focus:ring-2 focus:outline-none + dark:border-stone-600 dark:bg-stone-800" + /> +
+
+ + { + admission.child_cents = Math.round(parseFloat(e.currentTarget.value || '0') * 100); + }} + class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 + text-sm shadow-sm focus:ring-2 focus:outline-none + dark:border-stone-600 dark:bg-stone-800" + /> +
+
+ + { + admission.reduced_cents = Math.round(parseFloat(e.currentTarget.value || '0') * 100); + }} + class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 + text-sm shadow-sm focus:ring-2 focus:outline-none + dark:border-stone-600 dark:bg-stone-800" + /> +
+
+ +
+
+ + +
+
+ +
+ + +
+ + +
+ + {#if mode === 'admin'} +
+ Admin-Notizen + +
+ +
+
+ {/if} + + {#if extraFields} + {@render extraFields()} + {/if} + +
+ + {#if mode === 'admin'} + + + + {/if} +
+
diff --git a/web/src/lib/components/admin/ResearchPanel.svelte b/web/src/lib/components/admin/ResearchPanel.svelte new file mode 100644 index 0000000..e82460d --- /dev/null +++ b/web/src/lib/components/admin/ResearchPanel.svelte @@ -0,0 +1,186 @@ + + +
+
+

+ KI-Recherche Ergebnisse +

+ +
+ + {#if result.suggestions.length === 0} +

Keine Vorschläge gefunden.

+ {:else} +
+ {#each result.suggestions as suggestion, i} + + {/each} +
+ +
+ + +
+ {/if} + + {#if result.sources && result.sources.length > 0} +
+

Quellen:

+ +
+ {/if} +
diff --git a/web/src/lib/components/auth/LoginForm.svelte b/web/src/lib/components/auth/LoginForm.svelte new file mode 100644 index 0000000..a1169e9 --- /dev/null +++ b/web/src/lib/components/auth/LoginForm.svelte @@ -0,0 +1,55 @@ + + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-4" +> + {#if error} + {error} + {/if} + + + + + {#if requireTotp} + + {/if} + + +
diff --git a/web/src/lib/components/auth/MagicLinkForm.svelte b/web/src/lib/components/auth/MagicLinkForm.svelte new file mode 100644 index 0000000..a75cd4d --- /dev/null +++ b/web/src/lib/components/auth/MagicLinkForm.svelte @@ -0,0 +1,38 @@ + + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-4" +> + {#if error} + {error} + {/if} + {#if success} + {success} + {/if} + + + + +
diff --git a/web/src/lib/components/auth/OAuthButtons.svelte b/web/src/lib/components/auth/OAuthButtons.svelte new file mode 100644 index 0000000..1adff14 --- /dev/null +++ b/web/src/lib/components/auth/OAuthButtons.svelte @@ -0,0 +1,17 @@ + + +
+ {#each providers as provider} + + Mit {provider.label} anmelden + + {/each} +
diff --git a/web/src/lib/components/auth/RegisterForm.svelte b/web/src/lib/components/auth/RegisterForm.svelte new file mode 100644 index 0000000..729f51b --- /dev/null +++ b/web/src/lib/components/auth/RegisterForm.svelte @@ -0,0 +1,43 @@ + + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-4" +> + {#if error} + {error} + {/if} + + + + + + +
diff --git a/web/src/lib/components/auth/TOTPSetup.svelte b/web/src/lib/components/auth/TOTPSetup.svelte new file mode 100644 index 0000000..bb16210 --- /dev/null +++ b/web/src/lib/components/auth/TOTPSetup.svelte @@ -0,0 +1,76 @@ + + +{#if error} + {error} +{/if} +{#if success} + {success} +{/if} + +{#if secret && url} +
+

+ Scanne den QR-Code mit deiner Authenticator-App oder gib den Schlüssel manuell ein. +

+ +
+ TOTP QR-Code +
+ +
+

Schlüssel

+

+ {secret} +

+
+ +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + class="space-y-4" + > + + +
+
+{/if} diff --git a/web/src/lib/components/layout/Footer.svelte b/web/src/lib/components/layout/Footer.svelte new file mode 100644 index 0000000..ba685ed --- /dev/null +++ b/web/src/lib/components/layout/Footer.svelte @@ -0,0 +1,20 @@ + + + diff --git a/web/src/lib/components/layout/Header.svelte b/web/src/lib/components/layout/Header.svelte new file mode 100644 index 0000000..46a7474 --- /dev/null +++ b/web/src/lib/components/layout/Header.svelte @@ -0,0 +1,84 @@ + + +
+
+ + + Marktvogt + + + + + + +
+ +
+
+ + {#if mobileOpen} + (mobileOpen = false)} /> + {/if} +
diff --git a/web/src/lib/components/layout/MobileNav.svelte b/web/src/lib/components/layout/MobileNav.svelte new file mode 100644 index 0000000..15c8bdd --- /dev/null +++ b/web/src/lib/components/layout/MobileNav.svelte @@ -0,0 +1,57 @@ + + + diff --git a/web/src/lib/components/layout/UserMenu.svelte b/web/src/lib/components/layout/UserMenu.svelte new file mode 100644 index 0000000..b93fc2e --- /dev/null +++ b/web/src/lib/components/layout/UserMenu.svelte @@ -0,0 +1,105 @@ + + + +
+ + + {#if open} + + {/if} +
diff --git a/web/src/lib/components/market/MarketCard.svelte b/web/src/lib/components/market/MarketCard.svelte new file mode 100644 index 0000000..40f7263 --- /dev/null +++ b/web/src/lib/components/market/MarketCard.svelte @@ -0,0 +1,97 @@ + + + + {#if market.image_url} +
+ {market.name} +
+ {/if} +
+

+ {market.name} +

+

+ {market.city}{#if market.state}, {market.state}{/if} +

+
+ + + + + {formatDate(market.start_date)} – {formatDate(market.end_date)} + + {#if market.edition_count && market.edition_count > 1} + + +{market.edition_count - 1} weitere {market.edition_count > 2 ? 'Termine' : 'Termin'} + + {/if} + {#if market.distance !== undefined} + + + + + + {formatDistance(market.distance)} + + {/if} +
+ {#if market.organizer_name} +

von {market.organizer_name}

+ {/if} +
+
diff --git a/web/src/lib/components/market/MarketMap.svelte b/web/src/lib/components/market/MarketMap.svelte new file mode 100644 index 0000000..72c6b7e --- /dev/null +++ b/web/src/lib/components/market/MarketMap.svelte @@ -0,0 +1,83 @@ + + +
diff --git a/web/src/lib/components/market/Pagination.svelte b/web/src/lib/components/market/Pagination.svelte new file mode 100644 index 0000000..baf170e --- /dev/null +++ b/web/src/lib/components/market/Pagination.svelte @@ -0,0 +1,73 @@ + + +{#if meta.total_pages > 1} + +{/if} diff --git a/web/src/lib/components/market/SearchForm.svelte b/web/src/lib/components/market/SearchForm.svelte new file mode 100644 index 0000000..22f9575 --- /dev/null +++ b/web/src/lib/components/market/SearchForm.svelte @@ -0,0 +1,259 @@ + + + diff --git a/web/src/lib/components/ui/Alert.svelte b/web/src/lib/components/ui/Alert.svelte new file mode 100644 index 0000000..a17b1b4 --- /dev/null +++ b/web/src/lib/components/ui/Alert.svelte @@ -0,0 +1,24 @@ + + + diff --git a/web/src/lib/components/ui/Button.svelte b/web/src/lib/components/ui/Button.svelte new file mode 100644 index 0000000..1b7774b --- /dev/null +++ b/web/src/lib/components/ui/Button.svelte @@ -0,0 +1,64 @@ + + + diff --git a/web/src/lib/components/ui/Input.svelte b/web/src/lib/components/ui/Input.svelte new file mode 100644 index 0000000..f0a7e96 --- /dev/null +++ b/web/src/lib/components/ui/Input.svelte @@ -0,0 +1,31 @@ + + +
+ {#if label} + + {/if} + + {#if error} + + {/if} +
diff --git a/web/src/lib/components/ui/Select.svelte b/web/src/lib/components/ui/Select.svelte new file mode 100644 index 0000000..3c645e8 --- /dev/null +++ b/web/src/lib/components/ui/Select.svelte @@ -0,0 +1,266 @@ + + +
+ {#if label} + + {/if} + + + + {#if open} +
    + {#each options as opt, i} +
  • { + e.preventDefault(); + select(opt); + }} + onmouseenter={() => (activeIndex = i)} + class="cursor-pointer px-3 py-2 transition-colors select-none + {i === activeIndex + ? 'bg-primary-100 text-primary-900 dark:bg-primary-900 dark:text-primary-100' + : ''} + {opt.value === value && i !== activeIndex + ? 'text-primary-700 dark:text-primary-300 font-medium' + : ''} + {opt.value !== value && i !== activeIndex + ? 'text-stone-900 hover:bg-stone-100 dark:text-stone-100 dark:hover:bg-stone-700' + : ''}" + > + + {opt.label} + {#if opt.value === value} + + {/if} + +
  • + {/each} +
+ {/if} + + {#if name} + + {/if} + + {#if error} + + {/if} +
diff --git a/web/src/lib/components/ui/Spinner.svelte b/web/src/lib/components/ui/Spinner.svelte new file mode 100644 index 0000000..8c3ee19 --- /dev/null +++ b/web/src/lib/components/ui/Spinner.svelte @@ -0,0 +1,26 @@ + + + + + + diff --git a/web/src/lib/components/ui/ThemeToggle.svelte b/web/src/lib/components/ui/ThemeToggle.svelte new file mode 100644 index 0000000..dee4cc2 --- /dev/null +++ b/web/src/lib/components/ui/ThemeToggle.svelte @@ -0,0 +1,75 @@ + + + diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/web/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts new file mode 100644 index 0000000..6faeb37 --- /dev/null +++ b/web/src/lib/theme.ts @@ -0,0 +1,42 @@ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +export type ThemeMode = 'system' | 'light' | 'dark'; + +const STORAGE_KEY = 'marktvogt-theme'; + +function getInitial(): ThemeMode { + if (!browser) return 'system'; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark') return stored; + return 'system'; +} + +function resolveEffective(mode: ThemeMode): boolean { + if (mode === 'dark') return true; + if (mode === 'light') return false; + return browser && window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +function applyClass(isDark: boolean) { + if (!browser) return; + document.documentElement.classList.toggle('dark', isDark); +} + +export const theme = writable(getInitial()); + +if (browser) { + let currentMode: ThemeMode = getInitial(); + + theme.subscribe((mode) => { + currentMode = mode; + localStorage.setItem(STORAGE_KEY, mode); + applyClass(resolveEffective(mode)); + }); + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (currentMode === 'system') { + applyClass(resolveEffective('system')); + } + }); +} diff --git a/web/src/lib/utils/slug.ts b/web/src/lib/utils/slug.ts new file mode 100644 index 0000000..bb2ece6 --- /dev/null +++ b/web/src/lib/utils/slug.ts @@ -0,0 +1,47 @@ +const UMLAUT_MAP: Record = { + ä: 'ae', + ö: 'oe', + ü: 'ue', + Ä: 'Ae', + Ö: 'Oe', + Ü: 'Ue', + ß: 'ss' +}; + +export function toSlug(name: string): string { + return name + .toLowerCase() + .replace(/[äöüß]/g, (match) => UMLAUT_MAP[match] ?? match) + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** All 16 German states: slug → display name */ +export const STATE_SLUGS: Record = { + 'baden-wuerttemberg': 'Baden-Württemberg', + bayern: 'Bayern', + berlin: 'Berlin', + brandenburg: 'Brandenburg', + bremen: 'Bremen', + hamburg: 'Hamburg', + hessen: 'Hessen', + 'mecklenburg-vorpommern': 'Mecklenburg-Vorpommern', + niedersachsen: 'Niedersachsen', + 'nordrhein-westfalen': 'Nordrhein-Westfalen', + 'rheinland-pfalz': 'Rheinland-Pfalz', + saarland: 'Saarland', + sachsen: 'Sachsen', + 'sachsen-anhalt': 'Sachsen-Anhalt', + 'schleswig-holstein': 'Schleswig-Holstein', + thueringen: 'Thüringen' +}; + +const stateNameToSlug = new Map(Object.entries(STATE_SLUGS).map(([slug, name]) => [name, slug])); + +export function stateToSlug(state: string): string { + return stateNameToSlug.get(state) ?? toSlug(state); +} + +export function slugToState(slug: string): string | undefined { + return STATE_SLUGS[slug]; +} diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte new file mode 100644 index 0000000..f6ac17e --- /dev/null +++ b/web/src/routes/+error.svelte @@ -0,0 +1,27 @@ + + + + Fehler {$page.status} - Marktvogt + + +
+

{$page.status}

+

+ {#if $page.status === 404} + Die gesuchte Seite wurde nicht gefunden. + {:else} + Ein Fehler ist aufgetreten. + {/if} +

+ {#if $page.error?.message} +

{$page.error.message}

+ {/if} + +
diff --git a/web/src/routes/+layout.server.ts b/web/src/routes/+layout.server.ts new file mode 100644 index 0000000..5d5a2ce --- /dev/null +++ b/web/src/routes/+layout.server.ts @@ -0,0 +1,7 @@ +import type { LayoutServerLoad } from './$types.js'; + +export const load: LayoutServerLoad = async ({ locals }) => { + return { + user: locals.user + }; +}; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..d3540eb --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,47 @@ + + + + Marktvogt - Mittelaltermärkte finden + + + + + + + + + + +
+
+
+ {@render children()} +
+
+
diff --git a/web/src/routes/+page.server.ts b/web/src/routes/+page.server.ts new file mode 100644 index 0000000..53afc61 --- /dev/null +++ b/web/src/routes/+page.server.ts @@ -0,0 +1,59 @@ +import type { PageServerLoad } from './$types.js'; +import { apiFetch, buildSearchQuery } from '$lib/api/client.js'; +import type { MarketSummary, PaginationMeta } from '$lib/api/types.js'; + +export const load: PageServerLoad = async ({ url, fetch }) => { + const params: Record = {}; + + const q = url.searchParams.get('q'); + const lat = url.searchParams.get('lat'); + const lon = url.searchParams.get('lon'); + const radius = url.searchParams.get('radius'); + const from = url.searchParams.get('from'); + const to = url.searchParams.get('to'); + const sort = url.searchParams.get('sort'); + const page = url.searchParams.get('page'); + + if (q) params.q = q; + if (lat) params.lat = lat; + if (lon) params.lon = lon; + if (radius) params.radius = radius; + if (from) params.from = from; + if (to) params.to = to; + if (sort) params.sort = sort; + if (page) params.page = page; + + const query = buildSearchQuery(params); + const path = `/markets${query ? `?${query}` : ''}`; + + try { + const res = await apiFetch(path, { fetch }); + return { + markets: res.data, + meta: res.meta as PaginationMeta, + searchParams: { + q: q ?? '', + lat: lat ? Number(lat) : undefined, + lon: lon ? Number(lon) : undefined, + radius: radius ? Number(radius) : 25, + from: from ?? '', + to: to ?? '', + sort: sort ?? '' + } + }; + } catch { + return { + markets: [] as MarketSummary[], + meta: { page: 1, per_page: 20, total: 0, total_pages: 0 } as PaginationMeta, + searchParams: { + q: q ?? '', + lat: lat ? Number(lat) : undefined, + lon: lon ? Number(lon) : undefined, + radius: radius ? Number(radius) : 25, + from: from ?? '', + to: to ?? '', + sort: sort ?? '' + } + }; + } +}; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..40d04d4 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,124 @@ + + + + Marktvogt - Mittelaltermärkte finden + + + + {@html jsonLdHtml} + + +
+
+

+ Mittelaltermärkte finden +

+

+ Entdecke Mittelaltermärkte, Ritterturniere und historische Feste in deiner Nähe. +

+
+ + + +
+
+

+ {data.meta.total} + {data.meta.total === 1 ? 'Markt' : 'Märkte'} gefunden +

+
+ + +
+
+ + {#if view === 'list'} + {#if data.markets.length > 0} +
+ {#each data.markets as market (market.id)} + + {/each} +
+ {:else} +
+

+ Keine Märkte gefunden. Versuche andere Suchkriterien. +

+
+ {/if} + {:else} + + {/if} + + {#if data.meta.total_pages > 1} +
+ +
+ {/if} +
+
diff --git a/web/src/routes/admin/+layout.server.ts b/web/src/routes/admin/+layout.server.ts new file mode 100644 index 0000000..035c159 --- /dev/null +++ b/web/src/routes/admin/+layout.server.ts @@ -0,0 +1,6 @@ +import { requireAdmin } from '$lib/auth/guard.js'; +import type { LayoutServerLoad } from './$types.js'; + +export const load: LayoutServerLoad = async (event) => { + requireAdmin(event); +}; diff --git a/web/src/routes/admin/+layout.svelte b/web/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..217d122 --- /dev/null +++ b/web/src/routes/admin/+layout.svelte @@ -0,0 +1,47 @@ + + + + Admin - Marktvogt + + +
+
+ + +
+ {@render children()} +
+
+
diff --git a/web/src/routes/admin/maerkte/+page.server.ts b/web/src/routes/admin/maerkte/+page.server.ts new file mode 100644 index 0000000..bc75802 --- /dev/null +++ b/web/src/routes/admin/maerkte/+page.server.ts @@ -0,0 +1,31 @@ +import { error } from '@sveltejs/kit'; +import { serverFetch } from '$lib/api/client.server.js'; +import { ApiClientError } from '$lib/api/client.js'; +import { buildSearchQuery } from '$lib/api/client.js'; +import type { AdminSeriesGroup, PaginationMeta } from '$lib/api/types.js'; +import type { PageServerLoad } from './$types.js'; + +export const load: PageServerLoad = async ({ url, cookies }) => { + const status = url.searchParams.get('status') ?? ''; + const q = url.searchParams.get('q') ?? ''; + const page = url.searchParams.get('page') ?? '1'; + const sort = url.searchParams.get('sort') ?? ''; + const order = url.searchParams.get('order') ?? ''; + + const query = buildSearchQuery({ status, q, page, per_page: '20', sort, order }); + + try { + const res = await serverFetch(`/admin/markets/grouped?${query}`, cookies); + + return { + groups: res.data, + meta: res.meta as PaginationMeta, + filters: { status, q, sort, order } + }; + } catch (err) { + if (err instanceof ApiClientError) { + error(err.status, err.message); + } + throw err; + } +}; diff --git a/web/src/routes/admin/maerkte/+page.svelte b/web/src/routes/admin/maerkte/+page.svelte new file mode 100644 index 0000000..5188bd1 --- /dev/null +++ b/web/src/routes/admin/maerkte/+page.svelte @@ -0,0 +1,336 @@ + + + + Märkte verwalten - Admin - Marktvogt + + +
+
+

Märkte

+ + + +
+ + +
+
+ + + {#if currentQ} + + + + {/if} +
+
+ + +
+ {#each tabs as tab} + + {tab.label} + + {/each} +
+ + +
+ + + + + + + + + + + + + + + {#each data.groups as group} + {@const latest = group.editions[0]} + {@const hasMultiple = group.editions.length > 1} + {@const isExpanded = expandedSeries.has(group.series_id)} + + + + + + + + + + + + + {#if hasMultiple && isExpanded} + {#each group.editions.slice(1) as edition} + + + + + + + + + + + {/each} + {/if} + {:else} + + + + {/each} + +
+ + Name{sortIndicator('name')} + + + + Stadt{sortIndicator('city')} + + + + Status{sortIndicator('status')} + + Jahr + + Zeitraum{sortIndicator('date')} + + + + Erstellt{sortIndicator('created')} + + Aktionen
+ {#if hasMultiple} + + {/if} + + {latest.name} + {#if hasMultiple} + + ({group.editions.length} Ausgaben) + + {/if} + {latest.city} + + {statusLabels[latest.status]} + + + {latest.year} + + {formatDate(latest.start_date)} - {formatDate(latest.end_date)} + + {formatDate(latest.created_at)} + + +
+ {edition.name} + + {edition.city} + + + {statusLabels[edition.status]} + + + {edition.year} + + {formatDate(edition.start_date)} - {formatDate(edition.end_date)} + + {formatDate(edition.created_at)} + + +
+ Keine Märkte gefunden. +
+
+ + + {#if data.meta && data.meta.total_pages > 1} +
+

+ Seite {data.meta.page} von {data.meta.total_pages} ({data.meta.total} Serien) +

+
+ {#if data.meta.page > 1} + + + + {/if} + {#if data.meta.page < data.meta.total_pages} + + + + {/if} +
+
+ {/if} +
diff --git a/web/src/routes/admin/maerkte/[id]/+page.server.ts b/web/src/routes/admin/maerkte/[id]/+page.server.ts new file mode 100644 index 0000000..0fa3bb7 --- /dev/null +++ b/web/src/routes/admin/maerkte/[id]/+page.server.ts @@ -0,0 +1,114 @@ +import { fail } from '@sveltejs/kit'; +import { serverFetch } from '$lib/api/client.server.js'; +import type { + AdminMarketDetail, + DuplicateMarket, + EditionStatus, + SeriesEditionsResponse +} from '$lib/api/types.js'; +import type { Actions, PageServerLoad } from './$types.js'; + +export const load: PageServerLoad = async ({ params, cookies }) => { + const res = await serverFetch(`/admin/markets/${params.id}`, cookies); + const market = res.data; + + let duplicates: DuplicateMarket[] = []; + if (market.status === 'rumored') { + try { + const dupRes = await serverFetch( + `/admin/markets/${params.id}/duplicates`, + cookies + ); + duplicates = dupRes.data ?? []; + } catch { + // Non-fatal: duplicates are informational + } + } + + // Fetch other editions for this series + let editions: { id: string; year: number; status: EditionStatus }[] = []; + try { + const edRes = await serverFetch( + `/admin/series/${market.series_id}/editions`, + cookies + ); + // The response wraps editions inside { series, editions } + const raw = edRes.data as unknown as SeriesEditionsResponse; + editions = raw.editions.map((e) => ({ + id: e.id, + year: e.year, + status: e.status as EditionStatus + })); + } catch { + // Non-fatal + } + + return { market, duplicates, editions }; +}; + +export const actions: Actions = { + updateStatus: async ({ request, params, cookies, fetch }) => { + const form = await request.formData(); + const status = form.get('status')?.toString() ?? ''; + const adminNotes = form.get('admin_notes')?.toString() ?? ''; + + if (!status || !['approved', 'rejected'].includes(status)) { + return fail(400, { error: 'Ungueltiger Status.' }); + } + + try { + await serverFetch(`/admin/markets/${params.id}/status`, cookies, { + method: 'PATCH', + body: JSON.stringify({ status, admin_notes: adminNotes }), + fetch + }); + + return { success: true, action: status }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Statusaenderung fehlgeschlagen.'; + return fail(500, { error: message }); + } + }, + + delete: async ({ params, cookies, fetch }) => { + try { + await serverFetch(`/admin/markets/${params.id}`, cookies, { + method: 'DELETE', + fetch + }); + + return { deleted: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.'; + return fail(500, { error: message }); + } + }, + + createEdition: async ({ request, cookies, fetch }) => { + const form = await request.formData(); + const seriesId = form.get('series_id')?.toString() ?? ''; + const startDate = form.get('start_date')?.toString() ?? ''; + const endDate = form.get('end_date')?.toString() ?? ''; + + if (!seriesId || !startDate || !endDate) { + return fail(400, { error: 'Start- und Enddatum sind erforderlich.' }); + } + + try { + const res = await serverFetch( + `/admin/series/${seriesId}/editions`, + cookies, + { + method: 'POST', + body: JSON.stringify({ start_date: startDate, end_date: endDate }), + fetch + } + ); + + return { created: true, newEditionId: res.data.id }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Edition erstellen fehlgeschlagen.'; + return fail(500, { error: message }); + } + } +}; diff --git a/web/src/routes/admin/maerkte/[id]/+page.svelte b/web/src/routes/admin/maerkte/[id]/+page.svelte new file mode 100644 index 0000000..c982982 --- /dev/null +++ b/web/src/routes/admin/maerkte/[id]/+page.svelte @@ -0,0 +1,455 @@ + + + + {data.market.name} - Admin - Marktvogt + + +
+
+
+ + ← Zurück zur Liste + +

{data.market.name}

+

+ Edition {data.market.year} + {#if data.market.series_name !== data.market.name} + · Serie: {data.market.series_name} + {/if} +

+
+
+ {#if data.editions && data.editions.length > 1} +
+ + {data.editions.length} Editionen + +
+ {#each data.editions as ed} + + {ed.year} + + {statusLabels[ed.status]} + + + {/each} +
+
+ {/if} + + + + +
{ + if (!confirm('Markt wirklich löschen?')) { + e.cancel(); + return; + } + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + > + +
+
+
+ + {#if showNewEdition} +
+

+ Neue Edition für "{data.market.series_name || data.market.name}" +

+
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + > + +
+ + +
+
+ + +
+ + +
+
+ {/if} + + {#if form?.error} + {form.error} + {/if} + + {#if form?.success} + + Status erfolgreich auf "{form.action === 'approved' ? 'Bestätigt' : 'Abgesagt'}" geändert. + + {/if} + + {#if data.duplicates && data.duplicates.length > 0} +
+

+ Mögliche Duplikate gefunden +

+
    + {#each data.duplicates as dup} +
  • + + {dup.name} + + — {dup.city}, {formatDate(dup.start_date)} - {formatDate(dup.end_date)} + + ({Math.round(dup.similarity * 100)}% Ähnlichkeit) + +
  • + {/each} +
+
+ {/if} + + +
+
+ Status: + + {statusLabels[data.market.status]} + +
+ + {#if isReviewable} +
+
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + > +
+ + +
+
+ + +
+
+
+ {:else} + {#if data.market.admin_notes} +

+ Notizen: + {data.market.admin_notes} +

+ {/if} + {#if data.market.reviewed_at} +

+ Geprüft am {formatDate(data.market.reviewed_at)} +

+ {/if} + {/if} +
+ + +
+

Details

+
+
+
Beschreibung
+
+ {data.market.description || '-'} +
+
+
+
Adresse
+
+ {data.market.street ? `${data.market.street}, ` : ''} + {data.market.zip} + {data.market.city}, {data.market.state} + {data.market.country} +
+
+
+
Koordinaten
+
+ {data.market.latitude?.toFixed(6) ?? '—'}, {data.market.longitude?.toFixed(6) ?? '—'} +
+
+
+
Zeitraum
+
+ {formatDate(data.market.start_date)} - {formatDate(data.market.end_date)} +
+
+
+
Veranstalter
+
+ {data.market.organizer_name || '-'} +
+
+
+
Website
+
+ {#if data.market.website} + + {data.market.website} + + {:else} + - + {/if} +
+
+
+
Slug
+
+ {data.market.slug} +
+
+
+
+ + + {#if data.market.opening_hours && data.market.opening_hours.length > 0} +
+

Öffnungszeiten

+ + + + + + + + + + {#each data.market.opening_hours as entry} + + + + + + {/each} + +
TagVonBis
{entry.day}{entry.open}{entry.close}
+
+ {/if} + + + {#if data.market.admission_info && (data.market.admission_info.adult_cents > 0 || data.market.admission_info.child_cents > 0 || data.market.admission_info.notes)} +
+

Eintrittspreise

+
+
+
Erwachsene
+
+ {formatPrice(data.market.admission_info.adult_cents)} +
+
+
+
Kinder
+
+ {formatPrice(data.market.admission_info.child_cents)} +
+
+
+
Ermäßigt
+
+ {formatPrice(data.market.admission_info.reduced_cents)} +
+
+ {#if data.market.admission_info.free_under_age > 0} +
+
+ Frei unter (Alter) +
+
+ {data.market.admission_info.free_under_age} Jahre +
+
+ {/if} + {#if data.market.admission_info.notes} +
+
Hinweise
+
+ {data.market.admission_info.notes} +
+
+ {/if} +
+
+ {/if} + + + {#if data.market.submitter_email || data.market.submitter_name} +
+

Eingereicht von

+
+
+
Name
+
+ {data.market.submitter_name || '-'} +
+
+
+
E-Mail
+
+ {data.market.submitter_email || '-'} +
+
+
+
+ {/if} +
diff --git a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts new file mode 100644 index 0000000..a62750c --- /dev/null +++ b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts @@ -0,0 +1,84 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { serverFetch } from '$lib/api/client.server.js'; +import type { AdminMarketDetail, ResearchResult } from '$lib/api/types.js'; +import type { Actions, PageServerLoad } from './$types.js'; + +export const load: PageServerLoad = async ({ params, cookies }) => { + const res = await serverFetch(`/admin/markets/${params.id}`, cookies); + return { market: res.data }; +}; + +export const actions: Actions = { + save: async ({ request, params, cookies, fetch }) => { + const form = await request.formData(); + + const body: Record = {}; + + const strFields = [ + 'name', + 'description', + 'street', + 'city', + 'state', + 'zip', + 'country', + 'start_date', + 'end_date', + 'website', + 'organizer_name', + 'image_url', + 'admin_notes' + ]; + + for (const field of strFields) { + const val = form.get(field)?.toString(); + if (val !== undefined) { + body[field] = val; + } + } + + const lat = form.get('latitude')?.toString(); + const lon = form.get('longitude')?.toString(); + if (lat) body.latitude = parseFloat(lat); + if (lon) body.longitude = parseFloat(lon); + + const openingHoursRaw = form.get('opening_hours')?.toString(); + if (openingHoursRaw) { + const parsed = JSON.parse(openingHoursRaw); + body.opening_hours = Array.isArray(parsed) && parsed.length > 0 ? parsed : []; + } + + const admissionInfoRaw = form.get('admission_info')?.toString(); + if (admissionInfoRaw) { + body.admission_info = JSON.parse(admissionInfoRaw); + } + + try { + await serverFetch(`/admin/markets/${params.id}`, cookies, { + method: 'PUT', + body: JSON.stringify(body), + fetch + }); + + redirect(302, `/admin/maerkte/${params.id}`); + } catch (err) { + if (err instanceof Response || (err as { status?: number })?.status === 302) throw err; + const message = err instanceof Error ? err.message : 'Speichern fehlgeschlagen.'; + return fail(500, { error: message }); + } + }, + + research: async ({ params, cookies, fetch }) => { + try { + const res = await serverFetch( + `/admin/markets/${params.id}/research`, + cookies, + { method: 'POST', fetch } + ); + return { research: res.data }; + } catch (err) { + const message = err instanceof Error ? err.message : 'KI-Recherche fehlgeschlagen.'; + return fail(500, { error: message }); + } + } +}; diff --git a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte new file mode 100644 index 0000000..080e714 --- /dev/null +++ b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte @@ -0,0 +1,133 @@ + + + + {data.market.name} bearbeiten - Admin - Marktvogt + + +
+
+
+ + ← Zurück zum Markt + +

Markt bearbeiten

+
+
{ + researching = true; + return async ({ update }) => { + researching = false; + await update(); + }; + }} + > + +
+
+ + {#if researchResult} + { + researchResult = null; + dismissed = true; + }} + /> + {/if} + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + > + + +
diff --git a/web/src/routes/admin/maerkte/neu/+page.server.ts b/web/src/routes/admin/maerkte/neu/+page.server.ts new file mode 100644 index 0000000..76bb5db --- /dev/null +++ b/web/src/routes/admin/maerkte/neu/+page.server.ts @@ -0,0 +1,56 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { serverFetch } from '$lib/api/client.server.js'; +import type { AdminMarketDetail } from '$lib/api/types.js'; +import type { Actions } from './$types.js'; + +export const actions: Actions = { + default: async ({ request, cookies, fetch }) => { + const form = await request.formData(); + + const body: Record = { + name: form.get('name')?.toString().trim() ?? '', + description: form.get('description')?.toString().trim() ?? '', + street: form.get('street')?.toString().trim() ?? '', + city: form.get('city')?.toString().trim() ?? '', + state: form.get('state')?.toString().trim() ?? '', + zip: form.get('zip')?.toString().trim() ?? '', + country: form.get('country')?.toString().trim() || 'DE', + start_date: form.get('start_date')?.toString().trim() ?? '', + end_date: form.get('end_date')?.toString().trim() ?? '', + website: form.get('website')?.toString().trim() ?? '', + organizer_name: form.get('organizer_name')?.toString().trim() ?? '', + image_url: form.get('image_url')?.toString().trim() ?? '', + admin_notes: form.get('admin_notes')?.toString().trim() ?? '' + }; + + const lat = form.get('latitude')?.toString(); + const lon = form.get('longitude')?.toString(); + body.latitude = lat ? parseFloat(lat) : 0; + body.longitude = lon ? parseFloat(lon) : 0; + + const openingHoursRaw = form.get('opening_hours')?.toString(); + if (openingHoursRaw) { + const parsed = JSON.parse(openingHoursRaw); + body.opening_hours = Array.isArray(parsed) && parsed.length > 0 ? parsed : []; + } + + const admissionInfoRaw = form.get('admission_info')?.toString(); + if (admissionInfoRaw) { + body.admission_info = JSON.parse(admissionInfoRaw); + } + + try { + const res = await serverFetch('/admin/markets', cookies, { + method: 'POST', + body: JSON.stringify(body), + fetch + }); + + redirect(302, `/admin/maerkte/${res.data.id}`); + } catch (err) { + if (err instanceof Response || (err as { status?: number })?.status === 302) throw err; + const message = err instanceof Error ? err.message : 'Erstellen fehlgeschlagen.'; + return fail(500, { error: message }); + } + } +}; diff --git a/web/src/routes/admin/maerkte/neu/+page.svelte b/web/src/routes/admin/maerkte/neu/+page.svelte new file mode 100644 index 0000000..cff97f0 --- /dev/null +++ b/web/src/routes/admin/maerkte/neu/+page.svelte @@ -0,0 +1,36 @@ + + + + Neuer Markt - Admin - Marktvogt + + +
+
+ + ← Zurück zur Liste + +

Neuer Markt

+
+ +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + > + + +
diff --git a/web/src/routes/api/geocode/+server.ts b/web/src/routes/api/geocode/+server.ts new file mode 100644 index 0000000..fdaafcd --- /dev/null +++ b/web/src/routes/api/geocode/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import { apiFetch } from '$lib/api/client.js'; +import type { RequestHandler } from './$types.js'; + +export const POST: RequestHandler = async ({ request, fetch }) => { + const body = await request.json(); + + try { + const res = await apiFetch<{ latitude: number | null; longitude: number | null }>('/geocode', { + method: 'POST', + body: JSON.stringify(body), + fetch + }); + return json(res.data); + } catch (err) { + const message = err instanceof Error ? err.message : 'Geocoding failed'; + return json({ error: { message } }, { status: 500 }); + } +}; diff --git a/web/src/routes/auth/+layout.server.ts b/web/src/routes/auth/+layout.server.ts new file mode 100644 index 0000000..7ee891d --- /dev/null +++ b/web/src/routes/auth/+layout.server.ts @@ -0,0 +1,5 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = () => { + return {}; +}; diff --git a/web/src/routes/auth/abmelden/+page.server.ts b/web/src/routes/auth/abmelden/+page.server.ts new file mode 100644 index 0000000..9fe3098 --- /dev/null +++ b/web/src/routes/auth/abmelden/+page.server.ts @@ -0,0 +1,25 @@ +import type { Actions } from './$types.js'; +import { redirect } from '@sveltejs/kit'; +import { apiFetch } from '$lib/api/client.js'; +import { clearAuthCookies } from '$lib/auth/cookies.js'; + +export const actions: Actions = { + default: async ({ cookies, fetch }) => { + const accessToken = cookies.get('access_token'); + + if (accessToken) { + try { + await apiFetch('/auth/logout', { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + fetch + }); + } catch { + // Best-effort logout + } + } + + clearAuthCookies(cookies); + redirect(302, '/'); + } +}; diff --git a/web/src/routes/auth/anmelden/+page.server.ts b/web/src/routes/auth/anmelden/+page.server.ts new file mode 100644 index 0000000..dda0e0d --- /dev/null +++ b/web/src/routes/auth/anmelden/+page.server.ts @@ -0,0 +1,69 @@ +import type { Actions } from './$types.js'; +import { fail, redirect } from '@sveltejs/kit'; +import { apiFetch, ApiClientError } from '$lib/api/client.js'; +import type { AuthData, MessageData } from '$lib/api/types.js'; +import { setAuthCookies } from '$lib/auth/cookies.js'; + +export const load = async ({ locals, url }: { locals: App.Locals; url: URL }) => { + if (locals.user) redirect(302, '/'); + return { + redirectTo: url.searchParams.get('redirect') ?? '/' + }; +}; + +export const actions: Actions = { + login: async ({ request, cookies, fetch }) => { + const form = await request.formData(); + const email = form.get('email') as string; + const password = form.get('password') as string; + const totpCode = form.get('totp_code') as string | null; + const redirectTo = form.get('redirect_to') as string | '/'; + + try { + const res = await apiFetch('/auth/login', { + method: 'POST', + body: JSON.stringify({ + email, + password, + ...(totpCode ? { totp_code: totpCode } : {}) + }), + fetch + }); + + setAuthCookies(cookies, res.data); + } catch (e) { + if (e instanceof ApiClientError) { + if (e.code === '2fa_required') { + return fail(400, { error: e.message, requireTotp: true as const }); + } + return fail(e.status, { error: e.message, requireTotp: false as const }); + } + return fail(500, { + error: 'Ein unerwarteter Fehler ist aufgetreten.', + requireTotp: false as const + }); + } + + redirect(302, redirectTo || '/'); + }, + + magicLink: async ({ request, fetch }) => { + const form = await request.formData(); + const email = form.get('email') as string; + + try { + await apiFetch('/auth/magic-link', { + method: 'POST', + body: JSON.stringify({ email }), + fetch + }); + + return { magicLinkSuccess: 'Ein Login-Link wurde an deine E-Mail gesendet.' }; + } catch (e) { + if (e instanceof ApiClientError) { + return fail(e.status, { magicLinkError: e.message }); + } + return fail(500, { magicLinkError: 'Ein Fehler ist aufgetreten.' }); + } + } +}; diff --git a/web/src/routes/auth/anmelden/+page.svelte b/web/src/routes/auth/anmelden/+page.svelte new file mode 100644 index 0000000..dd39bf1 --- /dev/null +++ b/web/src/routes/auth/anmelden/+page.svelte @@ -0,0 +1,56 @@ + + + + Anmelden - Marktvogt + + +
+

Anmelden

+ +
+
+ + +
+ + {#if tab === 'password'} + + + {:else} + + {/if} +
+ +

+ Noch kein Konto? + Registrieren +

+
diff --git a/web/src/routes/auth/magic-link/verify/+page.server.ts b/web/src/routes/auth/magic-link/verify/+page.server.ts new file mode 100644 index 0000000..4ffd97f --- /dev/null +++ b/web/src/routes/auth/magic-link/verify/+page.server.ts @@ -0,0 +1,28 @@ +import type { PageServerLoad } from './$types.js'; +import { redirect, error } from '@sveltejs/kit'; +import { apiFetch, ApiClientError } from '$lib/api/client.js'; +import type { AuthData } from '$lib/api/types.js'; +import { setAuthCookies } from '$lib/auth/cookies.js'; + +export const load: PageServerLoad = async ({ url, cookies, fetch }) => { + const token = url.searchParams.get('token'); + + if (!token) { + error(400, { message: 'Kein Token angegeben.' }); + } + + try { + const res = await apiFetch(`/auth/magic-link/verify?token=${token}`, { + fetch + }); + + setAuthCookies(cookies, res.data); + } catch (e) { + if (e instanceof ApiClientError) { + error(e.status, { message: e.message }); + } + error(500, { message: 'Ein Fehler ist aufgetreten.' }); + } + + redirect(302, '/'); +}; diff --git a/web/src/routes/auth/oauth/callback/+page.server.ts b/web/src/routes/auth/oauth/callback/+page.server.ts new file mode 100644 index 0000000..463976c --- /dev/null +++ b/web/src/routes/auth/oauth/callback/+page.server.ts @@ -0,0 +1,22 @@ +import type { PageServerLoad } from './$types.js'; +import { redirect, error } from '@sveltejs/kit'; +import { setAuthCookies } from '$lib/auth/cookies.js'; + +export const load: PageServerLoad = async ({ url, cookies }) => { + const accessToken = url.searchParams.get('access_token'); + const sessionToken = url.searchParams.get('session_token'); + const expiresIn = url.searchParams.get('expires_in'); + + if (!accessToken || !sessionToken) { + const errMsg = url.searchParams.get('error'); + error(400, { message: errMsg ?? 'OAuth-Anmeldung fehlgeschlagen.' }); + } + + setAuthCookies(cookies, { + access_token: accessToken, + session_token: sessionToken, + expires_in: expiresIn ? parseInt(expiresIn, 10) : 900 + }); + + redirect(302, '/'); +}; diff --git a/web/src/routes/auth/registrieren/+page.server.ts b/web/src/routes/auth/registrieren/+page.server.ts new file mode 100644 index 0000000..e19bdf8 --- /dev/null +++ b/web/src/routes/auth/registrieren/+page.server.ts @@ -0,0 +1,39 @@ +import type { Actions } from './$types.js'; +import { fail, redirect } from '@sveltejs/kit'; +import { apiFetch, ApiClientError } from '$lib/api/client.js'; +import type { AuthData } from '$lib/api/types.js'; +import { setAuthCookies } from '$lib/auth/cookies.js'; + +export const load = async ({ locals }: { locals: App.Locals }) => { + if (locals.user) redirect(302, '/'); +}; + +export const actions: Actions = { + default: async ({ request, cookies, fetch }) => { + const form = await request.formData(); + const email = form.get('email') as string; + const password = form.get('password') as string; + const displayName = form.get('display_name') as string; + + try { + const res = await apiFetch('/auth/register', { + method: 'POST', + body: JSON.stringify({ + email, + password, + display_name: displayName + }), + fetch + }); + + setAuthCookies(cookies, res.data); + } catch (e) { + if (e instanceof ApiClientError) { + return fail(e.status, { error: e.message }); + } + return fail(500, { error: 'Ein unerwarteter Fehler ist aufgetreten.' }); + } + + redirect(302, '/'); + } +}; diff --git a/web/src/routes/auth/registrieren/+page.svelte b/web/src/routes/auth/registrieren/+page.svelte new file mode 100644 index 0000000..b7e1be5 --- /dev/null +++ b/web/src/routes/auth/registrieren/+page.svelte @@ -0,0 +1,28 @@ + + + + Registrieren - Marktvogt + + +
+

+ Konto erstellen +

+ +
+ +
+ +

+ Bereits ein Konto? + Anmelden +

+
diff --git a/web/src/routes/datenschutz/+page.svelte b/web/src/routes/datenschutz/+page.svelte new file mode 100644 index 0000000..1658e29 --- /dev/null +++ b/web/src/routes/datenschutz/+page.svelte @@ -0,0 +1,435 @@ + + Datenschutzerklärung - Marktvogt + + + + + + +
+

Datenschutzerklärung

+ +
+

1. Verantwortlicher

+

+ Christian Nachtigall
+ Karwendelstr. 21
+ 82061 Neuried
+ E-Mail: + christian@nachtigall.dev +

+
+ +
+

+ 2. Überblick der Verarbeitungen +

+

+ Marktvogt ist ein Verzeichnis für Mittelaltermärkte und historische Feste. Wir verarbeiten + personenbezogene Daten nur, soweit dies zur Bereitstellung der Funktionen unserer Website + erforderlich ist. Die Verarbeitung erfolgt auf Grundlage der DSGVO + (Datenschutz-Grundverordnung). +

+
+ +
+

3. Hosting

+

+ Diese Website wird auf Infrastruktur von itsh.dev gehostet. Beim Aufruf unserer + Website werden durch den Hostinganbieter automatisch Informationen in sogenannten Server-Logfiles + erfasst. Dazu gehören: +

+
    +
  • IP-Adresse des zugreifenden Geräts
  • +
  • Datum und Uhrzeit der Anfrage
  • +
  • HTTP-Methode und aufgerufene URL
  • +
  • HTTP-Statuscode
  • +
  • Antwortzeit des Servers
  • +
+

+ Diese Daten werden zur Sicherstellung eines störungsfreien Betriebs erhoben und zur Erkennung + von Missbrauch ausgewertet. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes + Interesse). +

+
+ +
+

+ 4. Registrierung und Benutzerkonto +

+

+ Sie können auf unserer Website ein Benutzerkonto erstellen. Dabei werden folgende Daten + verarbeitet: +

+
    +
  • E-Mail-Adresse – zur Identifikation und Kommunikation
  • +
  • + Passwort – wird ausschließlich als bcrypt-Hash gespeichert; das Klartext-Passwort + wird nicht gespeichert +
  • +
  • Anzeigename – frei wählbarer Name zur Darstellung im Profil
  • +
  • Profilbild-URL – sofern über einen OAuth-Anbieter bereitgestellt
  • +
+

+ Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) sowie + zur Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO). Ihr Konto kann jederzeit gelöscht werden. +

+
+ +
+

+ 5. Anmeldung über Drittanbieter (OAuth) +

+

+ Sie können sich mit einem bestehenden Konto bei folgenden Anbietern anmelden: +

+
    +
  • + Google – Abgerufene Daten: E-Mail-Adresse, Name, Profilbild, E-Mail-Verifizierungsstatus +
  • +
  • + GitHub – Abgerufene Daten: E-Mail-Adresse (primäre, verifizierte E-Mail) +
  • +
  • Facebook – Abgerufene Daten: E-Mail-Adresse, Name, Profilbild
  • +
+

+ Wir speichern die vom Anbieter übermittelten Daten (Anbieter-ID, Name, E-Mail) sowie ein + Zugriffstoken zur Verifizierung der Verknüpfung. Die OAuth-Anmeldung erfolgt auf Grundlage + Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Sie können die Verknüpfung jederzeit in Ihren + Profileinstellungen aufheben. Bitte beachten Sie die Datenschutzerklärungen der jeweiligen + Anbieter: +

+ +
+ +
+

+ 6. Magic-Link-Anmeldung +

+

+ Sie können sich über einen per E-Mail versendeten Einmal-Link (Magic Link) anmelden. Dabei + wird Ihre E-Mail-Adresse verarbeitet und ein einmalig gültiger, zeitlich begrenzter Token (15 + Minuten) erzeugt. Der Token wird als SHA-256-Hash gespeichert und nach Verwendung ungültig. + Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung). +

+
+ +
+

+ 7. Sitzungsverwaltung (Sessions) +

+

+ Nach der Anmeldung wird eine Sitzung erstellt. Dabei werden folgende Daten gespeichert: +

+
    +
  • IP-Adresse – zum Zeitpunkt der Sitzungserstellung
  • +
  • User-Agent – Browserkennung zum Zeitpunkt der Anmeldung
  • +
  • Sitzungstoken – als SHA-256-Hash gespeichert
  • +
+

+ Sitzungen laufen nach 30 Tagen automatisch ab. Die Speicherung dient der Sicherheit Ihres + Kontos (Erkennung ungewöhnlicher Anmeldeaktivitäten). Rechtsgrundlage ist Art. 6 Abs. 1 lit. f + DSGVO (berechtigtes Interesse an der Kontosicherheit). +

+
+ +
+

+ 8. Zwei-Faktor-Authentifizierung (2FA) +

+

+ Optional können Sie die Zwei-Faktor-Authentifizierung über ein TOTP-Verfahren (z. B. + Google Authenticator) aktivieren. Dabei wird ein kryptografisches Geheimnis (TOTP-Secret) mit + Ihrem Konto verknüpft gespeichert. Dieses wird bei Deaktivierung der 2FA oder Löschung des + Kontos gelöscht. +

+
+ +
+

+ 9. Cookies und lokale Speicherung +

+

+ Wir verwenden ausschließlich technisch notwendige Cookies: +

+
+ + + + + + + + + + + + + + + + + + + + +
NameZweckDauer
access_tokenJWT-Zugriffstoken für authentifizierte Anfragen15 Minuten
session_tokenSitzungstoken zur Erneuerung des Zugriffstokens30 Tage
+
+

+ Zusätzlich wird im localStorage des Browsers die Einstellung für das + Farbschema (marktvogt-theme) + gespeichert. Dies enthält keine personenbezogenen Daten. +

+

+ Es werden keine Tracking-, Analyse- oder Werbe-Cookies eingesetzt. +

+
+ +
+

+ 10. Markt einreichen (Einreichungsformular) +

+

+ Sie können über das Formular unter „Markt einreichen" einen Mittelaltermarkt zur Aufnahme + vorschlagen. Dabei werden folgende Daten verarbeitet: +

+
    +
  • + Marktdaten – Name, Beschreibung, Ort, Zeitraum, Website, Veranstalter, ggf. Koordinaten +
  • +
  • + Kontaktdaten – Ihr Name und Ihre E-Mail-Adresse (werden nicht veröffentlicht) +
  • +
+

+ Die Kontaktdaten werden ausschließlich für Rückfragen zur Einreichung verwendet und nicht an + Dritte weitergegeben. Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 + lit. a DSGVO). Eingereichte Daten werden bei Ablehnung des Marktes gelöscht. +

+
+ +
+

+ 11. Spam-Schutz (Cloudflare Turnstile) +

+

+ Zum Schutz des Einreichungsformulars vor automatisiertem Missbrauch setzen wir + Cloudflare Turnstile ein. Dabei werden technische Daten (z. B. IP-Adresse, + Browser-Informationen) an Cloudflare, Inc. übermittelt, um zu prüfen, ob die Eingabe von einem Menschen + stammt. Es werden keine Cookies gesetzt und kein Nutzer-Tracking durchgeführt. +

+

+ Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am Schutz vor Spam). + Weitere Informationen finden Sie in der + Datenschutzerklärung von Cloudflare. +

+
+ +
+

12. Standortdaten

+

+ Für die Umkreissuche nach Märkten können Sie optional Ihren Standort freigeben. Dabei kommen + zwei Verfahren zum Einsatz: +

+
    +
  • + Browser-Geolokalisierung – Ihr Browser fragt Ihre Erlaubnis, bevor Standortdaten + bereitgestellt werden. Die Koordinaten werden nicht auf unserem Server gespeichert, sondern nur + zur einmaligen Berechnung der Entfernung zu Märkten verwendet. +
  • +
  • + IP-basierte Geolokalisierung (Fallback) – Falls die + Browser-Geolokalisierung nicht verfügbar ist, wird der Dienst + geojs.io + zur ungefähren Standortbestimmung genutzt. Dabei wird Ihre IP-Adresse an geojs.io übermittelt. + Bitte beachten Sie die + Datenschutzerklärung von geojs.io. +
  • +
+
+ +
+

13. Kartendarstellung

+

+ Zur Darstellung von Karten verwenden wir Leaflet mit Kartenkacheln von + OpenStreetMap. Beim Laden der Karte werden Kartendaten von den Servern der + OpenStreetMap Foundation (tile.openstreetmap.org) abgerufen. Dabei wird Ihre IP-Adresse an die OpenStreetMap Foundation übermittelt. Weitere + Informationen finden Sie in der + Datenschutzerklärung der OpenStreetMap Foundation. +

+

+ Die Leaflet-Bibliothek wird über unpkg.com (CDN) geladen. Dabei kann Ihre IP-Adresse an den CDN-Betreiber übermittelt werden. +

+
+ +
+

14. Ihre Rechte

+

+ Sie haben gemäß DSGVO folgende Rechte bezüglich Ihrer personenbezogenen Daten: +

+
    +
  • + Auskunft (Art. 15 DSGVO) – Sie können Auskunft über Ihre gespeicherten Daten + verlangen. +
  • +
  • + Berichtigung (Art. 16 DSGVO) – Sie können die Berichtigung unrichtiger Daten + verlangen. +
  • +
  • + Löschung (Art. 17 DSGVO) – Sie können die Löschung Ihrer Daten verlangen. +
  • +
  • + Einschränkung (Art. 18 DSGVO) – Sie können die Einschränkung der Verarbeitung + verlangen. +
  • +
  • + Datenübertragbarkeit (Art. 20 DSGVO) – Sie können Ihre Daten in einem maschinenlesbaren + Format erhalten. +
  • +
  • + Widerspruch (Art. 21 DSGVO) – Sie können der Verarbeitung auf Basis berechtigter + Interessen widersprechen. +
  • +
  • + Widerruf der Einwilligung (Art. 7 Abs. 3 DSGVO) – Erteilte Einwilligungen können + jederzeit widerrufen werden. +
  • +
+

+ Zur Ausübung Ihrer Rechte wenden Sie sich an: christian@nachtigall.dev +

+
+ +
+

15. Beschwerderecht

+

+ Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde über die Verarbeitung Ihrer + personenbezogenen Daten zu beschweren. Die für uns zuständige Aufsichtsbehörde ist: +

+

+ Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)
+ Promenade 18
+ 91522 Ansbach
+ www.lda.bayern.de +

+
+ +
+

+ 16. Datenlöschung und Speicherdauer +

+

+ Personenbezogene Daten werden gelöscht, sobald der Zweck der Speicherung entfällt: +

+
    +
  • + Benutzerkonto – Bei Löschung Ihres Kontos werden Ihre Daten zunächst für 30 Tage + zur möglichen Wiederherstellung aufbewahrt und anschließend endgültig gelöscht. +
  • +
  • Sitzungsdaten – Automatische Löschung nach Ablauf (30 Tage).
  • +
  • Magic-Link-Tokens – Laufen nach 15 Minuten ab.
  • +
  • + Server-Logfiles – Werden nach den beim Hostinganbieter üblichen Fristen gelöscht. +
  • +
+
+ +
+

+ 17. Änderungen dieser Datenschutzerklärung +

+

+ Wir behalten uns vor, diese Datenschutzerklärung anzupassen, um sie an geänderte Rechtslagen + oder Änderungen des Dienstes anzupassen. Die aktuelle Version finden Sie stets auf dieser + Seite. +

+

Stand: Februar 2026

+
+
diff --git a/web/src/routes/impressum/+page.svelte b/web/src/routes/impressum/+page.svelte new file mode 100644 index 0000000..e948ee8 --- /dev/null +++ b/web/src/routes/impressum/+page.svelte @@ -0,0 +1,105 @@ + + Impressum - Marktvogt + + + + + + +
+

Impressum

+ +
+

Angaben gemäß § 5 TMG

+

+ Christian Nachtigall
+ Karwendelstr. 21
+ 82061 Neuried +

+
+ +
+

Kontakt

+

+ E-Mail: christian@nachtigall.dev +

+
+ +
+

+ Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV +

+

+ Christian Nachtigall
+ Karwendelstr. 21
+ 82061 Neuried +

+
+ +
+

Haftung für Inhalte

+

+ Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach + den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter + jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen + oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. +

+

+ Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den + allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab + dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von + entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen. +

+
+ +
+

+ Nutzereingereichte Inhalte +

+

+ Nutzer können über das Formular „Markt einreichen" Informationen zu Mittelaltermärkten zur + Veröffentlichung vorschlagen. Alle Einreichungen werden vor der Veröffentlichung redaktionell + geprüft. Für die Richtigkeit der von Nutzern eingesandten Informationen übernehmen wir keine + Gewähr. +

+
+ +
+

Haftung für Links

+

+ Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen + Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für + die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten + verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche + Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht + erkennbar. +

+

+ Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete + Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen + werden wir derartige Links umgehend entfernen. +

+
+ +
+

Urheberrecht

+

+ Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem + deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der + Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des + jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den + privaten, nicht kommerziellen Gebrauch gestattet. +

+

+ Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die + Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. + Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen + entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte + umgehend entfernen. +

+
+
diff --git a/web/src/routes/maerkte/+page.server.ts b/web/src/routes/maerkte/+page.server.ts new file mode 100644 index 0000000..bcaae79 --- /dev/null +++ b/web/src/routes/maerkte/+page.server.ts @@ -0,0 +1,43 @@ +import type { PageServerLoad } from './$types.js'; +import { apiFetch } from '$lib/api/client.js'; +import type { MarketSummary } from '$lib/api/types.js'; +import { stateToSlug, STATE_SLUGS } from '$lib/utils/slug.js'; + +export interface StateInfo { + slug: string; + name: string; + count: number; +} + +export const load: PageServerLoad = async ({ fetch }) => { + let markets: MarketSummary[] = []; + try { + const res = await apiFetch('/markets?per_page=1000', { fetch }); + markets = res.data; + } catch { + // Backend unreachable + } + + const countByState = new Map(); + for (const m of markets) { + countByState.set(m.state, (countByState.get(m.state) ?? 0) + 1); + } + + const states: StateInfo[] = Object.entries(STATE_SLUGS) + .map(([slug, name]) => ({ + slug, + name, + count: countByState.get(name) ?? 0 + })) + .filter((s) => s.count > 0) + .sort((a, b) => a.name.localeCompare(b.name, 'de')); + + // Also include any states from data not in the canonical list + for (const [state, count] of countByState) { + if (!states.some((s) => s.name === state)) { + states.push({ slug: stateToSlug(state), name: state, count }); + } + } + + return { states }; +}; diff --git a/web/src/routes/maerkte/+page.svelte b/web/src/routes/maerkte/+page.svelte new file mode 100644 index 0000000..97dbc7b --- /dev/null +++ b/web/src/routes/maerkte/+page.svelte @@ -0,0 +1,85 @@ + + + + Mittelaltermärkte nach Bundesland - Marktvogt + + + + + {@html jsonLdHtml} + + +
+ + +

+ Mittelaltermärkte nach Bundesland +

+

+ Entdecke Mittelaltermärkte, Ritterturniere und historische Feste in ganz Deutschland. +

+ + {#if states.length > 0} + + {:else} +
+

Aktuell keine Märkte verfügbar.

+
+ {/if} +
diff --git a/web/src/routes/maerkte/[state]/+page.server.ts b/web/src/routes/maerkte/[state]/+page.server.ts new file mode 100644 index 0000000..fc0daac --- /dev/null +++ b/web/src/routes/maerkte/[state]/+page.server.ts @@ -0,0 +1,48 @@ +import type { PageServerLoad } from './$types.js'; +import { apiFetch } from '$lib/api/client.js'; +import type { MarketSummary } from '$lib/api/types.js'; +import { slugToState, stateToSlug, toSlug } from '$lib/utils/slug.js'; +import { error } from '@sveltejs/kit'; + +export interface CityInfo { + slug: string; + name: string; + count: number; +} + +export const load: PageServerLoad = async ({ params, fetch }) => { + const stateName = slugToState(params.state); + if (!stateName) { + error(404, { message: 'Bundesland nicht gefunden.' }); + } + + let allMarkets: MarketSummary[] = []; + try { + const res = await apiFetch('/markets?per_page=1000', { fetch }); + allMarkets = res.data; + } catch { + // Backend unreachable + } + + const markets = allMarkets.filter((m) => stateToSlug(m.state) === params.state); + + if (markets.length === 0) { + error(404, { message: `Keine Märkte in ${stateName} gefunden.` }); + } + + const countByCity = new Map(); + for (const m of markets) { + countByCity.set(m.city, (countByCity.get(m.city) ?? 0) + 1); + } + + const cities: CityInfo[] = Array.from(countByCity.entries()) + .map(([name, count]) => ({ slug: toSlug(name), name, count })) + .sort((a, b) => a.name.localeCompare(b.name, 'de')); + + return { + stateName, + stateSlug: params.state, + markets, + cities + }; +}; diff --git a/web/src/routes/maerkte/[state]/+page.svelte b/web/src/routes/maerkte/[state]/+page.svelte new file mode 100644 index 0000000..54db301 --- /dev/null +++ b/web/src/routes/maerkte/[state]/+page.svelte @@ -0,0 +1,120 @@ + + + + Mittelaltermärkte in {stateName} - Marktvogt + + + + + {@html jsonLdBreadcrumbHtml} + {@html jsonLdItemListHtml} + + +
+ + +

+ Mittelaltermärkte in {stateName} +

+

+ {markets.length} + {markets.length === 1 ? 'Markt' : 'Märkte'} in {stateName} +

+ + {#if cities.length > 1} +
+

+ Städte +

+
+ {#each cities as city (city.slug)} + + {city.name} ({city.count}) + + {/each} +
+
+ {/if} + +
+ {#each markets as market (market.id)} + + {/each} +
+
diff --git a/web/src/routes/maerkte/[state]/[city]/+page.server.ts b/web/src/routes/maerkte/[state]/[city]/+page.server.ts new file mode 100644 index 0000000..5867ac3 --- /dev/null +++ b/web/src/routes/maerkte/[state]/[city]/+page.server.ts @@ -0,0 +1,38 @@ +import type { PageServerLoad } from './$types.js'; +import { apiFetch } from '$lib/api/client.js'; +import type { MarketSummary } from '$lib/api/types.js'; +import { slugToState, stateToSlug, toSlug } from '$lib/utils/slug.js'; +import { error } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ params, fetch }) => { + const stateName = slugToState(params.state); + if (!stateName) { + error(404, { message: 'Bundesland nicht gefunden.' }); + } + + let allMarkets: MarketSummary[] = []; + try { + const res = await apiFetch('/markets?per_page=1000', { fetch }); + allMarkets = res.data; + } catch { + // Backend unreachable + } + + const markets = allMarkets.filter( + (m) => stateToSlug(m.state) === params.state && toSlug(m.city) === params.city + ); + + if (markets.length === 0) { + error(404, { message: 'Keine Märkte in dieser Stadt gefunden.' }); + } + + const cityName = markets[0].city; + + return { + stateName, + stateSlug: params.state, + cityName, + citySlug: params.city, + markets + }; +}; diff --git a/web/src/routes/maerkte/[state]/[city]/+page.svelte b/web/src/routes/maerkte/[state]/[city]/+page.svelte new file mode 100644 index 0000000..a574789 --- /dev/null +++ b/web/src/routes/maerkte/[state]/[city]/+page.svelte @@ -0,0 +1,113 @@ + + + + Mittelaltermärkte in {cityName}, {stateName} - Marktvogt + + + + + {@html jsonLdBreadcrumbHtml} + {@html jsonLdItemListHtml} + + +
+ + +

+ Mittelaltermärkte in {cityName} +

+

+ {markets.length} + {markets.length === 1 ? 'Markt' : 'Märkte'} in {cityName}, {stateName} +

+ +
+ {#each markets as market (market.id)} + + {/each} +
+
diff --git a/web/src/routes/markt/[slug]/+page.server.ts b/web/src/routes/markt/[slug]/+page.server.ts new file mode 100644 index 0000000..31d3d34 --- /dev/null +++ b/web/src/routes/markt/[slug]/+page.server.ts @@ -0,0 +1,22 @@ +import type { PageServerLoad } from './$types.js'; +import { apiFetch, ApiClientError } from '$lib/api/client.js'; +import type { MarketDetail, EditionBrief } from '$lib/api/types.js'; +import { error } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ params, url, fetch }) => { + const year = url.searchParams.get('year') ?? ''; + const query = year ? `?year=${year}` : ''; + + try { + const res = await apiFetch(`/markets/${params.slug}${query}`, { fetch }); + return { + market: res.data, + editions: (res as unknown as { editions?: EditionBrief[] }).editions ?? [] + }; + } catch (e) { + if (e instanceof ApiClientError && e.status === 404) { + error(404, { message: 'Markt nicht gefunden.' }); + } + error(500, { message: 'Fehler beim Laden des Marktes.' }); + } +}; diff --git a/web/src/routes/markt/[slug]/+page.svelte b/web/src/routes/markt/[slug]/+page.svelte new file mode 100644 index 0000000..6faa86c --- /dev/null +++ b/web/src/routes/markt/[slug]/+page.svelte @@ -0,0 +1,464 @@ + + + + {market.name} - Marktvogt + + + + {#if market.image_url} + + + {/if} + + {@html jsonLdBreadcrumbHtml} + {@html jsonLdEventHtml} + + +
+ + + {#if market.image_url} +
+ {market.name} +
+ {/if} + +

{market.name}

+ +
+ + + + + {formatDate(market.start_date)} – {formatDate(market.end_date)} + + + + + + + {market.street}, {market.zip} + {market.city} + +
+ + {#if hasMultipleEditions} +
+ Ausgabe: +
+ {#each editions as edition} + {@const isActive = edition.year === currentYear} + + {edition.year} + + {/each} +
+
+ {/if} + + {#if market.organizer_name} +

+ Veranstalter: {market.organizer_name} +

+ {/if} + + {#if market.description} +
+

Beschreibung

+

+ {market.description} +

+
+ {/if} + +
+ {#if openingHours.length > 0} +
+

Öffnungszeiten

+ + + {#each openingHours as entry} + + + + + {/each} + +
{entry.day}{entry.open} – {entry.close}
+
+ {/if} + + {#if admission} +
+

Eintrittspreise

+ + + {#if parsedNotes.groups.length > 0} + {#each parsedNotes.groups as group, i} + + + + {#each group.entries as entry} + + + + + {/each} + {/each} + {:else} + + + + + {#if admission.reduced_cents > 0} + + + + + {/if} + {#if admission.child_cents > 0} + + + + + {/if} + {/if} + {#if admission.free_under_age > 0} + + + + + {/if} + +
{group.label}
{entry.category}{entry.price}
Erwachsene{centsToEuro(admission.adult_cents)}
Ermäßigt{centsToEuro(admission.reduced_cents)}
Kinder{centsToEuro(admission.child_cents)}
Frei unter{admission.free_under_age} Jahre
+ {#if parsedNotes.groups.length > 0 && parsedNotes.remaining} +

{parsedNotes.remaining}

+ {:else if parsedNotes.groups.length === 0 && admission.notes} +

{admission.notes}

+ {/if} +
+ {/if} +
+ + {#if market.website} + + {/if} + +
+

Standort

+ +
+
diff --git a/web/src/routes/markt/einreichen/+page.server.ts b/web/src/routes/markt/einreichen/+page.server.ts new file mode 100644 index 0000000..53e7015 --- /dev/null +++ b/web/src/routes/markt/einreichen/+page.server.ts @@ -0,0 +1,111 @@ +import { fail } from '@sveltejs/kit'; +import { env } from '$env/dynamic/public'; +import { apiFetch } from '$lib/api/client.js'; +import type { Actions, PageServerLoad } from './$types.js'; + +export const load: PageServerLoad = async () => { + return { + turnstileSiteKey: env.PUBLIC_TURNSTILE_SITE_KEY ?? '' + }; +}; + +export const actions: Actions = { + default: async ({ request, fetch }) => { + const form = await request.formData(); + + const name = form.get('name')?.toString().trim() ?? ''; + const description = form.get('description')?.toString().trim() ?? ''; + const street = form.get('street')?.toString().trim() ?? ''; + const latRaw = form.get('latitude')?.toString().trim() ?? ''; + const lonRaw = form.get('longitude')?.toString().trim() ?? ''; + const latitude = latRaw ? parseFloat(latRaw) : undefined; + const longitude = lonRaw ? parseFloat(lonRaw) : undefined; + const city = form.get('city')?.toString().trim() ?? ''; + const state = form.get('state')?.toString().trim() ?? ''; + const zip = form.get('zip')?.toString().trim() ?? ''; + const country = form.get('country')?.toString().trim() || 'DE'; + const startDate = form.get('start_date')?.toString().trim() ?? ''; + const endDate = form.get('end_date')?.toString().trim() ?? ''; + const website = form.get('website')?.toString().trim() ?? ''; + const organizerName = form.get('organizer_name')?.toString().trim() ?? ''; + const imageUrl = form.get('image_url')?.toString().trim() ?? ''; + const submitterEmail = form.get('submitter_email')?.toString().trim() ?? ''; + const submitterName = form.get('submitter_name')?.toString().trim() ?? ''; + const turnstileToken = form.get('cf-turnstile-response')?.toString() ?? ''; + + const formState = { + name, + description, + street, + latitude: latRaw, + longitude: lonRaw, + city, + state, + zip, + country, + startDate, + endDate, + website, + organizerName, + imageUrl, + submitterEmail, + submitterName + }; + + if (!name || !city || !startDate || !endDate || !submitterEmail || !submitterName) { + return fail(400, { error: 'Bitte fuelle alle Pflichtfelder aus.', ...formState }); + } + + if (!turnstileToken) { + return fail(400, { error: 'Bitte bestaetige die Spam-Pruefung.', ...formState }); + } + + const body: Record = { + name, + description, + street, + city, + state, + zip, + country, + start_date: startDate, + end_date: endDate, + website, + organizer_name: organizerName, + image_url: imageUrl, + submitter_email: submitterEmail, + submitter_name: submitterName, + turnstile_token: turnstileToken + }; + + if (latitude !== undefined && longitude !== undefined) { + body.latitude = latitude; + body.longitude = longitude; + } + + const openingHoursRaw = form.get('opening_hours')?.toString(); + if (openingHoursRaw) { + const parsed = JSON.parse(openingHoursRaw); + body.opening_hours = Array.isArray(parsed) && parsed.length > 0 ? parsed : null; + } + + const admissionInfoRaw = form.get('admission_info')?.toString(); + if (admissionInfoRaw) { + body.admission_info = JSON.parse(admissionInfoRaw); + } + + try { + await apiFetch('/markets/submit', { + method: 'POST', + body: JSON.stringify(body), + fetch + }); + + return { success: true }; + } catch (err) { + const message = + err instanceof Error ? err.message : 'Ein unbekannter Fehler ist aufgetreten.'; + return fail(500, { error: message, ...formState }); + } + } +}; diff --git a/web/src/routes/markt/einreichen/+page.svelte b/web/src/routes/markt/einreichen/+page.svelte new file mode 100644 index 0000000..ddf4484 --- /dev/null +++ b/web/src/routes/markt/einreichen/+page.svelte @@ -0,0 +1,104 @@ + + + + Markt einreichen - Marktvogt + + + + +
+

Markt einreichen

+

+ Kennst du einen Mittelaltermarkt, der noch nicht bei Marktvogt gelistet ist? Reiche ihn hier ein + und wir prüfen die Angaben. +

+ + {#if form?.success} +
+ + Vielen Dank! Dein Markt wurde eingereicht und wird nach Prüfung veröffentlicht. + +
+ {:else} + {#if form?.error} +
+ {form.error} +
+ {/if} + +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + > + + {#snippet extraFields()} +
+ + Deine Kontaktdaten + +

+ Werden nicht veröffentlicht. Nur für Rückfragen. +

+ +
+
+ + +
+
+ + +
+
+
+ + {#if data.turnstileSiteKey} +
+ {/if} + {/snippet} +
+
+ {/if} +
diff --git a/web/src/routes/profile/+layout.server.ts b/web/src/routes/profile/+layout.server.ts new file mode 100644 index 0000000..c549d06 --- /dev/null +++ b/web/src/routes/profile/+layout.server.ts @@ -0,0 +1,6 @@ +import { requireAuth } from '$lib/auth/guard.js'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = (event) => { + requireAuth(event); +}; diff --git a/web/src/routes/profile/+page.server.ts b/web/src/routes/profile/+page.server.ts new file mode 100644 index 0000000..32a42bd --- /dev/null +++ b/web/src/routes/profile/+page.server.ts @@ -0,0 +1,89 @@ +import type { Actions } from './$types.js'; +import { fail } from '@sveltejs/kit'; +import { requireAuth } from '$lib/auth/guard.js'; +import { serverFetch } from '$lib/api/client.server.js'; +import { ApiClientError } from '$lib/api/client.js'; +import { clearAuthCookies } from '$lib/auth/cookies.js'; +import { redirect } from '@sveltejs/kit'; +import type { ProfileData } from '$lib/api/types.js'; + +export const load = async (event: Parameters[0]) => { + requireAuth(event); + const res = await serverFetch('/users/me', event.cookies, { fetch: event.fetch }); + return { profile: res.data }; +}; + +export const actions: Actions = { + update: async ({ request, cookies, fetch }) => { + const form = await request.formData(); + const displayName = form.get('display_name') as string; + const avatarUrl = form.get('avatar_url') as string; + + const body: Record = {}; + if (displayName) body.display_name = displayName; + if (avatarUrl) body.avatar_url = avatarUrl; + + try { + await serverFetch('/users/me', cookies, { + method: 'PATCH', + body: JSON.stringify(body), + fetch + }); + return { success: 'Profil aktualisiert.' }; + } catch (e) { + if (e instanceof ApiClientError) { + return fail(e.status, { error: e.message }); + } + return fail(500, { error: 'Ein Fehler ist aufgetreten.' }); + } + }, + + password: async ({ request, cookies, fetch }) => { + const form = await request.formData(); + const currentPassword = form.get('current_password') as string; + const newPassword = form.get('new_password') as string; + const confirmPassword = form.get('confirm_password') as string; + + if (!newPassword || newPassword.length < 8) { + return fail(400, { error: 'Passwort muss mindestens 8 Zeichen lang sein.' }); + } + + if (newPassword !== confirmPassword) { + return fail(400, { error: 'Passwörter stimmen nicht überein.' }); + } + + const body: Record = { new_password: newPassword }; + if (currentPassword) body.current_password = currentPassword; + + try { + await serverFetch('/auth/password', cookies, { + method: 'PUT', + body: JSON.stringify(body), + fetch + }); + return { success: 'Passwort aktualisiert.' }; + } catch (e) { + if (e instanceof ApiClientError) { + return fail(e.status, { error: e.message }); + } + return fail(500, { error: 'Ein Fehler ist aufgetreten.' }); + } + }, + + delete: async ({ cookies, fetch }) => { + try { + await serverFetch('/users/me', cookies, { + method: 'DELETE', + fetch + }); + clearAuthCookies(cookies); + } catch (e) { + if (e instanceof ApiClientError) { + return fail(e.status, { error: e.message }); + } + return fail(500, { error: 'Ein Fehler ist aufgetreten.' }); + } + + redirect(302, '/'); + } +}; diff --git a/web/src/routes/profile/+page.svelte b/web/src/routes/profile/+page.svelte new file mode 100644 index 0000000..c571d17 --- /dev/null +++ b/web/src/routes/profile/+page.svelte @@ -0,0 +1,155 @@ + + + + Profil - Marktvogt + + +
+

Profil

+ +
+ +
+

+ Kontoinformationen +

+ + {#if form?.success} + {form.success} + {/if} + {#if form?.error} + {form.error} + {/if} + +
{ + updateLoading = true; + return async ({ update }) => { + updateLoading = false; + await update(); + }; + }} + class="mt-4 space-y-4" + > +
+ E-Mail: + {data.profile.email} +
+ + + + + + +
+
+ + +
+

Sicherheit

+ +
+ +
+

+ {data.profile.has_password ? 'Passwort ändern' : 'Passwort festlegen'} +

+
{ + passwordLoading = true; + return async ({ update }) => { + passwordLoading = false; + await update(); + }; + }} + class="space-y-4" + > + {#if data.profile.has_password} + + {/if} + + + + + + +
+
+ + + +
+
+ + +
+

Konto löschen

+

+ Dein Konto wird deaktiviert und nach 30 Tagen endgültig gelöscht. +

+ + {#if !showDeleteConfirm} + + {:else} +
+

+ Bist du sicher? Diese Aktion kann innerhalb von 30 Tagen rückgängig gemacht werden. +

+
+
{ + deleteLoading = true; + return async ({ update }) => { + deleteLoading = false; + await update(); + }; + }} + > + +
+ +
+
+ {/if} +
+
+
diff --git a/web/src/routes/profile/security/+page.server.ts b/web/src/routes/profile/security/+page.server.ts new file mode 100644 index 0000000..da810d8 --- /dev/null +++ b/web/src/routes/profile/security/+page.server.ts @@ -0,0 +1,65 @@ +import type { Actions } from './$types.js'; +import { fail } from '@sveltejs/kit'; +import { requireAuth } from '$lib/auth/guard.js'; +import { serverFetch } from '$lib/api/client.server.js'; +import { ApiClientError } from '$lib/api/client.js'; +import type { TOTPSetupData, MessageData } from '$lib/api/types.js'; + +export const load = async (event: Parameters[0]) => { + requireAuth(event); + return {}; +}; + +export const actions: Actions = { + setup: async ({ cookies, fetch }) => { + try { + const res = await serverFetch('/auth/2fa/setup', cookies, { + method: 'POST', + fetch + }); + return { + totpSecret: res.data.secret, + totpUrl: res.data.url + }; + } catch (e) { + if (e instanceof ApiClientError) { + return fail(e.status, { error: e.message }); + } + return fail(500, { error: 'Ein Fehler ist aufgetreten.' }); + } + }, + + verify: async ({ request, cookies, fetch }) => { + const form = await request.formData(); + const code = form.get('code') as string; + + try { + await serverFetch('/auth/2fa/verify', cookies, { + method: 'POST', + body: JSON.stringify({ code }), + fetch + }); + return { success: '2FA wurde erfolgreich aktiviert.' }; + } catch (e) { + if (e instanceof ApiClientError) { + return fail(e.status, { error: e.message }); + } + return fail(500, { error: 'Ein Fehler ist aufgetreten.' }); + } + }, + + disable: async ({ cookies, fetch }) => { + try { + await serverFetch('/auth/2fa', cookies, { + method: 'DELETE', + fetch + }); + return { success: '2FA wurde deaktiviert.' }; + } catch (e) { + if (e instanceof ApiClientError) { + return fail(e.status, { error: e.message }); + } + return fail(500, { error: 'Ein Fehler ist aufgetreten.' }); + } + } +}; diff --git a/web/src/routes/profile/security/+page.svelte b/web/src/routes/profile/security/+page.svelte new file mode 100644 index 0000000..a411584 --- /dev/null +++ b/web/src/routes/profile/security/+page.svelte @@ -0,0 +1,103 @@ + + + + Sicherheit - Marktvogt + + +
+ + +

+ Zwei-Faktor-Authentifizierung +

+ +
+ {#if form?.success} + {form.success} + {/if} + + {#if form?.totpSecret} + + {:else} +
+

+ Schütze dein Konto mit einer Authenticator-App (z.B. Google Authenticator, Authy). +

+ +
+
{ + setupLoading = true; + return async ({ update }) => { + setupLoading = false; + await update(); + }; + }} + > + +
+ + {#if !showDisableConfirm} + + {/if} +
+ + {#if showDisableConfirm} +
+

+ Bist du sicher? Dein Konto wird weniger sicher sein. +

+
+
{ + disableLoading = true; + return async ({ update }) => { + disableLoading = false; + await update(); + }; + }} + > + +
+ +
+
+ {/if} + + {#if form?.error && !form?.totpSecret} + {form.error} + {/if} +
+ {/if} +
+
diff --git a/web/src/routes/sitemap.xml/+server.ts b/web/src/routes/sitemap.xml/+server.ts new file mode 100644 index 0000000..d3bbb3a --- /dev/null +++ b/web/src/routes/sitemap.xml/+server.ts @@ -0,0 +1,94 @@ +import type { RequestHandler } from './$types.js'; +import { apiFetch } from '$lib/api/client.js'; +import type { MarketSummary } from '$lib/api/types.js'; +import { stateToSlug, toSlug } from '$lib/utils/slug.js'; + +const ORIGIN = 'https://marktvogt.de'; + +function escapeXml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>'); +} + +export const GET: RequestHandler = async ({ fetch }) => { + const staticPages = [ + { path: '/', priority: '1.0', changefreq: 'daily' }, + { path: '/impressum', priority: '0.2', changefreq: 'yearly' }, + { path: '/datenschutz', priority: '0.2', changefreq: 'yearly' } + ]; + + let markets: MarketSummary[] = []; + try { + const res = await apiFetch('/markets?per_page=1000', { fetch }); + markets = res.data; + } catch { + // Backend unreachable — return static pages only + } + + // Collect unique states and state+city combos + const stateSet = new Set(); + const citySet = new Set(); + for (const m of markets) { + const ss = stateToSlug(m.state); + stateSet.add(ss); + citySet.add(`${ss}/${toSlug(m.city)}`); + } + + const urls = staticPages + .map( + (p) => ` + ${ORIGIN}${p.path} + ${p.changefreq} + ${p.priority} + ` + ) + .concat( + ` + ${ORIGIN}/maerkte/ + weekly + 0.7 + ` + ) + .concat( + Array.from(stateSet) + .sort() + .map( + (s) => ` + ${ORIGIN}/maerkte/${escapeXml(s)}/ + weekly + 0.7 + ` + ) + ) + .concat( + Array.from(citySet) + .sort() + .map( + (sc) => ` + ${ORIGIN}/maerkte/${escapeXml(sc)}/ + weekly + 0.6 + ` + ) + ) + .concat( + markets.map( + (m) => ` + ${ORIGIN}/markt/${escapeXml(m.slug)} + weekly + 0.8 + ` + ) + ); + + const xml = ` + +${urls.join('\n')} +`; + + return new Response(xml, { + headers: { + 'Content-Type': 'application/xml', + 'Cache-Control': 'max-age=3600' + } + }); +}; diff --git a/web/static/apple-touch-icon.png b/web/static/apple-touch-icon.png new file mode 100644 index 0000000..adad01f Binary files /dev/null and b/web/static/apple-touch-icon.png differ diff --git a/web/static/favicon-32.png b/web/static/favicon-32.png new file mode 100644 index 0000000..b434832 Binary files /dev/null and b/web/static/favicon-32.png differ diff --git a/web/static/favicon.ico b/web/static/favicon.ico new file mode 100644 index 0000000..22bf84a Binary files /dev/null and b/web/static/favicon.ico differ diff --git a/web/static/favicon.svg b/web/static/favicon.svg new file mode 100644 index 0000000..5926a40 --- /dev/null +++ b/web/static/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/web/static/fonts/crimsonpro-400.woff2 b/web/static/fonts/crimsonpro-400.woff2 new file mode 100644 index 0000000..eebdf8a Binary files /dev/null and b/web/static/fonts/crimsonpro-400.woff2 differ diff --git a/web/static/fonts/crimsonpro-400i.woff2 b/web/static/fonts/crimsonpro-400i.woff2 new file mode 100644 index 0000000..ff98967 Binary files /dev/null and b/web/static/fonts/crimsonpro-400i.woff2 differ diff --git a/web/static/fonts/crimsonpro-500.woff2 b/web/static/fonts/crimsonpro-500.woff2 new file mode 100644 index 0000000..eebdf8a Binary files /dev/null and b/web/static/fonts/crimsonpro-500.woff2 differ diff --git a/web/static/fonts/crimsonpro-600.woff2 b/web/static/fonts/crimsonpro-600.woff2 new file mode 100644 index 0000000..eebdf8a Binary files /dev/null and b/web/static/fonts/crimsonpro-600.woff2 differ diff --git a/web/static/fonts/crimsonpro-700.woff2 b/web/static/fonts/crimsonpro-700.woff2 new file mode 100644 index 0000000..eebdf8a Binary files /dev/null and b/web/static/fonts/crimsonpro-700.woff2 differ diff --git a/web/static/fonts/medievalsharp-400.woff2 b/web/static/fonts/medievalsharp-400.woff2 new file mode 100644 index 0000000..9265f45 Binary files /dev/null and b/web/static/fonts/medievalsharp-400.woff2 differ diff --git a/web/static/logo-signet.svg b/web/static/logo-signet.svg new file mode 100644 index 0000000..5e18db8 --- /dev/null +++ b/web/static/logo-signet.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/web/static/robots.txt b/web/static/robots.txt new file mode 100644 index 0000000..18b0899 --- /dev/null +++ b/web/static/robots.txt @@ -0,0 +1,5 @@ +# allow crawling everything by default +User-agent: * +Disallow: + +Sitemap: https://marktvogt.de/sitemap.xml diff --git a/web/static/site.webmanifest b/web/static/site.webmanifest new file mode 100644 index 0000000..1d50804 --- /dev/null +++ b/web/static/site.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "Marktvogt", + "short_name": "Marktvogt", + "icons": [ + { "src": "/favicon-32.png", "sizes": "32x32", "type": "image/png" }, + { "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" } + ], + "theme_color": "#1a3d24", + "background_color": "#0f2818", + "display": "standalone" +} diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..3e90abb --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,13 @@ +import adapter from '@sveltejs/adapter-node'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter(), + version: { + pollInterval: 60_000 + } + } +}; + +export default config; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..bf699a8 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,7 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()] +});