fix: cleanup footer, smart navigation, and projects redesign

Removed aggressive background text in footer. Implemented intelligent back button for projects. Redesigned project archive page. Stabilized idle quote logic in activity feed.
This commit is contained in:
2026-02-16 01:35:35 +01:00
parent 7955dfbabb
commit 91eb446ac5
10 changed files with 176 additions and 2650 deletions

View File

@@ -3,10 +3,11 @@
import { motion } from "framer-motion";
import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2, Code } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { useRouter } from "next/navigation";
export type ProjectDetailData = {
id: number;
@@ -35,9 +36,15 @@ export default function ProjectDetailClient({
}) {
const tCommon = useTranslations("common");
const tDetail = useTranslations("projects.detail");
const tShared = useTranslations("projects.shared");
const router = useRouter();
const [canGoBack, setCanGoBack] = useState(false);
useEffect(() => {
// Prüfen, ob wir eine History haben (von Home gekommen)
if (typeof window !== 'undefined' && window.history.length > 1) {
setCanGoBack(true);
}
try {
navigator.sendBeacon?.(
"/api/analytics/track",
@@ -46,18 +53,31 @@ export default function ProjectDetailClient({
} catch {}
}, [project.id, project.slug, locale]);
const handleBack = (e: React.MouseEvent) => {
e.preventDefault();
// Wenn wir direkt auf die Seite gekommen sind (Deep Link), gehen wir zur Projektliste
// Ansonsten nutzen wir den Browser-Back, um an die exakte Stelle der Home oder Liste zurückzukehren
if (canGoBack) {
router.back();
} else {
router.push(`/${locale}/projects`);
}
};
return (
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
{/* Navigation */}
<Link
href={`/${locale}/projects`}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-12 group"
{/* Navigation - Intelligent Back */}
<button
onClick={handleBack}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-12 group bg-transparent border-none cursor-pointer"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToProjects")}</span>
</Link>
<span className="font-bold uppercase tracking-widest text-xs">
{tCommon("back")}
</span>
</button>
{/* Title Section */}
<div className="mb-20">
@@ -82,10 +102,7 @@ export default function ProjectDetailClient({
</div>
</div>
{/* Bento Details Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Main Content */}
<div className="lg:col-span-8 space-y-8">
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
@@ -94,12 +111,9 @@ export default function ProjectDetailClient({
</div>
</div>
{/* Sidebar Boxes */}
<div className="lg:col-span-4 space-y-8">
{/* Quick Links Box */}
<div className="bg-stone-900 dark:bg-stone-800 rounded-[3rem] p-10 border border-stone-800 dark:border-stone-700 shadow-2xl text-white">
<h3 className="text-xl font-black mb-8 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">Links</h3>
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-mint">Links</h3>
<div className="space-y-4">
{project.live && (
<a href={project.live} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between w-full p-5 bg-white text-stone-900 rounded-2xl font-black hover:scale-105 transition-transform group">
@@ -116,9 +130,8 @@ export default function ProjectDetailClient({
</div>
</div>
{/* Tech Stack Box */}
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
<h3 className="text-xl font-black mb-8 flex items-center gap-2 uppercase tracking-widest text-stone-400">Stack</h3>
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-stone-400">Stack</h3>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<span key={tag} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700">
@@ -127,27 +140,6 @@ export default function ProjectDetailClient({
))}
</div>
</div>
{/* Meta Stats */}
<div className="bg-liquid-mint/5 dark:bg-stone-900 rounded-[3rem] p-10 border border-liquid-mint/20 dark:border-stone-800/60">
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-white dark:bg-stone-800 flex items-center justify-center shadow-sm"><Calendar size={18} className="text-liquid-mint" /></div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-stone-400">Release Date</p>
<p className="font-bold text-stone-900 dark:text-stone-100">{new Date(project.date).toLocaleDateString(locale, { year: 'numeric', month: 'long' })}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-white dark:bg-stone-800 flex items-center justify-center shadow-sm"><Code size={18} className="text-liquid-sky" /></div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-stone-400">Category</p>
<p className="font-bold text-stone-900 dark:text-stone-100">{project.category}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -2,22 +2,19 @@
import { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import { ExternalLink, Github, Calendar, ArrowLeft, Search } from "lucide-react";
import { ArrowUpRight, ArrowLeft, Search } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import Image from "next/image";
export type ProjectListItem = {
id: number;
slug: string;
title: string;
description: string;
content: string;
tags: string[];
featured: boolean;
category: string;
date: string;
github?: string | null;
live?: string | null;
imageUrl?: string | null;
};
@@ -30,15 +27,9 @@ export default function ProjectsPageClient({
}) {
const tCommon = useTranslations("common");
const tList = useTranslations("projects.list");
const tShared = useTranslations("projects.shared");
const [selectedCategory, setSelectedCategory] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const categories = useMemo(() => {
const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean);
@@ -47,253 +38,100 @@ export default function ProjectsPageClient({
const filteredProjects = useMemo(() => {
let result = projects;
if (selectedCategory !== "all") {
result = result.filter((project) => project.category === selectedCategory);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(
(project) =>
project.title.toLowerCase().includes(query) ||
project.description.toLowerCase().includes(query) ||
project.tags.some((tag) => tag.toLowerCase().includes(query)),
(p) => p.title.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.tags.some(t => t.toLowerCase().includes(query))
);
}
return result;
}, [projects, selectedCategory, searchQuery]);
if (!mounted) return null;
return (
<div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20">
<div className="max-w-7xl mx-auto px-4">
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-40 pb-20 px-6 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className="mb-12"
>
<div className="mb-24">
<Link
href={`/${locale}`}
className="inline-flex items-center space-x-2 text-stone-500 hover:text-stone-800 transition-colors mb-8 group"
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span>{tCommon("backToHome")}</span>
<span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToHome")}</span>
</Link>
<h1 className="text-5xl md:text-6xl font-black font-sans mb-6 text-stone-900 tracking-tight">
{tList("title")}
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
Archive<span className="text-liquid-mint">.</span>
</h1>
<p className="text-xl text-stone-600 max-w-3xl font-light leading-relaxed">{tList("intro")}</p>
</motion.div>
<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">
{tList("intro")}
</p>
</div>
{/* Filters & Search */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="mb-12 flex flex-col md:flex-row gap-6 justify-between items-start md:items-center"
>
{/* Categories */}
{/* Filters */}
<div className="flex flex-col md:flex-row gap-8 justify-between items-start md:items-center mb-16">
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
{categories.map((cat) => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-5 py-2 rounded-full text-sm font-medium transition-all duration-200 border ${
selectedCategory === category
? "bg-stone-800 text-stone-50 border-stone-800 shadow-md"
: "bg-white text-stone-600 border-stone-200 hover:bg-stone-50 hover:border-stone-300"
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${
selectedCategory === cat
? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900"
: "bg-white dark:bg-stone-900 text-stone-500 border border-stone-200 dark:border-stone-800"
}`}
>
{category === "all" ? tList("all") : category}
{cat === 'all' ? tList('all') : cat}
</button>
))}
</div>
{/* Search */}
<div className="relative w-full md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
<div className="relative w-full md:w-80">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
<input
type="text"
placeholder={tList("searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-white border border-stone-200 rounded-full text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-200 focus:border-stone-400 transition-all"
className="w-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-2xl py-4 pl-12 pr-6 focus:outline-none focus:ring-2 focus:ring-liquid-mint/30 transition-all shadow-sm"
/>
</div>
</motion.div>
{/* Projects Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProjects.map((project, index) => (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: index * 0.1 }}
whileHover={{ y: -8 }}
className="group flex flex-col bg-white/40 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/60 shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-all duration-500"
>
{/* Image / Fallback / Cover Area */}
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
{project.imageUrl ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={project.imageUrl}
alt={project.title}
className="w-full h-full object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</>
) : (
<div className="absolute inset-0 bg-stone-200 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" />
<div className="absolute top-[-20%] left-[-10%] w-[70%] h-[70%] bg-white/20 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-[-10%] right-[-5%] w-[60%] h-[60%] bg-stone-400/10 rounded-full blur-2xl" />
<div className="relative z-10">
<span className="text-7xl font-serif font-black text-stone-800/10 group-hover:text-stone-800/20 transition-all duration-700 select-none tracking-tighter">
{project.title.charAt(0)}
</span>
</div>
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{filteredProjects.map((project) => (
<motion.div key={project.id} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
<Link href={`/${locale}/projects/${project.slug}`} className="group block h-full">
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm h-full hover:shadow-xl transition-all flex flex-col">
{project.imageUrl && (
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
<Image src={project.imageUrl} alt={project.title} fill className="object-cover group-hover:scale-105 transition-transform duration-700" />
</div>
)}
{/* Texture/Grain Overlay */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
{/* Animated Shine Effect */}
<div className="absolute inset-0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-in-out bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg] pointer-events-none" />
{project.featured && (
<div className="absolute top-3 left-3 z-20">
<div className="px-3 py-1 bg-[#292524]/80 backdrop-blur-md text-[#fdfcf8] text-[10px] font-bold uppercase tracking-widest rounded-full shadow-sm border border-white/10">
{tShared("featured")}
<div className="flex-1 flex flex-col">
<div className="flex justify-between items-start mb-4">
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tight">{project.title}</h3>
<div className="w-12 h-12 rounded-full bg-stone-50 dark:bg-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all">
<ArrowUpRight size={20} />
</div>
</div>
)}
{/* Overlay Links */}
<div className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out flex items-center justify-center gap-4 backdrop-blur-[2px] z-20 pointer-events-none">
{project.github && (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
aria-label="GitHub"
onClick={(e) => e.stopPropagation()}
>
<Github size={20} />
</a>
)}
{project.live && !project.title.toLowerCase().includes("kernel panic") && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
aria-label="Live Demo"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={20} />
</a>
)}
</div>
</div>
<div className="p-6 flex flex-col flex-1">
{/* Stretched Link covering the whole card (including image area) */}
<Link
href={`/${locale}/projects/${project.slug}`}
className="absolute inset-0 z-10"
aria-label={`View project ${project.title}`}
/>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-600 transition-colors">
{project.title}
</h3>
<div className="flex items-center space-x-2 text-stone-400 text-xs font-mono bg-white/50 px-2 py-1 rounded border border-stone-100">
<Calendar size={12} />
<span>
{(() => {
const d = new Date(project.date);
return isNaN(d.getTime()) ? project.date : d.getFullYear();
})()}
</span>
</div>
</div>
<p className="text-stone-600 mb-6 leading-relaxed line-clamp-3 text-sm flex-1">{project.description}</p>
<div className="flex flex-wrap gap-2 mb-6">
{project.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="px-2.5 py-1 bg-white/60 border border-stone-100 text-stone-600 text-xs font-medium rounded-md"
>
{tag}
</span>
<p className="text-stone-500 dark:text-stone-400 font-light text-lg mb-8 line-clamp-3 leading-relaxed">{project.description}</p>
<div className="mt-auto flex flex-wrap gap-2">
{project.tags.slice(0, 3).map(tag => (
<span key={tag} className="px-3 py-1 bg-stone-50 dark:bg-stone-800 rounded-lg text-[9px] font-black uppercase tracking-widest text-stone-400">{tag}</span>
))}
{project.tags.length > 4 && (
<span className="px-2 py-1 text-stone-400 text-xs">+ {project.tags.length - 4}</span>
)}
</div>
<div className="mt-auto pt-4 border-t border-stone-100 flex items-center justify-between relative z-20">
<div className="flex gap-3">
{project.github && (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<Github size={18} />
</a>
)}
{project.live && !project.title.toLowerCase().includes("kernel panic") && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={18} />
</a>
)}
</div>
</div>
</div>
</Link>
</motion.div>
))}
</div>
{filteredProjects.length === 0 && (
<div className="text-center py-20">
<p className="text-stone-500 text-lg">{tList("noResults")}</p>
<button
onClick={() => {
setSelectedCategory("all");
setSearchQuery("");
}}
className="mt-4 text-stone-800 font-medium hover:underline"
>
{tList("clearFilters")}
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,142 +1,85 @@
"use client";
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Heart, Code } from 'lucide-react';
import { SiGithub, SiLinkedin } from 'react-icons/si';
import Link from 'next/link';
import React from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useConsent } from "./ConsentProvider";
import { Github, Linkedin, Mail, ArrowUp } from "lucide-react";
import { motion } from "framer-motion";
const Footer = () => {
const locale = useLocale();
const t = useTranslations("footer");
const { resetConsent } = useConsent();
const year = new Date().getFullYear();
const [currentYear] = useState(() => new Date().getFullYear());
const socialLinks = [
{ icon: SiGithub, href: 'https://github.com/Denshooter', label: 'GitHub' },
{ icon: SiLinkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' }
];
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<footer className="relative py-12 px-4 bg-white border-t border-stone-200">
<footer className="bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-12 px-6 overflow-hidden transition-colors duration-500">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-center space-y-6 md:space-y-0">
{/* Brand */}
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4 }}
className="flex items-center space-x-3"
>
<motion.div
whileHover={{ rotate: 360, scale: 1.1 }}
transition={{ duration: 0.5 }}
className="w-12 h-12 bg-gradient-to-br from-liquid-mint to-liquid-lavender rounded-xl flex items-center justify-center shadow-md"
>
<Code className="w-6 h-6 text-stone-800" />
</motion.div>
<div>
<Link href={`/${locale}`} className="text-xl font-bold font-mono text-stone-800 hover:text-liquid-blue transition-colors">
dk<span className="text-liquid-rose">0</span>
</Link>
<p className="text-xs text-stone-500">{t("role")}</p>
</div>
</motion.div>
{/* Social Links */}
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.05 }}
className="flex space-x-3"
>
{socialLinks.map((social) => (
<motion.a
key={social.label}
href={social.href}
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.15, y: -3 }}
whileTap={{ scale: 0.95 }}
className="p-3 bg-stone-50 hover:bg-white rounded-xl text-stone-600 hover:text-stone-900 transition-all duration-200 border border-stone-200 hover:border-stone-300 shadow-sm"
aria-label={social.label}
>
<social.icon size={18} />
</motion.a>
))}
</motion.div>
{/* Copyright */}
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.1 }}
className="flex items-center space-x-2 text-stone-400 text-sm"
>
<span>© {currentYear}</span>
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 1.5, repeat: Infinity }}
>
<Heart size={14} className="text-liquid-rose fill-liquid-rose" />
</motion.div>
<span>{t("madeIn")}</span>
</motion.div>
{/* Huge Outlined Name */}
<div className="relative mb-24 select-none pointer-events-none">
<h2 className="text-[12vw] font-black leading-none text-stone-900/5 dark:text-white/5 uppercase tracking-tighter text-center">
Dennis Konkol
</h2>
</div>
{/* Legal Links */}
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.15 }}
className="mt-8 pt-6 border-t border-stone-100 flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0"
>
<div className="flex space-x-6 text-sm">
<Link
href={`/${locale}/legal-notice`}
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
>
{t("legalNotice")}
</Link>
<Link
href={`/${locale}/privacy-policy`}
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
>
{t("privacyPolicy")}
</Link>
<div className="grid grid-cols-1 md:grid-cols-12 gap-12 items-end">
{/* Copyright & Info */}
<div className="md:col-span-4 space-y-6">
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
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>
</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-400">Legal</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>
</div>
</div>
<div className="space-y-4">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Social</p>
<div className="flex flex-col gap-2">
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">GitHub</a>
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">LinkedIn</a>
</div>
</div>
</div>
{/* Back to Top */}
<div className="md:col-span-4 flex justify-start md:justify-end">
<button
type="button"
onClick={() => resetConsent()}
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
title={t("privacySettingsTitle")}
onClick={scrollToTop}
className="group flex flex-col items-center gap-4 text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
>
{t("privacySettings")}
<span className="text-[10px] font-black uppercase tracking-[0.3em] vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all shadow-sm">
<ArrowUp size={20} />
</div>
</button>
<Link
href="/404"
className="text-stone-500 hover:text-stone-800 transition-colors duration-200 font-mono text-xs"
title="Kernel Panic 404"
>
404
</Link>
</div>
</div>
<div className="text-xs text-stone-400 flex items-center space-x-1">
<span>{t("builtWith")}</span>
<span className="text-stone-600 font-semibold">Next.js</span>
<span className="text-stone-300"></span>
<span className="text-stone-600 font-semibold">TypeScript</span>
<span className="text-stone-300"></span>
<span className="text-stone-600 font-semibold">Tailwind CSS</span>
{/* Bottom Bar */}
<div className="mt-20 pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">
Built with Next.js, Directus & Passion.
</p>
<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-400 uppercase tracking-widest">Systems Online</span>
</div>
</div>
</motion.div>
</div>
</footer>
);

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X } from "lucide-react";
import Link from "next/link";
@@ -25,52 +25,51 @@ const Header = () => {
return (
<>
<div className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none">
<div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
<motion.nav
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="pointer-events-auto bg-white/80 dark:bg-stone-900/80 backdrop-blur-xl border border-stone-200/50 dark:border-stone-800/50 shadow-lg rounded-full px-2 py-2 flex items-center gap-2"
className="pointer-events-auto bg-white/70 dark:bg-stone-900/70 backdrop-blur-2xl border border-white/40 dark:border-white/5 shadow-[0_8px_32px_rgba(0,0,0,0.05)] rounded-full px-3 py-2 flex items-center gap-1 md:gap-4"
>
{/* Logo / Home Button */}
{/* Logo Pill */}
<Link
href={`/${locale}`}
className="w-10 h-10 flex items-center justify-center rounded-full bg-stone-100 dark:bg-stone-800 hover:bg-stone-200 dark:hover:bg-stone-700 transition-colors font-bold text-stone-900 dark:text-stone-100"
className="w-10 h-10 flex items-center justify-center rounded-full bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 transition-transform hover:scale-105 active:scale-95 shadow-lg"
>
dk
<span className="font-black text-xs tracking-tighter">dk</span>
</Link>
{/* Desktop Nav Items */}
<div className="hidden md:flex items-center gap-1 px-2">
{/* Desktop Menu */}
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
className="px-4 py-2 text-sm font-medium text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-100 hover:bg-stone-100 dark:hover:bg-stone-800/50 rounded-full transition-all"
className="px-5 py-2 text-[10px] font-black uppercase tracking-[0.2em] text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 rounded-full transition-all"
>
{item.name}
</Link>
))}
</div>
<div className="w-px h-6 bg-stone-200 dark:bg-stone-800 mx-1 hidden md:block"></div>
<div className="w-px h-4 bg-stone-200 dark:bg-white/10 mx-1 hidden md:block"></div>
{/* Actions */}
<div className="flex items-center gap-1">
{/* Actions Pill */}
<div className="flex items-center gap-1 bg-stone-100/50 dark:bg-white/5 rounded-full p-1">
<Link
href={locale === "en" ? pathname.replace(/^\/en/, "/de") : pathname.replace(/^\/de/, "/en")}
className="w-9 h-9 flex items-center justify-center text-xs font-bold text-stone-600 dark:text-stone-400 hover:bg-stone-100 dark:hover:bg-stone-800 rounded-full transition-colors"
className="w-8 h-8 flex items-center justify-center text-[10px] font-black text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 rounded-full transition-colors"
>
{locale === "en" ? "DE" : "EN"}
</Link>
<ThemeToggle />
{/* Mobile Menu Button */}
{/* Mobile Menu Toggle */}
<button
onClick={() => setIsOpen(!isOpen)}
className="w-9 h-9 flex md:hidden items-center justify-center text-stone-600 dark:text-stone-400 hover:bg-stone-100 dark:hover:bg-stone-800 rounded-full transition-colors"
className="w-8 h-8 flex md:hidden items-center justify-center text-stone-600 dark:text-stone-400 hover:bg-white dark:hover:bg-stone-800 rounded-full transition-colors shadow-sm"
>
{isOpen ? <X size={18} /> : <Menu size={18} />}
{isOpen ? <X size={14} /> : <Menu size={14} />}
</button>
</div>
</motion.nav>
@@ -80,18 +79,18 @@ const Header = () => {
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -20 }}
className="fixed top-20 left-4 right-4 z-40 bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-3xl shadow-2xl p-4 md:hidden"
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.98 }}
className="fixed top-24 left-6 right-6 z-40 bg-white/90 dark:bg-stone-900/95 backdrop-blur-3xl border border-white/40 dark:border-white/10 rounded-[2.5rem] shadow-2xl p-6 md:hidden overflow-hidden"
>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
onClick={() => setIsOpen(false)}
className="px-4 py-3 text-lg font-medium text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-stone-800/50 rounded-xl"
className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10"
>
{item.name}
</Link>

View File

@@ -6,6 +6,7 @@
"contact": "Kontakt"
},
"common": {
"back": "Zurück",
"backToHome": "Zurück zur Startseite",
"backToProjects": "Zurück zu den Projekten",
"viewAllProjects": "Alle Projekte ansehen",

View File

@@ -6,6 +6,7 @@
"contact": "Contact"
},
"common": {
"back": "Back",
"backToHome": "Back to Home",
"backToProjects": "Back to Projects",
"viewAllProjects": "View All Projects",

File diff suppressed because it is too large Load Diff

View File

@@ -1,123 +0,0 @@
#!/usr/bin/env node
/**
* Migrate Content Pages from PostgreSQL (Prisma) to Directus
*
* - Copies `content_pages` + translations from Postgres into Directus
* - Creates or updates items per (slug, locale)
*
* Usage:
* DATABASE_URL=postgresql://... DIRECTUS_STATIC_TOKEN=... DIRECTUS_URL=... \
* node scripts/migrate-content-pages-to-directus.js
*/
const fetch = require('node-fetch');
const { PrismaClient } = require('@prisma/client');
require('dotenv').config();
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
if (!DIRECTUS_TOKEN) {
console.error('❌ Error: DIRECTUS_STATIC_TOKEN not found in env');
process.exit(1);
}
const prisma = new PrismaClient();
const localeMap = {
en: 'en-US',
de: 'de-DE',
};
function toDirectusLocale(locale) {
return localeMap[locale] || locale;
}
async function directusRequest(endpoint, method = 'GET', body = null) {
const url = `${DIRECTUS_URL}/${endpoint}`;
const options = {
method,
headers: {
Authorization: `Bearer ${DIRECTUS_TOKEN}`,
'Content-Type': 'application/json',
},
};
if (body) {
options.body = JSON.stringify(body);
}
const res = await fetch(url, options);
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status} on ${endpoint}: ${text}`);
}
return res.json();
}
async function upsertContentIntoDirectus({ slug, locale, status, title, content }) {
const directusLocale = toDirectusLocale(locale);
// allow locale-specific slug variants: base for en, base-locale for others
const slugVariant = directusLocale === 'en-US' ? slug : `${slug}-${directusLocale.toLowerCase()}`;
const payload = {
slug: slugVariant,
locale: directusLocale,
status: status?.toLowerCase?.() === 'published' ? 'published' : status || 'draft',
title: title || slug,
content: content || null,
};
try {
const { data } = await directusRequest('items/content_pages', 'POST', payload);
console.log(` Created ${slugVariant} (${directusLocale}) [id=${data?.id}]`);
return data?.id;
} catch (error) {
const msg = error?.message || '';
if (msg.includes('already exists') || msg.includes('duplicate key') || msg.includes('UNIQUE')) {
console.log(` ⚠️ Skipping ${slugVariant} (${directusLocale}) already exists`);
return null;
}
throw error;
}
}
async function migrateContentPages() {
console.log('\n📦 Migrating Content Pages from PostgreSQL to Directus...');
const pages = await prisma.contentPage.findMany({
include: { translations: true },
});
console.log(`Found ${pages.length} pages in PostgreSQL`);
for (const page of pages) {
const status = page.status || 'PUBLISHED';
for (const tr of page.translations) {
await upsertContentIntoDirectus({
slug: page.key,
locale: tr.locale,
status,
title: tr.title,
content: tr.content,
});
}
}
console.log('✅ Content page migration finished.');
}
async function main() {
try {
await prisma.$connect();
await migrateContentPages();
} catch (error) {
console.error('❌ Migration failed:', error.message);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
main();

View File

@@ -1,185 +0,0 @@
#!/usr/bin/env node
/**
* Migrate Hobbies to Directus
*
* Migriert Hobbies-Daten aus messages/en.json und messages/de.json nach Directus
*
* Usage:
* node scripts/migrate-hobbies-to-directus.js
*/
const fetch = require('node-fetch');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
if (!DIRECTUS_TOKEN) {
console.error('❌ Error: DIRECTUS_STATIC_TOKEN not found in .env');
process.exit(1);
}
const messagesEn = JSON.parse(
fs.readFileSync(path.join(__dirname, '../messages/en.json'), 'utf-8')
);
const messagesDe = JSON.parse(
fs.readFileSync(path.join(__dirname, '../messages/de.json'), 'utf-8')
);
const hobbiesEn = messagesEn.home.about.hobbies;
const hobbiesDe = messagesDe.home.about.hobbies;
const HOBBIES_DATA = [
{
key: 'self_hosting',
icon: 'Code',
titleEn: hobbiesEn.selfHosting,
titleDe: hobbiesDe.selfHosting
},
{
key: 'gaming',
icon: 'Gamepad2',
titleEn: hobbiesEn.gaming,
titleDe: hobbiesDe.gaming
},
{
key: 'game_servers',
icon: 'Server',
titleEn: hobbiesEn.gameServers,
titleDe: hobbiesDe.gameServers
},
{
key: 'jogging',
icon: 'Activity',
titleEn: hobbiesEn.jogging,
titleDe: hobbiesDe.jogging
}
];
async function directusRequest(endpoint, method = 'GET', body = null) {
const url = `${DIRECTUS_URL}/${endpoint}`;
const options = {
method,
headers: {
'Authorization': `Bearer ${DIRECTUS_TOKEN}`,
'Content-Type': 'application/json'
}
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(url, options);
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}: ${text}`);
}
return await response.json();
} catch (error) {
console.error(`Error calling ${method} ${endpoint}:`, error.message);
throw error;
}
}
async function migrateHobbies() {
console.log('\n📦 Migrating Hobbies to Directus...\n');
for (const hobby of HOBBIES_DATA) {
console.log(`\n🎮 Hobby: ${hobby.key}`);
try {
// 1. Create Hobby
console.log(' Creating hobby...');
const hobbyData = {
key: hobby.key,
icon: hobby.icon,
status: 'published',
sort: HOBBIES_DATA.indexOf(hobby) + 1
};
const { data: createdHobby } = await directusRequest(
'items/hobbies',
'POST',
hobbyData
);
console.log(` ✅ Hobby created with ID: ${createdHobby.id}`);
// 2. Create Translations
console.log(' Creating translations...');
// English Translation
await directusRequest(
'items/hobbies_translations',
'POST',
{
hobbies_id: createdHobby.id,
languages_code: 'en-US',
title: hobby.titleEn
}
);
// German Translation
await directusRequest(
'items/hobbies_translations',
'POST',
{
hobbies_id: createdHobby.id,
languages_code: 'de-DE',
title: hobby.titleDe
}
);
console.log(' ✅ Translations created (en-US, de-DE)');
} catch (error) {
console.error(` ❌ Error migrating ${hobby.key}:`, error.message);
}
}
console.log('\n✨ Migration complete!\n');
}
async function verifyMigration() {
console.log('\n🔍 Verifying Migration...\n');
try {
const { data: hobbies } = await directusRequest(
'items/hobbies?fields=key,icon,status,translations.title,translations.languages_code'
);
console.log(`✅ Found ${hobbies.length} hobbies in Directus:`);
hobbies.forEach(h => {
const enTitle = h.translations?.find(t => t.languages_code === 'en-US')?.title;
console.log(` - ${h.key}: "${enTitle}"`);
});
console.log('\n🎉 Hobbies successfully migrated!\n');
console.log('Next steps:');
console.log(' 1. Visit: https://cms.dk0.dev/admin/content/hobbies');
console.log(' 2. Update About.tsx to load hobbies from Directus\n');
} catch (error) {
console.error('❌ Verification failed:', error.message);
}
}
async function main() {
console.log('\n╔════════════════════════════════════════╗');
console.log('║ Hobbies Migration to Directus ║');
console.log('╚════════════════════════════════════════╝\n');
try {
await migrateHobbies();
await verifyMigration();
} catch (error) {
console.error('\n❌ Migration failed:', error);
process.exit(1);
}
}
main();

View File

@@ -1,240 +0,0 @@
#!/usr/bin/env node
/**
* Directus Tech Stack Migration Script
*
* Migriert bestehende Tech Stack Daten aus messages/en.json und messages/de.json
* nach Directus Collections.
*
* Usage:
* npm install node-fetch@2 dotenv
* node scripts/migrate-tech-stack-to-directus.js
*/
const fetch = require('node-fetch');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
if (!DIRECTUS_TOKEN) {
console.error('❌ Error: DIRECTUS_STATIC_TOKEN not found in .env');
process.exit(1);
}
// Lade aktuelle Tech Stack Daten aus messages files
const messagesEn = JSON.parse(
fs.readFileSync(path.join(__dirname, '../messages/en.json'), 'utf-8')
);
const messagesDe = JSON.parse(
fs.readFileSync(path.join(__dirname, '../messages/de.json'), 'utf-8')
);
const techStackEn = messagesEn.home.about.techStack;
const techStackDe = messagesDe.home.about.techStack;
// Tech Stack Struktur aus About.tsx
const TECH_STACK_DATA = [
{
key: 'frontend',
icon: 'Globe',
nameEn: techStackEn.categories.frontendMobile,
nameDe: techStackDe.categories.frontendMobile,
items: ['Next.js', 'Tailwind CSS', 'Flutter']
},
{
key: 'backend',
icon: 'Server',
nameEn: techStackEn.categories.backendDevops,
nameDe: techStackDe.categories.backendDevops,
items: ['Docker', 'PostgreSQL', 'Redis', 'Traefik']
},
{
key: 'tools',
icon: 'Wrench',
nameEn: techStackEn.categories.toolsAutomation,
nameDe: techStackDe.categories.toolsAutomation,
items: ['Git', 'CI/CD', 'n8n', techStackEn.items.selfHostedServices]
},
{
key: 'security',
icon: 'Shield',
nameEn: techStackEn.categories.securityAdmin,
nameDe: techStackDe.categories.securityAdmin,
items: ['CrowdSec', 'Suricata', 'Proxmox']
}
];
async function directusRequest(endpoint, method = 'GET', body = null) {
const url = `${DIRECTUS_URL}/${endpoint}`;
const options = {
method,
headers: {
'Authorization': `Bearer ${DIRECTUS_TOKEN}`,
'Content-Type': 'application/json'
}
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(url, options);
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}: ${text}`);
}
return await response.json();
} catch (error) {
console.error(`Error calling ${method} ${endpoint}:`, error.message);
throw error;
}
}
async function ensureLanguagesExist() {
console.log('\n🌍 Checking Languages...');
try {
const { data: languages } = await directusRequest('items/languages');
const hasEnUS = languages.some(l => l.code === 'en-US');
const hasDeDE = languages.some(l => l.code === 'de-DE');
if (!hasEnUS) {
console.log(' Creating en-US language...');
await directusRequest('items/languages', 'POST', {
code: 'en-US',
name: 'English (United States)'
});
}
if (!hasDeDE) {
console.log(' Creating de-DE language...');
await directusRequest('items/languages', 'POST', {
code: 'de-DE',
name: 'German (Germany)'
});
}
console.log(' ✅ Languages ready');
} catch (error) {
console.log(' ⚠️ Languages collection might not exist yet');
}
}
async function migrateTechStack() {
console.log('\n📦 Migrating Tech Stack to Directus...\n');
await ensureLanguagesExist();
for (const category of TECH_STACK_DATA) {
console.log(`\n📁 Category: ${category.key}`);
try {
// 1. Create Category
console.log(' Creating category...');
const categoryData = {
key: category.key,
icon: category.icon,
status: 'published',
sort: TECH_STACK_DATA.indexOf(category) + 1
};
const { data: createdCategory } = await directusRequest(
'items/tech_stack_categories',
'POST',
categoryData
);
console.log(` ✅ Category created with ID: ${createdCategory.id}`);
// 2. Create Translations
console.log(' Creating translations...');
// English Translation
await directusRequest(
'items/tech_stack_categories_translations',
'POST',
{
tech_stack_categories_id: createdCategory.id,
languages_code: 'en-US',
name: category.nameEn
}
);
// German Translation
await directusRequest(
'items/tech_stack_categories_translations',
'POST',
{
tech_stack_categories_id: createdCategory.id,
languages_code: 'de-DE',
name: category.nameDe
}
);
console.log(' ✅ Translations created (en-US, de-DE)');
// 3. Create Items
console.log(` Creating ${category.items.length} items...`);
for (let i = 0; i < category.items.length; i++) {
const itemName = category.items[i];
await directusRequest(
'items/tech_stack_items',
'POST',
{
category: createdCategory.id,
name: itemName,
sort: i + 1
}
);
console.log(`${itemName}`);
}
} catch (error) {
console.error(` ❌ Error migrating ${category.key}:`, error.message);
}
}
console.log('\n✨ Migration complete!\n');
}
async function verifyMigration() {
console.log('\n🔍 Verifying Migration...\n');
try {
const { data: categories } = await directusRequest(
'items/tech_stack_categories?fields=*,translations.*,items.*'
);
console.log(`✅ Found ${categories.length} categories:`);
categories.forEach(cat => {
const enTranslation = cat.translations?.find(t => t.languages_code === 'en-US');
const itemCount = cat.items?.length || 0;
console.log(` - ${cat.key}: "${enTranslation?.name}" (${itemCount} items)`);
});
console.log('\n🎉 All data migrated successfully!\n');
console.log('Next steps:');
console.log(' 1. Visit https://cms.dk0.dev/admin/content/tech_stack_categories');
console.log(' 2. Verify data looks correct');
console.log(' 3. Run: npm run dev:directus (to test GraphQL queries)');
console.log(' 4. Update About.tsx to use Directus data\n');
} catch (error) {
console.error('❌ Verification failed:', error.message);
}
}
// Main execution
(async () => {
try {
await migrateTechStack();
await verifyMigration();
} catch (error) {
console.error('\n❌ Migration failed:', error);
process.exit(1);
}
})();