Initial SvelteKit frontend port of windwiderstand.de
Full parity with Astro site: content rows, post/tag routes, pagination, event badges + OSM map, comments, Live-Search via /api/search-index, CMS image proxy, RSS, sitemap. Deploy: Dockerfile + docker-compose.prod.yml + Gitea Actions workflow to build + push to git.pm86.de registry and ssh-deploy to Contabo.
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
<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-md 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-md 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-md 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-md 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>
|
||||
Reference in New Issue
Block a user