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:
@@ -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>
|
||||
Reference in New Issue
Block a user