From 4f5089ce38737c070bd37d2761acc4097ddcf950 Mon Sep 17 00:00:00 2001 From: denshooter Date: Thu, 15 Oct 2026 19:05:24 +0200 Subject: [PATCH] feat: plakat redesign phase 4 (chat window, contact, footer, cleanup) - AI chat moves into a window/sheet, opened from a new speech-bubble card on the desk; it now respects the chatbot consent and asks before sending anything to n8n (previously it ignored the consent) - Contact as red poster section with big mail address and the form on a paper card; validation errors are finally shown inline (aria-invalid, role=alert) - Footer as ink strip with dk0 wordmark, legal links and a button to reopen the privacy settings - "About me" in the header opens the about window on the home page - Remove the old About section and its parts (BentoChat, ActivityFeed, CurrentlyReading, ReadBooks), the unused ChatWidget, Projects and BentoGrid, their tests, wrappers, loaders, types and texts - scripts/directus-cleanup-redesign.mjs: dry run by default, --apply archives hobbies, tech stack categories and old content pages and deletes four unused message overrides after a full JSON backup; run only after the redesign is live on production Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + .../components/ActivityFeed.test.tsx | 151 ------ .../components/CurrentlyReading.test.tsx | 49 -- app/__tests__/pre-push-check.test.ts | 16 +- app/_ui/HomePageServer.tsx | 11 +- app/components/About.tsx | 253 --------- app/components/ActivityFeed.tsx | 248 --------- app/components/BentoChat.tsx | 114 ---- app/components/ChatWidget.tsx | 495 ------------------ app/components/ClientWrappers.tsx | 39 -- app/components/Contact.tsx | 285 ++++------ app/components/CurrentlyReading.tsx | 178 ------- app/components/Footer.tsx | 106 ++-- app/components/Header.tsx | 2 +- app/components/HeaderClient.tsx | 43 +- app/components/Projects.tsx | 140 ----- app/components/ReadBooks.tsx | 267 ---------- app/components/fenster/ChatBody.tsx | 154 ++++++ app/components/fenster/WindowContent.tsx | 8 +- app/components/fenster/getWindowLabels.ts | 11 + app/components/fenster/types.ts | 14 +- app/components/tisch/Board.tsx | 4 +- app/components/tisch/ObjectFace.tsx | 9 + app/components/tisch/TischSection.tsx | 3 + app/components/tisch/types.ts | 11 +- app/components/tisch/windowFor.ts | 4 + app/components/ui/BentoGrid.tsx | 60 --- e2e/activity-feed.spec.ts | 32 -- lib/translations-loader.ts | 64 --- messages/de.json | 93 +--- messages/en.json | 93 +--- scripts/directus-cleanup-redesign.mjs | 112 ++++ types/translations.ts | 34 -- 33 files changed, 534 insertions(+), 2572 deletions(-) delete mode 100644 app/__tests__/components/ActivityFeed.test.tsx delete mode 100644 app/__tests__/components/CurrentlyReading.test.tsx delete mode 100644 app/components/About.tsx delete mode 100644 app/components/ActivityFeed.tsx delete mode 100644 app/components/BentoChat.tsx delete mode 100644 app/components/ChatWidget.tsx delete mode 100644 app/components/CurrentlyReading.tsx delete mode 100644 app/components/Projects.tsx delete mode 100644 app/components/ReadBooks.tsx create mode 100644 app/components/fenster/ChatBody.tsx delete mode 100644 app/components/ui/BentoGrid.tsx delete mode 100644 e2e/activity-feed.spec.ts create mode 100644 scripts/directus-cleanup-redesign.mjs diff --git a/.gitignore b/.gitignore index 94f151a..c7acef7 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ bones/*.bones.json # OS .DS_Store Thumbs.db + +# Sicherungen aus scripts/directus-cleanup-redesign.mjs +directus-backup-*.json diff --git a/app/__tests__/components/ActivityFeed.test.tsx b/app/__tests__/components/ActivityFeed.test.tsx deleted file mode 100644 index b606581..0000000 --- a/app/__tests__/components/ActivityFeed.test.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import '@testing-library/jest-dom'; - -/** - * Unit tests for ActivityFeed NaN handling - * - * This test suite validates that the ActivityFeed component correctly handles - * NaN and numeric values in gaming and custom activity data to prevent - * "Received NaN for the children attribute" React errors. - */ -describe('ActivityFeed NaN Handling', () => { - describe('Gaming activity rendering logic', () => { - // Helper function to simulate getSafeGamingText behavior - const getSafeGamingText = (details: string | number | undefined, state: string | number | undefined, fallback: string): string => { - if (typeof details === 'string' && details.trim().length > 0) return details; - if (typeof state === 'string' && state.trim().length > 0) return state; - if (typeof details === 'number' && !isNaN(details)) return String(details); - if (typeof state === 'number' && !isNaN(state)) return String(state); - return fallback; - }; - - it('should safely handle NaN in gaming.details', () => { - const result = getSafeGamingText(NaN, 'Playing', 'Playing...'); - expect(result).toBe('Playing'); // Should fall through NaN to state - expect(result).not.toBe(NaN); - expect(typeof result).toBe('string'); - }); - - it('should safely handle NaN in both gaming.details and gaming.state', () => { - const result = getSafeGamingText(NaN, NaN, 'Playing...'); - expect(result).toBe('Playing...'); // Should use fallback - expect(typeof result).toBe('string'); - }); - - it('should prioritize string details over numeric state', () => { - const result = getSafeGamingText('Details text', 42, 'Playing...'); - expect(result).toBe('Details text'); // String details takes precedence - expect(typeof result).toBe('string'); - }); - - it('should prioritize string state over numeric details', () => { - const result = getSafeGamingText(42, 'State text', 'Playing...'); - expect(result).toBe('State text'); // String state takes precedence over numeric details - expect(typeof result).toBe('string'); - }); - - it('should convert valid numeric details to string', () => { - const result = getSafeGamingText(42, undefined, 'Playing...'); - expect(result).toBe('42'); - expect(typeof result).toBe('string'); - }); - - it('should handle empty strings correctly', () => { - const result1 = getSafeGamingText('', 'Playing', 'Playing...'); - expect(result1).toBe('Playing'); // Empty string should fall through to state - - const result2 = getSafeGamingText(' ', 'Playing', 'Playing...'); - expect(result2).toBe('Playing'); // Whitespace-only should fall through to state - }); - - it('should convert gaming.name to string safely', () => { - const validName = String('Test Game'); - expect(validName).toBe('Test Game'); - expect(typeof validName).toBe('string'); - - // In the actual code, we use String(data.gaming.name || '') - // If data.gaming.name is NaN, (NaN || '') evaluates to '' because NaN is falsy - const nanValue = NaN; - const nanName = String(nanValue || ''); - expect(nanName).toBe(''); // NaN is falsy, so it falls back to '' - expect(typeof nanName).toBe('string'); - }); - }); - - describe('Custom activities progress handling', () => { - it('should only render progress bar when progress is a valid number', () => { - const validProgress = 75; - const shouldRender = validProgress !== undefined && - typeof validProgress === 'number' && - !isNaN(validProgress); - expect(shouldRender).toBe(true); - }); - - it('should not render progress bar when progress is NaN', () => { - const invalidProgress = NaN; - const shouldRender = invalidProgress !== undefined && - typeof invalidProgress === 'number' && - !isNaN(invalidProgress); - expect(shouldRender).toBe(false); - }); - - it('should not render progress bar when progress is undefined', () => { - const undefinedProgress = undefined; - const shouldRender = undefinedProgress !== undefined && - typeof undefinedProgress === 'number' && - !isNaN(undefinedProgress); - expect(shouldRender).toBe(false); - }); - }); - - describe('Custom activities dynamic field rendering', () => { - it('should safely convert valid numeric values to string', () => { - const value = 42; - const shouldRender = typeof value === 'string' || - (typeof value === 'number' && !isNaN(value)); - - expect(shouldRender).toBe(true); - - if (shouldRender) { - const stringValue = String(value); - expect(stringValue).toBe('42'); - expect(typeof stringValue).toBe('string'); - } - }); - - it('should not render NaN values', () => { - const value = NaN; - const shouldRender = typeof value === 'string' || - (typeof value === 'number' && !isNaN(value)); - - expect(shouldRender).toBe(false); - }); - - it('should render valid string values', () => { - const value = 'Test String'; - const shouldRender = typeof value === 'string' || - (typeof value === 'number' && !isNaN(value)); - - expect(shouldRender).toBe(true); - - if (shouldRender) { - const stringValue = String(value); - expect(stringValue).toBe('Test String'); - expect(typeof stringValue).toBe('string'); - } - }); - - it('should render zero as a valid numeric value', () => { - const value = 0; - const shouldRender = typeof value === 'string' || - (typeof value === 'number' && !isNaN(value)); - - expect(shouldRender).toBe(true); - - if (shouldRender) { - const stringValue = String(value); - expect(stringValue).toBe('0'); - expect(typeof stringValue).toBe('string'); - } - }); - }); -}); diff --git a/app/__tests__/components/CurrentlyReading.test.tsx b/app/__tests__/components/CurrentlyReading.test.tsx deleted file mode 100644 index d7fc01e..0000000 --- a/app/__tests__/components/CurrentlyReading.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import CurrentlyReadingComp from "@/app/components/CurrentlyReading"; -import React from "react"; - -jest.mock("next-intl", () => ({ - useTranslations: () => (key: string) => key, - useLocale: () => "en", -})); - -jest.mock("next/image", () => ({ - __esModule: true, - default: (props: React.ImgHTMLAttributes) => {props.alt, -})); - -describe("CurrentlyReading Component", () => { - beforeEach(() => { - global.fetch = jest.fn(); - }); - - it("renders loading skeleton when loading", () => { - (global.fetch as jest.Mock).mockReturnValue(new Promise(() => {})); - render(); - expect(screen.getAllByText).toBeDefined(); - }); - - it("renders a book when data is fetched", async () => { - const mockBooks = [ - { - title: "Test Book", - authors: ["Test Author"], - image: "/test.jpg", - progress: 50, - startedAt: "2024-01-01" - }, - ]; - - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => ({ currentlyReading: mockBooks }), - }); - - render(); - - await waitFor(() => { - expect(screen.getByText("Test Book")).toBeInTheDocument(); - expect(screen.getByText("Test Author")).toBeInTheDocument(); - }); - }); -}); diff --git a/app/__tests__/pre-push-check.test.ts b/app/__tests__/pre-push-check.test.ts index b0f75b6..d9de496 100644 --- a/app/__tests__/pre-push-check.test.ts +++ b/app/__tests__/pre-push-check.test.ts @@ -83,23 +83,17 @@ describe("i18n: message files", () => { // ═══════════════════════════════════════════════════════════════════════════ const WRAPPED_COMPONENT_NAMESPACES: Record = { - About: ["home.about", "about"], - Projects: ["home.projects"], Contact: ["home.contact", "home.contact.form", "home.contact.info"], Footer: ["footer"], }; const CLIENT_WRAPPER_PROVIDES: Record = { - AboutClient: ["home.about", "about"], - ProjectsClient: ["home.projects"], ContactClient: ["home.contact"], FooterClient: ["footer"], }; describe("i18n: ClientWrappers provide all requested namespaces", () => { const componentToWrapper: Record = { - About: "AboutClient", - Projects: "ProjectsClient", Contact: "ContactClient", Footer: "FooterClient", }; @@ -137,17 +131,11 @@ const STATIC_KEYS: Record> = { }, consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] }, "home.hero": { Hero: ["kicker", "claim", "intro", "countdown", "ctaWork", "ctaContact"] }, - "home.tisch": { TischSection: ["title", "allProjects", "hintBoard", "hintDeck", "tidy", "mess", "flip", "details", "prev", "next", "ticketTitle", "ticketDays", "ticketJob", "ticketStub", "serverTitle", "servers.ingress.name", "servers.ingress.detail", "servers.mail.name", "servers.mail.detail", "servers.home.name", "servers.home.detail", "serverNote", "bookLabel", "bookFallback", "photoCaption", "sticker"] }, - "home.windows": { HomePageServer: ["close", "minimize", "openWindows", "projectPage", "source", "live", "stack", "year", "now.title", "now.days", "now.study.label", "now.study.text", "now.work.label", "now.work.text", "now.side.label", "now.side.text", "server.title", "server.headline", "read.title", "read.progress", "read.empty", "read.library", "about.title", "about.p1", "about.p2", "about.facts.laptop.label", "about.facts.laptop.value", "about.facts.pc.label", "about.facts.pc.value", "about.facts.study.label", "about.facts.study.value", "about.facts.languages.label", "about.facts.languages.value", "contact.title", "contact.body", "contact.copy", "contact.copied", "contact.form"] }, - "home.about": { About: ["title", "p1", "p2", "p3", "funFactTitle", "funFactBody", "hobbiesTitle"] }, - "home.about.activity": { ActivityFeed: ["idleStatus", "codingNow", "gaming", "inGame", "listening"] }, - "home.about.currentlyReading": { CurrentlyReading: ["title", "progress"] }, - "home.about.readBooks": { ReadBooks: ["title", "showMore", "showLess", "readMore", "collapseReview", "finishedAt", "empty"] }, - "home.projects": { Projects: ["title", "subtitle", "viewAll", "noProjects"] }, + "home.tisch": { TischSection: ["title", "allProjects", "hintBoard", "hintDeck", "tidy", "mess", "flip", "details", "prev", "next", "ticketTitle", "ticketDays", "ticketJob", "ticketStub", "serverTitle", "servers.ingress.name", "servers.ingress.detail", "servers.mail.name", "servers.mail.detail", "servers.home.name", "servers.home.detail", "serverNote", "bookLabel", "bookFallback", "photoCaption", "chatCard", "chatCardHint", "sticker"] }, + "home.windows": { HomePageServer: ["close", "minimize", "openWindows", "projectPage", "source", "live", "stack", "year", "now.title", "now.days", "now.study.label", "now.study.text", "now.work.label", "now.work.text", "now.side.label", "now.side.text", "server.title", "server.headline", "read.title", "read.progress", "read.empty", "read.library", "about.title", "about.p1", "about.p2", "about.facts.laptop.label", "about.facts.laptop.value", "about.facts.pc.label", "about.facts.pc.value", "about.facts.study.label", "about.facts.study.value", "about.facts.languages.label", "about.facts.languages.value", "chat.title", "chat.welcome", "chat.consent", "chat.allow", "chat.placeholder", "chat.send", "chat.error", "chat.thinking", "chat.note", "contact.title", "contact.body", "contact.copy", "contact.copied", "contact.form"] }, "home.contact": { Contact: ["title", "subtitle"] }, "home.contact.form": { Contact: ["title", "sending", "send", "labels.name", "labels.email", "labels.subject", "labels.message", "placeholders.name", "placeholders.email", "placeholders.subject", "placeholders.message", "errors.nameRequired", "errors.nameMin", "errors.emailRequired", "errors.emailInvalid", "errors.subjectRequired", "errors.subjectMin", "errors.messageRequired", "errors.messageMin", "characters"] }, "home.contact.info": { Contact: ["locationValue"] }, - about: { About: ["status", "aiAssistant", "library", "viewAll", "myGear", "gearMain", "gearPC", "gearServer", "gearOS", "curiosity"] }, "projects.list": { ProjectsPageClient: ["title", "intro", "searchPlaceholder", "all", "noResults", "clearFilters"] }, "projects.detail": { ProjectDetailClient: ["liveDemo", "viewSource"] }, footer: { Footer: ["role", "madeIn", "legalNotice", "privacyPolicy", "privacySettings", "privacySettingsTitle", "builtWith", "aiDisclaimer", "backToTop", "systemsOnline"] }, diff --git a/app/_ui/HomePageServer.tsx b/app/_ui/HomePageServer.tsx index adc141a..5f5aa11 100644 --- a/app/_ui/HomePageServer.tsx +++ b/app/_ui/HomePageServer.tsx @@ -6,12 +6,10 @@ import { getWindowLabels } from "../components/fenster/getWindowLabels"; import ScrollFadeIn from "../components/ScrollFadeIn"; import Script from "next/script"; import { - getAboutTranslations, getContactTranslations, getFooterTranslations, } from "@/lib/translations-loader"; import { - AboutClient, ContactClient, FooterClient, } from "../components/ClientWrappers"; @@ -22,8 +20,7 @@ interface HomePageServerProps { export default async function HomePageServer({ locale }: HomePageServerProps) { // Parallel laden aller Translations (hero translations handled by Hero server component) - const [aboutT, contactT, footerT, windowLabels] = await Promise.all([ - getAboutTranslations(locale), + const [contactT, footerT, windowLabels] = await Promise.all([ getContactTranslations(locale), getFooterTranslations(locale), getWindowLabels(locale), @@ -88,16 +85,12 @@ export default async function HomePageServer({ locale }: HomePageServerProps) { }), }} /> -
+
- - - - diff --git a/app/components/About.tsx b/app/components/About.tsx deleted file mode 100644 index 92acedf..0000000 --- a/app/components/About.tsx +++ /dev/null @@ -1,253 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react"; -import { useLocale, useTranslations } from "next-intl"; -import dynamic from "next/dynamic"; -const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false }); -import CurrentlyReading from "./CurrentlyReading"; -import ReadBooks from "./ReadBooks"; -import { motion } from "framer-motion"; -import { TechStackCategory, TechStackItem, Hobby } from "@/lib/directus"; -import Link from "next/link"; -import ActivityFeed from "./ActivityFeed"; -import BentoChat from "./BentoChat"; -import { Skeleton } from "./ui/Skeleton"; -import { LucideIcon } from "lucide-react"; - -const iconMap: Record = { - Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2, BookOpen, Tv, Plane, Camera, Stars, Music, Terminal, Cpu -}; - -const About = () => { - const locale = useLocale(); - const t = useTranslations("home.about"); - const at = useTranslations("about"); - const [_cmsMessages, setCmsMessages] = useState>({}); - const [cmsHtml, setCmsHtml] = useState(null); - const [techStack, setTechStack] = useState([]); - const [hobbies, setHobbies] = useState([]); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const fetchData = async () => { - try { - const [cmsRes, techRes, hobbiesRes, msgRes] = await Promise.all([ - fetch(`/api/content/page?key=home-about&locale=${locale}`), - fetch(`/api/tech-stack?locale=${locale}`), - fetch(`/api/hobbies?locale=${locale}`), - fetch(`/api/messages?locale=${locale}`) - ]); - - const cmsData = await cmsRes.json(); - if (cmsData?.content?.html) setCmsHtml(cmsData.content.html as string); - - const techData = await techRes.json(); - if (techData?.techStack) setTechStack(techData.techStack); - - const hobbiesData = await hobbiesRes.json(); - if (hobbiesData?.hobbies) setHobbies(hobbiesData.hobbies); - - const msgData = await msgRes.json(); - if (msgData?.messages) setCmsMessages(msgData.messages); - } catch (error) { - if (process.env.NODE_ENV === "development") console.error("About data fetch failed:", error); - } finally { - setIsLoading(false); - } - }; - fetchData(); - }, [locale]); - - return ( -
-
- -
- - {/* 1. Large Bio Text */} - -
-

- {t("title")}. -

-
- {isLoading ? ( -
- - - -
- ) : cmsHtml ? ( - - ) : ( -

{t("p1")} {t("p2")}

- )} -
-
-
-

{t("funFactTitle")}

- {isLoading ? :

{t("funFactBody")}

} -
-
-
-
- - {/* 2. Activity / Status Box */} - -
-

- {at("status")} -

- -
-
- - - {/* 3. AI Chat Box */} - -
- -

{at("aiAssistant")}

-
-
- -
-
- - {/* 4. Tech Stack */} - -
- {isLoading ? ( - Array.from({ length: 4 }).map((_, i) => ( -
- -
- - - -
-
- )) - ) : ( - techStack.map((cat) => ( -
-

{cat.name}

-
- {cat.items?.map((item: TechStackItem) => ( - - {item.name} - - ))} -
-
- )) - )} -
-
- - {/* 5. Library */} - -
-
-

- {at("library")} -

- - {at("viewAll")} - -
- -
- -
-
-
- - {/* 6. My Gear */} - -
-
-

- {at("myGear")} -

-
-
-

{at("gearMain")}

-

MacBook M4 Pro

-
-
-

{at("gearPC")}

-

RTX 3080 / R7

-
-
-

{at("gearServer")}

-

IONOS & RPi 4

-
-
-

{at("gearOS")}

-

macOS / Linux

-
-
-
-
-
- - - {/* 7. Hobbies */} - -
-
- {isLoading ? ( - Array.from({ length: 8 }).map((_, i) => ) - ) : ( - hobbies.map((hobby) => { - const Icon = iconMap[hobby.icon] || Lightbulb; - return ( -
-
- -

{hobby.title}

-
-

- {hobby.description} -

-
- ) - }) - )} -
-
-

{t("hobbiesTitle")}

-

{at("curiosity")}

-
-
-
- -
-
-
- ); -}; - -export default About; \ No newline at end of file diff --git a/app/components/ActivityFeed.tsx b/app/components/ActivityFeed.tsx deleted file mode 100644 index 69d8284..0000000 --- a/app/components/ActivityFeed.tsx +++ /dev/null @@ -1,248 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { Disc3, Gamepad2, Zap, Quote as QuoteIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import Image from "next/image"; - -interface CustomActivity { - [key: string]: unknown; -} - -interface StatusData { - music: { isPlaying: boolean; track: string; artist: string; albumArt: string; url: string; } | null; - gaming: { isPlaying: boolean; name: string; image: string | null; state?: string | number; details?: string | number; } | null; - coding: { isActive: boolean; project?: string; file?: string; language?: string; } | null; - customActivities?: Record; -} - -const techQuotes = { - de: [ - { content: "Informatik hat nicht mehr mit Computern zu tun als Astronomie mit Teleskopen.", author: "Edsger W. Dijkstra" }, - { content: "Einfachheit ist die Voraussetzung für Verlässlichkeit.", author: "Edsger W. Dijkstra" }, - { content: "Wenn Debugging der Prozess des Entfernens von Fehlern ist, dann muss Programmieren der Prozess des Einbauens sein.", author: "Edsger W. Dijkstra" }, - { content: "Gelöschter Code ist gedebuggter Code.", author: "Jeff Sickel" }, - { content: "Zuerst löse das Problem. Dann schreibe den Code.", author: "John Johnson" }, - { content: "Jedes Programm kann um mindestens einen Faktor zwei vereinfacht werden. Jedes Programm hat mindestens einen Bug.", author: "Kernighan's Law" }, - { content: "Code lesen ist schwieriger als Code schreiben — deshalb schreibt jeder neu.", author: "Joel Spolsky" }, - { content: "Die beste Performance-Optimierung ist der Übergang von nicht-funktionierend zu funktionierend.", author: "J. Osterhout" }, - { content: "Mach es funktionierend, dann mach es schön, dann mach es schnell — in dieser Reihenfolge.", author: "Kent Beck" }, - { content: "Software ist wie Entropie: Es ist schwer zu fassen, wiegt nichts und gehorcht dem zweiten Hauptsatz der Thermodynamik.", author: "Norman Augustine" }, - { content: "Gute Software ist nicht die, die keine Bugs hat — sondern die, deren Bugs keine Rolle spielen.", author: "Bruce Eckel" }, - { content: "Der einzige Weg, schnell zu gehen, ist, gut zu gehen.", author: "Robert C. Martin" }, - ], - en: [ - { content: "Computer Science is no more about computers than astronomy is about telescopes.", author: "Edsger W. Dijkstra" }, - { content: "Simplicity is prerequisite for reliability.", author: "Edsger W. Dijkstra" }, - { content: "If debugging is the process of removing software bugs, then programming must be the process of putting them in.", author: "Edsger W. Dijkstra" }, - { content: "Deleted code is debugged code.", author: "Jeff Sickel" }, - { content: "First, solve the problem. Then, write the code.", author: "John Johnson" }, - { content: "Any program can be simplified by at least a factor of two. Every program has at least one bug.", author: "Kernighan's Law" }, - { content: "It's harder to read code than to write it — that's why everyone rewrites.", author: "Joel Spolsky" }, - { content: "The best performance optimization is the transition from a non-working state to a working state.", author: "J. Osterhout" }, - { content: "Make it work, make it right, make it fast — in that order.", author: "Kent Beck" }, - { content: "Software is like entropy: it is difficult to grasp, weighs nothing, and obeys the second law of thermodynamics.", author: "Norman Augustine" }, - { content: "Good software isn't software with no bugs — it's software whose bugs don't matter.", author: "Bruce Eckel" }, - { content: "The only way to go fast is to go well.", author: "Robert C. Martin" }, - ] -}; - -function getSafeGamingText(details: string | number | undefined, state: string | number | undefined, fallback: string): string { - if (typeof details === 'string' && details.trim().length > 0) return details; - if (typeof state === 'string' && state.trim().length > 0) return state; - return fallback; -} - -export default function ActivityFeed({ - onActivityChange, - locale = 'en' -}: { - onActivityChange?: (active: boolean) => void; - locale?: string; -}) { - const [data, setData] = useState(null); - const [hasActivity, setHasActivity] = useState(false); - const [quoteIndex, setQuoteIndex] = useState(() => Math.floor(Math.random() * (techQuotes[locale as keyof typeof techQuotes] || techQuotes.en).length)); - const [loading, setLoading] = useState(true); - const t = useTranslations("home.about.activity"); - - const allQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en; - - useEffect(() => { - const fetchData = async () => { - try { - const res = await fetch("/api/n8n/status"); - if (!res.ok) throw new Error(); - const json = await res.json(); - const activityData = Array.isArray(json) ? json[0] : json; - - setData(activityData); - const isActive = Boolean( - activityData.coding?.isActive || - activityData.gaming?.isPlaying || - activityData.music?.isPlaying || - Object.values(activityData.customActivities || {}).some((act) => Boolean((act as { enabled?: boolean })?.enabled)) - ); - setHasActivity(isActive); - onActivityChange?.(isActive); - } catch { - setHasActivity(false); - onActivityChange?.(false); - } finally { - setLoading(false); - } - }; - - fetchData(); - const statusInterval = setInterval(fetchData, 30000); - - // Pick a random quote every 10 seconds (never the same one twice in a row) - const quoteInterval = setInterval(() => { - setQuoteIndex((prev) => { - let next; - do { next = Math.floor(Math.random() * allQuotes.length); } while (next === prev && allQuotes.length > 1); - return next; - }); - }, 10000); - - return () => { - clearInterval(statusInterval); - clearInterval(quoteInterval); - }; - }, [onActivityChange, allQuotes.length]); - - if (loading) { - return
-
-
; - } - - if (!hasActivity) { - return ( -
-
- -
-
- - -

- “{allQuotes[quoteIndex].content}” -

-

- — {allQuotes[quoteIndex].author} -

-
-
-
-
- - {t("idleStatus")} -
-
- ); - } - - return ( -
- {data?.coding?.isActive && ( - -
- - {t("codingNow")} -
-

{data.coding.project}

-

{data.coding.file}

-
- )} - - {data?.gaming?.isPlaying && ( - -
- - {t("gaming")} -
-
- {data.gaming.image ? ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {data.gaming.name} -
- ) : ( -
- -
- )} -
-

{data.gaming.name}

-

- {getSafeGamingText(data.gaming.details, data.gaming.state, t("inGame"))} -

-
-
-
- )} - - {data?.music?.isPlaying && ( - -
-
- - {t("listening")} -
- {/* Simple Animated Music Bars */} -
- {[0, 1, 2].map((i) => ( - - ))} -
-
- -
- Album Art -
-
-

{data.music.track}

-

{data.music.artist}

-
-
- {/* Subtle Spotify branding gradient */} -
- - )} -
- ); -} diff --git a/app/components/BentoChat.tsx b/app/components/BentoChat.tsx deleted file mode 100644 index f872ecc..0000000 --- a/app/components/BentoChat.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useRef } from "react"; -import { Send, Loader2 } from "lucide-react"; - -interface Message { - id: string; - text: string; - sender: "user" | "bot"; - timestamp: Date; -} - -interface StoredMessage { - id: string; - text: string; - sender: "user" | "bot"; - timestamp: string; -} - -export default function BentoChat() { - const [messages, setMessages] = useState([]); - const [inputValue, setInputValue] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [conversationId, setConversationId] = useState("default"); - const containerRef = useRef(null); - - useEffect(() => { - try { - const storedId = localStorage.getItem("chatSessionId"); - if (storedId) setConversationId(storedId); - else { - const newId = crypto.randomUUID(); - localStorage.setItem("chatSessionId", newId); - setConversationId(newId); - } - - const storedMsgs = localStorage.getItem("chatMessages"); - if (storedMsgs) { - setMessages(JSON.parse(storedMsgs).map((m: StoredMessage) => ({ ...m, timestamp: new Date(m.timestamp) }))); - } else { - setMessages([{ id: "welcome", text: "Hi! Ask me anything about Dennis! 🚀", sender: "bot", timestamp: new Date() }]); - } - } catch {} - }, []); - - useEffect(() => { - if (messages.length > 0) { - localStorage.setItem("chatMessages", JSON.stringify(messages)); - } - - if (containerRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight; - } - }, [messages]); - - const handleSend = async () => { - if (!inputValue.trim() || isLoading) return; - const userMsg: Message = { id: Date.now().toString(), text: inputValue.trim(), sender: "user", timestamp: new Date() }; - setMessages(prev => [...prev, userMsg]); - setInputValue(""); - setIsLoading(true); - - try { - const res = await fetch("/api/n8n/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: userMsg.text, conversationId, history: messages.slice(-5).map(m => ({ role: m.sender === "user" ? "user" : "assistant", content: m.text })) }), - }); - const data = await res.json(); - setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), text: data.reply || "Error", sender: "bot", timestamp: new Date() }]); - } catch { - setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), text: "Connection error.", sender: "bot", timestamp: new Date() }]); - } finally { - setIsLoading(true); - setTimeout(() => setIsLoading(false), 500); // Small delay for feel - } - }; - - return ( -
-
- {messages.map((m) => ( -
-
- {m.text} -
-
- ))} - {isLoading && ( -
-
-
- )} -
- -
- setInputValue(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSend()} - placeholder="Ask me..." - className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-[border-color,box-shadow,background-color] duration-150 ease-out shadow-inner dark:text-white" - /> - -
-
- ); -} diff --git a/app/components/ChatWidget.tsx b/app/components/ChatWidget.tsx deleted file mode 100644 index db9e3ec..0000000 --- a/app/components/ChatWidget.tsx +++ /dev/null @@ -1,495 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useRef } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { - MessageCircle, - X, - Send, - Loader2, - Sparkles, - Trash2, -} from "lucide-react"; - -interface Message { - id: string; - text: string; - sender: "user" | "bot"; - timestamp: Date; - isTyping?: boolean; -} - -export default function ChatWidget() { - // Prevent hydration mismatch by only rendering after mount - const [mounted, setMounted] = useState(false); - const [isOpen, setIsOpen] = useState(false); - const [messages, setMessages] = useState([]); - const [inputValue, setInputValue] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [conversationId, setConversationId] = useState("default"); - - useEffect(() => { - setMounted(true); - // Generate or retrieve conversation ID only on client - try { - const stored = localStorage.getItem("chatSessionId"); - if (stored) { - setConversationId(stored); - return; - } - - // Generate UUID with fallback for browsers without crypto.randomUUID - let newId: string; - if (typeof crypto !== "undefined" && crypto.randomUUID) { - newId = crypto.randomUUID(); - } else { - // Fallback UUID generation - newId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - } - - localStorage.setItem("chatSessionId", newId); - setConversationId(newId); - } catch (error) { - // localStorage might be disabled or full - if (process.env.NODE_ENV === 'development') { - console.warn('Failed to access localStorage for chat session:', error); - } - setConversationId(`session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`); - } - }, []); - - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - - // Auto-scroll to bottom when new messages arrive - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); - - // Focus input when chat opens - useEffect(() => { - if (isOpen) { - inputRef.current?.focus(); - } - }, [isOpen]); - - // Helper function to decode HTML entities - const decodeHtmlEntities = (text: string): string => { - if (!text || typeof text !== "string") return text; - const textarea = document.createElement("textarea"); - textarea.innerHTML = text; - return textarea.value; - }; - - // Load messages from localStorage - useEffect(() => { - if (typeof window !== "undefined") { - try { - const stored = localStorage.getItem("chatMessages"); - if (stored) { - try { - const parsed = JSON.parse(stored); - setMessages( - parsed.map((m: Message) => ({ - ...m, - text: decodeHtmlEntities(m.text), // Decode HTML entities when loading - timestamp: new Date(m.timestamp), - })), - ); - } catch (e) { - if (process.env.NODE_ENV === 'development') { - console.error("Failed to parse chat history", e); - } - // Clear corrupted data - try { - localStorage.removeItem("chatMessages"); - } catch { - // Ignore cleanup errors - } - // Add welcome message - setMessages([ - { - id: "welcome", - text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀", - sender: "bot", - timestamp: new Date(), - }, - ]); - } - } else { - // Add welcome message - setMessages([ - { - id: "welcome", - text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀", - sender: "bot", - timestamp: new Date(), - }, - ]); - } - } catch (error) { - // localStorage might be disabled - if (process.env.NODE_ENV === 'development') { - console.warn("Failed to load chat history from localStorage:", error); - } - // Add welcome message anyway - setMessages([ - { - id: "welcome", - text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀", - sender: "bot", - timestamp: new Date(), - }, - ]); - } - } - }, []); - - // Save messages to localStorage - useEffect(() => { - if (typeof window !== "undefined" && messages.length > 0) { - try { - localStorage.setItem("chatMessages", JSON.stringify(messages)); - } catch (error) { - // localStorage might be full or disabled - if (process.env.NODE_ENV === 'development') { - console.warn("Failed to save chat messages to localStorage:", error); - } - } - } - }, [messages]); - - const handleSend = async () => { - if (!inputValue.trim() || isLoading) return; - - const userMessage: Message = { - id: Date.now().toString(), - text: inputValue.trim(), - sender: "user", - timestamp: new Date(), - }; - - setMessages((prev) => [...prev, userMessage]); - setInputValue(""); - setIsLoading(true); - - // Get last 10 messages for context - const history = messages.slice(-10).map((m) => ({ - role: m.sender === "user" ? "user" : "assistant", - content: m.text, - })); - - try { - const response = await fetch("/api/n8n/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: userMessage.text, - conversationId, - history, - }), - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => "Unknown error"); - if (process.env.NODE_ENV === 'development') { - console.error("Chat API error:", { - status: response.status, - statusText: response.statusText, - error: errorText, - }); - } - throw new Error( - `Failed to get response: ${response.status} - ${errorText.substring(0, 100)}`, - ); - } - - const data = await response.json(); - - // Log response for debugging (only in development) - if (process.env.NODE_ENV === "development") { - console.log("Chat API response:", data); - } - - // Decode HTML entities in the reply - let replyText = - data.reply || "Sorry, I couldn't process that. Please try again."; - - // Decode HTML entities client-side (double safety) - replyText = decodeHtmlEntities(replyText); - - const botMessage: Message = { - id: (Date.now() + 1).toString(), - text: replyText, - sender: "bot", - timestamp: new Date(), - }; - - setMessages((prev) => [...prev, botMessage]); - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.error("Chat error:", error); - } - - const errorMessage: Message = { - id: (Date.now() + 1).toString(), - text: "Sorry, I'm having trouble connecting right now. Please try again later or use the contact form below.", - sender: "bot", - timestamp: new Date(), - }; - - setMessages((prev) => [...prev, errorMessage]); - } finally { - setIsLoading(false); - } - }; - - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }; - - const clearChat = () => { - // Reset session ID - const newId = crypto.randomUUID(); - setConversationId(newId); - if (typeof window !== "undefined") { - localStorage.setItem("chatSessionId", newId); - localStorage.removeItem("chatMessages"); - } - - setMessages([ - { - id: "welcome", - text: "Conversation restarted! Ask me anything about Dennis! 🚀", - sender: "bot", - timestamp: new Date(), - }, - ]); - }; - - // Don't render until mounted to prevent hydration mismatch - if (!mounted) { - return null; - } - - return ( - <> - {/* Chat Button */} - - {!isOpen && ( - setIsOpen(true)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - setIsOpen(true); - } - }} - className="fixed bottom-4 left-4 md:bottom-6 md:left-6 z-30 bg-white/80 backdrop-blur-xl text-stone-900 p-3.5 rounded-full shadow-[0_10px_26px_rgba(41,37,36,0.16)] hover:bg-white active:scale-[0.97] transition-[background-color,transform] duration-150 ease-out group cursor-pointer border border-white/60 ring-1 ring-white/30" - aria-label="Open chat" - > - - - - {/* Tooltip */} - - Chat with AI - - - )} - - - {/* Chat Window */} - - {isOpen && ( - - {/* Header */} -
-
-
-
- -
- -
-
-

- Assistant -

-

- Powered by AI -

-
-
- -
- - -
-
- - {/* Messages */} -
- {messages.map((message) => ( - -
-

- {message.text} -

-

- {message.timestamp.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} -

-
-
- ))} - - {/* Typing Indicator */} - {isLoading && ( - -
-
- - - -
-
-
- )} - -
-
- - {/* Input */} -
-
- setInputValue(e.target.value)} - onKeyPress={handleKeyPress} - placeholder="Ask anything..." - disabled={isLoading} - className="flex-1 px-4 py-3 text-sm bg-[#f5f5f4] text-[#292524] rounded-xl border border-[#e7e5e4] focus:outline-none focus:ring-2 focus:ring-[#e7e5e4] focus:border-[#a8a29e] focus:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[#78716c] transition-[border-color,box-shadow,background-color] duration-150 ease-out shadow-inner" - /> - -
- - {/* Quick Actions */} -
- {[ - "Skills 🛠️", - "Projects 🚀", - "Contact 📧", - ].map((suggestion, index) => ( - - ))} -
-
- - )} - - - ); -} diff --git a/app/components/ClientWrappers.tsx b/app/components/ClientWrappers.tsx index 8a644a2..2c80b0e 100644 --- a/app/components/ClientWrappers.tsx +++ b/app/components/ClientWrappers.tsx @@ -10,13 +10,9 @@ import dynamic from 'next/dynamic'; // Lazy-load below-fold components so their JS doesn't block initial paint / LCP. // SSR stays on (default) so content is in the initial HTML for SEO. -const About = dynamic(() => import('./About')); -const Projects = dynamic(() => import('./Projects')); const Contact = dynamic(() => import('./Contact')); const Footer = dynamic(() => import('./Footer')); import type { - AboutTranslations, - ProjectsTranslations, ContactTranslations, FooterTranslations, } from '@/types/translations'; @@ -29,41 +25,6 @@ function getNormalizedLocale(locale: string): 'en' | 'de' { return locale.startsWith('de') ? 'de' : 'en'; } -export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) { - const normalLocale = getNormalizedLocale(locale); - const baseMessages = messageMap[normalLocale]; - - const messages = { - home: { - about: baseMessages.home.about - }, - about: baseMessages.about - }; - - return ( - - - - ); -} - -export function ProjectsClient({ locale }: { locale: string; translations: ProjectsTranslations }) { - const normalLocale = getNormalizedLocale(locale); - const baseMessages = messageMap[normalLocale]; - - const messages = { - home: { - projects: baseMessages.home.projects - } - }; - - return ( - - - - ); -} - export function ContactClient({ locale }: { locale: string; translations: ContactTranslations }) { const normalLocale = getNormalizedLocale(locale); const baseMessages = messageMap[normalLocale]; diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx index 2636d59..10f22e3 100644 --- a/app/components/Contact.tsx +++ b/app/components/Contact.tsx @@ -1,8 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { motion } from "framer-motion"; -import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react"; +import { Send, Github, Linkedin } from "lucide-react"; import { useToast } from "@/components/Toast"; import { useLocale, useTranslations } from "next-intl"; import dynamic from "next/dynamic"; @@ -80,6 +79,7 @@ const Contact = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setTouched({ name: true, email: true, subject: true, message: true }); if (!validateForm()) { return; @@ -152,196 +152,103 @@ const Contact = () => { validateForm(); }; + const fields: { name: keyof typeof formData; type: string; multiline?: boolean; autoComplete?: string }[] = [ + { name: "name", type: "text", autoComplete: "name" }, + { name: "email", type: "email", autoComplete: "email" }, + { name: "subject", type: "text" }, + { name: "message", type: "text", multiline: true }, + ]; + + const pill = + "inline-flex items-center gap-2 rounded-full px-5 py-3 text-[15px] font-semibold shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-signal active:scale-[0.97]"; + return ( -
-
-
- - {/* Header Card */} - +
+
+

{tInfo("locationValue")}

+

+ {t("title")} +

+ {cmsHtml ? ( + + ) : ( +

{t("subtitle")}

+ )} + -
-

- {t("title")}. -

- {cmsHtml ? ( - - ) : ( -

- {t("subtitle")} -

- )} -
- - - {/* Info Side (Unified Connect Box) */} - -
-
-
-

Connect

-
- - Online -
-
- -
- {/* Email */} - -
- Email - contact@dk0.dev -
-
- -
-
- -
- - {/* GitHub */} - -
- Code - GitHub -
-
- -
-
- - -
- -
-

Location

-
- - {tInfo("locationValue")} -
-
-
- - - {/* Form Side */} - -

- {tForm("title")} -

- -
-
-
- - -
- -
- - -
-
- -
- - -
- -
- -