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
+24 -23
View File
@@ -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>
+4 -4
View File
@@ -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">
&ldquo;{allQuotes[quoteIndex].content}&rdquo;
</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 */}
+12 -8
View File
@@ -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}
+2 -1
View File
@@ -36,7 +36,8 @@ export function AboutClient({ locale }: { locale: string; translations: AboutTra
const messages = {
home: {
about: baseMessages.home.about
}
},
about: baseMessages.about
};
return (
+2 -2
View File
@@ -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">
+1 -1
View File
@@ -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 {
+8 -8
View File
@@ -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">&copy; {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 &amp; 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>
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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 {
+38 -11
View File
@@ -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() {
+14 -6
View File
@@ -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>
);
}
}