merge: web history into monorepo
This commit is contained in:
4
web/.env.example
Normal file
4
web/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
|
||||
# Cloudflare Turnstile (site key - public, safe to expose)
|
||||
PUBLIC_TURNSTILE_SITE_KEY=
|
||||
23
web/.gitignore
vendored
Normal file
23
web/.gitignore
vendored
Normal file
@@ -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-*
|
||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
36
web/.pre-commit-config.yaml
Normal file
36
web/.pre-commit-config.yaml
Normal file
@@ -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]
|
||||
5
web/.prettierignore
Normal file
5
web/.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
build/
|
||||
.svelte-kit/
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
deploy/
|
||||
8
web/.prettierrc
Normal file
8
web/.prettierrc
Normal file
@@ -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" } }]
|
||||
}
|
||||
57
web/.woodpecker.yml
Normal file
57
web/.woodpecker.yml
Normal file
@@ -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
|
||||
48
web/Dockerfile
Normal file
48
web/Dockerfile
Normal file
@@ -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"]
|
||||
3
web/README.md
Normal file
3
web/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Marktvogt Web
|
||||
|
||||
SvelteKit + Tailwind 4
|
||||
6
web/deploy/helm/Chart.yaml
Normal file
6
web/deploy/helm/Chart.yaml
Normal file
@@ -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"
|
||||
28
web/deploy/helm/templates/_helpers.tpl
Normal file
28
web/deploy/helm/templates/_helpers.tpl
Normal file
@@ -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 }}
|
||||
9
web/deploy/helm/templates/configmap.yaml
Normal file
9
web/deploy/helm/templates/configmap.yaml
Normal file
@@ -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 }}
|
||||
86
web/deploy/helm/templates/deployment.yaml
Normal file
86
web/deploy/helm/templates/deployment.yaml
Normal file
@@ -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 }}
|
||||
23
web/deploy/helm/templates/hpa.yaml
Normal file
23
web/deploy/helm/templates/hpa.yaml
Normal file
@@ -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 }}
|
||||
24
web/deploy/helm/templates/httproute-redirect.yaml
Normal file
24
web/deploy/helm/templates/httproute-redirect.yaml
Normal file
@@ -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 }}
|
||||
26
web/deploy/helm/templates/httproute.yaml
Normal file
26
web/deploy/helm/templates/httproute.yaml
Normal file
@@ -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 }}
|
||||
14
web/deploy/helm/templates/pdb.yaml
Normal file
14
web/deploy/helm/templates/pdb.yaml
Normal file
@@ -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 }}
|
||||
16
web/deploy/helm/templates/service.yaml
Normal file
16
web/deploy/helm/templates/service.yaml
Normal file
@@ -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
|
||||
8
web/deploy/helm/templates/serviceaccount.yaml
Normal file
8
web/deploy/helm/templates/serviceaccount.yaml
Normal file
@@ -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
|
||||
70
web/deploy/helm/values.yaml
Normal file
70
web/deploy/helm/values.yaml
Normal file
@@ -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: {}
|
||||
46
web/eslint.config.js
Normal file
46
web/eslint.config.js
Normal file
@@ -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/']
|
||||
}
|
||||
);
|
||||
43
web/package.json
Normal file
43
web/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
2520
web/pnpm-lock.yaml
generated
Normal file
2520
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
180
web/src/app.css
Normal file
180
web/src/app.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
18
web/src/app.d.ts
vendored
Normal file
18
web/src/app.d.ts
vendored
Normal file
@@ -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 {};
|
||||
40
web/src/app.html
Normal file
40
web/src/app.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="32x32" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
|
||||
<meta name="theme-color" content="#1a3d24" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0f2818" media="(prefers-color-scheme: dark)" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/fonts/crimsonpro-400.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/fonts/medievalsharp-400.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem('marktvogt-theme');
|
||||
var dark =
|
||||
t === 'dark' ||
|
||||
(t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
if (dark) document.documentElement.classList.add('dark');
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
65
web/src/hooks.server.ts
Normal file
65
web/src/hooks.server.ts
Normal file
@@ -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<ProfileData>('/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<ProfileData>('/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<ProfileData>('/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'
|
||||
});
|
||||
};
|
||||
45
web/src/lib/api/client.server.ts
Normal file
45
web/src/lib/api/client.server.ts
Normal file
@@ -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<T>(
|
||||
path: string,
|
||||
cookies: Cookies,
|
||||
init?: RequestInit & { fetch?: typeof globalThis.fetch }
|
||||
): Promise<ApiResponse<T>> {
|
||||
const accessToken = cookies.get('access_token');
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
return apiFetch<T>(path, {
|
||||
...init,
|
||||
headers: {
|
||||
...headers,
|
||||
...init?.headers
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshTokens(
|
||||
cookies: Cookies,
|
||||
fetchFn: typeof globalThis.fetch
|
||||
): Promise<boolean> {
|
||||
const sessionToken = cookies.get('session_token');
|
||||
if (!sessionToken) return false;
|
||||
|
||||
try {
|
||||
const res = await apiFetch<AuthData>('/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_token: sessionToken }),
|
||||
fetch: fetchFn
|
||||
});
|
||||
setAuthCookies(cookies, res.data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
54
web/src/lib/api/client.ts
Normal file
54
web/src/lib/api/client.ts
Normal file
@@ -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<T>(
|
||||
path: string,
|
||||
init?: RequestInit & { fetch?: typeof globalThis.fetch }
|
||||
): Promise<ApiResponse<T>> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
export function buildSearchQuery(params: Record<string, unknown>): 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();
|
||||
}
|
||||
243
web/src/lib/api/types.ts
Normal file
243
web/src/lib/api/types.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
// API response envelope
|
||||
export interface ApiResponse<T> {
|
||||
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;
|
||||
}
|
||||
1
web/src/lib/assets/favicon.svg
Normal file
1
web/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
26
web/src/lib/auth/cookies.ts
Normal file
26
web/src/lib/auth/cookies.ts
Normal file
@@ -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: '/' });
|
||||
}
|
||||
17
web/src/lib/auth/guard.ts
Normal file
17
web/src/lib/auth/guard.ts
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
552
web/src/lib/components/admin/MarketForm.svelte
Normal file
552
web/src/lib/components/admin/MarketForm.svelte
Normal file
@@ -0,0 +1,552 @@
|
||||
<script lang="ts">
|
||||
import type { AdminMarketDetail, OpeningHoursEntry, AdmissionInfo } from '$lib/api/types.js';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
market?: AdminMarketDetail;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
mode?: 'admin' | 'public';
|
||||
extraFields?: Snippet;
|
||||
}
|
||||
|
||||
let { market, loading = false, error, mode = 'admin', extraFields }: Props = $props();
|
||||
|
||||
const days = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
||||
|
||||
const currencyByCountry: Record<string, string> = {
|
||||
CH: 'CHF',
|
||||
LI: 'CHF',
|
||||
GB: 'GBP',
|
||||
DK: 'DKK',
|
||||
SE: 'SEK',
|
||||
NO: 'NOK',
|
||||
PL: 'PLN',
|
||||
CZ: 'CZK',
|
||||
HU: 'HUF',
|
||||
RO: 'RON',
|
||||
BG: 'BGN',
|
||||
HR: 'EUR',
|
||||
IS: 'ISK',
|
||||
RS: 'RSD',
|
||||
BA: 'BAM',
|
||||
AL: 'ALL',
|
||||
MK: 'MKD',
|
||||
MD: 'MDL',
|
||||
UA: 'UAH'
|
||||
};
|
||||
|
||||
let selectedCountry = $state(market?.country ?? 'DE');
|
||||
const currency = $derived(currencyByCountry[selectedCountry] ?? 'EUR');
|
||||
|
||||
let hours: OpeningHoursEntry[] = $state(
|
||||
market?.opening_hours?.length ? [...market.opening_hours] : []
|
||||
);
|
||||
|
||||
let admission: AdmissionInfo = $state(
|
||||
market?.admission_info ?? {
|
||||
adult_cents: 0,
|
||||
child_cents: 0,
|
||||
reduced_cents: 0,
|
||||
free_under_age: 0,
|
||||
notes: ''
|
||||
}
|
||||
);
|
||||
|
||||
let geocoding = $state(false);
|
||||
let geocodeError = $state('');
|
||||
|
||||
function addHoursRow() {
|
||||
hours = [...hours, { day: 'Samstag', open: '10:00', close: '18:00' }];
|
||||
}
|
||||
|
||||
function removeHoursRow(index: number) {
|
||||
hours = hours.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
const admissionJson = $derived(JSON.stringify(admission));
|
||||
|
||||
async function geocodeAddress() {
|
||||
geocoding = true;
|
||||
geocodeError = '';
|
||||
|
||||
const street = document.querySelector<HTMLInputElement>('[name="street"]')?.value ?? '';
|
||||
const city = document.querySelector<HTMLInputElement>('[name="city"]')?.value ?? '';
|
||||
const zip = document.querySelector<HTMLInputElement>('[name="zip"]')?.value ?? '';
|
||||
|
||||
if (!city) {
|
||||
geocodeError = 'Stadt ist erforderlich.';
|
||||
geocoding = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/geocode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ street, city, zip, country: selectedCountry })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
geocodeError = data.error?.message ?? 'Geocoding fehlgeschlagen.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.latitude != null && data.longitude != null) {
|
||||
const latEl = document.querySelector<HTMLInputElement>('[name="latitude"]');
|
||||
const lonEl = document.querySelector<HTMLInputElement>('[name="longitude"]');
|
||||
if (latEl) {
|
||||
latEl.value = String(data.latitude);
|
||||
latEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
if (lonEl) {
|
||||
lonEl.value = String(data.longitude);
|
||||
lonEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
} else {
|
||||
geocodeError = 'Keine Koordinaten gefunden.';
|
||||
}
|
||||
} catch {
|
||||
geocodeError = 'Geocoding fehlgeschlagen.';
|
||||
} finally {
|
||||
geocoding = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setHours(newHours: OpeningHoursEntry[]) {
|
||||
hours = newHours;
|
||||
}
|
||||
|
||||
export function setAdmission(newAdmission: AdmissionInfo) {
|
||||
admission = newAdmission;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="border-danger-200 bg-danger-50 text-danger-800 dark:border-danger-800 dark:bg-danger-950 dark:text-danger-200 mb-4 rounded-lg
|
||||
border p-4 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-6">
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Allgemein</legend>
|
||||
|
||||
<Input
|
||||
label="Name {mode === 'public' ? 'des Marktes' : ''} *"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
value={market?.name ?? ''}
|
||||
placeholder={mode === 'public' ? 'z.B. Ritterturnier zu München' : ''}
|
||||
/>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="description" class="block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="4"
|
||||
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"
|
||||
placeholder={mode === 'public' ? 'Beschreibe den Markt kurz...' : ''}
|
||||
>{market?.description ?? ''}</textarea
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Standort</legend>
|
||||
|
||||
<Input
|
||||
label="Straße"
|
||||
name="street"
|
||||
type="text"
|
||||
value={market?.street ?? ''}
|
||||
placeholder={mode === 'public' ? 'z.B. Marienplatz 1' : ''}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Stadt *"
|
||||
name="city"
|
||||
type="text"
|
||||
required
|
||||
value={market?.city ?? ''}
|
||||
placeholder={mode === 'public' ? 'z.B. München' : ''}
|
||||
/>
|
||||
<Input
|
||||
label="Bundesland"
|
||||
name="state"
|
||||
type="text"
|
||||
value={market?.state ?? ''}
|
||||
placeholder={mode === 'public' ? 'z.B. Bayern' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="PLZ"
|
||||
name="zip"
|
||||
type="text"
|
||||
value={market?.zip ?? ''}
|
||||
placeholder={mode === 'public' ? 'z.B. 80331' : ''}
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<label for="country" class="block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
Land *
|
||||
</label>
|
||||
<select
|
||||
id="country"
|
||||
name="country"
|
||||
required
|
||||
bind:value={selectedCountry}
|
||||
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"
|
||||
>
|
||||
<option value="DE">Deutschland</option>
|
||||
<option value="AT">Österreich</option>
|
||||
<option value="CH">Schweiz</option>
|
||||
<option disabled>──────────</option>
|
||||
<option value="AL">Albanien</option>
|
||||
<option value="AD">Andorra</option>
|
||||
<option value="BE">Belgien</option>
|
||||
<option value="BA">Bosnien und Herzegowina</option>
|
||||
<option value="BG">Bulgarien</option>
|
||||
<option value="DK">Dänemark</option>
|
||||
<option value="EE">Estland</option>
|
||||
<option value="FI">Finnland</option>
|
||||
<option value="FR">Frankreich</option>
|
||||
<option value="GR">Griechenland</option>
|
||||
<option value="IE">Irland</option>
|
||||
<option value="IS">Island</option>
|
||||
<option value="IT">Italien</option>
|
||||
<option value="XK">Kosovo</option>
|
||||
<option value="HR">Kroatien</option>
|
||||
<option value="LV">Lettland</option>
|
||||
<option value="LI">Liechtenstein</option>
|
||||
<option value="LT">Litauen</option>
|
||||
<option value="LU">Luxemburg</option>
|
||||
<option value="MT">Malta</option>
|
||||
<option value="MD">Moldawien</option>
|
||||
<option value="MC">Monaco</option>
|
||||
<option value="ME">Montenegro</option>
|
||||
<option value="NL">Niederlande</option>
|
||||
<option value="MK">Nordmazedonien</option>
|
||||
<option value="NO">Norwegen</option>
|
||||
<option value="PL">Polen</option>
|
||||
<option value="PT">Portugal</option>
|
||||
<option value="RO">Rumänien</option>
|
||||
<option value="SM">San Marino</option>
|
||||
<option value="SE">Schweden</option>
|
||||
<option value="RS">Serbien</option>
|
||||
<option value="SK">Slowakei</option>
|
||||
<option value="SI">Slowenien</option>
|
||||
<option value="ES">Spanien</option>
|
||||
<option value="CZ">Tschechien</option>
|
||||
<option value="UA">Ukraine</option>
|
||||
<option value="HU">Ungarn</option>
|
||||
<option value="VA">Vatikanstadt</option>
|
||||
<option value="GB">Vereinigtes Königreich</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Breitengrad"
|
||||
name="latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={market?.latitude ? String(market.latitude) : ''}
|
||||
placeholder={mode === 'public' ? 'z.B. 48.1351' : ''}
|
||||
/>
|
||||
<Input
|
||||
label="Längengrad"
|
||||
name="longitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={market?.longitude ? String(market.longitude) : ''}
|
||||
placeholder={mode === 'public' ? 'z.B. 11.5820' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={geocodeAddress}
|
||||
disabled={geocoding}
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{geocoding ? 'Ermittle...' : 'Koordinaten aus Adresse ermitteln'}
|
||||
</button>
|
||||
{#if geocodeError}
|
||||
<span class="text-danger-600 dark:text-danger-400 text-xs">{geocodeError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Zeitraum</legend>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Startdatum *"
|
||||
name="start_date"
|
||||
type="date"
|
||||
required
|
||||
value={market?.start_date ?? ''}
|
||||
/>
|
||||
<Input
|
||||
label="Enddatum *"
|
||||
name="end_date"
|
||||
type="date"
|
||||
required
|
||||
value={market?.end_date ?? ''}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Weitere Infos</legend>
|
||||
|
||||
<Input
|
||||
label="Website"
|
||||
name="website"
|
||||
type="url"
|
||||
value={market?.website ?? ''}
|
||||
placeholder={mode === 'public' ? 'https://...' : ''}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Veranstalter"
|
||||
name="organizer_name"
|
||||
type="text"
|
||||
value={market?.organizer_name ?? ''}
|
||||
placeholder={mode === 'public' ? 'Name des Veranstalters' : ''}
|
||||
/>
|
||||
|
||||
<Input label="Bild-URL" name="image_url" type="url" value={market?.image_url ?? ''} />
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Öffnungszeiten</legend>
|
||||
|
||||
{#each hours as row, i}
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="hours-day-{i}"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200">Tag</label
|
||||
>
|
||||
<select
|
||||
id="hours-day-{i}"
|
||||
bind:value={row.day}
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 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"
|
||||
>
|
||||
{#each days as d}
|
||||
<option value={d}>{d}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<Input
|
||||
label="Von"
|
||||
type="time"
|
||||
value={row.open}
|
||||
oninput={(e) => {
|
||||
row.open = e.currentTarget.value;
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="Bis"
|
||||
type="time"
|
||||
value={row.close}
|
||||
oninput={(e) => {
|
||||
row.close = e.currentTarget.value;
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeHoursRow(i)}
|
||||
class="text-danger-600 hover:text-danger-800 dark:text-danger-400 pb-1 text-sm"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={addHoursRow}
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm font-medium"
|
||||
>
|
||||
+ Zeile hinzufügen
|
||||
</button>
|
||||
|
||||
<input type="hidden" name="opening_hours" value={JSON.stringify(hours)} />
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Eintrittspreise</legend
|
||||
>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-adult"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>
|
||||
Erwachsene ({currency})
|
||||
</label>
|
||||
<input
|
||||
id="admission-adult"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={(admission.adult_cents / 100).toFixed(2)}
|
||||
onchange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-child"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>
|
||||
Kinder ({currency})
|
||||
</label>
|
||||
<input
|
||||
id="admission-child"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={(admission.child_cents / 100).toFixed(2)}
|
||||
onchange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-reduced"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>
|
||||
Ermäßigt ({currency})
|
||||
</label>
|
||||
<input
|
||||
id="admission-reduced"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={(admission.reduced_cents / 100).toFixed(2)}
|
||||
onchange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-free-under"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>
|
||||
Frei unter (Alter)
|
||||
</label>
|
||||
<input
|
||||
id="admission-free-under"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={admission.free_under_age}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-notes"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>
|
||||
Hinweise
|
||||
</label>
|
||||
<textarea
|
||||
id="admission-notes"
|
||||
rows="2"
|
||||
bind:value={admission.notes}
|
||||
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"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="admission_info" value={admissionJson} />
|
||||
</fieldset>
|
||||
|
||||
{#if mode === 'admin'}
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Admin-Notizen</legend
|
||||
>
|
||||
|
||||
<div class="space-y-1">
|
||||
<textarea
|
||||
id="admin_notes"
|
||||
name="admin_notes"
|
||||
rows="3"
|
||||
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"
|
||||
placeholder="Interne Notizen...">{market?.admin_notes ?? ''}</textarea
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
{/if}
|
||||
|
||||
{#if extraFields}
|
||||
{@render extraFields()}
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 border-t border-stone-200 pt-6 dark:border-stone-700">
|
||||
<Button type="submit" {loading}>
|
||||
{#if mode === 'public'}
|
||||
Markt einreichen
|
||||
{:else if market}
|
||||
Speichern
|
||||
{:else}
|
||||
Erstellen
|
||||
{/if}
|
||||
</Button>
|
||||
{#if mode === 'admin'}
|
||||
<a href="/admin/maerkte">
|
||||
<Button variant="secondary" type="button">Abbrechen</Button>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
186
web/src/lib/components/admin/ResearchPanel.svelte
Normal file
186
web/src/lib/components/admin/ResearchPanel.svelte
Normal file
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import type { ResearchResult, FieldSuggestion } from '$lib/api/types.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
result: ResearchResult;
|
||||
onApply: (suggestions: FieldSuggestion[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { result, onApply, onClose }: Props = $props();
|
||||
|
||||
let selected: boolean[] = $state(result.suggestions.map(() => true));
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
description: 'Beschreibung',
|
||||
street: 'Straße',
|
||||
city: 'Stadt',
|
||||
zip: 'PLZ',
|
||||
website: 'Website',
|
||||
organizer_name: 'Veranstalter',
|
||||
opening_hours: 'Öffnungszeiten',
|
||||
admission_info: 'Eintrittspreise',
|
||||
state: 'Bundesland',
|
||||
image_url: 'Bild-URL'
|
||||
};
|
||||
|
||||
const confidenceColors: Record<string, string> = {
|
||||
high: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
|
||||
low: 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400'
|
||||
};
|
||||
|
||||
function formatValue(val: unknown, _field?: string): string {
|
||||
if (val === null || val === undefined) return '—';
|
||||
if (typeof val !== 'object') return String(val);
|
||||
return JSON.stringify(val, null, 2);
|
||||
}
|
||||
|
||||
function isOpeningHours(
|
||||
val: unknown
|
||||
): val is Array<{ day: string; open: string; close: string }> {
|
||||
return Array.isArray(val) && val.length > 0 && 'day' in val[0];
|
||||
}
|
||||
|
||||
function isAdmissionInfo(val: unknown): val is {
|
||||
adult_cents: number;
|
||||
child_cents: number;
|
||||
reduced_cents: number;
|
||||
free_under_age: number;
|
||||
notes: string;
|
||||
} {
|
||||
return typeof val === 'object' && val !== null && 'adult_cents' in val;
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const chosen = result.suggestions.filter((_, i) => selected[i]);
|
||||
onApply(chosen);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-stone-200 bg-stone-50 p-6 dark:border-stone-700 dark:bg-stone-900"
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-stone-800 dark:text-stone-100">
|
||||
KI-Recherche Ergebnisse
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if result.suggestions.length === 0}
|
||||
<p class="text-sm text-stone-600 dark:text-stone-400">Keine Vorschläge gefunden.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each result.suggestions as suggestion, i}
|
||||
<label
|
||||
class="flex cursor-pointer gap-3 rounded-lg border border-stone-200 p-3 transition-colors hover:bg-stone-100 dark:border-stone-700 dark:hover:bg-stone-800"
|
||||
>
|
||||
<input type="checkbox" bind:checked={selected[i]} class="mt-1" />
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-stone-800 dark:text-stone-100">
|
||||
{fieldLabels[suggestion.field] ?? suggestion.field}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {confidenceColors[
|
||||
suggestion.confidence
|
||||
]}"
|
||||
>
|
||||
{suggestion.confidence}
|
||||
</span>
|
||||
</div>
|
||||
{#if suggestion.current_value !== null && suggestion.current_value !== undefined}
|
||||
<div class="text-xs text-stone-500 dark:text-stone-400">
|
||||
Aktuell: {formatValue(suggestion.current_value, suggestion.field)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-sm text-stone-900 dark:text-stone-100">
|
||||
{#if suggestion.field === 'opening_hours' && isOpeningHours(suggestion.suggested_value)}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-stone-500 dark:text-stone-400">
|
||||
<th class="pr-4 pb-1 font-medium">Tag</th>
|
||||
<th class="pr-4 pb-1 font-medium">Von</th>
|
||||
<th class="pb-1 font-medium">Bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each suggestion.suggested_value as entry}
|
||||
<tr>
|
||||
<td class="py-0.5 pr-4">{entry.day}</td>
|
||||
<td class="py-0.5 pr-4">{entry.open}</td>
|
||||
<td class="py-0.5">{entry.close}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if suggestion.field === 'admission_info' && isAdmissionInfo(suggestion.suggested_value)}
|
||||
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<dt class="text-stone-500 dark:text-stone-400">Erwachsene</dt>
|
||||
<dd>{formatCents(suggestion.suggested_value.adult_cents)}</dd>
|
||||
<dt class="text-stone-500 dark:text-stone-400">Kinder</dt>
|
||||
<dd>{formatCents(suggestion.suggested_value.child_cents)}</dd>
|
||||
<dt class="text-stone-500 dark:text-stone-400">Ermäßigt</dt>
|
||||
<dd>{formatCents(suggestion.suggested_value.reduced_cents)}</dd>
|
||||
{#if suggestion.suggested_value.free_under_age > 0}
|
||||
<dt class="text-stone-500 dark:text-stone-400">Frei unter</dt>
|
||||
<dd>{suggestion.suggested_value.free_under_age} Jahre</dd>
|
||||
{/if}
|
||||
{#if suggestion.suggested_value.notes}
|
||||
<dt class="col-span-2 mt-1 text-stone-500 dark:text-stone-400">
|
||||
{suggestion.suggested_value.notes}
|
||||
</dt>
|
||||
{/if}
|
||||
</dl>
|
||||
{:else}
|
||||
{formatValue(suggestion.suggested_value, suggestion.field)}
|
||||
{/if}
|
||||
</div>
|
||||
{#if suggestion.reason}
|
||||
<div class="text-xs text-stone-500 italic dark:text-stone-400">
|
||||
{suggestion.reason}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button type="button" onclick={handleApply}>Übernehmen</Button>
|
||||
<Button type="button" variant="secondary" onclick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if result.sources && result.sources.length > 0}
|
||||
<div class="mt-4 border-t border-stone-200 pt-3 dark:border-stone-700">
|
||||
<p class="mb-1 text-xs font-medium text-stone-500 dark:text-stone-400">Quellen:</p>
|
||||
<ul class="space-y-0.5">
|
||||
{#each result.sources as source}
|
||||
<li>
|
||||
<a
|
||||
href={source}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 dark:text-primary-400 text-xs break-all hover:underline"
|
||||
>
|
||||
{source}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
55
web/src/lib/components/auth/LoginForm.svelte
Normal file
55
web/src/lib/components/auth/LoginForm.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
error?: string;
|
||||
requireTotp?: boolean;
|
||||
}
|
||||
|
||||
let { error, requireTotp = false }: Props = $props();
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/auth/anmelden?/login"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
{#if error}
|
||||
<Alert variant="error">{error}</Alert>
|
||||
{/if}
|
||||
|
||||
<Input name="email" label="E-Mail" type="email" required autocomplete="email" />
|
||||
<Input
|
||||
name="password"
|
||||
label="Passwort"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
{#if requireTotp}
|
||||
<Input
|
||||
name="totp_code"
|
||||
label="2FA-Code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength={6}
|
||||
pattern={'[0-9]{6}'}
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" {loading} class="w-full">Anmelden</Button>
|
||||
</form>
|
||||
38
web/src/lib/components/auth/MagicLinkForm.svelte
Normal file
38
web/src/lib/components/auth/MagicLinkForm.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
error?: string;
|
||||
success?: string;
|
||||
}
|
||||
|
||||
let { error, success }: Props = $props();
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/auth/anmelden?/magicLink"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
{#if error}
|
||||
<Alert variant="error">{error}</Alert>
|
||||
{/if}
|
||||
{#if success}
|
||||
<Alert variant="success">{success}</Alert>
|
||||
{/if}
|
||||
|
||||
<Input name="email" label="E-Mail" type="email" required autocomplete="email" />
|
||||
|
||||
<Button type="submit" variant="secondary" {loading} class="w-full">Magic Link senden</Button>
|
||||
</form>
|
||||
17
web/src/lib/components/auth/OAuthButtons.svelte
Normal file
17
web/src/lib/components/auth/OAuthButtons.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
const providers = [
|
||||
{ id: 'google', label: 'Google' },
|
||||
{ id: 'github', label: 'GitHub' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each providers as provider}
|
||||
<a
|
||||
href="/api/v1/auth/oauth/{provider.id}/start"
|
||||
class="bg-vellum flex w-full items-center justify-center gap-2 rounded-lg border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 shadow-sm hover:bg-stone-100 dark:border-stone-600 dark:text-stone-200 dark:hover:bg-stone-700"
|
||||
>
|
||||
Mit {provider.label} anmelden
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
43
web/src/lib/components/auth/RegisterForm.svelte
Normal file
43
web/src/lib/components/auth/RegisterForm.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let { error }: Props = $props();
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/auth/registrieren"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
{#if error}
|
||||
<Alert variant="error">{error}</Alert>
|
||||
{/if}
|
||||
|
||||
<Input name="display_name" label="Anzeigename" required autocomplete="name" />
|
||||
<Input name="email" label="E-Mail" type="email" required autocomplete="email" />
|
||||
<Input
|
||||
name="password"
|
||||
label="Passwort"
|
||||
type="password"
|
||||
required
|
||||
minlength={8}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
|
||||
<Button type="submit" {loading} class="w-full">Registrieren</Button>
|
||||
</form>
|
||||
76
web/src/lib/components/auth/TOTPSetup.svelte
Normal file
76
web/src/lib/components/auth/TOTPSetup.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
secret?: string;
|
||||
url?: string;
|
||||
error?: string;
|
||||
success?: string;
|
||||
}
|
||||
|
||||
let { secret, url, error, success }: Props = $props();
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<Alert variant="error">{error}</Alert>
|
||||
{/if}
|
||||
{#if success}
|
||||
<Alert variant="success">{success}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if secret && url}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-stone-600 dark:text-stone-300">
|
||||
Scanne den QR-Code mit deiner Authenticator-App oder gib den Schlüssel manuell ein.
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encodeURIComponent(
|
||||
url
|
||||
)}"
|
||||
alt="TOTP QR-Code"
|
||||
class="rounded-lg"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-stone-50 p-3 text-center dark:bg-stone-800">
|
||||
<p class="text-xs text-stone-500 dark:text-stone-400">Schlüssel</p>
|
||||
<p class="mt-1 font-mono text-sm font-medium text-stone-900 select-all dark:text-stone-100">
|
||||
{secret}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/profile/security?/verify"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<Input
|
||||
name="code"
|
||||
label="Bestätigungscode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength={6}
|
||||
pattern={'[0-9]{6}'}
|
||||
placeholder="123456"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
<Button type="submit" {loading}>Bestätigen</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
20
web/src/lib/components/layout/Footer.svelte
Normal file
20
web/src/lib/components/layout/Footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import ThemeToggle from '$lib/components/ui/ThemeToggle.svelte';
|
||||
</script>
|
||||
|
||||
<footer class="border-primary-800 bg-primary-950 border-t">
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<p class="text-primary-400 text-sm">© {new Date().getFullYear()} Marktvogt</p>
|
||||
<div class="flex items-center gap-6">
|
||||
<nav class="flex gap-6">
|
||||
<a href="/impressum" class="text-primary-400 hover:text-primary-200 text-sm">Impressum</a>
|
||||
<a href="/datenschutz" class="text-primary-400 hover:text-primary-200 text-sm"
|
||||
>Datenschutz</a
|
||||
>
|
||||
</nav>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
84
web/src/lib/components/layout/Header.svelte
Normal file
84
web/src/lib/components/layout/Header.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import type { ProfileData } from '$lib/api/types.js';
|
||||
import MobileNav from './MobileNav.svelte';
|
||||
import UserMenu from './UserMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
user: ProfileData | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let mobileOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<header class="border-primary-800 bg-primary-900 border-b">
|
||||
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<a href="/" class="font-heading text-accent-300 flex items-center gap-2 text-xl font-bold">
|
||||
<svg class="h-8 w-7 shrink-0" viewBox="0 0 32 36" aria-hidden="true">
|
||||
<path
|
||||
d="M5,22 Q5,34 16,34 Q27,34 27,22"
|
||||
fill="none"
|
||||
stroke="#c4952e"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="16" cy="14" r="12.5" fill="#1a3d24" stroke="#c4952e" stroke-width="1.8" />
|
||||
<g transform="translate(16,13) scale(0.18)" fill="#d4a63a">
|
||||
<ellipse cx="0" cy="-24" rx="6.5" ry="25" />
|
||||
<ellipse cx="19" cy="-13" rx="6" ry="20" transform="rotate(42, 19, -13)" />
|
||||
<ellipse cx="-19" cy="-13" rx="6" ry="20" transform="rotate(-42, -19, -13)" />
|
||||
<rect x="-24" y="-3" width="48" height="8" rx="2" />
|
||||
<path d="M-8,5 L-9,46 C-9,56 9,56 9,46 L8,5 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
Marktvogt
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<nav class="hidden items-center gap-6 md:flex">
|
||||
<a href="/" class="text-primary-200 text-sm font-medium hover:text-white">Suche</a>
|
||||
<a href="/markt/einreichen" class="text-primary-200 text-sm font-medium hover:text-white">
|
||||
Markt einreichen
|
||||
</a>
|
||||
{#if user}
|
||||
<UserMenu {user} />
|
||||
{:else}
|
||||
<a href="/auth/anmelden" class="text-accent-300 hover:text-accent-200 text-sm font-medium">
|
||||
Anmelden
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- Mobile: menu button -->
|
||||
<div class="flex items-center gap-2 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-300 rounded-md p-2 hover:text-white"
|
||||
onclick={() => (mobileOpen = !mobileOpen)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{#if mobileOpen}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if mobileOpen}
|
||||
<MobileNav {user} onclose={() => (mobileOpen = false)} />
|
||||
{/if}
|
||||
</header>
|
||||
57
web/src/lib/components/layout/MobileNav.svelte
Normal file
57
web/src/lib/components/layout/MobileNav.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { ProfileData } from '$lib/api/types.js';
|
||||
|
||||
interface Props {
|
||||
user: ProfileData | null;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { user, onclose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav class="border-primary-800 bg-primary-900 border-t px-4 py-4 md:hidden">
|
||||
<div class="flex flex-col gap-3">
|
||||
<a href="/" class="text-primary-200 text-sm font-medium hover:text-white" onclick={onclose}>
|
||||
Suche
|
||||
</a>
|
||||
<a
|
||||
href="/markt/einreichen"
|
||||
class="text-primary-200 text-sm font-medium hover:text-white"
|
||||
onclick={onclose}
|
||||
>
|
||||
Markt einreichen
|
||||
</a>
|
||||
{#if user?.role === 'admin'}
|
||||
<a
|
||||
href="/admin/maerkte"
|
||||
class="text-accent-300 hover:text-accent-200 text-sm font-medium"
|
||||
onclick={onclose}
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
{/if}
|
||||
{#if user}
|
||||
<a
|
||||
href="/profile"
|
||||
class="text-primary-200 text-sm font-medium hover:text-white"
|
||||
onclick={onclose}
|
||||
>
|
||||
Profil
|
||||
</a>
|
||||
<form method="POST" action="/auth/abmelden">
|
||||
<button type="submit" class="text-primary-200 text-sm font-medium hover:text-white">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
<span class="text-primary-300 text-sm">{user.display_name}</span>
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/anmelden"
|
||||
class="text-accent-300 hover:text-accent-200 text-sm font-medium"
|
||||
onclick={onclose}
|
||||
>
|
||||
Anmelden
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
105
web/src/lib/components/layout/UserMenu.svelte
Normal file
105
web/src/lib/components/layout/UserMenu.svelte
Normal file
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import type { ProfileData } from '$lib/api/types.js';
|
||||
|
||||
interface Props {
|
||||
user: ProfileData;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
let open = $state(false);
|
||||
let menuRef = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (menuRef && !menuRef.contains(e.target as Node)) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="relative" bind:this={menuRef} onkeydown={onKeydown}>
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-200 flex items-center gap-1 text-sm font-medium hover:text-white"
|
||||
onclick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
{user.display_name}
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform {open ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="bg-primary-800 ring-primary-700 absolute right-0 z-50 mt-2 w-48 rounded-md py-1 shadow-lg ring-1"
|
||||
role="menu"
|
||||
>
|
||||
<a
|
||||
href="/profile"
|
||||
class="text-primary-100 hover:bg-primary-700 block px-4 py-2 text-sm"
|
||||
role="menuitem"
|
||||
onclick={close}
|
||||
>
|
||||
Profil
|
||||
</a>
|
||||
<a
|
||||
href="/profile/security"
|
||||
class="text-primary-100 hover:bg-primary-700 block px-4 py-2 text-sm"
|
||||
role="menuitem"
|
||||
onclick={close}
|
||||
>
|
||||
Sicherheit
|
||||
</a>
|
||||
|
||||
{#if user.role === 'admin'}
|
||||
<div class="border-primary-700 my-1 border-t"></div>
|
||||
<a
|
||||
href="/admin/maerkte"
|
||||
class="text-accent-300 hover:bg-primary-700 block px-4 py-2 text-sm"
|
||||
role="menuitem"
|
||||
onclick={close}
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="border-primary-700 my-1 border-t"></div>
|
||||
<form method="POST" action="/auth/abmelden">
|
||||
<button
|
||||
type="submit"
|
||||
class="text-primary-100 hover:bg-primary-700 block w-full px-4 py-2 text-left text-sm"
|
||||
role="menuitem"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
97
web/src/lib/components/market/MarketCard.svelte
Normal file
97
web/src/lib/components/market/MarketCard.svelte
Normal file
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
interface Props {
|
||||
market: MarketSummary;
|
||||
}
|
||||
|
||||
let { market }: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDistance(meters: number): string {
|
||||
if (meters < 1000) return `${Math.round(meters)} m`;
|
||||
return `${(meters / 1000).toFixed(1)} km`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="group bg-vellum block rounded-lg border border-stone-200 shadow-sm transition-shadow hover:shadow-md dark:border-stone-700"
|
||||
>
|
||||
{#if market.image_url}
|
||||
<div class="aspect-[16/9] overflow-hidden rounded-t-lg">
|
||||
<img
|
||||
src={market.image_url}
|
||||
alt={market.name}
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="p-4">
|
||||
<h3
|
||||
class="group-hover:text-primary-600 dark:group-hover:text-primary-400 text-lg font-semibold text-stone-900 dark:text-stone-100"
|
||||
>
|
||||
{market.name}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-stone-500 dark:text-stone-400">
|
||||
{market.city}{#if market.state}, {market.state}{/if}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3 text-sm text-stone-600 dark:text-stone-300">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
{formatDate(market.start_date)} – {formatDate(market.end_date)}
|
||||
</span>
|
||||
{#if market.edition_count && market.edition_count > 1}
|
||||
<span class="text-primary-600 dark:text-primary-400 text-xs font-medium">
|
||||
+{market.edition_count - 1} weitere {market.edition_count > 2 ? 'Termine' : 'Termin'}
|
||||
</span>
|
||||
{/if}
|
||||
{#if market.distance !== undefined}
|
||||
<span class="text-primary-600 dark:text-primary-400 flex items-center gap-1">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
</svg>
|
||||
{formatDistance(market.distance)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if market.organizer_name}
|
||||
<p class="mt-2 text-xs text-stone-400 dark:text-stone-500">von {market.organizer_name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
83
web/src/lib/components/market/MarketMap.svelte
Normal file
83
web/src/lib/components/market/MarketMap.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
|
||||
interface Props {
|
||||
markets: MarketSummary[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { markets, class: className = '' }: Props = $props();
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
let map: LeafletMap | undefined;
|
||||
|
||||
onMount(() => {
|
||||
let link: HTMLLinkElement;
|
||||
|
||||
(async () => {
|
||||
const L = await import('leaflet');
|
||||
|
||||
// Leaflet CSS
|
||||
link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
// Fix default icon paths
|
||||
// @ts-expect-error — Leaflet icon path workaround
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png'
|
||||
});
|
||||
|
||||
map = L.map(mapContainer).setView([51.1657, 10.4515], 6); // Germany center
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(map);
|
||||
|
||||
updateMarkers(L, markets);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
map?.remove();
|
||||
link?.remove();
|
||||
};
|
||||
});
|
||||
|
||||
function updateMarkers(L: typeof import('leaflet'), items: MarketSummary[]) {
|
||||
if (!map) return;
|
||||
|
||||
// Clear existing markers
|
||||
map.eachLayer((layer) => {
|
||||
if (layer instanceof L.Marker) map!.removeLayer(layer);
|
||||
});
|
||||
|
||||
if (items.length === 0) return;
|
||||
|
||||
const bounds = L.latLngBounds([]);
|
||||
|
||||
for (const m of items) {
|
||||
const marker = L.marker([m.latitude, m.longitude]).addTo(map);
|
||||
marker.bindPopup(`<strong><a href="/markt/${m.slug}">${m.name}</a></strong><br>${m.city}`);
|
||||
bounds.extend([m.latitude, m.longitude]);
|
||||
}
|
||||
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (map) {
|
||||
import('leaflet').then((L) => updateMarkers(L, markets));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={mapContainer}
|
||||
class="h-[400px] w-full rounded-lg border border-stone-200 dark:border-stone-700 {className}"
|
||||
></div>
|
||||
73
web/src/lib/components/market/Pagination.svelte
Normal file
73
web/src/lib/components/market/Pagination.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import type { PaginationMeta } from '$lib/api/types.js';
|
||||
|
||||
interface Props {
|
||||
meta: PaginationMeta;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
let { meta, baseUrl = '/' }: Props = $props();
|
||||
|
||||
function pageUrl(page: number): string {
|
||||
const url = new URL(baseUrl, 'http://localhost');
|
||||
const params = new URLSearchParams(url.search);
|
||||
params.set('page', String(page));
|
||||
return `${url.pathname}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const pages = $derived.by(() => {
|
||||
const result: (number | '...')[] = [];
|
||||
const total = meta.total_pages;
|
||||
const current = meta.page;
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) result.push(i);
|
||||
} else {
|
||||
result.push(1);
|
||||
if (current > 3) result.push('...');
|
||||
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
|
||||
result.push(i);
|
||||
}
|
||||
if (current < total - 2) result.push('...');
|
||||
result.push(total);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if meta.total_pages > 1}
|
||||
<nav class="flex items-center justify-center gap-1" aria-label="Seitennavigation">
|
||||
{#if meta.page > 1}
|
||||
<a
|
||||
href={pageUrl(meta.page - 1)}
|
||||
class="rounded-lg px-3 py-2 text-sm text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-700"
|
||||
>
|
||||
Zurück
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#each pages as p}
|
||||
{#if p === '...'}
|
||||
<span class="px-2 text-stone-400 dark:text-stone-500">...</span>
|
||||
{:else}
|
||||
<a
|
||||
href={pageUrl(p)}
|
||||
class="rounded-lg px-3 py-2 text-sm {p === meta.page
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-700'}"
|
||||
>
|
||||
{p}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if meta.page < meta.total_pages}
|
||||
<a
|
||||
href={pageUrl(meta.page + 1)}
|
||||
class="rounded-lg px-3 py-2 text-sm text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-700"
|
||||
>
|
||||
Weiter
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
259
web/src/lib/components/market/SearchForm.svelte
Normal file
259
web/src/lib/components/market/SearchForm.svelte
Normal file
@@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Select from '$lib/components/ui/Select.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
q?: string;
|
||||
plz?: string;
|
||||
radius?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sort?: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
}
|
||||
|
||||
let { q = '', plz = '', radius = 25, from = '', to = '', sort = '', lat, lon }: Props = $props();
|
||||
|
||||
let locating = $state(false);
|
||||
let locationError = $state('');
|
||||
let locationName = $state('');
|
||||
let gpsLat = $state<number | undefined>(undefined);
|
||||
let gpsLon = $state<number | undefined>(undefined);
|
||||
|
||||
const currentLat = $derived(gpsLat ?? lat);
|
||||
const currentLon = $derived(gpsLon ?? lon);
|
||||
const hasGPSLocation = $derived(gpsLat !== undefined && gpsLon !== undefined);
|
||||
|
||||
const radiusOptions = [
|
||||
{ value: '10', label: '10 km' },
|
||||
{ value: '25', label: '25 km' },
|
||||
{ value: '50', label: '50 km' },
|
||||
{ value: '100', label: '100 km' },
|
||||
{ value: '200', label: '200 km' }
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ value: '', label: 'Standard' },
|
||||
{ value: 'distance', label: 'Entfernung' },
|
||||
{ value: 'date', label: 'Datum' },
|
||||
{ value: 'name', label: 'Name' }
|
||||
];
|
||||
|
||||
async function fallbackIPLocation(): Promise<{ lat: number; lon: number; city?: string } | null> {
|
||||
try {
|
||||
const res = await fetch('https://get.geojs.io/v1/ip/geo.json');
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const lat = parseFloat(data.latitude);
|
||||
const lon = parseFloat(data.longitude);
|
||||
if (isNaN(lat) || isNaN(lon)) return null;
|
||||
return { lat, lon, city: data.city || undefined };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setLocation(lat: number, lon: number, name?: string) {
|
||||
gpsLat = lat;
|
||||
gpsLon = lon;
|
||||
locationName = name || `${lat.toFixed(2)}, ${lon.toFixed(2)}`;
|
||||
locationError = '';
|
||||
}
|
||||
|
||||
function clearLocation() {
|
||||
gpsLat = undefined;
|
||||
gpsLon = undefined;
|
||||
locationName = '';
|
||||
}
|
||||
|
||||
function useGPS() {
|
||||
if (!window.isSecureContext) {
|
||||
locationError = 'Standortabfrage erfordert eine sichere Verbindung (HTTPS).';
|
||||
return;
|
||||
}
|
||||
if (!navigator.geolocation) {
|
||||
tryIPFallback();
|
||||
return;
|
||||
}
|
||||
locating = true;
|
||||
locationError = '';
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setLocation(pos.coords.latitude, pos.coords.longitude, 'GPS-Standort');
|
||||
locating = false;
|
||||
},
|
||||
async (err) => {
|
||||
if (err.code === err.PERMISSION_DENIED) {
|
||||
locationError =
|
||||
'Standortzugriff wurde verweigert. Bitte erlaube den Zugriff in den Browsereinstellungen.';
|
||||
locating = false;
|
||||
return;
|
||||
}
|
||||
// POSITION_UNAVAILABLE or TIMEOUT — try IP fallback
|
||||
const ipLoc = await fallbackIPLocation();
|
||||
if (ipLoc) {
|
||||
setLocation(ipLoc.lat, ipLoc.lon, ipLoc.city ? `${ipLoc.city} (ungefähr)` : undefined);
|
||||
locating = false;
|
||||
} else {
|
||||
locationError =
|
||||
err.code === err.TIMEOUT
|
||||
? 'Standortabfrage hat zu lange gedauert. Bitte versuche es erneut.'
|
||||
: 'Standort konnte nicht ermittelt werden. Bitte gib eine PLZ ein.';
|
||||
locating = false;
|
||||
}
|
||||
},
|
||||
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 }
|
||||
);
|
||||
}
|
||||
|
||||
async function tryIPFallback() {
|
||||
locating = true;
|
||||
locationError = '';
|
||||
const ipLoc = await fallbackIPLocation();
|
||||
if (ipLoc) {
|
||||
setLocation(ipLoc.lat, ipLoc.lon, ipLoc.city ? `${ipLoc.city} (ungefähr)` : undefined);
|
||||
} else {
|
||||
locationError = 'Standort konnte nicht ermittelt werden. Bitte gib eine PLZ ein.';
|
||||
}
|
||||
locating = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="GET"
|
||||
action="/"
|
||||
class="bg-vellum space-y-4 rounded-lg border border-stone-200 p-4 shadow-sm sm:p-6 dark:border-stone-700"
|
||||
role="search"
|
||||
aria-label="Märkte suchen"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Input name="q" label="Suchbegriff" placeholder="z.B. Ritterturnier" value={q} />
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="plz" class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>PLZ / Standort</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
{#if hasGPSLocation}
|
||||
<div
|
||||
class="border-primary-300 bg-primary-50 text-primary-800 dark:border-primary-700 dark:bg-primary-950 dark:text-primary-200 flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="truncate">{locationName}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearLocation}
|
||||
class="text-primary-600 hover:bg-primary-100 hover:text-primary-800 dark:text-primary-400 dark:hover:bg-primary-900 dark:hover:text-primary-200 ml-auto shrink-0 rounded p-0.5"
|
||||
aria-label="Standort entfernen"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<input id="plz" name="plz" type="text" placeholder="z.B. 80331" value={plz} />
|
||||
<button
|
||||
type="button"
|
||||
onclick={useGPS}
|
||||
disabled={locating}
|
||||
class="bg-vellum focus-visible:ring-primary-500 shrink-0 rounded-lg border border-stone-300 px-3 py-2 text-sm text-stone-600 transition-colors hover:bg-stone-100 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-300 dark:hover:bg-stone-700"
|
||||
aria-label="Meinen Standort verwenden"
|
||||
>
|
||||
{#if locating}
|
||||
<svg
|
||||
class="h-5 w-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if locationError}
|
||||
<p class="text-danger-600 dark:text-danger-400 text-sm" role="alert" aria-live="assertive">
|
||||
{locationError}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Select name="radius" label="Umkreis" options={radiusOptions} value={String(radius)} />
|
||||
<Select name="sort" label="Sortierung" options={sortOptions} value={sort} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Input name="from" label="Von" type="date" value={from} />
|
||||
<Input name="to" label="Bis" type="date" value={to} />
|
||||
<div class="flex items-end lg:col-span-2">
|
||||
<Button type="submit" class="w-full sm:w-auto">Suchen</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if currentLat !== undefined && currentLon !== undefined}
|
||||
<input type="hidden" name="lat" value={currentLat} />
|
||||
<input type="hidden" name="lon" value={currentLon} />
|
||||
{/if}
|
||||
</form>
|
||||
24
web/src/lib/components/ui/Alert.svelte
Normal file
24
web/src/lib/components/ui/Alert.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'info' | 'success' | 'warning' | 'error';
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { variant = 'info', children }: Props = $props();
|
||||
|
||||
const styles = {
|
||||
info: 'bg-blue-50 text-blue-800 border-blue-200 dark:bg-blue-950 dark:text-blue-200 dark:border-blue-800',
|
||||
success:
|
||||
'bg-green-50 text-green-800 border-green-200 dark:bg-green-950 dark:text-green-200 dark:border-green-800',
|
||||
warning:
|
||||
'bg-amber-50 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-200 dark:border-amber-800',
|
||||
error:
|
||||
'bg-danger-50 text-danger-800 border-danger-200 dark:bg-danger-950 dark:text-danger-200 dark:border-danger-800'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border p-4 text-sm {styles[variant]}" role="alert">
|
||||
{@render children()}
|
||||
</div>
|
||||
64
web/src/lib/components/ui/Button.svelte
Normal file
64
web/src/lib/components/ui/Button.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
children,
|
||||
class: className = '',
|
||||
disabled,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary:
|
||||
'bg-primary-700 text-white border border-primary-800 hover:bg-primary-800 focus-visible:ring-primary-500 dark:bg-primary-600 dark:border-primary-700 dark:hover:bg-primary-700',
|
||||
secondary:
|
||||
'bg-vellum text-stone-700 border border-stone-300 hover:bg-stone-100 focus-visible:ring-primary-500 dark:text-stone-200 dark:border-stone-600 dark:bg-stone-800 dark:hover:bg-stone-700',
|
||||
danger:
|
||||
'bg-danger-600 text-white border border-danger-700 hover:bg-danger-700 focus-visible:ring-danger-500 dark:bg-danger-500 dark:border-danger-600 dark:hover:bg-danger-600',
|
||||
ghost:
|
||||
'text-stone-600 hover:text-stone-900 hover:bg-stone-100 focus-visible:ring-primary-500 dark:text-stone-300 dark:hover:text-stone-100 dark:hover:bg-stone-700'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base'
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="{base} {variants[variant]} {sizes[size]} {className}"
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading ? 'true' : undefined}
|
||||
{...rest}
|
||||
>
|
||||
{#if loading}
|
||||
<svg
|
||||
class="mr-2 -ml-1 h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</button>
|
||||
31
web/src/lib/components/ui/Input.svelte
Normal file
31
web/src/lib/components/ui/Input.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLInputAttributes {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let { label, error, id, class: className = '', ...rest }: Props = $props();
|
||||
|
||||
const inputId = $derived(id ?? label?.toLowerCase().replace(/\s+/g, '-'));
|
||||
const errorId = $derived(error ? `${inputId}-error` : undefined);
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
{#if label}
|
||||
<label for={inputId} class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>{label}</label
|
||||
>
|
||||
{/if}
|
||||
<input
|
||||
id={inputId}
|
||||
class={className}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={errorId}
|
||||
{...rest}
|
||||
/>
|
||||
{#if error}
|
||||
<p id={errorId} class="text-danger-600 dark:text-danger-400 text-sm" role="alert">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
266
web/src/lib/components/ui/Select.svelte
Normal file
266
web/src/lib/components/ui/Select.svelte
Normal file
@@ -0,0 +1,266 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: Option[];
|
||||
name?: string;
|
||||
value?: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
options,
|
||||
name,
|
||||
value = $bindable(''),
|
||||
label,
|
||||
placeholder = 'Auswählen...',
|
||||
error,
|
||||
id
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let activeIndex = $state(-1);
|
||||
let triggerEl = $state<HTMLButtonElement | null>(null);
|
||||
let listboxEl = $state<HTMLUListElement | null>(null);
|
||||
let typeBuffer = $state('');
|
||||
let typeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const selectId = $derived(id ?? name ?? label?.toLowerCase().replace(/\s+/g, '-'));
|
||||
const listboxId = $derived(`${selectId}-listbox`);
|
||||
const errorId = $derived(error ? `${selectId}-error` : undefined);
|
||||
const selectedLabel = $derived(options.find((o) => o.value === value)?.label ?? '');
|
||||
|
||||
function toggle() {
|
||||
if (open) {
|
||||
close();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
function openDropdown() {
|
||||
open = true;
|
||||
activeIndex = options.findIndex((o) => o.value === value);
|
||||
if (activeIndex < 0) activeIndex = 0;
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
activeIndex = -1;
|
||||
triggerEl?.focus();
|
||||
}
|
||||
|
||||
function select(opt: Option) {
|
||||
value = opt.value;
|
||||
close();
|
||||
}
|
||||
|
||||
function optionId(index: number) {
|
||||
return `${selectId}-opt-${index}`;
|
||||
}
|
||||
|
||||
async function scrollToActive() {
|
||||
await tick();
|
||||
const el = listboxEl?.querySelector(`[data-index="${activeIndex}"]`);
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function typeAhead(char: string) {
|
||||
clearTimeout(typeTimer);
|
||||
typeBuffer += char.toLowerCase();
|
||||
typeTimer = setTimeout(() => (typeBuffer = ''), 500);
|
||||
|
||||
const match = options.findIndex((o) => o.label.toLowerCase().startsWith(typeBuffer));
|
||||
if (match >= 0) {
|
||||
activeIndex = match;
|
||||
scrollToActive();
|
||||
}
|
||||
}
|
||||
|
||||
function onTriggerKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case 'ArrowUp':
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
if (!open) openDropdown();
|
||||
break;
|
||||
case 'Escape':
|
||||
if (open) {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onListboxKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % options.length;
|
||||
scrollToActive();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + options.length) % options.length;
|
||||
scrollToActive();
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
activeIndex = 0;
|
||||
scrollToActive();
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
activeIndex = options.length - 1;
|
||||
scrollToActive();
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0) select(options[activeIndex]);
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
close();
|
||||
break;
|
||||
case 'Tab':
|
||||
close();
|
||||
break;
|
||||
default:
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
typeAhead(e.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (!triggerEl?.contains(target) && !listboxEl?.contains(target)) {
|
||||
open = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('click', onClickOutside, true);
|
||||
tick().then(() => listboxEl?.focus());
|
||||
} else {
|
||||
document.removeEventListener('click', onClickOutside, true);
|
||||
}
|
||||
return () => document.removeEventListener('click', onClickOutside, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative space-y-1">
|
||||
{#if label}
|
||||
<label for={selectId} class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>{label}</label
|
||||
>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
type="button"
|
||||
id={selectId}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined}
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-describedby={errorId}
|
||||
onclick={toggle}
|
||||
onkeydown={onTriggerKeydown}
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 flex w-full items-center justify-between rounded-lg border border-stone-300 px-3 py-2 text-left text-sm
|
||||
shadow-sm transition-colors focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800
|
||||
{error ? 'border-danger-400 dark:border-danger-500' : ''}
|
||||
{selectedLabel ? 'text-stone-900 dark:text-stone-100' : 'text-stone-400 dark:text-stone-500'}"
|
||||
>
|
||||
<span class="truncate">{selectedLabel || placeholder}</span>
|
||||
<svg
|
||||
class="ml-2 h-4 w-4 shrink-0 text-stone-400 transition-transform dark:text-stone-500 {open
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<ul
|
||||
bind:this={listboxEl}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
onkeydown={onListboxKeydown}
|
||||
class="bg-vellum absolute z-40 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-stone-200 py-1 text-sm shadow-lg focus:outline-none dark:border-stone-600 dark:bg-stone-800"
|
||||
>
|
||||
{#each options as opt, i}
|
||||
<li
|
||||
id={optionId(i)}
|
||||
role="option"
|
||||
data-index={i}
|
||||
aria-selected={opt.value === value}
|
||||
onmousedown={(e: MouseEvent) => {
|
||||
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'
|
||||
: ''}"
|
||||
>
|
||||
<span class="flex items-center justify-between">
|
||||
{opt.label}
|
||||
{#if opt.value === value}
|
||||
<svg
|
||||
class="text-primary-600 dark:text-primary-400 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if name}
|
||||
<input type="hidden" {name} {value} />
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p id={errorId} class="text-danger-600 dark:text-danger-400 text-sm" role="alert">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
26
web/src/lib/components/ui/Spinner.svelte
Normal file
26
web/src/lib/components/ui/Spinner.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
let { size = 'md' }: Props = $props();
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12'
|
||||
};
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="text-primary-600 animate-spin {sizes[size]}"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
role="status"
|
||||
aria-label="Laden..."
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
75
web/src/lib/components/ui/ThemeToggle.svelte
Normal file
75
web/src/lib/components/ui/ThemeToggle.svelte
Normal file
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { theme, type ThemeMode } from '$lib/theme.js';
|
||||
|
||||
const modes: ThemeMode[] = ['system', 'light', 'dark'];
|
||||
const labels: Record<ThemeMode, string> = {
|
||||
system: 'System',
|
||||
light: 'Hell',
|
||||
dark: 'Dunkel'
|
||||
};
|
||||
|
||||
function cycle() {
|
||||
theme.update((current) => {
|
||||
const i = modes.indexOf(current);
|
||||
return modes[(i + 1) % modes.length];
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={cycle}
|
||||
class="text-primary-300 hover:bg-primary-800 hover:text-primary-100 focus-visible:ring-primary-400 rounded-lg p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
title="Farbschema: {labels[$theme]}"
|
||||
aria-label="Farbschema wechseln, aktuell: {labels[$theme]}"
|
||||
>
|
||||
{#if $theme === 'light'}
|
||||
<!-- Sun -->
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if $theme === 'dark'}
|
||||
<!-- Moon -->
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Monitor / system -->
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
1
web/src/lib/index.ts
Normal file
1
web/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
42
web/src/lib/theme.ts
Normal file
42
web/src/lib/theme.ts
Normal file
@@ -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<ThemeMode>(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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
47
web/src/lib/utils/slug.ts
Normal file
47
web/src/lib/utils/slug.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
const UMLAUT_MAP: Record<string, string> = {
|
||||
ä: '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<string, string> = {
|
||||
'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];
|
||||
}
|
||||
27
web/src/routes/+error.svelte
Normal file
27
web/src/routes/+error.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Fehler {$page.status} - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto flex max-w-lg flex-col items-center justify-center px-4 py-24 text-center">
|
||||
<h1 class="text-6xl font-bold text-stone-300 dark:text-stone-600">{$page.status}</h1>
|
||||
<p class="mt-4 text-lg text-stone-600 dark:text-stone-300">
|
||||
{#if $page.status === 404}
|
||||
Die gesuchte Seite wurde nicht gefunden.
|
||||
{:else}
|
||||
Ein Fehler ist aufgetreten.
|
||||
{/if}
|
||||
</p>
|
||||
{#if $page.error?.message}
|
||||
<p class="mt-2 text-sm text-stone-500 dark:text-stone-400">{$page.error.message}</p>
|
||||
{/if}
|
||||
<div class="mt-8">
|
||||
<a href="/">
|
||||
<Button variant="primary">Zur Startseite</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
7
web/src/routes/+layout.server.ts
Normal file
7
web/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types.js';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
47
web/src/routes/+layout.svelte
Normal file
47
web/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Header from '$lib/components/layout/Header.svelte';
|
||||
import Footer from '$lib/components/layout/Footer.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { updated } from '$app/stores';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
data: { user: import('$lib/api/types.js').ProfileData | null };
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { data, children }: Props = $props();
|
||||
|
||||
const canonicalUrl = $derived(`https://marktvogt.de${$page.url.pathname}`);
|
||||
|
||||
beforeNavigate(({ willUnload, to }) => {
|
||||
if ($updated && !willUnload && to?.url) {
|
||||
location.href = to.url.href;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Marktvogt - Mittelaltermärkte finden</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Finde Mittelaltermärkte, Ritterturniere und historische Feste in deiner Nähe."
|
||||
/>
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
<meta property="og:site_name" content="Marktvogt" />
|
||||
<meta property="og:locale" content="de_DE" />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
</svelte:head>
|
||||
|
||||
<a href="#main-content" class="skip-link">Zum Inhalt springen</a>
|
||||
|
||||
<div class="bg-parchment flex min-h-screen flex-col">
|
||||
<Header user={data.user} />
|
||||
<main id="main-content" class="flex-1" tabindex="-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
59
web/src/routes/+page.server.ts
Normal file
59
web/src/routes/+page.server.ts
Normal file
@@ -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<string, string> = {};
|
||||
|
||||
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<MarketSummary[]>(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 ?? ''
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
124
web/src/routes/+page.svelte
Normal file
124
web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import SearchForm from '$lib/components/market/SearchForm.svelte';
|
||||
import MarketCard from '$lib/components/market/MarketCard.svelte';
|
||||
import MarketMap from '$lib/components/market/MarketMap.svelte';
|
||||
import Pagination from '$lib/components/market/Pagination.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let view = $state<'list' | 'map'>('list');
|
||||
|
||||
const jsonLdHtml =
|
||||
'<script type="application/ld+json">' +
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Marktvogt',
|
||||
url: 'https://marktvogt.de',
|
||||
description:
|
||||
'Verzeichnis für Mittelaltermärkte, Ritterturniere und historische Feste in Deutschland',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: 'https://marktvogt.de/?q={search_term_string}',
|
||||
'query-input': 'required name=search_term_string'
|
||||
}
|
||||
}) +
|
||||
'</' +
|
||||
'script>';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Marktvogt - Mittelaltermärkte finden</title>
|
||||
<meta property="og:title" content="Marktvogt - Mittelaltermärkte finden" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Finde Mittelaltermärkte, Ritterturniere und historische Feste in deiner Nähe. Suche nach Ort, Datum oder Stichwort."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
{@html jsonLdHtml}
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-bold text-stone-900 sm:text-4xl dark:text-stone-100">
|
||||
Mittelaltermärkte finden
|
||||
</h1>
|
||||
<p class="mt-2 text-lg text-stone-600 dark:text-stone-300">
|
||||
Entdecke Mittelaltermärkte, Ritterturniere und historische Feste in deiner Nähe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchForm
|
||||
q={data.searchParams.q}
|
||||
radius={data.searchParams.radius}
|
||||
from={data.searchParams.from}
|
||||
to={data.searchParams.to}
|
||||
sort={data.searchParams.sort}
|
||||
lat={data.searchParams.lat}
|
||||
lon={data.searchParams.lon}
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<p class="text-sm text-stone-500 dark:text-stone-400">
|
||||
{data.meta.total}
|
||||
{data.meta.total === 1 ? 'Markt' : 'Märkte'} gefunden
|
||||
</p>
|
||||
<div
|
||||
class="bg-vellum flex gap-1 rounded-lg border border-stone-200 p-1 dark:border-stone-700"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {view === 'list'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300'
|
||||
: 'text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200'}"
|
||||
onclick={() => (view = 'list')}
|
||||
>
|
||||
Liste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {view === 'map'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300'
|
||||
: 'text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200'}"
|
||||
onclick={() => (view = 'map')}
|
||||
>
|
||||
Karte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if view === 'list'}
|
||||
{#if data.markets.length > 0}
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each data.markets as market (market.id)}
|
||||
<MarketCard {market} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-vellum rounded-lg border border-stone-200 py-16 text-center dark:border-stone-700"
|
||||
>
|
||||
<p class="text-stone-500 dark:text-stone-400">
|
||||
Keine Märkte gefunden. Versuche andere Suchkriterien.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<MarketMap markets={data.markets} />
|
||||
{/if}
|
||||
|
||||
{#if data.meta.total_pages > 1}
|
||||
<div class="mt-8">
|
||||
<Pagination
|
||||
meta={data.meta}
|
||||
baseUrl="/?{new URLSearchParams(
|
||||
Object.entries(data.searchParams)
|
||||
.filter(([, v]) => v !== undefined && v !== '')
|
||||
.map(([k, v]) => [k, String(v)])
|
||||
).toString()}"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
6
web/src/routes/admin/+layout.server.ts
Normal file
6
web/src/routes/admin/+layout.server.ts
Normal file
@@ -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);
|
||||
};
|
||||
47
web/src/routes/admin/+layout.svelte
Normal file
47
web/src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
const navItems = [{ href: '/admin/maerkte', label: 'Märkte' }];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return $page.url.pathname.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Admin - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col gap-6 md:flex-row">
|
||||
<nav class="w-full shrink-0 md:w-48">
|
||||
<h2 class="mb-3 text-sm font-semibold text-stone-500 uppercase dark:text-stone-400">Admin</h2>
|
||||
<ul class="flex gap-1 md:flex-col">
|
||||
{#each navItems as item}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class="block rounded-lg px-3 py-2 text-sm font-medium transition-colors
|
||||
{isActive(item.href)
|
||||
? 'bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200'
|
||||
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-800'}"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
31
web/src/routes/admin/maerkte/+page.server.ts
Normal file
31
web/src/routes/admin/maerkte/+page.server.ts
Normal file
@@ -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<AdminSeriesGroup[]>(`/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;
|
||||
}
|
||||
};
|
||||
336
web/src/routes/admin/maerkte/+page.svelte
Normal file
336
web/src/routes/admin/maerkte/+page.svelte
Normal file
@@ -0,0 +1,336 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import type { EditionStatus } from '$lib/api/types.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let searchValue = $state(data.filters.q);
|
||||
let expandedSeries = $state(new Set<string>());
|
||||
|
||||
const statusLabels: Record<EditionStatus, string> = {
|
||||
rumored: 'Ausstehend',
|
||||
confirmed: 'Bestätigt',
|
||||
active: 'Aktiv',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Abgesagt',
|
||||
archived: 'Archiviert'
|
||||
};
|
||||
|
||||
const statusColors: Record<EditionStatus, string> = {
|
||||
rumored: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
|
||||
confirmed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
active: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
completed: 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400',
|
||||
cancelled: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200',
|
||||
archived: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-500'
|
||||
};
|
||||
|
||||
const tabs: { label: string; value: string }[] = [
|
||||
{ label: 'Alle', value: '' },
|
||||
{ label: 'Ausstehend', value: 'rumored' },
|
||||
{ label: 'Bestätigt', value: 'confirmed' },
|
||||
{ label: 'Aktiv', value: 'active' },
|
||||
{ label: 'Abgeschlossen', value: 'completed' },
|
||||
{ label: 'Abgesagt', value: 'cancelled' }
|
||||
];
|
||||
|
||||
const currentStatus = $derived(page.url.searchParams.get('status') ?? '');
|
||||
const currentQ = $derived(page.url.searchParams.get('q') ?? '');
|
||||
const currentSort = $derived(page.url.searchParams.get('sort') ?? '');
|
||||
const currentOrder = $derived(page.url.searchParams.get('order') ?? '');
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
function buildUrl(overrides: Record<string, string> = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
const status = overrides.status ?? currentStatus;
|
||||
const q = overrides.q ?? currentQ;
|
||||
const sort = overrides.sort ?? currentSort;
|
||||
const order = overrides.order ?? currentOrder;
|
||||
const pg = overrides.page ?? '1';
|
||||
|
||||
if (status) params.set('status', status);
|
||||
if (q) params.set('q', q);
|
||||
if (sort) params.set('sort', sort);
|
||||
if (order) params.set('order', order);
|
||||
if (pg !== '1') params.set('page', pg);
|
||||
|
||||
const qs = params.toString();
|
||||
return `/admin/maerkte${qs ? `?${qs}` : ''}`;
|
||||
}
|
||||
|
||||
function sortUrl(column: string): string {
|
||||
const isActive = currentSort === column;
|
||||
const defaultAsc = column === 'name' || column === 'city';
|
||||
if (isActive) {
|
||||
const newOrder = currentOrder === 'asc' ? 'desc' : 'asc';
|
||||
return buildUrl({ sort: column, order: newOrder });
|
||||
}
|
||||
return buildUrl({ sort: column, order: defaultAsc ? 'asc' : 'desc' });
|
||||
}
|
||||
|
||||
function sortIndicator(column: string): string {
|
||||
if (currentSort !== column) return '';
|
||||
return currentOrder === 'asc' ? ' \u2191' : ' \u2193';
|
||||
}
|
||||
|
||||
function handleSearch(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
goto(buildUrl({ q: searchValue }));
|
||||
}
|
||||
|
||||
function toggleExpand(seriesId: string) {
|
||||
const next = new Set(expandedSeries);
|
||||
if (next.has(seriesId)) {
|
||||
next.delete(seriesId);
|
||||
} else {
|
||||
next.add(seriesId);
|
||||
}
|
||||
expandedSeries = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Märkte verwalten - Admin - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="text-2xl font-bold">Märkte</h1>
|
||||
<a href="/admin/maerkte/neu">
|
||||
<Button size="sm">Neuer Markt</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Search + status filters -->
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<form onsubmit={handleSearch} class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchValue}
|
||||
placeholder="Name oder Stadt suchen..."
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 rounded-lg border border-stone-300 px-3 py-1.5
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" size="sm">Suchen</Button>
|
||||
{#if currentQ}
|
||||
<a href={buildUrl({ q: '' })}>
|
||||
<Button type="button" variant="secondary" size="sm">Zurücksetzen</Button>
|
||||
</a>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Status filter tabs -->
|
||||
<div class="flex gap-1 rounded-lg bg-stone-100 p-1 dark:bg-stone-800">
|
||||
{#each tabs as tab}
|
||||
<a
|
||||
href={buildUrl({ status: tab.value })}
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors
|
||||
{currentStatus === tab.value
|
||||
? 'bg-white text-stone-900 shadow-sm dark:bg-stone-700 dark:text-stone-100'
|
||||
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200'}"
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Market table (grouped by series) -->
|
||||
<div class="overflow-x-auto rounded-lg border border-stone-200 dark:border-stone-700">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-stone-50 dark:bg-stone-800">
|
||||
<tr>
|
||||
<th class="w-8 px-2 py-3"></th>
|
||||
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">
|
||||
<a href={sortUrl('name')} class="hover:text-stone-900 dark:hover:text-stone-100">
|
||||
Name{sortIndicator('name')}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">
|
||||
<a href={sortUrl('city')} class="hover:text-stone-900 dark:hover:text-stone-100">
|
||||
Stadt{sortIndicator('city')}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">
|
||||
<a href={sortUrl('status')} class="hover:text-stone-900 dark:hover:text-stone-100">
|
||||
Status{sortIndicator('status')}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300"> Jahr </th>
|
||||
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">
|
||||
<a href={sortUrl('date')} class="hover:text-stone-900 dark:hover:text-stone-100">
|
||||
Zeitraum{sortIndicator('date')}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">
|
||||
<a href={sortUrl('created')} class="hover:text-stone-900 dark:hover:text-stone-100">
|
||||
Erstellt{sortIndicator('created')}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-stone-200 dark:divide-stone-700">
|
||||
{#each data.groups as group}
|
||||
{@const latest = group.editions[0]}
|
||||
{@const hasMultiple = group.editions.length > 1}
|
||||
{@const isExpanded = expandedSeries.has(group.series_id)}
|
||||
<!-- Primary row (latest edition) -->
|
||||
<tr class="hover:bg-stone-50 dark:hover:bg-stone-800/50">
|
||||
<td class="px-2 py-3 text-center">
|
||||
{#if hasMultiple}
|
||||
<button
|
||||
onclick={() => toggleExpand(group.series_id)}
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded text-stone-400 transition-colors hover:bg-stone-200 hover:text-stone-600 dark:hover:bg-stone-700 dark:hover:text-stone-300"
|
||||
aria-label={isExpanded ? 'Zuklappen' : 'Aufklappen'}
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 transition-transform {isExpanded ? 'rotate-90' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-medium text-stone-900 dark:text-stone-100">
|
||||
{latest.name}
|
||||
{#if hasMultiple}
|
||||
<span class="text-primary-600 dark:text-primary-400 ml-1.5 text-xs font-normal">
|
||||
({group.editions.length} Ausgaben)
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600 dark:text-stone-400">{latest.city}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusColors[
|
||||
latest.status
|
||||
]}"
|
||||
>
|
||||
{statusLabels[latest.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-stone-600 dark:text-stone-400">
|
||||
{latest.year}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-stone-600 dark:text-stone-400">
|
||||
{formatDate(latest.start_date)} - {formatDate(latest.end_date)}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-stone-600 dark:text-stone-400">
|
||||
{formatDate(latest.created_at)}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href="/admin/maerkte/{latest.id}"
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
|
||||
>
|
||||
Ansehen
|
||||
</a>
|
||||
<a
|
||||
href="/admin/maerkte/{latest.id}/bearbeiten"
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
|
||||
>
|
||||
Bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Expanded sub-rows (older editions) -->
|
||||
{#if hasMultiple && isExpanded}
|
||||
{#each group.editions.slice(1) as edition}
|
||||
<tr
|
||||
class="bg-stone-50/50 hover:bg-stone-100 dark:bg-stone-800/30 dark:hover:bg-stone-800/50"
|
||||
>
|
||||
<td class="px-2 py-2"></td>
|
||||
<td class="px-4 py-2 pl-8 text-stone-600 dark:text-stone-400">
|
||||
{edition.name}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-stone-500 dark:text-stone-500">
|
||||
{edition.city}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusColors[
|
||||
edition.status
|
||||
]}"
|
||||
>
|
||||
{statusLabels[edition.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-stone-500 dark:text-stone-500">
|
||||
{edition.year}
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-stone-500 dark:text-stone-500">
|
||||
{formatDate(edition.start_date)} - {formatDate(edition.end_date)}
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-stone-500 dark:text-stone-500">
|
||||
{formatDate(edition.created_at)}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href="/admin/maerkte/{edition.id}"
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
|
||||
>
|
||||
Ansehen
|
||||
</a>
|
||||
<a
|
||||
href="/admin/maerkte/{edition.id}/bearbeiten"
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
|
||||
>
|
||||
Bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-stone-500 dark:text-stone-400">
|
||||
Keine Märkte gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if data.meta && data.meta.total_pages > 1}
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-stone-600 dark:text-stone-400">
|
||||
Seite {data.meta.page} von {data.meta.total_pages} ({data.meta.total} Serien)
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{#if data.meta.page > 1}
|
||||
<a href={buildUrl({ page: String(data.meta.page - 1) })}>
|
||||
<Button variant="secondary" size="sm">Zurück</Button>
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.meta.page < data.meta.total_pages}
|
||||
<a href={buildUrl({ page: String(data.meta.page + 1) })}>
|
||||
<Button variant="secondary" size="sm">Weiter</Button>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
114
web/src/routes/admin/maerkte/[id]/+page.server.ts
Normal file
114
web/src/routes/admin/maerkte/[id]/+page.server.ts
Normal file
@@ -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<AdminMarketDetail>(`/admin/markets/${params.id}`, cookies);
|
||||
const market = res.data;
|
||||
|
||||
let duplicates: DuplicateMarket[] = [];
|
||||
if (market.status === 'rumored') {
|
||||
try {
|
||||
const dupRes = await serverFetch<DuplicateMarket[]>(
|
||||
`/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<SeriesEditionsResponse>(
|
||||
`/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<AdminMarketDetail>(
|
||||
`/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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
455
web/src/routes/admin/maerkte/[id]/+page.svelte
Normal file
455
web/src/routes/admin/maerkte/[id]/+page.svelte
Normal file
@@ -0,0 +1,455 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import type { EditionStatus } from '$lib/api/types.js';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let showNewEdition = $state(false);
|
||||
|
||||
const statusLabels: Record<EditionStatus, string> = {
|
||||
rumored: 'Ausstehend',
|
||||
confirmed: 'Bestätigt',
|
||||
active: 'Aktiv',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Abgesagt',
|
||||
archived: 'Archiviert'
|
||||
};
|
||||
|
||||
const statusColors: Record<EditionStatus, string> = {
|
||||
rumored: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
|
||||
confirmed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
active: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
completed: 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400',
|
||||
cancelled: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200',
|
||||
archived: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-500'
|
||||
};
|
||||
|
||||
const chfCountries = new Set(['CH', 'LI']);
|
||||
const currency = $derived(chfCountries.has(data.market.country) ? 'CHF' : 'EUR');
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
return (cents / 100).toFixed(2) + ' ' + currency;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '-';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
const isReviewable = $derived(data.market.status === 'rumored');
|
||||
|
||||
$effect(() => {
|
||||
if (form?.deleted) {
|
||||
goto('/admin/maerkte');
|
||||
}
|
||||
if (form?.created && form?.newEditionId) {
|
||||
goto(`/admin/maerkte/${form.newEditionId}`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.market.name} - Admin - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<a
|
||||
href="/admin/maerkte"
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm"
|
||||
>
|
||||
← Zurück zur Liste
|
||||
</a>
|
||||
<h1 class="mt-1 text-2xl font-bold">{data.market.name}</h1>
|
||||
<p class="mt-0.5 text-sm text-stone-500 dark:text-stone-400">
|
||||
Edition {data.market.year}
|
||||
{#if data.market.series_name !== data.market.name}
|
||||
· Serie: {data.market.series_name}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if data.editions && data.editions.length > 1}
|
||||
<details class="relative">
|
||||
<summary
|
||||
class="cursor-pointer rounded-lg border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-50 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-800"
|
||||
>
|
||||
{data.editions.length} Editionen
|
||||
</summary>
|
||||
<div
|
||||
class="absolute right-0 z-10 mt-1 min-w-48 rounded-lg border border-stone-200 bg-white p-2 shadow-lg dark:border-stone-700 dark:bg-stone-900"
|
||||
>
|
||||
{#each data.editions as ed}
|
||||
<a
|
||||
href="/admin/maerkte/{ed.id}"
|
||||
class="block rounded px-3 py-1.5 text-sm hover:bg-stone-100 dark:hover:bg-stone-800
|
||||
{ed.id === data.market.id
|
||||
? 'text-primary-600 dark:text-primary-400 font-semibold'
|
||||
: 'text-stone-700 dark:text-stone-300'}"
|
||||
>
|
||||
{ed.year}
|
||||
<span class="ml-1 rounded-full px-1.5 py-0.5 text-xs {statusColors[ed.status]}">
|
||||
{statusLabels[ed.status]}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-50 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-800"
|
||||
onclick={() => (showNewEdition = !showNewEdition)}
|
||||
>
|
||||
+ Neue Edition
|
||||
</button>
|
||||
<a href="/admin/maerkte/{data.market.id}/bearbeiten">
|
||||
<Button variant="secondary" size="sm">Bearbeiten</Button>
|
||||
</a>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={(e) => {
|
||||
if (!confirm('Markt wirklich löschen?')) {
|
||||
e.cancel();
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button variant="danger" size="sm" type="submit" {loading}>Löschen</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showNewEdition}
|
||||
<div
|
||||
class="border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-950 rounded-lg border p-4"
|
||||
>
|
||||
<h3 class="text-primary-800 dark:text-primary-200 mb-3 text-sm font-semibold">
|
||||
Neue Edition für "{data.market.series_name || data.market.name}"
|
||||
</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createEdition"
|
||||
class="flex flex-wrap items-end gap-3"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="series_id" value={data.market.series_id} />
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="new_start_date"
|
||||
class="block text-xs font-medium text-stone-700 dark:text-stone-300"
|
||||
>
|
||||
Startdatum
|
||||
</label>
|
||||
<input
|
||||
id="new_start_date"
|
||||
type="date"
|
||||
name="start_date"
|
||||
required
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 rounded-lg border border-stone-300 px-3 py-1.5
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="new_end_date"
|
||||
class="block text-xs font-medium text-stone-700 dark:text-stone-300"
|
||||
>
|
||||
Enddatum
|
||||
</label>
|
||||
<input
|
||||
id="new_end_date"
|
||||
type="date"
|
||||
name="end_date"
|
||||
required
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 rounded-lg border border-stone-300 px-3 py-1.5
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm" {loading}>Anlegen</Button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200"
|
||||
onclick={() => (showNewEdition = false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<Alert variant="error">{form.error}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<Alert variant="success">
|
||||
Status erfolgreich auf "{form.action === 'approved' ? 'Bestätigt' : 'Abgesagt'}" geändert.
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if data.duplicates && data.duplicates.length > 0}
|
||||
<div
|
||||
class="rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-950"
|
||||
>
|
||||
<h3 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
|
||||
Mögliche Duplikate gefunden
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
{#each data.duplicates as dup}
|
||||
<li class="text-sm text-amber-700 dark:text-amber-300">
|
||||
<a href="/admin/maerkte/{dup.id}" class="font-medium hover:underline">
|
||||
{dup.name}
|
||||
</a>
|
||||
— {dup.city}, {formatDate(dup.start_date)} - {formatDate(dup.end_date)}
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">
|
||||
({Math.round(dup.similarity * 100)}% Ähnlichkeit)
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Status badge + review section -->
|
||||
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-stone-600 dark:text-stone-400">Status:</span>
|
||||
<span
|
||||
class="inline-flex rounded-full px-3 py-1 text-sm font-medium {statusColors[
|
||||
data.market.status
|
||||
]}"
|
||||
>
|
||||
{statusLabels[data.market.status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if isReviewable}
|
||||
<div class="mt-4 border-t border-stone-200 pt-4 dark:border-stone-700">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateStatus"
|
||||
class="space-y-4"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admin_notes"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>
|
||||
Admin-Notizen
|
||||
</label>
|
||||
<textarea
|
||||
id="admin_notes"
|
||||
name="admin_notes"
|
||||
rows="3"
|
||||
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"
|
||||
placeholder="Optionale Notizen zur Entscheidung..."
|
||||
>{data.market.admin_notes}</textarea
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit" name="status" value="approved" {loading}>Genehmigen</Button>
|
||||
<Button type="submit" name="status" value="rejected" variant="danger" {loading}>
|
||||
Ablehnen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
{#if data.market.admin_notes}
|
||||
<p class="mt-2 text-sm text-stone-600 dark:text-stone-400">
|
||||
<span class="font-medium">Notizen:</span>
|
||||
{data.market.admin_notes}
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.market.reviewed_at}
|
||||
<p class="mt-1 text-sm text-stone-500 dark:text-stone-500">
|
||||
Geprüft am {formatDate(data.market.reviewed_at)}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Market details -->
|
||||
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
|
||||
<h2 class="mb-4 text-lg font-semibold">Details</h2>
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Beschreibung</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{data.market.description || '-'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Adresse</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{data.market.street ? `${data.market.street}, ` : ''}
|
||||
{data.market.zip}
|
||||
{data.market.city}, {data.market.state}
|
||||
{data.market.country}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Koordinaten</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{data.market.latitude?.toFixed(6) ?? '—'}, {data.market.longitude?.toFixed(6) ?? '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Zeitraum</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{formatDate(data.market.start_date)} - {formatDate(data.market.end_date)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Veranstalter</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{data.market.organizer_name || '-'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Website</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{#if data.market.website}
|
||||
<a
|
||||
href={data.market.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
{data.market.website}
|
||||
</a>
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Slug</dt>
|
||||
<dd class="mt-1 font-mono text-sm text-stone-900 dark:text-stone-100">
|
||||
{data.market.slug}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Opening hours -->
|
||||
{#if data.market.opening_hours && data.market.opening_hours.length > 0}
|
||||
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
|
||||
<h2 class="mb-4 text-lg font-semibold">Öffnungszeiten</h2>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-stone-500 dark:text-stone-400">
|
||||
<th class="pb-2 font-medium">Tag</th>
|
||||
<th class="pb-2 font-medium">Von</th>
|
||||
<th class="pb-2 font-medium">Bis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.market.opening_hours as entry}
|
||||
<tr class="border-t border-stone-100 dark:border-stone-700">
|
||||
<td class="py-1.5 text-stone-900 dark:text-stone-100">{entry.day}</td>
|
||||
<td class="py-1.5 text-stone-900 dark:text-stone-100">{entry.open}</td>
|
||||
<td class="py-1.5 text-stone-900 dark:text-stone-100">{entry.close}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Admission info -->
|
||||
{#if data.market.admission_info && (data.market.admission_info.adult_cents > 0 || data.market.admission_info.child_cents > 0 || data.market.admission_info.notes)}
|
||||
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
|
||||
<h2 class="mb-4 text-lg font-semibold">Eintrittspreise</h2>
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Erwachsene</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{formatPrice(data.market.admission_info.adult_cents)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Kinder</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{formatPrice(data.market.admission_info.child_cents)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Ermäßigt</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{formatPrice(data.market.admission_info.reduced_cents)}
|
||||
</dd>
|
||||
</div>
|
||||
{#if data.market.admission_info.free_under_age > 0}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">
|
||||
Frei unter (Alter)
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{data.market.admission_info.free_under_age} Jahre
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.market.admission_info.notes}
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Hinweise</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{data.market.admission_info.notes}
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submitter info -->
|
||||
{#if data.market.submitter_email || data.market.submitter_name}
|
||||
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
|
||||
<h2 class="mb-4 text-lg font-semibold">Eingereicht von</h2>
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Name</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{data.market.submitter_name || '-'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">E-Mail</dt>
|
||||
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
|
||||
{data.market.submitter_email || '-'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
84
web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts
Normal file
84
web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts
Normal file
@@ -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<AdminMarketDetail>(`/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<string, unknown> = {};
|
||||
|
||||
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<ResearchResult>(
|
||||
`/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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
133
web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte
Normal file
133
web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import MarketForm from '$lib/components/admin/MarketForm.svelte';
|
||||
import ResearchPanel from '$lib/components/admin/ResearchPanel.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import type {
|
||||
ResearchResult,
|
||||
FieldSuggestion,
|
||||
OpeningHoursEntry,
|
||||
AdmissionInfo
|
||||
} from '$lib/api/types.js';
|
||||
|
||||
let { data, form } = $props();
|
||||
let loading = $state(false);
|
||||
let researching = $state(false);
|
||||
let researchResult: ResearchResult | null = $state(null);
|
||||
let dismissed = $state(false);
|
||||
let marketForm: MarketForm;
|
||||
|
||||
$effect(() => {
|
||||
if (form?.research && !dismissed) {
|
||||
researchResult = form.research as ResearchResult;
|
||||
}
|
||||
});
|
||||
|
||||
const validDays = [
|
||||
'Sonntag',
|
||||
'Montag',
|
||||
'Dienstag',
|
||||
'Mittwoch',
|
||||
'Donnerstag',
|
||||
'Freitag',
|
||||
'Samstag'
|
||||
];
|
||||
|
||||
function normalizeDayName(day: string): string {
|
||||
const dateMatch = day.match(/(\d{2})\.(\d{2})\.(\d{4})/);
|
||||
if (dateMatch) {
|
||||
const d = new Date(+dateMatch[3], +dateMatch[2] - 1, +dateMatch[1]);
|
||||
return validDays[d.getDay()];
|
||||
}
|
||||
const match = validDays.find((d) => day.startsWith(d));
|
||||
return match ?? day;
|
||||
}
|
||||
|
||||
function applyResearch(suggestions: FieldSuggestion[]) {
|
||||
for (const s of suggestions) {
|
||||
if (s.field === 'opening_hours' && Array.isArray(s.suggested_value)) {
|
||||
const normalized = (s.suggested_value as OpeningHoursEntry[]).map((entry) => ({
|
||||
...entry,
|
||||
day: normalizeDayName(entry.day)
|
||||
}));
|
||||
marketForm.setHours(normalized);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
s.field === 'admission_info' &&
|
||||
typeof s.suggested_value === 'object' &&
|
||||
s.suggested_value !== null
|
||||
) {
|
||||
marketForm.setAdmission(s.suggested_value as AdmissionInfo);
|
||||
continue;
|
||||
}
|
||||
const el = document.querySelector<HTMLInputElement | HTMLTextAreaElement>(
|
||||
`[name="${s.field}"]`
|
||||
);
|
||||
if (el) {
|
||||
el.value =
|
||||
typeof s.suggested_value === 'string'
|
||||
? s.suggested_value
|
||||
: JSON.stringify(s.suggested_value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
researchResult = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.market.name} bearbeiten - Admin - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<a
|
||||
href="/admin/maerkte/{data.market.id}"
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm"
|
||||
>
|
||||
← Zurück zum Markt
|
||||
</a>
|
||||
<h1 class="mt-1 text-2xl font-bold">Markt bearbeiten</h1>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/research"
|
||||
use:enhance={() => {
|
||||
researching = true;
|
||||
return async ({ update }) => {
|
||||
researching = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="secondary" loading={researching}>Mit KI recherchieren</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#if researchResult}
|
||||
<ResearchPanel
|
||||
result={researchResult}
|
||||
onApply={applyResearch}
|
||||
onClose={() => {
|
||||
researchResult = null;
|
||||
dismissed = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/save"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<MarketForm bind:this={marketForm} market={data.market} {loading} error={form?.error} />
|
||||
</form>
|
||||
</div>
|
||||
56
web/src/routes/admin/maerkte/neu/+page.server.ts
Normal file
56
web/src/routes/admin/maerkte/neu/+page.server.ts
Normal file
@@ -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<string, unknown> = {
|
||||
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<AdminMarketDetail>('/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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
36
web/src/routes/admin/maerkte/neu/+page.svelte
Normal file
36
web/src/routes/admin/maerkte/neu/+page.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import MarketForm from '$lib/components/admin/MarketForm.svelte';
|
||||
|
||||
let { form } = $props();
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Neuer Markt - Admin - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<a
|
||||
href="/admin/maerkte"
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm"
|
||||
>
|
||||
← Zurück zur Liste
|
||||
</a>
|
||||
<h1 class="mt-1 text-2xl font-bold">Neuer Markt</h1>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<MarketForm {loading} error={form?.error} />
|
||||
</form>
|
||||
</div>
|
||||
19
web/src/routes/api/geocode/+server.ts
Normal file
19
web/src/routes/api/geocode/+server.ts
Normal file
@@ -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 });
|
||||
}
|
||||
};
|
||||
5
web/src/routes/auth/+layout.server.ts
Normal file
5
web/src/routes/auth/+layout.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = () => {
|
||||
return {};
|
||||
};
|
||||
25
web/src/routes/auth/abmelden/+page.server.ts
Normal file
25
web/src/routes/auth/abmelden/+page.server.ts
Normal file
@@ -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, '/');
|
||||
}
|
||||
};
|
||||
69
web/src/routes/auth/anmelden/+page.server.ts
Normal file
69
web/src/routes/auth/anmelden/+page.server.ts
Normal file
@@ -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<AuthData>('/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<MessageData>('/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.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
56
web/src/routes/auth/anmelden/+page.svelte
Normal file
56
web/src/routes/auth/anmelden/+page.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import LoginForm from '$lib/components/auth/LoginForm.svelte';
|
||||
import MagicLinkForm from '$lib/components/auth/MagicLinkForm.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
let tab = $state<'password' | 'magic'>('password');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Anmelden - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md px-4 py-16">
|
||||
<h1 class="mb-8 text-center text-2xl font-bold text-stone-900 dark:text-stone-100">Anmelden</h1>
|
||||
|
||||
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
|
||||
<div
|
||||
class="mb-6 flex gap-1 rounded-lg border border-stone-200 bg-stone-50 p-1 dark:border-stone-700 dark:bg-stone-800"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {tab === 'password'
|
||||
? 'bg-vellum text-stone-900 shadow-sm dark:bg-stone-700 dark:text-stone-100'
|
||||
: 'text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200'}"
|
||||
onclick={() => (tab = 'password')}
|
||||
>
|
||||
Passwort
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {tab === 'magic'
|
||||
? 'bg-vellum text-stone-900 shadow-sm dark:bg-stone-700 dark:text-stone-100'
|
||||
: 'text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200'}"
|
||||
onclick={() => (tab = 'magic')}
|
||||
>
|
||||
Magic Link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'password'}
|
||||
<LoginForm error={form?.error} requireTotp={form?.requireTotp ?? false} />
|
||||
<input type="hidden" name="redirect_to" value={data.redirectTo} form="login" />
|
||||
{:else}
|
||||
<MagicLinkForm error={form?.magicLinkError} success={form?.magicLinkSuccess} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
|
||||
Noch kein Konto?
|
||||
<a
|
||||
href="/auth/registrieren"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium"
|
||||
>Registrieren</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
28
web/src/routes/auth/magic-link/verify/+page.server.ts
Normal file
28
web/src/routes/auth/magic-link/verify/+page.server.ts
Normal file
@@ -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<AuthData>(`/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, '/');
|
||||
};
|
||||
22
web/src/routes/auth/oauth/callback/+page.server.ts
Normal file
22
web/src/routes/auth/oauth/callback/+page.server.ts
Normal file
@@ -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, '/');
|
||||
};
|
||||
39
web/src/routes/auth/registrieren/+page.server.ts
Normal file
39
web/src/routes/auth/registrieren/+page.server.ts
Normal file
@@ -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<AuthData>('/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, '/');
|
||||
}
|
||||
};
|
||||
28
web/src/routes/auth/registrieren/+page.svelte
Normal file
28
web/src/routes/auth/registrieren/+page.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import RegisterForm from '$lib/components/auth/RegisterForm.svelte';
|
||||
|
||||
let { form } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Registrieren - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md px-4 py-16">
|
||||
<h1 class="mb-8 text-center text-2xl font-bold text-stone-900 dark:text-stone-100">
|
||||
Konto erstellen
|
||||
</h1>
|
||||
|
||||
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
|
||||
<RegisterForm error={form?.error} />
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
|
||||
Bereits ein Konto?
|
||||
<a
|
||||
href="/auth/anmelden"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium"
|
||||
>Anmelden</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
435
web/src/routes/datenschutz/+page.svelte
Normal file
435
web/src/routes/datenschutz/+page.svelte
Normal file
@@ -0,0 +1,435 @@
|
||||
<svelte:head>
|
||||
<title>Datenschutzerklärung - Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Datenschutzerklärung für Marktvogt – Informationen zur Verarbeitung personenbezogener Daten."
|
||||
/>
|
||||
<meta property="og:title" content="Datenschutzerklärung - Marktvogt" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Datenschutzerklärung für Marktvogt – Informationen zur Verarbeitung personenbezogener Daten."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<h1 class="text-3xl font-bold text-stone-900 dark:text-stone-100">Datenschutzerklärung</h1>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">1. Verantwortlicher</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Christian Nachtigall<br />
|
||||
Karwendelstr. 21<br />
|
||||
82061 Neuried<br />
|
||||
E-Mail:
|
||||
<a
|
||||
href="mailto:christian@nachtigall.dev"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>christian@nachtigall.dev</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
2. Überblick der Verarbeitungen
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">3. Hosting</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Diese Website wird auf Infrastruktur von <strong>itsh.dev</strong> gehostet. Beim Aufruf unserer
|
||||
Website werden durch den Hostinganbieter automatisch Informationen in sogenannten Server-Logfiles
|
||||
erfasst. Dazu gehören:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>IP-Adresse des zugreifenden Geräts</li>
|
||||
<li>Datum und Uhrzeit der Anfrage</li>
|
||||
<li>HTTP-Methode und aufgerufene URL</li>
|
||||
<li>HTTP-Statuscode</li>
|
||||
<li>Antwortzeit des Servers</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
4. Registrierung und Benutzerkonto
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie können auf unserer Website ein Benutzerkonto erstellen. Dabei werden folgende Daten
|
||||
verarbeitet:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li><strong>E-Mail-Adresse</strong> – zur Identifikation und Kommunikation</li>
|
||||
<li>
|
||||
<strong>Passwort</strong> – wird ausschließlich als bcrypt-Hash gespeichert; das Klartext-Passwort
|
||||
wird nicht gespeichert
|
||||
</li>
|
||||
<li><strong>Anzeigename</strong> – frei wählbarer Name zur Darstellung im Profil</li>
|
||||
<li><strong>Profilbild-URL</strong> – sofern über einen OAuth-Anbieter bereitgestellt</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
5. Anmeldung über Drittanbieter (OAuth)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie können sich mit einem bestehenden Konto bei folgenden Anbietern anmelden:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Google</strong> – Abgerufene Daten: E-Mail-Adresse, Name, Profilbild, E-Mail-Verifizierungsstatus
|
||||
</li>
|
||||
<li>
|
||||
<strong>GitHub</strong> – Abgerufene Daten: E-Mail-Adresse (primäre, verifizierte E-Mail)
|
||||
</li>
|
||||
<li><strong>Facebook</strong> – Abgerufene Daten: E-Mail-Adresse, Name, Profilbild</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<a
|
||||
href="https://policies.google.com/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Google Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>GitHub Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.facebook.com/privacy/policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Facebook Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
6. Magic-Link-Anmeldung
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
7. Sitzungsverwaltung (Sessions)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Nach der Anmeldung wird eine Sitzung erstellt. Dabei werden folgende Daten gespeichert:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li><strong>IP-Adresse</strong> – zum Zeitpunkt der Sitzungserstellung</li>
|
||||
<li><strong>User-Agent</strong> – Browserkennung zum Zeitpunkt der Anmeldung</li>
|
||||
<li><strong>Sitzungstoken</strong> – als SHA-256-Hash gespeichert</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
8. Zwei-Faktor-Authentifizierung (2FA)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
9. Cookies und lokale Speicherung
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Wir verwenden ausschließlich technisch notwendige Cookies:
|
||||
</p>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-stone-200 dark:border-stone-700">
|
||||
<th class="py-2 pr-4 text-left font-semibold text-stone-900 dark:text-stone-100"
|
||||
>Name</th
|
||||
>
|
||||
<th class="py-2 pr-4 text-left font-semibold text-stone-900 dark:text-stone-100"
|
||||
>Zweck</th
|
||||
>
|
||||
<th class="py-2 pr-4 text-left font-semibold text-stone-900 dark:text-stone-100"
|
||||
>Dauer</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 pr-4 font-mono text-stone-700 dark:text-stone-300">access_token</td>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300"
|
||||
>JWT-Zugriffstoken für authentifizierte Anfragen</td
|
||||
>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300">15 Minuten</td>
|
||||
</tr>
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 pr-4 font-mono text-stone-700 dark:text-stone-300">session_token</td>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300"
|
||||
>Sitzungstoken zur Erneuerung des Zugriffstokens</td
|
||||
>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300">30 Tage</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-4 text-stone-700 dark:text-stone-300">
|
||||
Zusätzlich wird im <strong>localStorage</strong> des Browsers die Einstellung für das
|
||||
Farbschema (<code class="rounded bg-stone-100 px-1 dark:bg-stone-800">marktvogt-theme</code>)
|
||||
gespeichert. Dies enthält keine personenbezogenen Daten.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Es werden keine Tracking-, Analyse- oder Werbe-Cookies eingesetzt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
10. Markt einreichen (Einreichungsformular)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie können über das Formular unter „Markt einreichen" einen Mittelaltermarkt zur Aufnahme
|
||||
vorschlagen. Dabei werden folgende Daten verarbeitet:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Marktdaten</strong> – Name, Beschreibung, Ort, Zeitraum, Website, Veranstalter, ggf. Koordinaten
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kontaktdaten</strong> – Ihr Name und Ihre E-Mail-Adresse (werden nicht veröffentlicht)
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
11. Spam-Schutz (Cloudflare Turnstile)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Zum Schutz des Einreichungsformulars vor automatisiertem Missbrauch setzen wir
|
||||
<strong>Cloudflare Turnstile</strong> 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.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am Schutz vor Spam).
|
||||
Weitere Informationen finden Sie in der
|
||||
<a
|
||||
href="https://www.cloudflare.com/privacypolicy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Datenschutzerklärung von Cloudflare</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">12. Standortdaten</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Für die Umkreissuche nach Märkten können Sie optional Ihren Standort freigeben. Dabei kommen
|
||||
zwei Verfahren zum Einsatz:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Browser-Geolokalisierung</strong> – 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.
|
||||
</li>
|
||||
<li>
|
||||
<strong>IP-basierte Geolokalisierung (Fallback)</strong> – Falls die
|
||||
Browser-Geolokalisierung nicht verfügbar ist, wird der Dienst
|
||||
<a
|
||||
href="https://www.geojs.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>geojs.io</a
|
||||
>
|
||||
zur ungefähren Standortbestimmung genutzt. Dabei wird Ihre IP-Adresse an geojs.io übermittelt.
|
||||
Bitte beachten Sie die
|
||||
<a
|
||||
href="https://www.geojs.io/privacy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Datenschutzerklärung von geojs.io</a
|
||||
>.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">13. Kartendarstellung</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Zur Darstellung von Karten verwenden wir <strong>Leaflet</strong> mit Kartenkacheln von
|
||||
<strong>OpenStreetMap</strong>. Beim Laden der Karte werden Kartendaten von den Servern der
|
||||
OpenStreetMap Foundation (<code class="rounded bg-stone-100 px-1 dark:bg-stone-800"
|
||||
>tile.openstreetmap.org</code
|
||||
>) abgerufen. Dabei wird Ihre IP-Adresse an die OpenStreetMap Foundation übermittelt. Weitere
|
||||
Informationen finden Sie in der
|
||||
<a
|
||||
href="https://wiki.osmfoundation.org/wiki/Privacy_Policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Datenschutzerklärung der OpenStreetMap Foundation</a
|
||||
>.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Die Leaflet-Bibliothek wird über <code class="rounded bg-stone-100 px-1 dark:bg-stone-800"
|
||||
>unpkg.com</code
|
||||
> (CDN) geladen. Dabei kann Ihre IP-Adresse an den CDN-Betreiber übermittelt werden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">14. Ihre Rechte</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie haben gemäß DSGVO folgende Rechte bezüglich Ihrer personenbezogenen Daten:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Auskunft</strong> (Art. 15 DSGVO) – Sie können Auskunft über Ihre gespeicherten Daten
|
||||
verlangen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Berichtigung</strong> (Art. 16 DSGVO) – Sie können die Berichtigung unrichtiger Daten
|
||||
verlangen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Löschung</strong> (Art. 17 DSGVO) – Sie können die Löschung Ihrer Daten verlangen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Einschränkung</strong> (Art. 18 DSGVO) – Sie können die Einschränkung der Verarbeitung
|
||||
verlangen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Datenübertragbarkeit</strong> (Art. 20 DSGVO) – Sie können Ihre Daten in einem maschinenlesbaren
|
||||
Format erhalten.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Widerspruch</strong> (Art. 21 DSGVO) – Sie können der Verarbeitung auf Basis berechtigter
|
||||
Interessen widersprechen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Widerruf der Einwilligung</strong> (Art. 7 Abs. 3 DSGVO) – Erteilte Einwilligungen können
|
||||
jederzeit widerrufen werden.
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Zur Ausübung Ihrer Rechte wenden Sie sich an: <a
|
||||
href="mailto:christian@nachtigall.dev"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>christian@nachtigall.dev</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">15. Beschwerderecht</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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:
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)<br />
|
||||
Promenade 18<br />
|
||||
91522 Ansbach<br />
|
||||
<a
|
||||
href="https://www.lda.bayern.de"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>www.lda.bayern.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
16. Datenlöschung und Speicherdauer
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Personenbezogene Daten werden gelöscht, sobald der Zweck der Speicherung entfällt:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Benutzerkonto</strong> – 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.
|
||||
</li>
|
||||
<li><strong>Sitzungsdaten</strong> – Automatische Löschung nach Ablauf (30 Tage).</li>
|
||||
<li><strong>Magic-Link-Tokens</strong> – Laufen nach 15 Minuten ab.</li>
|
||||
<li>
|
||||
<strong>Server-Logfiles</strong> – Werden nach den beim Hostinganbieter üblichen Fristen gelöscht.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mt-8 mb-4">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
17. Änderungen dieser Datenschutzerklärung
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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.
|
||||
</p>
|
||||
<p class="mt-4 text-sm text-stone-500 dark:text-stone-400">Stand: Februar 2026</p>
|
||||
</section>
|
||||
</div>
|
||||
105
web/src/routes/impressum/+page.svelte
Normal file
105
web/src/routes/impressum/+page.svelte
Normal file
@@ -0,0 +1,105 @@
|
||||
<svelte:head>
|
||||
<title>Impressum - Marktvogt</title>
|
||||
<meta name="description" content="Impressum und Angaben gemäß § 5 TMG für Marktvogt." />
|
||||
<meta property="og:title" content="Impressum - Marktvogt" />
|
||||
<meta property="og:description" content="Impressum und Angaben gemäß § 5 TMG für Marktvogt." />
|
||||
<meta property="og:type" content="website" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<h1 class="text-3xl font-bold text-stone-900 dark:text-stone-100">Impressum</h1>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Angaben gemäß § 5 TMG</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Christian Nachtigall<br />
|
||||
Karwendelstr. 21<br />
|
||||
82061 Neuried
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Kontakt</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
E-Mail: <a
|
||||
href="mailto:christian@nachtigall.dev"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>christian@nachtigall.dev</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Christian Nachtigall<br />
|
||||
Karwendelstr. 21<br />
|
||||
82061 Neuried
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Haftung für Inhalte</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
Nutzereingereichte Inhalte
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Haftung für Links</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Urheberrecht</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
43
web/src/routes/maerkte/+page.server.ts
Normal file
43
web/src/routes/maerkte/+page.server.ts
Normal file
@@ -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<MarketSummary[]>('/markets?per_page=1000', { fetch });
|
||||
markets = res.data;
|
||||
} catch {
|
||||
// Backend unreachable
|
||||
}
|
||||
|
||||
const countByState = new Map<string, number>();
|
||||
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 };
|
||||
};
|
||||
85
web/src/routes/maerkte/+page.svelte
Normal file
85
web/src/routes/maerkte/+page.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import type { StateInfo } from './+page.server.js';
|
||||
|
||||
let { data } = $props();
|
||||
const states: StateInfo[] = $derived(data.states);
|
||||
|
||||
const jsonLdHtml =
|
||||
'<script type="application/ld+json">' +
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Startseite',
|
||||
item: 'https://marktvogt.de/'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Märkte nach Bundesland',
|
||||
item: 'https://marktvogt.de/maerkte/'
|
||||
}
|
||||
]
|
||||
}) +
|
||||
'</' +
|
||||
'script>';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mittelaltermärkte nach Bundesland - Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Finde Mittelaltermärkte in allen 16 Bundesländern. Durchstöbere Mittelaltermärkte, Ritterturniere und historische Feste nach Region."
|
||||
/>
|
||||
<meta property="og:title" content="Mittelaltermärkte nach Bundesland - Marktvogt" />
|
||||
<meta property="og:description" content="Finde Mittelaltermärkte in allen 16 Bundesländern." />
|
||||
<meta property="og:type" content="website" />
|
||||
{@html jsonLdHtml}
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<nav class="mb-6 text-sm text-stone-500 dark:text-stone-400" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-1.5">
|
||||
<li><a href="/" class="hover:text-stone-700 dark:hover:text-stone-200">Startseite</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li class="text-stone-900 dark:text-stone-100">Märkte nach Bundesland</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 class="text-3xl font-bold text-stone-900 sm:text-4xl dark:text-stone-100">
|
||||
Mittelaltermärkte nach Bundesland
|
||||
</h1>
|
||||
<p class="mt-2 text-lg text-stone-600 dark:text-stone-300">
|
||||
Entdecke Mittelaltermärkte, Ritterturniere und historische Feste in ganz Deutschland.
|
||||
</p>
|
||||
|
||||
{#if states.length > 0}
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each states as state (state.slug)}
|
||||
<a
|
||||
href="/maerkte/{state.slug}/"
|
||||
class="group bg-vellum rounded-lg border border-stone-200 p-5 shadow-sm transition-shadow hover:shadow-md dark:border-stone-700"
|
||||
>
|
||||
<h2
|
||||
class="group-hover:text-primary-600 dark:group-hover:text-primary-400 text-lg font-semibold text-stone-900 dark:text-stone-100"
|
||||
>
|
||||
{state.name}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-stone-500 dark:text-stone-400">
|
||||
{state.count}
|
||||
{state.count === 1 ? 'Markt' : 'Märkte'}
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-vellum mt-8 rounded-lg border border-stone-200 py-16 text-center dark:border-stone-700"
|
||||
>
|
||||
<p class="text-stone-500 dark:text-stone-400">Aktuell keine Märkte verfügbar.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
48
web/src/routes/maerkte/[state]/+page.server.ts
Normal file
48
web/src/routes/maerkte/[state]/+page.server.ts
Normal file
@@ -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<MarketSummary[]>('/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<string, number>();
|
||||
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
|
||||
};
|
||||
};
|
||||
120
web/src/routes/maerkte/[state]/+page.svelte
Normal file
120
web/src/routes/maerkte/[state]/+page.svelte
Normal file
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import MarketCard from '$lib/components/market/MarketCard.svelte';
|
||||
import type { CityInfo } from './+page.server.js';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
let { data } = $props();
|
||||
const stateName: string = $derived(data.stateName);
|
||||
const stateSlug: string = $derived(data.stateSlug);
|
||||
const markets: MarketSummary[] = $derived(data.markets);
|
||||
const cities: CityInfo[] = $derived(data.cities);
|
||||
|
||||
const jsonLdBreadcrumbHtml = $derived(
|
||||
'<script type="application/ld+json">' +
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Startseite',
|
||||
item: 'https://marktvogt.de/'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Märkte nach Bundesland',
|
||||
item: 'https://marktvogt.de/maerkte/'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: stateName,
|
||||
item: `https://marktvogt.de/maerkte/${stateSlug}/`
|
||||
}
|
||||
]
|
||||
}) +
|
||||
'</' +
|
||||
'script>'
|
||||
);
|
||||
|
||||
const jsonLdItemListHtml = $derived(
|
||||
'<script type="application/ld+json">' +
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: `Mittelaltermärkte in ${stateName}`,
|
||||
numberOfItems: markets.length,
|
||||
itemListElement: markets.slice(0, 30).map((m, i) => ({
|
||||
'@type': 'ListItem',
|
||||
position: i + 1,
|
||||
url: `https://marktvogt.de/markt/${m.slug}`
|
||||
}))
|
||||
}) +
|
||||
'</' +
|
||||
'script>'
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mittelaltermärkte in {stateName} - Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Finde {markets.length} Mittelaltermärkte in {stateName}. Ritterturniere, historische Feste und mittelalterliche Spektakel nach Stadt durchsuchen."
|
||||
/>
|
||||
<meta property="og:title" content="Mittelaltermärkte in {stateName} - Marktvogt" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="{markets.length} Mittelaltermärkte in {stateName} entdecken."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
{@html jsonLdBreadcrumbHtml}
|
||||
{@html jsonLdItemListHtml}
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<nav class="mb-6 text-sm text-stone-500 dark:text-stone-400" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-1.5">
|
||||
<li><a href="/" class="hover:text-stone-700 dark:hover:text-stone-200">Startseite</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li>
|
||||
<a href="/maerkte/" class="hover:text-stone-700 dark:hover:text-stone-200">Bundesländer</a>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li class="text-stone-900 dark:text-stone-100">{stateName}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 class="text-3xl font-bold text-stone-900 sm:text-4xl dark:text-stone-100">
|
||||
Mittelaltermärkte in {stateName}
|
||||
</h1>
|
||||
<p class="mt-2 text-lg text-stone-600 dark:text-stone-300">
|
||||
{markets.length}
|
||||
{markets.length === 1 ? 'Markt' : 'Märkte'} in {stateName}
|
||||
</p>
|
||||
|
||||
{#if cities.length > 1}
|
||||
<div class="mt-6">
|
||||
<h2 class="text-sm font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400">
|
||||
Städte
|
||||
</h2>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each cities as city (city.slug)}
|
||||
<a
|
||||
href="/maerkte/{stateSlug}/{city.slug}/"
|
||||
class="bg-vellum hover:border-primary-300 hover:text-primary-700 dark:hover:border-primary-500 dark:hover:text-primary-400 rounded-full border border-stone-200 px-3 py-1 text-sm text-stone-700 transition-colors dark:border-stone-600 dark:text-stone-300"
|
||||
>
|
||||
{city.name} ({city.count})
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each markets as market (market.id)}
|
||||
<MarketCard {market} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
38
web/src/routes/maerkte/[state]/[city]/+page.server.ts
Normal file
38
web/src/routes/maerkte/[state]/[city]/+page.server.ts
Normal file
@@ -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<MarketSummary[]>('/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
|
||||
};
|
||||
};
|
||||
113
web/src/routes/maerkte/[state]/[city]/+page.svelte
Normal file
113
web/src/routes/maerkte/[state]/[city]/+page.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import MarketCard from '$lib/components/market/MarketCard.svelte';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
let { data } = $props();
|
||||
const stateName: string = $derived(data.stateName);
|
||||
const stateSlug: string = $derived(data.stateSlug);
|
||||
const cityName: string = $derived(data.cityName);
|
||||
const markets: MarketSummary[] = $derived(data.markets);
|
||||
|
||||
const jsonLdBreadcrumbHtml = $derived(
|
||||
'<script type="application/ld+json">' +
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Startseite',
|
||||
item: 'https://marktvogt.de/'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Märkte nach Bundesland',
|
||||
item: 'https://marktvogt.de/maerkte/'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: stateName,
|
||||
item: `https://marktvogt.de/maerkte/${stateSlug}/`
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 4,
|
||||
name: cityName,
|
||||
item: `https://marktvogt.de/maerkte/${stateSlug}/${data.citySlug}/`
|
||||
}
|
||||
]
|
||||
}) +
|
||||
'</' +
|
||||
'script>'
|
||||
);
|
||||
|
||||
const jsonLdItemListHtml = $derived(
|
||||
'<script type="application/ld+json">' +
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: `Mittelaltermärkte in ${cityName}`,
|
||||
numberOfItems: markets.length,
|
||||
itemListElement: markets.map((m, i) => ({
|
||||
'@type': 'ListItem',
|
||||
position: i + 1,
|
||||
url: `https://marktvogt.de/markt/${m.slug}`
|
||||
}))
|
||||
}) +
|
||||
'</' +
|
||||
'script>'
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mittelaltermärkte in {cityName}, {stateName} - Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Finde {markets.length} Mittelaltermärkte in {cityName}, {stateName}. Ritterturniere, historische Feste und mittelalterliche Spektakel."
|
||||
/>
|
||||
<meta property="og:title" content="Mittelaltermärkte in {cityName}, {stateName} - Marktvogt" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="{markets.length} Mittelaltermärkte in {cityName} entdecken."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
{@html jsonLdBreadcrumbHtml}
|
||||
{@html jsonLdItemListHtml}
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<nav class="mb-6 text-sm text-stone-500 dark:text-stone-400" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-1.5">
|
||||
<li><a href="/" class="hover:text-stone-700 dark:hover:text-stone-200">Startseite</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li>
|
||||
<a href="/maerkte/" class="hover:text-stone-700 dark:hover:text-stone-200">Bundesländer</a>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li>
|
||||
<a href="/maerkte/{stateSlug}/" class="hover:text-stone-700 dark:hover:text-stone-200"
|
||||
>{stateName}</a
|
||||
>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li class="text-stone-900 dark:text-stone-100">{cityName}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 class="text-3xl font-bold text-stone-900 sm:text-4xl dark:text-stone-100">
|
||||
Mittelaltermärkte in {cityName}
|
||||
</h1>
|
||||
<p class="mt-2 text-lg text-stone-600 dark:text-stone-300">
|
||||
{markets.length}
|
||||
{markets.length === 1 ? 'Markt' : 'Märkte'} in {cityName}, {stateName}
|
||||
</p>
|
||||
|
||||
<div class="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each markets as market (market.id)}
|
||||
<MarketCard {market} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
22
web/src/routes/markt/[slug]/+page.server.ts
Normal file
22
web/src/routes/markt/[slug]/+page.server.ts
Normal file
@@ -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<MarketDetail>(`/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.' });
|
||||
}
|
||||
};
|
||||
464
web/src/routes/markt/[slug]/+page.svelte
Normal file
464
web/src/routes/markt/[slug]/+page.svelte
Normal file
@@ -0,0 +1,464 @@
|
||||
<script lang="ts">
|
||||
import MarketMap from '$lib/components/market/MarketMap.svelte';
|
||||
import type {
|
||||
MarketDetail,
|
||||
OpeningHoursEntry,
|
||||
AdmissionInfo,
|
||||
EditionBrief
|
||||
} from '$lib/api/types.js';
|
||||
import { stateToSlug, toSlug } from '$lib/utils/slug.js';
|
||||
|
||||
let { data } = $props();
|
||||
const market: MarketDetail = $derived(data.market);
|
||||
const editions: EditionBrief[] = $derived(data.editions);
|
||||
const currentYear = $derived(new Date(market.start_date).getFullYear());
|
||||
const hasMultipleEditions = $derived(editions.length > 1);
|
||||
const stateSlug = $derived(stateToSlug(market.state));
|
||||
const citySlug = $derived(toSlug(market.city));
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function centsToEuro(cents: number): string {
|
||||
return (cents / 100).toFixed(2).replace('.', ',') + ' \u20AC';
|
||||
}
|
||||
|
||||
const openingHours: OpeningHoursEntry[] = $derived(
|
||||
Array.isArray(market.opening_hours) ? market.opening_hours : []
|
||||
);
|
||||
|
||||
const admission: AdmissionInfo | null = $derived(
|
||||
market.admission_info && typeof market.admission_info === 'object'
|
||||
? market.admission_info
|
||||
: null
|
||||
);
|
||||
|
||||
interface ParsedPriceEntry {
|
||||
category: string;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface ParsedPriceGroup {
|
||||
label: string;
|
||||
entries: ParsedPriceEntry[];
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
Erw: 'Erwachsene',
|
||||
'Erw.': 'Erwachsene',
|
||||
Erm: 'Ermäßigt',
|
||||
'Erm.': 'Ermäßigt'
|
||||
};
|
||||
|
||||
function parseAdmissionNotes(notes: string): {
|
||||
groups: ParsedPriceGroup[];
|
||||
remaining: string;
|
||||
} {
|
||||
const groups: ParsedPriceGroup[] = [];
|
||||
const remainingParts: string[] = [];
|
||||
|
||||
// Split on ". " followed by uppercase — avoids breaking on "Erw." abbreviation
|
||||
const segments = notes.split(/\.\s+(?=[A-ZÄÖÜ])/).filter((s) => s.trim());
|
||||
|
||||
for (const raw of segments) {
|
||||
const segment = raw.replace(/\.+$/, '').trim();
|
||||
const colonIdx = segment.indexOf(':');
|
||||
if (colonIdx === -1) {
|
||||
remainingParts.push(segment);
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = segment.substring(0, colonIdx).trim();
|
||||
const pricesStr = segment.substring(colonIdx + 1).trim();
|
||||
|
||||
// If pricesStr contains colons, it's "Cat: Price, Cat: Price" format — skip parsing
|
||||
if (pricesStr.includes(':')) {
|
||||
remainingParts.push(segment);
|
||||
continue;
|
||||
}
|
||||
|
||||
const entries: ParsedPriceEntry[] = [];
|
||||
// Category must start with a letter (prevents matching bare prices like "13,00 €")
|
||||
const pricePattern = /(?:^|,\s*)([A-Za-zÄÖÜäöüß].*?)\s+(\d+(?:,\d+)?)\s*€/g;
|
||||
let m;
|
||||
|
||||
while ((m = pricePattern.exec(pricesStr)) !== null) {
|
||||
const cat = m[1].trim();
|
||||
entries.push({
|
||||
category: categoryLabels[cat] ?? cat,
|
||||
price: m[2] + ' €'
|
||||
});
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
groups.push({ label, entries });
|
||||
} else {
|
||||
remainingParts.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
remaining: remainingParts.length > 0 ? remainingParts.join('. ') + '.' : ''
|
||||
};
|
||||
}
|
||||
|
||||
const parsedNotes = $derived(
|
||||
admission?.notes ? parseAdmissionNotes(admission.notes) : { groups: [], remaining: '' }
|
||||
);
|
||||
|
||||
const jsonLdBreadcrumbHtml = $derived(
|
||||
'<script type="application/ld+json">' +
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Märkte',
|
||||
item: 'https://marktvogt.de/maerkte/'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: market.state,
|
||||
item: `https://marktvogt.de/maerkte/${stateSlug}/`
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: market.city,
|
||||
item: `https://marktvogt.de/maerkte/${stateSlug}/${citySlug}/`
|
||||
},
|
||||
{ '@type': 'ListItem', position: 4, name: market.name }
|
||||
]
|
||||
}) +
|
||||
'</' +
|
||||
'script>'
|
||||
);
|
||||
|
||||
const marketUrl = $derived(`https://marktvogt.de/markt/${market.slug}`);
|
||||
|
||||
const jsonLdEventHtml = $derived(
|
||||
'<script type="application/ld+json">' +
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Event',
|
||||
name: market.name,
|
||||
startDate: market.start_date,
|
||||
endDate: market.end_date,
|
||||
eventAttendanceMode: 'https://schema.org/OfflineEventAttendanceMode',
|
||||
eventStatus: 'https://schema.org/EventScheduled',
|
||||
...(market.description ? { description: market.description.slice(0, 500) } : {}),
|
||||
image: market.image_url || 'https://marktvogt.de/apple-touch-icon.png',
|
||||
...(market.website ? { url: market.website } : {}),
|
||||
...(market.organizer_name
|
||||
? {
|
||||
organizer: {
|
||||
'@type': 'Organization',
|
||||
name: market.organizer_name,
|
||||
url: market.website || marketUrl
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
location: {
|
||||
'@type': 'Place',
|
||||
name: market.name,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: market.street,
|
||||
addressLocality: market.city,
|
||||
postalCode: market.zip,
|
||||
addressRegion: market.state,
|
||||
addressCountry: market.country
|
||||
},
|
||||
geo: {
|
||||
'@type': 'GeoCoordinates',
|
||||
latitude: market.latitude,
|
||||
longitude: market.longitude
|
||||
}
|
||||
},
|
||||
...(admission && admission.adult_cents > 0
|
||||
? {
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: (admission.adult_cents / 100).toFixed(2),
|
||||
priceCurrency: 'EUR',
|
||||
availability: 'https://schema.org/InStock',
|
||||
validFrom: market.start_date,
|
||||
url: market.website || marketUrl
|
||||
}
|
||||
}
|
||||
: {
|
||||
isAccessibleForFree: true,
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'EUR',
|
||||
availability: 'https://schema.org/InStock',
|
||||
validFrom: market.start_date,
|
||||
url: market.website || marketUrl
|
||||
}
|
||||
})
|
||||
}) +
|
||||
'</' +
|
||||
'script>'
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{market.name} - Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="{market.name} in {market.city}. {market.description?.slice(0, 150)}"
|
||||
/>
|
||||
<meta property="og:title" content="{market.name} - Marktvogt" />
|
||||
<meta property="og:description" content={market.description?.slice(0, 200)} />
|
||||
{#if market.image_url}
|
||||
<meta property="og:image" content={market.image_url} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{/if}
|
||||
<meta property="og:type" content="event" />
|
||||
{@html jsonLdBreadcrumbHtml}
|
||||
{@html jsonLdEventHtml}
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<nav class="mb-6 text-sm text-stone-500 dark:text-stone-400" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-1.5">
|
||||
<li><a href="/maerkte/" class="hover:text-stone-700 dark:hover:text-stone-200">Märkte</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li>
|
||||
<a href="/maerkte/{stateSlug}/" class="hover:text-stone-700 dark:hover:text-stone-200"
|
||||
>{market.state}</a
|
||||
>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li>
|
||||
<a
|
||||
href="/maerkte/{stateSlug}/{citySlug}/"
|
||||
class="hover:text-stone-700 dark:hover:text-stone-200">{market.city}</a
|
||||
>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li class="truncate text-stone-900 dark:text-stone-100">{market.name}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{#if market.image_url}
|
||||
<div class="mb-8 overflow-hidden rounded-lg">
|
||||
<img src={market.image_url} alt={market.name} class="h-64 w-full object-cover sm:h-80" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h1 class="text-3xl font-bold text-stone-900 dark:text-stone-100">{market.name}</h1>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-4 text-sm text-stone-600 dark:text-stone-300">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
{formatDate(market.start_date)} – {formatDate(market.end_date)}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
</svg>
|
||||
{market.street}, {market.zip}
|
||||
{market.city}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if hasMultipleEditions}
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span class="text-sm text-stone-500 dark:text-stone-400">Ausgabe:</span>
|
||||
<div class="flex gap-1">
|
||||
{#each editions as edition}
|
||||
{@const isActive = edition.year === currentYear}
|
||||
<a
|
||||
href="/markt/{market.slug}{isActive ? '' : `?year=${edition.year}`}"
|
||||
class="rounded-md px-2.5 py-1 text-sm font-medium transition-colors
|
||||
{isActive
|
||||
? 'bg-primary-600 dark:bg-primary-500 text-white'
|
||||
: 'bg-stone-100 text-stone-600 hover:bg-stone-200 dark:bg-stone-800 dark:text-stone-400 dark:hover:bg-stone-700'}"
|
||||
>
|
||||
{edition.year}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if market.organizer_name}
|
||||
<p class="mt-2 text-sm text-stone-500 dark:text-stone-400">
|
||||
Veranstalter: {market.organizer_name}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if market.description}
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Beschreibung</h2>
|
||||
<p class="mt-2 whitespace-pre-line text-stone-700 dark:text-stone-300">
|
||||
{market.description}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8 grid gap-8 sm:grid-cols-2">
|
||||
{#if openingHours.length > 0}
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Öffnungszeiten</h2>
|
||||
<table class="mt-3 w-full text-sm">
|
||||
<tbody>
|
||||
{#each openingHours as entry}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">{entry.day}</td>
|
||||
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
|
||||
>{entry.open} – {entry.close}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if admission}
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Eintrittspreise</h2>
|
||||
<table class="mt-3 w-full text-sm">
|
||||
<tbody>
|
||||
{#if parsedNotes.groups.length > 0}
|
||||
{#each parsedNotes.groups as group, i}
|
||||
<tr class="border-b border-stone-200 dark:border-stone-600">
|
||||
<td
|
||||
colspan="2"
|
||||
class="{i > 0
|
||||
? 'pt-3'
|
||||
: 'pt-1'} pb-1 font-semibold text-stone-800 dark:text-stone-200"
|
||||
>{group.label}</td
|
||||
>
|
||||
</tr>
|
||||
{#each group.entries as entry}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 pl-3 font-medium text-stone-700 dark:text-stone-200"
|
||||
>{entry.category}</td
|
||||
>
|
||||
<td class="py-2 text-right text-stone-600 dark:text-stone-300">{entry.price}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Erwachsene</td>
|
||||
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
|
||||
>{centsToEuro(admission.adult_cents)}</td
|
||||
>
|
||||
</tr>
|
||||
{#if admission.reduced_cents > 0}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Ermäßigt</td>
|
||||
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
|
||||
>{centsToEuro(admission.reduced_cents)}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if admission.child_cents > 0}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Kinder</td>
|
||||
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
|
||||
>{centsToEuro(admission.child_cents)}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if admission.free_under_age > 0}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Frei unter</td>
|
||||
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
|
||||
>{admission.free_under_age} Jahre</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if parsedNotes.groups.length > 0 && parsedNotes.remaining}
|
||||
<p class="mt-2 text-sm text-stone-500 dark:text-stone-400">{parsedNotes.remaining}</p>
|
||||
{:else if parsedNotes.groups.length === 0 && admission.notes}
|
||||
<p class="mt-2 text-sm text-stone-500 dark:text-stone-400">{admission.notes}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if market.website}
|
||||
<div class="mt-8">
|
||||
<a
|
||||
href={market.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1 text-sm font-medium"
|
||||
>
|
||||
Website besuchen
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="mb-3 text-lg font-semibold text-stone-900 dark:text-stone-100">Standort</h2>
|
||||
<MarketMap
|
||||
markets={[
|
||||
{
|
||||
id: market.id,
|
||||
slug: market.slug,
|
||||
name: market.name,
|
||||
city: market.city,
|
||||
state: market.state,
|
||||
zip: market.zip,
|
||||
country: market.country,
|
||||
latitude: market.latitude,
|
||||
longitude: market.longitude,
|
||||
start_date: market.start_date,
|
||||
end_date: market.end_date,
|
||||
image_url: market.image_url,
|
||||
organizer_name: market.organizer_name
|
||||
}
|
||||
]}
|
||||
class="h-[300px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
111
web/src/routes/markt/einreichen/+page.server.ts
Normal file
111
web/src/routes/markt/einreichen/+page.server.ts
Normal file
@@ -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<string, unknown> = {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
104
web/src/routes/markt/einreichen/+page.svelte
Normal file
104
web/src/routes/markt/einreichen/+page.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import MarketForm from '$lib/components/admin/MarketForm.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Markt einreichen - Marktvogt</title>
|
||||
<meta name="description" content="Reiche einen Mittelaltermarkt bei Marktvogt ein." />
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<h1>Markt einreichen</h1>
|
||||
<p class="mt-2 text-stone-600 dark:text-stone-400">
|
||||
Kennst du einen Mittelaltermarkt, der noch nicht bei Marktvogt gelistet ist? Reiche ihn hier ein
|
||||
und wir prüfen die Angaben.
|
||||
</p>
|
||||
|
||||
{#if form?.success}
|
||||
<div class="mt-6">
|
||||
<Alert variant="success">
|
||||
Vielen Dank! Dein Markt wurde eingereicht und wird nach Prüfung veröffentlicht.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else}
|
||||
{#if form?.error}
|
||||
<div class="mt-6">
|
||||
<Alert variant="error">{form.error}</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
class="mt-6"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<MarketForm {loading} error={form?.error} mode="public">
|
||||
{#snippet extraFields()}
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">
|
||||
Deine Kontaktdaten
|
||||
</legend>
|
||||
<p class="text-sm text-stone-500 dark:text-stone-400">
|
||||
Werden nicht veröffentlicht. Nur für Rückfragen.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="submitter_name"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>
|
||||
Dein Name *
|
||||
</label>
|
||||
<input
|
||||
id="submitter_name"
|
||||
name="submitter_name"
|
||||
type="text"
|
||||
required
|
||||
value={form?.submitterName ?? ''}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="submitter_email"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>
|
||||
Deine E-Mail *
|
||||
</label>
|
||||
<input
|
||||
id="submitter_email"
|
||||
name="submitter_email"
|
||||
type="email"
|
||||
required
|
||||
value={form?.submitterEmail ?? ''}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if data.turnstileSiteKey}
|
||||
<div class="cf-turnstile" data-sitekey={data.turnstileSiteKey} data-theme="auto"></div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</MarketForm>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
6
web/src/routes/profile/+layout.server.ts
Normal file
6
web/src/routes/profile/+layout.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { requireAuth } from '$lib/auth/guard.js';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = (event) => {
|
||||
requireAuth(event);
|
||||
};
|
||||
89
web/src/routes/profile/+page.server.ts
Normal file
89
web/src/routes/profile/+page.server.ts
Normal file
@@ -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<import('./$types.js').PageServerLoad>[0]) => {
|
||||
requireAuth(event);
|
||||
const res = await serverFetch<ProfileData>('/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<string, string> = {};
|
||||
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<string, string> = { 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, '/');
|
||||
}
|
||||
};
|
||||
155
web/src/routes/profile/+page.svelte
Normal file
155
web/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let showDeleteConfirm = $state(false);
|
||||
let updateLoading = $state(false);
|
||||
let passwordLoading = $state(false);
|
||||
let deleteLoading = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profil - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6">
|
||||
<h1 class="mb-8 text-2xl font-bold text-stone-900 dark:text-stone-100">Profil</h1>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Profile info -->
|
||||
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
|
||||
<h2 class="mb-4 text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
Kontoinformationen
|
||||
</h2>
|
||||
|
||||
{#if form?.success}
|
||||
<Alert variant="success">{form.success}</Alert>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<Alert variant="error">{form.error}</Alert>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
updateLoading = true;
|
||||
return async ({ update }) => {
|
||||
updateLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="mt-4 space-y-4"
|
||||
>
|
||||
<div class="text-sm text-stone-500 dark:text-stone-400">
|
||||
<span class="font-medium text-stone-700 dark:text-stone-200">E-Mail:</span>
|
||||
{data.profile.email}
|
||||
</div>
|
||||
|
||||
<Input name="display_name" label="Anzeigename" value={data.profile.display_name} required />
|
||||
|
||||
<Input
|
||||
name="avatar_url"
|
||||
label="Avatar-URL"
|
||||
type="url"
|
||||
value={data.profile.avatar_url}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={updateLoading}>Speichern</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Security -->
|
||||
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
|
||||
<h2 class="mb-4 text-lg font-semibold text-stone-900 dark:text-stone-100">Sicherheit</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold text-stone-800 dark:text-stone-200">
|
||||
{data.profile.has_password ? 'Passwort ändern' : 'Passwort festlegen'}
|
||||
</h3>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/password"
|
||||
use:enhance={() => {
|
||||
passwordLoading = true;
|
||||
return async ({ update }) => {
|
||||
passwordLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
{#if data.profile.has_password}
|
||||
<Input name="current_password" label="Aktuelles Passwort" type="password" required />
|
||||
{/if}
|
||||
|
||||
<Input name="new_password" label="Neues Passwort" type="password" required />
|
||||
|
||||
<Input name="confirm_password" label="Passwort bestätigen" type="password" required />
|
||||
|
||||
<Button type="submit" loading={passwordLoading}>
|
||||
{data.profile.has_password ? 'Passwort ändern' : 'Passwort festlegen'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 2FA -->
|
||||
<div class="border-t border-stone-200 pt-4 dark:border-stone-700">
|
||||
<a
|
||||
href="/profile/security"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
|
||||
>
|
||||
Zwei-Faktor-Authentifizierung verwalten
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Danger zone -->
|
||||
<div class="border-danger-200 bg-vellum dark:border-danger-800 rounded-lg border p-6 shadow-sm">
|
||||
<h2 class="text-danger-600 dark:text-danger-400 mb-4 text-lg font-semibold">Konto löschen</h2>
|
||||
<p class="mb-4 text-sm text-stone-600 dark:text-stone-300">
|
||||
Dein Konto wird deaktiviert und nach 30 Tagen endgültig gelöscht.
|
||||
</p>
|
||||
|
||||
{#if !showDeleteConfirm}
|
||||
<Button variant="danger" onclick={() => (showDeleteConfirm = true)}>Konto löschen</Button>
|
||||
{:else}
|
||||
<div
|
||||
class="border-danger-200 bg-danger-50 dark:border-danger-800 dark:bg-danger-950 rounded-lg border p-4"
|
||||
>
|
||||
<p class="text-danger-800 dark:text-danger-200 mb-4 text-sm font-medium">
|
||||
Bist du sicher? Diese Aktion kann innerhalb von 30 Tagen rückgängig gemacht werden.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={() => {
|
||||
deleteLoading = true;
|
||||
return async ({ update }) => {
|
||||
deleteLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="danger" loading={deleteLoading}>
|
||||
Endgültig löschen
|
||||
</Button>
|
||||
</form>
|
||||
<Button variant="secondary" onclick={() => (showDeleteConfirm = false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
65
web/src/routes/profile/security/+page.server.ts
Normal file
65
web/src/routes/profile/security/+page.server.ts
Normal file
@@ -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<import('./$types.js').PageServerLoad>[0]) => {
|
||||
requireAuth(event);
|
||||
return {};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
setup: async ({ cookies, fetch }) => {
|
||||
try {
|
||||
const res = await serverFetch<TOTPSetupData>('/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<MessageData>('/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<MessageData>('/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.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
103
web/src/routes/profile/security/+page.svelte
Normal file
103
web/src/routes/profile/security/+page.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import TOTPSetup from '$lib/components/auth/TOTPSetup.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { form } = $props();
|
||||
|
||||
let setupLoading = $state(false);
|
||||
let disableLoading = $state(false);
|
||||
let showDisableConfirm = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sicherheit - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6">
|
||||
<div class="mb-6">
|
||||
<a
|
||||
href="/profile"
|
||||
class="text-sm text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200"
|
||||
>
|
||||
← Zurück zum Profil
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-8 text-2xl font-bold text-stone-900 dark:text-stone-100">
|
||||
Zwei-Faktor-Authentifizierung
|
||||
</h1>
|
||||
|
||||
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
|
||||
{#if form?.success}
|
||||
<Alert variant="success">{form.success}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if form?.totpSecret}
|
||||
<TOTPSetup secret={form.totpSecret} url={form.totpUrl} error={form?.error} />
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-stone-600 dark:text-stone-300">
|
||||
Schütze dein Konto mit einer Authenticator-App (z.B. Google Authenticator, Authy).
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setup"
|
||||
use:enhance={() => {
|
||||
setupLoading = true;
|
||||
return async ({ update }) => {
|
||||
setupLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" loading={setupLoading}>2FA einrichten</Button>
|
||||
</form>
|
||||
|
||||
{#if !showDisableConfirm}
|
||||
<Button variant="secondary" onclick={() => (showDisableConfirm = true)}>
|
||||
2FA deaktivieren
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showDisableConfirm}
|
||||
<div
|
||||
class="border-danger-200 bg-danger-50 dark:border-danger-800 dark:bg-danger-950 rounded-lg border p-4"
|
||||
>
|
||||
<p class="text-danger-800 dark:text-danger-200 mb-3 text-sm">
|
||||
Bist du sicher? Dein Konto wird weniger sicher sein.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/disable"
|
||||
use:enhance={() => {
|
||||
disableLoading = true;
|
||||
return async ({ update }) => {
|
||||
disableLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="danger" loading={disableLoading}>
|
||||
Deaktivieren
|
||||
</Button>
|
||||
</form>
|
||||
<Button variant="secondary" onclick={() => (showDisableConfirm = false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error && !form?.totpSecret}
|
||||
<Alert variant="error">{form.error}</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
94
web/src/routes/sitemap.xml/+server.ts
Normal file
94
web/src/routes/sitemap.xml/+server.ts
Normal file
@@ -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, '<').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<MarketSummary[]>('/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<string>();
|
||||
const citySet = new Set<string>();
|
||||
for (const m of markets) {
|
||||
const ss = stateToSlug(m.state);
|
||||
stateSet.add(ss);
|
||||
citySet.add(`${ss}/${toSlug(m.city)}`);
|
||||
}
|
||||
|
||||
const urls = staticPages
|
||||
.map(
|
||||
(p) => ` <url>
|
||||
<loc>${ORIGIN}${p.path}</loc>
|
||||
<changefreq>${p.changefreq}</changefreq>
|
||||
<priority>${p.priority}</priority>
|
||||
</url>`
|
||||
)
|
||||
.concat(
|
||||
` <url>
|
||||
<loc>${ORIGIN}/maerkte/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>`
|
||||
)
|
||||
.concat(
|
||||
Array.from(stateSet)
|
||||
.sort()
|
||||
.map(
|
||||
(s) => ` <url>
|
||||
<loc>${ORIGIN}/maerkte/${escapeXml(s)}/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>`
|
||||
)
|
||||
)
|
||||
.concat(
|
||||
Array.from(citySet)
|
||||
.sort()
|
||||
.map(
|
||||
(sc) => ` <url>
|
||||
<loc>${ORIGIN}/maerkte/${escapeXml(sc)}/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>`
|
||||
)
|
||||
)
|
||||
.concat(
|
||||
markets.map(
|
||||
(m) => ` <url>
|
||||
<loc>${ORIGIN}/markt/${escapeXml(m.slug)}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>`
|
||||
)
|
||||
);
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls.join('\n')}
|
||||
</urlset>`;
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'Cache-Control': 'max-age=3600'
|
||||
}
|
||||
});
|
||||
};
|
||||
BIN
web/static/apple-touch-icon.png
Normal file
BIN
web/static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user