From 8c7148f819990979f980e26fa02cd23f2a3077c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=88=B1=E5=96=9D=E6=B0=B4=E7=9A=84=E6=9C=A8=E5=AD=90?= Date: Fri, 20 Mar 2026 14:31:24 +0800 Subject: [PATCH] Add configurable homepage social links --- app/admin/page.tsx | 22 +++-- app/api/admin/settings/route.ts | 53 +++++++++++ app/page.tsx | 15 +++- components/SocialLinksBar.tsx | 60 +++++++++++++ components/SocialLinksManager.tsx | 140 ++++++++++++++++++++++++++++++ lib/site-settings.ts | 89 +++++++++++++++++++ types/site.ts | 10 +++ 7 files changed, 379 insertions(+), 10 deletions(-) create mode 100644 app/api/admin/settings/route.ts create mode 100644 components/SocialLinksBar.tsx create mode 100644 components/SocialLinksManager.tsx create mode 100644 lib/site-settings.ts create mode 100644 types/site.ts diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 53678dc..43a73e0 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -2,9 +2,11 @@ import { cookies } from "next/headers"; import { AdminPostList } from "@/components/AdminPostList"; import { AdminUserManager } from "@/components/AdminUserManager"; import { CreatePostForm } from "@/components/CreatePostForm"; +import { SocialLinksManager } from "@/components/SocialLinksManager"; import { cookieName, isAdminSession, verifySession } from "@/lib/auth"; import { getDb } from "@/lib/mongo"; import { buildOwnedPostFilter, buildPinnedSort, serializePost } from "@/lib/posts"; +import { getDefaultSocialLinks, getSiteSettings } from "@/lib/site-settings"; import { findUserById, getEffectiveDailyPostLimit, getShanghaiDayRange } from "@/lib/users"; import { Post } from "@/types/post"; @@ -203,13 +205,15 @@ export default async function AdminPage() { const adminView = isAdminSession(session); const roleLabel = ROLE_LABELS[(session?.role as ManagedUser["role"]) || "user"]; - const [recentPosts, favoritePosts, availableTags, publishQuota, managedUsers] = await Promise.all([ - fetchRecentPosts(session), - fetchFavoritePosts(session), - fetchAvailableTags(session), - fetchPublishQuota(session), - adminView ? fetchManagedUsers() : Promise.resolve([] as ManagedUser[]) - ]); + const [recentPosts, favoritePosts, availableTags, publishQuota, managedUsers, siteSettings] = + await Promise.all([ + fetchRecentPosts(session), + fetchFavoritePosts(session), + fetchAvailableTags(session), + fetchPublishQuota(session), + adminView ? fetchManagedUsers() : Promise.resolve([] as ManagedUser[]), + adminView ? getSiteSettings() : Promise.resolve({ socialLinks: getDefaultSocialLinks() }) + ]); return (
@@ -218,7 +222,7 @@ export default async function AdminPage() {

内容后台

- 登录用户可以发布、编辑自己的内容和管理自己的收藏;管理员额外拥有置顶、删帖、删用户和调整用户等级/额度的全部权限。 + 登录用户可以发布、编辑自己的内容和管理自己的收藏;管理员额外拥有置顶、删帖、删用户、调整用户等级/额度,以及维护首页社交链接的全部权限。

@@ -235,6 +239,8 @@ export default async function AdminPage() {
+ {adminView ? : null} + ({})); + const parsed = siteSettingsSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + await updateSiteSettings({ + socialLinks: parsed.data.socialLinks + }); + + return NextResponse.json({ ok: true, socialLinks: parsed.data.socialLinks }); +} diff --git a/app/page.tsx b/app/page.tsx index d43c606..91fffe0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,9 @@ import { PostCard } from "@/components/PostCard"; +import { SocialLinksBar } from "@/components/SocialLinksBar"; import { getDb } from "@/lib/mongo"; import { buildPinnedSort, serializePost } from "@/lib/posts"; import { buildSearchFilter } from "@/lib/search"; +import { getSiteSettings } from "@/lib/site-settings"; import { Post } from "@/types/post"; export const dynamic = "force-dynamic"; @@ -53,7 +55,11 @@ export default async function HomePage({ const tag = searchParams?.tag?.trim(); const q = searchParams?.q?.trim(); const page = Number.parseInt(searchParams?.page || "1", 10) || 1; - const { posts, total, totalPages, page: currentPage } = await fetchPosts({ tag, q, page }); + + const [{ posts, total, totalPages, page: currentPage }, siteSettings] = await Promise.all([ + fetchPosts({ tag, q, page }), + getSiteSettings() + ]); const buildHref = (targetPage: number) => { const params = new URLSearchParams(); @@ -85,8 +91,13 @@ export default async function HomePage({

未登录用户可以浏览全部内容;登录用户可以发布并修改自己的内容;置顶内容会优先展示。

+ +
+ +
+ {tag ? ( -
+
当前标签 #{tag} item.url && item.label); +} + +export function SocialLinksBar({ + links, + title = "社交链接", + compact = false +}: { + links: SocialLink[]; + title?: string; + compact?: boolean; +}) { + const visibleLinks = normalizeLinks(links); + if (visibleLinks.length === 0) { + return null; + } + + return ( + + ); +} diff --git a/components/SocialLinksManager.tsx b/components/SocialLinksManager.tsx new file mode 100644 index 0000000..763aa1d --- /dev/null +++ b/components/SocialLinksManager.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState } from "react"; +import { SocialLink } from "@/types/site"; + +function createDraftLink(index: number): SocialLink { + return { + id: `social-link-${Date.now()}-${index}`, + label: "", + url: "", + iconUrl: "" + }; +} + +export function SocialLinksManager({ initialLinks }: { initialLinks: SocialLink[] }) { + const [links, setLinks] = useState(initialLinks); + const [saving, setSaving] = useState(false); + + function updateLink(id: string, key: keyof SocialLink, value: string) { + setLinks((prev) => prev.map((item) => (item.id === id ? { ...item, [key]: value } : item))); + } + + function addLink() { + setLinks((prev) => [...prev, createDraftLink(prev.length + 1)]); + } + + function removeLink(id: string) { + setLinks((prev) => prev.filter((item) => item.id !== id)); + } + + async function handleSave() { + const sanitized = links + .map((item) => ({ + ...item, + label: item.label.trim(), + url: item.url.trim(), + iconUrl: item.iconUrl?.trim() || "" + })) + .filter((item) => item.label || item.url || item.iconUrl); + + if (sanitized.some((item) => !item.label || !item.url)) { + alert("每条社交链接都需要填写名称和地址。"); + return; + } + + setSaving(true); + try { + const res = await fetch("/api/admin/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ socialLinks: sanitized }) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error || "保存社交链接失败"); + return; + } + + setLinks(data.socialLinks || sanitized); + alert("社交链接已保存。"); + } finally { + setSaving(false); + } + } + + return ( +
+
+

首页社交链接

+

+ 可配置首页顶部的 GitHub、B站等入口,也可以新增其他链接,并为每个入口自定义图标地址。 +

+
+ +
+ {links.map((link, index) => ( +
+
+

链接 {index + 1}

+ +
+
+ + + +
+
+ ))} +
+ +
+ + +
+
+ ); +} diff --git a/lib/site-settings.ts b/lib/site-settings.ts new file mode 100644 index 0000000..5dffc09 --- /dev/null +++ b/lib/site-settings.ts @@ -0,0 +1,89 @@ +import { getDb } from "@/lib/mongo"; +import { SiteSettings, SocialLink } from "@/types/site"; + +const SETTINGS_COLLECTION = "settings"; +const SITE_SETTINGS_ID = "site"; + +type SiteSettingsDocument = { + _id: string; + socialLinks?: SocialLink[]; +}; + +const DEFAULT_SOCIAL_LINKS: SocialLink[] = [ + { + id: "github", + label: "GitHub", + url: "", + iconUrl: "" + }, + { + id: "bilibili", + label: "B站", + url: "", + iconUrl: "" + } +]; + +function normalizeSocialLink(link: any, index: number): SocialLink | null { + if (!link || typeof link !== "object") { + return null; + } + + const label = typeof link.label === "string" ? link.label.trim() : ""; + const url = typeof link.url === "string" ? link.url.trim() : ""; + const iconUrl = typeof link.iconUrl === "string" ? link.iconUrl.trim() : ""; + const id = + typeof link.id === "string" && link.id.trim() + ? link.id.trim() + : `social-link-${index + 1}`; + + if (!label && !url && !iconUrl) { + return null; + } + + return { + id, + label: label || `链接 ${index + 1}`, + url, + iconUrl + }; +} + +function getSettingsCollection() { + return getDb().then((db) => db.collection(SETTINGS_COLLECTION)); +} + +export function getDefaultSocialLinks() { + return DEFAULT_SOCIAL_LINKS.map((item) => ({ ...item })); +} + +export function normalizeSiteSettings(doc?: Partial | null): SiteSettings { + const socialLinks = Array.isArray(doc?.socialLinks) + ? doc.socialLinks + .map((item: any, index: number) => normalizeSocialLink(item, index)) + .filter(Boolean) + : []; + + return { + socialLinks: socialLinks.length > 0 ? (socialLinks as SocialLink[]) : getDefaultSocialLinks() + }; +} + +export async function getSiteSettings(): Promise { + const collection = await getSettingsCollection(); + const doc = await collection.findOne({ _id: SITE_SETTINGS_ID }); + return normalizeSiteSettings(doc); +} + +export async function updateSiteSettings(settings: SiteSettings) { + const collection = await getSettingsCollection(); + await collection.updateOne( + { _id: SITE_SETTINGS_ID }, + { + $set: { + socialLinks: settings.socialLinks + } + }, + { upsert: true } + ); +} diff --git a/types/site.ts b/types/site.ts new file mode 100644 index 0000000..abcba0f --- /dev/null +++ b/types/site.ts @@ -0,0 +1,10 @@ +export type SocialLink = { + id: string; + label: string; + url: string; + iconUrl?: string; +}; + +export type SiteSettings = { + socialLinks: SocialLink[]; +};