Files
portfolio/lib/directus.ts
2026-01-23 02:17:01 +00:00

597 lines
16 KiB
TypeScript

/**
* 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<string, string> = {
en: 'en-US',
de: 'de-DE',
};
function toDirectusLocale(locale: string): string {
return localeToDirectus[locale] || locale;
}
interface FetchOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: any;
}
async function directusRequest<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<T | null> {
// 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: 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?.message);
}
return null;
}
}
export async function getMessage(key: string, locale: string): Promise<string | null> {
// Note: messages collection doesn't exist in Directus yet
// The app uses JSON files as fallback via i18n-loader
// Return null to skip Directus and use JSON fallback directly
return null;
/* Commented out until messages collection is created in Directus
const directusLocale = toDirectusLocale(locale);
// GraphQL Query für Directus Native Translations
// Hole alle translations, filter client-side da GraphQL filter komplex ist
const query = `
query {
messages(filter: {key: {_eq: "${key}"}}, limit: 1) {
key
translations {
value
languages_code {
code
}
}
}
}
`;
try {
const result = await directusRequest(
'',
{ body: { query } }
);
const messages = (result as any)?.messages;
if (!messages || messages.length === 0) {
return null;
}
// Hole die Translation für die gewünschte Locale (client-side filter)
const translations = messages[0]?.translations || [];
const translation = translations.find((t: any) =>
t.languages_code?.code === directusLocale
);
return translation?.value || null;
} catch (error) {
console.error(`Failed to fetch message ${key} (${locale}):`, error);
return null;
}
*/
}
export async function getContentPage(
slug: string,
locale: string
): Promise<any | null> {
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 } }
);
const pages = (result as any)?.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 any)?.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<string, Array<{ key: string; items: string[] }>> = {
'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<string, string> = {
frontend: 'Globe',
backend: 'Server',
tools: 'Wrench',
security: 'Shield'
};
const categoryNames: Record<string, Record<string, string>> = {
'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<TechStackCategory[] | null> {
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<Hobby[] | null> {
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<Project[] | null> {
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
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,
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;
}
}