Split landing hero into interactive webcam grid
- Move hero into its own client component - Add webcam-backed pixel grid background - Update landing wiring and content test coverage
This commit is contained in:
@@ -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,13 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
145
src/components/landing-hero-section.tsx
Normal file
145
src/components/landing-hero-section.tsx
Normal file
@@ -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 (
|
||||||
|
<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 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.12}
|
||||||
|
darken={0.08}
|
||||||
|
borderColor="#ffffff"
|
||||||
|
borderOpacity={0.05}
|
||||||
|
quietWebcamErrors
|
||||||
|
/>
|
||||||
|
</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>Creative Direction</span>
|
||||||
|
<span>2026</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 das Raster."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={liveRasterOn}
|
||||||
|
aria-label={
|
||||||
|
liveRasterOn
|
||||||
|
? "Live-Raster und Kamera beenden"
|
||||||
|
: "Live-Raster mit Kamera starten"
|
||||||
|
}
|
||||||
|
onClick={() => setLiveRasterOn((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"relative h-9 w-[3.25rem] shrink-0 rounded-full border transition-colors duration-300 focus-visible:outline focus-visible:outline-2 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>
|
||||||
|
|
||||||
|
<div className="relative flex min-h-0 flex-1 flex-col justify-center py-10 lg:py-14">
|
||||||
|
<div className="pointer-events-none absolute bottom-8 right-0 h-36 w-36 border border-primary-foreground/35 sm:bottom-10 sm:right-2 lg:bottom-16 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">
|
||||||
|
Klarer Auftritt. Harte Kante. Weniger Agenturlärm.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#kontakt"
|
||||||
|
className="group relative z-10 mt-auto 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"
|
||||||
|
>
|
||||||
|
Projekt anfragen
|
||||||
|
<ArrowUpRight className="size-5 transition group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { LandingHeroSection };
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
ArrowUpRight,
|
|
||||||
Check,
|
Check,
|
||||||
CornerDownRight,
|
CornerDownRight,
|
||||||
Mail,
|
Mail,
|
||||||
@@ -51,72 +50,9 @@ const packages = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const Landing = () => {
|
const LandingRest = () => {
|
||||||
return (
|
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
|
<section
|
||||||
id="leistungen"
|
id="leistungen"
|
||||||
className="grid border-b border-border lg:grid-cols-[0.36fr_0.64fr]"
|
className="grid border-b border-border lg:grid-cols-[0.36fr_0.64fr]"
|
||||||
@@ -242,8 +178,8 @@ const Landing = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { Landing };
|
export { LandingRest };
|
||||||
|
|||||||
592
src/components/ui/webcam-pixel-grid.tsx
Normal file
592
src/components/ui/webcam-pixel-grid.tsx
Normal file
@@ -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<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 {
|
||||||
|
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;
|
||||||
@@ -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";
|
import "@/styles/global.css";
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,6 +18,9 @@ import "@/styles/global.css";
|
|||||||
defer></script>
|
defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Landing />
|
<main class="min-h-screen overflow-hidden bg-background text-foreground">
|
||||||
|
<LandingHeroSection client:media="(min-width: 1024px)" />
|
||||||
|
<LandingRest />
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ 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/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 () => {
|
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"]) {
|
for (const phrase of ["Projektbrief", "01", "Website", "Kontakt", "für", "müssen", "Änderungen"]) {
|
||||||
assert.match(source, new RegExp(phrase));
|
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 () => {
|
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user