6 Commits

Author SHA1 Message Date
denshooter 20b719893c feat: plakat redesign for subpages (projects, books, legal, privacy)
- Shared SubpageShell (HeaderClient paper strip + ink footer) and SubpageIntro,
  btn/ghost/card helpers in app/_ui/subpage.tsx
- Projects list: poster cards with hard ink shadows, filter pills, ink search field;
  no fake 800ms skeleton and no framer-motion opacity:0 in SSR
- Project detail: poster hero, .plakat-prose markdown, links/stack aside,
  back link works without JS
- Books: server-side fetch (visible without JS), cover + rating pills
- Legal notice / privacy: moved to app/_ui/*Client.tsx, root routes removed
  (middleware always redirects to /[locale]), consent reset via useConsent
- ProjectThumbnail rebuilt on Plakat tokens
- Old Header.tsx + test removed; brown/cream/sand palettes and unused
  glass-panel/admin-glass(-light)/liquid-bg-blob/float CSS dropped
  (liquid palette, glass-card, admin-glass-card stay for admin + 404)
- .plakat-prose replaces the missing typography plugin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:00:58 +02:00
denshooter 4f5089ce38 feat: plakat redesign phase 4 (chat window, contact, footer, cleanup)
- AI chat moves into a window/sheet, opened from a new speech-bubble card on
  the desk; it now respects the chatbot consent and asks before sending
  anything to n8n (previously it ignored the consent)
- Contact as red poster section with big mail address and the form on a paper
  card; validation errors are finally shown inline (aria-invalid, role=alert)
- Footer as ink strip with dk0 wordmark, legal links and a button to reopen
  the privacy settings
- "About me" in the header opens the about window on the home page
- Remove the old About section and its parts (BentoChat, ActivityFeed,
  CurrentlyReading, ReadBooks), the unused ChatWidget, Projects and BentoGrid,
  their tests, wrappers, loaders, types and texts
- scripts/directus-cleanup-redesign.mjs: dry run by default, --apply archives
  hobbies, tech stack categories and old content pages and deletes four
  unused message overrides after a full JSON backup; run only after the
  redesign is live on production

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:05:24 +02:00
denshooter 08e856120b feat: plakat redesign phase 3 (windows on desktop, sheets on mobile)
- Tapping an object on the desk opens a window (desktop) or a bottom sheet
  (mobile) instead of flipping: projects, now, servers, reading, about, contact
- Desktop windows in poster style (2px ink rule, hard offset shadow), open
  from the tapped object, thrown by the title bar with momentum and elastic
  viewport edges, stack by focus, minimize into a tray, Escape closes the top
  window, focus moves to close and returns to the trigger
- Mobile sheets via vaul 1.1.2: drag down to dismiss, overlay, focus trap
- Project window shows live/source links from Directus when present
- Hero "Write me" opens the contact window; still a plain #contact link
  without JavaScript
- Shared current-book loader for desk, deck and reading window
- Texts in de/en, i18n key check extended

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:56:48 +02:00
denshooter 70f378e720 feat: plakat redesign phase 2 (desk with throwable objects, mobile card deck)
- New "Tisch" section replaces the old projects block on the home page
- Objects: project cards from Directus (flip on tap, link to detail page),
  thesis ticket with countdown, server card, currently-reading book from
  Hardcover (with skeleton), polaroid photo, sticker
- Desktop: Framer Motion drag with momentum, elastic table edges, tilt that
  follows throw velocity, bring-to-front, "Tidy up" and "Mess it up"
- Mobile: card deck, swipe the top card away (distance or velocity),
  prev/next buttons, live counter, tap to flip
- Both layouts are server rendered and switched by CSS, so content is
  visible without JavaScript and nothing jumps on hydration
- Taps after a drag no longer flip cards
- New named colors polaroid/bookcover instead of raw hex classes
- Texts in de/en, i18n key check extended

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:47:42 +02:00
denshooter 9a82a3b0e7 feat: plakat redesign phase 1 (tokens, fonts, poster hero, header)
- New design tokens: signal red, ink and paper as RGB channels; ink and paper
  swap roles in dark mode so every component inverts cleanly
- Fonts via next/font: Bricolage Grotesque (with wdth/opsz axes), Geist,
  Geist Mono; drop Inter and Playfair
- Poster hero: kicker, giant DK0 wordmark that reacts to pointer distance
  (weight and width through a damped spring, desktop only, off for reduced
  motion), claim as h1, intro with thesis countdown, marquee strip
- Header in poster style: transparent over the red hero, paper strip with
  ink rule when scrolled or on subpages; drawer menu restyled
- Remove wavy SVG separators, CSS shader gradient, background blobs and
  unused providers that caused the pink/purple bands between sections
- Drop the global p/span/div color override so colors inherit properly
- New hero texts in de/en, tests and i18n key check updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:37:54 +02:00
denshooter c847a456a3 feat: polish motion with stronger easing, press feedback and reduced motion
- Override Tailwind ease-out/ease-in-out with stronger curves, add ease-drawer
- hoverOnlyWhenSupported so hover styles never stick on touch devices
- Replace transition-all with explicit properties across the public UI
- Press feedback via active:scale-[0.97] instead of hover:scale-105
- Global prefers-reduced-motion handling (CSS + MotionConfig reducedMotion="user")
- Mobile menu is a real drawer: stays mounted, inert when closed, Escape closes
- Progress and music bars animate scaleX/scaleY instead of width/height
- Fix undefined animate-spin-slow, contain hero CTA shine with overflow-hidden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:31:58 +02:00
60 changed files with 2840 additions and 3665 deletions
+9 -5
View File
@@ -18,11 +18,15 @@ import ScrollFadeIn from "@/app/components/ScrollFadeIn"
`AnimatePresence` is fine for modals and overlays that only appear after user interaction.
## Design system
- Colors: only `liquid-*` tokens — no hardcoded hex or raw Tailwind palette colors
- Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15 backdrop-blur-sm border-2 rounded-xl`
- Headlines: `uppercase tracking-tighter` with accent dot `<span className="text-emerald-600">.</span>`
- Body text: `text-stone-600 dark:text-stone-400` — never `text-stone-400` alone (fails contrast)
## Design system (Plakat)
- Colors: only the Plakat tokens `signal`, `ink`, `paper`, `paper-2`, `ok` — no hex, no raw Tailwind palette, no `liquid-*` (left over only in admin and 404)
- Ink and paper swap roles in dark mode on their own; `signal` stays red
- Fonts: `font-display` (Bricolage, `[font-variation-settings:'wdth'_80]` for posters), `font-sans` (Geist), `font-mono` (Geist Mono)
- Cards: `rounded-[18px] border-2 border-ink` + hard offset shadow `shadow-[6px_6px_0_rgb(var(--plakat-ink))]`
- Pills: `rounded-full` with `shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))]`, filled variant `bg-ink text-paper`
- Labels: `font-mono text-[13px]`; nothing below 12px
- Subpages: wrap in `app/_ui/SubpageShell.tsx`, reuse `SubpageIntro`, `btn`, `ghost`, `card` from `app/_ui/subpage.tsx`; long text in `.plakat-prose`
- Motion: `ease-out` / `ease-drawer`, press feedback `active:scale-[0.97]`, never `transition-all`
## Async components
Every component that fetches data must have a Skeleton loading state shown while data loads.
+3
View File
@@ -64,3 +64,6 @@ bones/*.bones.json
# OS
.DS_Store
Thumbs.db
# Sicherungen aus scripts/directus-cleanup-redesign.mjs
directus-backup-*.json
+107 -100
View File
@@ -1,114 +1,121 @@
"use client";
import { Star, ArrowLeft } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Skeleton } from "@/app/components/ui/Skeleton";
import { BookReview } from "@/lib/directus";
import Header from "@/app/components/Header";
import Footer from "@/app/components/Footer";
import ScrollFadeIn from "@/app/components/ScrollFadeIn";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { getBookReviews, type BookReview } from "@/lib/directus";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import SubpageShell from "@/app/_ui/SubpageShell";
import { BackArrow, SubpageIntro, card, ghost, monoLabel } from "@/app/_ui/subpage";
export default function BooksPage() {
const locale = useLocale();
const t = useTranslations("books");
const common = useTranslations("common");
const [reviews, setReviews] = useState<BookReview[]>([]);
const [loading, setLoading] = useState(true);
export const revalidate = 300;
useEffect(() => {
const fetchBooks = async () => {
try {
const res = await fetch(`/api/book-reviews?locale=${locale}`);
const data = await res.json();
if (data.bookReviews) setReviews(data.bookReviews);
} catch {
if (process.env.NODE_ENV === "development") {
// console.error in dev only
}
} finally {
setLoading(false);
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const isDe = locale === "de";
return {
title: isDe ? "Bibliothek – Dennis Konkol" : "Library – Dennis Konkol",
alternates: {
canonical: toAbsoluteUrl(`/${locale}/books`),
languages: getLanguageAlternates({ pathWithoutLocale: "books" }),
},
};
fetchBooks();
}, [locale]);
}
export default async function BooksPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "books" });
const common = await getTranslations({ locale, namespace: "common" });
// Serverseitig laden, damit die Liste auch ohne JavaScript dasteht
let reviews: BookReview[] = [];
try {
reviews = (await getBookReviews(locale)) ?? [];
} catch (err) {
if (process.env.NODE_ENV === "development") console.error("Book reviews fetch failed:", err);
}
return (
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 transition-colors duration-500">
<Header />
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true" />
<main className="pt-4 sm:pt-8 pb-20 px-4 sm:px-6">
<div className="max-w-7xl mx-auto">
<div className="mb-20">
<Link
href={`/${locale}`}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" />
<span className="font-bold uppercase tracking-widest text-xs">{common("backToHome")}</span>
<SubpageShell locale={locale}>
<SubpageIntro
back={
<Link href={`/${locale}`} className={ghost}>
<BackArrow /> {common("back")}
</Link>
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
{t("title")}<span className="text-liquid-purple">.</span>
</h1>
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
{t("subtitle")}
</p>
</div>
}
kicker={reviews.length > 0 ? `${String(reviews.length).padStart(2, "0")} ${locale === "de" ? "Bücher" : "books"}` : undefined}
title={t("title")}
>
<p>{t("subtitle")}</p>
</SubpageIntro>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
<Skeleton className="aspect-[3/4] rounded-2xl mb-8" />
<div className="space-y-3">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
))
) : reviews.length === 0 ? (
<div className="col-span-full text-center py-20 text-stone-400 dark:text-stone-500">
<Star size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-light">{t("empty")}</p>
{reviews.length === 0 ? (
<div className={`${card} mt-10 p-6 md:p-8`}>
<p className="font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold leading-tight tracking-[-0.03em]">{t("empty")}</p>
</div>
) : (
reviews.map((review, i) => (
<ScrollFadeIn key={review.id} delay={i * 0.1}>
<div className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full hover:shadow-xl transition-shadow duration-300 ease-out">
{review.book_image && (
<div className="relative aspect-[3/4] rounded-2xl overflow-hidden mb-8 shadow-xl border-4 border-stone-50 dark:border-stone-800">
<Image src={review.book_image} alt={review.book_title} fill className="object-cover" sizes="(max-width: 768px) 100vw, 33vw" />
</div>
<ul className="mt-10 grid gap-6 sm:grid-cols-2 md:gap-8 lg:grid-cols-3">
{reviews.map((review) => (
<li key={review.id} className="min-w-0">
<BookCard review={review} locale={locale} />
</li>
))}
</ul>
)}
<div className="flex-1 flex flex-col">
<div className="flex justify-between items-start gap-4 mb-4">
<h3 className="text-2xl font-black text-stone-900 dark:text-white leading-tight">{review.book_title}</h3>
{review.rating && review.rating > 0 && (
<div className="flex items-center gap-1 bg-stone-50 dark:bg-stone-800 px-3 py-1 rounded-full border border-stone-100 dark:border-stone-700">
<Star size={12} className="fill-amber-400 text-amber-400" />
<span className="text-xs font-black">{review.rating}</span>
</div>
)}
</div>
<p className="text-stone-600 dark:text-stone-400 font-bold text-sm mb-6">{review.book_author}</p>
{review.review && (
<div className="mt-auto pt-6 border-t border-stone-100 dark:border-stone-800">
<p className="text-stone-600 dark:text-stone-300 italic font-light leading-relaxed">
&ldquo;{review.review.replace(/<[^>]*>/g, '')}&rdquo;
</p>
</div>
)}
</div>
</div>
</ScrollFadeIn>
))
)}
</div>
</div>
</main>
<Footer />
</div>
</SubpageShell>
);
}
function BookCard({ review, locale }: { review: BookReview; locale: string }) {
const text = review.review?.replace(/<[^>]*>/g, "").trim();
const finished = review.finished_at ? new Date(review.finished_at) : null;
const finishedLabel =
finished && !Number.isNaN(finished.getTime())
? finished.toLocaleDateString(locale === "de" ? "de-DE" : "en-GB", { month: "short", year: "numeric" })
: null;
return (
<article className={`${card} flex h-full flex-col gap-4 p-5 md:p-6`}>
<div className="grid grid-cols-[88px_minmax(0,1fr)] gap-4">
<div className="relative h-[128px] overflow-hidden rounded-[3px_6px_6px_3px] border-2 border-ink bg-bookcover">
{review.book_image && <Image src={review.book_image} alt="" fill sizes="88px" className="object-cover" />}
</div>
<div className="min-w-0">
<h2 className="hyphens-auto break-words font-display text-[1.45rem] font-extrabold leading-[1.02] tracking-[-0.03em] sm:text-[1.6rem]">
{review.book_title}
</h2>
<p className={`${monoLabel} mt-2`}>{review.book_author}</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
{review.rating > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-ink px-2.5 py-1 font-mono text-xs text-paper">
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-3 w-3 fill-signal">
<path d="M12 1.5l2.6 7.9 7.9 2.6-7.9 2.6-2.6 7.9-2.6-7.9-7.9-2.6 7.9-2.6z" />
</svg>
<span>
<span className="sr-only">{locale === "de" ? "Bewertung: " : "Rating: "}</span>
{review.rating}/5
</span>
</span>
)}
{finishedLabel && <span className="font-mono text-xs">{finishedLabel}</span>}
</div>
</div>
</div>
{text && (
<blockquote className="mt-auto border-t-2 border-ink pt-4 text-[15px] leading-relaxed">
<span aria-hidden="true" className="font-display text-2xl font-extrabold leading-none text-signal">
{locale === "de" ? "„" : "“"}
</span>
{text}
</blockquote>
)}
</article>
);
}
+12 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export { default } from "../../legal-notice/page";
import SubpageShell from "@/app/_ui/SubpageShell";
import LegalNoticeClient from "@/app/_ui/LegalNoticeClient";
export async function generateMetadata({
params,
@@ -10,6 +11,7 @@ export async function generateMetadata({
const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "legal-notice" });
return {
title: locale === "de" ? "Impressum – Dennis Konkol" : "Legal notice – Dennis Konkol",
alternates: {
canonical: toAbsoluteUrl(`/${locale}/legal-notice`),
languages,
@@ -17,3 +19,12 @@ export async function generateMetadata({
};
}
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return (
<SubpageShell locale={locale}>
<LegalNoticeClient />
</SubpageShell>
);
}
+12 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export { default } from "../../privacy-policy/page";
import SubpageShell from "@/app/_ui/SubpageShell";
import PrivacyPolicyClient from "@/app/_ui/PrivacyPolicyClient";
export async function generateMetadata({
params,
@@ -10,6 +11,7 @@ export async function generateMetadata({
const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "privacy-policy" });
return {
title: locale === "de" ? "Datenschutz – Dennis Konkol" : "Privacy policy – Dennis Konkol",
alternates: {
canonical: toAbsoluteUrl(`/${locale}/privacy-policy`),
languages,
@@ -17,3 +19,12 @@ export async function generateMetadata({
};
}
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return (
<SubpageShell locale={locale}>
<PrivacyPolicyClient />
</SubpageShell>
);
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma";
import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
import SubpageShell from "@/app/_ui/SubpageShell";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
@@ -107,13 +108,13 @@ export default async function ProjectPage({
};
return (
<>
<SubpageShell locale={locale}>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProjectDetailClient project={projectData} locale={locale} />
</>
</SubpageShell>
);
}
+6 -1
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma";
import ProjectsPageClient, { ProjectListItem } from "@/app/_ui/ProjectsPageClient";
import SubpageShell from "@/app/_ui/SubpageShell";
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import { getProjects as getDirectusProjects } from "@/lib/directus";
@@ -94,6 +95,10 @@ export default async function ProjectsPage({
return dateB - dateA;
});
return <ProjectsPageClient projects={allProjects} locale={locale} />;
return (
<SubpageShell locale={locale}>
<ProjectsPageClient projects={allProjects} locale={locale} />
</SubpageShell>
);
}
@@ -1,151 +0,0 @@
import '@testing-library/jest-dom';
/**
* Unit tests for ActivityFeed NaN handling
*
* This test suite validates that the ActivityFeed component correctly handles
* NaN and numeric values in gaming and custom activity data to prevent
* "Received NaN for the children attribute" React errors.
*/
describe('ActivityFeed NaN Handling', () => {
describe('Gaming activity rendering logic', () => {
// Helper function to simulate getSafeGamingText behavior
const getSafeGamingText = (details: string | number | undefined, state: string | number | undefined, fallback: string): string => {
if (typeof details === 'string' && details.trim().length > 0) return details;
if (typeof state === 'string' && state.trim().length > 0) return state;
if (typeof details === 'number' && !isNaN(details)) return String(details);
if (typeof state === 'number' && !isNaN(state)) return String(state);
return fallback;
};
it('should safely handle NaN in gaming.details', () => {
const result = getSafeGamingText(NaN, 'Playing', 'Playing...');
expect(result).toBe('Playing'); // Should fall through NaN to state
expect(result).not.toBe(NaN);
expect(typeof result).toBe('string');
});
it('should safely handle NaN in both gaming.details and gaming.state', () => {
const result = getSafeGamingText(NaN, NaN, 'Playing...');
expect(result).toBe('Playing...'); // Should use fallback
expect(typeof result).toBe('string');
});
it('should prioritize string details over numeric state', () => {
const result = getSafeGamingText('Details text', 42, 'Playing...');
expect(result).toBe('Details text'); // String details takes precedence
expect(typeof result).toBe('string');
});
it('should prioritize string state over numeric details', () => {
const result = getSafeGamingText(42, 'State text', 'Playing...');
expect(result).toBe('State text'); // String state takes precedence over numeric details
expect(typeof result).toBe('string');
});
it('should convert valid numeric details to string', () => {
const result = getSafeGamingText(42, undefined, 'Playing...');
expect(result).toBe('42');
expect(typeof result).toBe('string');
});
it('should handle empty strings correctly', () => {
const result1 = getSafeGamingText('', 'Playing', 'Playing...');
expect(result1).toBe('Playing'); // Empty string should fall through to state
const result2 = getSafeGamingText(' ', 'Playing', 'Playing...');
expect(result2).toBe('Playing'); // Whitespace-only should fall through to state
});
it('should convert gaming.name to string safely', () => {
const validName = String('Test Game');
expect(validName).toBe('Test Game');
expect(typeof validName).toBe('string');
// In the actual code, we use String(data.gaming.name || '')
// If data.gaming.name is NaN, (NaN || '') evaluates to '' because NaN is falsy
const nanValue = NaN;
const nanName = String(nanValue || '');
expect(nanName).toBe(''); // NaN is falsy, so it falls back to ''
expect(typeof nanName).toBe('string');
});
});
describe('Custom activities progress handling', () => {
it('should only render progress bar when progress is a valid number', () => {
const validProgress = 75;
const shouldRender = validProgress !== undefined &&
typeof validProgress === 'number' &&
!isNaN(validProgress);
expect(shouldRender).toBe(true);
});
it('should not render progress bar when progress is NaN', () => {
const invalidProgress = NaN;
const shouldRender = invalidProgress !== undefined &&
typeof invalidProgress === 'number' &&
!isNaN(invalidProgress);
expect(shouldRender).toBe(false);
});
it('should not render progress bar when progress is undefined', () => {
const undefinedProgress = undefined;
const shouldRender = undefinedProgress !== undefined &&
typeof undefinedProgress === 'number' &&
!isNaN(undefinedProgress);
expect(shouldRender).toBe(false);
});
});
describe('Custom activities dynamic field rendering', () => {
it('should safely convert valid numeric values to string', () => {
const value = 42;
const shouldRender = typeof value === 'string' ||
(typeof value === 'number' && !isNaN(value));
expect(shouldRender).toBe(true);
if (shouldRender) {
const stringValue = String(value);
expect(stringValue).toBe('42');
expect(typeof stringValue).toBe('string');
}
});
it('should not render NaN values', () => {
const value = NaN;
const shouldRender = typeof value === 'string' ||
(typeof value === 'number' && !isNaN(value));
expect(shouldRender).toBe(false);
});
it('should render valid string values', () => {
const value = 'Test String';
const shouldRender = typeof value === 'string' ||
(typeof value === 'number' && !isNaN(value));
expect(shouldRender).toBe(true);
if (shouldRender) {
const stringValue = String(value);
expect(stringValue).toBe('Test String');
expect(typeof stringValue).toBe('string');
}
});
it('should render zero as a valid numeric value', () => {
const value = 0;
const shouldRender = typeof value === 'string' ||
(typeof value === 'number' && !isNaN(value));
expect(shouldRender).toBe(true);
if (shouldRender) {
const stringValue = String(value);
expect(stringValue).toBe('0');
expect(typeof stringValue).toBe('string');
}
});
});
});
@@ -1,49 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react";
import CurrentlyReadingComp from "@/app/components/CurrentlyReading";
import React from "react";
jest.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));
jest.mock("next/image", () => ({
__esModule: true,
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt || ""} />,
}));
describe("CurrentlyReading Component", () => {
beforeEach(() => {
global.fetch = jest.fn();
});
it("renders loading skeleton when loading", () => {
(global.fetch as jest.Mock).mockReturnValue(new Promise(() => {}));
render(<CurrentlyReadingComp />);
expect(screen.getAllByText).toBeDefined();
});
it("renders a book when data is fetched", async () => {
const mockBooks = [
{
title: "Test Book",
authors: ["Test Author"],
image: "/test.jpg",
progress: 50,
startedAt: "2024-01-01"
},
];
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({ currentlyReading: mockBooks }),
});
render(<CurrentlyReadingComp />);
await waitFor(() => {
expect(screen.getByText("Test Book")).toBeInTheDocument();
expect(screen.getByText("Test Author")).toBeInTheDocument();
});
});
});
-34
View File
@@ -1,34 +0,0 @@
import { render, screen } from '@testing-library/react';
import Header from '@/app/components/Header';
// Mock next-intl
jest.mock('next-intl', () => ({
useLocale: () => 'en',
useTranslations: () => (key: string) => {
const messages: Record<string, string> = {
home: 'Home',
about: 'About',
projects: 'Projects',
contact: 'Contact'
};
return messages[key] || key;
},
}));
// Mock next/navigation
jest.mock('next/navigation', () => ({
usePathname: () => '/en',
}));
describe('Header', () => {
it('renders the header with the dk logo', () => {
render(<Header />);
expect(screen.getByText('dk0')).toBeInTheDocument();
// Check for navigation links (appear in both desktop and mobile menus)
expect(screen.getAllByText('Home').length).toBeGreaterThan(0);
expect(screen.getAllByText('About').length).toBeGreaterThan(0);
expect(screen.getAllByText('Projects').length).toBeGreaterThan(0);
expect(screen.getAllByText('Contact').length).toBeGreaterThan(0);
});
});
+11 -18
View File
@@ -83,23 +83,17 @@ describe("i18n: message files", () => {
// ═══════════════════════════════════════════════════════════════════════════
const WRAPPED_COMPONENT_NAMESPACES: Record<string, string[]> = {
About: ["home.about", "about"],
Projects: ["home.projects"],
Contact: ["home.contact", "home.contact.form", "home.contact.info"],
Footer: ["footer"],
};
const CLIENT_WRAPPER_PROVIDES: Record<string, string[]> = {
AboutClient: ["home.about", "about"],
ProjectsClient: ["home.projects"],
ContactClient: ["home.contact"],
FooterClient: ["footer"],
};
describe("i18n: ClientWrappers provide all requested namespaces", () => {
const componentToWrapper: Record<string, string> = {
About: "AboutClient",
Projects: "ProjectsClient",
Contact: "ContactClient",
Footer: "FooterClient",
};
@@ -130,26 +124,25 @@ describe("i18n: ClientWrappers provide all requested namespaces", () => {
// ═══════════════════════════════════════════════════════════════════════════
const STATIC_KEYS: Record<string, Record<string, string[]>> = {
nav: { Header: ["home", "about", "projects", "contact"] },
nav: { HeaderClient: ["home", "about", "projects", "contact"] },
common: {
"books/page": ["back"], "legal-notice/page": ["back"], "privacy-policy/page": ["back"],
ProjectDetailClient: ["back"], ProjectsPageClient: ["backToHome"],
"books/page": ["back"], LegalNoticeClient: ["backToHome"], PrivacyPolicyClient: ["backToHome"],
ProjectDetailClient: ["back", "backToProjects"], ProjectsPageClient: ["backToHome"],
},
consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] },
"home.hero": { Hero: ["kicker", "claim", "intro", "countdown", "ctaWork", "ctaContact"] },
"home.tisch": { TischSection: ["title", "allProjects", "hintBoard", "hintDeck", "tidy", "mess", "flip", "details", "prev", "next", "ticketTitle", "ticketDays", "ticketJob", "ticketStub", "serverTitle", "servers.ingress.name", "servers.ingress.detail", "servers.mail.name", "servers.mail.detail", "servers.home.name", "servers.home.detail", "serverNote", "bookLabel", "bookFallback", "photoCaption", "sticker"] },
"home.about": { About: ["title", "p1", "p2", "p3", "funFactTitle", "funFactBody", "hobbiesTitle"] },
"home.about.activity": { ActivityFeed: ["idleStatus", "codingNow", "gaming", "inGame", "listening"] },
"home.about.currentlyReading": { CurrentlyReading: ["title", "progress"] },
"home.about.readBooks": { ReadBooks: ["title", "showMore", "showLess", "readMore", "collapseReview", "finishedAt", "empty"] },
"home.projects": { Projects: ["title", "subtitle", "viewAll", "noProjects"] },
"home.tisch": { TischSection: ["title", "allProjects", "hintBoard", "hintDeck", "tidy", "mess", "flip", "details", "prev", "next", "ticketTitle", "ticketDays", "ticketJob", "ticketStub", "serverTitle", "servers.ingress.name", "servers.ingress.detail", "servers.mail.name", "servers.mail.detail", "servers.home.name", "servers.home.detail", "serverNote", "bookLabel", "bookFallback", "photoCaption", "chatCard", "chatCardHint", "sticker"] },
"home.windows": { HomePageServer: ["close", "minimize", "openWindows", "projectPage", "source", "live", "stack", "year", "now.title", "now.days", "now.study.label", "now.study.text", "now.work.label", "now.work.text", "now.side.label", "now.side.text", "server.title", "server.headline", "read.title", "read.progress", "read.empty", "read.library", "about.title", "about.p1", "about.p2", "about.facts.laptop.label", "about.facts.laptop.value", "about.facts.pc.label", "about.facts.pc.value", "about.facts.study.label", "about.facts.study.value", "about.facts.languages.label", "about.facts.languages.value", "chat.title", "chat.welcome", "chat.consent", "chat.allow", "chat.placeholder", "chat.send", "chat.error", "chat.thinking", "chat.note", "contact.title", "contact.body", "contact.copy", "contact.copied", "contact.form"] },
"home.contact": { Contact: ["title", "subtitle"] },
"home.contact.form": { Contact: ["title", "sending", "send", "labels.name", "labels.email", "labels.subject", "labels.message", "placeholders.name", "placeholders.email", "placeholders.subject", "placeholders.message", "errors.nameRequired", "errors.nameMin", "errors.emailRequired", "errors.emailInvalid", "errors.subjectRequired", "errors.subjectMin", "errors.messageRequired", "errors.messageMin", "characters"] },
"home.contact.info": { Contact: ["locationValue"] },
about: { About: ["status", "aiAssistant", "library", "viewAll", "myGear", "gearMain", "gearPC", "gearServer", "gearOS", "curiosity"] },
"projects.list": { ProjectsPageClient: ["title", "intro", "searchPlaceholder", "all", "noResults", "clearFilters"] },
"projects.detail": { ProjectDetailClient: ["liveDemo", "viewSource"] },
footer: { Footer: ["role", "madeIn", "legalNotice", "privacyPolicy", "privacySettings", "privacySettingsTitle", "builtWith", "aiDisclaimer", "backToTop", "systemsOnline"] },
"projects.detail": { ProjectDetailClient: ["links", "liveDemo", "viewSource", "techStack"] },
footer: {
Footer: ["role", "madeIn", "legalNotice", "privacyPolicy", "privacySettings", "privacySettingsTitle", "builtWith", "aiDisclaimer", "backToTop", "systemsOnline"],
LegalNoticeClient: ["legalNotice"],
PrivacyPolicyClient: ["privacyPolicy", "privacySettings", "privacySettingsTitle"],
},
notFound: { "not-found": ["title", "description", "returnHome", "goBack", "exploreWork", "exploreWorkDesc", "viewProjects", "errorReport"] },
books: { "books/page": ["title", "subtitle", "empty"] },
};
+6 -9
View File
@@ -1,15 +1,15 @@
import Header from "../components/Header.server";
import Hero from "../components/Hero";
import TischSection from "../components/tisch/TischSection";
import WindowProvider from "../components/fenster/WindowProvider";
import { getWindowLabels } from "../components/fenster/getWindowLabels";
import ScrollFadeIn from "../components/ScrollFadeIn";
import Script from "next/script";
import {
getAboutTranslations,
getContactTranslations,
getFooterTranslations,
} from "@/lib/translations-loader";
import {
AboutClient,
ContactClient,
FooterClient,
} from "../components/ClientWrappers";
@@ -20,10 +20,10 @@ interface HomePageServerProps {
export default async function HomePageServer({ locale }: HomePageServerProps) {
// Parallel laden aller Translations (hero translations handled by Hero server component)
const [aboutT, contactT, footerT] = await Promise.all([
getAboutTranslations(locale),
const [contactT, footerT, windowLabels] = await Promise.all([
getContactTranslations(locale),
getFooterTranslations(locale),
getWindowLabels(locale),
]);
return (
@@ -85,16 +85,12 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
}),
}}
/>
<WindowProvider labels={windowLabels}>
<Header locale={locale} />
<main className="relative">
<Hero locale={locale} />
<TischSection locale={locale} />
<ScrollFadeIn>
<AboutClient locale={locale} translations={aboutT} />
</ScrollFadeIn>
<ScrollFadeIn>
<ContactClient locale={locale} translations={contactT} />
</ScrollFadeIn>
@@ -102,6 +98,7 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
<ScrollFadeIn>
<FooterClient locale={locale} translations={footerT} />
</ScrollFadeIn>
</WindowProvider>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import RichTextClient from "../components/RichTextClient";
import { BackArrow, SubpageIntro, card, frame, ghost, monoLabel } from "./subpage";
import { useCmsPage } from "./useCmsPage";
export default function LegalNoticeClient() {
const locale = useLocale();
const t = useTranslations("common");
const tFooter = useTranslations("footer");
const cmsHtml = useCmsPage("legal-notice", locale);
const de = locale === "de";
return (
<>
<SubpageIntro
back={
<Link href={`/${locale}`} className={ghost}>
<BackArrow /> {t("backToHome")}
</Link>
}
kicker="§ 5 TMG"
title={tFooter("legalNotice")}
/>
<div className="mt-10 grid gap-10 lg:grid-cols-12 lg:gap-12">
<div className="min-w-0 lg:col-span-8">
{cmsHtml ? (
<RichTextClient html={cmsHtml} className="plakat-prose max-w-[68ch]" />
) : (
<div className="plakat-prose max-w-[68ch]">
<h2>Angaben gemäß § 5 TMG</h2>
<p>
<strong>Dennis Konkol</strong>
<br />
Auf dem Ziegenbrink 2B
<br />
49082 Osnabrück, Deutschland
</p>
<h2>Haftungsausschluss</h2>
<p>
Die Inhalte dieser Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und
Aktualität der Inhalte kann ich jedoch keine Gewähr übernehmen.
</p>
</div>
)}
</div>
<aside className="grid content-start gap-6 lg:col-span-4">
<section className={`${frame} bg-signal p-5 text-ink md:p-6`} aria-labelledby="legal-contact">
<h2 id="legal-contact" className={monoLabel}>
{de ? "Direkter Kontakt" : "Direct contact"}
</h2>
<a
href="mailto:info@dk0.dev"
className="mt-3 block w-fit font-display text-[clamp(1.8rem,4vw,2.4rem)] font-extrabold leading-none tracking-[-0.04em] [overflow-wrap:anywhere] hover:underline hover:decoration-[3px] hover:underline-offset-[5px]"
>
info<span className="text-paper">@</span>dk0.dev
</a>
<p className="mt-4 text-[15px]">Osnabrück, {de ? "Deutschland" : "Germany"}</p>
</section>
<section className={`${card} p-5 md:p-6`} aria-labelledby="legal-review">
<h2 id="legal-review" className={monoLabel}>
{de ? "Zuletzt geprüft" : "Last review"}
</h2>
<p className="mt-2 font-display text-2xl font-extrabold tracking-[-0.03em]">{de ? "14. Mai 2025" : "May 14, 2025"}</p>
<p className="mt-3 text-[15px] leading-relaxed">
{de
? "Dieses Impressum gilt für alle Inhalte auf dk0.dev und die zugehörigen Social-Media-Profile."
: "This legal notice applies to all contents on dk0.dev and related social media profiles."}
</p>
</section>
</aside>
</div>
</>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import RichTextClient from "../components/RichTextClient";
import { useConsent } from "../components/ConsentProvider";
import { BackArrow, SubpageIntro, card, frame, ghost, monoLabel } from "./subpage";
import { useCmsPage } from "./useCmsPage";
export default function PrivacyPolicyClient() {
const locale = useLocale();
const t = useTranslations("common");
const tFooter = useTranslations("footer");
const { resetConsent } = useConsent();
const cmsHtml = useCmsPage("privacy-policy", locale);
const de = locale === "de";
const principles = de
? [
{ title: "Verschlüsselung", text: "Übertragung per SSL/TLS." },
{ title: "Transparenz", text: "Kein verstecktes Tracking." },
{ title: "DSGVO", text: "Ausgelegt auf die DSGVO." },
]
: [
{ title: "Encryption", text: "SSL/TLS secured data transfer." },
{ title: "Transparency", text: "No hidden tracking algorithms." },
{ title: "Compliance", text: "GDPR / DSGVO optimized." },
];
return (
<>
<SubpageIntro
back={
<Link href={`/${locale}`} className={ghost}>
<BackArrow /> {t("backToHome")}
</Link>
}
kicker="DSGVO · GDPR"
title={tFooter("privacyPolicy")}
/>
<div className="mt-10 grid gap-10 lg:grid-cols-12 lg:gap-12">
<div className="min-w-0 lg:col-span-8">
{cmsHtml ? (
<RichTextClient html={cmsHtml} className="plakat-prose max-w-[68ch]" />
) : (
<div className="plakat-prose max-w-[68ch]">
<h2>Verantwortlicher</h2>
<p>
<strong>Dennis Konkol</strong>
<br />
Auf dem Ziegenbrink 2B
<br />
49082 Osnabrück, Deutschland
<br />
E-Mail: <a href="mailto:contact@dk0.dev">contact@dk0.dev</a>
</p>
<p>
Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten
Verantwortlichen.
</p>
<h2>Datenerfassung</h2>
<p>
Beim Zugriff auf diese Website werden automatisch Informationen allgemeiner Natur erfasst. Diese beinhalten
unter anderem:
</p>
<ul>
<li>IP-Adresse (in anonymisierter Form)</li>
<li>Uhrzeit und Datum des Zugriffs</li>
<li>Browsertyp und Betriebssystem</li>
<li>Referrer-URL (die zuvor besuchte Seite)</li>
</ul>
<p>
Diese Informationen werden anonymisiert erfasst und dienen ausschließlich statistischen Auswertungen.
Rückschlüsse auf Ihre Person sind nicht möglich.
</p>
<h2>Analyse- und Tracking-Tools</h2>
<p>
Zur Analyse der Nutzung dieser Website setze ich <strong>Umami</strong> ein. Umami speichert keine
IP-Adressen oder Cookies. Alle erfassten Daten sind anonymisiert. Da ich Umami auf meinem eigenen Server
betreibe, erfolgt keine Weitergabe an Dritte. Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. f DSGVO
(berechtigtes Interesse an der Analyse und Optimierung der Website).
</p>
<h2>Kontaktformular</h2>
<p>
Wenn Sie das Kontaktformular nutzen oder per E-Mail Kontakt aufnehmen, werden Ihre Angaben zur Bearbeitung
Ihrer Anfrage gespeichert. Diese Daten werden nicht an Dritte weitergegeben und nach Erfüllung des Zwecks
gelöscht. Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. a DSGVO (Einwilligung).
</p>
<h2>Social Media Links</h2>
<p>
Diese Website enthält Links zu GitHub und LinkedIn. Durch das Anklicken dieser Links gelten die
Datenschutzbestimmungen der jeweiligen Anbieter.
</p>
<h2>Weitergabe von Daten</h2>
<p>Eine Weitergabe Ihrer personenbezogenen Daten erfolgt nur, wenn:</p>
<ul>
<li>Sie nach Art. 6 Abs. 1 S. 1 lit. a DSGVO ausdrücklich eingewilligt haben,</li>
<li>dies zur Vertragserfüllung gemäß Art. 6 Abs. 1 S. 1 lit. b DSGVO erforderlich ist,</li>
<li>eine gesetzliche Verpflichtung nach Art. 6 Abs. 1 S. 1 lit. c DSGVO besteht, oder</li>
<li>die Verarbeitung nach Art. 6 Abs. 1 S. 1 lit. f DSGVO zur Wahrung berechtigter Interessen erforderlich ist.</li>
</ul>
<h2>Ihre Rechte</h2>
<p>Sie haben gemäß DSGVO folgende Rechte:</p>
<ul>
<li>Art. 15 DSGVO: Auskunftsrecht über Ihre gespeicherten Daten</li>
<li>Art. 16 DSGVO: Recht auf Berichtigung unrichtiger Daten</li>
<li>Art. 17 DSGVO: Recht auf Löschung (soweit keine Aufbewahrungspflichten entgegenstehen)</li>
<li>Art. 18 DSGVO: Recht auf Einschränkung der Verarbeitung</li>
<li>Art. 20 DSGVO: Recht auf Datenübertragbarkeit</li>
<li>Art. 21 DSGVO: Widerspruchsrecht gegen die Verarbeitung</li>
</ul>
<p>
Beschwerden können Sie an die zuständige Datenschutzaufsichtsbehörde richten:{" "}
<a href="https://www.bfdi.bund.de/" target="_blank" rel="noopener noreferrer">
bfdi.bund.de
</a>
</p>
<h2>Datensicherheit</h2>
<p>
Ich setze technische und organisatorische Maßnahmen ein, um Ihre Daten zu schützen. Dazu gehören unter
anderem die SSL-Verschlüsselung. Diese Verschlüsselung erkennen Sie an dem Schloss-Symbol in der
Adresszeile Ihres Browsers und an der URL, die mit &ldquo;https://&rdquo; beginnt.
</p>
<h2>Änderungen</h2>
<p>
Diese Datenschutzerklärung wird regelmäßig aktualisiert, um den gesetzlichen Anforderungen zu entsprechen.
Die jeweils aktuelle Version finden Sie auf dieser Seite.
</p>
<p className="font-mono text-[13px]">Letzte Aktualisierung: Mai 2025</p>
</div>
)}
</div>
<aside className="grid content-start gap-6 lg:col-span-4">
<section className={`${frame} bg-ink p-5 text-paper md:p-6`} aria-labelledby="privacy-principles">
<h2 id="privacy-principles" className={monoLabel}>
{de ? "Grundsätze" : "Principles"}
</h2>
<dl className="mt-3">
{principles.map((p) => (
<div key={p.title} className="grid grid-cols-[10px_1fr] items-baseline gap-3 border-b border-paper/20 py-3 last:border-b-0">
<span className="h-2.5 w-2.5 rounded-full bg-signal" aria-hidden="true" />
<div>
<dt className="font-display text-xl font-extrabold tracking-[-0.02em]">{p.title}</dt>
<dd className="mt-0.5 text-[15px] opacity-80">{p.text}</dd>
</div>
</div>
))}
</dl>
</section>
<section className={`${card} p-5 md:p-6`} aria-labelledby="privacy-settings">
<h2 id="privacy-settings" className={`${monoLabel} flex items-center gap-2`}>
<span className="h-2.5 w-2.5 rounded-full bg-ok shadow-[0_0_0_3px_rgb(var(--plakat-ok)/0.2)]" aria-hidden="true" />
{de ? "Verbindung" : "Connection"}
</h2>
<p className="mt-3 font-display text-2xl font-extrabold leading-tight tracking-[-0.03em]">
{de ? "Deine Verbindung ist verschlüsselt." : "Your connection is private and secure."}
</p>
<button type="button" onClick={resetConsent} className={`${ghost} mt-4`} title={tFooter("privacySettingsTitle")}>
{tFooter("privacySettings")}
</button>
</section>
</aside>
</div>
</>
);
}
+75 -111
View File
@@ -1,12 +1,12 @@
"use client";
import { ExternalLink, ArrowLeft, Github as GithubIcon } from "lucide-react";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { useTranslations } from "next-intl";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ProjectThumbnail from "@/app/components/ProjectThumbnail";
import { BackArrow, SubpageIntro, btn, card, frame, ghost, monoLabel } from "./subpage";
export type ProjectDetailData = {
id: number;
@@ -39,137 +39,101 @@ export default function ProjectDetailClient({
const tCommon = useTranslations("common");
const tDetail = useTranslations("projects.detail");
const router = useRouter();
const [canGoBack, setCanGoBack] = useState(false);
useEffect(() => {
// Prüfen, ob wir eine History haben (von Home gekommen)
if (typeof window !== 'undefined' && window.history.length > 1) {
setCanGoBack(true);
}
}, []);
const handleBack = (e: React.MouseEvent) => {
const listHref = `/${locale}/projects`;
const handleBack = (e: React.MouseEvent<HTMLAnchorElement>) => {
// Kommt man von der eigenen Seite, geht es per Browser-Zurück an die exakte Stelle
// (Startseite oder Liste). Bei Deep Links, ohne JS oder mit Modifier-Taste gilt der Link selbst.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
const fromHere = document.referrer && new URL(document.referrer).origin === window.location.origin;
if (fromHere && window.history.length > 1) {
e.preventDefault();
// Wenn wir direkt auf die Seite gekommen sind (Deep Link), gehen wir zur Projektliste
// Ansonsten nutzen wir den Browser-Back, um an die exakte Stelle der Home oder Liste zurückzukehren
if (canGoBack) {
router.back();
} else {
router.push(`/${locale}/projects`);
}
};
const liveUrl = project.live && project.live !== "#" ? project.live : null;
const githubUrl = project.github && project.github !== "#" ? project.github : null;
const rawDate = project.date || project.created_at;
const year = rawDate && !Number.isNaN(new Date(rawDate).getFullYear()) ? String(new Date(rawDate).getFullYear()) : null;
const stack = project.technologies?.length ? project.technologies : project.tags;
return (
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
{/* Navigation - Intelligent Back */}
<button
onClick={handleBack}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-12 group bg-transparent border-none cursor-pointer"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" />
<span className="font-bold uppercase tracking-widest text-xs">
{tCommon("back")}
</span>
</button>
{/* Title Section */}
<div className="mb-20">
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase mb-8">
{project.title}<span className="text-liquid-mint">.</span>
</h1>
<p className="text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-4xl leading-snug tracking-tight">
{project.description}
</p>
</div>
{/* Feature Image Box */}
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-4 md:p-8 border border-stone-200/60 dark:border-stone-800/60 shadow-sm mb-12 overflow-hidden">
<div className="relative aspect-video rounded-[2rem] overflow-hidden border-4 border-stone-50 dark:border-stone-800 shadow-2xl">
{project.imageUrl ? (
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="100vw" />
) : (
<ProjectThumbnail
<article>
<SubpageIntro
back={
<Link href={listHref} onClick={handleBack} className={ghost}>
<BackArrow /> {tCommon("back")}
</Link>
}
kicker={[project.category, year].filter(Boolean).join(" · ")}
title={project.title}
category={project.category}
tags={project.tags}
slug={project.slug}
size="hero"
/>
>
<p>{project.description}</p>
</SubpageIntro>
<div className="relative mt-8 aspect-[4/3] overflow-hidden rounded-[18px] border-2 border-ink shadow-[8px_8px_0_rgb(var(--plakat-ink))] sm:aspect-video md:mt-12">
{project.imageUrl ? (
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="(max-width: 1400px) 100vw, 1400px" />
) : (
<div aria-hidden="true">
<ProjectThumbnail title={project.title} category={project.category} tags={project.tags} slug={project.slug} size="hero" />
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
<div className="lg:col-span-8 space-y-8">
<div className="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">
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<div className="mt-12 grid gap-10 lg:grid-cols-12 lg:gap-12">
<div className="min-w-0 lg:col-span-8">
{project.content ? (
<div className="plakat-prose max-w-[68ch]">
<ReactMarkdown>{project.content}</ReactMarkdown>
</div>
</div>
) : (
<p className="max-w-[68ch] text-lg leading-relaxed">{project.description}</p>
)}
</div>
<div className="lg:col-span-4 space-y-8">
{/* Quick Links Box - Only show if links exist */}
{((project.live && project.live !== "#") || (project.github && project.github !== "#")) && (
<div className="bg-stone-900 dark:bg-stone-800 rounded-[3rem] p-10 border border-stone-800 dark:border-stone-700 shadow-2xl text-white">
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-mint">Links</h3>
<div className="space-y-4">
{project.live && project.live !== "#" && (
<a href={project.live} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between w-full p-5 bg-white text-stone-900 rounded-2xl font-black active:scale-[0.97] transition-transform duration-150 ease-out group">
<span>{project.button_live_label || tDetail("liveDemo")}</span>
<ExternalLink size={20} className="group-hover:translate-x-1 transition-transform duration-200 ease-out" />
<aside className="grid content-start gap-6 lg:sticky lg:top-24 lg:col-span-4 lg:self-start">
{(liveUrl || githubUrl) && (
<section className={`${frame} bg-signal p-5 text-ink md:p-6`} aria-labelledby="project-links">
<h2 id="project-links" className={monoLabel}>
{tDetail("links")}
</h2>
<div className="mt-4 flex flex-wrap gap-2">
{liveUrl && (
<a href={liveUrl} target="_blank" rel="noopener noreferrer" className={btn}>
{project.button_live_label || tDetail("liveDemo")} <span aria-hidden="true">↗</span>
</a>
)}
{project.github && project.github !== "#" && (
<a href={project.github} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between w-full p-5 bg-stone-800 text-white border border-stone-700 rounded-2xl font-black hover:bg-stone-700 transition-colors group">
<span>{project.button_github_label || tDetail("viewSource")}</span>
<GithubIcon size={20} className="group-hover:rotate-12 transition-transform" />
{githubUrl && (
<a href={githubUrl} target="_blank" rel="noopener noreferrer" className={`${ghost} px-5 py-3 text-[15px] font-semibold`}>
{project.button_github_label || tDetail("viewSource")} <span aria-hidden="true">↗</span>
</a>
)}
</div>
</section>
)}
</div>
</div>
)}
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-stone-400">Stack</h3>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<span key={tag} 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">
{stack.length > 0 && (
<section className={`${card} p-5 md:p-6`} aria-labelledby="project-stack">
<h2 id="project-stack" className={monoLabel}>
{tDetail("techStack")}
</h2>
<ul className="mt-4 flex flex-wrap gap-2">
{stack.map((tag) => (
<li key={tag} className="rounded-full px-3 py-1.5 text-sm font-medium shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))]">
{tag}
</span>
</li>
))}
</ul>
</section>
)}
<Link href={listHref} className={`${ghost} w-fit`}>
<BackArrow /> {tCommon("backToProjects")}
</Link>
</aside>
</div>
</div>
</div>
</div>
</div>
</div>
</article>
);
}
+114 -82
View File
@@ -1,13 +1,12 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import { ArrowUpRight, ArrowLeft, Search } from "lucide-react";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "../components/ui/Skeleton";
import { useTranslations } from "next-intl";
import ProjectThumbnail from "@/app/components/ProjectThumbnail";
import ScrollFadeIn from "@/app/components/ScrollFadeIn";
import { BackArrow, SubpageIntro, card, ghost, monoLabel } from "./subpage";
export type ProjectListItem = {
id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
@@ -21,6 +20,18 @@ export type ProjectListItem = {
imageUrl?: string | null;
};
function yearOf(project: ProjectListItem): string | null {
const raw = project.date || project.createdAt;
if (!raw) return null;
const year = new Date(raw).getFullYear();
return Number.isNaN(year) ? null : String(year);
}
const filterPill = (active: boolean) =>
`rounded-full px-4 py-2 text-sm font-medium transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97] ${
active ? "bg-ink text-paper" : "shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] hover:bg-ink hover:text-paper"
}`;
export default function ProjectsPageClient({
projects,
locale,
@@ -33,13 +44,6 @@ export default function ProjectsPageClient({
const [selectedCategory, setSelectedCategory] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
// Simulate initial load for smoother entrance or handle actual fetch if needed
const timer = setTimeout(() => setLoading(false), 800);
return () => clearTimeout(timer);
}, []);
const categories = useMemo(() => {
const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean);
@@ -54,86 +58,103 @@ export default function ProjectsPageClient({
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(
(p) => p.title.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.tags.some(t => t.toLowerCase().includes(query))
(p) =>
p.title.toLowerCase().includes(query) ||
p.description.toLowerCase().includes(query) ||
p.tags.some((t) => t.toLowerCase().includes(query)),
);
}
return result;
}, [projects, selectedCategory, searchQuery]);
const resetFilters = () => {
setSelectedCategory("all");
setSearchQuery("");
};
return (
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 pt-40 pb-20 px-6 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-24">
<Link
href={`/${locale}`}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" />
<span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToHome")}</span>
<>
<SubpageIntro
back={
<Link href={`/${locale}`} className={ghost}>
<BackArrow /> {tCommon("backToHome")}
</Link>
}
kicker={`${String(projects.length).padStart(2, "0")} ${locale === "de" ? "Projekte" : "projects"}`}
title={tList("title")}
>
<p>{tList("intro")}</p>
</SubpageIntro>
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
{tList("title")}<span className="text-liquid-mint">.</span>
</h1>
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
{tList("intro")}
</p>
</div>
{/* Filters */}
<div className="flex flex-col md:flex-row gap-8 justify-between items-start md:items-center mb-16">
<div className="flex flex-wrap gap-2">
{/* Filter: Kategorien als Pillen, Suche als Tinten-Feld */}
<div className="mt-8 flex flex-col gap-4 md:mt-10 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-2" role="group" aria-label={locale === "de" ? "Kategorien" : "Categories"}>
{categories.map((cat) => (
<button
key={cat}
type="button"
onClick={() => setSelectedCategory(cat)}
className={`px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97] ${
selectedCategory === cat
? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900"
: "bg-white dark:bg-stone-900 text-stone-500 border border-stone-200 dark:border-stone-800"
}`}
aria-pressed={selectedCategory === cat}
className={filterPill(selectedCategory === cat)}
>
{cat === 'all' ? tList('all') : cat}
{cat === "all" ? tList("all") : cat}
</button>
))}
</div>
<div className="relative w-full md:w-80">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
<label className="relative block w-full md:w-80">
<span className="sr-only">{tList("searchPlaceholder")}</span>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="pointer-events-none absolute left-4 top-1/2 h-[18px] w-[18px] -translate-y-1/2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
type="text"
type="search"
placeholder={tList("searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-2xl py-4 pl-12 pr-6 focus:outline-none focus:ring-2 focus:ring-liquid-mint/30 transition-[border-color,box-shadow,background-color] duration-150 ease-out shadow-sm"
className="w-full rounded-full border-2 border-ink bg-paper py-3 pl-11 pr-5 text-base text-ink placeholder:text-ink/60 transition-shadow duration-150 ease-out focus:shadow-[4px_4px_0_rgb(var(--plakat-ink))] focus:outline-none"
/>
</div>
</label>
</div>
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
<Skeleton className="aspect-[16/10] rounded-[2rem] mb-8" />
<div className="space-y-3">
<Skeleton className="h-8 w-1/2" />
<Skeleton className="h-4 w-3/4" />
{filteredProjects.length === 0 ? (
<div className={`${card} mt-10 flex flex-col items-start gap-4 p-6 md:p-8`}>
<p className="font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold leading-tight tracking-[-0.03em]">
{tList("noResults")}
</p>
<button type="button" onClick={resetFilters} className={ghost}>
{tList("clearFilters")}
</button>
</div>
</div>
))
) : (
filteredProjects.map((project) => (
<motion.div key={project.id} initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-60px" }} transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}>
<Link href={`/${locale}/projects/${project.slug}`} className="group block h-full">
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm h-full hover:shadow-xl transition-shadow duration-300 ease-out flex flex-col">
<ul className="mt-10 grid gap-6 md:grid-cols-2 md:gap-8 xl:grid-cols-3">
{filteredProjects.map((project, i) => {
const year = yearOf(project);
return (
<li key={project.id} className="min-w-0">
<ScrollFadeIn delay={Math.min(i, 5) * 0.05} className="h-full">
<Link
href={`/${locale}/projects/${project.slug}`}
className={`${card} group flex h-full flex-col overflow-hidden transition-[transform,box-shadow] duration-200 ease-out hover:-translate-y-1 hover:shadow-[8px_10px_0_rgb(var(--plakat-ink))] active:scale-[0.97]`}
>
<div className="relative aspect-[16/10] border-b-2 border-ink" aria-hidden="true">
{project.imageUrl ? (
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
<Image src={project.imageUrl} alt={project.title} fill className="object-cover group-hover:scale-[1.03] transition-transform duration-500 ease-out" />
</div>
<Image
src={project.imageUrl}
alt=""
fill
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
className="object-cover"
/>
) : (
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
<ProjectThumbnail
title={project.title}
category={project.category}
@@ -141,28 +162,39 @@ export default function ProjectsPageClient({
slug={project.slug}
size="card"
/>
</div>
)}
<div className="flex-1 flex flex-col">
<div className="flex justify-between items-start mb-4">
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tight">{project.title}</h3>
<div className="w-12 h-12 rounded-full bg-stone-50 dark:bg-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 transition-colors duration-200">
<ArrowUpRight size={20} />
</div>
<div className="flex flex-1 flex-col gap-3 p-5 md:p-6">
<p className={monoLabel}>{[project.category, year].filter(Boolean).join(" · ")}</p>
<div className="flex items-start justify-between gap-4">
<h2 className="min-w-0 hyphens-auto break-words font-display text-[clamp(1.7rem,3vw,2.3rem)] font-extrabold leading-[0.95] tracking-[-0.04em]">
{project.title}
</h2>
<span
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-lg shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-colors duration-150 ease-out group-hover:bg-signal group-hover:text-ink"
>
↗
</span>
</div>
<p className="text-stone-500 dark:text-stone-400 font-light text-lg mb-8 line-clamp-3 leading-relaxed">{project.description}</p>
<div className="mt-auto flex flex-wrap gap-2">
{project.tags.slice(0, 3).map(tag => (
<span key={tag} className="px-3 py-1 bg-stone-50 dark:bg-stone-800 rounded-lg text-[9px] font-black uppercase tracking-widest text-stone-400">{tag}</span>
<p className="line-clamp-3 text-[15px] leading-relaxed">{project.description}</p>
{project.tags.length > 0 && (
<ul className="mt-auto flex flex-wrap gap-1.5 pt-2" aria-label="Stack">
{project.tags.slice(0, 4).map((tag) => (
<li key={tag} className="rounded-full bg-paper-2 px-2.5 py-1 font-mono text-xs">
{tag}
</li>
))}
</div>
</div>
</ul>
)}
</div>
</Link>
</motion.div>
)))}
</div>
</div>
</div>
</ScrollFadeIn>
</li>
);
})}
</ul>
)}
</>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import Header from "../components/Header.server";
import { FooterClient } from "../components/ClientWrappers";
import { getFooterTranslations } from "@/lib/translations-loader";
interface SubpageShellProps {
locale: string;
children: ReactNode;
}
/**
* Rahmen für alle Unterseiten: derselbe Kopf wie auf der Startseite
* (dort als Papierstreifen), Papier als Grund, Tinten-Footer.
*/
export default async function SubpageShell({ locale, children }: SubpageShellProps) {
const footerT = await getFooterTranslations(locale);
return (
<div id="top" className="min-h-screen bg-paper text-ink">
<Header locale={locale} />
<main className="px-4 pb-20 pt-24 sm:px-6 md:pb-28 md:pt-32 lg:px-10">
<div className="mx-auto max-w-[1400px]">{children}</div>
</main>
<FooterClient locale={locale} translations={footerT} />
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
// Gemeinsame Bausteine der Unterseiten im Plakat-Stil. Bewusst ohne "use client",
// damit Server- und Client-Seiten sie gleichermaßen nutzen können.
export const btn =
"inline-flex items-center gap-2 rounded-full bg-ink px-5 py-3 text-[15px] font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]";
export const ghost =
"inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium 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-paper active:scale-[0.97]";
/** Rahmen mit hartem Versatzschatten, ohne Hintergrund (für farbige Karten) */
export const frame = "rounded-[18px] border-2 border-ink shadow-[6px_6px_0_rgb(var(--plakat-ink))]";
export const card = `${frame} bg-paper`;
export const monoLabel = "font-mono text-[13px] leading-snug";
interface SubpageIntroProps {
/** Zurück-Link oben links */
back: ReactNode;
/** Kleine Zeile oben rechts, z. B. Kategorie oder Rechtsgrundlage */
kicker?: ReactNode;
title: ReactNode;
children?: ReactNode;
}
/** Kopf einer Unterseite: Zurück-Pille, große Plakat-Zeile, Einleitung. */
export function SubpageIntro({ back, kicker, title, children }: SubpageIntroProps) {
return (
<header className="border-b-2 border-ink pb-8 md:pb-12">
<div className="flex flex-wrap items-center justify-between gap-3">
{back}
{kicker && <p className={monoLabel}>{kicker}</p>}
</div>
<h1 className="mt-8 hyphens-auto break-words text-balance font-display text-[clamp(3rem,11vw,9.5rem)] font-extrabold leading-[0.86] tracking-[-0.05em] [font-variation-settings:'wdth'_80] md:mt-12">
{title}
<span className="text-signal">.</span>
</h1>
{children && <div className="mt-6 max-w-[52ch] text-lg leading-relaxed md:text-xl">{children}</div>}
</header>
);
}
/** Pfeil nach links für Zurück-Links, rein dekorativ */
export function BackArrow() {
return <span aria-hidden="true">←</span>;
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useEffect, useState } from "react";
/**
* Lädt eine Inhaltsseite (Impressum, Datenschutz) aus dem CMS nach.
* Bis dahin und ohne JavaScript bleibt der eingebaute Text stehen.
*/
export function useCmsPage(key: string, locale: string): string | null {
const [html, setHtml] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/content/page?key=${encodeURIComponent(key)}&locale=${encodeURIComponent(locale)}`);
const data = await res.json();
if (!cancelled && data?.content?.html && data?.content?.locale === locale) {
setHtml(data.content.html as string);
}
} catch {}
})();
return () => {
cancelled = true;
};
}, [key, locale]);
return html;
}
-253
View File
@@ -1,253 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import dynamic from "next/dynamic";
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
import CurrentlyReading from "./CurrentlyReading";
import ReadBooks from "./ReadBooks";
import { motion } from "framer-motion";
import { TechStackCategory, TechStackItem, Hobby } from "@/lib/directus";
import Link from "next/link";
import ActivityFeed from "./ActivityFeed";
import BentoChat from "./BentoChat";
import { Skeleton } from "./ui/Skeleton";
import { LucideIcon } from "lucide-react";
const iconMap: Record<string, LucideIcon> = {
Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2, BookOpen, Tv, Plane, Camera, Stars, Music, Terminal, Cpu
};
const About = () => {
const locale = useLocale();
const t = useTranslations("home.about");
const at = useTranslations("about");
const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
const [hobbies, setHobbies] = useState<Hobby[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [cmsRes, techRes, hobbiesRes, msgRes] = await Promise.all([
fetch(`/api/content/page?key=home-about&locale=${locale}`),
fetch(`/api/tech-stack?locale=${locale}`),
fetch(`/api/hobbies?locale=${locale}`),
fetch(`/api/messages?locale=${locale}`)
]);
const cmsData = await cmsRes.json();
if (cmsData?.content?.html) setCmsHtml(cmsData.content.html as string);
const techData = await techRes.json();
if (techData?.techStack) setTechStack(techData.techStack);
const hobbiesData = await hobbiesRes.json();
if (hobbiesData?.hobbies) setHobbies(hobbiesData.hobbies);
const msgData = await msgRes.json();
if (msgData?.messages) setCmsMessages(msgData.messages);
} catch (error) {
if (process.env.NODE_ENV === "development") console.error("About data fetch failed:", error);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [locale]);
return (
<section id="about" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
{/* 1. Large Bio Text */}
<motion.div
className="md:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="space-y-5 sm:space-y-6 md:space-y-8">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
</h2>
<div className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-[95%]" />
<Skeleton className="h-6 w-[90%]" />
</div>
) : cmsHtml ? (
<RichTextClient html={cmsHtml} />
) : (
<p>{t("p1")} {t("p2")}</p>
)}
</div>
<div className="pt-4 sm:pt-6 md:pt-8">
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-2xl sm:rounded-3xl border border-stone-100 dark:border-stone-700">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400 mb-1 sm:mb-2">{t("funFactTitle")}</p>
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-sm sm:text-base font-bold opacity-90">{t("funFactBody")}</p>}
</div>
</div>
</div>
</motion.div>
{/* 2. Activity / Status Box */}
<motion.div
transition={{ delay: 0.1 }}
className="md:col-span-4 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
>
<div className="relative z-10 h-full">
<h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
<Activity size={20} /> {at("status")}
</h3>
<ActivityFeed locale={locale} />
</div>
<div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" />
</motion.div>
{/* 3. AI Chat Box */}
<motion.div
transition={{ delay: 0.2 }}
className="md:col-span-12 lg:col-span-4 bg-stone-50 dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 flex flex-col shadow-sm"
>
<div className="flex items-center gap-2 mb-5 sm:mb-8">
<MessageSquare className="text-liquid-purple" size={20} />
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">{at("aiAssistant")}</h3>
</div>
<div className="flex-1">
<BentoChat />
</div>
</motion.div>
{/* 4. Tech Stack */}
<motion.div
transition={{ delay: 0.3 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 md:gap-12">
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-6">
<Skeleton className="h-3 w-20" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-24 rounded-xl" />
<Skeleton className="h-8 w-16 rounded-xl" />
<Skeleton className="h-8 w-20 rounded-xl" />
</div>
</div>
))
) : (
techStack.map((cat) => (
<div key={cat.id} className="space-y-6">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{cat.name}</h4>
<div className="flex flex-wrap gap-2">
{cat.items?.map((item: TechStackItem) => (
<span key={item.id} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700/50 hover:border-liquid-mint transition-colors">
{item.name}
</span>
))}
</div>
</div>
))
)}
</div>
</motion.div>
{/* 5. Library */}
<motion.div
transition={{ delay: 0.4 }}
className="md:col-span-7 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
>
<div className="relative z-10 flex flex-col h-full">
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter">
<BookOpen className="text-liquid-purple" size={24} /> {at("library")}
</h3>
<Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-opacity">
{at("viewAll")} <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" />
</Link>
</div>
<CurrentlyReading />
<div className="mt-6 flex-1">
<ReadBooks />
</div>
</div>
</motion.div>
{/* 6. My Gear */}
<motion.div
transition={{ delay: 0.5 }}
className="md:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8"
>
<div className="flex-1 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group">
<div className="relative z-10">
<h3 className="text-xl sm:text-2xl font-black mb-5 sm:mb-8 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter text-white">
<Cpu className="text-liquid-mint" size={24} /> {at("myGear")}
</h3>
<div className="grid grid-cols-2 gap-4 sm:gap-6">
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearMain")}</p>
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
</div>
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearPC")}</p>
<p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p>
</div>
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearServer")}</p>
<p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p>
</div>
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearOS")}</p>
<p className="text-sm font-bold text-stone-100">macOS / Linux</p>
</div>
</div>
</div>
<div className="absolute bottom-0 right-0 w-32 h-32 bg-liquid-mint/10 blur-3xl rounded-full -mr-16 -mb-16" />
</div>
</motion.div>
{/* 7. Hobbies */}
<motion.div
transition={{ delay: 0.5 }}
className="md:col-span-12"
>
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between">
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6 mb-6 sm:mb-8 md:mb-12">
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
) : (
hobbies.map((hobby) => {
const Icon = iconMap[hobby.icon] || Lightbulb;
return (
<div key={hobby.id} className="p-3 sm:p-4 md:p-6 bg-stone-50 dark:bg-stone-800 rounded-xl sm:rounded-2xl border border-stone-100 dark:border-stone-700 hover:border-liquid-mint transition-colors group">
<div className="flex items-center gap-2 sm:gap-3 mb-1.5 sm:mb-3">
<Icon size={16} className="text-liquid-mint group-hover:scale-110 transition-transform duration-200 ease-out shrink-0 sm:w-5 sm:h-5" />
<h4 className="font-bold text-xs sm:text-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4>
</div>
<p className="text-[11px] sm:text-xs text-stone-500 dark:text-stone-400 font-medium leading-relaxed hidden sm:block">
{hobby.description}
</p>
</div>
)
})
)}
</div>
<div className="space-y-1 sm:space-y-2 border-t border-stone-100 dark:border-stone-800 pt-4 sm:pt-6 md:pt-8">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3>
<p className="text-stone-500 font-light text-sm sm:text-base md:text-lg">{at("curiosity")}</p>
</div>
</div>
</motion.div>
</div>
</div>
</section>
);
};
export default About;
-248
View File
@@ -1,248 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Disc3, Gamepad2, Zap, Quote as QuoteIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Image from "next/image";
interface CustomActivity {
[key: string]: unknown;
}
interface StatusData {
music: { isPlaying: boolean; track: string; artist: string; albumArt: string; url: string; } | null;
gaming: { isPlaying: boolean; name: string; image: string | null; state?: string | number; details?: string | number; } | null;
coding: { isActive: boolean; project?: string; file?: string; language?: string; } | null;
customActivities?: Record<string, CustomActivity>;
}
const techQuotes = {
de: [
{ content: "Informatik hat nicht mehr mit Computern zu tun als Astronomie mit Teleskopen.", author: "Edsger W. Dijkstra" },
{ content: "Einfachheit ist die Voraussetzung für Verlässlichkeit.", author: "Edsger W. Dijkstra" },
{ content: "Wenn Debugging der Prozess des Entfernens von Fehlern ist, dann muss Programmieren der Prozess des Einbauens sein.", author: "Edsger W. Dijkstra" },
{ content: "Gelöschter Code ist gedebuggter Code.", author: "Jeff Sickel" },
{ content: "Zuerst löse das Problem. Dann schreibe den Code.", author: "John Johnson" },
{ content: "Jedes Programm kann um mindestens einen Faktor zwei vereinfacht werden. Jedes Programm hat mindestens einen Bug.", author: "Kernighan's Law" },
{ content: "Code lesen ist schwieriger als Code schreiben — deshalb schreibt jeder neu.", author: "Joel Spolsky" },
{ content: "Die beste Performance-Optimierung ist der Übergang von nicht-funktionierend zu funktionierend.", author: "J. Osterhout" },
{ content: "Mach es funktionierend, dann mach es schön, dann mach es schnell — in dieser Reihenfolge.", author: "Kent Beck" },
{ content: "Software ist wie Entropie: Es ist schwer zu fassen, wiegt nichts und gehorcht dem zweiten Hauptsatz der Thermodynamik.", author: "Norman Augustine" },
{ content: "Gute Software ist nicht die, die keine Bugs hat — sondern die, deren Bugs keine Rolle spielen.", author: "Bruce Eckel" },
{ content: "Der einzige Weg, schnell zu gehen, ist, gut zu gehen.", author: "Robert C. Martin" },
],
en: [
{ content: "Computer Science is no more about computers than astronomy is about telescopes.", author: "Edsger W. Dijkstra" },
{ content: "Simplicity is prerequisite for reliability.", author: "Edsger W. Dijkstra" },
{ content: "If debugging is the process of removing software bugs, then programming must be the process of putting them in.", author: "Edsger W. Dijkstra" },
{ content: "Deleted code is debugged code.", author: "Jeff Sickel" },
{ content: "First, solve the problem. Then, write the code.", author: "John Johnson" },
{ content: "Any program can be simplified by at least a factor of two. Every program has at least one bug.", author: "Kernighan's Law" },
{ content: "It's harder to read code than to write it — that's why everyone rewrites.", author: "Joel Spolsky" },
{ content: "The best performance optimization is the transition from a non-working state to a working state.", author: "J. Osterhout" },
{ content: "Make it work, make it right, make it fast — in that order.", author: "Kent Beck" },
{ content: "Software is like entropy: it is difficult to grasp, weighs nothing, and obeys the second law of thermodynamics.", author: "Norman Augustine" },
{ content: "Good software isn't software with no bugs — it's software whose bugs don't matter.", author: "Bruce Eckel" },
{ content: "The only way to go fast is to go well.", author: "Robert C. Martin" },
]
};
function getSafeGamingText(details: string | number | undefined, state: string | number | undefined, fallback: string): string {
if (typeof details === 'string' && details.trim().length > 0) return details;
if (typeof state === 'string' && state.trim().length > 0) return state;
return fallback;
}
export default function ActivityFeed({
onActivityChange,
locale = 'en'
}: {
onActivityChange?: (active: boolean) => void;
locale?: string;
}) {
const [data, setData] = useState<StatusData | null>(null);
const [hasActivity, setHasActivity] = useState(false);
const [quoteIndex, setQuoteIndex] = useState(() => Math.floor(Math.random() * (techQuotes[locale as keyof typeof techQuotes] || techQuotes.en).length));
const [loading, setLoading] = useState(true);
const t = useTranslations("home.about.activity");
const allQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en;
useEffect(() => {
const fetchData = async () => {
try {
const res = await fetch("/api/n8n/status");
if (!res.ok) throw new Error();
const json = await res.json();
const activityData = Array.isArray(json) ? json[0] : json;
setData(activityData);
const isActive = Boolean(
activityData.coding?.isActive ||
activityData.gaming?.isPlaying ||
activityData.music?.isPlaying ||
Object.values(activityData.customActivities || {}).some((act) => Boolean((act as { enabled?: boolean })?.enabled))
);
setHasActivity(isActive);
onActivityChange?.(isActive);
} catch {
setHasActivity(false);
onActivityChange?.(false);
} finally {
setLoading(false);
}
};
fetchData();
const statusInterval = setInterval(fetchData, 30000);
// Pick a random quote every 10 seconds (never the same one twice in a row)
const quoteInterval = setInterval(() => {
setQuoteIndex((prev) => {
let next;
do { next = Math.floor(Math.random() * allQuotes.length); } while (next === prev && allQuotes.length > 1);
return next;
});
}, 10000);
return () => {
clearInterval(statusInterval);
clearInterval(quoteInterval);
};
}, [onActivityChange, allQuotes.length]);
if (loading) {
return <div className="animate-pulse space-y-4">
<div className="h-24 bg-stone-100 dark:bg-stone-800 rounded-2xl" />
</div>;
}
if (!hasActivity) {
return (
<div className="h-full flex flex-col justify-center space-y-6">
<div className="w-10 h-10 rounded-full bg-liquid-mint/10 flex items-center justify-center">
<QuoteIcon size={18} className="text-emerald-600 dark:text-liquid-mint" />
</div>
<div className="min-h-[80px] sm:min-h-[120px] relative">
<AnimatePresence mode="wait">
<motion.div
key={quoteIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6, filter: "blur(2px)", transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] } }}
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
className="space-y-4"
>
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-400 dark:text-stone-400 italic">
&ldquo;{allQuotes[quoteIndex].content}&rdquo;
</p>
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest">
— {allQuotes[quoteIndex].author}
</p>
</motion.div>
</AnimatePresence>
</div>
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 dark:text-stone-400 pt-4 border-t border-stone-100 dark:border-stone-800">
<span className="w-1.5 h-1.5 rounded-full bg-stone-200 dark:bg-stone-700 animate-pulse" />
{t("idleStatus")}
</div>
</div>
);
}
return (
<div className="space-y-4">
{data?.coding?.isActive && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/20 rounded-2xl p-5">
<div className="flex items-center gap-3 mb-2">
<Zap size={14} className="text-emerald-600 dark:text-emerald-400 animate-pulse" />
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-600 dark:text-emerald-400">{t("codingNow")}</span>
</div>
<p className="font-bold text-stone-900 dark:text-white text-lg truncate">{data.coding.project}</p>
<p className="text-xs text-stone-500 dark:text-stone-400 truncate">{data.coding.file}</p>
</motion.div>
)}
{data?.gaming?.isPlaying && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-indigo-500/10 dark:bg-indigo-500/20 border border-indigo-500/20 dark:border-indigo-400/30 rounded-2xl p-5">
<div className="flex items-center gap-3 mb-3">
<Gamepad2 size={14} className="text-indigo-600 dark:text-indigo-300" />
<span className="text-[10px] font-black uppercase tracking-widest text-indigo-600 dark:text-indigo-300">{t("gaming")}</span>
</div>
<div className="flex gap-4">
{data.gaming.image ? (
<div className="w-12 h-12 rounded-xl overflow-hidden shrink-0 shadow-lg relative bg-indigo-500/10">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={data.gaming.image} alt={data.gaming.name} className="w-full h-full object-cover" />
</div>
) : (
<div className="w-12 h-12 rounded-xl shrink-0 bg-indigo-500/20 dark:bg-indigo-400/20 flex items-center justify-center">
<Gamepad2 size={20} className="text-indigo-500 dark:text-indigo-300" />
</div>
)}
<div className="min-w-0 flex flex-col justify-center">
<p className="font-bold text-indigo-700 dark:text-white text-base truncate">{data.gaming.name}</p>
<p className="text-xs text-indigo-600/70 dark:text-indigo-200/80 truncate">
{getSafeGamingText(data.gaming.details, data.gaming.state, t("inGame"))}
</p>
</div>
</div>
</motion.div>
)}
{data?.music?.isPlaying && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#1DB954]/5 dark:bg-[#1DB954]/10 border border-[#1DB954]/20 rounded-2xl p-5 relative overflow-hidden group"
>
<div className="flex items-center justify-between mb-3 relative z-10">
<div className="flex items-center gap-2">
<Disc3 size={14} className="text-[#1DB954] animate-spin-slow" />
<span className="text-[10px] font-black uppercase tracking-widest text-[#1DB954]">{t("listening")}</span>
</div>
{/* Simple Animated Music Bars */}
<div className="flex items-end gap-[2px] h-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
animate={{ scaleY: [0.2, 1, 0.2] }}
transition={{
duration: 0.8,
repeat: Infinity,
delay: i * 0.2,
ease: "easeInOut"
}}
className="w-[2px] h-full origin-bottom bg-[#1DB954] rounded-full"
/>
))}
</div>
</div>
<a
href={data.music.url}
target="_blank"
rel="noopener noreferrer"
className="flex gap-4 relative z-10"
>
<div className="w-16 h-16 rounded-lg overflow-hidden shrink-0 shadow-md relative group-hover:shadow-xl transition-shadow duration-300">
<Image
src={data.music.albumArt}
alt="Album Art"
fill
className="object-cover transition-transform duration-500 ease-out group-hover:scale-105"
sizes="64px"
/>
</div>
<div className="min-w-0 flex flex-col justify-center">
<p className="font-bold text-[#1DB954] dark:text-[#1DB954] text-base truncate leading-tight mb-1 hover:underline">{data.music.track}</p>
<p className="text-sm text-stone-600 dark:text-stone-400 truncate font-medium">{data.music.artist}</p>
</div>
</a>
{/* Subtle Spotify branding gradient */}
<div className="absolute top-0 right-0 w-32 h-32 bg-[#1DB954]/5 blur-[60px] rounded-full -mr-16 -mt-16 pointer-events-none" />
</motion.div>
)}
</div>
);
}
-114
View File
@@ -1,114 +0,0 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Send, Loader2 } from "lucide-react";
interface Message {
id: string;
text: string;
sender: "user" | "bot";
timestamp: Date;
}
interface StoredMessage {
id: string;
text: string;
sender: "user" | "bot";
timestamp: string;
}
export default function BentoChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [conversationId, setConversationId] = useState<string>("default");
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
try {
const storedId = localStorage.getItem("chatSessionId");
if (storedId) setConversationId(storedId);
else {
const newId = crypto.randomUUID();
localStorage.setItem("chatSessionId", newId);
setConversationId(newId);
}
const storedMsgs = localStorage.getItem("chatMessages");
if (storedMsgs) {
setMessages(JSON.parse(storedMsgs).map((m: StoredMessage) => ({ ...m, timestamp: new Date(m.timestamp) })));
} else {
setMessages([{ id: "welcome", text: "Hi! Ask me anything about Dennis! 🚀", sender: "bot", timestamp: new Date() }]);
}
} catch {}
}, []);
useEffect(() => {
if (messages.length > 0) {
localStorage.setItem("chatMessages", JSON.stringify(messages));
}
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [messages]);
const handleSend = async () => {
if (!inputValue.trim() || isLoading) return;
const userMsg: Message = { id: Date.now().toString(), text: inputValue.trim(), sender: "user", timestamp: new Date() };
setMessages(prev => [...prev, userMsg]);
setInputValue("");
setIsLoading(true);
try {
const res = await fetch("/api/n8n/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: userMsg.text, conversationId, history: messages.slice(-5).map(m => ({ role: m.sender === "user" ? "user" : "assistant", content: m.text })) }),
});
const data = await res.json();
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), text: data.reply || "Error", sender: "bot", timestamp: new Date() }]);
} catch {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), text: "Connection error.", sender: "bot", timestamp: new Date() }]);
} finally {
setIsLoading(true);
setTimeout(() => setIsLoading(false), 500); // Small delay for feel
}
};
return (
<div className="flex flex-col h-full min-h-[300px]">
<div
ref={containerRef}
className="flex-1 overflow-y-auto pr-2 scrollbar-hide space-y-4 mb-4"
>
{messages.map((m) => (
<div key={m.id} className={`flex ${m.sender === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[90%] rounded-2xl px-4 py-2 text-sm shadow-sm ${m.sender === "user" ? "bg-liquid-purple text-white" : "bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-100 dark:border-stone-700"}`}>
{m.text}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-stone-100 dark:bg-stone-800 rounded-2xl px-4 py-2"><Loader2 size={14} className="animate-spin text-stone-400" /></div>
</div>
)}
</div>
<div className="relative">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Ask me..."
className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-[border-color,box-shadow,background-color] duration-150 ease-out shadow-inner dark:text-white"
/>
<button onClick={handleSend} aria-label="Send message" className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple active:scale-[0.97] transition-transform duration-150 ease-out">
<Send size={18} />
</button>
</div>
</div>
);
}
-495
View File
@@ -1,495 +0,0 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
MessageCircle,
X,
Send,
Loader2,
Sparkles,
Trash2,
} from "lucide-react";
interface Message {
id: string;
text: string;
sender: "user" | "bot";
timestamp: Date;
isTyping?: boolean;
}
export default function ChatWidget() {
// Prevent hydration mismatch by only rendering after mount
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [conversationId, setConversationId] = useState<string>("default");
useEffect(() => {
setMounted(true);
// Generate or retrieve conversation ID only on client
try {
const stored = localStorage.getItem("chatSessionId");
if (stored) {
setConversationId(stored);
return;
}
// Generate UUID with fallback for browsers without crypto.randomUUID
let newId: string;
if (typeof crypto !== "undefined" && crypto.randomUUID) {
newId = crypto.randomUUID();
} else {
// Fallback UUID generation
newId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
localStorage.setItem("chatSessionId", newId);
setConversationId(newId);
} catch (error) {
// localStorage might be disabled or full
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to access localStorage for chat session:', error);
}
setConversationId(`session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
}
}, []);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Focus input when chat opens
useEffect(() => {
if (isOpen) {
inputRef.current?.focus();
}
}, [isOpen]);
// Helper function to decode HTML entities
const decodeHtmlEntities = (text: string): string => {
if (!text || typeof text !== "string") return text;
const textarea = document.createElement("textarea");
textarea.innerHTML = text;
return textarea.value;
};
// Load messages from localStorage
useEffect(() => {
if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem("chatMessages");
if (stored) {
try {
const parsed = JSON.parse(stored);
setMessages(
parsed.map((m: Message) => ({
...m,
text: decodeHtmlEntities(m.text), // Decode HTML entities when loading
timestamp: new Date(m.timestamp),
})),
);
} catch (e) {
if (process.env.NODE_ENV === 'development') {
console.error("Failed to parse chat history", e);
}
// Clear corrupted data
try {
localStorage.removeItem("chatMessages");
} catch {
// Ignore cleanup errors
}
// Add welcome message
setMessages([
{
id: "welcome",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
}
} else {
// Add welcome message
setMessages([
{
id: "welcome",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
}
} catch (error) {
// localStorage might be disabled
if (process.env.NODE_ENV === 'development') {
console.warn("Failed to load chat history from localStorage:", error);
}
// Add welcome message anyway
setMessages([
{
id: "welcome",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
}
}
}, []);
// Save messages to localStorage
useEffect(() => {
if (typeof window !== "undefined" && messages.length > 0) {
try {
localStorage.setItem("chatMessages", JSON.stringify(messages));
} catch (error) {
// localStorage might be full or disabled
if (process.env.NODE_ENV === 'development') {
console.warn("Failed to save chat messages to localStorage:", error);
}
}
}
}, [messages]);
const handleSend = async () => {
if (!inputValue.trim() || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
text: inputValue.trim(),
sender: "user",
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue("");
setIsLoading(true);
// Get last 10 messages for context
const history = messages.slice(-10).map((m) => ({
role: m.sender === "user" ? "user" : "assistant",
content: m.text,
}));
try {
const response = await fetch("/api/n8n/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: userMessage.text,
conversationId,
history,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error");
if (process.env.NODE_ENV === 'development') {
console.error("Chat API error:", {
status: response.status,
statusText: response.statusText,
error: errorText,
});
}
throw new Error(
`Failed to get response: ${response.status} - ${errorText.substring(0, 100)}`,
);
}
const data = await response.json();
// Log response for debugging (only in development)
if (process.env.NODE_ENV === "development") {
console.log("Chat API response:", data);
}
// Decode HTML entities in the reply
let replyText =
data.reply || "Sorry, I couldn't process that. Please try again.";
// Decode HTML entities client-side (double safety)
replyText = decodeHtmlEntities(replyText);
const botMessage: Message = {
id: (Date.now() + 1).toString(),
text: replyText,
sender: "bot",
timestamp: new Date(),
};
setMessages((prev) => [...prev, botMessage]);
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error("Chat error:", error);
}
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
text: "Sorry, I'm having trouble connecting right now. Please try again later or use the contact form below.",
sender: "bot",
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const clearChat = () => {
// Reset session ID
const newId = crypto.randomUUID();
setConversationId(newId);
if (typeof window !== "undefined") {
localStorage.setItem("chatSessionId", newId);
localStorage.removeItem("chatMessages");
}
setMessages([
{
id: "welcome",
text: "Conversation restarted! Ask me anything about Dennis! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
};
// Don't render until mounted to prevent hydration mismatch
if (!mounted) {
return null;
}
return (
<>
{/* Chat Button */}
<AnimatePresence>
{!isOpen && (
<motion.div
role="button"
tabIndex={0}
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1, transition: { duration: 0.25, ease: [0.23, 1, 0.32, 1] } }}
exit={{ scale: 0.9, opacity: 0, transition: { duration: 0.15, ease: [0.23, 1, 0.32, 1] } }}
onClick={() => setIsOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setIsOpen(true);
}
}}
className="fixed bottom-4 left-4 md:bottom-6 md:left-6 z-30 bg-white/80 backdrop-blur-xl text-stone-900 p-3.5 rounded-full shadow-[0_10px_26px_rgba(41,37,36,0.16)] hover:bg-white active:scale-[0.97] transition-[background-color,transform] duration-150 ease-out group cursor-pointer border border-white/60 ring-1 ring-white/30"
aria-label="Open chat"
>
<MessageCircle size={24} />
<span className="absolute top-0 right-0 w-3 h-3 bg-green-500 rounded-full animate-pulse shadow-sm border-2 border-white" />
{/* Tooltip */}
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-1.5 bg-stone-900/90 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-150 ease-out group-hover:delay-300 whitespace-nowrap pointer-events-none z-[100] shadow-xl backdrop-blur-sm">
Chat with AI
</span>
</motion.div>
)}
</AnimatePresence>
{/* Chat Window */}
<AnimatePresence>
{isOpen && (
<motion.div
data-chat-widget
initial={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className="origin-bottom-left fixed bottom-20 left-4 right-4 md:bottom-24 md:left-6 md:right-auto z-30 md:w-[380px] h-[60vh] md:h-[550px] max-h-[600px] bg-white/80 backdrop-blur-xl saturate-100 rounded-2xl shadow-[0_12px_40px_rgba(41,37,36,0.16)] flex flex-col overflow-hidden border border-white/60 ring-1 ring-white/30"
>
{/* Header */}
<div className="bg-white/70 text-stone-900 p-4 flex items-center justify-between border-b border-white/50">
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-liquid-mint/50 via-liquid-lavender/40 to-liquid-rose/40 flex items-center justify-center ring-1 ring-white/50 shadow-sm">
<Sparkles size={18} className="text-stone-800" />
</div>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-green-500 rounded-full border-2 border-white shadow-sm" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-bold text-sm truncate text-stone-900 tracking-tight">
Assistant
</h3>
<p className="text-[11px] font-medium text-stone-500 truncate">
Powered by AI
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={clearChat}
className="p-2 hover:bg-stone-200/40 rounded-full transition-colors text-stone-500 hover:text-red-500"
title="Clear conversation"
>
<Trash2 size={16} />
</button>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-stone-200/40 rounded-full transition-colors text-stone-500 hover:text-stone-900"
aria-label="Close chat"
>
<X size={20} />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto scrollbar-hide p-4 space-y-4 bg-transparent">
{messages.map((message) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-2xl px-4 py-3 shadow-sm ${
message.sender === "user"
? "bg-stone-900 text-white"
: "bg-white/70 text-stone-900 border border-white/60"
}`}
>
<p className={`text-sm whitespace-pre-wrap break-words leading-relaxed ${
message.sender === "user" ? "text-white/90 font-normal" : "text-stone-900 font-medium"
}`}>
{message.text}
</p>
<p
className={`text-[10px] mt-1.5 ${
message.sender === "user"
? "text-stone-400"
: "text-stone-500"
}`}
>
{message.timestamp.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</motion.div>
))}
{/* Typing Indicator */}
{isLoading && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-start"
>
<div className="bg-[#f3f1e7] border border-[#e7e5e4] rounded-2xl px-4 py-3 shadow-sm">
<div className="flex gap-1.5">
<motion.div
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -6, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0,
}}
/>
<motion.div
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -6, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0.1,
}}
/>
<motion.div
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -6, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0.2,
}}
/>
</div>
</div>
</motion.div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 bg-stone-50 border-t border-[#e7e5e4]">
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Ask anything..."
disabled={isLoading}
className="flex-1 px-4 py-3 text-sm bg-[#f5f5f4] text-[#292524] rounded-xl border border-[#e7e5e4] focus:outline-none focus:ring-2 focus:ring-[#e7e5e4] focus:border-[#a8a29e] focus:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[#78716c] transition-[border-color,box-shadow,background-color] duration-150 ease-out shadow-inner"
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isLoading}
className="p-3 bg-[#292524] text-[#fdfcf8] rounded-xl hover:bg-[#44403c] hover:shadow-lg active:scale-[0.97] transition-[background-color,box-shadow,transform] duration-150 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100 shadow-md flex items-center justify-center aspect-square"
aria-label="Send message"
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
</button>
</div>
{/* Quick Actions */}
<div className="flex gap-2 mt-3 overflow-x-auto pb-1 scrollbar-hide mask-fade-right">
{[
"Skills 🛠️",
"Projects 🚀",
"Contact 📧",
].map((suggestion, index) => (
<button
key={index}
onClick={() => {
setInputValue(suggestion.replace(/ .*/, '')); // Strip emoji for search if needed, or keep
inputRef.current?.focus();
}}
disabled={isLoading}
className="px-3 py-1.5 text-xs font-medium bg-[#f5f5f4] text-[#57534e] rounded-lg hover:bg-[#e7e5e4] hover:text-[#292524] border border-[#e7e5e4] transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97] whitespace-nowrap disabled:opacity-50 flex-shrink-0 shadow-sm"
>
{suggestion}
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
-39
View File
@@ -10,13 +10,9 @@ import dynamic from 'next/dynamic';
// Lazy-load below-fold components so their JS doesn't block initial paint / LCP.
// SSR stays on (default) so content is in the initial HTML for SEO.
const About = dynamic(() => import('./About'));
const Projects = dynamic(() => import('./Projects'));
const Contact = dynamic(() => import('./Contact'));
const Footer = dynamic(() => import('./Footer'));
import type {
AboutTranslations,
ProjectsTranslations,
ContactTranslations,
FooterTranslations,
} from '@/types/translations';
@@ -29,41 +25,6 @@ function getNormalizedLocale(locale: string): 'en' | 'de' {
return locale.startsWith('de') ? 'de' : 'en';
}
export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
const messages = {
home: {
about: baseMessages.home.about
},
about: baseMessages.about
};
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<About />
</NextIntlClientProvider>
);
}
export function ProjectsClient({ locale }: { locale: string; translations: ProjectsTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
const messages = {
home: {
projects: baseMessages.home.projects
}
};
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<Projects />
</NextIntlClientProvider>
);
}
export function ContactClient({ locale }: { locale: string; translations: ContactTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
+82 -175
View File
@@ -1,8 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
import { Send, Github, Linkedin } from "lucide-react";
import { useToast } from "@/components/Toast";
import { useLocale, useTranslations } from "next-intl";
import dynamic from "next/dynamic";
@@ -80,6 +79,7 @@ const Contact = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setTouched({ name: true, email: true, subject: true, message: true });
if (!validateForm()) {
return;
@@ -152,196 +152,103 @@ const Contact = () => {
validateForm();
};
return (
<section
id="contact"
className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950 transition-colors duration-500"
>
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
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 },
];
{/* Header Card */}
<motion.div
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="max-w-3xl">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
const pill =
"inline-flex items-center gap-2 rounded-full px-5 py-3 text-[15px] font-semibold shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-signal active:scale-[0.97]";
return (
<section id="contact" className="border-t-2 border-ink bg-signal px-4 pb-16 pt-14 text-ink sm:px-6 md:pb-24 md:pt-20 lg:px-10">
<div className="grid gap-10 lg:grid-cols-[1.1fr_1fr] lg:items-start">
<div className="min-w-0">
<p className="font-mono text-[13px]">{tInfo("locationValue")}</p>
<h2 className="mt-3 font-display text-[clamp(3rem,9vw,7.5rem)] font-extrabold leading-[0.86] tracking-[-0.05em] [font-variation-settings:'wdth'_80]">
{t("title")}
</h2>
{cmsHtml ? (
<RichTextClient html={cmsHtml} className="prose 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" />
<RichTextClient html={cmsHtml} className="prose mt-6 max-w-[46ch] text-lg leading-relaxed text-ink [&_*]:text-inherit" />
) : (
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
{t("subtitle")}
<p 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
onSubmit={handleSubmit}
noValidate
className="grid gap-4 rounded-[18px] border-2 border-ink bg-paper p-5 shadow-[8px_8px_0_rgb(var(--plakat-ink))] md:p-7"
>
<h3 className="font-display text-2xl font-extrabold tracking-[-0.03em]">{tForm("title")}</h3>
{fields.map((f) => {
const showError = touched[f.name] && errors[f.name];
const common = {
id: f.name,
name: f.name,
value: formData[f.name],
onChange: handleChange,
onBlur: handleBlur,
required: true,
placeholder: tForm(`placeholders.${f.name}`),
"aria-invalid": showError ? true : undefined,
"aria-describedby": showError ? `${f.name}-error` : undefined,
className: `w-full rounded-xl border-2 bg-paper px-4 py-3 text-base text-ink placeholder:text-ink/40 transition-[border-color,box-shadow] duration-150 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-signal ${
showError ? "border-signal" : "border-ink"
}`,
};
return (
<div key={f.name} className="grid gap-1.5">
<label htmlFor={f.name} className="font-mono text-[13px]">
{tForm(`labels.${f.name}`)}
</label>
{f.multiline ? (
<textarea {...common} rows={5} className={`${common.className} resize-none`} />
) : (
<input {...common} type={f.type} autoComplete={f.autoComplete} />
)}
{showError && (
<p id={`${f.name}-error`} className="text-sm font-medium text-signal" role="alert">
{errors[f.name]}
</p>
)}
</div>
</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
);
})}
<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"
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="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-paper/30 border-t-paper" aria-hidden="true" />
{tForm("sending")}
</>
) : (
<>
<Send size={16} />
<Send size={16} aria-hidden="true" />
{tForm("send")}
</>
)}
</motion.button>
</button>
</form>
</motion.div>
</div>
</div>
</section>
);
-178
View File
@@ -1,178 +0,0 @@
"use client";
import { motion } from "framer-motion";
import { BookOpen } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "./ui/Skeleton";
interface CurrentlyReading {
title: string;
authors: string[];
image: string | null;
progress: number;
startedAt: string | null;
}
const CurrentlyReading = () => {
const t = useTranslations("home.about.currentlyReading");
const [books, setBooks] = useState<CurrentlyReading[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Nur einmal beim Laden der Seite
const fetchCurrentlyReading = async () => {
try {
const res = await fetch("/api/n8n/hardcover/currently-reading", {
cache: "default",
});
if (!res.ok) {
throw new Error("Failed to fetch");
}
const data = await res.json();
// Handle both single book and array of books
if (data.currentlyReading) {
const booksArray = Array.isArray(data.currentlyReading)
? data.currentlyReading
: [data.currentlyReading];
setBooks(booksArray);
} else {
setBooks([]);
}
} catch (error) {
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") console.error("Error fetching currently reading:", error);
}
setBooks([]);
} finally {
setLoading(false);
}
};
fetchCurrentlyReading();
}, []); // Leeres Array = nur einmal beim Mount
// Zeige nichts wenn kein Buch gelesen wird
if (books.length === 0 && !loading) {
return null;
}
if (loading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-5 w-40" />
</div>
<div className="rounded-xl border-2 border-stone-200 dark:border-stone-700 p-6 space-y-3">
<div className="flex gap-4">
<Skeleton className="w-24 h-36 sm:w-28 sm:h-40 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-2 w-full rounded-full" />
</div>
</div>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<BookOpen size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
{t("title")} {books.length > 1 && `(${books.length})`}
</h3>
</div>
{/* Books List */}
{books.map((book, index) => (
<motion.div
key={`${book.title}-${index}`}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, delay: index * 0.06, ease: [0.23, 1, 0.32, 1] }}
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-lavender/30 dark:border-stone-700 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 dark:hover:border-stone-600 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-colors duration-300"
>
{/* Background Blob Animation */}
<motion.div
className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
animate={{
scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay: index * 0.5,
}}
/>
<div className="relative z-10 flex flex-col sm:flex-row gap-4 items-start">
{/* Book Cover */}
{book.image && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.1 + index * 0.06, ease: [0.23, 1, 0.32, 1] }}
className="flex-shrink-0"
>
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
<Image
src={book.image}
alt={book.title}
fill
className="object-cover"
sizes="(max-width: 640px) 96px, 112px"
/>
{/* Glossy Overlay */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
</div>
</motion.div>
)}
{/* Book Info */}
<div className="flex-1 min-w-0">
{/* Title */}
<h4 className="text-lg font-bold text-stone-900 dark:text-stone-100 mb-1 line-clamp-2">
{book.title}
</h4>
{/* Authors */}
<p className="text-sm text-stone-600 dark:text-stone-400 mb-4 line-clamp-1">
{book.authors.join(", ")}
</p>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-stone-600 dark:text-stone-400">
<span>{t("progress")}</span>
<span className="font-semibold">{isNaN(book.progress) ? 0 : book.progress}%</span>
</div>
<div className="relative h-2 bg-white/50 dark:bg-stone-700 rounded-full overflow-hidden border border-white/70 dark:border-stone-600">
{/* scaleX instead of width: stays on the compositor, no layout per frame */}
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: (isNaN(book.progress) ? 0 : book.progress) / 100 }}
transition={{ duration: 0.8, delay: 0.2 + index * 0.06, ease: [0.23, 1, 0.32, 1] }}
className="origin-left w-full absolute left-0 top-0 h-full bg-gradient-to-r from-liquid-lavender via-liquid-pink to-liquid-rose rounded-full shadow-sm"
/>
</div>
</div>
</div>
</div>
</motion.div>
))}
</div>
);
};
export default CurrentlyReading;
+39 -63
View File
@@ -3,80 +3,56 @@
import React from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { ArrowUp } from "lucide-react";
import { useConsent } from "./ConsentProvider";
const pill =
"inline-flex items-center rounded-full px-4 py-2 text-sm font-medium shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-paper)/0.5)] transition-[background-color,color,transform] duration-150 ease-out hover:bg-paper hover:text-ink active:scale-[0.97]";
const Footer = () => {
const locale = useLocale();
const t = useTranslations("footer");
const { resetConsent } = useConsent();
const year = new Date().getFullYear();
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<footer className="bg-stone-50 dark:bg-stone-950 pt-16 sm:pt-24 md:pt-32 pb-8 sm:pb-12 px-4 sm:px-6 overflow-hidden transition-colors duration-500">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end">
{/* Copyright & Info */}
<div className="md:col-span-4 space-y-6">
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
dk
</div>
<div className="space-y-2">
<p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("role")}</p>
<p className="text-stone-600 dark:text-stone-400 text-sm font-medium">&copy; {year} {t("madeIn")}.</p>
</div>
</div>
{/* Navigation Links */}
<div className="md:col-span-4 grid grid-cols-2 gap-8">
<div className="space-y-4">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{t("legalNotice")}</p>
<div className="flex flex-col gap-2">
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
</div>
</div>
<div className="space-y-4">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Social</p>
<div className="flex flex-col gap-2">
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">GitHub</a>
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">LinkedIn</a>
</div>
</div>
</div>
{/* Back to Top */}
<div className="md:col-span-4 flex justify-start md:justify-end">
<button
onClick={scrollToTop}
className="group flex flex-col items-center gap-4 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
<footer className="bg-ink px-4 pb-10 pt-12 text-paper sm:px-6 lg:px-10">
<div className="flex flex-wrap items-end justify-between gap-6">
<Link
href={`/${locale}`}
className="font-display text-[clamp(4rem,14vw,9rem)] font-extrabold leading-[0.8] tracking-[-0.05em]"
aria-label="dk0"
>
<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>
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>
{/* Bottom Bar */}
<div className="mt-10 sm:mt-16 md:mt-20 pt-6 sm:pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex flex-col gap-1">
<p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">
{t("builtWith")} Next.js, Directus &amp; Passion.
<nav className="mt-8 flex flex-wrap gap-2 border-t-2 border-paper/20 pt-6" aria-label={t("legalNotice")}>
<Link href={`/${locale}/legal-notice`} className={pill}>
{t("legalNotice")}
</Link>
<Link href={`/${locale}/privacy-policy`} className={pill}>
{t("privacyPolicy")}
</Link>
<button type="button" onClick={resetConsent} className={pill} title={t("privacySettingsTitle")}>
{t("privacySettings")}
</button>
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className={pill}>
GitHub
</a>
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className={pill}>
LinkedIn
</a>
</nav>
<div className="mt-8 grid gap-1.5 font-mono text-xs text-paper/70">
<p>
© {year} Dennis Konkol · {t("role")} · {t("madeIn")}
</p>
<p 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>
<p>{t("builtWith")}</p>
<p>{t("aiDisclaimer")}</p>
</div>
</footer>
);
-98
View File
@@ -1,98 +0,0 @@
"use client";
import { useState } from "react";
import { Menu, X } from "lucide-react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { usePathname } from "next/navigation";
import { ThemeToggle } from "./ThemeToggle";
const Header = () => {
const [isOpen, setIsOpen] = useState(false);
const locale = useLocale();
const pathname = usePathname();
const t = useTranslations("nav");
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
const navItems = [
{ name: t("home"), href: `/${locale}` },
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
{ name: t("projects"), href: isHome ? "#projects" : `/${locale}/projects` },
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
];
return (
<>
<div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
<nav className="animate-slide-down pointer-events-auto bg-white/70 dark:bg-stone-900/70 backdrop-blur-2xl border border-white/40 dark:border-white/5 shadow-[0_8px_32px_rgba(0,0,0,0.05)] rounded-full px-3 py-2 flex items-center gap-1 md:gap-4">
{/* Logo Pill */}
<Link
href={`/${locale}`}
className="w-10 h-10 flex items-center justify-center rounded-full bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 transition-transform duration-150 ease-out active:scale-[0.97] shadow-lg"
>
<span className="font-black text-xs tracking-tighter">dk0</span>
</Link>
{/* Desktop Menu */}
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
className="px-5 py-2 text-[10px] font-black uppercase tracking-[0.2em] text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 rounded-full transition-colors"
>
{item.name}
</Link>
))}
</div>
<div className="w-px h-4 bg-stone-200 dark:bg-white/10 mx-1 hidden md:block"></div>
{/* Actions Pill */}
<div className="flex items-center gap-1 bg-stone-100/50 dark:bg-white/5 rounded-full p-1">
<Link
href={locale === "en" ? pathname.replace(/^\/en/, "/de") : pathname.replace(/^\/de/, "/en")}
className="w-8 h-8 flex items-center justify-center text-[10px] font-black text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 rounded-full transition-colors"
>
{locale === "en" ? "DE" : "EN"}
</Link>
<ThemeToggle />
{/* Mobile Menu Toggle */}
<button
onClick={() => setIsOpen(!isOpen)}
className="w-8 h-8 flex md:hidden items-center justify-center text-stone-600 dark:text-stone-400 hover:bg-white dark:hover:bg-stone-800 rounded-full transition-[background-color,transform] duration-150 ease-out active:scale-[0.97] shadow-sm"
>
{isOpen ? <X size={14} /> : <Menu size={14} />}
</button>
</div>
</nav>
</div>
{/* Mobile Menu Overlay */}
<div
className={`fixed top-24 left-6 right-6 z-40 bg-white/90 dark:bg-stone-900/95 backdrop-blur-3xl border border-white/40 dark:border-white/10 rounded-[2.5rem] shadow-2xl p-6 md:hidden overflow-hidden origin-top transition-[opacity,transform] duration-200 ease-out ${
isOpen
? "opacity-100 translate-y-0 scale-100 pointer-events-auto"
: "opacity-0 -translate-y-2 scale-[0.97] pointer-events-none"
}`}
>
<div className="flex flex-col gap-3">
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
onClick={() => setIsOpen(false)}
className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10"
>
{item.name}
</Link>
))}
</div>
</div>
</>
);
};
export default Header;
+22 -11
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import type { NavTranslations } from "@/types/translations";
import { ThemeToggle } from "./ThemeToggle";
import OpenWindowLink from "./fenster/OpenWindowLink";
const SiGithubIcon = ({ size = 20 }: { size?: number }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
@@ -65,7 +66,8 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
const navItems = [
{ name: translations.home, href: `/${locale}` },
{ name: translations.about, href: isHome ? "#about" : `/${locale}#about` },
// „Über mich“ öffnet auf der Startseite das Fenster; ohne JS bzw. auf Unterseiten führt es zum Tisch mit dem Foto
{ name: translations.about, href: isHome ? "#projects" : `/${locale}#projects`, window: isHome ? ({ kind: "about" } as const) : undefined },
{ name: translations.projects, href: isHome ? "#projects" : `/${locale}/projects` },
{ name: translations.contact, href: isHome ? "#contact" : `/${locale}#contact` },
];
@@ -114,11 +116,17 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
</Link>
<nav className="hidden items-center gap-2 md:flex" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
{navItems.slice(1).map((item) => (
{navItems.slice(1).map((item) =>
item.window ? (
<OpenWindowLink key={item.name} href={item.href} request={item.window} className={pill}>
{item.name}
</OpenWindowLink>
) : (
<Link key={item.name} href={item.href} className={pill}>
{item.name}
</Link>
))}
),
)}
<div className="ml-2 flex items-center gap-1 border-l-2 border-ink/20 pl-3">
<Link href={deHref} className={langPill(locale === "de")} aria-current={locale === "de" ? "true" : undefined}>
DE
@@ -173,16 +181,19 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
</div>
<nav className="flex flex-col border-t-2 border-ink" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
onClick={() => setIsOpen(false)}
className="border-b-2 border-ink px-5 py-4 font-display text-3xl font-extrabold tracking-[-0.035em] transition-colors duration-150 active:bg-paper-2"
>
{navItems.map((item) => {
const cls =
"border-b-2 border-ink px-5 py-4 font-display text-3xl font-extrabold tracking-[-0.035em] transition-colors duration-150 active:bg-paper-2";
return item.window ? (
<OpenWindowLink key={item.name} href={item.href} request={item.window} onClick={() => setIsOpen(false)} className={cls}>
{item.name}
</OpenWindowLink>
) : (
<Link key={item.name} href={item.href} onClick={() => setIsOpen(false)} className={cls}>
{item.name}
</Link>
))}
);
})}
</nav>
<div className="flex items-center gap-1 px-5 py-5">
+4 -2
View File
@@ -1,5 +1,6 @@
import { getTranslations } from "next-intl/server";
import KineticWordmark from "./KineticWordmark";
import OpenWindowLink from "./fenster/OpenWindowLink";
interface HeroProps {
locale: string;
@@ -39,12 +40,13 @@ export default async function Hero({ locale }: HeroProps) {
>
{t("ctaWork")} <span aria-hidden="true">↓</span>
</a>
<a
<OpenWindowLink
href="#contact"
request={{ kind: "contact" }}
className="inline-flex items-center rounded-full px-5 py-3.5 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]"
>
{t("ctaContact")}
</a>
</OpenWindowLink>
</div>
</div>
</div>
+34 -89
View File
@@ -24,73 +24,30 @@ interface ProjectThumbnailProps {
size?: "card" | "hero";
}
// Plakatflächen statt Pastellverläufe: jede Kategorie bekommt eine feste Ton-Kombination
// aus den Plakat-Tokens. Tinte/Papier tauschen im Dark Mode von selbst die Rollen.
const tones = {
signal: "bg-signal text-ink",
ink: "bg-ink text-paper",
paper: "bg-paper-2 text-ink",
ok: "bg-ok text-ink",
} as const;
const categoryThemes: Record<
string,
{
icon: LucideIcon;
gradient: string;
darkGradient: string;
iconColor: string;
darkIconColor: string;
tone: keyof typeof tones;
pattern: "dots" | "grid" | "diagonal" | "circuit" | "waves" | "terminal";
}
> = {
"Web Development": {
icon: Code,
gradient: "from-liquid-sky/20 via-liquid-blue/10 to-liquid-lavender/20",
darkGradient: "dark:from-liquid-sky/10 dark:via-liquid-blue/5 dark:to-liquid-lavender/10",
iconColor: "text-blue-500",
darkIconColor: "dark:text-blue-400",
pattern: "circuit",
},
"Mobile Development": {
icon: Smartphone,
gradient: "from-liquid-mint/20 via-liquid-teal/10 to-liquid-sky/20",
darkGradient: "dark:from-liquid-mint/10 dark:via-liquid-teal/5 dark:to-liquid-sky/10",
iconColor: "text-emerald-500",
darkIconColor: "dark:text-emerald-400",
pattern: "waves",
},
"Web Application": {
icon: Globe,
gradient: "from-liquid-lavender/20 via-liquid-purple/10 to-liquid-pink/20",
darkGradient: "dark:from-liquid-lavender/10 dark:via-liquid-purple/5 dark:to-liquid-pink/10",
iconColor: "text-violet-500",
darkIconColor: "dark:text-violet-400",
pattern: "dots",
},
"Backend Development": {
icon: Cpu,
gradient: "from-liquid-amber/20 via-liquid-yellow/10 to-liquid-peach/20",
darkGradient: "dark:from-liquid-amber/10 dark:via-liquid-yellow/5 dark:to-liquid-peach/10",
iconColor: "text-amber-500",
darkIconColor: "dark:text-amber-400",
pattern: "grid",
},
"Full-Stack Development": {
icon: Boxes,
gradient: "from-liquid-teal/20 via-liquid-mint/10 to-liquid-lavender/20",
darkGradient: "dark:from-liquid-teal/10 dark:via-liquid-mint/5 dark:to-liquid-lavender/10",
iconColor: "text-teal-500",
darkIconColor: "dark:text-teal-400",
pattern: "grid",
},
DevOps: {
icon: Shield,
gradient: "from-liquid-coral/20 via-liquid-rose/10 to-liquid-peach/20",
darkGradient: "dark:from-liquid-coral/10 dark:via-liquid-rose/5 dark:to-liquid-peach/10",
iconColor: "text-red-500",
darkIconColor: "dark:text-red-400",
pattern: "diagonal",
},
default: {
icon: Wrench,
gradient: "from-liquid-peach/20 via-liquid-rose/10 to-liquid-lavender/20",
darkGradient: "dark:from-liquid-peach/10 dark:via-liquid-rose/5 dark:to-liquid-lavender/10",
iconColor: "text-stone-400",
darkIconColor: "dark:text-stone-500",
pattern: "dots",
},
"Web Development": { icon: Code, tone: "signal", pattern: "circuit" },
"Mobile Development": { icon: Smartphone, tone: "ink", pattern: "waves" },
"Web Application": { icon: Globe, tone: "paper", pattern: "dots" },
"Backend Development": { icon: Cpu, tone: "ink", pattern: "grid" },
"Full-Stack Development": { icon: Boxes, tone: "signal", pattern: "grid" },
DevOps: { icon: Shield, tone: "ok", pattern: "diagonal" },
default: { icon: Wrench, tone: "paper", pattern: "dots" },
};
const slugIcons: Record<string, LucideIcon> = {
@@ -106,7 +63,7 @@ const slugIcons: Record<string, LucideIcon> = {
function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
const patterns: Record<string, React.ReactNode> = {
dots: (
<svg className="absolute inset-0 w-full h-full opacity-[0.07] dark:opacity-[0.05]" xmlns="http://www.w3.org/2000/svg">
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`pat-dots-${id}`} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="1.5" fill="currentColor" />
@@ -116,7 +73,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg>
),
grid: (
<svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" xmlns="http://www.w3.org/2000/svg">
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`pat-grid-${id}`} x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="currentColor" strokeWidth="1" />
@@ -126,7 +83,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg>
),
diagonal: (
<svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" xmlns="http://www.w3.org/2000/svg">
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`pat-diag-${id}`} x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
<line x1="0" y1="24" x2="24" y2="0" stroke="currentColor" strokeWidth="1" />
@@ -136,7 +93,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg>
),
circuit: (
<svg className="absolute inset-0 w-full h-full opacity-[0.07] dark:opacity-[0.05]" xmlns="http://www.w3.org/2000/svg">
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`pat-circ-${id}`} x="0" y="0" width="60" height="60" patternUnits="userSpaceOnUse">
<path d="M0 30h20m20 0h20M30 0v20m0 20v20" stroke="currentColor" strokeWidth="0.8" fill="none" />
@@ -151,7 +108,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg>
),
waves: (
<svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" xmlns="http://www.w3.org/2000/svg">
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`pat-wave-${id}`} x="0" y="0" width="100" height="20" patternUnits="userSpaceOnUse">
<path d="M0 10 Q25 0 50 10 T100 10" fill="none" stroke="currentColor" strokeWidth="1" />
@@ -161,7 +118,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg>
),
terminal: (
<svg className="absolute inset-0 w-full h-full opacity-[0.08] dark:opacity-[0.06]" xmlns="http://www.w3.org/2000/svg">
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`pat-term-${id}`} x="0" y="0" width="200" height="30" patternUnits="userSpaceOnUse">
<text x="4" y="18" fontFamily="monospace" fontSize="10" fill="currentColor">$_</text>
@@ -197,40 +154,28 @@ export default function ProjectThumbnail({
const Icon = theme.icon;
const isHero = size === "hero";
const displayTags = tags?.slice(0, 3) ?? [];
const displayTags = tags?.slice(0, isHero ? 5 : 3) ?? [];
return (
<div
className={`absolute inset-0 flex items-center justify-center bg-gradient-to-br ${theme.gradient} ${theme.darkGradient}`}
>
<div className={`absolute inset-0 flex flex-col justify-between ${tones[theme.tone]} ${isHero ? "p-5 sm:p-8" : "p-4 sm:p-5"}`}>
<PatternOverlay pattern={theme.pattern} id={uniqueId} />
<div className="relative z-10 flex flex-col items-center gap-3 sm:gap-4">
<div
className={`flex items-center justify-center rounded-2xl bg-white/60 dark:bg-white/10 backdrop-blur-sm border border-white/40 dark:border-white/10 ${theme.iconColor} ${theme.darkIconColor} ${isHero ? "w-20 h-20 sm:w-28 sm:h-28" : "w-14 h-14 sm:w-20 sm:h-20"}`}
<div className="relative z-10 flex items-start justify-between gap-3">
<span className="font-mono text-xs leading-snug sm:text-[13px]">{displayTags.join(" · ")}</span>
<span
className={`flex shrink-0 items-center justify-center rounded-full shadow-[inset_0_0_0_2px_currentColor] ${isHero ? "h-14 w-14 sm:h-16 sm:w-16" : "h-10 w-10"}`}
>
<Icon className={isHero ? "w-10 h-10 sm:w-14 sm:h-14" : "w-7 h-7 sm:w-10 sm:h-10"} strokeWidth={1.5} />
<Icon className={isHero ? "h-7 w-7 sm:h-8 sm:w-8" : "h-5 w-5"} strokeWidth={1.75} />
</span>
</div>
<span
className={`font-black tracking-tighter uppercase ${isHero ? "text-2xl sm:text-4xl md:text-5xl" : "text-sm sm:text-lg"} text-stone-400/80 dark:text-stone-500/80`}
className={`relative z-10 font-display font-extrabold leading-[0.86] tracking-[-0.045em] [overflow-wrap:anywhere] [font-variation-settings:'wdth'_78] ${
isHero ? "text-[clamp(2.6rem,8vw,7rem)]" : "text-[clamp(1.8rem,4vw,2.8rem)]"
}`}
>
{title}
</span>
{displayTags.length > 0 && (
<div className={`flex flex-wrap justify-center gap-1.5 sm:gap-2 ${isHero ? "max-w-md" : "max-w-[200px]"}`}>
{displayTags.map((tag) => (
<span
key={tag}
className={`px-2 py-0.5 rounded-full bg-white/50 dark:bg-white/5 backdrop-blur-sm border border-white/30 dark:border-white/10 text-stone-500 dark:text-stone-400 font-medium ${isHero ? "text-xs sm:text-sm" : "text-[9px] sm:text-[10px]"}`}
>
{tag}
</span>
))}
</div>
)}
</div>
</div>
);
}
-140
View File
@@ -1,140 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useLocale, useTranslations } from "next-intl";
import { Skeleton } from "./ui/Skeleton";
import ProjectThumbnail from "./ProjectThumbnail";
interface Project {
id: number;
slug: string;
title: string;
description: string;
content: string;
tags: string[];
featured: boolean;
category: string;
date: string;
github?: string;
live?: string;
imageUrl?: string;
}
const Projects = () => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const locale = useLocale();
const t = useTranslations("home.projects");
useEffect(() => {
const loadProjects = async () => {
try {
const response = await fetch("/api/projects?featured=true&published=true&limit=6");
if (response.ok) {
const data = await response.json();
setProjects(data.projects || []);
}
} catch (error) {
if (process.env.NODE_ENV === "development") console.error("Featured projects fetch failed:", error);
} finally {
setLoading(false);
}
};
loadProjects();
}, []);
return (
<section id="projects" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 sm:mb-12 md:mb-16 gap-4 sm:gap-6">
<div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-2 sm:mb-4 uppercase">
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
</h2>
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
{t("subtitle")}
</p>
</div>
<Link href={`/${locale}/projects`} className="group flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-opacity text-xs uppercase tracking-widest">
{t("viewAll")} <ArrowUpRight className="group-hover:-translate-y-0.5 group-hover:translate-x-0.5 transition-transform duration-200 ease-out" size={14} />
</Link>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-4">
<Skeleton className="aspect-[16/10] sm:aspect-[4/3] rounded-2xl sm:rounded-3xl" />
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
{projects.length === 0 ? (
<div className="col-span-2 py-12 text-center text-stone-400 dark:text-stone-600 text-sm">
{t("noProjects")}
</div>
) : (
projects.map((project) => (
<motion.div
key={project.id}
className="group relative"
>
<Link href={`/${locale}/projects/${project.slug}`} className="block">
{/* Image Card */}
<div className="relative aspect-[16/10] sm:aspect-[4/3] rounded-2xl sm:rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-4 sm:mb-6">
{project.imageUrl ? (
<Image
src={project.imageUrl}
alt={project.title}
fill
className="object-cover transition-transform duration-500 ease-out group-hover:scale-[1.03]"
/>
) : (
<ProjectThumbnail
title={project.title}
category={project.category}
tags={project.tags}
slug={project.slug}
size="card"
/>
)}
{/* Overlay on Hover */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300" />
</div>
{/* Text Content */}
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg sm:text-xl md:text-2xl font-bold text-stone-900 dark:text-stone-100 mb-1 sm:mb-2 group-hover:underline decoration-2 underline-offset-4">
{project.title}
</h3>
<p className="text-sm sm:text-base text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
{project.description}
</p>
</div>
<div className="hidden sm:flex gap-2">
{project.tags.slice(0, 2).map(tag => (
<span key={tag} className="px-3 py-1 rounded-full border border-stone-200 dark:border-stone-800 text-xs font-medium text-stone-600 dark:text-stone-400">
{tag}
</span>
))}
</div>
</div>
</Link>
</motion.div>
)))}
</div>
)}
</div>
</section>
);
};
export default Projects;
-267
View File
@@ -1,267 +0,0 @@
"use client";
import { motion } from "framer-motion";
import { BookCheck, Star, ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "./ui/Skeleton";
interface BookReview {
id: string;
hardcover_id?: string;
book_title: string;
book_author: string;
book_image?: string;
rating?: number | null;
review?: string | null;
finished_at?: string;
}
const StarRating = ({ rating }: { rating: number }) => {
return (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
size={14}
className={
star <= rating
? "text-amber-500 fill-amber-500"
: "text-stone-300 dark:text-stone-600"
}
/>
))}
</div>
);
};
const stripHtml = (html: string) => {
if (typeof window === 'undefined') return html; // Fallback for SSR
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || "";
};
const ReadBooks = () => {
const locale = useLocale();
const t = useTranslations("home.about.readBooks");
const [reviews, setReviews] = useState<BookReview[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set());
const INITIAL_SHOW = 3;
useEffect(() => {
const fetchReviews = async () => {
try {
const res = await fetch(
`/api/book-reviews?locale=${encodeURIComponent(locale)}`,
{ cache: "default" }
);
if (!res.ok) {
throw new Error("Failed to fetch");
}
const data = await res.json();
if (data.bookReviews) {
setReviews(data.bookReviews);
} else {
setReviews([]);
}
} catch (error) {
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") console.error("Error fetching book reviews:", error);
}
setReviews([]);
} finally {
setLoading(false);
}
};
fetchReviews();
}, [locale]);
if (reviews.length === 0 && !loading) {
return (
<div className="flex items-center gap-3 py-4 text-sm text-stone-400 dark:text-stone-500">
<BookCheck size={16} className="shrink-0" />
<span>{t("empty")}</span>
</div>
);
}
const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);
const hasMore = reviews.length > INITIAL_SHOW;
if (loading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-5 w-40" />
</div>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border-2 border-stone-200 dark:border-stone-700 p-5 space-y-3">
<div className="flex gap-4">
<Skeleton className="w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-full" />
</div>
</div>
</div>
))}
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<BookCheck size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
{t("title")} ({reviews.length})
</h3>
</div>
{/* Book Reviews */}
{visibleReviews.map((review, index) => (
<motion.div
key={review.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{
duration: 0.5,
delay: index * 0.06,
ease: [0.23, 1, 0.32, 1],
}}
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-teal/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-mint/30 dark:border-stone-700 rounded-xl p-5 backdrop-blur-sm hover:border-liquid-mint/50 dark:hover:border-stone-600 hover:from-liquid-mint/20 hover:via-liquid-sky/15 hover:to-liquid-teal/20 transition-colors duration-300"
>
{/* Background Blob */}
<motion.div
className="absolute -bottom-8 -left-8 w-28 h-28 bg-gradient-to-br from-liquid-mint/20 to-liquid-sky/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
animate={{
scale: [1, 1.15, 1],
opacity: [0.3, 0.45, 0.3],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay: index * 0.5,
}}
/>
<div className="relative z-10 flex flex-col sm:flex-row gap-4 items-start">
{/* Book Cover */}
{review.book_image && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.1 + index * 0.06, ease: [0.23, 1, 0.32, 1] }}
className="flex-shrink-0"
>
<div className="relative w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
<Image
src={review.book_image}
alt={review.book_title}
fill
className="object-cover"
sizes="(max-width: 640px) 80px, 96px"
/>
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
</div>
</motion.div>
)}
{/* Book Info */}
<div className="flex-1 min-w-0">
<h4 className="text-base font-bold text-stone-900 dark:text-stone-100 mb-0.5 line-clamp-2">
{review.book_title}
</h4>
<p className="text-sm text-stone-600 dark:text-stone-400 mb-2 line-clamp-1">
{review.book_author}
</p>
{/* Rating (Optional) */}
{review.rating && review.rating > 0 && (
<div className="flex items-center gap-2 mb-2">
<StarRating rating={review.rating} />
<span className="text-xs text-stone-500 dark:text-stone-400 font-medium">
{review.rating}/5
</span>
</div>
)}
{/* Review Text (Optional) */}
{review.review && (
<div className="relative">
<p className={`text-sm text-stone-700 dark:text-stone-300 leading-relaxed italic ${!expandedReviews.has(review.id) ? 'line-clamp-3' : ''}`}>
&ldquo;{stripHtml(review.review)}&rdquo;
</p>
{stripHtml(review.review).length > 100 && (
<button
onClick={(e) => {
e.stopPropagation();
setExpandedReviews(prev => {
const next = new Set(prev);
if (next.has(review.id)) next.delete(review.id);
else next.add(review.id);
return next;
});
}}
className="text-xs text-liquid-sky hover:text-liquid-mint dark:text-liquid-sky dark:hover:text-liquid-mint font-medium mt-1 transition-colors"
>
{expandedReviews.has(review.id) ? t("collapseReview") : t("readMore")}
</button>
)}
</div>
)}
{/* Finished Date */}
{review.finished_at && (
<p className="text-xs text-stone-400 dark:text-stone-500 mt-2">
{t("finishedAt")}{" "}
{new Date(review.finished_at).toLocaleDateString(
locale === "de" ? "de-DE" : "en-US",
{ year: "numeric", month: "short" }
)}
</p>
)}
</div>
</div>
</motion.div>
))}
{/* Show More / Show Less */}
{hasMore && (
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-center gap-1.5 py-2.5 text-sm font-medium text-stone-600 dark:text-stone-400 hover:text-stone-800 dark:hover:text-stone-200 rounded-lg border-2 border-dashed border-stone-200 dark:border-stone-700 hover:border-stone-300 dark:hover:border-stone-600 transition-colors duration-300"
>
{expanded ? (
<>
{t("showLess")} <ChevronUp size={16} />
</>
) : (
<>
{t("showMore", { count: reviews.length - INITIAL_SHOW })}{" "}
<ChevronDown size={16} />
</>
)}
</motion.button>
)}
</div>
);
};
export default ReadBooks;
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useConsent } from "../ConsentProvider";
import type { WindowLabels } from "./types";
interface Message {
id: string;
text: string;
from: "user" | "bot";
}
const STORE_ID = "chatSessionId";
const STORE_MESSAGES = "chatMessages";
/**
* KI-Chat im Fenster. Schickt erst etwas an n8n, wenn der Chatbot im
* Datenschutz-Banner (oder hier) erlaubt wurde.
*/
export default function ChatBody({ labels }: { labels: WindowLabels }) {
const { consent, ready, setConsent } = useConsent();
const allowed = !!consent?.chat;
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [conversationId, setConversationId] = useState("default");
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!allowed) return;
try {
let id = localStorage.getItem(STORE_ID);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(STORE_ID, id);
}
setConversationId(id);
const stored = localStorage.getItem(STORE_MESSAGES);
const parsed = stored ? (JSON.parse(stored) as { id: string; text: string; sender?: string; from?: string }[]) : [];
setMessages(
parsed.length
? parsed.map((m) => ({
id: m.id,
// Begrüßung immer in der aktuellen Sprache, auch wenn noch eine alte gespeichert ist
text: m.id === "welcome" ? labels.chat.welcome : m.text,
from: (m.from ?? m.sender) === "user" ? "user" : "bot",
}))
: [{ id: "welcome", text: labels.chat.welcome, from: "bot" }],
);
} catch {
setMessages([{ id: "welcome", text: labels.chat.welcome, from: "bot" }]);
}
inputRef.current?.focus({ preventScroll: true });
}, [allowed, labels.chat.welcome]);
useEffect(() => {
if (!allowed || messages.length === 0) return;
try {
localStorage.setItem(STORE_MESSAGES, JSON.stringify(messages));
} catch {}
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [messages, allowed]);
const send = async () => {
const text = input.trim();
if (!text || busy) return;
const mine: Message = { id: String(Date.now()), text, from: "user" };
const history = messages.slice(-5).map((m) => ({ role: m.from === "user" ? "user" : "assistant", content: m.text }));
setMessages((prev) => [...prev, mine]);
setInput("");
setBusy(true);
try {
const res = await fetch("/api/n8n/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, conversationId, history }),
});
const data = await res.json();
setMessages((prev) => [...prev, { id: `${Date.now()}-bot`, text: data.reply || labels.chat.error, from: "bot" }]);
} catch {
setMessages((prev) => [...prev, { id: `${Date.now()}-bot`, text: labels.chat.error, from: "bot" }]);
} finally {
setBusy(false);
}
};
if (!ready) return <div className="h-24 animate-pulse rounded-xl bg-paper-2" aria-hidden="true" />;
if (!allowed) {
return (
<>
<p className="text-[15px] leading-relaxed">{labels.chat.consent}</p>
<button
type="button"
onClick={() => setConsent({ ...(consent ?? { chat: false }), chat: true })}
className="w-fit rounded-full bg-ink px-4 py-2.5 text-sm font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]"
>
{labels.chat.allow}
</button>
</>
);
}
return (
<div className="flex min-h-[320px] flex-col gap-3">
<div ref={listRef} className="flex max-h-[46vh] flex-1 flex-col gap-2.5 overflow-y-auto overscroll-contain pr-1" aria-live="polite">
{messages.map((m) => (
<div
key={m.id}
className={`max-w-[88%] rounded-2xl border-2 border-ink px-3.5 py-2 text-[14.5px] leading-snug ${
m.from === "user" ? "self-end rounded-br-md bg-ink text-paper" : "self-start rounded-bl-md bg-paper"
}`}
>
{m.text}
</div>
))}
{busy && (
<div className="self-start rounded-2xl rounded-bl-md border-2 border-ink px-3.5 py-2 font-mono text-xs" aria-label={labels.chat.thinking}>
<span className="inline-block animate-pulse">…</span>
</div>
)}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
send();
}}
>
<label htmlFor="chat-input" className="sr-only">
{labels.chat.placeholder}
</label>
<input
ref={inputRef}
id="chat-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={labels.chat.placeholder}
autoComplete="off"
className="min-w-0 flex-1 rounded-full border-2 border-ink bg-paper px-4 py-2.5 text-base text-ink placeholder:text-ink/45 focus:outline-none focus-visible:ring-2 focus-visible:ring-signal"
/>
<button
type="submit"
disabled={!input.trim() || busy}
className="rounded-full bg-ink px-4 text-sm font-semibold text-paper transition-[opacity,transform] duration-150 ease-out active:scale-[0.97] disabled:opacity-40"
>
{labels.chat.send}
</button>
</form>
<p className="font-mono text-[11.5px] leading-snug opacity-70">{labels.chat.note}</p>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useOpenWindow } from "./WindowProvider";
import type { WindowRequest } from "./types";
interface OpenWindowLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
request: WindowRequest;
}
/**
* Ein normaler Link (funktioniert ohne JavaScript), der mit JavaScript stattdessen
* ein Fenster bzw. Sheet öffnet.
*/
export default function OpenWindowLink({ request, onClick, ...props }: OpenWindowLinkProps) {
const openWindow = useOpenWindow();
return (
<a
{...props}
aria-haspopup={openWindow ? "dialog" : undefined}
onClick={(e) => {
onClick?.(e);
if (!openWindow || e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey) return;
e.preventDefault();
openWindow(request, e.currentTarget);
}}
/>
);
}
+253
View File
@@ -0,0 +1,253 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { loadCurrentBook, type CurrentBook } from "../tisch/currentBook";
import ChatBody from "./ChatBody";
import type { WindowLabels, WindowRequest } from "./types";
const EMAIL = "contact@dk0.dev";
const btn =
"inline-flex items-center gap-2 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]";
const ghost =
"inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-sm 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-paper active:scale-[0.97]";
/** Titelzeile eines Fensters, auch für die Ablage-Leiste. */
export function windowTitle(req: WindowRequest, labels: WindowLabels): string {
switch (req.kind) {
case "project":
return req.project.year ? `${req.project.title} · ${req.project.year}` : req.project.title;
case "now":
return labels.now.title;
case "server":
return labels.server.title;
case "read":
return labels.read.title;
case "about":
return labels.about.title;
case "contact":
return labels.contact.title;
case "chat":
return labels.chat.title;
}
}
/** Farbe des Punkts in der Ablage-Leiste */
export function windowAccent(req: WindowRequest): string {
if (req.kind === "project") return req.project.color;
return { now: "rgb(var(--plakat-red))", server: "rgb(var(--plakat-ok))", read: "#7a5240", about: "#2f5bea", contact: "rgb(var(--plakat-red))", chat: "#1d1b3d" }[req.kind];
}
interface WindowContentProps {
req: WindowRequest;
labels: WindowLabels;
/** Schließt das Fenster, z. B. wenn ein Link auf der Seite weiterführt */
onClose: () => void;
}
export default function WindowContent({ req, labels, onClose }: WindowContentProps) {
switch (req.kind) {
case "project": {
const p = req.project;
return (
<>
<div
className="-mx-5 -mt-5 flex min-h-[150px] flex-col justify-between border-b-2 border-ink p-5"
style={{ background: p.color, color: p.textColor }}
>
<span className="font-mono text-[12.5px]">{p.tags.slice(0, 4).join(" · ")}</span>
<span className="font-display text-[clamp(2.4rem,6vw,3.4rem)] font-extrabold leading-[0.86] tracking-[-0.045em] [font-variation-settings:'wdth'_78]">
{p.title}
</span>
</div>
<p className="text-[15px] leading-relaxed">{p.description}</p>
<dl className="border-t-2 border-ink">
{p.year && <Row label={labels.year}>{p.year}</Row>}
{p.tags.length > 0 && <Row label={labels.stack}>{p.tags.join(", ")}</Row>}
</dl>
<div className="flex flex-wrap gap-2">
<Link href={p.href} className={btn}>
{labels.projectPage} <span aria-hidden="true">→</span>
</Link>
{p.liveUrl && (
<a href={p.liveUrl} target="_blank" rel="noopener noreferrer" className={ghost}>
{labels.live} <span aria-hidden="true">↗</span>
</a>
)}
{p.githubUrl && (
<a href={p.githubUrl} target="_blank" rel="noopener noreferrer" className={ghost}>
{labels.source} <span aria-hidden="true">↗</span>
</a>
)}
</div>
</>
);
}
case "now":
return <NowBody labels={labels} />;
case "server":
return (
<>
<p className="font-display text-[1.8rem] font-extrabold leading-none tracking-[-0.03em]">{labels.server.headline}</p>
<div>
{labels.server.rows.map((r) => (
<div key={r.name} className="grid grid-cols-[10px_1fr] items-center gap-3 border-b border-ink/20 py-2.5 text-[15px]">
<span className="h-2.5 w-2.5 rounded-full bg-ok shadow-[0_0_0_3px_rgb(var(--plakat-ok)/0.2)]" aria-hidden="true" />
<span>
{r.name}
<span className="block font-mono text-xs opacity-75">{r.detail}</span>
</span>
</div>
))}
</div>
<p className="font-mono text-[12.5px] leading-snug">{labels.server.note}</p>
</>
);
case "read":
return <ReadBody labels={labels} />;
case "about":
return (
<>
{labels.about.paragraphs.map((text) => (
<p key={text} className="text-[15px] leading-relaxed">
{text}
</p>
))}
<dl className="border-t-2 border-ink">
{labels.about.facts.map((f) => (
<Row key={f.label} label={f.label}>
{f.value}
</Row>
))}
</dl>
</>
);
case "contact":
return <ContactBody labels={labels} onClose={onClose} />;
case "chat":
return <ChatBody labels={labels} />;
}
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[110px_minmax(0,1fr)] gap-3 border-b border-ink/20 py-2.5">
<dt className="font-mono text-[12.5px] leading-6">{label}</dt>
<dd className="m-0 text-[15px]">{children}</dd>
</div>
);
}
// Abgabe der Masterarbeit; auf dem Client gerechnet, damit das Fenster nie einen veralteten Stand zeigt
const THESIS_DEADLINE = new Date("2027-02-15T12:00:00+01:00");
function NowBody({ labels }: { labels: WindowLabels }) {
const days = Math.max(0, Math.ceil((THESIS_DEADLINE.getTime() - Date.now()) / 86_400_000));
return (
<>
<div>
<div className="font-display text-[5rem] font-extrabold leading-[0.82] tracking-[-0.05em] text-signal [font-variant-numeric:tabular-nums]">
{days}
</div>
<p className="mt-1.5 font-semibold">{labels.now.days}</p>
</div>
<dl className="border-t-2 border-ink">
{labels.now.rows.map((r) => (
<Row key={r.label} label={r.label}>
{r.text}
</Row>
))}
</dl>
</>
);
}
function ReadBody({ labels }: { labels: WindowLabels }) {
const [book, setBook] = useState<CurrentBook | null | undefined>(undefined);
useEffect(() => {
let alive = true;
loadCurrentBook().then((b) => alive && setBook(b));
return () => {
alive = false;
};
}, []);
const progress = book && Number.isFinite(book.progress) ? Math.max(0, Math.min(100, book.progress)) : 0;
return (
<>
{book === undefined ? (
<div className="grid grid-cols-[72px_1fr] gap-4" aria-hidden="true">
<div className="h-[104px] animate-pulse rounded bg-paper-2" />
<div className="space-y-2 pt-1">
<div className="h-6 w-4/5 animate-pulse rounded bg-paper-2" />
<div className="h-4 w-1/2 animate-pulse rounded bg-paper-2" />
</div>
</div>
) : book ? (
<div className="grid grid-cols-[72px_1fr] items-center gap-4">
<div className="relative h-[104px] overflow-hidden rounded-[3px_6px_6px_3px] border-2 border-ink bg-bookcover">
{book.image && <Image src={book.image} alt="" fill sizes="72px" className="object-cover" />}
</div>
<div>
<div className="font-display text-xl font-bold leading-tight">{book.title}</div>
<div className="font-mono text-xs">{book.authors.join(", ")}</div>
<div className="mt-2.5 font-mono text-xs">
{progress} % {labels.read.progress}
</div>
<div className="mt-1.5 h-1 overflow-hidden rounded bg-paper-2">
<div className="h-full origin-left bg-ink" style={{ transform: `scaleX(${progress / 100})` }} />
</div>
</div>
</div>
) : (
<p>{labels.read.empty}</p>
)}
<Link href={labels.read.libraryHref} className={`${ghost} w-fit`}>
{labels.read.library} <span aria-hidden="true">→</span>
</Link>
</>
);
}
function ContactBody({ labels, onClose }: { labels: WindowLabels; onClose: () => void }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(EMAIL);
setCopied(true);
setTimeout(() => setCopied(false), 1600);
} catch {
window.location.href = `mailto:${EMAIL}`;
}
};
return (
<>
<div className="font-display text-[clamp(1.8rem,5vw,2.4rem)] font-extrabold leading-[0.95] tracking-[-0.04em] [overflow-wrap:anywhere]">
contact<span className="text-signal">@</span>dk0.dev
</div>
<p className="text-[15px] leading-relaxed">{labels.contact.body}</p>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={copy} className={btn} aria-live="polite">
{copied ? labels.contact.copied : labels.contact.copy}
</button>
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className={ghost}>
GitHub
</a>
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className={ghost}>
LinkedIn
</a>
<a href="#contact" className={ghost} onClick={onClose}>
{labels.contact.form}
</a>
</div>
</>
);
}
+276
View File
@@ -0,0 +1,276 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState, type RefObject } from "react";
import { AnimatePresence, motion, useDragControls } from "framer-motion";
import { Drawer } from "vaul";
import WindowContent, { windowAccent, windowTitle } from "./WindowContent";
import { windowId, type WindowLabels, type WindowRequest } from "./types";
type OpenWindow = (req: WindowRequest, trigger?: HTMLElement | null) => void;
const WindowContext = createContext<OpenWindow | null>(null);
/** Öffnet ein Fenster (Desktop) oder Sheet (Handy). Ohne Provider passiert nichts. */
export function useOpenWindow(): OpenWindow | null {
return useContext(WindowContext);
}
interface WinState {
id: string;
req: WindowRequest;
x: number;
y: number;
z: number;
minimized: boolean;
/** Punkt, von dem aus das Fenster aufgeht (relativ zum Fenster) */
origin: { x: number; y: number };
trigger: HTMLElement | null;
}
const WIN_WIDTH = 440;
export default function WindowProvider({ labels, children }: { labels: WindowLabels; children: React.ReactNode }) {
const [wins, setWins] = useState<WinState[]>([]);
const [sheet, setSheet] = useState<{ req: WindowRequest; trigger: HTMLElement | null } | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const layerRef = useRef<HTMLDivElement>(null);
const zTop = useRef(10);
const opened = useRef(0);
const open = useCallback<OpenWindow>((req, trigger = null) => {
const desktop = window.matchMedia?.("(min-width: 768px)")?.matches ?? true;
if (!desktop) {
setSheet({ req, trigger });
setSheetOpen(true);
return;
}
const id = windowId(req);
setWins((prev) => {
const existing = prev.find((w) => w.id === id);
if (existing) {
return prev.map((w) => (w.id === id ? { ...w, minimized: false, z: ++zTop.current } : w));
}
const W = window.innerWidth;
const H = window.innerHeight;
const width = Math.min(WIN_WIDTH, W - 32);
const step = opened.current++ % 5;
const x = Math.max(16, Math.min(W - width - 16, W / 2 - width / 2 + step * 34 - 60));
const y = Math.max(76, Math.min(H - 420, H * 0.16 + step * 30));
const r = trigger?.getBoundingClientRect();
const origin = r ? { x: r.left + r.width / 2 - x, y: r.top + r.height / 2 - y } : { x: width / 2, y: 0 };
return [...prev, { id, req, x, y, z: ++zTop.current, minimized: false, origin, trigger }];
});
}, []);
const close = useCallback((id: string) => {
setWins((prev) => {
const w = prev.find((x) => x.id === id);
// Fokus zurück dorthin, wo das Fenster herkam
if (w?.trigger?.isConnected) requestAnimationFrame(() => w.trigger?.focus({ preventScroll: true }));
return prev.filter((x) => x.id !== id);
});
}, []);
const minimize = useCallback((id: string) => {
setWins((prev) => prev.map((w) => (w.id === id ? { ...w, minimized: true } : w)));
}, []);
const focusWin = useCallback((id: string) => {
setWins((prev) => {
const top = prev.reduce((a, b) => (b.z > a.z ? b : a), prev[0]);
// Schon vorn und sichtbar: nichts zu tun (ein abgelegtes Fenster muss trotzdem zurück)
if (top?.id === id && !top.minimized) return prev;
return prev.map((w) => (w.id === id ? { ...w, z: ++zTop.current, minimized: false } : w));
});
}, []);
// Escape schließt das oberste offene Fenster
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
const visible = wins.filter((w) => !w.minimized);
if (!visible.length) return;
const top = visible.reduce((a, b) => (b.z > a.z ? b : a));
close(top.id);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [wins, close]);
return (
<WindowContext.Provider value={open}>
{children}
{/* Desktop: Fenster schweben über der Seite, die Seite bleibt bedienbar */}
<div ref={layerRef} className="pointer-events-none fixed inset-0 z-[60]" aria-live="off">
<AnimatePresence>
{wins
.filter((w) => !w.minimized)
.map((w) => (
<Window
key={w.id}
win={w}
labels={labels}
layerRef={layerRef}
onClose={() => close(w.id)}
onMinimize={() => minimize(w.id)}
onFocus={() => focusWin(w.id)}
/>
))}
</AnimatePresence>
</div>
<AnimatePresence>
{wins.length > 0 && (
<motion.nav
aria-label={labels.openWindows}
className="fixed bottom-[18px] left-1/2 z-[61] hidden max-w-[calc(100vw-32px)] gap-1.5 overflow-x-auto rounded-full bg-ink p-1.5 md:flex"
style={{ x: "-50%" }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
>
{wins.map((w) => (
<button
key={w.id}
type="button"
onClick={() => focusWin(w.id)}
className={`inline-flex items-center gap-2 whitespace-nowrap rounded-full px-3.5 py-2 text-[13px] font-medium transition-[background-color,transform] duration-150 ease-out hover:bg-paper/15 active:scale-[0.97] ${
w.minimized ? "text-paper/60" : "text-paper"
}`}
>
<span className="h-2 w-2 rounded-full border border-paper/40" style={{ background: windowAccent(w.req) }} aria-hidden="true" />
{windowTitle(w.req, labels).split(" · ")[0]}
</button>
))}
</motion.nav>
)}
</AnimatePresence>
{/* Handy: ein Sheet von unten, nach unten ziehen schließt */}
<Drawer.Root
open={sheetOpen}
onOpenChange={(o) => {
setSheetOpen(o);
if (!o && sheet?.trigger?.isConnected) requestAnimationFrame(() => sheet.trigger?.focus({ preventScroll: true }));
}}
>
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 z-[70] bg-ink/40" />
<Drawer.Content
className="fixed inset-x-0 bottom-0 z-[71] flex max-h-[88svh] flex-col rounded-t-[22px] border-2 border-b-0 border-ink bg-paper text-ink outline-none"
aria-describedby={undefined}
>
<Drawer.Handle className="!mt-2.5 !h-[5px] !w-11 !bg-ink/30" />
{sheet && (
<>
<div className="flex items-center gap-3 border-b-2 border-ink px-5 pb-3 pt-2">
<Drawer.Title className="flex-1 truncate font-mono text-[13px] font-normal">{windowTitle(sheet.req, labels)}</Drawer.Title>
<Drawer.Close
className="grid h-8 w-8 place-items-center rounded-full border-2 border-ink text-[15px] font-semibold transition-transform duration-150 ease-out active:scale-95"
aria-label={labels.close}
>
×
</Drawer.Close>
</div>
<div className="grid gap-4 overflow-y-auto overscroll-contain p-5 pb-[calc(20px+env(safe-area-inset-bottom,0px))]">
<WindowContent req={sheet.req} labels={labels} onClose={() => setSheetOpen(false)} />
</div>
</>
)}
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
</WindowContext.Provider>
);
}
interface WindowProps {
win: WinState;
labels: WindowLabels;
layerRef: RefObject<HTMLDivElement | null>;
onClose: () => void;
onMinimize: () => void;
onFocus: () => void;
}
function Window({ win, labels, layerRef, onClose, onMinimize, onFocus }: WindowProps) {
const controls = useDragControls();
const [lifted, setLifted] = useState(false);
const closeRef = useRef<HTMLButtonElement>(null);
const titleId = useId();
useEffect(() => {
closeRef.current?.focus({ preventScroll: true });
}, []);
return (
<motion.div
role="dialog"
aria-labelledby={titleId}
drag
dragListener={false}
dragControls={controls}
dragConstraints={layerRef}
dragElastic={0.15}
dragTransition={{ power: 0.3, timeConstant: 300, bounceStiffness: 260, bounceDamping: 24 }}
onDragStart={() => setLifted(true)}
onDragEnd={() => setLifted(false)}
onPointerDown={onFocus}
initial={{ opacity: 0, scale: 0.86 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.15, ease: [0.23, 1, 0.32, 1] } }}
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
className={`pointer-events-auto fixed flex max-h-[calc(100vh-120px)] w-[min(440px,calc(100vw-32px))] flex-col rounded-[18px] border-2 border-ink bg-paper text-ink transition-shadow duration-200 ease-out ${
lifted ? "shadow-[16px_18px_0_rgb(var(--plakat-ink))]" : "shadow-[8px_8px_0_rgb(var(--plakat-ink))]"
}`}
style={{ left: win.x, top: win.y, zIndex: win.z, transformOrigin: `${win.origin.x}px ${win.origin.y}px` }}
>
<div
className="flex cursor-grab touch-none select-none items-center gap-2.5 border-b-2 border-ink py-2.5 pl-4 pr-2.5 active:cursor-grabbing"
onPointerDown={(e) => {
if ((e.target as HTMLElement).closest("button")) return;
controls.start(e);
}}
>
<span id={titleId} className="flex-1 truncate font-mono text-[13px]">
{windowTitle(win.req, labels)}
</span>
<HeadButton label={labels.minimize} onClick={onMinimize}>
–
</HeadButton>
<HeadButton label={labels.close} onClick={onClose} buttonRef={closeRef}>
×
</HeadButton>
</div>
<div className="grid gap-4 overflow-y-auto overscroll-contain p-5">
<WindowContent req={win.req} labels={labels} onClose={onClose} />
</div>
</motion.div>
);
}
function HeadButton({
label,
onClick,
buttonRef,
children,
}: {
label: string;
onClick: () => void;
buttonRef?: RefObject<HTMLButtonElement | null>;
children: React.ReactNode;
}) {
return (
<button
ref={buttonRef}
type="button"
aria-label={label}
onClick={onClick}
className="grid h-[30px] w-[30px] place-items-center rounded-full border-2 border-ink bg-paper text-[15px] font-semibold transition-[background-color,transform] duration-150 ease-out hover:bg-signal active:scale-[0.94]"
>
<span aria-hidden="true">{children}</span>
</button>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { getTranslations } from "next-intl/server";
import type { WindowLabels } from "./types";
/** Alle Texte für Fenster und Sheets, serverseitig geladen und als Props weitergereicht. */
export async function getWindowLabels(locale: string): Promise<WindowLabels> {
const t = await getTranslations({ locale, namespace: "home.windows" });
const tisch = await getTranslations({ locale, namespace: "home.tisch" });
return {
close: t("close"),
minimize: t("minimize"),
openWindows: t("openWindows"),
projectPage: t("projectPage"),
source: t("source"),
live: t("live"),
stack: t("stack"),
year: t("year"),
now: {
title: t("now.title"),
days: t("now.days"),
rows: (["study", "work", "side"] as const).map((k) => ({ label: t(`now.${k}.label`), text: t(`now.${k}.text`) })),
},
server: {
title: t("server.title"),
headline: t("server.headline"),
rows: (["ingress", "mail", "home"] as const).map((k) => ({
name: tisch(`servers.${k}.name`),
detail: tisch(`servers.${k}.detail`),
})),
note: tisch("serverNote"),
},
read: {
title: t("read.title"),
progress: t("read.progress"),
empty: t("read.empty"),
library: t("read.library"),
libraryHref: `/${locale}/books`,
},
about: {
title: t("about.title"),
paragraphs: [t("about.p1"), t("about.p2")],
facts: (["laptop", "pc", "study", "languages"] as const).map((k) => ({
label: t(`about.facts.${k}.label`),
value: t(`about.facts.${k}.value`),
})),
},
chat: {
title: t("chat.title"),
welcome: t("chat.welcome"),
consent: t("chat.consent"),
allow: t("chat.allow"),
placeholder: t("chat.placeholder"),
send: t("chat.send"),
error: t("chat.error"),
thinking: t("chat.thinking"),
note: t("chat.note"),
},
contact: {
title: t("contact.title"),
body: t("contact.body"),
copy: t("contact.copy"),
copied: t("contact.copied"),
form: t("contact.form"),
},
};
}
+67
View File
@@ -0,0 +1,67 @@
import type { TischProject } from "../tisch/types";
// Was ein Fenster (Desktop) bzw. Sheet (Handy) zeigen kann
export type WindowRequest =
| { kind: "project"; project: TischProject }
| { kind: "now" }
| { kind: "server" }
| { kind: "read" }
| { kind: "about" }
| { kind: "contact" }
| { kind: "chat" };
export function windowId(req: WindowRequest): string {
return req.kind === "project" ? `project:${req.project.id}` : req.kind;
}
export interface WindowLabels {
close: string;
minimize: string;
openWindows: string;
projectPage: string;
source: string;
live: string;
stack: string;
year: string;
now: {
title: string;
days: string;
rows: { label: string; text: string }[];
};
server: {
title: string;
headline: string;
rows: { name: string; detail: string }[];
note: string;
};
read: {
title: string;
progress: string;
empty: string;
library: string;
libraryHref: string;
};
about: {
title: string;
paragraphs: string[];
facts: { label: string; value: string }[];
};
chat: {
title: string;
welcome: string;
consent: string;
allow: string;
placeholder: string;
send: string;
error: string;
thinking: string;
note: string;
};
contact: {
title: string;
body: string;
copy: string;
copied: string;
form: string;
};
}
+21 -17
View File
@@ -12,6 +12,8 @@ import {
type MotionValue,
} from "framer-motion";
import ObjectFace from "./ObjectFace";
import { useOpenWindow } from "../fenster/WindowProvider";
import { labelFor, windowFor } from "./windowFor";
import { BOARD_SIZE, BOARD_SPOTS, type TischLabels, type TischObject } from "./types";
interface Handle {
@@ -138,7 +140,6 @@ interface BoardItemProps {
function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardItemProps) {
const reduce = useReducedMotion();
const [flipped, setFlipped] = useState(false);
const [lifted, setLifted] = useState(false);
// Framer meldet nach einem Wurf trotzdem einen Tap; der soll die Karte nicht umdrehen
const dragged = useRef(false);
@@ -151,12 +152,19 @@ function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardI
const rotate = useTransform(() => base.get() + (reduce ? 0 : tilt.get()));
const { w, h } = BOARD_SIZE[obj.kind];
const canFlip = obj.kind === "project";
const radius = { sticker: "rounded-full", photo: "rounded-md", book: "rounded-[6px_14px_14px_6px]" }[obj.kind as string] ?? "rounded-2xl";
const openWindow = useOpenWindow();
const elRef = useRef<HTMLDivElement | null>(null);
const openIt = () => openWindow?.(windowFor(obj), elRef.current);
const radius =
{ sticker: "rounded-full", photo: "rounded-md", book: "rounded-[6px_14px_14px_6px]", chat: "rounded-[22px_22px_22px_6px]" }[obj.kind as string] ??
"rounded-2xl";
return (
<motion.div
ref={(el) => register(el ? { el, x, y, base, spotRotation: spot.r } : null)}
ref={(el) => {
elRef.current = el;
register(el ? { el, x, y, base, spotRotation: spot.r } : null);
}}
drag
dragConstraints={tableRef}
dragElastic={0.18}
@@ -171,21 +179,17 @@ function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardI
setLifted(true);
}}
onDragEnd={() => setLifted(false)}
onTap={canFlip ? () => !dragged.current && setFlipped((f) => !f) : undefined}
onKeyDown={
canFlip
? (e) => {
onTap={() => !dragged.current && openIt()}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
setFlipped((f) => !f);
openIt();
}
}
: undefined
}
tabIndex={canFlip ? 0 : undefined}
role={canFlip ? "button" : undefined}
aria-pressed={canFlip ? flipped : undefined}
aria-label={canFlip && obj.kind === "project" ? obj.title : undefined}
}}
tabIndex={0}
role="button"
aria-haspopup="dialog"
aria-label={labelFor(obj, labels)}
className={`absolute cursor-grab touch-none select-none active:cursor-grabbing ${radius} transition-shadow duration-200 ease-out ${
lifted ? "shadow-[14px_16px_0_rgb(var(--plakat-ink))]" : "shadow-[6px_6px_0_rgb(var(--plakat-ink))]"
}`}
@@ -200,7 +204,7 @@ function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardI
zIndex: z,
}}
>
<ObjectFace obj={obj} labels={labels} flipped={flipped} />
<ObjectFace obj={obj} labels={labels} />
</motion.div>
);
}
+18 -7
View File
@@ -3,6 +3,8 @@
import { useRef, useState } from "react";
import { AnimatePresence, motion, useMotionValue, useTransform, type PanInfo, type Variants } from "framer-motion";
import ObjectFace from "./ObjectFace";
import { useOpenWindow } from "../fenster/WindowProvider";
import { labelFor, windowFor } from "./windowFor";
import type { TischLabels, TischObject } from "./types";
interface DeckProps {
@@ -101,12 +103,13 @@ interface DeckCardProps {
}
function DeckCard({ obj, labels, depth, enteringFromLeft, onDismiss }: DeckCardProps) {
const [flipped, setFlipped] = useState(false);
const dragged = useRef(false);
const cardRef = useRef<HTMLDivElement>(null);
const openWindow = useOpenWindow();
const openIt = () => openWindow?.(windowFor(obj), cardRef.current);
const x = useMotionValue(0);
const dragRotate = useTransform(x, [-220, 0, 220], [-14, 0, 14]);
const isTop = depth === 0;
const canFlip = obj.kind === "project";
const restRotate = depth === 0 ? 0 : depth % 2 ? 3 : -3;
const handleDragEnd = (_: unknown, info: PanInfo) => {
@@ -126,6 +129,7 @@ function DeckCard({ obj, labels, depth, enteringFromLeft, onDismiss }: DeckCardP
transition={{ type: "spring", bounce: 0, duration: 0.45 }}
>
<motion.div
ref={cardRef}
className={`h-full w-full ${obj.kind === "sticker" ? "rounded-full" : "rounded-2xl"} shadow-[6px_6px_0_rgb(var(--plakat-ink))] ${isTop ? "cursor-grab" : "pointer-events-none"}`}
style={{ x, rotate: dragRotate, touchAction: "pan-y" }}
drag={isTop ? "x" : false}
@@ -138,13 +142,20 @@ function DeckCard({ obj, labels, depth, enteringFromLeft, onDismiss }: DeckCardP
dragged.current = true;
}}
onDragEnd={handleDragEnd}
onTap={isTop && canFlip ? () => !dragged.current && setFlipped((f) => !f) : undefined}
tabIndex={isTop && canFlip ? 0 : -1}
role={canFlip ? "button" : undefined}
aria-pressed={canFlip ? flipped : undefined}
onTap={isTop ? () => !dragged.current && openIt() : undefined}
onKeyDown={(e) => {
if (isTop && e.key === " ") {
e.preventDefault();
openIt();
}
}}
tabIndex={isTop ? 0 : -1}
role="button"
aria-haspopup="dialog"
aria-label={labelFor(obj, labels)}
aria-hidden={!isTop}
>
<ObjectFace obj={obj} labels={labels} flipped={flipped} />
<ObjectFace obj={obj} labels={labels} />
</motion.div>
</motion.div>
);
+13 -54
View File
@@ -4,11 +4,11 @@ import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import type { TischLabels, TischObject } from "./types";
import { loadCurrentBook, type CurrentBook } from "./currentBook";
interface ObjectFaceProps {
obj: TischObject;
labels: TischLabels;
flipped?: boolean;
}
const pad = (n: number) => String(n).padStart(2, "0");
@@ -17,20 +17,11 @@ const pad = (n: number) => String(n).padStart(2, "0");
const edge = "h-full w-full overflow-hidden rounded-2xl border-2 border-ink";
/** Zeichnet ein Objekt. Größe und Position bestimmt der Container (Tisch oder Stapel). */
export default function ObjectFace({ obj, labels, flipped = false }: ObjectFaceProps) {
export default function ObjectFace({ obj, labels }: ObjectFaceProps) {
switch (obj.kind) {
case "project":
return (
<div className="h-full w-full [perspective:1200px]">
<div
className={`relative h-full w-full rounded-2xl transition-transform duration-[560ms] ease-in-out [transform-style:preserve-3d] ${
flipped ? "[transform:rotateY(180deg)]" : ""
}`}
>
<div
className={`${edge} absolute inset-0 flex flex-col justify-between p-4 [backface-visibility:hidden]`}
style={{ background: obj.color, color: obj.textColor }}
>
<div className={`${edge} flex flex-col justify-between p-4`} style={{ background: obj.color, color: obj.textColor }}>
<div className="flex justify-between font-mono text-xs">
<span>({pad(obj.index + 1)})</span>
<span>{obj.year}</span>
@@ -40,25 +31,7 @@ export default function ObjectFace({ obj, labels, flipped = false }: ObjectFaceP
</h3>
<div className="font-mono text-xs leading-snug">
<div>{obj.tags.slice(0, 3).join(" · ")}</div>
<div className="mt-2 opacity-70">{labels.flip} ↻</div>
</div>
</div>
<div
className={`${edge} absolute inset-0 flex flex-col justify-between bg-paper p-4 text-ink [backface-visibility:hidden] [transform:rotateY(180deg)]`}
>
<div className="flex justify-between font-mono text-xs">
<span className="truncate pr-2">{obj.title}</span>
<span>{obj.year}</span>
</div>
<p className="line-clamp-6 text-[15px] leading-snug">{obj.description}</p>
<Link
href={obj.href}
className="inline-flex w-fit items-center gap-1.5 rounded-full bg-ink px-3.5 py-2 text-sm font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]"
onPointerDownCapture={(e) => e.stopPropagation()}
>
{labels.details} <span aria-hidden="true">→</span>
</Link>
</div>
<div className="mt-2 opacity-70">{labels.flip} ↗</div>
</div>
</div>
);
@@ -112,6 +85,15 @@ export default function ObjectFace({ obj, labels, flipped = false }: ObjectFaceP
</div>
);
case "chat":
return (
<div className="flex h-full w-full flex-col justify-between rounded-[22px_22px_22px_6px] border-2 border-ink bg-ink p-4 text-paper">
<span className="font-mono text-xs uppercase tracking-wide text-signal">KI · n8n</span>
<span className="font-display text-[1.7rem] font-extrabold leading-[0.95] tracking-[-0.03em]">{labels.chatCard}</span>
<span className="font-mono text-xs opacity-70">{labels.chatCardHint} ↗</span>
</div>
);
case "sticker":
return (
<div className="grid h-full w-full place-items-center rounded-full border-2 border-ink bg-signal p-6 text-center font-display text-xl font-extrabold leading-[0.98] tracking-[-0.02em] text-ink">
@@ -121,29 +103,6 @@ export default function ObjectFace({ obj, labels, flipped = false }: ObjectFaceP
}
}
interface CurrentBook {
title: string;
authors: string[];
image: string | null;
progress: number;
}
// Tisch und Stapel zeigen das Buch beide; so wird es nur einmal geladen
let bookRequest: Promise<CurrentBook | null> | null = null;
function loadCurrentBook(): Promise<CurrentBook | null> {
if (!bookRequest) {
bookRequest = fetch("/api/n8n/hardcover/currently-reading")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
const list = data?.currentlyReading;
const first = Array.isArray(list) ? list[0] : list;
return first ?? null;
})
.catch(() => null);
}
return bookRequest;
}
function BookFace({ href, labels }: { href: string; labels: TischLabels }) {
const [book, setBook] = useState<CurrentBook | null | undefined>(undefined);
+5
View File
@@ -45,6 +45,8 @@ export default async function TischSection({ locale }: TischSectionProps) {
bookLabel: t("bookLabel"),
bookFallback: t("bookFallback"),
photoCaption: t("photoCaption"),
chatCard: t("chatCard"),
chatCardHint: t("chatCardHint"),
sticker: t("sticker"),
};
@@ -61,6 +63,8 @@ export default async function TischSection({ locale }: TischSectionProps) {
href: `/${locale}/projects/${p.slug}`,
color: color.bg,
textColor: color.text,
liveUrl: p.live_url && p.live_url !== "#" ? p.live_url : undefined,
githubUrl: p.github_url && p.github_url !== "#" ? p.github_url : undefined,
};
});
@@ -71,6 +75,7 @@ export default async function TischSection({ locale }: TischSectionProps) {
{ kind: "server", id: "server" },
{ kind: "book", id: "book", href: `/${locale}/books` },
{ kind: "photo", id: "photo" },
{ kind: "chat", id: "chat" },
{ kind: "sticker", id: "sticker" },
];
// Im Stapel wäre der runde Sticker eine Ellipse und wiederholt nur den Claim
+24
View File
@@ -0,0 +1,24 @@
// Aktuelles Buch aus Hardcover (über n8n). Tisch, Stapel und Lese-Fenster teilen sich eine Anfrage.
export interface CurrentBook {
title: string;
authors: string[];
image: string | null;
progress: number;
}
// Tisch und Stapel zeigen das Buch beide; so wird es nur einmal geladen
let bookRequest: Promise<CurrentBook | null> | null = null;
export function loadCurrentBook(): Promise<CurrentBook | null> {
if (!bookRequest) {
bookRequest = fetch("/api/n8n/hardcover/currently-reading")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
const list = data?.currentlyReading;
const first = Array.isArray(list) ? list[0] : list;
return first ?? null;
})
.catch(() => null);
}
return bookRequest;
}
+12 -1
View File
@@ -11,6 +11,8 @@ export interface TischProject {
href: string;
color: string;
textColor: string;
liveUrl?: string;
githubUrl?: string;
}
export interface TischTicket {
@@ -35,12 +37,17 @@ export interface TischPhoto {
id: "photo";
}
export interface TischChat {
kind: "chat";
id: "chat";
}
export interface TischSticker {
kind: "sticker";
id: "sticker";
}
export type TischObject = TischProject | TischTicket | TischServer | TischBook | TischPhoto | TischSticker;
export type TischObject = TischProject | TischTicket | TischServer | TischBook | TischPhoto | TischChat | TischSticker;
export interface TischLabels {
hintBoard: string;
@@ -61,6 +68,8 @@ export interface TischLabels {
bookLabel: string;
bookFallback: string;
photoCaption: string;
chatCard: string;
chatCardHint: string;
sticker: string;
}
@@ -71,6 +80,7 @@ export const BOARD_SIZE: Record<TischObject["kind"], { w: number; h: number }> =
server: { w: 270, h: 290 },
book: { w: 176, h: 262 },
photo: { w: 210, h: 250 },
chat: { w: 250, h: 170 },
sticker: { w: 180, h: 180 },
};
@@ -87,6 +97,7 @@ export const BOARD_SPOTS: { x: number; y: number; r: number }[] = [
{ x: 0.76, y: 450, r: -7 },
{ x: 0.42, y: 800, r: 7 },
{ x: 0.12, y: 800, r: -4 },
{ x: 0.66, y: 790, r: 4 },
];
// Farbfelder für Projektkarten, abwechselnd hell und dunkel
+41
View File
@@ -0,0 +1,41 @@
import type { WindowRequest } from "../fenster/types";
import type { TischLabels, TischObject } from "./types";
/** Welches Fenster ein Objekt auf dem Tisch öffnet */
export function windowFor(obj: TischObject): WindowRequest {
switch (obj.kind) {
case "project":
return { kind: "project", project: obj };
case "ticket":
return { kind: "now" };
case "server":
case "sticker":
return { kind: "server" };
case "book":
return { kind: "read" };
case "photo":
return { kind: "about" };
case "chat":
return { kind: "chat" };
}
}
/** Vorlesetext für das Objekt als Knopf */
export function labelFor(obj: TischObject, labels: TischLabels): string {
switch (obj.kind) {
case "project":
return obj.title;
case "ticket":
return labels.ticketTitle;
case "server":
return labels.serverTitle;
case "book":
return labels.bookLabel;
case "photo":
return labels.photoCaption;
case "chat":
return labels.chatCard;
case "sticker":
return labels.sticker;
}
}
-60
View File
@@ -1,60 +0,0 @@
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
export const BentoGrid = ({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) => {
return (
<div
className={cn(
"grid md:auto-rows-[18rem] grid-cols-1 md:grid-cols-3 gap-4 max-w-7xl mx-auto ",
className
)}
>
{children}
</div>
);
};
export const BentoGridItem = ({
className,
title,
description,
header,
icon,
onClick,
}: {
className?: string;
title?: string | React.ReactNode;
description?: string | React.ReactNode;
header?: React.ReactNode;
icon?: React.ReactNode;
onClick?: () => void;
}) => {
return (
<motion.div
whileHover={{ y: -5 }}
transition={{ duration: 0.2 }}
className={cn(
"row-span-1 rounded-3xl group/bento hover:shadow-xl transition duration-200 shadow-input dark:shadow-none p-4 dark:bg-stone-900 bg-white border border-stone-200 dark:border-stone-800 justify-between flex flex-col space-y-4",
className
)}
onClick={onClick}
>
{header}
<div className="group-hover/bento:translate-x-2 transition duration-200">
{icon}
<div className="font-sans font-bold text-stone-800 dark:text-stone-100 mb-2 mt-2">
{title}
</div>
<div className="font-sans font-normal text-stone-600 dark:text-stone-400 text-xs">
{description}
</div>
</div>
</motion.div>
);
};
+122 -57
View File
@@ -106,16 +106,7 @@ html {
}
}
/* Liquid Glass Effects */
.glass-panel {
background: var(--card);
backdrop-filter: blur(20px) saturate(130%);
-webkit-backdrop-filter: blur(20px) saturate(130%);
border: 1px solid var(--border);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
will-change: backdrop-filter;
}
/* Glass-Karten, nur noch im Editor */
.glass-card {
background: var(--card);
backdrop-filter: blur(30px) saturate(200%);
@@ -177,31 +168,6 @@ h6 {
scrollbar-width: none;
}
/* Animations */
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
.animate-float {
animation: float 8s ease-in-out infinite;
will-change: transform;
}
/* Liquid Blobs Background */
.liquid-bg-blob {
position: absolute;
filter: blur(80px);
opacity: 0.6;
z-index: -1;
animation: float 10s ease-in-out infinite;
}
/* Markdown Specifics for Blog/Projects */
.markdown h1 {
@apply text-4xl font-bold mb-6 tracking-tight;
@@ -246,31 +212,130 @@ h6 {
z-index: -1;
}
.admin-glass {
background: rgba(250, 248, 243, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid #d7ccc8;
color: #3e2723;
}
.admin-glass-light {
background: #fffcf5;
border: 1px solid #d7ccc8;
color: #3e2723;
transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 1px 2px rgba(62, 39, 35, 0.05);
}
.admin-glass-light:hover {
background: #faf8f3;
border-color: #bcaaa4;
box-shadow: 0 4px 6px rgba(62, 39, 35, 0.08);
}
.admin-glass-card {
background: #fffcf5;
border: 1px solid #d7ccc8;
box-shadow: 0 4px 6px -1px rgba(62, 39, 35, 0.06);
color: #3e2723;
}
/* Fließtext der Unterseiten (Markdown, CMS-HTML) im Plakat-Stil.
Ersetzt das nicht installierte Typography-Plugin. */
.plakat-prose {
font-size: 17px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.plakat-prose > * + * {
margin-top: 1em;
}
.plakat-prose h1,
.plakat-prose h2,
.plakat-prose h3,
.plakat-prose h4 {
font-family: var(--font-bricolage), "Avenir Next", "Segoe UI", sans-serif;
font-weight: 800;
letter-spacing: -0.035em;
line-height: 1;
margin-top: 1.8em;
}
.plakat-prose > :first-child {
margin-top: 0;
}
/* Markdown aus dem CMS beginnt oft mit dem Projekttitel als h1; die Seite hat schon einen */
.plakat-prose > h1:first-child {
display: none;
}
.plakat-prose > h1:first-child + * {
margin-top: 0;
}
.plakat-prose h1 {
font-size: clamp(2rem, 4vw, 2.75rem);
}
.plakat-prose h2 {
font-size: clamp(1.7rem, 3.2vw, 2.25rem);
}
.plakat-prose h3 {
font-size: 1.4rem;
}
.plakat-prose h4 {
font-size: 1.15rem;
}
.plakat-prose strong {
font-weight: 700;
}
.plakat-prose a {
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
text-decoration-color: rgb(var(--plakat-red));
transition: color 150ms var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
.plakat-prose a:hover {
color: rgb(var(--plakat-red));
}
}
.plakat-prose ul,
.plakat-prose ol {
padding-left: 1.25em;
}
.plakat-prose ul {
list-style: square;
}
.plakat-prose ol {
list-style: decimal;
}
.plakat-prose li + li {
margin-top: 0.35em;
}
.plakat-prose li::marker {
color: rgb(var(--plakat-red));
}
.plakat-prose blockquote {
border-left: 4px solid rgb(var(--plakat-red));
padding-left: 1em;
}
.plakat-prose code {
font-family: var(--font-geist-mono), "SF Mono", Menlo, monospace;
font-size: 0.9em;
background: rgb(var(--plakat-paper-2));
border-radius: 4px;
padding: 0.1em 0.35em;
}
.plakat-prose pre {
background: rgb(var(--plakat-ink));
color: rgb(var(--plakat-paper));
border-radius: 12px;
padding: 1rem 1.25rem;
overflow-x: auto;
overflow-wrap: normal;
}
.plakat-prose pre code {
background: transparent;
padding: 0;
color: inherit;
}
.plakat-prose img {
max-width: 100%;
height: auto;
border: 2px solid rgb(var(--plakat-ink));
border-radius: 12px;
}
.plakat-prose hr {
border: 0;
border-top: 2px solid rgb(var(--plakat-ink));
}
.plakat-prose table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
.plakat-prose th,
.plakat-prose td {
border-bottom: 1px solid rgb(var(--plakat-ink) / 0.2);
padding: 0.5em 0.75em;
text-align: left;
}
-135
View File
@@ -1,135 +0,0 @@
"use client";
import React from "react";
import { ArrowLeft, Mail, MapPin, Scale, ShieldCheck, Clock } from 'lucide-react';
import Header from "../components/Header";
import Footer from "../components/Footer";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import RichTextClient from "../components/RichTextClient";
export default function LegalNotice() {
const locale = useLocale();
const t = useTranslations("common");
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const res = await fetch(
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
if (data?.content?.html && data?.content?.locale === locale) {
setCmsHtml(data.content.html as string);
}
} catch {}
})();
}, [locale]);
return (
<>
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 transition-colors duration-500">
<Header />
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
{/* Editorial Header */}
<div className="animate-fade-in mb-20">
<Link
href={`/${locale}`}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span>
</Link>
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
Legal<span className="text-liquid-mint">.</span>
</h1>
</div>
{/* Bento Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Main Legal Content (Large Box) */}
<div className="animate-[fadeIn_0.5s_ease-out_0.1s_both] lg:col-span-8 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">
{cmsHtml ? (
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<RichTextClient html={cmsHtml} />
</div>
) : (
<div className="space-y-16">
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
<Scale className="text-liquid-mint" size={28} /> Angaben gemäß § 5 TMG
</h2>
<div className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 space-y-4">
<p className="font-bold text-stone-900 dark:text-stone-100">Dennis Konkol</p>
<p>Auf dem Ziegenbrink 2B</p>
<p>49082 Osnabrück, Deutschland</p>
</div>
</section>
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
<ShieldCheck className="text-liquid-sky" size={28} /> Haftungsausschluss
</h2>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
Die Inhalte dieser Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte kann ich jedoch keine Gewähr übernehmen.
</p>
</section>
</div>
)}
</div>
{/* Sidebar Widgets */}
<div className="lg:col-span-4 space-y-8">
{/* Quick Contact Box */}
<div className="bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white">
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-mint">Direct Contact</h3>
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-white/10 flex items-center justify-center border border-white/10">
<Mail className="text-liquid-mint" size={20} />
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-stone-500">Email</p>
<Link href="mailto:info@dk0.dev" className="font-bold hover:text-liquid-mint transition-colors">info@dk0.dev</Link>
</div>
</div>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-white/10 flex items-center justify-center border border-white/10">
<MapPin className="text-liquid-sky" size={20} />
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-stone-500">Location</p>
<p className="font-bold">Osnabrück, GER</p>
</div>
</div>
</div>
</div>
{/* Meta Info Box */}
<div className="bg-liquid-purple/5 dark:bg-stone-900 rounded-[3rem] p-10 border border-liquid-purple/20 dark:border-stone-800/60">
<div className="flex items-center gap-4 mb-6">
<Clock className="text-liquid-purple" size={20} />
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-stone-400">Last Review</p>
<p className="font-bold text-stone-900 dark:text-stone-100 text-sm">May 14, 2025</p>
</div>
</div>
<p className="text-xs text-stone-500 leading-relaxed">
This legal notice applies to all contents on dk0.dev and related social media profiles.
</p>
</div>
</div>
</div>
</main>
</div>
<Footer />
</>
);
}
-216
View File
@@ -1,216 +0,0 @@
"use client";
import React from "react";
import { ArrowLeft, Shield, Lock, Eye, Database, Globe } from 'lucide-react';
import Header from "../components/Header";
import Footer from "../components/Footer";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import RichTextClient from "../components/RichTextClient";
export default function PrivacyPolicy() {
const locale = useLocale();
const t = useTranslations("common");
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const res = await fetch(
`/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
if (data?.content?.html && data?.content?.locale === locale) {
setCmsHtml(data.content.html as string);
}
} catch {}
})();
}, [locale]);
return (
<>
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 transition-colors duration-500">
<Header />
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
{/* Editorial Header */}
<div className="animate-fade-in mb-20">
<Link
href={`/${locale}`}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span>
</Link>
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
Privacy<span className="text-liquid-purple">.</span>
</h1>
</div>
{/* Bento Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Main Privacy Text (Large) */}
<div className="animate-[fadeIn_0.5s_ease-out_0.1s_both] lg:col-span-8 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">
{cmsHtml ? (
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<RichTextClient html={cmsHtml} />
</div>
) : (
<div className="space-y-16">
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
<Shield className="text-liquid-mint" size={28} /> Verantwortlicher
</h2>
<div className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 space-y-2">
<p className="font-bold text-stone-900 dark:text-stone-100">Dennis Konkol</p>
<p>Auf dem Ziegenbrink 2B</p>
<p>49082 Osnabrück, Deutschland</p>
<p>E-Mail: <a href="mailto:contact@dk0.dev" className="text-liquid-mint hover:underline">contact@dk0.dev</a></p>
<p className="text-sm text-stone-500 dark:text-stone-400 mt-4">Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten Verantwortlichen.</p>
</div>
</section>
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
<Database className="text-liquid-sky" size={28} /> Datenerfassung
</h2>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
Beim Zugriff auf diese Website werden automatisch Informationen allgemeiner Natur erfasst. Diese beinhalten unter anderem:
</p>
<ul className="mt-4 space-y-2 text-xl font-light text-stone-600 dark:text-stone-400">
<li className="flex items-start gap-3"><span className="text-liquid-sky mt-2">•</span> IP-Adresse (in anonymisierter Form)</li>
<li className="flex items-start gap-3"><span className="text-liquid-sky mt-2">•</span> Uhrzeit und Datum des Zugriffs</li>
<li className="flex items-start gap-3"><span className="text-liquid-sky mt-2">•</span> Browsertyp und Betriebssystem</li>
<li className="flex items-start gap-3"><span className="text-liquid-sky mt-2">•</span> Referrer-URL (die zuvor besuchte Seite)</li>
</ul>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 mt-4">
Diese Informationen werden anonymisiert erfasst und dienen ausschließlich statistischen Auswertungen. Rückschlüsse auf Ihre Person sind nicht möglich.
</p>
</section>
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Analyse- und Tracking-Tools</h2>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
Zur Analyse der Nutzung dieser Website setze ich <strong className="text-stone-900 dark:text-stone-100">Umami</strong> ein. Umami speichert keine IP-Adressen oder Cookies. Alle erfassten Daten sind anonymisiert. Da ich Umami auf meinem eigenen Server betreibe, erfolgt keine Weitergabe an Dritte. Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. f DSGVO (berechtigtes Interesse an der Analyse und Optimierung der Website).
</p>
</section>
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Kontaktformular</h2>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
Wenn Sie das Kontaktformular nutzen oder per E-Mail Kontakt aufnehmen, werden Ihre Angaben zur Bearbeitung Ihrer Anfrage gespeichert. Diese Daten werden nicht an Dritte weitergegeben und nach Erfüllung des Zwecks gelöscht. Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. a DSGVO (Einwilligung).
</p>
</section>
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Social Media Links</h2>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
Diese Website enthält Links zu GitHub und LinkedIn. Durch das Anklicken dieser Links gelten die Datenschutzbestimmungen der jeweiligen Anbieter.
</p>
</section>
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Weitergabe von Daten</h2>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 mb-4">Eine Weitergabe Ihrer personenbezogenen Daten erfolgt nur, wenn:</p>
<ul className="space-y-2 text-xl font-light text-stone-600 dark:text-stone-400">
<li className="flex items-start gap-3"><span className="text-liquid-mint mt-2">•</span> Sie nach Art. 6 Abs. 1 S. 1 lit. a DSGVO ausdrücklich eingewilligt haben,</li>
<li className="flex items-start gap-3"><span className="text-liquid-mint mt-2">•</span> dies zur Vertragserfüllung gemäß Art. 6 Abs. 1 S. 1 lit. b DSGVO erforderlich ist,</li>
<li className="flex items-start gap-3"><span className="text-liquid-mint mt-2">•</span> eine gesetzliche Verpflichtung nach Art. 6 Abs. 1 S. 1 lit. c DSGVO besteht, oder</li>
<li className="flex items-start gap-3"><span className="text-liquid-mint mt-2">•</span> die Verarbeitung nach Art. 6 Abs. 1 S. 1 lit. f DSGVO zur Wahrung berechtigter Interessen erforderlich ist.</li>
</ul>
</section>
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Ihre Rechte</h2>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 mb-4">Sie haben gemäß DSGVO folgende Rechte:</p>
<ul className="space-y-2 text-xl font-light text-stone-600 dark:text-stone-400">
<li className="flex items-start gap-3"><span className="text-liquid-purple mt-2">•</span> Art. 15 DSGVO: Auskunftsrecht über Ihre gespeicherten Daten</li>
<li className="flex items-start gap-3"><span className="text-liquid-purple mt-2">•</span> Art. 16 DSGVO: Recht auf Berichtigung unrichtiger Daten</li>
<li className="flex items-start gap-3"><span className="text-liquid-purple mt-2">•</span> Art. 17 DSGVO: Recht auf Löschung (soweit keine Aufbewahrungspflichten entgegenstehen)</li>
<li className="flex items-start gap-3"><span className="text-liquid-purple mt-2">•</span> Art. 18 DSGVO: Recht auf Einschränkung der Verarbeitung</li>
<li className="flex items-start gap-3"><span className="text-liquid-purple mt-2">•</span> Art. 20 DSGVO: Recht auf Datenübertragbarkeit</li>
<li className="flex items-start gap-3"><span className="text-liquid-purple mt-2">•</span> Art. 21 DSGVO: Widerspruchsrecht gegen die Verarbeitung</li>
</ul>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 mt-4">
Beschwerden können Sie an die zuständige Datenschutzaufsichtsbehörde richten: <a href="https://www.bfdi.bund.de/" className="text-liquid-mint hover:underline" target="_blank" rel="noopener noreferrer">bfdi.bund.de</a>
</p>
</section>
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Datensicherheit</h2>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
Ich setze technische und organisatorische Maßnahmen ein, um Ihre Daten zu schützen. Dazu gehören unter anderem die SSL-Verschlüsselung. Diese Verschlüsselung erkennen Sie an dem Schloss-Symbol in der Adresszeile Ihres Browsers und an der URL, die mit &ldquo;https://&rdquo; beginnt.
</p>
</section>
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight">Änderungen</h2>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
Diese Datenschutzerklärung wird regelmäßig aktualisiert, um den gesetzlichen Anforderungen zu entsprechen. Die jeweils aktuelle Version finden Sie auf dieser Seite.
</p>
<p className="text-sm text-stone-500 dark:text-stone-400 mt-6">Letzte Aktualisierung: Mai 2025</p>
</section>
</div>
)}
</div>
{/* Quick Info Cards */}
<div className="lg:col-span-4 space-y-8">
{/* Core Values Box */}
<div className="bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white">
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-purple">Principles</h3>
<div className="space-y-6">
<div className="flex items-start gap-4">
<Lock className="text-liquid-mint mt-1" size={18} />
<div>
<p className="font-bold">Encryption</p>
<p className="text-xs text-stone-500">SSL/TLS secured data transfer.</p>
</div>
</div>
<div className="flex items-start gap-4">
<Eye className="text-liquid-sky mt-1" size={18} />
<div>
<p className="font-bold">Transparency</p>
<p className="text-xs text-stone-500">No hidden tracking algorithms.</p>
</div>
</div>
<div className="flex items-start gap-4">
<Globe className="text-liquid-purple mt-1" size={18} />
<div>
<p className="font-bold">Compliance</p>
<p className="text-xs text-stone-500">GDPR / DSGVO optimized.</p>
</div>
</div>
</div>
</div>
{/* Cookie Status Indicator */}
<div className="bg-liquid-mint/5 dark:bg-stone-900 rounded-[3rem] p-10 border border-liquid-mint/20 dark:border-stone-800/60">
<div className="flex items-center gap-3 mb-4">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<p className="text-xs font-black uppercase tracking-widest text-stone-400">Security Check</p>
</div>
<p className="text-stone-900 dark:text-stone-50 font-bold mb-4">Your connection is private and secure.</p>
<button
onClick={() => {
document.cookie = 'dk0_consent_v1=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'; localStorage.removeItem('cookie-consent');
window.location.reload();
}}
className="text-[10px] font-black uppercase tracking-widest border-b border-stone-300 dark:border-stone-700 pb-1 hover:text-liquid-mint transition-colors"
>
Reset Privacy Settings
</button>
</div>
</div>
</div>
</main>
</div>
<Footer />
</>
);
}
+2 -2
View File
@@ -105,8 +105,8 @@ The privacy policy and legal notice use a **dynamic CMS system**:
### Option 2: Edit Static Fallback
If you haven't set up CMS content, the fallback static content is in:
- Privacy Policy: `/app/privacy-policy/page.tsx` (lines 76-302)
- Legal Notice: `/app/legal-notice/page.tsx`
- Privacy Policy: `/app/_ui/PrivacyPolicyClient.tsx` (Fallback-Text, sonst CMS-Seite `privacy-policy`)
- Legal Notice: `/app/_ui/LegalNoticeClient.tsx` (Fallback-Text, sonst CMS-Seite `legal-notice`)
⚠️ **Note**: Static content is hardcoded in German. For CMS-based content, you can manage both languages separately.
-32
View File
@@ -1,32 +0,0 @@
import { test, expect } from "@playwright/test";
test.describe("ActivityFeed reload rendering", () => {
test("feed stays visible and dark after reload", async ({ page }) => {
await page.goto("/en", { waitUntil: "domcontentloaded" });
const feed = page.locator('[class*="bg-black/95"]').filter({ hasText: "Live Activity" }).first();
await expect(feed).toBeVisible({ timeout: 15000 });
const initialBox = await feed.boundingBox();
expect(initialBox).not.toBeNull();
expect(initialBox!.width).toBeGreaterThan(200);
expect(initialBox!.height).toBeGreaterThan(30);
const initialOpacity = await feed.evaluate((el) => getComputedStyle(el).opacity);
expect(Number(initialOpacity)).toBeGreaterThan(0.5);
await page.reload({ waitUntil: "domcontentloaded" });
const feedAfter = page.locator('[class*="bg-black/95"]').filter({ hasText: "Live Activity" }).first();
await expect(feedAfter).toBeVisible({ timeout: 15000 });
const afterBox = await feedAfter.boundingBox();
expect(afterBox).not.toBeNull();
expect(afterBox!.width).toBeGreaterThan(200);
expect(afterBox!.height).toBeGreaterThan(30);
const afterOpacity = await feedAfter.evaluate((el) => getComputedStyle(el).opacity);
expect(Number(afterOpacity)).toBeGreaterThan(0.5);
});
});
-64
View File
@@ -2,8 +2,6 @@ import { getLocalizedMessage } from '@/lib/i18n-loader';
import type {
NavTranslations,
FooterTranslations,
AboutTranslations,
ProjectsTranslations,
ContactTranslations,
ConsentTranslations,
} from '@/types/translations';
@@ -54,68 +52,6 @@ export async function getFooterTranslations(locale: string): Promise<FooterTrans
};
}
export async function getAboutTranslations(locale: string): Promise<AboutTranslations> {
const keys = [
'home.about.title',
'home.about.p1',
'home.about.p2',
'home.about.p3',
'home.about.funFactTitle',
'home.about.funFactBody',
'home.about.techStackTitle',
'home.about.techStack.categories.frontendMobile',
'home.about.techStack.categories.backendDevops',
'home.about.techStack.categories.toolsAutomation',
'home.about.techStack.categories.securityAdmin',
'home.about.techStack.items.selfHostedServices',
'home.about.hobbiesTitle',
'home.about.hobbies.selfHosting',
'home.about.hobbies.gaming',
'home.about.hobbies.gameServers',
'home.about.hobbies.jogging',
];
const values = await Promise.all(keys.map(key => getLocalizedMessage(key, locale)));
return {
title: values[0],
p1: values[1],
p2: values[2],
p3: values[3],
funFactTitle: values[4],
funFactBody: values[5],
techStackTitle: values[6],
techStack: {
categories: {
frontendMobile: values[7],
backendDevops: values[8],
toolsAutomation: values[9],
securityAdmin: values[10],
},
items: {
selfHostedServices: values[11],
},
},
hobbiesTitle: values[12],
hobbies: {
selfHosting: values[13],
gaming: values[14],
gameServers: values[15],
jogging: values[16],
},
};
}
export async function getProjectsTranslations(locale: string): Promise<ProjectsTranslations> {
const [title, subtitle, viewAll] = await Promise.all([
getLocalizedMessage('home.projects.title', locale),
getLocalizedMessage('home.projects.subtitle', locale),
getLocalizedMessage('home.projects.viewAll', locale),
]);
return { title, subtitle, viewAll };
}
export async function getContactTranslations(locale: string): Promise<ContactTranslations> {
const keys = [
'home.contact.title',
+82 -73
View File
@@ -36,11 +36,11 @@
"tisch": {
"title": "Der Tisch.",
"allProjects": "Alle Projekte",
"hintBoard": "Alles hier lässt sich anfassen. Werfen, stapeln, Karten umdrehen.",
"hintDeck": "Wisch die Karten zur Seite, tipp zum Umdrehen.",
"hintBoard": "Alles hier lässt sich anfassen. Werfen, stapeln, antippen für Details.",
"hintDeck": "Wisch die Karten zur Seite, tipp für Details.",
"tidy": "Aufräumen",
"mess": "Durcheinander",
"flip": "tippen zum Umdrehen",
"flip": "antippen für Details",
"details": "Zum Projekt",
"prev": "Vorherige Karte",
"next": "Nächste Karte",
@@ -67,65 +67,90 @@
"bookLabel": "Lese gerade",
"bookFallback": "Zur Bibliothek",
"photoCaption": "Das bin ich.",
"chatCard": "Frag mich etwas.",
"chatCardHint": "KI-Chat öffnen",
"sticker": "Läuft auf eigenen Servern."
},
"windows": {
"close": "Schließen",
"minimize": "Ablegen",
"openWindows": "Offene Fenster",
"projectPage": "Projektseite",
"source": "Quellcode",
"live": "Live ansehen",
"stack": "Technik",
"year": "Jahr",
"now": {
"title": "jetzt.txt · Stand Oktober 2026",
"days": "Tage bis zur Abgabe der Masterarbeit",
"study": {
"label": "Studium",
"text": "Federated Reinforcement Learning für einen Logistik-Hub, Abgabe Mitte Februar 2027"
},
"work": {
"label": "Arbeit",
"text": "IT bei einem Unternehmen in Osnabrück, ab Februar 2027 fest als Microsoft-365-Administrator"
},
"side": {
"label": "Nebenher",
"text": "nightly, eine App für Gedanken zwischen 2 und 5 Uhr nachts"
}
},
"server": {
"title": "server · alle grün",
"headline": "Drei Rechner, ein VPN."
},
"read": {
"title": "lesen · aus Hardcover",
"progress": "gelesen",
"empty": "Gerade liegt kein Buch auf dem Nachttisch.",
"library": "Zur Bibliothek"
},
"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"
"title": "über mich",
"p1": "Aufgewachsen bin ich in Lingen, inzwischen wohne ich in Osnabrück. Angefangen hat alles mit einem Raspberry Pi und der Frage, ob ich meine Fotos nicht selbst speichern kann. Daraus sind drei Server geworden.",
"p2": "Wenn ich nicht am Rechner sitze, bin ich dienstags bei Body Attack, im Winter auf der Piste oder mit Freunden bei einem Brettspiel.",
"facts": {
"laptop": {
"label": "Arbeitsrechner",
"value": "MacBook Pro M4 Pro"
},
"items": {
"selfHostedServices": "Self-hosted Services"
"pc": {
"label": "Zum Spielen",
"value": "PC mit RTX 3080"
},
"study": {
"label": "Studium",
"value": "Master Informatik"
},
"languages": {
"label": "Sprachen",
"value": "Deutsch, Englisch"
}
}
},
"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."
"chat": {
"title": "frag mich · KI",
"welcome": "Hi! Frag mich etwas über Dennis, seine Projekte oder sein Setup.",
"consent": "Der Chat läuft über einen KI-Dienst in meinem n8n. Deine Nachrichten werden dafür verarbeitet und kurz gespeichert. Erst wenn du zustimmst, wird etwas gesendet.",
"allow": "Chat erlauben",
"placeholder": "Deine Frage …",
"send": "Senden",
"error": "Gerade keine Verbindung. Versuch es gleich noch mal.",
"thinking": "Antwort wird geschrieben",
"note": "Antworten kommen von einer KI und können danebenliegen."
},
"contact": {
"title": "Kontakt",
"subtitle": "Du willst zusammenarbeiten oder hast Fragen zu meinen Projekten? Schreib mir gerne!",
"title": "kontakt",
"body": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.",
"copy": "Adresse kopieren",
"copied": "Kopiert",
"form": "Zum Formular"
}
},
"contact": {
"title": "Schreib mir.",
"subtitle": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.",
"getInTouch": "Melde dich",
"getInTouchBody": "Ich bin immer offen für neue Chancen, spannende Projekte oder einfach einen Tech-Talk.",
"info": {
@@ -185,13 +210,13 @@
}
},
"footer": {
"role": "Software Engineer",
"madeIn": "Made in Germany",
"role": "IT & Self-Hosting",
"madeIn": "selbst gehostet in Deutschland",
"legalNotice": "Impressum",
"privacyPolicy": "Datenschutz",
"privacySettings": "Datenschutz-Einstellungen",
"privacySettingsTitle": "Datenschutz-Banner wieder anzeigen",
"builtWith": "Built with",
"builtWith": "Gebaut mit Next.js und Directus, läuft auf meinen eigenen Servern.",
"aiDisclaimer": "Einige Inhalte dieser Seite können KI-generiert sein.",
"backToTop": "Nach oben",
"systemsOnline": "Systeme online"
@@ -211,21 +236,5 @@
"subtitle": "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben.",
"backHome": "Zurück",
"empty": "Noch keine Bücher."
},
"about": {
"status": "Status",
"aiAssistant": "KI-Assistent",
"library": "Bibliothek",
"viewAll": "Alle ansehen",
"myGear": "Mein Gear",
"gearMain": "Main",
"gearPC": "PC",
"gearServer": "Server",
"gearOS": "OS",
"curiosity": "Neugier über die Softwareentwicklung hinaus.",
"connect": "Verbinden",
"email": "E-Mail",
"code": "Code",
"professional": "Beruflich"
}
}
+82 -73
View File
@@ -36,11 +36,11 @@
"tisch": {
"title": "The desk.",
"allProjects": "All projects",
"hintBoard": "Everything here can be touched. Throw it, stack it, flip the cards.",
"hintDeck": "Swipe the cards aside, tap to flip.",
"hintBoard": "Everything here can be touched. Throw it, stack it, tap for details.",
"hintDeck": "Swipe the cards aside, tap for details.",
"tidy": "Tidy up",
"mess": "Mess it up",
"flip": "tap to flip",
"flip": "tap for details",
"details": "Open project",
"prev": "Previous card",
"next": "Next card",
@@ -67,65 +67,90 @@
"bookLabel": "Reading now",
"bookFallback": "To the library",
"photoCaption": "That's me.",
"chatCard": "Ask me something.",
"chatCardHint": "Open AI chat",
"sticker": "Runs on my own servers."
},
"windows": {
"close": "Close",
"minimize": "Minimize",
"openWindows": "Open windows",
"projectPage": "Project page",
"source": "Source",
"live": "See it live",
"stack": "Stack",
"year": "Year",
"now": {
"title": "now.txt · October 2026",
"days": "days until my master's thesis is due",
"study": {
"label": "Study",
"text": "Federated reinforcement learning for a logistics hub, due mid-February 2027"
},
"work": {
"label": "Work",
"text": "IT at a company in Osnabrück, from February 2027 full-time as Microsoft 365 administrator"
},
"side": {
"label": "On the side",
"text": "nightly, an app for thoughts between 2 and 5 a.m."
}
},
"server": {
"title": "servers · all green",
"headline": "Three machines, one VPN."
},
"read": {
"title": "reading · from Hardcover",
"progress": "read",
"empty": "No book on the nightstand right now.",
"library": "To the library"
},
"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"
"title": "about me",
"p1": "I grew up in Lingen and now live in Osnabrück. It all started with a Raspberry Pi and the question whether I could store my photos myself. That turned into three servers.",
"p2": "When I'm not at the computer, I'm at Body Attack on Tuesdays, on the slopes in winter, or playing board games with friends.",
"facts": {
"laptop": {
"label": "Work machine",
"value": "MacBook Pro M4 Pro"
},
"items": {
"selfHostedServices": "Self-hosted services"
"pc": {
"label": "For games",
"value": "PC with RTX 3080"
},
"study": {
"label": "Study",
"value": "Master's in computer science"
},
"languages": {
"label": "Languages",
"value": "German, English"
}
}
},
"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."
"chat": {
"title": "ask me · AI",
"welcome": "Hi! Ask me anything about Dennis, his projects or his setup.",
"consent": "The chat runs through an AI service in my n8n. Your messages are processed and briefly stored for that. Nothing is sent until you agree.",
"allow": "Allow chat",
"placeholder": "Your question …",
"send": "Send",
"error": "No connection right now. Try again in a moment.",
"thinking": "Writing a reply",
"note": "Answers come from an AI and can be wrong."
},
"contact": {
"title": "Contact Me",
"subtitle": "Interested in working together or have questions about my projects? Feel free to reach out!",
"title": "contact",
"body": "For projects, questions about my setup or just because. I usually reply within a day or two.",
"copy": "Copy address",
"copied": "Copied",
"form": "To the form"
}
},
"contact": {
"title": "Write me.",
"subtitle": "For projects, questions about my setup or just because. I usually reply within a day or two.",
"getInTouch": "Get In Touch",
"getInTouchBody": "I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation.",
"info": {
@@ -185,13 +210,13 @@
}
},
"footer": {
"role": "Software Engineer",
"madeIn": "Made in Germany",
"role": "IT & self-hosting",
"madeIn": "self-hosted in Germany",
"legalNotice": "Legal notice",
"privacyPolicy": "Privacy policy",
"privacySettings": "Privacy settings",
"privacySettingsTitle": "Show privacy settings banner again",
"builtWith": "Built with",
"builtWith": "Built with Next.js and Directus, running on my own servers.",
"aiDisclaimer": "Some content on this site may be AI-assisted.",
"backToTop": "Back to top",
"systemsOnline": "Systems Online"
@@ -211,21 +236,5 @@
"subtitle": "Books that shaped my mindset and expanded my horizons.",
"backHome": "Back Home",
"empty": "No books yet."
},
"about": {
"status": "Status",
"aiAssistant": "AI Assistant",
"library": "Library",
"viewAll": "View All",
"myGear": "My Gear",
"gearMain": "Main",
"gearPC": "PC",
"gearServer": "Server",
"gearOS": "OS",
"curiosity": "Curiosity beyond software engineering.",
"connect": "Connect",
"email": "Email",
"code": "Code",
"professional": "Professional"
}
}
+471 -1
View File
@@ -34,7 +34,8 @@
"react-markdown": "^10.1.0",
"redis": "^5.8.2",
"sanitize-html": "^2.17.0",
"tailwind-merge": "^2.6.0"
"tailwind-merge": "^2.6.0",
"vaul": "1.1.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -2994,6 +2995,323 @@
"@prisma/debug": "5.22.0"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
"integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
"license": "MIT"
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz",
"integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz",
"integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.2.0",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.3.0",
"@radix-ui/react-dismissable-layer": "1.2.1",
"@radix-ui/react-focus-guards": "1.2.0",
"@radix-ui/react-focus-scope": "1.3.1",
"@radix-ui/react-id": "1.1.4",
"@radix-ui/react-portal": "1.2.0",
"@radix-ui/react-presence": "1.1.11",
"@radix-ui/react-primitive": "2.2.0",
"@radix-ui/react-slot": "1.5.0",
"@radix-ui/react-use-controllable-state": "1.3.0",
"@radix-ui/react-use-layout-effect": "1.1.4",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz",
"integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.2.0",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-primitive": "2.2.0",
"@radix-ui/react-use-callback-ref": "1.1.5",
"@radix-ui/react-use-effect-event": "0.0.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz",
"integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz",
"integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-primitive": "2.2.0",
"@radix-ui/react-use-callback-ref": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz",
"integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.2.0",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz",
"integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.5.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz",
"integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz",
"integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.2.0",
"@radix-ui/react-use-effect-event": "0.0.5",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@redis/bloom": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.10.0.tgz",
@@ -5013,6 +5331,18 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -6388,6 +6718,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -7742,6 +8078,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@@ -12661,6 +13006,75 @@
"react": ">=18"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -14518,6 +14932,27 @@
"requires-port": "^1.0.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-intl": {
"version": "4.8.2",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.2.tgz",
@@ -14539,6 +14974,28 @@
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -14577,6 +15034,19 @@
"node": ">=10.12.0"
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+2 -1
View File
@@ -78,7 +78,8 @@
"react-markdown": "^10.1.0",
"redis": "^5.8.2",
"sanitize-html": "^2.17.0",
"tailwind-merge": "^2.6.0"
"tailwind-merge": "^2.6.0",
"vaul": "1.1.2"
},
"browserslist": [
"chrome >= 100",
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* Räumt Directus nach dem Plakat-Redesign auf.
*
* Erst ausführen, wenn das Redesign auf production (dk0.dev) live ist — die alte
* Seite liest diese Einträge noch.
*
* node scripts/directus-cleanup-redesign.mjs # Probelauf, ändert nichts
* node scripts/directus-cleanup-redesign.mjs --apply # sichert, dann ändern
*
* Was passiert (mit --apply):
* - hobbies: alle auf status "archived" (die neue Seite zeigt keine Hobby-Karten)
* - tech_stack_categories: alle auf "archived" (Tech-Stack steht jetzt im Laufband)
* - messages: hero.badge, hero.line1, hero.line2, about.quote.idle löschen
* (überschrieben alte Hero-Texte, werden nicht mehr gelesen)
* - content_pages: home-hero und home-about (beide Sprachen) auf "archived"
*
* Vorher wird alles Betroffene vollständig nach directus-backup-redesign-<zeit>.json gesichert.
* Archivieren lässt sich im Directus-Admin jederzeit rückgängig machen.
*/
import fs from "node:fs";
import path from "node:path";
const APPLY = process.argv.includes("--apply");
function loadEnv(file) {
if (!fs.existsSync(file)) return {};
return Object.fromEntries(
fs
.readFileSync(file, "utf8")
.split("\n")
.filter((l) => /^[A-Z_]+=/.test(l))
.map((l) => {
const i = l.indexOf("=");
return [l.slice(0, i), l.slice(i + 1).replace(/^["']|["']$/g, "")];
}),
);
}
const env = { ...loadEnv(path.join(process.cwd(), ".env")), ...process.env };
const BASE = env.DIRECTUS_URL || "https://cms.dk0.dev";
const TOKEN = env.DIRECTUS_STATIC_TOKEN;
if (!TOKEN) {
console.error("DIRECTUS_STATIC_TOKEN fehlt (.env oder Umgebung).");
process.exit(1);
}
const MESSAGE_KEYS = ["hero.badge", "hero.line1", "hero.line2", "about.quote.idle"];
const PAGE_SLUGS = ["home-hero", "home-hero-de-de", "home-about", "home-about-de-de"];
async function rest(method, p, body) {
const res = await fetch(BASE + p, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${method} ${p}: HTTP ${res.status} ${await res.text()}`);
return res.status === 204 ? null : res.json();
}
async function gql(query) {
const res = await fetch(BASE + "/graphql", {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
const json = await res.json();
if (json.errors) throw new Error(JSON.stringify(json.errors));
return json.data;
}
const hobbies = (await rest("GET", "/items/hobbies?fields=*,translations.*&limit=-1")).data;
const categories = (await rest("GET", "/items/tech_stack_categories?fields=*,translations.*&limit=-1")).data;
const messages = (await rest("GET", `/items/messages?fields=*,translations.*&limit=-1`)).data.filter((m) =>
MESSAGE_KEYS.includes(m.key),
);
const pages = (
await gql(`{ content_pages(limit: -1) { slug locale status title content meta_description keywords } }`)
).content_pages.filter((p) => PAGE_SLUGS.includes(p.slug));
const activeHobbies = hobbies.filter((h) => h.status !== "archived");
const activeCategories = categories.filter((c) => c.status !== "archived");
const activePages = pages.filter((p) => p.status !== "archived");
console.log(`${APPLY ? "AUSFÜHREN" : "PROBELAUF"} gegen ${BASE}\n`);
console.log(`hobbies archivieren: ${activeHobbies.length} (${activeHobbies.map((h) => h.key).join(", ") || "-"})`);
console.log(`tech_stack_categories archivieren: ${activeCategories.length} (${activeCategories.map((c) => c.key).join(", ") || "-"})`);
console.log(`messages löschen: ${messages.length} (${messages.map((m) => m.key).join(", ") || "-"})`);
console.log(`content_pages archivieren: ${activePages.length} (${activePages.map((p) => p.slug).join(", ") || "-"})`);
if (!APPLY) {
console.log("\nNichts geändert. Mit --apply ausführen, wenn das Redesign auf production live ist.");
process.exit(0);
}
const backupFile = path.join(process.cwd(), `directus-backup-redesign-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
fs.writeFileSync(backupFile, JSON.stringify({ hobbies, categories, messages, pages }, null, 2));
console.log(`\nSicherung: ${backupFile}`);
for (const h of activeHobbies) await rest("PATCH", `/items/hobbies/${h.id}`, { status: "archived" });
for (const c of activeCategories) await rest("PATCH", `/items/tech_stack_categories/${c.id}`, { status: "archived" });
for (const m of messages) await rest("DELETE", `/items/messages/${m.id}`);
for (const p of activePages) {
try {
await rest("PATCH", `/items/content_pages/${encodeURIComponent(p.slug)}`, { status: "archived" });
} catch {
// REST ist für content_pages teils gesperrt, GraphQL geht
await gql(`mutation { update_content_pages_item(id: "${p.slug}", data: { status: "archived" }) { slug } }`);
}
}
console.log("Fertig.");
+1 -15
View File
@@ -99,21 +99,6 @@ export default {
// Feste Objektfarben auf dem Tisch, unabhängig vom Theme
polaroid: { DEFAULT: "#fbfaf8", ink: "#140c0b" },
bookcover: { DEFAULT: "#2b1a14", text: "#f3e7dc" },
// Warm brown palette
cream: "#FAF8F3",
sand: "#EFEBE9",
brown: {
50: "#EFEBE9",
100: "#D7CCC8",
200: "#BCAAA4",
300: "#A1887F",
400: "#8D6E63",
500: "#795548",
600: "#6D4C41",
700: "#5D4037",
800: "#4E342E",
900: "#3E2723",
},
stone: {
50: "#FAFAF9",
100: "#F5F5F4",
@@ -126,6 +111,7 @@ export default {
800: "#292524",
900: "#1C1917",
},
// Alte Pastellpalette, nur noch im Admin-Bereich und auf der 404-Seite
liquid: {
mint: "#A7F3D0",
lavender: "#DDD6FE",
-34
View File
@@ -20,40 +20,6 @@ export interface FooterTranslations {
builtWith: string;
}
export interface AboutTranslations {
title: string;
p1: string;
p2: string;
p3: string;
funFactTitle: string;
funFactBody: string;
techStackTitle: string;
techStack: {
categories: {
frontendMobile: string;
backendDevops: string;
toolsAutomation: string;
securityAdmin: string;
};
items: {
selfHostedServices: string;
};
};
hobbiesTitle: string;
hobbies: {
selfHosting: string;
gaming: string;
gameServers: string;
jogging: string;
};
}
export interface ProjectsTranslations {
title: string;
subtitle: string;
viewAll: string;
}
export interface ContactTranslations {
title: string;
subtitle: string;