Footer überarbeitet: neues Design mit Flickering-Grid-Schriftzug
Neues Footer-Layout (Link-Spalten mit Chevron-Hover + animierter WEBklar-Schriftzug via FlickeringGrid). Inhalt (Branding, Seiten-, Kontakt- und Rechtliches-Links) aus dem bisherigen Footer übernommen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,91 +1,120 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import Logo from "@/components/Logo";
|
||||
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
type FooterLink = {
|
||||
id: number;
|
||||
title: string;
|
||||
/** internal react-router route */
|
||||
to?: string;
|
||||
/** anchor / mailto / tel / external */
|
||||
href?: string;
|
||||
};
|
||||
|
||||
const footerLinks: { title: string; links: FooterLink[] }[] = [
|
||||
{
|
||||
title: "Seiten",
|
||||
links: [
|
||||
{ id: 1, title: "Startseite", to: "/" },
|
||||
{ id: 2, title: "Über uns", to: "/ueber-uns" },
|
||||
{ id: 3, title: "Leistungen", href: "/#services" },
|
||||
{ id: 4, title: "Kontakt", to: "/kontakt" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Kontakt",
|
||||
links: [
|
||||
{ id: 5, title: "support@webklar.com", href: "mailto:support@webklar.com" },
|
||||
{ id: 6, title: "0170 4969375", href: "tel:+491704969375" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Rechtliches",
|
||||
links: [
|
||||
{ id: 7, title: "AGB", to: "/agb" },
|
||||
{ id: 8, title: "Impressum", to: "/impressum" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden border-t border-white/[0.1] bg-black px-8 py-20 dark:border-white/[0.1] dark:bg-black">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-start justify-between gap-12 text-sm text-neutral-500 sm:flex-row md:px-8">
|
||||
<div>
|
||||
<div className="mr-0 mb-4 md:mr-4 md:flex">
|
||||
<Link
|
||||
to="/"
|
||||
className="relative z-20 mr-4 flex items-center space-x-2 px-2 py-1 text-sm font-normal text-white"
|
||||
>
|
||||
<Logo width={30} height={30} />
|
||||
<span className="font-medium text-white">WEBklar</span>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-2 ml-2 max-w-xs leading-relaxed">
|
||||
Webagentur für KMU – Website, Prozesse und Automatisierung aus einer Hand.
|
||||
<footer
|
||||
id="footer"
|
||||
className="w-full overflow-hidden border-t border-white/[0.1] bg-black pb-0"
|
||||
>
|
||||
<div className="mx-auto flex max-w-7xl flex-col p-10 md:flex-row md:items-start md:justify-between">
|
||||
<div className="mx-0 flex max-w-xs flex-col items-start justify-start gap-y-5">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<Logo width={32} height={32} />
|
||||
<span className="text-xl font-semibold text-white">WEBklar</span>
|
||||
</Link>
|
||||
<p className="font-medium leading-relaxed tracking-tight text-neutral-400">
|
||||
Webagentur für KMU – Website, Prozesse und Automatisierung aus einer
|
||||
Hand.
|
||||
</p>
|
||||
<div className="mt-4 ml-2">© {new Date().getFullYear()} WEBklar. Alle Rechte vorbehalten.</div>
|
||||
<div className="text-sm text-neutral-500">
|
||||
© {new Date().getFullYear()} WEBklar. Alle Rechte vorbehalten.
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 items-start gap-10 sm:grid-cols-3 lg:gap-16">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<p className="font-bold text-neutral-300">Seiten</p>
|
||||
<ul className="list-none space-y-3 text-neutral-400">
|
||||
<li>
|
||||
<Link to="/" className="hover:text-neutral-200 transition-colors">
|
||||
Startseite
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/ueber-uns" className="hover:text-neutral-200 transition-colors">
|
||||
Über uns
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#services" className="hover:text-neutral-200 transition-colors">
|
||||
Leistungen
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/kontakt" className="hover:text-neutral-200 transition-colors">
|
||||
Kontakt
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<p className="font-bold text-neutral-300">Kontakt</p>
|
||||
<ul className="list-none space-y-3 text-neutral-400">
|
||||
<li>
|
||||
<a
|
||||
href="mailto:support@webklar.com"
|
||||
className="hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
support@webklar.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="tel:+491704969375" className="hover:text-neutral-200 transition-colors">
|
||||
0170 4969375
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<p className="font-bold text-neutral-300">Rechtliches</p>
|
||||
<ul className="list-none space-y-3 text-neutral-400">
|
||||
<li>
|
||||
<Link to="/agb" className="hover:text-neutral-200 transition-colors">
|
||||
AGB
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/impressum" className="hover:text-neutral-200 transition-colors">
|
||||
Impressum
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="pt-10 md:w-1/2 md:pt-0">
|
||||
<div className="flex flex-col items-start justify-start gap-y-8 sm:flex-row sm:justify-between lg:pl-10">
|
||||
{footerLinks.map((column, columnIndex) => (
|
||||
<ul key={columnIndex} className="flex flex-col gap-y-3">
|
||||
<li className="mb-1 text-sm font-semibold text-neutral-300">
|
||||
{column.title}
|
||||
</li>
|
||||
{column.links.map((link) => (
|
||||
<li
|
||||
key={link.id}
|
||||
className="group inline-flex cursor-pointer items-center justify-start gap-1 text-[15px]/snug text-neutral-400"
|
||||
>
|
||||
{link.to ? (
|
||||
<Link
|
||||
to={link.to}
|
||||
className="transition-colors group-hover:text-neutral-200"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
href={link.href}
|
||||
className="transition-colors group-hover:text-neutral-200"
|
||||
>
|
||||
{link.title}
|
||||
</a>
|
||||
)}
|
||||
<div className="flex size-4 translate-x-0 transform items-center justify-center rounded border border-white/[0.15] opacity-0 transition-all duration-300 ease-out group-hover:translate-x-1 group-hover:opacity-100">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="inset-x-0 mt-20 bg-gradient-to-b from-black via-neutral-950 to-neutral-900 bg-clip-text text-center text-5xl font-bold text-transparent md:text-9xl lg:text-[12rem] xl:text-[13rem]">
|
||||
WEBklar
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative z-0 mt-24 h-48 w-full md:h-64">
|
||||
<div className="absolute inset-0 z-10 bg-gradient-to-t from-transparent from-40% to-black" />
|
||||
<div className="absolute inset-0 mx-6">
|
||||
<FlickeringGrid
|
||||
text="WEBklar"
|
||||
fontSize={isMobile ? 70 : 90}
|
||||
className="h-full w-full"
|
||||
squareSize={2}
|
||||
gridGap={isMobile ? 2 : 3}
|
||||
color="#6B7280"
|
||||
maxOpacity={0.3}
|
||||
flickerChance={0.1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
247
src/components/ui/flickering-grid.tsx
Normal file
247
src/components/ui/flickering-grid.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Parse a CSS color string ("#RRGGBB", "#RGB", "rgb(r,g,b)") into [r, g, b].
|
||||
// Keeps this component dependency-free (no color-bits needed) since the grid
|
||||
// only ever receives a fixed hex color.
|
||||
const parseColor = (color: string): [number, number, number] => {
|
||||
const fallback: [number, number, number] = [180, 180, 180];
|
||||
if (!color) return fallback;
|
||||
|
||||
const hex = color.trim();
|
||||
if (hex.startsWith("#")) {
|
||||
let h = hex.slice(1);
|
||||
if (h.length === 3) {
|
||||
h = h
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("");
|
||||
}
|
||||
if (h.length === 6) {
|
||||
const num = parseInt(h, 16);
|
||||
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const rgbMatch = hex.match(/rgba?\(([^)]+)\)/);
|
||||
if (rgbMatch) {
|
||||
const parts = rgbMatch[1].split(",").map((p) => parseFloat(p.trim()));
|
||||
if (parts.length >= 3) {
|
||||
return [parts[0], parts[1], parts[2]];
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
interface FlickeringGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
squareSize?: number;
|
||||
gridGap?: number;
|
||||
flickerChance?: number;
|
||||
color?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
maxOpacity?: number;
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: number | string;
|
||||
}
|
||||
|
||||
export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
|
||||
squareSize = 3,
|
||||
gridGap = 3,
|
||||
flickerChance = 0.2,
|
||||
color = "#B4B4B4",
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
maxOpacity = 0.15,
|
||||
text = "",
|
||||
fontSize = 140,
|
||||
fontWeight = 600,
|
||||
...props
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
const rgb = useMemo(() => parseColor(color), [color]);
|
||||
|
||||
const drawGrid = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
cols: number,
|
||||
rows: number,
|
||||
squares: Float32Array,
|
||||
dpr: number,
|
||||
) => {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = width;
|
||||
maskCanvas.height = height;
|
||||
const maskCtx = maskCanvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!maskCtx) return;
|
||||
|
||||
if (text) {
|
||||
maskCtx.save();
|
||||
maskCtx.scale(dpr, dpr);
|
||||
maskCtx.fillStyle = "white";
|
||||
maskCtx.font = `${fontWeight} ${fontSize}px "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
|
||||
maskCtx.textAlign = "center";
|
||||
maskCtx.textBaseline = "middle";
|
||||
maskCtx.fillText(text, width / (2 * dpr), height / (2 * dpr));
|
||||
maskCtx.restore();
|
||||
}
|
||||
|
||||
for (let i = 0; i < cols; i++) {
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const x = i * (squareSize + gridGap) * dpr;
|
||||
const y = j * (squareSize + gridGap) * dpr;
|
||||
const squareWidth = squareSize * dpr;
|
||||
const squareHeight = squareSize * dpr;
|
||||
|
||||
const maskData = maskCtx.getImageData(
|
||||
x,
|
||||
y,
|
||||
squareWidth,
|
||||
squareHeight,
|
||||
).data;
|
||||
const hasText = maskData.some(
|
||||
(value, index) => index % 4 === 0 && value > 0,
|
||||
);
|
||||
|
||||
const opacity = squares[i * rows + j];
|
||||
const finalOpacity = hasText
|
||||
? Math.min(1, opacity * 3 + 0.4)
|
||||
: opacity;
|
||||
|
||||
ctx.fillStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${finalOpacity})`;
|
||||
ctx.fillRect(x, y, squareWidth, squareHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
[rgb, squareSize, gridGap, text, fontSize, fontWeight],
|
||||
);
|
||||
|
||||
const setupCanvas = useCallback(
|
||||
(canvas: HTMLCanvasElement, width: number, height: number) => {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
const cols = Math.ceil(width / (squareSize + gridGap));
|
||||
const rows = Math.ceil(height / (squareSize + gridGap));
|
||||
|
||||
const squares = new Float32Array(cols * rows);
|
||||
for (let i = 0; i < squares.length; i++) {
|
||||
squares[i] = Math.random() * maxOpacity;
|
||||
}
|
||||
|
||||
return { cols, rows, squares, dpr };
|
||||
},
|
||||
[squareSize, gridGap, maxOpacity],
|
||||
);
|
||||
|
||||
const updateSquares = useCallback(
|
||||
(squares: Float32Array, deltaTime: number) => {
|
||||
for (let i = 0; i < squares.length; i++) {
|
||||
if (Math.random() < flickerChance * deltaTime) {
|
||||
squares[i] = Math.random() * maxOpacity;
|
||||
}
|
||||
}
|
||||
},
|
||||
[flickerChance, maxOpacity],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let animationFrameId: number;
|
||||
let gridParams: ReturnType<typeof setupCanvas>;
|
||||
|
||||
const updateCanvasSize = () => {
|
||||
const newWidth = width || container.clientWidth;
|
||||
const newHeight = height || container.clientHeight;
|
||||
setCanvasSize({ width: newWidth, height: newHeight });
|
||||
gridParams = setupCanvas(canvas, newWidth, newHeight);
|
||||
};
|
||||
|
||||
updateCanvasSize();
|
||||
|
||||
let lastTime = 0;
|
||||
const animate = (time: number) => {
|
||||
if (!isInView) return;
|
||||
|
||||
const deltaTime = (time - lastTime) / 1000;
|
||||
lastTime = time;
|
||||
|
||||
updateSquares(gridParams.squares, deltaTime);
|
||||
drawGrid(
|
||||
ctx,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
gridParams.cols,
|
||||
gridParams.rows,
|
||||
gridParams.squares,
|
||||
gridParams.dpr,
|
||||
);
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateCanvasSize();
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIsInView(entry.isIntersecting);
|
||||
},
|
||||
{ threshold: 0 },
|
||||
);
|
||||
|
||||
intersectionObserver.observe(canvas);
|
||||
|
||||
if (isInView) {
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
};
|
||||
}, [setupCanvas, updateSquares, drawGrid, width, height, isInView]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("h-full w-full", className)}
|
||||
{...props}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none"
|
||||
style={{
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlickeringGrid;
|
||||
Reference in New Issue
Block a user