qudoco-fe/lib/articles-public.ts

134 lines
3.8 KiB
TypeScript
Raw Permalink Normal View History

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<ArticlesListResult | null> {
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<PublicArticle | null> {
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<string, { display: string; count: number }>();
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 }));
}