feat: plakat redesign phase 4 (chat window, contact, footer, cleanup) (#77)
CI / CD / test-build (push) Successful in 11m13s
CI / CD / deploy-dev (push) Successful in 2m25s
CI / CD / deploy-production (push) Has been skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
denshooter
2026-10-15 23:49:56 +02:00
committed by GitHub
parent 0fa10315b2
commit 9e6ae866d0
33 changed files with 534 additions and 2572 deletions
+3
View File
@@ -64,3 +64,6 @@ bones/*.bones.json
# OS
.DS_Store
Thumbs.db
# Sicherungen aus scripts/directus-cleanup-redesign.mjs
directus-backup-*.json
@@ -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');
}
});
});
});
@@ -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<HTMLImageElement>) => <img {...props} alt={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(<CurrentlyReadingComp />);
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(<CurrentlyReadingComp />);
await waitFor(() => {
expect(screen.getByText("Test Book")).toBeInTheDocument();
expect(screen.getByText("Test Author")).toBeInTheDocument();
});
});
});
+2 -14
View File
@@ -83,23 +83,17 @@ describe("i18n: message files", () => {
// ═══════════════════════════════════════════════════════════════════════════
const WRAPPED_COMPONENT_NAMESPACES: Record<string, string[]> = {
About: ["home.about", "about"],
Projects: ["home.projects"],
Contact: ["home.contact", "home.contact.form", "home.contact.info"],
Footer: ["footer"],
};
const CLIENT_WRAPPER_PROVIDES: Record<string, string[]> = {
AboutClient: ["home.about", "about"],
ProjectsClient: ["home.projects"],
ContactClient: ["home.contact"],
FooterClient: ["footer"],
};
describe("i18n: ClientWrappers provide all requested namespaces", () => {
const componentToWrapper: Record<string, string> = {
About: "AboutClient",
Projects: "ProjectsClient",
Contact: "ContactClient",
Footer: "FooterClient",
};
@@ -137,17 +131,11 @@ const STATIC_KEYS: Record<string, Record<string, string[]>> = {
},
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"] },
+2 -9
View File
@@ -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) {
}),
}}
/>
<Header locale={locale} />
<WindowProvider labels={windowLabels}>
<Header locale={locale} />
<main className="relative">
<Hero locale={locale} />
<TischSection locale={locale} />
<ScrollFadeIn>
<AboutClient locale={locale} translations={aboutT} />
</ScrollFadeIn>
<ScrollFadeIn>
<ContactClient locale={locale} translations={contactT} />
</ScrollFadeIn>
-253
View File
@@ -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<string, LucideIcon> = {
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<Record<string, string>>({});
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
const [hobbies, setHobbies] = useState<Hobby[]>([]);
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 (
<section id="about" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
{/* 1. Large Bio Text */}
<motion.div
className="md:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="space-y-5 sm:space-y-6 md:space-y-8">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
</h2>
<div className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-[95%]" />
<Skeleton className="h-6 w-[90%]" />
</div>
) : cmsHtml ? (
<RichTextClient html={cmsHtml} />
) : (
<p>{t("p1")} {t("p2")}</p>
)}
</div>
<div className="pt-4 sm:pt-6 md:pt-8">
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-2xl sm:rounded-3xl border border-stone-100 dark:border-stone-700">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400 mb-1 sm:mb-2">{t("funFactTitle")}</p>
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-sm sm:text-base font-bold opacity-90">{t("funFactBody")}</p>}
</div>
</div>
</div>
</motion.div>
{/* 2. Activity / Status Box */}
<motion.div
transition={{ delay: 0.1 }}
className="md:col-span-4 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
>
<div className="relative z-10 h-full">
<h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
<Activity size={20} /> {at("status")}
</h3>
<ActivityFeed locale={locale} />
</div>
<div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" />
</motion.div>
{/* 3. AI Chat Box */}
<motion.div
transition={{ delay: 0.2 }}
className="md:col-span-12 lg:col-span-4 bg-stone-50 dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 flex flex-col shadow-sm"
>
<div className="flex items-center gap-2 mb-5 sm:mb-8">
<MessageSquare className="text-liquid-purple" size={20} />
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">{at("aiAssistant")}</h3>
</div>
<div className="flex-1">
<BentoChat />
</div>
</motion.div>
{/* 4. Tech Stack */}
<motion.div
transition={{ delay: 0.3 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 md:gap-12">
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-6">
<Skeleton className="h-3 w-20" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-24 rounded-xl" />
<Skeleton className="h-8 w-16 rounded-xl" />
<Skeleton className="h-8 w-20 rounded-xl" />
</div>
</div>
))
) : (
techStack.map((cat) => (
<div key={cat.id} className="space-y-6">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{cat.name}</h4>
<div className="flex flex-wrap gap-2">
{cat.items?.map((item: TechStackItem) => (
<span key={item.id} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700/50 hover:border-liquid-mint transition-colors">
{item.name}
</span>
))}
</div>
</div>
))
)}
</div>
</motion.div>
{/* 5. Library */}
<motion.div
transition={{ delay: 0.4 }}
className="md:col-span-7 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
>
<div className="relative z-10 flex flex-col h-full">
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter">
<BookOpen className="text-liquid-purple" size={24} /> {at("library")}
</h3>
<Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-opacity">
{at("viewAll")} <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" />
</Link>
</div>
<CurrentlyReading />
<div className="mt-6 flex-1">
<ReadBooks />
</div>
</div>
</motion.div>
{/* 6. My Gear */}
<motion.div
transition={{ delay: 0.5 }}
className="md:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8"
>
<div className="flex-1 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group">
<div className="relative z-10">
<h3 className="text-xl sm:text-2xl font-black mb-5 sm:mb-8 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter text-white">
<Cpu className="text-liquid-mint" size={24} /> {at("myGear")}
</h3>
<div className="grid grid-cols-2 gap-4 sm:gap-6">
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearMain")}</p>
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
</div>
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearPC")}</p>
<p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p>
</div>
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearServer")}</p>
<p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p>
</div>
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearOS")}</p>
<p className="text-sm font-bold text-stone-100">macOS / Linux</p>
</div>
</div>
</div>
<div className="absolute bottom-0 right-0 w-32 h-32 bg-liquid-mint/10 blur-3xl rounded-full -mr-16 -mb-16" />
</div>
</motion.div>
{/* 7. Hobbies */}
<motion.div
transition={{ delay: 0.5 }}
className="md:col-span-12"
>
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between">
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6 mb-6 sm:mb-8 md:mb-12">
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
) : (
hobbies.map((hobby) => {
const Icon = iconMap[hobby.icon] || Lightbulb;
return (
<div key={hobby.id} className="p-3 sm:p-4 md:p-6 bg-stone-50 dark:bg-stone-800 rounded-xl sm:rounded-2xl border border-stone-100 dark:border-stone-700 hover:border-liquid-mint transition-colors group">
<div className="flex items-center gap-2 sm:gap-3 mb-1.5 sm:mb-3">
<Icon size={16} className="text-liquid-mint group-hover:scale-110 transition-transform duration-200 ease-out shrink-0 sm:w-5 sm:h-5" />
<h4 className="font-bold text-xs sm:text-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4>
</div>
<p className="text-[11px] sm:text-xs text-stone-500 dark:text-stone-400 font-medium leading-relaxed hidden sm:block">
{hobby.description}
</p>
</div>
)
})
)}
</div>
<div className="space-y-1 sm:space-y-2 border-t border-stone-100 dark:border-stone-800 pt-4 sm:pt-6 md:pt-8">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3>
<p className="text-stone-500 font-light text-sm sm:text-base md:text-lg">{at("curiosity")}</p>
</div>
</div>
</motion.div>
</div>
</div>
</section>
);
};
export default About;
-248
View File
@@ -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<string, CustomActivity>;
}
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<StatusData | null>(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 <div className="animate-pulse space-y-4">
<div className="h-24 bg-stone-100 dark:bg-stone-800 rounded-2xl" />
</div>;
}
if (!hasActivity) {
return (
<div className="h-full flex flex-col justify-center space-y-6">
<div className="w-10 h-10 rounded-full bg-liquid-mint/10 flex items-center justify-center">
<QuoteIcon size={18} className="text-emerald-600 dark:text-liquid-mint" />
</div>
<div className="min-h-[80px] sm:min-h-[120px] relative">
<AnimatePresence mode="wait">
<motion.div
key={quoteIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6, filter: "blur(2px)", transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] } }}
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
className="space-y-4"
>
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-400 dark:text-stone-400 italic">
&ldquo;{allQuotes[quoteIndex].content}&rdquo;
</p>
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest">
— {allQuotes[quoteIndex].author}
</p>
</motion.div>
</AnimatePresence>
</div>
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 dark:text-stone-400 pt-4 border-t border-stone-100 dark:border-stone-800">
<span className="w-1.5 h-1.5 rounded-full bg-stone-200 dark:bg-stone-700 animate-pulse" />
{t("idleStatus")}
</div>
</div>
);
}
return (
<div className="space-y-4">
{data?.coding?.isActive && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/20 rounded-2xl p-5">
<div className="flex items-center gap-3 mb-2">
<Zap size={14} className="text-emerald-600 dark:text-emerald-400 animate-pulse" />
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-600 dark:text-emerald-400">{t("codingNow")}</span>
</div>
<p className="font-bold text-stone-900 dark:text-white text-lg truncate">{data.coding.project}</p>
<p className="text-xs text-stone-500 dark:text-stone-400 truncate">{data.coding.file}</p>
</motion.div>
)}
{data?.gaming?.isPlaying && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-indigo-500/10 dark:bg-indigo-500/20 border border-indigo-500/20 dark:border-indigo-400/30 rounded-2xl p-5">
<div className="flex items-center gap-3 mb-3">
<Gamepad2 size={14} className="text-indigo-600 dark:text-indigo-300" />
<span className="text-[10px] font-black uppercase tracking-widest text-indigo-600 dark:text-indigo-300">{t("gaming")}</span>
</div>
<div className="flex gap-4">
{data.gaming.image ? (
<div className="w-12 h-12 rounded-xl overflow-hidden shrink-0 shadow-lg relative bg-indigo-500/10">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={data.gaming.image} alt={data.gaming.name} className="w-full h-full object-cover" />
</div>
) : (
<div className="w-12 h-12 rounded-xl shrink-0 bg-indigo-500/20 dark:bg-indigo-400/20 flex items-center justify-center">
<Gamepad2 size={20} className="text-indigo-500 dark:text-indigo-300" />
</div>
)}
<div className="min-w-0 flex flex-col justify-center">
<p className="font-bold text-indigo-700 dark:text-white text-base truncate">{data.gaming.name}</p>
<p className="text-xs text-indigo-600/70 dark:text-indigo-200/80 truncate">
{getSafeGamingText(data.gaming.details, data.gaming.state, t("inGame"))}
</p>
</div>
</div>
</motion.div>
)}
{data?.music?.isPlaying && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#1DB954]/5 dark:bg-[#1DB954]/10 border border-[#1DB954]/20 rounded-2xl p-5 relative overflow-hidden group"
>
<div className="flex items-center justify-between mb-3 relative z-10">
<div className="flex items-center gap-2">
<Disc3 size={14} className="text-[#1DB954] animate-spin-slow" />
<span className="text-[10px] font-black uppercase tracking-widest text-[#1DB954]">{t("listening")}</span>
</div>
{/* Simple Animated Music Bars */}
<div className="flex items-end gap-[2px] h-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
animate={{ scaleY: [0.2, 1, 0.2] }}
transition={{
duration: 0.8,
repeat: Infinity,
delay: i * 0.2,
ease: "easeInOut"
}}
className="w-[2px] h-full origin-bottom bg-[#1DB954] rounded-full"
/>
))}
</div>
</div>
<a
href={data.music.url}
target="_blank"
rel="noopener noreferrer"
className="flex gap-4 relative z-10"
>
<div className="w-16 h-16 rounded-lg overflow-hidden shrink-0 shadow-md relative group-hover:shadow-xl transition-shadow duration-300">
<Image
src={data.music.albumArt}
alt="Album Art"
fill
className="object-cover transition-transform duration-500 ease-out group-hover:scale-105"
sizes="64px"
/>
</div>
<div className="min-w-0 flex flex-col justify-center">
<p className="font-bold text-[#1DB954] dark:text-[#1DB954] text-base truncate leading-tight mb-1 hover:underline">{data.music.track}</p>
<p className="text-sm text-stone-600 dark:text-stone-400 truncate font-medium">{data.music.artist}</p>
</div>
</a>
{/* Subtle Spotify branding gradient */}
<div className="absolute top-0 right-0 w-32 h-32 bg-[#1DB954]/5 blur-[60px] rounded-full -mr-16 -mt-16 pointer-events-none" />
</motion.div>
)}
</div>
);
}
-114
View File
@@ -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<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [conversationId, setConversationId] = useState<string>("default");
const containerRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-col h-full min-h-[300px]">
<div
ref={containerRef}
className="flex-1 overflow-y-auto pr-2 scrollbar-hide space-y-4 mb-4"
>
{messages.map((m) => (
<div key={m.id} className={`flex ${m.sender === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[90%] rounded-2xl px-4 py-2 text-sm shadow-sm ${m.sender === "user" ? "bg-liquid-purple text-white" : "bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-100 dark:border-stone-700"}`}>
{m.text}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-stone-100 dark:bg-stone-800 rounded-2xl px-4 py-2"><Loader2 size={14} className="animate-spin text-stone-400" /></div>
</div>
)}
</div>
<div className="relative">
<input
type="text"
value={inputValue}
onChange={(e) => 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"
/>
<button onClick={handleSend} aria-label="Send message" className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple active:scale-[0.97] transition-transform duration-150 ease-out">
<Send size={18} />
</button>
</div>
</div>
);
}
-495
View File
@@ -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<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [conversationId, setConversationId] = useState<string>("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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 */}
<AnimatePresence>
{!isOpen && (
<motion.div
role="button"
tabIndex={0}
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1, transition: { duration: 0.25, ease: [0.23, 1, 0.32, 1] } }}
exit={{ scale: 0.9, opacity: 0, transition: { duration: 0.15, ease: [0.23, 1, 0.32, 1] } }}
onClick={() => 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"
>
<MessageCircle size={24} />
<span className="absolute top-0 right-0 w-3 h-3 bg-green-500 rounded-full animate-pulse shadow-sm border-2 border-white" />
{/* Tooltip */}
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-1.5 bg-stone-900/90 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-150 ease-out group-hover:delay-300 whitespace-nowrap pointer-events-none z-[100] shadow-xl backdrop-blur-sm">
Chat with AI
</span>
</motion.div>
)}
</AnimatePresence>
{/* Chat Window */}
<AnimatePresence>
{isOpen && (
<motion.div
data-chat-widget
initial={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className="origin-bottom-left fixed bottom-20 left-4 right-4 md:bottom-24 md:left-6 md:right-auto z-30 md:w-[380px] h-[60vh] md:h-[550px] max-h-[600px] bg-white/80 backdrop-blur-xl saturate-100 rounded-2xl shadow-[0_12px_40px_rgba(41,37,36,0.16)] flex flex-col overflow-hidden border border-white/60 ring-1 ring-white/30"
>
{/* Header */}
<div className="bg-white/70 text-stone-900 p-4 flex items-center justify-between border-b border-white/50">
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-liquid-mint/50 via-liquid-lavender/40 to-liquid-rose/40 flex items-center justify-center ring-1 ring-white/50 shadow-sm">
<Sparkles size={18} className="text-stone-800" />
</div>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-green-500 rounded-full border-2 border-white shadow-sm" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-bold text-sm truncate text-stone-900 tracking-tight">
Assistant
</h3>
<p className="text-[11px] font-medium text-stone-500 truncate">
Powered by AI
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={clearChat}
className="p-2 hover:bg-stone-200/40 rounded-full transition-colors text-stone-500 hover:text-red-500"
title="Clear conversation"
>
<Trash2 size={16} />
</button>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-stone-200/40 rounded-full transition-colors text-stone-500 hover:text-stone-900"
aria-label="Close chat"
>
<X size={20} />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto scrollbar-hide p-4 space-y-4 bg-transparent">
{messages.map((message) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-2xl px-4 py-3 shadow-sm ${
message.sender === "user"
? "bg-stone-900 text-white"
: "bg-white/70 text-stone-900 border border-white/60"
}`}
>
<p className={`text-sm whitespace-pre-wrap break-words leading-relaxed ${
message.sender === "user" ? "text-white/90 font-normal" : "text-stone-900 font-medium"
}`}>
{message.text}
</p>
<p
className={`text-[10px] mt-1.5 ${
message.sender === "user"
? "text-stone-400"
: "text-stone-500"
}`}
>
{message.timestamp.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</motion.div>
))}
{/* Typing Indicator */}
{isLoading && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-start"
>
<div className="bg-[#f3f1e7] border border-[#e7e5e4] rounded-2xl px-4 py-3 shadow-sm">
<div className="flex gap-1.5">
<motion.div
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -6, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0,
}}
/>
<motion.div
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -6, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0.1,
}}
/>
<motion.div
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -6, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0.2,
}}
/>
</div>
</div>
</motion.div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 bg-stone-50 border-t border-[#e7e5e4]">
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => 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"
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isLoading}
className="p-3 bg-[#292524] text-[#fdfcf8] rounded-xl hover:bg-[#44403c] hover:shadow-lg active:scale-[0.97] transition-[background-color,box-shadow,transform] duration-150 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100 shadow-md flex items-center justify-center aspect-square"
aria-label="Send message"
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
</button>
</div>
{/* Quick Actions */}
<div className="flex gap-2 mt-3 overflow-x-auto pb-1 scrollbar-hide mask-fade-right">
{[
"Skills 🛠️",
"Projects 🚀",
"Contact 📧",
].map((suggestion, index) => (
<button
key={index}
onClick={() => {
setInputValue(suggestion.replace(/ .*/, '')); // Strip emoji for search if needed, or keep
inputRef.current?.focus();
}}
disabled={isLoading}
className="px-3 py-1.5 text-xs font-medium bg-[#f5f5f4] text-[#57534e] rounded-lg hover:bg-[#e7e5e4] hover:text-[#292524] border border-[#e7e5e4] transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97] whitespace-nowrap disabled:opacity-50 flex-shrink-0 shadow-sm"
>
{suggestion}
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
-39
View File
@@ -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 (
<NextIntlClientProvider locale={locale} messages={messages}>
<About />
</NextIntlClientProvider>
);
}
export function ProjectsClient({ locale }: { locale: string; translations: ProjectsTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
const messages = {
home: {
projects: baseMessages.home.projects
}
};
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<Projects />
</NextIntlClientProvider>
);
}
export function ContactClient({ locale }: { locale: string; translations: ContactTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
+96 -189
View File
@@ -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 (
<section
id="contact"
className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950 transition-colors duration-500"
>
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
{/* Header Card */}
<motion.div
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
<section id="contact" className="border-t-2 border-ink bg-signal px-4 pb-16 pt-14 text-ink sm:px-6 md:pb-24 md:pt-20 lg:px-10">
<div className="grid gap-10 lg:grid-cols-[1.1fr_1fr] lg:items-start">
<div className="min-w-0">
<p className="font-mono text-[13px]">{tInfo("locationValue")}</p>
<h2 className="mt-3 font-display text-[clamp(3rem,9vw,7.5rem)] font-extrabold leading-[0.86] tracking-[-0.05em] [font-variation-settings:'wdth'_80]">
{t("title")}
</h2>
{cmsHtml ? (
<RichTextClient html={cmsHtml} className="prose mt-6 max-w-[46ch] text-lg leading-relaxed text-ink [&_*]:text-inherit" />
) : (
<p className="mt-6 max-w-[46ch] text-lg leading-relaxed">{t("subtitle")}</p>
)}
<a
href="mailto:contact@dk0.dev"
className="mt-8 block w-fit font-display text-[clamp(2rem,6vw,4.4rem)] font-extrabold leading-none tracking-[-0.045em] [overflow-wrap:anywhere] [font-variation-settings:'wdth'_80] hover:underline hover:decoration-[3px] hover:underline-offset-[6px]"
>
<div className="max-w-3xl">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
</h2>
{cmsHtml ? (
<RichTextClient html={cmsHtml} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
) : (
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
{t("subtitle")}
</p>
)}
</div>
</motion.div>
{/* Info Side (Unified Connect Box) */}
<motion.div
transition={{ delay: 0.1 }}
className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6"
>
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between relative overflow-hidden group">
<div className="relative z-10">
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-12">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Connect</p>
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 rounded-full border border-emerald-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-700 dark:text-emerald-400">Online</span>
</div>
</div>
<div className="space-y-5 sm:space-y-6 md:space-y-8">
{/* Email */}
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
<div className="flex flex-col">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Email</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
</div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-colors duration-200">
<Mail size={16} />
</div>
</a>
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
{/* GitHub */}
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
<div className="flex flex-col">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Code</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
</div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-colors duration-200">
<Github size={16} />
</div>
</a>
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
{/* LinkedIn */}
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
<div className="flex flex-col">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Professional</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
</div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-colors duration-200">
<Linkedin size={16} />
</div>
</a>
</div>
</div>
<div className="mt-6 sm:mt-8 md:mt-12 pt-4 sm:pt-6 md:pt-8 border-t border-stone-100 dark:border-stone-800 relative z-10">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-2">Location</p>
<div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
<MapPin size={14} className="text-liquid-mint" />
<span className="font-bold">{tInfo("locationValue")}</span>
</div>
</div>
</div>
</motion.div>
{/* Form Side */}
<motion.div
transition={{ delay: 0.2 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter mb-6 sm:mb-8 md:mb-10">
{tForm("title")}
</h3>
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6 md:space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
<div className="space-y-2">
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.name")}
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
onBlur={handleBlur}
required
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium"
placeholder={tForm("placeholders.name")}
/>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.email")}
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
required
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium"
placeholder={tForm("placeholders.email")}
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.subject")}
</label>
<input
type="text"
id="subject"
name="subject"
value={formData.subject}
onChange={handleChange}
onBlur={handleBlur}
required
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium"
placeholder={tForm("placeholders.subject")}
/>
</div>
<div className="space-y-2">
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.message")}
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
onBlur={handleBlur}
required
rows={5}
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium resize-none"
placeholder={tForm("placeholders.message")}
/>
</div>
<motion.button
type="submit"
disabled={isSubmitting}
whileTap={{ scale: 0.97 }}
transition={{ duration: 0.15, ease: [0.23, 1, 0.32, 1] }}
className="w-full py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] sm:tracking-[0.3em] flex items-center justify-center gap-3 shadow-xl hover:shadow-2xl transition-shadow duration-200 ease-out disabled:opacity-50"
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<Send size={16} />
{tForm("send")}
</>
)}
</motion.button>
</form>
</motion.div>
contact<span className="text-paper">@</span>dk0.dev
</a>
<div className="mt-6 flex flex-wrap gap-2">
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className={pill}>
<Github size={16} aria-hidden="true" /> GitHub
</a>
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className={pill}>
<Linkedin size={16} aria-hidden="true" /> LinkedIn
</a>
</div>
</div>
<form
onSubmit={handleSubmit}
noValidate
className="grid gap-4 rounded-[18px] border-2 border-ink bg-paper p-5 shadow-[8px_8px_0_rgb(var(--plakat-ink))] md:p-7"
>
<h3 className="font-display text-2xl font-extrabold tracking-[-0.03em]">{tForm("title")}</h3>
{fields.map((f) => {
const showError = touched[f.name] && errors[f.name];
const common = {
id: f.name,
name: f.name,
value: formData[f.name],
onChange: handleChange,
onBlur: handleBlur,
required: true,
placeholder: tForm(`placeholders.${f.name}`),
"aria-invalid": showError ? true : undefined,
"aria-describedby": showError ? `${f.name}-error` : undefined,
className: `w-full rounded-xl border-2 bg-paper px-4 py-3 text-base text-ink placeholder:text-ink/40 transition-[border-color,box-shadow] duration-150 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-signal ${
showError ? "border-signal" : "border-ink"
}`,
};
return (
<div key={f.name} className="grid gap-1.5">
<label htmlFor={f.name} className="font-mono text-[13px]">
{tForm(`labels.${f.name}`)}
</label>
{f.multiline ? (
<textarea {...common} rows={5} className={`${common.className} resize-none`} />
) : (
<input {...common} type={f.type} autoComplete={f.autoComplete} />
)}
{showError && (
<p id={`${f.name}-error`} className="text-sm font-medium text-signal" role="alert">
{errors[f.name]}
</p>
)}
</div>
);
})}
<button
type="submit"
disabled={isSubmitting}
className="mt-1 inline-flex items-center justify-center gap-2.5 rounded-full bg-ink px-5 py-3.5 text-[15px] font-semibold text-paper transition-[opacity,transform] duration-150 ease-out active:scale-[0.97] disabled:opacity-50"
>
{isSubmitting ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-paper/30 border-t-paper" aria-hidden="true" />
{tForm("sending")}
</>
) : (
<>
<Send size={16} aria-hidden="true" />
{tForm("send")}
</>
)}
</button>
</form>
</div>
</section>
);
-178
View File
@@ -1,178 +0,0 @@
"use client";
import { motion } from "framer-motion";
import { BookOpen } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "./ui/Skeleton";
interface CurrentlyReading {
title: string;
authors: string[];
image: string | null;
progress: number;
startedAt: string | null;
}
const CurrentlyReading = () => {
const t = useTranslations("home.about.currentlyReading");
const [books, setBooks] = useState<CurrentlyReading[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Nur einmal beim Laden der Seite
const fetchCurrentlyReading = async () => {
try {
const res = await fetch("/api/n8n/hardcover/currently-reading", {
cache: "default",
});
if (!res.ok) {
throw new Error("Failed to fetch");
}
const data = await res.json();
// Handle both single book and array of books
if (data.currentlyReading) {
const booksArray = Array.isArray(data.currentlyReading)
? data.currentlyReading
: [data.currentlyReading];
setBooks(booksArray);
} else {
setBooks([]);
}
} catch (error) {
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") console.error("Error fetching currently reading:", error);
}
setBooks([]);
} finally {
setLoading(false);
}
};
fetchCurrentlyReading();
}, []); // Leeres Array = nur einmal beim Mount
// Zeige nichts wenn kein Buch gelesen wird
if (books.length === 0 && !loading) {
return null;
}
if (loading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-5 w-40" />
</div>
<div className="rounded-xl border-2 border-stone-200 dark:border-stone-700 p-6 space-y-3">
<div className="flex gap-4">
<Skeleton className="w-24 h-36 sm:w-28 sm:h-40 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-2 w-full rounded-full" />
</div>
</div>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<BookOpen size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
{t("title")} {books.length > 1 && `(${books.length})`}
</h3>
</div>
{/* Books List */}
{books.map((book, index) => (
<motion.div
key={`${book.title}-${index}`}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, delay: index * 0.06, ease: [0.23, 1, 0.32, 1] }}
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-lavender/30 dark:border-stone-700 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 dark:hover:border-stone-600 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-colors duration-300"
>
{/* Background Blob Animation */}
<motion.div
className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
animate={{
scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay: index * 0.5,
}}
/>
<div className="relative z-10 flex flex-col sm:flex-row gap-4 items-start">
{/* Book Cover */}
{book.image && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.1 + index * 0.06, ease: [0.23, 1, 0.32, 1] }}
className="flex-shrink-0"
>
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
<Image
src={book.image}
alt={book.title}
fill
className="object-cover"
sizes="(max-width: 640px) 96px, 112px"
/>
{/* Glossy Overlay */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
</div>
</motion.div>
)}
{/* Book Info */}
<div className="flex-1 min-w-0">
{/* Title */}
<h4 className="text-lg font-bold text-stone-900 dark:text-stone-100 mb-1 line-clamp-2">
{book.title}
</h4>
{/* Authors */}
<p className="text-sm text-stone-600 dark:text-stone-400 mb-4 line-clamp-1">
{book.authors.join(", ")}
</p>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-stone-600 dark:text-stone-400">
<span>{t("progress")}</span>
<span className="font-semibold">{isNaN(book.progress) ? 0 : book.progress}%</span>
</div>
<div className="relative h-2 bg-white/50 dark:bg-stone-700 rounded-full overflow-hidden border border-white/70 dark:border-stone-600">
{/* scaleX instead of width: stays on the compositor, no layout per frame */}
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: (isNaN(book.progress) ? 0 : book.progress) / 100 }}
transition={{ duration: 0.8, delay: 0.2 + index * 0.06, ease: [0.23, 1, 0.32, 1] }}
className="origin-left w-full absolute left-0 top-0 h-full bg-gradient-to-r from-liquid-lavender via-liquid-pink to-liquid-rose rounded-full shadow-sm"
/>
</div>
</div>
</div>
</div>
</motion.div>
))}
</div>
);
};
export default CurrentlyReading;
+41 -65
View File
@@ -3,80 +3,56 @@
import React from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { ArrowUp } from "lucide-react";
import { useConsent } from "./ConsentProvider";
const pill =
"inline-flex items-center rounded-full px-4 py-2 text-sm font-medium shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-paper)/0.5)] transition-[background-color,color,transform] duration-150 ease-out hover:bg-paper hover:text-ink active:scale-[0.97]";
const Footer = () => {
const locale = useLocale();
const t = useTranslations("footer");
const { resetConsent } = useConsent();
const year = new Date().getFullYear();
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<footer className="bg-stone-50 dark:bg-stone-950 pt-16 sm:pt-24 md:pt-32 pb-8 sm:pb-12 px-4 sm:px-6 overflow-hidden transition-colors duration-500">
<div className="max-w-7xl mx-auto">
<footer className="bg-ink px-4 pb-10 pt-12 text-paper sm:px-6 lg:px-10">
<div className="flex flex-wrap items-end justify-between gap-6">
<Link
href={`/${locale}`}
className="font-display text-[clamp(4rem,14vw,9rem)] font-extrabold leading-[0.8] tracking-[-0.05em]"
aria-label="dk0"
>
dk<span className="text-signal">0</span>
</Link>
<a href="#top" className={pill}>
{t("backToTop")} <span aria-hidden="true" className="ml-1.5">↑</span>
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end">
{/* Copyright & Info */}
<div className="md:col-span-4 space-y-6">
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
dk
</div>
<div className="space-y-2">
<p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("role")}</p>
<p className="text-stone-600 dark:text-stone-400 text-sm font-medium">&copy; {year} {t("madeIn")}.</p>
</div>
</div>
<nav className="mt-8 flex flex-wrap gap-2 border-t-2 border-paper/20 pt-6" aria-label={t("legalNotice")}>
<Link href={`/${locale}/legal-notice`} className={pill}>
{t("legalNotice")}
</Link>
<Link href={`/${locale}/privacy-policy`} className={pill}>
{t("privacyPolicy")}
</Link>
<button type="button" onClick={resetConsent} className={pill} title={t("privacySettingsTitle")}>
{t("privacySettings")}
</button>
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className={pill}>
GitHub
</a>
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className={pill}>
LinkedIn
</a>
</nav>
{/* Navigation Links */}
<div className="md:col-span-4 grid grid-cols-2 gap-8">
<div className="space-y-4">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{t("legalNotice")}</p>
<div className="flex flex-col gap-2">
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
</div>
</div>
<div className="space-y-4">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Social</p>
<div className="flex flex-col gap-2">
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">GitHub</a>
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">LinkedIn</a>
</div>
</div>
</div>
{/* Back to Top */}
<div className="md:col-span-4 flex justify-start md:justify-end">
<button
onClick={scrollToTop}
className="group flex flex-col items-center gap-4 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
>
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-600 dark:text-stone-400" style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>{t("backToTop")}</span>
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 group-active:scale-[0.97] transition-[background-color,color,transform] duration-200 ease-out shadow-sm">
<ArrowUp size={20} />
</div>
</button>
</div>
</div>
{/* Bottom Bar */}
<div className="mt-10 sm:mt-16 md:mt-20 pt-6 sm:pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex flex-col gap-1">
<p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">
{t("builtWith")} Next.js, Directus &amp; Passion.
</p>
<p className="text-[10px] text-stone-500 dark:text-stone-500 tracking-wide">
{t("aiDisclaimer")}
</p>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">{t("systemsOnline")}</span>
</div>
</div>
<div className="mt-8 grid gap-1.5 font-mono text-xs text-paper/70">
<p>
© {year} Dennis Konkol · {t("role")} · {t("madeIn")}
</p>
<p>{t("builtWith")}</p>
<p>{t("aiDisclaimer")}</p>
</div>
</footer>
);
+1 -1
View File
@@ -17,7 +17,7 @@ const Header = () => {
const navItems = [
{ name: t("home"), href: `/${locale}` },
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
{ name: t("about"), href: isHome ? "#projects" : `/${locale}#projects` },
{ name: t("projects"), href: isHome ? "#projects" : `/${locale}/projects` },
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
];
+27 -16
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import type { NavTranslations } from "@/types/translations";
import { ThemeToggle } from "./ThemeToggle";
import OpenWindowLink from "./fenster/OpenWindowLink";
const SiGithubIcon = ({ size = 20 }: { size?: number }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
@@ -65,7 +66,8 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
const navItems = [
{ name: translations.home, href: `/${locale}` },
{ name: translations.about, href: isHome ? "#about" : `/${locale}#about` },
// „Über mich“ öffnet auf der Startseite das Fenster; ohne JS bzw. auf Unterseiten führt es zum Tisch mit dem Foto
{ name: translations.about, href: isHome ? "#projects" : `/${locale}#projects`, window: isHome ? ({ kind: "about" } as const) : undefined },
{ name: translations.projects, href: isHome ? "#projects" : `/${locale}/projects` },
{ name: translations.contact, href: isHome ? "#contact" : `/${locale}#contact` },
];
@@ -114,11 +116,17 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
</Link>
<nav className="hidden items-center gap-2 md:flex" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
{navItems.slice(1).map((item) => (
<Link key={item.name} href={item.href} className={pill}>
{item.name}
</Link>
))}
{navItems.slice(1).map((item) =>
item.window ? (
<OpenWindowLink key={item.name} href={item.href} request={item.window} className={pill}>
{item.name}
</OpenWindowLink>
) : (
<Link key={item.name} href={item.href} className={pill}>
{item.name}
</Link>
),
)}
<div className="ml-2 flex items-center gap-1 border-l-2 border-ink/20 pl-3">
<Link href={deHref} className={langPill(locale === "de")} aria-current={locale === "de" ? "true" : undefined}>
DE
@@ -173,16 +181,19 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
</div>
<nav className="flex flex-col border-t-2 border-ink" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
onClick={() => setIsOpen(false)}
className="border-b-2 border-ink px-5 py-4 font-display text-3xl font-extrabold tracking-[-0.035em] transition-colors duration-150 active:bg-paper-2"
>
{item.name}
</Link>
))}
{navItems.map((item) => {
const cls =
"border-b-2 border-ink px-5 py-4 font-display text-3xl font-extrabold tracking-[-0.035em] transition-colors duration-150 active:bg-paper-2";
return item.window ? (
<OpenWindowLink key={item.name} href={item.href} request={item.window} onClick={() => setIsOpen(false)} className={cls}>
{item.name}
</OpenWindowLink>
) : (
<Link key={item.name} href={item.href} onClick={() => setIsOpen(false)} className={cls}>
{item.name}
</Link>
);
})}
</nav>
<div className="flex items-center gap-1 px-5 py-5">
-140
View File
@@ -1,140 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useLocale, useTranslations } from "next-intl";
import { Skeleton } from "./ui/Skeleton";
import ProjectThumbnail from "./ProjectThumbnail";
interface Project {
id: number;
slug: string;
title: string;
description: string;
content: string;
tags: string[];
featured: boolean;
category: string;
date: string;
github?: string;
live?: string;
imageUrl?: string;
}
const Projects = () => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const locale = useLocale();
const t = useTranslations("home.projects");
useEffect(() => {
const loadProjects = async () => {
try {
const response = await fetch("/api/projects?featured=true&published=true&limit=6");
if (response.ok) {
const data = await response.json();
setProjects(data.projects || []);
}
} catch (error) {
if (process.env.NODE_ENV === "development") console.error("Featured projects fetch failed:", error);
} finally {
setLoading(false);
}
};
loadProjects();
}, []);
return (
<section id="projects" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 sm:mb-12 md:mb-16 gap-4 sm:gap-6">
<div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-2 sm:mb-4 uppercase">
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
</h2>
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
{t("subtitle")}
</p>
</div>
<Link href={`/${locale}/projects`} className="group flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-opacity text-xs uppercase tracking-widest">
{t("viewAll")} <ArrowUpRight className="group-hover:-translate-y-0.5 group-hover:translate-x-0.5 transition-transform duration-200 ease-out" size={14} />
</Link>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-4">
<Skeleton className="aspect-[16/10] sm:aspect-[4/3] rounded-2xl sm:rounded-3xl" />
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
{projects.length === 0 ? (
<div className="col-span-2 py-12 text-center text-stone-400 dark:text-stone-600 text-sm">
{t("noProjects")}
</div>
) : (
projects.map((project) => (
<motion.div
key={project.id}
className="group relative"
>
<Link href={`/${locale}/projects/${project.slug}`} className="block">
{/* Image Card */}
<div className="relative aspect-[16/10] sm:aspect-[4/3] rounded-2xl sm:rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-4 sm:mb-6">
{project.imageUrl ? (
<Image
src={project.imageUrl}
alt={project.title}
fill
className="object-cover transition-transform duration-500 ease-out group-hover:scale-[1.03]"
/>
) : (
<ProjectThumbnail
title={project.title}
category={project.category}
tags={project.tags}
slug={project.slug}
size="card"
/>
)}
{/* Overlay on Hover */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300" />
</div>
{/* Text Content */}
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg sm:text-xl md:text-2xl font-bold text-stone-900 dark:text-stone-100 mb-1 sm:mb-2 group-hover:underline decoration-2 underline-offset-4">
{project.title}
</h3>
<p className="text-sm sm:text-base text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
{project.description}
</p>
</div>
<div className="hidden sm:flex gap-2">
{project.tags.slice(0, 2).map(tag => (
<span key={tag} className="px-3 py-1 rounded-full border border-stone-200 dark:border-stone-800 text-xs font-medium text-stone-600 dark:text-stone-400">
{tag}
</span>
))}
</div>
</div>
</Link>
</motion.div>
)))}
</div>
)}
</div>
</section>
);
};
export default Projects;
-267
View File
@@ -1,267 +0,0 @@
"use client";
import { motion } from "framer-motion";
import { BookCheck, Star, ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "./ui/Skeleton";
interface BookReview {
id: string;
hardcover_id?: string;
book_title: string;
book_author: string;
book_image?: string;
rating?: number | null;
review?: string | null;
finished_at?: string;
}
const StarRating = ({ rating }: { rating: number }) => {
return (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
size={14}
className={
star <= rating
? "text-amber-500 fill-amber-500"
: "text-stone-300 dark:text-stone-600"
}
/>
))}
</div>
);
};
const stripHtml = (html: string) => {
if (typeof window === 'undefined') return html; // Fallback for SSR
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || "";
};
const ReadBooks = () => {
const locale = useLocale();
const t = useTranslations("home.about.readBooks");
const [reviews, setReviews] = useState<BookReview[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set());
const INITIAL_SHOW = 3;
useEffect(() => {
const fetchReviews = async () => {
try {
const res = await fetch(
`/api/book-reviews?locale=${encodeURIComponent(locale)}`,
{ cache: "default" }
);
if (!res.ok) {
throw new Error("Failed to fetch");
}
const data = await res.json();
if (data.bookReviews) {
setReviews(data.bookReviews);
} else {
setReviews([]);
}
} catch (error) {
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") console.error("Error fetching book reviews:", error);
}
setReviews([]);
} finally {
setLoading(false);
}
};
fetchReviews();
}, [locale]);
if (reviews.length === 0 && !loading) {
return (
<div className="flex items-center gap-3 py-4 text-sm text-stone-400 dark:text-stone-500">
<BookCheck size={16} className="shrink-0" />
<span>{t("empty")}</span>
</div>
);
}
const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);
const hasMore = reviews.length > INITIAL_SHOW;
if (loading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-5 w-40" />
</div>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border-2 border-stone-200 dark:border-stone-700 p-5 space-y-3">
<div className="flex gap-4">
<Skeleton className="w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-full" />
</div>
</div>
</div>
))}
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<BookCheck size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
{t("title")} ({reviews.length})
</h3>
</div>
{/* Book Reviews */}
{visibleReviews.map((review, index) => (
<motion.div
key={review.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{
duration: 0.5,
delay: index * 0.06,
ease: [0.23, 1, 0.32, 1],
}}
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-teal/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-mint/30 dark:border-stone-700 rounded-xl p-5 backdrop-blur-sm hover:border-liquid-mint/50 dark:hover:border-stone-600 hover:from-liquid-mint/20 hover:via-liquid-sky/15 hover:to-liquid-teal/20 transition-colors duration-300"
>
{/* Background Blob */}
<motion.div
className="absolute -bottom-8 -left-8 w-28 h-28 bg-gradient-to-br from-liquid-mint/20 to-liquid-sky/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
animate={{
scale: [1, 1.15, 1],
opacity: [0.3, 0.45, 0.3],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay: index * 0.5,
}}
/>
<div className="relative z-10 flex flex-col sm:flex-row gap-4 items-start">
{/* Book Cover */}
{review.book_image && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.1 + index * 0.06, ease: [0.23, 1, 0.32, 1] }}
className="flex-shrink-0"
>
<div className="relative w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
<Image
src={review.book_image}
alt={review.book_title}
fill
className="object-cover"
sizes="(max-width: 640px) 80px, 96px"
/>
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
</div>
</motion.div>
)}
{/* Book Info */}
<div className="flex-1 min-w-0">
<h4 className="text-base font-bold text-stone-900 dark:text-stone-100 mb-0.5 line-clamp-2">
{review.book_title}
</h4>
<p className="text-sm text-stone-600 dark:text-stone-400 mb-2 line-clamp-1">
{review.book_author}
</p>
{/* Rating (Optional) */}
{review.rating && review.rating > 0 && (
<div className="flex items-center gap-2 mb-2">
<StarRating rating={review.rating} />
<span className="text-xs text-stone-500 dark:text-stone-400 font-medium">
{review.rating}/5
</span>
</div>
)}
{/* Review Text (Optional) */}
{review.review && (
<div className="relative">
<p className={`text-sm text-stone-700 dark:text-stone-300 leading-relaxed italic ${!expandedReviews.has(review.id) ? 'line-clamp-3' : ''}`}>
&ldquo;{stripHtml(review.review)}&rdquo;
</p>
{stripHtml(review.review).length > 100 && (
<button
onClick={(e) => {
e.stopPropagation();
setExpandedReviews(prev => {
const next = new Set(prev);
if (next.has(review.id)) next.delete(review.id);
else next.add(review.id);
return next;
});
}}
className="text-xs text-liquid-sky hover:text-liquid-mint dark:text-liquid-sky dark:hover:text-liquid-mint font-medium mt-1 transition-colors"
>
{expandedReviews.has(review.id) ? t("collapseReview") : t("readMore")}
</button>
)}
</div>
)}
{/* Finished Date */}
{review.finished_at && (
<p className="text-xs text-stone-400 dark:text-stone-500 mt-2">
{t("finishedAt")}{" "}
{new Date(review.finished_at).toLocaleDateString(
locale === "de" ? "de-DE" : "en-US",
{ year: "numeric", month: "short" }
)}
</p>
)}
</div>
</div>
</motion.div>
))}
{/* Show More / Show Less */}
{hasMore && (
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-center gap-1.5 py-2.5 text-sm font-medium text-stone-600 dark:text-stone-400 hover:text-stone-800 dark:hover:text-stone-200 rounded-lg border-2 border-dashed border-stone-200 dark:border-stone-700 hover:border-stone-300 dark:hover:border-stone-600 transition-colors duration-300"
>
{expanded ? (
<>
{t("showLess")} <ChevronUp size={16} />
</>
) : (
<>
{t("showMore", { count: reviews.length - INITIAL_SHOW })}{" "}
<ChevronDown size={16} />
</>
)}
</motion.button>
)}
</div>
);
};
export default ReadBooks;
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useConsent } from "../ConsentProvider";
import type { WindowLabels } from "./types";
interface Message {
id: string;
text: string;
from: "user" | "bot";
}
const STORE_ID = "chatSessionId";
const STORE_MESSAGES = "chatMessages";
/**
* KI-Chat im Fenster. Schickt erst etwas an n8n, wenn der Chatbot im
* Datenschutz-Banner (oder hier) erlaubt wurde.
*/
export default function ChatBody({ labels }: { labels: WindowLabels }) {
const { consent, ready, setConsent } = useConsent();
const allowed = !!consent?.chat;
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [conversationId, setConversationId] = useState("default");
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!allowed) return;
try {
let id = localStorage.getItem(STORE_ID);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(STORE_ID, id);
}
setConversationId(id);
const stored = localStorage.getItem(STORE_MESSAGES);
const parsed = stored ? (JSON.parse(stored) as { id: string; text: string; sender?: string; from?: string }[]) : [];
setMessages(
parsed.length
? parsed.map((m) => ({
id: m.id,
// Begrüßung immer in der aktuellen Sprache, auch wenn noch eine alte gespeichert ist
text: m.id === "welcome" ? labels.chat.welcome : m.text,
from: (m.from ?? m.sender) === "user" ? "user" : "bot",
}))
: [{ id: "welcome", text: labels.chat.welcome, from: "bot" }],
);
} catch {
setMessages([{ id: "welcome", text: labels.chat.welcome, from: "bot" }]);
}
inputRef.current?.focus({ preventScroll: true });
}, [allowed, labels.chat.welcome]);
useEffect(() => {
if (!allowed || messages.length === 0) return;
try {
localStorage.setItem(STORE_MESSAGES, JSON.stringify(messages));
} catch {}
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [messages, allowed]);
const send = async () => {
const text = input.trim();
if (!text || busy) return;
const mine: Message = { id: String(Date.now()), text, from: "user" };
const history = messages.slice(-5).map((m) => ({ role: m.from === "user" ? "user" : "assistant", content: m.text }));
setMessages((prev) => [...prev, mine]);
setInput("");
setBusy(true);
try {
const res = await fetch("/api/n8n/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, conversationId, history }),
});
const data = await res.json();
setMessages((prev) => [...prev, { id: `${Date.now()}-bot`, text: data.reply || labels.chat.error, from: "bot" }]);
} catch {
setMessages((prev) => [...prev, { id: `${Date.now()}-bot`, text: labels.chat.error, from: "bot" }]);
} finally {
setBusy(false);
}
};
if (!ready) return <div className="h-24 animate-pulse rounded-xl bg-paper-2" aria-hidden="true" />;
if (!allowed) {
return (
<>
<p className="text-[15px] leading-relaxed">{labels.chat.consent}</p>
<button
type="button"
onClick={() => setConsent({ ...(consent ?? { chat: false }), chat: true })}
className="w-fit rounded-full bg-ink px-4 py-2.5 text-sm font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]"
>
{labels.chat.allow}
</button>
</>
);
}
return (
<div className="flex min-h-[320px] flex-col gap-3">
<div ref={listRef} className="flex max-h-[46vh] flex-1 flex-col gap-2.5 overflow-y-auto overscroll-contain pr-1" aria-live="polite">
{messages.map((m) => (
<div
key={m.id}
className={`max-w-[88%] rounded-2xl border-2 border-ink px-3.5 py-2 text-[14.5px] leading-snug ${
m.from === "user" ? "self-end rounded-br-md bg-ink text-paper" : "self-start rounded-bl-md bg-paper"
}`}
>
{m.text}
</div>
))}
{busy && (
<div className="self-start rounded-2xl rounded-bl-md border-2 border-ink px-3.5 py-2 font-mono text-xs" aria-label={labels.chat.thinking}>
<span className="inline-block animate-pulse">…</span>
</div>
)}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
send();
}}
>
<label htmlFor="chat-input" className="sr-only">
{labels.chat.placeholder}
</label>
<input
ref={inputRef}
id="chat-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={labels.chat.placeholder}
autoComplete="off"
className="min-w-0 flex-1 rounded-full border-2 border-ink bg-paper px-4 py-2.5 text-base text-ink placeholder:text-ink/45 focus:outline-none focus-visible:ring-2 focus-visible:ring-signal"
/>
<button
type="submit"
disabled={!input.trim() || busy}
className="rounded-full bg-ink px-4 text-sm font-semibold text-paper transition-[opacity,transform] duration-150 ease-out active:scale-[0.97] disabled:opacity-40"
>
{labels.chat.send}
</button>
</form>
<p className="font-mono text-[11.5px] leading-snug opacity-70">{labels.chat.note}</p>
</div>
);
}
+7 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { loadCurrentBook, type CurrentBook } from "../tisch/currentBook";
import ChatBody from "./ChatBody";
import type { WindowLabels, WindowRequest } from "./types";
const EMAIL = "contact@dk0.dev";
@@ -28,13 +29,15 @@ export function windowTitle(req: WindowRequest, labels: WindowLabels): string {
return labels.about.title;
case "contact":
return labels.contact.title;
case "chat":
return labels.chat.title;
}
}
/** Farbe des Punkts in der Ablage-Leiste */
export function windowAccent(req: WindowRequest): string {
if (req.kind === "project") return req.project.color;
return { now: "rgb(var(--plakat-red))", server: "rgb(var(--plakat-ok))", read: "#7a5240", about: "#2f5bea", contact: "rgb(var(--plakat-red))" }[req.kind];
return { now: "rgb(var(--plakat-red))", server: "rgb(var(--plakat-ok))", read: "#7a5240", about: "#2f5bea", contact: "rgb(var(--plakat-red))", chat: "#1d1b3d" }[req.kind];
}
interface WindowContentProps {
@@ -128,6 +131,9 @@ export default function WindowContent({ req, labels, onClose }: WindowContentPro
case "contact":
return <ContactBody labels={labels} onClose={onClose} />;
case "chat":
return <ChatBody labels={labels} />;
}
}
+11
View File
@@ -44,6 +44,17 @@ export async function getWindowLabels(locale: string): Promise<WindowLabels> {
value: t(`about.facts.${k}.value`),
})),
},
chat: {
title: t("chat.title"),
welcome: t("chat.welcome"),
consent: t("chat.consent"),
allow: t("chat.allow"),
placeholder: t("chat.placeholder"),
send: t("chat.send"),
error: t("chat.error"),
thinking: t("chat.thinking"),
note: t("chat.note"),
},
contact: {
title: t("contact.title"),
body: t("contact.body"),
+13 -1
View File
@@ -7,7 +7,8 @@ export type WindowRequest =
| { kind: "server" }
| { kind: "read" }
| { kind: "about" }
| { kind: "contact" };
| { kind: "contact" }
| { kind: "chat" };
export function windowId(req: WindowRequest): string {
return req.kind === "project" ? `project:${req.project.id}` : req.kind;
@@ -45,6 +46,17 @@ export interface WindowLabels {
paragraphs: string[];
facts: { label: string; value: string }[];
};
chat: {
title: string;
welcome: string;
consent: string;
allow: string;
placeholder: string;
send: string;
error: string;
thinking: string;
note: string;
};
contact: {
title: string;
body: string;
+3 -1
View File
@@ -155,7 +155,9 @@ function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardI
const openWindow = useOpenWindow();
const elRef = useRef<HTMLDivElement | null>(null);
const openIt = () => openWindow?.(windowFor(obj), elRef.current);
const radius = { sticker: "rounded-full", photo: "rounded-md", book: "rounded-[6px_14px_14px_6px]" }[obj.kind as string] ?? "rounded-2xl";
const radius =
{ sticker: "rounded-full", photo: "rounded-md", book: "rounded-[6px_14px_14px_6px]", chat: "rounded-[22px_22px_22px_6px]" }[obj.kind as string] ??
"rounded-2xl";
return (
<motion.div
+9
View File
@@ -85,6 +85,15 @@ export default function ObjectFace({ obj, labels }: ObjectFaceProps) {
</div>
);
case "chat":
return (
<div className="flex h-full w-full flex-col justify-between rounded-[22px_22px_22px_6px] border-2 border-ink bg-ink p-4 text-paper">
<span className="font-mono text-xs uppercase tracking-wide text-signal">KI · n8n</span>
<span className="font-display text-[1.7rem] font-extrabold leading-[0.95] tracking-[-0.03em]">{labels.chatCard}</span>
<span className="font-mono text-xs opacity-70">{labels.chatCardHint} ↗</span>
</div>
);
case "sticker":
return (
<div className="grid h-full w-full place-items-center rounded-full border-2 border-ink bg-signal p-6 text-center font-display text-xl font-extrabold leading-[0.98] tracking-[-0.02em] text-ink">
+3
View File
@@ -45,6 +45,8 @@ export default async function TischSection({ locale }: TischSectionProps) {
bookLabel: t("bookLabel"),
bookFallback: t("bookFallback"),
photoCaption: t("photoCaption"),
chatCard: t("chatCard"),
chatCardHint: t("chatCardHint"),
sticker: t("sticker"),
};
@@ -73,6 +75,7 @@ export default async function TischSection({ locale }: TischSectionProps) {
{ kind: "server", id: "server" },
{ kind: "book", id: "book", href: `/${locale}/books` },
{ kind: "photo", id: "photo" },
{ kind: "chat", id: "chat" },
{ kind: "sticker", id: "sticker" },
];
// Im Stapel wäre der runde Sticker eine Ellipse und wiederholt nur den Claim
+10 -1
View File
@@ -37,12 +37,17 @@ export interface TischPhoto {
id: "photo";
}
export interface TischChat {
kind: "chat";
id: "chat";
}
export interface TischSticker {
kind: "sticker";
id: "sticker";
}
export type TischObject = TischProject | TischTicket | TischServer | TischBook | TischPhoto | TischSticker;
export type TischObject = TischProject | TischTicket | TischServer | TischBook | TischPhoto | TischChat | TischSticker;
export interface TischLabels {
hintBoard: string;
@@ -63,6 +68,8 @@ export interface TischLabels {
bookLabel: string;
bookFallback: string;
photoCaption: string;
chatCard: string;
chatCardHint: string;
sticker: string;
}
@@ -73,6 +80,7 @@ export const BOARD_SIZE: Record<TischObject["kind"], { w: number; h: number }> =
server: { w: 270, h: 290 },
book: { w: 176, h: 262 },
photo: { w: 210, h: 250 },
chat: { w: 250, h: 170 },
sticker: { w: 180, h: 180 },
};
@@ -89,6 +97,7 @@ export const BOARD_SPOTS: { x: number; y: number; r: number }[] = [
{ x: 0.76, y: 450, r: -7 },
{ x: 0.42, y: 800, r: 7 },
{ x: 0.12, y: 800, r: -4 },
{ x: 0.66, y: 790, r: 4 },
];
// Farbfelder für Projektkarten, abwechselnd hell und dunkel
+4
View File
@@ -15,6 +15,8 @@ export function windowFor(obj: TischObject): WindowRequest {
return { kind: "read" };
case "photo":
return { kind: "about" };
case "chat":
return { kind: "chat" };
}
}
@@ -31,6 +33,8 @@ export function labelFor(obj: TischObject, labels: TischLabels): string {
return labels.bookLabel;
case "photo":
return labels.photoCaption;
case "chat":
return labels.chatCard;
case "sticker":
return labels.sticker;
}
-60
View File
@@ -1,60 +0,0 @@
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
export const BentoGrid = ({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) => {
return (
<div
className={cn(
"grid md:auto-rows-[18rem] grid-cols-1 md:grid-cols-3 gap-4 max-w-7xl mx-auto ",
className
)}
>
{children}
</div>
);
};
export const BentoGridItem = ({
className,
title,
description,
header,
icon,
onClick,
}: {
className?: string;
title?: string | React.ReactNode;
description?: string | React.ReactNode;
header?: React.ReactNode;
icon?: React.ReactNode;
onClick?: () => void;
}) => {
return (
<motion.div
whileHover={{ y: -5 }}
transition={{ duration: 0.2 }}
className={cn(
"row-span-1 rounded-3xl group/bento hover:shadow-xl transition duration-200 shadow-input dark:shadow-none p-4 dark:bg-stone-900 bg-white border border-stone-200 dark:border-stone-800 justify-between flex flex-col space-y-4",
className
)}
onClick={onClick}
>
{header}
<div className="group-hover/bento:translate-x-2 transition duration-200">
{icon}
<div className="font-sans font-bold text-stone-800 dark:text-stone-100 mb-2 mt-2">
{title}
</div>
<div className="font-sans font-normal text-stone-600 dark:text-stone-400 text-xs">
{description}
</div>
</div>
</motion.div>
);
};
-32
View File
@@ -1,32 +0,0 @@
import { test, expect } from "@playwright/test";
test.describe("ActivityFeed reload rendering", () => {
test("feed stays visible and dark after reload", async ({ page }) => {
await page.goto("/en", { waitUntil: "domcontentloaded" });
const feed = page.locator('[class*="bg-black/95"]').filter({ hasText: "Live Activity" }).first();
await expect(feed).toBeVisible({ timeout: 15000 });
const initialBox = await feed.boundingBox();
expect(initialBox).not.toBeNull();
expect(initialBox!.width).toBeGreaterThan(200);
expect(initialBox!.height).toBeGreaterThan(30);
const initialOpacity = await feed.evaluate((el) => getComputedStyle(el).opacity);
expect(Number(initialOpacity)).toBeGreaterThan(0.5);
await page.reload({ waitUntil: "domcontentloaded" });
const feedAfter = page.locator('[class*="bg-black/95"]').filter({ hasText: "Live Activity" }).first();
await expect(feedAfter).toBeVisible({ timeout: 15000 });
const afterBox = await feedAfter.boundingBox();
expect(afterBox).not.toBeNull();
expect(afterBox!.width).toBeGreaterThan(200);
expect(afterBox!.height).toBeGreaterThan(30);
const afterOpacity = await feedAfter.evaluate((el) => getComputedStyle(el).opacity);
expect(Number(afterOpacity)).toBeGreaterThan(0.5);
});
});
-64
View File
@@ -2,8 +2,6 @@ import { getLocalizedMessage } from '@/lib/i18n-loader';
import type {
NavTranslations,
FooterTranslations,
AboutTranslations,
ProjectsTranslations,
ContactTranslations,
ConsentTranslations,
} from '@/types/translations';
@@ -54,68 +52,6 @@ export async function getFooterTranslations(locale: string): Promise<FooterTrans
};
}
export async function getAboutTranslations(locale: string): Promise<AboutTranslations> {
const keys = [
'home.about.title',
'home.about.p1',
'home.about.p2',
'home.about.p3',
'home.about.funFactTitle',
'home.about.funFactBody',
'home.about.techStackTitle',
'home.about.techStack.categories.frontendMobile',
'home.about.techStack.categories.backendDevops',
'home.about.techStack.categories.toolsAutomation',
'home.about.techStack.categories.securityAdmin',
'home.about.techStack.items.selfHostedServices',
'home.about.hobbiesTitle',
'home.about.hobbies.selfHosting',
'home.about.hobbies.gaming',
'home.about.hobbies.gameServers',
'home.about.hobbies.jogging',
];
const values = await Promise.all(keys.map(key => getLocalizedMessage(key, locale)));
return {
title: values[0],
p1: values[1],
p2: values[2],
p3: values[3],
funFactTitle: values[4],
funFactBody: values[5],
techStackTitle: values[6],
techStack: {
categories: {
frontendMobile: values[7],
backendDevops: values[8],
toolsAutomation: values[9],
securityAdmin: values[10],
},
items: {
selfHostedServices: values[11],
},
},
hobbiesTitle: values[12],
hobbies: {
selfHosting: values[13],
gaming: values[14],
gameServers: values[15],
jogging: values[16],
},
};
}
export async function getProjectsTranslations(locale: string): Promise<ProjectsTranslations> {
const [title, subtitle, viewAll] = await Promise.all([
getLocalizedMessage('home.projects.title', locale),
getLocalizedMessage('home.projects.subtitle', locale),
getLocalizedMessage('home.projects.viewAll', locale),
]);
return { title, subtitle, viewAll };
}
export async function getContactTranslations(locale: string): Promise<ContactTranslations> {
const keys = [
'home.contact.title',
+18 -75
View File
@@ -67,6 +67,8 @@
"bookLabel": "Lese gerade",
"bookFallback": "Zur Bibliothek",
"photoCaption": "Das bin ich.",
"chatCard": "Frag mich etwas.",
"chatCardHint": "KI-Chat öffnen",
"sticker": "Läuft auf eigenen Servern."
},
"windows": {
@@ -127,6 +129,17 @@
}
}
},
"chat": {
"title": "frag mich · KI",
"welcome": "Hi! Frag mich etwas über Dennis, seine Projekte oder sein Setup.",
"consent": "Der Chat läuft über einen KI-Dienst in meinem n8n. Deine Nachrichten werden dafür verarbeitet und kurz gespeichert. Erst wenn du zustimmst, wird etwas gesendet.",
"allow": "Chat erlauben",
"placeholder": "Deine Frage …",
"send": "Senden",
"error": "Gerade keine Verbindung. Versuch es gleich noch mal.",
"thinking": "Antwort wird geschrieben",
"note": "Antworten kommen von einer KI und können danebenliegen."
},
"contact": {
"title": "kontakt",
"body": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.",
@@ -135,63 +148,9 @@
"form": "Zum Formular"
}
},
"about": {
"title": "Hinter den Kulissen",
"p1": "Schon seit ich meinen ersten eigenen Server aufgesetzt habe, lässt mich das Thema Infrastruktur nicht mehr los. Als Student in Osnabrück verbringe ich meine Zeit am liebsten damit, moderne Web-Apps mit Next.js zu bauen oder mobile Lösungen mit Flutter zu entwickeln.",
"p2": "Aber für mich hört es nicht beim Code auf: Ich liebe es, meine eigenen Docker-Cluster zu managen, CI/CD-Pipelines zu optimieren und sicherzustellen, dass alles stabil und sicher läuft. DevOps ist für mich kein Job-Titel, sondern eine Lebenseinstellung.",
"p3": "Wenn die Server einmal ohne mich klarkommen, findet man mich beim Laufen durch Osnabrück, beim Gaming oder beim Experimentieren mit neuen Automationen in n8n.",
"funFactTitle": "Hardcore analog",
"funFactBody": "Trotz Cloud und Automatisierung: Meine wichtigsten Pläne entstehen immer noch mit Füller auf Papier. Das ist mein Anker im digitalen Chaos.",
"techStackTitle": "Mein Tech Stack",
"hobbiesTitle": "Wenn ich nicht code",
"techStack": {
"categories": {
"frontendMobile": "Frontend & Mobile",
"backendDevops": "Backend & DevOps",
"toolsAutomation": "Tools & Automation",
"securityAdmin": "Security & Admin"
},
"items": {
"selfHostedServices": "Self-hosted Services"
}
},
"hobbies": {
"selfHosting": "Self-Hosting & DevOps",
"gaming": "Gaming",
"gameServers": "Game-Server einrichten",
"jogging": "Joggen um den Kopf freizubekommen und aktiv bleiben"
},
"currentlyReading": {
"title": "Aktuell am Lesen",
"progress": "Fortschritt"
},
"readBooks": {
"title": "Gelesene Bücher",
"finishedAt": "Beendet am",
"showMore": "{count} weitere anzeigen",
"showLess": "Weniger anzeigen",
"readMore": "Weiterlesen",
"collapseReview": "Weniger anzeigen",
"empty": "In Hardcover fertig gelesene Bücher erscheinen hier automatisch."
},
"activity": {
"idleStatus": "System im Leerlauf / Geist aktiv",
"codingNow": "Gerade am Coden",
"gaming": "Am Zocken",
"listening": "Hört gerade",
"inGame": "Im Spiel"
}
},
"projects": {
"title": "Ausgewählte Arbeiten",
"subtitle": "Projekte, die meine Grenzen erweitert haben.",
"featured": "Featured",
"viewAll": "Archiv ansehen",
"noProjects": "Noch keine Projekte."
},
"contact": {
"title": "Kontakt",
"subtitle": "Du willst zusammenarbeiten oder hast Fragen zu meinen Projekten? Schreib mir gerne!",
"title": "Schreib mir.",
"subtitle": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.",
"getInTouch": "Melde dich",
"getInTouchBody": "Ich bin immer offen für neue Chancen, spannende Projekte oder einfach einen Tech-Talk.",
"info": {
@@ -251,13 +210,13 @@
}
},
"footer": {
"role": "Software Engineer",
"madeIn": "Made in Germany",
"role": "IT & Self-Hosting",
"madeIn": "selbst gehostet in Deutschland",
"legalNotice": "Impressum",
"privacyPolicy": "Datenschutz",
"privacySettings": "Datenschutz-Einstellungen",
"privacySettingsTitle": "Datenschutz-Banner wieder anzeigen",
"builtWith": "Built with",
"builtWith": "Gebaut mit Next.js und Directus, läuft auf meinen eigenen Servern.",
"aiDisclaimer": "Einige Inhalte dieser Seite können KI-generiert sein.",
"backToTop": "Nach oben",
"systemsOnline": "Systeme online"
@@ -277,21 +236,5 @@
"subtitle": "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben.",
"backHome": "Zurück",
"empty": "Noch keine Bücher."
},
"about": {
"status": "Status",
"aiAssistant": "KI-Assistent",
"library": "Bibliothek",
"viewAll": "Alle ansehen",
"myGear": "Mein Gear",
"gearMain": "Main",
"gearPC": "PC",
"gearServer": "Server",
"gearOS": "OS",
"curiosity": "Neugier über die Softwareentwicklung hinaus.",
"connect": "Verbinden",
"email": "E-Mail",
"code": "Code",
"professional": "Beruflich"
}
}
+18 -75
View File
@@ -67,6 +67,8 @@
"bookLabel": "Reading now",
"bookFallback": "To the library",
"photoCaption": "That's me.",
"chatCard": "Ask me something.",
"chatCardHint": "Open AI chat",
"sticker": "Runs on my own servers."
},
"windows": {
@@ -127,6 +129,17 @@
}
}
},
"chat": {
"title": "ask me · AI",
"welcome": "Hi! Ask me anything about Dennis, his projects or his setup.",
"consent": "The chat runs through an AI service in my n8n. Your messages are processed and briefly stored for that. Nothing is sent until you agree.",
"allow": "Allow chat",
"placeholder": "Your question …",
"send": "Send",
"error": "No connection right now. Try again in a moment.",
"thinking": "Writing a reply",
"note": "Answers come from an AI and can be wrong."
},
"contact": {
"title": "contact",
"body": "For projects, questions about my setup or just because. I usually reply within a day or two.",
@@ -135,63 +148,9 @@
"form": "To the form"
}
},
"about": {
"title": "Behind the Code",
"p1": "Ever since I set up my first home server, I've been hooked on infrastructure. Currently studying in Osnabrück, I split my time between developing modern web apps with Next.js and building mobile experiences with Flutter.",
"p2": "For me, it doesn't stop at the code. I genuinely enjoy managing my own Docker clusters, optimizing CI/CD pipelines, and making sure everything is stable and secure. DevOps isn't just a part of my job – it's how I think about building things.",
"p3": "When the servers are running smoothly, you'll find me jogging through the city, gaming, or tinkering with new automation workflows in n8n.",
"funFactTitle": "Hardcore Analog",
"funFactBody": "Despite my love for automation and the cloud, my most important ideas are still born on paper with a fountain pen. It's my way of staying grounded.",
"techStackTitle": "My Tech Stack",
"hobbiesTitle": "When I'm Not Coding",
"techStack": {
"categories": {
"frontendMobile": "Frontend & Mobile",
"backendDevops": "Backend & DevOps",
"toolsAutomation": "Tools & Automation",
"securityAdmin": "Security & Admin"
},
"items": {
"selfHostedServices": "Self-hosted services"
}
},
"hobbies": {
"selfHosting": "Self-Hosting & DevOps",
"gaming": "Gaming",
"gameServers": "Setting up game servers",
"jogging": "Jogging to clear my mind and stay active"
},
"currentlyReading": {
"title": "Currently Reading",
"progress": "Progress"
},
"readBooks": {
"title": "Read",
"finishedAt": "Finished",
"showMore": "{count} more",
"showLess": "Show less",
"readMore": "Read more",
"collapseReview": "Show less",
"empty": "Books finished in Hardcover will appear here automatically."
},
"activity": {
"idleStatus": "System Idle / Mind Active",
"codingNow": "Coding Now",
"gaming": "Gaming",
"listening": "Listening",
"inGame": "In Game"
}
},
"projects": {
"title": "Selected Work",
"subtitle": "Projects that pushed my boundaries.",
"featured": "Featured",
"viewAll": "View Archive",
"noProjects": "No projects yet."
},
"contact": {
"title": "Contact Me",
"subtitle": "Interested in working together or have questions about my projects? Feel free to reach out!",
"title": "Write me.",
"subtitle": "For projects, questions about my setup or just because. I usually reply within a day or two.",
"getInTouch": "Get In Touch",
"getInTouchBody": "I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation.",
"info": {
@@ -251,13 +210,13 @@
}
},
"footer": {
"role": "Software Engineer",
"madeIn": "Made in Germany",
"role": "IT & self-hosting",
"madeIn": "self-hosted in Germany",
"legalNotice": "Legal notice",
"privacyPolicy": "Privacy policy",
"privacySettings": "Privacy settings",
"privacySettingsTitle": "Show privacy settings banner again",
"builtWith": "Built with",
"builtWith": "Built with Next.js and Directus, running on my own servers.",
"aiDisclaimer": "Some content on this site may be AI-assisted.",
"backToTop": "Back to top",
"systemsOnline": "Systems Online"
@@ -277,21 +236,5 @@
"subtitle": "Books that shaped my mindset and expanded my horizons.",
"backHome": "Back Home",
"empty": "No books yet."
},
"about": {
"status": "Status",
"aiAssistant": "AI Assistant",
"library": "Library",
"viewAll": "View All",
"myGear": "My Gear",
"gearMain": "Main",
"gearPC": "PC",
"gearServer": "Server",
"gearOS": "OS",
"curiosity": "Curiosity beyond software engineering.",
"connect": "Connect",
"email": "Email",
"code": "Code",
"professional": "Professional"
}
}
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* Räumt Directus nach dem Plakat-Redesign auf.
*
* Erst ausführen, wenn das Redesign auf production (dk0.dev) live ist — die alte
* Seite liest diese Einträge noch.
*
* node scripts/directus-cleanup-redesign.mjs # Probelauf, ändert nichts
* node scripts/directus-cleanup-redesign.mjs --apply # sichert, dann ändern
*
* Was passiert (mit --apply):
* - hobbies: alle auf status "archived" (die neue Seite zeigt keine Hobby-Karten)
* - tech_stack_categories: alle auf "archived" (Tech-Stack steht jetzt im Laufband)
* - messages: hero.badge, hero.line1, hero.line2, about.quote.idle löschen
* (überschrieben alte Hero-Texte, werden nicht mehr gelesen)
* - content_pages: home-hero und home-about (beide Sprachen) auf "archived"
*
* Vorher wird alles Betroffene vollständig nach directus-backup-redesign-<zeit>.json gesichert.
* Archivieren lässt sich im Directus-Admin jederzeit rückgängig machen.
*/
import fs from "node:fs";
import path from "node:path";
const APPLY = process.argv.includes("--apply");
function loadEnv(file) {
if (!fs.existsSync(file)) return {};
return Object.fromEntries(
fs
.readFileSync(file, "utf8")
.split("\n")
.filter((l) => /^[A-Z_]+=/.test(l))
.map((l) => {
const i = l.indexOf("=");
return [l.slice(0, i), l.slice(i + 1).replace(/^["']|["']$/g, "")];
}),
);
}
const env = { ...loadEnv(path.join(process.cwd(), ".env")), ...process.env };
const BASE = env.DIRECTUS_URL || "https://cms.dk0.dev";
const TOKEN = env.DIRECTUS_STATIC_TOKEN;
if (!TOKEN) {
console.error("DIRECTUS_STATIC_TOKEN fehlt (.env oder Umgebung).");
process.exit(1);
}
const MESSAGE_KEYS = ["hero.badge", "hero.line1", "hero.line2", "about.quote.idle"];
const PAGE_SLUGS = ["home-hero", "home-hero-de-de", "home-about", "home-about-de-de"];
async function rest(method, p, body) {
const res = await fetch(BASE + p, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${method} ${p}: HTTP ${res.status} ${await res.text()}`);
return res.status === 204 ? null : res.json();
}
async function gql(query) {
const res = await fetch(BASE + "/graphql", {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
const json = await res.json();
if (json.errors) throw new Error(JSON.stringify(json.errors));
return json.data;
}
const hobbies = (await rest("GET", "/items/hobbies?fields=*,translations.*&limit=-1")).data;
const categories = (await rest("GET", "/items/tech_stack_categories?fields=*,translations.*&limit=-1")).data;
const messages = (await rest("GET", `/items/messages?fields=*,translations.*&limit=-1`)).data.filter((m) =>
MESSAGE_KEYS.includes(m.key),
);
const pages = (
await gql(`{ content_pages(limit: -1) { slug locale status title content meta_description keywords } }`)
).content_pages.filter((p) => PAGE_SLUGS.includes(p.slug));
const activeHobbies = hobbies.filter((h) => h.status !== "archived");
const activeCategories = categories.filter((c) => c.status !== "archived");
const activePages = pages.filter((p) => p.status !== "archived");
console.log(`${APPLY ? "AUSFÜHREN" : "PROBELAUF"} gegen ${BASE}\n`);
console.log(`hobbies archivieren: ${activeHobbies.length} (${activeHobbies.map((h) => h.key).join(", ") || "-"})`);
console.log(`tech_stack_categories archivieren: ${activeCategories.length} (${activeCategories.map((c) => c.key).join(", ") || "-"})`);
console.log(`messages löschen: ${messages.length} (${messages.map((m) => m.key).join(", ") || "-"})`);
console.log(`content_pages archivieren: ${activePages.length} (${activePages.map((p) => p.slug).join(", ") || "-"})`);
if (!APPLY) {
console.log("\nNichts geändert. Mit --apply ausführen, wenn das Redesign auf production live ist.");
process.exit(0);
}
const backupFile = path.join(process.cwd(), `directus-backup-redesign-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
fs.writeFileSync(backupFile, JSON.stringify({ hobbies, categories, messages, pages }, null, 2));
console.log(`\nSicherung: ${backupFile}`);
for (const h of activeHobbies) await rest("PATCH", `/items/hobbies/${h.id}`, { status: "archived" });
for (const c of activeCategories) await rest("PATCH", `/items/tech_stack_categories/${c.id}`, { status: "archived" });
for (const m of messages) await rest("DELETE", `/items/messages/${m.id}`);
for (const p of activePages) {
try {
await rest("PATCH", `/items/content_pages/${encodeURIComponent(p.slug)}`, { status: "archived" });
} catch {
// REST ist für content_pages teils gesperrt, GraphQL geht
await gql(`mutation { update_content_pages_item(id: "${p.slug}", data: { status: "archived" }) { slug } }`);
}
}
console.log("Fertig.");
-34
View File
@@ -20,40 +20,6 @@ export interface FooterTranslations {
builtWith: string;
}
export interface AboutTranslations {
title: string;
p1: string;
p2: string;
p3: string;
funFactTitle: string;
funFactBody: string;
techStackTitle: string;
techStack: {
categories: {
frontendMobile: string;
backendDevops: string;
toolsAutomation: string;
securityAdmin: string;
};
items: {
selfHostedServices: string;
};
};
hobbiesTitle: string;
hobbies: {
selfHosting: string;
gaming: string;
gameServers: string;
jogging: string;
};
}
export interface ProjectsTranslations {
title: string;
subtitle: string;
viewAll: string;
}
export interface ContactTranslations {
title: string;
subtitle: string;