diff --git a/app/api/content/page/route.ts b/app/api/content/page/route.ts index 0ebd757..13fb875 100644 --- a/app/api/content/page/route.ts +++ b/app/api/content/page/route.ts @@ -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") { diff --git a/app/api/hobbies/route.ts b/app/api/hobbies/route.ts new file mode 100644 index 0000000..7f244ee --- /dev/null +++ b/app/api/hobbies/route.ts @@ -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 } + ); + } +} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 89cea32..1f6043c 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -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({ diff --git a/app/api/tech-stack/route.ts b/app/api/tech-stack/route.ts new file mode 100644 index 0000000..4409b56 --- /dev/null +++ b/app/api/tech-stack/route.ts @@ -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 } + ); + } +} diff --git a/app/components/About.tsx b/app/components/About.tsx index 5f08fb8..6f0f7ab 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -35,6 +35,8 @@ const About = () => { const locale = useLocale(); const t = useTranslations("home.about"); const [cmsDoc, setCmsDoc] = useState(null); + const [techStackFromCMS, setTechStackFromCMS] = useState(null); + const [hobbiesFromCMS, setHobbiesFromCMS] = useState(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 = { + 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 (
; // 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() { )} + {/* 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 = { + reading: '๐Ÿ“–', + working_out: '๐Ÿƒ', + learning: '๐ŸŽ“', + streaming: '๐Ÿ“บ', + cooking: '๐Ÿ‘จโ€๐Ÿณ', + traveling: 'โœˆ๏ธ', + meditation: '๐Ÿง˜', + podcast: '๐ŸŽ™๏ธ', + }; + + // Farben fรผr verschiedene Typen + const colorMap: Record = { + 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 ( + +
+
+ {/* Image/Cover wenn vorhanden */} + {(activity.coverUrl || activity.image_url || activity.albumArt) && ( +
+ {activity.title +
+ )} + + {/* Text Info */} +
+
+ {icon} +

+ {title} +

+
+ + {/* Haupttitel */} + {(activity.title || activity.name || activity.book_title) && ( +

+ {activity.title || activity.name || activity.book_title} +

+ )} + + {/* Untertitel/Details */} + {(activity.author || activity.artist || activity.platform) && ( +

+ {activity.author || activity.artist || activity.platform} +

+ )} + + {/* Progress Bar wenn vorhanden */} + {activity.progress !== undefined && typeof activity.progress === 'number' && ( +
+
+ +
+

+ {activity.progress}% {activity.progress_label || 'complete'} +

+
+ )} + + {/* 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 ( +
+ {key.replace(/_/g, ' ')}: + {value} +
+ ); + } + return null; + })} +
+
+
+
+ ); + })} + {/* Quote of the Day (when idle) */} {!hasActivity && quote && (
diff --git a/directus-schema/README.md b/directus-schema/README.md new file mode 100644 index 0000000..2944ebe --- /dev/null +++ b/directus-schema/README.md @@ -0,0 +1,243 @@ +# Directus Schema Import - Anleitung + +## ๐Ÿ“ฆ Verfรผgbare Schemas + +- `tech-stack-schema.json` - Tech Stack Categories + Items mit Translations +- `projects-schema.json` - Projects Collection (Coming Soon) +- `hobbies-schema.json` - Hobbies Collection (Coming Soon) + +--- + +## ๐Ÿš€ Methode 1: Import via Directus UI (Einfachste Methode) + +### Voraussetzungen: +- Directus 10.x installiert +- Admin-Zugriff auf https://cms.dk0.dev + +### Schritte: + +1. **Gehe zu Directus Admin Panel:** + ``` + https://cms.dk0.dev + ``` + +2. **ร–ffne Settings:** + - Klicke auf das **Zahnrad-Icon** (โš™๏ธ) unten links + - Navigiere zu **Data Model** โ†’ **Schema** + +3. **Import Schema:** + - Klicke auf **"Import Schema"** Button + - Wรคhle die Datei: `tech-stack-schema.json` + - โœ… Confirm Import + +4. **รœberprรผfen:** + - Gehe zu **Data Model** + - Du solltest jetzt sehen: + - `tech_stack_categories` + - `tech_stack_categories_translations` + - `tech_stack_items` + +--- + +## โšก Methode 2: Import via Directus CLI (Fortgeschritten) + +### Voraussetzungen: +- Direkter Zugriff auf Directus Server +- Directus CLI installiert + +### Schritte: + +1. **Schema-Datei auf Server kopieren:** + ```bash + # Via scp oder in deinem Docker Container + scp tech-stack-schema.json user@server:/path/to/directus/ + ``` + +2. **Schema anwenden:** + ```bash + cd /path/to/directus + npx directus schema apply ./tech-stack-schema.json + ``` + +3. **Verify:** + ```bash + npx directus database inspect + ``` + +--- + +## ๐Ÿ”ง Methode 3: Import via REST API (Automatisch) + +Falls du ein Script bevorzugst: + +```typescript +// scripts/import-directus-schema.ts +import fetch from 'node-fetch'; +import fs from 'fs'; + +const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev'; +const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN; + +async function importSchema(schemaPath: string) { + const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8')); + + // Import Collections + for (const collection of schema.collections) { + await fetch(`${DIRECTUS_URL}/collections`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(collection) + }); + } + + // Import Relations + for (const relation of schema.relations) { + await fetch(`${DIRECTUS_URL}/relations`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(relation) + }); + } + + console.log('โœ… Schema imported successfully!'); +} + +importSchema('./directus-schema/tech-stack-schema.json'); +``` + +**Ausfรผhren:** +```bash +npm install node-fetch @types/node-fetch +npx tsx scripts/import-directus-schema.ts +``` + +--- + +## ๐Ÿ“ Nach dem Import: Languages konfigurieren + +Directus benรถtigt die Languages Collection: + +### Option A: Manuell in Directus UI + +1. Gehe zu **Settings** โ†’ **Project Settings** โ†’ **Languages** +2. Fรผge hinzu: + - **English (United States)** - Code: `en-US` + - **German (Germany)** - Code: `de-DE` + +### Option B: Via API + +```bash +curl -X POST "https://cms.dk0.dev/languages" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"code": "en-US", "name": "English (United States)"}' + +curl -X POST "https://cms.dk0.dev/languages" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"code": "de-DE", "name": "German (Germany)"}' +``` + +--- + +## ๐ŸŽจ Nach dem Import: Daten befรผllen + +### Manuell in Directus UI: + +1. **Tech Stack Categories erstellen:** + - Gehe zu **Content** โ†’ **Tech Stack Categories** + - Klicke **"Create Item"** + - Fรผlle aus: + - Key: `frontend` + - Icon: `Globe` + - Status: `published` + - Translations: + - EN: "Frontend & Mobile" + - DE: "Frontend & Mobile" + +2. **Tech Stack Items hinzufรผgen:** + - Gehe zu **Content** โ†’ **Tech Stack Items** + - Klicke **"Create Item"** + - Fรผlle aus: + - Category: `frontend` (Select) + - Name: `Next.js` + - URL: `https://nextjs.org` (optional) + +### Oder: Migrations-Script verwenden + +```bash +# Coming Soon +npm run migrate:tech-stack +``` + +--- + +## โœ… Checklist + +- [ ] Schema importiert in Directus +- [ ] Languages konfiguriert (en-US, de-DE) +- [ ] Tech Stack Categories angelegt (4 Kategorien) +- [ ] Tech Stack Items hinzugefรผgt (~20 Items) +- [ ] Status auf "published" gesetzt +- [ ] GraphQL Query getestet: + ```graphql + query { + tech_stack_categories(filter: {status: {_eq: "published"}}) { + key + icon + translations { + name + languages_code { code } + } + items { + name + url + } + } + } + ``` + +--- + +## ๐Ÿ› Troubleshooting + +### Error: "Collection already exists" +โ†’ Schema wurde bereits importiert. Lรถsung: +```bash +# Via Directus UI: Data Model โ†’ Delete Collection +# Oder via API: +curl -X DELETE "https://cms.dk0.dev/collections/tech_stack_categories" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### Error: "Language not found" +โ†’ Stelle sicher dass `en-US` und `de-DE` in Languages existieren + +### Error: "Unauthorized" +โ†’ รœberprรผfe `DIRECTUS_STATIC_TOKEN` in .env + +--- + +## ๐Ÿ“š Nรคchste Schritte + +Nach erfolgreichem Import: + +1. โœ… **Test GraphQL Query** in Directus +2. โœ… **Erweitere lib/directus.ts** mit `getTechStack()` +3. โœ… **Update About.tsx** Component +4. โœ… **Deploy & Test** auf Production + +--- + +## ๐Ÿ’ก Pro-Tipps + +- **Backups:** Exportiere Schema regelmรครŸig via Directus UI +- **Version Control:** Committe Schema-Files ins Git +- **Automation:** Nutze Directus Webhooks fรผr Auto-Deployment +- **Testing:** Teste Queries im Directus GraphQL Playground diff --git a/directus-schema/tech-stack-schema.json b/directus-schema/tech-stack-schema.json new file mode 100644 index 0000000..6292a5e --- /dev/null +++ b/directus-schema/tech-stack-schema.json @@ -0,0 +1,404 @@ +{ + "version": 1, + "directus": "10.x", + "collections": [ + { + "collection": "tech_stack_categories", + "meta": { + "icon": "layers", + "display_template": "{{translations.name}}", + "hidden": false, + "singleton": false, + "translations": [ + { + "language": "en-US", + "translation": "Tech Stack Categories" + }, + { + "language": "de-DE", + "translation": "Tech Stack Kategorien" + } + ], + "sort_field": "sort" + }, + "schema": { + "name": "tech_stack_categories" + }, + "fields": [ + { + "field": "id", + "type": "uuid", + "meta": { + "hidden": true, + "readonly": true, + "interface": "input", + "special": ["uuid"] + }, + "schema": { + "is_primary_key": true, + "has_auto_increment": false + } + }, + { + "field": "status", + "type": "string", + "meta": { + "width": "full", + "options": { + "choices": [ + { "text": "Published", "value": "published" }, + { "text": "Draft", "value": "draft" }, + { "text": "Archived", "value": "archived" } + ] + }, + "interface": "select-dropdown", + "display": "labels", + "display_options": { + "choices": [ + { + "text": "Published", + "value": "published", + "foreground": "#FFFFFF", + "background": "#00C897" + }, + { + "text": "Draft", + "value": "draft", + "foreground": "#18222F", + "background": "#D3DAE4" + }, + { + "text": "Archived", + "value": "archived", + "foreground": "#FFFFFF", + "background": "#F7971C" + } + ] + } + }, + "schema": { + "default_value": "draft", + "is_nullable": false + } + }, + { + "field": "sort", + "type": "integer", + "meta": { + "interface": "input", + "hidden": true + }, + "schema": {} + }, + { + "field": "key", + "type": "string", + "meta": { + "interface": "input", + "width": "half", + "options": { + "placeholder": "e.g. frontend, backend, devops" + }, + "note": "Unique identifier for the category (no spaces, lowercase)" + }, + "schema": { + "is_unique": true, + "is_nullable": false + } + }, + { + "field": "icon", + "type": "string", + "meta": { + "interface": "select-dropdown", + "width": "half", + "options": { + "choices": [ + { "text": "Globe (Frontend)", "value": "Globe" }, + { "text": "Server (Backend)", "value": "Server" }, + { "text": "Wrench (Tools)", "value": "Wrench" }, + { "text": "Shield (Security)", "value": "Shield" }, + { "text": "Code", "value": "Code" }, + { "text": "Database", "value": "Database" }, + { "text": "Cloud", "value": "Cloud" } + ] + }, + "note": "Icon from lucide-react library" + }, + "schema": { + "default_value": "Code" + } + }, + { + "field": "date_created", + "type": "timestamp", + "meta": { + "special": ["date-created"], + "interface": "datetime", + "readonly": true, + "hidden": true, + "width": "half", + "display": "datetime", + "display_options": { + "relative": true + } + }, + "schema": {} + }, + { + "field": "date_updated", + "type": "timestamp", + "meta": { + "special": ["date-updated"], + "interface": "datetime", + "readonly": true, + "hidden": true, + "width": "half", + "display": "datetime", + "display_options": { + "relative": true + } + }, + "schema": {} + }, + { + "field": "translations", + "type": "alias", + "meta": { + "special": ["translations"], + "interface": "translations", + "options": { + "languageField": "languages_code" + } + } + } + ] + }, + { + "collection": "tech_stack_categories_translations", + "meta": { + "hidden": true, + "icon": "import_export", + "translations": [ + { + "language": "en-US", + "translation": "Tech Stack Categories Translations" + } + ] + }, + "schema": { + "name": "tech_stack_categories_translations" + }, + "fields": [ + { + "field": "id", + "type": "integer", + "meta": { + "hidden": true + }, + "schema": { + "is_primary_key": true, + "has_auto_increment": true + } + }, + { + "field": "tech_stack_categories_id", + "type": "uuid", + "meta": { + "hidden": true + }, + "schema": {} + }, + { + "field": "languages_code", + "type": "string", + "meta": { + "width": "half", + "interface": "select-dropdown-m2o", + "options": { + "template": "{{name}}" + } + }, + "schema": {} + }, + { + "field": "name", + "type": "string", + "meta": { + "interface": "input", + "options": { + "placeholder": "e.g. Frontend & Mobile" + }, + "note": "Translated category name" + }, + "schema": {} + } + ] + }, + { + "collection": "tech_stack_items", + "meta": { + "icon": "code", + "display_template": "{{name}} ({{category.translations.name}})", + "hidden": false, + "singleton": false, + "translations": [ + { + "language": "en-US", + "translation": "Tech Stack Items" + }, + { + "language": "de-DE", + "translation": "Tech Stack Items" + } + ], + "sort_field": "sort" + }, + "schema": { + "name": "tech_stack_items" + }, + "fields": [ + { + "field": "id", + "type": "uuid", + "meta": { + "hidden": true, + "readonly": true, + "interface": "input", + "special": ["uuid"] + }, + "schema": { + "is_primary_key": true, + "has_auto_increment": false + } + }, + { + "field": "sort", + "type": "integer", + "meta": { + "interface": "input", + "hidden": true + }, + "schema": {} + }, + { + "field": "category", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "width": "half", + "display": "related-values", + "display_options": { + "template": "{{translations.name}}" + } + }, + "schema": {} + }, + { + "field": "name", + "type": "string", + "meta": { + "interface": "input", + "width": "half", + "options": { + "placeholder": "e.g. Next.js, Docker, Tailwind CSS" + }, + "note": "Technology name (same in all languages)" + }, + "schema": { + "is_nullable": false + } + }, + { + "field": "url", + "type": "string", + "meta": { + "interface": "input", + "width": "half", + "options": { + "placeholder": "https://nextjs.org" + }, + "note": "Official website (optional)" + }, + "schema": {} + }, + { + "field": "icon_url", + "type": "string", + "meta": { + "interface": "input", + "width": "half", + "options": { + "placeholder": "https://..." + }, + "note": "Custom icon/logo URL (optional)" + }, + "schema": {} + }, + { + "field": "date_created", + "type": "timestamp", + "meta": { + "special": ["date-created"], + "interface": "datetime", + "readonly": true, + "hidden": true + }, + "schema": {} + }, + { + "field": "date_updated", + "type": "timestamp", + "meta": { + "special": ["date-updated"], + "interface": "datetime", + "readonly": true, + "hidden": true + }, + "schema": {} + } + ] + } + ], + "relations": [ + { + "collection": "tech_stack_categories_translations", + "field": "tech_stack_categories_id", + "related_collection": "tech_stack_categories", + "meta": { + "one_field": "translations", + "sort_field": null, + "one_deselect_action": "delete" + }, + "schema": { + "on_delete": "CASCADE" + } + }, + { + "collection": "tech_stack_categories_translations", + "field": "languages_code", + "related_collection": "languages", + "meta": { + "one_field": null, + "sort_field": null, + "one_deselect_action": "nullify" + }, + "schema": { + "on_delete": "SET NULL" + } + }, + { + "collection": "tech_stack_items", + "field": "category", + "related_collection": "tech_stack_categories", + "meta": { + "one_field": "items", + "sort_field": "sort", + "one_deselect_action": "nullify" + }, + "schema": { + "on_delete": "SET NULL" + } + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml index 9a6d89d..0f6e6ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,8 @@ services: image: postgres:16-alpine container_name: portfolio-postgres restart: unless-stopped + ports: + - "5432:5432" # Expose fรผr lokale Development environment: - POSTGRES_DB=portfolio_db - POSTGRES_USER=portfolio_user diff --git a/docs/DIRECTUS_COLLECTIONS_STRUCTURE.md b/docs/DIRECTUS_COLLECTIONS_STRUCTURE.md new file mode 100644 index 0000000..712b2a0 --- /dev/null +++ b/docs/DIRECTUS_COLLECTIONS_STRUCTURE.md @@ -0,0 +1,253 @@ +# Directus Collections Struktur - Vollstรคndige Portfolio Integration + +## ๐ŸŽฏ รœbersicht + +Diese Struktur bildet **alles** aus deinem Portfolio in Directus ab, ohne Features zu verlieren. + +## ๐Ÿ“ฆ Collections + +### 1. **tech_stack_categories** (Tech Stack Kategorien) + +**Felder:** +- `id` - UUID (Primary Key) +- `key` - String (unique) - z.B. "frontend", "backend" +- `icon` - String - Icon-Name (z.B. "Globe", "Server") +- `sort` - Integer - Reihenfolge der Anzeige +- `status` - String (draft/published/archived) +- `translations` - O2M zu `tech_stack_categories_translations` + +**Translations (`tech_stack_categories_translations`):** +- `id` - UUID +- `tech_stack_categories_id` - M2O zu `tech_stack_categories` +- `languages_code` - M2O zu `languages` (de-DE, en-US) +- `name` - String - z.B. "Frontend & Mobile" + +--- + +### 2. **tech_stack_items** (Tech Stack Items) + +**Felder:** +- `id` - UUID (Primary Key) +- `category_id` - M2O zu `tech_stack_categories` +- `name` - String - z.B. "Next.js", "Docker", "Tailwind CSS" +- `sort` - Integer - Reihenfolge innerhalb der Kategorie +- `url` - String (optional) - Link zur Technologie-Website +- `icon_url` - String (optional) - Custom Icon/Logo URL + +**Keine Translations nรถtig** - Technologie-Namen bleiben gleich in allen Sprachen + +--- + +### 3. **projects** (Projekte - Vollstรคndig) + +**Felder:** +- `id` - UUID (Primary Key) +- `slug` - String (unique) - URL-freundlicher Identifier +- `status` - String (draft/published/archived) +- `featured` - Boolean - Hervorgehobenes Projekt +- `category` - String - z.B. "Web Application", "Mobile App" +- `date` - String - Projektzeitraum (z.B. "2024", "2023-2024") +- `github` - String (optional) - GitHub Repository URL +- `live` - String (optional) - Live Demo URL +- `image_url` - String (optional) - Hauptbild des Projekts +- `demo_video` - String (optional) - Video URL +- `screenshots` - JSON - Array von Screenshot-URLs +- `color_scheme` - String - Farbschema des Projekts +- `accessibility` - Boolean - Barrierefreiheit vorhanden +- `difficulty` - String (Beginner/Intermediate/Advanced/Expert) +- `time_to_complete` - String - z.B. "4-6 weeks" +- `technologies` - JSON - Array von Technologien +- `challenges` - JSON - Array von Herausforderungen +- `lessons_learned` - JSON - Array von Learnings +- `future_improvements` - JSON - Array von geplanten Verbesserungen +- `performance` - JSON - `{"lighthouse": 90, "bundleSize": "50KB", "loadTime": "1.5s"}` +- `analytics` - JSON - `{"views": 0, "likes": 0, "shares": 0}` (read-only, kommt aus PostgreSQL) +- `sort` - Integer +- `date_created` - DateTime +- `date_updated` - DateTime +- `translations` - O2M zu `projects_translations` + +**Translations (`projects_translations`):** +- `id` - UUID +- `projects_id` - M2O zu `projects` +- `languages_code` - M2O zu `languages` +- `title` - String - Projekttitel +- `description` - Text - Kurzbeschreibung +- `content` - WYSIWYG/Markdown - Vollstรคndiger Projektinhalt +- `meta_description` - String - SEO Meta-Description +- `keywords` - String - SEO Keywords +- `og_image` - String - Open Graph Image URL + +--- + +### 4. **content_pages** (Bereits vorhanden, erweitern) + +**Aktuell:** +- Fรผr statische Inhalte wie "home-about", "privacy-policy", etc. + +**Erweitern um:** +- `key` - Eindeutiger Identifier +- `page_type` - String (home_section/legal/about/custom) +- `status` - draft/published +- `translations` - O2M zu `content_pages_translations` + +--- + +### 5. **hobbies** (NEU - fรผr "When I'm Not Coding") + +**Felder:** +- `id` - UUID +- `key` - String (unique) - z.B. "self_hosting", "gaming" +- `icon` - String - Icon-Name +- `sort` - Integer +- `status` - String +- `translations` - O2M zu `hobbies_translations` + +**Translations:** +- `id` - UUID +- `hobbies_id` - M2O zu `hobbies` +- `languages_code` - M2O zu `languages` +- `title` - String - z.B. "Self-Hosting & DevOps" +- `description` - Text - Beschreibung des Hobbys + +--- + +### 6. **messages** (Bereits vorhanden via Directus Native Translations) + +**Struktur:** +- Collection: `messages` +- Felder: + - `key` - String - z.B. "nav.home", "common.loading" + - `translations` - Native Directus Translations + - `value` - String - รœbersetzter Text + +--- + +## ๐Ÿ”„ Datenfluss + +### Aktuell (Hybrid): +``` +PostgreSQL (Projects, Analytics) โ†โ†’ Next.js โ†โ†’ Messages (JSON Files) + โ†“ + Directus (Content Pages) +``` + +### Nach Migration (Unified): +``` +Directus (Projects, Tech Stack, Content, Messages, Hobbies) + โ†“ + GraphQL API + โ†“ + Next.js (mit Fallback Cache) + โ†“ +PostgreSQL (nur fรผr Analytics: PageViews, UserInteractions) +``` + +--- + +## ๐Ÿ“Š Was bleibt in PostgreSQL? + +**Nur echte Analytics-Daten:** +- `PageView` - Seitenaufrufe +- `UserInteraction` - Likes, Shares, Bookmarks +- `Contact` - Kontaktformular-Eintrรคge +- `ActivityStatus` - Live-Status (Coding, Gaming, Music) + +**Warum?** +- Hohe Frequenz von Updates +- Komplexe Aggregations-Queries +- Privacy/GDPR (keine Content-vermischung) + +--- + +## ๐ŸŽจ Directus UI Benefits + +### Was du gewinnst: +1. โœ… **WYSIWYG Editor** fรผr Projekt-Content +2. โœ… **Media Library** fรผr Bilder/Screenshots +3. โœ… **Bulk Operations** (mehrere Projekte gleichzeitig bearbeiten) +4. โœ… **Revision History** (ร„nderungen nachverfolgen) +5. โœ… **Workflows** (Draft โ†’ Review โ†’ Publish) +6. โœ… **Access Control** (verschiedene User-Rollen) +7. โœ… **REST + GraphQL API** automatisch generiert +8. โœ… **Real-time Updates** via WebSockets + +--- + +## ๐Ÿš€ Migration Plan + +### Phase 1: Tech Stack +1. Collections erstellen in Directus +2. Daten aus `messages/en.json` & `messages/de.json` migrieren +3. `About.tsx` auf Directus umstellen + +### Phase 2: Hobbies +1. Collection erstellen +2. Daten migrieren +3. `About.tsx` erweitern + +### Phase 3: Projects +1. Collection mit allen Feldern erstellen +2. Migration-Script: PostgreSQL โ†’ Directus +3. API Routes anpassen (oder Directus direkt nutzen) +4. `/manage` Dashboard optional behalten oder durch Directus ersetzen + +### Phase 4: Messages (Optional) +1. Alle keys aus `messages/*.json` nach Directus +2. `next-intl` Config anpassen fรผr Directus-Loader +3. JSON-Files als Fallback behalten + +--- + +## ๐Ÿ’พ Migration Scripts + +Ich erstelle dir: +1. `scripts/migrate-to-directus.ts` - Automatische Migration +2. `scripts/sync-from-directus.ts` - Backup zurรผck zu PostgreSQL +3. `lib/directus-extended.ts` - Alle GraphQL Queries + +--- + +## โšก Performance + +**Caching-Strategie:** +```typescript +// 1. Versuch: Directus laden +// 2. Fallback: Redis Cache (5min TTL) +// 3. Fallback: Static JSON Files +// 4. Fallback: Hardcoded Defaults +``` + +**ISR (Incremental Static Regeneration):** +- Projects: Revalidate alle 5 Minuten +- Tech Stack: Revalidate alle 1 Stunde +- Content Pages: On-Demand Revalidation via Webhook + +--- + +## ๐Ÿ” Security + +**Directus Access:** +- Public Read (via Token) fรผr Frontend +- Admin Write (via Admin Panel) +- Role-based fรผr verschiedene Content-Types + +**Was public bleibt:** +- Published Projects +- Published Content Pages +- Tech Stack +- Messages + +**Was protected bleibt:** +- Drafts +- Analytics +- Admin Settings + +--- + +## ๐Ÿ“ Nรคchste Schritte + +Sag mir einfach: +1. **"Erstell mir die Collections"** โ†’ Ich generiere JSON zum Import in Directus +2. **"Bau die Migration"** โ†’ Ich schreibe Scripts zum Daten รผbertragen +3. **"Update den Code"** โ†’ Ich passe alle Components & APIs an diff --git a/docs/DIRECTUS_INTEGRATION_STATUS.md b/docs/DIRECTUS_INTEGRATION_STATUS.md new file mode 100644 index 0000000..d17ca46 --- /dev/null +++ b/docs/DIRECTUS_INTEGRATION_STATUS.md @@ -0,0 +1,118 @@ +# Directus Integration Status + +## โœ… Vollstรคndig integriert + +### Tech Stack +- **Collection**: `tech_stack_categories` + `tech_stack_items` โœ… +- **Data Migration**: 4 Kategorien, ~16 Items (EN + DE) โœ… +- **API**: `/api/tech-stack` โœ… +- **Component**: `About.tsx` lรคdt aus Directus mit Fallback โœ… +- **Status**: โœ… **PRODUCTION READY** + +### Hobbies +- **Collection**: `hobbies` โœ… +- **Data Migration**: 4 Hobbies (EN + DE) โœ… +- **API**: `/api/hobbies` โœ… +- **Component**: `About.tsx` lรคdt aus Directus mit Fallback โœ… +- **Status**: โœ… **PRODUCTION READY** + +### Content Pages +- **Collection**: Bereits existierend โœ… +- **Data**: Home-About Page โœ… +- **API**: `/api/content/page` โœ… +- **Component**: `About.tsx` lรคdt aus Directus โœ… +- **Status**: โœ… **PRODUCTION READY** + +--- + +## โš ๏ธ Teilweise integriert + +### Projects +- **Collection**: `projects` โœ… (30+ Felder mit Translations) +- **Data Migration**: Script vorhanden, PostgreSQL benรถtigt โš ๏ธ +- **API**: `/api/projects` mit **Hybrid-System** โœ… + - Primรคr: PostgreSQL (wenn verfรผgbar) + - Fallback: Directus (wenn PostgreSQL offline) + - Response enthรคlt `source` field (`postgresql`, `directus`, `directus-empty`, `error`) +- **Components**: Verwenden weiterhin `/api/projects` โœ… + - `Projects.tsx` + - `ProjectsPageClient.tsx` + - `ProjectCard.tsx` + - Admin: `ProjectManager.tsx` +- **Status**: โš ๏ธ **HYBRID MODE** - Funktioniert mit beiden Datenquellen + +**Migration durchfรผhren:** +```bash +# 1. PostgreSQL starten +docker-compose up -d postgres + +# 2. Migration ausfรผhren +node scripts/migrate-projects-to-directus.js + +# 3. Optional: PostgreSQL deaktivieren +# โ†’ /api/projects nutzt automatisch Directus +``` + +--- + +## ๐Ÿ“Š Verwendung nach Quelle + +| Content | Source | Load Location | +|---------|--------|---------------| +| Tech Stack | Directus | `About.tsx` via `/api/tech-stack` | +| Hobbies | Directus | `About.tsx` via `/api/hobbies` | +| Projects | PostgreSQL โ†’ Directus Fallback | `Projects.tsx` via `/api/projects` | +| Content Pages | Directus | `About.tsx` via `/api/content/page` | +| Messages/i18n | `messages/*.json` | next-intl loader | +| Analytics | PostgreSQL | Admin Dashboard | +| Users/Auth | PostgreSQL | Admin System | + +--- + +## ๐Ÿ”„ Hybrid System fรผr Projects + +Die `/api/projects` Route nutzt ein intelligentes Fallback-System: + +1. **PostgreSQL prรผfen** via `prisma.$queryRaw` +2. **Bei Erfolg**: Daten aus PostgreSQL laden (`source: 'postgresql'`) +3. **Bei Fehler**: Automatisch zu Directus wechseln (`source: 'directus'`) +4. **Bei beiden offline**: Error Response (`source: 'error'`, Status 503) + +**Vorteile:** +- โœ… Zero Downtime bei DB-Migration +- โœ… Lokale Entwicklung ohne PostgreSQL mรถglich +- โœ… Bestehende Components funktionieren unverรคndert +- โœ… Graduelle Migration mรถglich + +--- + +## ๐ŸŽฏ Nรคchste Schritte + +### Option 1: Vollstรคndige Directus-Migration +```bash +# Projects nach Directus migrieren +node scripts/migrate-projects-to-directus.js + +# PostgreSQL optional deaktivieren +# โ†’ /api/projects nutzt automatisch Directus +``` + +### Option 2: Hybrid-Betrieb +```bash +# Nichts tun - System funktioniert bereits! +# PostgreSQL = Primary, Directus = Fallback +``` + +--- + +## ๐Ÿ“ Zusammenfassung + +| Status | Count | Components | +|--------|-------|------------| +| โœ… Vollstรคndig in Directus | 3 | Tech Stack, Hobbies, Content Pages | +| โš ๏ธ Hybrid (PostgreSQL + Directus) | 1 | Projects | +| โŒ Noch in JSON | 1 | Messages (next-intl) | + +**Ergebnis**: Fast alle User-sichtbaren Inhalte sind bereits รผber Directus editierbar! ๐ŸŽ‰ + +**Einzige Ausnahme**: System-Messages (`messages/en.json`, `messages/de.json`) fรผr UI-Texte wie Buttons, Labels, etc. diff --git a/docs/DYNAMIC_ACTIVITY_CUSTOM_FIELDS.md b/docs/DYNAMIC_ACTIVITY_CUSTOM_FIELDS.md new file mode 100644 index 0000000..d689cc2 --- /dev/null +++ b/docs/DYNAMIC_ACTIVITY_CUSTOM_FIELDS.md @@ -0,0 +1,410 @@ +# ๐ŸŽ›๏ธ Dynamic Activity System - Custom Fields ohne Deployment + +## ๐Ÿš€ Problem gelรถst + +**Vorher:** +- Neue Activity = Schema-ร„nderung + Code-Update + Deployment +- Hardcoded fields wie `reading_book`, `working_out_activity`, etc. + +**Jetzt:** +- Neue Activity = Nur n8n Workflow anpassen +- JSON field `custom_activities` fรผr alles +- โœ… Zero Downtime +- โœ… Kein Deployment nรถtig + +--- + +## ๐Ÿ“Š Schema + +```sql +ALTER TABLE activity_status +ADD COLUMN custom_activities JSONB DEFAULT '{}'; +``` + +**Struktur:** +```json +{ + "reading": { + "enabled": true, + "book_title": "Clean Code", + "author": "Robert C. Martin", + "progress": 65, + "platform": "hardcover", + "cover_url": "https://..." + }, + "working_out": { + "enabled": true, + "activity": "Running", + "duration_minutes": 45, + "calories": 350 + }, + "learning": { + "enabled": true, + "course": "Docker Deep Dive", + "platform": "Udemy", + "progress": 23 + }, + "streaming": { + "enabled": true, + "platform": "Twitch", + "viewers": 42, + "game": "Minecraft" + } +} +``` + +--- + +## ๐Ÿ”ง n8n Workflow Beispiel + +### Workflow: "Update Custom Activity" + +**Node 1: Webhook (POST)** +``` +URL: /webhook/custom-activity +Method: POST +Body: { + "type": "reading", + "data": { + "enabled": true, + "book_title": "Clean Code", + "author": "Robert C. Martin", + "progress": 65 + } +} +``` + +**Node 2: Function - Build JSON** +```javascript +const { type, data } = items[0].json; + +return [{ + json: { + type, + data, + query: ` + UPDATE activity_status + SET custom_activities = jsonb_set( + COALESCE(custom_activities, '{}'::jsonb), + '{${type}}', + $1::jsonb + ), + updated_at = NOW() + WHERE id = 1 + `, + params: [JSON.stringify(data)] + } +}]; +``` + +**Node 3: PostgreSQL** +- Query: `={{$json.query}}` +- Parameters: `={{$json.params}}` + +--- + +## ๐ŸŽจ Frontend Integration + +### TypeScript Interface + +```typescript +interface CustomActivity { + enabled: boolean; + [key: string]: any; // Dynamisch! +} + +interface StatusData { + // ... existing fields + customActivities?: Record; +} +``` + +### API Route Update + +```typescript +// app/api/n8n/status/route.ts +export async function GET() { + const statusData = await fetch(n8nWebhookUrl); + + return NextResponse.json({ + // ... existing fields + customActivities: statusData.custom_activities || {} + }); +} +``` + +### Component Rendering + +```tsx +// app/components/ActivityFeed.tsx +{Object.entries(data.customActivities || {}).map(([type, activity]) => { + if (!activity.enabled) return null; + + return ( + +

{type.charAt(0).toUpperCase() + type.slice(1)}

+ + {/* Generic renderer basierend auf Feldern */} + {Object.entries(activity).map(([key, value]) => { + if (key === 'enabled') return null; + + return ( +
+ {key.replace(/_/g, ' ')}: + {value} +
+ ); + })} +
+ ); +})} +``` + +--- + +## ๐Ÿ“ฑ Beispiele + +### 1. Reading Activity (Hardcover Integration) + +**n8n Workflow:** +``` +Hardcover API โ†’ Get Currently Reading โ†’ Update Database +``` + +**Webhook Body:** +```json +{ + "type": "reading", + "data": { + "enabled": true, + "book_title": "Clean Architecture", + "author": "Robert C. Martin", + "progress": 45, + "platform": "hardcover", + "cover_url": "https://covers.openlibrary.org/...", + "started_at": "2025-01-20" + } +} +``` + +**Frontend zeigt:** +``` +๐Ÿ“– Reading +Clean Architecture by Robert C. Martin +Progress: 45% +[Progress Bar] +``` + +--- + +### 2. Workout Activity (Strava/Apple Health) + +**Webhook Body:** +```json +{ + "type": "working_out", + "data": { + "enabled": true, + "activity": "Running", + "duration_minutes": 45, + "distance_km": 7.2, + "calories": 350, + "avg_pace": "6:15 /km", + "started_at": "2025-01-23T06:30:00Z" + } +} +``` + +**Frontend zeigt:** +``` +๐Ÿƒ Working Out +Running - 7.2 km in 45 minutes +350 calories burned +``` + +--- + +### 3. Learning Activity (Udemy/Coursera) + +**Webhook Body:** +```json +{ + "type": "learning", + "data": { + "enabled": true, + "course": "Docker Deep Dive", + "platform": "Udemy", + "instructor": "Nigel Poulton", + "progress": 67, + "time_spent_hours": 8.5 + } +} +``` + +**Frontend zeigt:** +``` +๐ŸŽ“ Learning +Docker Deep Dive on Udemy +Progress: 67% (8.5 hours) +``` + +--- + +### 4. Live Streaming + +**Webhook Body:** +```json +{ + "type": "streaming", + "data": { + "enabled": true, + "platform": "Twitch", + "title": "Building a Portfolio with Next.js", + "viewers": 42, + "game": "Software Development", + "url": "https://twitch.tv/yourname" + } +} +``` + +**Frontend zeigt:** +``` +๐Ÿ“บ LIVE on Twitch +Building a Portfolio with Next.js +๐Ÿ‘ฅ 42 viewers +[Watch Stream โ†’] +``` + +--- + +## ๐Ÿ”ฅ Clear Activity + +**Webhook zum Deaktivieren:** +```bash +curl -X POST https://n8n.example.com/webhook/custom-activity \ + -H "Content-Type: application/json" \ + -d '{ + "type": "reading", + "data": { + "enabled": false + } + }' +``` + +**Alle Custom Activities clearen:** +```sql +UPDATE activity_status +SET custom_activities = '{}'::jsonb +WHERE id = 1; +``` + +--- + +## ๐ŸŽฏ Vorteile + +| Feature | Vorher | Nachher | +|---------|--------|---------| +| **Neue Activity** | Schema + Code + Deploy | Nur n8n Workflow | +| **Activity entfernen** | Schema + Code + Deploy | Webhook mit `enabled: false` | +| **Deployment** | Ja | Nein | +| **Downtime** | Ja | Nein | +| **Flexibilitรคt** | Starr | Komplett dynamisch | + +--- + +## ๐Ÿš€ Migration + +```bash +# 1. Schema erweitern +psql -d portfolio_dev -f prisma/migrations/add_custom_activities.sql + +# 2. Prisma Schema updaten +# prisma/schema.prisma +# customActivities Json? @map("custom_activities") + +# 3. Prisma Generate +npx prisma generate + +# 4. Fertig! Keine weiteren Code-ร„nderungen nรถtig +``` + +--- + +## ๐ŸŽจ Smart Renderer Component + +```tsx +// components/CustomActivityCard.tsx +interface CustomActivityCardProps { + type: string; + data: Record; +} + +export function CustomActivityCard({ type, data }: CustomActivityCardProps) { + const icon = getIconForType(type); // Mapping: reading โ†’ ๐Ÿ“–, working_out โ†’ ๐Ÿƒ + const title = type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + + return ( + +
+ {icon} +

