2 Commits

Author SHA1 Message Date
denshooter 486555dbf2 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:01:26 +02:00
denshooter 9e6ae866d0 feat: plakat redesign phase 4 (chat window, contact, footer, cleanup) (#77)
CI / CD / test-build (push) Successful in 11m13s
CI / CD / deploy-dev (push) Successful in 2m25s
CI / CD / deploy-production (push) Has been skipped
- AI chat moves into a window/sheet, opened from a new speech-bubble card on
  the desk; it now respects the chatbot consent and asks before sending
  anything to n8n (previously it ignored the consent)
- Contact as red poster section with big mail address and the form on a paper
  card; validation errors are finally shown inline (aria-invalid, role=alert)
- Footer as ink strip with dk0 wordmark, legal links and a button to reopen
  the privacy settings
- "About me" in the header opens the about window on the home page
- Remove the old About section and its parts (BentoChat, ActivityFeed,
  CurrentlyReading, ReadBooks), the unused ChatWidget, Projects and BentoGrid,
  their tests, wrappers, loaders, types and texts
- scripts/directus-cleanup-redesign.mjs: dry run by default, --apply archives
  hobbies, tech stack categories and old content pages and deletes four
  unused message overrides after a full JSON backup; run only after the
  redesign is live on production

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:49:56 +02:00
53 changed files with 1451 additions and 3576 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. `AnimatePresence` is fine for modals and overlays that only appear after user interaction.
## Design system ## Design system (Plakat)
- Colors: only `liquid-*` tokens — no hardcoded hex or raw Tailwind palette colors - 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)
- Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15 backdrop-blur-sm border-2 rounded-xl` - Ink and paper swap roles in dark mode on their own; `signal` stays red
- Headlines: `uppercase tracking-tighter` with accent dot `<span className="text-emerald-600">.</span>` - Fonts: `font-display` (Bricolage, `[font-variation-settings:'wdth'_80]` for posters), `font-sans` (Geist), `font-mono` (Geist Mono)
- Body text: `text-stone-600 dark:text-stone-400` — never `text-stone-400` alone (fails contrast) - 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 ## Async components
Every component that fetches data must have a Skeleton loading state shown while data loads. 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 # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Sicherungen aus scripts/directus-cleanup-redesign.mjs
directus-backup-*.json
+111 -104
View File
@@ -1,114 +1,121 @@
"use client";
import { Star, ArrowLeft } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import type { Metadata } from "next";
import { useLocale, useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server";
import { Skeleton } from "@/app/components/ui/Skeleton"; import { getBookReviews, type BookReview } from "@/lib/directus";
import { BookReview } from "@/lib/directus"; import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import Header from "@/app/components/Header"; import SubpageShell from "@/app/_ui/SubpageShell";
import Footer from "@/app/components/Footer"; import { BackArrow, SubpageIntro, card, ghost, monoLabel } from "@/app/_ui/subpage";
import ScrollFadeIn from "@/app/components/ScrollFadeIn";
export default function BooksPage() { export const revalidate = 300;
const locale = useLocale();
const t = useTranslations("books");
const common = useTranslations("common");
const [reviews, setReviews] = useState<BookReview[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { export async function generateMetadata({
const fetchBooks = async () => { params,
try { }: {
const res = await fetch(`/api/book-reviews?locale=${locale}`); params: Promise<{ locale: string }>;
const data = await res.json(); }): Promise<Metadata> {
if (data.bookReviews) setReviews(data.bookReviews); const { locale } = await params;
} catch { const isDe = locale === "de";
if (process.env.NODE_ENV === "development") { return {
// console.error in dev only title: isDe ? "Bibliothek – Dennis Konkol" : "Library – Dennis Konkol",
} alternates: {
} finally { canonical: toAbsoluteUrl(`/${locale}/books`),
setLoading(false); 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 ( return (
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 transition-colors duration-500"> <SubpageShell locale={locale}>
<Header /> <SubpageIntro
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true" /> back={
<main className="pt-4 sm:pt-8 pb-20 px-4 sm:px-6"> <Link href={`/${locale}`} className={ghost}>
<div className="max-w-7xl mx-auto"> <BackArrow /> {common("back")}
<div className="mb-20"> </Link>
<Link }
href={`/${locale}`} kicker={reviews.length > 0 ? `${String(reviews.length).padStart(2, "0")} ${locale === "de" ? "Bücher" : "books"}` : undefined}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group" title={t("title")}
> >
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" /> <p>{t("subtitle")}</p>
<span className="font-bold uppercase tracking-widest text-xs">{common("backToHome")}</span> </SubpageIntro>
</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>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> {reviews.length === 0 ? (
{loading ? ( <div className={`${card} mt-10 p-6 md:p-8`}>
Array.from({ length: 6 }).map((_, i) => ( <p className="font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold leading-tight tracking-[-0.03em]">{t("empty")}</p>
<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>
</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>
)}
<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> </div>
</main> ) : (
<Footer /> <ul className="mt-10 grid gap-6 sm:grid-cols-2 md:gap-8 lg:grid-cols-3">
</div> {reviews.map((review) => (
<li key={review.id} className="min-w-0">
<BookCard review={review} locale={locale} />
</li>
))}
</ul>
)}
</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 type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo"; 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({ export async function generateMetadata({
params, params,
@@ -10,6 +11,7 @@ export async function generateMetadata({
const { locale } = await params; const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "legal-notice" }); const languages = getLanguageAlternates({ pathWithoutLocale: "legal-notice" });
return { return {
title: locale === "de" ? "Impressum – Dennis Konkol" : "Legal notice – Dennis Konkol",
alternates: { alternates: {
canonical: toAbsoluteUrl(`/${locale}/legal-notice`), canonical: toAbsoluteUrl(`/${locale}/legal-notice`),
languages, 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 type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo"; 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({ export async function generateMetadata({
params, params,
@@ -10,6 +11,7 @@ export async function generateMetadata({
const { locale } = await params; const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "privacy-policy" }); const languages = getLanguageAlternates({ pathWithoutLocale: "privacy-policy" });
return { return {
title: locale === "de" ? "Datenschutz – Dennis Konkol" : "Privacy policy – Dennis Konkol",
alternates: { alternates: {
canonical: toAbsoluteUrl(`/${locale}/privacy-policy`), canonical: toAbsoluteUrl(`/${locale}/privacy-policy`),
languages, 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 { prisma } from "@/lib/prisma";
import ProjectDetailClient from "@/app/_ui/ProjectDetailClient"; import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
import SubpageShell from "@/app/_ui/SubpageShell";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo"; import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
@@ -107,13 +108,13 @@ export default async function ProjectPage({
}; };
return ( return (
<> <SubpageShell locale={locale}>
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
<ProjectDetailClient project={projectData} locale={locale} /> <ProjectDetailClient project={projectData} locale={locale} />
</> </SubpageShell>
); );
} }
+6 -1
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import ProjectsPageClient, { ProjectListItem } from "@/app/_ui/ProjectsPageClient"; import ProjectsPageClient, { ProjectListItem } from "@/app/_ui/ProjectsPageClient";
import SubpageShell from "@/app/_ui/SubpageShell";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo"; import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import { getProjects as getDirectusProjects } from "@/lib/directus"; import { getProjects as getDirectusProjects } from "@/lib/directus";
@@ -94,6 +95,10 @@ export default async function ProjectsPage({
return dateB - dateA; 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 -19
View File
@@ -83,23 +83,17 @@ describe("i18n: message files", () => {
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
const WRAPPED_COMPONENT_NAMESPACES: Record<string, string[]> = { const WRAPPED_COMPONENT_NAMESPACES: Record<string, string[]> = {
About: ["home.about", "about"],
Projects: ["home.projects"],
Contact: ["home.contact", "home.contact.form", "home.contact.info"], Contact: ["home.contact", "home.contact.form", "home.contact.info"],
Footer: ["footer"], Footer: ["footer"],
}; };
const CLIENT_WRAPPER_PROVIDES: Record<string, string[]> = { const CLIENT_WRAPPER_PROVIDES: Record<string, string[]> = {
AboutClient: ["home.about", "about"],
ProjectsClient: ["home.projects"],
ContactClient: ["home.contact"], ContactClient: ["home.contact"],
FooterClient: ["footer"], FooterClient: ["footer"],
}; };
describe("i18n: ClientWrappers provide all requested namespaces", () => { describe("i18n: ClientWrappers provide all requested namespaces", () => {
const componentToWrapper: Record<string, string> = { const componentToWrapper: Record<string, string> = {
About: "AboutClient",
Projects: "ProjectsClient",
Contact: "ContactClient", Contact: "ContactClient",
Footer: "FooterClient", Footer: "FooterClient",
}; };
@@ -130,27 +124,25 @@ describe("i18n: ClientWrappers provide all requested namespaces", () => {
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
const STATIC_KEYS: Record<string, Record<string, string[]>> = { const STATIC_KEYS: Record<string, Record<string, string[]>> = {
nav: { Header: ["home", "about", "projects", "contact"] }, nav: { HeaderClient: ["home", "about", "projects", "contact"] },
common: { common: {
"books/page": ["back"], "legal-notice/page": ["back"], "privacy-policy/page": ["back"], "books/page": ["back"], LegalNoticeClient: ["backToHome"], PrivacyPolicyClient: ["backToHome"],
ProjectDetailClient: ["back"], ProjectsPageClient: ["backToHome"], ProjectDetailClient: ["back", "backToProjects"], ProjectsPageClient: ["backToHome"],
}, },
consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] }, consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] },
"home.hero": { Hero: ["kicker", "claim", "intro", "countdown", "ctaWork", "ctaContact"] }, "home.hero": { Hero: ["kicker", "claim", "intro", "countdown", "ctaWork", "ctaContact"] },
"home.tisch": { TischSection: ["title", "allProjects", "hintBoard", "hintDeck", "tidy", "mess", "flip", "details", "prev", "next", "ticketTitle", "ticketDays", "ticketJob", "ticketStub", "serverTitle", "servers.ingress.name", "servers.ingress.detail", "servers.mail.name", "servers.mail.detail", "servers.home.name", "servers.home.detail", "serverNote", "bookLabel", "bookFallback", "photoCaption", "sticker"] }, "home.tisch": { TischSection: ["title", "allProjects", "hintBoard", "hintDeck", "tidy", "mess", "flip", "details", "prev", "next", "ticketTitle", "ticketDays", "ticketJob", "ticketStub", "serverTitle", "servers.ingress.name", "servers.ingress.detail", "servers.mail.name", "servers.mail.detail", "servers.home.name", "servers.home.detail", "serverNote", "bookLabel", "bookFallback", "photoCaption", "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", "contact.title", "contact.body", "contact.copy", "contact.copied", "contact.form"] }, "home.windows": { HomePageServer: ["close", "minimize", "openWindows", "projectPage", "source", "live", "stack", "year", "now.title", "now.days", "now.study.label", "now.study.text", "now.work.label", "now.work.text", "now.side.label", "now.side.text", "server.title", "server.headline", "read.title", "read.progress", "read.empty", "read.library", "about.title", "about.p1", "about.p2", "about.facts.laptop.label", "about.facts.laptop.value", "about.facts.pc.label", "about.facts.pc.value", "about.facts.study.label", "about.facts.study.value", "about.facts.languages.label", "about.facts.languages.value", "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.about": { About: ["title", "p1", "p2", "p3", "funFactTitle", "funFactBody", "hobbiesTitle"] },
"home.about.activity": { ActivityFeed: ["idleStatus", "codingNow", "gaming", "inGame", "listening"] },
"home.about.currentlyReading": { CurrentlyReading: ["title", "progress"] },
"home.about.readBooks": { ReadBooks: ["title", "showMore", "showLess", "readMore", "collapseReview", "finishedAt", "empty"] },
"home.projects": { Projects: ["title", "subtitle", "viewAll", "noProjects"] },
"home.contact": { Contact: ["title", "subtitle"] }, "home.contact": { Contact: ["title", "subtitle"] },
"home.contact.form": { Contact: ["title", "sending", "send", "labels.name", "labels.email", "labels.subject", "labels.message", "placeholders.name", "placeholders.email", "placeholders.subject", "placeholders.message", "errors.nameRequired", "errors.nameMin", "errors.emailRequired", "errors.emailInvalid", "errors.subjectRequired", "errors.subjectMin", "errors.messageRequired", "errors.messageMin", "characters"] }, "home.contact.form": { Contact: ["title", "sending", "send", "labels.name", "labels.email", "labels.subject", "labels.message", "placeholders.name", "placeholders.email", "placeholders.subject", "placeholders.message", "errors.nameRequired", "errors.nameMin", "errors.emailRequired", "errors.emailInvalid", "errors.subjectRequired", "errors.subjectMin", "errors.messageRequired", "errors.messageMin", "characters"] },
"home.contact.info": { Contact: ["locationValue"] }, "home.contact.info": { Contact: ["locationValue"] },
about: { About: ["status", "aiAssistant", "library", "viewAll", "myGear", "gearMain", "gearPC", "gearServer", "gearOS", "curiosity"] },
"projects.list": { ProjectsPageClient: ["title", "intro", "searchPlaceholder", "all", "noResults", "clearFilters"] }, "projects.list": { ProjectsPageClient: ["title", "intro", "searchPlaceholder", "all", "noResults", "clearFilters"] },
"projects.detail": { ProjectDetailClient: ["liveDemo", "viewSource"] }, "projects.detail": { ProjectDetailClient: ["links", "liveDemo", "viewSource", "techStack"] },
footer: { Footer: ["role", "madeIn", "legalNotice", "privacyPolicy", "privacySettings", "privacySettingsTitle", "builtWith", "aiDisclaimer", "backToTop", "systemsOnline"] }, 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"] }, notFound: { "not-found": ["title", "description", "returnHome", "goBack", "exploreWork", "exploreWorkDesc", "viewProjects", "errorReport"] },
books: { "books/page": ["title", "subtitle", "empty"] }, books: { "books/page": ["title", "subtitle", "empty"] },
}; };
+2 -9
View File
@@ -6,12 +6,10 @@ import { getWindowLabels } from "../components/fenster/getWindowLabels";
import ScrollFadeIn from "../components/ScrollFadeIn"; import ScrollFadeIn from "../components/ScrollFadeIn";
import Script from "next/script"; import Script from "next/script";
import { import {
getAboutTranslations,
getContactTranslations, getContactTranslations,
getFooterTranslations, getFooterTranslations,
} from "@/lib/translations-loader"; } from "@/lib/translations-loader";
import { import {
AboutClient,
ContactClient, ContactClient,
FooterClient, FooterClient,
} from "../components/ClientWrappers"; } from "../components/ClientWrappers";
@@ -22,8 +20,7 @@ interface HomePageServerProps {
export default async function HomePageServer({ locale }: HomePageServerProps) { export default async function HomePageServer({ locale }: HomePageServerProps) {
// Parallel laden aller Translations (hero translations handled by Hero server component) // Parallel laden aller Translations (hero translations handled by Hero server component)
const [aboutT, contactT, footerT, windowLabels] = await Promise.all([ const [contactT, footerT, windowLabels] = await Promise.all([
getAboutTranslations(locale),
getContactTranslations(locale), getContactTranslations(locale),
getFooterTranslations(locale), getFooterTranslations(locale),
getWindowLabels(locale), getWindowLabels(locale),
@@ -88,16 +85,12 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
}), }),
}} }}
/> />
<Header locale={locale} />
<WindowProvider labels={windowLabels}> <WindowProvider labels={windowLabels}>
<Header locale={locale} />
<main className="relative"> <main className="relative">
<Hero locale={locale} /> <Hero locale={locale} />
<TischSection locale={locale} /> <TischSection locale={locale} />
<ScrollFadeIn>
<AboutClient locale={locale} translations={aboutT} />
</ScrollFadeIn>
<ScrollFadeIn> <ScrollFadeIn>
<ContactClient locale={locale} translations={contactT} /> <ContactClient locale={locale} translations={contactT} />
</ScrollFadeIn> </ScrollFadeIn>
+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>
</>
);
}
+89 -125
View File
@@ -1,12 +1,12 @@
"use client"; "use client";
import { ExternalLink, ArrowLeft, Github as GithubIcon } from "lucide-react";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ProjectThumbnail from "@/app/components/ProjectThumbnail"; import ProjectThumbnail from "@/app/components/ProjectThumbnail";
import { BackArrow, SubpageIntro, btn, card, frame, ghost, monoLabel } from "./subpage";
export type ProjectDetailData = { export type ProjectDetailData = {
id: number; id: number;
@@ -39,137 +39,101 @@ export default function ProjectDetailClient({
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const tDetail = useTranslations("projects.detail"); const tDetail = useTranslations("projects.detail");
const router = useRouter(); const router = useRouter();
const [canGoBack, setCanGoBack] = useState(false);
useEffect(() => { const listHref = `/${locale}/projects`;
// Prüfen, ob wir eine History haben (von Home gekommen) const handleBack = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (typeof window !== 'undefined' && window.history.length > 1) { // Kommt man von der eigenen Seite, geht es per Browser-Zurück an die exakte Stelle
setCanGoBack(true); // (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();
const handleBack = (e: React.MouseEvent) => {
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(); 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 ( return (
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500"> <article>
<div className="max-w-7xl mx-auto"> <SubpageIntro
back={
<Link href={listHref} onClick={handleBack} className={ghost}>
<BackArrow /> {tCommon("back")}
</Link>
}
kicker={[project.category, year].filter(Boolean).join(" · ")}
title={project.title}
>
<p>{project.description}</p>
</SubpageIntro>
{/* Navigation - Intelligent Back */} <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">
<button {project.imageUrl ? (
onClick={handleBack} <Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="(max-width: 1400px) 100vw, 1400px" />
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" ) : (
> <div aria-hidden="true">
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" /> <ProjectThumbnail title={project.title} category={project.category} tags={project.tags} slug={project.slug} size="hero" />
<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
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">
<ReactMarkdown>{project.content}</ReactMarkdown>
</div>
</div>
</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" />
</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" />
</a>
)}
</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">
{tag}
</span>
))}
</div>
</div>
</div>
</div>
</div> </div>
</div>
<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>
) : (
<p className="max-w-[68ch] text-lg leading-relaxed">{project.description}</p>
)}
</div>
<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>
)}
{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>
)}
{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}
</li>
))}
</ul>
</section>
)}
<Link href={listHref} className={`${ghost} w-fit`}>
<BackArrow /> {tCommon("backToProjects")}
</Link>
</aside>
</div>
</article>
); );
} }
+144 -112
View File
@@ -1,13 +1,12 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { motion } from "framer-motion";
import { ArrowUpRight, ArrowLeft, Search } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl";
import Image from "next/image"; import Image from "next/image";
import { Skeleton } from "../components/ui/Skeleton"; import { useTranslations } from "next-intl";
import ProjectThumbnail from "@/app/components/ProjectThumbnail"; import ProjectThumbnail from "@/app/components/ProjectThumbnail";
import ScrollFadeIn from "@/app/components/ScrollFadeIn";
import { BackArrow, SubpageIntro, card, ghost, monoLabel } from "./subpage";
export type ProjectListItem = { export type ProjectListItem = {
id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
@@ -21,6 +20,18 @@ export type ProjectListItem = {
imageUrl?: string | null; 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({ export default function ProjectsPageClient({
projects, projects,
locale, locale,
@@ -33,13 +44,6 @@ export default function ProjectsPageClient({
const [selectedCategory, setSelectedCategory] = useState("all"); const [selectedCategory, setSelectedCategory] = useState("all");
const [searchQuery, setSearchQuery] = useState(""); 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 categories = useMemo(() => {
const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean); const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean);
@@ -54,115 +58,143 @@ export default function ProjectsPageClient({
if (searchQuery) { if (searchQuery) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
result = result.filter( 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; return result;
}, [projects, selectedCategory, searchQuery]); }, [projects, selectedCategory, searchQuery]);
const resetFilters = () => {
setSelectedCategory("all");
setSearchQuery("");
};
return ( 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"> <SubpageIntro
back={
{/* Header */} <Link href={`/${locale}`} className={ghost}>
<div className="mb-24"> <BackArrow /> {tCommon("backToHome")}
<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>
</Link> </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"> {/* Filter: Kategorien als Pillen, Suche als Tinten-Feld */}
{tList("title")}<span className="text-liquid-mint">.</span> <div className="mt-8 flex flex-col gap-4 md:mt-10 md:flex-row md:items-center md:justify-between">
</h1> <div className="flex flex-wrap gap-2" role="group" aria-label={locale === "de" ? "Kategorien" : "Categories"}>
<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"> {categories.map((cat) => (
{tList("intro")} <button
</p> key={cat}
</div> type="button"
onClick={() => setSelectedCategory(cat)}
{/* Filters */} aria-pressed={selectedCategory === cat}
<div className="flex flex-col md:flex-row gap-8 justify-between items-start md:items-center mb-16"> className={filterPill(selectedCategory === cat)}
<div className="flex flex-wrap gap-2"> >
{categories.map((cat) => ( {cat === "all" ? tList("all") : cat}
<button </button>
key={cat} ))}
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"
}`}
>
{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} />
<input
type="text"
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"
/>
</div>
</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" />
</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">
{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>
) : (
<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}
tags={project.tags}
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>
<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>
))}
</div>
</div>
</div>
</Link>
</motion.div>
)))}
</div> </div>
<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="search"
placeholder={tList("searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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"
/>
</label>
</div> </div>
</div>
{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>
) : (
<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 ? (
<Image
src={project.imageUrl}
alt=""
fill
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
className="object-cover"
/>
) : (
<ProjectThumbnail
title={project.title}
category={project.category}
tags={project.tags}
slug={project.slug}
size="card"
/>
)}
</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="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>
))}
</ul>
)}
</div>
</Link>
</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. // Lazy-load below-fold components so their JS doesn't block initial paint / LCP.
// SSR stays on (default) so content is in the initial HTML for SEO. // SSR stays on (default) so content is in the initial HTML for SEO.
const About = dynamic(() => import('./About'));
const Projects = dynamic(() => import('./Projects'));
const Contact = dynamic(() => import('./Contact')); const Contact = dynamic(() => import('./Contact'));
const Footer = dynamic(() => import('./Footer')); const Footer = dynamic(() => import('./Footer'));
import type { import type {
AboutTranslations,
ProjectsTranslations,
ContactTranslations, ContactTranslations,
FooterTranslations, FooterTranslations,
} from '@/types/translations'; } from '@/types/translations';
@@ -29,41 +25,6 @@ function getNormalizedLocale(locale: string): 'en' | 'de' {
return locale.startsWith('de') ? 'de' : 'en'; return locale.startsWith('de') ? 'de' : 'en';
} }
export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
const messages = {
home: {
about: baseMessages.home.about
},
about: baseMessages.about
};
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<About />
</NextIntlClientProvider>
);
}
export function ProjectsClient({ locale }: { locale: string; translations: ProjectsTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
const messages = {
home: {
projects: baseMessages.home.projects
}
};
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<Projects />
</NextIntlClientProvider>
);
}
export function ContactClient({ locale }: { locale: string; translations: ContactTranslations }) { export function ContactClient({ locale }: { locale: string; translations: ContactTranslations }) {
const normalLocale = getNormalizedLocale(locale); const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale]; const baseMessages = messageMap[normalLocale];
+96 -189
View File
@@ -1,8 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { motion } from "framer-motion"; import { Send, Github, Linkedin } from "lucide-react";
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@@ -80,6 +79,7 @@ const Contact = () => {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setTouched({ name: true, email: true, subject: true, message: true });
if (!validateForm()) { if (!validateForm()) {
return; return;
@@ -152,196 +152,103 @@ const Contact = () => {
validateForm(); validateForm();
}; };
const fields: { name: keyof typeof formData; type: string; multiline?: boolean; autoComplete?: string }[] = [
{ name: "name", type: "text", autoComplete: "name" },
{ name: "email", type: "email", autoComplete: "email" },
{ name: "subject", type: "text" },
{ name: "message", type: "text", multiline: true },
];
const pill =
"inline-flex items-center gap-2 rounded-full px-5 py-3 text-[15px] font-semibold shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-signal active:scale-[0.97]";
return ( return (
<section <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">
id="contact" <div className="grid gap-10 lg:grid-cols-[1.1fr_1fr] lg:items-start">
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="min-w-0">
> <p className="font-mono text-[13px]">{tInfo("locationValue")}</p>
<div className="max-w-7xl mx-auto"> <h2 className="mt-3 font-display text-[clamp(3rem,9vw,7.5rem)] font-extrabold leading-[0.86] tracking-[-0.05em] [font-variation-settings:'wdth'_80]">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8"> {t("title")}
</h2>
{/* Header Card */} {cmsHtml ? (
<motion.div <RichTextClient html={cmsHtml} className="prose mt-6 max-w-[46ch] text-lg leading-relaxed text-ink [&_*]:text-inherit" />
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" ) : (
<p className="mt-6 max-w-[46ch] text-lg leading-relaxed">{t("subtitle")}</p>
)}
<a
href="mailto:contact@dk0.dev"
className="mt-8 block w-fit font-display text-[clamp(2rem,6vw,4.4rem)] font-extrabold leading-none tracking-[-0.045em] [overflow-wrap:anywhere] [font-variation-settings:'wdth'_80] hover:underline hover:decoration-[3px] hover:underline-offset-[6px]"
> >
<div className="max-w-3xl"> contact<span className="text-paper">@</span>dk0.dev
<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"> </a>
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span> <div className="mt-6 flex flex-wrap gap-2">
</h2> <a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className={pill}>
{cmsHtml ? ( <Github size={16} aria-hidden="true" /> GitHub
<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" /> </a>
) : ( <a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className={pill}>
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400"> <Linkedin size={16} aria-hidden="true" /> LinkedIn
{t("subtitle")} </a>
</p> </div>
)}
</div>
</motion.div>
{/* Info Side (Unified Connect Box) */}
<motion.div
transition={{ delay: 0.1 }}
className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6"
>
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between relative overflow-hidden group">
<div className="relative z-10">
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-12">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Connect</p>
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 rounded-full border border-emerald-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-700 dark:text-emerald-400">Online</span>
</div>
</div>
<div className="space-y-5 sm:space-y-6 md:space-y-8">
{/* Email */}
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
<div className="flex flex-col">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Email</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
</div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-colors duration-200">
<Mail size={16} />
</div>
</a>
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
{/* GitHub */}
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
<div className="flex flex-col">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Code</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
</div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-colors duration-200">
<Github size={16} />
</div>
</a>
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
{/* LinkedIn */}
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
<div className="flex flex-col">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Professional</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
</div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-colors duration-200">
<Linkedin size={16} />
</div>
</a>
</div>
</div>
<div className="mt-6 sm:mt-8 md:mt-12 pt-4 sm:pt-6 md:pt-8 border-t border-stone-100 dark:border-stone-800 relative z-10">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-2">Location</p>
<div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
<MapPin size={14} className="text-liquid-mint" />
<span className="font-bold">{tInfo("locationValue")}</span>
</div>
</div>
</div>
</motion.div>
{/* Form Side */}
<motion.div
transition={{ delay: 0.2 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter mb-6 sm:mb-8 md:mb-10">
{tForm("title")}
</h3>
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6 md:space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
<div className="space-y-2">
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.name")}
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
onBlur={handleBlur}
required
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium"
placeholder={tForm("placeholders.name")}
/>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.email")}
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
required
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium"
placeholder={tForm("placeholders.email")}
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.subject")}
</label>
<input
type="text"
id="subject"
name="subject"
value={formData.subject}
onChange={handleChange}
onBlur={handleBlur}
required
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium"
placeholder={tForm("placeholders.subject")}
/>
</div>
<div className="space-y-2">
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.message")}
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
onBlur={handleBlur}
required
rows={5}
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium resize-none"
placeholder={tForm("placeholders.message")}
/>
</div>
<motion.button
type="submit"
disabled={isSubmitting}
whileTap={{ scale: 0.97 }}
transition={{ duration: 0.15, ease: [0.23, 1, 0.32, 1] }}
className="w-full py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] sm:tracking-[0.3em] flex items-center justify-center gap-3 shadow-xl hover:shadow-2xl transition-shadow duration-200 ease-out disabled:opacity-50"
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<Send size={16} />
{tForm("send")}
</>
)}
</motion.button>
</form>
</motion.div>
</div> </div>
<form
onSubmit={handleSubmit}
noValidate
className="grid gap-4 rounded-[18px] border-2 border-ink bg-paper p-5 shadow-[8px_8px_0_rgb(var(--plakat-ink))] md:p-7"
>
<h3 className="font-display text-2xl font-extrabold tracking-[-0.03em]">{tForm("title")}</h3>
{fields.map((f) => {
const showError = touched[f.name] && errors[f.name];
const common = {
id: f.name,
name: f.name,
value: formData[f.name],
onChange: handleChange,
onBlur: handleBlur,
required: true,
placeholder: tForm(`placeholders.${f.name}`),
"aria-invalid": showError ? true : undefined,
"aria-describedby": showError ? `${f.name}-error` : undefined,
className: `w-full rounded-xl border-2 bg-paper px-4 py-3 text-base text-ink placeholder:text-ink/40 transition-[border-color,box-shadow] duration-150 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-signal ${
showError ? "border-signal" : "border-ink"
}`,
};
return (
<div key={f.name} className="grid gap-1.5">
<label htmlFor={f.name} className="font-mono text-[13px]">
{tForm(`labels.${f.name}`)}
</label>
{f.multiline ? (
<textarea {...common} rows={5} className={`${common.className} resize-none`} />
) : (
<input {...common} type={f.type} autoComplete={f.autoComplete} />
)}
{showError && (
<p id={`${f.name}-error`} className="text-sm font-medium text-signal" role="alert">
{errors[f.name]}
</p>
)}
</div>
);
})}
<button
type="submit"
disabled={isSubmitting}
className="mt-1 inline-flex items-center justify-center gap-2.5 rounded-full bg-ink px-5 py-3.5 text-[15px] font-semibold text-paper transition-[opacity,transform] duration-150 ease-out active:scale-[0.97] disabled:opacity-50"
>
{isSubmitting ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-paper/30 border-t-paper" aria-hidden="true" />
{tForm("sending")}
</>
) : (
<>
<Send size={16} aria-hidden="true" />
{tForm("send")}
</>
)}
</button>
</form>
</div> </div>
</section> </section>
); );
-178
View File
@@ -1,178 +0,0 @@
"use client";
import { motion } from "framer-motion";
import { BookOpen } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "./ui/Skeleton";
interface CurrentlyReading {
title: string;
authors: string[];
image: string | null;
progress: number;
startedAt: string | null;
}
const CurrentlyReading = () => {
const t = useTranslations("home.about.currentlyReading");
const [books, setBooks] = useState<CurrentlyReading[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Nur einmal beim Laden der Seite
const fetchCurrentlyReading = async () => {
try {
const res = await fetch("/api/n8n/hardcover/currently-reading", {
cache: "default",
});
if (!res.ok) {
throw new Error("Failed to fetch");
}
const data = await res.json();
// Handle both single book and array of books
if (data.currentlyReading) {
const booksArray = Array.isArray(data.currentlyReading)
? data.currentlyReading
: [data.currentlyReading];
setBooks(booksArray);
} else {
setBooks([]);
}
} catch (error) {
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") console.error("Error fetching currently reading:", error);
}
setBooks([]);
} finally {
setLoading(false);
}
};
fetchCurrentlyReading();
}, []); // Leeres Array = nur einmal beim Mount
// Zeige nichts wenn kein Buch gelesen wird
if (books.length === 0 && !loading) {
return null;
}
if (loading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-5 w-40" />
</div>
<div className="rounded-xl border-2 border-stone-200 dark:border-stone-700 p-6 space-y-3">
<div className="flex gap-4">
<Skeleton className="w-24 h-36 sm:w-28 sm:h-40 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-2 w-full rounded-full" />
</div>
</div>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<BookOpen size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
{t("title")} {books.length > 1 && `(${books.length})`}
</h3>
</div>
{/* Books List */}
{books.map((book, index) => (
<motion.div
key={`${book.title}-${index}`}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, delay: index * 0.06, ease: [0.23, 1, 0.32, 1] }}
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-lavender/30 dark:border-stone-700 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 dark:hover:border-stone-600 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-colors duration-300"
>
{/* Background Blob Animation */}
<motion.div
className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
animate={{
scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay: index * 0.5,
}}
/>
<div className="relative z-10 flex flex-col sm:flex-row gap-4 items-start">
{/* Book Cover */}
{book.image && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.1 + index * 0.06, ease: [0.23, 1, 0.32, 1] }}
className="flex-shrink-0"
>
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
<Image
src={book.image}
alt={book.title}
fill
className="object-cover"
sizes="(max-width: 640px) 96px, 112px"
/>
{/* Glossy Overlay */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
</div>
</motion.div>
)}
{/* Book Info */}
<div className="flex-1 min-w-0">
{/* Title */}
<h4 className="text-lg font-bold text-stone-900 dark:text-stone-100 mb-1 line-clamp-2">
{book.title}
</h4>
{/* Authors */}
<p className="text-sm text-stone-600 dark:text-stone-400 mb-4 line-clamp-1">
{book.authors.join(", ")}
</p>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-stone-600 dark:text-stone-400">
<span>{t("progress")}</span>
<span className="font-semibold">{isNaN(book.progress) ? 0 : book.progress}%</span>
</div>
<div className="relative h-2 bg-white/50 dark:bg-stone-700 rounded-full overflow-hidden border border-white/70 dark:border-stone-600">
{/* scaleX instead of width: stays on the compositor, no layout per frame */}
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: (isNaN(book.progress) ? 0 : book.progress) / 100 }}
transition={{ duration: 0.8, delay: 0.2 + index * 0.06, ease: [0.23, 1, 0.32, 1] }}
className="origin-left w-full absolute left-0 top-0 h-full bg-gradient-to-r from-liquid-lavender via-liquid-pink to-liquid-rose rounded-full shadow-sm"
/>
</div>
</div>
</div>
</div>
</motion.div>
))}
</div>
);
};
export default CurrentlyReading;
+41 -65
View File
@@ -3,80 +3,56 @@
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { 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 Footer = () => {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("footer"); const t = useTranslations("footer");
const { resetConsent } = useConsent();
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
return ( return (
<footer className="bg-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"> <footer className="bg-ink px-4 pb-10 pt-12 text-paper sm:px-6 lg:px-10">
<div className="max-w-7xl mx-auto"> <div className="flex flex-wrap items-end justify-between gap-6">
<Link
href={`/${locale}`}
className="font-display text-[clamp(4rem,14vw,9rem)] font-extrabold leading-[0.8] tracking-[-0.05em]"
aria-label="dk0"
>
dk<span className="text-signal">0</span>
</Link>
<a href="#top" className={pill}>
{t("backToTop")} <span aria-hidden="true" className="ml-1.5">↑</span>
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end"> <nav className="mt-8 flex flex-wrap gap-2 border-t-2 border-paper/20 pt-6" aria-label={t("legalNotice")}>
{/* Copyright & Info */} <Link href={`/${locale}/legal-notice`} className={pill}>
<div className="md:col-span-4 space-y-6"> {t("legalNotice")}
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs"> </Link>
dk <Link href={`/${locale}/privacy-policy`} className={pill}>
</div> {t("privacyPolicy")}
<div className="space-y-2"> </Link>
<p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("role")}</p> <button type="button" onClick={resetConsent} className={pill} title={t("privacySettingsTitle")}>
<p className="text-stone-600 dark:text-stone-400 text-sm font-medium">&copy; {year} {t("madeIn")}.</p> {t("privacySettings")}
</div> </button>
</div> <a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className={pill}>
GitHub
</a>
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className={pill}>
LinkedIn
</a>
</nav>
{/* Navigation Links */} <div className="mt-8 grid gap-1.5 font-mono text-xs text-paper/70">
<div className="md:col-span-4 grid grid-cols-2 gap-8"> <p>
<div className="space-y-4"> © {year} Dennis Konkol · {t("role")} · {t("madeIn")}
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{t("legalNotice")}</p> </p>
<div className="flex flex-col gap-2"> <p>{t("builtWith")}</p>
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link> <p>{t("aiDisclaimer")}</p>
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
</div>
</div>
<div className="space-y-4">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Social</p>
<div className="flex flex-col gap-2">
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">GitHub</a>
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">LinkedIn</a>
</div>
</div>
</div>
{/* Back to Top */}
<div className="md:col-span-4 flex justify-start md:justify-end">
<button
onClick={scrollToTop}
className="group flex flex-col items-center gap-4 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
>
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-600 dark:text-stone-400" style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>{t("backToTop")}</span>
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 group-active:scale-[0.97] transition-[background-color,color,transform] duration-200 ease-out shadow-sm">
<ArrowUp size={20} />
</div>
</button>
</div>
</div>
{/* Bottom Bar */}
<div className="mt-10 sm:mt-16 md:mt-20 pt-6 sm:pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex flex-col gap-1">
<p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">
{t("builtWith")} Next.js, Directus &amp; Passion.
</p>
<p className="text-[10px] text-stone-500 dark:text-stone-500 tracking-wide">
{t("aiDisclaimer")}
</p>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">{t("systemsOnline")}</span>
</div>
</div>
</div> </div>
</footer> </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;
+27 -16
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import type { NavTranslations } from "@/types/translations"; import type { NavTranslations } from "@/types/translations";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
import OpenWindowLink from "./fenster/OpenWindowLink";
const SiGithubIcon = ({ size = 20 }: { size?: number }) => ( const SiGithubIcon = ({ size = 20 }: { size?: number }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
@@ -65,7 +66,8 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
const navItems = [ const navItems = [
{ name: translations.home, href: `/${locale}` }, { 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.projects, href: isHome ? "#projects" : `/${locale}/projects` },
{ name: translations.contact, href: isHome ? "#contact" : `/${locale}#contact` }, { name: translations.contact, href: isHome ? "#contact" : `/${locale}#contact` },
]; ];
@@ -114,11 +116,17 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
</Link> </Link>
<nav className="hidden items-center gap-2 md:flex" aria-label={de ? "Hauptnavigation" : "Main navigation"}> <nav className="hidden items-center gap-2 md:flex" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
{navItems.slice(1).map((item) => ( {navItems.slice(1).map((item) =>
<Link key={item.name} href={item.href} className={pill}> item.window ? (
{item.name} <OpenWindowLink key={item.name} href={item.href} request={item.window} className={pill}>
</Link> {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"> <div className="ml-2 flex items-center gap-1 border-l-2 border-ink/20 pl-3">
<Link href={deHref} className={langPill(locale === "de")} aria-current={locale === "de" ? "true" : undefined}> <Link href={deHref} className={langPill(locale === "de")} aria-current={locale === "de" ? "true" : undefined}>
DE DE
@@ -173,16 +181,19 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
</div> </div>
<nav className="flex flex-col border-t-2 border-ink" aria-label={de ? "Hauptnavigation" : "Main navigation"}> <nav className="flex flex-col border-t-2 border-ink" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
{navItems.map((item) => ( {navItems.map((item) => {
<Link const cls =
key={item.name} "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";
href={item.href} return item.window ? (
onClick={() => setIsOpen(false)} <OpenWindowLink key={item.name} href={item.href} request={item.window} onClick={() => setIsOpen(false)} className={cls}>
className="border-b-2 border-ink px-5 py-4 font-display text-3xl font-extrabold tracking-[-0.035em] transition-colors duration-150 active:bg-paper-2" {item.name}
> </OpenWindowLink>
{item.name} ) : (
</Link> <Link key={item.name} href={item.href} onClick={() => setIsOpen(false)} className={cls}>
))} {item.name}
</Link>
);
})}
</nav> </nav>
<div className="flex items-center gap-1 px-5 py-5"> <div className="flex items-center gap-1 px-5 py-5">
+37 -92
View File
@@ -24,73 +24,30 @@ interface ProjectThumbnailProps {
size?: "card" | "hero"; 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< const categoryThemes: Record<
string, string,
{ {
icon: LucideIcon; icon: LucideIcon;
gradient: string; tone: keyof typeof tones;
darkGradient: string;
iconColor: string;
darkIconColor: string;
pattern: "dots" | "grid" | "diagonal" | "circuit" | "waves" | "terminal"; pattern: "dots" | "grid" | "diagonal" | "circuit" | "waves" | "terminal";
} }
> = { > = {
"Web Development": { "Web Development": { icon: Code, tone: "signal", pattern: "circuit" },
icon: Code, "Mobile Development": { icon: Smartphone, tone: "ink", pattern: "waves" },
gradient: "from-liquid-sky/20 via-liquid-blue/10 to-liquid-lavender/20", "Web Application": { icon: Globe, tone: "paper", pattern: "dots" },
darkGradient: "dark:from-liquid-sky/10 dark:via-liquid-blue/5 dark:to-liquid-lavender/10", "Backend Development": { icon: Cpu, tone: "ink", pattern: "grid" },
iconColor: "text-blue-500", "Full-Stack Development": { icon: Boxes, tone: "signal", pattern: "grid" },
darkIconColor: "dark:text-blue-400", DevOps: { icon: Shield, tone: "ok", pattern: "diagonal" },
pattern: "circuit", default: { icon: Wrench, tone: "paper", pattern: "dots" },
},
"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",
},
}; };
const slugIcons: Record<string, LucideIcon> = { const slugIcons: Record<string, LucideIcon> = {
@@ -106,7 +63,7 @@ const slugIcons: Record<string, LucideIcon> = {
function PatternOverlay({ pattern, id }: { pattern: string; id: string }) { function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
const patterns: Record<string, React.ReactNode> = { const patterns: Record<string, React.ReactNode> = {
dots: ( 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> <defs>
<pattern id={`pat-dots-${id}`} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse"> <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" /> <circle cx="2" cy="2" r="1.5" fill="currentColor" />
@@ -116,7 +73,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg> </svg>
), ),
grid: ( 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> <defs>
<pattern id={`pat-grid-${id}`} x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse"> <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" /> <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> </svg>
), ),
diagonal: ( 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> <defs>
<pattern id={`pat-diag-${id}`} x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse"> <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" /> <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> </svg>
), ),
circuit: ( 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> <defs>
<pattern id={`pat-circ-${id}`} x="0" y="0" width="60" height="60" patternUnits="userSpaceOnUse"> <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" /> <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> </svg>
), ),
waves: ( 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> <defs>
<pattern id={`pat-wave-${id}`} x="0" y="0" width="100" height="20" patternUnits="userSpaceOnUse"> <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" /> <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> </svg>
), ),
terminal: ( 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> <defs>
<pattern id={`pat-term-${id}`} x="0" y="0" width="200" height="30" patternUnits="userSpaceOnUse"> <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> <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 Icon = theme.icon;
const isHero = size === "hero"; const isHero = size === "hero";
const displayTags = tags?.slice(0, 3) ?? []; const displayTags = tags?.slice(0, isHero ? 5 : 3) ?? [];
return ( return (
<div <div className={`absolute inset-0 flex flex-col justify-between ${tones[theme.tone]} ${isHero ? "p-5 sm:p-8" : "p-4 sm:p-5"}`}>
className={`absolute inset-0 flex items-center justify-center bg-gradient-to-br ${theme.gradient} ${theme.darkGradient}`}
>
<PatternOverlay pattern={theme.pattern} id={uniqueId} /> <PatternOverlay pattern={theme.pattern} id={uniqueId} />
<div className="relative z-10 flex flex-col items-center gap-3 sm:gap-4"> <div className="relative z-10 flex items-start justify-between gap-3">
<div <span className="font-mono text-xs leading-snug sm:text-[13px]">{displayTags.join(" · ")}</span>
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"}`}
>
<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} />
</div>
<span <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={`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"}`}
> >
{title} <Icon className={isHero ? "h-7 w-7 sm:h-8 sm:w-8" : "h-5 w-5"} strokeWidth={1.75} />
</span> </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>
<span
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>
</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>
);
}
+7 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { loadCurrentBook, type CurrentBook } from "../tisch/currentBook"; import { loadCurrentBook, type CurrentBook } from "../tisch/currentBook";
import ChatBody from "./ChatBody";
import type { WindowLabels, WindowRequest } from "./types"; import type { WindowLabels, WindowRequest } from "./types";
const EMAIL = "contact@dk0.dev"; const EMAIL = "contact@dk0.dev";
@@ -28,13 +29,15 @@ export function windowTitle(req: WindowRequest, labels: WindowLabels): string {
return labels.about.title; return labels.about.title;
case "contact": case "contact":
return labels.contact.title; return labels.contact.title;
case "chat":
return labels.chat.title;
} }
} }
/** Farbe des Punkts in der Ablage-Leiste */ /** Farbe des Punkts in der Ablage-Leiste */
export function windowAccent(req: WindowRequest): string { export function windowAccent(req: WindowRequest): string {
if (req.kind === "project") return req.project.color; if (req.kind === "project") return req.project.color;
return { now: "rgb(var(--plakat-red))", server: "rgb(var(--plakat-ok))", read: "#7a5240", about: "#2f5bea", contact: "rgb(var(--plakat-red))" }[req.kind]; return { now: "rgb(var(--plakat-red))", server: "rgb(var(--plakat-ok))", read: "#7a5240", about: "#2f5bea", contact: "rgb(var(--plakat-red))", chat: "#1d1b3d" }[req.kind];
} }
interface WindowContentProps { interface WindowContentProps {
@@ -128,6 +131,9 @@ export default function WindowContent({ req, labels, onClose }: WindowContentPro
case "contact": case "contact":
return <ContactBody labels={labels} onClose={onClose} />; return <ContactBody labels={labels} onClose={onClose} />;
case "chat":
return <ChatBody labels={labels} />;
} }
} }
+11
View File
@@ -44,6 +44,17 @@ export async function getWindowLabels(locale: string): Promise<WindowLabels> {
value: t(`about.facts.${k}.value`), value: t(`about.facts.${k}.value`),
})), })),
}, },
chat: {
title: t("chat.title"),
welcome: t("chat.welcome"),
consent: t("chat.consent"),
allow: t("chat.allow"),
placeholder: t("chat.placeholder"),
send: t("chat.send"),
error: t("chat.error"),
thinking: t("chat.thinking"),
note: t("chat.note"),
},
contact: { contact: {
title: t("contact.title"), title: t("contact.title"),
body: t("contact.body"), body: t("contact.body"),
+13 -1
View File
@@ -7,7 +7,8 @@ export type WindowRequest =
| { kind: "server" } | { kind: "server" }
| { kind: "read" } | { kind: "read" }
| { kind: "about" } | { kind: "about" }
| { kind: "contact" }; | { kind: "contact" }
| { kind: "chat" };
export function windowId(req: WindowRequest): string { export function windowId(req: WindowRequest): string {
return req.kind === "project" ? `project:${req.project.id}` : req.kind; return req.kind === "project" ? `project:${req.project.id}` : req.kind;
@@ -45,6 +46,17 @@ export interface WindowLabels {
paragraphs: string[]; paragraphs: string[];
facts: { label: string; value: string }[]; facts: { label: string; value: string }[];
}; };
chat: {
title: string;
welcome: string;
consent: string;
allow: string;
placeholder: string;
send: string;
error: string;
thinking: string;
note: string;
};
contact: { contact: {
title: string; title: string;
body: string; body: string;
+3 -1
View File
@@ -155,7 +155,9 @@ function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardI
const openWindow = useOpenWindow(); const openWindow = useOpenWindow();
const elRef = useRef<HTMLDivElement | null>(null); const elRef = useRef<HTMLDivElement | null>(null);
const openIt = () => openWindow?.(windowFor(obj), elRef.current); const openIt = () => openWindow?.(windowFor(obj), elRef.current);
const radius = { sticker: "rounded-full", photo: "rounded-md", book: "rounded-[6px_14px_14px_6px]" }[obj.kind as string] ?? "rounded-2xl"; const radius =
{ sticker: "rounded-full", photo: "rounded-md", book: "rounded-[6px_14px_14px_6px]", chat: "rounded-[22px_22px_22px_6px]" }[obj.kind as string] ??
"rounded-2xl";
return ( return (
<motion.div <motion.div
+9
View File
@@ -85,6 +85,15 @@ export default function ObjectFace({ obj, labels }: ObjectFaceProps) {
</div> </div>
); );
case "chat":
return (
<div className="flex h-full w-full flex-col justify-between rounded-[22px_22px_22px_6px] border-2 border-ink bg-ink p-4 text-paper">
<span className="font-mono text-xs uppercase tracking-wide text-signal">KI · n8n</span>
<span className="font-display text-[1.7rem] font-extrabold leading-[0.95] tracking-[-0.03em]">{labels.chatCard}</span>
<span className="font-mono text-xs opacity-70">{labels.chatCardHint} ↗</span>
</div>
);
case "sticker": case "sticker":
return ( return (
<div className="grid h-full w-full place-items-center rounded-full border-2 border-ink bg-signal p-6 text-center font-display text-xl font-extrabold leading-[0.98] tracking-[-0.02em] text-ink"> <div className="grid h-full w-full place-items-center rounded-full border-2 border-ink bg-signal p-6 text-center font-display text-xl font-extrabold leading-[0.98] tracking-[-0.02em] text-ink">
+3
View File
@@ -45,6 +45,8 @@ export default async function TischSection({ locale }: TischSectionProps) {
bookLabel: t("bookLabel"), bookLabel: t("bookLabel"),
bookFallback: t("bookFallback"), bookFallback: t("bookFallback"),
photoCaption: t("photoCaption"), photoCaption: t("photoCaption"),
chatCard: t("chatCard"),
chatCardHint: t("chatCardHint"),
sticker: t("sticker"), sticker: t("sticker"),
}; };
@@ -73,6 +75,7 @@ export default async function TischSection({ locale }: TischSectionProps) {
{ kind: "server", id: "server" }, { kind: "server", id: "server" },
{ kind: "book", id: "book", href: `/${locale}/books` }, { kind: "book", id: "book", href: `/${locale}/books` },
{ kind: "photo", id: "photo" }, { kind: "photo", id: "photo" },
{ kind: "chat", id: "chat" },
{ kind: "sticker", id: "sticker" }, { kind: "sticker", id: "sticker" },
]; ];
// Im Stapel wäre der runde Sticker eine Ellipse und wiederholt nur den Claim // Im Stapel wäre der runde Sticker eine Ellipse und wiederholt nur den Claim
+10 -1
View File
@@ -37,12 +37,17 @@ export interface TischPhoto {
id: "photo"; id: "photo";
} }
export interface TischChat {
kind: "chat";
id: "chat";
}
export interface TischSticker { export interface TischSticker {
kind: "sticker"; kind: "sticker";
id: "sticker"; id: "sticker";
} }
export type TischObject = TischProject | TischTicket | TischServer | TischBook | TischPhoto | TischSticker; export type TischObject = TischProject | TischTicket | TischServer | TischBook | TischPhoto | TischChat | TischSticker;
export interface TischLabels { export interface TischLabels {
hintBoard: string; hintBoard: string;
@@ -63,6 +68,8 @@ export interface TischLabels {
bookLabel: string; bookLabel: string;
bookFallback: string; bookFallback: string;
photoCaption: string; photoCaption: string;
chatCard: string;
chatCardHint: string;
sticker: string; sticker: string;
} }
@@ -73,6 +80,7 @@ export const BOARD_SIZE: Record<TischObject["kind"], { w: number; h: number }> =
server: { w: 270, h: 290 }, server: { w: 270, h: 290 },
book: { w: 176, h: 262 }, book: { w: 176, h: 262 },
photo: { w: 210, h: 250 }, photo: { w: 210, h: 250 },
chat: { w: 250, h: 170 },
sticker: { w: 180, h: 180 }, sticker: { w: 180, h: 180 },
}; };
@@ -89,6 +97,7 @@ export const BOARD_SPOTS: { x: number; y: number; r: number }[] = [
{ x: 0.76, y: 450, r: -7 }, { x: 0.76, y: 450, r: -7 },
{ x: 0.42, y: 800, r: 7 }, { x: 0.42, y: 800, r: 7 },
{ x: 0.12, y: 800, r: -4 }, { x: 0.12, y: 800, r: -4 },
{ x: 0.66, y: 790, r: 4 },
]; ];
// Farbfelder für Projektkarten, abwechselnd hell und dunkel // Farbfelder für Projektkarten, abwechselnd hell und dunkel
+4
View File
@@ -15,6 +15,8 @@ export function windowFor(obj: TischObject): WindowRequest {
return { kind: "read" }; return { kind: "read" };
case "photo": case "photo":
return { kind: "about" }; return { kind: "about" };
case "chat":
return { kind: "chat" };
} }
} }
@@ -31,6 +33,8 @@ export function labelFor(obj: TischObject, labels: TischLabels): string {
return labels.bookLabel; return labels.bookLabel;
case "photo": case "photo":
return labels.photoCaption; return labels.photoCaption;
case "chat":
return labels.chatCard;
case "sticker": case "sticker":
return labels.sticker; return labels.sticker;
} }
-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-Karten, nur noch im Editor */
.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-card { .glass-card {
background: var(--card); background: var(--card);
backdrop-filter: blur(30px) saturate(200%); backdrop-filter: blur(30px) saturate(200%);
@@ -177,31 +168,6 @@ h6 {
scrollbar-width: none; 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 Specifics for Blog/Projects */
.markdown h1 { .markdown h1 {
@apply text-4xl font-bold mb-6 tracking-tight; @apply text-4xl font-bold mb-6 tracking-tight;
@@ -246,31 +212,130 @@ h6 {
z-index: -1; 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 { .admin-glass-card {
background: #fffcf5; background: #fffcf5;
border: 1px solid #d7ccc8; border: 1px solid #d7ccc8;
box-shadow: 0 4px 6px -1px rgba(62, 39, 35, 0.06); box-shadow: 0 4px 6px -1px rgba(62, 39, 35, 0.06);
color: #3e2723; 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 ### Option 2: Edit Static Fallback
If you haven't set up CMS content, the fallback static content is in: If you haven't set up CMS content, the fallback static content is in:
- Privacy Policy: `/app/privacy-policy/page.tsx` (lines 76-302) - Privacy Policy: `/app/_ui/PrivacyPolicyClient.tsx` (Fallback-Text, sonst CMS-Seite `privacy-policy`)
- Legal Notice: `/app/legal-notice/page.tsx` - 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. ⚠️ **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 { import type {
NavTranslations, NavTranslations,
FooterTranslations, FooterTranslations,
AboutTranslations,
ProjectsTranslations,
ContactTranslations, ContactTranslations,
ConsentTranslations, ConsentTranslations,
} from '@/types/translations'; } 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> { export async function getContactTranslations(locale: string): Promise<ContactTranslations> {
const keys = [ const keys = [
'home.contact.title', 'home.contact.title',
+18 -75
View File
@@ -67,6 +67,8 @@
"bookLabel": "Lese gerade", "bookLabel": "Lese gerade",
"bookFallback": "Zur Bibliothek", "bookFallback": "Zur Bibliothek",
"photoCaption": "Das bin ich.", "photoCaption": "Das bin ich.",
"chatCard": "Frag mich etwas.",
"chatCardHint": "KI-Chat öffnen",
"sticker": "Läuft auf eigenen Servern." "sticker": "Läuft auf eigenen Servern."
}, },
"windows": { "windows": {
@@ -127,6 +129,17 @@
} }
} }
}, },
"chat": {
"title": "frag mich · KI",
"welcome": "Hi! Frag mich etwas über Dennis, seine Projekte oder sein Setup.",
"consent": "Der Chat läuft über einen KI-Dienst in meinem n8n. Deine Nachrichten werden dafür verarbeitet und kurz gespeichert. Erst wenn du zustimmst, wird etwas gesendet.",
"allow": "Chat erlauben",
"placeholder": "Deine Frage …",
"send": "Senden",
"error": "Gerade keine Verbindung. Versuch es gleich noch mal.",
"thinking": "Antwort wird geschrieben",
"note": "Antworten kommen von einer KI und können danebenliegen."
},
"contact": { "contact": {
"title": "kontakt", "title": "kontakt",
"body": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.", "body": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.",
@@ -135,63 +148,9 @@
"form": "Zum Formular" "form": "Zum Formular"
} }
}, },
"about": {
"title": "Hinter den Kulissen",
"p1": "Schon seit ich meinen ersten eigenen Server aufgesetzt habe, lässt mich das Thema Infrastruktur nicht mehr los. Als Student in Osnabrück verbringe ich meine Zeit am liebsten damit, moderne Web-Apps mit Next.js zu bauen oder mobile Lösungen mit Flutter zu entwickeln.",
"p2": "Aber für mich hört es nicht beim Code auf: Ich liebe es, meine eigenen Docker-Cluster zu managen, CI/CD-Pipelines zu optimieren und sicherzustellen, dass alles stabil und sicher läuft. DevOps ist für mich kein Job-Titel, sondern eine Lebenseinstellung.",
"p3": "Wenn die Server einmal ohne mich klarkommen, findet man mich beim Laufen durch Osnabrück, beim Gaming oder beim Experimentieren mit neuen Automationen in n8n.",
"funFactTitle": "Hardcore analog",
"funFactBody": "Trotz Cloud und Automatisierung: Meine wichtigsten Pläne entstehen immer noch mit Füller auf Papier. Das ist mein Anker im digitalen Chaos.",
"techStackTitle": "Mein Tech Stack",
"hobbiesTitle": "Wenn ich nicht code",
"techStack": {
"categories": {
"frontendMobile": "Frontend & Mobile",
"backendDevops": "Backend & DevOps",
"toolsAutomation": "Tools & Automation",
"securityAdmin": "Security & Admin"
},
"items": {
"selfHostedServices": "Self-hosted Services"
}
},
"hobbies": {
"selfHosting": "Self-Hosting & DevOps",
"gaming": "Gaming",
"gameServers": "Game-Server einrichten",
"jogging": "Joggen um den Kopf freizubekommen und aktiv bleiben"
},
"currentlyReading": {
"title": "Aktuell am Lesen",
"progress": "Fortschritt"
},
"readBooks": {
"title": "Gelesene Bücher",
"finishedAt": "Beendet am",
"showMore": "{count} weitere anzeigen",
"showLess": "Weniger anzeigen",
"readMore": "Weiterlesen",
"collapseReview": "Weniger anzeigen",
"empty": "In Hardcover fertig gelesene Bücher erscheinen hier automatisch."
},
"activity": {
"idleStatus": "System im Leerlauf / Geist aktiv",
"codingNow": "Gerade am Coden",
"gaming": "Am Zocken",
"listening": "Hört gerade",
"inGame": "Im Spiel"
}
},
"projects": {
"title": "Ausgewählte Arbeiten",
"subtitle": "Projekte, die meine Grenzen erweitert haben.",
"featured": "Featured",
"viewAll": "Archiv ansehen",
"noProjects": "Noch keine Projekte."
},
"contact": { "contact": {
"title": "Kontakt", "title": "Schreib mir.",
"subtitle": "Du willst zusammenarbeiten oder hast Fragen zu meinen Projekten? Schreib mir gerne!", "subtitle": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.",
"getInTouch": "Melde dich", "getInTouch": "Melde dich",
"getInTouchBody": "Ich bin immer offen für neue Chancen, spannende Projekte oder einfach einen Tech-Talk.", "getInTouchBody": "Ich bin immer offen für neue Chancen, spannende Projekte oder einfach einen Tech-Talk.",
"info": { "info": {
@@ -251,13 +210,13 @@
} }
}, },
"footer": { "footer": {
"role": "Software Engineer", "role": "IT & Self-Hosting",
"madeIn": "Made in Germany", "madeIn": "selbst gehostet in Deutschland",
"legalNotice": "Impressum", "legalNotice": "Impressum",
"privacyPolicy": "Datenschutz", "privacyPolicy": "Datenschutz",
"privacySettings": "Datenschutz-Einstellungen", "privacySettings": "Datenschutz-Einstellungen",
"privacySettingsTitle": "Datenschutz-Banner wieder anzeigen", "privacySettingsTitle": "Datenschutz-Banner wieder anzeigen",
"builtWith": "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.", "aiDisclaimer": "Einige Inhalte dieser Seite können KI-generiert sein.",
"backToTop": "Nach oben", "backToTop": "Nach oben",
"systemsOnline": "Systeme online" "systemsOnline": "Systeme online"
@@ -277,21 +236,5 @@
"subtitle": "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben.", "subtitle": "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben.",
"backHome": "Zurück", "backHome": "Zurück",
"empty": "Noch keine Bücher." "empty": "Noch keine Bücher."
},
"about": {
"status": "Status",
"aiAssistant": "KI-Assistent",
"library": "Bibliothek",
"viewAll": "Alle ansehen",
"myGear": "Mein Gear",
"gearMain": "Main",
"gearPC": "PC",
"gearServer": "Server",
"gearOS": "OS",
"curiosity": "Neugier über die Softwareentwicklung hinaus.",
"connect": "Verbinden",
"email": "E-Mail",
"code": "Code",
"professional": "Beruflich"
} }
} }
+18 -75
View File
@@ -67,6 +67,8 @@
"bookLabel": "Reading now", "bookLabel": "Reading now",
"bookFallback": "To the library", "bookFallback": "To the library",
"photoCaption": "That's me.", "photoCaption": "That's me.",
"chatCard": "Ask me something.",
"chatCardHint": "Open AI chat",
"sticker": "Runs on my own servers." "sticker": "Runs on my own servers."
}, },
"windows": { "windows": {
@@ -127,6 +129,17 @@
} }
} }
}, },
"chat": {
"title": "ask me · AI",
"welcome": "Hi! Ask me anything about Dennis, his projects or his setup.",
"consent": "The chat runs through an AI service in my n8n. Your messages are processed and briefly stored for that. Nothing is sent until you agree.",
"allow": "Allow chat",
"placeholder": "Your question …",
"send": "Send",
"error": "No connection right now. Try again in a moment.",
"thinking": "Writing a reply",
"note": "Answers come from an AI and can be wrong."
},
"contact": { "contact": {
"title": "contact", "title": "contact",
"body": "For projects, questions about my setup or just because. I usually reply within a day or two.", "body": "For projects, questions about my setup or just because. I usually reply within a day or two.",
@@ -135,63 +148,9 @@
"form": "To the form" "form": "To the form"
} }
}, },
"about": {
"title": "Behind the Code",
"p1": "Ever since I set up my first home server, I've been hooked on infrastructure. Currently studying in Osnabrück, I split my time between developing modern web apps with Next.js and building mobile experiences with Flutter.",
"p2": "For me, it doesn't stop at the code. I genuinely enjoy managing my own Docker clusters, optimizing CI/CD pipelines, and making sure everything is stable and secure. DevOps isn't just a part of my job – it's how I think about building things.",
"p3": "When the servers are running smoothly, you'll find me jogging through the city, gaming, or tinkering with new automation workflows in n8n.",
"funFactTitle": "Hardcore Analog",
"funFactBody": "Despite my love for automation and the cloud, my most important ideas are still born on paper with a fountain pen. It's my way of staying grounded.",
"techStackTitle": "My Tech Stack",
"hobbiesTitle": "When I'm Not Coding",
"techStack": {
"categories": {
"frontendMobile": "Frontend & Mobile",
"backendDevops": "Backend & DevOps",
"toolsAutomation": "Tools & Automation",
"securityAdmin": "Security & Admin"
},
"items": {
"selfHostedServices": "Self-hosted services"
}
},
"hobbies": {
"selfHosting": "Self-Hosting & DevOps",
"gaming": "Gaming",
"gameServers": "Setting up game servers",
"jogging": "Jogging to clear my mind and stay active"
},
"currentlyReading": {
"title": "Currently Reading",
"progress": "Progress"
},
"readBooks": {
"title": "Read",
"finishedAt": "Finished",
"showMore": "{count} more",
"showLess": "Show less",
"readMore": "Read more",
"collapseReview": "Show less",
"empty": "Books finished in Hardcover will appear here automatically."
},
"activity": {
"idleStatus": "System Idle / Mind Active",
"codingNow": "Coding Now",
"gaming": "Gaming",
"listening": "Listening",
"inGame": "In Game"
}
},
"projects": {
"title": "Selected Work",
"subtitle": "Projects that pushed my boundaries.",
"featured": "Featured",
"viewAll": "View Archive",
"noProjects": "No projects yet."
},
"contact": { "contact": {
"title": "Contact Me", "title": "Write me.",
"subtitle": "Interested in working together or have questions about my projects? Feel free to reach out!", "subtitle": "For projects, questions about my setup or just because. I usually reply within a day or two.",
"getInTouch": "Get In Touch", "getInTouch": "Get In Touch",
"getInTouchBody": "I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation.", "getInTouchBody": "I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation.",
"info": { "info": {
@@ -251,13 +210,13 @@
} }
}, },
"footer": { "footer": {
"role": "Software Engineer", "role": "IT & self-hosting",
"madeIn": "Made in Germany", "madeIn": "self-hosted in Germany",
"legalNotice": "Legal notice", "legalNotice": "Legal notice",
"privacyPolicy": "Privacy policy", "privacyPolicy": "Privacy policy",
"privacySettings": "Privacy settings", "privacySettings": "Privacy settings",
"privacySettingsTitle": "Show privacy settings banner again", "privacySettingsTitle": "Show privacy settings banner again",
"builtWith": "Built with", "builtWith": "Built with Next.js and Directus, running on my own servers.",
"aiDisclaimer": "Some content on this site may be AI-assisted.", "aiDisclaimer": "Some content on this site may be AI-assisted.",
"backToTop": "Back to top", "backToTop": "Back to top",
"systemsOnline": "Systems Online" "systemsOnline": "Systems Online"
@@ -277,21 +236,5 @@
"subtitle": "Books that shaped my mindset and expanded my horizons.", "subtitle": "Books that shaped my mindset and expanded my horizons.",
"backHome": "Back Home", "backHome": "Back Home",
"empty": "No books yet." "empty": "No books yet."
},
"about": {
"status": "Status",
"aiAssistant": "AI Assistant",
"library": "Library",
"viewAll": "View All",
"myGear": "My Gear",
"gearMain": "Main",
"gearPC": "PC",
"gearServer": "Server",
"gearOS": "OS",
"curiosity": "Curiosity beyond software engineering.",
"connect": "Connect",
"email": "Email",
"code": "Code",
"professional": "Professional"
} }
} }
+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 // Feste Objektfarben auf dem Tisch, unabhängig vom Theme
polaroid: { DEFAULT: "#fbfaf8", ink: "#140c0b" }, polaroid: { DEFAULT: "#fbfaf8", ink: "#140c0b" },
bookcover: { DEFAULT: "#2b1a14", text: "#f3e7dc" }, 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: { stone: {
50: "#FAFAF9", 50: "#FAFAF9",
100: "#F5F5F4", 100: "#F5F5F4",
@@ -126,6 +111,7 @@ export default {
800: "#292524", 800: "#292524",
900: "#1C1917", 900: "#1C1917",
}, },
// Alte Pastellpalette, nur noch im Admin-Bereich und auf der 404-Seite
liquid: { liquid: {
mint: "#A7F3D0", mint: "#A7F3D0",
lavender: "#DDD6FE", lavender: "#DDD6FE",
-34
View File
@@ -20,40 +20,6 @@ export interface FooterTranslations {
builtWith: string; builtWith: string;
} }
export interface AboutTranslations {
title: string;
p1: string;
p2: string;
p3: string;
funFactTitle: string;
funFactBody: string;
techStackTitle: string;
techStack: {
categories: {
frontendMobile: string;
backendDevops: string;
toolsAutomation: string;
securityAdmin: string;
};
items: {
selfHostedServices: string;
};
};
hobbiesTitle: string;
hobbies: {
selfHosting: string;
gaming: string;
gameServers: string;
jogging: string;
};
}
export interface ProjectsTranslations {
title: string;
subtitle: string;
viewAll: string;
}
export interface ContactTranslations { export interface ContactTranslations {
title: string; title: string;
subtitle: string; subtitle: string;