5 Commits

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:31:58 +02:00
22 changed files with 1000 additions and 913 deletions
+5 -9
View File
@@ -18,15 +18,11 @@ 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 (Plakat) ## Design system
- 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) - Colors: only `liquid-*` tokens — no hardcoded hex or raw Tailwind palette colors
- Ink and paper swap roles in dark mode on their own; `signal` stays red - Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15 backdrop-blur-sm border-2 rounded-xl`
- Fonts: `font-display` (Bricolage, `[font-variation-settings:'wdth'_80]` for posters), `font-sans` (Geist), `font-mono` (Geist Mono) - Headlines: `uppercase tracking-tighter` with accent dot `<span className="text-emerald-600">.</span>`
- Cards: `rounded-[18px] border-2 border-ink` + hard offset shadow `shadow-[6px_6px_0_rgb(var(--plakat-ink))]` - Body text: `text-stone-600 dark:text-stone-400` — never `text-stone-400` alone (fails contrast)
- 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.
+102 -109
View File
@@ -1,121 +1,114 @@
"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 type { Metadata } from "next"; import { useEffect, useState } from "react";
import { getTranslations } from "next-intl/server"; import { useLocale, useTranslations } from "next-intl";
import { getBookReviews, type BookReview } from "@/lib/directus"; import { Skeleton } from "@/app/components/ui/Skeleton";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo"; import { BookReview } from "@/lib/directus";
import SubpageShell from "@/app/_ui/SubpageShell"; import Header from "@/app/components/Header";
import { BackArrow, SubpageIntro, card, ghost, monoLabel } from "@/app/_ui/subpage"; import Footer from "@/app/components/Footer";
import ScrollFadeIn from "@/app/components/ScrollFadeIn";
export const revalidate = 300; export default function BooksPage() {
const locale = useLocale();
const t = useTranslations("books");
const common = useTranslations("common");
const [reviews, setReviews] = useState<BookReview[]>([]);
const [loading, setLoading] = useState(true);
export async function generateMetadata({ useEffect(() => {
params, const fetchBooks = async () => {
}: { try {
params: Promise<{ locale: string }>; const res = await fetch(`/api/book-reviews?locale=${locale}`);
}): Promise<Metadata> { const data = await res.json();
const { locale } = await params; if (data.bookReviews) setReviews(data.bookReviews);
const isDe = locale === "de"; } catch {
return { if (process.env.NODE_ENV === "development") {
title: isDe ? "Bibliothek – Dennis Konkol" : "Library – Dennis Konkol", // console.error in dev only
alternates: {
canonical: toAbsoluteUrl(`/${locale}/books`),
languages: getLanguageAlternates({ pathWithoutLocale: "books" }),
},
};
}
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 (
<SubpageShell locale={locale}>
<SubpageIntro
back={
<Link href={`/${locale}`} className={ghost}>
<BackArrow /> {common("back")}
</Link>
} }
kicker={reviews.length > 0 ? `${String(reviews.length).padStart(2, "0")} ${locale === "de" ? "Bücher" : "books"}` : undefined} } finally {
title={t("title")} setLoading(false);
> }
<p>{t("subtitle")}</p> };
</SubpageIntro> fetchBooks();
}, [locale]);
{reviews.length === 0 ? (
<div className={`${card} mt-10 p-6 md:p-8`}>
<p className="font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold leading-tight tracking-[-0.03em]">{t("empty")}</p>
</div>
) : (
<ul className="mt-10 grid gap-6 sm:grid-cols-2 md:gap-8 lg:grid-cols-3">
{reviews.map((review) => (
<li key={review.id} className="min-w-0">
<BookCard review={review} locale={locale} />
</li>
))}
</ul>
)}
</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 ( return (
<article className={`${card} flex h-full flex-col gap-4 p-5 md:p-6`}> <div className="min-h-screen bg-stone-50 dark:bg-stone-950 transition-colors duration-500">
<div className="grid grid-cols-[88px_minmax(0,1fr)] gap-4"> <Header />
<div className="relative h-[128px] overflow-hidden rounded-[3px_6px_6px_3px] border-2 border-ink bg-bookcover"> <div className="h-16 sm:h-24 md:h-32" aria-hidden="true" />
{review.book_image && <Image src={review.book_image} alt="" fill sizes="88px" className="object-cover" />} <main className="pt-4 sm:pt-8 pb-20 px-4 sm:px-6">
</div> <div className="max-w-7xl mx-auto">
<div className="min-w-0"> <div className="mb-20">
<h2 className="hyphens-auto break-words font-display text-[1.45rem] font-extrabold leading-[1.02] tracking-[-0.03em] sm:text-[1.6rem]"> <Link
{review.book_title} href={`/${locale}`}
</h2> className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
<p className={`${monoLabel} mt-2`}>{review.book_author}</p> >
<div className="mt-3 flex flex-wrap items-center gap-2"> <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" />
{review.rating > 0 && ( <span className="font-bold uppercase tracking-widest text-xs">{common("backToHome")}</span>
<span className="inline-flex items-center gap-1 rounded-full bg-ink px-2.5 py-1 font-mono text-xs text-paper"> </Link>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-3 w-3 fill-signal"> <h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
<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" /> {t("title")}<span className="text-liquid-purple">.</span>
</svg> </h1>
<span> <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">
<span className="sr-only">{locale === "de" ? "Bewertung: " : "Rating: "}</span> {t("subtitle")}
{review.rating}/5 </p>
</span> </div>
</span>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
<Skeleton className="aspect-[3/4] rounded-2xl mb-8" />
<div className="space-y-3">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
))
) : reviews.length === 0 ? (
<div className="col-span-full text-center py-20 text-stone-400 dark:text-stone-500">
<Star size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-light">{t("empty")}</p>
</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>
))
)} )}
{finishedLabel && <span className="font-mono text-xs">{finishedLabel}</span>}
</div> </div>
</div> </div>
</div> </main>
{text && ( <Footer />
<blockquote className="mt-auto border-t-2 border-ink pt-4 text-[15px] leading-relaxed"> </div>
<span aria-hidden="true" className="font-display text-2xl font-extrabold leading-none text-signal">
{locale === "de" ? "„" : "“"}
</span>
{text}
</blockquote>
)}
</article>
); );
} }
+1 -12
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo"; import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import SubpageShell from "@/app/_ui/SubpageShell"; export { default } from "../../legal-notice/page";
import LegalNoticeClient from "@/app/_ui/LegalNoticeClient";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -11,7 +10,6 @@ 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,
@@ -19,12 +17,3 @@ export async function generateMetadata({
}; };
} }
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return (
<SubpageShell locale={locale}>
<LegalNoticeClient />
</SubpageShell>
);
}
+1 -12
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo"; import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import SubpageShell from "@/app/_ui/SubpageShell"; export { default } from "../../privacy-policy/page";
import PrivacyPolicyClient from "@/app/_ui/PrivacyPolicyClient";
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -11,7 +10,6 @@ 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,
@@ -19,12 +17,3 @@ export async function generateMetadata({
}; };
} }
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return (
<SubpageShell locale={locale}>
<PrivacyPolicyClient />
</SubpageShell>
);
}
+2 -3
View File
@@ -1,6 +1,5 @@
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";
@@ -108,13 +107,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> </>
); );
} }
+1 -6
View File
@@ -1,6 +1,5 @@
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";
@@ -95,10 +94,6 @@ export default async function ProjectsPage({
return dateB - dateA; return dateB - dateA;
}); });
return ( return <ProjectsPageClient projects={allProjects} locale={locale} />;
<SubpageShell locale={locale}>
<ProjectsPageClient projects={allProjects} locale={locale} />
</SubpageShell>
);
} }
+34
View File
@@ -0,0 +1,34 @@
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);
});
});
+5 -9
View File
@@ -124,10 +124,10 @@ describe("i18n: ClientWrappers provide all requested namespaces", () => {
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
const STATIC_KEYS: Record<string, Record<string, string[]>> = { const STATIC_KEYS: Record<string, Record<string, string[]>> = {
nav: { HeaderClient: ["home", "about", "projects", "contact"] }, nav: { Header: ["home", "about", "projects", "contact"] },
common: { common: {
"books/page": ["back"], LegalNoticeClient: ["backToHome"], PrivacyPolicyClient: ["backToHome"], "books/page": ["back"], "legal-notice/page": ["back"], "privacy-policy/page": ["back"],
ProjectDetailClient: ["back", "backToProjects"], ProjectsPageClient: ["backToHome"], ProjectDetailClient: ["back"], 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"] },
@@ -137,12 +137,8 @@ const STATIC_KEYS: Record<string, Record<string, string[]>> = {
"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"] },
"projects.list": { ProjectsPageClient: ["title", "intro", "searchPlaceholder", "all", "noResults", "clearFilters"] }, "projects.list": { ProjectsPageClient: ["title", "intro", "searchPlaceholder", "all", "noResults", "clearFilters"] },
"projects.detail": { ProjectDetailClient: ["links", "liveDemo", "viewSource", "techStack"] }, "projects.detail": { ProjectDetailClient: ["liveDemo", "viewSource"] },
footer: { footer: { Footer: ["role", "madeIn", "legalNotice", "privacyPolicy", "privacySettings", "privacySettingsTitle", "builtWith", "aiDisclaimer", "backToTop", "systemsOnline"] },
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"] },
}; };
-80
View File
@@ -1,80 +0,0 @@
"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
@@ -1,177 +0,0 @@
"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>
</>
);
}
+119 -83
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,101 +39,137 @@ 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);
const listHref = `/${locale}/projects`; useEffect(() => {
const handleBack = (e: React.MouseEvent<HTMLAnchorElement>) => { // Prüfen, ob wir eine History haben (von Home gekommen)
// Kommt man von der eigenen Seite, geht es per Browser-Zurück an die exakte Stelle if (typeof window !== 'undefined' && window.history.length > 1) {
// (Startseite oder Liste). Bei Deep Links, ohne JS oder mit Modifier-Taste gilt der Link selbst. setCanGoBack(true);
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 (
<article> <div className="min-h-screen bg-stone-50 dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
<SubpageIntro <div className="max-w-7xl mx-auto">
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>
<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"> {/* Navigation - Intelligent Back */}
{project.imageUrl ? ( <button
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="(max-width: 1400px) 100vw, 1400px" /> onClick={handleBack}
) : ( className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-12 group bg-transparent border-none cursor-pointer"
<div aria-hidden="true"> >
<ProjectThumbnail title={project.title} category={project.category} tags={project.tags} slug={project.slug} size="hero" /> <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" />
</div> <span className="font-bold uppercase tracking-widest text-xs">
)} {tCommon("back")}
</div> </span>
</button>
<div className="mt-12 grid gap-10 lg:grid-cols-12 lg:gap-12"> {/* Title Section */}
<div className="min-w-0 lg:col-span-8"> <div className="mb-20">
{project.content ? ( <h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase mb-8">
<div className="plakat-prose max-w-[68ch]"> {project.title}<span className="text-liquid-mint">.</span>
<ReactMarkdown>{project.content}</ReactMarkdown> </h1>
</div> <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 className="max-w-[68ch] text-lg leading-relaxed">{project.description}</p> </p>
)}
</div> </div>
<aside className="grid content-start gap-6 lg:sticky lg:top-24 lg:col-span-4 lg:self-start"> {/* Feature Image Box */}
{(liveUrl || githubUrl) && ( <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">
<section className={`${frame} bg-signal p-5 text-ink md:p-6`} aria-labelledby="project-links"> <div className="relative aspect-video rounded-[2rem] overflow-hidden border-4 border-stone-50 dark:border-stone-800 shadow-2xl">
<h2 id="project-links" className={monoLabel}> {project.imageUrl ? (
{tDetail("links")} <Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="100vw" />
</h2> ) : (
<div className="mt-4 flex flex-wrap gap-2"> <ProjectThumbnail
{liveUrl && ( title={project.title}
<a href={liveUrl} target="_blank" rel="noopener noreferrer" className={btn}> category={project.category}
{project.button_live_label || tDetail("liveDemo")} <span aria-hidden="true">↗</span> tags={project.tags}
</a> slug={project.slug}
)} size="hero"
{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> </div>
</a> </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>
</section> </div>
)} </div>
{stack.length > 0 && ( <div className="lg:col-span-4 space-y-8">
<section className={`${card} p-5 md:p-6`} aria-labelledby="project-stack">
<h2 id="project-stack" className={monoLabel}>
{tDetail("techStack")}
</h2> {/* Quick Links Box - Only show if links exist */}
<ul className="mt-4 flex flex-wrap gap-2">
{stack.map((tag) => ( {((project.live && project.live !== "#") || (project.github && project.github !== "#")) && (
<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))]">
<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} {tag}
</li> </span>
))} ))}
</ul> </div>
</section> </div>
)} </div>
</div>
<Link href={listHref} className={`${ghost} w-fit`}>
<BackArrow /> {tCommon("backToProjects")}
</Link>
</aside>
</div> </div>
</article> </div>
); );
} }
+108 -140
View File
@@ -1,12 +1,13 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useEffect, 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 Image from "next/image";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "../components/ui/Skeleton";
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
@@ -20,18 +21,6 @@ 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,
@@ -44,6 +33,13 @@ 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);
@@ -58,143 +54,115 @@ export default function ProjectsPageClient({
if (searchQuery) { if (searchQuery) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
result = result.filter( result = result.filter(
(p) => (p) => p.title.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.tags.some(t => t.toLowerCase().includes(query))
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">
<SubpageIntro <div className="max-w-7xl mx-auto">
back={
<Link href={`/${locale}`} className={ghost}>
<BackArrow /> {tCommon("backToHome")}
</Link>
}
kicker={`${String(projects.length).padStart(2, "0")} ${locale === "de" ? "Projekte" : "projects"}`}
title={tList("title")}
>
<p>{tList("intro")}</p>
</SubpageIntro>
{/* Filter: Kategorien als Pillen, Suche als Tinten-Feld */} {/* Header */}
<div className="mt-8 flex flex-col gap-4 md:mt-10 md:flex-row md:items-center md:justify-between"> <div className="mb-24">
<div className="flex flex-wrap gap-2" role="group" aria-label={locale === "de" ? "Kategorien" : "Categories"}> <Link
{categories.map((cat) => ( href={`/${locale}`}
<button className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
key={cat}
type="button"
onClick={() => setSelectedCategory(cat)}
aria-pressed={selectedCategory === cat}
className={filterPill(selectedCategory === cat)}
>
{cat === "all" ? tList("all") : cat}
</button>
))}
</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" /> <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" />
<path d="m20 20-3.5-3.5" /> <span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToHome")}</span>
</svg> </Link>
<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>
{filteredProjects.length === 0 ? ( <h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
<div className={`${card} mt-10 flex flex-col items-start gap-4 p-6 md:p-8`}> {tList("title")}<span className="text-liquid-mint">.</span>
<p className="font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold leading-tight tracking-[-0.03em]"> </h1>
{tList("noResults")} <p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
{tList("intro")}
</p> </p>
<button type="button" onClick={resetFilters} className={ghost}>
{tList("clearFilters")}
</button>
</div> </div>
) : (
<ul className="mt-10 grid gap-6 md:grid-cols-2 md:gap-8 xl:grid-cols-3"> {/* Filters */}
{filteredProjects.map((project, i) => { <div className="flex flex-col md:flex-row gap-8 justify-between items-start md:items-center mb-16">
const year = yearOf(project); <div className="flex flex-wrap gap-2">
return ( {categories.map((cat) => (
<li key={project.id} className="min-w-0"> <button
<ScrollFadeIn delay={Math.min(i, 5) * 0.05} className="h-full"> key={cat}
<Link onClick={() => setSelectedCategory(cat)}
href={`/${locale}/projects/${project.slug}`} 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] ${
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]`} selectedCategory === cat
> ? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900"
<div className="relative aspect-[16/10] border-b-2 border-ink" aria-hidden="true"> : "bg-white dark:bg-stone-900 text-stone-500 border border-stone-200 dark:border-stone-800"
{project.imageUrl ? ( }`}
<Image >
src={project.imageUrl} {cat === 'all' ? tList('all') : cat}
alt="" </button>
fill ))}
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw" </div>
className="object-cover" <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
<ProjectThumbnail type="text"
title={project.title} placeholder={tList("searchPlaceholder")}
category={project.category} value={searchQuery}
tags={project.tags} onChange={(e) => setSearchQuery(e.target.value)}
slug={project.slug} 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"
size="card" />
/> </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>
<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="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
<div className="flex items-start justify-between gap-4"> <ProjectThumbnail
<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]"> title={project.title}
{project.title} category={project.category}
</h2> tags={project.tags}
<span slug={project.slug}
aria-hidden="true" size="card"
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" />
> </div>
↗ )}
</span> <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="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> </div>
</Link> <p className="text-stone-500 dark:text-stone-400 font-light text-lg mb-8 line-clamp-3 leading-relaxed">{project.description}</p>
</ScrollFadeIn> <div className="mt-auto flex flex-wrap gap-2">
</li> {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>
})} ))}
</ul> </div>
)} </div>
</> </div>
</Link>
</motion.div>
)))}
</div>
</div>
</div>
); );
} }
-27
View File
@@ -1,27 +0,0 @@
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
@@ -1,48 +0,0 @@
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
@@ -1,29 +0,0 @@
"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;
}
+98
View File
@@ -0,0 +1,98 @@
"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 ? "#projects" : `/${locale}#projects` },
{ 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;
+94 -39
View File
@@ -24,30 +24,73 @@ 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;
tone: keyof typeof tones; gradient: string;
darkGradient: string;
iconColor: string;
darkIconColor: string;
pattern: "dots" | "grid" | "diagonal" | "circuit" | "waves" | "terminal"; pattern: "dots" | "grid" | "diagonal" | "circuit" | "waves" | "terminal";
} }
> = { > = {
"Web Development": { icon: Code, tone: "signal", pattern: "circuit" }, "Web Development": {
"Mobile Development": { icon: Smartphone, tone: "ink", pattern: "waves" }, icon: Code,
"Web Application": { icon: Globe, tone: "paper", pattern: "dots" }, gradient: "from-liquid-sky/20 via-liquid-blue/10 to-liquid-lavender/20",
"Backend Development": { icon: Cpu, tone: "ink", pattern: "grid" }, darkGradient: "dark:from-liquid-sky/10 dark:via-liquid-blue/5 dark:to-liquid-lavender/10",
"Full-Stack Development": { icon: Boxes, tone: "signal", pattern: "grid" }, iconColor: "text-blue-500",
DevOps: { icon: Shield, tone: "ok", pattern: "diagonal" }, darkIconColor: "dark:text-blue-400",
default: { icon: Wrench, tone: "paper", pattern: "dots" }, pattern: "circuit",
},
"Mobile Development": {
icon: Smartphone,
gradient: "from-liquid-mint/20 via-liquid-teal/10 to-liquid-sky/20",
darkGradient: "dark:from-liquid-mint/10 dark:via-liquid-teal/5 dark:to-liquid-sky/10",
iconColor: "text-emerald-500",
darkIconColor: "dark:text-emerald-400",
pattern: "waves",
},
"Web Application": {
icon: Globe,
gradient: "from-liquid-lavender/20 via-liquid-purple/10 to-liquid-pink/20",
darkGradient: "dark:from-liquid-lavender/10 dark:via-liquid-purple/5 dark:to-liquid-pink/10",
iconColor: "text-violet-500",
darkIconColor: "dark:text-violet-400",
pattern: "dots",
},
"Backend Development": {
icon: Cpu,
gradient: "from-liquid-amber/20 via-liquid-yellow/10 to-liquid-peach/20",
darkGradient: "dark:from-liquid-amber/10 dark:via-liquid-yellow/5 dark:to-liquid-peach/10",
iconColor: "text-amber-500",
darkIconColor: "dark:text-amber-400",
pattern: "grid",
},
"Full-Stack Development": {
icon: Boxes,
gradient: "from-liquid-teal/20 via-liquid-mint/10 to-liquid-lavender/20",
darkGradient: "dark:from-liquid-teal/10 dark:via-liquid-mint/5 dark:to-liquid-lavender/10",
iconColor: "text-teal-500",
darkIconColor: "dark:text-teal-400",
pattern: "grid",
},
DevOps: {
icon: Shield,
gradient: "from-liquid-coral/20 via-liquid-rose/10 to-liquid-peach/20",
darkGradient: "dark:from-liquid-coral/10 dark:via-liquid-rose/5 dark:to-liquid-peach/10",
iconColor: "text-red-500",
darkIconColor: "dark:text-red-400",
pattern: "diagonal",
},
default: {
icon: Wrench,
gradient: "from-liquid-peach/20 via-liquid-rose/10 to-liquid-lavender/20",
darkGradient: "dark:from-liquid-peach/10 dark:via-liquid-rose/5 dark:to-liquid-lavender/10",
iconColor: "text-stone-400",
darkIconColor: "dark:text-stone-500",
pattern: "dots",
},
}; };
const slugIcons: Record<string, LucideIcon> = { const slugIcons: Record<string, LucideIcon> = {
@@ -63,7 +106,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.14]" xmlns="http://www.w3.org/2000/svg"> <svg className="absolute inset-0 w-full h-full opacity-[0.07] dark:opacity-[0.05]" 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" />
@@ -73,7 +116,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg> </svg>
), ),
grid: ( grid: (
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg"> <svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" 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" />
@@ -83,7 +126,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg> </svg>
), ),
diagonal: ( diagonal: (
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg"> <svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" 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" />
@@ -93,7 +136,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg> </svg>
), ),
circuit: ( circuit: (
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg"> <svg className="absolute inset-0 w-full h-full opacity-[0.07] dark:opacity-[0.05]" 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" />
@@ -108,7 +151,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg> </svg>
), ),
waves: ( waves: (
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg"> <svg className="absolute inset-0 w-full h-full opacity-[0.06] dark:opacity-[0.04]" 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" />
@@ -118,7 +161,7 @@ function PatternOverlay({ pattern, id }: { pattern: string; id: string }) {
</svg> </svg>
), ),
terminal: ( terminal: (
<svg className="absolute inset-0 w-full h-full opacity-[0.14]" xmlns="http://www.w3.org/2000/svg"> <svg className="absolute inset-0 w-full h-full opacity-[0.08] dark:opacity-[0.06]" 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>
@@ -154,28 +197,40 @@ 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, isHero ? 5 : 3) ?? []; const displayTags = tags?.slice(0, 3) ?? [];
return ( return (
<div className={`absolute inset-0 flex flex-col justify-between ${tones[theme.tone]} ${isHero ? "p-5 sm:p-8" : "p-4 sm:p-5"}`}> <div
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 items-start justify-between gap-3"> <div className="relative z-10 flex flex-col items-center gap-3 sm:gap-4">
<span className="font-mono text-xs leading-snug sm:text-[13px]">{displayTags.join(" · ")}</span> <div
<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"}`}
className={`flex shrink-0 items-center justify-center rounded-full shadow-[inset_0_0_0_2px_currentColor] ${isHero ? "h-14 w-14 sm:h-16 sm:w-16" : "h-10 w-10"}`}
> >
<Icon className={isHero ? "h-7 w-7 sm:h-8 sm:w-8" : "h-5 w-5"} strokeWidth={1.75} /> <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} />
</span> </div>
</div>
<span <span
className={`relative z-10 font-display font-extrabold leading-[0.86] tracking-[-0.045em] [overflow-wrap:anywhere] [font-variation-settings:'wdth'_78] ${ 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`}
isHero ? "text-[clamp(2.6rem,8vw,7rem)]" : "text-[clamp(1.8rem,4vw,2.8rem)]" >
}`} {title}
> </span>
{title}
</span> {displayTags.length > 0 && (
<div className={`flex flex-wrap justify-center gap-1.5 sm:gap-2 ${isHero ? "max-w-md" : "max-w-[200px]"}`}>
{displayTags.map((tag) => (
<span
key={tag}
className={`px-2 py-0.5 rounded-full bg-white/50 dark:bg-white/5 backdrop-blur-sm border border-white/30 dark:border-white/10 text-stone-500 dark:text-stone-400 font-medium ${isHero ? "text-xs sm:text-sm" : "text-[9px] sm:text-[10px]"}`}
>
{tag}
</span>
))}
</div>
)}
</div>
</div> </div>
); );
} }
+57 -122
View File
@@ -106,7 +106,16 @@ html {
} }
} }
/* Glass-Karten, nur noch im Editor */ /* Liquid Glass Effects */
.glass-panel {
background: var(--card);
backdrop-filter: blur(20px) saturate(130%);
-webkit-backdrop-filter: blur(20px) saturate(130%);
border: 1px solid var(--border);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
will-change: backdrop-filter;
}
.glass-card { .glass-card {
background: var(--card); background: var(--card);
backdrop-filter: blur(30px) saturate(200%); backdrop-filter: blur(30px) saturate(200%);
@@ -168,6 +177,31 @@ 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;
@@ -212,130 +246,31 @@ 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
@@ -0,0 +1,135 @@
"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
@@ -0,0 +1,216 @@
"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/_ui/PrivacyPolicyClient.tsx` (Fallback-Text, sonst CMS-Seite `privacy-policy`) - Privacy Policy: `/app/privacy-policy/page.tsx` (lines 76-302)
- Legal Notice: `/app/_ui/LegalNoticeClient.tsx` (Fallback-Text, sonst CMS-Seite `legal-notice`) - Legal Notice: `/app/legal-notice/page.tsx`
⚠️ **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.
+15 -1
View File
@@ -99,6 +99,21 @@ 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",
@@ -111,7 +126,6 @@ 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",