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 React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
import Logo from "@/components/Logo";
|
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 Footer: React.FC = () => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
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">
|
<footer
|
||||||
<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">
|
id="footer"
|
||||||
<div>
|
className="w-full overflow-hidden border-t border-white/[0.1] bg-black pb-0"
|
||||||
<div className="mr-0 mb-4 md:mr-4 md:flex">
|
>
|
||||||
|
<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="text-sm text-neutral-500">
|
||||||
|
© {new Date().getFullYear()} WEBklar. Alle Rechte vorbehalten.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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
|
<Link
|
||||||
to="/"
|
to={link.to}
|
||||||
className="relative z-20 mr-4 flex items-center space-x-2 px-2 py-1 text-sm font-normal text-white"
|
className="transition-colors group-hover:text-neutral-200"
|
||||||
>
|
>
|
||||||
<Logo width={30} height={30} />
|
{link.title}
|
||||||
<span className="font-medium text-white">WEBklar</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
) : (
|
||||||
<p className="mt-2 ml-2 max-w-xs leading-relaxed">
|
|
||||||
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>
|
|
||||||
<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
|
<a
|
||||||
href="mailto:support@webklar.com"
|
href={link.href}
|
||||||
className="hover:text-neutral-200 transition-colors"
|
className="transition-colors group-hover:text-neutral-200"
|
||||||
>
|
>
|
||||||
support@webklar.com
|
{link.title}
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="tel:+491704969375" className="hover:text-neutral-200 transition-colors">
|
|
||||||
0170 4969375
|
|
||||||
</a>
|
</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>
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
</div>
|
</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]">
|
<div className="relative z-0 mt-24 h-48 w-full md:h-64">
|
||||||
WEBklar
|
<div className="absolute inset-0 z-10 bg-gradient-to-t from-transparent from-40% to-black" />
|
||||||
</p>
|
<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>
|
||||||
|
</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