Initial SvelteKit frontend port of windwiderstand.de
Deploy / verify (push) Failing after 32s
Deploy / deploy (push) Has been skipped

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:
Peter Meier
2026-04-17 22:01:30 +02:00
parent 852cef0980
commit 2fef91a548
137 changed files with 28585 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
type Variant = 'default' | 'primary' | 'accent' | 'inactive' | 'date';
let {
label = '',
variant = 'default',
href = null as string | null,
active = false,
onclick = null as (() => void) | null,
} = $props();
const baseClass =
'inline-flex shrink-0 whitespace-nowrap rounded-full py-1 px-2.5 text-[0.6875rem] font-medium transition-all ring-1 no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1';
const variantClasses = $derived(
{
default: 'bg-stein-0 text-stein-800 ring-stein-200 hover:bg-stein-50',
primary: 'bg-wald-500 text-white ring-wald-600/30 hover:bg-wald-600',
accent: 'bg-himmel-400 text-white ring-himmel-500/30 hover:bg-himmel-500',
inactive: 'bg-stein-100 text-stein-500 ring-stein-200',
date: 'bg-wald-600 text-white ring-wald-700/40',
}[variant]
);
const shadowClass = $derived(
href || onclick
? variant === 'date'
? 'shadow-[0_0_6px_rgba(0,0,0,0.25)]'
: 'shadow-[0_2px_6px_rgba(0,0,0,0.12)]'
: ''
);
const classes = $derived(
`${baseClass} ${variantClasses} ${shadowClass} ${active ? 'pointer-events-none ring-2' : ''}`
);
</script>
{#if href}
<a
href={href}
class={classes}
aria-current={active ? 'true' : undefined}
>
{label}
</a>
{:else if onclick}
<button
type="button"
class={classes}
aria-current={active ? 'true' : undefined}
onclick={onclick}
>
{label}
</button>
{:else}
<span class={classes}>{label}</span>
{/if}
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import "$lib/iconify-offline";
import Icon from "@iconify/svelte";
/** @type {{ href?: string; label: string }[]} */
let { items = [] } = $props();
</script>
<nav aria-label="Breadcrumb" class="overflow-x-auto">
<ol class="list-none flex flex-nowrap items-center gap-x-1 text-xs py-4 pl-0 m-0!">
{#each items as item, i}
<li class="flex items-center gap-x-1.5 shrink-0 whitespace-nowrap">
{#if item.href && i < items.length - 1}
<a
href={item.href}
class="inline-flex font-medium no-underline items-center gap-1.5 text-stein-500 hover:text-wald-600 transition-colors duration-150 rounded px-1 -mx-1 py-0.5 -my-0.5"
aria-label={i === 0 ? item.label : undefined}
>
{#if i === 0}
<Icon icon="mdi:home" class="size-4 shrink-0" aria-hidden="true" />
{:else}
<span>{item.label}</span>
{/if}
</a>
{:else}
<span
class="font-medium text-stein-800"
aria-current={i === items.length - 1 ? 'page' : undefined}
>
{item.label}
</span>
{/if}
{#if i < items.length - 1}
<span aria-hidden="true" class="shrink-0 text-stein-300">
<Icon icon="mdi:chevron-right" class="size-3.5" />
</span>
{/if}
</li>
{/each}
</ol>
</nav>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
type Variant = 'primary' | 'secondary' | 'ghost';
type Size = 'sm' | 'md' | 'large';
let {
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
type = 'button' as 'button' | 'submit' | 'reset',
label = '',
} = $props();
const base =
'inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40';
const sizeClasses = $derived(
{ sm: 'h-9 px-4 py-2 text-sm', md: 'h-12 min-w-[120px] px-6 py-3 text-base', large: 'h-14 px-8 py-4 text-lg' }[
size
]
);
const variantClasses = $derived(
{
primary: 'bg-wald-500 text-white hover:bg-wald-600 active:bg-wald-700',
secondary:
'border-[1.5px] border-wald-500 bg-transparent text-wald-500 hover:bg-wald-50 active:bg-wald-100',
ghost: 'bg-transparent px-4 py-3 text-stein-700 hover:bg-stein-100 active:bg-stein-200',
}[variant]
);
const allClasses = $derived([base, sizeClasses, variantClasses].filter(Boolean).join(' '));
</script>
<button
{type}
{disabled}
class={allClasses}
>
{#if loading}
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
<span>Laden...</span>
{:else}
<slot>{label}</slot>
{/if}
</button>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
let {
checked = $bindable(false),
disabled = false,
label = '',
name = '',
value = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<input
type="checkbox"
bind:checked
{disabled}
{name}
{value}
class="h-5 w-5 rounded border-[1.5px] border-stein-400 text-wald-500 hover:border-wald-400 focus:ring-2 focus:ring-wald-100 focus:ring-offset-0 checked:border-wald-500 checked:bg-wald-500"
/>
{#if label}
<span class="ml-2 text-base text-stein-700">{label}</span>
{/if}
</label>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
/** Gleicher name für alle Radios in einer Gruppe; bind:group vom Parent hält den gewählten value. */
let {
name = '',
value = '',
/** Gebundener Wert der Gruppe entspricht dem value des ausgewählten Radios. */
group = $bindable(''),
disabled = false,
label = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<input
type="radio"
{name}
{value}
bind:group={group}
{disabled}
class="h-5 w-5 rounded-full border-[1.5px] border-stein-400 text-wald-500 hover:border-wald-400 focus:ring-2 focus:ring-wald-100 focus:ring-offset-0 checked:border-wald-500 checked:bg-wald-500"
/>
{#if label}
<span class="ml-2 text-base text-stein-700">{label}</span>
{/if}
</label>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
/** @type {{ value: string; label: string }[]} */
let {
options = [],
value = $bindable(''),
label = '',
placeholder = 'Bitte wählen',
disabled = false,
} = $props();
const selectId = $derived(`select-${Math.random().toString(36).slice(2)}`);
</script>
<div>
{#if label}
<label
for={selectId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
</label>
{/if}
<div class="relative">
<select
id={selectId}
bind:value
{disabled}
class="h-12 w-full appearance-none rounded-lg border border-stein-300 bg-white px-4 py-3 pr-10 text-base text-stein-800 outline-none hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100 disabled:bg-stein-100 disabled:opacity-60"
>
<option value="" disabled>{placeholder}</option>
{#each options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<span
class="pointer-events-none absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-stein-500"
aria-hidden="true"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</span>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
/**
* @type {{ label: string; links: { href: string; label: string; active?: boolean }[] }[]}
*/
let { groups = [] } = $props();
</script>
<nav aria-label="Seitennavigation" class="w-60 bg-stein-50 p-4">
{#each groups as group, i}
<div class="{i > 0 ? 'mt-6' : ''}">
<p
class="mb-2 text-xs font-medium uppercase tracking-wider text-stein-500"
>
{group.label}
</p>
<ul class="space-y-0">
{#each group.links as link}
<li>
<a
href={link.href}
class="block rounded-lg px-3 py-2 text-sm {link.active
? 'bg-wald-50 font-medium text-wald-500'
: 'text-stein-700 hover:bg-wald-50'}"
aria-current={link.active ? 'page' : undefined}
>
{link.label}
</a>
</li>
{/each}
</ul>
</div>
{/each}
</nav>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
let {
min = 0,
max = 100,
value = $bindable(50),
label = '',
disabled = false,
} = $props();
const inputId = $derived(`slider-${Math.random().toString(36).slice(2)}`);
</script>
<div>
{#if label}
<div class="mb-1.5 flex items-center justify-between">
<label for={inputId} class="text-sm font-medium text-stein-700">
{label}
</label>
<span class="text-sm font-semibold text-stein-700">{value}</span>
</div>
{/if}
<input
type="range"
id={inputId}
{min}
{max}
bind:value
{disabled}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(value)}
class="h-5 w-5 accent-wald-500 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-wald-500 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-stein-200"
/>
<div class="mt-1 flex justify-between text-xs text-stein-500">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
/** @type {{ id: string; label: string }[]} */
let {
tabs = [],
selectedId = $bindable(''),
labelledBy = '',
} = $props();
$effect(() => {
if (tabs.length && !selectedId) selectedId = tabs[0].id;
});
function selectTab(id: string) {
selectedId = id;
}
function handleKeydown(e: KeyboardEvent) {
const idx = tabs.findIndex((t) => t.id === selectedId);
if (e.key === 'ArrowRight' && idx < tabs.length - 1) {
e.preventDefault();
selectedId = tabs[idx + 1].id;
} else if (e.key === 'ArrowLeft' && idx > 0) {
e.preventDefault();
selectedId = tabs[idx - 1].id;
}
}
</script>
<div
role="tablist"
aria-label={labelledBy || 'Tabs'}
class="flex h-12 border-b border-stein-200"
onkeydown={handleKeydown}
>
{#each tabs as tab}
<button
type="button"
role="tab"
aria-selected={selectedId === tab.id}
aria-controls="panel-{tab.id}"
id="tab-{tab.id}"
class="border-b-2 px-4 text-sm font-medium transition-colors {selectedId === tab.id
? 'border-wald-500 text-wald-600 font-semibold'
: 'border-transparent text-stein-500 hover:text-stein-700'}"
tabindex={selectedId === tab.id ? 0 : -1}
onclick={() => selectTab(tab.id)}
>
{tab.label}
</button>
{/each}
</div>
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
let {
id = '',
name = '',
type = 'text',
value = $bindable(''),
placeholder = '',
label = '',
required = false,
disabled = false,
error = '',
helpText = '',
invalid = false,
} = $props();
const inputId = $derived(id || `input-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
{#if required}
<span class="text-error">*</span>
{/if}
</label>
{/if}
<input
{type}
{name}
{placeholder}
{required}
{disabled}
bind:value
id={inputId}
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="h-12 w-full rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
/>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
{helpText}
</p>
{/if}
</div>
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
let {
id = '',
name = '',
value = $bindable(''),
placeholder = '',
label = '',
required = false,
disabled = false,
error = '',
helpText = '',
invalid = false,
rows = 4,
} = $props();
const inputId = $derived(id || `textarea-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
{#if required}
<span class="text-error">*</span>
{/if}
</label>
{/if}
<textarea
{name}
{placeholder}
{required}
{disabled}
{rows}
bind:value
id={inputId}
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="min-h-[120px] w-full resize-y rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
/>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
{helpText}
</p>
{/if}
</div>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
let {
checked = $bindable(false),
disabled = false,
label = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center gap-2 {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<span
class="relative inline-block h-6 w-11 shrink-0 rounded-full transition-all duration-200 {checked ? 'bg-wald-500' : 'bg-stein-300'}"
role="switch"
aria-checked={checked}
>
<input
type="checkbox"
class="sr-only"
bind:checked
{disabled}
onkeydown={(e) => e.key === ' ' && (checked = !checked)}
/>
<span
class="absolute top-0.5 inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 {checked ? 'left-[22px] translate-x-0.5' : 'left-0.5'}"
/>
</span>
{#if label}
<span class="text-base text-stein-700">{label}</span>
{/if}
</label>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
content,
placement: preferredPlacement = "top",
children,
}: { content: string; placement?: "top" | "bottom"; children?: Snippet } = $props();
const TOOLTIP_GAP = 8;
const TOOLTIP_MAX_WIDTH_PX = 352; // 22rem
let wrapperEl = $state<HTMLDivElement | null>(null);
let showTooltip = $state(false);
let placement = $state<"top" | "bottom">("top");
let leftPx = $state<number | null>(null); // null = centered (CSS), number = clamped px from wrapper left
function updatePosition() {
if (!wrapperEl) return;
const rect = wrapperEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const spaceAbove = rect.top;
const spaceBelow = vh - rect.bottom;
placement =
preferredPlacement === "top"
? spaceAbove >= spaceBelow
? "top"
: "bottom"
: spaceBelow >= spaceAbove
? "bottom"
: "top";
const iconCenterX = rect.left + rect.width / 2;
const idealLeft = iconCenterX - TOOLTIP_MAX_WIDTH_PX / 2;
const clampedLeft = Math.max(
TOOLTIP_GAP,
Math.min(idealLeft, vw - TOOLTIP_MAX_WIDTH_PX - TOOLTIP_GAP),
);
leftPx = clampedLeft - rect.left;
}
function handleShow() {
updatePosition();
showTooltip = true;
}
function handleHide() {
showTooltip = false;
leftPx = null;
}
const placementClasses = $derived(
placement === "top"
? "bottom-full mb-2"
: "top-full mt-2",
);
const horizontalStyle = $derived(
leftPx != null ? `left: ${leftPx}px; transform: none;` : "left: 50%; transform: translateX(-50%);",
);
</script>
<div
bind:this={wrapperEl}
role="group"
class="relative inline-flex focus-within:outline-none"
onmouseenter={handleShow}
onmouseleave={handleHide}
onfocusin={handleShow}
onfocusout={handleHide}
>
{@render children?.()}
{#if showTooltip}
<div
role="tooltip"
class="absolute z-50 {placementClasses} px-3 py-2 text-xs font-normal text-stein-0 bg-stein-800 rounded-md shadow-lg min-w-56 max-w-88 text-center pointer-events-none whitespace-normal"
style={horizontalStyle}
>
{content}
</div>
{/if}
</div>
+16
View File
@@ -0,0 +1,16 @@
/**
* Windwiderstand UI components
*/
export { default as TabBar } from './TabBar.svelte';
export { default as SidebarNav } from './SidebarNav.svelte';
export { default as Breadcrumbs } from './Breadcrumbs.svelte';
export { default as Button } from './Button.svelte';
export { default as TextInput } from './TextInput.svelte';
export { default as Textarea } from './Textarea.svelte';
export { default as Select } from './Select.svelte';
export { default as Toggle } from './Toggle.svelte';
export { default as Checkbox } from './Checkbox.svelte';
export { default as Radio } from './Radio.svelte';
export { default as Slider } from './Slider.svelte';
export { default as Badge } from './Badge.svelte';
export { default as Tooltip } from './Tooltip.svelte';