Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0e257ad19 |
@@ -64,6 +64,3 @@ bones/*.bones.json
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Sicherungen aus scripts/directus-cleanup-redesign.mjs
|
|
||||||
directus-backup-*.json
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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,17 +83,23 @@ describe("i18n: message files", () => {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const WRAPPED_COMPONENT_NAMESPACES: Record<string, string[]> = {
|
const WRAPPED_COMPONENT_NAMESPACES: Record<string, string[]> = {
|
||||||
|
About: ["home.about", "about"],
|
||||||
|
Projects: ["home.projects"],
|
||||||
Contact: ["home.contact", "home.contact.form", "home.contact.info"],
|
Contact: ["home.contact", "home.contact.form", "home.contact.info"],
|
||||||
Footer: ["footer"],
|
Footer: ["footer"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const CLIENT_WRAPPER_PROVIDES: Record<string, string[]> = {
|
const CLIENT_WRAPPER_PROVIDES: Record<string, string[]> = {
|
||||||
|
AboutClient: ["home.about", "about"],
|
||||||
|
ProjectsClient: ["home.projects"],
|
||||||
ContactClient: ["home.contact"],
|
ContactClient: ["home.contact"],
|
||||||
FooterClient: ["footer"],
|
FooterClient: ["footer"],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("i18n: ClientWrappers provide all requested namespaces", () => {
|
describe("i18n: ClientWrappers provide all requested namespaces", () => {
|
||||||
const componentToWrapper: Record<string, string> = {
|
const componentToWrapper: Record<string, string> = {
|
||||||
|
About: "AboutClient",
|
||||||
|
Projects: "ProjectsClient",
|
||||||
Contact: "ContactClient",
|
Contact: "ContactClient",
|
||||||
Footer: "FooterClient",
|
Footer: "FooterClient",
|
||||||
};
|
};
|
||||||
@@ -131,11 +137,17 @@ const STATIC_KEYS: Record<string, Record<string, string[]>> = {
|
|||||||
},
|
},
|
||||||
consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] },
|
consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] },
|
||||||
"home.hero": { Hero: ["kicker", "claim", "intro", "countdown", "ctaWork", "ctaContact"] },
|
"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", "chatCard", "chatCardHint", "sticker"] },
|
"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", "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.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.contact": { Contact: ["title", "subtitle"] },
|
"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.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"] },
|
"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.list": { ProjectsPageClient: ["title", "intro", "searchPlaceholder", "all", "noResults", "clearFilters"] },
|
||||||
"projects.detail": { ProjectDetailClient: ["liveDemo", "viewSource"] },
|
"projects.detail": { ProjectDetailClient: ["liveDemo", "viewSource"] },
|
||||||
footer: { Footer: ["role", "madeIn", "legalNotice", "privacyPolicy", "privacySettings", "privacySettingsTitle", "builtWith", "aiDisclaimer", "backToTop", "systemsOnline"] },
|
footer: { Footer: ["role", "madeIn", "legalNotice", "privacyPolicy", "privacySettings", "privacySettingsTitle", "builtWith", "aiDisclaimer", "backToTop", "systemsOnline"] },
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { getWindowLabels } from "../components/fenster/getWindowLabels";
|
|||||||
import ScrollFadeIn from "../components/ScrollFadeIn";
|
import ScrollFadeIn from "../components/ScrollFadeIn";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import {
|
import {
|
||||||
|
getAboutTranslations,
|
||||||
getContactTranslations,
|
getContactTranslations,
|
||||||
getFooterTranslations,
|
getFooterTranslations,
|
||||||
} from "@/lib/translations-loader";
|
} from "@/lib/translations-loader";
|
||||||
import {
|
import {
|
||||||
|
AboutClient,
|
||||||
ContactClient,
|
ContactClient,
|
||||||
FooterClient,
|
FooterClient,
|
||||||
} from "../components/ClientWrappers";
|
} from "../components/ClientWrappers";
|
||||||
@@ -20,7 +22,8 @@ interface HomePageServerProps {
|
|||||||
|
|
||||||
export default async function HomePageServer({ locale }: HomePageServerProps) {
|
export default async function HomePageServer({ locale }: HomePageServerProps) {
|
||||||
// Parallel laden aller Translations (hero translations handled by Hero server component)
|
// Parallel laden aller Translations (hero translations handled by Hero server component)
|
||||||
const [contactT, footerT, windowLabels] = await Promise.all([
|
const [aboutT, contactT, footerT, windowLabels] = await Promise.all([
|
||||||
|
getAboutTranslations(locale),
|
||||||
getContactTranslations(locale),
|
getContactTranslations(locale),
|
||||||
getFooterTranslations(locale),
|
getFooterTranslations(locale),
|
||||||
getWindowLabels(locale),
|
getWindowLabels(locale),
|
||||||
@@ -85,12 +88,16 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Header locale={locale} />
|
||||||
<WindowProvider labels={windowLabels}>
|
<WindowProvider labels={windowLabels}>
|
||||||
<Header locale={locale} />
|
|
||||||
<main className="relative">
|
<main className="relative">
|
||||||
<Hero locale={locale} />
|
<Hero locale={locale} />
|
||||||
<TischSection locale={locale} />
|
<TischSection locale={locale} />
|
||||||
|
|
||||||
|
<ScrollFadeIn>
|
||||||
|
<AboutClient locale={locale} translations={aboutT} />
|
||||||
|
</ScrollFadeIn>
|
||||||
|
|
||||||
<ScrollFadeIn>
|
<ScrollFadeIn>
|
||||||
<ContactClient locale={locale} translations={contactT} />
|
<ContactClient locale={locale} translations={contactT} />
|
||||||
</ScrollFadeIn>
|
</ScrollFadeIn>
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
"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,9 +10,13 @@ import dynamic from 'next/dynamic';
|
|||||||
|
|
||||||
// Lazy-load below-fold components so their JS doesn't block initial paint / LCP.
|
// 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.
|
// 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 Contact = dynamic(() => import('./Contact'));
|
||||||
const Footer = dynamic(() => import('./Footer'));
|
const Footer = dynamic(() => import('./Footer'));
|
||||||
import type {
|
import type {
|
||||||
|
AboutTranslations,
|
||||||
|
ProjectsTranslations,
|
||||||
ContactTranslations,
|
ContactTranslations,
|
||||||
FooterTranslations,
|
FooterTranslations,
|
||||||
} from '@/types/translations';
|
} from '@/types/translations';
|
||||||
@@ -25,6 +29,41 @@ function getNormalizedLocale(locale: string): 'en' | 'de' {
|
|||||||
return locale.startsWith('de') ? 'de' : 'en';
|
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 }) {
|
export function ContactClient({ locale }: { locale: string; translations: ContactTranslations }) {
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
const baseMessages = messageMap[normalLocale];
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|||||||
+189
-96
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Send, Github, Linkedin } from "lucide-react";
|
import { motion } from "framer-motion";
|
||||||
|
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
@@ -79,7 +80,6 @@ const Contact = () => {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setTouched({ name: true, email: true, subject: true, message: true });
|
|
||||||
|
|
||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
return;
|
return;
|
||||||
@@ -152,103 +152,196 @@ const Contact = () => {
|
|||||||
validateForm();
|
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 (
|
return (
|
||||||
<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">
|
<section
|
||||||
<div className="grid gap-10 lg:grid-cols-[1.1fr_1fr] lg:items-start">
|
id="contact"
|
||||||
<div className="min-w-0">
|
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"
|
||||||
<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]">
|
<div className="max-w-7xl mx-auto">
|
||||||
{t("title")}
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
||||||
</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]"
|
|
||||||
>
|
|
||||||
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
|
{/* Header Card */}
|
||||||
onSubmit={handleSubmit}
|
<motion.div
|
||||||
noValidate
|
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"
|
||||||
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 ? (
|
<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">
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-paper/30 border-t-paper" aria-hidden="true" />
|
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||||
{tForm("sending")}
|
</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" />
|
||||||
<>
|
) : (
|
||||||
<Send size={16} aria-hidden="true" />
|
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
{tForm("send")}
|
{t("subtitle")}
|
||||||
</>
|
</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
</form>
|
</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"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;
|
||||||
+65
-41
@@ -3,56 +3,80 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useConsent } from "./ConsentProvider";
|
import { ArrowUp } from "lucide-react";
|
||||||
|
|
||||||
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 Footer = () => {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("footer");
|
const t = useTranslations("footer");
|
||||||
const { resetConsent } = useConsent();
|
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-ink px-4 pb-10 pt-12 text-paper sm:px-6 lg:px-10">
|
<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="flex flex-wrap items-end justify-between gap-6">
|
<div className="max-w-7xl mx-auto">
|
||||||
<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>
|
|
||||||
|
|
||||||
<nav className="mt-8 flex flex-wrap gap-2 border-t-2 border-paper/20 pt-6" aria-label={t("legalNotice")}>
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end">
|
||||||
<Link href={`/${locale}/legal-notice`} className={pill}>
|
{/* Copyright & Info */}
|
||||||
{t("legalNotice")}
|
<div className="md:col-span-4 space-y-6">
|
||||||
</Link>
|
<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">
|
||||||
<Link href={`/${locale}/privacy-policy`} className={pill}>
|
dk
|
||||||
{t("privacyPolicy")}
|
</div>
|
||||||
</Link>
|
<div className="space-y-2">
|
||||||
<button type="button" onClick={resetConsent} className={pill} title={t("privacySettingsTitle")}>
|
<p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("role")}</p>
|
||||||
{t("privacySettings")}
|
<p className="text-stone-600 dark:text-stone-400 text-sm font-medium">© {year} {t("madeIn")}.</p>
|
||||||
</button>
|
</div>
|
||||||
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className={pill}>
|
</div>
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className={pill}>
|
|
||||||
LinkedIn
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="mt-8 grid gap-1.5 font-mono text-xs text-paper/70">
|
{/* Navigation Links */}
|
||||||
<p>
|
<div className="md:col-span-4 grid grid-cols-2 gap-8">
|
||||||
© {year} Dennis Konkol · {t("role")} · {t("madeIn")}
|
<div className="space-y-4">
|
||||||
</p>
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{t("legalNotice")}</p>
|
||||||
<p>{t("builtWith")}</p>
|
<div className="flex flex-col gap-2">
|
||||||
<p>{t("aiDisclaimer")}</p>
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const Header = () => {
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: t("home"), href: `/${locale}` },
|
{ name: t("home"), href: `/${locale}` },
|
||||||
{ name: t("about"), href: isHome ? "#projects" : `/${locale}#projects` },
|
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
|
||||||
{ name: t("projects"), href: isHome ? "#projects" : `/${locale}/projects` },
|
{ name: t("projects"), href: isHome ? "#projects" : `/${locale}/projects` },
|
||||||
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
|
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Link from "next/link";
|
|||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import type { NavTranslations } from "@/types/translations";
|
import type { NavTranslations } from "@/types/translations";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
import OpenWindowLink from "./fenster/OpenWindowLink";
|
|
||||||
|
|
||||||
const SiGithubIcon = ({ size = 20 }: { size?: number }) => (
|
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>
|
<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>
|
||||||
@@ -66,8 +65,7 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: translations.home, href: `/${locale}` },
|
{ name: translations.home, href: `/${locale}` },
|
||||||
// „Ü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 ? "#about" : `/${locale}#about` },
|
||||||
{ 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.projects, href: isHome ? "#projects" : `/${locale}/projects` },
|
||||||
{ name: translations.contact, href: isHome ? "#contact" : `/${locale}#contact` },
|
{ name: translations.contact, href: isHome ? "#contact" : `/${locale}#contact` },
|
||||||
];
|
];
|
||||||
@@ -116,17 +114,11 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden items-center gap-2 md:flex" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
|
<nav className="hidden items-center gap-2 md:flex" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
|
||||||
{navItems.slice(1).map((item) =>
|
{navItems.slice(1).map((item) => (
|
||||||
item.window ? (
|
<Link key={item.name} href={item.href} className={pill}>
|
||||||
<OpenWindowLink key={item.name} href={item.href} request={item.window} className={pill}>
|
{item.name}
|
||||||
{item.name}
|
</Link>
|
||||||
</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">
|
<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}>
|
<Link href={deHref} className={langPill(locale === "de")} aria-current={locale === "de" ? "true" : undefined}>
|
||||||
DE
|
DE
|
||||||
@@ -181,19 +173,16 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-col border-t-2 border-ink" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
|
<nav className="flex flex-col border-t-2 border-ink" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => (
|
||||||
const cls =
|
<Link
|
||||||
"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";
|
key={item.name}
|
||||||
return item.window ? (
|
href={item.href}
|
||||||
<OpenWindowLink key={item.name} href={item.href} request={item.window} onClick={() => setIsOpen(false)} className={cls}>
|
onClick={() => setIsOpen(false)}
|
||||||
{item.name}
|
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"
|
||||||
</OpenWindowLink>
|
>
|
||||||
) : (
|
{item.name}
|
||||||
<Link key={item.name} href={item.href} onClick={() => setIsOpen(false)} className={cls}>
|
</Link>
|
||||||
{item.name}
|
))}
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 px-5 py-5">
|
<div className="flex items-center gap-1 px-5 py-5">
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"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;
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"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;
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
"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,7 +4,6 @@ import { useEffect, useState } from "react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { loadCurrentBook, type CurrentBook } from "../tisch/currentBook";
|
import { loadCurrentBook, type CurrentBook } from "../tisch/currentBook";
|
||||||
import ChatBody from "./ChatBody";
|
|
||||||
import type { WindowLabels, WindowRequest } from "./types";
|
import type { WindowLabels, WindowRequest } from "./types";
|
||||||
|
|
||||||
const EMAIL = "contact@dk0.dev";
|
const EMAIL = "contact@dk0.dev";
|
||||||
@@ -29,15 +28,13 @@ export function windowTitle(req: WindowRequest, labels: WindowLabels): string {
|
|||||||
return labels.about.title;
|
return labels.about.title;
|
||||||
case "contact":
|
case "contact":
|
||||||
return labels.contact.title;
|
return labels.contact.title;
|
||||||
case "chat":
|
|
||||||
return labels.chat.title;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Farbe des Punkts in der Ablage-Leiste */
|
/** Farbe des Punkts in der Ablage-Leiste */
|
||||||
export function windowAccent(req: WindowRequest): string {
|
export function windowAccent(req: WindowRequest): string {
|
||||||
if (req.kind === "project") return req.project.color;
|
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))", chat: "#1d1b3d" }[req.kind];
|
return { now: "rgb(var(--plakat-red))", server: "rgb(var(--plakat-ok))", read: "#7a5240", about: "#2f5bea", contact: "rgb(var(--plakat-red))" }[req.kind];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WindowContentProps {
|
interface WindowContentProps {
|
||||||
@@ -131,9 +128,6 @@ export default function WindowContent({ req, labels, onClose }: WindowContentPro
|
|||||||
|
|
||||||
case "contact":
|
case "contact":
|
||||||
return <ContactBody labels={labels} onClose={onClose} />;
|
return <ContactBody labels={labels} onClose={onClose} />;
|
||||||
|
|
||||||
case "chat":
|
|
||||||
return <ChatBody labels={labels} />;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,17 +44,6 @@ export async function getWindowLabels(locale: string): Promise<WindowLabels> {
|
|||||||
value: t(`about.facts.${k}.value`),
|
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: {
|
contact: {
|
||||||
title: t("contact.title"),
|
title: t("contact.title"),
|
||||||
body: t("contact.body"),
|
body: t("contact.body"),
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ export type WindowRequest =
|
|||||||
| { kind: "server" }
|
| { kind: "server" }
|
||||||
| { kind: "read" }
|
| { kind: "read" }
|
||||||
| { kind: "about" }
|
| { kind: "about" }
|
||||||
| { kind: "contact" }
|
| { kind: "contact" };
|
||||||
| { kind: "chat" };
|
|
||||||
|
|
||||||
export function windowId(req: WindowRequest): string {
|
export function windowId(req: WindowRequest): string {
|
||||||
return req.kind === "project" ? `project:${req.project.id}` : req.kind;
|
return req.kind === "project" ? `project:${req.project.id}` : req.kind;
|
||||||
@@ -46,17 +45,6 @@ export interface WindowLabels {
|
|||||||
paragraphs: string[];
|
paragraphs: string[];
|
||||||
facts: { label: string; value: 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: {
|
contact: {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
|||||||
@@ -155,9 +155,7 @@ function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardI
|
|||||||
const openWindow = useOpenWindow();
|
const openWindow = useOpenWindow();
|
||||||
const elRef = useRef<HTMLDivElement | null>(null);
|
const elRef = useRef<HTMLDivElement | null>(null);
|
||||||
const openIt = () => openWindow?.(windowFor(obj), elRef.current);
|
const openIt = () => openWindow?.(windowFor(obj), elRef.current);
|
||||||
const radius =
|
const radius = { sticker: "rounded-full", photo: "rounded-md", book: "rounded-[6px_14px_14px_6px]" }[obj.kind as string] ?? "rounded-2xl";
|
||||||
{ 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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -85,15 +85,6 @@ export default function ObjectFace({ obj, labels }: ObjectFaceProps) {
|
|||||||
</div>
|
</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":
|
case "sticker":
|
||||||
return (
|
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">
|
<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,8 +45,6 @@ export default async function TischSection({ locale }: TischSectionProps) {
|
|||||||
bookLabel: t("bookLabel"),
|
bookLabel: t("bookLabel"),
|
||||||
bookFallback: t("bookFallback"),
|
bookFallback: t("bookFallback"),
|
||||||
photoCaption: t("photoCaption"),
|
photoCaption: t("photoCaption"),
|
||||||
chatCard: t("chatCard"),
|
|
||||||
chatCardHint: t("chatCardHint"),
|
|
||||||
sticker: t("sticker"),
|
sticker: t("sticker"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,7 +73,6 @@ export default async function TischSection({ locale }: TischSectionProps) {
|
|||||||
{ kind: "server", id: "server" },
|
{ kind: "server", id: "server" },
|
||||||
{ kind: "book", id: "book", href: `/${locale}/books` },
|
{ kind: "book", id: "book", href: `/${locale}/books` },
|
||||||
{ kind: "photo", id: "photo" },
|
{ kind: "photo", id: "photo" },
|
||||||
{ kind: "chat", id: "chat" },
|
|
||||||
{ kind: "sticker", id: "sticker" },
|
{ kind: "sticker", id: "sticker" },
|
||||||
];
|
];
|
||||||
// Im Stapel wäre der runde Sticker eine Ellipse und wiederholt nur den Claim
|
// Im Stapel wäre der runde Sticker eine Ellipse und wiederholt nur den Claim
|
||||||
|
|||||||
@@ -37,17 +37,12 @@ export interface TischPhoto {
|
|||||||
id: "photo";
|
id: "photo";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TischChat {
|
|
||||||
kind: "chat";
|
|
||||||
id: "chat";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TischSticker {
|
export interface TischSticker {
|
||||||
kind: "sticker";
|
kind: "sticker";
|
||||||
id: "sticker";
|
id: "sticker";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TischObject = TischProject | TischTicket | TischServer | TischBook | TischPhoto | TischChat | TischSticker;
|
export type TischObject = TischProject | TischTicket | TischServer | TischBook | TischPhoto | TischSticker;
|
||||||
|
|
||||||
export interface TischLabels {
|
export interface TischLabels {
|
||||||
hintBoard: string;
|
hintBoard: string;
|
||||||
@@ -68,8 +63,6 @@ export interface TischLabels {
|
|||||||
bookLabel: string;
|
bookLabel: string;
|
||||||
bookFallback: string;
|
bookFallback: string;
|
||||||
photoCaption: string;
|
photoCaption: string;
|
||||||
chatCard: string;
|
|
||||||
chatCardHint: string;
|
|
||||||
sticker: string;
|
sticker: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +73,6 @@ export const BOARD_SIZE: Record<TischObject["kind"], { w: number; h: number }> =
|
|||||||
server: { w: 270, h: 290 },
|
server: { w: 270, h: 290 },
|
||||||
book: { w: 176, h: 262 },
|
book: { w: 176, h: 262 },
|
||||||
photo: { w: 210, h: 250 },
|
photo: { w: 210, h: 250 },
|
||||||
chat: { w: 250, h: 170 },
|
|
||||||
sticker: { w: 180, h: 180 },
|
sticker: { w: 180, h: 180 },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,7 +89,6 @@ export const BOARD_SPOTS: { x: number; y: number; r: number }[] = [
|
|||||||
{ x: 0.76, y: 450, r: -7 },
|
{ x: 0.76, y: 450, r: -7 },
|
||||||
{ x: 0.42, y: 800, r: 7 },
|
{ x: 0.42, y: 800, r: 7 },
|
||||||
{ x: 0.12, y: 800, r: -4 },
|
{ x: 0.12, y: 800, r: -4 },
|
||||||
{ x: 0.66, y: 790, r: 4 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Farbfelder für Projektkarten, abwechselnd hell und dunkel
|
// Farbfelder für Projektkarten, abwechselnd hell und dunkel
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ export function windowFor(obj: TischObject): WindowRequest {
|
|||||||
return { kind: "read" };
|
return { kind: "read" };
|
||||||
case "photo":
|
case "photo":
|
||||||
return { kind: "about" };
|
return { kind: "about" };
|
||||||
case "chat":
|
|
||||||
return { kind: "chat" };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,8 +31,6 @@ export function labelFor(obj: TischObject, labels: TischLabels): string {
|
|||||||
return labels.bookLabel;
|
return labels.bookLabel;
|
||||||
case "photo":
|
case "photo":
|
||||||
return labels.photoCaption;
|
return labels.photoCaption;
|
||||||
case "chat":
|
|
||||||
return labels.chatCard;
|
|
||||||
case "sticker":
|
case "sticker":
|
||||||
return labels.sticker;
|
return labels.sticker;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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,6 +2,8 @@ import { getLocalizedMessage } from '@/lib/i18n-loader';
|
|||||||
import type {
|
import type {
|
||||||
NavTranslations,
|
NavTranslations,
|
||||||
FooterTranslations,
|
FooterTranslations,
|
||||||
|
AboutTranslations,
|
||||||
|
ProjectsTranslations,
|
||||||
ContactTranslations,
|
ContactTranslations,
|
||||||
ConsentTranslations,
|
ConsentTranslations,
|
||||||
} from '@/types/translations';
|
} from '@/types/translations';
|
||||||
@@ -52,6 +54,68 @@ 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> {
|
export async function getContactTranslations(locale: string): Promise<ContactTranslations> {
|
||||||
const keys = [
|
const keys = [
|
||||||
'home.contact.title',
|
'home.contact.title',
|
||||||
|
|||||||
+75
-18
@@ -67,8 +67,6 @@
|
|||||||
"bookLabel": "Lese gerade",
|
"bookLabel": "Lese gerade",
|
||||||
"bookFallback": "Zur Bibliothek",
|
"bookFallback": "Zur Bibliothek",
|
||||||
"photoCaption": "Das bin ich.",
|
"photoCaption": "Das bin ich.",
|
||||||
"chatCard": "Frag mich etwas.",
|
|
||||||
"chatCardHint": "KI-Chat öffnen",
|
|
||||||
"sticker": "Läuft auf eigenen Servern."
|
"sticker": "Läuft auf eigenen Servern."
|
||||||
},
|
},
|
||||||
"windows": {
|
"windows": {
|
||||||
@@ -129,17 +127,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
"contact": {
|
||||||
"title": "kontakt",
|
"title": "kontakt",
|
||||||
"body": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.",
|
"body": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.",
|
||||||
@@ -148,9 +135,63 @@
|
|||||||
"form": "Zum Formular"
|
"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": {
|
"contact": {
|
||||||
"title": "Schreib mir.",
|
"title": "Kontakt",
|
||||||
"subtitle": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.",
|
"subtitle": "Du willst zusammenarbeiten oder hast Fragen zu meinen Projekten? Schreib mir gerne!",
|
||||||
"getInTouch": "Melde dich",
|
"getInTouch": "Melde dich",
|
||||||
"getInTouchBody": "Ich bin immer offen für neue Chancen, spannende Projekte oder einfach einen Tech-Talk.",
|
"getInTouchBody": "Ich bin immer offen für neue Chancen, spannende Projekte oder einfach einen Tech-Talk.",
|
||||||
"info": {
|
"info": {
|
||||||
@@ -210,13 +251,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"role": "IT & Self-Hosting",
|
"role": "Software Engineer",
|
||||||
"madeIn": "selbst gehostet in Deutschland",
|
"madeIn": "Made in Germany",
|
||||||
"legalNotice": "Impressum",
|
"legalNotice": "Impressum",
|
||||||
"privacyPolicy": "Datenschutz",
|
"privacyPolicy": "Datenschutz",
|
||||||
"privacySettings": "Datenschutz-Einstellungen",
|
"privacySettings": "Datenschutz-Einstellungen",
|
||||||
"privacySettingsTitle": "Datenschutz-Banner wieder anzeigen",
|
"privacySettingsTitle": "Datenschutz-Banner wieder anzeigen",
|
||||||
"builtWith": "Gebaut mit Next.js und Directus, läuft auf meinen eigenen Servern.",
|
"builtWith": "Built with",
|
||||||
"aiDisclaimer": "Einige Inhalte dieser Seite können KI-generiert sein.",
|
"aiDisclaimer": "Einige Inhalte dieser Seite können KI-generiert sein.",
|
||||||
"backToTop": "Nach oben",
|
"backToTop": "Nach oben",
|
||||||
"systemsOnline": "Systeme online"
|
"systemsOnline": "Systeme online"
|
||||||
@@ -236,5 +277,21 @@
|
|||||||
"subtitle": "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben.",
|
"subtitle": "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben.",
|
||||||
"backHome": "Zurück",
|
"backHome": "Zurück",
|
||||||
"empty": "Noch keine Bücher."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-18
@@ -67,8 +67,6 @@
|
|||||||
"bookLabel": "Reading now",
|
"bookLabel": "Reading now",
|
||||||
"bookFallback": "To the library",
|
"bookFallback": "To the library",
|
||||||
"photoCaption": "That's me.",
|
"photoCaption": "That's me.",
|
||||||
"chatCard": "Ask me something.",
|
|
||||||
"chatCardHint": "Open AI chat",
|
|
||||||
"sticker": "Runs on my own servers."
|
"sticker": "Runs on my own servers."
|
||||||
},
|
},
|
||||||
"windows": {
|
"windows": {
|
||||||
@@ -129,17 +127,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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": {
|
"contact": {
|
||||||
"title": "contact",
|
"title": "contact",
|
||||||
"body": "For projects, questions about my setup or just because. I usually reply within a day or two.",
|
"body": "For projects, questions about my setup or just because. I usually reply within a day or two.",
|
||||||
@@ -148,9 +135,63 @@
|
|||||||
"form": "To the form"
|
"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": {
|
"contact": {
|
||||||
"title": "Write me.",
|
"title": "Contact Me",
|
||||||
"subtitle": "For projects, questions about my setup or just because. I usually reply within a day or two.",
|
"subtitle": "Interested in working together or have questions about my projects? Feel free to reach out!",
|
||||||
"getInTouch": "Get In Touch",
|
"getInTouch": "Get In Touch",
|
||||||
"getInTouchBody": "I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation.",
|
"getInTouchBody": "I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation.",
|
||||||
"info": {
|
"info": {
|
||||||
@@ -210,13 +251,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"role": "IT & self-hosting",
|
"role": "Software Engineer",
|
||||||
"madeIn": "self-hosted in Germany",
|
"madeIn": "Made in Germany",
|
||||||
"legalNotice": "Legal notice",
|
"legalNotice": "Legal notice",
|
||||||
"privacyPolicy": "Privacy policy",
|
"privacyPolicy": "Privacy policy",
|
||||||
"privacySettings": "Privacy settings",
|
"privacySettings": "Privacy settings",
|
||||||
"privacySettingsTitle": "Show privacy settings banner again",
|
"privacySettingsTitle": "Show privacy settings banner again",
|
||||||
"builtWith": "Built with Next.js and Directus, running on my own servers.",
|
"builtWith": "Built with",
|
||||||
"aiDisclaimer": "Some content on this site may be AI-assisted.",
|
"aiDisclaimer": "Some content on this site may be AI-assisted.",
|
||||||
"backToTop": "Back to top",
|
"backToTop": "Back to top",
|
||||||
"systemsOnline": "Systems Online"
|
"systemsOnline": "Systems Online"
|
||||||
@@ -236,5 +277,21 @@
|
|||||||
"subtitle": "Books that shaped my mindset and expanded my horizons.",
|
"subtitle": "Books that shaped my mindset and expanded my horizons.",
|
||||||
"backHome": "Back Home",
|
"backHome": "Back Home",
|
||||||
"empty": "No books yet."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
#!/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,6 +20,40 @@ export interface FooterTranslations {
|
|||||||
builtWith: string;
|
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 {
|
export interface ContactTranslations {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user