Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Merged Directus and PostgreSQL project data, implemented single project fetch from CMS, and modernized the NotFound component with liquid design.
771 lines
20 KiB
TypeScript
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;
|
|
}
|
|
}
|