diff --git a/components.json b/components.json index 68fb4cc..7749d1c 100644 --- a/components.json +++ b/components.json @@ -12,6 +12,8 @@ }, "iconLibrary": "lucide", "rtl": false, + "menuColor": "default", + "menuAccent": "subtle", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -19,14 +21,13 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "menuColor": "default", - "menuAccent": "subtle", "registries": { "@shadcnblocks": { "url": "https://shadcnblocks.com/r/{name}", "headers": { "Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}" } - } + }, + "@aceternity": "https://ui.aceternity.com/registry/{name}.json" } } diff --git a/src/components/landing-hero-section.tsx b/src/components/landing-hero-section.tsx new file mode 100644 index 0000000..6ffbf8a --- /dev/null +++ b/src/components/landing-hero-section.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { ArrowUpRight } from "lucide-react"; +import { 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); + + return ( +
+
+
+ + Matthias Meister + + +
+ +
+

+ Projektbrief für regionale Unternehmen +

+

+ Website ohne Umweg +

+
+

+ Strategie trifft Umsetzung +

+

+ 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. +

+
+
+ +
+ Antwort in 24h + DSGVO-arm + Hosting aus DE +
+
+ + +
+ ); +}; + +export { LandingHeroSection }; diff --git a/src/components/landing.tsx b/src/components/landing.tsx index 0646759..2ef37c5 100644 --- a/src/components/landing.tsx +++ b/src/components/landing.tsx @@ -1,5 +1,4 @@ import { - ArrowUpRight, Check, CornerDownRight, Mail, @@ -51,72 +50,9 @@ const packages = [ }, ]; -const Landing = () => { +const LandingRest = () => { return ( -
-
-
-
- - Matthias Meister - - -
- -
-

- Projektbrief für regionale Unternehmen -

-

- Website ohne Umweg -

-
-

- Strategie trifft Umsetzung -

-

- 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. -

-
-
- -
- Antwort in 24h - DSGVO-arm - Hosting aus DE -
-
- - -
- + <>
{
-
+ ); }; -export { Landing }; +export { LandingRest }; diff --git a/src/components/ui/webcam-pixel-grid.tsx b/src/components/ui/webcam-pixel-grid.tsx new file mode 100644 index 0000000..4858aa9 --- /dev/null +++ b/src/components/ui/webcam-pixel-grid.tsx @@ -0,0 +1,592 @@ +"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 = ({ + 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(null); + const processingCanvasRef = useRef(null); + const displayCanvasRef = useRef(null); + const previousFrameRef = useRef(null); + const pixelDataRef = useRef([]); + const animationRef = useRef(0); + const [isReady, setIsReady] = useState(false); + const [error, setError] = useState(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(null); + + // Request camera access + const requestCameraAccess = useCallback(async () => { + try { + 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 ( +
+ {/* Hidden video element */} +
+ ); +}; + +export default WebcamPixelGrid; diff --git a/src/pages/index.astro b/src/pages/index.astro index 20633c9..b701f08 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,5 +1,6 @@ --- -import { Landing } from "@/components/landing"; +import { LandingHeroSection } from "@/components/landing-hero-section"; +import { LandingRest } from "@/components/landing"; import "@/styles/global.css"; --- @@ -17,6 +18,9 @@ import "@/styles/global.css"; defer> - +
+ + +
diff --git a/tests/landing-content.test.mjs b/tests/landing-content.test.mjs index 42d7e5f..353c25b 100644 --- a/tests/landing-content.test.mjs +++ b/tests/landing-content.test.mjs @@ -2,10 +2,15 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; import assert from "node:assert/strict"; -const componentPath = new URL("../src/components/landing.tsx", import.meta.url); +const sourcePaths = [ + new URL("../src/components/landing.tsx", import.meta.url), + new URL("../src/components/landing-hero-section.tsx", import.meta.url), +]; test("Landing component contains the core brief anchors", async () => { - const source = await readFile(componentPath, "utf8"); + const source = ( + await Promise.all(sourcePaths.map((p) => readFile(p, "utf8"))) + ).join("\n"); for (const phrase of ["Projektbrief", "01", "Website", "Kontakt", "für", "müssen", "Änderungen"]) { assert.match(source, new RegExp(phrase)); @@ -13,7 +18,9 @@ test("Landing component contains the core brief anchors", 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 [ "fuer",