diff --git a/.kilo/plans/1779906112073-kind-falcon.md b/.kilo/plans/1779906112073-kind-falcon.md new file mode 100644 index 0000000..b22879c --- /dev/null +++ b/.kilo/plans/1779906112073-kind-falcon.md @@ -0,0 +1,119 @@ +# MapCN auf Kontaktseite integrieren (statt Google-Maps-Link) + +## Ziel + +Die Platzhalter-Box auf der Kontaktseite (`/kontakt`) soll durch eine echte, interaktive Karte mit `mapcn` ersetzt werden. Die Karte zeigt den Praxisstandort inklusive Marker und optionalem Popup. + +## Ist-Zustand + +- `src/pages/kontakt.astro` enthält aktuell nur einen statischen Map-Placeholder mit Link zu Google Maps. +- React ist im Projekt bereits aktiviert (`@astrojs/react` in `astro.config.mjs`). +- Shadcn-Struktur ist vorhanden (`components.json`, `src/components/ui/button.tsx`). +- Es gibt noch keine Kartenkomponenten im Projekt. + +## Empfohlener Ansatz + +`mapcn` per shadcn-Registry hinzufügen und eine kleine, dedizierte React-Komponente für die Kontaktkarte bauen. Diese wird als Astro-Client-Island (`client:visible`) in `kontakt.astro` eingebunden. + +Warum dieser Ansatz: + +- Sauber gekapselte Kartenlogik (statt JSX direkt in `.astro`). +- Gute Performance (Hydration erst bei Sichtbarkeit). +- Einfache Wiederverwendung für Footer/Standortseiten später. + +## Umsetzungsplan + +### 1) MapCN-Komponente ins Projekt holen + +**Änderungen:** Abhängig von shadcn-Generator-Ausgabe + +- Command (im Implementierungsschritt): + - `bunx shadcn@latest add @mapcn/map` (alternativ `npx shadcn@latest add @mapcn/map`) +- Erwartete Ergebnisse: + - Neue Datei `src/components/ui/map.tsx` (oder äquivalent) + - Neue Dependency `maplibre-gl` in `package.json` + - Falls vom Generator benötigt: ergänzende CSS-Regeln/Imports (z. B. MapLibre-CSS) + +**Prüfpunkte:** + +- Map-Komponente ist importierbar aus `@/components/ui/map` +- Keine TypeScript- oder Build-Fehler nach dem Add + +### 2) Reusable Praxis-Map als React-Komponente erstellen + +**Neue Datei:** `src/components/maps/PracticeMap.tsx` + +Inhalt: + +- Import aus `@/components/ui/map`: + - `Map` + - `MapControls` (optional, aber sinnvoll) + - `MapMarker`, `MarkerContent`, `MarkerLabel` (für Standortpin) +- Container mit fester Höhe und Rounded Corners passend zur bestehenden Kontaktseite +- Initiales Viewport-Center auf Praxisadresse + - Empfohlene Koordinaten (OSM/Nominatim): `lon 12.3849586`, `lat 50.8203324` +- Zoom ca. `14–15` +- Markerlabel z. B. „Zahnarztpraxis Dr. Tittel“ +- Optional: kleines Popup mit Adresse + +Hinweis: + +- Falls die generierte `Map`-Komponente `children` voll unterstützt, Marker/Controls direkt als Children. +- Falls nicht, Minimalvariante nur mit `Map center/zoom` und Controls weglassen. + +### 3) Kontaktseite umbauen + +**Datei:** `src/pages/kontakt.astro` + +Geplante Änderungen: + +- Import der neuen Komponente `PracticeMap`. +- Bestehenden Placeholder-Block (Zeilenbereich um „Map placeholder“) entfernen/ersetzen. +- Neue Einbindung: + - `` +- Karte visuell im selben Card-Stil belassen: + - `rounded-2xl border border-border overflow-hidden` + +Optionaler UX-Zusatz unter der Karte: + +- Externer OpenStreetMap-/Google-Maps-Link als Fallback/Navigation beibehalten. + +### 4) (Optional, aber empfohlen) Standortdaten zentralisieren + +**Datei:** `src/data/site.ts` + +- `siteConfig` um `location` erweitern, z. B.: + - `latitude` + - `longitude` +- `PracticeMap` nutzt diese Werte statt hardcodierter Koordinaten. + +Vorteil: + +- Keine doppelten Daten, spätere Änderungen an einer Stelle. + +### 5) Validierung + +Nach der Implementierung ausführen: + +- `bun run typecheck` +- `bun run build` + +Manuelle Sichtprüfung: + +- `/kontakt` lädt ohne Console-Errors +- Karte rendert korrekt auf Desktop und Mobile +- Marker steht an plausibler Position (Bodelschwinghstraße 1, 08451 Crimmitschau) +- Seite bleibt performant (Hydration nur beim Scrollen in Sichtbereich) + +## Risiken / Stolpersteine + +- `maplibre-gl` benötigt ggf. CSS-Import; ohne den wirkt die Karte/Controls fehlerhaft. +- In Astro muss React-Komponente mit `client:*` eingebunden werden, sonst keine Interaktivität. +- Falls CSP/Cookie-Hinweise im Projekt strikt sind, muss Text „Karte wird nach Cookie-Einwilligung geladen“ angepasst/entfernt werden, da diese Map nicht dem bisherigen Google-Maps-Flow entspricht. + +## Definition of Done + +- Kontaktseite zeigt eine funktionierende `mapcn`-Karte statt Placeholder. +- Marker auf Praxisstandort vorhanden. +- Projekt baut und typcheckt fehlerfrei. +- Styling integriert sich in bestehendes Design ohne Layout-Bruch. diff --git a/astro.config.mjs b/astro.config.mjs index 00bde21..5d5c745 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -6,6 +6,7 @@ import react from "@astrojs/react" // https://astro.build/config export default defineConfig({ + output: "static", vite: { plugins: [tailwindcss()], }, diff --git a/bun.lock b/bun.lock index e49e96a..202c817 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,9 @@ "astro": "^5.18.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.40.0", "lucide-react": "^1.16.0", + "maplibre-gl": "^5.24.0", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -23,6 +25,7 @@ "tw-animate-css": "^1.4.0", }, "devDependencies": { + "@astrojs/check": "^0.9.9", "@eslint/js": "^9.39.4", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", @@ -31,16 +34,20 @@ "prettier": "^3.8.1", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-tailwindcss": "^0.7.2", - "typescript": "~5.9.3", + "typescript": "^6.0.3", "typescript-eslint": "^8.57.1", }, }, }, "packages": { + "@astrojs/check": ["@astrojs/check@0.9.9", "", { "dependencies": { "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg=="], + "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], + "@astrojs/language-server": ["@astrojs/language-server@2.16.10", "", { "dependencies": { "@astrojs/compiler": "^2.13.1", "@astrojs/yaml2ts": "^0.2.4", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.16", "volar-service-css": "0.0.70", "volar-service-emmet": "0.0.70", "volar-service-html": "0.0.70", "volar-service-prettier": "0.0.70", "volar-service-typescript": "0.0.70", "volar-service-typescript-twoslash-queries": "0.0.70", "volar-service-yaml": "0.0.70", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "./bin/nodeServer.js" } }, "sha512-87VQ/5GSdHlRnUA+hGuerYyIGAj+9RbZmATyuKLEUePinUXhQ5YkRnRrHhOD9sSi5JOErLjrLkHnfZFEvGrV8w=="], + "@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], "@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], @@ -49,6 +56,8 @@ "@astrojs/telemetry": ["@astrojs/telemetry@3.3.0", "", { "dependencies": { "ci-info": "^4.2.0", "debug": "^4.4.0", "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", "which-pm-runs": "^1.1.0" } }, "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ=="], + "@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.4", "", { "dependencies": { "yaml": "^2.8.3" } }, "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -115,6 +124,20 @@ "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], + "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], + + "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], + + "@emmetio/css-parser": ["@emmetio/css-parser@0.4.1", "", { "dependencies": { "@emmetio/stream-reader": "^2.2.0", "@emmetio/stream-reader-utils": "^0.1.0" } }, "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ=="], + + "@emmetio/html-matcher": ["@emmetio/html-matcher@1.3.0", "", { "dependencies": { "@emmetio/scanner": "^1.0.0" } }, "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ=="], + + "@emmetio/scanner": ["@emmetio/scanner@1.0.4", "", {}, "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA=="], + + "@emmetio/stream-reader": ["@emmetio/stream-reader@2.2.0", "", {}, "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw=="], + + "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], @@ -279,6 +302,26 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.2", "", {}, "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ=="], + + "@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="], + + "@mapbox/tiny-sdf": ["@mapbox/tiny-sdf@2.2.0", "", {}, "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg=="], + + "@mapbox/unitbezier": ["@mapbox/unitbezier@0.0.1", "", {}, "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="], + + "@mapbox/vector-tile": ["@mapbox/vector-tile@2.0.5", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^4.0.2" } }, "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw=="], + + "@mapbox/whoots-js": ["@mapbox/whoots-js@3.1.0", "", {}, "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q=="], + + "@maplibre/geojson-vt": ["@maplibre/geojson-vt@6.1.0", "", { "dependencies": { "kdbush": "^4.0.2" } }, "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ=="], + + "@maplibre/maplibre-gl-style-spec": ["@maplibre/maplibre-gl-style-spec@24.8.5", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "~2.0.2", "@mapbox/unitbezier": "^0.0.1", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" }, "bin": { "gl-style-migrate": "dist/gl-style-migrate.mjs", "gl-style-validate": "dist/gl-style-validate.mjs", "gl-style-format": "dist/gl-style-format.mjs" } }, "sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w=="], + + "@maplibre/mlt": ["@maplibre/mlt@1.1.10", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0" } }, "sha512-SByFHVVxqThkstQnwh8/48pyvUmeQ7NGZ/n+XHa4TkLTKK6lnsMh9Aa7LocS8OW5E3ZiXxmwYivSc7lcQsQBag=="], + + "@maplibre/vt-pbf": ["@maplibre/vt-pbf@4.3.0", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@mapbox/vector-tile": "^2.0.4", "@maplibre/geojson-vt": "^5.0.4", "@types/geojson": "^7946.0.16", "@types/supercluster": "^7.1.3", "pbf": "^4.0.1", "supercluster": "^8.0.1" } }, "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="], @@ -539,6 +582,8 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -559,6 +604,8 @@ "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + "@types/supercluster": ["@types/supercluster@7.1.3", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], @@ -587,6 +634,22 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], + + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + + "@volar/language-server": ["@volar/language-server@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "path-browserify": "^1.0.1", "request-light": "^0.7.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw=="], + + "@volar/language-service": ["@volar/language-service@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw=="], + + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], + + "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], + + "@vscode/emmet-helper": ["@vscode/emmet-helper@2.11.0", "", { "dependencies": { "emmet": "^2.4.3", "jsonc-parser": "^2.3.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.15.1", "vscode-uri": "^3.0.8" } }, "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw=="], + + "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -597,6 +660,8 @@ "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], @@ -667,7 +732,7 @@ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], @@ -785,13 +850,17 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "earcut": ["earcut@3.0.2", "", {}, "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ=="], + "eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "electron-to-chromium": ["electron-to-chromium@1.5.362", "", {}, "sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -907,6 +976,8 @@ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], @@ -935,6 +1006,8 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + "gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], @@ -1067,10 +1140,16 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json-stringify-pretty-compact": ["json-stringify-pretty-compact@4.0.0", "", {}, "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "kdbush": ["kdbush@4.1.0", "", {}, "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -1119,6 +1198,8 @@ "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], + "maplibre-gl": ["maplibre-gl@5.24.0", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.1.0", "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^2.0.4", "@mapbox/whoots-js": "^3.1.0", "@maplibre/geojson-vt": "^6.1.0", "@maplibre/maplibre-gl-style-spec": "^24.8.1", "@maplibre/mlt": "^1.1.8", "@maplibre/vt-pbf": "^4.3.0", "@types/geojson": "^7946.0.16", "earcut": "^3.0.2", "gl-matrix": "^3.4.4", "kdbush": "^4.0.2", "murmurhash-js": "^1.0.0", "pbf": "^4.0.1", "potpack": "^2.1.0", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" } }, "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -1229,12 +1310,20 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], + + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="], + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + + "murmurhash-js": ["murmurhash-js@1.0.0", "", {}, "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="], + "mute-stream": ["mute-stream@4.0.0", "", {}, "sha512-gSrprq0fJ3EiOErzjdIZrjysVVmJ4uu1QWfCDss5LypA5OXvrMje5Ym5z6V6RLyJ2eF87lasX7t6a0AnFvZblg=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], @@ -1321,6 +1410,8 @@ "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="], + "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1333,6 +1424,8 @@ "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + "potpack": ["potpack@2.1.0", "", {}, "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -1351,6 +1444,8 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -1359,6 +1454,8 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quickselect": ["quickselect@3.0.0", "", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="], + "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], @@ -1379,7 +1476,7 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], @@ -1407,12 +1504,16 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], @@ -1491,7 +1592,7 @@ "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], @@ -1507,6 +1608,8 @@ "suf-log": ["suf-log@2.5.3", "", { "dependencies": { "s.color": "0.0.15" } }, "sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow=="], + "supercluster": ["supercluster@8.0.1", "", { "dependencies": { "kdbush": "^4.0.2" } }, "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], @@ -1527,6 +1630,8 @@ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyqueue": ["tinyqueue@3.0.0", "", {}, "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="], + "tldts": ["tldts@7.4.0", "", { "dependencies": { "tldts-core": "^7.4.0" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg=="], "tldts-core": ["tldts-core@7.4.0", "", {}, "sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ=="], @@ -1559,7 +1664,11 @@ "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "typescript-auto-import-cache": ["typescript-auto-import-cache@0.3.6", "", { "dependencies": { "semver": "^7.3.8" } }, "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ=="], "typescript-eslint": ["typescript-eslint@8.60.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.60.0", "@typescript-eslint/parser": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/utils": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw=="], @@ -1629,6 +1738,40 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "volar-service-css": ["volar-service-css@0.0.70", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw=="], + + "volar-service-emmet": ["volar-service-emmet@0.0.70", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg=="], + + "volar-service-html": ["volar-service-html@0.0.70", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ=="], + + "volar-service-prettier": ["volar-service-prettier@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg=="], + + "volar-service-typescript": ["volar-service-typescript@0.0.70", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg=="], + + "volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ=="], + + "volar-service-yaml": ["volar-service-yaml@0.0.70", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.20.0" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ=="], + + "vscode-css-languageservice": ["vscode-css-languageservice@6.3.10", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA=="], + + "vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="], + + "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-nls": ["vscode-nls@5.2.0", "", {}, "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng=="], + + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], @@ -1653,6 +1796,10 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yaml-language-server": ["yaml-language-server@1.20.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -1693,6 +1840,8 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@maplibre/vt-pbf/@maplibre/geojson-vt": ["@maplibre/geojson-vt@5.0.4", "", {}, "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], @@ -1721,13 +1870,11 @@ "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "boxen/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1757,6 +1904,8 @@ "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], @@ -1767,15 +1916,29 @@ "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "widest-line/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "yaml-language-server/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], + + "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], @@ -1799,11 +1962,7 @@ "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "boxen/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1811,8 +1970,14 @@ "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "unstorage/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -1865,16 +2030,14 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "widest-line/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "yaml-language-server/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/components.json b/components.json index 16140cd..e2587b4 100644 --- a/components.json +++ b/components.json @@ -12,6 +12,8 @@ }, "iconLibrary": "lucide", "rtl": false, + "menuColor": "default", + "menuAccent": "subtle", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -19,7 +21,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": {} + "registries": { + "@mapcn": "https://mapcn.dev/r/{name}.json" + } } diff --git a/design-kritik.md b/design-kritik.md new file mode 100644 index 0000000..8819171 --- /dev/null +++ b/design-kritik.md @@ -0,0 +1,171 @@ +# Design-Kritik: Zahnarztpraxis Dr. Tittel + +**URL:** https://zahnarztpraxis-tittel.de/ +**Datum:** 2026-05-27 +**Prüfer:** Design-Direktor (Kilo) + +--- + +## Anti-Patterns-Verdikt: BESTANDEN ✓ + +Dies sieht **nicht** KI-generiert aus. Es ist eindeutig ein klassisches WordPress/Elementor-Projekt mit einer konservativen Arztpraxis-Ästhetik. Keiner der typischen KI-Fingerabdrücke ist vorhanden: + +- **Keine** Cyan-auf-Dunkel- oder Lila-zu-Blau-Verläufe +- **Keine** Neon-Akzente auf dunklem Hintergrund +- **Kein** Glassmorphismus oder Blur-Effekte +- **Keine** Verlaufstexte auf Überschriften oder Metriken +- **Keine** identischen Icon + Überschrift + Text-Kartenraster +- **Keine** generischen Schriften (nutzt eigene "iowan"-Schrift) +- **Keine** Hero-Metrik-Layouts mit großen Zahlen +- **Keine** abgerundeten Rechtecke mit dicken farbigen Rahmen +- **Keine** Sparklines oder dekorativen Diagramme + +Wenn man sagt "Das hat eine KI gemacht", wären Menschen skeptisch. Das Design ist zu simpel, zu WordPress-Template-mäßig für KI-Schludrigkeit. Aber: "Nicht KI-generiert" heißt nicht "gut gestaltet". Die Probleme sind organisch, nicht synthetisch. + +--- + +## Gesamteindruck + +Dies ist eine **kompetente, aber ängstliche** Zahnarztpraxis-Website. Sie kommuniziert Professionalität und Vertrauenswürdigkeit – die Farbpalette Navy (#1a386b) und Gold (#b6926d) passt zu einer medizinischen Praxis. Die eigene "iowan"-Schrift verleiht einen Hauch von Eigenständigkeit. Aber die Website wirkt, als sei sie gebaut worden, um "eine Website zu haben", nicht um **Patienten zu gewinnen**. Die Startseite ist eine Sackgasse. Es gibt keinen klaren Handlungsaufruf. Die User Journey endet bei "Willkommen". + +Die größte Chance: **Aus der digitalen Broschüre ein Patienten-Akquisitions-Werkzeug machen.** + +--- + +## Was funktioniert + +### 1. Die Farbpalette ist bewusst gewählt +Die Navy-Gold-Kombination ist stimmig und kontextuell angemessen. Navy signalisiert Vertrauen, medizinische Professionalität, Stabilität. Gold wärmt auf und verhindert, dass die Seite wie eine Bank oder Kanzlei wirkt. Die Palette ist nicht mutig, aber sie ist **richtig** für eine Familienzahnarztpraxis in einer kleinen deutschen Stadt. + +### 2. Progressive Offenlegung auf der Leistungsseite +Die Verwendung von Toggle-/Akkordeons für Leistungsbeschreibungen ist klug. Die Seite bleibt scannbar, während detailverliebte Patienten eintauchen können. Die Plus-/Minus-Icons bieten klare Affordance. Hier hat jemand über Informationsdichte nachgedacht. + +### 3. Team-Fotos machen die Praxis menschlich +Die Teamseite mit echten Porträts (nicht Stockfotos) baut Vertrauen auf. In einer kleinstädtischen Praxis wollen Patienten wissen, **wer** sie behandelt. Die Darstellung des gesamten Teams – inklusive Assistentinnen – ist ein Vertrauensbaustein, den viele Praxen auslassen. + +--- + +## Aktueller Content + +| Seite | Inhalt | Struktur | +|-------|--------|----------| +| **Startseite** | Hero mit "Ihr Zahnarzt in Crimmitschau", Philosophie-Toggle (Akkordeon) | Header (Logo + Name + Telefon) → Navigation → Hero → Footer (Sprechzeiten + Kontakt) | +| **Unser Team** | 7 Teammitglieder: 2 Zahnärztinnen, 5 Assistentinnen, mit Fotos und tabellarischen CVs | Hero → 2-Spalten-Layout (Zahnärztinnen) → 3-Spalten (Assistentinnen) → 2-Spalten (Assistentinnen) | +| **Unsere Leistungen** | 6 Leistungen: Ästhetische Zahnfüllungen, Implantologie, Wurzelkanalbehandlung, Professionelle Zahnreinigung, Kinderbehandlung, Zahnaufhellung | Hero → Alternierendes 2-Spalten-Layout (Bild \| Toggle-Akkordeon) × 6 | +| **Behandlungszeiten** | Sprechzeiten-Tabelle (Mo–Fr), Notdienst-Link, Terminvereinbarungshinweis | Hero → Tabelle → Notdienst → Kontakt-Link | +| **Kontakt** | Kontaktformular (Name, E-Mail, Betreff, Nachricht), Google Maps | Hero → 2-Spalten (Formular \| Karte) | + +### Design-Grundlagen +- **Farben:** Navy (#1a386b) + Gold (#b6926d) +- **Schrift:** Eigene "iowan" (via Use Any Font) +- **Builder:** Elementor (Hello Elementor Theme) +- **Max-Breite:** 1680px + +--- + +## Prioritäre Probleme + +### 1. Startseite ist eine Conversion-Sackgasse +**Was:** Die Startseite hat einen Hero-Bereich mit "Ihr Zahnarzt in Crimmitschau", einen Philosophie-Toggle und sonst nichts. Keine Leistungsvorschau, keine Teameinleitung, keine Patientenbewertungen, keine Notfallinformation, kein klarer Handlungspfad. + +**Warum es wichtig ist:** Die Startseite ist die Landingpage für die meisten Besucher. Finden sie nicht innerhalb von 10 Sekunden, was sie brauchen, springen sie ab. Ein Patient mit Zahnschmerzen will keine Philosophieerklärung lesen – er will wissen, ob Sie Notfälle behandeln, wann Sie offen haben und wie er Sie erreicht. Die aktuelle Startseite bietet nichts davon prominent. + +**Lösung:** Die Startseite als Conversion-Trichter restrukturieren: +- Hero: Überschrift + Unterzeile + **primärer CTA-Button** ("Jetzt Termin vereinbaren" oder "Anrufen") +- Abschnitt: 3 vorgestellte Leistungen mit Icons + "Alle Leistungen"-Link +- Abschnitt: Team-Vorschau mit "Unser Team kennenlernen"-Link +- Abschnitt: Sprechzeiten + Notfallinfo +- Abschnitt: Kontaktformular oder prominenter Telefon-CTA + +**Befehl:** `/distill` — Die Startseite auf ihre Kernaufgabe reduzieren und um Patientenbedürfnisse herum aufbauen, nicht um Praxis-Selbstbeschreibung. + +--- + +### 2. Kein prominenter Handlungsaufruf (CTA) irgendwo +**Was:** Die Telefonnummer (03762 56 29) steht als Fließtext im Header. Sie sieht nicht wie ein Button aus. Es gibt keinen "Termin vereinbaren"-Button, keinen sticky CTA auf Mobilgeräten, keinen Notfall-Hinweis für Patienten in Schmerzen. + +**Warum es wichtig ist:** Eine Zahnarztpraxis-Website hat EINEN Job: Patienten zur Kontaktaufnahme zu bewegen. Jede Seite sollte diese Aktion mühelos machen. Aktuell muss der Nutzer die Telefonnummer suchen oder zur Kontaktseite navigieren. Im medizinischen Kontext tötet Reibung Conversion. + +**Lösung:** Einen prominenten, sticky CTA hinzufügen: +- Header: Telefonnummer als ausgefüllter Button gestalten (Navy-Hintergrund, weiße Schrift, Telefon-Icon) +- Mobil: Sticky Bottom-Bar mit "Anrufen"- und "Kontakt"-Buttons +- Startseiten-Hero: Großer Primär-Button unter der Überschrift +- Sprechzeiten-Seite: Prominenter "Termin online buchen"- oder "Rufen Sie uns an"-CTA über der Tabelle + +**Befehl:** `/bolder` — Die primäre Aktion so verstärken, dass sie unmöglich zu übersehen ist. + +--- + +### 3. Cookie-Banner dominiert den ersten Eindruck +**Was:** Der Real Cookie Banner feuert einen massiven Modal beim ersten Besuch mit Wänden aus Rechtstext, drei Buttons, Akkordeon-Abschnitten und überwältigendem Detail. Der Banner-JSON-Code im HTML ist größer als der eigentliche Seiteninhalt der meisten Seiten. + +**Warum es wichtig ist:** Bei einer lokalen Zahnarztpraxis ist der Cookie-Banner wahrscheinlich das Erste, was ein neuer Patient sieht. Es ist einschüchternd, juristisch und erzeugt Angst, bevor der Nutzer überhaupt den Praxisnamen gesehen hat. Ein Patient mit akuten Zahnschmerzen will keine "Standardvertragsklauseln" und "Verarbeitung in unsicheren Drittländern" durchlesen. + +**Lösung:** Aggressiv vereinfachen: +- Banner reduzieren auf: "Wir verwenden Cookies für grundlegende Funktionen und Google Maps. [Alle akzeptieren] [Nur Essentials]" +- Detaillierte Rechtstexte hinter einen "Mehr erfahren"-Link verschieben +- Bottom-Bar statt Center-Modal verwenden – weniger aufdringlich +- Beschreibung von 200 Wörtern auf 30 kürzen + +**Befehl:** `/clarify` — Die aktuelle Microcopy ist rechtlich umfassend, aber menschenfeindlich. Die Stimme vereinfachen, ohne Compliance zu verlieren. + +--- + +### 4. Team-Raster ist visuell holperig +**Was:** Die Teamseite wechselt zwischen 2-Spalten- und 3-Spalten-Layouts (2 Zahnärztinnen, dann 3 Assistentinnen, dann 2 Assistentinnen). Das erzeugt einen unebenen, zufälligen Rhythmus. Die beiden Zahnärztinnen bekommen mehr visuelles Gewicht pro Person, während die Assistentinnen zusammengedrängt wirken. + +**Warum es wichtig ist:** Ungleiche Raster wirken unbeabsichtigt. Sie signalisieren, dass das Layout von "wie viele Leute passen hin" getrieben wurde, nicht von "wie präsentieren wir unser Team mit Würde". Es signalisiert auch subtil eine Hierarchie, die möglicherweise nicht beabsichtigt ist – die Zahnärztinnen bekommen 50% Breite, die Assistentinnen 33%. + +**Lösung:** Ein konsistentes Raster verwenden. Entweder: +- 3-Spalten-Raster für alle (Zahnärztinnen bekommen eine Featured-Behandlung wie größere Bilder oder Zitate) +- Oder 2-Spalten durchgehend, mit Reihen zu je 2 + +**Befehl:** `/normalize` — Ein konsistentes Rastersystem etablieren und einheitlich anwenden. + +--- + +### 5. Leistungsseiten-Layout wird monoton +**Was:** Sechs Leistungen, jede mit dem exakt selben alternierenden 2-Spalten-Layout (Bild links / Toggle rechts, dann Bild rechts / Toggle links). Ab der dritten Leistung ist das Muster vorhersehbar und visuell ermüdend. + +**Warum es wichtig ist:** Wiederholung ohne Variation ist Langeweile. Ein Patient, der durch Leistungen scrollt, sollte engagiert bleiben, nicht das Gefühl haben, durch eine PowerPoint-Präsentation zu klicken. Die identische Struktur signalisiert auch nicht, welche Leistungen am wichtigsten sind. + +**Lösung:** Die Präsentation variieren: +- Top 2-3 Leistungen mit dem vollen alternierenden Layout präsentieren +- Restliche Leistungen als saubere Liste oder Kartenraster darstellen +- Alternierendes Layout nur dort verwenden, wo der visuelle Bruch Mehrwert schafft +- "Am häufigsten nachgefragt"- oder "Notfall"-Badge zur Priorisierung hinzufügen + +**Befehl:** `/distill` — Das repetitive Muster reduzieren und Inhalte atmen lassen. + +--- + +## Schnelle Verbesserungen (Quick Wins) + +| # | Maßnahme | Impact | +|---|----------|--------| +| 1 | Footer-Sprechzeiten in Header oder als sticky Element heben | Hoch | +| 2 | "Philosophie"-Toggle von Startseite entfernen oder zurückstufen | Mittel | +| 3 | Team-CVs aus Tabellen in lesbare Texte umwandeln | Mittel | +| 4 | Contact Form 7 an Navy/Gold-Palette anpassen | Niedrig | +| 5 | Statisches Kartenbild als Fallback für abgelehnte Google-Maps-Cookies | Mittel | +| 6 | Patientenbewertungen/Testimonials hinzufügen | Hoch | +| 7 | Notfall-Callout prominent auf Startseite platzieren | Hoch | + +--- + +## Fragen zur Reflexion + +- **"Was wäre, wenn die primäre Aktion 3x größer wäre und am Viewport fixiert?"** Würden mehr Patienten anrufen? +- **"Weiß ein Erstbesucher in Schmerzen in unter 5 Sekunden, was zu tun ist?"** Testen Sie das. Die Antwort ist wahrscheinlich nein. +- **"Wie würde eine selbstbewusste Version dieser Praxis aussehen?"** Eine, die ihre Expertise besitzt statt sie nur aufzulisten? +- **"Warum hat der Cookie-Banner mehr Design-Aufmerksamkeit bekommen als die Patienten-Journey?"** Der Banner hat eigene Farben, Logos und Akkordeon-Widgets. Die Startseite hat einen Toggle und einen Footer. +- **"Was wäre, wenn die Teamseite mit einem Gruppenfoto starten würde statt mit Einzelkarten?"** Würde das mehr wie eine Familienpraxis und weniger wie ein Firmenverzeichnis wirken? +- **"Ist diese Website für die Praxis oder für die Patienten?"** Aktuell liest sie sich wie Ersteres. + +--- + +## Zusammenfassung + +Diese Website vermeidet KI-Schludrigkeit, indem sie zu konventionell ist, um trendy zu sein. Aber Konvention ohne Strategie ist nur eine Vorlage. Das Design ist **technisch solide** – es lädt, ist responsiv, die Farben passen – aber es ist **strategisch schwach**. Es führt Patienten nicht zur Handlung, priorisiert Informationen nicht nach Dringlichkeit und differenziert die Praxis nicht von jedem anderen Zahnarzt mit einer WordPress-Seite. + +Die gute Nachricht: Das sind lösbar Probleme. Die Palette ist solide, der Inhalt existiert, und der Builder (Elementor) erlaubt schnelle Iteration. Die Arbeit ist redaktionell, nicht technisch: Entscheiden, was Patienten am meisten brauchen, wegwerfen, was sie nicht brauchen, und jede Seite um einen einzigen, offensichtlichen nächsten Schritt herum gestalten. diff --git a/package.json b/package.json index ab0b43b..f287b3f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "astro": "^5.18.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.40.0", "lucide-react": "^1.16.0", + "maplibre-gl": "^5.24.0", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -31,6 +33,7 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@astrojs/check": "^0.9.9", "@eslint/js": "^9.39.4", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", @@ -39,7 +42,7 @@ "prettier": "^3.8.1", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-tailwindcss": "^0.7.2", - "typescript": "~5.9.3", + "typescript": "^6.0.3", "typescript-eslint": "^8.57.1" } } diff --git a/src/components/layout/CookieBanner.astro b/src/components/layout/CookieBanner.astro new file mode 100644 index 0000000..30d4aef --- /dev/null +++ b/src/components/layout/CookieBanner.astro @@ -0,0 +1,49 @@ +--- +--- + + + + diff --git a/src/components/layout/Footer.astro b/src/components/layout/Footer.astro new file mode 100644 index 0000000..777cd67 --- /dev/null +++ b/src/components/layout/Footer.astro @@ -0,0 +1,87 @@ +--- +import { siteConfig } from "@/data/site" +import { Phone, MapPin, Mail, Clock, Stethoscope } from "lucide-react" +--- + + diff --git a/src/components/layout/Header.astro b/src/components/layout/Header.astro new file mode 100644 index 0000000..189f1ba --- /dev/null +++ b/src/components/layout/Header.astro @@ -0,0 +1,71 @@ +--- +import { siteConfig } from "@/data/site" +import { Phone, Menu, X, Stethoscope } from "lucide-react" +import MobileNav from "./MobileNav.astro" + +const currentPath = Astro.url.pathname +--- + +
+
+ + + + + Dr. Tittel + + + + + + + +
+ + + +
+ + diff --git a/src/components/layout/MobileNav.astro b/src/components/layout/MobileNav.astro new file mode 100644 index 0000000..3cc8d47 --- /dev/null +++ b/src/components/layout/MobileNav.astro @@ -0,0 +1,43 @@ +--- +import { siteConfig } from "@/data/site" +import { Phone, MapPin, Clock } from "lucide-react" + +const currentPath = Astro.url.pathname +--- + + diff --git a/src/components/maps/PracticeMap.tsx b/src/components/maps/PracticeMap.tsx new file mode 100644 index 0000000..f87072c --- /dev/null +++ b/src/components/maps/PracticeMap.tsx @@ -0,0 +1,44 @@ +import { + Map, + MapControls, + MapMarker, + MarkerContent, + MarkerLabel, + MarkerPopup, +} from "@/components/ui/map" +import { MapPin } from "lucide-react" +import { siteConfig } from "@/data/site" + +interface PracticeMapProps { + className?: string +} + +export function PracticeMap({ className }: PracticeMapProps) { + const { longitude, latitude } = siteConfig.location + + return ( +
+ + + +
+ +
+
+ {siteConfig.name} + +
+

{siteConfig.name}

+

+ {siteConfig.address.street} +
+ {siteConfig.address.city} +

+
+
+
+ +
+
+ ) +} diff --git a/src/components/sections/EmergencyCTA.astro b/src/components/sections/EmergencyCTA.astro new file mode 100644 index 0000000..c164f33 --- /dev/null +++ b/src/components/sections/EmergencyCTA.astro @@ -0,0 +1,43 @@ +--- +import { siteConfig } from "@/data/site" +import { Phone, Clock, AlertTriangle, ArrowRight } from "lucide-react" +--- + +
+
+
+
+
+ + Zahnschmerzen? Wir helfen sofort. +
+ +

