Compare commits
13 Commits
243978bfdd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61e05910a9 | ||
|
|
cee5f470ad | ||
| 8a4ec60655 | |||
| e039fdf555 | |||
| 3440508bac | |||
| d2ba994fad | |||
| ed74fd0652 | |||
| 41120664f5 | |||
| d92ff8c065 | |||
| 6083844c24 | |||
| 9faca2a859 | |||
| 2032395472 | |||
| 67411ecaff |
BIN
.pnpm-store/v11/index.db
Normal file
BIN
.pnpm-store/v11/index.db
Normal file
Binary file not shown.
65
backlog/tasks/task-2 - Animate-cookie-banner-transitions.md
Normal file
65
backlog/tasks/task-2 - Animate-cookie-banner-transitions.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
id: TASK-2
|
||||||
|
title: Animate cookie banner transitions
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-05-06 18:52'
|
||||||
|
updated_date: '2026-05-06 19:10'
|
||||||
|
labels:
|
||||||
|
- frontend
|
||||||
|
- animation
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Animate the privacy notice transition between the vanilla-cookieconsent banner and the floating cookie button in both directions using the existing Motion dependency, while preserving consent state and accessibility behavior.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 The banner shrinks animatedly into the round cookie button.
|
||||||
|
- [ ] #2 The round cookie button opens animatedly back into the banner.
|
||||||
|
- [x] #3 prefers-reduced-motion receives a reduced fade-only variant.
|
||||||
|
- [x] #4 The production build completes successfully.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Inspect existing cookie animation and test setup.
|
||||||
|
2. Add a focused failing test for the exported animation helpers/guards where practical.
|
||||||
|
3. Implement Motion-based bidirectional banner/FAB transitions with reduced-motion fallback.
|
||||||
|
4. Run focused verification and production build.
|
||||||
|
5. Check acceptance criteria and report for manual testing.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented Motion-based bidirectional transition in src/lib/cookie-banner-info.ts using a ghost element, transform/opacity animation, and a reduced-motion fade path.
|
||||||
|
|
||||||
|
Verified focused cookie animation test passes with node --test tests/cookie-banner-info.test.mjs.
|
||||||
|
|
||||||
|
Verified production build passes with pnpm build.
|
||||||
|
|
||||||
|
Full node --test tests/*.mjs currently has an unrelated existing failure in tests/landing-content.test.mjs because the current landing content no longer contains the expected phrase "Projektbrief".
|
||||||
|
|
||||||
|
Follow-up bugfix: native CookieConsent wrapper could remain briefly visible during the custom open animation, so the temporary ghost and the native modal appeared together. Fixed by suppressing both #cc-main .cm-wrapper and #cc-main .cm during transitions.
|
||||||
|
|
||||||
|
Follow-up bugfix: the accept button listener used a one-shot binding, so after reopening the same modal could close with CookieConsent's native fade instead of the custom banner-to-FAB transition. Fixed by binding accept buttons through a WeakSet without once:true and rebinding after show(true).
|
||||||
|
|
||||||
|
Verified follow-up with node --test tests/cookie-banner-info.test.mjs and pnpm build.
|
||||||
|
|
||||||
|
Follow-up bugfix after manual report: clicking Verstanden could still leave no visible FAB if the Motion close sequence failed or did not resolve. Removed risky borderRadius/padding from the close Motion group, forced the ghost into layout before native modal suppression, and added an idempotent finalizeBannerToFab fallback timer so the FAB is always restored.
|
||||||
|
|
||||||
|
Verified with node --test tests/cookie-banner-info.test.mjs and pnpm build.
|
||||||
|
|
||||||
|
Rolled back the custom cookie circle and Motion transition work to the stable native CookieConsent behavior requested by the user: banner appears initially and clicking Verstanden lets CookieConsent close it normally. Removed custom FAB, ghost, Motion import, and event interception from src/lib/cookie-banner-info.ts.
|
||||||
|
|
||||||
|
Updated the focused cookie test to assert the native CookieConsent close behavior and absence of the custom FAB/Motion code.
|
||||||
|
|
||||||
|
Verified rollback with node --test tests/cookie-banner-info.test.mjs and pnpm build.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
id: TASK-3
|
||||||
|
title: Move right-panel intro above inquiry button
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-05-06 19:20'
|
||||||
|
updated_date: '2026-05-06 19:21'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Reposition the red-panel 01 headline and supporting copy so it sits directly above the Projekt anfragen button.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 The 01 headline and supporting copy appear above the Projekt anfragen button on the right panel
|
||||||
|
- [x] #2 Responsive layout remains intact without overlapping content
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Locate the right-panel intro markup and layout rules
|
||||||
|
2. Reposition the intro block above the CTA while preserving responsive spacing
|
||||||
|
3. Run focused checks and report verification
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Moved the right-panel intro block out of vertical centering and placed it directly above the project inquiry CTA. Removed the CTA mt-auto so it follows the intro block naturally.
|
||||||
|
Verification: pnpm build passed.
|
||||||
|
|
||||||
|
Additional verification: node --test tests/landing-content.test.mjs tests/cookie-banner-info.test.mjs fails because tests/landing-content.test.mjs still expects the removed/renamed phrase Projektbrief. This appears unrelated to the layout-only change; pnpm build remains passing.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
55
backlog/tasks/task-4 - Add-legal-footer-and-pages.md
Normal file
55
backlog/tasks/task-4 - Add-legal-footer-and-pages.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
id: TASK-4
|
||||||
|
title: Add legal footer and pages
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-05-06 19:43'
|
||||||
|
updated_date: '2026-05-06 19:51'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
modified_files:
|
||||||
|
- src/pages/index.astro
|
||||||
|
- src/components/footer27.tsx
|
||||||
|
- src/pages/impressum.astro
|
||||||
|
- src/pages/datenschutz.astro
|
||||||
|
- tests/landing-content.test.mjs
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Bind the existing footer into the landing page and add real Impressum and Datenschutz routes so the legal links resolve correctly.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Landing page renders the footer component below the main landing content
|
||||||
|
- [x] #2 Footer links point to /impressum and /datenschutz
|
||||||
|
- [x] #3 /impressum contains the provided business and VAT ID information
|
||||||
|
- [x] #4 /datenschutz states that no cookies are used and documents the current Rybbit analytics usage
|
||||||
|
- [x] #5 Automated content tests and Astro build pass
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add failing content tests for footer rendering and legal pages
|
||||||
|
2. Implement footer wiring and legal Astro pages
|
||||||
|
3. Run automated tests and build
|
||||||
|
4. Check off verified acceptance criteria and leave task In Progress for user confirmation
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented footer rendering on the landing page and added /impressum plus /datenschutz Astro routes.
|
||||||
|
|
||||||
|
Verified with node --test tests/*.mjs and env CI=true pnpm build. The first sandboxed build attempt failed because pnpm needed network access to recreate node_modules; the escalated build completed successfully.
|
||||||
|
|
||||||
|
Task remains In Progress pending explicit user confirmation before moving to Done.
|
||||||
|
|
||||||
|
Adjusted footer per visual feedback: removed the CTA/contact block and kept only the compact copyright, analytics note, Impressum, and Datenschutz row. Footer now uses the same horizontal page padding as the landing sections instead of max-w-6xl centering.
|
||||||
|
|
||||||
|
Re-verified after the footer adjustment with node --test tests/*.mjs and env CI=true pnpm build.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
id: TASK-5
|
||||||
|
title: Show the webcam toggle only when a webcam is available
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-05-06 20:11'
|
||||||
|
updated_date: '2026-05-07 05:58'
|
||||||
|
labels:
|
||||||
|
- frontend
|
||||||
|
- fallback
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Detect whether the browser reports an available webcam and only render the hero live-raster switch for visitors who can actually use the camera effect.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 The hero checks browser media devices for at least one video input without prompting for camera permission.
|
||||||
|
- [x] #2 The webcam switch and helper copy are hidden when no video input is reported or media-device enumeration is unavailable.
|
||||||
|
- [x] #3 The switch still starts and stops the existing webcam pixel grid for visitors with an available webcam.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Inspect current hero toggle and WebcamPixelGrid error behavior
|
||||||
|
2. Detect webcam availability with browser media-device enumeration
|
||||||
|
3. Hide the helper copy and switch when no video input is available
|
||||||
|
4. Preserve the existing webcam start/stop flow when a camera exists
|
||||||
|
5. Verify with build and update acceptance criteria
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented a hero fallback raster that activates when WebcamPixelGrid reports unavailable or denied camera access.
|
||||||
|
|
||||||
|
Added an explicit mediaDevices/getUserMedia availability check before requesting camera access.
|
||||||
|
|
||||||
|
Verified with npm run build; Astro built 3 static pages successfully. Dev server is running at http://127.0.0.1:4322/ for manual testing.
|
||||||
|
|
||||||
|
Changed direction after feedback: removed the visual fallback and now hide the switch unless enumerateDevices reports a videoinput.
|
||||||
|
|
||||||
|
Added a devicechange listener so the switch can appear or disappear if camera hardware availability changes during the session.
|
||||||
|
|
||||||
|
Verified the revised behavior compiles with npm run build.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
id: TASK-6
|
||||||
|
title: Modularisiere die Landingpage und entferne ungenutzte Komponenten
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-05-07 06:21'
|
||||||
|
updated_date: '2026-05-07 06:23'
|
||||||
|
labels:
|
||||||
|
- refactor
|
||||||
|
- frontend
|
||||||
|
dependencies: []
|
||||||
|
modified_files:
|
||||||
|
- src/pages/index.astro
|
||||||
|
- tests/landing-content.test.mjs
|
||||||
|
- src/components/landing-page-sections.tsx
|
||||||
|
- src/components/landing/services-section.tsx
|
||||||
|
- src/components/landing/deliverables-section.tsx
|
||||||
|
- src/components/landing/packages-section.tsx
|
||||||
|
- src/components/landing/contact-section.tsx
|
||||||
|
- src/components/landing.tsx
|
||||||
|
- src/components/about19.tsx
|
||||||
|
- src/components/contact21.tsx
|
||||||
|
- src/components/cta.tsx
|
||||||
|
- src/components/faq7.tsx
|
||||||
|
- src/components/feature284.tsx
|
||||||
|
- src/components/hero235.tsx
|
||||||
|
- src/components/pricing4.tsx
|
||||||
|
- src/components/stats11.tsx
|
||||||
|
- src/components/ui/accordion.tsx
|
||||||
|
- src/components/ui/badge.tsx
|
||||||
|
- src/components/ui/button.tsx
|
||||||
|
- src/components/ui/field.tsx
|
||||||
|
- src/components/ui/glowing-effect.tsx
|
||||||
|
- src/components/ui/input.tsx
|
||||||
|
- src/components/ui/label.tsx
|
||||||
|
- src/components/ui/separator.tsx
|
||||||
|
- src/components/ui/tabs.tsx
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Die bestehende Landingpage soll in klarere, kleinere Komponenten aufgeteilt werden. Nicht mehr referenzierte Komponenten sollen entfernt werden, ohne vorhandene Nutzer- oder laufende Änderungen zurückzudrehen.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Die Hauptseite nutzt klar benannte modulare Komponenten statt einer monolithischen LandingRest-Komponente.
|
||||||
|
- [x] #2 Nicht benötigte Komponenten im Komponentenordner sind entfernt oder nicht mehr Teil der Codebasis.
|
||||||
|
- [x] #3 Build und vorhandene Tests laufen nach der Änderung ohne Fehler.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Bestehende Landingpage-Struktur und Import-Verwendung prüfen.
|
||||||
|
2. LandingRest in klar benannte Sektionen extrahieren und Index-Import aktualisieren.
|
||||||
|
3. Nicht referenzierte Template- und UI-Komponenten entfernen.
|
||||||
|
4. Tests an neue Struktur anpassen und Build/Test ausführen.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
LandingRest wurde in ServicesSection, DeliverablesSection, PackagesSection und ContactSection extrahiert; index.astro nutzt nun LandingPageSections.
|
||||||
|
|
||||||
|
Nicht referenzierte Template-Komponenten sowie deren ungenutzte UI-Hilfskomponenten wurden entfernt. Vorbestehende Änderungen an landing-hero-section.tsx und ui/webcam-pixel-grid.tsx blieben unangetastet.
|
||||||
|
|
||||||
|
Verifikation: node --test tests/*.mjs und pnpm build laufen erfolgreich.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
"rtl": false,
|
"rtl": false,
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
@@ -19,14 +21,14 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"menuColor": "default",
|
|
||||||
"menuAccent": "subtle",
|
|
||||||
"registries": {
|
"registries": {
|
||||||
"@shadcnblocks": {
|
"@shadcnblocks": {
|
||||||
"url": "https://shadcnblocks.com/r/{name}",
|
"url": "https://shadcnblocks.com/r/{name}",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}"
|
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"@aceternity": "https://ui.aceternity.com/registry/{name}.json",
|
||||||
|
"@mapcn": "https://mapcn.dev/r/{name}.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,12 @@
|
|||||||
### Task 2: Canva-Inspired Landing Page
|
### Task 2: Canva-Inspired Landing Page
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `src/components/canva-landing.tsx`
|
- Create: `src/components/landing.tsx`
|
||||||
- Modify: `src/pages/index.astro`
|
- Modify: `src/pages/index.astro`
|
||||||
- Modify: `src/styles/global.css`
|
- Modify: `src/styles/global.css`
|
||||||
|
|
||||||
- [ ] Build a single-page layout with a dark editorial shell, red accent panels, large German headline, numbered sections, pricing/service strips, and a contact brief section.
|
- [ ] Build a single-page layout with a dark editorial shell, red accent panels, large German headline, numbered sections, pricing/service strips, and a contact brief section.
|
||||||
- [ ] Replace the existing homepage component stack with the new `CanvaLanding` component.
|
- [ ] Replace the existing homepage component stack with the new `Landing` component.
|
||||||
- [ ] Update global tokens for the dark, high-contrast Canva reference style.
|
- [ ] Update global tokens for the dark, high-contrast Canva reference style.
|
||||||
|
|
||||||
### Task 3: Verification
|
### Task 3: Verification
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
"maplibre-gl": "^5.24.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.3",
|
"tailwindcss": "^4.2.3",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"vanilla-cookieconsent": "^3.1.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
198
pnpm-lock.yaml
generated
198
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.8.0
|
specifier: ^1.8.0
|
||||||
version: 1.8.0(react@19.2.5)
|
version: 1.8.0(react@19.2.5)
|
||||||
|
maplibre-gl:
|
||||||
|
specifier: ^5.24.0
|
||||||
|
version: 5.24.0
|
||||||
motion:
|
motion:
|
||||||
specifier: ^12.38.0
|
specifier: ^12.38.0
|
||||||
version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
@@ -62,6 +65,9 @@ importers:
|
|||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
vanilla-cookieconsent:
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -654,6 +660,42 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@mapbox/jsonlint-lines-primitives@2.0.2':
|
||||||
|
resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
'@mapbox/point-geometry@1.1.0':
|
||||||
|
resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==}
|
||||||
|
|
||||||
|
'@mapbox/tiny-sdf@2.2.0':
|
||||||
|
resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==}
|
||||||
|
|
||||||
|
'@mapbox/unitbezier@0.0.1':
|
||||||
|
resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==}
|
||||||
|
|
||||||
|
'@mapbox/vector-tile@2.0.4':
|
||||||
|
resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==}
|
||||||
|
|
||||||
|
'@mapbox/whoots-js@3.1.0':
|
||||||
|
resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
'@maplibre/geojson-vt@5.0.4':
|
||||||
|
resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==}
|
||||||
|
|
||||||
|
'@maplibre/geojson-vt@6.1.0':
|
||||||
|
resolution: {integrity: sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==}
|
||||||
|
|
||||||
|
'@maplibre/maplibre-gl-style-spec@24.8.5':
|
||||||
|
resolution: {integrity: sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@maplibre/mlt@1.1.9':
|
||||||
|
resolution: {integrity: sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==}
|
||||||
|
|
||||||
|
'@maplibre/vt-pbf@4.3.0':
|
||||||
|
resolution: {integrity: sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==}
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.29.0':
|
'@modelcontextprotocol/sdk@1.29.0':
|
||||||
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
|
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1703,6 +1745,9 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16':
|
||||||
|
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||||
|
|
||||||
'@types/hast@3.0.4':
|
'@types/hast@3.0.4':
|
||||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||||
|
|
||||||
@@ -1732,6 +1777,9 @@ packages:
|
|||||||
'@types/statuses@2.0.6':
|
'@types/statuses@2.0.6':
|
||||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||||
|
|
||||||
|
'@types/supercluster@7.1.3':
|
||||||
|
resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==}
|
||||||
|
|
||||||
'@types/unist@3.0.3':
|
'@types/unist@3.0.3':
|
||||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||||
|
|
||||||
@@ -1740,6 +1788,7 @@ packages:
|
|||||||
|
|
||||||
'@ungap/structured-clone@1.3.0':
|
'@ungap/structured-clone@1.3.0':
|
||||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||||
|
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.2.0':
|
'@vitejs/plugin-react@5.2.0':
|
||||||
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||||
@@ -2108,6 +2157,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
earcut@3.0.2:
|
||||||
|
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
|
||||||
|
|
||||||
eciesjs@0.4.18:
|
eciesjs@0.4.18:
|
||||||
resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==}
|
resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==}
|
||||||
engines: {bun: '>=1', deno: '>=2', node: '>=16'}
|
engines: {bun: '>=1', deno: '>=2', node: '>=16'}
|
||||||
@@ -2369,6 +2421,9 @@ packages:
|
|||||||
github-slugger@2.0.0:
|
github-slugger@2.0.0:
|
||||||
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
|
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
|
||||||
|
|
||||||
|
gl-matrix@3.4.4:
|
||||||
|
resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
|
||||||
|
|
||||||
glob-parent@5.1.2:
|
glob-parent@5.1.2:
|
||||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -2598,6 +2653,9 @@ packages:
|
|||||||
json-schema-typed@8.0.2:
|
json-schema-typed@8.0.2:
|
||||||
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
||||||
|
|
||||||
|
json-stringify-pretty-compact@4.0.0:
|
||||||
|
resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==}
|
||||||
|
|
||||||
json5@2.2.3:
|
json5@2.2.3:
|
||||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2606,6 +2664,9 @@ packages:
|
|||||||
jsonfile@6.2.1:
|
jsonfile@6.2.1:
|
||||||
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
|
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
|
||||||
|
|
||||||
|
kdbush@4.0.2:
|
||||||
|
resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==}
|
||||||
|
|
||||||
kleur@3.0.3:
|
kleur@3.0.3:
|
||||||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2716,6 +2777,10 @@ packages:
|
|||||||
magicast@0.5.2:
|
magicast@0.5.2:
|
||||||
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
|
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
|
||||||
|
|
||||||
|
maplibre-gl@5.24.0:
|
||||||
|
resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==}
|
||||||
|
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
|
||||||
|
|
||||||
markdown-table@3.0.4:
|
markdown-table@3.0.4:
|
||||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||||
|
|
||||||
@@ -2931,6 +2996,9 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
murmurhash-js@1.0.0:
|
||||||
|
resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
|
||||||
|
|
||||||
mute-stream@3.0.0:
|
mute-stream@3.0.0:
|
||||||
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
@@ -3091,6 +3159,10 @@ packages:
|
|||||||
path-to-regexp@8.4.2:
|
path-to-regexp@8.4.2:
|
||||||
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
||||||
|
|
||||||
|
pbf@4.0.1:
|
||||||
|
resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
piccolore@0.1.3:
|
piccolore@0.1.3:
|
||||||
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
|
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
|
||||||
|
|
||||||
@@ -3117,6 +3189,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
potpack@2.1.0:
|
||||||
|
resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
|
||||||
|
|
||||||
powershell-utils@0.1.0:
|
powershell-utils@0.1.0:
|
||||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -3136,6 +3211,9 @@ packages:
|
|||||||
property-information@7.1.0:
|
property-information@7.1.0:
|
||||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||||
|
|
||||||
|
protocol-buffers-schema@3.6.1:
|
||||||
|
resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
|
||||||
|
|
||||||
proxy-addr@2.0.7:
|
proxy-addr@2.0.7:
|
||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -3147,6 +3225,9 @@ packages:
|
|||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
quickselect@3.0.0:
|
||||||
|
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
|
||||||
|
|
||||||
radix-ui@1.4.3:
|
radix-ui@1.4.3:
|
||||||
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
|
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3277,6 +3358,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
resolve-protobuf-schema@2.1.0:
|
||||||
|
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
|
||||||
|
|
||||||
restore-cursor@5.1.0:
|
restore-cursor@5.1.0:
|
||||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3456,6 +3540,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
|
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
supercluster@8.0.1:
|
||||||
|
resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==}
|
||||||
|
|
||||||
svgo@4.0.1:
|
svgo@4.0.1:
|
||||||
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
|
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -3493,6 +3580,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinyqueue@3.0.0:
|
||||||
|
resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==}
|
||||||
|
|
||||||
tldts-core@7.0.28:
|
tldts-core@7.0.28:
|
||||||
resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
|
resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
|
||||||
|
|
||||||
@@ -3709,6 +3799,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==}
|
resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==}
|
||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
|
|
||||||
|
vanilla-cookieconsent@3.1.0:
|
||||||
|
resolution: {integrity: sha512-/McNRtm/3IXzb9dhqMIcbquoU45SzbN2VB+To4jxEPqMmp7uVniP6BhGLjU8MC7ZCDsNQVOp27fhQTM/ruIXAA==}
|
||||||
|
|
||||||
vary@1.1.2:
|
vary@1.1.2:
|
||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -4405,6 +4498,51 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@mapbox/jsonlint-lines-primitives@2.0.2': {}
|
||||||
|
|
||||||
|
'@mapbox/point-geometry@1.1.0': {}
|
||||||
|
|
||||||
|
'@mapbox/tiny-sdf@2.2.0': {}
|
||||||
|
|
||||||
|
'@mapbox/unitbezier@0.0.1': {}
|
||||||
|
|
||||||
|
'@mapbox/vector-tile@2.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@mapbox/point-geometry': 1.1.0
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
pbf: 4.0.1
|
||||||
|
|
||||||
|
'@mapbox/whoots-js@3.1.0': {}
|
||||||
|
|
||||||
|
'@maplibre/geojson-vt@5.0.4': {}
|
||||||
|
|
||||||
|
'@maplibre/geojson-vt@6.1.0':
|
||||||
|
dependencies:
|
||||||
|
kdbush: 4.0.2
|
||||||
|
|
||||||
|
'@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
|
||||||
|
|
||||||
|
'@maplibre/mlt@1.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@mapbox/point-geometry': 1.1.0
|
||||||
|
|
||||||
|
'@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
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
|
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hono/node-server': 1.19.14(hono@4.12.14)
|
'@hono/node-server': 1.19.14(hono@4.12.14)
|
||||||
@@ -5448,6 +5586,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
'@types/hast@3.0.4':
|
'@types/hast@3.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
@@ -5480,6 +5620,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/statuses@2.0.6': {}
|
'@types/statuses@2.0.6': {}
|
||||||
|
|
||||||
|
'@types/supercluster@7.1.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
|
|
||||||
'@types/validate-npm-package-name@4.0.2': {}
|
'@types/validate-npm-package-name@4.0.2': {}
|
||||||
@@ -5885,6 +6029,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
earcut@3.0.2: {}
|
||||||
|
|
||||||
eciesjs@0.4.18:
|
eciesjs@0.4.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0)
|
'@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0)
|
||||||
@@ -6184,6 +6330,8 @@ snapshots:
|
|||||||
|
|
||||||
github-slugger@2.0.0: {}
|
github-slugger@2.0.0: {}
|
||||||
|
|
||||||
|
gl-matrix@3.4.4: {}
|
||||||
|
|
||||||
glob-parent@5.1.2:
|
glob-parent@5.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@@ -6418,6 +6566,8 @@ snapshots:
|
|||||||
|
|
||||||
json-schema-typed@8.0.2: {}
|
json-schema-typed@8.0.2: {}
|
||||||
|
|
||||||
|
json-stringify-pretty-compact@4.0.0: {}
|
||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
jsonfile@6.2.1:
|
jsonfile@6.2.1:
|
||||||
@@ -6426,6 +6576,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
|
kdbush@4.0.2: {}
|
||||||
|
|
||||||
kleur@3.0.3: {}
|
kleur@3.0.3: {}
|
||||||
|
|
||||||
kleur@4.1.5: {}
|
kleur@4.1.5: {}
|
||||||
@@ -6508,6 +6660,28 @@ snapshots:
|
|||||||
'@babel/types': 7.29.0
|
'@babel/types': 7.29.0
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
maplibre-gl@5.24.0:
|
||||||
|
dependencies:
|
||||||
|
'@mapbox/jsonlint-lines-primitives': 2.0.2
|
||||||
|
'@mapbox/point-geometry': 1.1.0
|
||||||
|
'@mapbox/tiny-sdf': 2.2.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.5
|
||||||
|
'@maplibre/mlt': 1.1.9
|
||||||
|
'@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
|
||||||
|
|
||||||
markdown-table@3.0.4: {}
|
markdown-table@3.0.4: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
@@ -6897,6 +7071,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
|
|
||||||
|
murmurhash-js@1.0.0: {}
|
||||||
|
|
||||||
mute-stream@3.0.0: {}
|
mute-stream@3.0.0: {}
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
@@ -7052,6 +7228,10 @@ snapshots:
|
|||||||
|
|
||||||
path-to-regexp@8.4.2: {}
|
path-to-regexp@8.4.2: {}
|
||||||
|
|
||||||
|
pbf@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
resolve-protobuf-schema: 2.1.0
|
||||||
|
|
||||||
piccolore@0.1.3: {}
|
piccolore@0.1.3: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
@@ -7073,6 +7253,8 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
potpack@2.1.0: {}
|
||||||
|
|
||||||
powershell-utils@0.1.0: {}
|
powershell-utils@0.1.0: {}
|
||||||
|
|
||||||
pretty-ms@9.3.0:
|
pretty-ms@9.3.0:
|
||||||
@@ -7088,6 +7270,8 @@ snapshots:
|
|||||||
|
|
||||||
property-information@7.1.0: {}
|
property-information@7.1.0: {}
|
||||||
|
|
||||||
|
protocol-buffers-schema@3.6.1: {}
|
||||||
|
|
||||||
proxy-addr@2.0.7:
|
proxy-addr@2.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
@@ -7099,6 +7283,8 @@ snapshots:
|
|||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
|
quickselect@3.0.0: {}
|
||||||
|
|
||||||
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -7305,6 +7491,10 @@ snapshots:
|
|||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
||||||
|
resolve-protobuf-schema@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
protocol-buffers-schema: 3.6.1
|
||||||
|
|
||||||
restore-cursor@5.1.0:
|
restore-cursor@5.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
onetime: 7.0.0
|
onetime: 7.0.0
|
||||||
@@ -7602,6 +7792,10 @@ snapshots:
|
|||||||
|
|
||||||
strip-final-newline@4.0.0: {}
|
strip-final-newline@4.0.0: {}
|
||||||
|
|
||||||
|
supercluster@8.0.1:
|
||||||
|
dependencies:
|
||||||
|
kdbush: 4.0.2
|
||||||
|
|
||||||
svgo@4.0.1:
|
svgo@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 11.1.0
|
commander: 11.1.0
|
||||||
@@ -7633,6 +7827,8 @@ snapshots:
|
|||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tinyqueue@3.0.0: {}
|
||||||
|
|
||||||
tldts-core@7.0.28: {}
|
tldts-core@7.0.28: {}
|
||||||
|
|
||||||
tldts@7.0.28:
|
tldts@7.0.28:
|
||||||
@@ -7794,6 +7990,8 @@ snapshots:
|
|||||||
|
|
||||||
validate-npm-package-name@7.0.2: {}
|
validate-npm-package-name@7.0.2: {}
|
||||||
|
|
||||||
|
vanilla-cookieconsent@3.1.0: {}
|
||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
vfile-location@5.0.3:
|
vfile-location@5.0.3:
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- "."
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
msw: true
|
msw: true
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface About19Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const About19 = ({ className }: About19Props) => {
|
|
||||||
return (
|
|
||||||
<section className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}>
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.88fr)] lg:items-start lg:gap-14">
|
|
||||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
|
||||||
<img
|
|
||||||
src="/about.jpg"
|
|
||||||
alt="Matthias Meister bei der Webentwicklung"
|
|
||||||
className="h-[20rem] w-full object-cover sm:h-[28rem] lg:h-[34rem]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
|
||||||
Über die Zusammenarbeit
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
|
||||||
Hallo, ich bin Matthias. Zurück in der Region und hier, um zu
|
|
||||||
bleiben.
|
|
||||||
</h2>
|
|
||||||
<div className="mt-8 space-y-5 text-base leading-8 text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
Ich bin in der Region aufgewachsen, war durch die Bundeswehr
|
|
||||||
viele Jahre weg und bin jetzt zurück.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Seit über 15 Jahren beschäftige ich mich mit Webentwicklung und
|
|
||||||
Software. Einen Großteil davon intern für die Bundeswehr:
|
|
||||||
Projekte, die ich Ihnen leider nicht zeigen kann. Was ich Ihnen
|
|
||||||
zeigen kann: wie ich arbeite. Zuverlässig, präzise und ohne
|
|
||||||
unnötigen Schnickschnack.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Neben Websites für regionale Unternehmen entwickle ich eigene
|
|
||||||
Software und Apps. Wenn Ihre Anforderungen irgendwann über eine
|
|
||||||
einfache Website hinausgehen, bleibt der Ansprechpartner also
|
|
||||||
derselbe.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-8 rounded-lg border border-border bg-card p-5 text-base font-medium leading-7 text-foreground">
|
|
||||||
Mein Ziel: Unternehmen aus der Region mit einer Website
|
|
||||||
ausstatten, die funktioniert, gefunden wird und Ihnen keine
|
|
||||||
Kopfschmerzen macht.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { About19 };
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
import {
|
|
||||||
ArrowUpRight,
|
|
||||||
Check,
|
|
||||||
CornerDownRight,
|
|
||||||
Mail,
|
|
||||||
MapPin,
|
|
||||||
Phone,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const services = [
|
|
||||||
{
|
|
||||||
number: "01",
|
|
||||||
title: "Website",
|
|
||||||
text: "Eine klare Startseite oder ein kompletter Auftritt, der sofort zeigt, warum man Ihnen vertrauen kann.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: "02",
|
|
||||||
title: "Struktur",
|
|
||||||
text: "Angebot, Beweise, Ablauf und Kontakt werden so sortiert, dass Besucher nicht suchen müssen.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: "03",
|
|
||||||
title: "Technik",
|
|
||||||
text: "Schnell, mobil sauber, DSGVO-arm und so gebaut, dass spätere Änderungen nicht zum Projekt werden.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const deliverables = [
|
|
||||||
"Strategie und Seitenstruktur",
|
|
||||||
"Individuelles Screen-Design",
|
|
||||||
"Astro/React Umsetzung",
|
|
||||||
"Kontaktformular und Datenschutz",
|
|
||||||
"Hosting, Wartung und Analytics",
|
|
||||||
];
|
|
||||||
|
|
||||||
const packages = [
|
|
||||||
{
|
|
||||||
name: "Basis",
|
|
||||||
price: "799 EUR",
|
|
||||||
detail: "Eine starke Seite für ein klares Angebot.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Profi",
|
|
||||||
price: "1.499 EUR",
|
|
||||||
detail: "Mehrere Seiten für Betriebe mit erklärungsbedürftigem Angebot.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Maßarbeit",
|
|
||||||
price: "2.499 EUR+",
|
|
||||||
detail: "Individuelle Struktur, CMS und besondere Anforderungen.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CanvaLanding = () => {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen overflow-hidden bg-background text-foreground">
|
|
||||||
<section className="relative grid min-h-screen grid-cols-1 border-b border-border lg:grid-cols-[1.08fr_0.92fr]">
|
|
||||||
<div className="flex min-h-[640px] flex-col justify-between px-5 py-5 sm:px-8 lg:px-12">
|
|
||||||
<header className="grid gap-4 border-b border-border pb-5 text-xs uppercase tracking-[0.28em] text-muted-foreground sm:grid-cols-[1fr_auto]">
|
|
||||||
<a href="/" className="font-semibold text-foreground">
|
|
||||||
Matthias Meister
|
|
||||||
</a>
|
|
||||||
<nav className="flex flex-wrap gap-5">
|
|
||||||
<a href="#leistungen">Leistungen</a>
|
|
||||||
<a href="#pakete">Pakete</a>
|
|
||||||
<a href="#kontakt">Kontakt</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="max-w-5xl py-16 sm:py-20 lg:py-24">
|
|
||||||
<p className="mb-6 max-w-sm text-sm uppercase tracking-[0.32em] text-primary">
|
|
||||||
Projektbrief für regionale Unternehmen
|
|
||||||
</p>
|
|
||||||
<h1 className="max-w-[11ch] text-[clamp(4.25rem,13vw,11.5rem)] font-black uppercase leading-[0.78] tracking-normal text-foreground">
|
|
||||||
Website ohne Umweg
|
|
||||||
</h1>
|
|
||||||
<div className="mt-8 grid gap-7 border-t border-border pt-7 lg:grid-cols-[0.72fr_1fr]">
|
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-muted-foreground">
|
|
||||||
Strategie trifft Umsetzung
|
|
||||||
</p>
|
|
||||||
<p className="max-w-2xl text-lg leading-8 text-foreground/78">
|
|
||||||
Ich baue Websites für Handwerk, Praxen, Salons und
|
|
||||||
Dienstleister aus der Region. Direkt, glaubwürdig und so
|
|
||||||
reduziert, dass der nächste Kontakt naheliegt.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 border-t border-border pt-5 text-sm uppercase tracking-[0.2em] text-muted-foreground sm:grid-cols-3">
|
|
||||||
<span>Antwort in 24h</span>
|
|
||||||
<span>DSGVO-arm</span>
|
|
||||||
<span>Hosting aus DE</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="relative flex min-h-[520px] flex-col justify-between border-t border-primary-foreground/25 bg-primary px-5 py-5 text-primary-foreground sm:px-8 lg:border-l lg:border-t-0 lg:px-12">
|
|
||||||
<div className="flex items-start justify-between border-b border-primary-foreground/30 pb-5 text-xs uppercase tracking-[0.28em]">
|
|
||||||
<span>Creative Direction</span>
|
|
||||||
<span>2026</span>
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-32 right-10 h-36 w-36 border border-primary-foreground/35" />
|
|
||||||
<div className="relative mt-28 max-w-xl">
|
|
||||||
<p className="text-[clamp(4rem,10vw,9rem)] font-black uppercase leading-[0.75] tracking-normal">
|
|
||||||
01
|
|
||||||
</p>
|
|
||||||
<p className="mt-7 max-w-sm text-2xl font-semibold uppercase leading-none">
|
|
||||||
Klarer Auftritt. Harte Kante. Weniger Agenturlärm.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href="#kontakt"
|
|
||||||
className="group inline-flex w-fit items-center gap-3 border border-primary-foreground px-5 py-4 text-sm font-semibold uppercase tracking-[0.18em] transition hover:bg-primary-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
Projekt anfragen
|
|
||||||
<ArrowUpRight className="size-5 transition group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
|
|
||||||
</a>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
id="leistungen"
|
|
||||||
className="grid border-b border-border lg:grid-cols-[0.36fr_0.64fr]"
|
|
||||||
>
|
|
||||||
<div className="border-b border-border px-5 py-12 sm:px-8 lg:border-b-0 lg:border-r lg:px-12 lg:py-20">
|
|
||||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
|
||||||
Leistungen (02)
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
|
||||||
Vom Brief zur Seite
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{services.map((service) => (
|
|
||||||
<article
|
|
||||||
key={service.number}
|
|
||||||
className="grid gap-8 px-5 py-10 sm:px-8 md:grid-cols-[7rem_0.4fr_1fr] lg:px-12"
|
|
||||||
>
|
|
||||||
<span className="text-5xl font-black text-primary">
|
|
||||||
{service.number}
|
|
||||||
</span>
|
|
||||||
<h3 className="text-3xl font-black uppercase leading-none">
|
|
||||||
{service.title}
|
|
||||||
</h3>
|
|
||||||
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
|
||||||
{service.text}
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid border-b border-border lg:grid-cols-2">
|
|
||||||
<div className="bg-foreground px-5 py-14 text-background sm:px-8 lg:px-12 lg:py-24">
|
|
||||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
|
||||||
Deliverables (03)
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-8 max-w-[10ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
|
||||||
Was am Ende steht
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="grid content-center gap-4 px-5 py-12 sm:px-8 lg:px-12">
|
|
||||||
{deliverables.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item}
|
|
||||||
className="flex items-center gap-4 border-b border-border pb-4 text-lg font-semibold uppercase tracking-[0.08em]"
|
|
||||||
>
|
|
||||||
<Check className="size-5 text-primary" />
|
|
||||||
<span>{item}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="pakete" className="border-b border-border px-5 py-14 sm:px-8 lg:px-12 lg:py-24">
|
|
||||||
<div className="grid gap-8 lg:grid-cols-[0.45fr_0.55fr]">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
|
||||||
Pakete (04)
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-8 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
|
||||||
Kosten ohne Nebel
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{packages.map((item) => (
|
|
||||||
<article
|
|
||||||
key={item.name}
|
|
||||||
className="grid gap-4 border border-border p-5 sm:grid-cols-[0.5fr_0.5fr] sm:p-6"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-primary">
|
|
||||||
{item.name}
|
|
||||||
</p>
|
|
||||||
<p className="mt-4 text-4xl font-black uppercase">
|
|
||||||
{item.price}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="self-end text-lg leading-7 text-muted-foreground">
|
|
||||||
{item.detail}
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
id="kontakt"
|
|
||||||
className="grid min-h-[620px] lg:grid-cols-[0.72fr_0.28fr]"
|
|
||||||
>
|
|
||||||
<div className="px-5 py-14 sm:px-8 lg:px-12 lg:py-24">
|
|
||||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
|
||||||
Kontakt (05)
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-8 max-w-[12ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl lg:text-8xl">
|
|
||||||
Erzählen Sie mir kurz vom Projekt
|
|
||||||
</h2>
|
|
||||||
<p className="mt-8 max-w-2xl text-xl leading-8 text-muted-foreground">
|
|
||||||
Ein paar Sätze reichen: Was bieten Sie an, was soll die Website
|
|
||||||
leisten, und wann soll sie online sein?
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="mailto:hallo@matthias-meister.com"
|
|
||||||
className="mt-10 inline-flex items-center gap-3 bg-primary px-6 py-5 text-sm font-black uppercase tracking-[0.18em] text-primary-foreground transition hover:bg-foreground hover:text-background"
|
|
||||||
>
|
|
||||||
<CornerDownRight className="size-5" />
|
|
||||||
Anfrage per Mail senden
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col justify-end gap-6 bg-primary px-5 py-10 text-primary-foreground sm:px-8 lg:px-10">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Mail className="size-5" />
|
|
||||||
<span>hallo@matthias-meister.com</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Phone className="size-5" />
|
|
||||||
<span>Rückmeldung innerhalb von 24 Stunden</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<MapPin className="size-5" />
|
|
||||||
<span>Regionale KMU in Deutschland</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { CanvaLanding };
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { CornerDownRight, LoaderIcon } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from "@/components/ui/field";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const contactFormSchema = z.object({
|
|
||||||
name: z.string().min(1, "Bitte geben Sie Ihren Namen ein"),
|
|
||||||
email: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Bitte geben Sie Ihre E-Mail ein")
|
|
||||||
.email("Bitte geben Sie eine gültige E-Mail ein"),
|
|
||||||
message: z.string().min(1, "Bitte beschreiben Sie kurz Ihr Anliegen"),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ContactFormData = z.infer<typeof contactFormSchema>;
|
|
||||||
|
|
||||||
interface Contact21Props {
|
|
||||||
className?: string;
|
|
||||||
onSubmit?: (data: ContactFormData) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Contact21 = ({ className, onSubmit }: Contact21Props) => {
|
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
||||||
const [showSuccess, setShowSuccess] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<ContactFormData>({
|
|
||||||
resolver: zodResolver(contactFormSchema),
|
|
||||||
mode: "onSubmit",
|
|
||||||
reValidateMode: "onSubmit",
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
email: "",
|
|
||||||
message: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleFormSubmit = async (data: ContactFormData) => {
|
|
||||||
try {
|
|
||||||
if (onSubmit) {
|
|
||||||
await onSubmit(data);
|
|
||||||
} else {
|
|
||||||
console.log("Form submitted:", data);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
setIsSubmitted(true);
|
|
||||||
setShowSuccess(true);
|
|
||||||
form.reset();
|
|
||||||
setTimeout(() => setShowSuccess(false), 4500);
|
|
||||||
setTimeout(() => setIsSubmitted(false), 5000);
|
|
||||||
} catch {
|
|
||||||
form.setError("root", {
|
|
||||||
message: "Beim Senden ist etwas schiefgelaufen. Bitte versuchen Sie es erneut.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="kontakt"
|
|
||||||
className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}
|
|
||||||
>
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<div className="grid gap-10 rounded-lg border border-border bg-card p-5 sm:p-8 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:gap-14 lg:p-10">
|
|
||||||
<div className="flex w-full max-w-lg flex-col justify-between gap-10">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
|
||||||
Kontakt
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
|
||||||
Erzählen Sie kurz, worum es geht.
|
|
||||||
</h2>
|
|
||||||
<p className="mt-5 text-base leading-7 text-muted-foreground">
|
|
||||||
Ich melde mich innerhalb von 24 Stunden mit einer ersten
|
|
||||||
Einschätzung und dem passenden nächsten Schritt.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex size-12 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground">
|
|
||||||
MM
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium tracking-tight">
|
|
||||||
Matthias Meister
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-foreground/40">
|
|
||||||
Freelance Webdesigner
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
{isSubmitted && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"mb-4 rounded-lg border border-primary/20 bg-primary/10 p-4 text-center transition-opacity duration-500",
|
|
||||||
showSuccess ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium text-primary">
|
|
||||||
Vielen Dank! Ich melde mich in Kürze bei Ihnen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(handleFormSubmit)}>
|
|
||||||
<FieldGroup className="gap-0">
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field data-invalid={fieldState.invalid}>
|
|
||||||
<FieldLabel htmlFor={field.name} className="sr-only">
|
|
||||||
Name
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
id={field.name}
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
placeholder="Ihr Name*"
|
|
||||||
className="h-14 rounded-none border-0 border-b border-b-border bg-transparent! px-0 shadow-none placeholder:text-muted-foreground/65 focus-visible:border-b-primary focus-visible:ring-0 lg:text-base"
|
|
||||||
/>
|
|
||||||
{fieldState.invalid && (
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field data-invalid={fieldState.invalid}>
|
|
||||||
<FieldLabel htmlFor={field.name} className="sr-only">
|
|
||||||
E-Mail
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
id={field.name}
|
|
||||||
type="email"
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
placeholder="Ihre E-Mail*"
|
|
||||||
className="h-14 rounded-none border-0 border-b border-b-border bg-transparent! px-0 shadow-none placeholder:text-muted-foreground/65 focus-visible:border-b-primary focus-visible:ring-0 lg:text-base"
|
|
||||||
/>
|
|
||||||
{fieldState.invalid && (
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="message"
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<Field data-invalid={fieldState.invalid}>
|
|
||||||
<FieldLabel htmlFor={field.name} className="sr-only">
|
|
||||||
Nachricht
|
|
||||||
</FieldLabel>
|
|
||||||
<textarea
|
|
||||||
{...field}
|
|
||||||
id={field.name}
|
|
||||||
aria-invalid={fieldState.invalid}
|
|
||||||
placeholder="Nachricht: Worum geht es bei Ihrem Projekt?"
|
|
||||||
rows={4}
|
|
||||||
className="min-h-36 w-full rounded-none border-0 border-b border-b-border bg-transparent px-0 py-4 text-base text-foreground shadow-none outline-none placeholder:text-muted-foreground/65 focus-visible:border-b-primary focus-visible:ring-0 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive lg:text-base"
|
|
||||||
/>
|
|
||||||
{fieldState.invalid && (
|
|
||||||
<FieldError errors={[fieldState.error]} />
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{form.formState.errors.root && (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{form.formState.errors.root.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="mt-8 flex h-11 w-full items-center justify-center gap-2 rounded-md px-6 lg:w-fit lg:text-base"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
{form.formState.isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<LoaderIcon className="size-5 animate-spin" />
|
|
||||||
Wird gesendet...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CornerDownRight className="size-5" />
|
|
||||||
Anfrage senden
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</FieldGroup>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Contact21 };
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const trustAnchors = [
|
|
||||||
{
|
|
||||||
title: "Direkter Kontakt",
|
|
||||||
description:
|
|
||||||
"Sie sprechen mit dem Menschen, der die Website auch plant und baut.",
|
|
||||||
note: "Keine Vertriebsrunde, keine unklaren Übergänge.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "15+ Jahre Erfahrung",
|
|
||||||
description:
|
|
||||||
"Webentwicklung und Software mit Fokus auf robuste, wartbare Lösungen.",
|
|
||||||
note: "Praxis statt Buzzwords und Technik nur dort, wo sie wirklich hilft.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Hosting in Sachsen",
|
|
||||||
description:
|
|
||||||
"Deutsche Server, DSGVO-konform und passend für regionale Unternehmen.",
|
|
||||||
note: "Greifbar, nachvollziehbar und ohne unnötiges Zusatztheater.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function CTASection() {
|
|
||||||
return (
|
|
||||||
<section className="px-4 pb-14 sm:px-6 lg:px-8 lg:pb-20">
|
|
||||||
<div className="mx-auto max-w-6xl border-y border-border/80 py-9 lg:grid lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.55fr)] lg:gap-14 lg:py-11">
|
|
||||||
<div className="max-w-md space-y-4 lg:pt-1">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
|
||||||
Vor dem Angebot
|
|
||||||
</p>
|
|
||||||
<h2 className="text-2xl font-semibold tracking-tight text-balance lg:text-3xl">
|
|
||||||
Erst verstehen, dann bauen.
|
|
||||||
</h2>
|
|
||||||
<p className="text-base leading-7 text-muted-foreground">
|
|
||||||
Die Zusammenarbeit ist bewusst direkt gehalten: ein Gespräch, eine
|
|
||||||
klare Empfehlung und ein Vorschlag, der zu Ihrem Betrieb passt.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<dl className="mt-8 grid gap-6 sm:grid-cols-3 lg:mt-0 lg:gap-0">
|
|
||||||
{trustAnchors.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={item.title}
|
|
||||||
className={cn(
|
|
||||||
"space-y-3",
|
|
||||||
index === 0
|
|
||||||
? ""
|
|
||||||
: "border-t border-border/70 pt-5 sm:border-t-0 sm:border-l sm:pl-6 sm:pt-0 lg:pl-8",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<dt className="text-sm font-semibold text-foreground">
|
|
||||||
{item.title}
|
|
||||||
</dt>
|
|
||||||
<dd className="space-y-2">
|
|
||||||
<p className="text-base font-medium leading-7 text-balance text-foreground lg:text-lg">
|
|
||||||
{item.description}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm leading-6 text-muted-foreground">
|
|
||||||
{item.note}
|
|
||||||
</p>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const faqs = [
|
|
||||||
{
|
|
||||||
question: "Wie lange dauert es bis meine Website fertig ist?",
|
|
||||||
answer:
|
|
||||||
"In der Regel ist Ihre Website innerhalb von zwei Wochen fertig — vom ersten Gespräch bis zum Go-Live. Nach der Entwicklung bekommen Sie einen Vorschau-Link, damit Sie alles in Ruhe prüfen können. Erst wenn Sie zufrieden sind, geht die Seite online.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Was passiert wenn ich das Hosting kündige?",
|
|
||||||
answer:
|
|
||||||
"Ihre Website und Ihre Domain gehören Ihnen — immer. Wenn Sie das Hosting kündigen, übertrage ich Ihnen alles ohne Wenn und Aber. Keine versteckten Abhängigkeiten, das ist vertraglich festgehalten.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Ich habe schon eine Domain — was passiert damit?",
|
|
||||||
answer:
|
|
||||||
"Kein Problem. Wir zeigen Ihre bestehende Domain einfach auf die neue Website um. Falls Sie möchten, kann ich die Domain auch zu mir umziehen — das macht die Verwaltung einfacher, ist aber kein Muss.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Brauche ich technisches Wissen?",
|
|
||||||
answer:
|
|
||||||
"Keins. Sie kümmern Sie um Ihr Geschäft, ich um alles Technische. Von der Domain über die E-Mails bis zu Updates — das liegt bei mir.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Kümmern Sie sich auch um Impressum und Datenschutz?",
|
|
||||||
answer:
|
|
||||||
"Ja, jede Website die ich baue kommt mit einem rechtssicheren Impressum und einer DSGVO-konformen Datenschutzerklärung. Kein Cookie-Banner-Chaos, kein Abmahnrisiko.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Faq7Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Faq7 = ({ className }: Faq7Props) => {
|
|
||||||
return (
|
|
||||||
<section className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}>
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<div className="grid gap-10 border-t border-border/80 pt-10 md:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-16">
|
|
||||||
<div className="flex max-w-md flex-col gap-6">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
|
||||||
Häufige Fragen
|
|
||||||
</p>
|
|
||||||
<h2 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
|
||||||
Vor dem Start soll nichts schwammig bleiben.
|
|
||||||
</h2>
|
|
||||||
<p className="text-base leading-7 text-muted-foreground">
|
|
||||||
Falls noch etwas offen ist, schreiben Sie mir gern über das
|
|
||||||
<a
|
|
||||||
href="#kontakt"
|
|
||||||
className="mx-1 whitespace-nowrap underline underline-offset-4 transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
Kontaktformular
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
<Button asChild size="lg" variant="outline" className="w-fit rounded-md">
|
|
||||||
<a href="#kontakt">Frage stellen</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Accordion type="multiple" className="rounded-lg border border-border bg-card px-4">
|
|
||||||
{faqs.map((faq, index) => (
|
|
||||||
<AccordionItem key={index} value={`item-${index}`}>
|
|
||||||
<AccordionTrigger className="text-left text-base font-semibold">
|
|
||||||
{faq.question}
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="text-muted-foreground">
|
|
||||||
{faq.answer}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Faq7 };
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import {
|
|
||||||
Gauge,
|
|
||||||
Handshake,
|
|
||||||
MapPinned,
|
|
||||||
Search,
|
|
||||||
Smartphone,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const featureData = [
|
|
||||||
{
|
|
||||||
desc: "Die Startseite sagt schnell, für wen Sie arbeiten, was Sie anbieten und wie Interessenten Kontakt aufnehmen.",
|
|
||||||
title: "Klare Positionierung",
|
|
||||||
badgeTitle: "01",
|
|
||||||
icon: MapPinned,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
desc: "Gestaltung, Texte und Struktur wirken seriös, ohne den Charakter eines regionalen Betriebs glattzubügeln.",
|
|
||||||
title: "Glaubwürdiger Auftritt",
|
|
||||||
badgeTitle: "02",
|
|
||||||
icon: Handshake,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
desc: "Telefonnummer, Formular und zentrale Informationen bleiben auf Smartphone und Desktop leicht erreichbar.",
|
|
||||||
title: "Mobil sauber geführt",
|
|
||||||
badgeTitle: "03",
|
|
||||||
icon: Smartphone,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
desc: "Technik, Bilder und Inhalte werden so umgesetzt, dass die Seite schnell lädt und stabil bleibt.",
|
|
||||||
title: "Schnell und robust",
|
|
||||||
badgeTitle: "04",
|
|
||||||
icon: Gauge,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
desc: "Google findet die richtigen Inhalte: Leistungen, Region, Kontakt und die wichtigsten Suchbegriffe.",
|
|
||||||
title: "Für Suche vorbereitet",
|
|
||||||
badgeTitle: "05",
|
|
||||||
icon: Search,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Feature284Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Feature284 = ({ className }: Feature284Props) => {
|
|
||||||
return (
|
|
||||||
<section className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}>
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<div className="grid gap-8 border-t border-border/80 pt-10 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)] lg:gap-16">
|
|
||||||
<div className="max-w-md">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
|
||||||
Was die Seite leisten muss
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
|
||||||
Professionell heißt hier: verständlich, erreichbar, belastbar.
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{featureData.map((feature, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="group flex min-h-52 flex-col justify-between rounded-lg border border-border bg-card p-5 transition-colors hover:border-primary/40"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
|
||||||
{feature.badgeTitle}
|
|
||||||
</p>
|
|
||||||
<feature.icon
|
|
||||||
className="size-5 text-primary transition-transform group-hover:-translate-y-0.5"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-10 space-y-3">
|
|
||||||
<h3 className="text-xl font-semibold tracking-tight">
|
|
||||||
{feature.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm leading-6 text-muted-foreground">
|
|
||||||
{feature.desc}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Feature284 };
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
import { ArrowRight, Mail, Phone } from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface Footer27Props {
|
interface Footer27Props {
|
||||||
@@ -9,47 +6,15 @@ interface Footer27Props {
|
|||||||
|
|
||||||
const Footer27 = ({ className }: Footer27Props) => {
|
const Footer27 = ({ className }: Footer27Props) => {
|
||||||
return (
|
return (
|
||||||
<footer className={cn("px-4 pb-10 sm:px-6 lg:px-8", className)}>
|
<footer className={cn("px-5 pb-10 sm:px-8 lg:px-12", className)}>
|
||||||
<div className="mx-auto max-w-6xl border-t border-border/80 pt-8">
|
<div className="border-t border-border/80 pt-6">
|
||||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start">
|
<div className="flex flex-col gap-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<h2 className="max-w-2xl text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
<p>© 2026 Matthias Meister Webdesign</p>
|
||||||
Bereit für eine Website, die Ihr Unternehmen klarer erklärt?
|
<p className="text-xs text-muted-foreground/90">
|
||||||
</h2>
|
Diese Website misst Nutzung anonym und cookiefrei (Rybbit).
|
||||||
<p className="mt-4 max-w-xl text-base leading-7 text-muted-foreground">
|
|
||||||
Eine kurze Nachricht reicht. Ich prüfe, welcher Weg sinnvoll ist,
|
|
||||||
und melde mich mit einer ehrlichen Einschätzung.
|
|
||||||
</p>
|
</p>
|
||||||
<Button asChild size="lg" className="mt-6 h-11 rounded-md px-5">
|
|
||||||
<a href="#kontakt">
|
|
||||||
Kostenloses Angebot anfordern
|
|
||||||
<ArrowRight className="shrink-0" aria-hidden />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<address className="not-italic">
|
|
||||||
<div className="space-y-3 text-sm text-muted-foreground">
|
|
||||||
<a
|
|
||||||
href="mailto:info@matthias-meister-webdesign.de"
|
|
||||||
className="flex items-center gap-2 transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Mail className="size-4" aria-hidden />
|
|
||||||
info@matthias-meister-webdesign.de
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="tel:037627984400"
|
|
||||||
className="flex items-center gap-2 transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Phone className="size-4" aria-hidden />
|
|
||||||
03762 798 4400
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</address>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-10 flex flex-col gap-4 border-t border-border/80 pt-6 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<p>© 2026 Matthias Meister Webdesign — Crimmitschau</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||||
<a
|
<a
|
||||||
href="/impressum"
|
href="/impressum"
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
import { ArrowRight, Mail, MapPin, Phone } from "lucide-react";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface Hero235Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Hero235 = ({ className }: Hero235Props) => {
|
|
||||||
return (
|
|
||||||
<section className={cn("px-4 pt-5 sm:px-6 lg:px-8", className)}>
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<header className="flex flex-col gap-4 border-b border-border/80 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
className="text-sm font-semibold tracking-tight text-foreground"
|
|
||||||
>
|
|
||||||
Matthias Meister Webdesign
|
|
||||||
</a>
|
|
||||||
<nav
|
|
||||||
aria-label="Direkte Kontaktwege"
|
|
||||||
className="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href="tel:037627984400"
|
|
||||||
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Phone className="size-3.5" aria-hidden />
|
|
||||||
03762 798 4400
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="mailto:info@matthias-meister-webdesign.de"
|
|
||||||
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Mail className="size-3.5" aria-hidden />
|
|
||||||
E-Mail
|
|
||||||
</a>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<MapPin className="size-3.5" aria-hidden />
|
|
||||||
Crimmitschau
|
|
||||||
</span>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="grid gap-10 py-16 sm:py-20 lg:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)] lg:items-end lg:py-24">
|
|
||||||
<div className="flex max-w-3xl flex-col gap-7">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
|
||||||
Webdesign für regionale KMU
|
|
||||||
</p>
|
|
||||||
<h1 className="max-w-[12ch] text-5xl font-semibold leading-[0.95] tracking-tight text-balance text-foreground sm:text-6xl lg:text-7xl">
|
|
||||||
Websites, die vor Ort Vertrauen schaffen.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-[62ch] text-lg leading-8 text-muted-foreground">
|
|
||||||
Für Handwerk, Praxen und kleine Betriebe: klar erklärt, schnell
|
|
||||||
gebaut und so strukturiert, dass Besucher ohne Umwege verstehen,
|
|
||||||
warum sie gerade Sie anfragen sollten.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-3 pt-1 sm:flex-row sm:items-center">
|
|
||||||
<Button asChild size="lg" className="h-11 rounded-md px-5">
|
|
||||||
<a href="#kontakt">
|
|
||||||
Projekt anfragen
|
|
||||||
<ArrowRight className="shrink-0" aria-hidden />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
size="lg"
|
|
||||||
variant="outline"
|
|
||||||
className="h-11 rounded-md px-5"
|
|
||||||
>
|
|
||||||
<a href="#preise">Pakete ansehen</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<dl className="grid gap-4 border-t border-border/80 pt-6 sm:grid-cols-3">
|
|
||||||
{[
|
|
||||||
["24h", "Rückmeldung"],
|
|
||||||
["2 Wochen", "typischer Go-Live"],
|
|
||||||
["Sachsen", "Hosting & Betrieb"],
|
|
||||||
].map(([value, label]) => (
|
|
||||||
<div key={label}>
|
|
||||||
<dt className="text-2xl font-semibold tracking-tight text-foreground">
|
|
||||||
{value}
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<figure className="overflow-hidden rounded-lg border border-border bg-card">
|
|
||||||
<img
|
|
||||||
src="/about.jpg"
|
|
||||||
alt="Arbeitsplatz von Matthias Meister beim Entwickeln einer Website"
|
|
||||||
className="h-[19rem] w-full object-cover sm:h-[24rem] lg:h-[31rem]"
|
|
||||||
/>
|
|
||||||
<figcaption className="grid gap-2 border-t border-border bg-card/95 p-5 sm:grid-cols-[1fr_auto] sm:items-center">
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
Direkt mit dem Entwickler statt mit wechselnden Agenturrollen.
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
||||||
Persönlich geplant
|
|
||||||
</span>
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Hero235 };
|
|
||||||
190
src/components/landing-hero-section.tsx
Normal file
190
src/components/landing-hero-section.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { WebcamPixelGrid } from "@/components/ui/webcam-pixel-grid";
|
||||||
|
|
||||||
|
/** Canvas-Hintergrund = Basiston der Raster-Palette (schließt optisch an bg-primary an). */
|
||||||
|
const PRIMARY_HERO_BG = "#B54440";
|
||||||
|
|
||||||
|
const LandingHeroSection = () => {
|
||||||
|
const [liveRasterOn, setLiveRasterOn] = useState(false);
|
||||||
|
const [hasWebcam, setHasWebcam] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaDevices = navigator.mediaDevices;
|
||||||
|
|
||||||
|
if (!mediaDevices?.enumerateDevices) {
|
||||||
|
setHasWebcam(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWebcamAvailability = async () => {
|
||||||
|
try {
|
||||||
|
const devices = await mediaDevices.enumerateDevices();
|
||||||
|
const hasVideoInput = devices.some(
|
||||||
|
(device) => device.kind === "videoinput",
|
||||||
|
);
|
||||||
|
|
||||||
|
setHasWebcam(hasVideoInput);
|
||||||
|
if (!hasVideoInput) {
|
||||||
|
setLiveRasterOn(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setHasWebcam(false);
|
||||||
|
setLiveRasterOn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateWebcamAvailability();
|
||||||
|
mediaDevices.addEventListener?.("devicechange", updateWebcamAvailability);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaDevices.removeEventListener?.(
|
||||||
|
"devicechange",
|
||||||
|
updateWebcamAvailability,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleLiveRaster = () => {
|
||||||
|
setLiveRasterOn((isOn) => !isOn);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative grid min-h-screen grid-cols-1 border-b border-border lg:grid-cols-[1.08fr_0.92fr]">
|
||||||
|
<div className="flex min-h-[640px] flex-col justify-between px-5 py-5 sm:px-8 lg:px-12">
|
||||||
|
<header className="grid gap-4 border-b border-border pb-5 text-xs uppercase tracking-[0.28em] text-muted-foreground sm:grid-cols-[1fr_auto]">
|
||||||
|
<a href="/" className="font-semibold text-foreground">
|
||||||
|
Matthias Meister
|
||||||
|
</a>
|
||||||
|
<nav className="flex flex-wrap gap-5">
|
||||||
|
<a href="#leistungen">Leistungen</a>
|
||||||
|
<a href="#ablauf">Ablauf</a>
|
||||||
|
<a href="#pakete">Pakete</a>
|
||||||
|
<a href="#kontakt">Kontakt</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-5xl py-16 sm:py-20 lg:py-24">
|
||||||
|
<p className="mb-6 max-w-sm text-sm uppercase tracking-[0.32em] text-primary">
|
||||||
|
Für Betriebe, die lieber arbeiten als basteln
|
||||||
|
</p>
|
||||||
|
<h1 className="max-w-[11ch] text-[clamp(4.25rem,13vw,11.5rem)] font-black uppercase leading-[0.79] tracking-normal text-foreground">
|
||||||
|
Online Fertig Passt
|
||||||
|
</h1>
|
||||||
|
<div className="mt-8 grid gap-7 border-t border-border pt-7 lg:grid-cols-[0.72fr_1fr]">
|
||||||
|
<p className="text-sm uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Ihr Betrieb im Netz — ohne Stress
|
||||||
|
</p>
|
||||||
|
<p className="max-w-2xl text-lg leading-8 text-foreground/80">
|
||||||
|
Ich baue Websites für Handwerk, Praxen, Salons und
|
||||||
|
Dienstleister aus der Region — klar genug, dass Besucher
|
||||||
|
anrufen statt weiterklicken.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 border-t border-border pt-5 text-sm uppercase tracking-[0.2em] text-muted-foreground sm:grid-cols-3">
|
||||||
|
<span>Antwort in 24h</span>
|
||||||
|
<span>abmahnsicher</span>
|
||||||
|
<span>Hosting in Sachsen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="relative flex min-h-[520px] flex-col overflow-hidden border-t border-primary-foreground/25 bg-primary px-5 py-5 text-primary-foreground sm:px-8 lg:min-h-screen lg:border-t-0 lg:border-l lg:px-12">
|
||||||
|
{liveRasterOn ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-0 hidden min-h-full lg:block">
|
||||||
|
<WebcamPixelGrid
|
||||||
|
className="min-h-full"
|
||||||
|
gridCols={40}
|
||||||
|
gridRows={30}
|
||||||
|
maxElevation={10}
|
||||||
|
motionSensitivity={0.52}
|
||||||
|
elevationSmoothing={0.12}
|
||||||
|
colorMode="redscale"
|
||||||
|
backgroundColor={PRIMARY_HERO_BG}
|
||||||
|
mirror
|
||||||
|
gapRatio={0}
|
||||||
|
darken={0.08}
|
||||||
|
borderColor="#ffffff"
|
||||||
|
borderOpacity={0}
|
||||||
|
quietWebcamErrors
|
||||||
|
onWebcamError={() => setLiveRasterOn(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="relative z-10 flex min-h-[520px] flex-1 flex-col lg:min-h-0">
|
||||||
|
<div className="flex shrink-0 items-start justify-between border-b border-primary-foreground/30 pb-5 text-xs uppercase tracking-[0.28em]">
|
||||||
|
<span className="text-primary-foreground/60">Webdesign</span>
|
||||||
|
<span>©2026</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasWebcam ? (
|
||||||
|
<div className="hidden shrink-0 flex-col items-end gap-2 pt-4 lg:flex">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"max-w-[18ch] text-right text-[10px] leading-snug font-medium uppercase tracking-[0.22em] text-primary-foreground/85",
|
||||||
|
!liveRasterOn && "motion-safe:animate-pulse",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{liveRasterOn
|
||||||
|
? "Kamera aus? Schalter zurück, fertig."
|
||||||
|
: "Psst ... einmal wippen, dann lebt die Seite."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={liveRasterOn}
|
||||||
|
aria-label={
|
||||||
|
liveRasterOn
|
||||||
|
? "Live-Raster beenden"
|
||||||
|
: "Live-Raster mit Kamera starten"
|
||||||
|
}
|
||||||
|
onClick={handleToggleLiveRaster}
|
||||||
|
className={cn(
|
||||||
|
"relative h-9 w-13 shrink-0 rounded-full border transition-colors duration-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-primary-foreground",
|
||||||
|
liveRasterOn
|
||||||
|
? "border-primary-foreground/50 bg-primary-foreground/20"
|
||||||
|
: "border-primary-foreground/45 bg-primary-foreground/10 hover:border-primary-foreground/70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1 left-1 size-7 rounded-full bg-primary-foreground shadow-sm transition-transform duration-300 ease-out",
|
||||||
|
liveRasterOn ? "translate-x-4" : "translate-x-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="relative mt-auto pt-16 pb-8 lg:pt-24 lg:pb-10">
|
||||||
|
<div className="pointer-events-none absolute bottom-0 right-0 h-36 w-36 border border-primary-foreground/35 sm:right-2 lg:right-10" />
|
||||||
|
<div className="relative max-w-xl">
|
||||||
|
<p className="text-[clamp(4rem,10vw,9rem)] font-black uppercase leading-[0.75] tracking-normal">
|
||||||
|
01
|
||||||
|
</p>
|
||||||
|
<p className="mt-7 max-w-sm text-2xl font-semibold uppercase leading-none">
|
||||||
|
Geradeaus. Ohne Buzzwords. Für echte Betriebe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#kontakt"
|
||||||
|
className="group relative z-10 inline-flex w-fit shrink-0 items-center gap-3 border border-primary-foreground px-5 py-4 text-sm font-semibold uppercase tracking-[0.18em] transition hover:bg-primary-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
Unverbindlich anfragen
|
||||||
|
<ArrowUpRight className="size-5 transition group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { LandingHeroSection };
|
||||||
17
src/components/landing-page-sections.tsx
Normal file
17
src/components/landing-page-sections.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { AboutSection } from "@/components/landing/about-section";
|
||||||
|
import { PackagesSection } from "@/components/landing/packages-section";
|
||||||
|
import { ProcessSection } from "@/components/landing/process-section";
|
||||||
|
import { ServicesSection } from "@/components/landing/services-section";
|
||||||
|
|
||||||
|
const LandingPageSections = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ServicesSection />
|
||||||
|
<AboutSection />
|
||||||
|
<ProcessSection />
|
||||||
|
<PackagesSection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { LandingPageSections };
|
||||||
36
src/components/landing/about-section.tsx
Normal file
36
src/components/landing/about-section.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const AboutSection = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="ueber"
|
||||||
|
className="grid border-b border-border lg:grid-cols-[0.36fr_0.64fr]"
|
||||||
|
>
|
||||||
|
<div className="border-b border-border bg-foreground px-5 py-12 text-background sm:px-8 lg:border-b-0 lg:border-r lg:px-12 lg:py-20">
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Über mich (03)
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-6 max-w-[12ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||||
|
Ein Mensch. Kein Ticketsystem.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-center gap-8 px-5 py-12 sm:px-8 lg:px-12 lg:py-20">
|
||||||
|
<p className="max-w-xl text-[clamp(1.5rem,3vw,2rem)] font-black uppercase leading-[0.95] text-foreground">
|
||||||
|
Ich bin Matthias — Webdesigner aus Sachsen.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||||
|
Ich baue Websites für Betriebe, die ihre Energie lieber in Kunden
|
||||||
|
stecken als in Technik. Kein Großraumbüro, kein Agentur-Overhead —
|
||||||
|
ein Ansprechpartner von der ersten Idee bis zum Go-live.
|
||||||
|
</p>
|
||||||
|
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||||
|
Meine Kunden sind Handwerker, Praxen, Salons und Dienstleister aus
|
||||||
|
der Region. Menschen, die eine Website wollen, die funktioniert —
|
||||||
|
und sich dann wieder um ihren Betrieb kümmern möchten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { AboutSection };
|
||||||
68
src/components/landing/contact-section.tsx
Normal file
68
src/components/landing/contact-section.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CornerDownRight, Mail, MapPin, Phone } from "lucide-react";
|
||||||
|
import { Map, MapControls, MapMarker, MarkerContent } from "@/components/ui/map";
|
||||||
|
|
||||||
|
/** Karl-Marx-Str. 22, 08451 Crimmitschau (OpenStreetMap) */
|
||||||
|
const OFFICE: [number, number] = [12.3829769, 50.8131218];
|
||||||
|
|
||||||
|
const ContactSection = () => {
|
||||||
|
return (
|
||||||
|
<section id="kontakt" className="grid min-h-[620px] lg:grid-cols-2">
|
||||||
|
<div className="flex flex-col justify-between px-5 py-14 sm:px-8 lg:px-12 lg:py-24">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Kontakt (06)
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-8 max-w-[12ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl lg:text-8xl">
|
||||||
|
Erzählen Sie mir kurz von Ihrem Betrieb
|
||||||
|
</h2>
|
||||||
|
<p className="mt-8 max-w-2xl text-xl leading-8 text-muted-foreground">
|
||||||
|
Ein paar Sätze reichen: Was bieten Sie an, was soll die Website für
|
||||||
|
Sie tun, und wann soll sie online sein?
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="mailto:support@matthias-meister-webdesign.de"
|
||||||
|
className="mt-10 inline-flex items-center gap-3 bg-primary px-6 py-5 text-sm font-black uppercase tracking-[0.18em] text-primary-foreground transition hover:bg-foreground hover:text-background"
|
||||||
|
>
|
||||||
|
<CornerDownRight className="size-5" />
|
||||||
|
Kurze Nachricht schreiben
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 flex flex-col gap-5 border-t border-border pt-8 text-sm text-muted-foreground">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Mail className="size-4 shrink-0 text-primary" />
|
||||||
|
<span>support@matthias-meister-webdesign.de</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Phone className="size-4 shrink-0 text-primary" />
|
||||||
|
<span>Rückmeldung innerhalb von 24 Stunden</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<MapPin className="size-4 shrink-0 text-primary" />
|
||||||
|
<span>Karl-Marx-Str. 22, 08451 Crimmitschau</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-map relative min-h-[400px] bg-primary lg:min-h-0">
|
||||||
|
<Map
|
||||||
|
center={OFFICE}
|
||||||
|
zoom={15}
|
||||||
|
theme="dark"
|
||||||
|
className="h-full w-full"
|
||||||
|
>
|
||||||
|
<MapControls position="bottom-right" showZoom />
|
||||||
|
<MapMarker longitude={OFFICE[0]} latitude={OFFICE[1]}>
|
||||||
|
<MarkerContent>
|
||||||
|
<div className="size-5 rounded-full border-[3px] border-primary-foreground bg-primary shadow-lg ring-4 ring-primary-foreground/50" />
|
||||||
|
</MarkerContent>
|
||||||
|
</MapMarker>
|
||||||
|
</Map>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ContactSection };
|
||||||
37
src/components/landing/deliverables-section.tsx
Normal file
37
src/components/landing/deliverables-section.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
const deliverables = [
|
||||||
|
"Strategie und Seitenstruktur",
|
||||||
|
"Individuelles Screen-Design",
|
||||||
|
"Moderne, schnelle Umsetzung",
|
||||||
|
"Kontaktformular und Datenschutz",
|
||||||
|
"Hosting, Wartung und Analytics",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DeliverablesSection = () => {
|
||||||
|
return (
|
||||||
|
<section className="grid border-b border-border lg:grid-cols-2">
|
||||||
|
<div className="bg-foreground px-5 py-14 text-background sm:px-8 lg:px-12 lg:py-24">
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Ergebnis (03)
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-8 max-w-[10ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||||
|
Was am Ende steht
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid content-center gap-4 px-5 py-12 sm:px-8 lg:px-12">
|
||||||
|
{deliverables.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="flex items-center gap-4 border-b border-border pb-4 text-lg font-semibold uppercase tracking-[0.08em]"
|
||||||
|
>
|
||||||
|
<Check className="size-5 text-primary" />
|
||||||
|
<span>{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DeliverablesSection };
|
||||||
110
src/components/landing/packages-section.tsx
Normal file
110
src/components/landing/packages-section.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
const packages = [
|
||||||
|
{
|
||||||
|
name: "Basis",
|
||||||
|
price: "799 EUR",
|
||||||
|
detail: "Eine starke Seite für ein klares Angebot.",
|
||||||
|
highlighted: false,
|
||||||
|
features: [
|
||||||
|
"One-Page-Website",
|
||||||
|
"Mobil optimiert",
|
||||||
|
"Kontaktformular",
|
||||||
|
"DSGVO & Impressum",
|
||||||
|
"Hosting für 1 Jahr",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Profi",
|
||||||
|
price: "1.499 EUR",
|
||||||
|
detail: "Mehrere Seiten für Betriebe mit mehr zu zeigen.",
|
||||||
|
highlighted: true,
|
||||||
|
features: [
|
||||||
|
"Alles aus Basis",
|
||||||
|
"Bis zu 5 Unterseiten",
|
||||||
|
"Individuelles Design",
|
||||||
|
"SEO-Grundoptimierung",
|
||||||
|
"Google Maps Einbindung",
|
||||||
|
"Kontaktformular",
|
||||||
|
"DSGVO & Impressum",
|
||||||
|
"Hosting für 1 Jahr",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Maßarbeit",
|
||||||
|
price: "2.499 EUR+",
|
||||||
|
detail: "Ihr Betrieb, Ihre Regeln. Inhalte selbst ändern, Seiten ergänzen, wachsen.",
|
||||||
|
highlighted: false,
|
||||||
|
features: [
|
||||||
|
"Alles aus Profi",
|
||||||
|
"Unbegrenzte Seiten",
|
||||||
|
"Inhalte selbst pflegbar (CMS)",
|
||||||
|
"Individuelles Design & Struktur",
|
||||||
|
"SEO-Optimierung",
|
||||||
|
"Blog oder News-Bereich",
|
||||||
|
"Erweiterte Funktionen",
|
||||||
|
"DSGVO & Impressum",
|
||||||
|
"Hosting für 1 Jahr",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PackagesSection = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="pakete"
|
||||||
|
className="border-b border-border px-5 py-14 sm:px-8 lg:px-12 lg:py-24"
|
||||||
|
>
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[0.45fr_0.55fr]">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Pakete (05)
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-8 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||||
|
Festpreis. Punkt.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{packages.map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.name}
|
||||||
|
className={`grid gap-6 border p-5 sm:grid-cols-[0.5fr_0.5fr] sm:p-6 ${item.highlighted ? "border-primary bg-primary/6" : "border-border"}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-sm uppercase tracking-[0.24em] text-primary">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
{item.highlighted ? (
|
||||||
|
<span className="bg-primary px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.2em] text-primary-foreground">
|
||||||
|
Beliebteste Wahl
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-4xl font-black uppercase">
|
||||||
|
{item.price}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-lg leading-7 text-muted-foreground">
|
||||||
|
{item.detail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-2.5 self-center">
|
||||||
|
{item.features.map((feature) => (
|
||||||
|
<li
|
||||||
|
key={feature}
|
||||||
|
className="flex items-center gap-3 text-sm text-foreground/85"
|
||||||
|
>
|
||||||
|
<Check className="size-4 shrink-0 text-primary" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { PackagesSection };
|
||||||
61
src/components/landing/process-section.tsx
Normal file
61
src/components/landing/process-section.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
number: "01",
|
||||||
|
title: "Gespräch",
|
||||||
|
text: "15 Minuten telefonieren. Sie erzählen, ich höre zu.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "02",
|
||||||
|
title: "Konzept",
|
||||||
|
text: "Seitenstruktur und Design-Vorschlag innerhalb einer Woche.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "03",
|
||||||
|
title: "Umsetzung",
|
||||||
|
text: "Fertige Website in 2–4 Wochen. Feedback, Anpassung, fertig.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "04",
|
||||||
|
title: "Online",
|
||||||
|
text: "Ich schalte live, richte Hosting ein, kümmere mich um den Rest.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ProcessSection = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="ablauf"
|
||||||
|
className="border-b border-border px-5 py-14 sm:px-8 lg:px-12 lg:py-24"
|
||||||
|
>
|
||||||
|
<div className="mb-12">
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Ablauf (04)
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||||
|
Vier Schritte. Fertig.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-0 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<article
|
||||||
|
key={step.number}
|
||||||
|
className={`relative flex flex-col gap-4 border-t border-border py-8 pr-6 lg:border-t-0 lg:border-l lg:py-0 lg:pl-8 lg:pr-10 ${i === 0 ? "lg:border-l-0 lg:pl-0" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="text-6xl font-black leading-none text-primary/25">
|
||||||
|
{step.number}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-xl font-black uppercase leading-none">
|
||||||
|
{step.title}
|
||||||
|
</h3>
|
||||||
|
<p className="max-w-xs text-base leading-7 text-muted-foreground">
|
||||||
|
{step.text}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ProcessSection };
|
||||||
55
src/components/landing/services-section.tsx
Normal file
55
src/components/landing/services-section.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const services = [
|
||||||
|
{
|
||||||
|
number: "01",
|
||||||
|
title: "Website",
|
||||||
|
text: "Vom Elektriker bis zur Physiotherapie — eine Seite, die in drei Sekunden zeigt, was Sie machen und wie man Sie erreicht.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "02",
|
||||||
|
title: "Struktur",
|
||||||
|
text: "Angebot, Leistungen, Ablauf und Kontakt — alles dort, wo Besucher es erwarten. Damit aus Klicks Anrufe werden.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "03",
|
||||||
|
title: "Technik",
|
||||||
|
text: "Lädt in unter zwei Sekunden, sieht auf jedem Handy gut aus und ist rechtssicher. Änderungen später? Ein Anruf genügt.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ServicesSection = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="leistungen"
|
||||||
|
className="grid border-b border-border lg:grid-cols-[0.36fr_0.64fr]"
|
||||||
|
>
|
||||||
|
<div className="border-b border-border px-5 py-12 sm:px-8 lg:border-b-0 lg:border-r lg:px-12 lg:py-20">
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Leistungen (02)
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||||
|
Drei Schritte. Eine Website.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{services.map((service) => (
|
||||||
|
<article
|
||||||
|
key={service.number}
|
||||||
|
className="grid gap-8 px-5 py-10 sm:px-8 md:grid-cols-[7rem_0.4fr_1fr] lg:px-12"
|
||||||
|
>
|
||||||
|
<span className="text-5xl font-black text-primary">
|
||||||
|
{service.number}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-3xl font-black uppercase leading-none">
|
||||||
|
{service.title}
|
||||||
|
</h3>
|
||||||
|
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||||
|
{service.text}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ServicesSection };
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Check } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface PricingPlan {
|
|
||||||
name: string;
|
|
||||||
price: string;
|
|
||||||
period: string;
|
|
||||||
description: string;
|
|
||||||
features: string[];
|
|
||||||
isPopular?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Pricing4Props {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const developmentPlans: PricingPlan[] = [
|
|
||||||
{
|
|
||||||
name: "Basis",
|
|
||||||
price: "799 €",
|
|
||||||
period: "Einmalpreis",
|
|
||||||
description: "Für einen klaren Webauftritt mit den wichtigsten Inhalten.",
|
|
||||||
features: [
|
|
||||||
"Eine Seite mit fünf Sektionen",
|
|
||||||
"Kontaktformular",
|
|
||||||
"Impressum und Datenschutz",
|
|
||||||
"Mobilfreundlich und für Google vorbereitet",
|
|
||||||
"Cookiefreie Analytics ohne Banner",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Profi",
|
|
||||||
price: "1.499 €",
|
|
||||||
period: "Einmalpreis",
|
|
||||||
description: "Für Betriebe, die mehrere Leistungen sauber erklären wollen.",
|
|
||||||
features: [
|
|
||||||
"Bis zu fünf Unterseiten",
|
|
||||||
"Google Maps Integration",
|
|
||||||
"SEO-Basis für lokale Auffindbarkeit",
|
|
||||||
"Optionaler Blog",
|
|
||||||
"Alles aus Basis inklusive",
|
|
||||||
],
|
|
||||||
isPopular: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Maßarbeit",
|
|
||||||
price: "2.499 €",
|
|
||||||
period: "Einmalpreis",
|
|
||||||
description: "Für individuelle Anforderungen, CMS und spätere Erweiterungen.",
|
|
||||||
features: [
|
|
||||||
"Individuelles Design nach Ihren Anforderungen",
|
|
||||||
"CMS zur eigenen Inhaltspflege",
|
|
||||||
"Erweiterbare Struktur",
|
|
||||||
"Alles aus Profi inklusive",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const servicePlans: PricingPlan[] = [
|
|
||||||
{
|
|
||||||
name: "Hosting",
|
|
||||||
price: "19 €",
|
|
||||||
period: "pro Monat",
|
|
||||||
description: "Solide technische Basis für kleine Unternehmensseiten.",
|
|
||||||
features: [
|
|
||||||
"Hosting auf deutschen Servern in Sachsen",
|
|
||||||
"SSL, Domain und tägliche Backups",
|
|
||||||
"Monatlicher Einblick in Besucherzahlen",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Wartung",
|
|
||||||
price: "39 €",
|
|
||||||
period: "pro Monat",
|
|
||||||
description: "Für Unternehmen, die Betrieb und Sicherheit abgeben möchten.",
|
|
||||||
features: [
|
|
||||||
"Alles aus Hosting inklusive",
|
|
||||||
"Regelmäßige Updates und Sicherheitschecks",
|
|
||||||
"1 Stunde Support pro Monat",
|
|
||||||
"Monitoring bei technischen Problemen",
|
|
||||||
],
|
|
||||||
isPopular: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Full Service",
|
|
||||||
price: "69 €",
|
|
||||||
period: "pro Monat",
|
|
||||||
description: "Für laufende Änderungen ohne jedes Mal ein neues Projekt.",
|
|
||||||
features: [
|
|
||||||
"Alles aus Wartung inklusive",
|
|
||||||
"Kleinere Inhaltsänderungen bis 2 Stunden pro Monat",
|
|
||||||
"Häufigerer Einblick in Besucherzahlen",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const Pricing4 = ({
|
|
||||||
title = "Pakete mit klarer Kante.",
|
|
||||||
description =
|
|
||||||
"Die Preise sind bewusst nachvollziehbar gehalten. Im Gespräch klären wir, welches Paket passt und wo ein schlankerer Weg sinnvoller ist.",
|
|
||||||
className,
|
|
||||||
}: Pricing4Props) => {
|
|
||||||
const [activeTab, setActiveTab] = useState<"development" | "service">(
|
|
||||||
"development",
|
|
||||||
);
|
|
||||||
const plans = activeTab === "development" ? developmentPlans : servicePlans;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id="preise"
|
|
||||||
className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}
|
|
||||||
>
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)] lg:items-end lg:gap-16">
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
|
||||||
Preise
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-5 text-base leading-7 text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={(value: string) =>
|
|
||||||
setActiveTab(value as "development" | "service")
|
|
||||||
}
|
|
||||||
className="w-fit lg:justify-self-end"
|
|
||||||
aria-label="Paketart auswählen"
|
|
||||||
>
|
|
||||||
<TabsList className="grid h-11 w-full grid-cols-2 rounded-md border border-border bg-card p-1 sm:w-max">
|
|
||||||
<TabsTrigger
|
|
||||||
value="development"
|
|
||||||
className="h-full min-h-0 px-5 py-0 text-sm font-semibold data-active:bg-primary data-active:text-primary-foreground"
|
|
||||||
>
|
|
||||||
Entwicklung
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="service"
|
|
||||||
className="h-full min-h-0 px-5 py-0 text-sm font-semibold data-active:bg-primary data-active:text-primary-foreground"
|
|
||||||
>
|
|
||||||
Hosting & Wartung
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-10 grid gap-4 lg:grid-cols-3">
|
|
||||||
{plans.map((plan) => (
|
|
||||||
<article
|
|
||||||
key={plan.name}
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-[32rem] flex-col rounded-lg border bg-card p-6",
|
|
||||||
plan.isPopular
|
|
||||||
? "border-primary shadow-[0_18px_60px_oklch(0.285_0.045_148/0.12)]"
|
|
||||||
: "border-border",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold tracking-tight">
|
|
||||||
{plan.name}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
|
||||||
{plan.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{plan.isPopular && (
|
|
||||||
<Badge variant="outline" className="rounded-md">
|
|
||||||
Empfohlen
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<p className="text-4xl font-semibold tracking-tight">
|
|
||||||
{plan.price}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{plan.period}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="mt-8 flex-1 space-y-4 text-sm leading-6 text-muted-foreground">
|
|
||||||
{plan.features.map((feature) => (
|
|
||||||
<li key={feature} className="flex gap-3">
|
|
||||||
<Check
|
|
||||||
className="mt-0.5 size-4 shrink-0 text-primary"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span>{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<Button asChild className="mt-8 w-full rounded-md">
|
|
||||||
<a href="#kontakt">Kostenloses Angebot anfordern</a>
|
|
||||||
</Button>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Pricing4 };
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface Stats11Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Stats11 = ({ className }: Stats11Props) => {
|
|
||||||
const stats = [
|
|
||||||
["SEO-ready", "Leistungsseiten, Region und Kontakt sauber strukturiert."],
|
|
||||||
["< 1 Sek.", "Auf schnelle Ladezeiten und klare Technik ausgelegt."],
|
|
||||||
["ab 799 €", "Transparente Einstiegspreise ohne Paketnebel."],
|
|
||||||
["2 Wochen", "Typischer Zeitraum vom Startgespräch bis zur Vorschau."],
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}>
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<div className="rounded-lg border border-border bg-primary p-6 text-primary-foreground sm:p-8 lg:grid lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.45fr)] lg:gap-12 lg:p-10">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary-foreground/65">
|
|
||||||
Messbare Grundlagen
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-4 max-w-xl text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
|
||||||
Gute Websites fühlen sich ruhig an, weil die Basis stimmt.
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="mt-10 grid gap-4 sm:grid-cols-2 lg:mt-0">
|
|
||||||
{stats.map(([value, label]) => (
|
|
||||||
<div
|
|
||||||
key={value}
|
|
||||||
className="rounded-md border border-primary-foreground/15 bg-primary-foreground/[0.06] p-5"
|
|
||||||
>
|
|
||||||
<p className="text-3xl font-semibold tracking-tight">
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-primary-foreground/72">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Stats11 };
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function Accordion({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<AccordionPrimitive.Root
|
|
||||||
data-slot="accordion"
|
|
||||||
className={cn("flex w-full flex-col", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccordionItem({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<AccordionPrimitive.Item
|
|
||||||
data-slot="accordion-item"
|
|
||||||
className={cn("not-last:border-b", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccordionTrigger({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<AccordionPrimitive.Header className="flex">
|
|
||||||
<AccordionPrimitive.Trigger
|
|
||||||
data-slot="accordion-trigger"
|
|
||||||
className={cn(
|
|
||||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
|
||||||
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
|
||||||
</AccordionPrimitive.Trigger>
|
|
||||||
</AccordionPrimitive.Header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccordionContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<AccordionPrimitive.Content
|
|
||||||
data-slot="accordion-content"
|
|
||||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</AccordionPrimitive.Content>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { Slot } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const badgeVariants = cva(
|
|
||||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
|
||||||
outline:
|
|
||||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
|
||||||
ghost:
|
|
||||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Badge({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span"> &
|
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
|
||||||
const Comp = asChild ? Slot.Root : "span"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="badge"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(badgeVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { Slot } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
||||||
outline:
|
|
||||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
||||||
ghost:
|
|
||||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default:
|
|
||||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
||||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
||||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
||||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
||||||
icon: "size-8",
|
|
||||||
"icon-xs":
|
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
||||||
"icon-sm":
|
|
||||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
||||||
"icon-lg": "size-9",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Button({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
size = "default",
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"button"> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
data-variant={variant}
|
|
||||||
data-size={size}
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
import { useMemo } from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
|
|
||||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
|
||||||
return (
|
|
||||||
<fieldset
|
|
||||||
data-slot="field-set"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldLegend({
|
|
||||||
className,
|
|
||||||
variant = "legend",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
|
||||||
return (
|
|
||||||
<legend
|
|
||||||
data-slot="field-legend"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(
|
|
||||||
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-group"
|
|
||||||
className={cn(
|
|
||||||
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldVariants = cva(
|
|
||||||
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
orientation: {
|
|
||||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
|
||||||
horizontal:
|
|
||||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
|
||||||
responsive:
|
|
||||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
className,
|
|
||||||
orientation = "vertical",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
data-slot="field"
|
|
||||||
data-orientation={orientation}
|
|
||||||
className={cn(fieldVariants({ orientation }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-content"
|
|
||||||
className={cn(
|
|
||||||
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Label>) {
|
|
||||||
return (
|
|
||||||
<Label
|
|
||||||
data-slot="field-label"
|
|
||||||
className={cn(
|
|
||||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
|
||||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-label"
|
|
||||||
className={cn(
|
|
||||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
data-slot="field-description"
|
|
||||||
className={cn(
|
|
||||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
|
||||||
"last:mt-0 nth-last-2:-mt-1",
|
|
||||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldSeparator({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
children?: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-separator"
|
|
||||||
data-content={!!children}
|
|
||||||
className={cn(
|
|
||||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Separator className="absolute inset-0 top-1/2" />
|
|
||||||
{children && (
|
|
||||||
<span
|
|
||||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
|
||||||
data-slot="field-separator-content"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldError({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
errors,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
errors?: Array<{ message?: string } | undefined>
|
|
||||||
}) {
|
|
||||||
const content = useMemo(() => {
|
|
||||||
if (children) {
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!errors?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueErrors = [
|
|
||||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (uniqueErrors?.length == 1) {
|
|
||||||
return uniqueErrors[0]?.message
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
|
||||||
{uniqueErrors.map(
|
|
||||||
(error, index) =>
|
|
||||||
error?.message && <li key={index}>{error.message}</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}, [children, errors])
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
data-slot="field-error"
|
|
||||||
className={cn("text-sm font-normal text-destructive", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Field,
|
|
||||||
FieldLabel,
|
|
||||||
FieldDescription,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLegend,
|
|
||||||
FieldSeparator,
|
|
||||||
FieldSet,
|
|
||||||
FieldContent,
|
|
||||||
FieldTitle,
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo, useCallback, useEffect, useRef } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { animate } from "motion/react";
|
|
||||||
|
|
||||||
interface GlowingEffectProps {
|
|
||||||
blur?: number;
|
|
||||||
inactiveZone?: number;
|
|
||||||
proximity?: number;
|
|
||||||
spread?: number;
|
|
||||||
variant?: "default" | "white";
|
|
||||||
glow?: boolean;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
movementDuration?: number;
|
|
||||||
borderWidth?: number;
|
|
||||||
}
|
|
||||||
const GlowingEffect = memo(
|
|
||||||
({
|
|
||||||
blur = 0,
|
|
||||||
inactiveZone = 0.7,
|
|
||||||
proximity = 0,
|
|
||||||
spread = 20,
|
|
||||||
variant = "default",
|
|
||||||
glow = false,
|
|
||||||
className,
|
|
||||||
movementDuration = 2,
|
|
||||||
borderWidth = 1,
|
|
||||||
disabled = true,
|
|
||||||
}: GlowingEffectProps) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const lastPosition = useRef({ x: 0, y: 0 });
|
|
||||||
const animationFrameRef = useRef<number>(0);
|
|
||||||
|
|
||||||
const handleMove = useCallback(
|
|
||||||
(e?: MouseEvent | { x: number; y: number }) => {
|
|
||||||
if (!containerRef.current) return;
|
|
||||||
|
|
||||||
if (animationFrameRef.current) {
|
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
animationFrameRef.current = requestAnimationFrame(() => {
|
|
||||||
const element = containerRef.current;
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
const { left, top, width, height } = element.getBoundingClientRect();
|
|
||||||
const mouseX = e?.x ?? lastPosition.current.x;
|
|
||||||
const mouseY = e?.y ?? lastPosition.current.y;
|
|
||||||
|
|
||||||
if (e) {
|
|
||||||
lastPosition.current = { x: mouseX, y: mouseY };
|
|
||||||
}
|
|
||||||
|
|
||||||
const center = [left + width * 0.5, top + height * 0.5];
|
|
||||||
const distanceFromCenter = Math.hypot(
|
|
||||||
mouseX - center[0],
|
|
||||||
mouseY - center[1]
|
|
||||||
);
|
|
||||||
const inactiveRadius = 0.5 * Math.min(width, height) * inactiveZone;
|
|
||||||
|
|
||||||
if (distanceFromCenter < inactiveRadius) {
|
|
||||||
element.style.setProperty("--active", "0");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive =
|
|
||||||
mouseX > left - proximity &&
|
|
||||||
mouseX < left + width + proximity &&
|
|
||||||
mouseY > top - proximity &&
|
|
||||||
mouseY < top + height + proximity;
|
|
||||||
|
|
||||||
element.style.setProperty("--active", isActive ? "1" : "0");
|
|
||||||
|
|
||||||
if (!isActive) return;
|
|
||||||
|
|
||||||
const currentAngle =
|
|
||||||
parseFloat(element.style.getPropertyValue("--start")) || 0;
|
|
||||||
let targetAngle =
|
|
||||||
(180 * Math.atan2(mouseY - center[1], mouseX - center[0])) /
|
|
||||||
Math.PI +
|
|
||||||
90;
|
|
||||||
|
|
||||||
const angleDiff = ((targetAngle - currentAngle + 180) % 360) - 180;
|
|
||||||
const newAngle = currentAngle + angleDiff;
|
|
||||||
|
|
||||||
animate(currentAngle, newAngle, {
|
|
||||||
duration: movementDuration,
|
|
||||||
ease: [0.16, 1, 0.3, 1],
|
|
||||||
onUpdate: (value) => {
|
|
||||||
element.style.setProperty("--start", String(value));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[inactiveZone, proximity, movementDuration]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
const handleScroll = () => handleMove();
|
|
||||||
const handlePointerMove = (e: PointerEvent) => handleMove(e);
|
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
||||||
document.body.addEventListener("pointermove", handlePointerMove, {
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (animationFrameRef.current) {
|
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
|
||||||
}
|
|
||||||
window.removeEventListener("scroll", handleScroll);
|
|
||||||
document.body.removeEventListener("pointermove", handlePointerMove);
|
|
||||||
};
|
|
||||||
}, [handleMove, disabled]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute -inset-px hidden rounded-[inherit] border opacity-0 transition-opacity",
|
|
||||||
glow && "opacity-100",
|
|
||||||
variant === "white" && "border-white",
|
|
||||||
disabled && "!block"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--blur": `${blur}px`,
|
|
||||||
"--spread": spread,
|
|
||||||
"--start": "0",
|
|
||||||
"--active": "0",
|
|
||||||
"--glowingeffect-border-width": `${borderWidth}px`,
|
|
||||||
"--repeating-conic-gradient-times": "5",
|
|
||||||
"--gradient":
|
|
||||||
variant === "white"
|
|
||||||
? `repeating-conic-gradient(
|
|
||||||
from 236.84deg at 50% 50%,
|
|
||||||
var(--black),
|
|
||||||
var(--black) calc(25% / var(--repeating-conic-gradient-times))
|
|
||||||
)`
|
|
||||||
: `radial-gradient(circle, #dd7bbb 10%, #dd7bbb00 20%),
|
|
||||||
radial-gradient(circle at 40% 40%, #d79f1e 5%, #d79f1e00 15%),
|
|
||||||
radial-gradient(circle at 60% 60%, #5a922c 10%, #5a922c00 20%),
|
|
||||||
radial-gradient(circle at 40% 60%, #4c7894 10%, #4c789400 20%),
|
|
||||||
repeating-conic-gradient(
|
|
||||||
from 236.84deg at 50% 50%,
|
|
||||||
#dd7bbb 0%,
|
|
||||||
#d79f1e calc(25% / var(--repeating-conic-gradient-times)),
|
|
||||||
#5a922c calc(50% / var(--repeating-conic-gradient-times)),
|
|
||||||
#4c7894 calc(75% / var(--repeating-conic-gradient-times)),
|
|
||||||
#dd7bbb calc(100% / var(--repeating-conic-gradient-times))
|
|
||||||
)`,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute inset-0 rounded-[inherit] opacity-100 transition-opacity",
|
|
||||||
glow && "opacity-100",
|
|
||||||
blur > 0 && "blur-[var(--blur)] ",
|
|
||||||
className,
|
|
||||||
disabled && "!hidden"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"glow",
|
|
||||||
"rounded-[inherit]",
|
|
||||||
'after:content-[""] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--glowingeffect-border-width))]',
|
|
||||||
"after:[border:var(--glowingeffect-border-width)_solid_transparent]",
|
|
||||||
"after:[background:var(--gradient)] after:[background-attachment:fixed]",
|
|
||||||
"after:opacity-[var(--active)] after:transition-opacity after:duration-300",
|
|
||||||
"after:[mask-clip:padding-box,border-box]",
|
|
||||||
"after:[mask-composite:intersect]",
|
|
||||||
"after:[mask-image:linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
GlowingEffect.displayName = "GlowingEffect";
|
|
||||||
|
|
||||||
export { GlowingEffect };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
data-slot="input"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Input }
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Label as LabelPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Label({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<LabelPrimitive.Root
|
|
||||||
data-slot="label"
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Label }
|
|
||||||
1844
src/components/ui/map.tsx
Normal file
1844
src/components/ui/map.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Separator({
|
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
decorative = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
data-slot="separator"
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Separator }
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Tabs({
|
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Root
|
|
||||||
data-slot="tabs"
|
|
||||||
data-orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
|
||||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-muted",
|
|
||||||
line: "gap-1 bg-transparent",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function TabsList({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
|
||||||
VariantProps<typeof tabsListVariants>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.List
|
|
||||||
data-slot="tabs-list"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(tabsListVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsTrigger({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Trigger
|
|
||||||
data-slot="tabs-trigger"
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
|
||||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
|
||||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Content
|
|
||||||
data-slot="tabs-content"
|
|
||||||
className={cn("flex-1 text-sm outline-none", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
|
||||||
596
src/components/ui/webcam-pixel-grid.tsx
Normal file
596
src/components/ui/webcam-pixel-grid.tsx
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useRef, useEffect, useState, useCallback } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Rotstufen: dunkel → hell (Helligkeit der Webcam steuert Position im Verlauf). */
|
||||||
|
const DEFAULT_REDSCALE_STOPS: string[] = [
|
||||||
|
"#4B1C1B",
|
||||||
|
"#6A2725",
|
||||||
|
"#883330",
|
||||||
|
"#B54440",
|
||||||
|
"#C55D59",
|
||||||
|
"#CF7A77",
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseHexRgb(hex: string): { r: number; g: number; b: number } {
|
||||||
|
const h = hex.replace("#", "");
|
||||||
|
return {
|
||||||
|
r: parseInt(h.slice(0, 2), 16),
|
||||||
|
g: parseInt(h.slice(2, 4), 16),
|
||||||
|
b: parseInt(h.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleRedscale(
|
||||||
|
t: number,
|
||||||
|
stops: { r: number; g: number; b: number }[],
|
||||||
|
): { r: number; g: number; b: number } {
|
||||||
|
if (stops.length === 0) return { r: 0, g: 0, b: 0 };
|
||||||
|
if (stops.length === 1) return stops[0];
|
||||||
|
const tClamped = Math.min(1, Math.max(0, t));
|
||||||
|
const scaled = tClamped * (stops.length - 1);
|
||||||
|
const i = Math.min(stops.length - 2, Math.floor(scaled));
|
||||||
|
const f = scaled - i;
|
||||||
|
const a = stops[i];
|
||||||
|
const b = stops[i + 1];
|
||||||
|
return {
|
||||||
|
r: Math.round(a.r + (b.r - a.r) * f),
|
||||||
|
g: Math.round(a.g + (b.g - a.g) * f),
|
||||||
|
b: Math.round(a.b + (b.b - a.b) * f),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebcamPixelGridProps = {
|
||||||
|
/** Number of columns in the grid */
|
||||||
|
gridCols?: number;
|
||||||
|
/** Number of rows in the grid */
|
||||||
|
gridRows?: number;
|
||||||
|
/** Maximum elevation for motion detection */
|
||||||
|
maxElevation?: number;
|
||||||
|
/** Motion sensitivity (0-1) */
|
||||||
|
motionSensitivity?: number;
|
||||||
|
/** Smoothing factor for elevation transitions */
|
||||||
|
elevationSmoothing?: number;
|
||||||
|
/** Color mode: webcam = Farben; monochrome = ein Kanal; redscale = Rotstufen nach Helligkeit */
|
||||||
|
colorMode?: "webcam" | "monochrome" | "redscale";
|
||||||
|
/** Monochrome mode: einzige Tinte (Hex) */
|
||||||
|
monochromeColor?: string;
|
||||||
|
/** Redscale mode: Stufen von dunkel zu hell (Hex), Standard = Marken-Rotverlauf */
|
||||||
|
redscaleStops?: string[];
|
||||||
|
/** Background color */
|
||||||
|
backgroundColor?: string;
|
||||||
|
/** Whether to mirror the webcam feed */
|
||||||
|
mirror?: boolean;
|
||||||
|
/** Gap between cells (0-1, fraction of cell size) */
|
||||||
|
gapRatio?: number;
|
||||||
|
/** Invert the colors */
|
||||||
|
invertColors?: boolean;
|
||||||
|
/** Darken factor (0-1, 0 = no darkening, 1 = fully dark) */
|
||||||
|
darken?: number;
|
||||||
|
/** Border color for cells */
|
||||||
|
borderColor?: string;
|
||||||
|
/** Border opacity (0-1) */
|
||||||
|
borderOpacity?: number;
|
||||||
|
/** Additional class name */
|
||||||
|
className?: string;
|
||||||
|
/** Callback when webcam access is denied */
|
||||||
|
onWebcamError?: (error: Error) => void;
|
||||||
|
/** Callback when webcam is ready */
|
||||||
|
onWebcamReady?: () => void;
|
||||||
|
/** If true, no toast or floating error UI (still calls onWebcamError) */
|
||||||
|
quietWebcamErrors?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PixelData = {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
motion: number;
|
||||||
|
targetElevation: number;
|
||||||
|
currentElevation: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WebcamPixelGrid: React.FC<WebcamPixelGridProps> = ({
|
||||||
|
gridCols = 64,
|
||||||
|
gridRows = 48,
|
||||||
|
maxElevation = 15,
|
||||||
|
motionSensitivity = 0.4,
|
||||||
|
elevationSmoothing = 0.1,
|
||||||
|
colorMode = "webcam",
|
||||||
|
monochromeColor = "#00ff88",
|
||||||
|
backgroundColor = "#0a0a0a",
|
||||||
|
mirror = true,
|
||||||
|
gapRatio = 0.1,
|
||||||
|
invertColors = false,
|
||||||
|
darken = 0,
|
||||||
|
borderColor = "#ffffff",
|
||||||
|
borderOpacity = 0.08,
|
||||||
|
className,
|
||||||
|
onWebcamError,
|
||||||
|
onWebcamReady,
|
||||||
|
quietWebcamErrors = false,
|
||||||
|
redscaleStops = DEFAULT_REDSCALE_STOPS,
|
||||||
|
}) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const processingCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const displayCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const previousFrameRef = useRef<Uint8ClampedArray | null>(null);
|
||||||
|
const pixelDataRef = useRef<PixelData[][]>([]);
|
||||||
|
const animationRef = useRef<number>(0);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showErrorPopup, setShowErrorPopup] = useState(true);
|
||||||
|
|
||||||
|
// Parse monochrome color
|
||||||
|
const monoRGB = React.useMemo(() => {
|
||||||
|
const hex = monochromeColor.replace("#", "");
|
||||||
|
return {
|
||||||
|
r: parseInt(hex.slice(0, 2), 16),
|
||||||
|
g: parseInt(hex.slice(2, 4), 16),
|
||||||
|
b: parseInt(hex.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}, [monochromeColor]);
|
||||||
|
|
||||||
|
const redscaleRGBStops = React.useMemo(
|
||||||
|
() => redscaleStops.map(parseHexRgb),
|
||||||
|
[redscaleStops],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse border color
|
||||||
|
const borderRGB = React.useMemo(() => {
|
||||||
|
const hex = borderColor.replace("#", "");
|
||||||
|
return {
|
||||||
|
r: parseInt(hex.slice(0, 2), 16),
|
||||||
|
g: parseInt(hex.slice(2, 4), 16),
|
||||||
|
b: parseInt(hex.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}, [borderColor]);
|
||||||
|
|
||||||
|
// Initialize pixel data
|
||||||
|
useEffect(() => {
|
||||||
|
pixelDataRef.current = Array.from({ length: gridRows }, () =>
|
||||||
|
Array.from({ length: gridCols }, () => ({
|
||||||
|
r: 30,
|
||||||
|
g: 30,
|
||||||
|
b: 30,
|
||||||
|
motion: 0,
|
||||||
|
targetElevation: 0,
|
||||||
|
currentElevation: 0,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}, [gridCols, gridRows]);
|
||||||
|
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
// Request camera access
|
||||||
|
const requestCameraAccess = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
throw new Error("Webcam access is not available in this browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
width: { ideal: 640 },
|
||||||
|
height: { ideal: 480 },
|
||||||
|
facingMode: "user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
await videoRef.current.play();
|
||||||
|
setIsReady(true);
|
||||||
|
setError(null);
|
||||||
|
setShowErrorPopup(false);
|
||||||
|
onWebcamReady?.();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error =
|
||||||
|
err instanceof Error ? err : new Error("Webcam access denied");
|
||||||
|
setError(error.message);
|
||||||
|
onWebcamError?.(error);
|
||||||
|
}
|
||||||
|
}, [onWebcamError, onWebcamReady]);
|
||||||
|
|
||||||
|
// Initialize webcam on mount
|
||||||
|
useEffect(() => {
|
||||||
|
requestCameraAccess();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [requestCameraAccess]);
|
||||||
|
|
||||||
|
// Main render loop
|
||||||
|
const render = useCallback(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
const processingCanvas = processingCanvasRef.current;
|
||||||
|
const displayCanvas = displayCanvasRef.current;
|
||||||
|
|
||||||
|
if (!video || !processingCanvas || !displayCanvas || video.readyState < 2) {
|
||||||
|
animationRef.current = requestAnimationFrame(render);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const procCtx = processingCanvas.getContext("2d", {
|
||||||
|
willReadFrequently: true,
|
||||||
|
});
|
||||||
|
const dispCtx = displayCanvas.getContext("2d");
|
||||||
|
|
||||||
|
if (!procCtx || !dispCtx) {
|
||||||
|
animationRef.current = requestAnimationFrame(render);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set processing canvas size to grid dimensions
|
||||||
|
processingCanvas.width = gridCols;
|
||||||
|
processingCanvas.height = gridRows;
|
||||||
|
|
||||||
|
// Draw video to processing canvas (scaled down)
|
||||||
|
procCtx.save();
|
||||||
|
if (mirror) {
|
||||||
|
procCtx.scale(-1, 1);
|
||||||
|
procCtx.drawImage(video, -gridCols, 0, gridCols, gridRows);
|
||||||
|
} else {
|
||||||
|
procCtx.drawImage(video, 0, 0, gridCols, gridRows);
|
||||||
|
}
|
||||||
|
procCtx.restore();
|
||||||
|
|
||||||
|
// Get pixel data
|
||||||
|
const imageData = procCtx.getImageData(0, 0, gridCols, gridRows);
|
||||||
|
const currentData = imageData.data;
|
||||||
|
const previousData = previousFrameRef.current;
|
||||||
|
|
||||||
|
// Update pixel data with motion detection
|
||||||
|
const pixels = pixelDataRef.current;
|
||||||
|
for (let row = 0; row < gridRows; row++) {
|
||||||
|
for (let col = 0; col < gridCols; col++) {
|
||||||
|
const idx = (row * gridCols + col) * 4;
|
||||||
|
const r = currentData[idx];
|
||||||
|
const g = currentData[idx + 1];
|
||||||
|
const b = currentData[idx + 2];
|
||||||
|
|
||||||
|
const pixel = pixels[row]?.[col];
|
||||||
|
if (!pixel) continue;
|
||||||
|
|
||||||
|
// Calculate motion
|
||||||
|
let motion = 0;
|
||||||
|
if (previousData) {
|
||||||
|
const prevR = previousData[idx];
|
||||||
|
const prevG = previousData[idx + 1];
|
||||||
|
const prevB = previousData[idx + 2];
|
||||||
|
const diff =
|
||||||
|
Math.abs(r - prevR) + Math.abs(g - prevG) + Math.abs(b - prevB);
|
||||||
|
motion = Math.min(1, diff / 255 / motionSensitivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth motion
|
||||||
|
pixel.motion = pixel.motion * 0.7 + motion * 0.3;
|
||||||
|
|
||||||
|
// Set colors
|
||||||
|
let finalR = r;
|
||||||
|
let finalG = g;
|
||||||
|
let finalB = b;
|
||||||
|
|
||||||
|
if (colorMode === "monochrome") {
|
||||||
|
const brightness = (r + g + b) / 3 / 255;
|
||||||
|
finalR = Math.round(monoRGB.r * brightness);
|
||||||
|
finalG = Math.round(monoRGB.g * brightness);
|
||||||
|
finalB = Math.round(monoRGB.b * brightness);
|
||||||
|
} else if (colorMode === "redscale") {
|
||||||
|
const brightness = (r + g + b) / 3 / 255;
|
||||||
|
const t = Math.pow(Math.min(1, Math.max(0, brightness)), 0.88);
|
||||||
|
const { r: sr, g: sg, b: sb } = sampleRedscale(t, redscaleRGBStops);
|
||||||
|
finalR = sr;
|
||||||
|
finalG = sg;
|
||||||
|
finalB = sb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply invert
|
||||||
|
if (invertColors) {
|
||||||
|
finalR = 255 - finalR;
|
||||||
|
finalG = 255 - finalG;
|
||||||
|
finalB = 255 - finalB;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply darken
|
||||||
|
if (darken > 0) {
|
||||||
|
const darkenFactor = 1 - darken;
|
||||||
|
finalR = Math.round(finalR * darkenFactor);
|
||||||
|
finalG = Math.round(finalG * darkenFactor);
|
||||||
|
finalB = Math.round(finalB * darkenFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
pixel.r = finalR;
|
||||||
|
pixel.g = finalG;
|
||||||
|
pixel.b = finalB;
|
||||||
|
|
||||||
|
// Set target elevation
|
||||||
|
pixel.targetElevation = pixel.motion * maxElevation;
|
||||||
|
|
||||||
|
// Smooth elevation transition
|
||||||
|
pixel.currentElevation +=
|
||||||
|
(pixel.targetElevation - pixel.currentElevation) * elevationSmoothing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store current frame for next comparison
|
||||||
|
previousFrameRef.current = new Uint8ClampedArray(currentData);
|
||||||
|
|
||||||
|
// Render to display canvas
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const displayWidth = displayCanvas.clientWidth;
|
||||||
|
const displayHeight = displayCanvas.clientHeight;
|
||||||
|
|
||||||
|
displayCanvas.width = displayWidth * dpr;
|
||||||
|
displayCanvas.height = displayHeight * dpr;
|
||||||
|
dispCtx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
dispCtx.fillStyle = backgroundColor;
|
||||||
|
dispCtx.fillRect(0, 0, displayWidth, displayHeight);
|
||||||
|
|
||||||
|
// Calculate cell size (always square, cover entire container like object-fit: cover)
|
||||||
|
const cellSize = Math.max(
|
||||||
|
displayWidth / gridCols,
|
||||||
|
displayHeight / gridRows,
|
||||||
|
);
|
||||||
|
const gap = cellSize * gapRatio;
|
||||||
|
|
||||||
|
// Calculate offset to center the grid (negative offset for overflow, creating cover effect)
|
||||||
|
const gridWidth = cellSize * gridCols;
|
||||||
|
const gridHeight = cellSize * gridRows;
|
||||||
|
const offsetXGrid = (displayWidth - gridWidth) / 2;
|
||||||
|
const offsetYGrid = (displayHeight - gridHeight) / 2;
|
||||||
|
|
||||||
|
// Draw cells with 3D effect
|
||||||
|
for (let row = 0; row < gridRows; row++) {
|
||||||
|
for (let col = 0; col < gridCols; col++) {
|
||||||
|
const pixel = pixels[row]?.[col];
|
||||||
|
if (!pixel) continue;
|
||||||
|
|
||||||
|
const x = offsetXGrid + col * cellSize;
|
||||||
|
const y = offsetYGrid + row * cellSize;
|
||||||
|
const elevation = pixel.currentElevation;
|
||||||
|
|
||||||
|
// Calculate 3D offset (isometric-like projection) - MUCH larger effect
|
||||||
|
const offsetX = -elevation * 1.2;
|
||||||
|
const offsetY = -elevation * 1.8;
|
||||||
|
|
||||||
|
// Draw shadow - larger and more visible
|
||||||
|
if (elevation > 0.5) {
|
||||||
|
dispCtx.fillStyle = `rgba(0, 0, 0, ${Math.min(0.6, elevation * 0.04)})`;
|
||||||
|
dispCtx.fillRect(
|
||||||
|
x + gap / 2 + elevation * 1.5,
|
||||||
|
y + gap / 2 + elevation * 2.0,
|
||||||
|
cellSize - gap,
|
||||||
|
cellSize - gap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw side faces for 3D effect - thicker sides
|
||||||
|
if (elevation > 0.5) {
|
||||||
|
// Right side
|
||||||
|
dispCtx.fillStyle = `rgb(${Math.max(0, pixel.r - 80)}, ${Math.max(0, pixel.g - 80)}, ${Math.max(0, pixel.b - 80)})`;
|
||||||
|
dispCtx.beginPath();
|
||||||
|
dispCtx.moveTo(
|
||||||
|
x + cellSize - gap / 2 + offsetX,
|
||||||
|
y + gap / 2 + offsetY,
|
||||||
|
);
|
||||||
|
dispCtx.lineTo(x + cellSize - gap / 2, y + gap / 2);
|
||||||
|
dispCtx.lineTo(x + cellSize - gap / 2, y + cellSize - gap / 2);
|
||||||
|
dispCtx.lineTo(
|
||||||
|
x + cellSize - gap / 2 + offsetX,
|
||||||
|
y + cellSize - gap / 2 + offsetY,
|
||||||
|
);
|
||||||
|
dispCtx.closePath();
|
||||||
|
dispCtx.fill();
|
||||||
|
|
||||||
|
// Bottom side
|
||||||
|
dispCtx.fillStyle = `rgb(${Math.max(0, pixel.r - 50)}, ${Math.max(0, pixel.g - 50)}, ${Math.max(0, pixel.b - 50)})`;
|
||||||
|
dispCtx.beginPath();
|
||||||
|
dispCtx.moveTo(
|
||||||
|
x + gap / 2 + offsetX,
|
||||||
|
y + cellSize - gap / 2 + offsetY,
|
||||||
|
);
|
||||||
|
dispCtx.lineTo(x + gap / 2, y + cellSize - gap / 2);
|
||||||
|
dispCtx.lineTo(x + cellSize - gap / 2, y + cellSize - gap / 2);
|
||||||
|
dispCtx.lineTo(
|
||||||
|
x + cellSize - gap / 2 + offsetX,
|
||||||
|
y + cellSize - gap / 2 + offsetY,
|
||||||
|
);
|
||||||
|
dispCtx.closePath();
|
||||||
|
dispCtx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw top face (main cell) - brighter when elevated
|
||||||
|
const brightness = 1 + elevation * 0.05;
|
||||||
|
dispCtx.fillStyle = `rgb(${Math.min(255, Math.round(pixel.r * brightness))}, ${Math.min(255, Math.round(pixel.g * brightness))}, ${Math.min(255, Math.round(pixel.b * brightness))})`;
|
||||||
|
dispCtx.fillRect(
|
||||||
|
x + gap / 2 + offsetX,
|
||||||
|
y + gap / 2 + offsetY,
|
||||||
|
cellSize - gap,
|
||||||
|
cellSize - gap,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw light border around top face
|
||||||
|
dispCtx.strokeStyle = `rgba(${borderRGB.r}, ${borderRGB.g}, ${borderRGB.b}, ${borderOpacity + elevation * 0.008})`;
|
||||||
|
dispCtx.lineWidth = 0.5;
|
||||||
|
dispCtx.strokeRect(
|
||||||
|
x + gap / 2 + offsetX,
|
||||||
|
y + gap / 2 + offsetY,
|
||||||
|
cellSize - gap,
|
||||||
|
cellSize - gap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(render);
|
||||||
|
}, [
|
||||||
|
gridCols,
|
||||||
|
gridRows,
|
||||||
|
mirror,
|
||||||
|
motionSensitivity,
|
||||||
|
colorMode,
|
||||||
|
monoRGB,
|
||||||
|
redscaleRGBStops,
|
||||||
|
maxElevation,
|
||||||
|
elevationSmoothing,
|
||||||
|
backgroundColor,
|
||||||
|
gapRatio,
|
||||||
|
invertColors,
|
||||||
|
darken,
|
||||||
|
borderRGB,
|
||||||
|
borderOpacity,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Start render loop when ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isReady) return;
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(render);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
};
|
||||||
|
}, [isReady, render]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative h-full w-full", className)}>
|
||||||
|
{/* Hidden video element */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="pointer-events-none absolute h-0 w-0 opacity-0"
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hidden processing canvas */}
|
||||||
|
<canvas
|
||||||
|
ref={processingCanvasRef}
|
||||||
|
className="pointer-events-none absolute h-0 w-0 opacity-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Display canvas with fade-in */}
|
||||||
|
<canvas
|
||||||
|
ref={displayCanvasRef}
|
||||||
|
className={cn(
|
||||||
|
"h-full w-full transition-opacity duration-1000",
|
||||||
|
isReady ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Error popup */}
|
||||||
|
{error && showErrorPopup && !quietWebcamErrors && (
|
||||||
|
<div className="animate-in fade-in slide-in-from-top-2 fixed top-4 right-4 z-50 duration-300">
|
||||||
|
<div className="relative flex max-w-sm items-start gap-3 rounded-lg border border-white/10 bg-black/80 p-4 shadow-2xl backdrop-blur-xl">
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowErrorPopup(false)}
|
||||||
|
className="absolute top-2 right-2 rounded-md p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/70"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Camera icon */}
|
||||||
|
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/10">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-white/60"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 pr-4">
|
||||||
|
<p className="text-sm font-medium text-white/90">
|
||||||
|
Camera access needed
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-white/50">
|
||||||
|
Enable camera for the interactive background effect
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={requestCameraAccess}
|
||||||
|
className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-white/10 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Enable Camera
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Minimized error indicator */}
|
||||||
|
{error && !showErrorPopup && !quietWebcamErrors && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowErrorPopup(true)}
|
||||||
|
className="fixed top-4 right-4 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-black/60 text-white/50 shadow-lg backdrop-blur-xl transition-all hover:scale-105 hover:bg-black/80 hover:text-white/80"
|
||||||
|
title="Camera access required"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 3l18 18"
|
||||||
|
className="text-red-400"
|
||||||
|
stroke="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebcamPixelGrid;
|
||||||
38
src/lib/cookie-banner-info.ts
Normal file
38
src/lib/cookie-banner-info.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { run } from "vanilla-cookieconsent";
|
||||||
|
import "vanilla-cookieconsent/dist/cookieconsent.css";
|
||||||
|
|
||||||
|
const bannerText =
|
||||||
|
"Diese Website setzt keine Cookies. Wir erfassen ausschließlich anonymisierte Analytics-Daten, die keinen Rückschluss auf einzelne Nutzer zulassen.";
|
||||||
|
|
||||||
|
export function initCookieInfoBanner(): void {
|
||||||
|
if (typeof globalThis.window === "undefined") return;
|
||||||
|
|
||||||
|
void run({
|
||||||
|
hideFromBots: false,
|
||||||
|
categories: {
|
||||||
|
necessary: {
|
||||||
|
enabled: true,
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cookie: {
|
||||||
|
name: "cc_notice",
|
||||||
|
useLocalStorage: true,
|
||||||
|
expiresAfterDays: 365,
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
default: "de",
|
||||||
|
translations: {
|
||||||
|
de: {
|
||||||
|
consentModal: {
|
||||||
|
title: "Datenschutzhinweis",
|
||||||
|
description: bannerText,
|
||||||
|
acceptNecessaryBtn: "Verstanden",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error("[CookieBanner]", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
153
src/pages/datenschutz.astro
Normal file
153
src/pages/datenschutz.astro
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
---
|
||||||
|
import "@/styles/global.css";
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<title>Datenschutz | Matthias Meister Softwareentwicklung</title>
|
||||||
|
<script
|
||||||
|
src="https://rybbit.matthias.lol/api/script.js"
|
||||||
|
data-site-id="60abc81e438a"
|
||||||
|
defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main
|
||||||
|
class="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 lg:px-12"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground transition hover:text-foreground"
|
||||||
|
>
|
||||||
|
Matthias Meister
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="mx-auto grid max-w-5xl gap-12 py-16 lg:grid-cols-[0.42fr_0.58fr] lg:py-24"
|
||||||
|
>
|
||||||
|
<div class="lg:sticky lg:top-8 lg:self-start">
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Datenschutz
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
class="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl"
|
||||||
|
>
|
||||||
|
Ihre Daten
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-10 text-lg leading-8 text-foreground/85">
|
||||||
|
<section>
|
||||||
|
<h2
|
||||||
|
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||||
|
>
|
||||||
|
Verantwortlicher
|
||||||
|
</h2>
|
||||||
|
<div class="mt-4 space-y-1 text-muted-foreground">
|
||||||
|
<p>Matthias Meister Softwareentwicklung</p>
|
||||||
|
<p>Inhaber: Matthias Meister</p>
|
||||||
|
<p>Karl-Marx-Str. 22</p>
|
||||||
|
<p>08451 Crimmitschau</p>
|
||||||
|
<p>
|
||||||
|
E-Mail:
|
||||||
|
<a
|
||||||
|
class="underline underline-offset-4 transition hover:text-foreground"
|
||||||
|
href="mailto:support@matthias-meister-webdesign.de"
|
||||||
|
>
|
||||||
|
support@matthias-meister-webdesign.de
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2
|
||||||
|
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||||
|
>
|
||||||
|
Keine Cookies
|
||||||
|
</h2>
|
||||||
|
<p class="mt-4 text-muted-foreground">
|
||||||
|
Diese Website setzt keine Cookies. Es gibt keine Nutzerkonten,
|
||||||
|
keinen Newsletter, keine Zahlungsabwicklung und keine
|
||||||
|
eingebetteten Drittanbieter-Medien.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2
|
||||||
|
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||||
|
>
|
||||||
|
Kontakt per E-Mail
|
||||||
|
</h2>
|
||||||
|
<p class="mt-4 text-muted-foreground">
|
||||||
|
Wenn Sie über einen mailto-Link Kontakt aufnehmen, werden die von
|
||||||
|
Ihnen per E-Mail übermittelten Angaben zur Bearbeitung Ihrer
|
||||||
|
Anfrage verarbeitet. Auf dieser Website wird dafür kein
|
||||||
|
Kontaktformular gespeichert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2
|
||||||
|
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||||
|
>
|
||||||
|
Reichweitenmessung mit Rybbit
|
||||||
|
</h2>
|
||||||
|
<p class="mt-4 text-muted-foreground">
|
||||||
|
Diese Website nutzt Rybbit Analytics, um anonymisierte und
|
||||||
|
aggregierte Statistiken zur Nutzung der Website zu erhalten.
|
||||||
|
Rybbit verwendet keine Cookies oder local storage für das
|
||||||
|
Tracking.
|
||||||
|
</p>
|
||||||
|
<p class="mt-4 text-muted-foreground">
|
||||||
|
Dabei können technische Besuchsdaten wie aufgerufene Seiten,
|
||||||
|
Referrer, Browser- und Geräteinformationen sowie aus der
|
||||||
|
IP-Adresse abgeleitete ungefähre Standortdaten verarbeitet werden.
|
||||||
|
Die IP-Adresse wird dabei nur vorübergehend zur Verarbeitung
|
||||||
|
genutzt und nicht als Klartext in der Analysedatenbank
|
||||||
|
gespeichert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2
|
||||||
|
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||||
|
>
|
||||||
|
Ihre Rechte
|
||||||
|
</h2>
|
||||||
|
<p class="mt-4 text-muted-foreground">
|
||||||
|
Sie haben im Rahmen der gesetzlichen Vorgaben insbesondere Rechte
|
||||||
|
auf Auskunft, Berichtigung, Löschung, Einschränkung der
|
||||||
|
Verarbeitung und Widerspruch gegen die Verarbeitung Ihrer
|
||||||
|
personenbezogenen Daten. Bitte wenden Sie sich dafür an die oben
|
||||||
|
genannte Kontaktadresse.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="border-t border-border pt-8 text-sm text-muted-foreground">
|
||||||
|
Hinweis: Dieser Text wird fortlaufend aktualisiert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<nav class="flex flex-wrap gap-5 text-sm text-muted-foreground">
|
||||||
|
<a
|
||||||
|
class="underline underline-offset-4 transition hover:text-foreground"
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
Startseite
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="underline underline-offset-4 transition hover:text-foreground"
|
||||||
|
href="/impressum"
|
||||||
|
>
|
||||||
|
Impressum
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
106
src/pages/impressum.astro
Normal file
106
src/pages/impressum.astro
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
import "@/styles/global.css";
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<title>Impressum | Matthias Meister Softwareentwicklung</title>
|
||||||
|
<script
|
||||||
|
src="https://rybbit.matthias.lol/api/script.js"
|
||||||
|
data-site-id="60abc81e438a"
|
||||||
|
defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main
|
||||||
|
class="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 lg:px-12"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground transition hover:text-foreground"
|
||||||
|
>
|
||||||
|
Matthias Meister
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="mx-auto grid max-w-5xl gap-12 py-16 lg:grid-cols-[0.42fr_0.58fr] lg:py-24"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Rechtliches
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
class="mt-6 max-w-[8ch] text-5xl font-black uppercase leading-[0.86] sm:text-6xl"
|
||||||
|
>
|
||||||
|
Impressum
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-10 text-lg leading-8 text-foreground/85">
|
||||||
|
<section>
|
||||||
|
<h2
|
||||||
|
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||||
|
>
|
||||||
|
Angaben gemäß § 5 TMG
|
||||||
|
</h2>
|
||||||
|
<div class="mt-4 space-y-1 text-muted-foreground">
|
||||||
|
<p>Matthias Meister Softwareentwicklung</p>
|
||||||
|
<p>Inhaber: Matthias Meister</p>
|
||||||
|
<p>Karl-Marx-Str. 22</p>
|
||||||
|
<p>08451 Crimmitschau</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2
|
||||||
|
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||||
|
>
|
||||||
|
Umsatzsteuer
|
||||||
|
</h2>
|
||||||
|
<p class="mt-4 text-muted-foreground">
|
||||||
|
Umsatzsteuer-Identifikationsnummer: DE460155697
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2
|
||||||
|
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||||
|
>
|
||||||
|
Kontakt
|
||||||
|
</h2>
|
||||||
|
<p class="mt-4 text-muted-foreground">
|
||||||
|
E-Mail:
|
||||||
|
<a
|
||||||
|
class="underline underline-offset-4 transition hover:text-foreground"
|
||||||
|
href="mailto:support@matthias-meister-webdesign.de"
|
||||||
|
>
|
||||||
|
support@matthias-meister-webdesign.de
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
class="flex flex-wrap gap-5 border-t border-border pt-8 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="underline underline-offset-4 transition hover:text-foreground"
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
Startseite
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="underline underline-offset-4 transition hover:text-foreground"
|
||||||
|
href="/datenschutz"
|
||||||
|
>
|
||||||
|
Datenschutz
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
---
|
---
|
||||||
import { CanvaLanding } from "@/components/canva-landing";
|
import { LandingHeroSection } from "@/components/landing-hero-section";
|
||||||
|
import { LandingPageSections } from "@/components/landing-page-sections";
|
||||||
|
import { ContactSection } from "@/components/landing/contact-section";
|
||||||
|
import { Footer27 } from "@/components/footer27";
|
||||||
import "@/styles/global.css";
|
import "@/styles/global.css";
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,6 +20,15 @@ import "@/styles/global.css";
|
|||||||
defer></script>
|
defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<CanvaLanding />
|
<main class="min-h-screen overflow-hidden bg-background text-foreground">
|
||||||
|
<LandingHeroSection client:media="(min-width: 1024px)" />
|
||||||
|
<LandingPageSections />
|
||||||
|
<ContactSection client:visible />
|
||||||
|
<Footer27 />
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
import { initCookieInfoBanner } from "@/lib/cookie-banner-info";
|
||||||
|
initCookieInfoBanner();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -129,4 +129,31 @@
|
|||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
}
|
.maplibregl-popup-content {
|
||||||
|
@apply bg-transparent! shadow-none! p-0! rounded-none!;
|
||||||
|
}
|
||||||
|
.maplibregl-popup-tip {
|
||||||
|
@apply hidden!;
|
||||||
|
}
|
||||||
|
.contact-map .maplibregl-canvas {
|
||||||
|
/* Dunkle Carto-Tiles: Sepia kippt alles in Braun, hue-rotate(325deg)
|
||||||
|
schiebt Braun (~38°) auf Rot (~3°). Helle Straßen/Labels werden
|
||||||
|
zu hellem Rot, der dunkle Hintergrund zu dunklem Rot. */
|
||||||
|
filter: sepia(1) hue-rotate(325deg) saturate(4.5) brightness(0.82);
|
||||||
|
}
|
||||||
|
.contact-map .maplibregl-ctrl-attrib {
|
||||||
|
background: oklch(0.115 0.012 22 / 70%) !important;
|
||||||
|
}
|
||||||
|
.contact-map .maplibregl-ctrl-attrib a {
|
||||||
|
color: oklch(0.985 0.01 76 / 80%) !important;
|
||||||
|
}
|
||||||
|
.contact-map .maplibregl-ctrl-group {
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.contact-map .maplibregl-ctrl-group button {
|
||||||
|
background: oklch(0.115 0.012 22 / 80%) !important;
|
||||||
|
color: oklch(0.985 0.01 76) !important;
|
||||||
|
border-color: oklch(0.985 0.01 76 / 20%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tests/cookie-banner-info.test.mjs
Normal file
14
tests/cookie-banner-info.test.mjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
const sourcePath = new URL("../src/lib/cookie-banner-info.ts", import.meta.url);
|
||||||
|
|
||||||
|
test("cookie banner uses the native CookieConsent close behavior", async () => {
|
||||||
|
const source = await readFile(sourcePath, "utf8");
|
||||||
|
|
||||||
|
assert.match(source, /from "vanilla-cookieconsent"/);
|
||||||
|
assert.match(source, /acceptNecessaryBtn: "Verstanden"/);
|
||||||
|
assert.doesNotMatch(source, /motion\/mini/);
|
||||||
|
assert.doesNotMatch(source, /cookie-info-fab/);
|
||||||
|
});
|
||||||
@@ -2,18 +2,33 @@ import { readFile } from "node:fs/promises";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
const componentPath = new URL("../src/components/canva-landing.tsx", import.meta.url);
|
const sourcePaths = [
|
||||||
|
new URL("../src/components/landing-hero-section.tsx", import.meta.url),
|
||||||
|
new URL("../src/components/landing/services-section.tsx", import.meta.url),
|
||||||
|
new URL("../src/components/landing/deliverables-section.tsx", import.meta.url),
|
||||||
|
new URL("../src/components/landing/packages-section.tsx", import.meta.url),
|
||||||
|
new URL("../src/components/landing/contact-section.tsx", import.meta.url),
|
||||||
|
];
|
||||||
|
|
||||||
test("Canva landing component contains the core brief anchors", async () => {
|
const footerPath = new URL("../src/components/footer27.tsx", import.meta.url);
|
||||||
const source = await readFile(componentPath, "utf8");
|
const indexPath = new URL("../src/pages/index.astro", import.meta.url);
|
||||||
|
const impressumPath = new URL("../src/pages/impressum.astro", import.meta.url);
|
||||||
|
const datenschutzPath = new URL("../src/pages/datenschutz.astro", import.meta.url);
|
||||||
|
|
||||||
for (const phrase of ["Projektbrief", "01", "Website", "Kontakt", "für", "müssen", "Änderungen"]) {
|
test("Landing component contains the core brief anchors", async () => {
|
||||||
|
const source = (
|
||||||
|
await Promise.all(sourcePaths.map((p) => readFile(p, "utf8")))
|
||||||
|
).join("\n");
|
||||||
|
|
||||||
|
for (const phrase of ["Online Fertig Passt", "01", "Website", "Kontakt", "für", "müssen", "Änderungen"]) {
|
||||||
assert.match(source, new RegExp(phrase));
|
assert.match(source, new RegExp(phrase));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Canva landing component uses real German umlauts in visible copy", async () => {
|
test("Landing component uses real German umlauts in visible copy", async () => {
|
||||||
const source = await readFile(componentPath, "utf8");
|
const source = (
|
||||||
|
await Promise.all(sourcePaths.map((p) => readFile(p, "utf8")))
|
||||||
|
).join("\n");
|
||||||
|
|
||||||
for (const asciiFallback of [
|
for (const asciiFallback of [
|
||||||
"fuer",
|
"fuer",
|
||||||
@@ -30,3 +45,44 @@ test("Canva landing component uses real German umlauts in visible copy", async (
|
|||||||
assert.doesNotMatch(source, new RegExp(asciiFallback));
|
assert.doesNotMatch(source, new RegExp(asciiFallback));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Landing page renders the footer with legal links", async () => {
|
||||||
|
const [indexSource, footerSource] = await Promise.all([
|
||||||
|
readFile(indexPath, "utf8"),
|
||||||
|
readFile(footerPath, "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(indexSource, /import\s+\{\s*Footer27\s*\}/);
|
||||||
|
assert.match(indexSource, /<Footer27\s*\/>/);
|
||||||
|
assert.match(footerSource, /href="\/impressum"/);
|
||||||
|
assert.match(footerSource, /href="\/datenschutz"/);
|
||||||
|
assert.match(footerSource, /© 2026 Matthias Meister Webdesign/);
|
||||||
|
assert.doesNotMatch(footerSource, /Bereit für eine Website/);
|
||||||
|
assert.doesNotMatch(footerSource, /Kostenloses Angebot anfordern/);
|
||||||
|
assert.doesNotMatch(footerSource, /max-w-6xl/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Legal pages contain required business and privacy information", async () => {
|
||||||
|
const [impressumSource, datenschutzSource] = await Promise.all([
|
||||||
|
readFile(impressumPath, "utf8"),
|
||||||
|
readFile(datenschutzPath, "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const phrase of [
|
||||||
|
"Matthias Meister Softwareentwicklung",
|
||||||
|
"Karl-Marx-Str. 22",
|
||||||
|
"08451 Crimmitschau",
|
||||||
|
"DE460155697",
|
||||||
|
]) {
|
||||||
|
assert.match(impressumSource, new RegExp(phrase));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const phrase of [
|
||||||
|
"keine Cookies",
|
||||||
|
"mailto",
|
||||||
|
"Rybbit",
|
||||||
|
"rybbit.matthias.lol",
|
||||||
|
]) {
|
||||||
|
assert.match(datenschutzSource, new RegExp(phrase));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user