- Add POST /api/n8n/hardcover/sync-books — n8n calls this after detecting
finished books in Hardcover. Authenticates via N8N_SECRET_TOKEN/N8N_API_KEY,
deduplicates by hardcover_id, creates new book_reviews entries in Directus.
- Add getBookReviewByHardcoverId() + createBookReview() to lib/directus.ts.
Check uses GraphQL filter; create uses Directus REST POST /items/book_reviews.
- ReadBooks: replace silent return null with a visible empty state so the
section stays visible with a hint until the n8n sync populates it.
- Projects: add "No projects yet." placeholder instead of blank grid when
both Directus and PostgreSQL return no data.
- Add home.about.readBooks.empty i18n key (EN + DE).
n8n workflow setup:
Schedule → HTTP Hardcover GraphQL (books_read) → Code (transform) →
POST /api/n8n/hardcover/sync-books with array of { hardcover_id, title,
author, image, rating, finished_at }
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1076 lines
28 KiB
TypeScript
1076 lines
28 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?: {
|
|
query?: string;
|
|
variables?: Record<string, unknown>;
|
|
[key: string]: unknown;
|
|
};
|
|
}
|
|
|
|
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: 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<Record<string, string>> {
|
|
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<string, string> = {};
|
|
|
|
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<string | null> {
|
|
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<ContentPage | 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 } }
|
|
);
|
|
|
|
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<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 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 } }
|
|
);
|
|
|
|
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: TechStackItem) => ({
|
|
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 } }
|
|
);
|
|
|
|
interface HobbiesResult {
|
|
hobbies: Array<{
|
|
id: string;
|
|
key: string;
|
|
icon: string;
|
|
translations?: Array<{
|
|
title?: string;
|
|
description?: string;
|
|
}>;
|
|
}>;
|
|
}
|
|
const hobbies = (result as HobbiesResult | null)?.hobbies;
|
|
if (!hobbies || hobbies.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return hobbies.map((hobby) => ({
|
|
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"]
|
|
) {
|
|
id
|
|
hardcover_id
|
|
book_title
|
|
book_author
|
|
book_image
|
|
rating
|
|
finished_at
|
|
translations {
|
|
review
|
|
languages_code {
|
|
code
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
try {
|
|
const result = await directusRequest(
|
|
'',
|
|
{ body: { query } }
|
|
);
|
|
|
|
interface BookReviewsResult {
|
|
book_reviews: Array<{
|
|
id: string;
|
|
hardcover_id?: string;
|
|
book_title: string;
|
|
book_author: string;
|
|
book_image?: string;
|
|
rating: number | string;
|
|
finished_at?: string;
|
|
translations?: Array<{
|
|
review?: string;
|
|
languages_code?: { code: string };
|
|
}>;
|
|
}>;
|
|
}
|
|
const reviews = (result as BookReviewsResult | null)?.book_reviews;
|
|
if (!reviews || reviews.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return reviews.map((item) => {
|
|
// Filter die passende Übersetzung im Code
|
|
const translation = item.translations?.find(
|
|
(t) => 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<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
|
|
button_live_label
|
|
button_github_label
|
|
languages_code { code }
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
try {
|
|
const result = await directusRequest(
|
|
'',
|
|
{ body: { query } }
|
|
);
|
|
|
|
interface ProjectsResult {
|
|
projects: Array<{
|
|
id: string;
|
|
slug: string;
|
|
category?: string;
|
|
difficulty?: string;
|
|
tags?: string[] | string;
|
|
technologies?: string[] | string;
|
|
challenges?: string;
|
|
lessons_learned?: string;
|
|
future_improvements?: string;
|
|
github?: string;
|
|
live?: string;
|
|
image_url?: string;
|
|
demo_video?: string;
|
|
performance_metrics?: string;
|
|
screenshots?: string[] | string;
|
|
date_created?: string;
|
|
date_updated?: string;
|
|
featured?: boolean | number;
|
|
status?: string;
|
|
translations?: Array<{
|
|
title?: string;
|
|
description?: string;
|
|
content?: string;
|
|
meta_description?: string;
|
|
keywords?: string;
|
|
button_live_label?: string;
|
|
button_github_label?: string;
|
|
languages_code?: { code: string };
|
|
}>;
|
|
}>;
|
|
}
|
|
const projects = (result as ProjectsResult | null)?.projects;
|
|
if (!projects || projects.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return projects.map((proj) => {
|
|
const trans =
|
|
proj.translations?.find((t) => t.languages_code?.code === directusLocale) ||
|
|
proj.translations?.[0] ||
|
|
{};
|
|
|
|
// Parse JSON string fields if needed
|
|
const parseTags = (tags: string[] | string | undefined): string[] => {
|
|
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<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
|
|
button_live_label
|
|
button_github_label
|
|
languages_code { code }
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
try {
|
|
const result = await directusRequest(
|
|
'',
|
|
{ body: { query } }
|
|
);
|
|
|
|
interface ProjectResult {
|
|
projects: Array<{
|
|
id: string;
|
|
slug: string;
|
|
category?: string;
|
|
difficulty?: string;
|
|
tags?: string[] | string;
|
|
technologies?: string[] | string;
|
|
challenges?: string;
|
|
lessons_learned?: string;
|
|
future_improvements?: string;
|
|
github?: string;
|
|
live?: string;
|
|
image_url?: string;
|
|
demo_video?: string;
|
|
screenshots?: string[] | string;
|
|
date_created?: string;
|
|
date_updated?: string;
|
|
featured?: boolean | number;
|
|
status?: string;
|
|
translations?: Array<{
|
|
title?: string;
|
|
description?: string;
|
|
content?: string;
|
|
meta_description?: string;
|
|
keywords?: string;
|
|
button_live_label?: string;
|
|
button_github_label?: string;
|
|
languages_code?: { code: string };
|
|
}>;
|
|
}>;
|
|
}
|
|
const projects = (result as ProjectResult | null)?.projects;
|
|
if (!projects || projects.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const proj = projects[0];
|
|
const trans =
|
|
proj.translations?.find((t) => t.languages_code?.code === directusLocale) ||
|
|
proj.translations?.[0] ||
|
|
{};
|
|
|
|
// Parse JSON string fields if needed
|
|
const parseTags = (tags: string[] | string | undefined): string[] => {
|
|
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<Snippet[] | null> {
|
|
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 } }
|
|
);
|
|
|
|
interface SnippetsResult {
|
|
snippets: Snippet[];
|
|
}
|
|
const snippets = (result as SnippetsResult | null)?.snippets;
|
|
if (!snippets || snippets.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return snippets;
|
|
} catch (_error) {
|
|
console.error('Failed to fetch snippets:', _error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Hardcover → Directus sync helpers ───────────────────────────────────────
|
|
|
|
export interface BookReviewCreate {
|
|
hardcover_id: string;
|
|
book_title: string;
|
|
book_author: string;
|
|
book_image?: string;
|
|
rating?: number;
|
|
finished_at?: string;
|
|
status: 'published' | 'draft';
|
|
}
|
|
|
|
/**
|
|
* Check if a book review already exists in Directus by Hardcover ID.
|
|
* Used for deduplication during sync.
|
|
*/
|
|
export async function getBookReviewByHardcoverId(
|
|
hardcoverId: string
|
|
): Promise<{ id: string } | null> {
|
|
const query = `
|
|
query {
|
|
book_reviews(
|
|
filter: { hardcover_id: { _eq: "${hardcoverId}" } }
|
|
limit: 1
|
|
) {
|
|
id
|
|
}
|
|
}
|
|
`;
|
|
|
|
try {
|
|
const result = await directusRequest<{ book_reviews: Array<{ id: string }> }>(
|
|
'',
|
|
{ body: { query } }
|
|
);
|
|
const item = result?.book_reviews?.[0];
|
|
return item ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new book review in the Directus book_reviews collection via REST API.
|
|
* Returns the created item id, or null on failure.
|
|
*/
|
|
export async function createBookReview(
|
|
data: BookReviewCreate
|
|
): Promise<{ id: string } | null> {
|
|
if (!DIRECTUS_TOKEN) return null;
|
|
|
|
try {
|
|
const response = await fetch(`${DIRECTUS_URL}/items/book_reviews`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${DIRECTUS_TOKEN}`,
|
|
},
|
|
body: JSON.stringify(data),
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error(`Directus create book_review failed ${response.status}:`, text.slice(0, 200));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const result = await response.json();
|
|
return result?.data?.id ? { id: result.data.id } : null;
|
|
} catch (error) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.error('createBookReview error:', error);
|
|
}
|
|
return null;
|
|
}
|
|
}
|