+ Notfall? Rufen Sie uns an. +

+

+ Wir kümmern uns um akute Schmerzfälle. Auch kurzfristige Termine möglich. +

+
+ + +
+
+
diff --git a/src/components/sections/FeaturedServices.astro b/src/components/sections/FeaturedServices.astro new file mode 100644 index 0000000..7f18069 --- /dev/null +++ b/src/components/sections/FeaturedServices.astro @@ -0,0 +1,58 @@ +--- +import { services } from "@/data/site" +import { ArrowRight, Sparkles, CircleDot, Shield, GitBranch, Baby, Sun } from "lucide-react" + +const iconMap: Record = { + Sparkles, + CircleDot, + Shield, + GitBranch, + Baby, + Sun, +} + +const featuredServices = services.filter(s => s.featured) +--- + +
+
+
+

+ Unsere Leistungen +

+
+ +
+ {featuredServices.map((service) => { + const IconComponent = iconMap[service.icon] || Sparkles + return ( +
+ + +

{service.title}

+ +

{service.shortDesc}

+ + + Mehr erfahren + + +
+ ) + })} +
+ + +
+
diff --git a/src/components/sections/Hero.astro b/src/components/sections/Hero.astro new file mode 100644 index 0000000..5787e77 --- /dev/null +++ b/src/components/sections/Hero.astro @@ -0,0 +1,78 @@ +--- +import { siteConfig } from "@/data/site" +import { Phone, ArrowRight, Stethoscope } from "lucide-react" +--- + +
+
+
+ +
+
+ + Moderne Zahnheilkunde mit Herz +
+ +

