177 lines
5.6 KiB
TypeScript
177 lines
5.6 KiB
TypeScript
import { and, asc, desc, eq, isNull, ne, sql } from "drizzle-orm";
|
|
import type { DrizzleD1Database } from "drizzle-orm/d1";
|
|
import {
|
|
blogKeywords,
|
|
cases,
|
|
contentItems,
|
|
CONTENT_KINDS,
|
|
posts,
|
|
siteSettings,
|
|
type BlogKeyword,
|
|
type CaseStudy,
|
|
type ContentItem,
|
|
type ContentKind,
|
|
type Post,
|
|
} from "../db/schema";
|
|
import * as schema from "../db/schema";
|
|
|
|
export type Db = DrizzleD1Database<typeof schema>;
|
|
export type Settings = Record<string, string>;
|
|
|
|
export type HomeData = {
|
|
settings: Settings;
|
|
services: ContentItem[];
|
|
features: ContentItem[];
|
|
steps: ContentItem[];
|
|
faqs: ContentItem[];
|
|
cases: CaseStudy[];
|
|
};
|
|
|
|
export async function getSettings(db: Db): Promise<Settings> {
|
|
const rows = await db.select().from(siteSettings);
|
|
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
|
}
|
|
|
|
export async function getItems(db: Db, kind: ContentKind): Promise<ContentItem[]> {
|
|
return db
|
|
.select()
|
|
.from(contentItems)
|
|
.where(and(eq(contentItems.kind, kind), eq(contentItems.status, "published")))
|
|
.orderBy(asc(contentItems.sortOrder));
|
|
}
|
|
|
|
export async function getCases(db: Db): Promise<CaseStudy[]> {
|
|
return db
|
|
.select()
|
|
.from(cases)
|
|
.where(eq(cases.status, "published"))
|
|
.orderBy(asc(cases.sortOrder));
|
|
}
|
|
|
|
export async function getPublishedPosts(db: Db): Promise<Post[]> {
|
|
return db
|
|
.select()
|
|
.from(posts)
|
|
.where(eq(posts.status, "published"))
|
|
.orderBy(desc(posts.publishedAt));
|
|
}
|
|
|
|
export async function getPostBySlug(db: Db, slug: string): Promise<Post | null> {
|
|
const [post] = await db
|
|
.select()
|
|
.from(posts)
|
|
.where(and(eq(posts.slug, slug), eq(posts.status, "published")))
|
|
.limit(1);
|
|
return post ?? null;
|
|
}
|
|
|
|
export async function getKeywords(db: Db): Promise<BlogKeyword[]> {
|
|
return db
|
|
.select()
|
|
.from(blogKeywords)
|
|
.orderBy(asc(blogKeywords.createdAt), asc(blogKeywords.id));
|
|
}
|
|
|
|
export type DashboardData = {
|
|
posts: { published: number; draft: number };
|
|
cases: { published: number; draft: number };
|
|
content: { kind: ContentKind; published: number; draft: number }[];
|
|
keywords: { pending: number; generated: number; skipped: number };
|
|
recentPosts: Post[];
|
|
postsWithoutCover: number;
|
|
};
|
|
|
|
/** 後台總覽用嘅統計(只讀 DB;AI secrets 由頁面層提供)。 */
|
|
export async function getDashboardData(db: Db): Promise<DashboardData> {
|
|
const [postRows, caseRows, itemRows, kwRows, recentPosts, noCover] = await Promise.all([
|
|
db.select({ status: posts.status, n: sql<number>`count(*)` }).from(posts).groupBy(posts.status),
|
|
db.select({ status: cases.status, n: sql<number>`count(*)` }).from(cases).groupBy(cases.status),
|
|
db
|
|
.select({ kind: contentItems.kind, status: contentItems.status, n: sql<number>`count(*)` })
|
|
.from(contentItems)
|
|
.groupBy(contentItems.kind, contentItems.status),
|
|
db
|
|
.select({ status: blogKeywords.status, n: sql<number>`count(*)` })
|
|
.from(blogKeywords)
|
|
.groupBy(blogKeywords.status),
|
|
db.select().from(posts).orderBy(desc(posts.updatedAt)).limit(5),
|
|
db.select({ n: sql<number>`count(*)` }).from(posts).where(isNull(posts.coverImage)),
|
|
]);
|
|
|
|
const pick = <T extends string>(rows: { status: T; n: number }[], status: T) =>
|
|
Number(rows.find((r) => r.status === status)?.n ?? 0);
|
|
|
|
return {
|
|
posts: { published: pick(postRows, "published"), draft: pick(postRows, "draft") },
|
|
cases: { published: pick(caseRows, "published"), draft: pick(caseRows, "draft") },
|
|
content: CONTENT_KINDS.map((kind) => ({
|
|
kind,
|
|
published: Number(itemRows.find((r) => r.kind === kind && r.status === "published")?.n ?? 0),
|
|
draft: Number(itemRows.find((r) => r.kind === kind && r.status === "draft")?.n ?? 0),
|
|
})),
|
|
keywords: {
|
|
pending: pick(kwRows, "pending"),
|
|
generated: pick(kwRows, "generated"),
|
|
skipped: pick(kwRows, "skipped"),
|
|
},
|
|
recentPosts,
|
|
postsWithoutCover: Number(noCover[0]?.n ?? 0),
|
|
};
|
|
}
|
|
|
|
export async function getHomeData(db: Db): Promise<HomeData> {
|
|
const [settings, services, features, steps, faqs, caseList] = await Promise.all([
|
|
getSettings(db),
|
|
getItems(db, "service"),
|
|
getItems(db, "feature"),
|
|
getItems(db, "step"),
|
|
getItems(db, "faq"),
|
|
getCases(db),
|
|
]);
|
|
return { settings, services, features, steps, faqs, cases: caseList };
|
|
}
|
|
|
|
export function digits(input: string | undefined): string {
|
|
return (input ?? "").replace(/\D/g, "");
|
|
}
|
|
|
|
export function whatsappHref(
|
|
settings: Settings,
|
|
text = "你好,我想查詢村屋太陽能安裝,想預約免費評估。",
|
|
): string {
|
|
return `https://api.whatsapp.com/send?phone=${digits(settings.whatsapp)}&text=${encodeURIComponent(text)}`;
|
|
}
|
|
|
|
export function telHref(settings: Settings): string {
|
|
const phone = (settings.phone ?? "").replace(/[^\d+]/g, "");
|
|
return `tel:${phone}`;
|
|
}
|
|
|
|
export function mailHref(settings: Settings): string {
|
|
return `mailto:${settings.email ?? ""}?subject=${encodeURIComponent("查詢村屋太陽能安裝")}`;
|
|
}
|
|
|
|
export function formatDate(d: Date | null | undefined): string {
|
|
if (!d) return "";
|
|
return new Date(d).toLocaleDateString("zh-Hant-HK", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
/** slug 撞咗就加 -2、-3…;selfId 用喺更新自己時排除自己。 */
|
|
export async function uniqueSlug(db: Db, base: string, selfId?: string): Promise<string> {
|
|
let candidate = base;
|
|
for (let i = 2; i < 50; i++) {
|
|
const rows = await db
|
|
.select({ id: posts.id })
|
|
.from(posts)
|
|
.where(selfId ? and(eq(posts.slug, candidate), ne(posts.id, selfId)) : eq(posts.slug, candidate))
|
|
.limit(1);
|
|
if (rows.length === 0) return candidate;
|
|
candidate = `${base}-${i}`;
|
|
}
|
|
return `${base}-${Date.now()}`;
|
|
}
|