Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 911439ebd8 | |||
| 5e24be03af | |||
| 00d43675ff | |||
| 418a4411f3 |
@@ -73,8 +73,9 @@ fi
|
||||
|
||||
# 6. Web checks — only when web/ files are staged.
|
||||
if [ -n "$(staged_match '^web/')" ]; then
|
||||
echo "→ web: prettier --check"
|
||||
( cd web && pnpm run format:check )
|
||||
echo "→ web: prettier --write"
|
||||
( cd web && pnpm run format )
|
||||
git add $(git diff --cached --name-only --diff-filter=ACMR | grep '^web/' | tr '\n' ' ')
|
||||
|
||||
echo "→ web: eslint"
|
||||
( cd web && pnpm run lint )
|
||||
|
||||
@@ -2,6 +2,7 @@ package market
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -30,6 +31,7 @@ func (h *Handler) Search(c *gin.Context) { //nolint:dupl // similar structure to
|
||||
|
||||
markets, total, err := h.service.Search(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
slog.ErrorContext(c.Request.Context(), "market search failed", "error", err)
|
||||
apiErr := apierror.Internal("failed to search markets")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
@@ -77,6 +79,7 @@ func (h *Handler) GetBySlug(c *gin.Context) {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
slog.ErrorContext(c.Request.Context(), "market get by slug failed", "slug", slug, "error", err)
|
||||
apiErr := apierror.Internal("failed to get market")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Marktvogt Design System — Burgund
|
||||
|
||||
Established May 2026 via Claude Design handoff (bundle: `marktvogt-de.tar.gz`).
|
||||
|
||||
## Identity
|
||||
|
||||
**Burgund** is the locked visual identity for Marktvogt. The name comes from the deep sealing-wax red at its core.
|
||||
|
||||
Design principles chosen in the original session:
|
||||
|
||||
- **Editorial-classical** — Cormorant Garamond display, EB Garamond body, editorial proportions
|
||||
- **Mid-density** — enough whitespace to breathe, enough content to inform
|
||||
- **Minimal decoration** — one ornament glyph (✦), drop-caps, double rules only. No rounded corners, no gradient badges, no color-coded semantic variants.
|
||||
- **Body-lead hierarchy** — lead paragraph at 22px italic, then 16/15/14px body; display headlines at 76/56/44px
|
||||
|
||||
## Tokens
|
||||
|
||||
Source of truth: `web/src/app.css` (`@theme` block and `:root.dark` overrides).
|
||||
|
||||
| Token | Light | Dark |
|
||||
| --------------------- | -------------------------------- | ------------------------ |
|
||||
| `--color-bg` | `#f5efe4` (warm parchment) | `#0f0c0a` (deep ink) |
|
||||
| `--color-surface` | `#ffffff` | `#191411` |
|
||||
| `--color-surface-alt` | `#ece4d4` | `#241c17` |
|
||||
| `--color-ink` | `#181410` | `#f0e6d2` |
|
||||
| `--color-ink-soft` | `#3a322a` | `#c0b094` |
|
||||
| `--color-ink-muted` | `#6e6253` | `#74644f` |
|
||||
| `--color-rule` | `#181410` | `#3a2e22` |
|
||||
| `--color-rule-soft` | `#c9b58c` | `#2a221d` |
|
||||
| `--color-accent` | `#9a1e2c` (sealing-wax burgundy) | `#d86268` (halbton rose) |
|
||||
| `--color-accent-soft` | `#c84858` | `#8a2a32` |
|
||||
| `--color-on-accent` | `#f5efe4` | `#0f0c0a` |
|
||||
|
||||
The dark accent is the "Halbton" step chosen from the design session — midway between the original loud `#e84a5e` and the subdued `#c87a7a`. The dark Submit-CTA "Bordeau block" uses `surface-alt #241c17` with cream `ink` foreground.
|
||||
|
||||
## Typography
|
||||
|
||||
| Role | Font | Usage |
|
||||
| -------------- | ------------------ | ------------------------------------------------------------------- |
|
||||
| `font-display` | Cormorant Garamond | Headlines, section titles, drop-caps |
|
||||
| `font-serif` | EB Garamond | Body text, nav links, buttons, table content |
|
||||
| `font-sans` | Inter | Reserved; currently unused in UI — available for UI forms if needed |
|
||||
| `font-mono` | JetBrains Mono | CAPS labels, tags, mono counters |
|
||||
|
||||
All fonts self-hosted under `web/static/fonts/`. Variable fonts covering the declared weight ranges.
|
||||
|
||||
### Type scale (derived from design files)
|
||||
|
||||
| Use | Size | Weight | Font |
|
||||
| --------------- | ----------- | ------- | --------------------------------------- |
|
||||
| Display hero | 76–88px | 500 | display |
|
||||
| Display large | 56px | 500 | display |
|
||||
| Display medium | 44px | 500 | display |
|
||||
| Display small | 24px | 400–500 | display |
|
||||
| Lead / intro | 22px italic | 400 | serif |
|
||||
| Body large | 19px | 400 | serif |
|
||||
| Body | 16px | 400 | serif |
|
||||
| Body small | 15px | 400 | serif |
|
||||
| UI text | 14px | 400 | serif |
|
||||
| Caption | 13px | 400 | serif |
|
||||
| Mono caps large | 11px | 400 | mono + uppercase + tracking 0.18em |
|
||||
| Mono caps | 10px | 400 | mono + uppercase + tracking 0.12–0.15em |
|
||||
| Mono caps small | 9px | 400 | mono + uppercase + tracking 0.1em |
|
||||
|
||||
## Atoms
|
||||
|
||||
Source files: `web/src/lib/components/atoms/`
|
||||
|
||||
### `MarktvogtMark`
|
||||
|
||||
The shield-M logo. Props: `size` (default 32). Uses `currentColor` — inherits from parent element color.
|
||||
|
||||
```svelte
|
||||
<MarktvogtMark size={36} />
|
||||
```
|
||||
|
||||
The SVG source lives at `web/src/lib/assets/marktvogt-logo.svg`.
|
||||
|
||||
### `Caps`
|
||||
|
||||
Mono uppercase label. Props: `size` (px, default 11), `color` (CSS value, defaults to `ink-muted`).
|
||||
|
||||
```svelte
|
||||
<Caps size={10}>Hessen · Nr. 015</Caps>
|
||||
```
|
||||
|
||||
### `Tag`
|
||||
|
||||
Pill/badge. Props: `accent` (boolean, default false). Accent variant: bg + border in accent color, on-accent foreground.
|
||||
|
||||
```svelte
|
||||
<Tag accent>Empfohlen</Tag>
|
||||
<Tag>Burg</Tag>
|
||||
```
|
||||
|
||||
### `Rule`
|
||||
|
||||
Section divider. Props: `kind`: `'thin'` | `'double'` | `'ornament'` (default `'thin'`).
|
||||
|
||||
```svelte
|
||||
<Rule kind="ornament" />
|
||||
<!-- ─────── ✦ ─────── -->
|
||||
<Rule kind="double" />
|
||||
<!-- thin + thin gap -->
|
||||
<Rule />
|
||||
<!-- single rule-soft border -->
|
||||
```
|
||||
|
||||
### `Heraldry`
|
||||
|
||||
Procedural heraldic SVG by string seed. Used as market card hero fallback when no real photo is available. Props: `seed` (string), `palette` (`{ a, b, bg, fg }`).
|
||||
|
||||
```svelte
|
||||
<Heraldry seed={market.slug} />
|
||||
```
|
||||
|
||||
8 deterministic variants: Stripes, Checky, Chevron, Banner, Tower, Cross, Saltire, Fleury.
|
||||
|
||||
## Decoration vocabulary
|
||||
|
||||
**Allowed:**
|
||||
|
||||
- `✦` ornament glyph — ornament Rule, section transitions
|
||||
- Drop-cap — first character of lead paragraph, 40–56px Cormorant Garamond, accent color
|
||||
- Double rule — between major page sections
|
||||
- Section headers: mono-caps label + thin rule below
|
||||
- Breadcrumb line: mono-caps "Verzeichnis · Region · Nr. XXX"
|
||||
|
||||
**Forbidden:**
|
||||
|
||||
- Gold underline on headings (the old `h1::after` accent-400 stripe — removed)
|
||||
- Color-coded semantic Alert variants (info/success/warning/error with distinct hues) — use `surface-alt + border-rule-soft` for neutral blocks, `border-accent` for errors only
|
||||
- Rounded corners (`rounded-*`) on content elements — squares only; `rounded-sm` allowed on focus rings only
|
||||
- Badge pills with backgrounds other than accent (the full surface-alt + rule-soft pair only)
|
||||
|
||||
## Dark mode
|
||||
|
||||
Dark mode applies via `.dark` class on `<html>` (set by `lib/theme.ts`, user-controlled via ThemeToggle). Three states: light / dark / system.
|
||||
|
||||
The dark "Bordeau block" pattern (used in Submit-CTA, strong CTAs):
|
||||
|
||||
```css
|
||||
background: var(--color-surface-alt); /* #241c17 */
|
||||
color: var(--color-ink); /* #f0e6d2 cream */
|
||||
accent: var(--color-accent); /* #d86268 */
|
||||
```
|
||||
|
||||
## Voice & tone
|
||||
|
||||
Editorial-warm. "Hannes" is the editorial voice — a Kunstfigur (constructed character) representing the collective editorial team. The disclaimer appears under his signature:
|
||||
|
||||
> ✦ — Hannes, der Marktvogt · Hessen · Met-Brauer · Lagergänger seit 2003
|
||||
> _eine Kunstfigur · die Redaktion arbeitet kollektiv_
|
||||
|
||||
This is intentional and not removable. See `chat1.md` lines 860–895 for the design decision.
|
||||
|
||||
## Open items (Phase 1 does not address)
|
||||
|
||||
- Photography pipeline — Lagerleben articles, camp profiles, real market photos
|
||||
- OCR/AI program parsing — Submit-Flow optional upload step
|
||||
- Real-time messaging — dashboard inboxes use REST for Phase 4b; WebSocket later
|
||||
- Newsletter/Saisonbrief backend — Home form ships as non-functional in Phase 2
|
||||
|
||||
## Phase roadmap
|
||||
|
||||
| Phase | Branch | Status |
|
||||
| ---------------------------------------------------------------------------------- | ------------------------- | ----------- |
|
||||
| 1 · Design system | `feat/burgund-redesign` | In progress |
|
||||
| 2 · Public surfaces (Startseite, Verzeichnis, Detail, Kalender, Karte, Lagerleben) | `feat/burgund-public` | Planned |
|
||||
| 3 · Flows (Auth, Submit-Flow) | `feat/burgund-flows` | Planned |
|
||||
| 4a · Backend (roles, groups, applications, Lagerleben CMS) | `feat/burgund-backend` | Planned |
|
||||
| 4b · Dashboards (Veranstalter, Admin, Händler, Lager) | `feat/burgund-dashboards` | Planned |
|
||||
@@ -2,154 +2,211 @@
|
||||
|
||||
/* ── Fonts ───────────────────────────────────────────────── */
|
||||
|
||||
/* Cormorant Garamond — display */
|
||||
@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-family: 'Cormorant Garamond';
|
||||
src: url('/fonts/cormorant-garamond-400-ext.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Crimson Pro';
|
||||
src: url('/fonts/crimsonpro-400i.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-family: 'Cormorant Garamond';
|
||||
src: url('/fonts/cormorant-garamond-400.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Cormorant Garamond';
|
||||
src: url('/fonts/cormorant-garamond-400i-ext.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Cormorant Garamond';
|
||||
src: url('/fonts/cormorant-garamond-400i.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* ── Dark mode variant: .dark class only (JS resolves system preference) ── */
|
||||
/* EB Garamond — body serif */
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
src: url('/fonts/eb-garamond-400-ext.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
src: url('/fonts/eb-garamond-400.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
src: url('/fonts/eb-garamond-400i-ext.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
src: url('/fonts/eb-garamond-400i.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Inter — sans UI */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/inter-400-ext.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/inter-400.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* JetBrains Mono — mono caps labels */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/jetbrains-mono-400-ext.woff2') format('woff2');
|
||||
font-weight: 100 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/jetbrains-mono-400.woff2') format('woff2');
|
||||
font-weight: 100 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* ── Dark mode: .dark class (JS resolves system preference) ── */
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── Theme tokens ────────────────────────────────────────── */
|
||||
/* ── Burgund design token system ─────────────────────────── */
|
||||
|
||||
@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);
|
||||
/* Color tokens */
|
||||
--color-bg: #f5efe4;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #ece4d4;
|
||||
--color-ink: #181410;
|
||||
--color-ink-soft: #3a322a;
|
||||
--color-ink-muted: #6e6253;
|
||||
--color-rule: #181410;
|
||||
--color-rule-soft: #c9b58c;
|
||||
--color-accent: #9a1e2c;
|
||||
--color-accent-soft: #c84858;
|
||||
--color-on-accent: #f5efe4;
|
||||
|
||||
/* Typography */
|
||||
--font-heading: 'MedievalSharp', cursive;
|
||||
--font-sans: 'Crimson Pro', 'Georgia', serif;
|
||||
--font-display: 'Cormorant Garamond', 'Garamond', serif;
|
||||
--font-serif: 'EB Garamond', 'Garamond', serif;
|
||||
--font-sans: 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* ── Dark surface overrides (.dark class set by JS) ──────── */
|
||||
/* ── Dark mode overrides (.dark on <html>) ───────────────── */
|
||||
|
||||
:root.dark {
|
||||
color-scheme: dark;
|
||||
--color-parchment: oklch(0.14 0.007 20);
|
||||
--color-vellum: oklch(0.19 0.009 18);
|
||||
--color-bg: #0f0c0a;
|
||||
--color-surface: #191411;
|
||||
--color-surface-alt: #241c17;
|
||||
--color-ink: #f0e6d2;
|
||||
--color-ink-soft: #c0b094;
|
||||
--color-ink-muted: #74644f;
|
||||
--color-rule: #3a2e22;
|
||||
--color-rule-soft: #2a221d;
|
||||
--color-accent: #d86268;
|
||||
--color-accent-soft: #8a2a32;
|
||||
--color-on-accent: #0f0c0a;
|
||||
}
|
||||
|
||||
/* ── Base layer ──────────────────────────────────────────── */
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-parchment text-stone-800 antialiased;
|
||||
@apply bg-bg text-ink font-serif antialiased;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
@apply text-stone-200;
|
||||
}
|
||||
|
||||
/* Skip-to-content link (visible on focus only) */
|
||||
/* Skip-to-content link */
|
||||
.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;
|
||||
@apply bg-accent text-on-accent absolute -top-full left-4 z-50 px-4 py-2 text-sm font-medium;
|
||||
@apply focus:ring-accent focus:top-0 focus:ring-2 focus:outline-none;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-heading);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
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 ───────────────────────── */
|
||||
/* ── Form controls ───────────────────────────────────── */
|
||||
|
||||
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;
|
||||
textarea,
|
||||
select {
|
||||
@apply border-rule-soft bg-surface text-ink block w-full border px-3 py-2 text-sm shadow-sm transition-colors;
|
||||
@apply placeholder-ink-muted;
|
||||
@apply focus:border-accent focus:ring-accent focus:ring-1 focus:outline-none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.dark
|
||||
@@ -158,23 +215,18 @@
|
||||
[type='button']
|
||||
):not([type='reset'])
|
||||
),
|
||||
.dark textarea {
|
||||
@apply border-stone-600 bg-stone-800 text-stone-100 placeholder-stone-500;
|
||||
.dark textarea,
|
||||
.dark select {
|
||||
@apply border-rule-soft bg-surface-alt text-ink placeholder-ink-muted;
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
input[aria-invalid='true'],
|
||||
textarea[aria-invalid='true'] {
|
||||
@apply border-danger-400 text-danger-900 placeholder-danger-300;
|
||||
textarea[aria-invalid='true'],
|
||||
select[aria-invalid='true'] {
|
||||
@apply border-accent-soft;
|
||||
}
|
||||
|
||||
.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);
|
||||
--tw-ring-offset-color: var(--color-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,18 @@
|
||||
<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)" />
|
||||
<meta name="theme-color" content="#f5efe4" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0f0c0a" media="(prefers-color-scheme: dark)" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/fonts/crimsonpro-400.woff2"
|
||||
href="%sveltekit.assets%/fonts/eb-garamond-400.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/fonts/medievalsharp-400.woff2"
|
||||
href="%sveltekit.assets%/fonts/cormorant-garamond-400.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
<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>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="460" viewBox="0 0 40 46">
|
||||
<path d="M2 2 L38 2 L38 26 C38 36 30 42 20 44 C10 42 2 36 2 26 Z" fill="none" stroke="#9a1e2c" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M9 32 L9 14 L14 14 L20 24 L26 14 L31 14 L31 32 L27 32 L27 22 L22 30 L18 30 L13 22 L13 32 Z" fill="#9a1e2c"/>
|
||||
<circle cx="20" cy="9" r="1.5" fill="#9a1e2c"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 403 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="460" viewBox="0 0 40 46">
|
||||
<path d="M2 2 L38 2 L38 26 C38 36 30 42 20 44 C10 42 2 36 2 26 Z" fill="none" stroke="#1a1612" stroke-width="2" stroke-linejoin="round"></path>
|
||||
<path d="M9 32 L9 14 L14 14 L20 24 L26 14 L31 14 L31 32 L27 32 L27 22 L22 30 L18 30 L13 22 L13 32 Z" fill="#1a1612"></path>
|
||||
<circle cx="20" cy="9" r="1.5" fill="#1a1612"></circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 422 B |
@@ -270,18 +270,14 @@
|
||||
</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"
|
||||
>
|
||||
<div class="border-rule-soft bg-surface-alt text-accent mb-4 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>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">Allgemein</legend>
|
||||
|
||||
<Input
|
||||
label="Name {mode === 'public' ? 'des Marktes' : ''} *"
|
||||
@@ -293,16 +289,17 @@
|
||||
/>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="description" class="block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
<label
|
||||
for="description"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
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"
|
||||
class=""
|
||||
placeholder={mode === 'public' ? 'Beschreibe den Markt kurz...' : ''}
|
||||
bind:value={description}
|
||||
></textarea>
|
||||
@@ -310,7 +307,7 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Standort</legend>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">Standort</legend>
|
||||
|
||||
<Input
|
||||
label="Straße"
|
||||
@@ -347,18 +344,13 @@
|
||||
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">
|
||||
<label
|
||||
for="country"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
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"
|
||||
>
|
||||
<select id="country" name="country" required bind:value={selectedCountry} class="">
|
||||
<option value="DE">Deutschland</option>
|
||||
<option value="AT">Österreich</option>
|
||||
<option value="CH">Schweiz</option>
|
||||
@@ -431,30 +423,30 @@
|
||||
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"
|
||||
class="text-accent font-serif text-sm font-[500] 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>
|
||||
<span class="text-accent text-xs">{geocodeError}</span>
|
||||
{/if}
|
||||
<span class="text-stone-300 dark:text-stone-600" aria-hidden="true">·</span>
|
||||
<span class="text-rule-soft" aria-hidden="true">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={reverseGeocode}
|
||||
disabled={reverseGeocoding}
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm font-medium disabled:opacity-50"
|
||||
class="text-accent font-serif text-sm font-[500] disabled:opacity-50"
|
||||
>
|
||||
{reverseGeocoding ? 'Ermittle...' : 'Adresse aus Koordinaten ermitteln'}
|
||||
</button>
|
||||
{#if reverseGeocodeError}
|
||||
<span class="text-danger-600 dark:text-danger-400 text-xs">{reverseGeocodeError}</span>
|
||||
<span class="text-accent text-xs">{reverseGeocodeError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Zeitraum</legend>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">Zeitraum</legend>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input label="Startdatum *" name="start_date" type="date" required value={startDate} />
|
||||
@@ -463,7 +455,7 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Weitere Infos</legend>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">Weitere Infos</legend>
|
||||
|
||||
<Input
|
||||
label="Website"
|
||||
@@ -527,22 +519,17 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Öffnungszeiten</legend>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">Ö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"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>Tag</label
|
||||
>
|
||||
<select id="hours-day-{i}" bind:value={row.day} class="">
|
||||
{#each days as d}
|
||||
<option value={d}>{d}</option>
|
||||
{/each}
|
||||
@@ -571,18 +558,14 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeHoursRow(i)}
|
||||
class="text-danger-600 hover:text-danger-800 dark:text-danger-400 pb-1 text-sm"
|
||||
class="text-accent-soft pb-1 font-serif 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"
|
||||
>
|
||||
<button type="button" onclick={addHoursRow} class="text-accent font-serif text-sm font-[500]">
|
||||
+ Zeile hinzufügen
|
||||
</button>
|
||||
|
||||
@@ -590,14 +573,13 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Eintrittspreise</legend
|
||||
>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">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"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Erwachsene ({currency})
|
||||
</label>
|
||||
@@ -610,15 +592,13 @@
|
||||
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"
|
||||
class=""
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-child"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Kinder ({currency})
|
||||
</label>
|
||||
@@ -631,15 +611,13 @@
|
||||
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"
|
||||
class=""
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-reduced"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Ermäßigt ({currency})
|
||||
</label>
|
||||
@@ -652,9 +630,7 @@
|
||||
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"
|
||||
class=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -663,7 +639,7 @@
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-free-under"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Frei unter (Alter)
|
||||
</label>
|
||||
@@ -672,9 +648,7 @@
|
||||
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"
|
||||
class=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -682,18 +656,11 @@
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-notes"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
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>
|
||||
<textarea id="admission-notes" rows="2" bind:value={admission.notes} class=""></textarea>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="admission_info" value={admissionJson} />
|
||||
@@ -701,17 +668,14 @@
|
||||
|
||||
{#if mode === 'admin'}
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Admin-Notizen</legend
|
||||
>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">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"
|
||||
class=""
|
||||
placeholder="Interne Notizen...">{market?.admin_notes ?? ''}</textarea
|
||||
>
|
||||
</div>
|
||||
@@ -722,7 +686,7 @@
|
||||
{@render extraFields()}
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 border-t border-stone-200 pt-6 dark:border-stone-700">
|
||||
<div class="border-rule-soft flex gap-3 border-t pt-6">
|
||||
<Button type="submit" {loading}>
|
||||
{#if mode === 'public'}
|
||||
Markt einreichen
|
||||
@@ -734,7 +698,7 @@
|
||||
</Button>
|
||||
{#if mode === 'admin'}
|
||||
<a href="/admin/maerkte">
|
||||
<Button variant="secondary" type="button">Abbrechen</Button>
|
||||
<Button variant="outline" type="button">Abbrechen</Button>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import type { AdminMarketDetail, DuplicateMarket, MarketMergeProposal } from '$lib/api/types.js';
|
||||
import { fieldLabels, formatValue } from './fieldRenderers.js';
|
||||
@@ -15,7 +16,7 @@
|
||||
let { proposal, candidate, current, applying, onApply, onClose }: Props = $props();
|
||||
|
||||
// Local copy the admin can edit before applying.
|
||||
let edited = $state($state.snapshot(proposal));
|
||||
let edited = $state(untrack(() => $state.snapshot(proposal)));
|
||||
|
||||
const fieldOrder = Object.keys(edited.field_merges ?? {});
|
||||
|
||||
@@ -156,7 +157,7 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex gap-2">
|
||||
<Button variant="danger" loading={applying} onclick={handleApply}>Merge anwenden</Button>
|
||||
<Button variant="secondary" onclick={onClose}>Abbrechen</Button>
|
||||
<Button variant="primary" loading={applying} onclick={handleApply}>Merge anwenden</Button>
|
||||
<Button variant="outline" onclick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
{#if totalSelectable > 0}
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button type="button" onclick={handleApply}>Übernehmen</Button>
|
||||
<Button type="button" variant="secondary" onclick={onClose}>Abbrechen</Button>
|
||||
<Button type="button" variant="outline" onclick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
size?: number;
|
||||
color?: string;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { size = 11, color, class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="font-mono tracking-[0.18em] uppercase {className}"
|
||||
style="font-size:{size}px;{color ? `color:${color}` : 'color:var(--color-ink-muted)'}"
|
||||
>
|
||||
{@render children()}
|
||||
</span>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
interface Palette {
|
||||
a?: string;
|
||||
b?: string;
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
seed: string;
|
||||
palette?: Palette;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { seed, palette = {}, class: className = '' }: Props = $props();
|
||||
|
||||
const VARIANTS = [
|
||||
'Stripes',
|
||||
'Checky',
|
||||
'Chevron',
|
||||
'Banner',
|
||||
'Tower',
|
||||
'Cross',
|
||||
'Saltire',
|
||||
'Fleury'
|
||||
] as const;
|
||||
type Variant = (typeof VARIANTS)[number];
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
const variant: Variant = $derived(VARIANTS[hashString(seed) % VARIANTS.length]);
|
||||
|
||||
const a = $derived(palette.a ?? 'var(--color-accent)');
|
||||
const _b = $derived(palette.b ?? 'var(--color-surface)');
|
||||
const bg = $derived(palette.bg ?? 'var(--color-surface-alt)');
|
||||
const fg = $derived(palette.fg ?? 'var(--color-accent)');
|
||||
</script>
|
||||
|
||||
<div class="h-full w-full overflow-hidden {className}">
|
||||
{#if variant === 'Stripes'}
|
||||
<svg width="100%" height="100%" preserveAspectRatio="xMidYMid slice" style="display:block">
|
||||
<defs>
|
||||
<pattern
|
||||
id="h-stripes-{seed}"
|
||||
width="28"
|
||||
height="28"
|
||||
patternUnits="userSpaceOnUse"
|
||||
patternTransform="rotate(45)"
|
||||
>
|
||||
<rect width="28" height="28" fill={bg} />
|
||||
<rect width="14" height="28" fill={a} opacity="0.35" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#h-stripes-{seed})" />
|
||||
</svg>
|
||||
{:else if variant === 'Checky'}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
viewBox="0 0 8 8"
|
||||
style="display:block"
|
||||
>
|
||||
<rect width="8" height="8" fill={bg} />
|
||||
{#each Array.from({ length: 64 }, (_, i) => i) as i}
|
||||
{@const x = i % 8}
|
||||
{@const y = Math.floor(i / 8)}
|
||||
{#if (x + y) % 2 === 0}
|
||||
<rect {x} {y} width="1" height="1" fill={a} opacity="0.5" />
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
{:else if variant === 'Chevron'}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 100 60"
|
||||
style="display:block"
|
||||
>
|
||||
<rect width="100" height="60" fill={bg} />
|
||||
<path d="M0,60 L50,18 L100,60 Z" fill={a} opacity="0.55" />
|
||||
<path d="M0,60 L50,30 L100,60 Z" fill={bg} />
|
||||
</svg>
|
||||
{:else if variant === 'Banner'}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 100 60"
|
||||
style="display:block"
|
||||
>
|
||||
<rect width="100" height="60" fill={bg} />
|
||||
<rect x="33" width="34" height="60" fill={a} opacity="0.4" />
|
||||
<rect x="48" width="4" height="60" fill={a} opacity="0.7" />
|
||||
</svg>
|
||||
{:else if variant === 'Tower'}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
viewBox="0 0 100 60"
|
||||
style="display:block"
|
||||
>
|
||||
<rect width="100" height="60" fill={bg} />
|
||||
<g fill={fg} opacity="0.6">
|
||||
<rect x="20" y="30" width="14" height="22" />
|
||||
<rect x="43" y="20" width="14" height="32" />
|
||||
<rect x="66" y="34" width="14" height="18" />
|
||||
<rect x="20" y="26" width="3" height="4" />
|
||||
<rect x="26" y="26" width="3" height="4" />
|
||||
<rect x="32" y="26" width="2" height="4" />
|
||||
<rect x="43" y="16" width="3" height="4" />
|
||||
<rect x="49" y="16" width="3" height="4" />
|
||||
<rect x="55" y="16" width="2" height="4" />
|
||||
<rect x="66" y="30" width="3" height="4" />
|
||||
<rect x="72" y="30" width="3" height="4" />
|
||||
<rect x="78" y="30" width="2" height="4" />
|
||||
</g>
|
||||
</svg>
|
||||
{:else if variant === 'Cross'}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
viewBox="0 0 100 60"
|
||||
style="display:block"
|
||||
>
|
||||
<rect width="100" height="60" fill={bg} />
|
||||
<rect x="44" y="10" width="12" height="40" fill={a} opacity="0.6" />
|
||||
<rect x="30" y="24" width="40" height="12" fill={a} opacity="0.6" />
|
||||
</svg>
|
||||
{:else if variant === 'Saltire'}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 100 60"
|
||||
style="display:block"
|
||||
>
|
||||
<rect width="100" height="60" fill={bg} />
|
||||
<path d="M0,0 L100,60 M100,0 L0,60" stroke={a} stroke-width="14" opacity="0.5" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Fleury -->
|
||||
<svg width="100%" height="100%" preserveAspectRatio="xMidYMid slice" style="display:block">
|
||||
<defs>
|
||||
<pattern id="h-fleury-{seed}" width="64" height="64" patternUnits="userSpaceOnUse">
|
||||
<rect width="64" height="64" fill={bg} />
|
||||
<g transform="translate(32,32)" fill={fg} opacity="0.55">
|
||||
<circle r="2" />
|
||||
<path d="M0,-14 C4,-10 4,-6 0,-3 C-4,-6 -4,-10 0,-14 Z" />
|
||||
<path d="M0,14 C4,10 4,6 0,3 C-4,6 -4,10 0,14 Z" />
|
||||
<path d="M-14,0 C-10,4 -6,4 -3,0 C-6,-4 -10,-4 -14,0 Z" />
|
||||
<path d="M14,0 C10,4 6,4 3,0 C6,-4 10,-4 14,0 Z" />
|
||||
</g>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#h-fleury-{seed})" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { size = 32, class: className = '' }: Props = $props();
|
||||
|
||||
const height = $derived(Math.round(size * 1.15));
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
{height}
|
||||
viewBox="0 0 40 46"
|
||||
aria-hidden="true"
|
||||
class={className}
|
||||
style="display:block;color:inherit"
|
||||
>
|
||||
<path
|
||||
d="M2 2 L38 2 L38 26 C38 36 30 42 20 44 C10 42 2 36 2 26 Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 32 L9 14 L14 14 L20 24 L26 14 L31 14 L31 32 L27 32 L27 22 L22 30 L18 30 L13 22 L13 32 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<circle cx="20" cy="9" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
type Kind = 'thin' | 'double' | 'ornament';
|
||||
|
||||
interface Props {
|
||||
kind?: Kind;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { kind = 'thin', class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if kind === 'double'}
|
||||
<div class="h-1.5 {className}">
|
||||
<div class="border-rule-soft border-t"></div>
|
||||
<div class="border-rule-soft mt-0.5 border-t"></div>
|
||||
</div>
|
||||
{:else if kind === 'ornament'}
|
||||
<div class="flex items-center gap-3 {className}">
|
||||
<div class="border-rule-soft flex-1 border-t"></div>
|
||||
<span class="font-display text-accent text-lg leading-none">✦</span>
|
||||
<div class="border-rule-soft flex-1 border-t"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="border-rule-soft border-t {className}"></div>
|
||||
{/if}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
accent?: boolean;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { accent = false, class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-block border px-2 py-0.5 font-mono text-[10px] tracking-[0.12em] uppercase {accent
|
||||
? 'border-accent bg-accent text-on-accent'
|
||||
: 'border-rule-soft bg-surface text-ink-soft'} {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</span>
|
||||
@@ -34,5 +34,5 @@
|
||||
|
||||
<Input name="email" label="E-Mail" type="email" required autocomplete="email" />
|
||||
|
||||
<Button type="submit" variant="secondary" {loading} class="w-full">Magic Link senden</Button>
|
||||
<Button type="submit" variant="outline" {loading} class="w-full">Magic Link senden</Button>
|
||||
</form>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{#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"
|
||||
class="border-rule-soft bg-surface hover:bg-surface-alt text-ink flex w-full items-center justify-center gap-2 border px-4 py-2.5 font-serif text-[14px]"
|
||||
>
|
||||
Mit {provider.label} anmelden
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<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 Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
@@ -16,61 +17,79 @@
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<Alert variant="error">{error}</Alert>
|
||||
<div class="mb-4"><Alert variant="error">{error}</Alert></div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<Alert variant="success">{success}</Alert>
|
||||
<div class="mb-4"><Alert variant="success">{success}</Alert></div>
|
||||
{/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>
|
||||
<Caps class="mb-6">QR-Code scannen</Caps>
|
||||
<p class="text-ink-soft mb-6 font-serif text-[15px] leading-[1.65]">
|
||||
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="flex justify-center py-4">
|
||||
<img
|
||||
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encodeURIComponent(url)}"
|
||||
alt="TOTP QR-Code"
|
||||
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>
|
||||
<Rule kind="thin" class="my-6" />
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/profile/security?/verify"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
<div class="bg-surface-alt border-rule-soft mb-6 border p-4 text-center">
|
||||
<span class="text-ink-muted block font-mono text-[9px] tracking-[0.15em] uppercase"
|
||||
>Schlüssel</span
|
||||
>
|
||||
<Input
|
||||
<span class="text-ink mt-2 block font-mono text-[15px] font-[500] tracking-[0.08em] select-all">
|
||||
{secret}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/profile/security?/verify"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-5"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="totp_code"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Bestätigungscode
|
||||
</label>
|
||||
<input
|
||||
id="totp_code"
|
||||
name="code"
|
||||
label="Bestätigungscode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength={6}
|
||||
pattern={'[0-9]{6}'}
|
||||
pattern="[0-9]{6}"
|
||||
placeholder="123456"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
<Button type="submit" {loading}>Bestätigen</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-rule-soft border-t pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="border-ink bg-ink text-bg flex items-center gap-2 px-5 py-2.5 font-serif text-[14px] font-[500] disabled:opacity-50"
|
||||
>
|
||||
{#if loading}<Spinner size={14} />{/if}
|
||||
Bestätigen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
<script lang="ts">
|
||||
import ThemeToggle from '$lib/components/ui/ThemeToggle.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
</script>
|
||||
|
||||
<footer class="border-primary-800 bg-primary-950 border-t">
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<p class="text-primary-400 text-sm">© {new Date().getFullYear()} Marktvogt</p>
|
||||
<div class="flex items-center gap-6">
|
||||
<nav class="flex gap-6">
|
||||
<a href="/impressum" class="text-primary-400 hover:text-primary-200 text-sm">Impressum</a>
|
||||
<a href="/datenschutz" class="text-primary-400 hover:text-primary-200 text-sm"
|
||||
<footer class="border-rule-soft bg-bg border-t">
|
||||
<div class="mx-auto max-w-7xl px-8 py-10">
|
||||
<div class="grid grid-cols-2 gap-8 md:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-ink-muted font-mono text-[10px] tracking-[0.18em] uppercase">Entdecken</p>
|
||||
<nav class="mt-3 flex flex-col gap-2">
|
||||
<a href="/maerkte" class="text-ink-soft hover:text-ink font-serif text-sm">Märkte</a>
|
||||
<a href="/kalender" class="text-ink-soft hover:text-ink font-serif text-sm">Kalender</a>
|
||||
<a href="/karte" class="text-ink-soft hover:text-ink font-serif text-sm">Karte</a>
|
||||
<a href="/lagerleben" class="text-ink-soft hover:text-ink font-serif text-sm"
|
||||
>Lagerleben</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-ink-muted font-mono text-[10px] tracking-[0.18em] uppercase">Mitmachen</p>
|
||||
<nav class="mt-3 flex flex-col gap-2">
|
||||
<a href="/markt/einreichen" class="text-ink-soft hover:text-ink font-serif text-sm"
|
||||
>Markt einreichen</a
|
||||
>
|
||||
<a href="/auth/registrieren" class="text-ink-soft hover:text-ink font-serif text-sm"
|
||||
>Konto erstellen</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-ink-muted font-mono text-[10px] tracking-[0.18em] uppercase">Rechtliches</p>
|
||||
<nav class="mt-3 flex flex-col gap-2">
|
||||
<a href="/impressum" class="text-ink-soft hover:text-ink font-serif text-sm">Impressum</a>
|
||||
<a href="/datenschutz" class="text-ink-soft hover:text-ink font-serif text-sm"
|
||||
>Datenschutz</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<p class="text-ink-muted font-mono text-[10px] tracking-[0.18em] uppercase">Darstellung</p>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Rule kind="ornament" class="my-8" />
|
||||
|
||||
<p class="text-ink-muted font-mono text-[10px] tracking-[0.14em] uppercase">
|
||||
© {new Date().getFullYear()} Marktvogt · Ein Verzeichnis historischer Märkte im DACH-Raum
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { ProfileData } from '$lib/api/types.js';
|
||||
import MarktvogtMark from '$lib/components/atoms/MarktvogtMark.svelte';
|
||||
import MobileNav from './MobileNav.svelte';
|
||||
import UserMenu from './UserMenu.svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Props {
|
||||
user: ProfileData | null;
|
||||
@@ -9,73 +11,91 @@
|
||||
|
||||
let { user }: Props = $props();
|
||||
let mobileOpen = $state(false);
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/maerkte', label: 'Märkte' },
|
||||
{ href: '/kalender', label: 'Kalender' },
|
||||
{ href: '/karte', label: 'Karte' },
|
||||
{ href: '/lagerleben', label: 'Lagerleben' }
|
||||
];
|
||||
</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
|
||||
<header class="border-rule-soft bg-bg border-b">
|
||||
<!-- Top strip: anno + count -->
|
||||
<div class="border-rule-soft flex items-center justify-between border-b px-8 py-2.5 opacity-80">
|
||||
<span class="text-ink-muted font-mono text-[10px] tracking-[0.18em] uppercase">
|
||||
Anno MMXXVI · Verzeichnis historischer Märkte
|
||||
</span>
|
||||
<span class="text-ink-muted font-mono text-[10px] tracking-[0.18em] uppercase">
|
||||
DACH-Region
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Main row -->
|
||||
<div class="flex items-center justify-between gap-8 px-8 py-5">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="text-ink flex items-center gap-2.5" aria-label="Marktvogt">
|
||||
<MarktvogtMark size={30} />
|
||||
<span class="font-display text-2xl leading-none font-semibold tracking-[0.01em]">
|
||||
Marktvogt
|
||||
</span>
|
||||
</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">
|
||||
<nav class="hidden items-center gap-8 md:flex">
|
||||
{#each navLinks as link}
|
||||
{@const active = $page.url.pathname.startsWith(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
class="font-serif text-[15px] transition-colors {active
|
||||
? 'border-ink text-ink border-b'
|
||||
: 'text-ink-soft hover:text-ink'}"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="hidden items-center gap-2 md:flex">
|
||||
<a
|
||||
href="/markt/einreichen"
|
||||
class="border-ink text-ink hover:bg-surface-alt inline-flex items-center border px-4 py-1.5 font-serif text-sm transition-colors"
|
||||
>
|
||||
Markt einreichen
|
||||
</a>
|
||||
{#if user}
|
||||
<UserMenu {user} />
|
||||
{:else}
|
||||
<a href="/auth/anmelden" class="text-accent-300 hover:text-accent-200 text-sm font-medium">
|
||||
<a
|
||||
href="/auth/anmelden"
|
||||
class="border-accent bg-accent text-on-accent hover:bg-accent-soft inline-flex items-center border px-4 py-1.5 font-serif text-sm transition-colors"
|
||||
>
|
||||
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>
|
||||
|
||||
<!-- Mobile: hamburger -->
|
||||
<button
|
||||
type="button"
|
||||
class="text-ink-soft hover:text-ink md:hidden"
|
||||
onclick={() => (mobileOpen = !mobileOpen)}
|
||||
aria-label="Menü"
|
||||
aria-expanded={mobileOpen}
|
||||
>
|
||||
<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>
|
||||
|
||||
{#if mobileOpen}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { ProfileData } from '$lib/api/types.js';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
|
||||
interface Props {
|
||||
user: ProfileData | null;
|
||||
@@ -9,45 +10,66 @@
|
||||
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
|
||||
<nav class="border-rule-soft bg-bg border-t px-6 py-5 md:hidden">
|
||||
<div class="flex flex-col gap-4">
|
||||
<a href="/maerkte" class="text-ink hover:text-ink-soft font-serif text-base" onclick={onclose}>
|
||||
Märkte
|
||||
</a>
|
||||
<a href="/kalender" class="text-ink hover:text-ink-soft font-serif text-base" onclick={onclose}>
|
||||
Kalender
|
||||
</a>
|
||||
<a href="/karte" class="text-ink hover:text-ink-soft font-serif text-base" onclick={onclose}>
|
||||
Karte
|
||||
</a>
|
||||
<a
|
||||
href="/lagerleben"
|
||||
class="text-ink hover:text-ink-soft font-serif text-base"
|
||||
onclick={onclose}
|
||||
>
|
||||
Lagerleben
|
||||
</a>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<a
|
||||
href="/markt/einreichen"
|
||||
class="text-primary-200 text-sm font-medium hover:text-white"
|
||||
class="text-ink hover:text-ink-soft font-serif text-base"
|
||||
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"
|
||||
class="text-accent font-mono text-[11px] tracking-[0.15em] uppercase"
|
||||
onclick={onclose}
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if user}
|
||||
<Rule kind="thin" />
|
||||
<a
|
||||
href="/profile"
|
||||
class="text-primary-200 text-sm font-medium hover:text-white"
|
||||
class="text-ink hover:text-ink-soft font-serif text-base"
|
||||
onclick={onclose}
|
||||
>
|
||||
Profil
|
||||
</a>
|
||||
<form method="POST" action="/auth/abmelden">
|
||||
<button type="submit" class="text-primary-200 text-sm font-medium hover:text-white">
|
||||
<button type="submit" class="text-ink-soft hover:text-ink font-serif text-base">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
<span class="text-primary-300 text-sm">{user.display_name}</span>
|
||||
<span class="text-ink-muted font-mono text-[10px] tracking-[0.1em] uppercase">
|
||||
{user.display_name}
|
||||
</span>
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/anmelden"
|
||||
class="text-accent-300 hover:text-accent-200 text-sm font-medium"
|
||||
class="border-accent bg-accent text-on-accent inline-flex w-full items-center justify-center border px-4 py-2 font-serif text-sm"
|
||||
onclick={onclose}
|
||||
>
|
||||
Anmelden
|
||||
|
||||
@@ -39,14 +39,14 @@
|
||||
<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"
|
||||
class="text-ink-soft hover:text-ink flex items-center gap-1 font-serif text-sm transition-colors"
|
||||
onclick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
{user.display_name}
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform {open ? 'rotate-180' : ''}"
|
||||
class="h-3.5 w-3.5 transition-transform {open ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
@@ -58,12 +58,12 @@
|
||||
|
||||
{#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"
|
||||
class="border-rule-soft bg-surface absolute right-0 z-50 mt-2 w-44 border py-1 shadow-sm"
|
||||
role="menu"
|
||||
>
|
||||
<a
|
||||
href="/profile"
|
||||
class="text-primary-100 hover:bg-primary-700 block px-4 py-2 text-sm"
|
||||
class="text-ink hover:bg-surface-alt block px-4 py-2 font-serif text-sm"
|
||||
role="menuitem"
|
||||
onclick={close}
|
||||
>
|
||||
@@ -71,7 +71,7 @@
|
||||
</a>
|
||||
<a
|
||||
href="/profile/security"
|
||||
class="text-primary-100 hover:bg-primary-700 block px-4 py-2 text-sm"
|
||||
class="text-ink hover:bg-surface-alt block px-4 py-2 font-serif text-sm"
|
||||
role="menuitem"
|
||||
onclick={close}
|
||||
>
|
||||
@@ -79,10 +79,10 @@
|
||||
</a>
|
||||
|
||||
{#if user.role === 'admin'}
|
||||
<div class="border-primary-700 my-1 border-t"></div>
|
||||
<div class="border-rule-soft my-1 border-t"></div>
|
||||
<a
|
||||
href="/admin/maerkte"
|
||||
class="text-accent-300 hover:bg-primary-700 block px-4 py-2 text-sm"
|
||||
class="text-accent hover:bg-surface-alt block px-4 py-2 font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
role="menuitem"
|
||||
onclick={close}
|
||||
>
|
||||
@@ -90,11 +90,11 @@
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="border-primary-700 my-1 border-t"></div>
|
||||
<div class="border-rule-soft 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"
|
||||
class="text-ink-soft hover:bg-surface-alt hover:text-ink block w-full px-4 py-2 text-left font-serif text-sm"
|
||||
role="menuitem"
|
||||
>
|
||||
Abmelden
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
|
||||
interface Props {
|
||||
market: MarketSummary;
|
||||
@@ -27,10 +28,11 @@
|
||||
|
||||
<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"
|
||||
class="group border-rule-soft bg-surface block border transition-shadow hover:shadow-md"
|
||||
>
|
||||
<!-- Image / Logo / Heraldry hero -->
|
||||
{#if showImage}
|
||||
<div class="h-[150px] overflow-hidden rounded-t-lg">
|
||||
<div class="h-[150px] overflow-hidden">
|
||||
<img
|
||||
src={market.image_url}
|
||||
alt={market.name}
|
||||
@@ -42,82 +44,49 @@
|
||||
/>
|
||||
</div>
|
||||
{:else if showLogo}
|
||||
<img
|
||||
src={market.logo_url}
|
||||
alt={market.name}
|
||||
class="w-full rounded-t-lg"
|
||||
style="padding: 16px 16px 0; max-height: 150px; object-fit: contain;"
|
||||
loading="lazy"
|
||||
onerror={() => {
|
||||
logoFailed = true;
|
||||
}}
|
||||
/>
|
||||
<div class="bg-surface-alt flex h-[150px] items-center justify-center p-4">
|
||||
<img
|
||||
src={market.logo_url}
|
||||
alt={market.name}
|
||||
class="max-h-[120px] w-auto object-contain"
|
||||
loading="lazy"
|
||||
onerror={() => {
|
||||
logoFailed = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-[150px] items-center justify-center rounded-t-lg bg-gradient-to-br from-stone-800 to-stone-900 dark:from-stone-900 dark:to-stone-950"
|
||||
>
|
||||
<span class="text-5xl font-bold text-stone-600 uppercase select-none dark:text-stone-700">
|
||||
{market.city.charAt(0)}
|
||||
</span>
|
||||
<div class="bg-surface-alt h-[150px]">
|
||||
<Heraldry seed={market.slug ?? market.name} />
|
||||
</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"
|
||||
>
|
||||
<h3 class="font-display text-ink group-hover:text-accent text-xl leading-tight font-medium">
|
||||
{market.name}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-stone-500 dark:text-stone-400">
|
||||
<p class="text-ink-muted mt-1 font-mono text-[10px] tracking-[0.12em] uppercase">
|
||||
{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>
|
||||
<div class="text-ink-soft mt-3 flex flex-wrap items-center gap-3 font-serif text-sm">
|
||||
<span>
|
||||
{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">
|
||||
<span class="text-accent font-mono text-[10px] tracking-[0.1em] uppercase">
|
||||
+{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>
|
||||
<span class="text-ink-muted">
|
||||
{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>
|
||||
<p class="text-ink-muted mt-2 font-mono text-[10px] tracking-[0.1em] uppercase">
|
||||
{market.organizer_name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
@@ -22,7 +23,7 @@
|
||||
|
||||
let dialogEl = $state<HTMLDialogElement | null>(null);
|
||||
let loading = $state(false);
|
||||
let category = $state(form?.category ?? 'incorrect_data');
|
||||
let category = $state(untrack(() => form?.category ?? 'incorrect_data'));
|
||||
|
||||
$effect(() => {
|
||||
if (!dialogEl) return;
|
||||
@@ -71,7 +72,7 @@
|
||||
<div class="px-6 py-6">
|
||||
<Alert variant="success">Danke! Dein Feedback wurde übermittelt und wird geprüft.</Alert>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button variant="secondary" onclick={onClose}>Schließen</Button>
|
||||
<Button variant="outline" onclick={onClose}>Schließen</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -6,78 +6,126 @@
|
||||
interface Props {
|
||||
markets: MarketSummary[];
|
||||
class?: string;
|
||||
selected?: MarketSummary | null;
|
||||
onSelect?: (market: MarketSummary) => void;
|
||||
}
|
||||
|
||||
let { markets, class: className = '' }: Props = $props();
|
||||
let { markets, class: className = '', selected, onSelect }: Props = $props();
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
let map: LeafletMap | undefined;
|
||||
let map = $state<LeafletMap | undefined>(undefined);
|
||||
let tileLayer: import('leaflet').TileLayer | undefined;
|
||||
// slug → marker reference so sidebar clicks can fly to the right spot
|
||||
const markerRefs = new Map<string, import('leaflet').Marker>();
|
||||
|
||||
function isDark(): boolean {
|
||||
return document.documentElement.classList.contains('dark');
|
||||
}
|
||||
|
||||
function tileUrl(dark: boolean): string {
|
||||
return dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
}
|
||||
|
||||
const cartoAttribution =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
||||
|
||||
function makeDot(L: typeof import('leaflet')): import('leaflet').DivIcon {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: '<div style="width:10px;height:10px;border-radius:50%;background:var(--color-accent);border:2px solid var(--color-bg);box-shadow:0 1px 3px rgba(0,0,0,.4)"></div>',
|
||||
iconSize: [10, 10],
|
||||
iconAnchor: [5, 5]
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let link: HTMLLinkElement;
|
||||
let observer: MutationObserver;
|
||||
|
||||
(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'
|
||||
});
|
||||
// assign to $state variable so $effects that depend on `map` re-run
|
||||
map = L.map(mapContainer, { zoomControl: false }).setView([51.1657, 10.4515], 6);
|
||||
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
||||
|
||||
map = L.map(mapContainer).setView([51.1657, 10.4515], 6); // Germany center
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
tileLayer = L.tileLayer(tileUrl(isDark()), {
|
||||
attribution: cartoAttribution,
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
observer = new MutationObserver(() => tileLayer?.setUrl(tileUrl(isDark())));
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
updateMarkers(L, markets);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
map?.remove();
|
||||
link?.remove();
|
||||
observer?.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
markerRefs.clear();
|
||||
|
||||
if (items.length === 0) return;
|
||||
|
||||
const dot = makeDot(L);
|
||||
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}`);
|
||||
if (!m.latitude || !m.longitude) continue;
|
||||
const marker = L.marker([m.latitude, m.longitude], { icon: dot }).addTo(map!);
|
||||
marker.bindPopup(
|
||||
`<div style="font-family:var(--font-serif);min-width:160px;line-height:1.4">` +
|
||||
`<a href="/markt/${m.slug}" style="font-weight:600;text-decoration:none;color:inherit">${m.name}</a>` +
|
||||
`<br><span style="font-size:12px;opacity:.7">${m.city} · ${m.state}</span>` +
|
||||
`</div>`
|
||||
);
|
||||
if (onSelect) marker.on('click', () => onSelect(m));
|
||||
markerRefs.set(m.slug, marker);
|
||||
bounds.extend([m.latitude, m.longitude]);
|
||||
}
|
||||
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
||||
}
|
||||
}
|
||||
|
||||
// Re-draw markers when the markets list changes
|
||||
$effect(() => {
|
||||
if (map) {
|
||||
import('leaflet').then((L) => updateMarkers(L, markets));
|
||||
}
|
||||
});
|
||||
|
||||
// Fly to selected market when sidebar selection changes
|
||||
$effect(() => {
|
||||
if (!map || !selected) return;
|
||||
const marker = markerRefs.get(selected.slug);
|
||||
if (marker) {
|
||||
map.flyTo(marker.getLatLng(), Math.max((map as LeafletMap).getZoom(), 10), { duration: 0.5 });
|
||||
// open popup after the fly animation settles
|
||||
setTimeout(() => marker.openPopup(), 550);
|
||||
} else if (selected.latitude && selected.longitude) {
|
||||
map.flyTo([selected.latitude, selected.longitude], 10, { duration: 0.5 });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={mapContainer}
|
||||
class="h-[400px] w-full rounded-lg border border-stone-200 dark:border-stone-700 {className}"
|
||||
></div>
|
||||
<div bind:this={mapContainer} class="h-full w-full {className}"></div>
|
||||
|
||||
@@ -7,18 +7,13 @@
|
||||
}
|
||||
|
||||
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">
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt text-ink border-l-2 p-4 text-sm {variant === 'error'
|
||||
? 'border-accent'
|
||||
: ''}"
|
||||
role="alert"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
variant?: 'primary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
variant = 'outline',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
children,
|
||||
@@ -20,23 +20,19 @@
|
||||
}: 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';
|
||||
'inline-flex items-center justify-center font-serif transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent focus-visible:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer';
|
||||
|
||||
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',
|
||||
primary: 'bg-accent text-on-accent border border-accent hover:bg-accent-soft',
|
||||
outline: 'bg-transparent text-ink border border-ink hover:bg-surface-alt',
|
||||
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'
|
||||
'bg-transparent text-ink-soft border border-transparent hover:text-ink hover:bg-surface-alt'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base'
|
||||
sm: 'px-3.5 py-1.5 text-sm',
|
||||
md: 'px-5 py-2 text-base',
|
||||
lg: 'px-7 py-2.5 text-lg'
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
|
||||
<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
|
||||
<label
|
||||
for={inputId}
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase">{label}</label
|
||||
>
|
||||
{/if}
|
||||
<input
|
||||
@@ -26,6 +27,12 @@
|
||||
{...rest}
|
||||
/>
|
||||
{#if error}
|
||||
<p id={errorId} class="text-danger-600 dark:text-danger-400 text-sm" role="alert">{error}</p>
|
||||
<p
|
||||
id={errorId}
|
||||
class="text-accent font-mono text-[10px] tracking-[0.1em] uppercase"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -166,8 +166,9 @@
|
||||
|
||||
<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
|
||||
<label
|
||||
for={selectId}
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase">{label}</label
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -184,17 +185,14 @@
|
||||
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'}"
|
||||
class="border-rule-soft bg-surface focus:border-accent focus:ring-accent flex w-full items-center justify-between border px-3 py-2
|
||||
text-left text-sm shadow-sm transition-colors focus:ring-1 focus:outline-none
|
||||
{error ? 'border-accent-soft' : ''}
|
||||
{selectedLabel ? 'text-ink' : 'text-ink-muted'}"
|
||||
>
|
||||
<span class="truncate">{selectedLabel || placeholder}</span>
|
||||
<span class="truncate font-serif">{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'
|
||||
: ''}"
|
||||
class="text-ink-muted ml-2 h-4 w-4 shrink-0 transition-transform {open ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
@@ -212,7 +210,7 @@
|
||||
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"
|
||||
class="border-rule-soft bg-surface absolute z-40 mt-1 max-h-60 w-full overflow-auto border py-1 text-sm shadow-md focus:outline-none"
|
||||
>
|
||||
{#each options as opt, i}
|
||||
<li
|
||||
@@ -225,22 +223,16 @@
|
||||
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'
|
||||
: ''}"
|
||||
class="cursor-pointer px-3 py-2 font-serif transition-colors select-none
|
||||
{i === activeIndex ? 'bg-surface-alt text-ink' : ''}
|
||||
{opt.value === value && i !== activeIndex ? 'text-accent font-medium' : ''}
|
||||
{opt.value !== value && i !== activeIndex ? 'text-ink hover:bg-surface-alt' : ''}"
|
||||
>
|
||||
<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"
|
||||
class="text-accent h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
@@ -261,6 +253,12 @@
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p id={errorId} class="text-danger-600 dark:text-danger-400 text-sm" role="alert">{error}</p>
|
||||
<p
|
||||
id={errorId}
|
||||
class="text-accent font-mono text-[10px] tracking-[0.1em] uppercase"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="text-primary-600 animate-spin {sizes[size]}"
|
||||
class="text-accent animate-spin {sizes[size]}"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
const modes: ThemeMode[] = ['system', 'light', 'dark'];
|
||||
const labels: Record<ThemeMode, string> = {
|
||||
system: 'System',
|
||||
system: 'Auto',
|
||||
light: 'Hell',
|
||||
dark: 'Dunkel'
|
||||
};
|
||||
@@ -19,57 +19,9 @@
|
||||
<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"
|
||||
class="text-ink-muted hover:text-ink focus-visible:ring-accent font-mono text-[10px] tracking-[0.15em] uppercase transition-colors focus-visible:ring-1 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}
|
||||
{labels[$theme]}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"articles": [
|
||||
{
|
||||
"slug": "das-handwerk-des-schwertschmieds",
|
||||
"title": "Das Handwerk des Schwertschmieds",
|
||||
"subtitle": "Zwischen Amboss und Feuer — ein Tag in der Werkstatt von Konrad Brenner",
|
||||
"category": "Handwerk",
|
||||
"date": "2026-04-12",
|
||||
"excerpt": "Seit dreißig Jahren schmiedet Konrad Brenner Schwerter für Mittelaltermärkte in ganz Europa. Wir haben ihn in seiner Werkstatt in Dinkelsbühl besucht und zugeschaut.",
|
||||
"image_placeholder": "handwerk"
|
||||
},
|
||||
{
|
||||
"slug": "lager-aufbauen-checkliste",
|
||||
"title": "Lager aufbauen in 4 Stunden",
|
||||
"subtitle": "Die bewährte Checkliste des Compagnie du Cerf Rouge",
|
||||
"category": "Praxis",
|
||||
"date": "2026-03-28",
|
||||
"excerpt": "Wer ein Lager auf dem Markt aufbaut, kennt das Chaos der ersten Stunden. Die Compagnie du Cerf Rouge hat ihre Routine über Jahre verfeinert und teilt sie hier.",
|
||||
"image_placeholder": "praxis"
|
||||
},
|
||||
{
|
||||
"slug": "historische-stoffe-1350",
|
||||
"title": "Stoffe des 14. Jahrhunderts",
|
||||
"subtitle": "Was ist historisch korrekt — und was sieht nur so aus?",
|
||||
"category": "Recherche",
|
||||
"date": "2026-03-10",
|
||||
"excerpt": "Wollköper, Leinen, gelegentlich Seide: Die Auswahl historisch korrekter Stoffe ist kleiner als der Markt suggeriert. Ein Überblick über Quellen und Fallstricke.",
|
||||
"image_placeholder": "recherche"
|
||||
},
|
||||
{
|
||||
"slug": "kinder-im-lager",
|
||||
"title": "Kinder im Lager",
|
||||
"subtitle": "Wie Familien das Lagerleben gestalten — ohne auf Authentizität zu verzichten",
|
||||
"category": "Gemeinschaft",
|
||||
"date": "2026-02-20",
|
||||
"excerpt": "Immer mehr Familien sind Teil der Mittelalterszene. Was bedeutet das für das Lagerkonzept, den Marktablauf und die Gemeinschaft?",
|
||||
"image_placeholder": "gemeinschaft"
|
||||
}
|
||||
],
|
||||
"camps": [
|
||||
{
|
||||
"slug": "compagnie-du-cerf-rouge",
|
||||
"name": "Compagnie du Cerf Rouge",
|
||||
"region": "Bayern",
|
||||
"period": "um 1350",
|
||||
"excerpt": "Lebendige Darstellung eines fahrenden Söldnertrupps des 14. Jahrhunderts. Schwerpunkt: textile Handarbeit, Feldkochen und Waffenkunde.",
|
||||
"members": 14
|
||||
},
|
||||
{
|
||||
"slug": "lagergemeinschaft-nordmark",
|
||||
"name": "Lagergemeinschaft Nordmark",
|
||||
"region": "Schleswig-Holstein",
|
||||
"period": "Wikingerzeit",
|
||||
"excerpt": "Gemeinschaft zur Darstellung des wikingerzeitlichen Alltags auf skandinavischen und deutschen Märkten.",
|
||||
"members": 22
|
||||
},
|
||||
{
|
||||
"slug": "familia-von-hohenstein",
|
||||
"name": "Familia von Hohenstein",
|
||||
"region": "Baden-Württemberg",
|
||||
"period": "Hochmittelalter",
|
||||
"excerpt": "Adelige Haushaltung mit vollständiger Küchenausstattung, Weberei und Kinderdarstellung. Besonderen Wert legen wir auf quellenbasierte Kostümierung.",
|
||||
"members": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<a href="#main-content" class="skip-link">Zum Inhalt springen</a>
|
||||
|
||||
<div class="bg-parchment flex min-h-screen flex-col">
|
||||
<div class="bg-bg text-ink flex min-h-screen flex-col font-serif">
|
||||
<Header user={data.user} />
|
||||
<main id="main-content" class="flex-1" tabindex="-1">
|
||||
{@render children()}
|
||||
|
||||
@@ -1,81 +1,33 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { apiFetch, buildSearchQuery } from '$lib/api/client.js';
|
||||
import { apiFetch } from '$lib/api/client.js';
|
||||
import type { MarketSummary, PaginationMeta } from '$lib/api/types.js';
|
||||
|
||||
type Coords = { lat?: string; lon?: string };
|
||||
|
||||
async function resolveCoords(
|
||||
plz: string | null,
|
||||
urlLat: string | null,
|
||||
urlLon: string | null,
|
||||
fetch: typeof globalThis.fetch
|
||||
): Promise<Coords> {
|
||||
if (urlLat && urlLon) return { lat: urlLat, lon: urlLon };
|
||||
if (!plz) return {};
|
||||
|
||||
try {
|
||||
const res = await apiFetch<{ latitude: number | null; longitude: number | null }>('/geocode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ city: '', zip: plz, country: 'DE' }),
|
||||
fetch
|
||||
});
|
||||
const { latitude, longitude } = res.data;
|
||||
if (latitude == null || longitude == null) return {};
|
||||
return { lat: String(latitude), lon: String(longitude) };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
const q = url.searchParams.get('q');
|
||||
const plz = url.searchParams.get('plz');
|
||||
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');
|
||||
function daysFromNow(n: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + n);
|
||||
return isoDate(d);
|
||||
}
|
||||
|
||||
const coords = await resolveCoords(plz, lat, lon, fetch);
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const fromDate = isoDate(new Date());
|
||||
const weekendTo = daysFromNow(8);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (q) params.q = q;
|
||||
if (coords.lat) params.lat = coords.lat;
|
||||
if (coords.lon) params.lon = coords.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 [weekendRes, seasonRes, totalRes] = await Promise.allSettled([
|
||||
apiFetch<MarketSummary[]>(`/markets?from=${fromDate}&to=${weekendTo}&per_page=4`, { fetch }),
|
||||
apiFetch<MarketSummary[]>(`/markets?from=${fromDate}&per_page=6`, { fetch }),
|
||||
apiFetch<MarketSummary[]>('/markets?per_page=1', { fetch })
|
||||
]);
|
||||
|
||||
const query = buildSearchQuery(params);
|
||||
const path = `/markets${query ? `?${query}` : ''}`;
|
||||
const weekendMarkets = weekendRes.status === 'fulfilled' ? weekendRes.value.data : [];
|
||||
const seasonMarkets = seasonRes.status === 'fulfilled' ? seasonRes.value.data : [];
|
||||
const total =
|
||||
totalRes.status === 'fulfilled'
|
||||
? ((totalRes.value.meta as PaginationMeta | undefined)?.total ?? 0)
|
||||
: 0;
|
||||
|
||||
const searchParams = {
|
||||
q: q ?? '',
|
||||
plz: plz ?? '',
|
||||
lat: lat ? Number(lat) : undefined,
|
||||
lon: lon ? Number(lon) : undefined,
|
||||
radius: radius ? Number(radius) : 25,
|
||||
from: from ?? '',
|
||||
to: to ?? '',
|
||||
sort: sort ?? ''
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiFetch<MarketSummary[]>(path, { fetch });
|
||||
return {
|
||||
markets: res.data,
|
||||
meta: res.meta as PaginationMeta,
|
||||
searchParams
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
markets: [] as MarketSummary[],
|
||||
meta: { page: 1, per_page: 20, total: 0, total_pages: 0 } as PaginationMeta,
|
||||
searchParams
|
||||
};
|
||||
}
|
||||
return { weekendMarkets, seasonMarkets, total };
|
||||
};
|
||||
|
||||
@@ -1,125 +1,392 @@
|
||||
<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';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let view = $state<'list' | 'map'>('list');
|
||||
function fmtDay(iso: string): string {
|
||||
return String(new Date(iso).getUTCDate());
|
||||
}
|
||||
function fmtMonthShort(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', { month: 'short', timeZone: 'UTC' });
|
||||
}
|
||||
function fmtDateRange(from: string, to: string): string {
|
||||
const fDay = new Date(from).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
const tDay = new Date(to).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
return `${fDay} – ${tDay}`;
|
||||
}
|
||||
function padNum(n: number): string {
|
||||
return String(n).padStart(3, '0');
|
||||
}
|
||||
|
||||
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>';
|
||||
// Compute next weekend label
|
||||
const now = new Date();
|
||||
const daysToSat = (6 - now.getDay() + 7) % 7 || 7;
|
||||
const sat = new Date(now);
|
||||
sat.setDate(now.getDate() + daysToSat);
|
||||
const sun = new Date(sat);
|
||||
sun.setDate(sat.getDate() + 1);
|
||||
const weekendLabel = `${sat.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' })} – ${sun.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })}`;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Marktvogt - Mittelaltermärkte finden</title>
|
||||
<meta property="og:title" content="Marktvogt - Mittelaltermärkte finden" />
|
||||
<title>Marktvogt — Verzeichnis historischer Märkte</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Das deutschsprachige Verzeichnis historischer Märkte — Spektakel, Lichterfeste und Lagerleben, gepflegt von einem, der hingeht."
|
||||
/>
|
||||
<meta property="og:title" content="Marktvogt — Verzeichnis historischer Märkte" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Finde Mittelaltermärkte, Ritterturniere und historische Feste in deiner Nähe. Suche nach Ort, Datum oder Stichwort."
|
||||
content="Das deutschsprachige Verzeichnis historischer Märkte — Spektakel, Lichterfeste und Lagerleben."
|
||||
/>
|
||||
<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
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
HERO — editorial
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="bg-bg relative overflow-hidden px-10 pt-[100px] pb-[90px]">
|
||||
<span
|
||||
class="font-display text-accent absolute top-[60px] left-[12%] text-[32px] opacity-40"
|
||||
aria-hidden="true">✦</span
|
||||
>
|
||||
<span
|
||||
class="font-display text-accent absolute right-[14%] bottom-[80px] text-[24px] opacity-30"
|
||||
aria-hidden="true">✦</span
|
||||
>
|
||||
|
||||
<div class="relative mx-auto max-w-[1200px] text-center">
|
||||
<Caps>Saisonal kuratiert · Anno {new Date().getFullYear()} · {data.total} Einträge</Caps>
|
||||
|
||||
<h1
|
||||
class="font-display text-ink mt-[26px] mb-[22px] text-[clamp(56px,10vw,132px)] leading-[0.92] font-[500] tracking-[-0.015em]"
|
||||
>
|
||||
Wo das <em class="text-accent italic">Mittelalter</em><br />
|
||||
nach Glühmet riecht.
|
||||
</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
|
||||
class="text-ink-soft mx-auto mb-9 max-w-[680px] font-serif text-[22px] leading-[1.45] italic"
|
||||
>
|
||||
Das deutschsprachige Verzeichnis historischer Märkte — Spektakel, Lichterfeste und
|
||||
Lager­leben, gepflegt von einem, der hingeht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchForm
|
||||
q={data.searchParams.q}
|
||||
plz={data.searchParams.plz}
|
||||
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"
|
||||
<span class="inline-flex items-center gap-[14px]">
|
||||
<a
|
||||
href="/maerkte"
|
||||
class="border-accent bg-accent text-on-accent border px-[26px] py-[13px] font-serif text-[15px] font-[500] tracking-[0.01em] no-underline"
|
||||
>
|
||||
<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>
|
||||
Was läuft dieses Wochenende ›
|
||||
</a>
|
||||
<a
|
||||
href="/maerkte"
|
||||
class="border-ink text-ink border bg-transparent px-[26px] py-[13px] font-serif text-[15px] font-[500] tracking-[0.01em] no-underline"
|
||||
>
|
||||
Verzeichnis durchstöbern
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<div class="mt-14">
|
||||
<Rule kind="ornament" />
|
||||
</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} class="h-[600px]" />
|
||||
{/if}
|
||||
|
||||
{#if view === 'list' && 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 class="mt-9 flex justify-center gap-14">
|
||||
{#each [[String(data.total), 'Märkte'], ['16', 'Regionen'], ['52', 'Wochen'], ['seit 2019', 'kuratiert']] as [n, l]}
|
||||
<span class="text-center">
|
||||
<div class="font-display text-ink text-[36px] leading-none font-[500]">{n}</div>
|
||||
<Caps size={10} class="mt-1 block">{l}</Caps>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
WOCHENENDE
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
{#if data.weekendMarkets.length > 0}
|
||||
<section class="border-rule-soft bg-bg border-t px-10 py-[90px]">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
<div class="mb-9 flex items-end justify-between">
|
||||
<span>
|
||||
<Caps>Frisch im Kalender · {weekendLabel}</Caps>
|
||||
<h2
|
||||
class="font-display text-ink mt-3 mb-1 text-[clamp(36px,5vw,64px)] leading-none font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Was läuft <em class="text-accent italic">dieses Wochenende</em>
|
||||
</h2>
|
||||
<div class="font-display text-ink-soft text-[18px] italic">
|
||||
{data.weekendMarkets.length}
|
||||
{data.weekendMarkets.length === 1 ? 'Markt' : 'Märkte'} in den nächsten Tagen.
|
||||
</div>
|
||||
</span>
|
||||
<a
|
||||
href="/maerkte"
|
||||
class="border-accent text-accent border-b pb-0.5 font-serif text-[15px] whitespace-nowrap no-underline"
|
||||
>Alle anzeigen ›</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 lg:grid-cols-4">
|
||||
{#each data.weekendMarkets as market, i}
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="border-rule-soft bg-surface block border no-underline"
|
||||
>
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt relative border-b"
|
||||
style="aspect-ratio: 1.3 / 1"
|
||||
>
|
||||
<span class="absolute inset-0 flex items-center justify-center" aria-hidden="true">
|
||||
<span class="h-[75%] w-[55%]">
|
||||
<Heraldry seed={market.slug} class="h-full w-full" />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="border-rule-soft bg-bg absolute top-3 left-3 border px-2.5 py-1.5 text-center"
|
||||
>
|
||||
<div class="font-display text-accent text-[24px] leading-none font-[500]">
|
||||
{fmtDay(market.start_date)}
|
||||
</div>
|
||||
<Caps size={9}>{fmtMonthShort(market.start_date)}</Caps>
|
||||
</span>
|
||||
<Caps size={9} class="absolute top-3.5 right-3">№ {padNum(i + 1)}</Caps>
|
||||
</div>
|
||||
<div class="px-5 pt-[18px] pb-[22px]">
|
||||
<div class="font-display text-ink text-[22px] leading-[1.1] font-[500]">
|
||||
{market.name}
|
||||
</div>
|
||||
<div class="font-display text-ink-soft mt-1 text-[14px] italic">
|
||||
{market.city} · {market.state}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
SAISONBLOCK
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
{#if data.seasonMarkets.length > 0}
|
||||
{@const lead = data.seasonMarkets[0]}
|
||||
{@const secondaries = data.seasonMarkets.slice(1, 5)}
|
||||
<section class="border-rule-soft bg-surface-alt border-y px-10 py-[100px]">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
<div class="mb-14 text-center">
|
||||
<Caps>Aktuelle Saison · Demnächst</Caps>
|
||||
<h2
|
||||
class="font-display text-ink mt-[18px] mb-4 text-[clamp(44px,7vw,88px)] leading-[0.95] font-[500] tracking-[-0.01em] italic"
|
||||
>
|
||||
Der Saisonauftakt
|
||||
</h2>
|
||||
<p class="text-ink-soft mx-auto max-w-[640px] font-serif text-[19px] leading-[1.5] italic">
|
||||
Wenn die ersten Burgtore wieder öffnen — Ostermärkte, Lenzfeste und Saisonauftakte.
|
||||
</p>
|
||||
<div class="mt-7">
|
||||
<Rule kind="ornament" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-[1.4fr_1fr_1fr]">
|
||||
<!-- lead — spans 2 rows on md+ -->
|
||||
<a
|
||||
href="/markt/{lead.slug}"
|
||||
class="border-rule-soft bg-bg flex flex-col border font-serif no-underline md:row-span-2"
|
||||
>
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt relative overflow-hidden border-b"
|
||||
style="aspect-ratio: 5 / 4"
|
||||
>
|
||||
<span class="absolute inset-0 flex items-center justify-center" aria-hidden="true">
|
||||
<span class="h-[78%] w-[60%]">
|
||||
<Heraldry seed={lead.slug} class="h-full w-full" />
|
||||
</span>
|
||||
</span>
|
||||
<Caps
|
||||
size={9}
|
||||
color="var(--color-on-accent)"
|
||||
class="bg-accent absolute top-4 left-4 px-2 py-1">Empfohlen</Caps
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col px-7 pt-[26px] pb-[30px]">
|
||||
<Caps size={10} color="var(--color-accent)"
|
||||
>{fmtDateRange(lead.start_date, lead.end_date)}</Caps
|
||||
>
|
||||
<div class="font-display text-ink mt-2 text-[36px] leading-[1.05] font-[500] italic">
|
||||
{lead.name}
|
||||
</div>
|
||||
<div class="font-display text-ink-soft mt-1.5 text-[16px] italic">
|
||||
{lead.city} · {lead.state}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- secondaries -->
|
||||
{#each secondaries as market}
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="border-rule-soft bg-bg flex gap-4 border p-5 no-underline"
|
||||
>
|
||||
<span class="w-20 flex-shrink-0">
|
||||
<span class="relative block" style="aspect-ratio: 1 / 1.15">
|
||||
<Heraldry seed={market.slug} class="h-full w-full" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex-1">
|
||||
<Caps size={9} color="var(--color-accent)"
|
||||
>{new Date(market.start_date).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: 'UTC'
|
||||
})}</Caps
|
||||
>
|
||||
<div class="font-display text-ink mt-1 text-[19px] leading-[1.15] font-[500]">
|
||||
{market.name}
|
||||
</div>
|
||||
<div class="font-display text-ink-soft mt-0.5 text-[13px] italic">
|
||||
{market.city} · {market.state}
|
||||
</div>
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-10 text-center">
|
||||
<a
|
||||
href="/maerkte"
|
||||
class="border-ink text-ink border bg-transparent px-[26px] py-[13px] font-serif text-[15px] font-[500] no-underline"
|
||||
>
|
||||
Alle Märkte im Verzeichnis ›
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
MANIFEST
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="bg-bg px-10 py-[120px]">
|
||||
<div class="mx-auto max-w-[880px] text-center">
|
||||
<Caps>Über Marktvogt · Manifest</Caps>
|
||||
<blockquote
|
||||
class="font-display text-ink my-8 text-[clamp(28px,4.5vw,52px)] leading-[1.15] font-normal tracking-[-0.005em] italic"
|
||||
style="text-wrap: balance"
|
||||
>
|
||||
„Ein Verzeichnis ist kein Algorithmus. Es ist <span class="text-accent">jemand</span>, der
|
||||
hingeht, zuhört und ehrlich aufschreibt, ob es sich gelohnt hat."
|
||||
</blockquote>
|
||||
<div class="mt-8 flex flex-col items-center gap-1.5">
|
||||
<span class="text-ink font-serif text-[14px] italic">— Hannes, der Marktvogt</span>
|
||||
<Caps size={9}>Hessen · Met-Brauer · Lager­gänger seit 2003</Caps>
|
||||
<span class="text-ink-muted mt-1 font-serif text-[12px] italic">
|
||||
✦ Hannes ist eine Kunstfigur — die Redaktion arbeitet kollektiv.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
NEWSLETTER
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="border-rule-soft bg-surface-alt border-y px-10 py-[90px]">
|
||||
<div class="mx-auto grid max-w-[980px] grid-cols-1 items-center gap-[60px] md:grid-cols-2">
|
||||
<span>
|
||||
<Caps>Saisonbrief · viermal im Jahr</Caps>
|
||||
<h2
|
||||
class="font-display text-ink mt-4 mb-3.5 text-[clamp(36px,4.5vw,56px)] leading-none font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Der <em class="text-accent italic">Saisonbrief</em>.
|
||||
</h2>
|
||||
<p class="text-ink-soft font-serif text-[17px] leading-[1.55] italic">
|
||||
Vier Briefe im Jahr — zu Imbolc, vor Pfingsten, im Hochsommer, vor dem ersten Schnee. Was
|
||||
lohnt sich, was wird neu, wo war ich gerade. Kein Marketing.
|
||||
</p>
|
||||
</span>
|
||||
<span class="border-rule-soft bg-bg border p-8">
|
||||
<Caps size={9} class="mb-2.5 block">E-Mail-Adresse</Caps>
|
||||
<span class="border-ink flex border">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="elsa@beispiel.de"
|
||||
class="bg-bg text-ink-muted flex-1 px-4 py-3.5 font-serif text-[15px] italic outline-none"
|
||||
disabled
|
||||
aria-label="E-Mail für Saisonbrief"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="border-ink bg-ink text-bg cursor-not-allowed border-l px-6 font-mono text-[11px] font-[500] tracking-[0.18em] uppercase"
|
||||
disabled
|
||||
>
|
||||
Abonnieren
|
||||
</button>
|
||||
</span>
|
||||
<div class="text-ink-muted mt-4 font-serif text-[12px] leading-[1.5] italic">
|
||||
Keine Werbung, kein Tracking. Abmelden mit einem Klick. Demnächst verfügbar.
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
SUBMIT CTA
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="bg-ink text-bg relative overflow-hidden px-10 py-[100px]">
|
||||
<span
|
||||
class="absolute top-[-20px] right-[-40px] h-[380px] w-[320px] opacity-[0.06]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Heraldry seed="submit-watermark" class="h-full w-full" />
|
||||
</span>
|
||||
<div
|
||||
class="relative mx-auto grid max-w-[1100px] grid-cols-1 items-center gap-[60px] md:grid-cols-[1.4fr_1fr]"
|
||||
>
|
||||
<span>
|
||||
<Caps color="var(--color-accent)">Für Veranstalter</Caps>
|
||||
<h2
|
||||
class="font-display text-bg mt-4 mb-[18px] text-[clamp(36px,5.5vw,68px)] leading-none font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Veranstalten Sie einen Markt?
|
||||
</h2>
|
||||
<p class="text-bg/70 mb-7 max-w-[560px] font-serif text-[18px] leading-[1.55] italic">
|
||||
Eintrag im Verzeichnis ist kostenlos und bleibt es. Wir prüfen jeden Markt redaktionell —
|
||||
kein Vermittlungsgeschäft, keine Provision.
|
||||
</p>
|
||||
<a
|
||||
href="/markt/einreichen"
|
||||
class="border-accent bg-accent text-on-accent border px-7 py-3.5 font-serif text-[16px] font-[500] tracking-[0.01em] no-underline"
|
||||
>
|
||||
Markt einreichen ›
|
||||
</a>
|
||||
</span>
|
||||
<span class="border-bg/15 border-l pl-10">
|
||||
<Caps size={10} color="rgba(245,239,228,0.65)" class="mb-[18px] block">So läuft's</Caps>
|
||||
{#each [['I.', 'Formular ausfüllen', 'Eckdaten, Veranstalter, Stilrichtung.'], ['II.', 'Redaktionelle Prüfung', 'Wir lesen, manchmal kommen wir vorbei.'], ['III.', 'Eintrag geht live', 'In der Regel binnen 5 Werktagen.']] as [n, h, b]}
|
||||
<span class="border-bg/15 flex gap-4 border-t py-3.5">
|
||||
<span class="font-display text-accent min-w-[32px] text-[22px] leading-none italic"
|
||||
>{n}</span
|
||||
>
|
||||
<span>
|
||||
<div class="text-bg mb-0.5 font-serif text-[16px] font-[500]">{h}</div>
|
||||
<div class="text-bg/70 font-serif text-[13px] leading-[1.5]">{b}</div>
|
||||
</span>
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -133,10 +133,10 @@
|
||||
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>
|
||||
<Button type="submit" variant="outline" size="sm">Suchen</Button>
|
||||
{#if currentQ}
|
||||
<a href={buildUrl({ q: '' })}>
|
||||
<Button type="button" variant="secondary" size="sm">Zurücksetzen</Button>
|
||||
<Button type="button" variant="outline" size="sm">Zurücksetzen</Button>
|
||||
</a>
|
||||
{/if}
|
||||
</form>
|
||||
@@ -365,12 +365,12 @@
|
||||
<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>
|
||||
<Button variant="outline" 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>
|
||||
<Button variant="outline" size="sm">Weiter</Button>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
+ Neue Edition
|
||||
</button>
|
||||
<a href="/admin/maerkte/{data.market.id}/bearbeiten">
|
||||
<Button variant="secondary" size="sm">Bearbeiten</Button>
|
||||
<Button variant="outline" size="sm">Bearbeiten</Button>
|
||||
</a>
|
||||
<form
|
||||
method="POST"
|
||||
@@ -232,7 +232,7 @@
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button variant="danger" size="sm" type="submit" {loading}>Löschen</Button>
|
||||
<Button variant="primary" size="sm" type="submit" {loading}>Löschen</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,7 +385,7 @@
|
||||
</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}>
|
||||
<Button type="submit" name="status" value="rejected" variant="primary" {loading}>
|
||||
Ablehnen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<h1 class="mt-1 text-2xl font-bold">Markt bearbeiten</h1>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<Button type="button" variant="secondary" loading={researching} onclick={runResearchPlan}>
|
||||
<Button type="button" variant="outline" loading={researching} onclick={runResearchPlan}>
|
||||
Mit KI recherchieren
|
||||
</Button>
|
||||
{#if planError}
|
||||
|
||||
@@ -11,26 +11,26 @@
|
||||
</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>
|
||||
<h1 class="font-display text-ink mb-8 text-center text-[32px] font-[500]">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"
|
||||
>
|
||||
<div class="border-rule-soft bg-surface border p-8">
|
||||
<div class="border-rule-soft mb-6 flex border">
|
||||
<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'}"
|
||||
class="flex-1 px-3 py-2 font-mono text-[11px] tracking-[0.12em] uppercase transition-colors {tab ===
|
||||
'password'
|
||||
? 'bg-ink text-bg'
|
||||
: 'bg-bg text-ink-muted hover:text-ink'}"
|
||||
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'}"
|
||||
class="flex-1 px-3 py-2 font-mono text-[11px] tracking-[0.12em] uppercase transition-colors {tab ===
|
||||
'magic'
|
||||
? 'bg-ink text-bg'
|
||||
: 'bg-bg text-ink-muted hover:text-ink'}"
|
||||
onclick={() => (tab = 'magic')}
|
||||
>
|
||||
Magic Link
|
||||
@@ -45,12 +45,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
|
||||
<p class="text-ink-muted mt-6 text-center font-serif text-sm">
|
||||
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
|
||||
>
|
||||
<a href="/auth/registrieren" class="text-accent font-[500]">Registrieren</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -9,20 +9,14 @@
|
||||
</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>
|
||||
<h1 class="font-display text-ink mb-8 text-center text-[32px] font-[500]">Konto erstellen</h1>
|
||||
|
||||
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
|
||||
<div class="border-rule-soft bg-surface border p-8">
|
||||
<RegisterForm error={form?.error} />
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
|
||||
<p class="text-ink-muted mt-6 text-center font-serif text-sm">
|
||||
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
|
||||
>
|
||||
<a href="/auth/anmelden" class="text-accent font-[500]">Anmelden</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,443 +1,512 @@
|
||||
<script lang="ts">
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Datenschutzerklärung - Marktvogt</title>
|
||||
<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:title" content="Datenschutzerklärung — 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">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:contact@marktvogt.de"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>contact@marktvogt.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
2. Überblick der Verarbeitungen
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Marktvogt ist ein Verzeichnis für Mittelaltermärkte und historische Feste. Wir verarbeiten
|
||||
personenbezogene Daten nur, soweit dies zur Bereitstellung der Funktionen unserer Website
|
||||
erforderlich ist. Die Verarbeitung erfolgt auf Grundlage der DSGVO
|
||||
(Datenschutz-Grundverordnung).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">3. Hosting</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Diese Website wird auf Infrastruktur von <strong>itsh.dev</strong> gehostet. Beim Aufruf unserer
|
||||
Website werden durch den Hostinganbieter automatisch Informationen in sogenannten Server-Logfiles
|
||||
erfasst. Dazu gehören:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>IP-Adresse des zugreifenden Geräts</li>
|
||||
<li>Datum und Uhrzeit der Anfrage</li>
|
||||
<li>HTTP-Methode und aufgerufene URL</li>
|
||||
<li>HTTP-Statuscode</li>
|
||||
<li>Antwortzeit des Servers</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Diese Daten werden zur Sicherstellung eines störungsfreien Betriebs erhoben und zur Erkennung
|
||||
von Missbrauch ausgewertet. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes
|
||||
Interesse).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
4. Registrierung und Benutzerkonto
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie können auf unserer Website ein Benutzerkonto erstellen. Dabei werden folgende Daten
|
||||
verarbeitet:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li><strong>E-Mail-Adresse</strong> – zur Identifikation und Kommunikation</li>
|
||||
<li>
|
||||
<strong>Passwort</strong> – wird ausschließlich als bcrypt-Hash gespeichert; das Klartext-Passwort
|
||||
wird nicht gespeichert
|
||||
</li>
|
||||
<li><strong>Anzeigename</strong> – frei wählbarer Name zur Darstellung im Profil</li>
|
||||
<li><strong>Profilbild-URL</strong> – sofern über einen OAuth-Anbieter bereitgestellt</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) sowie
|
||||
zur Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO). Ihr Konto kann jederzeit gelöscht werden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
5. Anmeldung über Drittanbieter (OAuth)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie können sich mit einem bestehenden Konto bei folgenden Anbietern anmelden:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Google</strong> – Abgerufene Daten: E-Mail-Adresse, Name, Profilbild, E-Mail-Verifizierungsstatus
|
||||
</li>
|
||||
<li>
|
||||
<strong>GitHub</strong> – Abgerufene Daten: E-Mail-Adresse (primäre, verifizierte E-Mail)
|
||||
</li>
|
||||
<li><strong>Facebook</strong> – Abgerufene Daten: E-Mail-Adresse, Name, Profilbild</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Wir speichern die vom Anbieter übermittelten Daten (Anbieter-ID, Name, E-Mail) sowie ein
|
||||
Zugriffstoken zur Verifizierung der Verknüpfung. Die OAuth-Anmeldung erfolgt auf Grundlage
|
||||
Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Sie können die Verknüpfung jederzeit in Ihren
|
||||
Profileinstellungen aufheben. Bitte beachten Sie die Datenschutzerklärungen der jeweiligen
|
||||
Anbieter:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<a
|
||||
href="https://policies.google.com/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Google Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>GitHub Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.facebook.com/privacy/policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Facebook Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
6. Magic-Link-Anmeldung
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie können sich über einen per E-Mail versendeten Einmal-Link (Magic Link) anmelden. Dabei
|
||||
wird Ihre E-Mail-Adresse verarbeitet und ein einmalig gültiger, zeitlich begrenzter Token (15
|
||||
Minuten) erzeugt. Der Token wird als SHA-256-Hash gespeichert und nach Verwendung ungültig.
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
7. Sitzungsverwaltung (Sessions)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Nach der Anmeldung wird eine Sitzung erstellt. Dabei werden folgende Daten gespeichert:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li><strong>IP-Adresse</strong> – zum Zeitpunkt der Sitzungserstellung</li>
|
||||
<li><strong>User-Agent</strong> – Browserkennung zum Zeitpunkt der Anmeldung</li>
|
||||
<li><strong>Sitzungstoken</strong> – als SHA-256-Hash gespeichert</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sitzungen laufen nach 30 Tagen automatisch ab. Die Speicherung dient der Sicherheit Ihres
|
||||
Kontos (Erkennung ungewöhnlicher Anmeldeaktivitäten). Rechtsgrundlage ist Art. 6 Abs. 1 lit. f
|
||||
DSGVO (berechtigtes Interesse an der Kontosicherheit).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
8. Zwei-Faktor-Authentifizierung (2FA)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Optional können Sie die Zwei-Faktor-Authentifizierung über ein TOTP-Verfahren (z. B.
|
||||
Google Authenticator) aktivieren. Dabei wird ein kryptografisches Geheimnis (TOTP-Secret) mit
|
||||
Ihrem Konto verknüpft gespeichert. Dieses wird bei Deaktivierung der 2FA oder Löschung des
|
||||
Kontos gelöscht.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
9. Cookies und lokale Speicherung
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Wir verwenden ausschließlich technisch notwendige Cookies:
|
||||
</p>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-stone-200 dark:border-stone-700">
|
||||
<th class="py-2 pr-4 text-left font-semibold text-stone-900 dark:text-stone-100"
|
||||
>Name</th
|
||||
>
|
||||
<th class="py-2 pr-4 text-left font-semibold text-stone-900 dark:text-stone-100"
|
||||
>Zweck</th
|
||||
>
|
||||
<th class="py-2 pr-4 text-left font-semibold text-stone-900 dark:text-stone-100"
|
||||
>Dauer</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 pr-4 font-mono text-stone-700 dark:text-stone-300">access_token</td>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300"
|
||||
>Zugriffstoken für authentifizierte Anfragen</td
|
||||
>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300">30 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">refresh_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>
|
||||
<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_expires_at</td
|
||||
>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300"
|
||||
>Ablaufzeitpunkt des Zugriffstokens (kein HttpOnly)</td
|
||||
>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300">30 Minuten</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Title block -->
|
||||
<section class="bg-bg px-10 pt-[64px] pb-[48px]">
|
||||
<div class="mx-auto max-w-[760px]">
|
||||
<Caps class="mb-3">Rechtliche Angaben</Caps>
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(36px,5vw,60px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Datenschutz­erklärung
|
||||
</h1>
|
||||
<div class="mt-6">
|
||||
<Rule kind="thin" />
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
10. Markt einreichen (Einreichungsformular)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie können über das Formular unter „Markt einreichen" einen Mittelaltermarkt zur Aufnahme
|
||||
vorschlagen. Dabei werden folgende Daten verarbeitet:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Marktdaten</strong> – Name, Beschreibung, Ort, Zeitraum, Website, Veranstalter, ggf. Koordinaten
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kontaktdaten</strong> – Ihr Name und Ihre E-Mail-Adresse (werden nicht veröffentlicht)
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Die Kontaktdaten werden ausschließlich für Rückfragen zur Einreichung verwendet und nicht an
|
||||
Dritte weitergegeben. Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1
|
||||
lit. a DSGVO). Eingereichte Daten werden bei Ablehnung des Marktes gelöscht.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
11. Spam-Schutz (Cloudflare Turnstile)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Zum Schutz des Einreichungsformulars vor automatisiertem Missbrauch setzen wir
|
||||
<strong>Cloudflare Turnstile</strong> ein. Dabei werden technische Daten (z. B. IP-Adresse,
|
||||
Browser-Informationen) an Cloudflare, Inc. übermittelt, um zu prüfen, ob die Eingabe von einem Menschen
|
||||
stammt. Es werden keine Cookies gesetzt und kein Nutzer-Tracking durchgeführt.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am Schutz vor Spam).
|
||||
Weitere Informationen finden Sie in der
|
||||
<a
|
||||
href="https://www.cloudflare.com/privacypolicy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Datenschutzerklärung von Cloudflare</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">12. Standortdaten</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Für die Umkreissuche nach Märkten können Sie optional Ihren Standort freigeben. Dabei kommen
|
||||
zwei Verfahren zum Einsatz:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Browser-Geolokalisierung</strong> – Ihr Browser fragt Ihre Erlaubnis, bevor Standortdaten
|
||||
bereitgestellt werden. Die Koordinaten werden nicht auf unserem Server gespeichert, sondern nur
|
||||
zur einmaligen Berechnung der Entfernung zu Märkten verwendet.
|
||||
</li>
|
||||
<li>
|
||||
<strong>IP-basierte Geolokalisierung (Fallback)</strong> – Falls die
|
||||
Browser-Geolokalisierung nicht verfügbar ist, wird der Dienst
|
||||
<a
|
||||
href="https://www.geojs.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>geojs.io</a
|
||||
<!-- Content -->
|
||||
<div class="bg-bg px-10 pb-20">
|
||||
<div class="mx-auto max-w-[760px] space-y-10">
|
||||
<section>
|
||||
<Caps class="mb-3">1 · Verantwortlicher</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Christian Nachtigall<br />
|
||||
Karwendelstr. 21<br />
|
||||
82061 Neuried<br />
|
||||
E-Mail:
|
||||
<a href="mailto:contact@marktvogt.de" class="text-accent hover:underline"
|
||||
>contact@marktvogt.de</a
|
||||
>
|
||||
zur ungefähren Standortbestimmung genutzt. Dabei wird Ihre IP-Adresse an geojs.io übermittelt.
|
||||
Bitte beachten Sie die
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">2 · Überblick der Verarbeitungen</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
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>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">3 · Hosting</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Diese Website wird auf Infrastruktur von <strong class="text-ink font-[600]"
|
||||
>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="text-ink-soft mt-3 space-y-1.5 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
IP-Adresse des zugreifenden Geräts
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
Datum und Uhrzeit der Anfrage
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
HTTP-Methode und aufgerufene URL
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">HTTP-Statuscode</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
Antwortzeit des Servers
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
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>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">4 · Registrierung und Benutzerkonto</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Sie können auf unserer Website ein Benutzerkonto erstellen. Dabei werden folgende Daten
|
||||
verarbeitet:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-1.5 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">E-Mail-Adresse</strong> – zur Identifikation und Kommunikation
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Passwort</strong> – wird ausschließlich als bcrypt-Hash
|
||||
gespeichert; das Klartext-Passwort wird nicht gespeichert
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Anzeigename</strong> – frei wählbarer Name zur Darstellung
|
||||
im Profil
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Profilbild-URL</strong> – sofern über einen OAuth-Anbieter
|
||||
bereitgestellt
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
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>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">5 · Anmeldung über Drittanbieter (OAuth)</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Sie können sich mit einem bestehenden Konto bei folgenden Anbietern anmelden:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-1.5 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Google</strong> – Abgerufene Daten: E-Mail-Adresse, Name,
|
||||
Profilbild, E-Mail-Verifizierungsstatus
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">GitHub</strong> – Abgerufene Daten: E-Mail-Adresse (primäre,
|
||||
verifizierte E-Mail)
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Facebook</strong> – Abgerufene Daten: E-Mail-Adresse, Name,
|
||||
Profilbild
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
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="text-ink-soft mt-3 space-y-1.5 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<a
|
||||
href="https://policies.google.com/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">Google Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<a
|
||||
href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">GitHub Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<a
|
||||
href="https://www.facebook.com/privacy/policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">Facebook Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">6 · Magic-Link-Anmeldung</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
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>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">7 · Sitzungsverwaltung (Sessions)</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Nach der Anmeldung wird eine Sitzung erstellt. Dabei werden folgende Daten gespeichert:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-1.5 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">IP-Adresse</strong> – zum Zeitpunkt der Sitzungserstellung
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">User-Agent</strong> – Browserkennung zum Zeitpunkt der Anmeldung
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Sitzungstoken</strong> – als SHA-256-Hash gespeichert
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
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>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">8 · Zwei-Faktor-Authentifizierung (2FA)</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Optional können Sie die Zwei-Faktor-Authentifizierung über ein TOTP-Verfahren (z. B.
|
||||
Google Authenticator) aktivieren. Dabei wird ein kryptografisches Geheimnis (TOTP-Secret)
|
||||
mit Ihrem Konto verknüpft gespeichert. Dieses wird bei Deaktivierung der 2FA oder Löschung
|
||||
des Kontos gelöscht.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">9 · Cookies und lokale Speicherung</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Wir verwenden ausschließlich technisch notwendige Cookies:
|
||||
</p>
|
||||
<div class="border-rule-soft mt-4 overflow-x-auto border">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-rule-soft bg-surface-alt border-b">
|
||||
<th
|
||||
class="text-ink-muted px-4 py-2.5 text-left font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>Name</th
|
||||
>
|
||||
<th
|
||||
class="text-ink-muted px-4 py-2.5 text-left font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>Zweck</th
|
||||
>
|
||||
<th
|
||||
class="text-ink-muted px-4 py-2.5 text-left font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>Dauer</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink px-4 py-2.5 font-mono text-[12px]">access_token</td>
|
||||
<td class="text-ink-soft px-4 py-2.5 font-serif text-[14px]"
|
||||
>Zugriffstoken für authentifizierte Anfragen</td
|
||||
>
|
||||
<td class="text-ink-soft px-4 py-2.5 font-serif text-[14px]">30 Minuten</td>
|
||||
</tr>
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink px-4 py-2.5 font-mono text-[12px]">refresh_token</td>
|
||||
<td class="text-ink-soft px-4 py-2.5 font-serif text-[14px]"
|
||||
>Sitzungstoken zur Erneuerung des Zugriffstokens</td
|
||||
>
|
||||
<td class="text-ink-soft px-4 py-2.5 font-serif text-[14px]">30 Tage</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-ink px-4 py-2.5 font-mono text-[12px]">access_expires_at</td>
|
||||
<td class="text-ink-soft px-4 py-2.5 font-serif text-[14px]"
|
||||
>Ablaufzeitpunkt des Zugriffstokens (kein HttpOnly)</td
|
||||
>
|
||||
<td class="text-ink-soft px-4 py-2.5 font-serif text-[14px]">30 Minuten</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Zusätzlich wird im <strong class="text-ink font-[600]">localStorage</strong> des Browsers
|
||||
die Einstellung für das Farbschema (<code
|
||||
class="bg-surface-alt px-1.5 py-0.5 font-mono text-[13px]">marktvogt-theme</code
|
||||
>) gespeichert. Dies enthält keine personenbezogenen Daten.
|
||||
</p>
|
||||
<p class="text-ink-soft mt-3 font-serif text-[16px] leading-[1.7]">
|
||||
Es werden keine Tracking-, Analyse- oder Werbe-Cookies eingesetzt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">10 · Markt einreichen (Einreichungsformular)</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Sie können über das Formular unter „Markt einreichen" einen Mittelaltermarkt zur Aufnahme
|
||||
vorschlagen. Dabei werden folgende Daten verarbeitet:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-1.5 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Marktdaten</strong> – Name, Beschreibung, Ort, Zeitraum,
|
||||
Website, Veranstalter, ggf. Koordinaten
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Kontaktdaten</strong> – Ihr Name und Ihre E-Mail-Adresse
|
||||
(werden nicht veröffentlicht)
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
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>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">11 · Spam-Schutz (Cloudflare Turnstile)</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Zum Schutz des Einreichungsformulars vor automatisiertem Missbrauch setzen wir
|
||||
<strong class="text-ink font-[600]">Cloudflare Turnstile</strong> ein. Dabei werden technische
|
||||
Daten (z. B. IP-Adresse, Browser-Informationen) an Cloudflare, Inc. übermittelt, um zu prüfen,
|
||||
ob die Eingabe von einem Menschen stammt. Es werden keine Cookies gesetzt und kein Nutzer-Tracking
|
||||
durchgeführt.
|
||||
</p>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
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.geojs.io/privacy/"
|
||||
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 geojs.io</a
|
||||
class="text-accent hover:underline">Datenschutzerklärung von Cloudflare</a
|
||||
>.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</p>
|
||||
</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>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<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:contact@marktvogt.de"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>contact@marktvogt.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<Caps class="mb-3">12 · Standortdaten</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Für die Umkreissuche nach Märkten können Sie optional Ihren Standort freigeben. Dabei kommen
|
||||
zwei Verfahren zum Einsatz:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-2 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">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 class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">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-accent hover:underline">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-accent hover:underline">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">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>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<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>
|
||||
<Caps class="mb-3">13 · Kartendarstellung</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Zur Darstellung von Karten verwenden wir <strong class="text-ink font-[600]">Leaflet</strong
|
||||
>
|
||||
mit Kartenkacheln von <strong class="text-ink font-[600]">CARTO</strong> (basierend auf
|
||||
OpenStreetMap-Daten). Beim Laden der Karte werden Kartendaten von den CARTO-Servern
|
||||
abgerufen. Dabei wird Ihre IP-Adresse an CARTO übermittelt. Weitere Informationen finden Sie
|
||||
in der
|
||||
<a
|
||||
href="https://carto.com/privacy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">Datenschutzerklärung von CARTO</a
|
||||
>
|
||||
sowie der
|
||||
<a
|
||||
href="https://wiki.osmfoundation.org/wiki/Privacy_Policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">Datenschutzerklärung der OpenStreetMap Foundation</a
|
||||
>.
|
||||
</p>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Die Leaflet-Bibliothek wird über <code
|
||||
class="bg-surface-alt px-1.5 py-0.5 font-mono text-[13px]">unpkg.com</code
|
||||
>
|
||||
(CDN) geladen. Dabei kann Ihre IP-Adresse an den CDN-Betreiber übermittelt werden.
|
||||
</p>
|
||||
</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>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">14 · Ihre Rechte</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Sie haben gemäß DSGVO folgende Rechte bezüglich Ihrer personenbezogenen Daten:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-2 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Auskunft</strong> (Art. 15 DSGVO) – Sie können Auskunft
|
||||
über Ihre gespeicherten Daten verlangen.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Berichtigung</strong> (Art. 16 DSGVO) – Sie können die Berichtigung
|
||||
unrichtiger Daten verlangen.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Löschung</strong> (Art. 17 DSGVO) – Sie können die Löschung
|
||||
Ihrer Daten verlangen.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Einschränkung</strong> (Art. 18 DSGVO) – Sie können die
|
||||
Einschränkung der Verarbeitung verlangen.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Datenübertragbarkeit</strong> (Art. 20 DSGVO) – Sie können
|
||||
Ihre Daten in einem maschinenlesbaren Format erhalten.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Widerspruch</strong> (Art. 21 DSGVO) – Sie können der Verarbeitung
|
||||
auf Basis berechtigter Interessen widersprechen.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Widerruf der Einwilligung</strong> (Art. 7 Abs. 3 DSGVO)
|
||||
– Erteilte Einwilligungen können jederzeit widerrufen werden.
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Zur Ausübung Ihrer Rechte wenden Sie sich an:
|
||||
<a href="mailto:contact@marktvogt.de" class="text-accent hover:underline"
|
||||
>contact@marktvogt.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">15 · Beschwerderecht</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
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="text-ink-soft mt-3 font-serif text-[16px] leading-[1.7]">
|
||||
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-accent hover:underline">www.lda.bayern.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">16 · Datenlöschung und Speicherdauer</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Personenbezogene Daten werden gelöscht, sobald der Zweck der Speicherung entfällt:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-2 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">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 class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Sitzungsdaten</strong> – Automatische Löschung nach Ablauf
|
||||
(30 Tage).
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Magic-Link-Tokens</strong> – Laufen nach 15 Minuten ab.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Server-Logfiles</strong> – Werden nach den beim Hostinganbieter
|
||||
üblichen Fristen gelöscht.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">17 · Änderungen dieser Datenschutzerklärung</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
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="text-ink-muted mt-6 font-mono text-[10px] tracking-[0.15em] uppercase">
|
||||
Stand: Februar 2026
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,118 +1,145 @@
|
||||
<script lang="ts">
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Impressum - Marktvogt</title>
|
||||
<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:title" content="Impressum — 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>
|
||||
<!-- Title block -->
|
||||
<section class="bg-bg px-10 pt-[64px] pb-[48px]">
|
||||
<div class="mx-auto max-w-[760px]">
|
||||
<Caps class="mb-3">Rechtliche Angaben</Caps>
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(36px,5vw,60px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Impressum
|
||||
</h1>
|
||||
<div class="mt-6">
|
||||
<Rule kind="thin" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<!-- Content -->
|
||||
<div class="bg-bg px-10 pb-20">
|
||||
<div class="mx-auto max-w-[760px] space-y-10">
|
||||
<section>
|
||||
<Caps class="mb-3">Angaben gemäß § 5 TMG</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
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:contact@marktvogt.de"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>contact@marktvogt.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<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>
|
||||
<Caps class="mb-3">Kontakt</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
E-Mail: <a href="mailto:contact@marktvogt.de" class="text-accent hover:underline"
|
||||
>contact@marktvogt.de</a
|
||||
>
|
||||
</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>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
Keine Gewähr für Vollständigkeit und Richtigkeit
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Die auf dieser Plattform bereitgestellten Informationen zu Mittelaltermärkten, Veranstaltungen
|
||||
und Anbietern werden nach bestem Wissen zusammengestellt. Wir übernehmen jedoch keine Gewähr
|
||||
für die Aktualität, Vollständigkeit oder Richtigkeit der dargestellten Daten. Angaben zu
|
||||
Terminen, Orten, Preisen und sonstigen Veranstaltungsdetails können sich kurzfristig ändern.
|
||||
Verbindliche Informationen sind stets direkt beim jeweiligen Veranstalter einzuholen.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<Caps class="mb-3">Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
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">
|
||||
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>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<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>
|
||||
<Caps class="mb-3">Haftung für Inhalte</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
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="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
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">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>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">Keine Gewähr für Vollständigkeit und Richtigkeit</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Die auf dieser Plattform bereitgestellten Informationen zu Mittelaltermärkten,
|
||||
Veranstaltungen und Anbietern werden nach bestem Wissen zusammengestellt. Wir übernehmen
|
||||
jedoch keine Gewähr für die Aktualität, Vollständigkeit oder Richtigkeit der dargestellten
|
||||
Daten. Angaben zu Terminen, Orten, Preisen und sonstigen Veranstaltungsdetails können sich
|
||||
kurzfristig ändern. Verbindliche Informationen sind stets direkt beim jeweiligen
|
||||
Veranstalter einzuholen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">Nutzereingereichte Inhalte</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
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>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">Haftung für Links</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
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="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
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>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">Urheberrecht</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
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="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { apiFetch } from '$lib/api/client.js';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
const now = new Date();
|
||||
const year = parseInt(url.searchParams.get('year') ?? String(now.getUTCFullYear()));
|
||||
const month = parseInt(url.searchParams.get('month') ?? String(now.getUTCMonth() + 1));
|
||||
|
||||
const mm = String(month).padStart(2, '0');
|
||||
const from = `${year}-${mm}-01`;
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
const to = `${year}-${mm}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
try {
|
||||
const res = await apiFetch<MarketSummary[]>(
|
||||
`/markets?from=${from}&to=${to}&per_page=200&sort=date`,
|
||||
{ fetch }
|
||||
);
|
||||
return { markets: res.data, year, month };
|
||||
} catch {
|
||||
return { markets: [] as MarketSummary[], year, month };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
<script lang="ts">
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const MONTHS_DE = [
|
||||
'Januar',
|
||||
'Februar',
|
||||
'März',
|
||||
'April',
|
||||
'Mai',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'August',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Dezember'
|
||||
];
|
||||
const DAYS_LONG = [
|
||||
'Montag',
|
||||
'Dienstag',
|
||||
'Mittwoch',
|
||||
'Donnerstag',
|
||||
'Freitag',
|
||||
'Samstag',
|
||||
'Sonntag'
|
||||
];
|
||||
const DAYS_SHORT = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
function prevUrl(): string {
|
||||
let m = data.month - 1,
|
||||
y = data.year;
|
||||
if (m < 1) {
|
||||
m = 12;
|
||||
y--;
|
||||
}
|
||||
return `/kalender?year=${y}&month=${m}`;
|
||||
}
|
||||
function nextUrl(): string {
|
||||
let m = data.month + 1,
|
||||
y = data.year;
|
||||
if (m > 12) {
|
||||
m = 1;
|
||||
y++;
|
||||
}
|
||||
return `/kalender?year=${y}&month=${m}`;
|
||||
}
|
||||
|
||||
const calendarWeeks = $derived(
|
||||
(() => {
|
||||
const first = new Date(Date.UTC(data.year, data.month - 1, 1));
|
||||
const last = new Date(Date.UTC(data.year, data.month, 0));
|
||||
const startOffset = (first.getUTCDay() + 6) % 7; // Mon=0
|
||||
|
||||
const cells: Array<{ day: number | null; date: string | null; colIdx: number }> = [];
|
||||
for (let i = 0; i < startOffset; i++) cells.push({ day: null, date: null, colIdx: i });
|
||||
for (let d = 1; d <= last.getUTCDate(); d++) {
|
||||
const colIdx = (startOffset + d - 1) % 7;
|
||||
const mm = String(data.month).padStart(2, '0');
|
||||
const dd = String(d).padStart(2, '0');
|
||||
cells.push({ day: d, date: `${data.year}-${mm}-${dd}`, colIdx });
|
||||
}
|
||||
while (cells.length % 7 !== 0)
|
||||
cells.push({ day: null, date: null, colIdx: cells.length % 7 });
|
||||
|
||||
const weeks: (typeof cells)[] = [];
|
||||
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
|
||||
return weeks;
|
||||
})()
|
||||
);
|
||||
|
||||
// Map: date string → markets active on that day
|
||||
const marketsByDate = $derived(
|
||||
(() => {
|
||||
const map = new Map<string, MarketSummary[]>();
|
||||
for (const m of data.markets) {
|
||||
const start = new Date(m.start_date + 'T00:00:00Z');
|
||||
const end = new Date(m.end_date + 'T00:00:00Z');
|
||||
const cur = new Date(start);
|
||||
while (cur <= end) {
|
||||
const key = cur.toISOString().slice(0, 10);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(m);
|
||||
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})()
|
||||
);
|
||||
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Deduplicated list for the list view
|
||||
const uniqueMarkets = $derived(
|
||||
data.markets.filter((m, i, arr) => arr.findIndex((x) => x.slug === m.slug) === i)
|
||||
);
|
||||
|
||||
function fmtRange(from: string, to: string): string {
|
||||
const opts: Intl.DateTimeFormatOptions = { day: '2-digit', month: 'short', timeZone: 'UTC' };
|
||||
const f = new Date(from).toLocaleDateString('de-DE', opts);
|
||||
const t = new Date(to).toLocaleDateString('de-DE', { ...opts, year: 'numeric' });
|
||||
return from === to ? f : `${f} – ${t}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Kalender · {MONTHS_DE[data.month - 1]} {data.year} — Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Alle Mittelaltermärkte im {MONTHS_DE[data.month - 1]} {data.year}."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Page header ──────────────────────────────────── -->
|
||||
<section class="bg-bg border-rule-soft border-b px-10 py-8">
|
||||
<div class="mx-auto flex max-w-[1320px] items-end justify-between gap-8">
|
||||
<!-- Month title -->
|
||||
<div>
|
||||
<Caps class="mb-2">Kalender historischer Märkte</Caps>
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(36px,5vw,68px)] leading-[0.93] font-[500] tracking-[-0.02em]"
|
||||
>
|
||||
{MONTHS_DE[data.month - 1]}
|
||||
<span class="text-ink-muted">{data.year}</span>
|
||||
</h1>
|
||||
{#if uniqueMarkets.length > 0}
|
||||
<p class="text-ink-muted mt-2 font-serif text-[14px] italic">
|
||||
{uniqueMarkets.length}
|
||||
{uniqueMarkets.length === 1 ? 'Markt' : 'Märkte'} in diesem Monat
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="border-rule-soft flex items-center gap-0 border" aria-label="Monatsnavigation">
|
||||
<a
|
||||
href={prevUrl()}
|
||||
class="border-rule-soft text-ink-muted hover:bg-surface hover:text-ink flex items-center gap-2 border-r px-5 py-3 font-mono text-[11px] tracking-[0.1em] uppercase"
|
||||
>
|
||||
‹ <span class="hidden sm:inline">{MONTHS_DE[(data.month + 10) % 12]}</span>
|
||||
</a>
|
||||
<a
|
||||
href="/kalender"
|
||||
class="border-rule-soft text-ink-muted hover:bg-surface hover:text-ink border-r px-4 py-3 font-mono text-[11px] tracking-[0.1em] uppercase"
|
||||
title="Aktueller Monat"
|
||||
>
|
||||
Heute
|
||||
</a>
|
||||
<a
|
||||
href={nextUrl()}
|
||||
class="text-ink-muted hover:bg-surface hover:text-ink flex items-center gap-2 px-5 py-3 font-mono text-[11px] tracking-[0.1em] uppercase"
|
||||
>
|
||||
<span class="hidden sm:inline">{MONTHS_DE[data.month % 12]}</span> ›
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Calendar grid ────────────────────────────────── -->
|
||||
<div class="bg-bg">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
<!-- Day-of-week headers -->
|
||||
<div class="border-rule-soft grid grid-cols-7 border-b">
|
||||
{#each DAYS_SHORT as day, i}
|
||||
<div
|
||||
class="border-rule-soft border-r py-2.5 text-center font-mono text-[10px] tracking-[0.15em] uppercase last:border-r-0 {i >=
|
||||
5
|
||||
? 'text-accent'
|
||||
: 'text-ink-muted'}"
|
||||
>
|
||||
<span class="hidden sm:inline">{DAYS_LONG[i]}</span>
|
||||
<span class="sm:hidden">{day}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Week rows -->
|
||||
{#each calendarWeeks as week}
|
||||
<div class="border-rule-soft grid grid-cols-7 border-b">
|
||||
{#each week as cell}
|
||||
{@const cellMarkets = cell.date ? (marketsByDate.get(cell.date) ?? []) : []}
|
||||
{@const isToday = cell.date === todayIso}
|
||||
{@const isWeekend = cell.colIdx >= 5}
|
||||
{@const hasMarkets = cellMarkets.length > 0}
|
||||
<div
|
||||
class="border-rule-soft relative min-h-[120px] border-r p-3 last:border-r-0
|
||||
{isWeekend && cell.day !== null ? 'bg-surface' : ''}
|
||||
{!cell.day ? 'bg-surface-alt opacity-30' : ''}"
|
||||
>
|
||||
{#if cell.day !== null}
|
||||
<!-- Day number -->
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span
|
||||
class="font-mono text-[13px] leading-none font-[500]
|
||||
{isToday ? 'text-accent font-[700]' : isWeekend ? 'text-ink-soft' : 'text-ink-muted'}"
|
||||
>
|
||||
{cell.day}
|
||||
</span>
|
||||
{#if hasMarkets}
|
||||
<span
|
||||
class="bg-accent h-1.5 w-1.5 rounded-full"
|
||||
title="{cellMarkets.length} {cellMarkets.length === 1 ? 'Markt' : 'Märkte'}"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Markets in this cell -->
|
||||
{#each cellMarkets.slice(0, 3) as market}
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="text-ink hover:text-accent mb-1 block truncate font-serif text-[12px] leading-[1.3] no-underline"
|
||||
title="{market.name} — {market.city}"
|
||||
>
|
||||
{market.name}
|
||||
</a>
|
||||
{/each}
|
||||
{#if cellMarkets.length > 3}
|
||||
<span class="text-ink-muted font-mono text-[9px] tracking-[0.08em] uppercase">
|
||||
+{cellMarkets.length - 3} weitere
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Market list ──────────────────────────────────── -->
|
||||
<div class="bg-bg px-10 py-12">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
{#if uniqueMarkets.length === 0}
|
||||
<div class="py-20 text-center">
|
||||
<div class="font-display text-ink-muted text-[56px] italic">∅</div>
|
||||
<p class="text-ink-muted mt-4 font-serif text-[17px] italic">
|
||||
Keine Märkte in diesem Monat.
|
||||
</p>
|
||||
<a
|
||||
href="/maerkte"
|
||||
class="text-accent mt-6 inline-block font-serif text-[15px] no-underline"
|
||||
>
|
||||
Alle Märkte anzeigen ›
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-8 flex items-center gap-6">
|
||||
<Caps size={11}>
|
||||
{uniqueMarkets.length}
|
||||
{uniqueMarkets.length === 1 ? 'Markt' : 'Märkte'} im {MONTHS_DE[data.month - 1]}
|
||||
</Caps>
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
</div>
|
||||
|
||||
{#each uniqueMarkets as market}
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="border-rule-soft hover:bg-surface-alt grid items-baseline gap-4 border-t py-4 no-underline transition-colors
|
||||
sm:grid-cols-[120px_1fr_200px]"
|
||||
>
|
||||
<div>
|
||||
<div class="font-display text-accent text-[22px] leading-none font-[500]">
|
||||
{new Date(market.start_date).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: 'UTC'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-display text-ink text-[18px] leading-[1.05] font-[500]">
|
||||
{market.name}
|
||||
</div>
|
||||
<div class="font-display text-ink-soft mt-0.5 text-[13px] italic">
|
||||
{market.city} · {market.state}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-ink-muted hidden text-right font-serif text-[13px] italic sm:block">
|
||||
{fmtRange(market.start_date, market.end_date)}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { apiFetch } from '$lib/api/client.js';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const res = await apiFetch<MarketSummary[]>(`/markets?from=${today}&per_page=500`, { fetch });
|
||||
return { markets: res.data };
|
||||
} catch {
|
||||
return { markets: [] as MarketSummary[] };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import MarketMap from '$lib/components/market/MarketMap.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let selected = $state<MarketSummary | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let containerTop = $state(0);
|
||||
|
||||
const filtered = $derived(
|
||||
searchQuery.trim()
|
||||
? data.markets.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
m.city.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
m.state.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: data.markets
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
// Measure actual header height so the container fills exactly the remaining viewport
|
||||
const header = document.querySelector('header');
|
||||
containerTop = header ? header.getBoundingClientRect().bottom : 72;
|
||||
|
||||
// Prevent the page from scrolling behind the map/sidebar
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
});
|
||||
|
||||
function fmtDateShort(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDateRange(from: string, to: string): string {
|
||||
const f = fmtDateShort(from);
|
||||
const t = fmtDateShort(to);
|
||||
return from === to ? f : `${f} – ${t}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Karte — Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Alle kommenden Mittelaltermärkte in Deutschland, Österreich und der Schweiz auf der Karte."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="fixed right-0 bottom-0 left-0 flex" style="top: {containerTop}px;">
|
||||
<!-- Sidebar -->
|
||||
<aside class="border-rule-soft bg-bg flex w-[340px] flex-shrink-0 flex-col border-r">
|
||||
<!-- Header -->
|
||||
<div class="border-rule-soft border-b px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="font-display text-ink text-[20px] leading-none font-[500]">Karte</h1>
|
||||
<Caps size={9}>{filtered.length} Märkte</Caps>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Markt, Ort oder Region…"
|
||||
class="border-rule-soft bg-surface text-ink placeholder:text-ink-muted focus:border-accent mt-3 w-full border px-3 py-2 font-serif text-[13px] italic focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Market list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if filtered.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<p class="text-ink-muted font-serif text-[14px] italic">Keine Märkte gefunden.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each filtered as market}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selected = market)}
|
||||
class="border-rule-soft w-full border-b px-5 py-3.5 text-left transition-colors {selected?.slug ===
|
||||
market.slug
|
||||
? 'bg-surface-alt'
|
||||
: 'hover:bg-surface'}"
|
||||
>
|
||||
<div class="font-display text-ink text-[15px] leading-[1.1] font-[500]">
|
||||
{market.name}
|
||||
</div>
|
||||
<div class="text-ink-soft mt-0.5 font-serif text-[12px] italic">
|
||||
{market.city} · {market.state}
|
||||
</div>
|
||||
<div class="text-ink-muted mt-1 font-mono text-[10px] tracking-[0.1em] uppercase">
|
||||
{fmtDateRange(market.start_date, market.end_date)}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Selected market panel -->
|
||||
{#if selected}
|
||||
<div class="border-rule-soft bg-surface-alt border-t px-5 py-4">
|
||||
<div class="mb-1 flex items-start justify-between gap-2">
|
||||
<div class="font-display text-ink text-[16px] leading-[1.1] font-[500]">
|
||||
{selected.name}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selected = null)}
|
||||
class="text-ink-muted hover:text-ink mt-0.5 flex-shrink-0 font-mono text-[14px] leading-none"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-ink-soft font-serif text-[12px] italic">
|
||||
{selected.city} · {selected.state}
|
||||
</div>
|
||||
<div class="text-ink-muted mt-1 font-mono text-[10px] tracking-[0.1em] uppercase">
|
||||
{fmtDateRange(selected.start_date, selected.end_date)}
|
||||
</div>
|
||||
<a
|
||||
href="/markt/{selected.slug}"
|
||||
class="border-ink bg-ink text-bg mt-4 inline-block border px-4 py-1.5 font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
Details →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<!-- Map -->
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
<MarketMap
|
||||
markets={filtered}
|
||||
{selected}
|
||||
class="h-full w-full"
|
||||
onSelect={(m) => (selected = m)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import mock from '$lib/mock/lagerleben.json';
|
||||
|
||||
export const load: PageServerLoad = () => {
|
||||
return { articles: mock.articles, camps: mock.camps };
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Lagerleben — Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Reportagen, Ratgeber und Lagerporträts aus der Welt des lebendigen Mittelalters."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Title block -->
|
||||
<section class="bg-bg px-10 pt-[72px] pb-[56px]">
|
||||
<div class="mx-auto max-w-[1320px] text-center">
|
||||
<Caps>Das Magazin für lebendiges Mittelalter</Caps>
|
||||
<h1
|
||||
class="font-display text-ink mt-4 text-[clamp(48px,7vw,88px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Lagerleben
|
||||
</h1>
|
||||
<div class="mt-5 flex items-center gap-6">
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
<span class="text-ink-muted font-serif text-[15px] italic"
|
||||
>Handwerk · Recherche · Gemeinschaft</span
|
||||
>
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- In-progress notice strip -->
|
||||
<div class="border-rule-soft bg-surface-alt border-y px-10 py-4">
|
||||
<div class="mx-auto flex max-w-[1320px] items-center gap-6">
|
||||
<Caps color="var(--color-accent)">Im Aufbau</Caps>
|
||||
<p class="text-ink-muted font-serif text-[13px] italic">
|
||||
Beispielinhalte — vollständige Redaktion und Einreichung folgen in einer späteren Phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lead article + secondary grid -->
|
||||
<div class="bg-bg px-10 py-12">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
{#if data.articles.length > 0}
|
||||
{@const lead = data.articles[0]}
|
||||
<!-- Lead -->
|
||||
<a
|
||||
href="/lagerleben/reportage/{lead.slug}"
|
||||
class="border-rule-soft mb-12 grid border no-underline transition-shadow hover:shadow-md sm:grid-cols-[1fr_420px]"
|
||||
>
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt flex items-center justify-center border-b sm:border-r sm:border-b-0"
|
||||
style="min-height: 300px;"
|
||||
>
|
||||
<div class="h-[50%] w-[30%]">
|
||||
<Heraldry seed={lead.slug} class="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-10 py-10">
|
||||
<Caps color="var(--color-accent)" class="mb-4">{lead.category}</Caps>
|
||||
<h2 class="font-display text-ink text-[32px] leading-[1.0] font-[500]">{lead.title}</h2>
|
||||
<p class="font-display text-ink-soft mt-3 text-[17px] italic">{lead.subtitle}</p>
|
||||
<Rule kind="thin" class="my-6" />
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.65]">{lead.excerpt}</p>
|
||||
<p class="text-ink-muted mt-6 font-mono text-[10px] tracking-[0.15em] uppercase">
|
||||
{fmtDate(lead.date)}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Section header -->
|
||||
<div class="mb-6 flex items-center gap-6">
|
||||
<Caps size={11}>Weitere Beiträge</Caps>
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
</div>
|
||||
|
||||
<!-- Secondary grid -->
|
||||
<div class="border-rule-soft bg-rule-soft grid gap-px border sm:grid-cols-3">
|
||||
{#each data.articles.slice(1) as article}
|
||||
<a
|
||||
href="/lagerleben/reportage/{article.slug}"
|
||||
class="bg-surface hover:bg-surface-alt flex flex-col no-underline transition-colors"
|
||||
>
|
||||
<div class="flex flex-1 flex-col p-8">
|
||||
<Caps size={9} color="var(--color-accent)" class="mb-3">{article.category}</Caps>
|
||||
<h3 class="font-display text-ink text-[20px] leading-[1.05] font-[500]">
|
||||
{article.title}
|
||||
</h3>
|
||||
<p class="font-display text-ink-soft mt-2 text-[13px] italic">{article.subtitle}</p>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[14px] leading-[1.6]">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
<p
|
||||
class="text-ink-muted mt-auto pt-6 font-mono text-[9px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
{fmtDate(article.date)}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camps section -->
|
||||
<div class="border-rule-soft bg-surface-alt border-t px-10 py-12">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
<div class="mb-8 flex items-center justify-between gap-6">
|
||||
<div>
|
||||
<Caps class="mb-2">Lagerporträts</Caps>
|
||||
<p class="text-ink-muted font-serif text-[15px] italic">
|
||||
Gruppen und Gemeinschaften vorstellen
|
||||
</p>
|
||||
</div>
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
</div>
|
||||
|
||||
<div class="border-rule-soft grid gap-0 border sm:grid-cols-3">
|
||||
{#each data.camps as camp, i}
|
||||
<a
|
||||
href="/lagerleben/lager/{camp.slug}"
|
||||
class="border-rule-soft bg-surface hover:bg-surface-alt border-b p-8 no-underline transition-colors sm:border-b-0 {i >
|
||||
0
|
||||
? 'sm:border-l'
|
||||
: ''} border-rule-soft"
|
||||
>
|
||||
<div class="mb-4 h-12 w-10">
|
||||
<Heraldry seed={camp.slug} class="h-full w-full" />
|
||||
</div>
|
||||
<Caps size={9} class="mb-2">{camp.period} · {camp.region}</Caps>
|
||||
<h3 class="font-display text-ink text-[20px] leading-[1.05] font-[500]">{camp.name}</h3>
|
||||
<Rule kind="thin" class="my-4" />
|
||||
<p class="text-ink-soft font-serif text-[14px] leading-[1.6]">{camp.excerpt}</p>
|
||||
<p class="text-ink-muted mt-5 font-mono text-[10px] tracking-[0.12em] uppercase">
|
||||
{camp.members} Mitglieder
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import mock from '$lib/mock/lagerleben.json';
|
||||
|
||||
export const load: PageServerLoad = ({ params }) => {
|
||||
const camp = mock.camps.find((c) => c.slug === params.slug);
|
||||
if (!camp) error(404, 'Lager nicht gefunden');
|
||||
return { camp };
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const camp = $derived(data.camp);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{camp.name} — Lagerleben — Marktvogt</title>
|
||||
<meta name="description" content={camp.excerpt} />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Hero -->
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt flex items-center justify-center border-b"
|
||||
style="height: 220px;"
|
||||
>
|
||||
<div class="h-[60%] w-[15%]">
|
||||
<Heraldry seed={camp.slug} class="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-[760px] px-10 py-12">
|
||||
<nav class="mb-8">
|
||||
<ol
|
||||
class="text-ink-muted flex items-center gap-1.5 font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
<li><a href="/lagerleben" class="hover:text-ink">Lagerleben</a></li>
|
||||
<li aria-hidden="true">›</li>
|
||||
<li class="text-ink">Lagerporträt</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<Caps class="mb-4">{camp.period} · {camp.region}</Caps>
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(32px,5vw,52px)] leading-[0.97] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
{camp.name}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center gap-4">
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
<Caps size={9}>{camp.members} Mitglieder</Caps>
|
||||
</div>
|
||||
|
||||
<div class="border-rule-soft bg-surface-alt mt-8 border p-6">
|
||||
<Caps color="var(--color-accent)" class="mb-3">Im Aufbau</Caps>
|
||||
<p class="text-ink-soft font-serif text-[15px] leading-[1.65] italic">
|
||||
Lagerporträts befinden sich noch im Aufbau. Vollständige Profile mit Galerie, Mitgliederliste
|
||||
und Kontaktformular folgen in einer späteren Phase.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-ink-soft mt-8 font-serif text-[16px] leading-[1.65]">
|
||||
{camp.excerpt}
|
||||
</p>
|
||||
|
||||
<div class="mt-10">
|
||||
<Rule kind="thin" />
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<a
|
||||
href="/lagerleben"
|
||||
class="text-ink-muted hover:text-ink font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
‹ Zurück zum Magazin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import mock from '$lib/mock/lagerleben.json';
|
||||
|
||||
export const load: PageServerLoad = ({ params }) => {
|
||||
const article = mock.articles.find((a) => a.slug === params.slug);
|
||||
if (!article) error(404, 'Beitrag nicht gefunden');
|
||||
return { article };
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const article = $derived(data.article);
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{article.title} — Lagerleben — Marktvogt</title>
|
||||
<meta name="description" content={article.excerpt} />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Hero -->
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt flex items-center justify-center border-b"
|
||||
style="height: 280px;"
|
||||
>
|
||||
<div class="h-[55%] w-[18%]">
|
||||
<Heraldry seed={article.slug} class="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-[760px] px-10 py-12">
|
||||
<nav class="mb-8">
|
||||
<ol
|
||||
class="text-ink-muted flex items-center gap-1.5 font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
<li><a href="/lagerleben" class="hover:text-ink">Lagerleben</a></li>
|
||||
<li aria-hidden="true">›</li>
|
||||
<li class="text-ink">{article.title}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<Caps color="var(--color-accent)" class="mb-4">{article.category}</Caps>
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(32px,5vw,52px)] leading-[0.97] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
{article.title}
|
||||
</h1>
|
||||
<p class="font-display text-ink-soft mt-3 text-[18px] italic">{article.subtitle}</p>
|
||||
|
||||
<div class="mt-5 flex items-center gap-4">
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
<Caps size={9}>{fmtDate(article.date)}</Caps>
|
||||
</div>
|
||||
|
||||
<div class="border-rule-soft bg-surface-alt mt-8 border p-6">
|
||||
<Caps color="var(--color-accent)" class="mb-3">Im Aufbau</Caps>
|
||||
<p class="text-ink-soft font-serif text-[15px] leading-[1.65] italic">
|
||||
Dieser Beitrag ist ein Beispielinhalt. Die vollständigen Inhalte des Lagerleben-Magazins
|
||||
folgen in einer späteren Entwicklungsphase.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
<Rule kind="thin" />
|
||||
</div>
|
||||
|
||||
<p class="text-ink-soft mt-8 font-serif text-[15px] leading-[1.65]">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
|
||||
<div class="mt-10">
|
||||
<a
|
||||
href="/lagerleben"
|
||||
class="text-ink-muted hover:text-ink font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
‹ Zurück zum Magazin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,43 +1,81 @@
|
||||
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';
|
||||
import { apiFetch, buildSearchQuery } from '$lib/api/client.js';
|
||||
import type { MarketSummary, PaginationMeta } from '$lib/api/types.js';
|
||||
|
||||
export interface StateInfo {
|
||||
slug: string;
|
||||
name: string;
|
||||
count: number;
|
||||
type Coords = { lat?: string; lon?: string };
|
||||
|
||||
async function resolveCoords(
|
||||
plz: string | null,
|
||||
urlLat: string | null,
|
||||
urlLon: string | null,
|
||||
fetch: typeof globalThis.fetch
|
||||
): Promise<Coords> {
|
||||
if (urlLat && urlLon) return { lat: urlLat, lon: urlLon };
|
||||
if (!plz) return {};
|
||||
|
||||
try {
|
||||
const res = await apiFetch<{ latitude: number | null; longitude: number | null }>('/geocode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ city: '', zip: plz, country: 'DE' }),
|
||||
fetch
|
||||
});
|
||||
const { latitude, longitude } = res.data;
|
||||
if (latitude == null || longitude == null) return {};
|
||||
return { lat: String(latitude), lon: String(longitude) };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
let markets: MarketSummary[] = [];
|
||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
const q = url.searchParams.get('q');
|
||||
const plz = url.searchParams.get('plz');
|
||||
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');
|
||||
|
||||
const coords = await resolveCoords(plz, lat, lon, fetch);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (q) params.q = q;
|
||||
if (coords.lat) params.lat = coords.lat;
|
||||
if (coords.lon) params.lon = coords.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}` : ''}`;
|
||||
|
||||
const searchParams = {
|
||||
q: q ?? '',
|
||||
plz: plz ?? '',
|
||||
lat: lat ? Number(lat) : undefined,
|
||||
lon: lon ? Number(lon) : undefined,
|
||||
radius: radius ? Number(radius) : 25,
|
||||
from: from ?? '',
|
||||
to: to ?? '',
|
||||
sort: sort ?? ''
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiFetch<MarketSummary[]>('/markets?per_page=1000', { fetch });
|
||||
markets = res.data;
|
||||
const res = await apiFetch<MarketSummary[]>(path, { fetch });
|
||||
return {
|
||||
markets: res.data,
|
||||
meta: res.meta as PaginationMeta,
|
||||
searchParams
|
||||
};
|
||||
} catch {
|
||||
// Backend unreachable
|
||||
return {
|
||||
markets: [] as MarketSummary[],
|
||||
meta: { page: 1, per_page: 20, total: 0, total_pages: 0 } as PaginationMeta,
|
||||
searchParams
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -1,85 +1,245 @@
|
||||
<script lang="ts">
|
||||
import type { StateInfo } from './+page.server.js';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import MarketMap from '$lib/components/market/MarketMap.svelte';
|
||||
import Pagination from '$lib/components/market/Pagination.svelte';
|
||||
|
||||
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>';
|
||||
let view = $state<'cards' | 'rows' | 'map'>('cards');
|
||||
|
||||
function fmtDay(iso: string): string {
|
||||
return String(new Date(iso).getUTCDate());
|
||||
}
|
||||
function fmtMonthShort(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', { month: 'short', timeZone: 'UTC' });
|
||||
}
|
||||
function fmtDateRange(from: string, to: string): string {
|
||||
const fDay = new Date(from).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
const tDay = new Date(to).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
return `${fDay} – ${tDay}`;
|
||||
}
|
||||
function padNum(n: number): string {
|
||||
return String(n + 1).padStart(3, '0');
|
||||
}
|
||||
|
||||
const searchUrl = $derived(() => {
|
||||
const params = Object.entries(data.searchParams)
|
||||
.filter(([, v]) => v !== undefined && v !== '')
|
||||
.map(([k, v]) => [k, String(v)]);
|
||||
return params.length ? `/maerkte?${new URLSearchParams(params).toString()}` : '/maerkte';
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mittelaltermärkte nach Bundesland - Marktvogt</title>
|
||||
<title>Alle {data.meta.total} Märkte — Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Finde Mittelaltermärkte in allen 16 Bundesländern. Durchstöbere Mittelaltermärkte, Ritterturniere und historische Feste nach Region."
|
||||
content="Das vollständige Verzeichnis historischer Märkte, Mittelalterspektakel und Lagerleben in Deutschland, Österreich und der Schweiz."
|
||||
/>
|
||||
<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:title" content="Alle Märkte — Marktvogt" />
|
||||
<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"
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
TITLE BLOCK
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="bg-bg px-10 pt-[72px] pb-[56px]">
|
||||
<div class="mx-auto max-w-[1320px] text-center">
|
||||
<Caps>Verzeichnis historischer Märkte</Caps>
|
||||
<h1
|
||||
class="font-display text-ink mt-4 text-[clamp(48px,7vw,88px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
<p class="text-stone-500 dark:text-stone-400">Aktuell keine Märkte verfügbar.</p>
|
||||
Alle {data.meta.total} Märkte
|
||||
</h1>
|
||||
<div class="mt-5">
|
||||
<Rule kind="ornament" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
FILTER BAR
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="border-rule-soft bg-bg/95 sticky top-0 z-20 border-y px-10 py-3 backdrop-blur-sm">
|
||||
<div class="mx-auto flex max-w-[1320px] items-center justify-between gap-4">
|
||||
<form method="get" action="/maerkte" class="flex flex-1 items-center gap-3">
|
||||
<!-- Text search -->
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value={data.searchParams.q}
|
||||
placeholder="Markt, Ort oder Region…"
|
||||
class="border-rule-soft bg-surface text-ink placeholder:text-ink-muted focus:border-accent flex-1 border px-4 py-2 font-serif text-[14px] italic focus:outline-none"
|
||||
/>
|
||||
<!-- Sort -->
|
||||
<select
|
||||
name="sort"
|
||||
class="border-rule-soft bg-surface text-ink focus:border-accent border px-3 py-2 font-mono text-[11px] tracking-[0.12em] uppercase focus:outline-none"
|
||||
>
|
||||
<option
|
||||
value="date"
|
||||
selected={data.searchParams.sort === '' || data.searchParams.sort === 'date'}
|
||||
>Datum</option
|
||||
>
|
||||
<option value="name" selected={data.searchParams.sort === 'name'}>Name</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="border-ink bg-ink text-bg border px-5 py-2 font-mono text-[11px] tracking-[0.14em] uppercase"
|
||||
>Suchen</button
|
||||
>
|
||||
{#if data.searchParams.q || data.searchParams.from || data.searchParams.to}
|
||||
<a
|
||||
href="/maerkte"
|
||||
class="text-ink-muted hover:text-accent font-serif text-[13px] no-underline">× Filter</a
|
||||
>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<!-- Result count + view toggle -->
|
||||
<div class="flex flex-shrink-0 items-center gap-4">
|
||||
<Caps size={10}>{data.meta.total} Märkte</Caps>
|
||||
<span class="border-rule-soft flex border">
|
||||
{#each [['cards', '▦'], ['rows', '☰'], ['map', '⊕']] as [v, icon]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (view = v as 'cards' | 'rows' | 'map')}
|
||||
class="px-3 py-1.5 font-mono text-[12px] transition-colors {view === v
|
||||
? 'bg-ink text-bg'
|
||||
: 'bg-bg text-ink-muted hover:text-ink'}"
|
||||
aria-label={v}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
RESULTS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="bg-bg px-10 py-10">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
{#if data.markets.length === 0}
|
||||
<div class="py-24 text-center">
|
||||
<div class="font-display text-ink-muted text-[48px] italic">∅</div>
|
||||
<p class="text-ink-muted mt-4 font-serif text-[17px] italic">
|
||||
Keine Märkte gefunden. Andere Suchkriterien versuchen?
|
||||
</p>
|
||||
<a href="/maerkte" class="text-accent mt-6 inline-block font-serif text-[15px] no-underline"
|
||||
>Alle Märkte anzeigen ›</a
|
||||
>
|
||||
</div>
|
||||
{:else if view === 'map'}
|
||||
<MarketMap markets={data.markets} class="border-rule-soft h-[600px] border" />
|
||||
{:else if view === 'rows'}
|
||||
<!-- Row list grouped by month -->
|
||||
{@const grouped = (() => {
|
||||
const map = new Map<string, typeof data.markets>();
|
||||
for (const m of data.markets) {
|
||||
const key = new Date(m.start_date).toLocaleDateString('de-DE', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(m);
|
||||
}
|
||||
return [...map.entries()];
|
||||
})()}
|
||||
{#each grouped as [month, markets]}
|
||||
<div class="mb-8">
|
||||
<div class="mb-3 flex items-center gap-4">
|
||||
<div class="font-display text-ink text-[28px] leading-none italic">{month}</div>
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
</div>
|
||||
{#each markets as market}
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="border-rule-soft hover:bg-surface-alt flex items-baseline gap-6 border-t py-3 no-underline transition-colors"
|
||||
>
|
||||
<Caps size={9} class="w-12 flex-shrink-0 text-right"
|
||||
>{padNum(data.markets.indexOf(market))}</Caps
|
||||
>
|
||||
<div class="w-24 flex-shrink-0 text-right">
|
||||
<div class="font-display text-accent text-[20px] leading-none font-[500]">
|
||||
{fmtDay(market.start_date)}
|
||||
</div>
|
||||
<Caps size={9}>{fmtMonthShort(market.start_date)}</Caps>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-display text-ink text-[18px] font-[500]">{market.name}</div>
|
||||
<div class="font-display text-ink-soft text-[13px] italic">
|
||||
{market.city} · {market.state}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-ink-muted hidden font-serif text-[13px] italic md:block">
|
||||
{fmtDateRange(market.start_date, market.end_date)}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Card grid -->
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each data.markets as market, i}
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="border-rule-soft bg-surface block border no-underline transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt relative border-b"
|
||||
style="aspect-ratio: 1.3 / 1"
|
||||
>
|
||||
<span class="absolute inset-0 flex items-center justify-center" aria-hidden="true">
|
||||
<span class="h-[75%] w-[55%]">
|
||||
<Heraldry seed={market.slug} class="h-full w-full" />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="border-rule-soft bg-bg absolute top-3 left-3 border px-2.5 py-1.5 text-center"
|
||||
>
|
||||
<div class="font-display text-accent text-[22px] leading-none font-[500]">
|
||||
{fmtDay(market.start_date)}
|
||||
</div>
|
||||
<Caps size={9}>{fmtMonthShort(market.start_date)}</Caps>
|
||||
</span>
|
||||
<Caps size={9} class="absolute top-3.5 right-3">№ {padNum(i)}</Caps>
|
||||
</div>
|
||||
<div class="px-5 pt-[18px] pb-[22px]">
|
||||
<div class="font-display text-ink text-[20px] leading-[1.1] font-[500]">
|
||||
{market.name}
|
||||
</div>
|
||||
<div class="font-display text-ink-soft mt-1 text-[13px] italic">
|
||||
{market.city} · {market.state}
|
||||
</div>
|
||||
<div class="text-ink-muted mt-2 font-serif text-[12px] italic">
|
||||
{fmtDateRange(market.start_date, market.end_date)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if view !== 'map' && data.meta.total_pages > 1}
|
||||
<div class="mt-12 flex justify-center">
|
||||
<Pagination meta={data.meta} baseUrl={searchUrl()} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import MarketMap from '$lib/components/market/MarketMap.svelte';
|
||||
import MarketFeedbackDialog from '$lib/components/market/MarketFeedbackDialog.svelte';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import type {
|
||||
MarketDetail,
|
||||
OpeningHoursEntry,
|
||||
@@ -239,301 +242,254 @@
|
||||
{@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>
|
||||
<!-- ── Hero ─────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="bg-surface-alt border-rule-soft border-b"
|
||||
style="aspect-ratio: 2.8 / 1; max-height: 340px; overflow: hidden;"
|
||||
>
|
||||
{#if market.image_url}
|
||||
<img
|
||||
src={market.image_url}
|
||||
alt={market.name}
|
||||
class="h-full w-full object-cover"
|
||||
onerror={(e) => {
|
||||
const wrap = e.currentTarget.parentElement;
|
||||
if (wrap) wrap.classList.add('heraldry-fallback');
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<div class="h-[55%] w-[20%]">
|
||||
<Heraldry seed={market.slug} class="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Main content ───────────────────────────────────── -->
|
||||
<div class="mx-auto max-w-[1320px] px-10 pt-10 pb-20">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="Breadcrumb" class="mb-6">
|
||||
<ol
|
||||
class="text-ink-muted flex items-center gap-1.5 font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
<li><a href="/maerkte" class="hover:text-ink">Märkte</a></li>
|
||||
<li aria-hidden="true">›</li>
|
||||
<li><a href="/maerkte?state={market.state}" class="hover:text-ink">{market.state}</a></li>
|
||||
<li aria-hidden="true">›</li>
|
||||
<li class="text-ink">{market.name}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{#if market.image_url}
|
||||
<div class="mb-8 overflow-hidden rounded-lg" style="max-height: 250px;">
|
||||
<img
|
||||
src={market.image_url}
|
||||
alt={market.name}
|
||||
class="w-full object-cover"
|
||||
onerror={(e) => {
|
||||
const wrap = e.currentTarget.parentElement;
|
||||
if (wrap) wrap.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else if market.logo_url}
|
||||
<div class="mb-8 rounded-lg">
|
||||
<img
|
||||
src={market.logo_url}
|
||||
alt={market.name}
|
||||
class="w-full rounded-lg"
|
||||
style="object-fit: contain; max-height: 150px;"
|
||||
onerror={(e) => {
|
||||
const wrap = e.currentTarget.parentElement;
|
||||
if (wrap) wrap.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Title block -->
|
||||
<div class="mb-2">
|
||||
<Caps>{market.city} · {market.state}</Caps>
|
||||
</div>
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(36px,5vw,64px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
{market.name}
|
||||
</h1>
|
||||
|
||||
<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>
|
||||
<!-- Date + address meta row -->
|
||||
<div class="text-ink-soft mt-4 flex flex-wrap gap-x-6 gap-y-1 font-serif text-[15px] italic">
|
||||
<span>{formatDate(market.start_date)} – {formatDate(market.end_date)}</span>
|
||||
{#if market.street}
|
||||
<span>{market.street}, {market.zip} {market.city}</span>
|
||||
{:else}
|
||||
<span>{market.zip} {market.city}</span>
|
||||
{/if}
|
||||
{#if market.organizer_name}
|
||||
<span>Veranstalter: {market.organizer_name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Edition switcher -->
|
||||
{#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">
|
||||
<div class="mt-5 flex items-center gap-3">
|
||||
<Caps size={9}>Ausgabe</Caps>
|
||||
<span class="border-rule-soft flex border">
|
||||
{#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'}"
|
||||
class="px-3 py-1.5 font-mono text-[11px] tracking-[0.08em] transition-colors {isActive
|
||||
? 'bg-ink text-bg'
|
||||
: 'text-ink-muted hover:text-ink'}"
|
||||
>
|
||||
{edition.year}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</span>
|
||||
</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">
|
||||
<!-- Action row -->
|
||||
<div class="mt-6 flex flex-wrap items-center gap-4">
|
||||
{#if market.website}
|
||||
<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"
|
||||
class="border-ink bg-ink text-bg border px-5 py-2 font-mono text-[11px] tracking-[0.14em] uppercase"
|
||||
>
|
||||
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>
|
||||
Website →
|
||||
</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,
|
||||
logo_url: market.logo_url,
|
||||
organizer_name: market.organizer_name
|
||||
}
|
||||
]}
|
||||
class="h-[300px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-8 flex flex-wrap items-center justify-end gap-3 border-t border-stone-200 pt-6 dark:border-stone-700"
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (feedbackOpen = true)}
|
||||
class="inline-flex items-center gap-1.5 text-sm font-medium text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100"
|
||||
class="text-ink-muted hover:text-accent font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
<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="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
Falsche oder fehlende Angaben melden
|
||||
Angaben melden
|
||||
</button>
|
||||
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin/maerkte/{market.id}/bearbeiten"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-amber-400/60 bg-amber-50 px-2.5 py-1 text-sm font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-500/40 dark:bg-amber-900/30 dark:text-amber-200 dark:hover:bg-amber-900/50"
|
||||
title="Nur Admins sehen diesen Link"
|
||||
class="border-accent text-accent border px-3 py-1.5 font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
<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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"
|
||||
/>
|
||||
</svg>
|
||||
Bearbeiten
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
<Rule kind="thin" />
|
||||
</div>
|
||||
|
||||
<!-- Content grid -->
|
||||
<div class="mt-10 grid gap-12 lg:grid-cols-[1fr_320px]">
|
||||
<!-- Left column -->
|
||||
<div class="space-y-10">
|
||||
{#if market.description}
|
||||
<section>
|
||||
<Caps class="mb-4">Beschreibung</Caps>
|
||||
<p class="text-ink-soft font-serif text-[17px] leading-[1.65] whitespace-pre-line">
|
||||
{market.description}
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if openingHours.length > 0}
|
||||
<section>
|
||||
<Caps class="mb-4">Öffnungszeiten</Caps>
|
||||
<table class="w-full">
|
||||
<tbody>
|
||||
{#each openingHours as entry}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink py-2.5 font-serif text-[15px]">{entry.day}</td>
|
||||
<td
|
||||
class="text-ink-soft py-2.5 text-right font-mono text-[12px] tracking-[0.05em]"
|
||||
>{entry.open} – {entry.close}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if admission}
|
||||
<section>
|
||||
<Caps class="mb-4">Eintrittspreise</Caps>
|
||||
<table class="w-full">
|
||||
<tbody>
|
||||
{#if parsedNotes.groups.length > 0}
|
||||
{#each parsedNotes.groups as group, i}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td
|
||||
colspan="2"
|
||||
class="{i > 0
|
||||
? 'pt-4'
|
||||
: 'pt-1'} text-ink-muted pb-1 font-serif text-[13px] tracking-[0.08em] uppercase"
|
||||
>{group.label}</td
|
||||
>
|
||||
</tr>
|
||||
{#each group.entries as entry}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink py-2.5 pl-3 font-serif text-[15px]">{entry.category}</td>
|
||||
<td class="text-ink-soft py-2.5 text-right font-mono text-[13px]"
|
||||
>{entry.price}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink py-2.5 font-serif text-[15px]">Erwachsene</td>
|
||||
<td class="text-ink-soft py-2.5 text-right font-mono text-[13px]"
|
||||
>{centsToEuro(admission.adult_cents)}</td
|
||||
>
|
||||
</tr>
|
||||
{#if admission.reduced_cents > 0}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink py-2.5 font-serif text-[15px]">Ermäßigt</td>
|
||||
<td class="text-ink-soft py-2.5 text-right font-mono text-[13px]"
|
||||
>{centsToEuro(admission.reduced_cents)}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if admission.child_cents > 0}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink py-2.5 font-serif text-[15px]">Kinder</td>
|
||||
<td class="text-ink-soft py-2.5 text-right font-mono text-[13px]"
|
||||
>{centsToEuro(admission.child_cents)}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if admission.free_under_age > 0}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink py-2.5 font-serif text-[15px]">Frei unter</td>
|
||||
<td class="text-ink-soft py-2.5 text-right font-mono text-[13px]"
|
||||
>{admission.free_under_age} Jahre</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if parsedNotes.remaining || (parsedNotes.groups.length === 0 && admission.notes)}
|
||||
<p class="text-ink-muted mt-3 font-serif text-[13px] italic">
|
||||
{parsedNotes.remaining || admission.notes}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right column — map -->
|
||||
<aside>
|
||||
<Caps class="mb-4">Standort</Caps>
|
||||
<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,
|
||||
logo_url: market.logo_url,
|
||||
organizer_name: market.organizer_name
|
||||
}
|
||||
]}
|
||||
class="border-rule-soft h-[280px] border"
|
||||
/>
|
||||
{#if market.street}
|
||||
<p class="text-ink-muted mt-3 font-serif text-[13px] italic">
|
||||
{market.street}<br />{market.zip}
|
||||
{market.city}
|
||||
</p>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MarketFeedbackDialog
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<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">
|
||||
<p class="text-ink-muted mt-2 font-serif">
|
||||
Kennst du einen Mittelaltermarkt, der noch nicht bei Marktvogt gelistet ist? Reiche ihn hier ein
|
||||
und wir prüfen die Angaben.
|
||||
</p>
|
||||
@@ -47,10 +47,8 @@
|
||||
<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">
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]"> Deine Kontaktdaten </legend>
|
||||
<p class="text-ink-muted font-serif text-sm">
|
||||
Werden nicht veröffentlicht. Nur für Rückfragen.
|
||||
</p>
|
||||
|
||||
@@ -58,7 +56,7 @@
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="submitter_name"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Dein Name *
|
||||
</label>
|
||||
@@ -68,15 +66,12 @@
|
||||
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"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Deine E-Mail *
|
||||
</label>
|
||||
@@ -86,9 +81,6 @@
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -13,143 +14,223 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profil - Marktvogt</title>
|
||||
<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="mx-auto max-w-[760px] px-10 py-12">
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(28px,4vw,44px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
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>
|
||||
<Rule kind="thin" class="my-8" />
|
||||
|
||||
{#if form?.success}
|
||||
<Alert variant="success">{form.success}</Alert>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<Alert variant="error">{form.error}</Alert>
|
||||
{/if}
|
||||
<!-- Kontoinformationen -->
|
||||
<section class="border-rule-soft bg-surface border p-8">
|
||||
<Caps class="mb-6">Kontoinformationen</Caps>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
updateLoading = true;
|
||||
return async ({ update }) => {
|
||||
updateLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="mt-4 space-y-4"
|
||||
{#if form?.success}
|
||||
<div class="mb-6"><Alert variant="success">{form.success}</Alert></div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="mb-6"><Alert variant="error">{form.error}</Alert></div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-6">
|
||||
<span class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>E-Mail</span
|
||||
>
|
||||
<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>
|
||||
<span class="text-ink mt-1 block font-serif text-[16px]">{data.profile.email}</span>
|
||||
</div>
|
||||
|
||||
<Input name="display_name" label="Anzeigename" value={data.profile.display_name} required />
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
updateLoading = true;
|
||||
return async ({ update }) => {
|
||||
updateLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-5"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="display_name"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
id="display_name"
|
||||
name="display_name"
|
||||
type="text"
|
||||
required
|
||||
value={data.profile.display_name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="avatar_url"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Avatar-URL
|
||||
</label>
|
||||
<input
|
||||
id="avatar_url"
|
||||
name="avatar_url"
|
||||
label="Avatar-URL"
|
||||
type="url"
|
||||
value={data.profile.avatar_url}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" loading={updateLoading}>Speichern</Button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="border-rule-soft flex items-center gap-3 border-t pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateLoading}
|
||||
class="border-ink bg-ink text-bg flex items-center gap-2 px-5 py-2.5 font-serif text-[14px] font-[500] disabled:opacity-50"
|
||||
>
|
||||
{#if updateLoading}<Spinner size={14} />{/if}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- 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>
|
||||
<Rule kind="thin" class="my-6" />
|
||||
|
||||
<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>
|
||||
<!-- Sicherheit -->
|
||||
<section class="border-rule-soft bg-surface border p-8">
|
||||
<Caps class="mb-6">Sicherheit</Caps>
|
||||
|
||||
<!-- Password -->
|
||||
<h3 class="text-ink mb-4 font-serif text-[16px] font-[500]">
|
||||
{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-5"
|
||||
>
|
||||
{#if data.profile.has_password}
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="current_password"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Aktuelles Passwort
|
||||
</label>
|
||||
<input id="current_password" name="current_password" type="password" required />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="new_password"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input id="new_password" name="new_password" type="password" required />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="confirm_password"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Passwort bestätigen
|
||||
</label>
|
||||
<input id="confirm_password" name="confirm_password" type="password" required />
|
||||
</div>
|
||||
|
||||
<div class="border-rule-soft flex items-center gap-3 border-t pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={passwordLoading}
|
||||
class="border-ink bg-ink text-bg flex items-center gap-2 px-5 py-2.5 font-serif text-[14px] font-[500] disabled:opacity-50"
|
||||
>
|
||||
{#if passwordLoading}<Spinner size={14} />{/if}
|
||||
{data.profile.has_password ? 'Passwort ändern' : 'Passwort festlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Rule kind="thin" class="my-6" />
|
||||
|
||||
<!-- 2FA link -->
|
||||
<a
|
||||
href="/profile/security"
|
||||
class="text-accent font-mono text-[10px] tracking-[0.15em] uppercase hover:underline"
|
||||
>
|
||||
Zwei-Faktor-Authentifizierung verwalten →
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" class="my-6" />
|
||||
|
||||
<!-- Danger zone -->
|
||||
<section class="border-rule-soft border-l-accent bg-surface border border-l-2 p-8">
|
||||
<Caps color="var(--color-accent)" class="mb-4">Konto löschen</Caps>
|
||||
<p class="text-ink-soft mb-6 font-serif text-[15px] leading-[1.65]">
|
||||
Dein Konto wird deaktiviert und nach 30 Tagen endgültig gelöscht.
|
||||
</p>
|
||||
|
||||
{#if !showDeleteConfirm}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDeleteConfirm = true)}
|
||||
class="border-accent text-accent border px-5 py-2.5 font-serif text-[14px] font-[500]"
|
||||
>
|
||||
Konto löschen
|
||||
</button>
|
||||
{:else}
|
||||
<div class="border-rule-soft bg-surface-alt border p-6">
|
||||
<p class="text-ink mb-5 font-serif text-[15px] font-[500]">
|
||||
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="?/password"
|
||||
action="?/delete"
|
||||
use:enhance={() => {
|
||||
passwordLoading = true;
|
||||
deleteLoading = true;
|
||||
return async ({ update }) => {
|
||||
passwordLoading = false;
|
||||
deleteLoading = 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>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={deleteLoading}
|
||||
class="border-accent bg-accent text-on-accent flex items-center gap-2 border px-5 py-2.5 font-serif text-[14px] font-[500] disabled:opacity-50"
|
||||
>
|
||||
{#if deleteLoading}<Spinner size={14} />{/if}
|
||||
Endgültig löschen
|
||||
</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"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDeleteConfirm = false)}
|
||||
class="border-rule-soft text-ink-muted hover:text-ink border px-5 py-2.5 font-serif text-[14px]"
|
||||
>
|
||||
Zwei-Faktor-Authentifizierung verwalten
|
||||
</a>
|
||||
Abbrechen
|
||||
</button>
|
||||
</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>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import TOTPSetup from '$lib/components/auth/TOTPSetup.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
@@ -12,92 +14,113 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sicherheit - Marktvogt</title>
|
||||
<title>Sicherheit — Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6">
|
||||
<div class="mb-6">
|
||||
<div class="mx-auto max-w-[760px] px-10 py-12">
|
||||
<nav class="mb-8">
|
||||
<a
|
||||
href="/profile"
|
||||
class="text-sm text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200"
|
||||
class="text-ink-muted hover:text-ink font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
← Zurück zum Profil
|
||||
‹ Zurück zum Profil
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<h1 class="mb-8 text-2xl font-bold text-stone-900 dark:text-stone-100">
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(28px,4vw,44px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Zwei-Faktor-Authentifizierung
|
||||
</h1>
|
||||
|
||||
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
|
||||
<Rule kind="thin" class="my-8" />
|
||||
|
||||
<section class="border-rule-soft bg-surface border p-8">
|
||||
{#if form?.success}
|
||||
<Alert variant="success">{form.success}</Alert>
|
||||
<div class="mb-6"><Alert variant="success">{form.success}</Alert></div>
|
||||
{/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>
|
||||
<Caps class="mb-6">Authenticator-App</Caps>
|
||||
<p class="text-ink-soft mb-6 font-serif text-[15px] leading-[1.65]">
|
||||
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();
|
||||
};
|
||||
}}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setup"
|
||||
use:enhance={() => {
|
||||
setupLoading = true;
|
||||
return async ({ update }) => {
|
||||
setupLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={setupLoading}
|
||||
class="border-ink bg-ink text-bg flex items-center gap-2 px-5 py-2.5 font-serif text-[14px] font-[500] disabled:opacity-50"
|
||||
>
|
||||
<Button type="submit" loading={setupLoading}>2FA einrichten</Button>
|
||||
</form>
|
||||
{#if setupLoading}<Spinner size={14} />{/if}
|
||||
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"
|
||||
{#if !showDisableConfirm}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDisableConfirm = true)}
|
||||
class="border-rule-soft text-ink-muted hover:text-ink border px-5 py-2.5 font-serif text-[14px]"
|
||||
>
|
||||
<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>
|
||||
2FA deaktivieren
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showDisableConfirm}
|
||||
<div class="border-rule-soft bg-surface-alt mt-6 border p-6">
|
||||
<p class="text-ink mb-5 font-serif text-[15px]">
|
||||
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"
|
||||
disabled={disableLoading}
|
||||
class="border-accent bg-accent text-on-accent flex items-center gap-2 border px-5 py-2.5 font-serif text-[14px] font-[500] disabled:opacity-50"
|
||||
>
|
||||
{#if disableLoading}<Spinner size={14} />{/if}
|
||||
Deaktivieren
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDisableConfirm = false)}
|
||||
class="border-rule-soft text-ink-muted hover:text-ink border px-5 py-2.5 font-serif text-[14px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error && !form?.totpSecret}
|
||||
<div class="mt-4"><Alert variant="error">{form.error}</Alert></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1,10 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="14.5" fill="#1a3d24" stroke="#c4952e" stroke-width="2"/>
|
||||
<g transform="translate(16,15) scale(0.21)" 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 xmlns="http://www.w3.org/2000/svg" width="400" height="460" viewBox="0 0 40 46">
|
||||
<path d="M2 2 L38 2 L38 26 C38 36 30 42 20 44 C10 42 2 36 2 26 Z" fill="none" stroke="#9a1e2c" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M9 32 L9 14 L14 14 L20 24 L26 14 L31 14 L31 32 L27 32 L27 22 L22 30 L18 30 L13 22 L13 32 Z" fill="#9a1e2c"/>
|
||||
<circle cx="20" cy="9" r="1.5" fill="#9a1e2c"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 541 B After Width: | Height: | Size: 403 B |
@@ -1,11 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 36">
|
||||
<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>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="460" viewBox="0 0 40 46">
|
||||
<path d="M2 2 L38 2 L38 26 C38 36 30 42 20 44 C10 42 2 36 2 26 Z" fill="none" stroke="#1a1612" stroke-width="2" stroke-linejoin="round"></path>
|
||||
<path d="M9 32 L9 14 L14 14 L20 24 L26 14 L31 14 L31 32 L27 32 L27 22 L22 30 L18 30 L13 22 L13 32 Z" fill="#1a1612"></path>
|
||||
<circle cx="20" cy="9" r="1.5" fill="#1a1612"></circle>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 659 B After Width: | Height: | Size: 422 B |
@@ -5,7 +5,7 @@
|
||||
{ "src": "/favicon-32.png", "sizes": "32x32", "type": "image/png" },
|
||||
{ "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
|
||||
],
|
||||
"theme_color": "#1a3d24",
|
||||
"background_color": "#0f2818",
|
||||
"theme_color": "#f5efe4",
|
||||
"background_color": "#f5efe4",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||