feat: Add Directus setup scripts for collections, fields, and relations

- Created setup-directus-collections.js to automate the creation of tech stack collections, fields, and relations in Directus.
- Created setup-directus-hobbies.js for setting up hobbies collection with translations.
- Created setup-directus-projects.js for establishing projects collection with comprehensive fields and translations.
- Added setup-tech-stack-directus.js to populate tech_stack_items with predefined data.
This commit is contained in:
2026-01-23 02:53:31 +01:00
parent 7604e00e0f
commit e431ff50fc
28 changed files with 5253 additions and 23 deletions

View File

@@ -35,6 +35,8 @@ const About = () => {
const locale = useLocale();
const t = useTranslations("home.about");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
const [techStackFromCMS, setTechStackFromCMS] = useState<any[] | null>(null);
const [hobbiesFromCMS, setHobbiesFromCMS] = useState<any[] | null>(null);
useEffect(() => {
(async () => {
@@ -56,36 +58,110 @@ const About = () => {
})();
}, [locale]);
const techStack = [
// Load Tech Stack from Directus
useEffect(() => {
(async () => {
try {
const res = await fetch(`/api/tech-stack?locale=${encodeURIComponent(locale)}`);
if (res.ok) {
const data = await res.json();
if (data?.techStack && data.techStack.length > 0) {
setTechStackFromCMS(data.techStack);
}
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.log('Tech Stack from Directus not available, using fallback');
}
}
})();
}, [locale]);
// Load Hobbies from Directus
useEffect(() => {
(async () => {
try {
const res = await fetch(`/api/hobbies?locale=${encodeURIComponent(locale)}`);
if (res.ok) {
const data = await res.json();
if (data?.hobbies && data.hobbies.length > 0) {
setHobbiesFromCMS(data.hobbies);
}
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.log('Hobbies from Directus not available, using fallback');
}
}
})();
}, [locale]);
// Fallback Tech Stack (from messages/en.json, messages/de.json)
const techStackFallback = [
{
key: 'frontend',
category: t("techStack.categories.frontendMobile"),
icon: Globe,
items: ["Next.js", "Tailwind CSS", "Flutter"],
},
{
key: 'backend',
category: t("techStack.categories.backendDevops"),
icon: Server,
items: ["Docker Swarm", "Traefik", "Nginx Proxy Manager", "Redis"],
},
{
key: 'tools',
category: t("techStack.categories.toolsAutomation"),
icon: Wrench,
items: ["Git", "CI/CD", "n8n", t("techStack.items.selfHostedServices")],
},
{
key: 'security',
category: t("techStack.categories.securityAdmin"),
icon: Shield,
items: ["CrowdSec", "Suricata", "Mailcow"],
},
];
const hobbies: Array<{ icon: typeof Code; text: string }> = [
// Map icon names from Directus to Lucide components
const iconMap: Record<string, any> = {
Globe,
Server,
Code,
Wrench,
Shield,
Activity,
Lightbulb,
Gamepad2
};
// Fallback Hobbies
const hobbiesFallback: Array<{ icon: typeof Code; text: string }> = [
{ icon: Code, text: t("hobbies.selfHosting") },
{ icon: Gamepad2, text: t("hobbies.gaming") },
{ icon: Server, text: t("hobbies.gameServers") },
{ icon: Activity, text: t("hobbies.jogging") },
];
// Use CMS Hobbies if available, otherwise fallback
const hobbies = hobbiesFromCMS
? hobbiesFromCMS.map((hobby: any) => ({
icon: iconMap[hobby.icon] || Code,
text: hobby.title
}))
: hobbiesFallback;
// Use CMS Tech Stack if available, otherwise fallback
const techStack = techStackFromCMS
? techStackFromCMS.map((cat: any) => ({
key: cat.key,
category: cat.name,
icon: iconMap[cat.icon] || Code,
items: cat.items.map((item: any) => item.name)
}))
: techStackFallback;
return (
<section
id="about"

View File

@@ -16,6 +16,10 @@ import {
} from "lucide-react";
// Types matching your n8n output
interface CustomActivity {
[key: string]: any; // Komplett dynamisch!
}
interface StatusData {
status: {
text: string;
@@ -47,6 +51,7 @@ interface StatusData {
topProject: string;
};
} | null;
customActivities?: Record<string, CustomActivity>; // Dynamisch!
}
export default function ActivityFeed() {
@@ -162,11 +167,13 @@ export default function ActivityFeed() {
const coding = activityData.coding;
const gaming = activityData.gaming;
const music = activityData.music;
const customActivities = activityData.customActivities || {};
const hasActiveActivity = Boolean(
coding?.isActive ||
gaming?.isPlaying ||
music?.isPlaying
music?.isPlaying ||
Object.values(customActivities).some((act: any) => act?.enabled)
);
if (process.env.NODE_ENV === 'development') {
@@ -174,6 +181,7 @@ export default function ActivityFeed() {
coding: coding?.isActive,
gaming: gaming?.isPlaying,
music: music?.isPlaying,
customActivities: Object.keys(customActivities).length,
});
}
@@ -1882,6 +1890,124 @@ export default function ActivityFeed() {
</motion.div>
)}
{/* CUSTOM ACTIVITIES - Dynamisch aus n8n */}
{data.customActivities && Object.entries(data.customActivities).map(([type, activity]: [string, any]) => {
if (!activity?.enabled) return null;
// Icon Mapping für bekannte Typen
const iconMap: Record<string, any> = {
reading: '📖',
working_out: '🏃',
learning: '🎓',
streaming: '📺',
cooking: '👨‍🍳',
traveling: '✈️',
meditation: '🧘',
podcast: '🎙️',
};
// Farben für verschiedene Typen
const colorMap: Record<string, { from: string; to: string; border: string; shadow: string }> = {
reading: { from: 'amber-500/10', to: 'orange-500/5', border: 'amber-500/30', shadow: 'amber-500/10' },
working_out: { from: 'red-500/10', to: 'orange-500/5', border: 'red-500/30', shadow: 'red-500/10' },
learning: { from: 'purple-500/10', to: 'pink-500/5', border: 'purple-500/30', shadow: 'purple-500/10' },
streaming: { from: 'violet-500/10', to: 'purple-500/5', border: 'violet-500/30', shadow: 'violet-500/10' },
};
const colors = colorMap[type] || { from: 'gray-500/10', to: 'gray-500/5', border: 'gray-500/30', shadow: 'gray-500/10' };
const icon = iconMap[type] || '✨';
const title = type.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase());
return (
<motion.div
key={type}
layout
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className={`relative bg-gradient-to-br from-${colors.from} to-${colors.to} border border-${colors.border} rounded-xl p-3 overflow-visible shadow-lg shadow-${colors.shadow}`}
>
<div className="relative z-10">
<div className="flex items-start gap-3">
{/* Image/Cover wenn vorhanden */}
{(activity.coverUrl || activity.image_url || activity.albumArt) && (
<div className="w-10 h-14 rounded overflow-hidden flex-shrink-0 border border-white/10 shadow-md">
<Image
src={activity.coverUrl || activity.image_url || activity.albumArt}
alt={activity.title || activity.name || title}
width={40}
height={56}
className="w-full h-full object-cover"
unoptimized
/>
</div>
)}
{/* Text Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm">{icon}</span>
<p className="text-[10px] font-bold text-white/80 uppercase tracking-wider">
{title}
</p>
</div>
{/* Haupttitel */}
{(activity.title || activity.name || activity.book_title) && (
<p className="font-bold text-xs text-white truncate mb-0.5">
{activity.title || activity.name || activity.book_title}
</p>
)}
{/* Untertitel/Details */}
{(activity.author || activity.artist || activity.platform) && (
<p className="text-xs text-white/60 truncate mb-1">
{activity.author || activity.artist || activity.platform}
</p>
)}
{/* Progress Bar wenn vorhanden */}
{activity.progress !== undefined && typeof activity.progress === 'number' && (
<div className="mt-1.5">
<div className="h-1 bg-white/10 rounded-full overflow-hidden">
<motion.div
className="h-full bg-white/60"
initial={{ width: 0 }}
animate={{ width: `${activity.progress}%` }}
transition={{ duration: 1, ease: "easeOut" }}
/>
</div>
<p className="text-[9px] text-white/50 mt-0.5">
{activity.progress}% {activity.progress_label || 'complete'}
</p>
</div>
)}
{/* Zusätzliche Felder dynamisch rendern */}
{Object.entries(activity).map(([key, value]) => {
// Skip bereits gerenderte und interne Felder
if (['enabled', 'title', 'name', 'book_title', 'author', 'artist', 'platform', 'progress', 'progress_label', 'coverUrl', 'image_url', 'albumArt'].includes(key)) {
return null;
}
// Nur einfache Werte rendern
if (typeof value === 'string' || typeof value === 'number') {
return (
<div key={key} className="text-[10px] text-white/50 mt-0.5">
<span className="capitalize">{key.replace(/_/g, ' ')}: </span>
<span className="font-medium">{value}</span>
</div>
);
}
return null;
})}
</div>
</div>
</div>
</motion.div>
);
})}
{/* Quote of the Day (when idle) */}
{!hasActivity && quote && (
<div className="bg-white/5 rounded-lg p-3 border border-white/10 relative overflow-hidden group hover:bg-white/10 transition-colors">