4 Commits

Author SHA1 Message Date
vikingowl 911439ebd8 feat(web): implement Burgund Phase 3 — profile, security, auth component cleanup
ci/someci/push/web Pipeline was successful
ci/someci/pr/web Pipeline was successful
ci/someci/pr/backend Pipeline was successful
2026-05-10 16:23:17 +02:00
vikingowl 5e24be03af feat(web): implement Burgund Phase 2 — public surfaces
ci/someci/pr/web Pipeline was successful
ci/someci/push/web Pipeline was successful
ci/someci/push/backend Pipeline was successful
ci/someci/pr/backend Pipeline was successful
2026-05-10 14:54:27 +02:00
vikingowl 00d43675ff fix(web): replace old signet with Burgund shield-M favicon across all sizes
Updates static/favicon.svg (was the old forest-green MedievalSharp signet),
regenerates favicon.ico, favicon-32.png, apple-touch-icon.png. Fixes
site.webmanifest theme_color and background_color to Burgund parchment.
Swaps app.html font preloads to EB Garamond + Cormorant Garamond and
theme-color meta tags to Burgund bg values.
2026-05-10 13:14:46 +02:00
vikingowl 418a4411f3 feat(web): implement Burgund design system foundation (Phase 1)
Establishes the Burgund visual identity system: 11-token color palette
(sealing-wax burgundy #9a1e2c light / halbton rosé #d86268 dark), editorial
typography (Cormorant Garamond display, EB Garamond serif, JetBrains Mono
caps), and all atoms (MarktvogtMark, Caps, Tag, Rule, Heraldry). Rewrites
Header, Footer, MobileNav, all ui/ components, and MarketCard to Burgund
tokens. Self-hosts 12 woff2 variable font subsets. Adds design-system.md
reference doc. Also switches pre-commit hook prettier step to --write.
2026-05-10 12:56:51 +02:00
76 changed files with 3766 additions and 1826 deletions
+3 -2
View File
@@ -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
+172
View File
@@ -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 | 7688px | 500 | display |
| Display large | 56px | 500 | display |
| Display medium | 44px | 500 | display |
| Display small | 24px | 400500 | 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.120.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, 4056px 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 860895 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 |
+167 -115
View File
@@ -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);
}
}
+4 -4
View File
@@ -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
+5 -1
View File
@@ -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

+5
View File
@@ -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

+41 -77
View File
@@ -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}
+19
View File
@@ -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>
+25
View File
@@ -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}
+19
View File
@@ -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>
+61 -42
View File
@@ -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}
+40 -8
View File
@@ -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">&copy; {new Date().getFullYear()} Marktvogt</p>
<div class="flex items-center gap-6">
<nav class="flex gap-6">
<a href="/impressum" class="text-primary-400 hover:text-primary-200 text-sm">Impressum</a>
<a href="/datenschutz" class="text-primary-400 hover:text-primary-200 text-sm"
<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">
&copy; {new Date().getFullYear()} Marktvogt · Ein Verzeichnis historischer Märkte im DACH-Raum
</p>
</div>
</footer>
+73 -53
View File
@@ -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}
+32 -10
View File
@@ -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
+27 -58
View File
@@ -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}
+71 -23
View File
@@ -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 =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <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: '&copy; <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>
+6 -11
View File
@@ -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>
+9 -13
View File
@@ -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>
+10 -3
View File
@@ -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>
+22 -24
View File
@@ -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>
+1 -1
View File
@@ -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 -51
View File
@@ -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>
+66
View File
@@ -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
}
]
}
+1 -1
View File
@@ -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()}
+23 -71
View File
@@ -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 };
};
+371 -104
View File
@@ -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&shy;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&shy;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>
+4 -4
View File
@@ -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}
+13 -17
View File
@@ -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>
+4 -10
View File
@@ -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>
+491 -422
View File
@@ -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.&nbsp;B.
Google Authenticator) aktivieren. Dabei wird ein kryptografisches Geheimnis (TOTP-Secret) mit
Ihrem Konto verknüpft gespeichert. Dieses wird bei Deaktivierung der 2FA oder Löschung des
Kontos gelöscht.
</p>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
9. Cookies und lokale Speicherung
</h2>
<p class="mt-2 text-stone-700 dark:text-stone-300">
Wir verwenden ausschließlich technisch notwendige Cookies:
</p>
<div class="mt-4 overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-stone-200 dark:border-stone-700">
<th class="py-2 pr-4 text-left font-semibold text-stone-900 dark:text-stone-100"
>Name</th
>
<th class="py-2 pr-4 text-left font-semibold text-stone-900 dark:text-stone-100"
>Zweck</th
>
<th class="py-2 pr-4 text-left font-semibold text-stone-900 dark:text-stone-100"
>Dauer</th
>
</tr>
</thead>
<tbody>
<tr class="border-b border-stone-100 dark:border-stone-700">
<td class="py-2 pr-4 font-mono text-stone-700 dark:text-stone-300">access_token</td>
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300"
>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&shy;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.&nbsp;B. IP-Adresse,
Browser-Informationen) an Cloudflare, Inc. übermittelt, um zu prüfen, ob die Eingabe von einem Menschen
stammt. Es werden keine Cookies gesetzt und kein Nutzer-Tracking durchgeführt.
</p>
<p class="mt-2 text-stone-700 dark:text-stone-300">
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am Schutz vor Spam).
Weitere Informationen finden Sie in der
<a
href="https://www.cloudflare.com/privacypolicy/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>Datenschutzerklärung von Cloudflare</a
>.
</p>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">12. Standortdaten</h2>
<p class="mt-2 text-stone-700 dark:text-stone-300">
Für die Umkreissuche nach Märkten können Sie optional Ihren Standort freigeben. Dabei kommen
zwei Verfahren zum Einsatz:
</p>
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
<li>
<strong>Browser-Geolokalisierung</strong> Ihr Browser fragt Ihre Erlaubnis, bevor Standortdaten
bereitgestellt werden. Die Koordinaten werden nicht auf unserem Server gespeichert, sondern nur
zur einmaligen Berechnung der Entfernung zu Märkten verwendet.
</li>
<li>
<strong>IP-basierte Geolokalisierung (Fallback)</strong> Falls die
Browser-Geolokalisierung nicht verfügbar ist, wird der Dienst
<a
href="https://www.geojs.io/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>geojs.io</a
<!-- 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.&nbsp;B.
Google Authenticator) aktivieren. Dabei wird ein kryptografisches Geheimnis (TOTP-Secret)
mit Ihrem Konto verknüpft gespeichert. Dieses wird bei Deaktivierung der 2FA oder Löschung
des Kontos gelöscht.
</p>
</section>
<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.&nbsp;B. IP-Adresse, Browser-Informationen) an Cloudflare, Inc. übermittelt, um zu prüfen,
ob die Eingabe von einem Menschen stammt. Es werden keine Cookies gesetzt und kein Nutzer-Tracking
durchgeführt.
</p>
<p class="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>
+131 -104
View File
@@ -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>
+24
View File
@@ -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 };
}
};
+288
View File
@@ -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>
+13
View File
@@ -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[] };
}
};
+148
View File
@@ -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 };
};
+156
View File
@@ -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>
+73 -35
View File
@@ -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 };
};
+229 -69
View File
@@ -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>
+214 -258
View File
@@ -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
+5 -13
View File
@@ -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>
+194 -113
View File
@@ -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>
+89 -66
View File
@@ -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"
>
&larr; 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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

+4 -9
View File
@@ -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

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -11
View File
@@ -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

+2 -2
View File
@@ -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"
}