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:
@@ -3,10 +3,11 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2, Code } from "lucide-react";
|
import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2, Code } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export type ProjectDetailData = {
|
export type ProjectDetailData = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -35,9 +36,15 @@ export default function ProjectDetailClient({
|
|||||||
}) {
|
}) {
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const tDetail = useTranslations("projects.detail");
|
const tDetail = useTranslations("projects.detail");
|
||||||
const tShared = useTranslations("projects.shared");
|
const router = useRouter();
|
||||||
|
const [canGoBack, setCanGoBack] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Prüfen, ob wir eine History haben (von Home gekommen)
|
||||||
|
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||||
|
setCanGoBack(true);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
navigator.sendBeacon?.(
|
navigator.sendBeacon?.(
|
||||||
"/api/analytics/track",
|
"/api/analytics/track",
|
||||||
@@ -46,18 +53,31 @@ export default function ProjectDetailClient({
|
|||||||
} catch {}
|
} catch {}
|
||||||
}, [project.id, project.slug, locale]);
|
}, [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 (
|
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-[#fdfcf8] 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 */}
|
{/* Navigation - Intelligent Back */}
|
||||||
<Link
|
<button
|
||||||
href={`/${locale}/projects`}
|
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"
|
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" />
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
<span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToProjects")}</span>
|
<span className="font-bold uppercase tracking-widest text-xs">
|
||||||
</Link>
|
{tCommon("back")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Title Section */}
|
{/* Title Section */}
|
||||||
<div className="mb-20">
|
<div className="mb-20">
|
||||||
@@ -82,10 +102,7 @@ export default function ProjectDetailClient({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bento Details Grid */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
<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="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="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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar Boxes */}
|
|
||||||
<div className="lg:col-span-4 space-y-8">
|
<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">
|
<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">
|
<div className="space-y-4">
|
||||||
{project.live && (
|
{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">
|
<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>
|
||||||
</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">
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{project.tags.map((tag) => (
|
{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">
|
<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>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,22 +2,19 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { motion } from "framer-motion";
|
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 Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
export type ProjectListItem = {
|
export type ProjectListItem = {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
content: string;
|
|
||||||
tags: string[];
|
tags: string[];
|
||||||
featured: boolean;
|
|
||||||
category: string;
|
category: string;
|
||||||
date: string;
|
date: string;
|
||||||
github?: string | null;
|
|
||||||
live?: string | null;
|
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,15 +27,9 @@ export default function ProjectsPageClient({
|
|||||||
}) {
|
}) {
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const tList = useTranslations("projects.list");
|
const tList = useTranslations("projects.list");
|
||||||
const tShared = useTranslations("projects.shared");
|
|
||||||
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean);
|
const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean);
|
||||||
@@ -47,253 +38,100 @@ export default function ProjectsPageClient({
|
|||||||
|
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
let result = projects;
|
let result = projects;
|
||||||
|
|
||||||
if (selectedCategory !== "all") {
|
if (selectedCategory !== "all") {
|
||||||
result = result.filter((project) => project.category === selectedCategory);
|
result = result.filter((project) => project.category === selectedCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
(project) =>
|
(p) => p.title.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.tags.some(t => t.toLowerCase().includes(query))
|
||||||
project.title.toLowerCase().includes(query) ||
|
|
||||||
project.description.toLowerCase().includes(query) ||
|
|
||||||
project.tags.some((tag) => tag.toLowerCase().includes(query)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [projects, selectedCategory, searchQuery]);
|
}, [projects, selectedCategory, searchQuery]);
|
||||||
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20">
|
<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 px-4">
|
<div className="max-w-7xl mx-auto">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<div className="mb-24">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8 }}
|
|
||||||
className="mb-12"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
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" />
|
<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>
|
</Link>
|
||||||
|
|
||||||
<h1 className="text-5xl md:text-6xl font-black font-sans mb-6 text-stone-900 tracking-tight">
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||||
{tList("title")}
|
Archive<span className="text-liquid-mint">.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-stone-600 max-w-3xl font-light leading-relaxed">{tList("intro")}</p>
|
<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">
|
||||||
</motion.div>
|
{tList("intro")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters & Search */}
|
{/* Filters */}
|
||||||
<motion.div
|
<div className="flex flex-col md:flex-row gap-8 justify-between items-start md:items-center mb-16">
|
||||||
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 */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{categories.map((category) => (
|
{categories.map((cat) => (
|
||||||
<button
|
<button
|
||||||
key={category}
|
key={cat}
|
||||||
onClick={() => setSelectedCategory(category)}
|
onClick={() => setSelectedCategory(cat)}
|
||||||
className={`px-5 py-2 rounded-full text-sm font-medium transition-all duration-200 border ${
|
className={`px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${
|
||||||
selectedCategory === category
|
selectedCategory === cat
|
||||||
? "bg-stone-800 text-stone-50 border-stone-800 shadow-md"
|
? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900"
|
||||||
: "bg-white text-stone-600 border-stone-200 hover:bg-stone-50 hover:border-stone-300"
|
: "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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative w-full md:w-80">
|
||||||
{/* Search */}
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
||||||
<div className="relative w-full md:w-64">
|
<input
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
type="text"
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={tList("searchPlaceholder")}
|
placeholder={tList("searchPlaceholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Projects Grid */}
|
{/* Grid */}
|
||||||
<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 gap-8">
|
||||||
{filteredProjects.map((project, index) => (
|
{filteredProjects.map((project) => (
|
||||||
<motion.div
|
<motion.div key={project.id} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
|
||||||
key={project.id}
|
<Link href={`/${locale}/projects/${project.slug}`} className="group block h-full">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
<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">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{project.imageUrl && (
|
||||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
|
||||||
whileHover={{ y: -8 }}
|
<Image src={project.imageUrl} alt={project.title} fill className="object-cover group-hover:scale-105 transition-transform duration-700" />
|
||||||
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"
|
</div>
|
||||||
>
|
)}
|
||||||
{/* Image / Fallback / Cover Area */}
|
<div className="flex-1 flex flex-col">
|
||||||
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
|
<div className="flex justify-between items-start mb-4">
|
||||||
{project.imageUrl ? (
|
<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">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<ArrowUpRight size={20} />
|
||||||
<img
|
</div>
|
||||||
src={project.imageUrl}
|
</div>
|
||||||
alt={project.title}
|
<p className="text-stone-500 dark:text-stone-400 font-light text-lg mb-8 line-clamp-3 leading-relaxed">{project.description}</p>
|
||||||
className="w-full h-full object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
|
<div className="mt-auto flex flex-wrap gap-2">
|
||||||
/>
|
{project.tags.slice(0, 3).map(tag => (
|
||||||
<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" />
|
<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>
|
||||||
</>
|
))}
|
||||||
) : (
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
</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>
|
</Link>
|
||||||
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
{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>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,142 +1,85 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import React from "react";
|
||||||
import { motion } from 'framer-motion';
|
import Link from "next/link";
|
||||||
import { Heart, Code } from 'lucide-react';
|
|
||||||
import { SiGithub, SiLinkedin } from 'react-icons/si';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
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 Footer = () => {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("footer");
|
const t = useTranslations("footer");
|
||||||
const { resetConsent } = useConsent();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const [currentYear] = useState(() => new Date().getFullYear());
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
const socialLinks = [
|
};
|
||||||
{ icon: SiGithub, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
|
||||||
{ icon: SiLinkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
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="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 */}
|
{/* Huge Outlined Name */}
|
||||||
<motion.div
|
<div className="relative mb-24 select-none pointer-events-none">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<h2 className="text-[12vw] font-black leading-none text-stone-900/5 dark:text-white/5 uppercase tracking-tighter text-center">
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
Dennis Konkol
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
</h2>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legal Links */}
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-12 items-end">
|
||||||
<motion.div
|
{/* Copyright & Info */}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<div className="md:col-span-4 space-y-6">
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
<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">
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
dk
|
||||||
transition={{ duration: 0.4, delay: 0.15 }}
|
</div>
|
||||||
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="space-y-2">
|
||||||
>
|
<p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">Software Engineer</p>
|
||||||
<div className="flex space-x-6 text-sm">
|
<p className="text-stone-500 text-sm font-medium">© {year} All rights reserved.</p>
|
||||||
<Link
|
</div>
|
||||||
href={`/${locale}/legal-notice`}
|
</div>
|
||||||
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
|
|
||||||
|
{/* 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
|
||||||
|
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("legalNotice")}
|
<span className="text-[10px] font-black uppercase tracking-[0.3em] vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
|
||||||
</Link>
|
<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">
|
||||||
<Link
|
<ArrowUp size={20} />
|
||||||
href={`/${locale}/privacy-policy`}
|
</div>
|
||||||
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{t("privacyPolicy")}
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => resetConsent()}
|
|
||||||
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
|
|
||||||
title={t("privacySettingsTitle")}
|
|
||||||
>
|
|
||||||
{t("privacySettings")}
|
|
||||||
</button>
|
</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>
|
||||||
<div className="text-xs text-stone-400 flex items-center space-x-1">
|
|
||||||
<span>{t("builtWith")}</span>
|
{/* Bottom Bar */}
|
||||||
<span className="text-stone-600 font-semibold">Next.js</span>
|
<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">
|
||||||
<span className="text-stone-300">•</span>
|
<p className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">
|
||||||
<span className="text-stone-600 font-semibold">TypeScript</span>
|
Built with Next.js, Directus & Passion.
|
||||||
<span className="text-stone-300">•</span>
|
</p>
|
||||||
<span className="text-stone-600 font-semibold">Tailwind CSS</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Menu, X } from "lucide-react";
|
import { Menu, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -25,52 +25,51 @@ const Header = () => {
|
|||||||
|
|
||||||
return (
|
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
|
<motion.nav
|
||||||
initial={{ y: -100, opacity: 0 }}
|
initial={{ y: -100, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{/* Logo / Home Button */}
|
{/* Logo Pill */}
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
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>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Nav Items */}
|
{/* Desktop Menu */}
|
||||||
<div className="hidden md:flex items-center gap-1 px-2">
|
<div className="hidden md:flex items-center gap-1">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
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}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Actions Pill */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 bg-stone-100/50 dark:bg-white/5 rounded-full p-1">
|
||||||
<Link
|
<Link
|
||||||
href={locale === "en" ? pathname.replace(/^\/en/, "/de") : pathname.replace(/^\/de/, "/en")}
|
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"}
|
{locale === "en" ? "DE" : "EN"}
|
||||||
</Link>
|
</Link>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.nav>
|
</motion.nav>
|
||||||
@@ -80,18 +79,18 @@ const Header = () => {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||||
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"
|
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) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={() => setIsOpen(false)}
|
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}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"contact": "Kontakt"
|
"contact": "Kontakt"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"back": "Zurück",
|
||||||
"backToHome": "Zurück zur Startseite",
|
"backToHome": "Zurück zur Startseite",
|
||||||
"backToProjects": "Zurück zu den Projekten",
|
"backToProjects": "Zurück zu den Projekten",
|
||||||
"viewAllProjects": "Alle Projekte ansehen",
|
"viewAllProjects": "Alle Projekte ansehen",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"contact": "Contact"
|
"contact": "Contact"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"back": "Back",
|
||||||
"backToHome": "Back to Home",
|
"backToHome": "Back to Home",
|
||||||
"backToProjects": "Back to Projects",
|
"backToProjects": "Back to Projects",
|
||||||
"viewAllProjects": "View All Projects",
|
"viewAllProjects": "View All Projects",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
Reference in New Issue
Block a user