diff --git a/.kiro/specs/system-theme-detection/.config.kiro b/.kiro/specs/system-theme-detection/.config.kiro new file mode 100644 index 0000000..365784a --- /dev/null +++ b/.kiro/specs/system-theme-detection/.config.kiro @@ -0,0 +1 @@ +{"specId": "19abeaa5-fd7a-4c4e-9557-d63c62f2e8e1", "workflowType": "requirements-first", "specType": "feature"} \ No newline at end of file diff --git a/.kiro/specs/system-theme-detection/design.md b/.kiro/specs/system-theme-detection/design.md new file mode 100644 index 0000000..c48f6a3 --- /dev/null +++ b/.kiro/specs/system-theme-detection/design.md @@ -0,0 +1,263 @@ +# Design-Dokument – System-Theme-Erkennung (Light/Dark Mode) + +## Übersicht + +Dieses Feature integriert die bereits installierte Bibliothek `next-themes` (v0.3.0) in die bestehende React + Vite + Tailwind + shadcn/ui-Webseite, um eine automatische Erkennung des Betriebssystem-Themes (hell/dunkel) sowie manuelles Umschalten zu ermöglichen. + +Aktuell definiert `:root` in `src/index.css` das dunkle Farbschema. Es existiert zwar ein `.dark`-Block, dieser wird aber nicht aktiv genutzt. Die Tailwind-Konfiguration verwendet bereits `darkMode: ["class"]`, was perfekt mit `next-themes` zusammenarbeitet. + +Kernentscheidungen: +- `next-themes` wird als ThemeProvider eingesetzt (bereits als Dependency vorhanden, `attribute: "class"` für Tailwind-Kompatibilität) +- Das aktuelle `:root`-Farbschema wird zum Light Theme umstrukturiert, ein neues Dark Theme wird unter `.dark` definiert +- Ein Theme-Toggle-Button wird im Header platziert (Desktop und Mobile) +- Ein Inline-Script in `index.html` verhindert Theme-Flackern beim Laden (FOUC-Prevention) + +## Architektur + +```mermaid +graph TD + A[index.html – Inline-Script] -->|Setzt class auf html| B[html-Element] + B --> C[ThemeProvider – next-themes] + C --> D[App.tsx] + D --> E[Header mit ThemeToggle] + D --> F[Alle Seiten & Komponenten] + + G[localStorage – theme] <-->|Lesen/Schreiben| C + H[prefers-color-scheme] -->|System-Erkennung| C + + I[index.css – :root Light] --> F + J[index.css – .dark Dark] --> F +``` + +### Datenfluss + +1. Beim Laden der Seite liest ein Inline-Script in `index.html` den `localStorage`-Wert (`theme`) aus. Falls keiner vorhanden ist, wird `prefers-color-scheme` geprüft. Die entsprechende CSS-Klasse (`dark` oder keine) wird auf `` gesetzt – noch bevor React rendert. +2. `ThemeProvider` aus `next-themes` übernimmt die Verwaltung im React-Baum. Er synchronisiert den Zustand mit `localStorage` und reagiert auf Änderungen der System-Einstellung via `matchMedia`-Listener. +3. Der `ThemeToggle`-Button im Header nutzt den `useTheme()`-Hook, um zwischen `light`, `dark` und `system` zu wechseln. +4. Alle Komponenten nutzen weiterhin `hsl(var(--...))` CSS-Variablen – der Wechsel erfolgt rein über CSS-Klassen. + +## Komponenten und Schnittstellen + +### 1. ThemeProvider-Wrapper (`src/components/ThemeProvider.tsx`) + +Dünner Wrapper um `ThemeProvider` aus `next-themes`: + +```typescript +interface ThemeProviderProps { + children: React.ReactNode; + defaultTheme?: string; // Standard: "system" + storageKey?: string; // Standard: "theme" + enableSystem?: boolean; // Standard: true + disableTransitionOnChange?: boolean; // Standard: false +} +``` + +Konfiguration: +- `attribute="class"` – Tailwind-kompatibel +- `defaultTheme="system"` – Erstbesucher erhalten System-Theme +- `enableSystem={true}` – Automatische Erkennung aktiv +- `storageKey="theme"` – localStorage-Schlüssel + +Wird in `App.tsx` als äußerster Wrapper um den gesamten Komponentenbaum eingebunden. + +### 2. ThemeToggle-Komponente (`src/components/ThemeToggle.tsx`) + +Button-Komponente mit Dropdown-Menü (shadcn/ui `DropdownMenu`): + +```typescript +interface ThemeToggleProps { + className?: string; +} +``` + +Funktionalität: +- Zeigt ein Icon basierend auf dem aktuellen Theme (Sun, Moon, Monitor aus `lucide-react`) +- Dropdown mit drei Optionen: „Hell", „Dunkel", „System" +- Nutzt `useTheme()` Hook für `theme`, `setTheme`, `resolvedTheme` +- `aria-label` beschreibt den aktuellen Zustand +- Tastatur-bedienbar (Tab, Enter/Space) +- Mounted-Check verhindert Hydration-Mismatch (Icon wird erst nach Mount gerendert) + +### 3. Anpassungen am Header (`src/components/Header.tsx`) + +- ThemeToggle wird im Desktop-NavBody neben dem „Kontakt"-Button eingefügt +- ThemeToggle wird im Mobile-Nav-Header neben dem Hamburger-Menü eingefügt +- Positionierung: rechts, vor dem Kontakt-Button (Desktop) bzw. links vom Toggle-Icon (Mobile) + +### 4. Inline-Script in `index.html` + +Ein ` WEBklar diff --git a/package-lock.json b/package-lock.json index 0609f0c..883d765 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "vite_react_shadcn_ts", "version": "0.0.0", + "hasInstallScript": true, "dependencies": { "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.11", @@ -50,6 +51,7 @@ "motion": "^12.29.2", "next-themes": "^0.3.0", "ogl": "^1.0.11", + "postprocessing": "^6.36.4", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -77,6 +79,7 @@ "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "fast-check": "^4.7.0", "globals": "^15.15.0", "jsdom": "^20.0.3", "lovable-tagger": "^1.1.13", @@ -139,7 +142,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3054,7 +3056,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3202,7 +3205,6 @@ "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3218,7 +3220,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3230,7 +3231,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3296,7 +3296,6 @@ "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", @@ -3666,7 +3665,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3972,7 +3970,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -4502,7 +4499,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -4617,7 +4613,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -4675,8 +4672,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -4865,7 +4861,6 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5078,6 +5073,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5884,7 +5902,6 @@ "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "abab": "^2.0.6", "acorn": "^8.8.1", @@ -6611,6 +6628,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7125,7 +7143,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7250,6 +7267,15 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postprocessing": { + "version": "6.39.1", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.1.tgz", + "integrity": "sha512-R2dG2zy+BAx3USl5EHw+PvnrlbT5PKnZVp3se0HCR0pWH8WQdh742yNG4YWOsq6c0bFpffk0Gd2RqPeoP/wKng==", + "license": "Zlib", + "peerDependencies": { + "three": ">= 0.168.0 < 0.185.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7266,6 +7292,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7281,6 +7308,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -7291,6 +7319,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -7303,7 +7332,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prop-types": { "version": "15.8.1", @@ -7345,6 +7375,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -7377,7 +7424,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7404,7 +7450,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7418,7 +7463,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz", "integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -8172,7 +8216,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8239,8 +8282,7 @@ "version": "0.182.0", "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tiny-invariant": { "version": "1.3.3", @@ -8303,7 +8345,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8436,7 +8477,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8637,7 +8677,6 @@ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package.json b/package.json index 1d5c782..b465d99 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "motion": "^12.29.2", "next-themes": "^0.3.0", "ogl": "^1.0.11", + "postprocessing": "^6.36.4", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -85,6 +86,7 @@ "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "fast-check": "^4.7.0", "globals": "^15.15.0", "jsdom": "^20.0.3", "lovable-tagger": "^1.1.13", diff --git a/public/svg/csharp.svg b/public/svg/csharp.svg new file mode 100644 index 0000000..45fdd12 --- /dev/null +++ b/public/svg/csharp.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/svg/html5.svg b/public/svg/html5.svg new file mode 100644 index 0000000..0b3bd1f --- /dev/null +++ b/public/svg/html5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svg/nextjs.svg b/public/svg/nextjs.svg new file mode 100644 index 0000000..924afe5 --- /dev/null +++ b/public/svg/nextjs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svg/php.svg b/public/svg/php.svg new file mode 100644 index 0000000..c2748bb --- /dev/null +++ b/public/svg/php.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svg/react.svg b/public/svg/react.svg new file mode 100644 index 0000000..7ba9556 --- /dev/null +++ b/public/svg/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/svg/sql.svg b/public/svg/sql.svg new file mode 100644 index 0000000..323d09e --- /dev/null +++ b/public/svg/sql.svg @@ -0,0 +1 @@ +SQL \ No newline at end of file diff --git a/public/svg/vite.svg b/public/svg/vite.svg new file mode 100644 index 0000000..724b39e --- /dev/null +++ b/public/svg/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index c6069d5..41ce76c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,31 +3,36 @@ import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { ThemeProvider } from "@/components/ThemeProvider"; import Index from "./pages/Index"; import ContactPage from "./pages/Contact"; import AGBPage from "./pages/AGB"; import ImpressumPage from "./pages/Impressum"; +import AboutPage from "./pages/About"; import NotFound from "./pages/NotFound"; const queryClient = new QueryClient(); const App = () => ( - - - - - - - } /> - } /> - } /> - } /> - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - - - - + + + + + + + + } /> + } /> + } /> + } /> + } /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + } /> + + + + + ); export default App; diff --git a/src/components/BorderGlow.css b/src/components/BorderGlow.css new file mode 100644 index 0000000..dbab05d --- /dev/null +++ b/src/components/BorderGlow.css @@ -0,0 +1,156 @@ +.border-glow-card { + --edge-proximity: 0; + --cursor-angle: 45deg; + --edge-sensitivity: 30; + --color-sensitivity: calc(var(--edge-sensitivity) + 20); + --border-radius: 28px; + --glow-padding: 40px; + --cone-spread: 25; + position: relative; + border-radius: var(--border-radius); + isolation: isolate; + transform: translate3d(0, 0, 0.01px); + display: grid; + border: 1px solid rgb(255 255 255 / 8%); + background: var(--card-bg, #000); + overflow: hidden; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + box-shadow: + rgba(0, 0, 0, 0.1) 0px 1px 2px, + rgba(0, 0, 0, 0.1) 0px 2px 4px, + rgba(0, 0, 0, 0.1) 0px 4px 8px, + rgba(0, 0, 0, 0.1) 0px 8px 16px, + rgba(0, 0, 0, 0.1) 0px 16px 32px, + rgba(0, 0, 0, 0.1) 0px 32px 64px; +} + +.border-glow-card::before, +.border-glow-card::after, +.border-glow-card > .edge-light { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + transition: opacity 0.25s ease-out; + z-index: -1; + pointer-events: none; +} + +.border-glow-card:not(:hover):not(.sweep-active)::before, +.border-glow-card:not(:hover):not(.sweep-active)::after, +.border-glow-card:not(:hover):not(.sweep-active) > .edge-light { + opacity: 0 !important; + visibility: hidden; + transition: opacity 0.75s ease-in-out, visibility 0.75s ease-in-out; +} + +/* colored mesh-gradient border */ +.border-glow-card::before { + border: 1px solid transparent; + background: + linear-gradient(var(--card-bg, #120F17) 0 100%) padding-box, + linear-gradient(rgb(255 255 255 / 0%) 0% 100%) border-box, + var(--gradient-one, radial-gradient(at 80% 55%, hsla(268, 100%, 76%, 1) 0px, transparent 50%)) border-box, + var(--gradient-two, radial-gradient(at 69% 34%, hsla(349, 100%, 74%, 1) 0px, transparent 50%)) border-box, + var(--gradient-three, radial-gradient(at 8% 6%, hsla(136, 100%, 78%, 1) 0px, transparent 50%)) border-box, + var(--gradient-four, radial-gradient(at 41% 38%, hsla(192, 100%, 64%, 1) 0px, transparent 50%)) border-box, + var(--gradient-five, radial-gradient(at 86% 85%, hsla(186, 100%, 74%, 1) 0px, transparent 50%)) border-box, + var(--gradient-six, radial-gradient(at 82% 18%, hsla(52, 100%, 65%, 1) 0px, transparent 50%)) border-box, + var(--gradient-seven, radial-gradient(at 51% 4%, hsla(12, 100%, 72%, 1) 0px, transparent 50%)) border-box, + var(--gradient-base, linear-gradient(#c299ff 0 100%)) border-box; + opacity: calc( + (var(--edge-proximity) - var(--color-sensitivity)) / + (100 - var(--color-sensitivity)) + ); + mask-image: conic-gradient( + from var(--cursor-angle) at center, + black calc(var(--cone-spread) * 1%), + transparent calc((var(--cone-spread) + 15) * 1%), + transparent calc((100 - var(--cone-spread) - 15) * 1%), + black calc((100 - var(--cone-spread)) * 1%) + ); +} + +/* colored mesh-gradient background fill near edges */ +.border-glow-card::after { + border: 1px solid transparent; + background: + var(--gradient-one, radial-gradient(at 80% 55%, hsla(268, 100%, 76%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-two, radial-gradient(at 69% 34%, hsla(349, 100%, 74%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-three, radial-gradient(at 8% 6%, hsla(136, 100%, 78%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-four, radial-gradient(at 41% 38%, hsla(192, 100%, 64%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-five, radial-gradient(at 86% 85%, hsla(186, 100%, 74%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-six, radial-gradient(at 82% 18%, hsla(52, 100%, 65%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-seven, radial-gradient(at 51% 4%, hsla(12, 100%, 72%, 1) 0px, transparent 50%)) padding-box, + var(--gradient-base, linear-gradient(#c299ff 0 100%)) padding-box; + mask-image: + linear-gradient(to bottom, black, black), + radial-gradient(ellipse at 50% 50%, black 40%, transparent 65%), + radial-gradient(ellipse at 66% 66%, black 5%, transparent 40%), + radial-gradient(ellipse at 33% 33%, black 5%, transparent 40%), + radial-gradient(ellipse at 66% 33%, black 5%, transparent 40%), + radial-gradient(ellipse at 33% 66%, black 5%, transparent 40%), + conic-gradient( + from var(--cursor-angle) at center, + transparent 5%, + black 15%, + black 85%, + transparent 95% + ); + mask-composite: subtract, add, add, add, add, add; + opacity: calc( + var(--fill-opacity, 0.5) * + (var(--edge-proximity) - var(--color-sensitivity)) / + (100 - var(--color-sensitivity)) + ); + mix-blend-mode: soft-light; +} + +/* outer glow layer */ +.border-glow-card > .edge-light { + inset: 0; + pointer-events: none; + z-index: 1; + mask-image: conic-gradient( + from var(--cursor-angle) at center, + black 2.5%, + transparent 10%, + transparent 90%, + black 97.5% + ); + opacity: calc( + (var(--edge-proximity) - var(--edge-sensitivity)) / + (100 - var(--edge-sensitivity)) + ); + mix-blend-mode: plus-lighter; +} + +.border-glow-card > .edge-light::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: + inset 0 0 0 1px var(--glow-color, hsl(40deg 80% 80% / 100%)), + inset 0 0 1px 0 var(--glow-color-60, hsl(40deg 80% 80% / 60%)), + inset 0 0 3px 0 var(--glow-color-50, hsl(40deg 80% 80% / 50%)), + inset 0 0 6px 0 var(--glow-color-40, hsl(40deg 80% 80% / 40%)), + inset 0 0 15px 0 var(--glow-color-30, hsl(40deg 80% 80% / 30%)), + inset 0 0 25px 2px var(--glow-color-20, hsl(40deg 80% 80% / 20%)), + inset 0 0 50px 2px var(--glow-color-10, hsl(40deg 80% 80% / 10%)), + 0 0 1px 0 var(--glow-color-60, hsl(40deg 80% 80% / 60%)), + 0 0 3px 0 var(--glow-color-50, hsl(40deg 80% 80% / 50%)), + 0 0 6px 0 var(--glow-color-40, hsl(40deg 80% 80% / 40%)), + 0 0 15px 0 var(--glow-color-30, hsl(40deg 80% 80% / 30%)), + 0 0 25px 2px var(--glow-color-20, hsl(40deg 80% 80% / 20%)), + 0 0 50px 2px var(--glow-color-10, hsl(40deg 80% 80% / 10%)); +} + +.border-glow-inner { + display: flex; + flex-direction: column; + position: relative; + overflow: auto; + z-index: 1; +} diff --git a/src/components/BorderGlow.tsx b/src/components/BorderGlow.tsx new file mode 100644 index 0000000..b34e1e2 --- /dev/null +++ b/src/components/BorderGlow.tsx @@ -0,0 +1,184 @@ +import { useRef, useCallback, useEffect, type ReactNode } from 'react'; +import './BorderGlow.css'; + +function parseHSL(hslStr: string) { + const match = hslStr.match(/([\d.]+)\s*([\d.]+)%?\s*([\d.]+)%?/); + if (!match) return { h: 40, s: 80, l: 80 }; + return { h: parseFloat(match[1]), s: parseFloat(match[2]), l: parseFloat(match[3]) }; +} + +function buildGlowVars(glowColor: string, intensity: number) { + const { h, s, l } = parseHSL(glowColor); + const base = `${h}deg ${s}% ${l}%`; + const opacities = [100, 60, 50, 40, 30, 20, 10]; + const keys = ['', '-60', '-50', '-40', '-30', '-20', '-10']; + const vars: Record = {}; + for (let i = 0; i < opacities.length; i++) { + vars[`--glow-color${keys[i]}`] = `hsl(${base} / ${Math.min(opacities[i] * intensity, 100)}%)`; + } + return vars; +} + +const GRADIENT_POSITIONS = ['80% 55%', '69% 34%', '8% 6%', '41% 38%', '86% 85%', '82% 18%', '51% 4%']; +const GRADIENT_KEYS = ['--gradient-one', '--gradient-two', '--gradient-three', '--gradient-four', '--gradient-five', '--gradient-six', '--gradient-seven']; +const COLOR_MAP = [0, 1, 2, 0, 1, 2, 1]; + +function buildGradientVars(colors: string[]) { + const vars: Record = {}; + for (let i = 0; i < 7; i++) { + const c = colors[Math.min(COLOR_MAP[i], colors.length - 1)]; + vars[GRADIENT_KEYS[i]] = `radial-gradient(at ${GRADIENT_POSITIONS[i]}, ${c} 0px, transparent 50%)`; + } + vars['--gradient-base'] = `linear-gradient(${colors[0]} 0 100%)`; + return vars; +} + +function easeOutCubic(x: number) { return 1 - Math.pow(1 - x, 3); } +function easeInCubic(x: number) { return x * x * x; } + +function animateValue({ start = 0, end = 100, duration = 1000, delay = 0, ease = easeOutCubic, onUpdate, onEnd }: { + start?: number; end?: number; duration?: number; delay?: number; + ease?: (x: number) => number; onUpdate: (v: number) => void; onEnd?: () => void; +}) { + const t0 = performance.now() + delay; + function tick() { + const elapsed = performance.now() - t0; + const t = Math.min(elapsed / duration, 1); + onUpdate(start + (end - start) * ease(t)); + if (t < 1) requestAnimationFrame(tick); + else if (onEnd) onEnd(); + } + setTimeout(() => requestAnimationFrame(tick), delay); +} + +interface BorderGlowProps { + children: ReactNode; + className?: string; + edgeSensitivity?: number; + glowColor?: string; + backgroundColor?: string; + borderRadius?: number; + glowRadius?: number; + glowIntensity?: number; + coneSpread?: number; + animated?: boolean; + colors?: string[]; + fillOpacity?: number; +} + +const BorderGlow = ({ + children, + className = '', + edgeSensitivity = 30, + glowColor = '40 80 80', + backgroundColor = '#120F17', + borderRadius = 28, + glowRadius = 40, + glowIntensity = 1.0, + coneSpread = 25, + animated = false, + colors = ['#c084fc', '#f472b6', '#38bdf8'], + fillOpacity = 0.5, +}: BorderGlowProps) => { + const cardRef = useRef(null); + + const getCenterOfElement = useCallback((el: HTMLElement) => { + const { width, height } = el.getBoundingClientRect(); + return [width / 2, height / 2]; + }, []); + + const getEdgeProximity = useCallback((el: HTMLElement, x: number, y: number) => { + const [cx, cy] = getCenterOfElement(el); + const dx = x - cx; + const dy = y - cy; + let kx = Infinity; + let ky = Infinity; + if (dx !== 0) kx = cx / Math.abs(dx); + if (dy !== 0) ky = cy / Math.abs(dy); + return Math.min(Math.max(1 / Math.min(kx, ky), 0), 1); + }, [getCenterOfElement]); + + const getCursorAngle = useCallback((el: HTMLElement, x: number, y: number) => { + const [cx, cy] = getCenterOfElement(el); + const dx = x - cx; + const dy = y - cy; + if (dx === 0 && dy === 0) return 0; + const radians = Math.atan2(dy, dx); + let degrees = radians * (180 / Math.PI) + 90; + if (degrees < 0) degrees += 360; + return degrees; + }, [getCenterOfElement]); + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + const card = cardRef.current; + if (!card) return; + const rect = card.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const edge = getEdgeProximity(card, x, y); + const angle = getCursorAngle(card, x, y); + card.style.setProperty('--edge-proximity', `${(edge * 100).toFixed(3)}`); + card.style.setProperty('--cursor-angle', `${angle.toFixed(3)}deg`); + }, [getEdgeProximity, getCursorAngle]); + + useEffect(() => { + if (!animated || !cardRef.current) return; + const card = cardRef.current; + const angleStart = 110; + const angleEnd = 465; + let cancelled = false; + + function runSweep() { + if (cancelled || !card) return; + card.classList.add('sweep-active'); + card.style.setProperty('--cursor-angle', `${angleStart}deg`); + animateValue({ duration: 500, onUpdate: v => card.style.setProperty('--edge-proximity', String(v)) }); + animateValue({ + ease: easeInCubic, duration: 1500, end: 50, + onUpdate: v => { card.style.setProperty('--cursor-angle', `${(angleEnd - angleStart) * (v / 100) + angleStart}deg`); } + }); + animateValue({ + ease: easeOutCubic, delay: 1500, duration: 2250, start: 50, end: 100, + onUpdate: v => { card.style.setProperty('--cursor-angle', `${(angleEnd - angleStart) * (v / 100) + angleStart}deg`); } + }); + animateValue({ + ease: easeInCubic, delay: 2500, duration: 1500, start: 100, end: 0, + onUpdate: v => card.style.setProperty('--edge-proximity', String(v)), + onEnd: () => { + card.classList.remove('sweep-active'); + if (!cancelled) setTimeout(runSweep, 800); + }, + }); + } + + runSweep(); + return () => { cancelled = true; }; + }, [animated]); + + const glowVars = buildGlowVars(glowColor, glowIntensity); + + return ( +
+ +
+ {children} +
+
+ ); +}; + +export default BorderGlow; diff --git a/src/components/Contact.tsx b/src/components/Contact.tsx index 60c8783..07e9a72 100644 --- a/src/components/Contact.tsx +++ b/src/components/Contact.tsx @@ -1,50 +1,62 @@ import { Link } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { ArrowRight, Mail, Phone } from "lucide-react"; +import BorderGlow from "@/components/BorderGlow"; const Contact = () => { return (
-
- {/* Section Header */} -
-
Kontakt
-

- Bereit für Ihr
- nächstes Projekt? -

-

- Lassen Sie uns gemeinsam Ihre digitale Vision verwirklichen. - Buchen Sie jetzt ein kostenloses Erstgespräch. -

-
+
+ +
+
+
Kontakt
+

+ Bereit für Ihr
+ nächstes Projekt? +

+

+ Lassen Sie uns gemeinsam Ihre digitale Vision verwirklichen. + Buchen Sie jetzt ein kostenloses Erstgespräch. +

+
- {/* CTA */} -
- - - -
+
+ + + +
- {/* Contact Info */} -
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 2faef1f..46a7b7f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -14,10 +14,11 @@ import { MobileNavMenu, } from "@/components/ui/resizable-navbar"; import Logo from "@/components/Logo"; +import { ThemeToggle } from "@/components/ThemeToggle"; const Header = () => { const navItems = [ - { name: "Über uns", link: "#about" }, + { name: "Über uns", link: "/ueber-uns" }, { name: "Leistungen", link: "#services" }, { name: "Projekte", link: "#projects" }, { name: "Ablauf", link: "#process" }, @@ -38,6 +39,7 @@ const Header = () => {
+ { Webklar - setIsMobileMenuOpen(!isMobileMenuOpen)} - /> +
+ + setIsMobileMenuOpen(!isMobileMenuOpen)} + /> +
setIsMobileMenuOpen(false)} > - {navItems.map((item, idx) => ( - setIsMobileMenuOpen(false)} - className="relative text-neutral-600 dark:text-neutral-300" - > - - {item.name} - - - ))} + {navItems.map((item, idx) => + item.link.startsWith("/") ? ( + setIsMobileMenuOpen(false)} + className="relative text-neutral-600 dark:text-neutral-300" + > + + {item.name} + + + ) : ( + setIsMobileMenuOpen(false)} + className="relative text-neutral-600 dark:text-neutral-300" + > + + {item.name} + + + ) + )}
setIsMobileMenuOpen(false)}> { const navigate = useNavigate(); + const { resolvedTheme } = useTheme(); const [companyAge, setCompanyAge] = useState(""); const secondBtnRef = useRef(null); @@ -96,9 +98,19 @@ const Hero = () => { calculateAge(); const interval = setInterval(calculateAge, 60 * 60 * 1000); // Update every hour (only days/hours shown) + return () => clearInterval(interval); }, []); + // ── Silk-Hintergrund Farben ── + const isDark = resolvedTheme === "dark"; + // Silk: Hauptfarbe (Wellenspitzen) + const silkColor = isDark ? "#6a6a6a" : "#ffffff"; + // Silk: Zweite Farbe (Wellentäler) + const silkColor2 = isDark ? "#000000" : "#c0c0c0"; + // Silk: Rausch-Intensität + const silkNoise = isDark ? 4 : 1.5; + return (
{/* Silk animated background */} @@ -106,9 +118,9 @@ const Hero = () => {
diff --git a/src/components/Partners.tsx b/src/components/Partners.tsx index 917fd40..56d7d00 100644 --- a/src/components/Partners.tsx +++ b/src/components/Partners.tsx @@ -1,33 +1,76 @@ -const tools = ["Cursor", "Kiro", "N8N", "Mistral", "Hetzner", "Porkbun", "Appwrite", "Traefik"]; +const technologies = [ + { name: "C#", logo: "/svg/csharp.svg" }, + { name: "PHP", logo: "/svg/php.svg" }, + { name: "HTML5", logo: "/svg/html5.svg" }, + { name: "React", logo: "/svg/react.svg" }, + { name: "Next.js", logo: "/svg/nextjs.svg" }, + { name: "Vite", logo: "/svg/vite.svg" }, + { name: "SQL", logo: "/svg/sql.svg" }, +]; + const Partners = () => { - return
-
-
UNSERE TOOLS MIT DEN WIR ARBEITEN
+ return ( +
+
+
+ PROGRAMMIERSPRACHEN +
- -
+ +
{/* Fade edges */} -
-
- +
+
+ {/* Marquee */}
- {[...tools, ...tools].map((tool, index) =>
- - {tool} + {[...technologies, ...technologies].map((tech, index) => ( +
= technologies.length} + > + {tech.name} + + {tech.name} -
)} +
+ ))}
-
; +
+ ); }; -export default Partners; \ No newline at end of file + +export default Partners; diff --git a/src/components/PixelBlast.css b/src/components/PixelBlast.css new file mode 100644 index 0000000..5a38a90 --- /dev/null +++ b/src/components/PixelBlast.css @@ -0,0 +1,6 @@ +.pixel-blast-container { + width: 100%; + height: 100%; + position: relative; + overflow: hidden; +} diff --git a/src/components/PixelBlast.tsx b/src/components/PixelBlast.tsx new file mode 100644 index 0000000..b386a9c --- /dev/null +++ b/src/components/PixelBlast.tsx @@ -0,0 +1,525 @@ +/* eslint-disable react/no-unknown-property */ +import { Effect, EffectComposer, EffectPass, RenderPass } from 'postprocessing'; +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; +import './PixelBlast.css'; + +const createTouchTexture = () => { + const size = 64; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('2D context not available'); + ctx.fillStyle = 'black'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const texture = new THREE.Texture(canvas); + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.generateMipmaps = false; + + const trail: Array<{ x: number; y: number; age: number; force: number; vx: number; vy: number }> = []; + let last: { x: number; y: number } | null = null; + const maxAge = 64; + let radius = 0.1 * size; + const speed = 1 / maxAge; + + const clear = () => { + ctx.fillStyle = 'black'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + }; + + const drawPoint = (p: { x: number; y: number; age: number; force: number; vx: number; vy: number }) => { + const pos = { x: p.x * size, y: (1 - p.y) * size }; + let intensity = 1; + const easeOutSine = (t: number) => Math.sin((t * Math.PI) / 2); + const easeOutQuad = (t: number) => -t * (t - 2); + + if (p.age < maxAge * 0.3) intensity = easeOutSine(p.age / (maxAge * 0.3)); + else intensity = easeOutQuad(1 - (p.age - maxAge * 0.3) / (maxAge * 0.7)) || 0; + + intensity *= p.force; + const color = `${((p.vx + 1) / 2) * 255}, ${((p.vy + 1) / 2) * 255}, ${intensity * 255}`; + const offset = size * 5; + ctx.shadowOffsetX = offset; + ctx.shadowOffsetY = offset; + ctx.shadowBlur = radius; + ctx.shadowColor = `rgba(${color},${0.22 * intensity})`; + ctx.beginPath(); + ctx.fillStyle = 'rgba(255,0,0,1)'; + ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2); + ctx.fill(); + }; + + const addTouch = (norm: { x: number; y: number }) => { + let force = 0; + let vx = 0; + let vy = 0; + if (last) { + const dx = norm.x - last.x; + const dy = norm.y - last.y; + if (dx === 0 && dy === 0) return; + const dd = dx * dx + dy * dy; + const d = Math.sqrt(dd); + vx = dx / (d || 1); + vy = dy / (d || 1); + force = Math.min(dd * 10000, 1); + } + last = { x: norm.x, y: norm.y }; + trail.push({ x: norm.x, y: norm.y, age: 0, force, vx, vy }); + }; + + const update = () => { + clear(); + for (let i = trail.length - 1; i >= 0; i--) { + const point = trail[i]; + const f = point.force * speed * (1 - point.age / maxAge); + point.x += point.vx * f; + point.y += point.vy * f; + point.age++; + if (point.age > maxAge) trail.splice(i, 1); + } + for (let i = 0; i < trail.length; i++) drawPoint(trail[i]); + texture.needsUpdate = true; + }; + + return { + canvas, + texture, + addTouch, + update, + set radiusScale(v: number) { radius = 0.1 * size * v; }, + get radiusScale() { return radius / (0.1 * size); }, + size, + }; +}; + +const createLiquidEffect = (texture: THREE.Texture, opts?: { strength?: number; freq?: number }) => { + const fragment = ` + uniform sampler2D uTexture; + uniform float uStrength; + uniform float uTime; + uniform float uFreq; + void mainUv(inout vec2 uv) { + vec4 tex = texture2D(uTexture, uv); + float vx = tex.r * 2.0 - 1.0; + float vy = tex.g * 2.0 - 1.0; + float intensity = tex.b; + float wave = 0.5 + 0.5 * sin(uTime * uFreq + intensity * 6.2831853); + float amt = uStrength * intensity * wave; + uv += vec2(vx, vy) * amt; + } + `; + return new Effect('LiquidEffect', fragment, { + uniforms: new Map([ + ['uTexture', new THREE.Uniform(texture)], + ['uStrength', new THREE.Uniform(opts?.strength ?? 0.025)], + ['uTime', new THREE.Uniform(0)], + ['uFreq', new THREE.Uniform(opts?.freq ?? 4.5)], + ]), + }); +}; + +const SHAPE_MAP: Record = { square: 0, circle: 1, triangle: 2, diamond: 3 }; + +const VERTEX_SRC = `void main() { gl_Position = vec4(position, 1.0); }`; + +const FRAGMENT_SRC = `precision highp float; +uniform vec3 uColor; +uniform vec2 uResolution; +uniform float uTime; +uniform float uPixelSize; +uniform float uScale; +uniform float uDensity; +uniform float uPixelJitter; +uniform int uEnableRipples; +uniform float uRippleSpeed; +uniform float uRippleThickness; +uniform float uRippleIntensity; +uniform float uEdgeFade; +uniform int uShapeType; +const int SHAPE_SQUARE = 0; +const int SHAPE_CIRCLE = 1; +const int SHAPE_TRIANGLE = 2; +const int SHAPE_DIAMOND = 3; +const int MAX_CLICKS = 10; +uniform vec2 uClickPos [MAX_CLICKS]; +uniform float uClickTimes[MAX_CLICKS]; +out vec4 fragColor; +float Bayer2(vec2 a) { a = floor(a); return fract(a.x / 2. + a.y * a.y * .75); } +#define Bayer4(a) (Bayer2(.5*(a))*0.25 + Bayer2(a)) +#define Bayer8(a) (Bayer4(.5*(a))*0.25 + Bayer2(a)) +#define FBM_OCTAVES 5 +#define FBM_LACUNARITY 1.25 +#define FBM_GAIN 1.0 +float hash11(float n){ return fract(sin(n)*43758.5453); } +float vnoise(vec3 p){ + vec3 ip = floor(p); vec3 fp = fract(p); + float n000 = hash11(dot(ip + vec3(0,0,0), vec3(1,57,113))); + float n100 = hash11(dot(ip + vec3(1,0,0), vec3(1,57,113))); + float n010 = hash11(dot(ip + vec3(0,1,0), vec3(1,57,113))); + float n110 = hash11(dot(ip + vec3(1,1,0), vec3(1,57,113))); + float n001 = hash11(dot(ip + vec3(0,0,1), vec3(1,57,113))); + float n101 = hash11(dot(ip + vec3(1,0,1), vec3(1,57,113))); + float n011 = hash11(dot(ip + vec3(0,1,1), vec3(1,57,113))); + float n111 = hash11(dot(ip + vec3(1,1,1), vec3(1,57,113))); + vec3 w = fp*fp*fp*(fp*(fp*6.0-15.0)+10.0); + float x00 = mix(n000, n100, w.x); float x10 = mix(n010, n110, w.x); + float x01 = mix(n001, n101, w.x); float x11 = mix(n011, n111, w.x); + float y0 = mix(x00, x10, w.y); float y1 = mix(x01, x11, w.y); + return mix(y0, y1, w.z) * 2.0 - 1.0; +} +float fbm2(vec2 uv, float t){ + vec3 p = vec3(uv * uScale, t); + float amp = 1.0; float freq = 1.0; float sum = 1.0; + for (int i = 0; i < FBM_OCTAVES; ++i){ sum += amp * vnoise(p * freq); freq *= FBM_LACUNARITY; amp *= FBM_GAIN; } + return sum * 0.5 + 0.5; +} +float maskCircle(vec2 p, float cov){ float r = sqrt(cov) * .25; float d = length(p - 0.5) - r; float aa = 0.5 * fwidth(d); return cov * (1.0 - smoothstep(-aa, aa, d * 2.0)); } +float maskTriangle(vec2 p, vec2 id, float cov){ bool flip = mod(id.x + id.y, 2.0) > 0.5; if (flip) p.x = 1.0 - p.x; float r = sqrt(cov); float d = p.y - r*(1.0 - p.x); float aa = fwidth(d); return cov * clamp(0.5 - d/aa, 0.0, 1.0); } +float maskDiamond(vec2 p, float cov){ float r = sqrt(cov) * 0.564; return step(abs(p.x - 0.49) + abs(p.y - 0.49), r); } +void main(){ + float pixelSize = uPixelSize; + vec2 fragCoord = gl_FragCoord.xy - uResolution * .5; + float aspectRatio = uResolution.x / uResolution.y; + vec2 pixelId = floor(fragCoord / pixelSize); + vec2 pixelUV = fract(fragCoord / pixelSize); + float cellPixelSize = 8.0 * pixelSize; + vec2 cellId = floor(fragCoord / cellPixelSize); + vec2 cellCoord = cellId * cellPixelSize; + vec2 uv = cellCoord / uResolution * vec2(aspectRatio, 1.0); + float base = fbm2(uv, uTime * 0.05); + base = base * 0.5 - 0.65; + float feed = base + (uDensity - 0.5) * 0.3; + float speed = uRippleSpeed; float thickness = uRippleThickness; + const float dampT = 1.0; const float dampR = 10.0; + if (uEnableRipples == 1) { + for (int i = 0; i < MAX_CLICKS; ++i){ + vec2 pos = uClickPos[i]; if (pos.x < 0.0) continue; + float cellPixelSize2 = 8.0 * pixelSize; + vec2 cuv = (((pos - uResolution * .5 - cellPixelSize2 * .5) / (uResolution))) * vec2(aspectRatio, 1.0); + float t = max(uTime - uClickTimes[i], 0.0); + float r = distance(uv, cuv); + float waveR = speed * t; + float ring = exp(-pow((r - waveR) / thickness, 2.0)); + float atten = exp(-dampT * t) * exp(-dampR * r); + feed = max(feed, ring * atten * uRippleIntensity); + } + } + float bayer = Bayer8(fragCoord / uPixelSize) - 0.5; + float bw = step(0.5, feed + bayer); + float h = fract(sin(dot(floor(fragCoord / uPixelSize), vec2(127.1, 311.7))) * 43758.5453); + float jitterScale = 1.0 + (h - 0.5) * uPixelJitter; + float coverage = bw * jitterScale; + float M; + if (uShapeType == SHAPE_CIRCLE) M = maskCircle (pixelUV, coverage); + else if (uShapeType == SHAPE_TRIANGLE) M = maskTriangle(pixelUV, pixelId, coverage); + else if (uShapeType == SHAPE_DIAMOND) M = maskDiamond(pixelUV, coverage); + else M = coverage; + if (uEdgeFade > 0.0) { + vec2 norm = gl_FragCoord.xy / uResolution; + float edge = min(min(norm.x, norm.y), min(1.0 - norm.x, 1.0 - norm.y)); + float fade = smoothstep(0.0, uEdgeFade, edge); + M *= fade; + } + vec3 color = uColor; + vec3 srgbColor = mix(color * 12.92, 1.055 * pow(color, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, color)); + fragColor = vec4(srgbColor, M); +}`; + +const MAX_CLICKS = 10; + +interface PixelBlastProps { + variant?: 'square' | 'circle' | 'triangle' | 'diamond'; + pixelSize?: number; + color?: string; + className?: string; + style?: React.CSSProperties; + antialias?: boolean; + patternScale?: number; + patternDensity?: number; + liquid?: boolean; + liquidStrength?: number; + liquidRadius?: number; + pixelSizeJitter?: number; + enableRipples?: boolean; + rippleIntensityScale?: number; + rippleThickness?: number; + rippleSpeed?: number; + liquidWobbleSpeed?: number; + autoPauseOffscreen?: boolean; + speed?: number; + transparent?: boolean; + edgeFade?: number; + noiseAmount?: number; +} + +const PixelBlast = ({ + variant = 'square', + pixelSize = 3, + color = '#B497CF', + className, + style, + antialias = true, + patternScale = 2, + patternDensity = 1, + liquid = false, + liquidStrength = 0.1, + liquidRadius = 1, + pixelSizeJitter = 0, + enableRipples = true, + rippleIntensityScale = 1, + rippleThickness = 0.1, + rippleSpeed = 0.3, + liquidWobbleSpeed = 4.5, + autoPauseOffscreen = true, + speed = 0.5, + transparent = true, + edgeFade = 0.5, + noiseAmount = 0, +}: PixelBlastProps) => { + const containerRef = useRef(null); + const visibilityRef = useRef({ visible: true }); + const speedRef = useRef(speed); + const threeRef = useRef(null); + const prevConfigRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + speedRef.current = speed; + + const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount']; + const cfg: Record = { antialias, liquid, noiseAmount }; + let mustReinit = false; + if (!threeRef.current) mustReinit = true; + else if (prevConfigRef.current) { + for (const k of needsReinitKeys) + if (prevConfigRef.current[k] !== cfg[k]) { mustReinit = true; break; } + } + + if (mustReinit) { + if (threeRef.current) { + const t = threeRef.current; + t.resizeObserver?.disconnect(); + cancelAnimationFrame(t.raf); + t.quad?.geometry.dispose(); + t.material.dispose(); + t.composer?.dispose(); + t.renderer.dispose(); + t.renderer.forceContextLoss(); + if (t.renderer.domElement.parentElement === container) + container.removeChild(t.renderer.domElement); + threeRef.current = null; + } + + const canvas = document.createElement('canvas'); + const renderer = new THREE.WebGLRenderer({ canvas, antialias, alpha: true, powerPreference: 'high-performance' }); + renderer.domElement.style.width = '100%'; + renderer.domElement.style.height = '100%'; + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + container.appendChild(renderer.domElement); + if (transparent) renderer.setClearAlpha(0); + else renderer.setClearColor(0x000000, 1); + + const uniforms: Record = { + uResolution: { value: new THREE.Vector2(0, 0) }, + uTime: { value: 0 }, + uColor: { value: new THREE.Color(color) }, + uClickPos: { value: Array.from({ length: MAX_CLICKS }, () => new THREE.Vector2(-1, -1)) }, + uClickTimes: { value: new Float32Array(MAX_CLICKS) }, + uShapeType: { value: SHAPE_MAP[variant] ?? 0 }, + uPixelSize: { value: pixelSize * renderer.getPixelRatio() }, + uScale: { value: patternScale }, + uDensity: { value: patternDensity }, + uPixelJitter: { value: pixelSizeJitter }, + uEnableRipples: { value: enableRipples ? 1 : 0 }, + uRippleSpeed: { value: rippleSpeed }, + uRippleThickness: { value: rippleThickness }, + uRippleIntensity: { value: rippleIntensityScale }, + uEdgeFade: { value: edgeFade }, + }; + + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + const material = new THREE.ShaderMaterial({ + vertexShader: VERTEX_SRC, + fragmentShader: FRAGMENT_SRC, + uniforms, + transparent: true, + depthTest: false, + depthWrite: false, + glslVersion: THREE.GLSL3, + }); + const quadGeom = new THREE.PlaneGeometry(2, 2); + const quad = new THREE.Mesh(quadGeom, material); + scene.add(quad); + + const clock = new THREE.Clock(); + const setSize = () => { + const w = container.clientWidth || 1; + const h = container.clientHeight || 1; + renderer.setSize(w, h, false); + uniforms.uResolution.value.set(renderer.domElement.width, renderer.domElement.height); + if (threeRef.current?.composer) + threeRef.current.composer.setSize(renderer.domElement.width, renderer.domElement.height); + uniforms.uPixelSize.value = pixelSize * renderer.getPixelRatio(); + }; + setSize(); + const ro = new ResizeObserver(setSize); + ro.observe(container); + + const randomFloat = () => { + if (typeof window !== 'undefined' && window.crypto?.getRandomValues) { + const u32 = new Uint32Array(1); + window.crypto.getRandomValues(u32); + return u32[0] / 0xffffffff; + } + return Math.random(); + }; + const timeOffset = randomFloat() * 1000; + + let composer: EffectComposer | undefined; + let touch: ReturnType | undefined; + let liquidEffect: Effect | undefined; + + if (liquid) { + touch = createTouchTexture(); + touch.radiusScale = liquidRadius; + composer = new EffectComposer(renderer); + const renderPass = new RenderPass(scene, camera); + liquidEffect = createLiquidEffect(touch.texture, { strength: liquidStrength, freq: liquidWobbleSpeed }); + const effectPass = new EffectPass(camera, liquidEffect); + effectPass.renderToScreen = true; + composer.addPass(renderPass); + composer.addPass(effectPass); + } + + if (noiseAmount > 0) { + if (!composer) { + composer = new EffectComposer(renderer); + composer.addPass(new RenderPass(scene, camera)); + } + const noiseEffect = new Effect('NoiseEffect', + `uniform float uTime; uniform float uAmount; + float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453); } + void mainUv(inout vec2 uv){} + void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor){ + float n=hash(floor(uv*vec2(1920.0,1080.0))+floor(uTime*60.0)); + float g=(n-0.5)*uAmount; + outputColor=inputColor+vec4(vec3(g),0.0); + }`, + { uniforms: new Map([['uTime', new THREE.Uniform(0)], ['uAmount', new THREE.Uniform(noiseAmount)]]) } + ); + const noisePass = new EffectPass(camera, noiseEffect); + noisePass.renderToScreen = true; + if (composer && composer.passes.length > 0) composer.passes.forEach((p: any) => (p.renderToScreen = false)); + composer.addPass(noisePass); + } + + if (composer) composer.setSize(renderer.domElement.width, renderer.domElement.height); + + const mapToPixels = (e: PointerEvent) => { + const rect = renderer.domElement.getBoundingClientRect(); + const scaleX = renderer.domElement.width / rect.width; + const scaleY = renderer.domElement.height / rect.height; + const fx = (e.clientX - rect.left) * scaleX; + const fy = (rect.height - (e.clientY - rect.top)) * scaleY; + return { fx, fy, w: renderer.domElement.width, h: renderer.domElement.height }; + }; + + const onPointerDown = (e: PointerEvent) => { + const { fx, fy } = mapToPixels(e); + const ix = threeRef.current?.clickIx ?? 0; + uniforms.uClickPos.value[ix].set(fx, fy); + uniforms.uClickTimes.value[ix] = uniforms.uTime.value; + if (threeRef.current) threeRef.current.clickIx = (ix + 1) % MAX_CLICKS; + }; + + const onPointerMove = (e: PointerEvent) => { + if (!touch) return; + const { fx, fy, w, h } = mapToPixels(e); + touch.addTouch({ x: fx / w, y: fy / h }); + }; + + renderer.domElement.addEventListener('pointerdown', onPointerDown, { passive: true }); + renderer.domElement.addEventListener('pointermove', onPointerMove, { passive: true }); + + let raf = 0; + const animate = () => { + if (autoPauseOffscreen && !visibilityRef.current.visible) { + raf = requestAnimationFrame(animate); + return; + } + uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current; + if (liquidEffect) liquidEffect.uniforms.get('uTime')!.value = uniforms.uTime.value; + if (composer) { + if (touch) touch.update(); + composer.passes.forEach((p: any) => { + const effs = p.effects; + if (effs) effs.forEach((eff: any) => { const u = eff.uniforms?.get('uTime'); if (u) u.value = uniforms.uTime.value; }); + }); + composer.render(); + } else renderer.render(scene, camera); + raf = requestAnimationFrame(animate); + }; + raf = requestAnimationFrame(animate); + + threeRef.current = { renderer, scene, camera, material, clock, clickIx: 0, uniforms, resizeObserver: ro, raf, quad, timeOffset, composer, touch, liquidEffect }; + } else { + const t = threeRef.current; + t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0; + t.uniforms.uPixelSize.value = pixelSize * t.renderer.getPixelRatio(); + t.uniforms.uColor.value.set(color); + t.uniforms.uScale.value = patternScale; + t.uniforms.uDensity.value = patternDensity; + t.uniforms.uPixelJitter.value = pixelSizeJitter; + t.uniforms.uEnableRipples.value = enableRipples ? 1 : 0; + t.uniforms.uRippleIntensity.value = rippleIntensityScale; + t.uniforms.uRippleThickness.value = rippleThickness; + t.uniforms.uRippleSpeed.value = rippleSpeed; + t.uniforms.uEdgeFade.value = edgeFade; + if (transparent) t.renderer.setClearAlpha(0); + else t.renderer.setClearColor(0x000000, 1); + if (t.liquidEffect) { + const uFreq = t.liquidEffect.uniforms.get('uFreq'); + if (uFreq) uFreq.value = liquidWobbleSpeed; + } + if (t.touch) t.touch.radiusScale = liquidRadius; + } + + prevConfigRef.current = cfg; + return () => { + if (threeRef.current && mustReinit) return; + if (!threeRef.current) return; + const t = threeRef.current; + t.resizeObserver?.disconnect(); + cancelAnimationFrame(t.raf); + t.quad?.geometry.dispose(); + t.material.dispose(); + t.composer?.dispose(); + t.renderer.dispose(); + t.renderer.forceContextLoss(); + if (t.renderer.domElement.parentElement === container) + container.removeChild(t.renderer.domElement); + threeRef.current = null; + }; + }, [ + antialias, liquid, noiseAmount, pixelSize, patternScale, patternDensity, + enableRipples, rippleIntensityScale, rippleThickness, rippleSpeed, + pixelSizeJitter, edgeFade, transparent, liquidStrength, liquidRadius, + liquidWobbleSpeed, autoPauseOffscreen, variant, color, speed, + ]); + + return ( +
+ ); +}; + +export default PixelBlast; diff --git a/src/components/ProblemSection.tsx b/src/components/ProblemSection.tsx index 530e32b..618c251 100644 --- a/src/components/ProblemSection.tsx +++ b/src/components/ProblemSection.tsx @@ -1,8 +1,21 @@ import { Calendar, MessageSquareOff, TrendingDown, Folders } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTheme } from "next-themes"; import { LampTop } from "@/components/ui/lamp"; import LightRays from "@/components/LightRays"; +import PixelBlast from "@/components/PixelBlast"; const ProblemSection = () => { + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const isDark = mounted && resolvedTheme === "dark"; + const isLight = mounted && resolvedTheme === "light"; + const problems = [ { icon: Calendar, @@ -24,6 +37,23 @@ const ProblemSection = () => { return (
+ {/* PixelBlast animated background - nur im Light Mode */} + {isLight && ( +
+ +
+ )} {/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite */}
{ }} aria-hidden /> -
- -
+ {/* LightRays - nur im Dark Mode */} + {isDark && ( +
+ +
+ )}
{/* Section Header */} @@ -66,7 +99,7 @@ const ProblemSection = () => { {problems.map((problem, index) => (
diff --git a/src/components/Process.tsx b/src/components/Process.tsx index d1edfa1..b242e2b 100644 --- a/src/components/Process.tsx +++ b/src/components/Process.tsx @@ -1,4 +1,6 @@ import CountUp from "@/components/CountUp"; +import { TextHoverEffect } from "@/components/ui/text-hover-effect"; +import PixelBlast from "@/components/PixelBlast"; const Process = () => { const steps = [ @@ -25,14 +27,37 @@ const Process = () => { ]; return ( -
-
+
+ {/* PixelBlast animated background */} +
+ +
+ {/* TextHoverEffect */} +
+ +
+
{/* Section Header */} -
+
So arbeiten wir
-

- Ablauf -

diff --git a/src/components/ProjectShowcase.tsx b/src/components/ProjectShowcase.tsx index a9d2a83..bb7111e 100644 --- a/src/components/ProjectShowcase.tsx +++ b/src/components/ProjectShowcase.tsx @@ -1,4 +1,7 @@ import { ArrowUpRight } from "lucide-react"; +import { useTheme } from "next-themes"; +import BorderGlow from "@/components/BorderGlow"; +import { TextHoverEffect } from "@/components/ui/text-hover-effect"; type Project = { title: string; @@ -41,49 +44,60 @@ const projects: Project[] = [ ]; const ProjectShowcase = () => { + const { resolvedTheme } = useTheme(); + const cardBg = resolvedTheme === "dark" ? "hsl(0 0% 6%)" : "hsl(0 0% 96%)"; + return ( -
+
+ {/* TextHoverEffect */} +
+ +
- {/* Section Header */} -
-
Ausgewählte Arbeiten
-

- Projekte -

-
{/* Projects Grid */}
{projects.map((project, index) => ( - - diff --git a/src/components/Services.tsx b/src/components/Services.tsx index 70c168f..9bf4c25 100644 --- a/src/components/Services.tsx +++ b/src/components/Services.tsx @@ -29,14 +29,11 @@ const Services = () => { ]; return ( -
+
{/* Section Header */} -
+
Was wir tun
-

- Leistungen -

diff --git a/src/components/Silk.tsx b/src/components/Silk.tsx index 470c388..95b7030 100644 --- a/src/components/Silk.tsx +++ b/src/components/Silk.tsx @@ -1,4 +1,4 @@ -/* eslint-disable react/no-unknown-property */ + /* eslint-disable react/no-unknown-property */ import { Canvas, useFrame, useThree } from "@react-three/fiber"; import { forwardRef, useRef, useMemo, useLayoutEffect } from "react"; import { Color, type Mesh, type ShaderMaterial } from "three"; @@ -33,6 +33,7 @@ uniform float uSpeed; uniform float uScale; uniform float uRotation; uniform float uNoiseIntensity; +uniform vec3 uColor2; const float e = 2.71828182845904523536; @@ -63,7 +64,8 @@ void main() { 0.02 * tOffset) + sin(20.0 * (tex.x + tex.y - 0.1 * tOffset))); - vec4 col = vec4(uColor, 1.0) * vec4(pattern) - rnd / 15.0 * uNoiseIntensity; + vec3 mixed = mix(uColor2, uColor, pattern); + vec4 col = vec4(mixed, 1.0) - rnd / 15.0 * uNoiseIntensity; col.a = 1.0; gl_FragColor = col; } @@ -74,6 +76,7 @@ type SilkPlaneProps = { uSpeed: { value: number }; uScale: { value: number }; uNoiseIntensity: { value: number }; + uColor2: { value: Color }; uColor: { value: Color }; uRotation: { value: number }; uTime: { value: number }; @@ -118,6 +121,7 @@ type SilkProps = { speed?: number; scale?: number; color?: string; + color2?: string; noiseIntensity?: number; rotation?: number; }; @@ -126,6 +130,7 @@ const Silk = ({ speed = 5, scale = 1, color = "#7B7481", + color2 = "#000000", noiseIntensity = 1.5, rotation = 0, }: SilkProps) => { @@ -136,11 +141,12 @@ const Silk = ({ uSpeed: { value: speed }, uScale: { value: scale }, uNoiseIntensity: { value: noiseIntensity }, + uColor2: { value: new Color(...hexToNormalizedRGB(color2)) }, uColor: { value: new Color(...hexToNormalizedRGB(color)) }, uRotation: { value: rotation }, uTime: { value: 0 }, }), - [speed, scale, noiseIntensity, color, rotation] + [speed, scale, noiseIntensity, color, color2, rotation] ); return ( diff --git a/src/components/SolutionSection.tsx b/src/components/SolutionSection.tsx index 73064c6..3c26954 100644 --- a/src/components/SolutionSection.tsx +++ b/src/components/SolutionSection.tsx @@ -1,10 +1,23 @@ import { Link } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { useTheme } from "next-themes"; import { Button } from "@/components/ui/button"; import { ArrowRight, CheckCircle2 } from "lucide-react"; import { LampTop } from "@/components/ui/lamp"; import LightRays from "@/components/LightRays"; +import PixelBlast from "@/components/PixelBlast"; const SolutionSection = () => { + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const isDark = mounted && resolvedTheme === "dark"; + const isLight = mounted && resolvedTheme === "light"; + const benefits = [ "Alle Prozesse greifen ineinander.", "Informationen fließen automatisch.", @@ -13,6 +26,23 @@ const SolutionSection = () => { return (
+ {/* PixelBlast animated background - nur im Light Mode */} + {isLight && ( +
+ +
+ )} {/* Hintergrundbild mittig, auf Handy maximale Breite */}
{ }} aria-hidden /> -
- -
+ {/* LightRays - nur im Dark Mode */} + {isDark && ( +
+ +
+ )}
@@ -74,7 +107,7 @@ const SolutionSection = () => { {/* Right Content - Visual Element */}
-
+
Das Ergebnis

diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx new file mode 100644 index 0000000..19f94ca --- /dev/null +++ b/src/components/ThemeProvider.tsx @@ -0,0 +1,20 @@ +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import type { ThemeProviderProps as NextThemeProviderProps } from "next-themes"; + +export interface ThemeProviderProps extends Omit { + children: React.ReactNode; +} + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return ( + + {children} + + ); +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..ed52b8c --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react"; +import { useTheme } from "next-themes"; +import { Sun, Moon, Monitor } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +interface ThemeToggleProps { + className?: string; +} + +const themeLabels: Record = { + light: "Hell", + dark: "Dunkel", + system: "System", +}; + +export function ThemeToggle({ className }: ThemeToggleProps) { + const { theme, setTheme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const currentLabel = themeLabels[theme ?? "system"] ?? "System"; + + const icon = !mounted ? ( + + ) : theme === "system" ? ( + + ) : resolvedTheme === "dark" ? ( + + ) : ( + + ); + + return ( + + + + + + setTheme("light")}> + + Hell + + setTheme("dark")}> + + Dunkel + + setTheme("system")}> + + System + + + + ); +} diff --git a/src/components/Values.tsx b/src/components/Values.tsx index 3619cf2..ca49a9c 100644 --- a/src/components/Values.tsx +++ b/src/components/Values.tsx @@ -1,4 +1,6 @@ import { Users, Cog, MessageSquare, Target, BarChart3, Layers } from "lucide-react"; +import BorderGlow from "@/components/BorderGlow"; +import { TextHoverEffect } from "@/components/ui/text-hover-effect"; const Values = () => { const features = [ @@ -35,10 +37,14 @@ const Values = () => { ]; return ( -
+
+ {/* TextHoverEffect */} +
+ +
{/* Hintergrundbild: auf Handy maximale Breite */}
{
{features.map((feature, index) => ( -
-
- +
+
+ +
+

+ {feature.title} +

+

+ {feature.description} +

-

- {feature.title} -

-

- {feature.description} -

-
+ ))}
diff --git a/src/components/ui/lamp.tsx b/src/components/ui/lamp.tsx index 4501a80..8a8ebde 100644 --- a/src/components/ui/lamp.tsx +++ b/src/components/ui/lamp.tsx @@ -18,7 +18,7 @@ export const LampTop = ({ return (
diff --git a/src/components/ui/resizable-navbar.tsx b/src/components/ui/resizable-navbar.tsx index 83657db..cc50c39 100644 --- a/src/components/ui/resizable-navbar.tsx +++ b/src/components/ui/resizable-navbar.tsx @@ -9,6 +9,7 @@ import { useMotionValueEvent, } from "motion/react"; import React, { useRef, useState } from "react"; +import { Link as RouterLink } from "react-router-dom"; interface NavbarProps { children: React.ReactNode; @@ -72,8 +73,8 @@ export const NavBody = ({ children, className, visible }: NavBodyProps) => { }} className={cn( "relative z-[60] mx-auto hidden w-full max-w-7xl flex-row items-center justify-between self-start rounded-full bg-transparent px-4 py-2 lg:flex", - "text-white [&_a]:text-white [&_a:hover]:text-white/90 [&_.navbar-actions_a]:!text-black", - visible && "bg-black/90", + "text-black dark:text-white [&_a]:text-black dark:[&_a]:text-white [&_a:hover]:text-black/70 dark:[&_a:hover]:text-white/90 [&_.navbar-actions_a]:!text-black", + visible && "!text-white dark:!text-white [&_a]:!text-white dark:[&_a]:!text-white [&_a:hover]:!text-white/90 bg-black/90", className )} > @@ -108,23 +109,31 @@ export const NavItems = ({ className )} > - {items.map((item, idx) => ( - setHovered(idx)} - onClick={onItemClick} - className="relative px-4 py-2 text-neutral-600 dark:text-neutral-300" - key={`link-${idx}`} - href={item.link} - > - {hovered === idx && ( - - )} - {item.name} - - ))} + {items.map((item, idx) => { + const isRoute = item.link.startsWith("/"); + const commonProps = { + onMouseEnter: () => setHovered(idx), + onClick: onItemClick, + className: "relative px-4 py-2 text-neutral-900 dark:text-neutral-300", + key: `link-${idx}`, + }; + const inner = ( + <> + {hovered === idx && ( + + )} + {item.name} + + ); + return isRoute ? ( + {inner} + ) : ( + {inner} + ); + })} ); }; @@ -160,8 +169,8 @@ export const MobileNav = ({ }} className={cn( "relative z-50 mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between bg-transparent px-0 py-2 lg:hidden", - "[&>div:first-child]:text-white [&>div:first-child_a]:text-white [&>div:first-child_svg]:text-white", - visible && "bg-black/90", + "[&>div:first-child]:text-black dark:[&>div:first-child]:text-white [&>div:first-child_a]:text-black dark:[&>div:first-child_a]:text-white [&>div:first-child_svg]:text-black dark:[&>div:first-child_svg]:text-white", + visible && "[&>div:first-child]:!text-white dark:[&>div:first-child]:!text-white [&>div:first-child_a]:!text-white [&>div:first-child_svg]:!text-white bg-black/90", className )} > diff --git a/src/components/ui/text-hover-effect.tsx b/src/components/ui/text-hover-effect.tsx new file mode 100644 index 0000000..f3df46b --- /dev/null +++ b/src/components/ui/text-hover-effect.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useRef, useState, useCallback, useId } from "react"; +import { motion } from "motion/react"; + +export const TextHoverEffect = ({ + text, +}: { + text: string; + duration?: number; +}) => { + const svgRef = useRef(null); + const [hovered, setHovered] = useState(false); + const [cursorPos, setCursorPos] = useState({ x: 150, y: 30 }); + const id = useId(); + const gradientId = `textGradient-${id}`; + const revealMaskId = `revealMask-${id}`; + const textMaskId = `textMask-${id}`; + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!svgRef.current) return; + const rect = svgRef.current.getBoundingClientRect(); + // Map screen coords to viewBox coords (0 0 300 60) + const x = ((e.clientX - rect.left) / rect.width) * 300; + const y = ((e.clientY - rect.top) / rect.height) * 60; + setCursorPos({ x, y }); + }, + [] + ); + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + onMouseMove={handleMouseMove} + className="select-none" + > + + + + + + + + + + + + + + + + + + + + {/* Faint outline always visible */} + + {text} + + + {/* Animated stroke draw */} + + {text} + + + {/* Colored gradient revealed on hover */} + + {text} + + + ); +}; diff --git a/src/index.css b/src/index.css index b164083..712423b 100644 --- a/src/index.css +++ b/src/index.css @@ -10,11 +10,79 @@ @tailwind components; @tailwind utilities; -/* webklar Design System - Muradov Inspired Minimal Dark Theme */ +/* webklar Design System - Light/Dark Theme Support */ @layer base { :root { - /* Ultra Minimal Deep Black Theme - Muradov Inspired */ + /* Light Theme */ + --background: 0 0% 100%; + --foreground: 0 0% 9%; + + --card: 0 0% 98%; + --card-foreground: 0 0% 9%; + + --popover: 0 0% 98%; + --popover-foreground: 0 0% 9%; + + /* Primary - Cyan-Blau */ + --primary: 198 93% 42%; + --primary-foreground: 0 0% 98%; + + /* Secondary - Helles Grau */ + --secondary: 0 0% 94%; + --secondary-foreground: 0 0% 9%; + + /* Muted */ + --muted: 0 0% 94%; + --muted-foreground: 0 0% 40%; + + /* Accent */ + --accent: 0 0% 94%; + --accent-foreground: 0 0% 9%; + + --destructive: 0 62% 50%; + --destructive-foreground: 0 0% 98%; + + --border: 0 0% 88%; + --input: 0 0% 88%; + --ring: 198 93% 42%; + + --radius: 0.5rem; + + /* Shadows */ + --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); + --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); + --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); + + --sidebar-background: 0 0% 97%; + --sidebar-foreground: 0 0% 9%; + --sidebar-primary: 198 93% 42%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 0 0% 94%; + --sidebar-accent-foreground: 0 0% 9%; + --sidebar-border: 0 0% 88%; + --sidebar-ring: 198 93% 42%; + --chart-1: 198 93% 42%; + --chart-2: 213 93% 50%; + --chart-3: 0 0% 45%; + --chart-4: 0 0% 35%; + --chart-5: 0 0% 25%; + --sidebar: 0 0% 97%; + --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; + --font-display: 'Space Grotesk', system-ui, sans-serif; + --spacing: 0.25rem; + --font-serif: 'Lora', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; + --font-mono: 'Space Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); + --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); + --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); + --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); + --tracking-normal: 0em; + } + + .dark { + /* Dark Theme – original :root dark color scheme */ --background: 0 0% 0%; --foreground: 0 0% 92%; @@ -68,61 +136,10 @@ --chart-4: 215 16% 46%; --chart-5: 215 19% 34%; --sidebar: 210 40% 98%; - --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; - --font-display: 'Space Grotesk', system-ui, sans-serif; - --spacing: 0.25rem; - --font-serif: 'Lora', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; - --font-mono: 'Space Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); - --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); - --tracking-normal: 0em; - } - - .dark { - --background: 222 47% 11%; - --foreground: 210 40% 98%; - --card: 217 32% 17%; - --card-foreground: 210 40% 98%; - --popover: 215 24% 26%; - --popover-foreground: 210 40% 98%; - --primary: 198 93% 59%; - --primary-foreground: 204 80% 15%; - --secondary: 212 26% 83%; - --secondary-foreground: 228 84% 4%; - --muted: 215 16% 46%; - --muted-foreground: 210 40% 98%; - --accent: 228 84% 4%; - --accent-foreground: 215 20% 65%; - --destructive: 0 84% 60%; - --destructive-foreground: 0 85% 97%; - --border: 215 19% 34%; - --input: 215 19% 34%; - --ring: 198 93% 59%; - --chart-1: 199 95% 73%; - --chart-2: 211 96% 78%; - --chart-3: 215 20% 65%; - --chart-4: 215 16% 46%; - --chart-5: 215 19% 34%; - --sidebar: 217 32% 17%; - --sidebar-foreground: 210 40% 98%; - --sidebar-primary: 198 93% 59%; - --sidebar-primary-foreground: 204 80% 15%; - --sidebar-accent: 215 20% 65%; - --sidebar-accent-foreground: 228 84% 4%; - --sidebar-border: 215 19% 34%; - --sidebar-ring: 198 93% 59%; - --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); - --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); - --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); - --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); - --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); - --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); - --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); - --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); - --radius: 0rem; } } @@ -216,11 +233,11 @@ border-radius: inherit; } - /* Minimal glass nav */ + /* Minimal glass nav – theme-aware */ .glass-nav { @apply backdrop-blur-xl border-b; - background: hsl(0 0% 3% / 0.9); - border-color: hsl(0 0% 15% / 0.5); + background: hsl(var(--background) / 0.9); + border-color: hsl(var(--border) / 0.5); } /* Card minimal */ @@ -230,12 +247,12 @@ border: 1px solid hsl(var(--border)); } .card-minimal:hover { - border-color: hsl(0 0% 25%); + border-color: hsl(var(--muted-foreground)); } - /* Text gradient - subtle */ + /* Text gradient - theme-aware */ .text-gradient { - background: linear-gradient(135deg, hsl(0 0% 100%) 0%, hsl(0 0% 70%) 100%); + background: linear-gradient(135deg, hsl(var(--foreground)) 0%, hsl(var(--muted-foreground)) 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -248,7 +265,7 @@ .link-underline::after { content: ''; @apply absolute bottom-0 left-0 w-full h-px scale-x-0 origin-right transition-transform duration-300; - background: hsl(0 0% 98%); + background: hsl(var(--foreground)); } .link-underline:hover::after { @apply scale-x-100 origin-left; @@ -268,37 +285,37 @@ animation: marquee 30s linear infinite; } - /* Minimal project card */ + /* Minimal project card – theme-aware */ .project-card { @apply transition-all duration-500 overflow-hidden; - background: hsl(0 0% 6%); - border: 1px solid hsl(0 0% 12%); + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); } .project-card:hover { - border-color: hsl(0 0% 25%); + border-color: hsl(var(--muted-foreground)); transform: translateY(-4px); } - /* Minimal button */ + /* Minimal button – theme-aware */ .btn-minimal { @apply relative transition-all duration-300; - background: hsl(0 0% 98%); - color: hsl(0 0% 3%); + background: hsl(var(--foreground)); + color: hsl(var(--background)); } .btn-minimal:hover { - background: hsl(0 0% 85%); + background: hsl(var(--muted-foreground)); } - /* Outline button */ + /* Outline button – theme-aware */ .btn-outline { @apply relative transition-all duration-300 border; background: transparent; - color: hsl(0 0% 98%); - border-color: hsl(0 0% 25%); + color: hsl(var(--foreground)); + border-color: hsl(var(--muted-foreground)); } .btn-outline:hover { - border-color: hsl(0 0% 50%); - background: hsl(0 0% 10%); + border-color: hsl(var(--foreground)); + background: hsl(var(--accent)); } /* Custom CTA button (System-Demo) */ @@ -764,41 +781,33 @@ line-height: 1; } - /* Count-up with gradient */ + /* Count-up with gradient – theme-aware */ .count-up-text { - background: linear-gradient(135deg, hsl(0 0% 92%) 0%, hsl(0 0% 70%) 50%, hsl(0 0% 92%) 100%); + background: linear-gradient(135deg, hsl(var(--foreground)) 0%, hsl(var(--muted-foreground)) 50%, hsl(var(--foreground)) 100%); background-size: 200% auto; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } - .dark .count-up-text { - background: linear-gradient(135deg, hsl(0 0% 98%) 0%, hsl(0 0% 75%) 50%, hsl(0 0% 98%) 100%); - background-size: 200% auto; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - } - - /* Grid line decoration */ + /* Grid line decoration – theme-aware */ .grid-lines { background-image: - linear-gradient(hsl(0 0% 12%) 1px, transparent 1px), - linear-gradient(90deg, hsl(0 0% 12%) 1px, transparent 1px); + linear-gradient(hsl(var(--border)) 1px, transparent 1px), + linear-gradient(90deg, hsl(var(--border)) 1px, transparent 1px); background-size: 80px 80px; } - /* Horizontal divider */ + /* Horizontal divider – theme-aware */ .divider { @apply w-full h-px; - background: linear-gradient(90deg, transparent 0%, hsl(0 0% 20%) 50%, transparent 100%); + background: linear-gradient(90deg, transparent 0%, hsl(var(--border)) 50%, transparent 100%); } - /* Label/Tag */ + /* Label/Tag – theme-aware */ .label-tag { @apply text-xs uppercase tracking-widest font-medium; - color: hsl(0 0% 50%); + color: hsl(var(--muted-foreground)); } } @@ -865,4 +874,4 @@ .animate-fade-in-up { animation: fade-in-up 0.6s ease-out forwards; -} \ No newline at end of file +} diff --git a/src/pages/About.tsx b/src/pages/About.tsx new file mode 100644 index 0000000..d8fc630 --- /dev/null +++ b/src/pages/About.tsx @@ -0,0 +1,183 @@ +import { Link } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, ArrowRight } from "lucide-react"; +import CountUp from "@/components/CountUp"; +import BorderGlow from "@/components/BorderGlow"; + +const About = () => { + return ( +
+ {/* Header */} +
+
+
+ + + Webklar + + + + + +
+
+
+ + {/* Main Content */} +
+
+
+
Die Realität
+ +

+ Mehr Arbeit darf nicht die einzige Antwort auf Wachstum sein. +

+ +
+

+ Neue Kunden sollten Ihr Unternehmen nicht ins Chaos stürzen. +

+ +

+ Wenn mehr Umsatz automatisch mehr Stress bedeutet, liegt das + Problem nicht an Ihrem Markt –{" "} + + sondern an fehlenden Systemen. + +

+ +
+

+ Erfolgreiche Unternehmen skalieren Prozesse. +

+

+ Nicht Arbeitsstunden. +

+
+ +

+ Genau dort setzen wir an. +

+
+ + {/* Back / Contact */} +
+ + + + + + +
+
+
+ + {/* Der Unterschied */} +
+
+
+
Der Unterschied
+ +

+ Warum Unternehmen zu uns wechseln. +

+ +
+ +
+
+ +
+

+ Alles aus einer Hand +

+

+ Keine 10 verschiedenen Anbieter. Ein Partner für Ihre gesamte digitale Infrastruktur. +

+
+
+ + +
+
+ +
+

+ Systeme statt Inseln +

+

+ Wir verbinden Ihre Tools zu einem durchdachten Gesamtsystem. +

+
+
+ + +
+
+ +
+

+ Langfristige Partnerschaft +

+

+ Wir begleiten Sie nicht nur beim Launch, sondern beim Wachstum. +

+
+
+
+ + + + +
+
+
+
+
+ ); +}; + +export default About; diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index a59f83f..4bb0ff8 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -2,10 +2,8 @@ import Header from "@/components/Header"; import Hero from "@/components/Hero"; import Partners from "@/components/Partners"; import ProblemSection from "@/components/ProblemSection"; -import AgitationSection from "@/components/AgitationSection"; import SolutionSection from "@/components/SolutionSection"; import Values from "@/components/Values"; -import DifferentiationSection from "@/components/DifferentiationSection"; import Services from "@/components/Services"; import ProjectShowcase from "@/components/ProjectShowcase"; import Process from "@/components/Process"; @@ -19,10 +17,8 @@ const Index = () => { - -