feat: comprehensive UI/a11y/i18n fixes and pre-push quality test
CI / CD / test-build (push) Failing after 5m43s
CI / CD / deploy-dev (push) Has been skipped
CI / CD / deploy-production (push) Has been skipped

- Fix ClientWrappers missing 'about' namespace (MISSING_MESSAGE error)
- Add system/light/dark theme toggle with prefers-color-scheme detection
- Rewrite 404 page with i18n, accessibility, and proper navigation
- Rewrite books page with Header/Footer, i18n, and semantic HTML
- Add i18n keys to About, Footer, and both locale files
- Fix dark mode contrast: text-stone-300/600 -> text-stone-400
- Replace raw hex bg-[#fdfcf8] with bg-stone-50 across all components
- Guard console.error in ChatWidget and manage/page behind NODE_ENV
- Add aria-label to admin login form
- Remove emoji from manage page password toggle
- Update stale dates in privacy-policy and legal-notice
- Fix ScrollFadeIn index->delay prop type error in books page
- Fix privacy-policy and legal-notice landmark structure
- Add pre-push-check.test.ts: 13-category static analysis
  (i18n parity, namespace coverage, key resolution, accessibility,
   email validation, hex colors, emojis, console guards, env docs, types)
- Add explicit i18n check step to CI workflow
This commit is contained in:
denshooter
2026-05-14 15:42:52 +02:00
parent 462fde15c7
commit 31560a712f
26 changed files with 905 additions and 285 deletions
+3
View File
@@ -30,6 +30,9 @@ 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
+78 -64
View File
@@ -4,12 +4,17 @@ 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 } from "next-intl"; import { useLocale, useTranslations } 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);
@@ -19,8 +24,10 @@ 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 (error) { } catch {
console.error("Books fetch failed:", error); if (process.env.NODE_ENV === "development") {
// console.error in dev only
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -29,72 +36,79 @@ export default function BooksPage() {
}, [locale]); }, [locale]);
return ( 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 transition-colors duration-500">
<div className="max-w-7xl mx-auto"> <Header />
<div className="mb-20"> <div className="h-16 sm:h-24 md:h-32" aria-hidden="true" />
<Link <main className="pt-4 sm:pt-8 pb-20 px-4 sm:px-6">
href={`/${locale}`} <div className="max-w-7xl mx-auto">
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group" <div className="mb-20">
> <Link
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" /> href={`/${locale}`}
<span className="font-bold uppercase tracking-widest text-xs">{locale === 'de' ? 'Zurück' : 'Back Home'}</span> className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
</Link> >
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase"> <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
Library<span className="text-liquid-purple">.</span> <span className="font-bold uppercase tracking-widest text-xs">{common("backToHome")}</span>
</h1> </Link>
<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"> <h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
{locale === "de" {t("title")}<span className="text-liquid-purple">.</span>
? "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben." </h1>
: "Books that shaped my mindset and expanded my horizons."} <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">
</p> {t("subtitle")}
</div> </p>
</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>
))
) : (
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> </div>
)} </div>
<div className="flex-1 flex flex-col"> ))
<div className="flex justify-between items-start gap-4 mb-4"> ) : reviews.length === 0 ? (
<h3 className="text-2xl font-black text-stone-900 dark:text-white leading-tight">{review.book_title}</h3> <div className="col-span-full text-center py-20 text-stone-400 dark:text-stone-500">
{review.rating && ( <Star size={48} className="mx-auto mb-4 opacity-50" />
<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"> <p className="text-lg font-light">{t("empty")}</p>
<Star size={12} className="fill-amber-400 text-amber-400" /> </div>
<span className="text-xs font-black">{review.rating}</span> ) : (
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>
)} )}
</div> <div className="flex-1 flex flex-col">
<p className="text-stone-500 dark:text-stone-400 font-bold text-sm mb-6">{review.book_author}</p> <div className="flex justify-between items-start gap-4 mb-4">
{review.review && ( <h3 className="text-2xl font-black text-stone-900 dark:text-white leading-tight">{review.book_title}</h3>
<div className="mt-auto pt-6 border-t border-stone-50 dark:border-stone-800"> {review.rating && review.rating > 0 && (
<p className="text-stone-600 dark:text-stone-300 italic font-light leading-relaxed"> <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">
&ldquo;{review.review.replace(/<[^>]*>/g, '')}&rdquo; <Star size={12} className="fill-amber-400 text-amber-400" />
</p> <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>
</div> </ScrollFadeIn>
</div> ))
)) )}
)} </div>
</div> </div>
</div> </main>
<Footer />
</div> </div>
); );
} }
+1 -1
View File
@@ -55,7 +55,7 @@ export default async function ProjectsPage({
})) as ProjectListItem[]; })) as ProjectListItem[];
} }
} catch (err) { } 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) => { const localizedDb: ProjectListItem[] = dbProjects.map((p) => {
+16 -7
View File
@@ -1,7 +1,6 @@
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(),
@@ -9,16 +8,26 @@ jest.mock('next/navigation', () => ({
}), }),
})); }));
// Mock next-intl
jest.mock('next-intl', () => ({ jest.mock('next-intl', () => ({
useLocale: () => 'en', 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', () => { describe('NotFound', () => {
it('renders the 404 page with the new design text', () => { it('renders the 404 page', () => {
render(<NotFound />); render(<NotFound />);
expect(screen.getByText(/Page not/i)).toBeInTheDocument(); expect(screen.getByText('404')).toBeInTheDocument();
expect(screen.getByText(/Found/i)).toBeInTheDocument();
}); });
}); });
+593
View File
@@ -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")}`);
}
});
});
-119
View File
@@ -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>
);
}
+1 -1
View File
@@ -61,7 +61,7 @@ export default function ProjectDetailClient({
}; };
return ( 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"> <div className="max-w-7xl mx-auto">
{/* Navigation - Intelligent Back */} {/* Navigation - Intelligent Back */}
+1 -1
View File
@@ -61,7 +61,7 @@ export default function ProjectsPageClient({
}, [projects, selectedCategory, searchQuery]); }, [projects, selectedCategory, searchQuery]);
return ( 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"> <div className="max-w-7xl mx-auto">
{/* Header */} {/* Header */}
+24 -23
View File
@@ -22,10 +22,11 @@ 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(() => {
@@ -50,7 +51,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) {
console.error("About data fetch failed:", error); if (process.env.NODE_ENV === "development") console.error("About data fetch failed:", error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -59,7 +60,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-[#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="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">
@@ -101,7 +102,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} /> Status <Activity size={20} /> {at("status")}
</h3> </h3>
<ActivityFeed locale={locale} /> <ActivityFeed locale={locale} />
</div> </div>
@@ -115,7 +116,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">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>
<div className="flex-1"> <div className="flex-1">
<BentoChat /> <BentoChat />
@@ -164,10 +165,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} /> Library <BookOpen className="text-liquid-purple" size={24} /> {at("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-all"> <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> </Link>
</div> </div>
<CurrentlyReading /> <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="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} /> My Gear <Cpu className="text-liquid-mint" size={24} /> {at("myGear")}
</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">Main</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> <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">PC</p> <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> <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">Server</p> <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> <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">OS</p> <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> <p className="text-sm font-bold text-stone-100">macOS / Linux</p>
</div> </div>
</div> </div>
</div> </div>
@@ -238,7 +239,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">{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>
</div> </div>
</motion.div> </motion.div>
+4 -4
View File
@@ -134,7 +134,7 @@ export default function ActivityFeed({
transition={{ duration: 0.5 }} 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-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">
&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-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" /> <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-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> </motion.div>
)} )}
@@ -236,7 +236,7 @@ export default function ActivityFeed({
</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-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> </div>
</a> </a>
{/* Subtle Spotify branding gradient */} {/* Subtle Spotify branding gradient */}
+12 -8
View File
@@ -196,11 +196,13 @@ 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");
console.error("Chat API error:", { if (process.env.NODE_ENV === 'development') {
status: response.status, console.error("Chat API error:", {
statusText: response.statusText, status: response.status,
error: errorText, statusText: response.statusText,
}); 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)}`,
); );
@@ -229,7 +231,9 @@ export default function ChatWidget() {
setMessages((prev) => [...prev, botMessage]); setMessages((prev) => [...prev, botMessage]);
} catch (error) { } catch (error) {
console.error("Chat error:", error); if (process.env.NODE_ENV === 'development') {
console.error("Chat error:", error);
}
const errorMessage: Message = { const errorMessage: Message = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
@@ -436,7 +440,7 @@ export default function ChatWidget() {
</div> </div>
{/* Input */} {/* 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"> <div className="flex gap-2">
<input <input
ref={inputRef} ref={inputRef}
@@ -446,7 +450,7 @@ 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-[#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 <button
onClick={handleSend} onClick={handleSend}
+2 -1
View File
@@ -36,7 +36,8 @@ 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 (
+2 -2
View File
@@ -114,7 +114,7 @@ const Contact = () => {
} }
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === 'development') { 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( 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-[#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="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">
+1 -1
View File
@@ -44,7 +44,7 @@ const CurrentlyReading = () => {
} }
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === "development") { 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([]); setBooks([]);
} finally { } finally {
+8 -8
View File
@@ -15,7 +15,7 @@ const Footer = () => {
}; };
return ( 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="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">Software Engineer</p> <p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("role")}</p>
<p className="text-stone-500 text-sm font-medium">© {year} All rights reserved.</p> <p className="text-stone-600 dark:text-stone-400 text-sm font-medium">&copy; {year} {t("madeIn")}.</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">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"> <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,7 +54,7 @@ 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 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"> <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>
@@ -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">
Built with Next.js, Directus & Passion. {t("builtWith")} Next.js, Directus &amp; Passion.
</p> </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")} {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">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> </div>
</div> </div>
+1 -1
View File
@@ -39,7 +39,7 @@ const Projects = () => {
setProjects(data.projects || []); setProjects(data.projects || []);
} }
} catch (error) { } catch (error) {
console.error("Featured projects fetch failed:", error); if (process.env.NODE_ENV === "development") console.error("Featured projects fetch failed:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
+1 -1
View File
@@ -72,7 +72,7 @@ const ReadBooks = () => {
} }
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === "development") { 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([]); setReviews([]);
} finally { } finally {
+38 -11
View File
@@ -1,36 +1,63 @@
"use client"; "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 }>({ const ThemeCtx = createContext<{ theme: Theme; resolvedTheme: "light" | "dark"; setTheme: (t: Theme) => void }>({
theme: "light", theme: "system",
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>("light"); const [theme, setThemeState] = useState<Theme>("system");
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") { if (stored === "dark" || stored === "light" || stored === "system") {
setThemeState(stored); setThemeState(stored);
document.documentElement.classList.toggle("dark", stored === "dark");
} }
} catch {} } 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); 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, setTheme }}>{children}</ThemeCtx.Provider>; return <ThemeCtx.Provider value={{ theme, resolvedTheme, setTheme }}>{children}</ThemeCtx.Provider>;
} }
export function useTheme() { export function useTheme() {
+14 -6
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { Moon, Sun } from "lucide-react"; import { Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "./ThemeProvider"; import { useTheme } from "./ThemeProvider";
export function ThemeToggle() { export function ThemeToggle() {
@@ -16,17 +16,25 @@ export function ThemeToggle() {
return <div className="w-9 h-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={() => setTheme(theme === "dark" ? "light" : "dark")} 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 transition-colors border border-stone-200 dark:border-stone-700 shadow-sm hover:scale-105 active:scale-95 transition-transform" 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="Toggle theme" 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" /> <Sun size={18} className="text-amber-400" />
) : ( ) : (
<Moon size={18} className="text-stone-600" /> <Moon size={18} className="text-stone-600" />
)} )}
</button> </button>
); );
} }
+2 -2
View File
@@ -34,8 +34,8 @@ 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 before React hydrates */} {/* Prevent flash of unstyled theme — reads localStorage + system preference before React hydrates */}
<script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('theme');if(t==='dark')document.documentElement.classList.add('dark');}catch(e){}` }} /> <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> </head>
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning> <body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
<div className="grain-overlay" aria-hidden="true" /> <div className="grain-overlay" aria-hidden="true" />
+5 -3
View File
@@ -29,7 +29,8 @@ 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">
@@ -116,7 +117,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">February 15, 2025</p> <p className="font-bold text-stone-900 dark:text-stone-100 text-sm">May 14, 2025</p>
</div> </div>
</div> </div>
<p className="text-xs text-stone-500 leading-relaxed"> <p className="text-xs text-stone-500 leading-relaxed">
@@ -127,7 +128,8 @@ export default function LegalNotice() {
</div> </div>
</div> </div>
</main> </main>
</div>
<Footer /> <Footer />
</div> </>
); );
} }
+5 -3
View File
@@ -49,7 +49,9 @@ const AdminPage = () => {
return data.csrfToken; return data.csrfToken;
} }
} catch { } catch {
console.error('Failed to fetch CSRF token'); if (process.env.NODE_ENV === 'development') {
console.error('Failed to fetch CSRF token');
}
} }
return ''; return '';
}, []); }, []);
@@ -339,7 +341,7 @@ const AdminPage = () => {
</p> </p>
</div> </div>
<form onSubmit={handleLogin} className="space-y-4"> <form onSubmit={handleLogin} className="space-y-4" aria-label="Admin login form">
<div> <div>
<div className="relative"> <div className="relative">
<input <input
@@ -355,7 +357,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 ? '👁️' : '👁️‍🗨️'} {authState.showPassword ? 'Hide' : 'Show'}
</button> </button>
</div> </div>
{authState.error && ( {authState.error && (
+17 -12
View File
@@ -4,10 +4,13 @@ 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);
@@ -16,7 +19,7 @@ export default function NotFound() {
if (!mounted) return null; if (!mounted) return null;
return ( 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="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">
@@ -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"> <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">Error Report</span> <span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">{t("errorReport")}</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">
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> </h1>
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light text-stone-500 max-w-md leading-relaxed"> <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">
The content you are looking for has been moved, deleted, or never existed. {t("description")}
</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="/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" 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> </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"
> >
Go Back {t("goBack")}
</button> </button>
</div> </div>
</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="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">Explore Work</h3> <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">Maybe what you need is in my project archive?</p> <p className="text-stone-400 text-sm font-medium">{t("exploreWorkDesc")}</p>
</div> </div>
<Link <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" 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> </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>
+6 -4
View File
@@ -29,7 +29,8 @@ 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">
@@ -150,7 +151,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-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> </section>
</div> </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> <p className="text-stone-900 dark:text-stone-50 font-bold mb-4">Your connection is private and secure.</p>
<button <button
onClick={() => { 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(); 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"
@@ -208,7 +209,8 @@ export default function PrivacyPolicy() {
</div> </div>
</div> </div>
</main> </main>
</div>
<Footer /> <Footer />
</div> </>
); );
} }
+35 -1
View File
@@ -161,6 +161,40 @@
"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"
} }
} }
+35 -1
View File
@@ -164,7 +164,41 @@
"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"
} }
} }