diff --git a/app/[locale]/books/page.tsx b/app/[locale]/books/page.tsx new file mode 100644 index 0000000..bc129c9 --- /dev/null +++ b/app/[locale]/books/page.tsx @@ -0,0 +1,90 @@ +import { getBookReviews } from "@/lib/directus"; +import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo"; +import type { Metadata } from "next"; +import { BookOpen, ArrowLeft, Star } from "lucide-react"; +import Link from "next/link"; +import Image from "next/image"; + +export const revalidate = 300; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await params; + return { + title: locale === "de" ? "Meine Bibliothek" : "My Library", + alternates: { + canonical: toAbsoluteUrl(`/${locale}/books`), + languages: getLanguageAlternates({ pathWithoutLocale: "books" }), + }, + }; +} + +export default async function BooksPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + const reviews = await getBookReviews(locale); + + return ( +
+
+
+ + + {locale === 'de' ? 'Zurück' : 'Back Home'} + +

+ Library. +

+

+ {locale === "de" + ? "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben." + : "Books that shaped my mindset and expanded my horizons."} +

+
+ +
+ {reviews?.map((review) => ( +
+ {review.book_image && ( +
+ {review.book_title} +
+ )} +
+
+

{review.book_title}

+ {review.rating && ( +
+ + {review.rating} +
+ )} +
+

{review.book_author}

+ {review.review && ( +
+

+ “{review.review.replace(/<[^>]*>/g, '')}” +

+
+ )} +
+
+ ))} +
+
+
+ ); +} diff --git a/app/_ui/ProjectDetailClient.tsx b/app/_ui/ProjectDetailClient.tsx index 4a50da2..c079794 100644 --- a/app/_ui/ProjectDetailClient.tsx +++ b/app/_ui/ProjectDetailClient.tsx @@ -1,7 +1,7 @@ "use client"; import { motion } from "framer-motion"; -import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2 } from "lucide-react"; +import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2, Code } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; import ReactMarkdown from "react-markdown"; @@ -23,6 +23,7 @@ export type ProjectDetailData = { button_live_label?: string | null; button_github_label?: string | null; imageUrl?: string | null; + technologies?: string[]; }; export default function ProjectDetailClient({ @@ -36,217 +37,120 @@ export default function ProjectDetailClient({ const tDetail = useTranslations("projects.detail"); const tShared = useTranslations("projects.shared"); - // Track page view (non-blocking) useEffect(() => { try { navigator.sendBeacon?.( "/api/analytics/track", - new Blob( - [ - JSON.stringify({ - type: "pageview", - projectId: project.id.toString(), - page: `/${locale}/projects/${project.slug}`, - }), - ], - { type: "application/json" }, - ), + new Blob([JSON.stringify({ type: "pageview", projectId: project.id.toString(), page: `/${locale}/projects/${project.slug}` })], { type: "application/json" }), ); - } catch { - // ignore - } + } catch {} }, [project.id, project.slug, locale]); return ( -
-
+
+
+ {/* Navigation */} - - - - {tCommon("backToProjects")} - - + + {tCommon("backToProjects")} + - {/* Header & Meta */} - -
-

- {project.title} -

-
- {project.featured && ( - - {tShared("featured")} - - )} - - {project.category} - -
-
- -

+ {/* Title Section */} +

+

+ {project.title}. +

+

{project.description}

+
-
-
- - - {new Date(project.date).toLocaleDateString(locale || undefined, { - year: "numeric", - month: "long", - day: "numeric", - })} - -
-
-
- {project.tags.map((tag) => ( - - #{tag} - - ))} + {/* Feature Image Box */} +
+
+ {project.imageUrl ? ( + {project.title} + ) : ( +
+ {project.title.charAt(0)} +
+ )} +
+
+ + {/* Bento Details Grid */} +
+ + {/* Main Content */} +
+
+
+ {project.content} +
- - {/* Featured Image / Fallback */} - - {project.imageUrl ? ( - {project.title} - ) : ( -
- - {project.title.charAt(0)} - -
- )} -
- - {/* Content & Sidebar Layout */} -
- {/* Main Content */} - -
- ( -

{children}

- ), - h2: ({ children }) => ( -

{children}

- ), - p: ({ children }) =>

{children}

, - li: ({ children }) =>
  • {children}
  • , - code: ({ children }) => ( - - {children} - - ), - pre: ({ children }) => ( -
    -                      {children}
    -                    
    - ), - }} - > - {project.content} -
    -
    -
    - - {/* Sidebar / Actions */} - -
    -

    - - {tDetail("links")} -

    -
    - {project.live && project.live.trim() && project.live !== "#" ? ( - + {/* Sidebar Boxes */} +
    + + {/* Quick Links Box */} +
    +

    Links

    +
    + {project.live && ( + {project.button_live_label || tDetail("liveDemo")} - + - ) : ( -
    - {tDetail("liveNotAvailable")} -
    )} - - {project.github && project.github.trim() && project.github !== "#" ? ( - + {project.github && ( + {project.button_github_label || tDetail("viewSource")} - + - ) : null} + )}
    +
    -
    -

    {tDetail("techStack")}

    -
    - {project.tags.map((tag) => ( - - {tag} - - ))} + {/* Tech Stack Box */} +
    +

    Stack

    +
    + {project.tags.map((tag) => ( + + {tag} + + ))} +
    +
    + + {/* Meta Stats */} +
    +
    +
    +
    +
    +

    Release Date

    +

    {new Date(project.date).toLocaleDateString(locale, { year: 'numeric', month: 'long' })}

    +
    +
    +
    +
    +
    +

    Category

    +

    {project.category}

    +
    - + +
    ); } - diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts index 666293a..3208aff 100644 --- a/app/api/messages/route.ts +++ b/app/api/messages/route.ts @@ -1,94 +1,14 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getLocalizedMessage } from '@/lib/i18n-loader'; -import enMessages from '@/messages/en.json'; -import deMessages from '@/messages/de.json'; +import { NextRequest, NextResponse } from "next/server"; +import { getMessages } from "@/lib/directus"; -// Cache für 5 Minuten -export const revalidate = 300; - -const messagesMap = { en: enMessages, de: deMessages }; - -/** - * GET /api/messages?locale=en - * Lädt ALLE Messages aus Directus + JSON Fallback - * Wird von next-intl als messages source verwendet - */ -export async function GET(req: NextRequest) { - const locale = req.nextUrl.searchParams.get('locale') || 'en'; - - // Normalize locale (de-DE -> de) - const normalizedLocale = locale.startsWith('de') ? 'de' : 'en'; +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const locale = searchParams.get("locale") || "en"; try { - // Starte mit JSON als Basis - const jsonMessages = messagesMap[normalizedLocale as 'en' | 'de']; - - // Clone das Objekt - const messages = JSON.parse(JSON.stringify(jsonMessages)); - - // Flatten alle Keys - const allKeys = getAllKeys(messages); - - // Lade jeden Key aus Directus (überschreibt JSON wenn vorhanden) - await Promise.all( - allKeys.map(async (key) => { - try { - const value = await getLocalizedMessage(key, locale); - if (value && value !== key) { - // Überschreibe den Wert im messages Objekt - setNestedValue(messages, key, value); - } - } catch (error) { - // Fallback auf JSON Wert (schon vorhanden) - } - }) - ); - - return NextResponse.json(messages, { - headers: { - 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', - }, - }); + const messages = await getMessages(locale); + return NextResponse.json({ messages }); } catch (error) { - console.error('Messages API error:', error); - // Fallback: Return nur JSON messages - return NextResponse.json(messagesMap[normalizedLocale as 'en' | 'de'], { - headers: { - 'Cache-Control': 'public, s-maxage=60', - }, - }); + return NextResponse.json({ messages: {} }, { status: 500 }); } } - -// Helper: Sammle alle Keys aus verschachteltem Objekt -function getAllKeys(obj: any, prefix = ''): string[] { - const keys: string[] = []; - - for (const [key, value] of Object.entries(obj)) { - const fullKey = prefix ? `${prefix}.${key}` : key; - - if (value && typeof value === 'object' && !Array.isArray(value)) { - keys.push(...getAllKeys(value, fullKey)); - } else { - keys.push(fullKey); - } - } - - return keys; -} - -// Helper: Setze Wert in verschachteltem Objekt -function setNestedValue(obj: any, path: string, value: any) { - const keys = path.split('.'); - const lastKey = keys.pop()!; - - let current = obj; - for (const key of keys) { - if (!(key in current)) { - current[key] = {}; - } - current = current[key]; - } - - current[lastKey] = value; -} diff --git a/app/components/About.tsx b/app/components/About.tsx index c4e2d06..f2275cb 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, User, BookOpen } from "lucide-react"; +import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, User, BookOpen, MessageSquare, ExternalLink, ArrowRight } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; import type { JSONContent } from "@tiptap/react"; import RichTextClient from "./RichTextClient"; @@ -9,6 +9,8 @@ import CurrentlyReading from "./CurrentlyReading"; import ReadBooks from "./ReadBooks"; import { motion } from "framer-motion"; import { TechStackCategory, Hobby } from "@/lib/directus"; +import Link from "next/link"; +import ActivityFeed from "./ActivityFeed"; const iconMap: Record = { Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2 @@ -20,7 +22,6 @@ const About = () => { const [cmsDoc, setCmsDoc] = useState(null); const [techStack, setTechStack] = useState([]); const [hobbies, setHobbies] = useState([]); - const [loading, setLoading] = useState(true); useEffect(() => { const fetchData = async () => { @@ -36,61 +37,58 @@ const About = () => { if (techData?.techStack) setTechStack(techData.techStack); const hobbiesData = await hobbiesRes.json(); if (hobbiesData?.hobbies) setHobbies(hobbiesData.hobbies); - } catch (error) { - console.error(error); - } finally { - setLoading(false); - } + } catch (error) {} }; fetchData(); }, [locale]); return ( -
    +
    -
    +
    {/* 1. Large Bio Text */}
    -

    +

    {t("title")}

    -
    +
    {cmsDoc ? :

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

    }
    -
    -
    -

    {t("funFactTitle")}

    -

    {t("funFactBody")}

    +
    +
    +

    {t("funFactTitle")}

    +

    {t("funFactBody")}

    - {/* 2. Reading Log */} + {/* 2. Doing Right Now (Status) */} -

    - Reading -

    -
    - -
    - +
    +

    + Doing Now +

    +
    +
    + {/* Ambient Background for Status */} +
    {/* 3. Tech Stack */} @@ -99,7 +97,7 @@ const About = () => { whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay: 0.2 }} - className="md:col-span-12 bg-white dark:bg-stone-900 rounded-[3rem] p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm" + className="md:col-span-12 lg:col-span-9 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm" >
    {techStack.map((cat) => ( @@ -117,27 +115,70 @@ const About = () => {
    - {/* 4. Hobbies */} + {/* 4. AI Chat Box */} { + const chatBtn = document.querySelector('[aria-label="Open chat"]') as HTMLElement; + if (chatBtn) chatBtn.click(); + }} > -

    - {t("hobbiesTitle")} -

    -
    - {hobbies.map((hobby) => { - const Icon = iconMap[hobby.icon] || Lightbulb; - return ( -
    - - {hobby.title} -
    - ) - })} +
    + +
    +
    +

    AI Assistant

    +

    Have questions about my projects or experience? Ask my digital twin.

    +
    +
    + Start Chat +
    + + + {/* 5. Reading & Hobbies Archive Row */} + + {/* Reading Mini-Box */} +
    +
    +

    + Reading +

    + + View Library + +
    +
    + +
    +
    + + {/* Hobbies Mini-Box */} +
    +
    + {hobbies.map((hobby) => { + const Icon = iconMap[hobby.icon] || Lightbulb; + return ( +
    + + {hobby.title} +
    + ) + })} +
    +
    +

    {t("hobbiesTitle")}

    +

    Things that spark my curiosity outside of software engineering.

    +
    diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index 301f9d4..8f9a8c5 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -1,17 +1,34 @@ "use client"; import { motion } from "framer-motion"; -import { ArrowDown, Github, Linkedin, Mail, Code, Zap, Globe } from "lucide-react"; +import { ArrowDown, Github, Linkedin, Mail, Code, Zap } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; import Image from "next/image"; +import { useEffect, useState } from "react"; const Hero = () => { const locale = useLocale(); const t = useTranslations("home.hero"); + const [cmsMessages, setCmsMessages] = useState>({}); + + useEffect(() => { + (async () => { + try { + const res = await fetch(`/api/messages?locale=${locale}`); + if (res.ok) { + const data = await res.json(); + setCmsMessages(data.messages || {}); + } + } catch {} + })(); + }, [locale]); + + // Helper to get CMS text or fallback + const getLabel = (key: string, fallback: string) => cmsMessages[key] || fallback; return (
    - {/* Background Decor */} + {/* Liquid Ambient Background */}
    {
    {/* Left: Text Content */} -
    +
    - - Student & Self-Hoster + + {getLabel("hero.badge", "Student & Self-Hoster")} -

    +

    - Building Stuff. + {getLabel("hero.line1", "Building")} - Running Servers. + {getLabel("hero.line2", "Stuff.")}

    @@ -63,7 +80,7 @@ const Hero = () => { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 1, delay: 0.4 }} - className="text-lg md:text-xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto lg:mx-0 font-light leading-relaxed" + className="text-xl md:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto lg:mx-0 font-light leading-relaxed tracking-tight" > {t("description")} @@ -72,10 +89,11 @@ const Hero = () => { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, delay: 0.6 }} - className="flex flex-col sm:flex-row items-center gap-6 justify-center lg:justify-start pt-4" + className="flex flex-col sm:flex-row items-center gap-8 justify-center lg:justify-start pt-4" > - - {t("ctaWork").toUpperCase()} + +
    + {t("ctaWork")}
    {[ @@ -83,48 +101,40 @@ const Hero = () => { { icon: Linkedin, href: "https://linkedin.com/in/dkonkol" }, { icon: Mail, href: "mailto:contact@dk0.dev" } ].map((social, i) => ( - - + + ))}
    - {/* Right: The Photo (Visible Immediately) */} + {/* Right: The Photo */} -
    -
    - Dennis Konkol +
    +
    + Dennis Konkol
    - {/* Minimal Badge */} -
    - dk0.dev +
    + dk0.dev
    - {/* Scroll Down */} - +
    ); diff --git a/lib/directus.ts b/lib/directus.ts index 51a930b..42f3d9d 100644 --- a/lib/directus.ts +++ b/lib/directus.ts @@ -91,54 +91,34 @@ async function directusRequest( } } -export async function getMessage(key: string, locale: string): Promise { - // Note: messages collection doesn't exist in Directus yet - // The app uses JSON files as fallback via i18n-loader - // Return null to skip Directus and use JSON fallback directly - return null; - - /* Commented out until messages collection is created in Directus +export async function getMessages(locale: string): Promise> { const directusLocale = toDirectusLocale(locale); - - // GraphQL Query für Directus Native Translations - // Hole alle translations, filter client-side da GraphQL filter komplex ist const query = ` query { - messages(filter: {key: {_eq: "${key}"}}, limit: 1) { + messages { key translations { value - languages_code { - code - } + languages_code { code } } } } `; try { - const result = await directusRequest( - '', - { body: { query } } - ); + const result = await directusRequest('', { body: { query } }); + const messages = (result as any)?.messages || []; + const dictionary: Record = {}; - const messages = (result as any)?.messages; - if (!messages || messages.length === 0) { - return null; - } - - // Hole die Translation für die gewünschte Locale (client-side filter) - const translations = messages[0]?.translations || []; - const translation = translations.find((t: any) => - t.languages_code?.code === directusLocale - ); - - return translation?.value || null; + messages.forEach((m: any) => { + const trans = m.translations?.find((t: any) => t.languages_code?.code === directusLocale); + if (trans?.value) dictionary[m.key] = trans.value; + }); + + return dictionary; } catch (error) { - console.error(`Failed to fetch message ${key} (${locale}):`, error); - return null; + return {}; } - */ } export async function getContentPage(