const DEFAULT_CLIENT_KEY = "9ca7f706-a8b0-4520-b467-5e8321df36fb"; function clientKey() { return process.env.NEXT_PUBLIC_X_CLIENT_KEY ?? DEFAULT_CLIENT_KEY; } function apiBase() { const base = process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, ""); return base ?? ""; } /** Article shape returned by public GET /articles and GET /articles/:id (camelCase JSON). */ export type PublicArticle = { id: number; title: string; slug: string; description: string; htmlDescription?: string; categoryName?: string; typeId: number; tags?: string; thumbnailUrl?: string; viewCount?: number | null; publishedAt?: string | null; createdAt: string; isPublish?: boolean | null; files?: Array<{ fileUrl?: string | null; fileName?: string | null; }>; }; export type ArticlesListResult = { items: PublicArticle[]; meta?: { totalPage?: number; count?: number }; }; type FetchMode = "server" | "client"; async function articlesFetchJson( path: string, mode: FetchMode = "server", ): Promise<{ data: unknown; meta?: ArticlesListResult["meta"]; } | null> { const base = apiBase(); if (!base) return null; const url = `${base}${path.startsWith("/") ? path : `/${path}`}`; try { const res = await fetch(url, { headers: { "Content-Type": "application/json", "X-Client-Key": clientKey(), }, ...(mode === "server" ? { next: { revalidate: 60 } } : { cache: "no-store" as RequestCache }), }); if (!res.ok) return null; const json = (await res.json()) as { data?: unknown; meta?: ArticlesListResult["meta"]; }; return { data: json.data, meta: json.meta }; } catch { return null; } } export async function fetchPublishedArticles( params: { typeId?: number; limit?: number; page?: number; sortBy?: string; sort?: string; title?: string; /** Partial match on `articles.tags` (comma-separated string in DB). */ tags?: string; }, options?: { mode?: FetchMode }, ): Promise { const sp = new URLSearchParams(); sp.set("limit", String(params.limit ?? 8)); sp.set("page", String(params.page ?? 1)); sp.set("isPublish", "true"); sp.set("sortBy", params.sortBy ?? "created_at"); sp.set("sort", params.sort ?? "desc"); if (params.typeId != null) sp.set("typeId", String(params.typeId)); if (params.title) sp.set("title", params.title); if (params.tags) sp.set("tags", params.tags); const mode = options?.mode ?? "server"; const raw = await articlesFetchJson(`/articles?${sp.toString()}`, mode); if (!raw) return null; const items = Array.isArray(raw.data) ? (raw.data as PublicArticle[]) : []; return { items, meta: raw.meta }; } export async function fetchArticlePublic( id: number, options?: { mode?: FetchMode }, ): Promise { const mode = options?.mode ?? "server"; const raw = await articlesFetchJson(`/articles/${id}`, mode); if (raw?.data == null || typeof raw.data !== "object") return null; return raw.data as PublicArticle; } /** Count tags (comma-separated on articles) for a “popular tags” list. */ export function aggregateTagStats( articles: PublicArticle[], topN = 8, ): { name: string; count: number }[] { const counts = new Map(); for (const a of articles) { if (!a.tags?.trim()) continue; for (const raw of a.tags.split(",")) { const display = raw.trim(); if (!display) continue; const key = display.toLowerCase(); const cur = counts.get(key); if (cur) cur.count += 1; else counts.set(key, { display, count: 1 }); } } return [...counts.values()] .sort((a, b) => b.count - a.count) .slice(0, topN) .map(({ display, count }) => ({ name: display, count })); }