17 Commits

Author SHA1 Message Date
denshooter 4a8c86b320 Merge branch 'dev' into production
CI / CD / test-build (push) Successful in 10m19s
CI / CD / deploy-dev (push) Has been skipped
CI / CD / deploy-production (push) Successful in 26s
2026-04-23 23:33:52 +02:00
denshooter 0ca14230f2 Merge branch 'dev' into production 2026-04-23 23:11:02 +02:00
denshooter 2c88b284c1 Merge branch 'dev' into production 2026-04-23 23:06:31 +02:00
denshooter 890b8c79c7 Merge branch 'dev' into production 2026-04-23 22:40:22 +02:00
denshooter 3494b361d6 Merge branch 'dev' into production 2026-04-23 20:20:37 +02:00
denshooter c9153d2660 Merge branch 'dev' into production 2026-04-23 20:18:46 +02:00
denshooter 52586ef28a Merge branch 'dev' into production 2026-04-22 11:50:28 +02:00
denshooter a44a90c69d Merge branch 'dev' into production 2026-04-22 11:43:52 +02:00
denshooter 258143b362 Merge branch 'dev' into production 2026-04-19 15:47:39 +02:00
denshooter edd8dc58ab Merge branch 'dev' into production 2026-04-17 09:50:38 +02:00
denshooter f17f0031a1 Merge branch 'dev' into production 2026-04-16 14:39:54 +02:00
denshooter 4d5dc1f8f9 Merge branch 'dev' into production 2026-04-15 15:53:22 +02:00
denshooter 8397e5acf2 Merge dev into production 2026-04-09 18:02:37 +02:00
denshooter 5bcaade558 Merge dev into production 2026-04-09 17:23:29 +02:00
denshooter aee811309b fix: scroll to top on locale switch and remove dashes from hero text
- HeaderClient: track locale prop changes with useRef and call
  window.scrollTo on switch to reliably reset scroll position
- messages/en.json + de.json: replace em dash with comma and remove
  hyphens from Self-Hoster/Full-Stack in hero description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 14:37:56 +01:00
denshooter 48a29cd872 fix: pass locale explicitly to Hero and force-dynamic on locale-sensitive API routes
- Hero.tsx: pass locale prop directly to getTranslations instead of
  relying on setRequestLocale async storage, which can be lost during
  Next.js RSC streaming
- book-reviews route: replace revalidate=300 with force-dynamic to
  prevent cached English responses being served to German locale requests
- content/page route: add runtime=nodejs and force-dynamic (was missing
  both, violating CLAUDE.md API route conventions)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 13:43:26 +01:00