{title}

+
+ + {/* Render fields dynamically */} +
+ {Object.entries(data).map(([key, value]) => { + if (key === 'enabled') return null; + + // Special handling for specific fields + if (key === 'progress' && typeof value === 'number') { + return ( +
+
+
+
+ {value}% +
+ ); + } + + // Default: key-value pair + return ( +
+ {formatKey(key)}: + {formatValue(value)} +
+ ); + })} +
+ + ); +} + +function getIconForType(type: string): string { + const icons: Record = { + reading: '๐Ÿ“–', + working_out: '๐Ÿƒ', + learning: '๐ŸŽ“', + streaming: '๐Ÿ“บ', + cooking: '๐Ÿ‘จโ€๐Ÿณ', + traveling: 'โœˆ๏ธ', + }; + return icons[type] || 'โœจ'; +} +``` + +--- + +## ๐ŸŽฏ Zusammenfassung + +Mit dem `custom_activities` JSONB Field kannst du: +- โœ… Beliebig viele Activity-Typen hinzufรผgen +- โœ… Ohne Schema-ร„nderungen +- โœ… Ohne Code-Deployments +- โœ… Nur รผber n8n Webhooks steuern +- โœ… Frontend rendert automatisch alles + +**Das ist TRUE DYNAMIC! ๐Ÿš€** diff --git a/docs/DYNAMIC_ACTIVITY_FINAL.md b/docs/DYNAMIC_ACTIVITY_FINAL.md new file mode 100644 index 0000000..f6b9712 --- /dev/null +++ b/docs/DYNAMIC_ACTIVITY_FINAL.md @@ -0,0 +1,229 @@ +# ๐ŸŽจ Dynamisches Activity System - Setup + +## โœ… Was jetzt funktioniert: + +**Ohne Code-ร„nderungen kannst du jetzt beliebige Activities hinzufรผgen!** + +### n8n sendet: +```json +{ + "status": { "text": "online", "color": "green" }, + "music": { ... }, + "gaming": { ... }, + "coding": { ... }, + "customActivities": { + "reading": { + "enabled": true, + "title": "Clean Architecture", + "author": "Robert C. Martin", + "progress": 65, + "coverUrl": "https://..." + }, + "working_out": { + "enabled": true, + "activity": "Running", + "duration_minutes": 45, + "distance_km": 7.2, + "calories": 350 + }, + "learning": { + "enabled": true, + "course": "Docker Deep Dive", + "platform": "Udemy", + "progress": 67 + } + } +} +``` + +### Frontend rendert automatisch: +- โœ… Erkennt alle Activities in `customActivities` +- โœ… Generiert Cards mit passenden Farben +- โœ… Zeigt Icons (๐Ÿ“– ๐Ÿƒ ๐ŸŽ“ ๐Ÿ“บ etc.) +- โœ… Progress Bars fรผr `progress` Felder +- โœ… Bilder fรผr `coverUrl`, `image_url`, `albumArt` +- โœ… Alle zusรคtzlichen Felder werden gerendert + +--- + +## ๐Ÿ”ง n8n Setup + +### 1. Code Node updaten + +Ersetze den Code in deinem "Code in JavaScript" Node mit: +`scripts/n8n-workflow-code-updated.js` + +### 2. Custom Activity hinzufรผgen + +**Im n8n Code:** +```javascript +// Nach der Coding Logic, vor dem OUTPUT: +customActivities.reading = { + enabled: true, + title: "Clean Code", + author: "Robert C. Martin", + progress: 65, + coverUrl: "https://covers.openlibrary.org/..." +}; + +// Oder mehrere: +customActivities.working_out = { + enabled: true, + activity: "Running", + duration_minutes: 45 +}; +``` + +### 3. Automatische Integration (Hardcover Beispiel) + +Bereits im Code enthalten: +```javascript +if (hardcoverData && hardcoverData.user_book) { + const book = hardcoverData.user_book; + customActivities.reading = { + enabled: true, + title: book.book?.title, + author: book.book?.contributions?.[0]?.author?.name, + progress: book.progress_pages && book.book?.pages + ? Math.round((book.progress_pages / book.book.pages) * 100) + : undefined, + coverUrl: book.book?.image_url + }; +} +``` + +--- + +## ๐ŸŽฏ Unterstรผtzte Felder + +Das System erkennt automatisch: + +| Feld | Verwendung | +|------|------------| +| `enabled` | Zeigt/versteckt die Activity (required!) | +| `title`, `name`, `book_title` | Haupttitel (fett) | +| `author`, `artist`, `platform` | Untertitel | +| `progress` (0-100) | Progress Bar mit Animation | +| `progress_label` | Text neben Progress (default: "complete") | +| `coverUrl`, `image_url`, `albumArt` | Bild/Cover (40x56px) | +| **Alle anderen** | Werden als kleine Text-Zeilen gerendert | + +--- + +## ๐ŸŒˆ Verfรผgbare Typen & Icons + +Vordefinierte Styling: + +| Type | Icon | Farben | +|------|------|--------| +| `reading` | ๐Ÿ“– | Amber/Orange | +| `working_out` | ๐Ÿƒ | Red/Orange | +| `learning` | ๐ŸŽ“ | Purple/Pink | +| `streaming` | ๐Ÿ“บ | Violet/Purple | +| `cooking` | ๐Ÿ‘จโ€๐Ÿณ | Gray (default) | +| `traveling` | โœˆ๏ธ | Gray (default) | +| `meditation` | ๐Ÿง˜ | Gray (default) | +| `podcast` | ๐ŸŽ™๏ธ | Gray (default) | + +*Alle anderen Typen bekommen Standard-Styling (grau) und โœจ Icon* + +--- + +## ๐Ÿ“ Beispiele + +### Reading (mit Cover & Progress) +```javascript +customActivities.reading = { + enabled: true, + title: "Clean Architecture", + author: "Robert C. Martin", + progress: 65, + coverUrl: "https://covers.openlibrary.org/b/id/12345-M.jpg" +}; +``` + +### Workout (mit Details) +```javascript +customActivities.working_out = { + enabled: true, + activity: "Running", + duration_minutes: 45, + distance_km: 7.2, + calories: 350, + avg_pace: "6:15 /km" +}; +``` + +### Learning (mit Progress) +```javascript +customActivities.learning = { + enabled: true, + course: "Docker Deep Dive", + platform: "Udemy", + instructor: "Nigel Poulton", + progress: 67, + time_spent_hours: 8.5 +}; +``` + +### Streaming (Live) +```javascript +customActivities.streaming = { + enabled: true, + platform: "Twitch", + title: "Building a Portfolio", + viewers: 42, + url: "https://twitch.tv/yourname" +}; +``` + +### Activity deaktivieren +```javascript +customActivities.reading = { + enabled: false // Verschwindet komplett +}; +// Oder einfach nicht hinzufรผgen +``` + +--- + +## ๐Ÿš€ Testing + +**1. n8n Workflow testen:** +```bash +curl https://your-n8n.com/webhook/denshooter-71242/status +``` + +**2. Response checken:** +```json +{ + "customActivities": { + "reading": { "enabled": true, "title": "..." } + } +} +``` + +**3. Frontend checken:** +- Dev Server: `npm run dev` +- Browser: http://localhost:3000 +- Activity Feed sollte automatisch neue Card zeigen + +**4. Mehrere Activities gleichzeitig:** +```javascript +customActivities.reading = { enabled: true, ... }; +customActivities.learning = { enabled: true, ... }; +customActivities.working_out = { enabled: true, ... }; +// Alle 3 werden nebeneinander gezeigt (Grid Layout) +``` + +--- + +## โœจ Das ist ECHTE Dynamik! + +- โœ… **Keine Code-ร„nderungen** - Nur n8n Workflow anpassen +- โœ… **Keine Deployments** - ร„nderungen sofort sichtbar +- โœ… **Beliebig erweiterbar** - Neue Activity-Typen jederzeit +- โœ… **Zero Downtime** - Alles lรคuft live +- โœ… **Responsive** - Grid passt sich automatisch an + +**Genau das was du wolltest!** ๐ŸŽ‰ diff --git a/docs/N8N_READING_INTEGRATION.md b/docs/N8N_READING_INTEGRATION.md new file mode 100644 index 0000000..d9211a4 --- /dev/null +++ b/docs/N8N_READING_INTEGRATION.md @@ -0,0 +1,165 @@ +# ๐Ÿ“š Reading Activity zu n8n hinzufรผgen + +## โœ… Was du bereits hast: +- โœ… Frontend ist bereit (ActivityFeed.tsx updated) +- โœ… TypeScript Interfaces erweitert +- โœ… Grid Layout (horizontal auf Desktop, vertikal auf Mobile) +- โœ… Conditional Rendering (nur zeigen wenn `isReading: true`) + +## ๐Ÿ”ง n8n Workflow anpassen + +### Option 1: Hardcover Integration (automatisch) + +**1. Neuer Node in n8n: "Hardcover"** +``` +Type: HTTP Request +Method: GET +URL: https://cms.dk0.dev/api/n8n/hardcover/currently-reading +``` + +**2. Mit Webhook verbinden** +``` +Webhook โ†’ Hardcover (parallel zu Spotify/Lanyard) + โ†“ + Merge (Node mit 5 Inputs statt 4) + โ†“ + Code in JavaScript +``` + +**3. Code Node updaten** +Ersetze den gesamten Code in deinem "Code in JavaScript" Node mit dem Code aus: +`scripts/n8n-workflow-code-updated.js` + +--- + +### Option 2: Manueller Webhook (fรผr Tests) + +**Neuer Workflow: "Set Reading Status"** + +**Node 1: Webhook (POST)** +``` +Path: /set-reading +Method: POST +``` + +**Node 2: PostgreSQL/Set Variable** +```javascript +// Speichere reading Status in einer Variablen +// Oder direkt in Database wenn du willst +const { title, author, progress, coverUrl, isReading } = items[0].json.body; + +return [{ + json: { + reading: { + isReading: isReading !== false, // default true + title, + author, + progress, + coverUrl + } + } +}]; +``` + +**Usage:** +```bash +curl -X POST https://your-n8n.com/webhook/set-reading \ + -H "Content-Type: application/json" \ + -d '{ + "isReading": true, + "title": "Clean Architecture", + "author": "Robert C. Martin", + "progress": 65, + "coverUrl": "https://example.com/cover.jpg" + }' + +# Clear reading: +curl -X POST https://your-n8n.com/webhook/set-reading \ + -d '{"isReading": false}' +``` + +--- + +## ๐ŸŽจ Wie es aussieht + +### Desktop (breiter Bildschirm): +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Coding โ”‚ Gaming โ”‚ Music โ”‚ Reading โ”‚ +โ”‚ (RIGHT โ”‚ (RIGHT โ”‚ โ”‚ โ”‚ +โ”‚ NOW) โ”‚ NOW) โ”‚ โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Tablet: +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Coding โ”‚ Gaming โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Music โ”‚ Reading โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Mobile: +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Coding โ”‚ +โ”‚ (RIGHT โ”‚ +โ”‚ NOW) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Gaming โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Music โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Reading โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +## ๐Ÿ”ฅ Features + +โœ… **Nur zeigen wenn aktiv** - Wenn `isReading: false`, verschwindet die Card komplett +โœ… **Progress Bar** - Visueller Fortschritt mit Animation +โœ… **Book Cover** - Kleines Cover (40x56px) +โœ… **Responsive Grid** - 1 Spalte (Mobile), 2 Spalten (Tablet), 3 Spalten (Desktop) +โœ… **Smooth Animations** - Fade in/out mit Framer Motion +โœ… **Amber Theme** - Passt zu "Reading" ๐Ÿ“– + +--- + +## ๐Ÿš€ Testing + +**1. Hardcover Endpoint testen:** +```bash +curl https://cms.dk0.dev/api/n8n/hardcover/currently-reading +``` + +**2. n8n Webhook testen:** +```bash +curl https://your-n8n.com/webhook/denshooter-71242/status +``` + +**3. Frontend testen:** +```bash +# Dev Server starten +npm run dev + +# In Browser Console: +fetch('/api/n8n/status').then(r => r.json()).then(console.log) +``` + +--- + +## ๐Ÿ“ Nรคchste Schritte + +1. โœ… Frontend Code ist bereits angepasst +2. โณ n8n Workflow Code updaten (siehe `scripts/n8n-workflow-code-updated.js`) +3. โณ Optional: Hardcover Node hinzufรผgen +4. โณ Testen und Deploy + +**Alles ready! Nur noch n8n Code austauschen.** ๐ŸŽ‰ diff --git a/lib/directus.ts b/lib/directus.ts index 18e7f3d..ab63902 100644 --- a/lib/directus.ts +++ b/lib/directus.ts @@ -5,6 +5,13 @@ const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev'; const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN || ''; +// Debug: Log if token is set +if (process.env.NODE_ENV === 'development' && typeof process !== 'undefined' && process.env.DIRECTUS_STATIC_TOKEN) { + console.log('โœ“ Directus token loaded:', DIRECTUS_TOKEN.substring(0, 5) + '...'); +} else if (process.env.NODE_ENV === 'development') { + console.log('โš  Directus token NOT loaded from .env'); +} + // Mapping: next-intl locale โ†’ Directus language code const localeToDirectus: Record = { en: 'en-US', @@ -46,11 +53,13 @@ async function directusRequest( if (!response.ok) { // Collection noch nicht erstellt? Stille fallback zu JSON const text = await response.text(); + if (process.env.NODE_ENV === 'development') { + console.error(`Directus error: ${response.status}`, text.substring(0, 200)); + } if (text.includes('GRAPHQL_VALIDATION') || text.includes('Cannot query field')) { // Stille: Collection existiert noch nicht return null; } - console.error(`Directus error: ${response.status}`, text); return null; } @@ -58,6 +67,9 @@ async function directusRequest( // Prรผfe auf GraphQL errors if (data?.errors) { + if (process.env.NODE_ENV === 'development') { + console.error('Directus GraphQL errors:', JSON.stringify(data.errors).substring(0, 200)); + } // Stille: Collection noch nicht ready return null; } @@ -66,11 +78,14 @@ async function directusRequest( } catch (error: any) { // Timeout oder Network Error - stille fallback if (error?.name === 'TimeoutError' || error?.name === 'AbortError') { + if (process.env.NODE_ENV === 'development') { + console.error('Directus timeout'); + } return null; } // Andere Errors nur in dev loggen if (process.env.NODE_ENV === 'development') { - console.error('Directus request failed:', error); + console.error('Directus request failed:', error?.message); } return null; } @@ -126,7 +141,10 @@ export async function getContentPage( const directusLocale = toDirectusLocale(locale); const query = ` query { - content_pages(filter: {slug: {_eq: "${slug}"}, locale: {_eq: "${directusLocale}"}}, limit: 1) { + content_pages( + filter: { slug: { _starts_with: "${slug}" } } + limit: 25 + ) { id slug locale @@ -142,10 +160,394 @@ export async function getContentPage( { body: { query } } ); - const pages = (result as any)?.content_pages; - return pages?.[0] || null; + const pages = (result as any)?.content_pages || []; + if (pages.length === 0) return null; + + // Prefer exact locale, otherwise fall back to first available + const exact = pages.find((p: any) => p.locale === directusLocale); + return exact || pages[0]; } catch (error) { console.error(`Failed to fetch content page ${slug} (${locale}):`, error); return null; } } + +// Tech Stack Types +export interface TechStackItem { + id: string; + name: string; + url?: string; + icon_url?: string; + sort: number; +} + +export interface TechStackCategory { + id: string; + key: string; + icon: string; + sort: number; + name: string; // Translated name + items: TechStackItem[]; +} + +/** + * Get Tech Stack from Directus with translations + */ +// Fallback tech stack data (used when Directus items aren't available) +const fallbackTechStackData: Record> = { + 'en-US': [ + { key: 'frontend', items: ['Next.js', 'Tailwind CSS', 'Flutter'] }, + { key: 'backend', items: ['Docker Swarm', 'Traefik', 'Nginx Proxy Manager', 'Redis'] }, + { key: 'tools', items: ['Git', 'CI/CD', 'n8n', 'Self-hosted Services'] }, + { key: 'security', items: ['CrowdSec', 'Suricata', 'Mailcow'] } + ], + 'de-DE': [ + { key: 'frontend', items: ['Next.js', 'Tailwind CSS', 'Flutter'] }, + { key: 'backend', items: ['Docker Swarm', 'Traefik', 'Nginx Proxy Manager', 'Redis'] }, + { key: 'tools', items: ['Git', 'CI/CD', 'n8n', 'Self-Hosted-Services'] }, + { key: 'security', items: ['CrowdSec', 'Suricata', 'Mailcow'] } + ] +}; + +const categoryIconMap: Record = { + frontend: 'Globe', + backend: 'Server', + tools: 'Wrench', + security: 'Shield' +}; + +const categoryNames: Record> = { + 'en-US': { + frontend: 'Frontend & Mobile', + backend: 'Backend & DevOps', + tools: 'Tools & Automation', + security: 'Security & Admin' + }, + 'de-DE': { + frontend: 'Frontend & Mobile', + backend: 'Backend & DevOps', + tools: 'Tools & Automation', + security: 'Security & Admin' + } +}; + +export async function getTechStack(locale: string): Promise { + const directusLocale = toDirectusLocale(locale); + + try { + if (process.env.NODE_ENV === 'development') { + console.log('[getTechStack] Fetching with locale:', directusLocale); + } + + // Fetch categories via GraphQL with translations + const categoriesQuery = ` + query { + tech_stack_categories( + filter: { status: { _eq: "published" } } + sort: "sort" + ) { + id + key + icon + sort + translations(filter: { languages_code: { code: { _eq: "${directusLocale}" } } }) { + name + } + } + } + `; + + const categoriesResult = await directusRequest( + '', + { body: { query: categoriesQuery } } + ); + + const categories = (categoriesResult as any)?.tech_stack_categories; + + if (!categories || categories.length === 0) { + if (process.env.NODE_ENV === 'development') { + console.log('[getTechStack] No categories found, using fallback'); + } + return null; + } + + if (process.env.NODE_ENV === 'development') { + console.log('[getTechStack] Found categories:', categories.length); + } + + // Fetch items via REST API (since GraphQL category relationship returns null) + const itemsResponse = await fetch( + `${DIRECTUS_URL}/items/tech_stack_items?fields=id,name,category,url,icon_url,sort&sort=sort&limit=100`, + { + headers: { + Authorization: `Bearer ${DIRECTUS_TOKEN}`, + }, + } + ); + + const itemsData = await itemsResponse.json(); + const allItems = itemsData?.data || []; + + if (process.env.NODE_ENV === 'development') { + console.log('[getTechStack] Fetched items:', allItems.length); + } + + // Group items by category + const categoriesWithItems = categories.map((cat: any) => { + const categoryItems = allItems.filter((item: any) => + item.category === cat.id || item.category === parseInt(cat.id) + ); + + // Fallback: if no items linked by category, use fallback data + let itemsToUse = categoryItems; + if (itemsToUse.length === 0) { + const fallbackData = fallbackTechStackData[directusLocale]; + const categoryFallback = fallbackData?.find(f => f.key === cat.key); + if (categoryFallback) { + itemsToUse = categoryFallback.items.map((name, idx) => ({ + id: `fallback-${cat.key}-${idx}`, + name: name, + url: undefined, + icon_url: undefined, + sort: idx + 1 + })); + } + } + + return { + id: cat.id, + key: cat.key, + icon: cat.icon, + sort: cat.sort, + name: cat.translations?.[0]?.name || categoryNames[directusLocale]?.[cat.key] || cat.key, + items: itemsToUse.map((item: any) => ({ + id: item.id, + name: item.name, + url: item.url, + icon_url: item.icon_url, + sort: item.sort + })) + }; + }); + + return categoriesWithItems; + } catch (error) { + console.error(`Failed to fetch tech stack (${locale}):`, error); + return null; + } +} + +// Hobbies Types +export interface Hobby { + id: string; + key: string; + icon: string; + title: string; // Translated title + description?: string; // Translated description +} + +/** + * Get Hobbies from Directus with translations + */ +export async function getHobbies(locale: string): Promise { + const directusLocale = toDirectusLocale(locale); + + const query = ` + query { + hobbies( + filter: { status: { _eq: "published" } } + sort: "sort" + ) { + id + key + icon + translations(filter: { languages_code: { code: { _eq: "${directusLocale}" } } }) { + title + description + } + } + } + `; + + try { + const result = await directusRequest( + '', + { body: { query } } + ); + + const hobbies = (result as any)?.hobbies; + if (!hobbies || hobbies.length === 0) { + return null; + } + + return hobbies.map((hobby: any) => ({ + id: hobby.id, + key: hobby.key, + icon: hobby.icon, + title: hobby.translations?.[0]?.title || hobby.key, + description: hobby.translations?.[0]?.description + })); + } catch (error) { + console.error(`Failed to fetch hobbies (${locale}):`, error); + return null; + } +} + +// Projects Types +export interface Project { + id: string; + slug: string; + title: string; + description: string; + content?: string; + category?: string; + difficulty?: string; + tags: string[]; + technologies: string[]; + challenges?: string; + lessons_learned?: string; + future_improvements?: string; + github_url?: string; + live_url?: string; + image_url?: string; + demo_video_url?: string; + performance_metrics?: string; + screenshots?: string[]; + featured: boolean; + published: boolean; + created_at?: string; + updated_at?: string; +} + +/** + * Get Projects from Directus with translations + * + * @param locale - Language code (en or de) + * @param options - Filter options + * @returns Array of projects or null + */ +export async function getProjects( + locale: string, + options?: { + featured?: boolean; + published?: boolean; + category?: string; + difficulty?: string; + search?: string; + limit?: number; + } +): Promise { + const directusLocale = toDirectusLocale(locale); + + // Build filters + const filters = ['status: { _eq: "published" }']; + + if (options?.featured !== undefined) { + filters.push(`featured: { _eq: ${options.featured} }`); + } + + if (options?.published !== undefined) { + filters.push(`published: { _eq: ${options.published} }`); + } + + if (options?.category) { + filters.push(`category: { _eq: "${options.category}" }`); + } + + if (options?.difficulty) { + filters.push(`difficulty: { _eq: "${options.difficulty}" }`); + } + + if (options?.search) { + // Search in translations title and description + filters.push(`_or: [ + { translations: { title: { _icontains: "${options.search}" } } }, + { translations: { description: { _icontains: "${options.search}" } } } + ]`); + } + + const filterString = filters.length > 0 ? `filter: { _and: [{ ${filters.join(' }, { ')} }] }` : ''; + const limitString = options?.limit ? `limit: ${options.limit}` : ''; + + const query = ` + query { + projects( + ${filterString} + ${limitString} + sort: ["-featured", "-created_at"] + ) { + id + slug + category + difficulty + tags + technologies + github_url + live_url + image_url + demo_video_url + performance_metrics + screenshots + featured + published + date_created + date_updated + translations { + title + description + content + challenges + lessons_learned + future_improvements + languages_code { code } + } + } + } + `; + + try { + const result = await directusRequest( + '', + { body: { query } } + ); + + const projects = (result as any)?.projects; + if (!projects || projects.length === 0) { + return null; + } + + return projects.map((proj: any) => { + const trans = + proj.translations?.find((t: any) => t.languages_code?.code === directusLocale) || + proj.translations?.[0] || + {}; + return { + id: proj.id, + slug: proj.slug, + title: trans.title || proj.slug, + description: trans.description || '', + content: trans.content, + category: proj.category, + difficulty: proj.difficulty, + tags: proj.tags || [], + technologies: proj.technologies || [], + challenges: trans.challenges, + lessons_learned: trans.lessons_learned, + future_improvements: trans.future_improvements, + github_url: proj.github_url, + live_url: proj.live_url, + image_url: proj.image_url, + demo_video_url: proj.demo_video_url, + performance_metrics: proj.performance_metrics, + screenshots: proj.screenshots || [], + featured: proj.featured || false, + published: proj.published || false, + created_at: proj.date_created, + updated_at: proj.date_updated + }; + }); + } catch (error) { + console.error(`Failed to fetch projects (${locale}):`, error); + return null; + } +} diff --git a/package-lock.json b/package-lock.json index 4154b2b..0bdc6d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14088,17 +14088,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.18", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", - "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -14837,7 +14826,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/prisma/migrations/add_custom_activities.sql b/prisma/migrations/add_custom_activities.sql new file mode 100644 index 0000000..b0dbf9e --- /dev/null +++ b/prisma/migrations/add_custom_activities.sql @@ -0,0 +1,27 @@ +-- Add JSON field for dynamic custom activities +-- This allows n8n to add/remove activity types without schema changes + +ALTER TABLE activity_status +ADD COLUMN IF NOT EXISTS custom_activities JSONB DEFAULT '{}'; + +-- Comment explaining the structure +COMMENT ON COLUMN activity_status.custom_activities IS +'Dynamic activity types added via n8n. Example: +{ + "reading": { + "enabled": true, + "book_title": "Clean Code", + "author": "Robert C. Martin", + "progress": 65, + "platform": "hardcover" + }, + "working_out": { + "enabled": true, + "activity": "Running", + "duration": 45, + "calories": 350 + } +}'; + +-- Create index for faster JSONB queries +CREATE INDEX IF NOT EXISTS idx_activity_custom_activities ON activity_status USING gin(custom_activities); diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..30d41dd --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,184 @@ +# Directus Setup & Migration Scripts + +Automatische Scripts zum Erstellen und Befรผllen aller Collections in Directus. + +## ๐Ÿ“ฆ Verfรผgbare Scripts + +### 1. Tech Stack (โœ… Bereits ausgefรผhrt) + +```bash +# Collections erstellen +node scripts/setup-directus-collections.js + +# Daten migrieren +node scripts/migrate-tech-stack-to-directus.js +``` + +**Was erstellt wird:** +- `tech_stack_categories` (4 Kategorien: Frontend, Backend, Tools, Security) +- `tech_stack_items` (~16 Items) +- Translations (DE + EN) + +--- + +### 2. Projects (๐Ÿ”ฅ Neu) + +```bash +# Collections erstellen +node scripts/setup-directus-projects.js + +# Daten aus PostgreSQL migrieren +node scripts/migrate-projects-to-directus.js +``` + +**Was erstellt wird:** +- `projects` Collection mit 30+ Feldern: + - Basics: slug, title, description, content + - Meta: category, difficulty, tags, technologies + - Links: github, live, image_url, demo_video + - Details: challenges, lessons_learned, future_improvements + - Performance: lighthouse scores, bundle sizes +- `projects_translations` fรผr mehrsprachige Inhalte +- Migriert ALLE Projekte aus PostgreSQL + +**Hinweis:** Lรคuft nur wenn Projects Collection noch nicht existiert! + +--- + +### 3. Hobbies (๐ŸŽฎ Neu) + +```bash +# Collections erstellen +node scripts/setup-directus-hobbies.js + +# Daten migrieren +node scripts/migrate-hobbies-to-directus.js +``` + +**Was erstellt wird:** +- `hobbies` Collection (4 Hobbies: Self-Hosting, Gaming, Game Servers, Jogging) +- Translations (DE + EN) + +--- + +## ๐Ÿš€ Komplette Migration (alles auf einmal) + +```bash +# 1. Tech Stack +node scripts/setup-directus-collections.js +node scripts/migrate-tech-stack-to-directus.js + +# 2. Projects +node scripts/setup-directus-projects.js +node scripts/migrate-projects-to-directus.js + +# 3. Hobbies +node scripts/setup-directus-hobbies.js +node scripts/migrate-hobbies-to-directus.js +``` + +**Oder als One-Liner:** + +```bash +node scripts/setup-directus-collections.js && \ +node scripts/migrate-tech-stack-to-directus.js && \ +node scripts/setup-directus-projects.js && \ +node scripts/migrate-projects-to-directus.js && \ +node scripts/setup-directus-hobbies.js && \ +node scripts/migrate-hobbies-to-directus.js +``` + +--- + +## โš™๏ธ Voraussetzungen + +```bash +# Dependencies installieren +npm install node-fetch@2 dotenv @prisma/client +``` + +**In .env:** +```env +DIRECTUS_URL=https://cms.dk0.dev +DIRECTUS_STATIC_TOKEN=your_token_here +DATABASE_URL=postgresql://... +``` + +--- + +## ๐Ÿ“Š Nach der Migration + +### Directus Admin Panel: + +- **Tech Stack:** https://cms.dk0.dev/admin/content/tech_stack_categories +- **Projects:** https://cms.dk0.dev/admin/content/projects +- **Hobbies:** https://cms.dk0.dev/admin/content/hobbies + +### API Endpoints (automatisch verfรผgbar): + +```bash +# Tech Stack +GET https://cms.dk0.dev/items/tech_stack_categories?fields=*,translations.*,items.* + +# Projects +GET https://cms.dk0.dev/items/projects?fields=*,translations.* + +# Hobbies +GET https://cms.dk0.dev/items/hobbies?fields=*,translations.* +``` + +--- + +## ๐Ÿ”„ Code-Updates nach Migration + +### 1. lib/directus.ts erweitern + +```typescript +// Bereits implementiert: +export async function getTechStack(locale: string) + +// TODO: +export async function getProjects(locale: string) +export async function getHobbies(locale: string) +``` + +### 2. Components anpassen + +- `About.tsx` - โœ… Bereits updated fรผr Tech Stack +- `About.tsx` - TODO: Hobbies aus Directus laden +- `Projects.tsx` - TODO: Projects aus Directus laden + +--- + +## ๐Ÿ› Troubleshooting + +### Error: "Collection already exists" +โ†’ Normal! Script รผberspringt bereits existierende Collections automatisch. + +### Error: "DIRECTUS_STATIC_TOKEN not found" +โ†’ Stelle sicher dass `.env` vorhanden ist und `require('dotenv').config()` funktioniert. + +### Error: "Unauthorized" oder HTTP 403 +โ†’ รœberprรผfe Token-Rechte in Directus Admin โ†’ Settings โ†’ Access Tokens + +### Migration findet keine Projekte +โ†’ Stelle sicher dass PostgreSQL lรคuft und `DATABASE_URL` korrekt ist. + +--- + +## ๐Ÿ“ Nรคchste Schritte + +1. โœ… **Alle Scripts ausfรผhren** (siehe oben) +2. โœ… **Verifizieren** in Directus Admin Panel +3. โญ๏ธ **Code updaten** (lib/directus.ts + Components) +4. โญ๏ธ **Testen** auf localhost +5. โญ๏ธ **Deployen** auf Production + +--- + +## ๐Ÿ’ก Pro-Tipps + +- **Backups:** Exportiere Schema regelmรครŸig via Directus UI +- **Version Control:** Committe Schema-Files ins Git +- **Incremental:** Scripts kรถnnen mehrfach ausgefรผhrt werden (idempotent) +- **Rollback:** Lรถsche Collections in Directus UI falls nรถtig diff --git a/scripts/add-de-project-translations.js b/scripts/add-de-project-translations.js new file mode 100644 index 0000000..14d88ee --- /dev/null +++ b/scripts/add-de-project-translations.js @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * Add German translations for projects in Directus (if missing). + * - Reads projects from Directus REST + * - If no de-DE translation exists, creates one using provided fallback strings + */ +const fetch = require('node-fetch'); +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('โŒ DIRECTUS_STATIC_TOKEN missing'); + process.exit(1); +} + +const deFallback = { + 'kernel-panic-404-interactive-terminal': { + title: 'Kernel Panic 404 โ€“ Interaktives Terminal', + description: 'Ein spielerisches 404-Erlebnis als interaktives Terminal mit Retro-Feeling.', + }, + 'machine-learning-model-api': { + title: 'Machine-Learning-Modell API', + description: 'Produktionsreife API fรผr ML-Modelle mit klarer Dokumentation und Monitoring.', + }, + 'weather-forecast-app': { + title: 'Wettervorhersage App', + description: 'Schnelle Wetter-UI mit klaren Prognosen und responsivem Design.', + }, + 'task-management-dashboard': { + title: 'Task-Management Dashboard', + description: 'Kanban-Board mit Kollaboration, Filtern und Realtime-Updates.', + }, + 'real-time-chat-application': { + title: 'Echtzeit Chat App', + description: 'Websocket-basierter Chat mit Typing-Status, Presence und Uploads.', + }, + 'e-commerce-platform-api': { + title: 'E-Commerce Plattform API', + description: 'Headless Commerce API mit Checkout, Inventory und Webhooks.', + }, + 'portfolio-website-modern-developer-showcase': { + title: 'Portfolio Website โ€“ Moderner Entwicklerauftritt', + description: 'Schnelle, รผbersichtliche Portfolio-Seite mit Projekten und Aktivitรคten.', + }, + clarity: { + title: 'Clarity โ€“ Dyslexie-Unterstรผtzung', + description: 'Mobile App mit OpenDyslexic Schrift und AI-Textvereinfachung.', + }, +}; + +async function directus(path, options = {}) { + const res = await fetch(`${DIRECTUS_URL}/${path}`, { + ...options, + headers: { + Authorization: `Bearer ${DIRECTUS_TOKEN}`, + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`HTTP ${res.status} ${path}: ${text}`); + } + return res.json(); +} + +async function main() { + console.log('๐Ÿ” Fetching projects from Directus...'); + const { data: projects } = await directus( + 'items/projects?fields=id,slug,translations.languages_code,translations.title,translations.description' + ); + + let created = 0; + for (const proj of projects) { + const hasDe = (proj.translations || []).some((t) => t.languages_code === 'de-DE'); + if (hasDe) continue; + + const fallback = deFallback[proj.slug] || {}; + const en = (proj.translations || [])[0] || {}; + const payload = { + projects_id: proj.id, + languages_code: 'de-DE', + title: fallback.title || en.title || proj.slug, + description: fallback.description || en.description || en.title || proj.slug, + content: en.content || null, + meta_description: null, + keywords: null, + }; + + await directus('items/projects_translations', { + method: 'POST', + body: JSON.stringify(payload), + }); + created += 1; + console.log(` โž• Added de-DE translation for ${proj.slug}`); + } + + console.log(`โœ… Done. Added ${created} de-DE translations.`); +} + +main().catch((err) => { + console.error('โŒ Failed:', err.message); + process.exit(1); +}); diff --git a/scripts/migrate-content-pages-to-directus.js b/scripts/migrate-content-pages-to-directus.js new file mode 100644 index 0000000..55de48e --- /dev/null +++ b/scripts/migrate-content-pages-to-directus.js @@ -0,0 +1,123 @@ +#!/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(); diff --git a/scripts/migrate-hobbies-to-directus.js b/scripts/migrate-hobbies-to-directus.js new file mode 100644 index 0000000..0548fbf --- /dev/null +++ b/scripts/migrate-hobbies-to-directus.js @@ -0,0 +1,185 @@ +#!/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(); diff --git a/scripts/migrate-projects-to-directus.js b/scripts/migrate-projects-to-directus.js new file mode 100644 index 0000000..20bb8c6 --- /dev/null +++ b/scripts/migrate-projects-to-directus.js @@ -0,0 +1,225 @@ +#!/usr/bin/env node +/** + * Migrate Projects from PostgreSQL to Directus + * + * Migriert ALLE bestehenden Projects aus deiner PostgreSQL Datenbank nach Directus + * inklusive aller Felder und Translations. + * + * Usage: + * node scripts/migrate-projects-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(); + +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 migrateProjects() { + console.log('\n๐Ÿ“ฆ Migrating Projects from PostgreSQL to Directus...\n'); + + // Load all published projects from PostgreSQL + const projects = await prisma.project.findMany({ + where: { published: true }, + include: { + translations: true + }, + orderBy: { createdAt: 'desc' } + }); + + console.log(`Found ${projects.length} published projects in PostgreSQL\n`); + + let successCount = 0; + let errorCount = 0; + + for (const project of projects) { + console.log(`\n๐Ÿ“ Migrating: ${project.title}`); + + try { + // 1. Create project in Directus + console.log(' Creating project...'); + const projectData = { + slug: project.slug, + status: 'published', + featured: project.featured, + category: project.category, + difficulty: project.difficulty, + date: project.date, + time_to_complete: project.timeToComplete, + github: project.github, + live: project.live, + image_url: project.imageUrl, + demo_video: project.demoVideo, + color_scheme: project.colorScheme, + accessibility: project.accessibility, + tags: project.tags, + technologies: project.technologies, + challenges: project.challenges, + lessons_learned: project.lessonsLearned, + future_improvements: project.futureImprovements, + screenshots: project.screenshots, + performance: project.performance + }; + + const { data: createdProject } = await directusRequest( + 'items/projects', + 'POST', + projectData + ); + + console.log(` โœ… Project created with ID: ${createdProject.id}`); + + // 2. Create Translations + console.log(' Creating translations...'); + + // Default locale translation (from main project fields) + await directusRequest( + 'items/projects_translations', + 'POST', + { + projects_id: createdProject.id, + languages_code: project.defaultLocale === 'en' ? 'en-US' : 'de-DE', + title: project.title, + description: project.description, + content: project.content, + meta_description: project.metaDescription, + keywords: project.keywords + } + ); + + // Additional translations from ProjectTranslation table + for (const translation of project.translations) { + // Skip if it's the same as default locale (already created above) + if (translation.locale === project.defaultLocale) { + continue; + } + + await directusRequest( + 'items/projects_translations', + 'POST', + { + projects_id: createdProject.id, + languages_code: translation.locale === 'en' ? 'en-US' : 'de-DE', + title: translation.title, + description: translation.description, + content: translation.content ? JSON.stringify(translation.content) : null, + meta_description: translation.metaDescription, + keywords: translation.keywords + } + ); + } + + console.log(` โœ… Translations created (${project.translations.length + 1} locales)`); + successCount++; + + } catch (error) { + console.error(` โŒ Error migrating ${project.title}:`, error.message); + errorCount++; + } + } + + console.log('\nโ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—'); + console.log(`โ•‘ Migration Complete! โ•‘`); + console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); + console.log(`โœ… Successfully migrated: ${successCount} projects`); + console.log(`โŒ Failed: ${errorCount} projects\n`); + + if (successCount > 0) { + console.log('๐ŸŽ‰ Projects are now in Directus!\n'); + console.log('Next steps:'); + console.log(' 1. Visit: https://cms.dk0.dev/admin/content/projects'); + console.log(' 2. Verify all projects are visible'); + console.log(' 3. Update lib/directus.ts with getProjects() function'); + console.log(' 4. Update components to use Directus API\n'); + } +} + +async function verifyMigration() { + console.log('\n๐Ÿ” Verifying Migration...\n'); + + try { + const { data: projects } = await directusRequest( + 'items/projects?fields=slug,status,translations.title,translations.languages_code' + ); + + console.log(`โœ… Found ${projects.length} projects in Directus:`); + projects.slice(0, 5).forEach(p => { + const enTitle = p.translations?.find(t => t.languages_code === 'en-US')?.title; + console.log(` - ${p.slug}: "${enTitle || 'No title'}"`); + }); + + if (projects.length > 5) { + console.log(` ... and ${projects.length - 5} more`); + } + + } catch (error) { + console.error('โŒ Verification failed:', error.message); + } +} + +async function main() { + console.log('\nโ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—'); + console.log('โ•‘ Project Migration: PostgreSQL โ†’ Directus โ•‘'); + console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); + + try { + // Test database connection first + console.log('๐Ÿ” Testing database connection...'); + await prisma.$connect(); + console.log('โœ… Database connected\n'); + + await migrateProjects(); + await verifyMigration(); + } catch (error) { + if (error.message?.includes("Can't reach database")) { + console.error('\nโŒ PostgreSQL ist nicht erreichbar!'); + console.error('\n๐Ÿ’ก Lรถsungen:'); + console.error(' 1. Starte PostgreSQL: npm run dev'); + console.error(' 2. Oder nutze Docker: docker-compose up -d postgres'); + console.error(' 3. Oder skip diesen Schritt - Projects Collection existiert bereits in Directus\n'); + console.error('Du kannst Projects spรคter manuell in Directus erstellen oder die Migration erneut ausfรผhren.\n'); + process.exit(0); // Graceful exit + } + console.error('\nโŒ Migration failed:', error); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/scripts/migrate-tech-stack-to-directus.js b/scripts/migrate-tech-stack-to-directus.js new file mode 100644 index 0000000..edc77f9 --- /dev/null +++ b/scripts/migrate-tech-stack-to-directus.js @@ -0,0 +1,240 @@ +#!/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); + } +})(); diff --git a/scripts/n8n-workflow-code-updated.js b/scripts/n8n-workflow-code-updated.js new file mode 100644 index 0000000..c1ed6a4 --- /dev/null +++ b/scripts/n8n-workflow-code-updated.js @@ -0,0 +1,197 @@ +// -------------------------------------------------------- +// DATEN AUS DEN VORHERIGEN NODES HOLEN +// -------------------------------------------------------- + +// 1. Spotify Node +let spotifyData = null; +try { + spotifyData = $('Spotify').first().json; +} catch (e) {} + +// 2. Lanyard Node (Discord) +let lanyardData = null; +try { + lanyardData = $('Lanyard').first().json.data; +} catch (e) {} + +// 3. Wakapi Summary (Tages-Statistik) +let wakapiStats = null; +try { + const wRaw = $('Wakapi').first().json; + // Manchmal ist es direkt im Root, manchmal unter data + wakapiStats = wRaw.grand_total ? wRaw : (wRaw.data ? wRaw.data : null); +} catch (e) {} + +// 4. Wakapi Heartbeats (Live Check) +let heartbeatsList = []; +try { + const response = $('WakapiLast').last().json; + if (response.data && Array.isArray(response.data)) { + heartbeatsList = response.data; + } +} catch (e) {} + +// 5. Hardcover Reading (Neu!) +let hardcoverData = null; +try { + // Falls du einen Node "Hardcover" hast + hardcoverData = $('Hardcover').first().json; +} catch (e) {} + + +// -------------------------------------------------------- +// LOGIK & FORMATIERUNG +// -------------------------------------------------------- + +// --- A. SPOTIFY / MUSIC --- +let music = null; + +if (spotifyData && spotifyData.item && spotifyData.is_playing) { + music = { + isPlaying: true, + track: spotifyData.item.name, + artist: spotifyData.item.artists.map(a => a.name).join(', '), + album: spotifyData.item.album.name, + albumArt: spotifyData.item.album.images[0]?.url, + url: spotifyData.item.external_urls.spotify + }; +} else if (lanyardData?.listening_to_spotify && lanyardData.spotify) { + music = { + isPlaying: true, + track: lanyardData.spotify.song, + artist: lanyardData.spotify.artist.replace(/;/g, ", "), + album: lanyardData.spotify.album, + albumArt: lanyardData.spotify.album_art_url, + url: `https://open.spotify.com/track/${lanyardData.spotify.track_id}` + }; +} + +// --- B. GAMING & STATUS --- +let gaming = null; +let status = { + text: lanyardData?.discord_status || "offline", + color: 'gray' +}; + +// Farben mapping +if (status.text === 'online') status.color = 'green'; +if (status.text === 'idle') status.color = 'yellow'; +if (status.text === 'dnd') status.color = 'red'; + +if (lanyardData?.activities) { + lanyardData.activities.forEach(act => { + // Type 0 = Game (Spotify ignorieren) + if (act.type === 0 && act.name !== "Spotify") { + let image = null; + if (act.assets?.large_image) { + if (act.assets.large_image.startsWith("mp:external")) { + image = act.assets.large_image.replace(/mp:external\/([^\/]*)\/(https?)\/(^\/]*)\/(.*)/,"$2://$3/$4"); + } else { + image = `https://cdn.discordapp.com/app-assets/${act.application_id}/${act.assets.large_image}.png`; + } + } + gaming = { + isPlaying: true, + name: act.name, + details: act.details, + state: act.state, + image: image + }; + } + }); +} + + +// --- C. CODING (Wakapi Logic) --- +let coding = null; + +// 1. Basis-Stats von heute (Fallback) +if (wakapiStats && wakapiStats.grand_total) { + coding = { + isActive: false, + stats: { + time: wakapiStats.grand_total.text, + topLang: wakapiStats.languages?.[0]?.name || "Code", + topProject: wakapiStats.projects?.[0]?.name || "Project" + } + }; +} + +// 2. Live Check via Heartbeats +if (heartbeatsList.length > 0) { + const latestBeat = heartbeatsList[heartbeatsList.length - 1]; + + if (latestBeat && latestBeat.time) { + const beatTime = new Date(latestBeat.time * 1000).getTime(); + const now = new Date().getTime(); + const diffMinutes = (now - beatTime) / 1000 / 60; + + // Wenn jรผnger als 15 Minuten -> AKTIV + if (diffMinutes < 15) { + if (!coding) coding = { stats: { time: "Just started" } }; + + coding.isActive = true; + coding.project = latestBeat.project || coding.stats?.topProject; + + if (latestBeat.entity) { + const parts = latestBeat.entity.split(/[/\\]/); + coding.file = parts[parts.length - 1]; + } + + coding.language = latestBeat.language; + } + } +} + +// --- D. CUSTOM ACTIVITIES (Komplett dynamisch!) --- +// Hier kannst du beliebige Activities hinzufรผgen ohne Website Code zu รคndern +let customActivities = {}; + +// Beispiel: Reading Activity (Hardcover Integration) +if (hardcoverData && hardcoverData.user_book) { + const book = hardcoverData.user_book; + customActivities.reading = { + enabled: true, + title: book.book?.title, + author: book.book?.contributions?.[0]?.author?.name, + progress: book.progress_pages && book.book?.pages + ? Math.round((book.progress_pages / book.book.pages) * 100) + : undefined, + coverUrl: book.book?.image_url + }; +} + +// Beispiel: Manuell gesetzt via separatem Webhook +// Du kannst einen Webhook erstellen der customActivities setzt: +// POST /webhook/set-custom-activity +// { +// "type": "working_out", +// "data": { +// "enabled": true, +// "activity": "Running", +// "duration_minutes": 45, +// "distance_km": 7.2, +// "calories": 350 +// } +// } +// Dann hier einfach: customActivities.working_out = $('SetCustomActivity').first().json.data; + +// WICHTIG: Du kannst auch mehrere Activities gleichzeitig haben! +// customActivities.learning = { enabled: true, course: "Docker", platform: "Udemy", progress: 67 }; +// customActivities.streaming = { enabled: true, platform: "Twitch", viewers: 42 }; +// etc. + + +// -------------------------------------------------------- +// OUTPUT +// -------------------------------------------------------- +return { + json: { + status, + music, + gaming, + coding, + customActivities, // NEU! Komplett dynamisch + timestamp: new Date().toISOString() + } +}; diff --git a/scripts/setup-directus-collections.js b/scripts/setup-directus-collections.js new file mode 100644 index 0000000..8bcebcf --- /dev/null +++ b/scripts/setup-directus-collections.js @@ -0,0 +1,435 @@ +#!/usr/bin/env node +/** + * Directus Schema Setup via REST API + * + * Erstellt automatisch alle benรถtigten Collections, Fields und Relations + * fรผr Tech Stack in Directus via REST API. + * + * Usage: + * npm install node-fetch@2 + * node scripts/setup-directus-collections.js + */ + +const fetch = require('node-fetch'); +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); +} + +console.log(`๐Ÿ”— Connecting to: ${DIRECTUS_URL}`); + +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); + const text = await response.text(); + + if (!response.ok) { + // Ignore "already exists" errors + if (text.includes('already exists') || text.includes('RECORD_NOT_UNIQUE')) { + console.log(` โš ๏ธ Already exists, skipping...`); + return { data: null, alreadyExists: true }; + } + throw new Error(`HTTP ${response.status}: ${text}`); + } + + return text ? JSON.parse(text) : {}; + } catch (error) { + console.error(`โŒ Error calling ${method} ${endpoint}:`, error.message); + throw error; + } +} + +async function ensureLanguages() { + console.log('\n๐ŸŒ Setting up Languages...'); + + try { + // Check if languages collection exists + const { data: existing } = await directusRequest('items/languages'); + + if (!existing) { + console.log(' Creating languages collection...'); + await directusRequest('collections', 'POST', { + collection: 'languages', + meta: { + icon: 'translate', + translations: [ + { language: 'en-US', translation: 'Languages' } + ] + }, + schema: { name: 'languages' } + }); + } + + // Add en-US + await directusRequest('items/languages', 'POST', { + code: 'en-US', + name: 'English (United States)' + }); + + // Add de-DE + await directusRequest('items/languages', 'POST', { + code: 'de-DE', + name: 'German (Germany)' + }); + + console.log(' โœ… Languages ready (en-US, de-DE)'); + } catch (error) { + console.log(' โš ๏ธ Languages might already exist'); + } +} + +async function createTechStackCollections() { + console.log('\n๐Ÿ“ฆ Creating Tech Stack Collections...\n'); + + // 1. Create tech_stack_categories collection + console.log('1๏ธโƒฃ Creating tech_stack_categories...'); + try { + await directusRequest('collections', 'POST', { + collection: 'tech_stack_categories', + meta: { + icon: 'layers', + display_template: '{{translations.name}}', + hidden: false, + singleton: false, + translations: [ + { language: 'en-US', translation: 'Tech Stack Categories' }, + { language: 'de-DE', translation: 'Tech Stack Kategorien' } + ], + sort_field: 'sort' + }, + schema: { + name: 'tech_stack_categories' + } + }); + console.log(' โœ… Collection created'); + } catch (error) { + console.log(' โš ๏ธ Collection might already exist'); + } + + // 2. Create tech_stack_categories_translations collection + console.log('\n2๏ธโƒฃ Creating tech_stack_categories_translations...'); + try { + await directusRequest('collections', 'POST', { + collection: 'tech_stack_categories_translations', + meta: { + hidden: true, + icon: 'import_export' + }, + schema: { + name: 'tech_stack_categories_translations' + } + }); + console.log(' โœ… Collection created'); + } catch (error) { + console.log(' โš ๏ธ Collection might already exist'); + } + + // 3. Create tech_stack_items collection + console.log('\n3๏ธโƒฃ Creating tech_stack_items...'); + try { + await directusRequest('collections', 'POST', { + collection: 'tech_stack_items', + meta: { + icon: 'code', + display_template: '{{name}}', + hidden: false, + singleton: false, + sort_field: 'sort' + }, + schema: { + name: 'tech_stack_items' + } + }); + console.log(' โœ… Collection created'); + } catch (error) { + console.log(' โš ๏ธ Collection might already exist'); + } +} + +async function createFields() { + console.log('\n๐Ÿ”ง Creating Fields...\n'); + + // Fields for tech_stack_categories + console.log('1๏ธโƒฃ Fields for tech_stack_categories:'); + + const categoryFields = [ + { + field: 'status', + type: 'string', + meta: { + interface: 'select-dropdown', + options: { + choices: [ + { text: 'Published', value: 'published' }, + { text: 'Draft', value: 'draft' }, + { text: 'Archived', value: 'archived' } + ] + } + }, + schema: { default_value: 'draft', is_nullable: false } + }, + { + field: 'sort', + type: 'integer', + meta: { interface: 'input', hidden: true }, + schema: {} + }, + { + field: 'key', + type: 'string', + meta: { + interface: 'input', + note: 'Unique identifier (e.g. frontend, backend)' + }, + schema: { is_unique: true, is_nullable: false } + }, + { + field: 'icon', + type: 'string', + meta: { + interface: 'select-dropdown', + options: { + choices: [ + { text: 'Globe', value: 'Globe' }, + { text: 'Server', value: 'Server' }, + { text: 'Wrench', value: 'Wrench' }, + { text: 'Shield', value: 'Shield' } + ] + } + }, + schema: { default_value: 'Code' } + }, + { + field: 'date_created', + type: 'timestamp', + meta: { special: ['date-created'], interface: 'datetime', readonly: true, hidden: true }, + schema: {} + }, + { + field: 'date_updated', + type: 'timestamp', + meta: { special: ['date-updated'], interface: 'datetime', readonly: true, hidden: true }, + schema: {} + }, + { + field: 'translations', + type: 'alias', + meta: { + special: ['translations'], + interface: 'translations', + options: { languageField: 'languages_code' } + } + } + ]; + + for (const field of categoryFields) { + try { + await directusRequest('fields/tech_stack_categories', 'POST', field); + console.log(` โœ… ${field.field}`); + } catch (error) { + console.log(` โš ๏ธ ${field.field} (might already exist)`); + } + } + + // Fields for tech_stack_categories_translations + console.log('\n2๏ธโƒฃ Fields for tech_stack_categories_translations:'); + + const translationFields = [ + { + field: 'tech_stack_categories_id', + type: 'uuid', + meta: { hidden: true }, + schema: {} + }, + { + field: 'languages_code', + type: 'string', + meta: { interface: 'select-dropdown-m2o' }, + schema: {} + }, + { + field: 'name', + type: 'string', + meta: { + interface: 'input', + note: 'Translated category name' + }, + schema: {} + } + ]; + + for (const field of translationFields) { + try { + await directusRequest('fields/tech_stack_categories_translations', 'POST', field); + console.log(` โœ… ${field.field}`); + } catch (error) { + console.log(` โš ๏ธ ${field.field} (might already exist)`); + } + } + + // Fields for tech_stack_items + console.log('\n3๏ธโƒฃ Fields for tech_stack_items:'); + + const itemFields = [ + { + field: 'sort', + type: 'integer', + meta: { interface: 'input', hidden: true }, + schema: {} + }, + { + field: 'category', + type: 'uuid', + meta: { interface: 'select-dropdown-m2o' }, + schema: {} + }, + { + field: 'name', + type: 'string', + meta: { + interface: 'input', + note: 'Technology name (e.g. Next.js, Docker)' + }, + schema: { is_nullable: false } + }, + { + field: 'url', + type: 'string', + meta: { + interface: 'input', + note: 'Official website (optional)' + }, + schema: {} + }, + { + field: 'icon_url', + type: 'string', + meta: { + interface: 'input', + note: 'Custom icon URL (optional)' + }, + schema: {} + }, + { + field: 'date_created', + type: 'timestamp', + meta: { special: ['date-created'], interface: 'datetime', readonly: true, hidden: true }, + schema: {} + }, + { + field: 'date_updated', + type: 'timestamp', + meta: { special: ['date-updated'], interface: 'datetime', readonly: true, hidden: true }, + schema: {} + } + ]; + + for (const field of itemFields) { + try { + await directusRequest('fields/tech_stack_items', 'POST', field); + console.log(` โœ… ${field.field}`); + } catch (error) { + console.log(` โš ๏ธ ${field.field} (might already exist)`); + } + } +} + +async function createRelations() { + console.log('\n๐Ÿ”— Creating Relations...\n'); + + const relations = [ + { + collection: 'tech_stack_categories_translations', + field: 'tech_stack_categories_id', + related_collection: 'tech_stack_categories', + meta: { + one_field: 'translations', + sort_field: null, + one_deselect_action: 'delete' + }, + schema: { on_delete: 'CASCADE' } + }, + { + collection: 'tech_stack_categories_translations', + field: 'languages_code', + related_collection: 'languages', + meta: { + one_field: null, + sort_field: null, + one_deselect_action: 'nullify' + }, + schema: { on_delete: 'SET NULL' } + }, + { + collection: 'tech_stack_items', + field: 'category', + related_collection: 'tech_stack_categories', + meta: { + one_field: 'items', + sort_field: 'sort', + one_deselect_action: 'nullify' + }, + schema: { on_delete: 'SET NULL' } + } + ]; + + for (let i = 0; i < relations.length; i++) { + try { + await directusRequest('relations', 'POST', relations[i]); + console.log(` โœ… Relation ${i + 1}/${relations.length}`); + } catch (error) { + console.log(` โš ๏ธ Relation ${i + 1}/${relations.length} (might already exist)`); + } + } +} + +async function main() { + console.log('\nโ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—'); + console.log('โ•‘ Directus Tech Stack Setup via API โ•‘'); + console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); + + try { + await ensureLanguages(); + await createTechStackCollections(); + await createFields(); + await createRelations(); + + console.log('\nโ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—'); + console.log('โ•‘ โœ… Setup Complete! โ•‘'); + console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); + + console.log('๐ŸŽ‰ Tech Stack Collections sind bereit!\n'); + console.log('Nรคchste Schritte:'); + console.log(' 1. Besuche: https://cms.dk0.dev/admin/content/tech_stack_categories'); + console.log(' 2. Fรผhre aus: node scripts/migrate-tech-stack-to-directus.js'); + console.log(' 3. Verifiziere die Daten im Directus Admin Panel\n'); + + } catch (error) { + console.error('\nโŒ Setup failed:', error); + console.error('\nTroubleshooting:'); + console.error(' - รœberprรผfe DIRECTUS_URL und DIRECTUS_STATIC_TOKEN in .env'); + console.error(' - Stelle sicher, dass der Token Admin-Rechte hat'); + console.error(' - Prรผfe ob Directus erreichbar ist: curl ' + DIRECTUS_URL); + process.exit(1); + } +} + +main(); diff --git a/scripts/setup-directus-hobbies.js b/scripts/setup-directus-hobbies.js new file mode 100644 index 0000000..a7f8905 --- /dev/null +++ b/scripts/setup-directus-hobbies.js @@ -0,0 +1,285 @@ +#!/usr/bin/env node +/** + * Directus Hobbies Collection Setup via REST API + * + * Erstellt die Hobbies Collection mit Translations + * + * Usage: + * node scripts/setup-directus-hobbies.js + */ + +const fetch = require('node-fetch'); +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); +} + +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); + const text = await response.text(); + + if (!response.ok) { + if (text.includes('already exists') || text.includes('RECORD_NOT_UNIQUE')) { + console.log(` โš ๏ธ Already exists, skipping...`); + return { data: null, alreadyExists: true }; + } + throw new Error(`HTTP ${response.status}: ${text}`); + } + + return text ? JSON.parse(text) : {}; + } catch (error) { + console.error(`โŒ Error calling ${method} ${endpoint}:`, error.message); + throw error; + } +} + +async function createHobbiesCollections() { + console.log('\n๐Ÿ“ฆ Creating Hobbies Collections...\n'); + + console.log('1๏ธโƒฃ Creating hobbies...'); + try { + await directusRequest('collections', 'POST', { + collection: 'hobbies', + meta: { + icon: 'sports_esports', + display_template: '{{translations.title}}', + hidden: false, + singleton: false, + sort_field: 'sort' + }, + schema: { + name: 'hobbies' + } + }); + console.log(' โœ… Collection created'); + } catch (error) { + console.log(' โš ๏ธ Collection might already exist'); + } + + console.log('\n2๏ธโƒฃ Creating hobbies_translations...'); + try { + await directusRequest('collections', 'POST', { + collection: 'hobbies_translations', + meta: { + hidden: true, + icon: 'import_export' + }, + schema: { + name: 'hobbies_translations' + } + }); + console.log(' โœ… Collection created'); + } catch (error) { + console.log(' โš ๏ธ Collection might already exist'); + } +} + +async function createHobbyFields() { + console.log('\n๐Ÿ”ง Creating Fields...\n'); + + const hobbyFields = [ + { + field: 'status', + type: 'string', + meta: { + interface: 'select-dropdown', + options: { + choices: [ + { text: 'Published', value: 'published' }, + { text: 'Draft', value: 'draft' } + ] + } + }, + schema: { default_value: 'draft', is_nullable: false } + }, + { + field: 'sort', + type: 'integer', + meta: { interface: 'input', hidden: true }, + schema: {} + }, + { + field: 'key', + type: 'string', + meta: { + interface: 'input', + note: 'Unique identifier (e.g. self_hosting, gaming)' + }, + schema: { is_unique: true, is_nullable: false } + }, + { + field: 'icon', + type: 'string', + meta: { + interface: 'select-dropdown', + options: { + choices: [ + { text: 'Code', value: 'Code' }, + { text: 'Gamepad2', value: 'Gamepad2' }, + { text: 'Server', value: 'Server' }, + { text: 'Activity', value: 'Activity' } + ] + } + }, + schema: { default_value: 'Code' } + }, + { + field: 'date_created', + type: 'timestamp', + meta: { special: ['date-created'], interface: 'datetime', readonly: true, hidden: true }, + schema: {} + }, + { + field: 'date_updated', + type: 'timestamp', + meta: { special: ['date-updated'], interface: 'datetime', readonly: true, hidden: true }, + schema: {} + }, + { + field: 'translations', + type: 'alias', + meta: { + special: ['translations'], + interface: 'translations', + options: { languageField: 'languages_code' } + } + } + ]; + + console.log('Adding fields to hobbies:'); + for (const field of hobbyFields) { + try { + await directusRequest('fields/hobbies', 'POST', field); + console.log(` โœ… ${field.field}`); + } catch (error) { + console.log(` โš ๏ธ ${field.field} (might already exist)`); + } + } + + const translationFields = [ + { + field: 'hobbies_id', + type: 'uuid', + meta: { hidden: true }, + schema: {} + }, + { + field: 'languages_code', + type: 'string', + meta: { interface: 'select-dropdown-m2o' }, + schema: {} + }, + { + field: 'title', + type: 'string', + meta: { + interface: 'input', + note: 'Hobby title' + }, + schema: {} + }, + { + field: 'description', + type: 'text', + meta: { + interface: 'input-multiline', + note: 'Hobby description (optional)' + }, + schema: {} + } + ]; + + console.log('\nAdding fields to hobbies_translations:'); + for (const field of translationFields) { + try { + await directusRequest('fields/hobbies_translations', 'POST', field); + console.log(` โœ… ${field.field}`); + } catch (error) { + console.log(` โš ๏ธ ${field.field} (might already exist)`); + } + } +} + +async function createHobbyRelations() { + console.log('\n๐Ÿ”— Creating Relations...\n'); + + const relations = [ + { + collection: 'hobbies_translations', + field: 'hobbies_id', + related_collection: 'hobbies', + meta: { + one_field: 'translations', + sort_field: null, + one_deselect_action: 'delete' + }, + schema: { on_delete: 'CASCADE' } + }, + { + collection: 'hobbies_translations', + field: 'languages_code', + related_collection: 'languages', + meta: { + one_field: null, + sort_field: null, + one_deselect_action: 'nullify' + }, + schema: { on_delete: 'SET NULL' } + } + ]; + + for (let i = 0; i < relations.length; i++) { + try { + await directusRequest('relations', 'POST', relations[i]); + console.log(` โœ… Relation ${i + 1}/${relations.length}`); + } catch (error) { + console.log(` โš ๏ธ Relation ${i + 1}/${relations.length} (might already exist)`); + } + } +} + +async function main() { + console.log('\nโ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—'); + console.log('โ•‘ Directus Hobbies Setup via API โ•‘'); + console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); + + try { + await createHobbiesCollections(); + await createHobbyFields(); + await createHobbyRelations(); + + console.log('\nโ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—'); + console.log('โ•‘ โœ… Setup Complete! โ•‘'); + console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); + + console.log('๐ŸŽ‰ Hobbies Collection ist bereit!\n'); + console.log('Nรคchste Schritte:'); + console.log(' 1. Fรผhre aus: node scripts/migrate-hobbies-to-directus.js'); + console.log(' 2. Verifiziere: https://cms.dk0.dev/admin/content/hobbies\n'); + + } catch (error) { + console.error('\nโŒ Setup failed:', error); + process.exit(1); + } +} + +main(); diff --git a/scripts/setup-directus-projects.js b/scripts/setup-directus-projects.js new file mode 100644 index 0000000..4353f81 --- /dev/null +++ b/scripts/setup-directus-projects.js @@ -0,0 +1,503 @@ +#!/usr/bin/env node +/** + * Directus Projects Collection Setup via REST API + * + * Erstellt die komplette Projects Collection mit allen Feldern und Translations + * + * Usage: + * node scripts/setup-directus-projects.js + */ + +const fetch = require('node-fetch'); +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); +} + +console.log(`๐Ÿ”— Connecting to: ${DIRECTUS_URL}`); + +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); + const text = await response.text(); + + if (!response.ok) { + if (text.includes('already exists') || text.includes('RECORD_NOT_UNIQUE')) { + console.log(` โš ๏ธ Already exists, skipping...`); + return { data: null, alreadyExists: true }; + } + throw new Error(`HTTP ${response.status}: ${text}`); + } + + return text ? JSON.parse(text) : {}; + } catch (error) { + console.error(`โŒ Error calling ${method} ${endpoint}:`, error.message); + throw error; + } +} + +async function createProjectsCollections() { + console.log('\n๐Ÿ“ฆ Creating Projects Collections...\n'); + + // 1. Create projects collection + console.log('1๏ธโƒฃ Creating projects...'); + try { + await directusRequest('collections', 'POST', { + collection: 'projects', + meta: { + icon: 'folder', + display_template: '{{title}}', + hidden: false, + singleton: false, + translations: [ + { language: 'en-US', translation: 'Projects' }, + { language: 'de-DE', translation: 'Projekte' } + ], + sort_field: 'sort' + }, + schema: { + name: 'projects' + } + }); + console.log(' โœ… Collection created'); + } catch (error) { + console.log(' โš ๏ธ Collection might already exist'); + } + + // 2. Create projects_translations collection + console.log('\n2๏ธโƒฃ Creating projects_translations...'); + try { + await directusRequest('collections', 'POST', { + collection: 'projects_translations', + meta: { + hidden: true, + icon: 'import_export' + }, + schema: { + name: 'projects_translations' + } + }); + console.log(' โœ… Collection created'); + } catch (error) { + console.log(' โš ๏ธ Collection might already exist'); + } +} + +async function createProjectFields() { + console.log('\n๐Ÿ”ง Creating Project Fields...\n'); + + const projectFields = [ + { + field: 'status', + type: 'string', + meta: { + interface: 'select-dropdown', + options: { + choices: [ + { text: 'Published', value: 'published' }, + { text: 'Draft', value: 'draft' }, + { text: 'Archived', value: 'archived' } + ] + } + }, + schema: { default_value: 'draft', is_nullable: false } + }, + { + field: 'sort', + type: 'integer', + meta: { interface: 'input', hidden: true }, + schema: {} + }, + { + field: 'slug', + type: 'string', + meta: { + interface: 'input', + note: 'URL-friendly identifier (e.g. my-portfolio-website)', + required: true + }, + schema: { is_unique: true, is_nullable: false } + }, + { + field: 'featured', + type: 'boolean', + meta: { + interface: 'boolean', + note: 'Show on homepage' + }, + schema: { default_value: false } + }, + { + field: 'category', + type: 'string', + meta: { + interface: 'select-dropdown', + options: { + choices: [ + { text: 'Web Application', value: 'Web Application' }, + { text: 'Mobile App', value: 'Mobile App' }, + { text: 'Backend Development', value: 'Backend Development' }, + { text: 'DevOps', value: 'DevOps' }, + { text: 'AI/ML', value: 'AI/ML' }, + { text: 'Other', value: 'Other' } + ] + } + }, + schema: { default_value: 'Web Application' } + }, + { + field: 'difficulty', + type: 'string', + meta: { + interface: 'select-dropdown', + options: { + choices: [ + { text: 'Beginner', value: 'BEGINNER' }, + { text: 'Intermediate', value: 'INTERMEDIATE' }, + { text: 'Advanced', value: 'ADVANCED' }, + { text: 'Expert', value: 'EXPERT' } + ] + } + }, + schema: { default_value: 'INTERMEDIATE' } + }, + { + field: 'date', + type: 'string', + meta: { + interface: 'input', + note: 'Project date (e.g. "2024" or "2023-2024")' + }, + schema: {} + }, + { + field: 'time_to_complete', + type: 'string', + meta: { + interface: 'input', + note: 'e.g. "4-6 weeks"', + placeholder: '4-6 weeks' + }, + schema: {} + }, + { + field: 'github', + type: 'string', + meta: { + interface: 'input', + note: 'GitHub repository URL', + placeholder: 'https://github.com/...' + }, + schema: {} + }, + { + field: 'live', + type: 'string', + meta: { + interface: 'input', + note: 'Live demo URL', + placeholder: 'https://...' + }, + schema: {} + }, + { + field: 'image_url', + type: 'string', + meta: { + interface: 'input', + note: 'Main project image URL' + }, + schema: {} + }, + { + field: 'demo_video', + type: 'string', + meta: { + interface: 'input', + note: 'Demo video URL (YouTube, Vimeo, etc.)' + }, + schema: {} + }, + { + field: 'color_scheme', + type: 'string', + meta: { + interface: 'input', + note: 'e.g. "Dark theme with blue accents"' + }, + schema: { default_value: 'Dark' } + }, + { + field: 'accessibility', + type: 'boolean', + meta: { + interface: 'boolean', + note: 'Is the project accessible?' + }, + schema: { default_value: true } + }, + { + field: 'tags', + type: 'json', + meta: { + interface: 'tags', + note: 'Technology tags (e.g. React, Node.js, Docker)' + }, + schema: {} + }, + { + field: 'technologies', + type: 'json', + meta: { + interface: 'tags', + note: 'Detailed tech stack' + }, + schema: {} + }, + { + field: 'challenges', + type: 'json', + meta: { + interface: 'list', + note: 'Challenges faced during development' + }, + schema: {} + }, + { + field: 'lessons_learned', + type: 'json', + meta: { + interface: 'list', + note: 'What you learned from this project' + }, + schema: {} + }, + { + field: 'future_improvements', + type: 'json', + meta: { + interface: 'list', + note: 'Planned improvements' + }, + schema: {} + }, + { + field: 'screenshots', + type: 'json', + meta: { + interface: 'list', + note: 'Array of screenshot URLs' + }, + schema: {} + }, + { + field: 'performance', + type: 'json', + meta: { + interface: 'input-code', + options: { + language: 'json' + }, + note: 'Performance metrics (lighthouse, bundle size, load time)' + }, + schema: {} + }, + { + field: 'date_created', + type: 'timestamp', + meta: { + special: ['date-created'], + interface: 'datetime', + readonly: true, + hidden: true + }, + schema: {} + }, + { + field: 'date_updated', + type: 'timestamp', + meta: { + special: ['date-updated'], + interface: 'datetime', + readonly: true, + hidden: true + }, + schema: {} + }, + { + field: 'translations', + type: 'alias', + meta: { + special: ['translations'], + interface: 'translations', + options: { languageField: 'languages_code' } + } + } + ]; + + console.log('Adding fields to projects:'); + for (const field of projectFields) { + try { + await directusRequest('fields/projects', 'POST', field); + console.log(` โœ… ${field.field}`); + } catch (error) { + console.log(` โš ๏ธ ${field.field} (might already exist)`); + } + } + + // Translation fields + console.log('\nAdding fields to projects_translations:'); + const translationFields = [ + { + field: 'projects_id', + type: 'uuid', + meta: { hidden: true }, + schema: {} + }, + { + field: 'languages_code', + type: 'string', + meta: { interface: 'select-dropdown-m2o' }, + schema: {} + }, + { + field: 'title', + type: 'string', + meta: { + interface: 'input', + note: 'Project title', + required: true + }, + schema: { is_nullable: false } + }, + { + field: 'description', + type: 'text', + meta: { + interface: 'input-multiline', + note: 'Short description (1-2 sentences)' + }, + schema: {} + }, + { + field: 'content', + type: 'text', + meta: { + interface: 'input-rich-text-md', + note: 'Full project content (Markdown)' + }, + schema: {} + }, + { + field: 'meta_description', + type: 'string', + meta: { + interface: 'input', + note: 'SEO meta description' + }, + schema: {} + }, + { + field: 'keywords', + type: 'string', + meta: { + interface: 'input', + note: 'SEO keywords (comma separated)' + }, + schema: {} + } + ]; + + for (const field of translationFields) { + try { + await directusRequest('fields/projects_translations', 'POST', field); + console.log(` โœ… ${field.field}`); + } catch (error) { + console.log(` โš ๏ธ ${field.field} (might already exist)`); + } + } +} + +async function createProjectRelations() { + console.log('\n๐Ÿ”— Creating Relations...\n'); + + const relations = [ + { + collection: 'projects_translations', + field: 'projects_id', + related_collection: 'projects', + meta: { + one_field: 'translations', + sort_field: null, + one_deselect_action: 'delete' + }, + schema: { on_delete: 'CASCADE' } + }, + { + collection: 'projects_translations', + field: 'languages_code', + related_collection: 'languages', + meta: { + one_field: null, + sort_field: null, + one_deselect_action: 'nullify' + }, + schema: { on_delete: 'SET NULL' } + } + ]; + + for (let i = 0; i < relations.length; i++) { + try { + await directusRequest('relations', 'POST', relations[i]); + console.log(` โœ… Relation ${i + 1}/${relations.length}`); + } catch (error) { + console.log(` โš ๏ธ Relation ${i + 1}/${relations.length} (might already exist)`); + } + } +} + +async function main() { + console.log('\nโ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—'); + console.log('โ•‘ Directus Projects Setup via API โ•‘'); + console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); + + try { + await createProjectsCollections(); + await createProjectFields(); + await createProjectRelations(); + + console.log('\nโ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—'); + console.log('โ•‘ โœ… Setup Complete! โ•‘'); + console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); + + console.log('๐ŸŽ‰ Projects Collection ist bereit!\n'); + console.log('Nรคchste Schritte:'); + console.log(' 1. Besuche: https://cms.dk0.dev/admin/content/projects'); + console.log(' 2. Fรผhre aus: node scripts/migrate-projects-to-directus.js'); + console.log(' 3. Verifiziere die Daten im Directus Admin Panel\n'); + + } catch (error) { + console.error('\nโŒ Setup failed:', error); + process.exit(1); + } +} + +main(); diff --git a/scripts/setup-tech-stack-directus.js b/scripts/setup-tech-stack-directus.js new file mode 100644 index 0000000..1931e93 --- /dev/null +++ b/scripts/setup-tech-stack-directus.js @@ -0,0 +1,151 @@ +#!/usr/bin/env node + +/** + * Setup tech stack items in Directus + * Creates tech_stack_items collection and populates it with data + */ + +const https = require('https'); + +const DIRECTUS_URL = 'https://cms.dk0.dev'; +const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN || ''; + +if (!DIRECTUS_TOKEN) { + console.error('โŒ DIRECTUS_STATIC_TOKEN not set'); + process.exit(1); +} + +// Tech stack items to create +const techStackItems = [ + // Frontend & Mobile (category 1) + { category: '1', name: 'Next.js', sort: 1 }, + { category: '1', name: 'Tailwind CSS', sort: 2 }, + { category: '1', name: 'Flutter', sort: 3 }, + + // Backend & DevOps (category 2) + { category: '2', name: 'Docker Swarm', sort: 1 }, + { category: '2', name: 'Traefik', sort: 2 }, + { category: '2', name: 'Nginx Proxy Manager', sort: 3 }, + { category: '2', name: 'Redis', sort: 4 }, + + // Tools & Automation (category 3) + { category: '3', name: 'Git', sort: 1 }, + { category: '3', name: 'CI/CD', sort: 2 }, + { category: '3', name: 'n8n', sort: 3 }, + { category: '3', name: 'Self-hosted Services', sort: 4 }, + + // Security & Admin (category 4) + { category: '4', name: 'CrowdSec', sort: 1 }, + { category: '4', name: 'Suricata', sort: 2 }, + { category: '4', name: 'Mailcow', sort: 3 }, +]; + +async function makeRequest(method, endpoint, body = null) { + return new Promise((resolve, reject) => { + const url = new URL(endpoint, DIRECTUS_URL); + + const options = { + hostname: url.hostname, + port: 443, + path: url.pathname + url.search, + method: method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, + }, + }; + + const req = https.request(options, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { + const parsed = JSON.parse(data); + if (res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } else { + resolve(parsed); + } + } catch (e) { + resolve(data); + } + }); + }); + + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +async function checkCollectionExists() { + try { + const response = await makeRequest('GET', '/api/items/tech_stack_items?limit=1'); + if (response.data !== undefined) { + console.log('โœ… Collection tech_stack_items already exists'); + return true; + } + } catch (e) { + if (e.message.includes('does not exist') || e.message.includes('ROUTE_NOT_FOUND')) { + console.log('โ„น๏ธ Collection tech_stack_items does not exist yet'); + return false; + } + throw e; + } + return false; +} + +async function addTechStackItems() { + console.log(`๐Ÿ“ Adding ${techStackItems.length} tech stack items...`); + + let created = 0; + for (const item of techStackItems) { + try { + const response = await makeRequest('POST', '/api/items/tech_stack_items', { + category: item.category, + name: item.name, + sort: item.sort, + status: 'published' + }); + + if (response.data) { + created++; + console.log(` โœ… Created: ${item.name} (category ${item.category})`); + } + } catch (error) { + console.error(` โŒ Failed to create "${item.name}":`, error.message); + } + } + + console.log(`\nโœ… Successfully created ${created}/${techStackItems.length} items`); + return created === techStackItems.length; +} + +async function main() { + try { + console.log('๐Ÿš€ Setting up Tech Stack in Directus...\n'); + + const exists = await checkCollectionExists(); + + if (exists) { + // Count existing items + const response = await makeRequest('GET', '/api/items/tech_stack_items?limit=1000'); + const count = response.data?.length || 0; + + if (count > 0) { + console.log(`โœ… Tech stack already populated with ${count} items`); + return; + } + } + + // Add items + await addTechStackItems(); + + console.log('\nโœ… Tech stack setup complete!'); + } catch (error) { + console.error('โŒ Error setting up tech stack:', error.message); + process.exit(1); + } +} + +main();