/** * Directus API Client (REST-based, no SDK dependencies) */ 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', de: 'de-DE', }; function toDirectusLocale(locale: string): string { return localeToDirectus[locale] || locale; } interface FetchOptions { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: { query?: string; variables?: Record; [key: string]: unknown; }; } async function directusRequest( endpoint: string, options: FetchOptions = {} ): Promise { // Wenn kein Token gesetzt, skip Directus (nutze JSON fallback) if (!DIRECTUS_TOKEN || DIRECTUS_TOKEN === '') { return null; } const url = `${DIRECTUS_URL}/graphql`; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${DIRECTUS_TOKEN}`, }, body: JSON.stringify(options.body || {}), // Timeout nach 2 Sekunden signal: AbortSignal.timeout(2000), }); 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; } return null; } const data = await response.json(); // 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; } return data?.data || null; } catch (error: unknown) { // Timeout oder Network Error - stille fallback if (error && typeof error === 'object' && 'name' in error && (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') { const message = error && typeof error === 'object' && 'message' in error ? String(error.message) : 'Unknown error'; console.error('Directus request failed:', message); } return null; } } export async function getMessages(locale: string): Promise> { const directusLocale = toDirectusLocale(locale); const query = ` query { messages { key translations { value languages_code { code } } } } `; try { const result = await directusRequest('', { body: { query } }); interface MessageData { messages: Array<{ key: string; translations?: Array<{ languages_code?: { code: string }; value?: string; }>; }>; } const messages = (result as MessageData | null)?.messages || []; const dictionary: Record = {}; messages.forEach((m) => { const trans = m.translations?.find((t) => t.languages_code?.code === directusLocale); if (trans?.value) dictionary[m.key] = trans.value; }); return dictionary; } catch (error) { return {}; } } /** * Get a single message by key from Directus */ export async function getMessage(key: string, locale: string): Promise { const directusLocale = toDirectusLocale(locale); const query = ` query { messages(filter: {key: {_eq: "${key}"}}, limit: 1) { key translations { value languages_code { code } } } } `; try { const result = await directusRequest('', { body: { query } }); interface SingleMessageData { messages: Array<{ translations?: Array<{ languages_code?: { code: string }; value?: string; }>; }>; } const messages = (result as SingleMessageData | null)?.messages; if (!messages || messages.length === 0) return null; const translations = messages[0]?.translations || []; const translation = translations.find((t) => t.languages_code?.code === directusLocale); return translation?.value || null; } catch (error) { return null; } } export interface ContentPage { slug: string; content?: string; [key: string]: unknown; } export async function getContentPage( slug: string, locale: string ): Promise { const directusLocale = toDirectusLocale(locale); const query = ` query { content_pages( filter: { _and: [ { slug: { _eq: "${slug}" } }, { locale: { _eq: "${directusLocale}" } } ] } limit: 1 ) { slug locale title content status } } `; try { const result = await directusRequest( '', { body: { query } } ); interface ContentPagesResult { content_pages: ContentPage[]; } const pages = (result as ContentPagesResult | null)?.content_pages || []; if (pages.length === 0) { // Try without locale filter const fallbackQuery = ` query { content_pages( filter: { slug: { _eq: "${slug}" } } limit: 1 ) { slug locale title content status } } `; const fallbackResult = await directusRequest('', { body: { query: fallbackQuery } }); const fallbackPages = (fallbackResult as ContentPagesResult | null)?.content_pages || []; return fallbackPages[0] || null; } return 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 } } ); interface TechStackCategoriesResult { tech_stack_categories: Array<{ id: string; key: string; icon: string; sort: number; translations?: Array<{ languages_code?: { code: string }; name?: string; }>; }>; } const categories = (categoriesResult as TechStackCategoriesResult | null)?.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(); interface ItemsResponseData { data: Array<{ id: string; name: string; category: string | number; url?: string; icon_url?: string; sort: number; }>; } const allItems = (itemsData as ItemsResponseData | null)?.data || []; if (process.env.NODE_ENV === 'development') { console.log('[getTechStack] Fetched items:', allItems.length); } // Group items by category const categoriesWithItems = categories.map((cat) => { const categoryItems = allItems.filter((item) => 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, category: cat.id, 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; } } // Book Review Types export interface BookReview { id: string; hardcover_id?: string; book_title: string; book_author: string; book_image?: string; rating: number; // 1-5 review?: string; // Translated review text finished_at?: string; } /** * Get Book Reviews from Directus with translations */ export async function getBookReviews(locale: string): Promise { const directusLocale = toDirectusLocale(locale); const query = ` query { book_reviews( filter: { status: { _eq: "published" } } sort: ["-finished_at"] ) { id hardcover_id book_title book_author book_image rating finished_at translations { review languages_code { code } } } } `; try { const result = await directusRequest( '', { body: { query } } ); const reviews = (result as any)?.book_reviews; if (!reviews || reviews.length === 0) { return null; } return reviews.map((item: any) => { // Filter die passende Übersetzung im Code const translation = item.translations?.find( (t: any) => t.languages_code?.code === directusLocale ) || item.translations?.[0]; // Fallback auf die erste Übersetzung falls locale nicht passt return { id: item.id, hardcover_id: item.hardcover_id || undefined, book_title: item.book_title, book_author: item.book_author, book_image: item.book_image || undefined, rating: typeof item.rating === 'number' ? item.rating : parseInt(item.rating) || 0, review: translation?.review || undefined, finished_at: item.finished_at || undefined, }; }); } catch (error) { console.error(`Failed to fetch book reviews (${locale}):`, error); return null; } } // Projects Types export interface Project { id: string | number; // Allow both string (from Directus) and number (from Prisma) 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; button_live_label?: string; button_github_label?: 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 ? 'true' : 'false'} }`); } // Remove published filter since it doesn't exist in Directus schema // The status field already handles published/draft state 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", "-date_created"] ) { id slug category difficulty tags technologies challenges lessons_learned future_improvements github live image_url demo_video date_created date_updated featured status translations { title description content meta_description keywords button_live_label button_github_label 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] || {}; // Parse JSON string fields if needed const parseTags = (tags: any) => { if (!tags) return []; if (Array.isArray(tags)) return tags; if (typeof tags === 'string') { try { return JSON.parse(tags); } catch { return []; } } return []; }; 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: parseTags(proj.tags), technologies: parseTags(proj.technologies), challenges: proj.challenges, lessons_learned: proj.lessons_learned, future_improvements: proj.future_improvements, github_url: proj.github, live_url: proj.live, button_live_label: trans.button_live_label, button_github_label: trans.button_github_label, image_url: proj.image_url, demo_video_url: proj.demo_video, performance_metrics: proj.performance_metrics, screenshots: parseTags(proj.screenshots), featured: proj.featured === 1 || proj.featured === true, published: proj.status === 'published', created_at: proj.date_created, updated_at: proj.date_updated }; }); } catch (error) { console.error(`Failed to fetch projects (${locale}):`, error); return null; } } /** * Get a single project by slug from Directus */ export async function getProjectBySlug( slug: string, locale: string ): Promise { const directusLocale = toDirectusLocale(locale); const query = ` query { projects( filter: { _and: [ { slug: { _eq: "${slug}" } }, { status: { _eq: "published" } } ] } limit: 1 ) { id slug category difficulty tags technologies challenges lessons_learned future_improvements github live image_url demo_video date_created date_updated featured status translations { title description content meta_description keywords button_live_label button_github_label languages_code { code } } } } `; try { const result = await directusRequest( '', { body: { query } } ); const projects = (result as any)?.projects; if (!projects || projects.length === 0) { return null; } const proj = projects[0]; const trans = proj.translations?.find((t: any) => t.languages_code?.code === directusLocale) || proj.translations?.[0] || {}; // Parse JSON string fields if needed const parseTags = (tags: any) => { if (!tags) return []; if (Array.isArray(tags)) return tags; if (typeof tags === 'string') { try { return JSON.parse(tags); } catch { return []; } } return []; }; 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: parseTags(proj.tags), technologies: parseTags(proj.technologies), challenges: proj.challenges, lessons_learned: proj.lessons_learned, future_improvements: proj.future_improvements, github_url: proj.github, live_url: proj.live, button_live_label: trans.button_live_label, button_github_label: trans.button_github_label, image_url: proj.image_url, demo_video_url: proj.demo_video, screenshots: parseTags(proj.screenshots), featured: proj.featured === 1 || proj.featured === true, published: proj.status === 'published', created_at: proj.date_created, updated_at: proj.date_updated }; } catch (error) { console.error(`Failed to fetch project by slug ${slug} (${locale}):`, error); return null; } } // Snippets Types export interface Snippet { id: string; title: string; category: string; code: string; description: string; language: string; } /** * Get Snippets from Directus */ export async function getSnippets(limit = 10, featured?: boolean): Promise { const filters = ['status: { _eq: "published" }']; if (featured !== undefined) { filters.push(`featured: { _eq: ${featured} }`); } const filterString = `filter: { _and: [{ ${filters.join(' }, { ')} }] }`; const query = ` query { snippets( ${filterString} limit: ${limit} ) { id title category code description language } } `; try { const result = await directusRequest( '', { body: { query } } ); const snippets = (result as any)?.snippets; if (!snippets || snippets.length === 0) { return null; } return snippets; } catch (error) { console.error('Failed to fetch snippets:', error); return null; } }