From c442aa447bfd7fee99e6c04579a79c2b65408540 Mon Sep 17 00:00:00 2001 From: denshooter Date: Thu, 16 Apr 2026 13:46:10 +0200 Subject: [PATCH 1/2] feat: add ProjectThumbnail component with category-themed visuals for projects without images --- app/_ui/ProjectDetailClient.tsx | 11 +- app/_ui/ProjectsPageClient.tsx | 13 +- app/components/ProjectThumbnail.tsx | 241 ++++++++++++++++++++++++++++ app/components/Projects.tsx | 11 +- 4 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 app/components/ProjectThumbnail.tsx diff --git a/app/_ui/ProjectDetailClient.tsx b/app/_ui/ProjectDetailClient.tsx index d642379..ba35c97 100644 --- a/app/_ui/ProjectDetailClient.tsx +++ b/app/_ui/ProjectDetailClient.tsx @@ -6,6 +6,7 @@ import ReactMarkdown from "react-markdown"; import { useTranslations } from "next-intl"; import Image from "next/image"; import { useRouter } from "next/navigation"; +import ProjectThumbnail from "@/app/components/ProjectThumbnail"; export type ProjectDetailData = { id: number; @@ -90,9 +91,13 @@ export default function ProjectDetailClient({ {project.imageUrl ? ( {project.title} ) : ( -
- {project.title.charAt(0)} -
+ )} diff --git a/app/_ui/ProjectsPageClient.tsx b/app/_ui/ProjectsPageClient.tsx index 227bdc0..319b308 100644 --- a/app/_ui/ProjectsPageClient.tsx +++ b/app/_ui/ProjectsPageClient.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { useTranslations } from "next-intl"; import Image from "next/image"; import { Skeleton } from "../components/ui/Skeleton"; +import ProjectThumbnail from "@/app/components/ProjectThumbnail"; export type ProjectListItem = { id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility @@ -127,10 +128,20 @@ export default function ProjectsPageClient({
- {project.imageUrl && ( + {project.imageUrl ? (
{project.title}
+ ) : ( +
+ +
)}
diff --git a/app/components/ProjectThumbnail.tsx b/app/components/ProjectThumbnail.tsx new file mode 100644 index 0000000..d81f92c --- /dev/null +++ b/app/components/ProjectThumbnail.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useMemo } from "react"; +import { + Terminal, + Smartphone, + Globe, + Code, + LayoutDashboard, + MessageSquare, + Cloud, + Wrench, + Cpu, + Shield, + Boxes, + type LucideIcon, +} from "lucide-react"; + +interface ProjectThumbnailProps { + title: string; + category?: string; + tags?: string[]; + slug?: string; + size?: "card" | "hero"; +} + +const categoryThemes: Record< + string, + { + icon: LucideIcon; + gradient: string; + darkGradient: string; + iconColor: string; + darkIconColor: string; + pattern: "dots" | "grid" | "diagonal" | "circuit" | "waves" | "terminal"; + } +> = { + "Web Development": { + icon: Code, + gradient: "from-liquid-sky/20 via-liquid-blue/10 to-liquid-lavender/20", + darkGradient: "dark:from-liquid-sky/10 dark:via-liquid-blue/5 dark:to-liquid-lavender/10", + iconColor: "text-blue-500", + darkIconColor: "dark:text-blue-400", + pattern: "circuit", + }, + "Mobile Development": { + icon: Smartphone, + gradient: "from-liquid-mint/20 via-liquid-teal/10 to-liquid-sky/20", + darkGradient: "dark:from-liquid-mint/10 dark:via-liquid-teal/5 dark:to-liquid-sky/10", + iconColor: "text-emerald-500", + darkIconColor: "dark:text-emerald-400", + pattern: "waves", + }, + "Web Application": { + icon: Globe, + gradient: "from-liquid-lavender/20 via-liquid-purple/10 to-liquid-pink/20", + darkGradient: "dark:from-liquid-lavender/10 dark:via-liquid-purple/5 dark:to-liquid-pink/10", + iconColor: "text-violet-500", + darkIconColor: "dark:text-violet-400", + pattern: "dots", + }, + "Backend Development": { + icon: Cpu, + gradient: "from-liquid-amber/20 via-liquid-yellow/10 to-liquid-peach/20", + darkGradient: "dark:from-liquid-amber/10 dark:via-liquid-yellow/5 dark:to-liquid-peach/10", + iconColor: "text-amber-500", + darkIconColor: "dark:text-amber-400", + pattern: "grid", + }, + "Full-Stack Development": { + icon: Boxes, + gradient: "from-liquid-teal/20 via-liquid-mint/10 to-liquid-lavender/20", + darkGradient: "dark:from-liquid-teal/10 dark:via-liquid-mint/5 dark:to-liquid-lavender/10", + iconColor: "text-teal-500", + darkIconColor: "dark:text-teal-400", + pattern: "grid", + }, + DevOps: { + icon: Shield, + gradient: "from-liquid-coral/20 via-liquid-rose/10 to-liquid-peach/20", + darkGradient: "dark:from-liquid-coral/10 dark:via-liquid-rose/5 dark:to-liquid-peach/10", + iconColor: "text-red-500", + darkIconColor: "dark:text-red-400", + pattern: "diagonal", + }, + default: { + icon: Wrench, + gradient: "from-liquid-peach/20 via-liquid-rose/10 to-liquid-lavender/20", + darkGradient: "dark:from-liquid-peach/10 dark:via-liquid-rose/5 dark:to-liquid-lavender/10", + iconColor: "text-stone-400", + darkIconColor: "dark:text-stone-500", + pattern: "dots", + }, +}; + +const slugIcons: Record = { + "kernel-panic-404-interactive-terminal": Terminal, + "portfolio-website": LayoutDashboard, + "real-time-chat-application": MessageSquare, + "weather-forecast-app": Cloud, + "clarity": Smartphone, + "e-commerce-platform-api": Boxes, + "task-management-dashboard": LayoutDashboard, +}; + +function PatternOverlay({ pattern }: { pattern: string }) { + const patternMap: Record = { + dots: ( + + + + + + + + + ), + grid: ( + + + + + + + + + ), + diagonal: ( + + + + + + + + + ), + circuit: ( + + + + + + + + + + + + + + ), + waves: ( + + + + + + + + + ), + terminal: ( + + + + $_ + + 404 + + ERR + + + + + ), + }; + + return patternMap[pattern] || patternMap.dots; +} + +export default function ProjectThumbnail({ + title, + category, + tags, + slug, + size = "card", +}: ProjectThumbnailProps) { + const theme = useMemo(() => { + if (slug && slugIcons[slug]) { + const matchedTheme = categoryThemes[category || ""] || categoryThemes.default; + return { ...matchedTheme, icon: slugIcons[slug] }; + } + return categoryThemes[category || ""] || categoryThemes.default; + }, [category, slug]); + + const Icon = theme.icon; + + const isHero = size === "hero"; + + const displayTags = tags?.slice(0, 3) ?? []; + + const sizeClasses = isHero + ? "" + : ""; + + return ( +
+ + +
+
+ +
+ + + {title} + + + {displayTags.length > 0 && ( +
+ {displayTags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/components/Projects.tsx b/app/components/Projects.tsx index d6de5bc..daf0179 100644 --- a/app/components/Projects.tsx +++ b/app/components/Projects.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import Image from "next/image"; import { useLocale, useTranslations } from "next-intl"; import { Skeleton } from "boneyard-js/react"; +import ProjectThumbnail from "./ProjectThumbnail"; interface Project { id: number; @@ -86,9 +87,13 @@ const Projects = () => { className="object-cover transition-transform duration-700 group-hover:scale-105" /> ) : ( -
- {project.title.charAt(0)} -
+ )} {/* Overlay on Hover */}
From dd46bcddc7c4fe8f7fb00e13263aec9a991a19cf Mon Sep 17 00:00:00 2001 From: denshooter Date: Thu, 16 Apr 2026 14:39:17 +0200 Subject: [PATCH 2/2] fix: i18n for project section strings, unique SVG pattern IDs, remove hardcoded text - Projects.tsx: use t() for title, subtitle, viewAll, noProjects - ProjectsPageClient.tsx: use tList('title') instead of hardcoded 'Archive' - ProjectThumbnail.tsx: useId() for unique SVG pattern IDs to avoid collisions - Remove unused sizeClasses variable - en.json: update project subtitle and add noProjects key - de.json: update German translations for project section --- app/_ui/ProjectsPageClient.tsx | 2 +- app/components/ProjectThumbnail.tsx | 47 +++++++++++++---------------- app/components/Projects.tsx | 14 ++++----- messages/de.json | 7 +++-- messages/en.json | 7 +++-- 5 files changed, 37 insertions(+), 40 deletions(-) diff --git a/app/_ui/ProjectsPageClient.tsx b/app/_ui/ProjectsPageClient.tsx index 319b308..7643578 100644 --- a/app/_ui/ProjectsPageClient.tsx +++ b/app/_ui/ProjectsPageClient.tsx @@ -75,7 +75,7 @@ export default function ProjectsPageClient({

- Archive. + {tList("title")}.

{tList("intro")} diff --git a/app/components/ProjectThumbnail.tsx b/app/components/ProjectThumbnail.tsx index d81f92c..44eec1f 100644 --- a/app/components/ProjectThumbnail.tsx +++ b/app/components/ProjectThumbnail.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useId } from "react"; import { Terminal, Smartphone, @@ -103,42 +103,42 @@ const slugIcons: Record = { "task-management-dashboard": LayoutDashboard, }; -function PatternOverlay({ pattern }: { pattern: string }) { - const patternMap: Record = { +function PatternOverlay({ pattern, id }: { pattern: string; id: string }) { + const patterns: Record = { dots: ( - + - + ), grid: ( - + - + ), diagonal: ( - + - + ), circuit: ( - + @@ -147,36 +147,36 @@ function PatternOverlay({ pattern }: { pattern: string }) { - + ), waves: ( - + - + ), terminal: ( - + $_ - + 404 - + ERR - + ), }; - return patternMap[pattern] || patternMap.dots; + return patterns[pattern] || patterns.dots; } export default function ProjectThumbnail({ @@ -186,6 +186,7 @@ export default function ProjectThumbnail({ slug, size = "card", }: ProjectThumbnailProps) { + const uniqueId = useId(); const theme = useMemo(() => { if (slug && slugIcons[slug]) { const matchedTheme = categoryThemes[category || ""] || categoryThemes.default; @@ -195,20 +196,14 @@ export default function ProjectThumbnail({ }, [category, slug]); const Icon = theme.icon; - const isHero = size === "hero"; - const displayTags = tags?.slice(0, 3) ?? []; - const sizeClasses = isHero - ? "" - : ""; - return (

- +
{ const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const locale = useLocale(); - useTranslations("home.projects"); + const t = useTranslations("home.projects"); useEffect(() => { const loadProjects = async () => { @@ -53,22 +53,22 @@ const Projects = () => {

- Selected Work. + {t("title")}.

- Projects that pushed my boundaries. + {t("subtitle")}

- - View Archive - + + {t("viewAll")} +
{projects.length === 0 && !loading ? (
- No projects yet. + {t("noProjects")}
) : ( projects.map((project) => ( diff --git a/messages/de.json b/messages/de.json index 804867c..d3e56b9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -86,10 +86,11 @@ } }, "projects": { - "title": "Ausgewählte Projekte", - "subtitle": "Eine Auswahl an Projekten, an denen ich gearbeitet habe – von Web-Apps bis zu Experimenten.", + "title": "Ausgewählte Arbeiten", + "subtitle": "Projekte, die meine Grenzen erweitert haben.", "featured": "Featured", - "viewAll": "Alle Projekte ansehen" + "viewAll": "Archiv ansehen", + "noProjects": "Noch keine Projekte." }, "contact": { "title": "Kontakt", diff --git a/messages/en.json b/messages/en.json index 189c247..b8cff94 100644 --- a/messages/en.json +++ b/messages/en.json @@ -87,10 +87,11 @@ } }, "projects": { - "title": "Selected Works", - "subtitle": "A collection of projects I've worked on, ranging from web applications to experiments.", + "title": "Selected Work", + "subtitle": "Projects that pushed my boundaries.", "featured": "Featured", - "viewAll": "View All Projects" + "viewAll": "View Archive", + "noProjects": "No projects yet." }, "contact": { "title": "Contact Me",