Initial commit

This commit is contained in:
Basiclean committed 2026-05-02 10:41:00 +02:00
commit bcca176137
88 files changed
+8725

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+8
View File
@@ -0,0 +1,8 @@
export default function Datenschutz() {
return (
<main>
<h1>Datenschutz</h1>
</main>
);
}
+74
View File
@@ -0,0 +1,74 @@
.wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 80dvh;
padding: 0 var(--space-4);
overflow: hidden;
background-color: var(--color-background);
width: 100%;
}
.ghost {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(12rem, 40vw, 28rem);
font-weight: 900;
line-height: 1;
letter-spacing: -0.05em;
user-select: none;
pointer-events: none;
color: transparent;
-webkit-text-stroke: 1px rgba(23, 23, 23, 0.06);
}
.content {
position: relative;
z-index: 1;
text-align: center;
width: 100%;
}
.label {
font-size: var(--font-size-small);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--letter-spacing-wide);
text-transform: uppercase;
color: var(--color-text-soft);
margin-bottom: var(--space-4);
}
.heading {
margin-bottom: var(--space-4);
}
.description {
color: var(--color-text-muted);
margin-bottom: var(--space-7);
max-width: 38ch;
margin-inline: auto;
}
.cta {
gap: var(--btn-gap);
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-4);
}
@media (min-width: 768px) {
}
@media (min-width: 1024px) {
.wrapper {
padding: 0 var(--space-6);
}
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import styles from "./error.module.css";
import {
ArrowCounterClockwiseIcon,
ArrowLeftIcon,
} from "@phosphor-icons/react/dist/ssr";
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function Error({ error, reset }: ErrorProps) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<main className={styles.wrapper}>
<span className={styles.ghost} aria-hidden="true">
500
</span>
<div className={styles.content}>
<p className={styles.label}>Serverfehler</p>
<h1 className={styles.heading}>Etwas ist schiefgelaufen</h1>
<p className={styles.description}>
Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut oder
komm später zurück.
</p>
<div className={styles.actions}>
<button
type="button"
onClick={reset}
className={`btn btn--filled ${styles.cta}`}
>
<ArrowCounterClockwiseIcon className="btn-icon" size={18} />
Erneut versuchen
</button>
<Link href="/" className={`btn btn--outline ${styles.cta}`}>
<ArrowLeftIcon className="btn-icon" size={18} />
Zurück zur Startseite
</Link>
</div>
</div>
</main>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+58
View File
@@ -0,0 +1,58 @@
@import "../styles/tokens.css";
@import "../styles/buttons.css";
@import "../styles/forms.css";
@import "../styles/typography.css";
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
height: 100%;
font-size: 112.5%; /* 16px × 1.125 = 18px */
}
html.has-menu-open {
scrollbar-gutter: stable;
}
html,
body {
max-width: 100vw;
}
body {
min-height: 100%;
display: flex;
flex-direction: column;
color: var(--font-body-color);
background: var(--color-background);
font-family: var(--font-body);
font-size: 1rem;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: var(--line-height-body);
-webkit-tap-highlight-color: transparent;
}
main {
width: 100%;
max-width: 1280px;
margin-inline: auto;
padding: 0 var(--space-4);
}
@media (min-width: 768px) {
main {
padding: 0 var(--space-4);
}
}
@media (min-width: 1024px) {
main {
padding: 0 var(--space-6);
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024"><metadata><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Description><dc:creator>RealFaviconGenerator</dc:creator><dc:source>https://realfavicongenerator.net</dc:source></rdf:Description></rdf:RDF></metadata><image width="1024" height="1024" xlink:href="data:image/png;base64,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 truncated

After

Width:  |  Height:  |  Size: 30 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+8
View File
@@ -0,0 +1,8 @@
export default function Impressum() {
return (
<main>
<h1>impressum</h1>
</main>
);
}
+104
View File
@@ -0,0 +1,104 @@
import CookieConsentBanner from "@/components/CookieConsent";
import type { Metadata, Viewport } from "next";
import { Manrope, Nunito_Sans } from "next/font/google";
import "./globals.css";
import Footer from "@/components/Footer";
import Header from "@/components/Header";
const nunitoSans = Nunito_Sans({
subsets: ["latin"],
weight: ["400", "600", "700"],
variable: "--font-body",
display: "swap",
});
const manrope = Manrope({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-heading",
display: "swap",
});
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
};
const siteName = "ENVU";
const title = "ENVU - Code Connect";
const description = "Wir bieten Ihnen maßgeschneiderte Lösungen.";
const siteUrl = "https://envu.de";
const ogImage = {
url: "/images/envu.png",
alt: "Envu Wallpaper",
width: 1200,
height: 628,
};
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
default: title,
template: `%s | ${siteName}`,
},
description,
keywords: "Envu, Hannover",
authors: [{ name: siteName }],
robots: "index, follow",
alternates: {
canonical: siteUrl,
},
appleWebApp: {
title: siteName,
},
openGraph: {
type: "website",
siteName,
locale: "de_DE",
title,
description,
images: [ogImage],
url: siteUrl,
},
twitter: {
card: "summary_large_image",
site: "@envu",
title,
description,
images: [{ url: ogImage.url, alt: ogImage.alt }],
},
};
const jsonLd = {
"@context": "https://schema.org",
"@type": "LocalBusiness",
name: siteName,
url: siteUrl,
description,
address: {
"@type": "PostalAddress",
addressLocality: "Hannover",
addressCountry: "DE",
},
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="de" className={`${nunitoSans.variable} ${manrope.variable}`}>
<body>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<Header />
{children}
<Footer />
<CookieConsentBanner />
</body>
</html>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function Loading() {
return (
<main>
<p>Laden...</p>
</main>
);
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "ENVU",
"short_name": "ENVU",
"icons": [
{
"src": "/favicon/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/favicon/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+67
View File
@@ -0,0 +1,67 @@
.wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 80dvh;
padding: 0 var(--space-4);
overflow: hidden;
background-color: var(--color-background);
width: 100%;
}
.ghost {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(12rem, 40vw, 28rem);
font-weight: 900;
line-height: 1;
letter-spacing: -0.05em;
user-select: none;
pointer-events: none;
color: transparent;
-webkit-text-stroke: 1px rgba(23, 23, 23, 0.06);
}
.content {
position: relative;
z-index: 1;
text-align: center;
width: 100%;
}
.label {
font-size: var(--font-size-small);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--letter-spacing-wide);
text-transform: uppercase;
color: var(--color-text-soft);
margin-bottom: var(--space-4);
}
.heading {
margin-bottom: var(--space-4);
}
.description {
color: var(--color-text-muted);
margin-bottom: var(--space-7);
max-width: 38ch;
margin-inline: auto;
}
.cta {
gap: var(--btn-gap);
}
@media (min-width: 768px) {
}
@media (min-width: 1024px) {
.wrapper {
padding: 0 var(--space-6);
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import Link from "next/link";
import styles from "./not-found.module.css";
import { ArrowLeftIcon } from "@phosphor-icons/react/dist/ssr";
export const metadata: Metadata = {
title: "Seite nicht gefunden",
description: "Die gesuchte Seite existiert nicht.",
};
export default function NotFound() {
return (
<main className={styles.wrapper}>
<span className={styles.ghost} aria-hidden="true">
404
</span>
<div className={styles.content}>
<p className={styles.label}>Fehler 404</p>
<h1 className={styles.heading}>Seite nicht gefunden</h1>
<p className={styles.description}>
Die gesuchte Seite existiert leider nicht oder wurde verschoben.
</p>
<Link href="/" className={`btn btn--filled ${styles.cta}`}>
<ArrowLeftIcon className="btn-icon" size={18} />
Zurück zur Startseite
</Link>
</div>
</main>
);
}
View File
Whitespace-only changes.
+192
View File
@@ -0,0 +1,192 @@
import { ArrowRightIcon } from "@phosphor-icons/react/dist/ssr";
export default function Home() {
return (
<main>
<h1>Startseite</h1>
<h2>Startseite</h2>
<h3>Startseite</h3>
<h4>Startseite</h4>
<p>Startseite</p>
<br />
<button className="btn btn--filled">Filled</button>
<br />
<br />
<button className="btn btn--outline">Outline</button>
<br />
<br />
<button className="btn btn--ghost">Ghost</button>
<br />
<br />
<button className="btn btn--tertiary">Tertiary</button>
<br />
<br />
<button className="btn btn--filled" disabled>
Disabled
</button>
<br />
<br />
<button className="btn btn--filled">
<ArrowRightIcon className="btn-icon animate" size={18} />
Jetzt starten
</button>
<br />
<br />
<button className="btn btn--outline">
<ArrowRightIcon className="btn-icon animate" size={18} />
Outline
</button>
<br />
<br />
<button className="btn btn--ghost">
<ArrowRightIcon className="btn-icon animate" size={18} />
Ghost
</button>
<br />
<br />
<button className="btn btn--tertiary">
<ArrowRightIcon className="btn-icon animate" size={18} />
Tertiary
</button>
<br />
<br />
<button className="btn btn--filled" disabled>
<ArrowRightIcon className="btn-icon animate" size={18} />
Disabled
</button>
<br />
<br />
<p>
<a href="#">Webdesign</a> und Webentwicklung bilden das Fundament jeder
erfolgreichen digitalen Präsenz. Während Webdesign die visuelle
Gestaltung, Benutzerführung und das Nutzererlebnis in den Vordergrund
stellt, sorgt die Webentwicklung für die technische Umsetzung im
Hintergrund. Beide Disziplinen greifen eng ineinander – ein
ansprechendes Design entfaltet seine volle Wirkung nur durch sauberen,
performanten Code.
</p>
<p>
Moderne Websites müssen auf allen Geräten funktionieren, schnell laden
und barrierefrei zugänglich sein. Suchmaschinenoptimierung, Ladezeiten
und intuitive Navigation sind dabei keine optionalen Extras, sondern
entscheidende Faktoren für den Erfolg. Wer heute im Netz wahrgenommen
werden möchte, braucht mehr als nur eine schöne Oberfläche – er braucht
eine durchdachte digitale Strategie, die Design und Technik vereint.
</p>
<br />
<h2>Kontakt</h2>
<form>
{/* Name */}
<div className="form-group">
<label className="form-label form-label--required" htmlFor="name">
Name
</label>
<input
type="text"
id="name"
name="name"
placeholder="Max Mustermann"
/>
</div>
{/* E-Mail */}
<div className="form-group">
<label className="form-label form-label--required" htmlFor="email">
E-Mail
</label>
<input
type="email"
id="email"
name="email"
placeholder="max@beispiel.de"
/>
</div>
{/* Telefon */}
<div className="form-group">
<label className="form-label" htmlFor="telefon">
Telefon
</label>
<input
type="tel"
id="telefon"
name="telefon"
placeholder="+49 511 123456"
/>
</div>
{/* Betreff */}
<div className="form-group">
<label className="form-label" htmlFor="betreff">
Betreff
</label>
<select id="betreff" name="betreff">
<option value="">Bitte auswählen</option>
<option value="anfrage">Allgemeine Anfrage</option>
<option value="angebot">Angebotsanfrage</option>
<option value="support">Support</option>
</select>
</div>
{/* Nachricht */}
<div className="form-group">
<label
className="form-label form-label--required"
htmlFor="nachricht"
>
Nachricht
</label>
<textarea
id="nachricht"
name="nachricht"
placeholder="Ihre Nachricht..."
/>
</div>
{/* Datenschutz Checkbox */}
<div className="form-group">
<label className="form-check">
<input type="checkbox" />
<span className="form-check-label">
Ich habe die Datenschutzerklärung gelesen und stimme zu.
</span>
</label>
</div>
{/* Error-Beispiel zum Testen */}
<div className="form-group">
<label className="form-label" htmlFor="fehler">
Fehler-Beispiel
</label>
<input
type="text"
id="fehler"
className="is-error"
defaultValue="Falscher Wert"
/>
<span className="form-error-msg">
Bitte gib einen gültigen Wert ein.
</span>
</div>
{/* Disabled-Beispiel */}
<div className="form-group">
<label className="form-label" htmlFor="deaktiviert">
Deaktiviert
</label>
<input
type="text"
id="deaktiviert"
disabled
defaultValue="Nicht bearbeitbar"
/>
</div>
{/* Submit */}
<button type="submit" className="btn btn--filled">
Nachricht senden
</button>
</form>
</main>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { MetadataRoute } from "next";
const BASE_URL = "https://deinedomain.de"; // ← hier deine Domain eintragen
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*", // gilt für alle Crawler (Google, Bing etc.)
allow: "/", // alles erlaubt
disallow: [
"/api/", // API-Routen nicht indexieren
"/admin/", // falls vorhanden: Admin-Bereich sperren
"/datenschutz",
"/impressum",
],
},
],
sitemap: `${BASE_URL}/sitemap.xml`, // ← Sitemap verlinken
};
}
+70
View File
@@ -0,0 +1,70 @@
// app/sitemap.ts
// Erreichbar unter: https://deinedomain.de/sitemap.xml
import type { MetadataRoute } from "next";
const BASE_URL = "https://deinedomain.de"; // ← Domain anpassen
// ─── Typen ───────────────────────────────────────────────────────────────────
// interface BlogPost {
// slug: string;
// updatedAt: Date;
// }
// ─── Daten laden (nur aktivieren wenn Blog vorhanden) ────────────────────────
// async function getBlogPosts(): Promise<BlogPost[]> {
// Hier API/CMS-Call, z.B.:
// const res = await fetch(`${BASE_URL}/api/posts`);
// return res.json();
// }
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// ─── Statische Seiten ──────────────────────────────────────────────────────
const staticPages: MetadataRoute.Sitemap = [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1.0,
},
{
url: `${BASE_URL}/about`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${BASE_URL}/kontakt`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.6,
},
{
url: `${BASE_URL}/datenschutz`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.3,
},
{
url: `${BASE_URL}/impressum`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.3,
},
];
// ─── Dynamische Blog-Seiten (auskommentiert bis Blog vorhanden) ────────────
// const posts = await getBlogPosts();
// const blogPages: MetadataRoute.Sitemap = posts.map((post) => ({
// url: `${BASE_URL}/blog/${post.slug}`,
// lastModified: post.updatedAt,
// changeFrequency: "weekly",
// priority: 0.7,
// }));
// ─── Alles zusammenführen ──────────────────────────────────────────────────
return [
...staticPages,
// ...blogPages, ← aktivieren wenn Blog live ist
];
}
+5
View File
@@ -0,0 +1,5 @@
"use client";
export default function TestErrorPage() {
throw new Error("Testfehler – Error-Seite prüfen");
}
View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
export default function UeberUns() {
return (
<main>
<h1>Über Uns</h1>
</main>
);
}
+33
View File
@@ -0,0 +1,33 @@
:global(#cc-main) {
/* Deine bestehenden Button-Overrides */
--cc-font-family: var(--font-body);
--cc-bg: #ffffff;
--cc-btn-bg-primary: var(--btn-bg-primary);
--cc-btn-bg-primary-hover: var(--btn-bg-primary-hover);
--cc-btn-bg-primary-active: var(--btn-bg-primary-active);
--cc-btn-text-light: var(--btn-text-light);
--cc-btn-text-dark: var(--btn-text-dark);
--cc-btn-bg-secondary: var(--btn-bg-ghost-hover);
--cc-btn-bg-secondary-hover: var(--btn-bg-primary);
--cc-btn-bg-secondary-text: var(--btn-text-dark);
--cc-btn-border-radius: var(--btn-radius);
--cc-modal-border-radius: 12px;
--cc-primary-color: var(--font-body-color);
--cc-toggle-on-bg: var(--btn-bg-primary);
}
:global(#cc-main a),
:global(#cc-main a:link),
:global(#cc-main a:visited),
:global(#cc-main a:hover),
:global(#cc-main a:focus),
:global(#cc-main a:active) {
text-decoration: none !important;
border-bottom: none !important;
box-shadow: none !important;
background-image: none !important;
}
:global(#cc-main .cm) {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08) !important;
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useEffect } from "react";
import * as CookieConsent from "vanilla-cookieconsent";
import "vanilla-cookieconsent/dist/cookieconsent.css";
import "./CookieConsent.module.css";
export default function CookieConsentBanner() {
useEffect(() => {
CookieConsent.run({
categories: {
necessary: {
enabled: true,
readOnly: true,
},
analytics: {},
marketing: {},
},
language: {
default: "de",
translations: {
de: {
consentModal: {
title: "Ihre Privatsphäre ist uns wichtig",
description:
"Wir verwenden Cookies, um Ihnen die bestmögliche Nutzung unserer Website zu ermöglichen. " +
"Einige Cookies sind technisch notwendig, andere helfen uns, die Website zu verbessern " +
"und Ihnen relevante Inhalte anzuzeigen. Sie können Ihre Auswahl jederzeit unter " +
"<a href='/datenschutz'>Datenschutz</a> anpassen oder widerrufen.",
acceptAllBtn: "Alle akzeptieren",
acceptNecessaryBtn: "Nur notwendige",
showPreferencesBtn: "Einstellungen anpassen",
footer:
"<a href='/impressum'>Impressum</a>" +
"<a href='/datenschutz'>Datenschutzerklärung</a>",
},
preferencesModal: {
title: "Cookie-Einstellungen",
acceptAllBtn: "Alle akzeptieren",
acceptNecessaryBtn: "Nur notwendige",
savePreferencesBtn: "Auswahl speichern",
closeIconLabel: "Schließen",
sections: [
{
title: "Ihre Privatsphäre",
description:
"Wir setzen Cookies ein, um die Funktionalität unserer Website sicherzustellen " +
"und unser Angebot kontinuierlich zu verbessern. Sie entscheiden selbst, " +
"welche Kategorien Sie zulassen möchten.",
},
{
title: "Technisch notwendig",
description:
"Diese Cookies sind für den reibungslosen Betrieb der Website unbedingt erforderlich " +
"und können nicht deaktiviert werden. Sie speichern keine personenbezogenen Daten " +
"und werden ausschließlich als Reaktion auf von Ihnen durchgeführte Aktionen gesetzt " +
"(z. B. Spracheinstellungen oder Sicherheits-Token).",
linkedCategory: "necessary",
},
{
title: "Analyse & Statistik",
description:
"Diese Cookies helfen uns zu verstehen, wie Besucher mit unserer Website interagieren. " +
"Alle Daten werden anonymisiert gesammelt und dienen ausschließlich zur Verbesserung " +
"unseres Angebots. Wir nutzen Google Analytics (GA4) – Ihre IP-Adresse wird dabei " +
"anonymisiert und nicht gespeichert.",
linkedCategory: "analytics",
},
{
title: "Marketing & externe Medien",
description:
"Diese Cookies ermöglichen die Einbindung externer Inhalte wie YouTube-Videos, " +
"Google Maps, Instagram-, Facebook- und X (Twitter)-Inhalte. " +
"Ohne Ihre Zustimmung werden diese Inhalte nicht geladen und keine Daten übertragen. " +
"Bitte beachten Sie, dass bei Aktivierung personenbezogene Daten " +
"an Drittanbieter (ggf. auch in die USA) übertragen werden können.",
linkedCategory: "marketing",
},
{
title: "Weitere Informationen",
description:
"Bei Fragen zu unserer Cookie-Richtlinie wenden Sie sich bitte an uns über das " +
"<a href='/kontakt'>Kontaktformular</a>. Mehr Details finden Sie in unserer " +
"<a href='/datenschutz'>Datenschutzerklärung</a>.",
},
],
},
},
},
},
});
}, []);
return null;
}
+154
View File
@@ -0,0 +1,154 @@
/*
FOOTER
Datei: components/Footer.module.css
*/
:root {
--footer-bg: #171717;
--footer-text: #a1a1aa;
--footer-text-hover: #ffffff;
--footer-title: #ffffff;
--footer-divider: rgba(255, 255, 255, 0.1);
--footer-padding-y: clamp(3rem, 6vw, 5rem);
--footer-gap: clamp(2rem, 5vw, 4rem);
}
/* Wrapper */
.footer {
background-color: var(--footer-bg);
color: var(--footer-text);
font-family: inherit;
}
.footer__inner {
max-width: 1280px;
margin-inline: auto;
padding: var(--footer-padding-y) clamp(1rem, 5vw, 2rem);
}
/* Top */
.footer__top {
display: grid;
grid-template-columns: 1fr;
gap: var(--footer-gap);
}
@media (min-width: 768px) {
.footer__top {
grid-template-columns: 1fr 1fr;
}
}
@media (min-width: 1024px) {
.footer__top {
grid-template-columns: 1.5fr 1fr;
}
}
/* Brand */
.footer__logo {
display: inline-block;
font-size: 1.4rem;
font-weight: 700;
color: var(--footer-title);
margin-bottom: 0.75rem;
transition: opacity 0.2s ease;
}
.footer__logo:hover {
opacity: 0.8;
}
.footer__tagline {
font-size: 0.9rem;
line-height: 1.6;
max-width: 32ch;
color: var(--footer-text);
}
/* Navigation */
.footer__nav {
display: flex;
gap: var(--footer-gap);
flex-wrap: wrap;
}
.footer__nav ul {
list-style: none;
}
.footer__nav-group ul {
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.footer__nav-title {
display: block;
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--footer-title);
margin-bottom: 1rem;
}
.footer__nav-group a {
font-size: 0.9rem;
color: var(--footer-text);
transition: color 0.2s ease;
}
.footer__nav-group a:hover {
color: var(--footer-text-hover);
}
/* Divider */
.footer__divider {
height: 1px;
background: var(--footer-divider);
margin: 2.5rem 0;
}
/* Bottom */
.footer__bottom {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.footer__copy {
font-size: 0.85rem;
color: var(--footer-text);
}
/* Social */
.footer__social {
display: flex;
gap: 1rem;
}
.footer__social a {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid var(--footer-divider);
color: var(--footer-text);
transition:
color 0.2s ease,
border-color 0.2s ease,
background 0.2s ease;
}
.footer__social a:hover {
color: var(--footer-text-hover);
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.05);
}
+106
View File
@@ -0,0 +1,106 @@
import styles from "./Footer.module.css";
import Link from "next/link";
import {
InstagramLogoIcon,
LinkedinLogoIcon,
XLogoIcon,
FacebookLogoIcon,
} from "@phosphor-icons/react/dist/ssr";
const currentYear = new Date().getFullYear();
export default function Footer() {
return (
<footer className={styles.footer}>
<div className={styles.footer__inner}>
{/* Top: Brand + Navigation */}
<div className={styles.footer__top}>
{/* Brand */}
<div className={styles.footer__brand}>
<Link href="/" className={styles.footer__logo}>
ENVU {/* ← Logo oder Bildkomponente hier */}
</Link>
<p className={styles.footer__tagline}>
Maßgeschneiderte digitale Lösungen für Ihr Unternehmen.
</p>
</div>
{/* Navigation Spalten */}
<nav className={styles.footer__nav} aria-label="Footer Navigation">
<div className={styles.footer__nav_group}>
<span className={styles.footer__nav_title}>Unternehmen</span>
<ul>
<li>
<Link href="/ueberuns">Über uns</Link>
</li>
<li>
<Link href="/leistungen">Leistungen</Link>
</li>
<li>
<Link href="/kontakt">Kontakt</Link>
</li>
</ul>
</div>
<div className={styles.footer__nav_group}>
<span className={styles.footer__nav_title}>Rechtliches</span>
<ul>
<li>
<Link href="/impressum">Impressum</Link>
</li>
<li>
<Link href="/datenschutz">Datenschutz</Link>
</li>
</ul>
</div>
</nav>
</div>
{/* Divider */}
<div className={styles.footer__divider} />
{/* Bottom: Copyright + Social */}
<div className={styles.footer__bottom}>
<p className={styles.footer__copy}>
© {currentYear} ENVU. Alle Rechte vorbehalten.
</p>
<div className={styles.footer__social}>
<a
href="https://instagram.com"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
>
<InstagramLogoIcon size={20} />
</a>
<a
href="https://linkedin.com"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
>
<LinkedinLogoIcon size={20} />
</a>
<a
href="https://x.com"
target="_blank"
rel="noopener noreferrer"
aria-label="X (Twitter)"
>
<XLogoIcon size={20} />
</a>
<a
href="https://facebook.com"
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
>
<FacebookLogoIcon size={20} />
</a>
</div>
</div>
</div>
</footer>
);
}
+10
View File
@@ -0,0 +1,10 @@
:root {
--header-bg: var(--color-surface);
}
.header {
position: sticky;
top: 0;
z-index: 1000;
background-color: var(--header-bg);
box-shadow: 0 4px 6px -6px rgb(0 0 0 / 30%);
}
+10
View File
@@ -0,0 +1,10 @@
import styles from "./Header.module.css";
import Navbar from "./Navbar/Navbar";
export default function Header() {
return (
<header className={styles["header"]}>
<Navbar />
</header>
);
}
+219
View File
@@ -0,0 +1,219 @@
:root {
--nav-max-width: var(--container-width);
--nav-bg: var(--color-surface);
--nav-padding-tb: 0;
--nav-padding-lr: var(--space-4);
--nav-font-size: 1rem;
--nav-font-weight: 600;
--nav-font-color: #5f5f5f;
--nav-hamburger-icon: var(--nav-font-color);
}
.noTransition {
transition: none !important;
}
.nav {
padding: var(--nav-padding-tb) 0 var(--nav-padding-tb) var(--nav-padding-lr);
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
max-width: var(--nav-max-width);
margin: 0 auto;
font-family: var(--font-heading);
font-size: var(--nav-font-size) !important;
font-weight: var(--nav-font-weight);
background-color: var(--nav-bg);
color: var(--nav-font-color);
}
.nav-logo {
width: auto;
display: flex;
}
.nav-logo-image {
width: auto;
height: 30px;
display: block;
}
.nav-links button:focus-visible {
outline: 2px solid #000000;
}
.nav-links {
background-color: inherit;
position: fixed;
inset: var(--nav-h) 0 0 0;
overflow-x: hidden;
overflow-y: auto;
max-height: 0;
overflow: hidden;
pointer-events: none;
transition: max-height 400ms ease;
/* Scrollbar ausblenden (Firefox/IE/Edge legacy) */
scrollbar-width: none;
-ms-overflow-style: none;
}
/* Chrome/Safari/Opera */
.nav-links::-webkit-scrollbar {
display: none;
}
.nav-links.open {
max-height: 100vh;
pointer-events: auto;
overflow-y: auto;
}
.nav-links li {
list-style: none;
display: flex;
align-items: center;
font-size: inherit;
}
.nav-links > li:first-child {
padding: 8px 0 0 0;
}
.nav-links > li:last-child {
padding: 0 0 48px 0;
}
.nav-links li a {
display: flex;
align-items: center;
height: 100%;
width: 100%;
padding: 12px 16px;
}
.nav-links li a:focus-visible {
outline: 2px solid #393636;
}
.nav-hamburger {
width: 48px;
height: 48px;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
cursor: pointer;
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.nav-hamburger:focus {
outline: none;
}
.nav-hamburger:focus-visible {
outline: none;
box-shadow: 0 0 0 3px #000000;
border-radius: 6px;
}
.nav-hamburger span {
position: absolute;
right: 16px;
width: 16px;
height: 2px;
background-color: var(--nav-hamburger-icon);
border-radius: 2px;
transform-origin: center;
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.nav-hamburger span:nth-child(1) {
top: 50%;
transform: translateY(calc(-50% - 5px));
}
.nav-hamburger span:nth-child(2) {
top: 50%;
transform: translateY(-50%);
}
.nav-hamburger span:nth-child(3) {
top: 50%;
transform: translateY(calc(-50% + 5px));
}
.nav-hamburger.open span:nth-child(1) {
transform: translateY(-50%) rotate(-45deg);
}
.nav-hamburger.open span:nth-child(2) {
opacity: 0;
transform: translateY(-50%) translateX(8px);
}
.nav-hamburger.open span:nth-child(3) {
transform: translateY(-50%) rotate(45deg);
}
@media (min-width: 768px) {
:root {
--nav-padding-lr: var(--space-4);
}
}
@media (min-width: 1024px) {
:root {
--nav-font-size: 16px;
--nav-font-weight: 500;
--nav-padding-lr: var(--space-6);
}
.nav {
padding: var(--nav-padding-tb) var(--nav-padding-lr) var(--nav-padding-tb)
var(--nav-padding-lr);
}
.nav-logo-image {
width: auto;
height: 36px;
}
.nav-links {
position: static;
inset: auto;
overflow: visible;
display: flex;
align-items: center;
flex-direction: row;
visibility: visible;
pointer-events: auto;
transform: none;
transition: none;
max-height: none;
}
.nav-links > li a {
padding: 20px 16px;
}
.nav-links > li:first-child {
padding: 0;
}
.nav-links > li:last-child {
padding: 0;
}
.nav-links.animating {
transition: none;
}
.nav-hamburger {
display: none;
}
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { endLinks, singleLinks, subMenus } from "@/data/navigation";
import { useHeightVar } from "@/hooks/useHeightVar";
import { useNavToggleMenu } from "@/hooks/useNavToggleMenu";
import { useSupportsHover } from "@/hooks/useSupportsHover";
import { useToggleBodyOverflow } from "@/hooks/useToggleBodyOverflow";
import styles from "./Navbar.module.css";
import { NavbarSubMenu } from "./NavbarSubMenu";
type BasicLink = {
label: string;
to: string;
};
export default function Navbar() {
const pathname = usePathname();
const navRef = useRef<HTMLElement | null>(null);
const supportsHover = useSupportsHover();
const [instantSwitch, setInstantSwitch] = useState(false);
const raf1Ref = useRef<number | null>(null);
const raf2Ref = useRef<number | null>(null);
const {
menuOpen,
openSubmenuId,
noTransition,
closeInstantNoAnim,
setOpenSubmenuId,
toggleMenu,
} = useNavToggleMenu({ supportsHover, desktopBp: 1024 });
useToggleBodyOverflow(menuOpen);
useHeightVar(navRef, "--nav-h", "offset");
useEffect(() => {
return () => {
if (raf1Ref.current !== null) cancelAnimationFrame(raf1Ref.current);
if (raf2Ref.current !== null) cancelAnimationFrame(raf2Ref.current);
};
}, []);
const clearInstantSwitchSoon = () => {
if (raf1Ref.current !== null) cancelAnimationFrame(raf1Ref.current);
if (raf2Ref.current !== null) cancelAnimationFrame(raf2Ref.current);
raf1Ref.current = requestAnimationFrame(() => {
raf2Ref.current = requestAnimationFrame(() => {
setInstantSwitch(false);
raf1Ref.current = null;
raf2Ref.current = null;
});
});
};
const setOpenSubmenuIdWithInstantSwitch = (id: string | null) => {
if (id !== null && openSubmenuId !== null && openSubmenuId !== id) {
setInstantSwitch(true);
setOpenSubmenuId(id);
clearInstantSwitchSoon();
return;
}
setInstantSwitch(false);
setOpenSubmenuId(id);
};
const isActivePath = (to: string) => {
if (to === "/") return pathname === "/";
return pathname === to || pathname.startsWith(`${to}/`);
};
const handleNavLinkClick = () => {
setInstantSwitch(false);
closeInstantNoAnim();
};
const renderNavLink = (link: BasicLink) => (
<li key={link.to + link.label}>
<Link
href={link.to}
onClick={handleNavLinkClick}
aria-current={isActivePath(link.to) ? "page" : undefined}
>
{link.label}
</Link>
</li>
);
return (
<nav className={styles["nav"]} ref={navRef} aria-label="Hauptnavigation">
<div className={styles["nav-logo"]}>
<Link href="/" aria-label="Startseite">
<Image
src="/logo/logo.svg"
alt="Logo"
width={160}
height={48}
priority
className={styles["nav-logo-image"]}
/>
</Link>
</div>
<ul
id="main-navigation"
className={[
styles["nav-links"],
menuOpen ? styles.open : "",
noTransition ? styles.noTransition : "",
].join(" ")}
>
{singleLinks.map(renderNavLink)}
{subMenus.map((menu) => (
<NavbarSubMenu
key={menu.id}
id={menu.id}
label={menu.label}
items={menu.items}
open={openSubmenuId === menu.id}
noTransition={noTransition}
instantSwitch={instantSwitch}
setOpenSubmenuId={setOpenSubmenuIdWithInstantSwitch}
closeInstantNoAnim={handleNavLinkClick}
/>
))}
{endLinks.map(renderNavLink)}
</ul>
<button
type="button"
className={`${styles["nav-hamburger"]} ${menuOpen ? styles.open : ""}`}
onClick={toggleMenu}
aria-label={menuOpen ? "Menü schließen" : "Menü öffnen"}
aria-expanded={menuOpen}
aria-controls="main-navigation"
>
<span></span>
<span></span>
<span></span>
</button>
</nav>
);
}
+104
View File
@@ -0,0 +1,104 @@
:root {
--nav-submenu-max-width: var(--container-width);
--nav-submenu-bg: var(--color-surface);
}
.nav-submenu {
display: grid;
list-style: none;
width: 100%;
max-height: 0;
opacity: 1;
overflow: hidden;
pointer-events: none;
transition: max-height 250ms ease;
background-color: var(--nav-bg);
}
.nav-submenu.open {
max-height: var(--submenu-h);
pointer-events: auto;
}
.nav-submenu-inner {
width: 100%;
height: auto;
}
.nav-submenu-list {
width: var(--nav-submenu-max-width);
margin: 0 auto;
}
.nav-sub-toggle {
display: flex;
align-items: center;
height: 100%;
width: 100%;
padding: 12px 16px;
background-color: inherit;
border: 0;
text-align: left;
cursor: pointer;
font: inherit;
color: inherit;
}
.nav-item-with-sub {
display: flex;
flex-direction: column;
}
.nav-submenu > li {
padding: 0;
}
.nav-submenu > li a {
padding: 12px 24px;
}
@media (min-width: 1024px) {
.nav-submenu {
position: fixed;
left: 0;
right: 0;
top: var(--nav-h);
pointer-events: none;
box-shadow: 0 4px 6px -6px rgb(0 0 0 / 30%);
opacity: 0;
transition:
max-height 300ms,
opacity 300ms;
}
.nav-submenu.open {
opacity: 1;
}
.nav-submenu > li {
padding: 0;
}
.nav-submenu > li a {
padding: 0;
}
.nav-submenu-list {
width: min(100%, var(--nav-submenu-max-width));
margin: 0 auto;
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(5, auto);
grid-auto-columns: minmax(180px, max-content);
column-gap: 48px;
row-gap: 4px;
justify-content: start;
align-items: start;
padding: 24px 24px 48px 24px;
font-size: 16px;
font-weight: 500;
}
.nav-sub-toggle {
position: relative;
padding: 16px 8px;
}
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import type { FC, MouseEvent } from "react";
import { useRef } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useHeightVar } from "@/hooks/useHeightVar";
import { useIsDesktop } from "@/hooks/useIsDesktop";
import styles from "./NavbarSubMenu.module.css";
type SubMenuItem = {
label: string;
to: string;
};
type Props = {
id: string;
open: boolean;
noTransition: boolean;
instantSwitch: boolean;
setOpenSubmenuId: (id: string | null) => void;
closeInstantNoAnim?: () => void;
label: string;
items: SubMenuItem[];
};
export const NavbarSubMenu: FC<Props> = ({
id,
open,
noTransition,
instantSwitch,
setOpenSubmenuId,
closeInstantNoAnim,
label,
items,
}) => {
const pathname = usePathname();
const subMenuRef = useRef<HTMLUListElement | null>(null);
const isDesktop = useIsDesktop();
const openMenu = () => setOpenSubmenuId(id);
const closeMenu = () => setOpenSubmenuId(null);
const toggleMenu = () => setOpenSubmenuId(open ? null : id);
useHeightVar(subMenuRef, "--submenu-h", "scroll", open);
const handleSubmenuClick = (e: MouseEvent<HTMLUListElement>) => {
const target = e.target as HTMLElement;
if (target.closest("a")) {
closeInstantNoAnim?.();
}
};
return (
<li
className={styles["nav-item-with-sub"]}
onMouseEnter={isDesktop ? openMenu : undefined}
onMouseLeave={isDesktop ? closeMenu : undefined}
>
<button
type="button"
className={styles["nav-sub-toggle"]}
onClick={toggleMenu}
aria-expanded={open}
aria-controls={`submenu-${id}`}
>
{label}
</button>
<ul
ref={subMenuRef}
id={`submenu-${id}`}
className={[
styles["nav-submenu"],
open ? styles.open : "",
noTransition || instantSwitch ? styles.noTransition : "",
].join(" ")}
onClick={handleSubmenuClick}
>
<li className={styles["nav-submenu-inner"]}>
<ul className={styles["nav-submenu-list"]}>
{items.map((item) => {
const isActive =
item.to === "/"
? pathname === "/"
: pathname === item.to || pathname.startsWith(`${item.to}/`);
return (
<li key={item.to}>
<Link
href={item.to}
aria-current={isActive ? "page" : undefined}
onClick={closeInstantNoAnim}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</li>
</ul>
</li>
);
};
+52
View File
@@ -0,0 +1,52 @@
export type NavItem = {
label: string;
to: string;
};
export type SubMenu = {
id: string;
label: string;
items: NavItem[];
};
export const singleLinks: NavItem[] = [{ label: "Home", to: "/" }];
export const subMenus: SubMenu[] = [
{
id: "services",
label: "Dienstleistungen",
items: [
{ label: "Überblick", to: "/dienstleistungen" },
{ label: "Zweiter Link", to: "/dienstleistungen/zweiter-link" },
{ label: "Dritter Link", to: "/dienstleistungen/Dritter Link" },
{ label: "Produkt 5", to: "/produkte/produkt-5" },
{ label: "Produkt 6", to: "/produkte/produkt-6" },
{ label: "Produkt 7", to: "/produkte/produkt-7" }
],
},
{
id: "products",
label: "Produkte",
items: [
{ label: "Produkt 1", to: "/produkte" },
{ label: "Produkt 2", to: "/produkte/produkt-2" },
{ label: "Produkt 3", to: "/produkte/produkt-3" },
{ label: "Produkt 4", to: "/produkte/produkt-4" },
{ label: "Produkt 5", to: "/produkte/produkt-5" },
{ label: "Produkt 6", to: "/produkte/produkt-6" },
{ label: "Produkt 7", to: "/produkte/produkt-7" },
{ label: "Produkt 8", to: "/produkte/produkt-8" },
{ label: "Produkt 9", to: "/produkte/produkt-9" },
{ label: "Produkt 10", to: "/produkte/produkt-10" },
{ label: "Produkt 11", to: "/produkte/produkt-11" },
{ label: "Produkt 12", to: "/produkte/produkt-12" },
{ label: "Produkt 13", to: "/produkte/produkt-13" },
],
},
];
export const endLinks: NavItem[] = [
{ label: "Branche", to: "/branche" },
{ label: "Kontakt", to: "/kontakt" },
{ label: "Blog", to: "/blog" },
];
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useLayoutEffect } from "react";
type Mode = "scroll" | "offset";
export function useHeightVar(
ref: React.RefObject<HTMLElement | null>,
cssVarName: string,
mode: Mode = "scroll",
trigger?: unknown,
) {
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const h = mode === "scroll" ? el.scrollHeight : el.offsetHeight;
el.style.setProperty(cssVarName, `${h}px`);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [ref, cssVarName, mode, trigger]);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useEffect, useState } from "react";
export function useIsDesktop(breakpoint = 1024) {
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
const mql = window.matchMedia(`(min-width: ${breakpoint}px)`);
const update = () => setIsDesktop(mql.matches);
update();
if (mql.addEventListener) {
mql.addEventListener("change", update);
return () => mql.removeEventListener("change", update);
}
mql.addListener(update);
return () => mql.removeListener(update);
}, [breakpoint]);
return isDesktop;
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
type Options = {
supportsHover: boolean;
desktopBp?: number;
};
export function useNavToggleMenu({ desktopBp = 1024 }: Options) {
const [menuOpen, setMenuOpen] = useState(false);
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null);
const [noTransition, setNoTransition] = useState(false);
const rafRef = useRef<number | null>(null);
const clearRaf = useCallback(() => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
}, []);
const closeMenu = useCallback(() => {
setMenuOpen(false);
setOpenSubmenuId(null);
}, []);
const closeInstantNoAnim = useCallback(() => {
clearRaf();
setNoTransition(true);
closeMenu();
rafRef.current = requestAnimationFrame(() => {
setNoTransition(false);
rafRef.current = null;
});
}, [clearRaf, closeMenu]);
const toggleMenu = useCallback(() => {
clearRaf();
setNoTransition(false);
setMenuOpen((prev) => {
const next = !prev;
if (!next) {
setOpenSubmenuId(null);
}
return next;
});
}, [clearRaf]);
const toggleSubmenu = useCallback((id: string) => {
setOpenSubmenuId((cur) => (cur === id ? null : id));
}, []);
useEffect(() => {
const mql = window.matchMedia(`(min-width: ${desktopBp}px)`);
const onChange = () => {
clearRaf();
setNoTransition(true);
closeMenu();
requestAnimationFrame(() => setNoTransition(false));
};
if (mql.addEventListener) {
mql.addEventListener("change", onChange);
} else {
mql.addListener(onChange);
}
return () => {
if (mql.removeEventListener) {
mql.removeEventListener("change", onChange);
} else {
mql.removeListener(onChange);
}
};
}, [desktopBp, clearRaf, closeMenu]);
useEffect(() => clearRaf, [clearRaf]);
return {
menuOpen,
openSubmenuId,
noTransition,
setMenuOpen,
setOpenSubmenuId,
toggleMenu,
toggleSubmenu,
closeMenu,
closeInstantNoAnim,
};
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useEffect, useState } from "react";
export function useSupportsHover() {
const [supportsHover, setSupportsHover] = useState(false);
useEffect(() => {
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setSupportsHover(mql.matches);
update();
if (mql.addEventListener) {
mql.addEventListener("change", update);
return () => mql.removeEventListener("change", update);
}
mql.addListener(update);
return () => mql.removeListener(update);
}, []);
return supportsHover;
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useEffect } from "react";
type Options = {
className?: string;
};
export function useToggleBodyOverflow(locked: boolean, options: Options = {}) {
const { className = "has-menu-open" } = options;
useEffect(() => {
const root = document.documentElement;
root.classList.toggle(className, locked);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = locked ? "hidden" : "";
return () => {
root.classList.remove(className);
document.body.style.overflow = prevOverflow;
};
}, [locked, className]);
}
+12
View File
@@ -0,0 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
trailingSlash: false,
poweredByHeader: false,
allowedDevOrigins: [
"192.168.178.59", // deine PC-IP
],
};
export default nextConfig;
+5968
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "template",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"vanilla-cookieconsent": "^3.1.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"typescript": "^5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024"><metadata><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Description><dc:creator>RealFaviconGenerator</dc:creator><dc:source>https://realfavicongenerator.net</dc:source></rdf:Description></rdf:RDF></metadata><image width="1024" height="1024" xlink:href="data:image/png;base64,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 truncated

After

Width:  |  Height:  |  Size: 30 KiB

+244
View File
@@ -0,0 +1,244 @@
:root {
/* Colors */
--btn-bg-primary: #474747;
--btn-bg-primary-hover: #3b3b3b;
--btn-bg-primary-active: #353030;
--btn-text-light: #fefcfb;
--btn-text-dark: #474747;
--btn-border-primary: #474747;
--btn-border-ghost: #e9e9e9;
--btn-border-ghost-hover: #ececec;
--btn-border-ghost-active: #e1e1e1;
--btn-bg-ghost-hover: #ececec;
--btn-bg-ghost-active: #e1e1e1;
/* Disabled */
--btn-bg-disabled: #cccccc;
--btn-text-disabled: #888888;
--btn-border-disabled: #cccccc;
/* Focus */
--btn-focus-ring: 3px solid #a9a9a9;
--btn-focus-offset: 2px;
/* Layout */
--btn-py: 12px;
--btn-px: 16px;
--btn-py-lg: 16px;
--btn-px-lg: 24px;
--btn-radius: 4px;
--btn-gap: 8px;
--btn-max-height: 52px;
--btn-icon-size: 18px;
--btn-tertiary-px: 4px;
/* Typography */
--btn-font-family: inherit;
--btn-font-size: 0.889rem;
--btn-font-weight: 600;
--btn-line-height: 1;
/* Shadow */
--btn-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
--btn-shadow-none: none;
/* Motion */
--btn-transition:
background-color 0.25s ease, border-color 0.25s ease, color 0.25s ease,
transform 0.25s ease, box-shadow 0.25s ease;
}
/* Base */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--btn-gap);
padding: var(--btn-py) var(--btn-px);
max-height: var(--btn-max-height);
border: 2px solid transparent;
border-radius: var(--btn-radius);
font-family: var(--btn-font-family);
font-size: var(--btn-font-size);
font-weight: var(--btn-font-weight);
line-height: var(--btn-line-height);
white-space: nowrap;
text-decoration: none;
vertical-align: middle;
cursor: pointer;
user-select: none;
transition: var(--btn-transition);
-webkit-tap-highlight-color: transparent;
}
.btn:focus {
outline: none;
}
.btn:focus-visible {
outline: var(--btn-focus-ring);
outline-offset: var(--btn-focus-offset);
}
.btn .btn-icon {
display: flex;
align-items: center;
flex-shrink: 0;
width: var(--btn-icon-size);
height: var(--btn-icon-size);
transition:
transform 0.25s ease,
color 0.25s ease;
}
.btn:hover .btn-icon.animate {
transform: translateX(4px);
}
/* Filled */
.btn--filled {
background-color: var(--btn-bg-primary);
color: var(--btn-text-light);
border-color: var(--btn-border-primary);
box-shadow: var(--btn-shadow);
}
.btn--filled .btn-icon {
color: currentColor;
}
.btn--filled:hover {
background-color: var(--btn-bg-primary-hover);
border-color: var(--btn-bg-primary-hover);
}
.btn--filled:active {
background-color: var(--btn-bg-primary-active);
border-color: var(--btn-bg-primary-active);
transition: none;
}
/* Outline */
.btn--outline {
background-color: transparent;
color: var(--btn-text-dark);
border-color: var(--btn-border-primary);
box-shadow: var(--btn-shadow-none);
}
.btn--outline .btn-icon {
color: currentColor;
}
.btn--outline:hover {
background-color: var(--btn-bg-primary);
border-color: var(--btn-bg-primary);
color: var(--btn-text-light);
}
.btn--outline:active {
background-color: var(--btn-bg-primary-active);
border-color: var(--btn-bg-primary-active);
color: var(--btn-text-light);
transition: none;
}
/* Ghost */
.btn--ghost {
background-color: transparent;
color: var(--btn-text-dark);
border-color: var(--btn-border-ghost);
box-shadow: var(--btn-shadow-none);
}
.btn--ghost .btn-icon {
color: currentColor;
}
.btn--ghost:hover {
background-color: var(--btn-bg-ghost-hover);
border-color: var(--btn-border-ghost-hover);
}
.btn--ghost:active {
background-color: var(--btn-bg-ghost-active);
border-color: var(--btn-border-ghost-active);
transition: none;
}
/* Tertiary */
.btn--tertiary {
background-color: transparent;
color: var(--btn-text-dark);
border-color: transparent;
box-shadow: var(--btn-shadow-none);
padding-inline: var(--btn-tertiary-px);
}
.btn--tertiary .btn-icon {
color: currentColor;
}
.btn--tertiary:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.btn--tertiary:active {
opacity: 0.7;
transition: none;
}
/* Disabled */
.btn:disabled,
.btn[aria-disabled="true"] {
background-color: var(--btn-bg-disabled) !important;
border-color: var(--btn-border-disabled) !important;
color: var(--btn-text-disabled) !important;
box-shadow: none !important;
text-decoration: none !important;
cursor: default !important;
pointer-events: none;
transform: none !important;
}
.btn:disabled .btn-icon,
.btn[aria-disabled="true"] .btn-icon {
color: var(--btn-text-disabled) !important;
}
/* Touch */
@media (hover: none) {
.btn--filled:hover {
background-color: var(--btn-bg-primary);
border-color: var(--btn-bg-primary);
}
.btn--outline:hover {
background-color: transparent;
border-color: var(--btn-border-primary);
color: var(--btn-text-dark);
}
.btn--ghost:hover {
background-color: transparent;
border-color: var(--btn-border-ghost);
}
.btn--tertiary:hover {
text-decoration: none;
}
.btn:hover .btn-icon.animate {
transform: none;
}
}
@media (min-width: 768px) {
.btn {
padding: var(--btn-py-lg) var(--btn-px-lg);
}
}
+235
View File
@@ -0,0 +1,235 @@
:root {
/* Typography */
--form-font-size: inherit;
--form-font-family: inherit;
--form-label-size: 0.875rem;
--form-helper-size: 0.8rem;
/* Colors */
--form-text: #171717;
--form-placeholder: #9ca3af;
--form-label: #374151;
--form-border: #d1d5db;
--form-border-hover: #bfc5cf;
--form-border-focus: #474747;
--form-border-error: #ef4444;
--form-bg: #ffffff;
--form-bg-disabled: #f3f4f6;
--form-text-disabled: #9ca3af;
--form-error: #ef4444;
--form-success: #22c55e;
--form-check-accent: #474747;
/* Layout */
--form-radius: 8px;
--form-padding-y: 10px;
--form-padding-x: 14px;
--form-gap: 6px;
--form-field-gap: 1.25rem;
--form-control-gap: 10px;
--form-min-height: 44px;
/* Motion */
--form-transition:
border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease,
color 0.2s ease;
/* Focus */
--form-focus-shadow: 0 0 0 3px rgba(71, 71, 71, 0.15);
--form-error-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
}
/* Group */
.form-group {
display: flex;
flex-direction: column;
gap: var(--form-gap);
margin-bottom: var(--form-field-gap);
}
fieldset {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
/* Labels */
label,
.form-label {
font-size: var(--form-label-size);
font-weight: 600;
color: var(--form-label);
cursor: pointer;
}
.form-label--required::after {
content: " *";
color: var(--form-error);
}
/* Controls */
input[type="text"],
input[type="email"],
input[type="tel"],
input[type="number"],
input[type="password"],
input[type="search"],
input[type="url"],
textarea,
select {
width: 100%;
display: block;
font-family: var(--form-font-family);
font-size: var(--form-font-size);
color: var(--form-text);
background-color: var(--form-bg);
border: 1.5px solid var(--form-border);
border-radius: var(--form-radius);
padding: var(--form-padding-y) var(--form-padding-x);
min-height: var(--form-min-height);
transition: var(--form-transition);
appearance: none;
-webkit-appearance: none;
outline: none;
}
input::placeholder,
textarea::placeholder {
color: var(--form-placeholder);
}
input:hover,
textarea:hover,
select:hover {
border-color: var(--form-border-hover);
}
textarea {
resize: vertical;
min-height: 120px;
line-height: 1.6;
}
/* Focus */
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
border-color: var(--form-border-focus);
box-shadow: var(--form-focus-shadow);
}
/* Error */
input.is-error,
textarea.is-error,
select.is-error {
border-color: var(--form-border-error);
}
input.is-error:focus-visible,
textarea.is-error:focus-visible,
select.is-error:focus-visible {
box-shadow: var(--form-error-shadow);
}
.form-error-msg {
font-size: var(--form-helper-size);
color: var(--form-error);
margin-top: 2px;
}
/* Helper / Success */
.form-help {
font-size: var(--form-helper-size);
color: var(--form-label);
}
.form-success-msg {
font-size: var(--form-helper-size);
color: var(--form-success);
margin-top: 2px;
}
/* Disabled */
input:disabled,
textarea:disabled,
select:disabled {
background-color: var(--form-bg-disabled);
color: var(--form-text-disabled);
cursor: not-allowed;
opacity: 1;
}
/* Select */
select {
cursor: pointer;
padding-right: 40px;
background-repeat: no-repeat;
background-position: right 14px center;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23474747' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
}
/* Checkbox / Radio */
.form-check {
display: flex;
align-items: center;
gap: var(--form-control-gap);
cursor: pointer;
}
.form-check input[type="checkbox"],
.form-check input[type="radio"] {
width: 18px;
height: 18px;
min-width: 18px;
border: 1.5px solid var(--form-border);
background-color: var(--form-bg);
cursor: pointer;
padding: 0;
transition: var(--form-transition);
appearance: none;
-webkit-appearance: none;
}
.form-check input[type="checkbox"] {
border-radius: 4px;
}
.form-check input[type="radio"] {
border-radius: 50%;
}
.form-check input[type="checkbox"]:checked,
.form-check input[type="radio"]:checked {
background-color: var(--form-check-accent);
border-color: var(--form-check-accent);
}
.form-check input[type="checkbox"]:checked {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
}
.form-check input[type="radio"]:checked {
box-shadow: inset 0 0 0 4px white;
}
.form-check input:focus-visible {
box-shadow: var(--form-focus-shadow);
}
.form-check-label {
font-size: 0.9rem;
color: var(--form-text);
user-select: none;
}
/* Touch */
@media (hover: none) {
input:hover,
textarea:hover,
select:hover {
border-color: var(--form-border);
}
}
+34
View File
@@ -0,0 +1,34 @@
:root {
/* Layout */
--container-width: 1280px;
--content-width: 72rem;
--padding-inline: clamp(16px, 4vw, 24px);
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 32px;
/* Colors */
--color-surface: #ffffff;
--color-surface-alt: #f9f9f9;
--color-background: #ffffff;
--color-background-alt: #f9f9f9;
--color-text: #171717;
--color-text-muted: #5f5f5f;
--color-text-soft: #7a7a7a;
--color-heading: #171717;
/* Radius */
--radius-none: 0;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
--radius-full: 9999px;
}
+102
View File
@@ -0,0 +1,102 @@
:root {
/* Font sizes */
--font-size-body: 1rem;
--font-size-small: 0.875rem;
--font-size-lead: 1.125rem;
--font-size-h1: clamp(
2.566rem,
calc(
2.566rem + (3.247rem - 2.566rem) * ((100vw - 768px) / (1024px - 768px))
),
3.247rem
);
--font-size-h2: clamp(
2.027rem,
calc(
2.027rem + (2.566rem - 2.027rem) * ((100vw - 768px) / (1024px - 768px))
),
2.566rem
);
--font-size-h3: clamp(
1.602rem,
calc(
1.602rem + (2.027rem - 1.602rem) * ((100vw - 768px) / (1024px - 768px))
),
2.027rem
);
--font-size-h4: clamp(
1.266rem,
calc(
1.266rem + (1.602rem - 1.266rem) * ((100vw - 768px) / (1024px - 768px))
),
1.602rem
);
--line-height-body: 1.5;
--line-height-heading: 1.2;
--letter-spacing-tight: -0.02em;
--letter-spacing-normal: 0;
--letter-spacing-wide: 0.025em;
--letter-spacing-wider: 0.05em;
--letter-spacing-widest: 0.12em;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-body-color: #000000;
--font-heading-color: #302e2e;
}
a {
color: inherit;
text-decoration: none;
}
p,
li {
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
small,
.text-small {
font-size: var(--font-size-small);
line-height: 1.4;
}
.lead,
.text-lead {
font-size: var(--font-size-lead);
line-height: 1.6;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: var(--line-height-heading);
color: var(--font-heading-color);
font-family: var(--font-heading);
font-weight: var(--font-weight-bold);
letter-spacing: var(--letter-spacing-tight);
}
h1 {
font-size: var(--font-size-h1);
}
h2 {
font-size: var(--font-size-h2);
}
h3 {
font-size: var(--font-size-h3);
}
h4 {
font-size: var(--font-size-h4);
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}