b7093b703a
- Palette: gray/zinc/slate/neutral → stein, red → fire, amber → erde, sky → himmel, green → wald - Footer: replaced raw rgb with stein tokens - Radius: unified to rounded-xs across cards, buttons, inputs, pagination - Buttons: .btn-primary/.btn-secondary now font-medium; added secondary - Card hover: -translate-y-0.5 + shadow + wald-300 border - Header active link: wald border-bottom + wald-700 text - Mobile nav: bigger touch targets + wald active accent - Pagination: prev/next text labels + wald-500 active state with separated shape/color classes to avoid Tailwind conflicts - Lead paragraph: scoped .markdown > p:first-child for top-level MarkdownBlock only via [data-block-type="markdown"] - Section dividers: subtle wald-200 gradient between content rows - Global focus-visible ring (wald-500) - Inline hex → palette tokens (Badge amber, Tag custom-color contrast) - Font weights snapped to design system (300/400/500/600/700) - transition-all → transition-colors where only color changes - Removed em-dashes from user-visible templates Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
350 lines
11 KiB
Svelte
350 lines
11 KiB
Svelte
<script lang="ts">
|
||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||
import type {
|
||
CalendarBlockData,
|
||
CalendarItemData,
|
||
} from "$lib/block-types";
|
||
import { t as tStatic, T } from "$lib/translations";
|
||
import type { Translations } from "$lib/translations";
|
||
import "$lib/iconify-offline";
|
||
import Icon from "@iconify/svelte";
|
||
|
||
type EventCardItem = CalendarItemData & {
|
||
date: Date | null;
|
||
timeStr: string;
|
||
};
|
||
|
||
let { block, translations = {} }: { block: CalendarBlockData; translations?: Translations | null } = $props();
|
||
|
||
function t(key: string, replacements?: Record<string, string | number>) {
|
||
return tStatic(translations ?? null, key, replacements);
|
||
}
|
||
|
||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||
|
||
const items = $derived.by(() => {
|
||
const raw = block.items ?? [];
|
||
return raw
|
||
.filter(
|
||
(x): x is CalendarItemData =>
|
||
typeof x === "object" &&
|
||
x !== null &&
|
||
"terminZeit" in x &&
|
||
"title" in x,
|
||
)
|
||
.map((x) => ({
|
||
...x,
|
||
date: parseDate(x.terminZeit),
|
||
timeStr: formatTime(x.terminZeit),
|
||
}))
|
||
.filter((x) => x.date)
|
||
.sort((a, b) => (a.date!.getTime() ?? 0) - (b.date!.getTime() ?? 0));
|
||
});
|
||
|
||
function parseDate(iso: string): Date | null {
|
||
const d = new Date(iso);
|
||
return isNaN(d.getTime()) ? null : d;
|
||
}
|
||
|
||
/** Link aus calendar_item: API kann string (URL) oder aufgelöstes Objekt (_slug oder url) liefern. */
|
||
function getEventLinkHref(link: unknown): string {
|
||
if (typeof link === "string" && link) return link;
|
||
if (link && typeof link === "object") {
|
||
const o = link as { url?: string; _slug?: string };
|
||
if (typeof o.url === "string" && o.url) return o.url;
|
||
if (typeof o._slug === "string" && o._slug)
|
||
return `/post/${encodeURIComponent(o._slug)}`;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function formatTime(iso: string): string {
|
||
const d = new Date(iso);
|
||
if (isNaN(d.getTime())) return "";
|
||
const h = d.getHours();
|
||
const m = d.getMinutes();
|
||
if (h === 0 && m === 0) return "";
|
||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")} Uhr`;
|
||
}
|
||
|
||
function dayKey(d: Date): string {
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
const eventsByDay = $derived.by(() => {
|
||
const map = new Map<string, typeof items>();
|
||
for (const item of items) {
|
||
if (!item.date) continue;
|
||
const key = dayKey(item.date);
|
||
if (!map.has(key)) map.set(key, []);
|
||
map.get(key)!.push(item);
|
||
}
|
||
return map;
|
||
});
|
||
|
||
let currentMonth = $state(new Date());
|
||
let selectedDay = $state<string | null>(null);
|
||
|
||
const year = $derived(currentMonth.getFullYear());
|
||
const month = $derived(currentMonth.getMonth());
|
||
const monthLabel = $derived(
|
||
currentMonth.toLocaleDateString("de-DE", {
|
||
month: "long",
|
||
year: "numeric",
|
||
}),
|
||
);
|
||
|
||
const daysInMonth = $derived.by(() => {
|
||
const first = new Date(year, month, 1);
|
||
const last = new Date(year, month + 1, 0);
|
||
const count = last.getDate();
|
||
const startWeekday = (first.getDay() + 6) % 7;
|
||
return { count, startWeekday, last };
|
||
});
|
||
|
||
const calendarDays = $derived.by(() => {
|
||
const { count, startWeekday } = daysInMonth;
|
||
const empty = Array(startWeekday).fill(null);
|
||
const days = Array.from(
|
||
{ length: count },
|
||
(_, i) => new Date(year, month, i + 1),
|
||
);
|
||
return [...empty, ...days];
|
||
});
|
||
|
||
const selectedDayEvents = $derived(
|
||
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
|
||
);
|
||
|
||
/** Nächste 3 Termine ab heute – zur Laufzeit gefiltert. */
|
||
const nextUpcomingEvents = $derived.by(() => {
|
||
const startOfToday = new Date();
|
||
startOfToday.setHours(0, 0, 0, 0);
|
||
const todayMs = startOfToday.getTime();
|
||
return items
|
||
.filter((item) => item.date && item.date.getTime() >= todayMs)
|
||
.slice(0, 3);
|
||
});
|
||
|
||
function prevMonth() {
|
||
currentMonth = new Date(year, month - 1);
|
||
selectedDay = null;
|
||
}
|
||
|
||
function nextMonth() {
|
||
currentMonth = new Date(year, month + 1);
|
||
selectedDay = null;
|
||
}
|
||
|
||
function selectDay(key: string) {
|
||
selectedDay = selectedDay === key ? null : key;
|
||
}
|
||
|
||
const todayKey = $derived(dayKey(new Date()));
|
||
|
||
const weekdays = $derived([
|
||
t(T.calendar_weekday_mo),
|
||
t(T.calendar_weekday_di),
|
||
t(T.calendar_weekday_mi),
|
||
t(T.calendar_weekday_do),
|
||
t(T.calendar_weekday_fr),
|
||
t(T.calendar_weekday_sa),
|
||
t(T.calendar_weekday_so),
|
||
]);
|
||
</script>
|
||
|
||
<div
|
||
class="{layoutClasses} w-full min-w-0 calendar-block border border-stein-200 overflow-hidden"
|
||
data-block-type="calendar"
|
||
data-block-slug={block._slug}
|
||
>
|
||
{#snippet eventCard(item: EventCardItem)}
|
||
{@const eventHref = getEventLinkHref(item.link)}
|
||
<li class="bg-himmel-100 p-2">
|
||
<div class="flex gap-1">
|
||
{#if item.date}
|
||
<div class="text-xs text-stein-500 mb-0.5">
|
||
{item.date.toLocaleDateString("de-DE", {
|
||
weekday: "short",
|
||
day: "numeric",
|
||
month: "short",
|
||
})}
|
||
</div>
|
||
{#if item.timeStr}
|
||
<span class="text-xs text-stein-600">/</span>
|
||
{/if}
|
||
{/if}
|
||
{#if item.timeStr}
|
||
<div class="text-xs text-stein-600">
|
||
{item.timeStr}
|
||
{t(T.calendar_time_suffix)}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{#if eventHref}
|
||
<a
|
||
href={eventHref}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="text-base font-medium text-stein-900 text-himmel-600 hover:text-himmel-700 hover:underline"
|
||
>
|
||
{item.title}
|
||
</a>
|
||
{:else}
|
||
<div class="text-base font-medium text-stein-900">{item.title}</div>
|
||
{/if}
|
||
|
||
{#if item.description}
|
||
<p class="text-sm font-light text-stein-600 mb-0! line-clamp-3">
|
||
{item.description}
|
||
</p>
|
||
{#if eventHref}
|
||
<a
|
||
href={eventHref}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
|
||
>
|
||
{t(T.calendar_more_info)}
|
||
<Icon
|
||
icon="mdi:open-in-new"
|
||
class="size-4"
|
||
aria-hidden="true"
|
||
/>
|
||
</a>
|
||
{/if}
|
||
{:else if eventHref}
|
||
<a
|
||
href={eventHref}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
|
||
>
|
||
{t(T.calendar_more_info)}
|
||
<Icon
|
||
icon="mdi:open-in-new"
|
||
class="size-4"
|
||
aria-hidden="true"
|
||
/>
|
||
</a>
|
||
{/if}
|
||
</li>
|
||
{/snippet}
|
||
|
||
{#if block.title}
|
||
<h3 class="text-lg font-semibold text-stein-900 px-4 pt-4 pb-2">
|
||
{block.title}
|
||
</h3>
|
||
{/if}
|
||
|
||
<div class="calendar-grid-wrapper grid w-full grid-cols-1 gap-0">
|
||
<!-- Links: Kalender -->
|
||
<div class="calendar-column min-h-full p-4 flex flex-col bg-himmel-800 text-himmel-100 shadow-sm">
|
||
<div class="flex items-center justify-between gap-2 mb-4">
|
||
<button
|
||
type="button"
|
||
class="p-2 rounded-xs text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
|
||
aria-label={t(T.calendar_prev_month)}
|
||
onclick={prevMonth}
|
||
>
|
||
<Icon icon="mdi:chevron-left" class="size-5" aria-hidden="true" />
|
||
</button>
|
||
<span
|
||
class="text-sm font-medium text-himmel-100 capitalize whitespace-nowrap"
|
||
>{monthLabel}</span
|
||
>
|
||
<button
|
||
type="button"
|
||
class="p-2 rounded-xs text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
|
||
aria-label={t(T.calendar_next_month)}
|
||
onclick={nextMonth}
|
||
>
|
||
<Icon icon="mdi:chevron-right" class="size-5" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-7 gap-0.5 text-center text-sm">
|
||
{#each weekdays as w}
|
||
<div class="py-1.5 text-xs font-medium text-himmel-100 opacity-80">{w}</div>
|
||
{/each}
|
||
{#each calendarDays as d}
|
||
{#if d === null}
|
||
<div class="aspect-square" aria-hidden="true"></div>
|
||
{:else}
|
||
{@const key = dayKey(d)}
|
||
{@const isCurrentMonth = d.getMonth() === month}
|
||
{@const events = eventsByDay.get(key) ?? []}
|
||
{@const hasEvents = events.length > 0}
|
||
{@const isSelected = selectedDay === key}
|
||
{@const isFuture = key >= todayKey}
|
||
{#if hasEvents}
|
||
<button
|
||
type="button"
|
||
class="relative aspect-square rounded-xs flex flex-col items-center justify-center min-w-0 transition-colors cursor-pointer {isCurrentMonth
|
||
? 'text-himmel-100 hover:bg-himmel-700'
|
||
: 'text-himmel-100 opacity-60 hover:opacity-80'} bg-himmel-700/50 font-semibold hover:bg-himmel-600 {isSelected
|
||
? 'ring-2 ring-himmel-400 bg-himmel-600'
|
||
: ''}"
|
||
onclick={() => selectDay(key)}
|
||
>
|
||
<span class="text-xs">{d.getDate()}</span>
|
||
<span
|
||
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 px-0.5 rounded-full text-stein-0 text-[9px] font-medium leading-none {isFuture ? 'bg-wald-400' : 'bg-error'}"
|
||
aria-hidden="true"
|
||
>
|
||
{events.length}
|
||
</span>
|
||
</button>
|
||
{:else}
|
||
<div
|
||
class="relative aspect-square rounded-xs flex flex-col items-center justify-center min-w-0 cursor-default {isCurrentMonth
|
||
? 'text-himmel-100'
|
||
: 'text-himmel-100 opacity-50'}"
|
||
>
|
||
<span class="text-xs">{d.getDate()}</span>
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rechts: Events -->
|
||
<div class="calendar-events-wrapper min-h-0">
|
||
<div
|
||
class="calendar-events-panel border-t border-stein-200 p-4 bg-stein-0/50 flex flex-col overflow-hidden"
|
||
>
|
||
{#if items.length === 0}
|
||
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_no_events)}</p>
|
||
{:else if selectedDay}
|
||
<p class="text-xs font-medium text-stein-500 mb-2">
|
||
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
|
||
weekday: "long",
|
||
day: "numeric",
|
||
month: "long",
|
||
})}
|
||
</p>
|
||
<ul class="m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2">
|
||
{#each selectedDayEvents as item}
|
||
{@render eventCard(item)}
|
||
{/each}
|
||
</ul>
|
||
{:else if nextUpcomingEvents.length > 0}
|
||
<p class="text-xs uppercase font-medium text-stein-500">
|
||
{t(T.calendar_next_events)}
|
||
</p>
|
||
<ul
|
||
class="space-y-3 m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2"
|
||
>
|
||
{#each nextUpcomingEvents as item}
|
||
{@render eventCard(item)}
|
||
{/each}
|
||
</ul>
|
||
{:else}
|
||
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_select_day)}</p>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|