134 lines
3.8 KiB
TypeScript
134 lines
3.8 KiB
TypeScript
|
|
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 }));
|
||
|
|
}
|