+ Ihr Zahnarzt in Crimmitschau +

+ +

+ Von Implantologie bis Kinderbehandlung – wir begleiten Sie mit modernster + Technik und persönlicher Betreuung auf dem Weg zu Ihrem gesunden Lächeln. +

+ + + + +
+
+
+ Termine kurzfristig möglich +
+
+
+ Barrierefreie Praxis +
+
+
+ Kinderfreundlich +
+
+
+ + + +
+
+
diff --git a/src/components/sections/HoursPreview.astro b/src/components/sections/HoursPreview.astro new file mode 100644 index 0000000..8771e6c --- /dev/null +++ b/src/components/sections/HoursPreview.astro @@ -0,0 +1,73 @@ +--- +import { siteConfig } from "@/data/site" +import { Clock, ArrowRight, MapPin, Phone } from "lucide-react" +--- + +
+
+
+
+
+ + Sprechzeiten +
+ +

+ Wann wir für Sie da sind +

+

+ Flexible Termine, auch außerhalb der regulären Sprechzeiten nach Vereinbarung. +

+ +
+
+ Montag – Dienstag + 8:00 – 12:00, 14:00 – 18:00 +
+
+ Mittwoch + 8:00 – 12:00 +
+
+ Donnerstag + 8:00 – 12:00, 14:00 – 18:00 +
+
+ Freitag + 8:00 – 12:00 +
+
+ + + Vollständige Sprechzeiten & Notdienst + + +
+ +
+
+ +
+