denshooter c95fc3101b chore: merge branch 'dev' into 'production' (Release: Design Overhaul & Admin Redesign) 2026-03-08 13:18:26 +01:00
43 changed files with 952 additions and 1414 deletions
-3
View File
@@ -30,9 +30,6 @@ jobs:
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Pre-push content check
run: npx jest app/__tests__/pre-push-check.test.ts --no-coverage --verbose
- name: Test - name: Test
run: npm run test run: npm run test
+62 -76
View File
@@ -4,17 +4,12 @@ import { Star, ArrowLeft } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl"; import { useLocale } from "next-intl";
import { Skeleton } from "@/app/components/ui/Skeleton"; import { Skeleton } from "@/app/components/ui/Skeleton";
import { BookReview } from "@/lib/directus"; import { BookReview } from "@/lib/directus";
import Header from "@/app/components/Header";
import Footer from "@/app/components/Footer";
import ScrollFadeIn from "@/app/components/ScrollFadeIn";
export default function BooksPage() { export default function BooksPage() {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("books");
const common = useTranslations("common");
const [reviews, setReviews] = useState<BookReview[]>([]); const [reviews, setReviews] = useState<BookReview[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -24,10 +19,8 @@ export default function BooksPage() {
const res = await fetch(`/api/book-reviews?locale=${locale}`); const res = await fetch(`/api/book-reviews?locale=${locale}`);
const data = await res.json(); const data = await res.json();
if (data.bookReviews) setReviews(data.bookReviews); if (data.bookReviews) setReviews(data.bookReviews);
} catch { } catch (error) {
if (process.env.NODE_ENV === "development") { console.error("Books fetch failed:", error);
// console.error in dev only
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -36,79 +29,72 @@ export default function BooksPage() {
}, [locale]); }, [locale]);
return ( return (
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 transition-colors duration-500"> <div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
<Header /> <div className="max-w-7xl mx-auto">
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true" /> <div className="mb-20">
<main className="pt-4 sm:pt-8 pb-20 px-4 sm:px-6"> <Link
<div className="max-w-7xl mx-auto"> href={`/${locale}`}
<div className="mb-20"> className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
<Link >
href={`/${locale}`} <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group" <span className="font-bold uppercase tracking-widest text-xs">{locale === 'de' ? 'Zurück' : 'Back Home'}</span>
> </Link>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" /> <h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
<span className="font-bold uppercase tracking-widest text-xs">{common("backToHome")}</span> Library<span className="text-liquid-purple">.</span>
</Link> </h1>
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase"> <p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
{t("title")}<span className="text-liquid-purple">.</span> {locale === "de"
</h1> ? "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben."
<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"> : "Books that shaped my mindset and expanded my horizons."}
{t("subtitle")} </p>
</p> </div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{loading ? ( {loading ? (
Array.from({ length: 6 }).map((_, i) => ( 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"> <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" /> <Skeleton className="aspect-[3/4] rounded-2xl mb-8" />
<div className="space-y-3"> <div className="space-y-3">
<Skeleton className="h-8 w-3/4" /> <Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/2" /> <Skeleton className="h-4 w-1/2" />
</div>
</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> </div>
) : ( ))
reviews.map((review, i) => ( ) : (
<ScrollFadeIn key={review.id} delay={i * 0.1}> reviews?.map((review) => (
<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"> <div
{review.book_image && ( key={review.id}
<div className="relative aspect-[3/4] rounded-2xl overflow-hidden mb-8 shadow-xl border-4 border-stone-50 dark:border-stone-800"> 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-all"
<Image src={review.book_image} alt={review.book_title} fill className="object-cover" sizes="(max-width: 768px) 100vw, 33vw" /> >
{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" />
</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 && (
<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>
)} )}
<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> </div>
</ScrollFadeIn> <p className="text-stone-500 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-50 dark:border-stone-800">
</div> <p className="text-stone-600 dark:text-stone-300 italic font-light leading-relaxed">
&ldquo;{review.review.replace(/<[^>]*>/g, '')}&rdquo;
</p>
</div>
)}
</div>
</div>
))
)}
</div> </div>
</main> </div>
<Footer />
</div> </div>
); );
} }
+1 -1
View File
@@ -55,7 +55,7 @@ export default async function ProjectsPage({
})) as ProjectListItem[]; })) as ProjectListItem[];
} }
} catch (err) { } catch (err) {
if (process.env.NODE_ENV === "development") console.error("Directus projects fetch failed:", err); console.error("Directus projects fetch failed:", err);
} }
const localizedDb: ProjectListItem[] = dbProjects.map((p) => { const localizedDb: ProjectListItem[] = dbProjects.map((p) => {
+45 -25
View File
@@ -3,38 +3,58 @@ import Hero from '@/app/components/Hero';
// Mock next-intl/server // Mock next-intl/server
jest.mock('next-intl/server', () => ({ jest.mock('next-intl/server', () => ({
getTranslations: () => getTranslations: () => Promise.resolve((key: string) => {
Promise.resolve((key: string, values?: { days?: number }) => { const messages: Record<string, string> = {
const messages: Record<string, string> = { badge: 'Student & Self-Hoster',
kicker: 'Dennis Konkol — Osnabrück, Germany', line1: 'Building',
claim: 'I self-host half my life. The rest I code.', line2: 'Stuff.',
intro: "IT admin in Osnabrück and master's student.", description: 'Dennis is a student and passionate self-hoster.',
countdown: `${values?.days ?? 0} days to go.`, ctaWork: 'View My Work',
ctaWork: 'See projects', ctaContact: 'Get in touch',
ctaContact: 'Write me', };
}; return messages[key] || key;
return messages[key] || key; }),
}), }));
// Mock next/image
interface ImageProps {
src: string;
alt: string;
fill?: boolean;
priority?: boolean;
[key: string]: unknown;
}
jest.mock('next/image', () => ({
__esModule: true,
default: ({ src, alt, fill, priority, ...props }: ImageProps) => (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
data-fill={fill?.toString()}
data-priority={priority?.toString()}
{...props}
/>
),
})); }));
describe('Hero', () => { describe('Hero', () => {
it('renders the poster hero with claim, intro and calls to action', async () => { it('renders the hero section correctly', async () => {
const HeroResolved = await Hero({ locale: 'en' }); const HeroResolved = await Hero({ locale: 'en' });
render(HeroResolved); render(HeroResolved);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('I self-host half my life. The rest I code.'); // Check for the main headlines (defaults in Hero.tsx)
expect(screen.getByText('Dennis Konkol — Osnabrück, Germany')).toBeInTheDocument(); expect(screen.getByText('Building')).toBeInTheDocument();
expect(screen.getByText(/IT admin in Osnabrück/)).toBeInTheDocument(); expect(screen.getByText('Stuff.')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /See projects/ })).toHaveAttribute('href', '#projects');
expect(screen.getByRole('link', { name: 'Write me' })).toHaveAttribute('href', '#contact');
});
it('renders the DK0 wordmark as decoration hidden from assistive tech', async () => { // Check for the description from our mock
const HeroResolved = await Hero({ locale: 'en' }); expect(screen.getByText(/Dennis is a student/i)).toBeInTheDocument();
const { container } = render(HeroResolved);
const wordmark = container.querySelector('[aria-hidden="true"].font-display'); // Check for the image
expect(wordmark).not.toBeNull(); expect(screen.getByAltText('Dennis Konkol')).toBeInTheDocument();
expect(wordmark).toHaveTextContent('DK0');
// Check for CTA
expect(screen.getByText('View My Work')).toBeInTheDocument();
}); });
}); });
+6 -15
View File
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import NotFound from '@/app/not-found'; import NotFound from '@/app/not-found';
// Mock next/navigation
jest.mock('next/navigation', () => ({ jest.mock('next/navigation', () => ({
useRouter: () => ({ useRouter: () => ({
back: jest.fn(), back: jest.fn(),
@@ -8,26 +9,16 @@ jest.mock('next/navigation', () => ({
}), }),
})); }));
// Mock next-intl
jest.mock('next-intl', () => ({ jest.mock('next-intl', () => ({
useLocale: () => 'en', useLocale: () => 'en',
useTranslations: () => (key: string) => { useTranslations: () => (key: string) => key,
const map: Record<string, string> = {
'title': 'Page not Found',
'description': 'The content you are looking for has been moved, deleted, or never existed.',
'returnHome': 'Return Home',
'goBack': 'Go Back',
'exploreWork': 'Explore Work',
'exploreWorkDesc': 'Maybe what you need is in my project archive?',
'viewProjects': 'View Projects',
'errorReport': 'Error Report',
};
return map[key] ?? key;
},
})); }));
describe('NotFound', () => { describe('NotFound', () => {
it('renders the 404 page', () => { it('renders the 404 page with the new design text', () => {
render(<NotFound />); render(<NotFound />);
expect(screen.getByText('404')).toBeInTheDocument(); expect(screen.getByText(/Page not/i)).toBeInTheDocument();
expect(screen.getByText(/Found/i)).toBeInTheDocument();
}); });
}); });
-609
View File
@@ -1,609 +0,0 @@
/**
* Pre-Push Content & Quality Check
*
* Comprehensive static analysis that catches bugs BEFORE push:
* 1. i18n: key parity, namespace coverage, ClientWrapper gaps, missing keys
* 2. Accessibility: buttons need aria-label or text, images need alt, forms need labels
* 3. Conventions: no raw hex colors, no emojis, no unguarded console.error in client code
* 4. Email: all mailto: links use valid email format
* 5. Environment variables: all process.env.XXX are documented
* 6. Code quality: no `any` type in app/ source files
*
* Run: npx jest app/__tests__/pre-push-check.test.ts --verbose
* (or just `npm test` — it's included in the suite)
*/
import fs from "fs";
import path from "path";
const ROOT = path.resolve(__dirname, "../..");
const SRC_ROOT = path.resolve(__dirname, "..");
const en = JSON.parse(fs.readFileSync(path.join(ROOT, "messages/en.json"), "utf-8"));
const de = JSON.parse(fs.readFileSync(path.join(ROOT, "messages/de.json"), "utf-8"));
const LOCALES: Record<string, Record<string, unknown>> = { en, de };
function collectPaths(obj: unknown, prefix = ""): string[] {
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return [prefix];
return Object.entries(obj as Record<string, unknown>).flatMap(([k, v]) =>
collectPaths(v, prefix ? `${prefix}.${k}` : k),
);
}
function resolve(obj: unknown, keyPath: string): unknown {
return keyPath.split(".").reduce<unknown>((o, k) => {
if (o === null || typeof o !== "object") return undefined;
return (o as Record<string, unknown>)[k];
}, obj);
}
function findTsxFiles(dir: string, excludeTests = true): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules" && entry.name !== ".next") {
if (excludeTests && entry.name === "__tests__") return [];
return findTsxFiles(full, excludeTests);
}
return entry.name.endsWith(".tsx") || entry.name.endsWith(".ts") ? [full] : [];
});
}
function readFile(p: string): string {
return fs.readFileSync(p, "utf-8");
}
// ═══════════════════════════════════════════════════════════════════════════
// 1. i18n: Structural parity
// ═══════════════════════════════════════════════════════════════════════════
describe("i18n: message files", () => {
it("en.json and de.json have identical key paths", () => {
const enPaths = new Set(collectPaths(en));
const dePaths = new Set(collectPaths(de));
const missingInDe = [...enPaths].filter((p) => !dePaths.has(p));
const missingInEn = [...dePaths].filter((p) => !enPaths.has(p));
expect(missingInDe).toEqual([]);
expect(missingInEn).toEqual([]);
});
it("no empty string values in any locale", () => {
for (const [locale, messages] of Object.entries(LOCALES)) {
const emptyPaths = collectPaths(messages).filter((p) => {
const val = p.split(".").reduce<unknown>((o, k) => (o as Record<string, unknown>)[k], messages);
return val === "";
});
if (emptyPaths.length > 0) {
throw new Error(`Empty translations in ${locale}: ${emptyPaths.join(", ")}`);
}
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 2. i18n: ClientWrapper namespace coverage
// ═══════════════════════════════════════════════════════════════════════════
const WRAPPED_COMPONENT_NAMESPACES: Record<string, string[]> = {
About: ["home.about", "about"],
Projects: ["home.projects"],
Contact: ["home.contact", "home.contact.form", "home.contact.info"],
Footer: ["footer"],
};
const CLIENT_WRAPPER_PROVIDES: Record<string, string[]> = {
AboutClient: ["home.about", "about"],
ProjectsClient: ["home.projects"],
ContactClient: ["home.contact"],
FooterClient: ["footer"],
};
describe("i18n: ClientWrappers provide all requested namespaces", () => {
const componentToWrapper: Record<string, string> = {
About: "AboutClient",
Projects: "ProjectsClient",
Contact: "ContactClient",
Footer: "FooterClient",
};
for (const [component, wrapper] of Object.entries(componentToWrapper)) {
const required = WRAPPED_COMPONENT_NAMESPACES[component];
const provided = CLIENT_WRAPPER_PROVIDES[wrapper];
describe(`${component} → ${wrapper}`, () => {
for (const ns of required) {
it(`provides namespace "${ns}"`, () => {
const found = provided.some((p) => ns === p || ns.startsWith(p + "."));
if (!found) {
throw new Error(
`${component} calls useTranslations("${ns}") but ${wrapper} does not provide it.\n` +
` Provided: [${provided.join(", ")}]\n` +
` Fix: add "${ns}" to the messages object in ${wrapper}`,
);
}
});
}
});
}
});
// ═══════════════════════════════════════════════════════════════════════════
// 3. i18n: Static key coverage
// ═══════════════════════════════════════════════════════════════════════════
const STATIC_KEYS: Record<string, Record<string, string[]>> = {
nav: { Header: ["home", "about", "projects", "contact"] },
common: {
"books/page": ["back"], "legal-notice/page": ["back"], "privacy-policy/page": ["back"],
ProjectDetailClient: ["back"], ProjectsPageClient: ["backToHome"],
},
consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] },
"home.hero": { Hero: ["kicker", "claim", "intro", "countdown", "ctaWork", "ctaContact"] },
"home.about": { About: ["title", "p1", "p2", "p3", "funFactTitle", "funFactBody", "hobbiesTitle"] },
"home.about.activity": { ActivityFeed: ["idleStatus", "codingNow", "gaming", "inGame", "listening"] },
"home.about.currentlyReading": { CurrentlyReading: ["title", "progress"] },
"home.about.readBooks": { ReadBooks: ["title", "showMore", "showLess", "readMore", "collapseReview", "finishedAt", "empty"] },
"home.projects": { Projects: ["title", "subtitle", "viewAll", "noProjects"] },
"home.contact": { Contact: ["title", "subtitle"] },
"home.contact.form": { Contact: ["title", "sending", "send", "labels.name", "labels.email", "labels.subject", "labels.message", "placeholders.name", "placeholders.email", "placeholders.subject", "placeholders.message", "errors.nameRequired", "errors.nameMin", "errors.emailRequired", "errors.emailInvalid", "errors.subjectRequired", "errors.subjectMin", "errors.messageRequired", "errors.messageMin", "characters"] },
"home.contact.info": { Contact: ["locationValue"] },
about: { About: ["status", "aiAssistant", "library", "viewAll", "myGear", "gearMain", "gearPC", "gearServer", "gearOS", "curiosity"] },
"projects.list": { ProjectsPageClient: ["title", "intro", "searchPlaceholder", "all", "noResults", "clearFilters"] },
"projects.detail": { ProjectDetailClient: ["liveDemo", "viewSource"] },
footer: { Footer: ["role", "madeIn", "legalNotice", "privacyPolicy", "privacySettings", "privacySettingsTitle", "builtWith", "aiDisclaimer", "backToTop", "systemsOnline"] },
notFound: { "not-found": ["title", "description", "returnHome", "goBack", "exploreWork", "exploreWorkDesc", "viewProjects", "errorReport"] },
books: { "books/page": ["title", "subtitle", "empty"] },
};
describe("i18n: all static translation keys resolve", () => {
for (const [namespace, components] of Object.entries(STATIC_KEYS)) {
describe(`namespace "${namespace}"`, () => {
for (const [component, keys] of Object.entries(components)) {
for (const keyPath of keys) {
it(`${component} → "${keyPath}"`, () => {
for (const locale of Object.keys(LOCALES)) {
const nsObj = resolve(LOCALES[locale], namespace);
if (nsObj === undefined) throw new Error(`Namespace "${namespace}" missing in ${locale}.json`);
const val = resolve(nsObj, keyPath);
if (val === undefined) throw new Error(`Key "${keyPath}" missing in ${locale}.json → ${namespace}`);
if (val === "") throw new Error(`Key "${keyPath}" is empty string in ${locale}.json → ${namespace}`);
}
});
}
}
});
}
});
// ═══════════════════════════════════════════════════════════════════════════
// 4. i18n: Unregistered namespaces detected
// ═══════════════════════════════════════════════════════════════════════════
describe("i18n: no unregistered useTranslation namespaces", () => {
const knownNamespaces = new Set(Object.keys(STATIC_KEYS));
const allTsx = findTsxFiles(SRC_ROOT);
const useNamespaceRegex = /useTranslations\(["']([^"']+)["']\)/g;
const serverNamespaceRegex = /getTranslations\s*\(\s*\{[^}]*namespace\s*:\s*["']([^"']+)["']/g;
const foundInSource = new Map<string, string[]>();
for (const file of allTsx) {
const content = readFile(file);
const rel = path.relative(SRC_ROOT, file);
let match: RegExpExecArray | null;
useNamespaceRegex.lastIndex = 0;
while ((match = useNamespaceRegex.exec(content)) !== null) {
if (!foundInSource.has(match[1])) foundInSource.set(match[1], []);
foundInSource.get(match[1])!.push(rel);
}
serverNamespaceRegex.lastIndex = 0;
while ((match = serverNamespaceRegex.exec(content)) !== null) {
if (!foundInSource.has(match[1])) foundInSource.set(match[1], []);
foundInSource.get(match[1])!.push(rel);
}
}
for (const [ns, files] of foundInSource) {
it(`namespace "${ns}" registered`, () => {
if (!knownNamespaces.has(ns)) {
throw new Error(`Namespace "${ns}" used in source but not in STATIC_KEYS.\nUsed in: ${files.join(", ")}`);
}
});
}
});
// ═══════════════════════════════════════════════════════════════════════════
// 5. Accessibility: Buttons need aria-label or visible text
// ═══════════════════════════════════════════════════════════════════════════
describe("Accessibility: buttons have accessible names", () => {
const appFiles = findTsxFiles(path.join(SRC_ROOT, "components"));
const ariaLabelRegex = /aria-label=["'{]/;
const titleAttrRegex = /title=["'{]/;
const violations: string[] = [];
for (const file of appFiles) {
const content = readFile(file);
const rel = path.relative(SRC_ROOT, file);
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.includes("<button")) continue;
const hasAriaLabel = ariaLabelRegex.test(line);
const hasTitle = titleAttrRegex.test(line);
const hasVisibleText = /<button[^>]*>\s*\S/.test(line) || /<button[^>]*>[^<]/.test(line);
if (!hasAriaLabel && !hasTitle && !hasVisibleText) {
const isIconButton = /<\w+\s[^>]*size\s*=\s*\{?\d/.test(line) || line.includes("<Trash2") || line.includes("<X ") || line.includes("<X>") || line.includes("<Menu") || line.includes("<ChevronUp") || line.includes("<ArrowLeft");
if (isIconButton) {
violations.push(`${rel}:${i + 1} — icon-only button with no aria-label or title`);
}
}
ariaLabelRegex.lastIndex = 0;
titleAttrRegex.lastIndex = 0;
}
}
it("no icon-only buttons missing aria-label", () => {
if (violations.length > 0) {
throw new Error(`Found ${violations.length} button(s) missing accessible names:\n${violations.join("\n")}`);
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 6. Accessibility: Images must have alt text
// ═══════════════════════════════════════════════════════════════════════════
describe("Accessibility: images have alt text", () => {
it("no <img> tags missing alt attribute", () => {
const appFiles = findTsxFiles(SRC_ROOT);
const violations: string[] = [];
for (const file of appFiles) {
if (file.includes("__tests__")) continue;
const content = readFile(file);
const rel = path.relative(SRC_ROOT, file);
const imgRegex = /<img\b[^>]*>/g;
let match: RegExpExecArray | null;
while ((match = imgRegex.exec(content)) !== null) {
const tag = match[0];
if (!tag.includes("alt=")) {
const lineNum = content.slice(0, match.index).split("\n").length;
violations.push(`${rel}:${lineNum}`);
}
}
imgRegex.lastIndex = 0;
}
if (violations.length > 0) {
throw new Error(`Found <img> tags without alt attribute:\n${violations.join("\n")}`);
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 7. Accessibility: Forms must have aria-label or labeled inputs
// ═══════════════════════════════════════════════════════════════════════════
describe("Accessibility: forms have labels", () => {
it("all <form> elements have aria-label or <label> children", () => {
const appFiles = findTsxFiles(SRC_ROOT);
const violations: string[] = [];
for (const file of appFiles) {
if (file.includes("__tests__")) continue;
const content = readFile(file);
const rel = path.relative(SRC_ROOT, file);
const formRegex = /<form[^>]*>/g;
let match: RegExpExecArray | null;
while ((match = formRegex.exec(content)) !== null) {
const formTag = match[0];
const position = match.index;
if (!formTag.includes("aria-label") && !formTag.includes("aria-labelledby")) {
const afterForm = content.slice(position, position + 2000);
if (!afterForm.includes("<label") && !afterForm.includes("htmlFor")) {
const lineNum = content.slice(0, position).split("\n").length;
violations.push(`${rel}:${lineNum} — <form> with no aria-label and no <label> children`);
}
}
}
}
if (violations.length > 0) {
throw new Error(`Found unlabeled forms:\n${violations.join("\n")}`);
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 8. Email: mailto links must use valid email format
// ═══════════════════════════════════════════════════════════════════════════
describe("Email: mailto links use valid addresses", () => {
it("all mailto: hrefs contain valid email addresses", () => {
const appFiles = findTsxFiles(SRC_ROOT);
const violations: string[] = [];
const emailRegex = /mailto:([^\s"')>]+)/g;
const validEmailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
for (const file of appFiles) {
if (file.includes("__tests__")) continue;
const content = readFile(file);
const rel = path.relative(SRC_ROOT, file);
let match: RegExpExecArray | null;
while ((match = emailRegex.exec(content)) !== null) {
const email = match[1];
if (!email.includes("${") && !email.includes("{") && !email.includes("atob") && !validEmailPattern.test(email)) {
const lineNum = content.slice(0, match.index).split("\n").length;
violations.push(`${rel}:${lineNum} — invalid email: "${email}"`);
}
}
}
if (violations.length > 0) {
throw new Error(`Found invalid email addresses:\n${violations.join("\n")}`);
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 9. Convention: no raw hex colors (should use Tailwind tokens)
// ═══════════════════════════════════════════════════════════════════════════
describe("Convention: no raw hex colors in classNames", () => {
const ALLOWED_HEX = new Set([
"#1DB954", // Spotify brand green — intentional
"#0077b5", // LinkedIn brand blue — intentional
]);
it("no bg-[#...], text-[#...], border-[#...], etc. in app/ components (except allowed brands)", () => {
const appFiles = findTsxFiles(path.join(SRC_ROOT, "components"));
const pageFiles = findTsxFiles(path.join(SRC_ROOT, "[locale]"));
const allFiles = [...appFiles, ...pageFiles];
const EXCLUDED_FROM_HEX_CHECK = new Set([
"components/ChatWidget.tsx",
"components/ActivityFeed.tsx",
"components/Contact.tsx",
]);
const violations: string[] = [];
for (const file of allFiles) {
const content = readFile(file);
const rel = path.relative(SRC_ROOT, file);
if (EXCLUDED_FROM_HEX_CHECK.has(rel)) continue;
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const hexInClassRegex = /(?:bg|text|border|ring|focus:ring|focus:border|placeholder:text|hover:bg|hover:text)-\[(#[0-9a-fA-F]{3,8})\]/g;
let match: RegExpExecArray | null;
while ((match = hexInClassRegex.exec(line)) !== null) {
const hex = match[1].toUpperCase();
if (!ALLOWED_HEX.has(hex)) {
violations.push(`${rel}:${i + 1} — ${match[0]} (use Tailwind token instead)`);
}
}
}
}
if (violations.length > 0) {
throw new Error(`Found ${violations.length} raw hex color(s) that should use Tailwind tokens:\n${violations.join("\n")}`);
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 10. Convention: no emoji in JSX (CLAUDE.md rule)
// ═══════════════════════════════════════════════════════════════════════════
describe("Convention: no emoji in app/ source code", () => {
function lineContainsEmoji(line: string): boolean {
for (let i = 0; i < line.length; i++) {
const code = line.codePointAt(i);
if (code === undefined) continue;
if (code >= 0x1F300 && code <= 0x1FAFF) return true;
if (code >= 0x2600 && code <= 0x27BF) return true;
if (code >= 0xFE00 && code <= 0xFE0F) return true;
if (code === 0x200D) return true;
if (code === 0x20E3) return true;
if (code > 0xFFFF) i++;
}
return false;
}
const EXCLUDED_DIRS = new Set(["components/admin", "components/modern", "editor"]);
const EXCLUDED_FILES = new Set([
"components/ChatWidget.tsx",
"components/BentoChat.tsx",
"api/email/route.tsx",
"api/email/respond/route.tsx",
]);
it("no emoji characters in app/ source files (excluding admin/editor/chat-widget)", () => {
const appFiles = findTsxFiles(SRC_ROOT);
const violations: string[] = [];
for (const file of appFiles) {
if (file.includes("__tests__")) continue;
const rel = path.relative(SRC_ROOT, file);
const isExcluded = [...EXCLUDED_DIRS].some((d) => rel.includes(d));
if (isExcluded || EXCLUDED_FILES.has(rel)) continue;
const content = readFile(file);
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith("//") || line.trim().startsWith("*")) continue;
if (lineContainsEmoji(line)) {
const emojiChars: string[] = [];
for (let j = 0; j < line.length; j++) {
const code = line.codePointAt(j);
if (code !== undefined && ((code >= 0x1F300 && code <= 0x1FAFF) || (code >= 0x2600 && code <= 0x27BF) || (code >= 0xFE00 && code <= 0xFE0F) || code === 0x200D || code === 0x20E3)) {
emojiChars.push(String.fromCodePoint(code));
if (code > 0xFFFF) j++;
}
}
violations.push(`${rel}:${i + 1} — found emoji: ${emojiChars.join(", ")}`);
}
}
}
if (violations.length > 0) {
throw new Error(`Found ${violations.length} emoji(s) in source code (CLAUDE.md: no emojis in code):\n${violations.join("\n")}`);
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 11. Convention: console.error must be guarded in client components
// ═══════════════════════════════════════════════════════════════════════════
describe("Convention: console.error guarded in client components", () => {
const ALLOWED_UNGUARDED = new Set([
"error.tsx",
"global-error.tsx",
]);
it("all console.error in 'use client' files are guarded by NODE_ENV check", () => {
const appFiles = findTsxFiles(SRC_ROOT);
const violations: string[] = [];
for (const file of appFiles) {
if (file.includes("__tests__")) continue;
const content = readFile(file);
const rel = path.relative(SRC_ROOT, file);
const basename = path.basename(rel);
if (ALLOWED_UNGUARDED.has(basename)) continue;
const isClientComponent = content.includes('"use client"') || content.includes("'use client'");
if (!isClientComponent) continue;
const lines = content.split("\n");
let insideGuard = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.includes("process.env.NODE_ENV") && line.includes("development")) {
insideGuard = true;
}
if (line === "}" || line === "};") {
insideGuard = false;
}
if (line.includes("console.error") || line.includes("console.warn")) {
if (!insideGuard && !line.includes("process.env.NODE_ENV")) {
const prevLine = i > 0 ? lines[i - 1].trim() : "";
const prevPrevLine = i > 1 ? lines[i - 2].trim() : "";
if (!prevLine.includes("process.env.NODE_ENV") && !prevPrevLine.includes("process.env.NODE_ENV")) {
violations.push(`${rel}:${i + 1}`);
}
}
}
}
}
if (violations.length > 0) {
throw new Error(`Found unguarded console.error/warn in client components:\n${violations.join("\n")}\nGuard with: if (process.env.NODE_ENV === 'development')`);
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 12. Environment variables: all process.env.XXX are documented
// ═══════════════════════════════════════════════════════════════════════════
describe("Environment variables: all used vars are documented", () => {
const DOCUMENTED_ENV_VARS = new Set([
"DATABASE_URL", "REDIS_URL", "DIRECTUS_URL", "DIRECTUS_STATIC_TOKEN",
"DIRECTUS_TOKEN", "N8N_WEBHOOK_URL", "N8N_SECRET_TOKEN", "N8N_API_KEY",
"MY_EMAIL", "MY_PASSWORD", "MY_INFO_EMAIL", "MY_INFO_PASSWORD",
"ADMIN_BASIC_AUTH", "ADMIN_SESSION_SECRET",
"NEXT_PUBLIC_BASE_URL", "NEXT_PUBLIC_GOOGLE_VERIFICATION", "NEXT_PUBLIC_SITE_URL",
"NEXTAUTH_URL", "NODE_ENV", "ANALYZE",
"SMTP_ALLOW_INSECURE_TLS", "SMTP_ALLOW_SELF_SIGNED",
"DB_WAIT_RETRIES", "DB_WAIT_INTERVAL_MS", "SKIP_PRISMA_MIGRATE", "PRISMA_AUTO_BASELINE",
]);
it("all process.env references are documented in CLAUDE.md", () => {
const appFiles = findTsxFiles(SRC_ROOT);
const scriptFiles = findTsxFiles(path.join(ROOT, "scripts"), false);
const allFiles = [...appFiles, ...scriptFiles];
const envVars = new Set<string>();
const envRegex = /process\.env\.([A-Z_][A-Z0-9_]*)/g;
for (const file of allFiles) {
if (file.includes("__tests__") || file.includes("jest.setup")) continue;
const content = readFile(file);
let match: RegExpExecArray | null;
while ((match = envRegex.exec(content)) !== null) {
envVars.add(match[1]);
}
envRegex.lastIndex = 0;
}
const undocumented = [...envVars].filter((v) => !DOCUMENTED_ENV_VARS.has(v));
if (undocumented.length > 0) {
throw new Error(`Undocumented env vars found: ${undocumented.join(", ")}\nAdd them to the DOCUMENTED_ENV_VARS set in this test and to CLAUDE.md`);
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 13. Code quality: no `any` type in app/ source (excluding tests/config)
// ═══════════════════════════════════════════════════════════════════════════
describe("Code quality: no `any` type in app/ source", () => {
it("no explicit `any` type annotations in app/ source files", () => {
const appFiles = findTsxFiles(SRC_ROOT);
const violations: string[] = [];
const anyTypeRegex = /:\s*any\b/g;
const ALLOWED_ANY_FILES = new Set([
"api/content/page/route.ts",
"api/fetchImage/route.tsx",
"components/KernelPanic404.tsx",
"editor/page.tsx",
]);
for (const file of appFiles) {
if (file.includes("__tests__")) continue;
if (file.includes("jest.setup")) continue;
const content = readFile(file);
const rel = path.relative(SRC_ROOT, file);
if (ALLOWED_ANY_FILES.has(rel)) continue;
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes("// eslint-disable-next-line")) continue;
if (line.includes("@ts-ignore") || line.includes("@ts-expect-error")) continue;
if (line.includes("as any") && line.includes("// eslint-disable")) continue;
if (anyTypeRegex.test(line) || line.includes("as any")) {
if (!line.includes("// eslint-disable")) {
violations.push(`${rel}:${i + 1} — ${line.trim()}`);
}
}
anyTypeRegex.lastIndex = 0;
}
}
if (violations.length > 0) {
throw new Error(`Found ${violations.length} 'any' type usage(s) (CLAUDE.md: no 'any'):\n${violations.join("\n")}`);
}
});
});
+119
View File
@@ -0,0 +1,119 @@
import Header from "../components/Header";
import Hero from "../components/Hero";
import About from "../components/About";
import Projects from "../components/Projects";
import Contact from "../components/Contact";
import Footer from "../components/Footer";
import Script from "next/script";
import { useEffect } from "react";
export default function HomePage() {
useEffect(() => {
// Force scroll to top on mount to prevent starting at lower sections
window.scrollTo(0, 0);
}, []);
return (
<div className="min-h-screen">
<Script
id={"structured-data"}
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Person",
name: "Dennis Konkol",
url: "https://dk0.dev",
jobTitle: "Software Engineer",
address: {
"@type": "PostalAddress",
addressLocality: "Osnabrück",
addressCountry: "Germany",
},
sameAs: [
"https://github.com/Denshooter",
"https://linkedin.com/in/dkonkol",
],
}),
}}
/>
<Header />
{/* Spacer to prevent navbar overlap */}
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true"></div>
<main className="relative">
<Hero locale="en" />
{/* Wavy Separator 1 - Hero to About */}
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
>
<path
d="M0,64 C240,96 480,32 720,64 C960,96 1200,32 1440,64 L1440,120 L0,120 Z"
fill="url(#gradient1)"
/>
<defs>
<linearGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#BAE6FD" stopOpacity="0.4" />
<stop offset="50%" stopColor="#DDD6FE" stopOpacity="0.4" />
<stop offset="100%" stopColor="#FBCFE8" stopOpacity="0.4" />
</linearGradient>
</defs>
</svg>
</div>
<About />
{/* Wavy Separator 2 - About to Projects */}
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
>
<path
d="M0,32 C240,64 480,96 720,32 C960,64 1200,96 1440,32 L1440,120 L0,120 Z"
fill="url(#gradient2)"
/>
<defs>
<linearGradient id="gradient2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FED7AA" stopOpacity="0.4" />
<stop offset="50%" stopColor="#FDE68A" stopOpacity="0.4" />
<stop offset="100%" stopColor="#FCA5A5" stopOpacity="0.4" />
</linearGradient>
</defs>
</svg>
</div>
<Projects />
{/* Wavy Separator 3 - Projects to Contact */}
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
>
<path
d="M0,96 C240,32 480,64 720,96 C960,32 1200,64 1440,96 L1440,120 L0,120 Z"
fill="url(#gradient3)"
/>
<defs>
<linearGradient id="gradient3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#99F6E4" stopOpacity="0.4" />
<stop offset="50%" stopColor="#A7F3D0" stopOpacity="0.4" />
<stop offset="100%" stopColor="#D9F99D" stopOpacity="0.4" />
</linearGradient>
</defs>
</svg>
</div>
<Contact />
</main>
<Footer />
</div>
);
}
+65
View File
@@ -88,17 +88,82 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
}} }}
/> />
<Header locale={locale} /> <Header locale={locale} />
{/* Spacer to prevent navbar overlap */}
<div className="h-24 md:h-32" aria-hidden="true"></div>
<main className="relative"> <main className="relative">
<Hero locale={locale} /> <Hero locale={locale} />
{/* Wavy Separator 1 - Hero to About */}
<div className="relative h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
>
<path
d="M0,64 C240,96 480,32 720,64 C960,96 1200,32 1440,64 L1440,120 L0,120 Z"
fill="url(#gradient1)"
/>
<defs>
<linearGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#BAE6FD" stopOpacity="0.4" />
<stop offset="50%" stopColor="#DDD6FE" stopOpacity="0.4" />
<stop offset="100%" stopColor="#FBCFE8" stopOpacity="0.4" />
</linearGradient>
</defs>
</svg>
</div>
<ScrollFadeIn> <ScrollFadeIn>
<AboutClient locale={locale} translations={aboutT} /> <AboutClient locale={locale} translations={aboutT} />
</ScrollFadeIn> </ScrollFadeIn>
{/* Wavy Separator 2 - About to Projects */}
<div className="relative h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
>
<path
d="M0,64 C360,96 720,32 1080,64 C1200,96 1320,32 1440,64 L1440,0 L0,0 Z"
fill="url(#gradient2)"
/>
<defs>
<linearGradient id="gradient2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#A7F3D0" stopOpacity="0.3" />
<stop offset="50%" stopColor="#BFDBFE" stopOpacity="0.3" />
<stop offset="100%" stopColor="#DDD6FE" stopOpacity="0.3" />
</linearGradient>
</defs>
</svg>
</div>
<ScrollFadeIn> <ScrollFadeIn>
<ProjectsClient locale={locale} translations={projectsT} /> <ProjectsClient locale={locale} translations={projectsT} />
</ScrollFadeIn> </ScrollFadeIn>
{/* Wavy Separator 3 - Projects to Contact */}
<div className="relative h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
>
<path
d="M0,32 C240,64 480,0 720,32 C960,64 1200,0 1440,32 L1440,120 L0,120 Z"
fill="url(#gradient3)"
/>
<defs>
<linearGradient id="gradient3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FDE68A" stopOpacity="0.3" />
<stop offset="50%" stopColor="#FCA5A5" stopOpacity="0.3" />
<stop offset="100%" stopColor="#C4B5FD" stopOpacity="0.3" />
</linearGradient>
</defs>
</svg>
</div>
<ScrollFadeIn> <ScrollFadeIn>
<ContactClient locale={locale} translations={contactT} /> <ContactClient locale={locale} translations={contactT} />
</ScrollFadeIn> </ScrollFadeIn>
+4 -4
View File
@@ -61,7 +61,7 @@ export default function ProjectDetailClient({
}; };
return ( return (
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500"> <div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Navigation - Intelligent Back */} {/* Navigation - Intelligent Back */}
@@ -69,7 +69,7 @@ export default function ProjectDetailClient({
onClick={handleBack} 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" className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-12 group bg-transparent border-none cursor-pointer"
> >
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" /> <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-bold uppercase tracking-widest text-xs"> <span className="font-bold uppercase tracking-widest text-xs">
{tCommon("back")} {tCommon("back")}
</span> </span>
@@ -127,11 +127,11 @@ export default function ProjectDetailClient({
{project.live && project.live !== "#" && ( {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"> <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 hover:scale-105 transition-transform group">
<span>{project.button_live_label || tDetail("liveDemo")}</span> <span>{project.button_live_label || tDetail("liveDemo")}</span>
<ExternalLink size={20} className="group-hover:translate-x-1 transition-transform duration-200 ease-out" /> <ExternalLink size={20} className="group-hover:translate-x-1 transition-transform" />
</a> </a>
+8 -8
View File
@@ -61,7 +61,7 @@ export default function ProjectsPageClient({
}, [projects, selectedCategory, searchQuery]); }, [projects, selectedCategory, searchQuery]);
return ( return (
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 pt-40 pb-20 px-6 transition-colors duration-500"> <div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-40 pb-20 px-6 transition-colors duration-500">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Header */} {/* Header */}
@@ -70,7 +70,7 @@ export default function ProjectsPageClient({
href={`/${locale}`} 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" className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
> >
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform duration-200 ease-out" /> <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToHome")}</span> <span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToHome")}</span>
</Link> </Link>
@@ -89,7 +89,7 @@ export default function ProjectsPageClient({
<button <button
key={cat} key={cat}
onClick={() => setSelectedCategory(cat)} onClick={() => setSelectedCategory(cat)}
className={`px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97] ${ className={`px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${
selectedCategory === cat selectedCategory === cat
? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900" ? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900"
: "bg-white dark:bg-stone-900 text-stone-500 border border-stone-200 dark:border-stone-800" : "bg-white dark:bg-stone-900 text-stone-500 border border-stone-200 dark:border-stone-800"
@@ -106,7 +106,7 @@ export default function ProjectsPageClient({
placeholder={tList("searchPlaceholder")} placeholder={tList("searchPlaceholder")}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-2xl py-4 pl-12 pr-6 focus:outline-none focus:ring-2 focus:ring-liquid-mint/30 transition-[border-color,box-shadow,background-color] duration-150 ease-out shadow-sm" className="w-full 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-all shadow-sm"
/> />
</div> </div>
</div> </div>
@@ -125,12 +125,12 @@ export default function ProjectsPageClient({
)) ))
) : ( ) : (
filteredProjects.map((project) => ( 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] }}> <motion.div key={project.id} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
<Link href={`/${locale}/projects/${project.slug}`} className="group block h-full"> <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"> <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-all flex flex-col">
{project.imageUrl ? ( {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"> <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" /> <Image src={project.imageUrl} alt={project.title} fill className="object-cover group-hover:scale-105 transition-transform duration-700" />
</div> </div>
) : ( ) : (
<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="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
@@ -146,7 +146,7 @@ export default function ProjectsPageClient({
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<div className="flex justify-between items-start mb-4"> <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> <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"> <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-all">
<ArrowUpRight size={20} /> <ArrowUpRight size={20} />
</div> </div>
</div> </div>
+25 -26
View File
@@ -22,11 +22,10 @@ const iconMap: Record<string, LucideIcon> = {
const About = () => { const About = () => {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("home.about"); const t = useTranslations("home.about");
const at = useTranslations("about");
const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
const [cmsHtml, setCmsHtml] = useState<string | null>(null); const [cmsHtml, setCmsHtml] = useState<string | null>(null);
const [techStack, setTechStack] = useState<TechStackCategory[]>([]); const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
const [hobbies, setHobbies] = useState<Hobby[]>([]); const [hobbies, setHobbies] = useState<Hobby[]>([]);
const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
@@ -51,7 +50,7 @@ const About = () => {
const msgData = await msgRes.json(); const msgData = await msgRes.json();
if (msgData?.messages) setCmsMessages(msgData.messages); if (msgData?.messages) setCmsMessages(msgData.messages);
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === "development") console.error("About data fetch failed:", error); console.error("About data fetch failed:", error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -60,7 +59,7 @@ const About = () => {
}, [locale]); }, [locale]);
return ( return (
<section id="about" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950 transition-colors duration-500"> <section id="about" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8"> <div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
@@ -102,7 +101,7 @@ const About = () => {
> >
<div className="relative z-10 h-full"> <div className="relative z-10 h-full">
<h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint"> <h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
<Activity size={20} /> {at("status")} <Activity size={20} /> Status
</h3> </h3>
<ActivityFeed locale={locale} /> <ActivityFeed locale={locale} />
</div> </div>
@@ -116,7 +115,7 @@ const About = () => {
> >
<div className="flex items-center gap-2 mb-5 sm:mb-8"> <div className="flex items-center gap-2 mb-5 sm:mb-8">
<MessageSquare className="text-liquid-purple" size={20} /> <MessageSquare className="text-liquid-purple" size={20} />
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">{at("aiAssistant")}</h3> <h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">AI Assistant</h3>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<BentoChat /> <BentoChat />
@@ -165,10 +164,10 @@ const About = () => {
<div className="relative z-10 flex flex-col h-full"> <div className="relative z-10 flex flex-col h-full">
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10"> <div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter"> <h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter">
<BookOpen className="text-liquid-purple" size={24} /> {at("library")} <BookOpen className="text-liquid-purple" size={24} /> Library
</h3> </h3>
<Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-opacity"> <Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all">
{at("viewAll")} <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" /> View All <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" />
</Link> </Link>
</div> </div>
<CurrentlyReading /> <CurrentlyReading />
@@ -186,24 +185,24 @@ const About = () => {
<div className="flex-1 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group"> <div className="flex-1 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group">
<div className="relative z-10"> <div className="relative z-10">
<h3 className="text-xl sm:text-2xl font-black mb-5 sm:mb-8 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter text-white"> <h3 className="text-xl sm:text-2xl font-black mb-5 sm:mb-8 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter text-white">
<Cpu className="text-liquid-mint" size={24} /> {at("myGear")} <Cpu className="text-liquid-mint" size={24} /> My Gear
</h3> </h3>
<div className="grid grid-cols-2 gap-4 sm:gap-6"> <div className="grid grid-cols-2 gap-4 sm:gap-6">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearMain")}</p> <p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Main</p>
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p> <p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearPC")}</p> <p className="text-[9px] font-black uppercase tracking-widest text-stone-400">PC</p>
<p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p> <p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearServer")}</p> <p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Server</p>
<p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p> <p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearOS")}</p> <p className="text-[9px] font-black uppercase tracking-widest text-stone-400">OS</p>
<p className="text-sm font-bold text-stone-100">macOS / Linux</p> <p className="text-sm font-bold text-stone-100">macOS / Linux</p>
</div> </div>
</div> </div>
</div> </div>
@@ -226,7 +225,7 @@ const About = () => {
return ( return (
<div key={hobby.id} className="p-3 sm:p-4 md:p-6 bg-stone-50 dark:bg-stone-800 rounded-xl sm:rounded-2xl border border-stone-100 dark:border-stone-700 hover:border-liquid-mint transition-colors group"> <div key={hobby.id} className="p-3 sm:p-4 md:p-6 bg-stone-50 dark:bg-stone-800 rounded-xl sm:rounded-2xl border border-stone-100 dark:border-stone-700 hover:border-liquid-mint transition-colors group">
<div className="flex items-center gap-2 sm:gap-3 mb-1.5 sm:mb-3"> <div className="flex items-center gap-2 sm:gap-3 mb-1.5 sm:mb-3">
<Icon size={16} className="text-liquid-mint group-hover:scale-110 transition-transform duration-200 ease-out shrink-0 sm:w-5 sm:h-5" /> <Icon size={16} className="text-liquid-mint group-hover:scale-110 transition-transform shrink-0 sm:w-5 sm:h-5" />
<h4 className="font-bold text-xs sm:text-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4> <h4 className="font-bold text-xs sm:text-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4>
</div> </div>
<p className="text-[11px] sm:text-xs text-stone-500 dark:text-stone-400 font-medium leading-relaxed hidden sm:block"> <p className="text-[11px] sm:text-xs text-stone-500 dark:text-stone-400 font-medium leading-relaxed hidden sm:block">
@@ -239,7 +238,7 @@ const About = () => {
</div> </div>
<div className="space-y-1 sm:space-y-2 border-t border-stone-100 dark:border-stone-800 pt-4 sm:pt-6 md:pt-8"> <div className="space-y-1 sm:space-y-2 border-t border-stone-100 dark:border-stone-800 pt-4 sm:pt-6 md:pt-8">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3> <h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3>
<p className="text-stone-500 font-light text-sm sm:text-base md:text-lg">{at("curiosity")}</p> <p className="text-stone-500 font-light text-sm sm:text-base md:text-lg">{locale === 'de' ? 'Neugier über die Softwareentwicklung hinaus.' : 'Curiosity beyond software engineering.'}</p>
</div> </div>
</div> </div>
</motion.div> </motion.div>
+10 -10
View File
@@ -130,11 +130,11 @@ export default function ActivityFeed({
key={quoteIndex} key={quoteIndex}
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6, filter: "blur(2px)", transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] } }} exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.5 }}
className="space-y-4" className="space-y-4"
> >
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-400 dark:text-stone-400 italic"> <p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-300 italic">
&ldquo;{allQuotes[quoteIndex].content}&rdquo; &ldquo;{allQuotes[quoteIndex].content}&rdquo;
</p> </p>
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest"> <p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest">
@@ -143,7 +143,7 @@ export default function ActivityFeed({
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div> </div>
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 dark:text-stone-400 pt-4 border-t border-stone-100 dark:border-stone-800"> <div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 dark:text-stone-600 pt-4 border-t border-stone-100 dark:border-stone-800">
<span className="w-1.5 h-1.5 rounded-full bg-stone-200 dark:bg-stone-700 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-stone-200 dark:bg-stone-700 animate-pulse" />
{t("idleStatus")} {t("idleStatus")}
</div> </div>
@@ -160,7 +160,7 @@ export default function ActivityFeed({
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-600 dark:text-emerald-400">{t("codingNow")}</span> <span className="text-[10px] font-black uppercase tracking-widest text-emerald-600 dark:text-emerald-400">{t("codingNow")}</span>
</div> </div>
<p className="font-bold text-stone-900 dark:text-white text-lg truncate">{data.coding.project}</p> <p className="font-bold text-stone-900 dark:text-white text-lg truncate">{data.coding.project}</p>
<p className="text-xs text-stone-500 dark:text-stone-400 truncate">{data.coding.file}</p> <p className="text-xs text-stone-500 dark:text-white/50 truncate">{data.coding.file}</p>
</motion.div> </motion.div>
)} )}
@@ -207,14 +207,14 @@ export default function ActivityFeed({
{[0, 1, 2].map((i) => ( {[0, 1, 2].map((i) => (
<motion.div <motion.div
key={i} key={i}
animate={{ scaleY: [0.2, 1, 0.2] }} animate={{ height: ["20%", "100%", "20%"] }}
transition={{ transition={{
duration: 0.8, duration: 0.8,
repeat: Infinity, repeat: Infinity,
delay: i * 0.2, delay: i * 0.2,
ease: "easeInOut" ease: "easeInOut"
}} }}
className="w-[2px] h-full origin-bottom bg-[#1DB954] rounded-full" className="w-[2px] bg-[#1DB954] rounded-full"
/> />
))} ))}
</div> </div>
@@ -225,18 +225,18 @@ export default function ActivityFeed({
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex gap-4 relative z-10" className="flex gap-4 relative z-10"
> >
<div className="w-16 h-16 rounded-lg overflow-hidden shrink-0 shadow-md relative group-hover:shadow-xl transition-shadow duration-300"> <div className="w-16 h-16 rounded-lg overflow-hidden shrink-0 shadow-md relative group-hover:shadow-xl transition-shadow duration-500">
<Image <Image
src={data.music.albumArt} src={data.music.albumArt}
alt="Album Art" alt="Album Art"
fill fill
className="object-cover transition-transform duration-500 ease-out group-hover:scale-105" className="object-cover transition-transform duration-700 group-hover:scale-110"
sizes="64px" sizes="64px"
/> />
</div> </div>
<div className="min-w-0 flex flex-col justify-center"> <div className="min-w-0 flex flex-col justify-center">
<p className="font-bold text-[#1DB954] dark:text-[#1DB954] text-base truncate leading-tight mb-1 hover:underline">{data.music.track}</p> <p className="font-bold text-[#1DB954] dark:text-[#1DB954] text-base truncate leading-tight mb-1 hover:underline">{data.music.track}</p>
<p className="text-sm text-stone-600 dark:text-stone-400 truncate font-medium">{data.music.artist}</p> <p className="text-sm text-stone-600 dark:text-white/60 truncate font-medium">{data.music.artist}</p>
</div> </div>
</a> </a>
{/* Subtle Spotify branding gradient */} {/* Subtle Spotify branding gradient */}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import React, { useEffect, useState } from "react";
import BackgroundBlobs from "@/components/BackgroundBlobs";
export default function BackgroundBlobsClient() {
// Avoid SSR/webpack bailout issues from `next/dynamic({ ssr:false })`
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <BackgroundBlobs />;
}
+2 -2
View File
@@ -103,9 +103,9 @@ export default function BentoChat() {
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()} onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Ask me..." placeholder="Ask me..."
className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-[border-color,box-shadow,background-color] duration-150 ease-out shadow-inner dark:text-white" className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-all shadow-inner dark:text-white"
/> />
<button onClick={handleSend} aria-label="Send message" className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple active:scale-[0.97] transition-transform duration-150 ease-out"> <button onClick={handleSend} aria-label="Send message" className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple hover:scale-110 transition-transform">
<Send size={18} /> <Send size={18} />
</button> </button>
</div> </div>
+17 -21
View File
@@ -196,13 +196,11 @@ export default function ChatWidget() {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error"); const errorText = await response.text().catch(() => "Unknown error");
if (process.env.NODE_ENV === 'development') { console.error("Chat API error:", {
console.error("Chat API error:", { status: response.status,
status: response.status, statusText: response.statusText,
statusText: response.statusText, error: errorText,
error: errorText, });
});
}
throw new Error( throw new Error(
`Failed to get response: ${response.status} - ${errorText.substring(0, 100)}`, `Failed to get response: ${response.status} - ${errorText.substring(0, 100)}`,
); );
@@ -231,9 +229,7 @@ export default function ChatWidget() {
setMessages((prev) => [...prev, botMessage]); setMessages((prev) => [...prev, botMessage]);
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { console.error("Chat error:", error);
console.error("Chat error:", error);
}
const errorMessage: Message = { const errorMessage: Message = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
@@ -287,23 +283,23 @@ export default function ChatWidget() {
<motion.div <motion.div
role="button" role="button"
tabIndex={0} tabIndex={0}
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1, transition: { duration: 0.25, ease: [0.23, 1, 0.32, 1] } }} animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0, transition: { duration: 0.15, ease: [0.23, 1, 0.32, 1] } }} exit={{ scale: 0, opacity: 0 }}
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
setIsOpen(true); setIsOpen(true);
} }
}} }}
className="fixed bottom-4 left-4 md:bottom-6 md:left-6 z-30 bg-white/80 backdrop-blur-xl text-stone-900 p-3.5 rounded-full shadow-[0_10px_26px_rgba(41,37,36,0.16)] hover:bg-white active:scale-[0.97] transition-[background-color,transform] duration-150 ease-out group cursor-pointer border border-white/60 ring-1 ring-white/30" className="fixed bottom-4 left-4 md:bottom-6 md:left-6 z-30 bg-white/80 backdrop-blur-xl text-stone-900 p-3.5 rounded-full shadow-[0_10px_26px_rgba(41,37,36,0.16)] hover:bg-white hover:scale-105 transition-all duration-300 group cursor-pointer border border-white/60 ring-1 ring-white/30"
aria-label="Open chat" aria-label="Open chat"
> >
<MessageCircle size={24} /> <MessageCircle size={24} />
<span className="absolute top-0 right-0 w-3 h-3 bg-green-500 rounded-full animate-pulse shadow-sm border-2 border-white" /> <span className="absolute top-0 right-0 w-3 h-3 bg-green-500 rounded-full animate-pulse shadow-sm border-2 border-white" />
{/* Tooltip */} {/* Tooltip */}
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-1.5 bg-stone-900/90 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-150 ease-out group-hover:delay-300 whitespace-nowrap pointer-events-none z-[100] shadow-xl backdrop-blur-sm"> <span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-1.5 bg-stone-900/90 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-[100] shadow-xl backdrop-blur-sm">
Chat with AI Chat with AI
</span> </span>
</motion.div> </motion.div>
@@ -318,8 +314,8 @@ export default function ChatWidget() {
initial={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }} initial={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }} animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }} exit={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
transition={{ type: "spring", duration: 0.35, bounce: 0 }} transition={{ type: "spring", damping: 30, stiffness: 400 }}
className="origin-bottom-left fixed bottom-20 left-4 right-4 md:bottom-24 md:left-6 md:right-auto z-30 md:w-[380px] h-[60vh] md:h-[550px] max-h-[600px] bg-white/80 backdrop-blur-xl saturate-100 rounded-2xl shadow-[0_12px_40px_rgba(41,37,36,0.16)] flex flex-col overflow-hidden border border-white/60 ring-1 ring-white/30" className="fixed bottom-20 left-4 right-4 md:bottom-24 md:left-6 md:right-auto z-30 md:w-[380px] h-[60vh] md:h-[550px] max-h-[600px] bg-white/80 backdrop-blur-xl saturate-100 rounded-2xl shadow-[0_12px_40px_rgba(41,37,36,0.16)] flex flex-col overflow-hidden border border-white/60 ring-1 ring-white/30"
> >
{/* Header */} {/* Header */}
<div className="bg-white/70 text-stone-900 p-4 flex items-center justify-between border-b border-white/50"> <div className="bg-white/70 text-stone-900 p-4 flex items-center justify-between border-b border-white/50">
@@ -440,7 +436,7 @@ export default function ChatWidget() {
</div> </div>
{/* Input */} {/* Input */}
<div className="p-4 bg-stone-50 border-t border-[#e7e5e4]"> <div className="p-4 bg-[#fdfcf8] border-t border-[#e7e5e4]">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
ref={inputRef} ref={inputRef}
@@ -450,12 +446,12 @@ export default function ChatWidget() {
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
placeholder="Ask anything..." placeholder="Ask anything..."
disabled={isLoading} disabled={isLoading}
className="flex-1 px-4 py-3 text-sm bg-[#f5f5f4] text-[#292524] rounded-xl border border-[#e7e5e4] focus:outline-none focus:ring-2 focus:ring-[#e7e5e4] focus:border-[#a8a29e] focus:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[#78716c] transition-[border-color,box-shadow,background-color] duration-150 ease-out shadow-inner" className="flex-1 px-4 py-3 text-sm bg-[#f5f5f4] text-[#292524] rounded-xl border border-[#e7e5e4] focus:outline-none focus:ring-2 focus:ring-[#e7e5e4] focus:border-[#a8a29e] focus:bg-[#fdfcf8] disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[#78716c] transition-all shadow-inner"
/> />
<button <button
onClick={handleSend} onClick={handleSend}
disabled={!inputValue.trim() || isLoading} disabled={!inputValue.trim() || isLoading}
className="p-3 bg-[#292524] text-[#fdfcf8] rounded-xl hover:bg-[#44403c] hover:shadow-lg active:scale-[0.97] transition-[background-color,box-shadow,transform] duration-150 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100 shadow-md flex items-center justify-center aspect-square" className="p-3 bg-[#292524] text-[#fdfcf8] rounded-xl hover:bg-[#44403c] hover:shadow-lg hover:scale-105 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 shadow-md flex items-center justify-center aspect-square"
aria-label="Send message" aria-label="Send message"
> >
{isLoading ? ( {isLoading ? (
@@ -480,7 +476,7 @@ export default function ChatWidget() {
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
disabled={isLoading} disabled={isLoading}
className="px-3 py-1.5 text-xs font-medium bg-[#f5f5f4] text-[#57534e] rounded-lg hover:bg-[#e7e5e4] hover:text-[#292524] border border-[#e7e5e4] transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97] whitespace-nowrap disabled:opacity-50 flex-shrink-0 shadow-sm" className="px-3 py-1.5 text-xs font-medium bg-[#f5f5f4] text-[#57534e] rounded-lg hover:bg-[#e7e5e4] hover:text-[#292524] border border-[#e7e5e4] transition-all whitespace-nowrap disabled:opacity-50 flex-shrink-0 shadow-sm"
> >
{suggestion} {suggestion}
</button> </button>
+40 -9
View File
@@ -1,28 +1,38 @@
"use client"; "use client";
import React from "react"; import React, { useEffect, useState } from "react";
import { MotionConfig } from "framer-motion"; import { usePathname } from "next/navigation";
import dynamic from "next/dynamic";
import { ToastProvider } from "@/components/Toast"; import { ToastProvider } from "@/components/Toast";
import ErrorBoundary from "@/components/ErrorBoundary"; import ErrorBoundary from "@/components/ErrorBoundary";
import { ConsentProvider } from "./ConsentProvider"; import { ConsentProvider } from "./ConsentProvider";
import { ThemeProvider } from "./ThemeProvider"; import { ThemeProvider } from "./ThemeProvider";
const BackgroundBlobs = dynamic(
() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })),
{ ssr: false, loading: () => null }
);
export default function ClientProviders({ export default function ClientProviders({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [mounted, setMounted] = useState(false);
const pathname = usePathname();
useEffect(() => {
setMounted(true);
}, [pathname]);
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ErrorBoundary> <ErrorBoundary>
<ConsentProvider> <ConsentProvider>
<ThemeProvider> <ThemeProvider>
{/* Respect prefers-reduced-motion in every Framer Motion animation */} <GatedProviders mounted={mounted}>
<MotionConfig reducedMotion="user"> {children}
<GatedProviders> </GatedProviders>
{children}
</GatedProviders>
</MotionConfig>
</ThemeProvider> </ThemeProvider>
</ConsentProvider> </ConsentProvider>
</ErrorBoundary> </ErrorBoundary>
@@ -30,10 +40,31 @@ export default function ClientProviders({
); );
} }
function GatedProviders({ children }: { children: React.ReactNode }) { function GatedProviders({
children,
mounted,
}: {
children: React.ReactNode;
mounted: boolean;
}) {
// Defer animated background blobs until after LCP
const [deferredReady, setDeferredReady] = useState(false);
useEffect(() => {
if (!mounted) return;
let id: ReturnType<typeof setTimeout> | number;
if (typeof requestIdleCallback !== "undefined") {
id = requestIdleCallback(() => setDeferredReady(true), { timeout: 5000 });
return () => cancelIdleCallback(id as number);
} else {
id = setTimeout(() => setDeferredReady(true), 200);
return () => clearTimeout(id);
}
}, [mounted]);
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ToastProvider> <ToastProvider>
{deferredReady && <BackgroundBlobs />}
<div className="relative z-10">{children}</div> <div className="relative z-10">{children}</div>
</ToastProvider> </ToastProvider>
</ErrorBoundary> </ErrorBoundary>
+1 -2
View File
@@ -36,8 +36,7 @@ export function AboutClient({ locale }: { locale: string; translations: AboutTra
const messages = { const messages = {
home: { home: {
about: baseMessages.home.about about: baseMessages.home.about
}, }
about: baseMessages.about
}; };
return ( return (
+12 -12
View File
@@ -114,7 +114,7 @@ const Contact = () => {
} }
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
if (process.env.NODE_ENV === "development") console.error("Error sending email:", error); console.error("Error sending email:", error);
} }
showEmailError( showEmailError(
"Network error. Please check your connection and try again.", "Network error. Please check your connection and try again.",
@@ -155,7 +155,7 @@ const Contact = () => {
return ( return (
<section <section
id="contact" id="contact"
className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950 transition-colors duration-500" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500"
> >
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8"> <div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
@@ -200,7 +200,7 @@ const Contact = () => {
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Email</span> <span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Email</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span> <span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
</div> </div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-colors duration-200"> <div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
<Mail size={16} /> <Mail size={16} />
</div> </div>
</a> </a>
@@ -213,7 +213,7 @@ const Contact = () => {
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Code</span> <span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Code</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span> <span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
</div> </div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-colors duration-200"> <div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
<Github size={16} /> <Github size={16} />
</div> </div>
</a> </a>
@@ -226,7 +226,7 @@ const Contact = () => {
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Professional</span> <span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Professional</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span> <span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
</div> </div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-colors duration-200"> <div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
<Linkedin size={16} /> <Linkedin size={16} />
</div> </div>
</a> </a>
@@ -266,7 +266,7 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
required required
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium" className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
placeholder={tForm("placeholders.name")} placeholder={tForm("placeholders.name")}
/> />
</div> </div>
@@ -283,7 +283,7 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
required required
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium" className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
placeholder={tForm("placeholders.email")} placeholder={tForm("placeholders.email")}
/> />
</div> </div>
@@ -301,7 +301,7 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
required required
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium" className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
placeholder={tForm("placeholders.subject")} placeholder={tForm("placeholders.subject")}
/> />
</div> </div>
@@ -318,7 +318,7 @@ const Contact = () => {
onBlur={handleBlur} onBlur={handleBlur}
required required
rows={5} rows={5}
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-[border-color,box-shadow,background-color] duration-150 ease-out font-medium resize-none" className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium resize-none"
placeholder={tForm("placeholders.message")} placeholder={tForm("placeholders.message")}
/> />
</div> </div>
@@ -326,9 +326,9 @@ const Contact = () => {
<motion.button <motion.button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
whileTap={{ scale: 0.97 }} whileHover={{ scale: 1.01 }}
transition={{ duration: 0.15, ease: [0.23, 1, 0.32, 1] }} whileTap={{ scale: 0.99 }}
className="w-full py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] sm:tracking-[0.3em] flex items-center justify-center gap-3 shadow-xl hover:shadow-2xl transition-shadow duration-200 ease-out disabled:opacity-50" className="w-full py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] sm:tracking-[0.3em] flex items-center justify-center gap-3 shadow-xl hover:shadow-2xl transition-all disabled:opacity-50"
> >
{isSubmitting ? ( {isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
+13 -10
View File
@@ -44,7 +44,7 @@ const CurrentlyReading = () => {
} }
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") console.error("Error fetching currently reading:", error); console.error("Error fetching currently reading:", error);
} }
setBooks([]); setBooks([]);
} finally { } finally {
@@ -98,8 +98,12 @@ const CurrentlyReading = () => {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }} viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, delay: index * 0.06, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.6, delay: index * 0.1, ease: [0.25, 0.1, 0.25, 1] }}
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-lavender/30 dark:border-stone-700 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 dark:hover:border-stone-600 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-colors duration-300" whileHover={{
scale: 1.02,
transition: { duration: 0.4, ease: "easeOut" },
}}
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-lavender/30 dark:border-stone-700 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 dark:hover:border-stone-600 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-all duration-500 ease-out"
> >
{/* Background Blob Animation */} {/* Background Blob Animation */}
<motion.div <motion.div
@@ -120,9 +124,9 @@ const CurrentlyReading = () => {
{/* Book Cover */} {/* Book Cover */}
{book.image && ( {book.image && (
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.1 + index * 0.06, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
className="flex-shrink-0" className="flex-shrink-0"
> >
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600"> <div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
@@ -158,12 +162,11 @@ const CurrentlyReading = () => {
<span className="font-semibold">{isNaN(book.progress) ? 0 : book.progress}%</span> <span className="font-semibold">{isNaN(book.progress) ? 0 : book.progress}%</span>
</div> </div>
<div className="relative h-2 bg-white/50 dark:bg-stone-700 rounded-full overflow-hidden border border-white/70 dark:border-stone-600"> <div className="relative h-2 bg-white/50 dark:bg-stone-700 rounded-full overflow-hidden border border-white/70 dark:border-stone-600">
{/* scaleX instead of width: stays on the compositor, no layout per frame */}
<motion.div <motion.div
initial={{ scaleX: 0 }} initial={{ width: 0 }}
animate={{ scaleX: (isNaN(book.progress) ? 0 : book.progress) / 100 }} animate={{ width: `${isNaN(book.progress) ? 0 : book.progress}%` }}
transition={{ duration: 0.8, delay: 0.2 + index * 0.06, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 1, delay: 0.3 + index * 0.1, ease: "easeOut" }}
className="origin-left w-full absolute left-0 top-0 h-full bg-gradient-to-r from-liquid-lavender via-liquid-pink to-liquid-rose rounded-full shadow-sm" className="absolute left-0 top-0 h-full bg-gradient-to-r from-liquid-lavender via-liquid-pink to-liquid-rose rounded-full shadow-sm"
/> />
</div> </div>
</div> </div>
+9 -9
View File
@@ -15,7 +15,7 @@ const Footer = () => {
}; };
return ( return (
<footer className="bg-stone-50 dark:bg-stone-950 pt-16 sm:pt-24 md:pt-32 pb-8 sm:pb-12 px-4 sm:px-6 overflow-hidden transition-colors duration-500"> <footer className="bg-[#fdfcf8] dark:bg-stone-950 pt-16 sm:pt-24 md:pt-32 pb-8 sm:pb-12 px-4 sm:px-6 overflow-hidden transition-colors duration-500">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end"> <div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end">
@@ -25,15 +25,15 @@ const Footer = () => {
dk dk
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("role")}</p> <p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">Software Engineer</p>
<p className="text-stone-600 dark:text-stone-400 text-sm font-medium">&copy; {year} {t("madeIn")}.</p> <p className="text-stone-500 text-sm font-medium">© {year} All rights reserved.</p>
</div> </div>
</div> </div>
{/* Navigation Links */} {/* Navigation Links */}
<div className="md:col-span-4 grid grid-cols-2 gap-8"> <div className="md:col-span-4 grid grid-cols-2 gap-8">
<div className="space-y-4"> <div className="space-y-4">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{t("legalNotice")}</p> <p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Legal</p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link> <Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link> <Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
@@ -54,8 +54,8 @@ const Footer = () => {
onClick={scrollToTop} onClick={scrollToTop}
className="group flex flex-col items-center gap-4 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors" className="group flex flex-col items-center gap-4 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
> >
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-600 dark:text-stone-400" style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>{t("backToTop")}</span> <span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-600 dark:text-stone-400 vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 group-active:scale-[0.97] transition-[background-color,color,transform] duration-200 ease-out shadow-sm"> <div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all shadow-sm">
<ArrowUp size={20} /> <ArrowUp size={20} />
</div> </div>
</button> </button>
@@ -66,15 +66,15 @@ const Footer = () => {
<div className="mt-10 sm:mt-16 md:mt-20 pt-6 sm:pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4"> <div className="mt-10 sm:mt-16 md:mt-20 pt-6 sm:pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest"> <p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">
{t("builtWith")} Next.js, Directus &amp; Passion. Built with Next.js, Directus & Passion.
</p> </p>
<p className="text-[10px] text-stone-500 dark:text-stone-500 tracking-wide"> <p className="text-[10px] text-stone-400 dark:text-stone-600 tracking-wide">
{t("aiDisclaimer")} {t("aiDisclaimer")}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" /> <div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">{t("systemsOnline")}</span> <span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">Systems Online</span>
</div> </div>
</div> </div>
</div> </div>
+6 -6
View File
@@ -29,7 +29,7 @@ const Header = () => {
{/* Logo Pill */} {/* Logo Pill */}
<Link <Link
href={`/${locale}`} 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" 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 hover:scale-105 active:scale-95 shadow-lg"
> >
<span className="font-black text-xs tracking-tighter">dk0</span> <span className="font-black text-xs tracking-tighter">dk0</span>
</Link> </Link>
@@ -40,7 +40,7 @@ const Header = () => {
<Link <Link
key={item.name} key={item.name}
href={item.href} 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" 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-all"
> >
{item.name} {item.name}
</Link> </Link>
@@ -62,7 +62,7 @@ const Header = () => {
{/* Mobile Menu Toggle */} {/* Mobile Menu Toggle */}
<button <button
onClick={() => setIsOpen(!isOpen)} 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" 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-colors shadow-sm"
> >
{isOpen ? <X size={14} /> : <Menu size={14} />} {isOpen ? <X size={14} /> : <Menu size={14} />}
</button> </button>
@@ -72,10 +72,10 @@ const Header = () => {
{/* Mobile Menu Overlay */} {/* Mobile Menu Overlay */}
<div <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 ${ 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 transition-all duration-200 ${
isOpen isOpen
? "opacity-100 translate-y-0 scale-100 pointer-events-auto" ? "opacity-100 translate-y-0 pointer-events-auto"
: "opacity-0 -translate-y-2 scale-[0.97] pointer-events-none" : "opacity-0 -translate-y-2 pointer-events-none"
}`} }`}
> >
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
+150 -120
View File
@@ -50,19 +50,10 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
setScrolled(window.scrollY > 50); setScrolled(window.scrollY > 50);
}; };
window.addEventListener("scroll", handleScroll, { passive: true }); window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, []); }, []);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [isOpen]);
const navItems = [ const navItems = [
{ name: translations.home, href: `/${locale}` }, { name: translations.home, href: `/${locale}` },
{ name: translations.about, href: isHome ? "#about" : `/${locale}#about` }, { name: translations.about, href: isHome ? "#about" : `/${locale}#about` },
@@ -86,133 +77,172 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
const enHref = `/en${pathWithoutLocale}${query}`; const enHref = `/en${pathWithoutLocale}${query}`;
const deHref = `/de${pathWithoutLocale}${query}`; const deHref = `/de${pathWithoutLocale}${query}`;
const de = locale === "de";
// Über dem roten Plakat bleibt der Kopf transparent, sonst wird er zum Papierstreifen
const solid = scrolled || !isHome;
const pill =
"rounded-full border-[1.5px] border-ink px-4 py-2 text-sm font-medium transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-paper active:scale-[0.97]";
const langPill = (active: boolean) =>
`rounded-full px-3 py-2 font-mono text-xs transition-[background-color,color,transform] duration-150 ease-out active:scale-[0.97] ${
active ? "bg-ink text-paper" : "hover:bg-ink/10"
}`;
return ( return (
<> <>
<header <header className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none">
className={`fixed inset-x-0 top-0 z-50 border-b-2 transition-[background-color,border-color] duration-200 ease-out ${ <div
solid ? "border-ink bg-paper/90 backdrop-blur-md" : "border-transparent bg-transparent" className={`pointer-events-auto transition-all duration-500 ease-out ${
}`} scrolled ? "w-full max-w-5xl" : "w-full max-w-7xl"
> }`}
<div className="flex items-center justify-between gap-4 px-4 py-3 text-ink sm:px-6 lg:px-10"> >
<Link <div
href={`/${locale}`} className={`backdrop-blur-xl transition-all duration-500 flex justify-between items-center ${
className="font-display text-2xl font-extrabold tracking-[-0.03em] transition-transform duration-150 ease-out active:scale-[0.97]" scrolled
aria-label={de ? "dk0, zur Startseite" : "dk0, home"} ? "bg-white/95 border border-stone-200/50 shadow-[0_8px_30px_rgba(0,0,0,0.12)] rounded-full px-6 py-3"
: "bg-white/85 border border-stone-200/30 shadow-[0_4px_24px_rgba(0,0,0,0.08)] px-4 py-4 rounded-full"
}`}
> >
dk<span className={`transition-colors duration-200 ${solid ? "text-signal" : "text-paper"}`}>0</span> <div className="flex items-center space-x-2 hover:scale-105 transition-transform">
</Link> <Link
href={`/${locale}`}
<nav className="hidden items-center gap-2 md:flex" aria-label={de ? "Hauptnavigation" : "Main navigation"}> className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center"
{navItems.slice(1).map((item) => ( >
<Link key={item.name} href={item.href} className={pill}> dk<span className="text-red-500">0</span>
{item.name}
</Link> </Link>
))}
<div className="ml-2 flex items-center gap-1 border-l-2 border-ink/20 pl-3">
<Link href={deHref} className={langPill(locale === "de")} aria-current={locale === "de" ? "true" : undefined}>
DE
</Link>
<Link href={enHref} className={langPill(locale === "en")} aria-current={locale === "en" ? "true" : undefined}>
EN
</Link>
<ThemeToggle />
</div> </div>
</nav>
<button <nav className="hidden md:flex items-center space-x-8">
onClick={() => setIsOpen(!isOpen)} {navItems.map((item) => (
className={`${pill} inline-flex items-center gap-2 md:hidden`} <div key={item.name} className="hover:-translate-y-0.5 active:scale-95 transition-all">
aria-label={de ? "Menü öffnen" : "Open menu"} <Link
aria-expanded={isOpen} href={item.href}
> className="text-stone-700 hover:text-stone-900 font-medium transition-colors relative liquid-hover"
<MenuIcon size={18} /> {de ? "Menü" : "Menu"} >
</button> {item.name}
</Link>
</div>
))}
{/* Language Switcher */}
<div className="flex items-center space-x-2 ml-4 pl-4 border-l border-stone-300">
<Link
href={enHref}
className={`px-2 py-1 text-sm font-medium rounded transition-all ${
locale === "en"
? "bg-stone-900 text-white"
: "text-stone-600 hover:text-stone-900 hover:bg-stone-100"
}`}
>
EN
</Link>
<Link
href={deHref}
className={`px-2 py-1 text-sm font-medium rounded transition-all ${
locale === "de"
? "bg-stone-900 text-white"
: "text-stone-600 hover:text-stone-900 hover:bg-stone-100"
}`}
>
DE
</Link>
<ThemeToggle />
</div>
</nav>
<button
onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-all hover:scale-105 active:scale-95"
aria-label="Toggle menu"
>
{isOpen ? <XIcon size={24} /> : <MenuIcon size={24} />}
</button>
</div>
</div> </div>
</header> </header>
{/* Abdunklung hinter dem Menü — bleibt gemountet, damit sie ausblenden kann */} {/* Mobile menu overlay */}
<div {isOpen && (
aria-hidden="true" <div
className={`fixed inset-0 z-40 bg-ink/40 transition-opacity md:hidden ${ className="fixed inset-0 bg-stone-900/50 backdrop-blur-sm z-40 md:hidden"
isOpen ? "opacity-100 duration-300 ease-out" : "pointer-events-none opacity-0 duration-200 ease-out" onClick={() => setIsOpen(false)}
}`} />
onClick={() => setIsOpen(false)} )}
/>
{/* Menü als Drawer: Drawer-Kurve rein, schneller raus */} {/* Mobile menu panel */}
<div {isOpen && (
inert={!isOpen} <div
role="dialog" className="fixed right-0 top-0 h-full w-80 bg-white shadow-2xl z-50 md:hidden overflow-y-auto"
aria-label={de ? "Menü" : "Menu"} >
className={`fixed right-0 top-0 z-50 flex h-full w-80 max-w-[85vw] flex-col overflow-y-auto overscroll-contain border-l-2 border-ink bg-paper text-ink transition-[transform,visibility] md:hidden ${ <div className="p-6">
isOpen ? "visible translate-x-0 duration-500 ease-drawer" : "invisible translate-x-full duration-200 ease-out" <div className="flex justify-between items-center mb-8">
}`}
>
<div className="flex items-center justify-between px-5 py-3">
<Link
href={`/${locale}`}
className="font-display text-2xl font-extrabold tracking-[-0.03em]"
onClick={() => setIsOpen(false)}
>
dk<span className="text-signal">0</span>
</Link>
<button onClick={() => setIsOpen(false)} className={`${pill} inline-flex items-center`} aria-label={de ? "Menü schließen" : "Close menu"}>
<XIcon size={18} />
</button>
</div>
<nav className="flex flex-col border-t-2 border-ink" aria-label={de ? "Hauptnavigation" : "Main navigation"}>
{navItems.map((item) => (
<Link <Link
key={item.name} href={`/${locale}`}
href={item.href} className="text-2xl font-black text-stone-900"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="border-b-2 border-ink px-5 py-4 font-display text-3xl font-extrabold tracking-[-0.035em] transition-colors duration-150 active:bg-paper-2"
> >
{item.name} dk<span className="text-red-500">0</span>
</Link> </Link>
))} <button
</nav> onClick={() => setIsOpen(false)}
className="p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700"
aria-label="Close menu"
>
<XIcon size={24} />
</button>
</div>
<div className="flex items-center gap-1 px-5 py-5"> <nav className="space-y-2">
<Link href={deHref} onClick={() => setIsOpen(false)} className={langPill(locale === "de")}> {navItems.map((item) => (
DE <Link
</Link> key={item.name}
<Link href={enHref} onClick={() => setIsOpen(false)} className={langPill(locale === "en")}> href={item.href}
EN onClick={() => setIsOpen(false)}
</Link> className="block px-4 py-3 text-stone-700 hover:bg-stone-50 rounded-lg font-medium transition-colors"
<ThemeToggle />
</div>
<div className="mt-auto flex gap-2 px-5 pb-8">
{socialLinks.map((link) => {
const Icon = link.icon;
return (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={`${pill} inline-flex items-center`}
aria-label={link.label}
> >
<Icon size={18} /> {item.name}
</a> </Link>
); ))}
})} </nav>
{/* Language Switcher Mobile */}
<div className="flex items-center gap-2 mt-6 pt-6 border-t border-stone-200">
<Link
href={enHref}
onClick={() => setIsOpen(false)}
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${
locale === "en"
? "bg-stone-900 text-white"
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
}`}
>
EN
</Link>
<Link
href={deHref}
onClick={() => setIsOpen(false)}
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${
locale === "de"
? "bg-stone-900 text-white"
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
}`}
>
DE
</Link>
<ThemeToggle />
</div>
<div className="mt-8 pt-6 border-t border-stone-200">
<div className="flex justify-center space-x-6">
{socialLinks.map((link) => {
const Icon = link.icon;
return (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-stone-100 hover:bg-stone-900 hover:text-white text-stone-700 transition-all"
aria-label={link.label}
>
<Icon size={20} />
</a>
);
})}
</div>
</div>
</div> </div>
</div> </div>
)}
</> </>
); );
} }
+50 -44
View File
@@ -1,67 +1,73 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import KineticWordmark from "./KineticWordmark"; import Image from "next/image";
interface HeroProps { interface HeroProps {
locale: string; locale: string;
} }
// Abgabe der Masterarbeit, Grundlage für den Countdown im Intro
const THESIS_DEADLINE = new Date("2027-02-15T12:00:00+01:00");
const MARQUEE = ["Self-Hosting", "Next.js", "SwiftUI", "Flutter", "Docker", "Microsoft 365", "Godot", "Home Assistant"];
export default async function Hero({ locale }: HeroProps) { export default async function Hero({ locale }: HeroProps) {
const t = await getTranslations({ locale, namespace: "home.hero" }); const t = await getTranslations({ locale, namespace: "home.hero" });
const daysLeft = Math.max(0, Math.ceil((THESIS_DEADLINE.getTime() - Date.now()) / 86_400_000));
return ( return (
<> <section className="relative min-h-screen flex flex-col items-center justify-center bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
<section id="top" className="relative overflow-hidden bg-signal px-4 pt-24 text-ink sm:px-6 md:pt-28 lg:px-10"> {/* Liquid Ambient Background — overflow-hidden here so the blobs are clipped, not the image/badge */}
<p className="font-mono text-[13px] leading-snug">{t("kicker")}</p> <div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px] opacity-20" />
<div className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px] opacity-15" />
</div>
<div className="mt-4 sm:mt-8"> <div className="relative z-10 max-w-7xl mx-auto w-full pt-12 sm:pt-16 md:pt-20 pb-10 sm:pb-16">
<KineticWordmark /> <div className="flex flex-col xl:flex-row items-center gap-8 sm:gap-10 xl:gap-24">
</div>
<div className="mt-3 grid gap-6 border-t-2 border-ink pb-8 pt-6 md:grid-cols-[1.3fr_1fr] md:items-end md:pb-10"> {/* Left: Text Content */}
<h1 className="text-balance font-display text-[clamp(2rem,4.4vw,4rem)] font-extrabold leading-[0.96] tracking-[-0.035em] [font-variation-settings:'wdth'_85]"> <div className="flex-1 text-center xl:text-left space-y-6 sm:space-y-8 md:space-y-10">
<span className="sr-only">Dennis Konkol: </span> <div className="inline-flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2 sm:py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm animate-[fadeIn_0.5s_ease-out]">
{t("claim")} <span className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-emerald-500 rounded-full animate-pulse" />
</h1> <span className="font-mono text-[10px] sm:text-[11px] font-black uppercase tracking-[0.2em] sm:tracking-[0.3em] text-stone-500">{t("badge")}</span>
<div> </div>
<p className="max-w-[42ch] text-[17px] leading-relaxed">
{t("intro")} {daysLeft > 0 && t("countdown", { days: daysLeft })} <h1 className="text-[2.75rem] sm:text-6xl md:text-8xl lg:text-[9.5rem] font-black tracking-tighter leading-[0.85] text-stone-900 dark:text-stone-50 uppercase">
<span className="block">
{t("line1")}
</span>
<span className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-2 sm:pb-4">
{t("line2")}
</span>
</h1>
<p className="text-base sm:text-lg md:text-xl lg:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto xl:mx-0 font-light leading-relaxed tracking-tight">
{t("description")}
</p> </p>
<div className="mt-5 flex flex-wrap gap-2.5">
<a <div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 justify-center xl:justify-start pt-2 sm:pt-4 animate-[fadeIn_0.6s_ease-out_0.3s_both]">
href="#projects" <a href="#projects" className="group relative px-8 sm:px-12 py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl sm:rounded-3xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all shadow-2xl">
className="inline-flex items-center gap-2 rounded-full bg-ink px-5 py-3.5 text-[15px] font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]" <div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
> {t("ctaWork")}
{t("ctaWork")} <span aria-hidden="true">↓</span>
</a> </a>
<a <a href="#contact" className="font-black text-xs uppercase tracking-[0.2em] text-stone-700 dark:text-stone-300 hover:text-stone-900 dark:hover:text-white transition-colors">
href="#contact"
className="inline-flex items-center rounded-full px-5 py-3.5 text-[15px] font-semibold shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-signal active:scale-[0.97]"
>
{t("ctaContact")} {t("ctaContact")}
</a> </a>
</div> </div>
</div> </div>
</div>
</section>
<div className="overflow-hidden bg-ink text-paper" aria-hidden="true"> {/* Right: The Photo */}
<div className="flex w-max animate-marquee gap-9 py-[18px] font-display text-[clamp(1.1rem,2.4vw,1.7rem)] font-bold leading-none"> <div className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-80 md:h-80 xl:w-[500px] xl:h-[500px] shrink-0 mt-4 sm:mt-8 xl:mt-0 animate-[fadeIn_1s_ease-out]">
{[...MARQUEE, ...MARQUEE].map((item, i) => ( <div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint to-liquid-purple rounded-[3rem] sm:rounded-[4rem] lg:rounded-[5rem] rotate-12 scale-90 opacity-20 blur-3xl" />
<span key={i} className="flex items-center gap-9 whitespace-nowrap"> <div className="relative w-full h-full rounded-[2.5rem] sm:rounded-[3rem] lg:rounded-[4rem] overflow-hidden border-[12px] sm:border-[16px] lg:border-[24px] border-white dark:border-stone-900 shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)] sm:shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]" style={{ willChange: "transform" }}>
{item} <Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority fetchPriority="high" sizes="(max-width: 640px) 208px, (max-width: 768px) 256px, (max-width: 1280px) 320px, 500px" />
<svg viewBox="0 0 24 24" className="h-[0.7em] w-[0.7em] fill-signal"> </div>
<path d="M12 1.5l2.6 7.9 7.9 2.6-7.9 2.6-2.6 7.9-2.6-7.9-7.9-2.6 7.9-2.6z" />
</svg> <div className="absolute -bottom-4 -left-4 sm:-bottom-6 sm:-left-6 bg-white dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-xl sm:rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
</span> <span className="font-mono text-xs sm:text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
))} </div>
</div>
</div> </div>
</div> </div>
</>
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4 animate-bounce">
<div className="w-px h-16 bg-gradient-to-b from-stone-300 dark:from-stone-700 to-transparent" />
</div>
</section>
); );
} }
-99
View File
@@ -1,99 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
const LETTERS = ["D", "K", "0"] as const;
// Ruhezustand und Spannweite der variablen Achsen von Bricolage Grotesque
const REST = { wght: 800, wdth: 100 };
const NEAR = { wght: 220, wdth: 75 };
const REACH_PX = 520;
/**
* Das große „DK0“ im Plakat. Die Buchstaben werden dünner und schmaler, je näher
* der Zeiger kommt, geglättet über eine kritisch gedämpfte Feder.
* Ohne Maus oder bei reduzierter Bewegung bleibt es statisch. Das Markup ist
* vollständig server-gerendert, JS fügt nur die Reaktion hinzu.
*/
export default function KineticWordmark() {
const rootRef = useRef<HTMLDivElement>(null);
const letterRefs = useRef<(HTMLSpanElement | null)[]>([]);
useEffect(() => {
const root = rootRef.current;
const area = root?.closest("section");
if (!root || !area) return;
const fine = window.matchMedia?.("(hover: hover) and (pointer: fine)")?.matches ?? false;
const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
if (!fine || reduce) return;
const state = LETTERS.map(() => ({ w: REST.wght, d: REST.wdth, vw: 0, vd: 0, tw: REST.wght, td: REST.wdth }));
let raf = 0;
const loop = () => {
let moving = false;
state.forEach((s, i) => {
s.vw = (s.vw + (s.tw - s.w) * 0.08) * 0.78;
s.w += s.vw;
s.vd = (s.vd + (s.td - s.d) * 0.08) * 0.78;
s.d += s.vd;
if (Math.abs(s.vw) + Math.abs(s.vd) > 0.05) moving = true;
const el = letterRefs.current[i];
if (el) el.style.fontVariationSettings = `"wght" ${s.w.toFixed(1)}, "wdth" ${s.d.toFixed(1)}`;
});
raf = moving ? requestAnimationFrame(loop) : 0;
};
const kick = () => {
if (!raf) raf = requestAnimationFrame(loop);
};
const onMove = (e: PointerEvent) => {
letterRefs.current.forEach((el, i) => {
if (!el) return;
const r = el.getBoundingClientRect();
const dist = Math.hypot(e.clientX - (r.left + r.width / 2), e.clientY - (r.top + r.height / 2));
const f = Math.min(1, dist / REACH_PX);
state[i].tw = NEAR.wght + f * (REST.wght - NEAR.wght);
state[i].td = NEAR.wdth + f * (REST.wdth - NEAR.wdth);
});
kick();
};
const onLeave = () => {
state.forEach((s) => {
s.tw = REST.wght;
s.td = REST.wdth;
});
kick();
};
area.addEventListener("pointermove", onMove);
area.addEventListener("pointerleave", onLeave);
return () => {
cancelAnimationFrame(raf);
area.removeEventListener("pointermove", onMove);
area.removeEventListener("pointerleave", onLeave);
};
}, []);
return (
<div
ref={rootRef}
aria-hidden="true"
className="flex select-none items-end justify-between font-display text-[clamp(8.5rem,33vw,31rem)] leading-[0.78] tracking-[-0.05em]"
>
{LETTERS.map((letter, i) => (
<span
key={letter}
ref={(el) => {
letterRefs.current[i] = el;
}}
className={`inline-block animate-wordmark-up ${letter === "0" ? "text-paper" : ""}`}
style={{ fontVariationSettings: `"wght" ${REST.wght}, "wdth" ${REST.wdth}`, animationDelay: `${i * 80}ms` }}
>
{letter}
</span>
))}
</div>
);
}
+5 -5
View File
@@ -39,7 +39,7 @@ const Projects = () => {
setProjects(data.projects || []); setProjects(data.projects || []);
} }
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === "development") console.error("Featured projects fetch failed:", error); console.error("Featured projects fetch failed:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -59,8 +59,8 @@ const Projects = () => {
{t("subtitle")} {t("subtitle")}
</p> </p>
</div> </div>
<Link href={`/${locale}/projects`} className="group flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-opacity text-xs uppercase tracking-widest"> <Link href={`/${locale}/projects`} className="group flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all text-xs uppercase tracking-widest">
{t("viewAll")} <ArrowUpRight className="group-hover:-translate-y-0.5 group-hover:translate-x-0.5 transition-transform duration-200 ease-out" size={14} /> {t("viewAll")} <ArrowUpRight className="group-hover:-translate-y-1 group-hover:translate-x-1 transition-transform" size={14} />
</Link> </Link>
</div> </div>
@@ -94,7 +94,7 @@ const Projects = () => {
src={project.imageUrl} src={project.imageUrl}
alt={project.title} alt={project.title}
fill fill
className="object-cover transition-transform duration-500 ease-out group-hover:scale-[1.03]" className="object-cover transition-transform duration-700 group-hover:scale-105"
/> />
) : ( ) : (
<ProjectThumbnail <ProjectThumbnail
@@ -106,7 +106,7 @@ const Projects = () => {
/> />
)} )}
{/* Overlay on Hover */} {/* Overlay on Hover */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300" /> <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-500" />
</div> </div>
{/* Text Content */} {/* Text Content */}
+11 -7
View File
@@ -72,7 +72,7 @@ const ReadBooks = () => {
} }
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development") console.error("Error fetching book reviews:", error); console.error("Error fetching book reviews:", error);
} }
setReviews([]); setReviews([]);
} finally { } finally {
@@ -136,11 +136,15 @@ const ReadBooks = () => {
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }} viewport={{ once: true, margin: "-50px" }}
transition={{ transition={{
duration: 0.5, duration: 0.6,
delay: index * 0.06, delay: index * 0.1,
ease: [0.23, 1, 0.32, 1], ease: [0.25, 0.1, 0.25, 1],
}} }}
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-teal/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-mint/30 dark:border-stone-700 rounded-xl p-5 backdrop-blur-sm hover:border-liquid-mint/50 dark:hover:border-stone-600 hover:from-liquid-mint/20 hover:via-liquid-sky/15 hover:to-liquid-teal/20 transition-colors duration-300" whileHover={{
scale: 1.02,
transition: { duration: 0.4, ease: "easeOut" },
}}
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-teal/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-mint/30 dark:border-stone-700 rounded-xl p-5 backdrop-blur-sm hover:border-liquid-mint/50 dark:hover:border-stone-600 hover:from-liquid-mint/20 hover:via-liquid-sky/15 hover:to-liquid-teal/20 transition-all duration-500 ease-out"
> >
{/* Background Blob */} {/* Background Blob */}
<motion.div <motion.div
@@ -161,9 +165,9 @@ const ReadBooks = () => {
{/* Book Cover */} {/* Book Cover */}
{review.book_image && ( {review.book_image && (
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.1 + index * 0.06, ease: [0.23, 1, 0.32, 1] }} transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
className="flex-shrink-0" className="flex-shrink-0"
> >
<div className="relative w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600"> <div className="relative w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
+41
View File
@@ -0,0 +1,41 @@
"use client";
import React, { useEffect, useState } from "react";
const ToastProvider = React.lazy(() =>
import("@/components/Toast").then((mod) => ({
default: mod.ToastProvider,
}))
);
const BackgroundBlobs = React.lazy(() =>
import("@/components/BackgroundBlobs")
);
const ChatWidget = React.lazy(() => import("./ChatWidget"));
export default function RootProviders({
children,
}: {
children: React.ReactNode;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div className="relative z-10">{children}</div>;
}
return (
<React.Suspense fallback={<div className="relative z-10">{children}</div>}>
<ToastProvider>
<BackgroundBlobs />
<div className="relative z-10">{children}</div>
<ChatWidget />
</ToastProvider>
</React.Suspense>
);
}
+3 -6
View File
@@ -18,11 +18,9 @@ export default function ScrollFadeIn({ children, className = "", delay = 0 }: Sc
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [hasMounted, setHasMounted] = useState(false); const [hasMounted, setHasMounted] = useState(false);
const [reduceMotion, setReduceMotion] = useState(false);
useEffect(() => { useEffect(() => {
setHasMounted(true); setHasMounted(true);
setReduceMotion(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
@@ -39,8 +37,7 @@ export default function ScrollFadeIn({ children, className = "", delay = 0 }: Sc
observer.unobserve(el); observer.unobserve(el);
} }
}, },
// Start a little before the section is fully in view so it never feels late { threshold: 0.1 }
{ threshold: 0.1, rootMargin: "0px 0px -60px 0px" }
); );
observer.observe(el); observer.observe(el);
@@ -53,8 +50,8 @@ export default function ScrollFadeIn({ children, className = "", delay = 0 }: Sc
className={className} className={className}
style={hasMounted ? { style={hasMounted ? {
opacity: isVisible ? 1 : 0, opacity: isVisible ? 1 : 0,
transform: isVisible || reduceMotion ? "translateY(0)" : "translateY(16px)", transform: isVisible ? "translateY(0)" : "translateY(30px)",
transition: `opacity 0.6s var(--ease-out) ${delay}s, transform 0.6s var(--ease-out) ${delay}s`, transition: `opacity 0.6s ease ${delay}s, transform 0.6s ease ${delay}s`,
} : undefined} } : undefined}
> >
{children} {children}
@@ -0,0 +1,33 @@
// Pure CSS gradient background — replaces the Three.js/WebGL shader gradient.
// Server component: no "use client", zero JS bundle cost, renders in initial HTML.
// Visual result is identical since all original spheres had animate="off" (static).
export default function ShaderGradientBackground() {
return (
<div
aria-hidden="true"
className="fixed inset-0 -z-10 overflow-hidden pointer-events-none"
>
<div
className="absolute -top-[10%] -left-[15%] w-[55%] h-[65%] rounded-full opacity-60"
style={{
background: "radial-gradient(ellipse at 50% 50%, #b01040 0%, #e167c5 40%, transparent 70%)",
filter: "blur(80px)",
}}
/>
<div
className="absolute top-[25%] -right-[10%] w-[50%] h-[60%] rounded-full opacity-55"
style={{
background: "radial-gradient(ellipse at 50% 50%, #e167c5 0%, #b01040 40%, transparent 70%)",
filter: "blur(80px)",
}}
/>
<div
className="absolute -bottom-[15%] left-[5%] w-[50%] h-[60%] rounded-full opacity-50"
style={{
background: "radial-gradient(ellipse at 50% 50%, #b04a17 0%, #e167c5 40%, transparent 70%)",
filter: "blur(80px)",
}}
/>
</div>
);
}
+11 -38
View File
@@ -1,63 +1,36 @@
"use client"; "use client";
import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; import React, { createContext, useContext, useEffect, useState } from "react";
type Theme = "system" | "light" | "dark"; type Theme = "light" | "dark";
const ThemeCtx = createContext<{ theme: Theme; resolvedTheme: "light" | "dark"; setTheme: (t: Theme) => void }>({ const ThemeCtx = createContext<{ theme: Theme; setTheme: (t: Theme) => void }>({
theme: "system", theme: "light",
resolvedTheme: "light",
setTheme: () => {}, setTheme: () => {},
}); });
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme: Theme) {
const resolved = theme === "system" ? getSystemTheme() : theme;
document.documentElement.classList.toggle("dark", resolved === "dark");
return resolved;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) { export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>("system"); const [theme, setThemeState] = useState<Theme>("light");
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
useEffect(() => { useEffect(() => {
try { try {
const stored = localStorage.getItem("theme") as Theme | null; const stored = localStorage.getItem("theme") as Theme | null;
if (stored === "dark" || stored === "light" || stored === "system") { if (stored === "dark" || stored === "light") {
setThemeState(stored); setThemeState(stored);
document.documentElement.classList.toggle("dark", stored === "dark");
} }
} catch {} } catch {}
}, []); }, []);
useEffect(() => { const setTheme = (t: Theme) => {
const resolved = applyTheme(theme);
setResolvedTheme(resolved);
}, [theme]);
useEffect(() => {
if (theme !== "system") return;
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => {
const resolved = applyTheme(theme);
setResolvedTheme(resolved);
};
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [theme]);
const setTheme = useCallback((t: Theme) => {
setThemeState(t); setThemeState(t);
try { try {
localStorage.setItem("theme", t); localStorage.setItem("theme", t);
} catch {} } catch {}
}, []); document.documentElement.classList.toggle("dark", t === "dark");
};
return <ThemeCtx.Provider value={{ theme, resolvedTheme, setTheme }}>{children}</ThemeCtx.Provider>; return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
} }
export function useTheme() { export function useTheme() {
+8 -16
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { Monitor, Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useTheme } from "./ThemeProvider"; import { useTheme } from "./ThemeProvider";
export function ThemeToggle() { export function ThemeToggle() {
@@ -13,27 +13,19 @@ export function ThemeToggle() {
}, []); }, []);
if (!mounted) { if (!mounted) {
return <div className="ml-1 h-9 w-9" />; return <div className="w-9 h-9" />;
} }
const cycle = () => {
const next = theme === "system" ? "light" : theme === "light" ? "dark" : "system";
setTheme(next);
};
return ( return (
<button <button
onClick={cycle} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="ml-1 rounded-full border-[1.5px] border-ink p-2 text-ink transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-paper active:scale-[0.97]" className="p-2 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-800 dark:text-stone-100 hover:bg-stone-200 dark:hover:bg-stone-700 transition-colors border border-stone-200 dark:border-stone-700 shadow-sm hover:scale-105 active:scale-95 transition-transform"
aria-label={`Current theme: ${theme}. Click to switch.`} aria-label="Toggle theme"
title={theme === "system" ? "System" : theme === "dark" ? "Dark" : "Light"}
> >
{theme === "system" ? ( {theme === "dark" ? (
<Monitor size={16} /> <Sun size={18} className="text-amber-400" />
) : theme === "dark" ? (
<Sun size={16} />
) : ( ) : (
<Moon size={16} /> <Moon size={18} className="text-stone-600" />
)} )}
</button> </button>
); );
+16 -45
View File
@@ -25,18 +25,6 @@
--input: #efebe9; --input: #efebe9;
--ring: #a1887f; /* Warm brown ring */ --ring: #a1887f; /* Warm brown ring */
--radius: 1rem; --radius: 1rem;
/* Motion tokens: enter/exit = ease-out, on-screen movement = ease-in-out */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
/* Plakat-Design (RGB-Kanäle, damit Tailwind-Deckkraft funktioniert) */
--plakat-red: 232 64 47;
--plakat-ink: 20 12 11;
--plakat-paper: 246 243 241;
--plakat-paper-2: 235 230 226;
--plakat-ok: 31 157 99;
} }
.dark { .dark {
@@ -59,18 +47,12 @@
--border: #44403c; --border: #44403c;
--input: #292524; --input: #292524;
--ring: #d6d3d1; --ring: #d6d3d1;
/* Tinte und Papier tauschen die Rollen, das Rot bleibt */
--plakat-ink: 243 238 233;
--plakat-paper: 21 16 15;
--plakat-paper-2: 36 30 28;
--plakat-ok: 60 199 138;
} }
body { body {
background: rgb(var(--plakat-paper)); background: var(--background);
color: rgb(var(--plakat-ink)); color: var(--foreground);
font-family: var(--font-geist), "Helvetica Neue", Arial, sans-serif; font-family: "Inter", sans-serif;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow-x: hidden; overflow-x: hidden;
@@ -83,8 +65,8 @@ body {
/* Custom Selection */ /* Custom Selection */
::selection { ::selection {
background: rgb(var(--plakat-ink)); background: var(--primary); /* Rich brown for better contrast */
color: rgb(var(--plakat-paper)); color: var(--primary-foreground); /* Off-white */
} }
/* Smooth Scrolling */ /* Smooth Scrolling */
@@ -92,20 +74,6 @@ html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
/* Reduced motion: drop movement, keep colour/opacity feedback */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
animation-delay: 0ms !important;
}
}
/* Liquid Glass Effects */ /* Liquid Glass Effects */
.glass-panel { .glass-panel {
background: var(--card); background: var(--card);
@@ -124,13 +92,10 @@ html {
box-shadow: box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 4px 6px -1px rgba(0, 0, 0, 0.06),
0 2px 4px -1px rgba(0, 0, 0, 0.05); 0 2px 4px -1px rgba(0, 0, 0, 0.05);
transition: transition: all 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
transform 250ms var(--ease-out), will-change: transform, box-shadow;
box-shadow 250ms var(--ease-out),
border-color 200ms ease;
} }
@media (hover: hover) and (pointer: fine) {
.glass-card:hover { .glass-card:hover {
background: var(--card); background: var(--card);
box-shadow: box-shadow:
@@ -139,7 +104,6 @@ html {
transform: translateY(-4px); transform: translateY(-4px);
border-color: var(--ring); border-color: var(--ring);
} }
}
/* Typography & Headings */ /* Typography & Headings */
h1, h1,
@@ -148,11 +112,18 @@ h3,
h4, h4,
h5, h5,
h6 { h6 {
font-family: var(--font-bricolage), "Avenir Next", "Segoe UI", sans-serif; font-family: var(--font-playfair), Georgia, serif;
letter-spacing: -0.02em; letter-spacing: -0.02em;
font-weight: 700; font-weight: 700;
color: var(--foreground);
} }
/* Improve text contrast - using foreground variable for WCAG AA compliance */
p,
span,
div {
color: var(--foreground); /* #3e2723 - meets WCAG AA standards */
}
/* Hide scrollbar but keep functionality */ /* Hide scrollbar but keep functionality */
::-webkit-scrollbar { ::-webkit-scrollbar {
@@ -258,7 +229,7 @@ h6 {
background: #fffcf5; background: #fffcf5;
border: 1px solid #d7ccc8; border: 1px solid #d7ccc8;
color: #3e2723; color: #3e2723;
transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(62, 39, 35, 0.05); box-shadow: 0 1px 2px rgba(62, 39, 35, 0.05);
} }
+12 -17
View File
@@ -1,31 +1,24 @@
import "./globals.css"; import "./globals.css";
import { Metadata } from "next"; import { Metadata } from "next";
import { Bricolage_Grotesque, Geist, Geist_Mono } from "next/font/google"; import { Inter, Playfair_Display } from "next/font/google";
import React from "react"; import React from "react";
import ClientProviders from "./components/ClientProviders"; import ClientProviders from "./components/ClientProviders";
import ShaderGradientBackground from "./components/ShaderGradientBackground";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getBaseUrl } from "@/lib/seo"; import { getBaseUrl } from "@/lib/seo";
// Bricolage behält die Achsen für Breite und optische Größe, damit das Plakat-Wortbild variieren kann const inter = Inter({
const bricolage = Bricolage_Grotesque({ variable: "--font-inter",
variable: "--font-bricolage",
subsets: ["latin"],
axes: ["opsz", "wdth"],
display: "swap",
adjustFontFallback: true,
});
const geist = Geist({
variable: "--font-geist",
subsets: ["latin"], subsets: ["latin"],
display: "swap", display: "swap",
adjustFontFallback: true, adjustFontFallback: true,
}); });
const geistMono = Geist_Mono({ const playfair = Playfair_Display({
variable: "--font-geist-mono", variable: "--font-playfair",
subsets: ["latin"], subsets: ["latin"],
display: "swap", display: "swap",
adjustFontFallback: true,
}); });
export default async function RootLayout({ export default async function RootLayout({
@@ -41,10 +34,12 @@ export default async function RootLayout({
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<link rel="preconnect" href="https://assets.hardcover.app" /> <link rel="preconnect" href="https://assets.hardcover.app" />
<link rel="preconnect" href="https://cms.dk0.dev" /> <link rel="preconnect" href="https://cms.dk0.dev" />
{/* Prevent flash of unstyled theme — reads localStorage + system preference before React hydrates */} {/* Prevent flash of unstyled theme — reads localStorage before React hydrates */}
<script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('theme');var d=t==='dark'||((!t||t==='system')&&window.matchMedia('(prefers-color-scheme:dark)').matches);if(d)document.documentElement.classList.add('dark');}catch(e){}` }} /> <script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('theme');if(t==='dark')document.documentElement.classList.add('dark');}catch(e){}` }} />
</head> </head>
<body className={`${bricolage.variable} ${geist.variable} ${geistMono.variable}`} suppressHydrationWarning> <body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
<div className="grain-overlay" aria-hidden="true" />
<ShaderGradientBackground />
<ClientProviders>{children}</ClientProviders> <ClientProviders>{children}</ClientProviders>
</body> </body>
</html> </html>
+3 -5
View File
@@ -29,8 +29,7 @@ export default function LegalNotice() {
}, [locale]); }, [locale]);
return ( return (
<> <div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 transition-colors duration-500">
<Header /> <Header />
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20"> <main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
@@ -117,7 +116,7 @@ export default function LegalNotice() {
<Clock className="text-liquid-purple" size={20} /> <Clock className="text-liquid-purple" size={20} />
<div> <div>
<p className="text-[10px] font-black uppercase tracking-widest text-stone-400">Last Review</p> <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> <p className="font-bold text-stone-900 dark:text-stone-100 text-sm">February 15, 2025</p>
</div> </div>
</div> </div>
<p className="text-xs text-stone-500 leading-relaxed"> <p className="text-xs text-stone-500 leading-relaxed">
@@ -128,8 +127,7 @@ export default function LegalNotice() {
</div> </div>
</div> </div>
</main> </main>
</div>
<Footer /> <Footer />
</> </div>
); );
} }
+3 -5
View File
@@ -49,9 +49,7 @@ const AdminPage = () => {
return data.csrfToken; return data.csrfToken;
} }
} catch { } catch {
if (process.env.NODE_ENV === 'development') { console.error('Failed to fetch CSRF token');
console.error('Failed to fetch CSRF token');
}
} }
return ''; return '';
}, []); }, []);
@@ -341,7 +339,7 @@ const AdminPage = () => {
</p> </p>
</div> </div>
<form onSubmit={handleLogin} className="space-y-4" aria-label="Admin login form"> <form onSubmit={handleLogin} className="space-y-4">
<div> <div>
<div className="relative"> <div className="relative">
<input <input
@@ -357,7 +355,7 @@ const AdminPage = () => {
onClick={() => setAuthState(prev => ({ ...prev, showPassword: !prev.showPassword }))} onClick={() => setAuthState(prev => ({ ...prev, showPassword: !prev.showPassword }))}
className="absolute right-4 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-700 dark:hover:text-stone-200 p-1 transition-colors" className="absolute right-4 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-700 dark:hover:text-stone-200 p-1 transition-colors"
> >
{authState.showPassword ? 'Hide' : 'Show'} {authState.showPassword ? '👁️' : '👁️‍🗨️'}
</button> </button>
</div> </div>
{authState.error && ( {authState.error && (
+12 -17
View File
@@ -4,13 +4,10 @@ import { ArrowLeft, Search } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
export default function NotFound() { export default function NotFound() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const router = useRouter(); const router = useRouter();
const locale = useLocale();
const t = useTranslations("notFound");
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
@@ -19,7 +16,7 @@ export default function NotFound() {
if (!mounted) return null; if (!mounted) return null;
return ( return (
<main className="min-h-screen bg-stone-50 dark:bg-stone-950 py-16 sm:py-20 md:py-24 px-4 sm:px-6 flex items-center justify-center transition-colors duration-500"> <main className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 py-16 sm:py-20 md:py-24 px-4 sm:px-6 flex items-center justify-center transition-colors duration-500">
<div className="max-w-7xl mx-auto w-full"> <div className="max-w-7xl mx-auto w-full">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8 max-w-5xl mx-auto"> <div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8 max-w-5xl mx-auto">
@@ -29,30 +26,28 @@ export default function NotFound() {
<div className="w-10 h-10 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs"> <div className="w-10 h-10 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
404 404
</div> </div>
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">{t("errorReport")}</span> <span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">Error Report</span>
</div> </div>
<h1 className="text-4xl sm:text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 leading-[0.85] mb-4 sm:mb-6 md:mb-8"> <h1 className="text-4xl sm:text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 leading-[0.85] mb-4 sm:mb-6 md:mb-8">
{t("title").split(" ").map((word, i) => ( Page not <br/>Found<span className="text-liquid-mint">.</span>
<span key={i}>{i > 0 ? " " : ""}{word}{i === 0 ? "" : ""}{i === t("title").split(" ").length - 1 ? <span className="text-emerald-600 dark:text-emerald-400">.</span> : ""}</span>
))}
</h1> </h1>
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light text-stone-500 dark:text-stone-400 max-w-md leading-relaxed"> <p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light text-stone-500 max-w-md leading-relaxed">
{t("description")} The content you are looking for has been moved, deleted, or never existed.
</p> </p>
</div> </div>
<div className="mt-8 sm:mt-10 md:mt-12 flex flex-wrap gap-3 sm:gap-4"> <div className="mt-8 sm:mt-10 md:mt-12 flex flex-wrap gap-3 sm:gap-4">
<Link <Link
href={`/${locale}`} href="/en"
className="group relative px-6 sm:px-10 py-3 sm:py-4 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] shadow-xl hover:scale-105 transition-all" className="group relative px-6 sm:px-10 py-3 sm:py-4 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] shadow-xl hover:scale-105 transition-all"
> >
{t("returnHome")} Return Home
</Link> </Link>
<button <button
onClick={() => router.back()} onClick={() => router.back()}
className="px-6 sm:px-10 py-3 sm:py-4 bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-200 dark:border-stone-700 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:bg-stone-50 dark:hover:bg-stone-700 transition-all" className="px-6 sm:px-10 py-3 sm:py-4 bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-200 dark:border-stone-700 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:bg-stone-50 dark:hover:bg-stone-700 transition-all"
> >
{t("goBack")} Go Back
</button> </button>
</div> </div>
</div> </div>
@@ -60,14 +55,14 @@ export default function NotFound() {
<div className="md:col-span-12 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex flex-col justify-between min-h-[200px]"> <div className="md:col-span-12 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex flex-col justify-between min-h-[200px]">
<div className="relative z-10"> <div className="relative z-10">
<Search className="text-liquid-mint mb-4 sm:mb-6" size={28} /> <Search className="text-liquid-mint mb-4 sm:mb-6" size={28} />
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2">{t("exploreWork")}</h3> <h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2">Explore Work</h3>
<p className="text-stone-400 text-sm font-medium">{t("exploreWorkDesc")}</p> <p className="text-stone-400 text-sm font-medium">Maybe what you need is in my project archive?</p>
</div> </div>
<Link <Link
href={`/${locale}/projects`} href="/en/projects"
className="mt-5 sm:mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-liquid-mint group-hover:gap-4 transition-all" className="mt-5 sm:mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-liquid-mint group-hover:gap-4 transition-all"
> >
{t("viewProjects")} <ArrowLeft className="rotate-180" size={14} /> View Projects <ArrowLeft className="rotate-180" size={14} />
</Link> </Link>
<div className="absolute top-0 right-0 w-32 h-32 bg-liquid-mint/5 blur-3xl rounded-full -mr-16 -mt-16" /> <div className="absolute top-0 right-0 w-32 h-32 bg-liquid-mint/5 blur-3xl rounded-full -mr-16 -mt-16" />
</div> </div>
+4 -6
View File
@@ -29,8 +29,7 @@ export default function PrivacyPolicy() {
}, [locale]); }, [locale]);
return ( return (
<> <div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 transition-colors duration-500">
<Header /> <Header />
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20"> <main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
@@ -151,7 +150,7 @@ export default function PrivacyPolicy() {
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400"> <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. Diese Datenschutzerklärung wird regelmäßig aktualisiert, um den gesetzlichen Anforderungen zu entsprechen. Die jeweils aktuelle Version finden Sie auf dieser Seite.
</p> </p>
<p className="text-sm text-stone-500 dark:text-stone-400 mt-6">Letzte Aktualisierung: Mai 2025</p> <p className="text-sm text-stone-400 dark:text-stone-500 mt-6">Letzte Aktualisierung: April 2025</p>
</section> </section>
</div> </div>
)} )}
@@ -197,7 +196,7 @@ export default function PrivacyPolicy() {
<p className="text-stone-900 dark:text-stone-50 font-bold mb-4">Your connection is private and secure.</p> <p className="text-stone-900 dark:text-stone-50 font-bold mb-4">Your connection is private and secure.</p>
<button <button
onClick={() => { onClick={() => {
document.cookie = 'dk0_consent_v1=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'; localStorage.removeItem('cookie-consent'); localStorage.removeItem('cookie-consent');
window.location.reload(); 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" 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"
@@ -209,8 +208,7 @@ export default function PrivacyPolicy() {
</div> </div>
</div> </div>
</main> </main>
</div>
<Footer /> <Footer />
</> </div>
); );
} }
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
const BackgroundBlobs = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return (
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
{/* Mint blob - top left */}
<motion.div
className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-liquid-mint/40 rounded-full blur-[60px] opacity-70"
animate={{ scale: [1, 1.15, 1] }}
transition={{ duration: 40, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
/>
{/* Lavender blob - top right */}
<motion.div
className="absolute top-[10%] right-[-5%] w-[35vw] h-[35vw] bg-liquid-lavender/35 rounded-full blur-[60px] opacity-70"
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 45, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
/>
{/* Rose blob - bottom left */}
<motion.div
className="absolute bottom-[-5%] left-[15%] w-[45vw] h-[45vw] bg-liquid-rose/35 rounded-full blur-[60px] opacity-70"
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 50, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
/>
{/* Peach blob - middle right */}
<motion.div
className="absolute top-[40%] right-[10%] w-[30vw] h-[30vw] bg-orange-200/30 rounded-full blur-[60px] opacity-70"
animate={{ scale: [1, 1.25, 1] }}
transition={{ duration: 55, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
/>
</div>
);
};
export default BackgroundBlobs;
+25
View File
@@ -2,6 +2,7 @@ import { getLocalizedMessage } from '@/lib/i18n-loader';
import type { import type {
NavTranslations, NavTranslations,
FooterTranslations, FooterTranslations,
HeroTranslations,
AboutTranslations, AboutTranslations,
ProjectsTranslations, ProjectsTranslations,
ContactTranslations, ContactTranslations,
@@ -54,6 +55,30 @@ export async function getFooterTranslations(locale: string): Promise<FooterTrans
}; };
} }
export async function getHeroTranslations(locale: string): Promise<HeroTranslations> {
const keys = [
'home.hero.description',
'home.hero.ctaWork',
'home.hero.ctaContact',
'home.hero.features.f1',
'home.hero.features.f2',
'home.hero.features.f3',
];
const values = await Promise.all(keys.map(key => getLocalizedMessage(key, locale)));
return {
description: values[0],
ctaWork: values[1],
ctaContact: values[2],
features: {
f1: values[3],
f2: values[4],
f3: values[5],
},
};
}
export async function getAboutTranslations(locale: string): Promise<AboutTranslations> { export async function getAboutTranslations(locale: string): Promise<AboutTranslations> {
const keys = [ const keys = [
'home.about.title', 'home.about.title',
+12 -41
View File
@@ -26,12 +26,17 @@
}, },
"home": { "home": {
"hero": { "hero": {
"kicker": "Dennis Konkol — Osnabrück", "badge": "Student & Self-Hoster",
"claim": "Ich hoste mein halbes Leben selbst. Den Rest programmiere ich.", "line1": "Building",
"intro": "IT-Admin in Osnabrück und Masterstudent. Ab Februar 2027 kümmere ich mich hauptberuflich um Microsoft 365, bis dahin um meine Masterarbeit.", "line2": "Stuff.",
"countdown": "{days, plural, one {Noch # Tag.} other {Noch # Tage.}}", "features": {
"ctaWork": "Projekte ansehen", "f1": "Next.js & Flutter",
"ctaContact": "Schreib mir" "f2": "Docker Swarm & CI/CD",
"f3": "Self-Hosted Infrastruktur"
},
"description": "Ich bin Dennis Konkol, Informatik-Student und Webentwickler aus Osnabrück. Ich entwickle Fullstack-Apps mit Next.js und Flutter und betreibe meine eigene Infrastruktur mit Docker und CI/CD.",
"ctaWork": "Meine Projekte",
"ctaContact": "Kontakt"
}, },
"about": { "about": {
"title": "Hinter den Kulissen", "title": "Hinter den Kulissen",
@@ -156,40 +161,6 @@
"privacySettings": "Datenschutz-Einstellungen", "privacySettings": "Datenschutz-Einstellungen",
"privacySettingsTitle": "Datenschutz-Banner wieder anzeigen", "privacySettingsTitle": "Datenschutz-Banner wieder anzeigen",
"builtWith": "Built with", "builtWith": "Built with",
"aiDisclaimer": "Einige Inhalte dieser Seite können KI-generiert sein.", "aiDisclaimer": "Einige Inhalte dieser Seite können KI-generiert sein."
"backToTop": "Nach oben",
"systemsOnline": "Systeme online"
},
"notFound": {
"title": "Seite nicht gefunden",
"description": "Der gesuchte Inhalt wurde verschoben, gelöscht oder hat nie existiert.",
"returnHome": "Zurück zur Startseite",
"goBack": "Zurück",
"exploreWork": "Arbeiten entdecken",
"exploreWorkDesc": "Vielleicht findest du, was du suchst, in meinem Projektarchiv?",
"viewProjects": "Projekte ansehen",
"errorReport": "Fehlerbericht"
},
"books": {
"title": "Bibliothek",
"subtitle": "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben.",
"backHome": "Zurück",
"empty": "Noch keine Bücher."
},
"about": {
"status": "Status",
"aiAssistant": "KI-Assistent",
"library": "Bibliothek",
"viewAll": "Alle ansehen",
"myGear": "Mein Gear",
"gearMain": "Main",
"gearPC": "PC",
"gearServer": "Server",
"gearOS": "OS",
"curiosity": "Neugier über die Softwareentwicklung hinaus.",
"connect": "Verbinden",
"email": "E-Mail",
"code": "Code",
"professional": "Beruflich"
} }
} }
+19 -44
View File
@@ -23,15 +23,21 @@
"acceptSelected": "Accept selected", "acceptSelected": "Accept selected",
"rejectAll": "Reject all", "rejectAll": "Reject all",
"hide": "Hide" "hide": "Hide"
}, }
,
"home": { "home": {
"hero": { "hero": {
"kicker": "Dennis Konkol — Osnabrück, Germany", "badge": "Student & Self-Hoster",
"claim": "I self-host half my life. The rest I code.", "line1": "Building",
"intro": "IT admin in Osnabrück and master's student. From February 2027 I look after Microsoft 365 full-time, until then my master's thesis.", "line2": "Stuff.",
"countdown": "{days, plural, one {# day to go.} other {# days to go.}}", "features": {
"ctaWork": "See projects", "f1": "Next.js & Flutter",
"ctaContact": "Write me" "f2": "Docker Swarm & CI/CD",
"f3": "Self-Hosted Infrastructure"
},
"description": "I'm Dennis Konkol, a computer science student and web developer from Osnabrück, Germany. I build fullstack apps with Next.js and Flutter and love running my own infrastructure with Docker and CI/CD.",
"ctaWork": "View Projects",
"ctaContact": "Get in touch"
}, },
"about": { "about": {
"title": "Behind the Code", "title": "Behind the Code",
@@ -127,7 +133,8 @@
"characters": "{count} characters" "characters": "{count} characters"
} }
} }
}, }
,
"projects": { "projects": {
"shared": { "shared": {
"featured": "Featured" "featured": "Featured"
@@ -147,7 +154,8 @@
"viewSource": "View Source", "viewSource": "View Source",
"techStack": "Tech Stack" "techStack": "Tech Stack"
} }
}, }
,
"footer": { "footer": {
"role": "Software Engineer", "role": "Software Engineer",
"madeIn": "Made in Germany", "madeIn": "Made in Germany",
@@ -156,40 +164,7 @@
"privacySettings": "Privacy settings", "privacySettings": "Privacy settings",
"privacySettingsTitle": "Show privacy settings banner again", "privacySettingsTitle": "Show privacy settings banner again",
"builtWith": "Built with", "builtWith": "Built with",
"aiDisclaimer": "Some content on this site may be AI-assisted.", "aiDisclaimer": "Some content on this site may be AI-assisted."
"backToTop": "Back to top",
"systemsOnline": "Systems Online"
},
"notFound": {
"title": "Page not Found",
"description": "The content you are looking for has been moved, deleted, or never existed.",
"returnHome": "Return Home",
"goBack": "Go Back",
"exploreWork": "Explore Work",
"exploreWorkDesc": "Maybe what you need is in my project archive?",
"viewProjects": "View Projects",
"errorReport": "Error Report"
},
"books": {
"title": "Library",
"subtitle": "Books that shaped my mindset and expanded my horizons.",
"backHome": "Back Home",
"empty": "No books yet."
},
"about": {
"status": "Status",
"aiAssistant": "AI Assistant",
"library": "Library",
"viewAll": "View All",
"myGear": "My Gear",
"gearMain": "Main",
"gearPC": "PC",
"gearServer": "Server",
"gearOS": "OS",
"curiosity": "Curiosity beyond software engineering.",
"connect": "Connect",
"email": "Email",
"code": "Code",
"professional": "Professional"
} }
} }
+7 -45
View File
@@ -4,11 +4,6 @@ import type { Config } from "tailwindcss";
// tailwind.config.js // tailwind.config.js
export default { export default {
darkMode: "class", // Enables class-based dark mode darkMode: "class", // Enables class-based dark mode
// Touch devices fire :hover on tap and keep it stuck; only apply hover styles
// where a real pointer exists.
future: {
hoverOnlyWhenSupported: true,
},
content: [ content: [
"./app/**/*.{js,ts,jsx,tsx}", "./app/**/*.{js,ts,jsx,tsx}",
"./app/components/**/*.{js,ts,jsx,tsx}", "./app/components/**/*.{js,ts,jsx,tsx}",
@@ -16,43 +11,19 @@ export default {
], ],
theme: { theme: {
extend: { extend: {
// Stronger curves than the CSS defaults. Overriding `out` and `in-out`
// upgrades every existing `ease-out` / `ease-in-out` utility at once.
transitionTimingFunction: {
out: "cubic-bezier(0.23, 1, 0.32, 1)",
"in-out": "cubic-bezier(0.77, 0, 0.175, 1)",
drawer: "cubic-bezier(0.32, 0.72, 0, 1)",
},
keyframes: { keyframes: {
fadeIn: { fadeIn: {
"0%": { opacity: "0", transform: "translateY(8px)" }, "0%": { opacity: "0", transform: "translateY(10px)" },
"100%": { opacity: "1", transform: "translateY(0)" }, "100%": { opacity: "1", transform: "translateY(0)" },
}, },
slideDown: { slideDown: {
"0%": { opacity: "0", transform: "translateY(-12px)" }, "0%": { opacity: "0", transform: "translateY(-20px)" },
"100%": { opacity: "1", transform: "translateY(0)" }, "100%": { opacity: "1", transform: "translateY(0)" },
}, },
wordmarkUp: {
"0%": { opacity: "0", transform: "translateY(40%)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
marquee: {
"0%": { transform: "translateX(0)" },
"100%": { transform: "translateX(-50%)" },
},
scrollHint: {
"0%": { opacity: "0", transform: "scaleY(0.4)" },
"40%": { opacity: "1", transform: "scaleY(1)" },
"100%": { opacity: "0", transform: "scaleY(1)" },
},
}, },
animation: { animation: {
"fade-in": "fadeIn 600ms cubic-bezier(0.23, 1, 0.32, 1) both", "fade-in": "fadeIn 0.5s ease-out",
"slide-down": "slideDown 500ms cubic-bezier(0.23, 1, 0.32, 1) both", "slide-down": "slideDown 0.4s ease-out",
"scroll-hint": "scrollHint 2.4s cubic-bezier(0.77, 0, 0.175, 1) infinite",
"spin-slow": "spin 4s linear infinite",
"wordmark-up": "wordmarkUp 900ms cubic-bezier(0.23, 1, 0.32, 1) both",
marquee: "marquee 32s linear infinite",
}, },
colors: { colors: {
background: "var(--background)", background: "var(--background)",
@@ -88,14 +59,6 @@ export default {
border: "var(--border)", border: "var(--border)",
input: "var(--input)", input: "var(--input)",
ring: "var(--ring)", ring: "var(--ring)",
// Plakat-Design: Rot, Tinte, Papier. Tinte und Papier tauschen im Dark Mode die Rollen.
signal: "rgb(var(--plakat-red) / <alpha-value>)",
ink: "rgb(var(--plakat-ink) / <alpha-value>)",
paper: {
DEFAULT: "rgb(var(--plakat-paper) / <alpha-value>)",
2: "rgb(var(--plakat-paper-2) / <alpha-value>)",
},
ok: "rgb(var(--plakat-ok) / <alpha-value>)",
// Warm brown palette // Warm brown palette
cream: "#FAF8F3", cream: "#FAF8F3",
sand: "#EFEBE9", sand: "#EFEBE9",
@@ -140,10 +103,9 @@ export default {
}, },
}, },
fontFamily: { fontFamily: {
sans: ["var(--font-geist)", "Helvetica Neue", "Arial", "sans-serif"], sans: ["var(--font-inter)", "sans-serif"],
display: ["var(--font-bricolage)", "Avenir Next", "Segoe UI", "sans-serif"], serif: ["var(--font-playfair)", "Georgia", "serif"],
serif: ["var(--font-bricolage)", "Avenir Next", "Segoe UI", "sans-serif"], mono: ["var(--font-roboto-mono)", "Monaco", "Courier New", "monospace"],
mono: ["var(--font-geist-mono)", "SF Mono", "Menlo", "monospace"],
}, },
}, },
}, },
+11
View File
@@ -20,6 +20,17 @@ export interface FooterTranslations {
builtWith: string; builtWith: string;
} }
export interface HeroTranslations {
description: string;
ctaWork: string;
ctaContact: string;
features: {
f1: string;
f2: string;
f3: string;
};
}
export interface AboutTranslations { export interface AboutTranslations {
title: string; title: string;
p1: string; p1: string;