merge: web history into monorepo

This commit is contained in:
2026-04-07 02:45:51 +02:00
115 changed files with 10244 additions and 0 deletions

4
web/.env.example Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
engine-strict=true

View 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
View File

@@ -0,0 +1,5 @@
build/
.svelte-kit/
node_modules/
pnpm-lock.yaml
deploy/

8
web/.prettierrc Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
# Marktvogt Web
SvelteKit + Tailwind 4

View 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"

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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

View 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

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

180
web/src/app.css Normal file
View 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
View 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
View 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
View 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'
});
};

View 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
View 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
View 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;
}

View 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

View 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
View 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');
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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}

View 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">&copy; {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>

View 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>

View 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>

View 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>

View 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>

View 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: '&copy; <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>

View 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}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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];
}

View 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>

View File

@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types.js';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user
};
};

View 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>

View 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
View 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>

View 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);
};

View 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>

View 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;
}
};

View 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>

View 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 });
}
}
};

View 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"
>
&larr; 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}
&middot; 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>

View 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 });
}
}
};

View 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"
>
&larr; 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>

View 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 });
}
}
};

View 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"
>
&larr; 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>

View 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 });
}
};

View File

@@ -0,0 +1,5 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = () => {
return {};
};

View 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, '/');
}
};

View 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.' });
}
}
};

View 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>

View 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, '/');
};

View 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, '/');
};

View 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, '/');
}
};

View 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>

View 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.&nbsp;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.&nbsp;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>

View 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>

View 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 };
};

View 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>

View 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
};
};

View 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>

View 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
};
};

View 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>

View 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.' });
}
};

View 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>

View 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 });
}
}
};

View 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>

View File

@@ -0,0 +1,6 @@
import { requireAuth } from '$lib/auth/guard.js';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = (event) => {
requireAuth(event);
};

View 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, '/');
}
};

View 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>

View 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.' });
}
}
};

View 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"
>
&larr; 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>

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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'
}
});
};

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