feat(web): saisonal admin curator + homepage uses /season/markets

- New admin page at /admin/saisonal: split layout with current selection
  on the left (reorderable + removable) and chronological suggestions on
  the right (one-click pin). Count input (1..24). Server action PUTs the
  full config in one shot.
- Admin sidebar: new 'Saisonal' link between 'Märkte' and 'Discovery'.
- Homepage seasonMarkets fetch switched from /markets?from=today&per_page=6
  to /season/markets so the curated order is what users actually see.
This commit is contained in:
2026-05-16 03:20:08 +02:00
parent 3bb9da759f
commit 51b7dc7bd3
4 changed files with 295 additions and 1 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ export const load: PageServerLoad = async ({ fetch }) => {
const [weekendRes, seasonRes, statsRes] = await Promise.allSettled([
apiFetch<MarketSummary[]>(`/markets?from=${fromDate}&to=${weekendTo}&per_page=4`, { fetch }),
apiFetch<MarketSummary[]>(`/markets?from=${fromDate}&per_page=6`, { fetch }),
apiFetch<MarketSummary[]>('/season/markets', { fetch }),
apiFetch<MarketStats>('/markets/stats', { fetch }),
]);
@@ -10,6 +10,7 @@
const navItems = [
{ href: '/admin/maerkte', label: 'Märkte' },
{ href: '/admin/saisonal', label: 'Saisonal' },
{ href: '/admin/discovery', label: 'Discovery' },
{ href: '/admin/einstellungen', label: 'Einstellungen' },
];
@@ -0,0 +1,59 @@
import { fail } from '@sveltejs/kit';
import { serverFetch } from '$lib/api/client.server.js';
import type { MarketSummary } from '$lib/api/types.js';
import type { Actions, PageServerLoad } from './$types.js';
interface SeasonConfig {
slugs: string[];
count: number;
}
export const load: PageServerLoad = async ({ cookies, fetch }) => {
const [cfgRes, suggestionsRes] = await Promise.all([
serverFetch<SeasonConfig>('/admin/settings/season', cookies, { fetch }).catch(() => null),
serverFetch<MarketSummary[]>('/admin/season/suggestions', cookies, { fetch }).catch(
() => null,
),
]);
const config: SeasonConfig = cfgRes?.data ?? { slugs: [], count: 6 };
const allSuggestions: MarketSummary[] = suggestionsRes?.data ?? [];
const pinnedSet = new Set(config.slugs);
const pinnedMap = new Map(allSuggestions.map((m) => [m.slug, m]));
const pinned: MarketSummary[] = config.slugs
.map((slug) => pinnedMap.get(slug))
.filter((m): m is MarketSummary => Boolean(m));
const suggestions = allSuggestions.filter((m) => !pinnedSet.has(m.slug));
return { config, pinned, suggestions };
};
export const actions: Actions = {
save: async ({ cookies, fetch, request }) => {
const data = await request.formData();
const slugsRaw = data.get('slugs');
const countRaw = data.get('count');
if (typeof slugsRaw !== 'string' || typeof countRaw !== 'string') {
return fail(400, { error: 'Ungültige Eingabe.' });
}
const slugs = slugsRaw.split(',').map((s) => s.trim()).filter(Boolean);
const count = parseInt(countRaw, 10);
if (!Number.isFinite(count) || count < 1 || count > 24) {
return fail(400, { error: 'Anzahl muss zwischen 1 und 24 liegen.' });
}
try {
await serverFetch('/admin/settings/season', cookies, {
method: 'PUT',
body: JSON.stringify({ slugs, count }),
fetch,
});
return { success: true };
} catch (err) {
return fail(500, { error: err instanceof Error ? err.message : 'Fehler beim Speichern.' });
}
},
};
@@ -0,0 +1,234 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types.js';
import type { MarketSummary } from '$lib/api/types.js';
interface Props {
data: PageData;
form: ActionData;
}
let { data, form }: Props = $props();
// Wrap data reads in closures so the $state initializers don't trip the
// state_referenced_locally warning — we explicitly want the snapshot at
// mount; load reruns after save are folded back via form result.
const snapshotPinned = (): MarketSummary[] => [...data.pinned];
const snapshotSuggestions = (): MarketSummary[] => [...data.suggestions];
const snapshotCount = (): number => data.config.count;
let pinned = $state<MarketSummary[]>(snapshotPinned());
let suggestions = $state<MarketSummary[]>(snapshotSuggestions());
let count = $state(snapshotCount());
let saving = $state(false);
function add(market: MarketSummary) {
pinned = [...pinned, market];
suggestions = suggestions.filter((s) => s.slug !== market.slug);
}
function remove(slug: string) {
const removed = pinned.find((m) => m.slug === slug);
pinned = pinned.filter((m) => m.slug !== slug);
if (removed) suggestions = [removed, ...suggestions];
}
function move(slug: string, delta: -1 | 1) {
const idx = pinned.findIndex((m) => m.slug === slug);
const target = idx + delta;
if (idx < 0 || target < 0 || target >= pinned.length) return;
const next = [...pinned];
[next[idx], next[target]] = [next[target], next[idx]];
pinned = next;
}
function fmtDateRange(from: string, to: string): string {
const fmt = (iso: string) =>
new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
timeZone: 'UTC',
});
return from === to ? fmt(from) : `${fmt(from)} ${fmt(to)}`;
}
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-stone-900 dark:text-stone-100">Saisonale Märkte</h1>
<p class="mt-1 text-sm text-stone-500 dark:text-stone-400">
Wähle die Märkte, die auf der Startseite hervorgehoben werden. Fehlende Plätze werden mit
kommenden Märkten in chronologischer Reihenfolge aufgefüllt.
</p>
</div>
{#if form?.success}
<div
class="border-l-2 border-emerald-500 bg-emerald-50 p-3 text-sm text-emerald-900 dark:bg-emerald-900/30 dark:text-emerald-100"
>
Gespeichert.
</div>
{:else if form?.error}
<div
class="border-l-2 border-red-500 bg-red-50 p-3 text-sm text-red-900 dark:bg-red-900/30 dark:text-red-100"
>
{form.error}
</div>
{/if}
<form
method="POST"
action="?/save"
use:enhance={() => {
saving = true;
return async ({ update }) => {
saving = false;
await update();
};
}}
>
<input type="hidden" name="slugs" value={pinned.map((m) => m.slug).join(',')} />
<input type="hidden" name="count" value={count} />
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Pinned -->
<section class="border border-stone-200 dark:border-stone-700">
<header
class="flex items-center justify-between border-b border-stone-200 px-4 py-3 dark:border-stone-700"
>
<h2
class="text-sm font-semibold tracking-wide text-stone-700 uppercase dark:text-stone-300"
>
Aktuelle Auswahl ({pinned.length})
</h2>
<label class="flex items-center gap-2 text-xs text-stone-500 dark:text-stone-400">
Anzeigen:
<input
type="number"
min="1"
max="24"
bind:value={count}
class="w-16 border border-stone-300 px-2 py-1 text-xs dark:border-stone-600 dark:bg-stone-800"
/>
</label>
</header>
{#if pinned.length === 0}
<p class="p-4 text-sm italic text-stone-500 dark:text-stone-400">
Noch keine Märkte ausgewählt. Klicke auf einen Vorschlag rechts.
</p>
{:else}
<ul>
{#each pinned as market, i}
<li
class="flex items-start gap-3 border-b border-stone-100 px-4 py-3 last:border-0 dark:border-stone-800"
>
<span
class="mt-0.5 w-5 shrink-0 font-mono text-xs text-stone-400 dark:text-stone-500"
>
{i + 1}.
</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-stone-900 dark:text-stone-100">
{market.name}
</div>
<div class="text-xs text-stone-500 dark:text-stone-400">
{market.city} · {market.state} · {
fmtDateRange(
market.start_date,
market.end_date,
)
}
</div>
</div>
<div class="flex shrink-0 gap-1">
<button
type="button"
onclick={() => move(market.slug, -1)}
disabled={i === 0}
aria-label="Nach oben"
class="px-2 py-1 text-stone-500 hover:text-stone-900 disabled:opacity-30 dark:hover:text-stone-100"
>
</button>
<button
type="button"
onclick={() => move(market.slug, 1)}
disabled={i === pinned.length - 1}
aria-label="Nach unten"
class="px-2 py-1 text-stone-500 hover:text-stone-900 disabled:opacity-30 dark:hover:text-stone-100"
>
</button>
<button
type="button"
onclick={() => remove(market.slug)}
aria-label="Entfernen"
class="px-2 py-1 text-stone-500 hover:text-red-600 dark:hover:text-red-400"
>
×
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<!-- Suggestions -->
<section class="border border-stone-200 dark:border-stone-700">
<header class="border-b border-stone-200 px-4 py-3 dark:border-stone-700">
<h2
class="text-sm font-semibold tracking-wide text-stone-700 uppercase dark:text-stone-300"
>
Vorschläge — kommende Märkte ({suggestions.length})
</h2>
</header>
{#if suggestions.length === 0}
<p class="p-4 text-sm italic text-stone-500 dark:text-stone-400">
Keine weiteren Vorschläge verfügbar.
</p>
{:else}
<ul class="max-h-[600px] overflow-y-auto">
{#each suggestions as market}
<li
class="flex items-start gap-3 border-b border-stone-100 px-4 py-3 last:border-0 dark:border-stone-800"
>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-stone-900 dark:text-stone-100">
{market.name}
</div>
<div class="text-xs text-stone-500 dark:text-stone-400">
{market.city} · {market.state} · {
fmtDateRange(
market.start_date,
market.end_date,
)
}
</div>
</div>
<button
type="button"
onclick={() => add(market)}
class="shrink-0 border border-stone-300 px-3 py-1 text-xs font-medium text-stone-700 hover:bg-stone-100 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-800"
>
+ Pin
</button>
</li>
{/each}
</ul>
{/if}
</section>
</div>
<div class="mt-6 flex justify-end">
<button
type="submit"
disabled={saving}
class="border border-stone-900 bg-stone-900 px-5 py-2 text-sm font-medium text-white hover:bg-stone-800 disabled:opacity-50 dark:border-stone-100 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-stone-200"
>
{saving ? 'Speichere…' : 'Auswahl speichern'}
</button>
</div>
</form>
</div>