Files
portfolio/lib/directus.ts
denshooter cc8fff14d2
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
fix: resolve project 404s with Directus fallback and upgrade 404 page
Merged Directus and PostgreSQL project data, implemented single project fetch from CMS, and modernized the NotFound component with liquid design.
2026-02-15 22:47:25 +01:00

771 lines
20 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;
}
}
// 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<BookReview[] | null> {
const directusLocale = toDirectusLocale(locale);
const query = `
query {
book_reviews(
filter: { status: { _eq: "published" } }
sort: ["-finished_at", "-date_created"]
) {
id
hardcover_id
book_title
book_author
book_image
rating
finished_at
translations(filter: { languages_code: { code: { _eq: "${directusLocale}" } } }) {
review
}
}
}
`;
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) => ({
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: item.translations?.[0]?.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;
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;
}
}
/**
* Get a single project by slug from Directus
*/
export async function getProjectBySlug(
slug: string,
locale: string
): Promise<Project | null> {
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
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,
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;
}
}