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:
@@ -1 +1,4 @@
|
||||
PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
|
||||
# Cloudflare Turnstile (site key - public, safe to expose)
|
||||
PUBLIC_TURNSTILE_SITE_KEY=
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
139
web/src/lib/components/admin/MarketForm.svelte
Normal file
139
web/src/lib/components/admin/MarketForm.svelte
Normal 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>
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/auth/anmelden"
|
||||
action="/auth/anmelden?/login"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
6
web/src/routes/admin/+layout.server.ts
Normal file
6
web/src/routes/admin/+layout.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { requireAdmin } from '$lib/auth/guard.js';
|
||||
import type { LayoutServerLoad } from './$types.js';
|
||||
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
requireAdmin(event);
|
||||
};
|
||||
47
web/src/routes/admin/+layout.svelte
Normal file
47
web/src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
const navItems = [{ href: '/admin/maerkte', label: '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>
|
||||
19
web/src/routes/admin/maerkte/+page.server.ts
Normal file
19
web/src/routes/admin/maerkte/+page.server.ts
Normal 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 }
|
||||
};
|
||||
};
|
||||
153
web/src/routes/admin/maerkte/+page.svelte
Normal file
153
web/src/routes/admin/maerkte/+page.svelte
Normal 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>
|
||||
48
web/src/routes/admin/maerkte/[id]/+page.server.ts
Normal file
48
web/src/routes/admin/maerkte/[id]/+page.server.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
237
web/src/routes/admin/maerkte/[id]/+page.svelte
Normal file
237
web/src/routes/admin/maerkte/[id]/+page.svelte
Normal 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"
|
||||
>
|
||||
← 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>
|
||||
58
web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts
Normal file
58
web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
36
web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte
Normal file
36
web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import MarketForm from '$lib/components/admin/MarketForm.svelte';
|
||||
|
||||
let { 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"
|
||||
>
|
||||
← 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>
|
||||
44
web/src/routes/admin/maerkte/neu/+page.server.ts
Normal file
44
web/src/routes/admin/maerkte/neu/+page.server.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
36
web/src/routes/admin/maerkte/neu/+page.svelte
Normal file
36
web/src/routes/admin/maerkte/neu/+page.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import MarketForm from '$lib/components/admin/MarketForm.svelte';
|
||||
|
||||
let { form } = $props();
|
||||
let loading = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Neuer Markt - Admin - Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<a
|
||||
href="/admin/maerkte"
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm"
|
||||
>
|
||||
← 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>
|
||||
@@ -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 {};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. 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
|
||||
|
||||
@@ -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">
|
||||
|
||||
88
web/src/routes/markt/einreichen/+page.server.ts
Normal file
88
web/src/routes/markt/einreichen/+page.server.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
214
web/src/routes/markt/einreichen/+page.svelte
Normal file
214
web/src/routes/markt/einreichen/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user