feat: add admin panel, market submission form, and legal updates

- Admin layout with auth guard at /admin
- Admin market list with status filter tabs (pending/approved/rejected)
- Admin market detail/review with approve/reject actions
- Admin market create and edit forms with shared MarketForm component
- Anonymous market submission form at /markt/einreichen with Turnstile
- Optional latitude/longitude fields on submission form
- Admin and submission types added to API types
- requireAdmin guard for role-based frontend access
- Header/mobile nav updated with admin and submission links
- Auth layout redirect removed to re-enable login flow
- Login form action renamed to fix named actions conflict
- Impressum updated with user-submitted content section
- Datenschutz updated with submission form and Turnstile sections
This commit is contained in:
2026-02-27 11:04:31 +01:00
parent 63636bfb7f
commit 2e6acceb33
23 changed files with 1292 additions and 13 deletions

View File

@@ -1 +1,4 @@
PUBLIC_API_BASE_URL=http://localhost:8080
# Cloudflare Turnstile (site key - public, safe to expose)
PUBLIC_TURNSTILE_SITE_KEY=

View File

@@ -97,6 +97,68 @@ export interface ProfileData {
created_at: string;
}
// Admin types
export type MarketStatus = 'pending' | 'approved' | 'rejected';
export interface AdminMarketSummary {
id: string;
slug: string;
name: string;
city: string;
state: string;
status: MarketStatus;
start_date: string;
end_date: string;
organizer_name: string;
submitter_name: string;
created_at: string;
}
export interface AdminMarketDetail {
id: string;
slug: string;
name: string;
description: string;
street: string;
city: string;
state: string;
zip: string;
country: string;
latitude: number;
longitude: number;
start_date: string;
end_date: string;
opening_hours: OpeningHoursEntry[] | null;
admission_info: AdmissionInfo | null;
website: string;
organizer_name: string;
image_url: string;
status: MarketStatus;
submitter_email?: string;
submitter_name: string;
admin_notes: string;
reviewed_at?: string;
reviewed_by?: string;
created_at: string;
updated_at: string;
}
export interface SubmitMarketRequest {
name: string;
description: string;
city: string;
state: string;
zip: string;
country: string;
start_date: string;
end_date: string;
website: string;
organizer_name: string;
submitter_email: string;
submitter_name: string;
turnstile_token: string;
}
// Search params (mirrors backend SearchParams)
export interface MarketSearchParams {
lat?: number;

View File

@@ -1,4 +1,4 @@
import { redirect } from '@sveltejs/kit';
import { error, redirect } from '@sveltejs/kit';
import type { ServerLoadEvent } from '@sveltejs/kit';
export function requireAuth(event: ServerLoadEvent): void {
@@ -6,3 +6,12 @@ export function requireAuth(event: ServerLoadEvent): void {
redirect(302, `/auth/anmelden?redirect=${encodeURIComponent(event.url.pathname)}`);
}
}
export function requireAdmin(event: ServerLoadEvent): void {
if (!event.locals.user) {
redirect(302, `/auth/anmelden?redirect=${encodeURIComponent(event.url.pathname)}`);
}
if (event.locals.user.role !== 'admin') {
error(403, 'Zugriff verweigert');
}
}

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import type { AdminMarketDetail } from '$lib/api/types.js';
import Input from '$lib/components/ui/Input.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface Props {
market?: AdminMarketDetail;
loading?: boolean;
error?: string;
}
let { market, loading = false, error }: Props = $props();
</script>
{#if error}
<div
class="border-danger-200 bg-danger-50 text-danger-800 dark:border-danger-800 dark:bg-danger-950 dark:text-danger-200 mb-4 rounded-lg
border p-4 text-sm"
role="alert"
>
{error}
</div>
{/if}
<div class="space-y-6">
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Allgemein</legend>
<Input label="Name *" name="name" type="text" required value={market?.name ?? ''} />
<div class="space-y-1">
<label for="description" class="block text-sm font-medium text-stone-700 dark:text-stone-200">
Beschreibung
</label>
<textarea
id="description"
name="description"
rows="4"
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
text-sm shadow-sm focus:ring-2 focus:outline-none
dark:border-stone-600 dark:bg-stone-800">{market?.description ?? ''}</textarea
>
</div>
</fieldset>
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Standort</legend>
<Input label="Strasse" name="street" type="text" value={market?.street ?? ''} />
<div class="grid grid-cols-2 gap-4">
<Input label="Stadt *" name="city" type="text" required value={market?.city ?? ''} />
<Input label="Bundesland" name="state" type="text" value={market?.state ?? ''} />
</div>
<div class="grid grid-cols-2 gap-4">
<Input label="PLZ" name="zip" type="text" value={market?.zip ?? ''} />
<div class="space-y-1">
<label for="country" class="block text-sm font-medium text-stone-700 dark:text-stone-200">
Land *
</label>
<select
id="country"
name="country"
required
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
text-sm shadow-sm focus:ring-2 focus:outline-none
dark:border-stone-600 dark:bg-stone-800"
>
<option value="DE" selected={(market?.country ?? 'DE') === 'DE'}>Deutschland</option>
<option value="AT" selected={market?.country === 'AT'}>Oesterreich</option>
<option value="CH" selected={market?.country === 'CH'}>Schweiz</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
label="Breitengrad *"
name="latitude"
type="number"
required
step="any"
value={String(market?.latitude ?? '')}
/>
<Input
label="Laengengrad *"
name="longitude"
type="number"
required
step="any"
value={String(market?.longitude ?? '')}
/>
</div>
</fieldset>
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Zeitraum</legend>
<div class="grid grid-cols-2 gap-4">
<Input
label="Startdatum *"
name="start_date"
type="date"
required
value={market?.start_date ?? ''}
/>
<Input
label="Enddatum *"
name="end_date"
type="date"
required
value={market?.end_date ?? ''}
/>
</div>
</fieldset>
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Weitere Infos</legend>
<Input label="Website" name="website" type="url" value={market?.website ?? ''} />
<Input
label="Veranstalter"
name="organizer_name"
type="text"
value={market?.organizer_name ?? ''}
/>
<Input label="Bild-URL" name="image_url" type="url" value={market?.image_url ?? ''} />
</fieldset>
<div class="flex gap-3 border-t border-stone-200 pt-6 dark:border-stone-700">
<Button type="submit" {loading}>{market ? 'Speichern' : 'Erstellen'}</Button>
<a href="/admin/maerkte">
<Button variant="secondary" type="button">Abbrechen</Button>
</a>
</div>
</div>

View File

@@ -15,7 +15,7 @@
<form
method="POST"
action="/auth/anmelden"
action="/auth/anmelden?/login"
use:enhance={() => {
loading = true;
return async ({ update }) => {

View File

@@ -37,6 +37,14 @@
<!-- Desktop nav -->
<nav class="hidden items-center gap-6 md:flex">
<a href="/" class="text-primary-200 text-sm font-medium hover:text-white">Suche</a>
<a href="/markt/einreichen" class="text-primary-200 text-sm font-medium hover:text-white">
Markt einreichen
</a>
{#if user?.role === 'admin'}
<a href="/admin/maerkte" class="text-accent-300 hover:text-accent-200 text-sm font-medium">
Admin
</a>
{/if}
<!-- TODO: re-enable auth nav when login/signup is ready
{#if user}
<a href="/profile" class="text-sm font-medium text-primary-200 hover:text-white">Profil</a>

View File

@@ -6,7 +6,7 @@
onclose: () => void;
}
let { user: _user, onclose }: Props = $props();
let { user, onclose }: Props = $props();
</script>
<nav class="border-primary-800 bg-primary-900 border-t px-4 py-4 md:hidden">
@@ -14,6 +14,22 @@
<a href="/" class="text-primary-200 text-sm font-medium hover:text-white" onclick={onclose}>
Suche
</a>
<a
href="/markt/einreichen"
class="text-primary-200 text-sm font-medium hover:text-white"
onclick={onclose}
>
Markt einreichen
</a>
{#if user?.role === 'admin'}
<a
href="/admin/maerkte"
class="text-accent-300 hover:text-accent-200 text-sm font-medium"
onclick={onclose}
>
Admin
</a>
{/if}
<!-- TODO: re-enable auth nav when login/signup is ready
{#if user}
<a href="/profile" class="text-sm font-medium text-primary-200 hover:text-white" onclick={onclose}>

View File

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

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/stores';
interface Props {
children: Snippet;
}
let { children }: Props = $props();
const navItems = [{ href: '/admin/maerkte', label: 'Maerkte' }];
function isActive(href: string): boolean {
return $page.url.pathname.startsWith(href);
}
</script>
<svelte:head>
<title>Admin - Marktvogt</title>
</svelte:head>
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div class="flex flex-col gap-6 md:flex-row">
<nav class="w-full shrink-0 md:w-48">
<h2 class="mb-3 text-sm font-semibold text-stone-500 uppercase dark:text-stone-400">Admin</h2>
<ul class="flex gap-1 md:flex-col">
{#each navItems as item}
<li>
<a
href={item.href}
class="block rounded-lg px-3 py-2 text-sm font-medium transition-colors
{isActive(item.href)
? 'bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200'
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-800'}"
>
{item.label}
</a>
</li>
{/each}
</ul>
</nav>
<div class="min-w-0 flex-1">
{@render children()}
</div>
</div>
</div>

View File

@@ -0,0 +1,19 @@
import { serverFetch } from '$lib/api/client.server.js';
import type { AdminMarketSummary, PaginationMeta } from '$lib/api/types.js';
import { buildSearchQuery } from '$lib/api/client.js';
import type { PageServerLoad } from './$types.js';
export const load: PageServerLoad = async ({ url, cookies }) => {
const status = url.searchParams.get('status') ?? '';
const q = url.searchParams.get('q') ?? '';
const page = url.searchParams.get('page') ?? '1';
const query = buildSearchQuery({ status, q, page, per_page: '20' });
const res = await serverFetch<AdminMarketSummary[]>(`/admin/markets?${query}`, cookies);
return {
markets: res.data,
meta: res.meta as PaginationMeta,
filters: { status, q }
};
};

View File

@@ -0,0 +1,153 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
import type { MarketStatus } from '$lib/api/types.js';
let { data } = $props();
const statusLabels: Record<MarketStatus, string> = {
pending: 'Ausstehend',
approved: 'Genehmigt',
rejected: 'Abgelehnt'
};
const statusColors: Record<MarketStatus, string> = {
pending: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
approved: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
rejected: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200'
};
const tabs: { label: string; value: string }[] = [
{ label: 'Alle', value: '' },
{ label: 'Ausstehend', value: 'pending' },
{ label: 'Genehmigt', value: 'approved' },
{ label: 'Abgelehnt', value: 'rejected' }
];
function formatDate(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
</script>
<svelte:head>
<title>Maerkte verwalten - Admin - Marktvogt</title>
</svelte:head>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-2xl font-bold">Maerkte</h1>
<a href="/admin/maerkte/neu">
<Button size="sm">Neuer Markt</Button>
</a>
</div>
<!-- Status filter tabs -->
<div class="flex gap-1 rounded-lg bg-stone-100 p-1 dark:bg-stone-800">
{#each tabs as tab}
<a
href="/admin/maerkte?status={tab.value}{data.filters.q ? `&q=${data.filters.q}` : ''}"
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors
{data.filters.status === tab.value
? 'bg-white text-stone-900 shadow-sm dark:bg-stone-700 dark:text-stone-100'
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200'}"
>
{tab.label}
</a>
{/each}
</div>
<!-- Market table -->
<div class="overflow-x-auto rounded-lg border border-stone-200 dark:border-stone-700">
<table class="w-full text-left text-sm">
<thead class="bg-stone-50 dark:bg-stone-800">
<tr>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Name</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Stadt</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Status</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Zeitraum</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Erstellt</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-200 dark:divide-stone-700">
{#each data.markets as market}
<tr class="hover:bg-stone-50 dark:hover:bg-stone-800/50">
<td class="px-4 py-3 font-medium text-stone-900 dark:text-stone-100">
{market.name}
</td>
<td class="px-4 py-3 text-stone-600 dark:text-stone-400">{market.city}</td>
<td class="px-4 py-3">
<span
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusColors[
market.status
]}"
>
{statusLabels[market.status]}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-stone-600 dark:text-stone-400">
{formatDate(market.start_date)} - {formatDate(market.end_date)}
</td>
<td class="px-4 py-3 whitespace-nowrap text-stone-600 dark:text-stone-400">
{formatDate(market.created_at)}
</td>
<td class="px-4 py-3">
<div class="flex gap-2">
<a
href="/admin/maerkte/{market.id}"
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
>
Ansehen
</a>
<a
href="/admin/maerkte/{market.id}/bearbeiten"
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
>
Bearbeiten
</a>
</div>
</td>
</tr>
{:else}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-stone-500 dark:text-stone-400">
Keine Maerkte gefunden.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination -->
{#if data.meta && data.meta.total_pages > 1}
<div class="flex items-center justify-between">
<p class="text-sm text-stone-600 dark:text-stone-400">
Seite {data.meta.page} von {data.meta.total_pages} ({data.meta.total} Eintraege)
</p>
<div class="flex gap-2">
{#if data.meta.page > 1}
<a
href="/admin/maerkte?page={data.meta.page - 1}&status={data.filters.status}{data.filters
.q
? `&q=${data.filters.q}`
: ''}"
>
<Button variant="secondary" size="sm">Zurueck</Button>
</a>
{/if}
{#if data.meta.page < data.meta.total_pages}
<a
href="/admin/maerkte?page={data.meta.page + 1}&status={data.filters.status}{data.filters
.q
? `&q=${data.filters.q}`
: ''}"
>
<Button variant="secondary" size="sm">Weiter</Button>
</a>
{/if}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,48 @@
import { fail } from '@sveltejs/kit';
import { serverFetch } from '$lib/api/client.server.js';
import type { AdminMarketDetail } from '$lib/api/types.js';
import type { Actions, PageServerLoad } from './$types.js';
export const load: PageServerLoad = async ({ params, cookies }) => {
const res = await serverFetch<AdminMarketDetail>(`/admin/markets/${params.id}`, cookies);
return { market: res.data };
};
export const actions: Actions = {
updateStatus: async ({ request, params, cookies, fetch }) => {
const form = await request.formData();
const status = form.get('status')?.toString() ?? '';
const adminNotes = form.get('admin_notes')?.toString() ?? '';
if (!status || !['approved', 'rejected'].includes(status)) {
return fail(400, { error: 'Ungueltiger Status.' });
}
try {
await serverFetch(`/admin/markets/${params.id}/status`, cookies, {
method: 'PATCH',
body: JSON.stringify({ status, admin_notes: adminNotes }),
fetch
});
return { success: true, action: status };
} catch (err) {
const message = err instanceof Error ? err.message : 'Statusaenderung fehlgeschlagen.';
return fail(500, { error: message });
}
},
delete: async ({ params, cookies, fetch }) => {
try {
await serverFetch(`/admin/markets/${params.id}`, cookies, {
method: 'DELETE',
fetch
});
return { deleted: true };
} catch (err) {
const message = err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.';
return fail(500, { error: message });
}
}
};

View File

@@ -0,0 +1,237 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import Button from '$lib/components/ui/Button.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import type { MarketStatus } from '$lib/api/types.js';
let { data, form } = $props();
let loading = $state(false);
const statusLabels: Record<MarketStatus, string> = {
pending: 'Ausstehend',
approved: 'Genehmigt',
rejected: 'Abgelehnt'
};
const statusColors: Record<MarketStatus, string> = {
pending: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
approved: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
rejected: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200'
};
function formatDate(dateStr: string): string {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
$effect(() => {
if (form?.deleted) {
goto('/admin/maerkte');
}
});
</script>
<svelte:head>
<title>{data.market.name} - Admin - Marktvogt</title>
</svelte:head>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<a
href="/admin/maerkte"
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm"
>
&larr; Zurueck zur Liste
</a>
<h1 class="mt-1 text-2xl font-bold">{data.market.name}</h1>
</div>
<div class="flex gap-2">
<a href="/admin/maerkte/{data.market.id}/bearbeiten">
<Button variant="secondary" size="sm">Bearbeiten</Button>
</a>
<form
method="POST"
action="?/delete"
use:enhance={(e) => {
if (!confirm('Markt wirklich loeschen?')) {
e.cancel();
return;
}
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
>
<Button variant="danger" size="sm" type="submit" {loading}>Loeschen</Button>
</form>
</div>
</div>
{#if form?.error}
<Alert variant="error">{form.error}</Alert>
{/if}
{#if form?.success}
<Alert variant="success">
Status erfolgreich auf "{form.action === 'approved' ? 'Genehmigt' : 'Abgelehnt'}" geaendert.
</Alert>
{/if}
<!-- Status badge + review section -->
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-stone-600 dark:text-stone-400">Status:</span>
<span
class="inline-flex rounded-full px-3 py-1 text-sm font-medium {statusColors[
data.market.status
]}"
>
{statusLabels[data.market.status]}
</span>
</div>
{#if data.market.status === 'pending'}
<div class="mt-4 border-t border-stone-200 pt-4 dark:border-stone-700">
<form
method="POST"
action="?/updateStatus"
class="space-y-4"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
>
<div class="space-y-1">
<label
for="admin_notes"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Admin-Notizen
</label>
<textarea
id="admin_notes"
name="admin_notes"
rows="3"
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
text-sm shadow-sm focus:ring-2 focus:outline-none
dark:border-stone-600 dark:bg-stone-800"
placeholder="Optionale Notizen zur Entscheidung..."
>{data.market.admin_notes}</textarea
>
</div>
<div class="flex gap-2">
<Button type="submit" name="status" value="approved" {loading}>Genehmigen</Button>
<Button type="submit" name="status" value="rejected" variant="danger" {loading}>
Ablehnen
</Button>
</div>
</form>
</div>
{:else}
{#if data.market.admin_notes}
<p class="mt-2 text-sm text-stone-600 dark:text-stone-400">
<span class="font-medium">Notizen:</span>
{data.market.admin_notes}
</p>
{/if}
{#if data.market.reviewed_at}
<p class="mt-1 text-sm text-stone-500 dark:text-stone-500">
Geprueft am {formatDate(data.market.reviewed_at)}
</p>
{/if}
{/if}
</div>
<!-- Market details -->
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
<h2 class="mb-4 text-lg font-semibold">Details</h2>
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Beschreibung</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{data.market.description || '-'}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Adresse</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{data.market.street ? `${data.market.street}, ` : ''}
{data.market.zip}
{data.market.city}, {data.market.state}
{data.market.country}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Koordinaten</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{data.market.latitude.toFixed(6)}, {data.market.longitude.toFixed(6)}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Zeitraum</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{formatDate(data.market.start_date)} - {formatDate(data.market.end_date)}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Veranstalter</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{data.market.organizer_name || '-'}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Website</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{#if data.market.website}
<a
href={data.market.website}
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 dark:text-primary-400 hover:underline"
>
{data.market.website}
</a>
{:else}
-
{/if}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Slug</dt>
<dd class="mt-1 font-mono text-sm text-stone-900 dark:text-stone-100">
{data.market.slug}
</dd>
</div>
</dl>
</div>
<!-- Submitter info -->
{#if data.market.submitter_email || data.market.submitter_name}
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
<h2 class="mb-4 text-lg font-semibold">Eingereicht von</h2>
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Name</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{data.market.submitter_name || '-'}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">E-Mail</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{data.market.submitter_email || '-'}
</dd>
</div>
</dl>
</div>
{/if}
</div>

View File

@@ -0,0 +1,58 @@
import { fail, redirect } from '@sveltejs/kit';
import { serverFetch } from '$lib/api/client.server.js';
import type { AdminMarketDetail } from '$lib/api/types.js';
import type { Actions, PageServerLoad } from './$types.js';
export const load: PageServerLoad = async ({ params, cookies }) => {
const res = await serverFetch<AdminMarketDetail>(`/admin/markets/${params.id}`, cookies);
return { market: res.data };
};
export const actions: Actions = {
default: async ({ request, params, cookies, fetch }) => {
const form = await request.formData();
const body: Record<string, unknown> = {};
const strFields = [
'name',
'description',
'street',
'city',
'state',
'zip',
'country',
'start_date',
'end_date',
'website',
'organizer_name',
'image_url'
];
for (const field of strFields) {
const val = form.get(field)?.toString();
if (val !== undefined) {
body[field] = val;
}
}
const lat = form.get('latitude')?.toString();
const lon = form.get('longitude')?.toString();
if (lat) body.latitude = parseFloat(lat);
if (lon) body.longitude = parseFloat(lon);
try {
await serverFetch(`/admin/markets/${params.id}`, cookies, {
method: 'PUT',
body: JSON.stringify(body),
fetch
});
redirect(302, `/admin/maerkte/${params.id}`);
} catch (err) {
if (err instanceof Response || (err as { status?: number })?.status === 302) throw err;
const message = err instanceof Error ? err.message : 'Speichern fehlgeschlagen.';
return fail(500, { error: message });
}
}
};

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { enhance } from '$app/forms';
import MarketForm from '$lib/components/admin/MarketForm.svelte';
let { data, form } = $props();
let loading = $state(false);
</script>
<svelte:head>
<title>{data.market.name} bearbeiten - Admin - Marktvogt</title>
</svelte:head>
<div class="space-y-6">
<div>
<a
href="/admin/maerkte/{data.market.id}"
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm"
>
&larr; Zurueck zum Markt
</a>
<h1 class="mt-1 text-2xl font-bold">Markt bearbeiten</h1>
</div>
<form
method="POST"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
>
<MarketForm market={data.market} {loading} error={form?.error} />
</form>
</div>

View File

@@ -0,0 +1,44 @@
import { fail, redirect } from '@sveltejs/kit';
import { serverFetch } from '$lib/api/client.server.js';
import type { AdminMarketDetail } from '$lib/api/types.js';
import type { Actions } from './$types.js';
export const actions: Actions = {
default: async ({ request, cookies, fetch }) => {
const form = await request.formData();
const body: Record<string, unknown> = {
name: form.get('name')?.toString().trim() ?? '',
description: form.get('description')?.toString().trim() ?? '',
street: form.get('street')?.toString().trim() ?? '',
city: form.get('city')?.toString().trim() ?? '',
state: form.get('state')?.toString().trim() ?? '',
zip: form.get('zip')?.toString().trim() ?? '',
country: form.get('country')?.toString().trim() || 'DE',
start_date: form.get('start_date')?.toString().trim() ?? '',
end_date: form.get('end_date')?.toString().trim() ?? '',
website: form.get('website')?.toString().trim() ?? '',
organizer_name: form.get('organizer_name')?.toString().trim() ?? '',
image_url: form.get('image_url')?.toString().trim() ?? ''
};
const lat = form.get('latitude')?.toString();
const lon = form.get('longitude')?.toString();
body.latitude = lat ? parseFloat(lat) : 0;
body.longitude = lon ? parseFloat(lon) : 0;
try {
const res = await serverFetch<AdminMarketDetail>('/admin/markets', cookies, {
method: 'POST',
body: JSON.stringify(body),
fetch
});
redirect(302, `/admin/maerkte/${res.data.id}`);
} catch (err) {
if (err instanceof Response || (err as { status?: number })?.status === 302) throw err;
const message = err instanceof Error ? err.message : 'Erstellen fehlgeschlagen.';
return fail(500, { error: message });
}
}
};

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { enhance } from '$app/forms';
import MarketForm from '$lib/components/admin/MarketForm.svelte';
let { form } = $props();
let loading = $state(false);
</script>
<svelte:head>
<title>Neuer Markt - Admin - Marktvogt</title>
</svelte:head>
<div class="space-y-6">
<div>
<a
href="/admin/maerkte"
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm"
>
&larr; Zurueck zur Liste
</a>
<h1 class="mt-1 text-2xl font-bold">Neuer Markt</h1>
</div>
<form
method="POST"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
>
<MarketForm {loading} error={form?.error} />
</form>
</div>

View File

@@ -1,7 +1,5 @@
// TODO: remove this redirect when login/signup is re-enabled
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = () => {
redirect(302, '/');
return {};
};

View File

@@ -12,7 +12,7 @@ export const load = async ({ locals, url }: { locals: App.Locals; url: URL }) =>
};
export const actions: Actions = {
default: async ({ request, cookies, fetch }) => {
login: async ({ request, cookies, fetch }) => {
const form = await request.formData();
const email = form.get('email') as string;
const password = form.get('password') as string;

View File

@@ -234,7 +234,53 @@
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">10. Standortdaten</h2>
<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:
@@ -269,7 +315,7 @@
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">11. Kartendarstellung</h2>
<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
@@ -293,7 +339,7 @@
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">12. Ihre Rechte</h2>
<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>
@@ -336,7 +382,7 @@
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">13. Beschwerderecht</h2>
<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:
@@ -357,7 +403,7 @@
<section class="mt-8">
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
14. Datenlöschung und Speicherdauer
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:
@@ -377,7 +423,7 @@
<section class="mt-8 mb-4">
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
15. Änderungen dieser Datenschutzerklärung
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

View File

@@ -56,6 +56,18 @@
</p>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
Nutzereingereichte Inhalte
</h2>
<p class="mt-2 text-stone-700 dark:text-stone-300">
Nutzer können über das Formular „Markt einreichen" Informationen zu Mittelaltermärkten zur
Veröffentlichung vorschlagen. Alle Einreichungen werden vor der Veröffentlichung redaktionell
geprüft. Für die Richtigkeit der von Nutzern eingesandten Informationen übernehmen wir keine
Gewähr.
</p>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Haftung für Links</h2>
<p class="mt-2 text-stone-700 dark:text-stone-300">

View File

@@ -0,0 +1,88 @@
import { fail } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
import { apiFetch } from '$lib/api/client.js';
import type { Actions, PageServerLoad } from './$types.js';
export const load: PageServerLoad = async () => {
return {
turnstileSiteKey: env.PUBLIC_TURNSTILE_SITE_KEY ?? ''
};
};
export const actions: Actions = {
default: async ({ request, fetch }) => {
const form = await request.formData();
const name = form.get('name')?.toString().trim() ?? '';
const description = form.get('description')?.toString().trim() ?? '';
const latRaw = form.get('latitude')?.toString().trim() ?? '';
const lonRaw = form.get('longitude')?.toString().trim() ?? '';
const latitude = latRaw ? parseFloat(latRaw) : undefined;
const longitude = lonRaw ? parseFloat(lonRaw) : undefined;
const city = form.get('city')?.toString().trim() ?? '';
const state = form.get('state')?.toString().trim() ?? '';
const zip = form.get('zip')?.toString().trim() ?? '';
const country = form.get('country')?.toString().trim() || 'DE';
const startDate = form.get('start_date')?.toString().trim() ?? '';
const endDate = form.get('end_date')?.toString().trim() ?? '';
const website = form.get('website')?.toString().trim() ?? '';
const organizerName = form.get('organizer_name')?.toString().trim() ?? '';
const submitterEmail = form.get('submitter_email')?.toString().trim() ?? '';
const submitterName = form.get('submitter_name')?.toString().trim() ?? '';
const turnstileToken = form.get('cf-turnstile-response')?.toString() ?? '';
const formState = {
name,
description,
latitude: latRaw,
longitude: lonRaw,
city,
state,
zip,
country,
startDate,
endDate,
website,
organizerName,
submitterEmail,
submitterName
};
if (!name || !city || !startDate || !endDate || !submitterEmail || !submitterName) {
return fail(400, { error: 'Bitte fuelle alle Pflichtfelder aus.', ...formState });
}
if (!turnstileToken) {
return fail(400, { error: 'Bitte bestaetige die Spam-Pruefung.', ...formState });
}
try {
await apiFetch('/markets/submit', {
method: 'POST',
body: JSON.stringify({
name,
description,
...(latitude !== undefined && longitude !== undefined ? { latitude, longitude } : {}),
city,
state,
zip,
country,
start_date: startDate,
end_date: endDate,
website,
organizer_name: organizerName,
submitter_email: submitterEmail,
submitter_name: submitterName,
turnstile_token: turnstileToken
}),
fetch
});
return { success: true };
} catch (err) {
const message =
err instanceof Error ? err.message : 'Ein unbekannter Fehler ist aufgetreten.';
return fail(500, { error: message, ...formState });
}
}
};

View File

@@ -0,0 +1,214 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Input from '$lib/components/ui/Input.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
let { data, form } = $props();
let loading = $state(false);
</script>
<svelte:head>
<title>Markt einreichen - Marktvogt</title>
<meta name="description" content="Reiche einen Mittelaltermarkt bei Marktvogt ein." />
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8">
<h1>Markt einreichen</h1>
<p class="mt-2 text-stone-600 dark:text-stone-400">
Kennst du einen Mittelaltermarkt, der noch nicht bei Marktvogt gelistet ist? Reiche ihn hier ein
und wir pruefen die Angaben.
</p>
{#if form?.success}
<div class="mt-6">
<Alert variant="success">
Vielen Dank! Dein Markt wurde eingereicht und wird nach Pruefung veroeffentlicht.
</Alert>
</div>
{:else}
{#if form?.error}
<div class="mt-6">
<Alert variant="error">{form.error}</Alert>
</div>
{/if}
<form
method="POST"
class="mt-6 space-y-6"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
>
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">
Markt-Informationen
</legend>
<Input
label="Name des Marktes *"
name="name"
type="text"
required
value={form?.name ?? ''}
placeholder="z.B. Ritterturnier zu Muenchen"
/>
<div class="space-y-1">
<label
for="description"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Beschreibung
</label>
<textarea
id="description"
name="description"
rows="4"
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
text-sm shadow-sm focus:ring-2 focus:outline-none
dark:border-stone-600 dark:bg-stone-800"
placeholder="Beschreibe den Markt kurz...">{form?.description ?? ''}</textarea
>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
label="Stadt *"
name="city"
type="text"
required
value={form?.city ?? ''}
placeholder="z.B. Muenchen"
/>
<Input
label="Bundesland"
name="state"
type="text"
value={form?.state ?? ''}
placeholder="z.B. Bayern"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
label="PLZ"
name="zip"
type="text"
value={form?.zip ?? ''}
placeholder="z.B. 80331"
/>
<div class="space-y-1">
<label
for="country"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Land *
</label>
<select
id="country"
name="country"
required
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
text-sm shadow-sm focus:ring-2 focus:outline-none
dark:border-stone-600 dark:bg-stone-800"
>
<option value="DE" selected={form?.country !== 'AT' && form?.country !== 'CH'}>
Deutschland
</option>
<option value="AT" selected={form?.country === 'AT'}>Oesterreich</option>
<option value="CH" selected={form?.country === 'CH'}>Schweiz</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
label="Breitengrad"
name="latitude"
type="number"
value={form?.latitude ?? ''}
placeholder="z.B. 48.1351"
step="any"
/>
<Input
label="Laengengrad"
name="longitude"
type="number"
value={form?.longitude ?? ''}
placeholder="z.B. 11.5820"
step="any"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
label="Startdatum *"
name="start_date"
type="date"
required
value={form?.startDate ?? ''}
/>
<Input
label="Enddatum *"
name="end_date"
type="date"
required
value={form?.endDate ?? ''}
/>
</div>
<Input
label="Website"
name="website"
type="url"
value={form?.website ?? ''}
placeholder="https://..."
/>
<Input
label="Veranstalter"
name="organizer_name"
type="text"
value={form?.organizerName ?? ''}
placeholder="Name des Veranstalters"
/>
</fieldset>
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">
Deine Kontaktdaten
</legend>
<p class="text-sm text-stone-500 dark:text-stone-400">
Werden nicht veroeffentlicht. Nur fuer Rueckfragen.
</p>
<Input
label="Dein Name *"
name="submitter_name"
type="text"
required
value={form?.submitterName ?? ''}
/>
<Input
label="Deine E-Mail *"
name="submitter_email"
type="email"
required
value={form?.submitterEmail ?? ''}
/>
</fieldset>
{#if data.turnstileSiteKey}
<div class="cf-turnstile" data-sitekey={data.turnstileSiteKey} data-theme="auto"></div>
{/if}
<Button type="submit" {loading}>Markt einreichen</Button>
</form>
{/if}
</div>