feat: plakat redesign phase 4 (chat window, contact, footer, cleanup) (#77)
- 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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
“{allQuotes[quoteIndex].content}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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">© {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 & 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>
|
||||
);
|
||||
|
||||
@@ -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` },
|
||||
];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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' : ''}`}>
|
||||
“{stripHtml(review.review)}”
|
||||
</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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user