Files
windwiderstand/src/lib/components/WindAreaPanel.svelte
T
Peter Meier 0ab2c58eb4
Deploy / verify (push) Successful in 1m26s
Deploy / deploy (push) Successful in 2m26s
feat(header): icon nav redesign + migrate icons mdi→lucide
- Header desktop nav: social icons grouped in a wald-tinted pill with
  labels, active-state (aria-current + bold), opacity hover (no per-item bg)
- Mobile overlay: social links as icon+label rows with active-state
- Search button: lucide:search, icon-only
- Migrate all icons mdi→lucide app-wide (234 refs); brand/language logos
  (google, whatsapp, mastodon, telegram, language-*) stay on mdi
- generate-icons: emit mdi + lucide subsets; add @iconify-json/lucide
- iconify-offline: register lucide collection
- scripts/icon-map|validate|apply: reusable migration tooling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:44:56 +02:00

290 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import type { WindArea, TextFragment } from "$lib/windkarte";
import { useTranslate, T } from "$lib/translations";
import InfoAccordion from "$lib/components/InfoAccordion.svelte";
let {
area,
center,
cmsBase,
onclose,
}: {
area: WindArea | null;
center: [number, number] | null;
cmsBase: string;
onclose: () => void;
} = $props();
const t = useTranslate();
const STATUS_KEY: Record<string, string> = {
rechtskraeftig: T.windkarte_status_rechtskraeftig,
entwurf_2: T.windkarte_status_entwurf_2,
entwurf_3: T.windkarte_status_entwurf_3,
};
const STATUS_CLASS: Record<string, string> = {
rechtskraeftig: "bg-wald-100 text-wald-800",
entwurf_2: "bg-erde-100 text-erde-800",
entwurf_3: "bg-himmel-100 text-himmel-800",
};
function stellungnahmeHref(s: WindArea["stellungnahme"]): string | null {
if (!s) return null;
if (typeof s === "string") return `/post/${s}`;
if (s._slug) return `/post/${s._slug}`;
return null;
}
let kriterien = $state<TextFragment[]>([]);
let loadingKriterien = $state(false);
let expandedSlug = $state<string | null>(null);
$effect(() => {
const slugs = (area?.stellungnahme_kriterien ?? [])
.map((k) => (typeof k === "string" ? k : k._slug))
.filter(Boolean);
kriterien = [];
expandedSlug = null;
if (slugs.length === 0) return;
loadingKriterien = true;
Promise.all(
slugs.map((slug) =>
fetch(`${cmsBase}/api/content/text_fragment/${slug}`)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
),
).then((results) => {
kriterien = results.filter(Boolean) as TextFragment[];
loadingKriterien = false;
});
});
let copied = $state(false);
async function copyLink() {
await navigator.clipboard.writeText(location.href);
copied = true;
setTimeout(() => (copied = false), 2000);
}
function osmHref(c: [number, number] | null): string | null {
if (!c) return null;
return `https://www.openstreetmap.org/?mlat=${c[0].toFixed(5)}&mlon=${c[1].toFixed(5)}#map=14/${c[0].toFixed(5)}/${c[1].toFixed(5)}`;
}
const exportInfo = $derived(
area
? [
`${area.gebiets_nr}${area.bezeichnung ? ` ${area.bezeichnung}` : ''}`,
area.flaeche_ha != null ? `${t(T.windkarte_label_flaeche)}: ${area.flaeche_ha.toFixed(1)} ha` : null,
]
.filter(Boolean)
.join('\n')
: ''
);
</script>
{#if area}
<div class="pointer-events-auto flex h-full flex-col overflow-y-auto bg-stein-0 shadow-xl">
<!-- Header -->
<div class="flex items-start justify-between gap-3 border-b border-stein-200 px-5 py-4">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xl font-semibold text-stein-800">{area.gebiets_nr}</span>
{#if area.status && area.status !== 'rechtskraeftig'}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_CLASS[area.status] ?? 'bg-stein-100 text-stein-600'}">
{t(STATUS_KEY[area.status] ?? area.status)}
</span>
{/if}
</div>
{#if area.bezeichnung}
<p class="mt-0.5 text-sm text-stein-500">{area.bezeichnung}</p>
{/if}
</div>
<button
onclick={onclose}
class="shrink-0 rounded-md p-1.5 text-stein-400 hover:bg-stein-100 hover:text-stein-700 transition-colors"
aria-label={t(T.windkarte_panel_close)}
>
<Icon icon="lucide:x" class="size-5" />
</button>
</div>
<!-- Body -->
<div class="flex-1 space-y-5 px-5 py-4 text-sm">
<!-- Kenndaten -->
<div class="flex flex-col divide-y divide-stein-100 border border-stein-100 rounded-lg overflow-hidden text-sm">
{#if area.flaeche_ha != null}
<div class="flex items-center justify-between px-3 py-1.5">
<span class="flex items-center gap-1.5 text-xs text-stein-500">
<Icon icon="lucide:ruler" class="size-3.5 text-stein-400" />
{t(T.windkarte_label_flaeche)}
</span>
<span class="font-semibold text-stein-800">{area.flaeche_ha.toFixed(1)} ha</span>
</div>
{/if}
{#if area.anlagen_geplant != null}
<div class="flex items-center justify-between px-3 py-1.5">
<span class="flex items-center gap-1.5 text-xs text-stein-500">
<Icon icon="lucide:fan" class="size-3.5 text-stein-400" />
{t(T.windkarte_label_anlagen_geplant)}
</span>
<span class="flex items-center gap-1.5 font-semibold text-stein-800">
{area.anlagen_geplant}
{#if area.anlagen_geplant_hinweis}
<Icon icon="lucide:info" class="size-3.5 text-amber-700" />
{/if}
</span>
</div>
{/if}
{#if area.investor}
<div class="flex items-center justify-between px-3 py-1.5">
<span class="flex items-center gap-1.5 text-xs text-stein-500">
<Icon icon="lucide:building-2" class="size-3.5 text-stein-400" />
{t(T.windkarte_label_investor)}
</span>
<span class="font-semibold text-stein-800">{area.investor}</span>
</div>
{/if}
</div>
{#if area.anlagen_geplant_hinweis}
<InfoAccordion
title={t(T.windkarte_label_anlagen_hinweis)}
body={area.anlagen_geplant_hinweis}
variant="amber"
/>
{/if}
<!-- Gemeinden -->
{#if area.gemeinden && area.gemeinden.length > 0}
<div>
<p class="mb-2 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-stein-500">
<Icon icon="lucide:map-pin" class="size-3.5 text-stein-400" />
{t(T.windkarte_label_gemeinden)}
</p>
<div class="flex flex-wrap gap-1.5">
{#each area.gemeinden as g}
<span class="rounded-full bg-himmel-100 px-2.5 py-0.5 text-xs font-medium text-himmel-800">{g}</span>
{/each}
</div>
</div>
{/if}
<!-- Notizen -->
{#if area.notizen}
<div>
<p class="mb-1.5 text-xs font-medium uppercase tracking-wide text-stein-500">{t(T.windkarte_label_notizen)}</p>
<div class="text-stein-700 leading-relaxed markdown prose prose-sm prose-zinc max-w-none">{@html area.notizen}</div>
</div>
{/if}
<!-- Stellungnahme-Kriterien -->
{#if loadingKriterien}
<div class="flex items-center gap-2 text-xs text-stein-400">
<Icon icon="lucide:loader-circle" class="size-3.5 animate-spin" />
<span>{t(T.windkarte_kriterien_loading)}</span>
</div>
{:else if kriterien.length > 0}
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-stein-500">{t(T.windkarte_label_kriterien)}</p>
<div class="divide-y divide-stein-100 rounded-lg border border-stein-100 overflow-hidden">
{#each kriterien as k (k._slug)}
<div>
<button
onclick={() => { expandedSlug = expandedSlug === k._slug ? null : k._slug; }}
class="flex w-full items-start justify-between gap-2 px-3 py-2 text-left text-xs font-medium text-stein-700 hover:bg-stein-50 transition-colors"
>
<span class="min-w-0 line-clamp-2">{k.title ?? k._slug}</span>
<Icon
icon={expandedSlug === k._slug ? "lucide:chevron-up" : "lucide:chevron-down"}
class="size-3.5 shrink-0 mt-0.5 text-stein-400"
/>
</button>
{#if expandedSlug === k._slug && k.text}
<div class="px-3 pb-3 pt-1 text-xs text-stein-700 leading-relaxed markdown prose prose-xs prose-zinc max-w-none border-t border-stein-100">
{@html k.text}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Stellungnahme-Generator (interne URL) -->
{#if area.stellungnahmeUrl}
<a
href={area.stellungnahmeUrl}
class="flex items-center gap-2 rounded-lg bg-wald-700 px-4 py-3 text-sm font-semibold text-white no-underline hover:bg-wald-800 transition-colors"
>
<Icon icon="lucide:file-pen" class="size-4 shrink-0" />
Einwendung erstellen
</a>
{/if}
<!-- Stellungnahme-Vorlage (Post) -->
{#if stellungnahmeHref(area.stellungnahme)}
<a
href={stellungnahmeHref(area.stellungnahme)}
class="flex items-center gap-2 rounded-lg border border-wald-300 bg-wald-50 px-4 py-3 text-sm font-medium text-wald-700 hover:bg-wald-100 transition-colors"
>
<Icon icon="lucide:file-pen" class="size-4 shrink-0" />
{t(T.windkarte_stellungnahme_link)}
</a>
{/if}
<!-- Actions -->
<div class="flex gap-2 pt-1">
<button
onclick={copyLink}
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 hover:bg-stein-50 transition-colors whitespace-nowrap"
>
<Icon icon={copied ? "lucide:check" : "lucide:link"} class="size-3.5 shrink-0" />
{copied ? t(T.windkarte_action_copied) : t(T.windkarte_action_copy_link)}
</button>
{#if osmHref(center)}
<a
href={osmHref(center)}
target="_blank"
rel="noopener noreferrer"
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 hover:bg-stein-50 transition-colors whitespace-nowrap"
>
<Icon icon="lucide:map" class="size-3.5 shrink-0" />
{t(T.windkarte_action_osm)}
</a>
{/if}
</div>
<!-- Export-Downloads -->
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-stein-500">{t(T.windkarte_label_geodaten_export, { nr: area.gebiets_nr })}</p>
<div class="flex gap-2">
<a
href="/api/vorranggebiete.geojson?id={encodeURIComponent(area.gebiets_nr)}"
download
title={`${t(T.windkarte_action_download_geojson)}\n${exportInfo}`}
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 no-underline hover:bg-stein-50 transition-colors whitespace-nowrap"
>
<Icon icon="lucide:download" class="size-3.5 shrink-0" />
GeoJSON
</a>
<a
href="/api/vorranggebiete.kml?id={encodeURIComponent(area.gebiets_nr)}"
download
title={`${t(T.windkarte_action_download_kml)}\n${exportInfo}`}
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 no-underline hover:bg-stein-50 transition-colors whitespace-nowrap"
>
<Icon icon="lucide:download" class="size-3.5 shrink-0" />
KML
</a>
</div>
</div>
</div>
</div>
{/if}