Merge branch 'dev' into production
This commit is contained in:
@@ -30,6 +30,9 @@ jobs:
|
||||
- name: 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
|
||||
run: npm run test
|
||||
|
||||
|
||||
+78
-64
@@ -4,12 +4,17 @@ import { Star, ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Skeleton } from "@/app/components/ui/Skeleton";
|
||||
import { BookReview } from "@/lib/directus";
|
||||
import Header from "@/app/components/Header";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import ScrollFadeIn from "@/app/components/ScrollFadeIn";
|
||||
|
||||
export default function BooksPage() {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("books");
|
||||
const common = useTranslations("common");
|
||||
const [reviews, setReviews] = useState<BookReview[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -19,8 +24,10 @@ export default function BooksPage() {
|
||||
const res = await fetch(`/api/book-reviews?locale=${locale}`);
|
||||
const data = await res.json();
|
||||
if (data.bookReviews) setReviews(data.bookReviews);
|
||||
} catch (error) {
|
||||
console.error("Books fetch failed:", error);
|
||||
} catch {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// console.error in dev only
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -29,72 +36,79 @@ export default function BooksPage() {
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<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="mb-20">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
||||
>
|
||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="font-bold uppercase tracking-widest text-xs">{locale === 'de' ? 'Zurück' : 'Back Home'}</span>
|
||||
</Link>
|
||||
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||
Library<span className="text-liquid-purple">.</span>
|
||||
</h1>
|
||||
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
|
||||
{locale === "de"
|
||||
? "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben."
|
||||
: "Books that shaped my mindset and expanded my horizons."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 transition-colors duration-500">
|
||||
<Header />
|
||||
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true" />
|
||||
<main className="pt-4 sm:pt-8 pb-20 px-4 sm:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-20">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
||||
>
|
||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="font-bold uppercase tracking-widest text-xs">{common("backToHome")}</span>
|
||||
</Link>
|
||||
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||
{t("title")}<span className="text-liquid-purple">.</span>
|
||||
</h1>
|
||||
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{loading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
|
||||
<Skeleton className="aspect-[3/4] rounded-2xl mb-8" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
reviews?.map((review) => (
|
||||
<div
|
||||
key={review.id}
|
||||
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"
|
||||
>
|
||||
{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 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{loading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
|
||||
<Skeleton className="aspect-[3/4] rounded-2xl mb-8" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
<div 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>
|
||||
))
|
||||
) : reviews.length === 0 ? (
|
||||
<div className="col-span-full text-center py-20 text-stone-400 dark:text-stone-500">
|
||||
<Star size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-light">{t("empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
reviews.map((review, i) => (
|
||||
<ScrollFadeIn key={review.id} delay={i * 0.1}>
|
||||
<div className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full hover:shadow-xl transition-all">
|
||||
{review.book_image && (
|
||||
<div className="relative aspect-[3/4] rounded-2xl overflow-hidden mb-8 shadow-xl border-4 border-stone-50 dark:border-stone-800">
|
||||
<Image src={review.book_image} alt={review.book_title} fill className="object-cover" sizes="(max-width: 768px) 100vw, 33vw" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<p className="text-stone-600 dark:text-stone-300 italic font-light leading-relaxed">
|
||||
“{review.review.replace(/<[^>]*>/g, '')}”
|
||||
</p>
|
||||
<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">
|
||||
“{review.review.replace(/<[^>]*>/g, '')}”
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollFadeIn>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export default async function ProjectsPage({
|
||||
})) as ProjectListItem[];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Directus projects fetch failed:", err);
|
||||
if (process.env.NODE_ENV === "development") console.error("Directus projects fetch failed:", err);
|
||||
}
|
||||
|
||||
const localizedDb: ProjectListItem[] = dbProjects.map((p) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import NotFound from '@/app/not-found';
|
||||
|
||||
// Mock next/navigation
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
back: jest.fn(),
|
||||
@@ -9,16 +8,26 @@ jest.mock('next/navigation', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock next-intl
|
||||
jest.mock('next-intl', () => ({
|
||||
useLocale: () => 'en',
|
||||
useTranslations: () => (key: string) => key,
|
||||
useTranslations: () => (key: string) => {
|
||||
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', () => {
|
||||
it('renders the 404 page with the new design text', () => {
|
||||
it('renders the 404 page', () => {
|
||||
render(<NotFound />);
|
||||
expect(screen.getByText(/Page not/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Found/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('404')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* 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: ["badge", "line1", "line2", "description", "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[^>]*>/gs;
|
||||
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", () => {
|
||||
const EMOJI_REGEX = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{1FC00}-\u{1FCFF}\u{1FD00}-\u{1FDFF}\u{1FE00}-\u{1FEFF}\u{200D}\u{20E3}\u{E0020}-\u{E007F}]/gu;
|
||||
|
||||
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 (EMOJI_REGEX.test(line)) {
|
||||
const emoji = line.match(EMOJI_REGEX);
|
||||
if (emoji) {
|
||||
violations.push(`${rel}:${i + 1} — found emoji: ${emoji.join(", ")}`);
|
||||
}
|
||||
}
|
||||
EMOJI_REGEX.lastIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
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")}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function ProjectDetailClient({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
|
||||
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
{/* Navigation - Intelligent Back */}
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function ProjectsPageClient({
|
||||
}, [projects, selectedCategory, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-40 pb-20 px-6 transition-colors duration-500">
|
||||
<div className="min-h-screen bg-stone-50 dark:bg-stone-950 pt-40 pb-20 px-6 transition-colors duration-500">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
{/* Header */}
|
||||
|
||||
+24
-23
@@ -22,10 +22,11 @@ const iconMap: Record<string, LucideIcon> = {
|
||||
const About = () => {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("home.about");
|
||||
const at = useTranslations("about");
|
||||
const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
||||
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
||||
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
|
||||
const [hobbies, setHobbies] = useState<Hobby[]>([]);
|
||||
const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,7 +51,7 @@ const About = () => {
|
||||
const msgData = await msgRes.json();
|
||||
if (msgData?.messages) setCmsMessages(msgData.messages);
|
||||
} catch (error) {
|
||||
console.error("About data fetch failed:", error);
|
||||
if (process.env.NODE_ENV === "development") console.error("About data fetch failed:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -59,7 +60,7 @@ const About = () => {
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<section id="about" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950 transition-colors duration-500">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
||||
@@ -101,7 +102,7 @@ const About = () => {
|
||||
>
|
||||
<div className="relative z-10 h-full">
|
||||
<h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
|
||||
<Activity size={20} /> Status
|
||||
<Activity size={20} /> {at("status")}
|
||||
</h3>
|
||||
<ActivityFeed locale={locale} />
|
||||
</div>
|
||||
@@ -115,7 +116,7 @@ const About = () => {
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-5 sm:mb-8">
|
||||
<MessageSquare className="text-liquid-purple" size={20} />
|
||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">AI Assistant</h3>
|
||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">{at("aiAssistant")}</h3>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<BentoChat />
|
||||
@@ -164,10 +165,10 @@ const About = () => {
|
||||
<div className="relative z-10 flex flex-col h-full">
|
||||
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10">
|
||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter">
|
||||
<BookOpen className="text-liquid-purple" size={24} /> Library
|
||||
<BookOpen className="text-liquid-purple" size={24} /> {at("library")}
|
||||
</h3>
|
||||
<Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all">
|
||||
View All <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" />
|
||||
{at("viewAll")} <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
<CurrentlyReading />
|
||||
@@ -185,24 +186,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="relative z-10">
|
||||
<h3 className="text-xl sm:text-2xl font-black mb-5 sm:mb-8 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter text-white">
|
||||
<Cpu className="text-liquid-mint" size={24} /> My Gear
|
||||
<Cpu className="text-liquid-mint" size={24} /> {at("myGear")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 sm:gap-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Main</p>
|
||||
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">PC</p>
|
||||
<p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Server</p>
|
||||
<p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">OS</p>
|
||||
<p className="text-sm font-bold text-stone-100">macOS / Linux</p>
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearMain")}</p>
|
||||
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearPC")}</p>
|
||||
<p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearServer")}</p>
|
||||
<p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">{at("gearOS")}</p>
|
||||
<p className="text-sm font-bold text-stone-100">macOS / Linux</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,7 +239,7 @@ const About = () => {
|
||||
</div>
|
||||
<div className="space-y-1 sm:space-y-2 border-t border-stone-100 dark:border-stone-800 pt-4 sm:pt-6 md:pt-8">
|
||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3>
|
||||
<p className="text-stone-500 font-light text-sm sm:text-base md:text-lg">{locale === 'de' ? 'Neugier über die Softwareentwicklung hinaus.' : 'Curiosity beyond software engineering.'}</p>
|
||||
<p className="text-stone-500 font-light text-sm sm:text-base md:text-lg">{at("curiosity")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function ActivityFeed({
|
||||
transition={{ duration: 0.5 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-300 italic">
|
||||
<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">
|
||||
“{allQuotes[quoteIndex].content}”
|
||||
</p>
|
||||
<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>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<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">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 dark:text-stone-400 pt-4 border-t border-stone-100 dark:border-stone-800">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-stone-200 dark:bg-stone-700 animate-pulse" />
|
||||
{t("idleStatus")}
|
||||
</div>
|
||||
@@ -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>
|
||||
</div>
|
||||
<p className="font-bold text-stone-900 dark:text-white text-lg truncate">{data.coding.project}</p>
|
||||
<p className="text-xs text-stone-500 dark:text-white/50 truncate">{data.coding.file}</p>
|
||||
<p className="text-xs text-stone-500 dark:text-stone-400 truncate">{data.coding.file}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -236,7 +236,7 @@ export default function ActivityFeed({
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col justify-center">
|
||||
<p className="font-bold text-[#1DB954] dark:text-[#1DB954] text-base truncate leading-tight mb-1 hover:underline">{data.music.track}</p>
|
||||
<p className="text-sm text-stone-600 dark:text-white/60 truncate font-medium">{data.music.artist}</p>
|
||||
<p className="text-sm text-stone-600 dark:text-stone-400 truncate font-medium">{data.music.artist}</p>
|
||||
</div>
|
||||
</a>
|
||||
{/* Subtle Spotify branding gradient */}
|
||||
|
||||
@@ -196,11 +196,13 @@ export default function ChatWidget() {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "Unknown error");
|
||||
console.error("Chat API error:", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
});
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Chat API error:", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to get response: ${response.status} - ${errorText.substring(0, 100)}`,
|
||||
);
|
||||
@@ -229,7 +231,9 @@ export default function ChatWidget() {
|
||||
|
||||
setMessages((prev) => [...prev, botMessage]);
|
||||
} catch (error) {
|
||||
console.error("Chat error:", error);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Chat error:", error);
|
||||
}
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
@@ -436,7 +440,7 @@ export default function ChatWidget() {
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-4 bg-[#fdfcf8] border-t border-[#e7e5e4]">
|
||||
<div className="p-4 bg-stone-50 border-t border-[#e7e5e4]">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -446,7 +450,7 @@ export default function ChatWidget() {
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Ask anything..."
|
||||
disabled={isLoading}
|
||||
className="flex-1 px-4 py-3 text-sm bg-[#f5f5f4] text-[#292524] rounded-xl border border-[#e7e5e4] focus:outline-none focus:ring-2 focus:ring-[#e7e5e4] focus:border-[#a8a29e] focus:bg-[#fdfcf8] disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[#78716c] transition-all 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-stone-50 disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[#78716c] transition-all shadow-inner"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
|
||||
@@ -36,7 +36,8 @@ export function AboutClient({ locale }: { locale: string; translations: AboutTra
|
||||
const messages = {
|
||||
home: {
|
||||
about: baseMessages.home.about
|
||||
}
|
||||
},
|
||||
about: baseMessages.about
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -114,7 +114,7 @@ const Contact = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error("Error sending email:", error);
|
||||
if (process.env.NODE_ENV === "development") console.error("Error sending email:", error);
|
||||
}
|
||||
showEmailError(
|
||||
"Network error. Please check your connection and try again.",
|
||||
@@ -155,7 +155,7 @@ const Contact = () => {
|
||||
return (
|
||||
<section
|
||||
id="contact"
|
||||
className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500"
|
||||
className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950 transition-colors duration-500"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
||||
|
||||
@@ -44,7 +44,7 @@ const CurrentlyReading = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error fetching currently reading:", error);
|
||||
if (process.env.NODE_ENV === "development") console.error("Error fetching currently reading:", error);
|
||||
}
|
||||
setBooks([]);
|
||||
} finally {
|
||||
|
||||
@@ -15,7 +15,7 @@ const Footer = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<footer className="bg-stone-50 dark:bg-stone-950 pt-16 sm:pt-24 md:pt-32 pb-8 sm:pb-12 px-4 sm:px-6 overflow-hidden transition-colors duration-500">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end">
|
||||
@@ -25,15 +25,15 @@ const Footer = () => {
|
||||
dk
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">Software Engineer</p>
|
||||
<p className="text-stone-500 text-sm font-medium">© {year} All rights reserved.</p>
|
||||
<p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("role")}</p>
|
||||
<p className="text-stone-600 dark:text-stone-400 text-sm font-medium">© {year} {t("madeIn")}.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="md:col-span-4 grid grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Legal</p>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{t("legalNotice")}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
|
||||
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
|
||||
@@ -54,7 +54,7 @@ const Footer = () => {
|
||||
onClick={scrollToTop}
|
||||
className="group flex flex-col items-center gap-4 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-600 dark:text-stone-400 vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-600 dark:text-stone-400" style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>{t("backToTop")}</span>
|
||||
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all shadow-sm">
|
||||
<ArrowUp size={20} />
|
||||
</div>
|
||||
@@ -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="flex flex-col gap-1">
|
||||
<p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">
|
||||
Built with Next.js, Directus & Passion.
|
||||
{t("builtWith")} Next.js, Directus & Passion.
|
||||
</p>
|
||||
<p className="text-[10px] text-stone-400 dark:text-stone-600 tracking-wide">
|
||||
<p className="text-[10px] text-stone-500 dark:text-stone-500 tracking-wide">
|
||||
{t("aiDisclaimer")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">Systems Online</span>
|
||||
<span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">{t("systemsOnline")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ const Projects = () => {
|
||||
setProjects(data.projects || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Featured projects fetch failed:", error);
|
||||
if (process.env.NODE_ENV === "development") console.error("Featured projects fetch failed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ const ReadBooks = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error fetching book reviews:", error);
|
||||
if (process.env.NODE_ENV === "development") console.error("Error fetching book reviews:", error);
|
||||
}
|
||||
setReviews([]);
|
||||
} finally {
|
||||
|
||||
@@ -1,36 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
type Theme = "system" | "light" | "dark";
|
||||
|
||||
const ThemeCtx = createContext<{ theme: Theme; setTheme: (t: Theme) => void }>({
|
||||
theme: "light",
|
||||
const ThemeCtx = createContext<{ theme: Theme; resolvedTheme: "light" | "dark"; setTheme: (t: Theme) => void }>({
|
||||
theme: "system",
|
||||
resolvedTheme: "light",
|
||||
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 }) {
|
||||
const [theme, setThemeState] = useState<Theme>("light");
|
||||
const [theme, setThemeState] = useState<Theme>("system");
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("theme") as Theme | null;
|
||||
if (stored === "dark" || stored === "light") {
|
||||
if (stored === "dark" || stored === "light" || stored === "system") {
|
||||
setThemeState(stored);
|
||||
document.documentElement.classList.toggle("dark", stored === "dark");
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const setTheme = (t: Theme) => {
|
||||
useEffect(() => {
|
||||
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);
|
||||
try {
|
||||
localStorage.setItem("theme", t);
|
||||
} catch {}
|
||||
document.documentElement.classList.toggle("dark", t === "dark");
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
|
||||
return <ThemeCtx.Provider value={{ theme, resolvedTheme, setTheme }}>{children}</ThemeCtx.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
|
||||
export function ThemeToggle() {
|
||||
@@ -16,17 +16,25 @@ export function ThemeToggle() {
|
||||
return <div className="w-9 h-9" />;
|
||||
}
|
||||
|
||||
const cycle = () => {
|
||||
const next = theme === "system" ? "light" : theme === "light" ? "dark" : "system";
|
||||
setTheme(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
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="Toggle theme"
|
||||
onClick={cycle}
|
||||
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 border border-stone-200 dark:border-stone-700 shadow-sm hover:scale-105 active:scale-95 transition-all"
|
||||
aria-label={`Current theme: ${theme}. Click to switch.`}
|
||||
title={theme === "system" ? "System" : theme === "dark" ? "Dark" : "Light"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
{theme === "system" ? (
|
||||
<Monitor size={18} className="text-stone-500 dark:text-stone-300" />
|
||||
) : theme === "dark" ? (
|
||||
<Sun size={18} className="text-amber-400" />
|
||||
) : (
|
||||
<Moon size={18} className="text-stone-600" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -34,8 +34,8 @@ export default async function RootLayout({
|
||||
<meta charSet="utf-8" />
|
||||
<link rel="preconnect" href="https://assets.hardcover.app" />
|
||||
<link rel="preconnect" href="https://cms.dk0.dev" />
|
||||
{/* Prevent flash of unstyled theme — reads localStorage before React hydrates */}
|
||||
<script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('theme');if(t==='dark')document.documentElement.classList.add('dark');}catch(e){}` }} />
|
||||
{/* Prevent flash of unstyled theme — reads localStorage + system preference 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){}` }} />
|
||||
</head>
|
||||
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
||||
<div className="grain-overlay" aria-hidden="true" />
|
||||
|
||||
@@ -29,7 +29,8 @@ export default function LegalNotice() {
|
||||
}, [locale]);
|
||||
|
||||
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 />
|
||||
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
|
||||
|
||||
@@ -116,7 +117,7 @@ export default function LegalNotice() {
|
||||
<Clock className="text-liquid-purple" size={20} />
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-stone-400">Last Review</p>
|
||||
<p className="font-bold text-stone-900 dark:text-stone-100 text-sm">February 15, 2025</p>
|
||||
<p className="font-bold text-stone-900 dark:text-stone-100 text-sm">May 14, 2025</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 leading-relaxed">
|
||||
@@ -127,7 +128,8 @@ export default function LegalNotice() {
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-3
@@ -49,7 +49,9 @@ const AdminPage = () => {
|
||||
return data.csrfToken;
|
||||
}
|
||||
} catch {
|
||||
console.error('Failed to fetch CSRF token');
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Failed to fetch CSRF token');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}, []);
|
||||
@@ -339,7 +341,7 @@ const AdminPage = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<form onSubmit={handleLogin} className="space-y-4" aria-label="Admin login form">
|
||||
<div>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -355,7 +357,7 @@ const AdminPage = () => {
|
||||
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"
|
||||
>
|
||||
{authState.showPassword ? '👁️' : '👁️🗨️'}
|
||||
{authState.showPassword ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
{authState.error && (
|
||||
|
||||
+17
-12
@@ -4,10 +4,13 @@ import { ArrowLeft, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
export default function NotFound() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("notFound");
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -16,7 +19,7 @@ export default function NotFound() {
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
|
||||
@@ -26,28 +29,30 @@ 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">
|
||||
404
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">Error Report</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">{t("errorReport")}</span>
|
||||
</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">
|
||||
Page not <br/>Found<span className="text-liquid-mint">.</span>
|
||||
{t("title").split(" ").map((word, i) => (
|
||||
<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>
|
||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light text-stone-500 max-w-md leading-relaxed">
|
||||
The content you are looking for has been moved, deleted, or never existed.
|
||||
<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">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mt-10 md:mt-12 flex flex-wrap gap-3 sm:gap-4">
|
||||
<Link
|
||||
href="/en"
|
||||
href={`/${locale}`}
|
||||
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"
|
||||
>
|
||||
Return Home
|
||||
{t("returnHome")}
|
||||
</Link>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Go Back
|
||||
{t("goBack")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,14 +60,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="relative z-10">
|
||||
<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">Explore Work</h3>
|
||||
<p className="text-stone-400 text-sm font-medium">Maybe what you need is in my project archive?</p>
|
||||
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2">{t("exploreWork")}</h3>
|
||||
<p className="text-stone-400 text-sm font-medium">{t("exploreWorkDesc")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/en/projects"
|
||||
href={`/${locale}/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"
|
||||
>
|
||||
View Projects <ArrowLeft className="rotate-180" size={14} />
|
||||
{t("viewProjects")} <ArrowLeft className="rotate-180" size={14} />
|
||||
</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>
|
||||
|
||||
@@ -29,7 +29,8 @@ export default function PrivacyPolicy() {
|
||||
}, [locale]);
|
||||
|
||||
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 />
|
||||
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
|
||||
|
||||
@@ -150,7 +151,7 @@ export default function PrivacyPolicy() {
|
||||
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||
Diese Datenschutzerklärung wird regelmäßig aktualisiert, um den gesetzlichen Anforderungen zu entsprechen. Die jeweils aktuelle Version finden Sie auf dieser Seite.
|
||||
</p>
|
||||
<p className="text-sm text-stone-400 dark:text-stone-500 mt-6">Letzte Aktualisierung: April 2025</p>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400 mt-6">Letzte Aktualisierung: Mai 2025</p>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
@@ -196,7 +197,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>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem('cookie-consent');
|
||||
document.cookie = 'dk0_consent_v1=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'; localStorage.removeItem('cookie-consent');
|
||||
window.location.reload();
|
||||
}}
|
||||
className="text-[10px] font-black uppercase tracking-widest border-b border-stone-300 dark:border-stone-700 pb-1 hover:text-liquid-mint transition-colors"
|
||||
@@ -208,7 +209,8 @@ export default function PrivacyPolicy() {
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+35
-1
@@ -161,6 +161,40 @@
|
||||
"privacySettings": "Datenschutz-Einstellungen",
|
||||
"privacySettingsTitle": "Datenschutz-Banner wieder anzeigen",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-1
@@ -164,7 +164,41 @@
|
||||
"privacySettings": "Privacy settings",
|
||||
"privacySettingsTitle": "Show privacy settings banner again",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user