{siteConfig.name}

+

{siteConfig.address.street}
{siteConfig.address.city}

+
+
+ + + + {siteConfig.phone} + + +

+ Kostenfreie Parkplätze direkt vor der Praxis +

+
+
+
+
diff --git a/src/components/sections/TeamPreview.astro b/src/components/sections/TeamPreview.astro new file mode 100644 index 0000000..8c6166a --- /dev/null +++ b/src/components/sections/TeamPreview.astro @@ -0,0 +1,54 @@ +--- +import { teamMembers } from "@/data/site" +import { ArrowRight, Users } from "lucide-react" + +const previewMembers = teamMembers.slice(0, 3) +--- + +
+
+
+
+
+ + Ihr Team +
+ +

+ Ihr Praxisteam +

+

+ Lernen Sie das Team kennen, das sich mit Herz und Fachwissen um Ihre Zahngesundheit kümmert. +

+
+ + + Das ganze Team + + +
+ +
+ {previewMembers.map((member) => ( +
+
+ {member.name} +
+ +

{member.name}

+

{member.role}

+

{member.bio}

+
+ ))} +
+
+
diff --git a/src/components/ui/map.tsx b/src/components/ui/map.tsx new file mode 100644 index 0000000..a69471c --- /dev/null +++ b/src/components/ui/map.tsx @@ -0,0 +1,1844 @@ +"use client"; + +import MapLibreGL, { type PopupOptions, type MarkerOptions } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { + createContext, + forwardRef, + useCallback, + useContext, + useEffect, + useId, + useImperativeHandle, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { X, Minus, Plus, Locate, Maximize, Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const defaultStyles = { + dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", +}; + +type Theme = "light" | "dark"; + +// Check document class for theme (works with next-themes, etc.) +function getDocumentTheme(): Theme | null { + if (typeof document === "undefined") return null; + if (document.documentElement.classList.contains("dark")) return "dark"; + if (document.documentElement.classList.contains("light")) return "light"; + return null; +} + +// Get system preference +function getSystemTheme(): Theme { + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function useResolvedTheme(themeProp?: "light" | "dark"): Theme { + const [detectedTheme, setDetectedTheme] = useState( + () => getDocumentTheme() ?? getSystemTheme(), + ); + + useEffect(() => { + if (themeProp) return; // Skip detection if theme is provided via prop + + // Watch for document class changes (e.g., next-themes toggling dark class) + const observer = new MutationObserver(() => { + const docTheme = getDocumentTheme(); + if (docTheme) { + setDetectedTheme(docTheme); + } + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + // Also watch for system preference changes + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleSystemChange = (e: MediaQueryListEvent) => { + // Only use system preference if no document class is set + if (!getDocumentTheme()) { + setDetectedTheme(e.matches ? "dark" : "light"); + } + }; + mediaQuery.addEventListener("change", handleSystemChange); + + return () => { + observer.disconnect(); + mediaQuery.removeEventListener("change", handleSystemChange); + }; + }, [themeProp]); + + return themeProp ?? detectedTheme; +} + +type MapContextValue = { + map: MapLibreGL.Map | null; + isLoaded: boolean; +}; + +const MapContext = createContext(null); + +function useMap() { + const context = useContext(MapContext); + if (!context) { + throw new Error("useMap must be used within a Map component"); + } + return context; +} + +/** Map viewport state */ +type MapViewport = { + /** Center coordinates [longitude, latitude] */ + center: [number, number]; + /** Zoom level */ + zoom: number; + /** Bearing (rotation) in degrees */ + bearing: number; + /** Pitch (tilt) in degrees */ + pitch: number; +}; + +type MapStyleOption = string | MapLibreGL.StyleSpecification; + +type MapRef = MapLibreGL.Map; + +type MapProps = { + children?: ReactNode; + /** Additional CSS classes for the map container */ + className?: string; + /** + * Theme for the map. If not provided, automatically detects system preference. + * Pass your theme value here. + */ + theme?: Theme; + /** Custom map styles for light and dark themes. Overrides the default Carto styles. */ + styles?: { + light?: MapStyleOption; + dark?: MapStyleOption; + }; + /** Map projection type. Use `{ type: "globe" }` for 3D globe view. */ + projection?: MapLibreGL.ProjectionSpecification; + /** + * Controlled viewport. When provided with onViewportChange, + * the map becomes controlled and viewport is driven by this prop. + */ + viewport?: Partial; + /** + * Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch). + * Can be used standalone to observe changes, or with `viewport` prop + * to enable controlled mode where the map viewport is driven by your state. + */ + onViewportChange?: (viewport: MapViewport) => void; + /** Show a loading indicator on the map */ + loading?: boolean; +} & Omit; + +function DefaultLoader() { + return ( +
+
+ + + +
+
+ ); +} + +function getViewport(map: MapLibreGL.Map): MapViewport { + const center = map.getCenter(); + return { + center: [center.lng, center.lat], + zoom: map.getZoom(), + bearing: map.getBearing(), + pitch: map.getPitch(), + }; +} + +const Map = forwardRef(function Map( + { + children, + className, + theme: themeProp, + styles, + projection, + viewport, + onViewportChange, + loading = false, + ...props + }, + ref, +) { + const containerRef = useRef(null); + const [mapInstance, setMapInstance] = useState(null); + const [isLoaded, setIsLoaded] = useState(false); + const [isStyleLoaded, setIsStyleLoaded] = useState(false); + const currentStyleRef = useRef(null); + const styleTimeoutRef = useRef | null>(null); + const internalUpdateRef = useRef(false); + const resolvedTheme = useResolvedTheme(themeProp); + + const isControlled = viewport !== undefined && onViewportChange !== undefined; + + const onViewportChangeRef = useRef(onViewportChange); + onViewportChangeRef.current = onViewportChange; + + const mapStyles = useMemo( + () => ({ + dark: styles?.dark ?? defaultStyles.dark, + light: styles?.light ?? defaultStyles.light, + }), + [styles], + ); + + // Expose the map instance to the parent component + useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]); + + const clearStyleTimeout = useCallback(() => { + if (styleTimeoutRef.current) { + clearTimeout(styleTimeoutRef.current); + styleTimeoutRef.current = null; + } + }, []); + + // Initialize the map + useEffect(() => { + if (!containerRef.current) return; + + const initialStyle = + resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; + currentStyleRef.current = initialStyle; + + const map = new MapLibreGL.Map({ + container: containerRef.current, + style: initialStyle, + renderWorldCopies: false, + attributionControl: { + compact: true, + }, + ...props, + ...viewport, + }); + + const styleDataHandler = () => { + clearStyleTimeout(); + // Delay to ensure style is fully processed before allowing layer operations + // This is a workaround to avoid race conditions with the style loading + // else we have to force update every layer on setStyle change + styleTimeoutRef.current = setTimeout(() => { + setIsStyleLoaded(true); + if (projection) { + map.setProjection(projection); + } + }, 100); + }; + const loadHandler = () => setIsLoaded(true); + + // Viewport change handler - skip if triggered by internal update + const handleMove = () => { + if (internalUpdateRef.current) return; + onViewportChangeRef.current?.(getViewport(map)); + }; + + map.on("load", loadHandler); + map.on("styledata", styleDataHandler); + map.on("move", handleMove); + setMapInstance(map); + + return () => { + clearStyleTimeout(); + map.off("load", loadHandler); + map.off("styledata", styleDataHandler); + map.off("move", handleMove); + map.remove(); + setIsLoaded(false); + setIsStyleLoaded(false); + setMapInstance(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Sync controlled viewport to map + useEffect(() => { + if (!mapInstance || !isControlled || !viewport) return; + if (mapInstance.isMoving()) return; + + const current = getViewport(mapInstance); + const next = { + center: viewport.center ?? current.center, + zoom: viewport.zoom ?? current.zoom, + bearing: viewport.bearing ?? current.bearing, + pitch: viewport.pitch ?? current.pitch, + }; + + if ( + next.center[0] === current.center[0] && + next.center[1] === current.center[1] && + next.zoom === current.zoom && + next.bearing === current.bearing && + next.pitch === current.pitch + ) { + return; + } + + internalUpdateRef.current = true; + mapInstance.jumpTo(next); + internalUpdateRef.current = false; + }, [mapInstance, isControlled, viewport]); + + // Handle style change + useEffect(() => { + if (!mapInstance || !resolvedTheme) return; + + const newStyle = + resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; + + if (currentStyleRef.current === newStyle) return; + + clearStyleTimeout(); + currentStyleRef.current = newStyle; + setIsStyleLoaded(false); + + mapInstance.setStyle(newStyle, { diff: true }); + }, [mapInstance, resolvedTheme, mapStyles, clearStyleTimeout]); + + const contextValue = useMemo( + () => ({ + map: mapInstance, + isLoaded: isLoaded && isStyleLoaded, + }), + [mapInstance, isLoaded, isStyleLoaded], + ); + + return ( + +
+ {(!isLoaded || loading) && } + {/* SSR-safe: children render only when map is loaded on client */} + {mapInstance && children} +
+
+ ); +}); + +type MarkerContextValue = { + marker: MapLibreGL.Marker; + map: MapLibreGL.Map | null; +}; + +const MarkerContext = createContext(null); + +function useMarkerContext() { + const context = useContext(MarkerContext); + if (!context) { + throw new Error("Marker components must be used within MapMarker"); + } + return context; +} + +type MapMarkerProps = { + /** Longitude coordinate for marker position */ + longitude: number; + /** Latitude coordinate for marker position */ + latitude: number; + /** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */ + children: ReactNode; + /** Callback when marker is clicked */ + onClick?: (e: MouseEvent) => void; + /** Callback when mouse enters marker */ + onMouseEnter?: (e: MouseEvent) => void; + /** Callback when mouse leaves marker */ + onMouseLeave?: (e: MouseEvent) => void; + /** Callback when marker drag starts (requires draggable: true) */ + onDragStart?: (lngLat: { lng: number; lat: number }) => void; + /** Callback during marker drag (requires draggable: true) */ + onDrag?: (lngLat: { lng: number; lat: number }) => void; + /** Callback when marker drag ends (requires draggable: true) */ + onDragEnd?: (lngLat: { lng: number; lat: number }) => void; +} & Omit; + +function MapMarker({ + longitude, + latitude, + children, + onClick, + onMouseEnter, + onMouseLeave, + onDragStart, + onDrag, + onDragEnd, + draggable = false, + ...markerOptions +}: MapMarkerProps) { + const { map } = useMap(); + + const callbacksRef = useRef({ + onClick, + onMouseEnter, + onMouseLeave, + onDragStart, + onDrag, + onDragEnd, + }); + callbacksRef.current = { + onClick, + onMouseEnter, + onMouseLeave, + onDragStart, + onDrag, + onDragEnd, + }; + + const marker = useMemo(() => { + const markerInstance = new MapLibreGL.Marker({ + ...markerOptions, + element: document.createElement("div"), + draggable, + }).setLngLat([longitude, latitude]); + + const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e); + const handleMouseEnter = (e: MouseEvent) => + callbacksRef.current.onMouseEnter?.(e); + const handleMouseLeave = (e: MouseEvent) => + callbacksRef.current.onMouseLeave?.(e); + + markerInstance.getElement()?.addEventListener("click", handleClick); + markerInstance + .getElement() + ?.addEventListener("mouseenter", handleMouseEnter); + markerInstance + .getElement() + ?.addEventListener("mouseleave", handleMouseLeave); + + const handleDragStart = () => { + const lngLat = markerInstance.getLngLat(); + callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat }); + }; + const handleDrag = () => { + const lngLat = markerInstance.getLngLat(); + callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat }); + }; + const handleDragEnd = () => { + const lngLat = markerInstance.getLngLat(); + callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat }); + }; + + markerInstance.on("dragstart", handleDragStart); + markerInstance.on("drag", handleDrag); + markerInstance.on("dragend", handleDragEnd); + + return markerInstance; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + + marker.addTo(map); + + return () => { + marker.remove(); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + if ( + marker.getLngLat().lng !== longitude || + marker.getLngLat().lat !== latitude + ) { + marker.setLngLat([longitude, latitude]); + } + if (marker.isDraggable() !== draggable) { + marker.setDraggable(draggable); + } + + const currentOffset = marker.getOffset(); + const newOffset = markerOptions.offset ?? [0, 0]; + const [newOffsetX, newOffsetY] = Array.isArray(newOffset) + ? newOffset + : [newOffset.x, newOffset.y]; + if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) { + marker.setOffset(newOffset); + } + + if (marker.getRotation() !== markerOptions.rotation) { + marker.setRotation(markerOptions.rotation ?? 0); + } + if (marker.getRotationAlignment() !== markerOptions.rotationAlignment) { + marker.setRotationAlignment(markerOptions.rotationAlignment ?? "auto"); + } + if (marker.getPitchAlignment() !== markerOptions.pitchAlignment) { + marker.setPitchAlignment(markerOptions.pitchAlignment ?? "auto"); + } + + return ( + + {children} + + ); +} + +type MarkerContentProps = { + /** Custom marker content. Defaults to a blue dot if not provided */ + children?: ReactNode; + /** Additional CSS classes for the marker container */ + className?: string; +}; + +function MarkerContent({ children, className }: MarkerContentProps) { + const { marker } = useMarkerContext(); + + return createPortal( +
+ {children || } +
, + marker.getElement(), + ); +} + +function DefaultMarkerIcon() { + return ( +
+ ); +} + +function PopupCloseButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +type MarkerPopupProps = { + /** Popup content */ + children: ReactNode; + /** Additional CSS classes for the popup container */ + className?: string; + /** Show a close button in the popup (default: false) */ + closeButton?: boolean; +} & Omit; + +function MarkerPopup({ + children, + className, + closeButton = false, + ...popupOptions +}: MarkerPopupProps) { + const { marker, map } = useMarkerContext(); + const container = useMemo(() => document.createElement("div"), []); + const prevPopupOptions = useRef(popupOptions); + + const popup = useMemo(() => { + const popupInstance = new MapLibreGL.Popup({ + offset: 16, + ...popupOptions, + closeButton: false, + }) + .setMaxWidth("none") + .setDOMContent(container); + + return popupInstance; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + + popup.setDOMContent(container); + marker.setPopup(popup); + + return () => { + marker.setPopup(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + if (popup.isOpen()) { + const prev = prevPopupOptions.current; + + if (prev.offset !== popupOptions.offset) { + popup.setOffset(popupOptions.offset ?? 16); + } + if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { + popup.setMaxWidth(popupOptions.maxWidth ?? "none"); + } + + prevPopupOptions.current = popupOptions; + } + + const handleClose = () => popup.remove(); + + return createPortal( +
+ {closeButton && } + {children} +
, + container, + ); +} + +type MarkerTooltipProps = { + /** Tooltip content */ + children: ReactNode; + /** Additional CSS classes for the tooltip container */ + className?: string; +} & Omit; + +function MarkerTooltip({ + children, + className, + ...popupOptions +}: MarkerTooltipProps) { + const { marker, map } = useMarkerContext(); + const container = useMemo(() => document.createElement("div"), []); + const prevTooltipOptions = useRef(popupOptions); + + const tooltip = useMemo(() => { + const tooltipInstance = new MapLibreGL.Popup({ + offset: 16, + ...popupOptions, + closeOnClick: true, + closeButton: false, + }).setMaxWidth("none"); + + return tooltipInstance; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + + tooltip.setDOMContent(container); + + const handleMouseEnter = () => { + tooltip.setLngLat(marker.getLngLat()).addTo(map); + }; + const handleMouseLeave = () => tooltip.remove(); + + marker.getElement()?.addEventListener("mouseenter", handleMouseEnter); + marker.getElement()?.addEventListener("mouseleave", handleMouseLeave); + + return () => { + marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter); + marker.getElement()?.removeEventListener("mouseleave", handleMouseLeave); + tooltip.remove(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + if (tooltip.isOpen()) { + const prev = prevTooltipOptions.current; + + if (prev.offset !== popupOptions.offset) { + tooltip.setOffset(popupOptions.offset ?? 16); + } + if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { + tooltip.setMaxWidth(popupOptions.maxWidth ?? "none"); + } + + prevTooltipOptions.current = popupOptions; + } + + return createPortal( +
+ {children} +
, + container, + ); +} + +type MarkerLabelProps = { + /** Label text content */ + children: ReactNode; + /** Additional CSS classes for the label */ + className?: string; + /** Position of the label relative to the marker (default: "top") */ + position?: "top" | "bottom"; +}; + +function MarkerLabel({ + children, + className, + position = "top", +}: MarkerLabelProps) { + const positionClasses = { + top: "bottom-full mb-1", + bottom: "top-full mt-1", + }; + + return ( +
+ {children} +
+ ); +} + +type MapControlsProps = { + /** Position of the controls on the map (default: "bottom-right") */ + position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; + /** Show zoom in/out buttons (default: true) */ + showZoom?: boolean; + /** Show compass button to reset bearing (default: false) */ + showCompass?: boolean; + /** Show locate button to find user's location (default: false) */ + showLocate?: boolean; + /** Show fullscreen toggle button (default: false) */ + showFullscreen?: boolean; + /** Additional CSS classes for the controls container */ + className?: string; + /** Callback with user coordinates when located */ + onLocate?: (coords: { longitude: number; latitude: number }) => void; +}; + +const positionClasses = { + "top-left": "top-2 left-2", + "top-right": "top-2 right-2", + "bottom-left": "bottom-2 left-2", + "bottom-right": "bottom-10 right-2", +}; + +function ControlGroup({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function ControlButton({ + onClick, + label, + children, + disabled = false, +}: { + onClick: () => void; + label: string; + children: React.ReactNode; + disabled?: boolean; +}) { + return ( + + ); +} + +function MapControls({ + position = "bottom-right", + showZoom = true, + showCompass = false, + showLocate = false, + showFullscreen = false, + className, + onLocate, +}: MapControlsProps) { + const { map } = useMap(); + const [waitingForLocation, setWaitingForLocation] = useState(false); + + const handleZoomIn = useCallback(() => { + map?.zoomTo(map.getZoom() + 1, { duration: 300 }); + }, [map]); + + const handleZoomOut = useCallback(() => { + map?.zoomTo(map.getZoom() - 1, { duration: 300 }); + }, [map]); + + const handleResetBearing = useCallback(() => { + map?.resetNorthPitch({ duration: 300 }); + }, [map]); + + const handleLocate = useCallback(() => { + setWaitingForLocation(true); + if ("geolocation" in navigator) { + navigator.geolocation.getCurrentPosition( + (pos) => { + const coords = { + longitude: pos.coords.longitude, + latitude: pos.coords.latitude, + }; + map?.flyTo({ + center: [coords.longitude, coords.latitude], + zoom: 14, + duration: 1500, + }); + onLocate?.(coords); + setWaitingForLocation(false); + }, + (error) => { + console.error("Error getting location:", error); + setWaitingForLocation(false); + }, + ); + } + }, [map, onLocate]); + + const handleFullscreen = useCallback(() => { + const container = map?.getContainer(); + if (!container) return; + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + container.requestFullscreen(); + } + }, [map]); + + return ( +
+ {showZoom && ( + + + + + + + + + )} + {showCompass && ( + + + + )} + {showLocate && ( + + + {waitingForLocation ? ( + + ) : ( + + )} + + + )} + {showFullscreen && ( + + + + + + )} +
+ ); +} + +function CompassButton({ onClick }: { onClick: () => void }) { + const { map } = useMap(); + const compassRef = useRef(null); + + useEffect(() => { + if (!map || !compassRef.current) return; + + const compass = compassRef.current; + + const updateRotation = () => { + const bearing = map.getBearing(); + const pitch = map.getPitch(); + compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`; + }; + + map.on("rotate", updateRotation); + map.on("pitch", updateRotation); + updateRotation(); + + return () => { + map.off("rotate", updateRotation); + map.off("pitch", updateRotation); + }; + }, [map]); + + return ( + + + + + + + + + ); +} + +type MapPopupProps = { + /** Longitude coordinate for popup position */ + longitude: number; + /** Latitude coordinate for popup position */ + latitude: number; + /** Callback when popup is closed */ + onClose?: () => void; + /** Popup content */ + children: ReactNode; + /** Additional CSS classes for the popup container */ + className?: string; + /** Show a close button in the popup (default: false) */ + closeButton?: boolean; +} & Omit; + +function MapPopup({ + longitude, + latitude, + onClose, + children, + className, + closeButton = false, + ...popupOptions +}: MapPopupProps) { + const { map } = useMap(); + const popupOptionsRef = useRef(popupOptions); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const container = useMemo(() => document.createElement("div"), []); + + const popup = useMemo(() => { + const popupInstance = new MapLibreGL.Popup({ + offset: 16, + ...popupOptions, + closeButton: false, + }) + .setMaxWidth("none") + .setLngLat([longitude, latitude]); + + return popupInstance; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + + const onCloseProp = () => onCloseRef.current?.(); + + popup.on("close", onCloseProp); + + popup.setDOMContent(container); + popup.addTo(map); + + return () => { + popup.off("close", onCloseProp); + if (popup.isOpen()) { + popup.remove(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + if (popup.isOpen()) { + const prev = popupOptionsRef.current; + + if ( + popup.getLngLat().lng !== longitude || + popup.getLngLat().lat !== latitude + ) { + popup.setLngLat([longitude, latitude]); + } + + if (prev.offset !== popupOptions.offset) { + popup.setOffset(popupOptions.offset ?? 16); + } + if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { + popup.setMaxWidth(popupOptions.maxWidth ?? "none"); + } + popupOptionsRef.current = popupOptions; + } + + const handleClose = () => { + popup.remove(); + }; + + return createPortal( +
+ {closeButton && } + {children} +
, + container, + ); +} + +type MapRouteProps = { + /** Optional unique identifier for the route layer */ + id?: string; + /** Array of [longitude, latitude] coordinate pairs defining the route */ + coordinates: [number, number][]; + /** Line color as CSS color value (default: "#4285F4") */ + color?: string; + /** Line width in pixels (default: 3) */ + width?: number; + /** Line opacity from 0 to 1 (default: 0.8) */ + opacity?: number; + /** Dash pattern [dash length, gap length] for dashed lines */ + dashArray?: [number, number]; + /** Callback when the route line is clicked */ + onClick?: () => void; + /** Callback when mouse enters the route line */ + onMouseEnter?: () => void; + /** Callback when mouse leaves the route line */ + onMouseLeave?: () => void; + /** Whether the route is interactive - shows pointer cursor on hover (default: true) */ + interactive?: boolean; +}; + +function MapRoute({ + id: propId, + coordinates, + color = "#4285F4", + width = 3, + opacity = 0.8, + dashArray, + onClick, + onMouseEnter, + onMouseLeave, + interactive = true, +}: MapRouteProps) { + const { map, isLoaded } = useMap(); + const autoId = useId(); + const id = propId ?? autoId; + const sourceId = `route-source-${id}`; + const layerId = `route-layer-${id}`; + + // Add source and layer on mount + useEffect(() => { + if (!isLoaded || !map) return; + + map.addSource(sourceId, { + type: "geojson", + data: { + type: "Feature", + properties: {}, + geometry: { type: "LineString", coordinates: [] }, + }, + }); + + map.addLayer({ + id: layerId, + type: "line", + source: sourceId, + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": color, + "line-width": width, + "line-opacity": opacity, + ...(dashArray && { "line-dasharray": dashArray }), + }, + }); + + return () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } catch { + // ignore + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, map]); + + // When coordinates change, update the source data + useEffect(() => { + if (!isLoaded || !map || coordinates.length < 2) return; + + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + if (source) { + source.setData({ + type: "Feature", + properties: {}, + geometry: { type: "LineString", coordinates }, + }); + } + }, [isLoaded, map, coordinates, sourceId]); + + useEffect(() => { + if (!isLoaded || !map || !map.getLayer(layerId)) return; + + map.setPaintProperty(layerId, "line-color", color); + map.setPaintProperty(layerId, "line-width", width); + map.setPaintProperty(layerId, "line-opacity", opacity); + if (dashArray) { + map.setPaintProperty(layerId, "line-dasharray", dashArray); + } + }, [isLoaded, map, layerId, color, width, opacity, dashArray]); + + // Handle click and hover events + useEffect(() => { + if (!isLoaded || !map || !interactive) return; + + const handleClick = () => { + onClick?.(); + }; + const handleMouseEnter = () => { + map.getCanvas().style.cursor = "pointer"; + onMouseEnter?.(); + }; + const handleMouseLeave = () => { + map.getCanvas().style.cursor = ""; + onMouseLeave?.(); + }; + + map.on("click", layerId, handleClick); + map.on("mouseenter", layerId, handleMouseEnter); + map.on("mouseleave", layerId, handleMouseLeave); + + return () => { + map.off("click", layerId, handleClick); + map.off("mouseenter", layerId, handleMouseEnter); + map.off("mouseleave", layerId, handleMouseLeave); + }; + }, [ + isLoaded, + map, + layerId, + onClick, + onMouseEnter, + onMouseLeave, + interactive, + ]); + + return null; +} + +/** A single arc to render inside . */ +type MapArcDatum = { + /** Unique identifier for this arc. Required for hover state tracking and event payloads. */ + id: string | number; + /** Start coordinate as [longitude, latitude]. */ + from: [number, number]; + /** End coordinate as [longitude, latitude]. */ + to: [number, number]; +}; + +/** Event payload passed to MapArc interaction callbacks. */ +type MapArcEvent = { + /** The arc datum that was hovered or clicked. */ + arc: T; + /** Longitude of the cursor at the time of the event. */ + longitude: number; + /** Latitude of the cursor at the time of the event. */ + latitude: number; + /** The underlying MapLibre mouse event for advanced use cases. */ + originalEvent: MapLibreGL.MapMouseEvent; +}; + +type MapArcLinePaint = NonNullable; +type MapArcLineLayout = NonNullable< + MapLibreGL.LineLayerSpecification["layout"] +>; + +type MapArcProps = { + /** Array of arcs to render. Each arc must have a unique `id`. */ + data: T[]; + /** Optional unique identifier prefix for the arc source/layers. Auto-generated if not provided. */ + id?: string; + /** + * How far each arc bows away from a straight line. `0` renders straight + * lines; higher values bend further. Negative values bend to the opposite + * side. Arcs are computed as a quadratic Bézier in lng/lat space and do not + * account for the antimeridian. (default: 0.2) + */ + curvature?: number; + /** Number of samples used to render each curve. Higher = smoother. (default: 64) */ + samples?: number; + /** + * MapLibre paint properties for the arc layer. Merged on top of sensible + * defaults (`line-color: #4285F4`, `line-width: 2`, `line-opacity: 0.85`). + * Any value can be a MapLibre expression for per-feature styling, every + * field on each arc datum (besides `from`/`to`) is exposed via `["get", ...]`. + */ + paint?: MapArcLinePaint; + /** MapLibre layout properties for the arc layer. Defaults to rounded joins/caps. */ + layout?: MapArcLineLayout; + /** + * Paint properties applied to the arc currently under the cursor. Each key + * is merged into `paint` as a `case` expression keyed on per-feature hover + * state, so only the hovered arc changes appearance. + */ + hoverPaint?: MapArcLinePaint; + /** Callback when an arc is clicked. */ + onClick?: (e: MapArcEvent) => void; + /** + * Callback fired when the hovered arc changes. Receives the cursor's + * lng/lat at the moment of entry, and `null` when the cursor leaves the + * last hovered arc. + */ + onHover?: (e: MapArcEvent | null) => void; + /** Whether arcs respond to mouse events (default: true). */ + interactive?: boolean; + /** Optional MapLibre layer id to insert the arc layers before (z-order control). */ + beforeId?: string; +}; + +const DEFAULT_ARC_CURVATURE = 0.2; +const DEFAULT_ARC_SAMPLES = 64; +const ARC_HIT_MIN_WIDTH = 12; +const ARC_HIT_PADDING = 6; + +const DEFAULT_ARC_PAINT: MapArcLinePaint = { + "line-color": "#4285F4", + "line-width": 2, + "line-opacity": 0.85, +}; + +const DEFAULT_ARC_LAYOUT: MapArcLineLayout = { + "line-join": "round", + "line-cap": "round", +}; + +function mergeArcPaint( + paint: MapArcLinePaint, + hoverPaint: MapArcLinePaint | undefined, +): MapArcLinePaint { + if (!hoverPaint) return paint; + const merged: Record = { ...paint }; + for (const [key, hoverValue] of Object.entries(hoverPaint)) { + if (hoverValue === undefined) continue; + const baseValue = merged[key]; + merged[key] = + baseValue === undefined + ? hoverValue + : [ + "case", + ["boolean", ["feature-state", "hover"], false], + hoverValue, + baseValue, + ]; + } + return merged as MapArcLinePaint; +} + +function buildArcCoordinates( + from: [number, number], + to: [number, number], + curvature: number, + samples: number, +): [number, number][] { + const [x0, y0] = from; + const [x2, y2] = to; + const dx = x2 - x0; + const dy = y2 - y0; + const distance = Math.hypot(dx, dy); + + if (distance === 0 || curvature === 0) return [from, to]; + + const mx = (x0 + x2) / 2; + const my = (y0 + y2) / 2; + const nx = -dy / distance; + const ny = dx / distance; + const offset = distance * curvature; + const cx = mx + nx * offset; + const cy = my + ny * offset; + + const points: [number, number][] = []; + const segments = Math.max(2, Math.floor(samples)); + for (let i = 0; i <= segments; i += 1) { + const t = i / segments; + const inv = 1 - t; + const x = inv * inv * x0 + 2 * inv * t * cx + t * t * x2; + const y = inv * inv * y0 + 2 * inv * t * cy + t * t * y2; + points.push([x, y]); + } + return points; +} + +function MapArc({ + data, + id: propId, + curvature = DEFAULT_ARC_CURVATURE, + samples = DEFAULT_ARC_SAMPLES, + paint, + layout, + hoverPaint, + onClick, + onHover, + interactive = true, + beforeId, +}: MapArcProps) { + const { map, isLoaded } = useMap(); + const autoId = useId(); + const id = propId ?? autoId; + const sourceId = `arc-source-${id}`; + const layerId = `arc-layer-${id}`; + const hitLayerId = `arc-hit-layer-${id}`; + + const mergedPaint = useMemo( + () => mergeArcPaint({ ...DEFAULT_ARC_PAINT, ...paint }, hoverPaint), + [paint, hoverPaint], + ); + const mergedLayout = useMemo( + () => ({ ...DEFAULT_ARC_LAYOUT, ...layout }), + [layout], + ); + + const hitWidth = useMemo(() => { + const w = paint?.["line-width"] ?? DEFAULT_ARC_PAINT["line-width"]; + const base = typeof w === "number" ? w : ARC_HIT_MIN_WIDTH; + return Math.max(base + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH); + }, [paint]); + + const geoJSON = useMemo>( + () => ({ + type: "FeatureCollection", + features: data.map((arc) => { + const { from, to, ...properties } = arc; + return { + type: "Feature", + properties, + geometry: { + type: "LineString", + coordinates: buildArcCoordinates(from, to, curvature, samples), + }, + }; + }), + }), + [data, curvature, samples], + ); + + const latestRef = useRef({ data, onClick, onHover }); + latestRef.current = { data, onClick, onHover }; + + // Add source and layers on mount. + useEffect(() => { + if (!isLoaded || !map) return; + + map.addSource(sourceId, { + type: "geojson", + data: geoJSON, + promoteId: "id", + }); + + map.addLayer( + { + id: hitLayerId, + type: "line", + source: sourceId, + layout: DEFAULT_ARC_LAYOUT, + paint: { + "line-color": "rgba(0, 0, 0, 0)", + "line-width": hitWidth, + "line-opacity": 1, + }, + }, + beforeId, + ); + + map.addLayer( + { + id: layerId, + type: "line", + source: sourceId, + layout: mergedLayout, + paint: mergedPaint, + }, + beforeId, + ); + + return () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } catch { + // ignore + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, map]); + + // Sync features when data / curvature / samples change. + useEffect(() => { + if (!isLoaded || !map) return; + const source = map.getSource(sourceId) as + | MapLibreGL.GeoJSONSource + | undefined; + source?.setData(geoJSON); + }, [isLoaded, map, geoJSON, sourceId]); + + // Sync paint/layout when they change. + useEffect(() => { + if (!isLoaded || !map || !map.getLayer(layerId)) return; + for (const [key, value] of Object.entries(mergedPaint)) { + map.setPaintProperty( + layerId, + key as keyof MapArcLinePaint, + value as never, + ); + } + for (const [key, value] of Object.entries(mergedLayout)) { + map.setLayoutProperty( + layerId, + key as keyof MapArcLineLayout, + value as never, + ); + } + if (map.getLayer(hitLayerId)) { + map.setPaintProperty(hitLayerId, "line-width", hitWidth); + } + }, [isLoaded, map, layerId, hitLayerId, mergedPaint, mergedLayout, hitWidth]); + + // Interaction handlers + useEffect(() => { + if (!isLoaded || !map || !interactive) return; + + let hoveredId: string | number | null = null; + + const setHover = (next: string | number | null) => { + if (next === hoveredId) return; + const sourceExists = !!map.getSource(sourceId); + if (hoveredId != null && sourceExists) { + map.setFeatureState( + { source: sourceId, id: hoveredId }, + { hover: false }, + ); + } + hoveredId = next; + if (next != null && sourceExists) { + map.setFeatureState({ source: sourceId, id: next }, { hover: true }); + } + }; + + const findArc = (featureId: string | number | undefined) => + featureId == null + ? undefined + : latestRef.current.data.find( + (arc) => String(arc.id) === String(featureId), + ); + + const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => { + const featureId = e.features?.[0]?.id as string | number | undefined; + if (featureId == null || featureId === hoveredId) return; + + setHover(featureId); + map.getCanvas().style.cursor = "pointer"; + + const arc = findArc(featureId); + if (arc) { + latestRef.current.onHover?.({ + arc: arc as T, + longitude: e.lngLat.lng, + latitude: e.lngLat.lat, + originalEvent: e, + }); + } + }; + + const handleMouseLeave = () => { + setHover(null); + map.getCanvas().style.cursor = ""; + latestRef.current.onHover?.(null); + }; + + const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => { + const arc = findArc(e.features?.[0]?.id as string | number | undefined); + if (!arc) return; + latestRef.current.onClick?.({ + arc: arc as T, + longitude: e.lngLat.lng, + latitude: e.lngLat.lat, + originalEvent: e, + }); + }; + + map.on("mousemove", hitLayerId, handleMouseMove); + map.on("mouseleave", hitLayerId, handleMouseLeave); + map.on("click", hitLayerId, handleClick); + + return () => { + map.off("mousemove", hitLayerId, handleMouseMove); + map.off("mouseleave", hitLayerId, handleMouseLeave); + map.off("click", hitLayerId, handleClick); + setHover(null); + map.getCanvas().style.cursor = ""; + }; + }, [isLoaded, map, hitLayerId, sourceId, interactive]); + + return null; +} + +type MapClusterLayerProps< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, +> = { + /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */ + data: string | GeoJSON.FeatureCollection; + /** Maximum zoom level to cluster points on (default: 14) */ + clusterMaxZoom?: number; + /** Radius of each cluster when clustering points in pixels (default: 50) */ + clusterRadius?: number; + /** Colors for cluster circles: [small, medium, large] based on point count (default: ["#22c55e", "#eab308", "#ef4444"]) */ + clusterColors?: [string, string, string]; + /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */ + clusterThresholds?: [number, number]; + /** Color for unclustered individual points (default: "#3b82f6") */ + pointColor?: string; + /** Callback when an unclustered point is clicked */ + onPointClick?: ( + feature: GeoJSON.Feature, + coordinates: [number, number], + ) => void; + /** Callback when a cluster is clicked. If not provided, zooms into the cluster */ + onClusterClick?: ( + clusterId: number, + coordinates: [number, number], + pointCount: number, + ) => void; +}; + +function MapClusterLayer< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, +>({ + data, + clusterMaxZoom = 14, + clusterRadius = 50, + clusterColors = ["#22c55e", "#eab308", "#ef4444"], + clusterThresholds = [100, 750], + pointColor = "#3b82f6", + onPointClick, + onClusterClick, +}: MapClusterLayerProps

) { + const { map, isLoaded } = useMap(); + const id = useId(); + const sourceId = `cluster-source-${id}`; + const clusterLayerId = `clusters-${id}`; + const clusterCountLayerId = `cluster-count-${id}`; + const unclusteredLayerId = `unclustered-point-${id}`; + + const stylePropsRef = useRef({ + clusterColors, + clusterThresholds, + pointColor, + }); + + // Add source and layers on mount + useEffect(() => { + if (!isLoaded || !map) return; + + // Add clustered GeoJSON source + map.addSource(sourceId, { + type: "geojson", + data, + cluster: true, + clusterMaxZoom, + clusterRadius, + }); + + // Add cluster circles layer + map.addLayer({ + id: clusterLayerId, + type: "circle", + source: sourceId, + filter: ["has", "point_count"], + paint: { + "circle-color": [ + "step", + ["get", "point_count"], + clusterColors[0], + clusterThresholds[0], + clusterColors[1], + clusterThresholds[1], + clusterColors[2], + ], + "circle-radius": [ + "step", + ["get", "point_count"], + 20, + clusterThresholds[0], + 30, + clusterThresholds[1], + 40, + ], + "circle-stroke-width": 1, + "circle-stroke-color": "#fff", + "circle-opacity": 0.85, + }, + }); + + // Add cluster count text layer + map.addLayer({ + id: clusterCountLayerId, + type: "symbol", + source: sourceId, + filter: ["has", "point_count"], + layout: { + "text-field": "{point_count_abbreviated}", + "text-font": ["Open Sans"], + "text-size": 12, + }, + paint: { + "text-color": "#fff", + }, + }); + + // Add unclustered point layer + map.addLayer({ + id: unclusteredLayerId, + type: "circle", + source: sourceId, + filter: ["!", ["has", "point_count"]], + paint: { + "circle-color": pointColor, + "circle-radius": 5, + "circle-stroke-width": 2, + "circle-stroke-color": "#fff", + }, + }); + + return () => { + try { + if (map.getLayer(clusterCountLayerId)) + map.removeLayer(clusterCountLayerId); + if (map.getLayer(unclusteredLayerId)) + map.removeLayer(unclusteredLayerId); + if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } catch { + // ignore + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, map, sourceId]); + + // Update source data when data prop changes (only for non-URL data) + useEffect(() => { + if (!isLoaded || !map || typeof data === "string") return; + + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + if (source) { + source.setData(data); + } + }, [isLoaded, map, data, sourceId]); + + // Update layer styles when props change + useEffect(() => { + if (!isLoaded || !map) return; + + const prev = stylePropsRef.current; + const colorsChanged = + prev.clusterColors !== clusterColors || + prev.clusterThresholds !== clusterThresholds; + + // Update cluster layer colors and sizes + if (map.getLayer(clusterLayerId) && colorsChanged) { + map.setPaintProperty(clusterLayerId, "circle-color", [ + "step", + ["get", "point_count"], + clusterColors[0], + clusterThresholds[0], + clusterColors[1], + clusterThresholds[1], + clusterColors[2], + ]); + map.setPaintProperty(clusterLayerId, "circle-radius", [ + "step", + ["get", "point_count"], + 20, + clusterThresholds[0], + 30, + clusterThresholds[1], + 40, + ]); + } + + // Update unclustered point layer color + if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) { + map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor); + } + + stylePropsRef.current = { clusterColors, clusterThresholds, pointColor }; + }, [ + isLoaded, + map, + clusterLayerId, + unclusteredLayerId, + clusterColors, + clusterThresholds, + pointColor, + ]); + + // Handle click events + useEffect(() => { + if (!isLoaded || !map) return; + + // Cluster click handler - zoom into cluster + const handleClusterClick = async ( + e: MapLibreGL.MapMouseEvent & { + features?: MapLibreGL.MapGeoJSONFeature[]; + }, + ) => { + const features = map.queryRenderedFeatures(e.point, { + layers: [clusterLayerId], + }); + if (!features.length) return; + + const feature = features[0]; + const clusterId = feature.properties?.cluster_id as number; + const pointCount = feature.properties?.point_count as number; + const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [ + number, + number, + ]; + + if (onClusterClick) { + onClusterClick(clusterId, coordinates, pointCount); + } else { + // Default behavior: zoom to cluster expansion zoom + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + const zoom = await source.getClusterExpansionZoom(clusterId); + map.easeTo({ + center: coordinates, + zoom, + }); + } + }; + + // Unclustered point click handler + const handlePointClick = ( + e: MapLibreGL.MapMouseEvent & { + features?: MapLibreGL.MapGeoJSONFeature[]; + }, + ) => { + if (!onPointClick || !e.features?.length) return; + + const feature = e.features[0]; + const coordinates = ( + feature.geometry as GeoJSON.Point + ).coordinates.slice() as [number, number]; + + // Handle world copies + while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { + coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; + } + + onPointClick( + feature as unknown as GeoJSON.Feature, + coordinates, + ); + }; + + // Cursor style handlers + const handleMouseEnterCluster = () => { + map.getCanvas().style.cursor = "pointer"; + }; + const handleMouseLeaveCluster = () => { + map.getCanvas().style.cursor = ""; + }; + const handleMouseEnterPoint = () => { + if (onPointClick) { + map.getCanvas().style.cursor = "pointer"; + } + }; + const handleMouseLeavePoint = () => { + map.getCanvas().style.cursor = ""; + }; + + map.on("click", clusterLayerId, handleClusterClick); + map.on("click", unclusteredLayerId, handlePointClick); + map.on("mouseenter", clusterLayerId, handleMouseEnterCluster); + map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster); + map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint); + map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint); + + return () => { + map.off("click", clusterLayerId, handleClusterClick); + map.off("click", unclusteredLayerId, handlePointClick); + map.off("mouseenter", clusterLayerId, handleMouseEnterCluster); + map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster); + map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint); + map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint); + }; + }, [ + isLoaded, + map, + clusterLayerId, + unclusteredLayerId, + sourceId, + onClusterClick, + onPointClick, + ]); + + return null; +} + +export { + Map, + useMap, + MapMarker, + MarkerContent, + MarkerPopup, + MarkerTooltip, + MarkerLabel, + MapPopup, + MapControls, + MapRoute, + MapArc, + MapClusterLayer, +}; + +export type { MapRef, MapViewport, MapArcDatum, MapArcEvent }; diff --git a/src/data/site.ts b/src/data/site.ts new file mode 100644 index 0000000..25d490d --- /dev/null +++ b/src/data/site.ts @@ -0,0 +1,142 @@ +export const siteConfig = { + name: "Zahnarztpraxis Dr. Tittel", + tagline: "Ihr Zahnarzt in Crimmitschau", + phone: "03762 56 29", + phoneLink: "tel:037625629", + address: { + street: "Bodelschwinghstraße 1", + city: "08451 Crimmitschau", + }, + location: { + latitude: 50.8203324, + longitude: 12.3849586, + }, + email: "praxis@zahnarzt-tittel.de", + hours: { + monday: "8:00 – 12:00, 14:00 – 18:00", + tuesday: "8:00 – 12:00, 14:00 – 18:00", + wednesday: "8:00 – 12:00", + thursday: "8:00 – 12:00, 14:00 – 18:00", + friday: "8:00 – 12:00", + }, + emergency: { + url: "https://www.zahnaerzte-in-sachsen.de", + text: "Zahnärztlicher Notdienst Sachsen", + }, + navigation: [ + { label: "Startseite", href: "/" }, + { label: "Unser Team", href: "/unser-team" }, + { label: "Leistungen", href: "/leistungen" }, + { label: "Sprechzeiten", href: "/sprechzeiten" }, + { label: "Kontakt", href: "/kontakt" }, + ], +} + +export const teamMembers = [ + { + name: "Dr. Sophie Tittel", + role: "Zahnärztin", + image: + "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&w=400&h=400&q=80", + bio: "Spezialisiert auf ästhetische Zahnheilkunde und Implantologie.", + }, + { + name: "Dr. Sybille Weber", + role: "Zahnärztin", + image: + "https://images.unsplash.com/photo-1551836022-d5d88e9218df?auto=format&fit=crop&w=400&h=400&q=80", + bio: "Expertin für Wurzelkanalbehandlungen und Kinderzahnheilkunde.", + }, + { + name: "Sabine Schnabel", + role: "Zahnarzthelferin", + image: + "https://images.unsplash.com/photo-1594824476967-48c8b964273f?auto=format&fit=crop&w=400&h=400&q=80", + bio: "Verantwortlich für Prophylaxe und Patientenbetreuung.", + }, + { + name: "Susan Gabler", + role: "Zahnarzthelferin", + image: + "https://images.unsplash.com/photo-1580489944761-15a19d654956?auto=format&fit=crop&w=400&h=400&q=80", + bio: "Spezialisiert auf Assistenz bei chirurgischen Eingriffen.", + }, + { + name: "Denise Golesh", + role: "Zahnarzthelferin", + image: + "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&w=400&h=400&q=80", + bio: "Zuständig für Verwaltung und Terminkoordination.", + }, + { + name: "Christiane Zill", + role: "Zahnarzthelferin", + image: + "https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&w=400&h=400&q=80", + bio: "Expertin für professionelle Zahnreinigung.", + }, + { + name: "Angelika Schulz", + role: "Zahnarzthelferin", + image: + "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=400&h=400&q=80", + bio: "Spezialisiert auf Röntgendiagnostik und Laborarbeit.", + }, +] + +export const services = [ + { + id: "fuellungen", + title: "Ästhetische Zahnfüllungen", + shortDesc: "Natürlich aussehende Füllungen aus hochwertigen Kompositen.", + fullDesc: + "Wir setzen auf unsichtbare, zahnfarbene Füllungen, die sich perfekt an Ihre natürliche Zahnfarbe anpassen. Moderne Komposite bieten höchste Ästhetik und Langlebigkeit.", + icon: "Sparkles", + featured: true, + }, + { + id: "implantate", + title: "Implantologie", + shortDesc: "Feste Zähne dank modernster Implantattechnologie.", + fullDesc: + "Fehlende Zähne ersetzen wir durch hochwertige Zahnimplantate. Mit modernster 3D-Planung und computerunterstützter Chirurgie erreichen wir optimale Ergebnisse.", + icon: "CircleDot", + featured: true, + }, + { + id: "wurzelkanal", + title: "Wurzelkanalbehandlung", + shortDesc: "Schonende Behandlung mit modernsten Verfahren.", + fullDesc: + "Mit modernsten mikroskopischen Verfahren und elektronischer Längenmessung behandeln wir entzündete Zahnwurzeln schonend und erfolgreich.", + icon: "GitBranch", + featured: false, + }, + { + id: "prophylaxe", + title: "Professionelle Zahnreinigung", + shortDesc: "Vorbeugen ist besser als Heilen.", + fullDesc: + "Regelmäßige professionelle Zahnreinigung schützt vor Karies und Parodontitis. Wir entfernen hartnäckige Beläge und polieren Ihre Zähne gründlich.", + icon: "Shield", + featured: true, + }, + { + id: "kinder", + title: "Kinderbehandlung", + shortDesc: "Sanfte Zahnheilkunde für die Kleinsten.", + fullDesc: + "Wir nehmen uns Zeit für Ihre Kinder und sorgen für eine entspannte Atmosphäre. Frühe Gewöhnung an die Zahnarztbesuche legt den Grundstein für lebenslange Zahngesundheit.", + icon: "Baby", + featured: false, + }, + { + id: "aufhellung", + title: "Zahnaufhellung", + shortDesc: "Strahlend weißes Lächeln.", + fullDesc: + "Mit schonenden Bleaching-Verfahren bringen wir Ihr Lächeln zum Strahlen. Wir beraten Sie individuell über die beste Methode für Ihre Zähne.", + icon: "Sun", + featured: false, + }, +] diff --git a/src/layouts/main.astro b/src/layouts/main.astro index 69b80d2..1bca62f 100644 --- a/src/layouts/main.astro +++ b/src/layouts/main.astro @@ -1,15 +1,36 @@ --- import "@/styles/global.css" +import Header from "@/components/layout/Header.astro" +import Footer from "@/components/layout/Footer.astro" +import CookieBanner from "@/components/layout/CookieBanner.astro" +import { siteConfig } from "@/data/site" + +interface Props { + title?: string + description?: string +} + +const { + title = siteConfig.name, + description = "Ihr vertrauensvoller Zahnarzt in Crimmitschau. Moderne Zahnheilkunde mit Herz – von Implantologie bis Kinderbehandlung." +} = Astro.props --- - + + - + + - Astro App + {title} - - + +

+
+ +
+