1 Commits

Author SHA1 Message Date
denshooter 5b97d3f7a1 ci: route staging through traefik at testing.dk0.dev (#79)
CI / CD / test-build (push) Successful in 10m14s
CI / CD / deploy-dev (push) Successful in 1m11s
CI / CD / deploy-production (push) Has been skipped
Attach portfolio-app-dev to the edge network on every deploy and default the dev base URL to https://testing.dk0.dev. Route lives in the homelab repo (apps/doxx/traefik/portfolio-testing.yml).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:08:46 +02:00
23 changed files with 1005 additions and 915 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.
+5 -2
View File
@@ -132,7 +132,7 @@ jobs:
-p ${HEALTH_PORT}:3000 \ -p ${HEALTH_PORT}:3000 \
-e NODE_ENV=production \ -e NODE_ENV=production \
-e LOG_LEVEL=${LOG_LEVEL:-debug} \ -e LOG_LEVEL=${LOG_LEVEL:-debug} \
-e NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} \ -e NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://testing.dk0.dev} \
-e DATABASE_URL="${DATABASE_URL}" \ -e DATABASE_URL="${DATABASE_URL}" \
-e REDIS_URL="${REDIS_URL}" \ -e REDIS_URL="${REDIS_URL}" \
-e MY_EMAIL="${MY_EMAIL}" \ -e MY_EMAIL="${MY_EMAIL}" \
@@ -150,6 +150,9 @@ jobs:
# Connect to proxy network # Connect to proxy network
docker network connect proxy $CONTAINER_NAME 2>/dev/null || true docker network connect proxy $CONTAINER_NAME 2>/dev/null || true
# Traefik erreicht den Container über das Netz edge; die Route testing.dk0.dev
# liegt im homelab-Repo (apps/doxx/traefik/portfolio-testing.yml)
docker network connect edge $CONTAINER_NAME 2>/dev/null || true
# Wait for health # Wait for health
echo "⏳ Waiting for container to be healthy..." echo "⏳ Waiting for container to be healthy..."
@@ -173,7 +176,7 @@ jobs:
echo "✅ Dev deployment completed!" echo "✅ Dev deployment completed!"
env: env:
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }} LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
NEXT_PUBLIC_BASE_URL_DEV: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }} NEXT_PUBLIC_BASE_URL_DEV: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://testing.dk0.dev' }}
DATABASE_URL: postgresql://portfolio_user:portfolio_pass@portfolio-postgres:5432/portfolio_db?schema=public DATABASE_URL: postgresql://portfolio_user:portfolio_pass@portfolio-postgres:5432/portfolio_db?schema=public
REDIS_URL: redis://portfolio-redis:6379 REDIS_URL: redis://portfolio-redis:6379
MY_EMAIL: ${{ vars.MY_EMAIL }} MY_EMAIL: ${{ vars.MY_EMAIL }}
+103 -110
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>
</>
);
}
+120 -84
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}> {/* Navigation - Intelligent Back */}
<BackArrow /> {tCommon("back")} <button
</Link> 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"
kicker={[project.category, year].filter(Boolean).join(" · ")} >
title={project.title} <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" />
> <span className="font-bold uppercase tracking-widest text-xs">
<p>{project.description}</p> {tCommon("back")}
</SubpageIntro> </span>
</button>
<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"> {/* Title Section */}
{project.imageUrl ? ( <div className="mb-20">
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="(max-width: 1400px) 100vw, 1400px" /> <h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase mb-8">
) : ( {project.title}<span className="text-liquid-mint">.</span>
<div aria-hidden="true"> </h1>
<ProjectThumbnail title={project.title} category={project.category} tags={project.tags} slug={project.slug} size="hero" /> <p className="text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-4xl leading-snug tracking-tight">
</div> {project.description}
)} </p>
</div>
<div className="mt-12 grid gap-10 lg:grid-cols-12 lg:gap-12">
<div className="min-w-0 lg:col-span-8">
{project.content ? (
<div className="plakat-prose max-w-[68ch]">
<ReactMarkdown>{project.content}</ReactMarkdown>
</div>
) : (
<p className="max-w-[68ch] text-lg leading-relaxed">{project.description}</p>
)}
</div> </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>
); );
} }
+110 -142
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}> {/* Header */}
<BackArrow /> {tCommon("backToHome")} <div className="mb-24">
</Link> <Link
} href={`/${locale}`}
kicker={`${String(projects.length).padStart(2, "0")} ${locale === "de" ? "Projekte" : "projects"}`} className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
title={tList("title")}
>
<p>{tList("intro")}</p>
</SubpageIntro>
{/* Filter: Kategorien als Pillen, Suche als Tinten-Feld */}
<div className="mt-8 flex flex-col gap-4 md:mt-10 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-2" role="group" aria-label={locale === "de" ? "Kategorien" : "Categories"}>
{categories.map((cat) => (
<button
key={cat}
type="button"
onClick={() => setSelectedCategory(cat)}
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" <h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
placeholder={tList("searchPlaceholder")} {tList("title")}<span className="text-liquid-mint">.</span>
value={searchQuery} </h1>
onChange={(e) => setSearchQuery(e.target.value)} <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">
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" {tList("intro")}
/>
</label>
</div>
{filteredProjects.length === 0 ? (
<div className={`${card} mt-10 flex flex-col items-start gap-4 p-6 md:p-8`}>
<p className="font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold leading-tight tracking-[-0.03em]">
{tList("noResults")}
</p> </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;
+95 -40
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",