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:
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getContentByKey } from "@/lib/content";
|
||||
import { getContentPage } from "@/lib/directus";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -11,9 +12,24 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 1) Try Directus first
|
||||
const directusPage = await getContentPage(key, locale);
|
||||
if (directusPage) {
|
||||
return NextResponse.json({
|
||||
content: {
|
||||
title: directusPage.title,
|
||||
slug: directusPage.slug,
|
||||
locale: directusPage.locale || locale,
|
||||
content: directusPage.content,
|
||||
},
|
||||
source: "directus",
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Fallback: PostgreSQL
|
||||
const translation = await getContentByKey({ key, locale });
|
||||
if (!translation) return NextResponse.json({ content: null });
|
||||
return NextResponse.json({ content: translation });
|
||||
return NextResponse.json({ content: translation, source: "postgresql" });
|
||||
} catch (error) {
|
||||
// If DB isn't migrated/available, fail soft so the UI can fall back to next-intl strings.
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
|
||||
47
app/api/hobbies/route.ts
Normal file
47
app/api/hobbies/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getHobbies } from '@/lib/directus';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/hobbies
|
||||
*
|
||||
* Loads Hobbies from Directus with fallback to static data
|
||||
*
|
||||
* Query params:
|
||||
* - locale: en or de (default: en)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = searchParams.get('locale') || 'en';
|
||||
|
||||
// Try to load from Directus
|
||||
const hobbies = await getHobbies(locale);
|
||||
|
||||
if (hobbies && hobbies.length > 0) {
|
||||
return NextResponse.json({
|
||||
hobbies,
|
||||
source: 'directus'
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: return empty (component will use hardcoded fallback)
|
||||
return NextResponse.json({
|
||||
hobbies: null,
|
||||
source: 'fallback'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading hobbies:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
hobbies: null,
|
||||
error: 'Failed to load hobbies',
|
||||
source: 'error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { apiCache } from '@/lib/cache';
|
||||
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders, getClientIp } from '@/lib/auth';
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||
import { generateUniqueSlug } from '@/lib/slug';
|
||||
import { getProjects as getDirectusProjects } from '@/lib/directus';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -43,6 +44,47 @@ export async function GET(request: NextRequest) {
|
||||
const published = searchParams.get('published');
|
||||
const difficulty = searchParams.get('difficulty');
|
||||
const search = searchParams.get('search');
|
||||
const locale = searchParams.get('locale') || 'en';
|
||||
|
||||
// Try Directus FIRST (Primary Source)
|
||||
try {
|
||||
const directusProjects = await getDirectusProjects(locale, {
|
||||
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
|
||||
published: published === 'true' ? true : published === 'false' ? false : undefined,
|
||||
category: category || undefined,
|
||||
difficulty: difficulty || undefined,
|
||||
search: search || undefined,
|
||||
limit
|
||||
});
|
||||
|
||||
if (directusProjects && directusProjects.length > 0) {
|
||||
return NextResponse.json({
|
||||
projects: directusProjects,
|
||||
total: directusProjects.length,
|
||||
page: 1,
|
||||
limit: directusProjects.length,
|
||||
source: 'directus'
|
||||
});
|
||||
}
|
||||
} catch (directusError) {
|
||||
console.log('Directus not available, trying PostgreSQL fallback');
|
||||
}
|
||||
|
||||
// Fallback 1: Try PostgreSQL
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
} catch (dbError) {
|
||||
console.log('PostgreSQL also not available, using empty fallback');
|
||||
|
||||
// Fallback 2: Return empty (components should have hardcoded fallback)
|
||||
return NextResponse.json({
|
||||
projects: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit,
|
||||
source: 'fallback'
|
||||
});
|
||||
}
|
||||
|
||||
// Create cache parameters object
|
||||
const cacheParams = {
|
||||
@@ -93,7 +135,8 @@ export async function GET(request: NextRequest) {
|
||||
projects,
|
||||
total,
|
||||
pages: Math.ceil(total / limit),
|
||||
currentPage: page
|
||||
currentPage: page,
|
||||
source: 'postgresql'
|
||||
};
|
||||
|
||||
// Cache the result (only for non-search queries)
|
||||
@@ -105,7 +148,7 @@ export async function GET(request: NextRequest) {
|
||||
} catch (error) {
|
||||
// Handle missing database table gracefully
|
||||
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (process.env.NOD-fallbackE_ENV === 'development') {
|
||||
console.warn('Project table does not exist. Returning empty result.');
|
||||
}
|
||||
return NextResponse.json({
|
||||
|
||||
47
app/api/tech-stack/route.ts
Normal file
47
app/api/tech-stack/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getTechStack } from '@/lib/directus';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/tech-stack
|
||||
*
|
||||
* Loads Tech Stack from Directus with fallback to static data
|
||||
*
|
||||
* Query params:
|
||||
* - locale: en or de (default: en)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = searchParams.get('locale') || 'en';
|
||||
|
||||
// Try to load from Directus
|
||||
const techStack = await getTechStack(locale);
|
||||
|
||||
if (techStack && techStack.length > 0) {
|
||||
return NextResponse.json({
|
||||
techStack,
|
||||
source: 'directus'
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: return empty (component will use hardcoded fallback)
|
||||
return NextResponse.json({
|
||||
techStack: null,
|
||||
source: 'fallback'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading tech stack:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
techStack: null,
|
||||
error: 'Failed to load tech stack',
|
||||
source: 'error'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user