- Created setup-directus-collections.js to automate the creation of tech stack collections, fields, and relations in Directus. - Created setup-directus-hobbies.js for setting up hobbies collection with translations. - Created setup-directus-projects.js for establishing projects collection with comprehensive fields and translations. - Added setup-tech-stack-directus.js to populate tech_stack_items with predefined data.
554 lines
14 KiB
TypeScript
554 lines
14 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> {
|
|
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: { slug: { _starts_with: "${slug}" } }
|
|
limit: 25
|
|
) {
|
|
id
|
|
slug
|
|
locale
|
|
title
|
|
content
|
|
}
|
|
}
|
|
`;
|
|
|
|
try {
|
|
const result = await directusRequest(
|
|
'',
|
|
{ body: { query } }
|
|
);
|
|
|
|
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<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} }`);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|