diff --git a/.gitignore b/.gitignore index a9f0382..2eaecd5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ node_modules/ # ────────────────────── 构建产出(不推送)────────────────────── dist/ .wrangler/ +.cache/ +.vite/ +coverage/ +playwright-report/ +test-results/ # ────────────────────── 环境变量与密钥(绝不推送)────────────────── # 所有 .env 文件都可能含密钥,禁止推送 @@ -17,6 +22,7 @@ dist/ # Wrangler 本地密钥(含 ADMIN_PASSWORD / JWT_SECRET 等) .dev.vars +.dev.vars.* # ────────────────────── 系统与编辑器临时文件(不推送)────────────── .DS_Store @@ -31,15 +37,15 @@ Thumbs.db *.code-workspace # ────────────────────── AI 工具私有数据(绝不推送)────────────────── -# 通用记忆库(Aider / Cline / 其他) +# 通用记忆库(Aider / Cline / Serena / 其他) .agents/ +.serena/ -# Trae 项目私有数据:memory/ 含密码教训、PAT 用法、踩坑记录等敏感信息 -# rules/ skills/ 也仅供本机女仆使用,不入仓库 -.trae/ +# Codex 项目私有 AI 上下文与本机配置,不入仓库 +.codex/ +scripts/maintain-codex-context.mjs # 各家 AI 编辑器的项目入口与全局配置(含个人偏好/上下文,不入仓库) -CLAUDE.md GEMINI.md AGENTS.md opencode.json @@ -51,6 +57,9 @@ opencode.json # ────────────────────── 日志与调试产物(不推送)────────────────── *.log npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* # ────────────────────── 测试与临时输出(不推送)────────────────── tmp/ @@ -61,6 +70,8 @@ tests/output/ # ✅ package-lock.json — 锁定依赖版本,CI 的 npm ci 依赖它 # ✅ client/functions/ — Pages Functions 反向代理,漏掉则 /api/* 回退为首页 HTML # ✅ scripts/ — 部署脚本 (deploy-cloudflare.mjs) +# ✅ scripts/reconcile-d1-schema.mjs — 部署前 D1 schema 兼容补齐 +# ✅ server/src/migrations/ — D1 正式迁移,生产 schema 入口 # ✅ .github/ — CI/CD workflows、Issue/PR 模板、分支保护 # ✅ .coderabbit.yaml — CodeRabbit 代码审查配置 # ✅ SECURITY.md — 安全政策 diff --git a/client/src/components/admin-gate.tsx b/client/src/components/admin-gate.tsx index 166fbd3..1b26fe4 100644 --- a/client/src/components/admin-gate.tsx +++ b/client/src/components/admin-gate.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useLocation } from "wouter"; import { checkAuth, login } from "@/lib/api"; +import { ArrowRight, KeyRound, ShieldCheck, X } from "lucide-react"; /** * 管理后台暗门组件 @@ -21,6 +22,7 @@ export function AdminGate({ const [checking, setChecking] = useState(true); const [, setLocation] = useLocation(); const inputRef = useRef(null); + const modalRef = useRef(null); // 打开时立即检查是否已登录 useEffect(() => { @@ -64,11 +66,35 @@ export function AdminGate({ [password, onClose, setLocation] ); - // ESC 关闭 + // ESC 关闭,并将键盘焦点限制在弹窗内 useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") { + onClose(); + return; + } + + if (e.key !== "Tab") return; + + const focusableElements = Array.from( + modalRef.current?.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) || [] + ).filter((element) => !element.hasAttribute("disabled") && element.offsetParent !== null); + + if (focusableElements.length === 0) return; + + const first = focusableElements[0]; + const last = focusableElements[focusableElements.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); @@ -80,75 +106,115 @@ export function AdminGate({ <> {/* 背景遮罩 */}
{/* 密码框 */} -
-
- {checking ? ( -
- 验证中... +
+
+
+
+
+ +
+
+

后台安全验证

+

Monolith Admin Gate

+
- ) : ( -
-
-
- 🔐 -
-

- 管理员验证 -

+ +
+ +
+ {checking ? ( +
+
+ 正在检查登录状态
+ ) : ( + +
+
+
+ +
+
+

进入管理后台

+

+ 输入本地或生产环境配置的管理密码,验证通过后进入内容控制台。 +

+
+
+
+ 内容 + 媒体 + SEO +
+
+ + {/* 隐藏 username 字段:让 Bitwarden / 1Password / Chrome 等密码管理器识别为登录表单 */} + + + { + setPassword(e.target.value); + setError(""); + }} + placeholder="输入密码" + autoComplete="current-password" + aria-label="管理员密码" + className="h-[46px] w-full rounded-md border border-border/45 bg-background/55 px-[14px] pr-[48px] text-[15px] text-foreground outline-none transition-all placeholder:text-muted-foreground/35 focus:border-cyan-400/45 focus:ring-1 focus:ring-cyan-400/20" + /> + + {error && ( +

+ {error} +

+ )} + + - {/* 隐藏 username 字段:让 Bitwarden / 1Password / Chrome 等密码管理器识别为登录表单 */} - - - { - setPassword(e.target.value); - setError(""); - }} - placeholder="输入密码" - autoComplete="current-password" - aria-label="管理员密码" - className="h-[40px] w-full rounded-lg border border-border/40 bg-background/50 px-[14px] text-[14px] text-foreground placeholder:text-muted-foreground/30 outline-none focus:border-foreground/25 focus:ring-1 focus:ring-foreground/10 transition-all" - /> - - {error && ( -

- {error} +

+ ESC 关闭 · Ctrl Shift A 呼出

- )} - - - -

- 按 ESC 关闭 -

- - )} + + )} +
diff --git a/client/src/components/admin-layout.tsx b/client/src/components/admin-layout.tsx index cdaa976..570b85a 100644 --- a/client/src/components/admin-layout.tsx +++ b/client/src/components/admin-layout.tsx @@ -13,6 +13,7 @@ import { LogOut, ExternalLink, Menu, + Search, } from "lucide-react"; import { ThemeToggle } from "@/components/theme-toggle"; @@ -64,9 +65,14 @@ export function AdminLayout({ children }: AdminLayoutProps) { }, ]; + const currentTitle = + navGroups.flatMap((group) => group.items).find((item) => + item.href === "/admin" ? location === "/admin" : location.startsWith(item.href) + )?.label || "管理后台"; + const SidebarFooter = () => ( -
-
+
+
主题
@@ -74,14 +80,14 @@ export function AdminLayout({ children }: AdminLayoutProps) { href="/" target="_blank" rel="noopener noreferrer" - className="flex items-center gap-[10px] px-[12px] py-[8px] rounded-md text-[13px] font-medium text-muted-foreground/60 hover:bg-muted/50 hover:text-foreground transition-colors" + className="flex min-h-[44px] items-center gap-[10px] rounded-md px-[12px] py-[8px] text-[13px] font-medium text-muted-foreground/60 transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" > 查看站点
+
+
+

Monolith 管理后台

+

{currentTitle}

+
+ +
{children}
); -} \ No newline at end of file +} diff --git a/client/src/components/article-card.tsx b/client/src/components/article-card.tsx index d14362c..be48fa4 100644 --- a/client/src/components/article-card.tsx +++ b/client/src/components/article-card.tsx @@ -1,6 +1,7 @@ import { Link } from "wouter"; import { Badge } from "@/components/ui/badge"; import type { PostMeta } from "@/lib/api"; +import { ArrowRight, Pin } from "lucide-react"; function formatDate(dateStr: string): string { const date = new Date(dateStr); @@ -19,12 +20,15 @@ export function ArticleCard({ post }: { post: PostMeta }) { const gradient = post.coverColor || "from-gray-500/20 to-gray-600/20"; return ( - -
+ +
{/* 封面区 */} -
-
+
+
{cover ? ( {/* 内容区 */} -
+
{post.pinned && ( - 📌 + + + 置顶 + )} {post.tags.slice(0, 2).map((tag) => ( - {tag} + {tag} ))} {formatDate(post.createdAt)}
-

+

{post.title}

{post.excerpt}

-
+
阅读全文 - - - +
diff --git a/client/src/components/cookie-consent.tsx b/client/src/components/cookie-consent.tsx index 4534f82..01c9196 100644 --- a/client/src/components/cookie-consent.tsx +++ b/client/src/components/cookie-consent.tsx @@ -40,21 +40,21 @@ export function CookieConsent() { return (
-
+
本站使用 Cookie 进行访问统计与第三方脚本加载。继续访问即表示您同意我们的{" "} 隐私政策
-
+
@@ -62,4 +62,4 @@ export function CookieConsent() {
); -} \ No newline at end of file +} diff --git a/client/src/components/hero.tsx b/client/src/components/hero.tsx index 7642275..611e208 100644 --- a/client/src/components/hero.tsx +++ b/client/src/components/hero.tsx @@ -1,20 +1,23 @@ export function Hero() { return ( -
+
-
+
-

Monolith

+

+ Monolith +

书写代码、设计与边缘计算的个人博客。
- 在秩序与混沌的交界处,寻找属于自己的巨石碑。 + + 在秩序与混沌的交界处,寻找属于自己的巨石碑。 +

-
); } diff --git a/client/src/components/navbar.tsx b/client/src/components/navbar.tsx index 1073fb4..b048a1d 100644 --- a/client/src/components/navbar.tsx +++ b/client/src/components/navbar.tsx @@ -55,7 +55,7 @@ export function Navbar() { return ( <> -
+
- + @@ -126,4 +129,4 @@ export function Navbar() { )} ); -} \ No newline at end of file +} diff --git a/client/src/components/reading-controls.tsx b/client/src/components/reading-controls.tsx index 7d80e88..e73a04c 100644 --- a/client/src/components/reading-controls.tsx +++ b/client/src/components/reading-controls.tsx @@ -86,12 +86,13 @@ export function ReadingControls({ > {/* 选项面板 (Popover) */} {isOpen && ( -
+
- 阅读偏好 + 阅读偏好 @@ -104,22 +105,22 @@ export function ReadingControls({
@@ -131,13 +132,13 @@ export function ReadingControls({
@@ -149,22 +150,22 @@ export function ReadingControls({ 排版 (A: {preferences.fontSize} / H: {preferences.lineHeight.toFixed(1)})
{/* 字号 */} -
- -
{/* 行距 */} -
- Hgt -
@@ -174,12 +175,12 @@ export function ReadingControls({ {/* 专注宽度 */}
阅读区宽度 -
- {preferences.maxWidth}px -
diff --git a/client/src/components/search.tsx b/client/src/components/search.tsx index 8d34dc9..d5a0767 100644 --- a/client/src/components/search.tsx +++ b/client/src/components/search.tsx @@ -155,7 +155,7 @@ export function SearchOverlay() { return (
setOpen(false)} > {/* 背景遮罩 */} @@ -163,11 +163,14 @@ export function SearchOverlay() { {/* 搜索面板 */}
e.stopPropagation()} > {/* 搜索输入框 */} -
+
handleInputChange(e.target.value)} onKeyDown={handleKeyDown} - className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground/60 outline-none" + className="min-h-[44px] flex-1 bg-transparent text-[16px] text-foreground outline-none placeholder:text-muted-foreground/60 sm:text-[15px]" /> {loading && }
{/* 搜索结果 */} -
+
{query.trim() && !loading && results.length === 0 && (
@@ -202,13 +206,13 @@ export function SearchOverlay() { key={result.slug} href={`/posts/${result.slug}`} onClick={() => setOpen(false)} - className={`flex items-start gap-[12px] px-[20px] py-[14px] transition-colors duration-150 cursor-pointer ${ + className={`flex min-h-[72px] cursor-pointer items-start gap-[12px] px-[14px] py-[14px] transition-colors duration-150 sm:px-[20px] ${ index === selectedIndex ? "bg-accent/60" : "hover:bg-accent/30" }`} > -
+
@@ -238,7 +242,7 @@ export function SearchOverlay() {
{/* 底部提示 */} -
+
↑↓ diff --git a/client/src/components/theme-toggle.tsx b/client/src/components/theme-toggle.tsx index 1172b94..7be9016 100644 --- a/client/src/components/theme-toggle.tsx +++ b/client/src/components/theme-toggle.tsx @@ -54,7 +54,7 @@ export function ThemeToggle() { ))} -
+
- 浏览量 + 浏览量

{viewStats?.totalViews?.toLocaleString() ?? "—"}

@@ -167,7 +173,7 @@ export function AdminDashboard() { setSearch(e.target.value)} placeholder="搜索标题、Slug 或标签..." - className="h-[38px] w-full rounded-lg border border-border/20 bg-card/8 pl-[36px] pr-[14px] text-[13px] text-foreground placeholder:text-muted-foreground/25 outline-none focus:border-foreground/15 transition-all" + className="h-[44px] w-full rounded-md border border-border/20 bg-background/35 pl-[36px] pr-[14px] text-[14px] text-foreground outline-none transition-all placeholder:text-muted-foreground/35 focus:border-foreground/25 focus:bg-background/55" />
@@ -178,7 +184,7 @@ export function AdminDashboard() { {/* ─── 文章列表 ─── */}
-

+

{filter === "all" ? "所有文章" : filter === "published" ? "已发布" : "草稿箱"} {selectedTag && <>·{selectedTag}}

@@ -187,9 +193,9 @@ export function AdminDashboard() { {/* 批量操作工具栏 */} {filteredPosts.length > 0 && ( -
0 ? "border-cyan-500/30 bg-cyan-500/5" : ""}`}> +
0 ? "border-cyan-500/30 bg-cyan-500/5" : ""}`}>
- @@ -197,13 +203,13 @@ export function AdminDashboard() { {selectedSlugs.size > 0 && (
- - -
@@ -229,10 +235,10 @@ export function AdminDashboard() {
{filteredPosts.map((post) => ( -
+
{/* 复选框 */} - @@ -241,7 +247,7 @@ export function AdminDashboard() {
- {post.title} + {post.title} {post.pinned && 置顶}
@@ -252,14 +258,14 @@ export function AdminDashboard() {
{/* 操作按钮 — hover 显现 */} -
- +
+ - + -
@@ -297,9 +303,9 @@ export function AdminDashboard() { const scoreBorder = score >= 90 ? "border-emerald-500/20" : score >= 70 ? "border-amber-500/20" : "border-red-500/20"; return ( -
+
-

+

SEO 健康

{score}% @@ -340,7 +346,7 @@ export function AdminDashboard() { {allTags.length > 0 && (
-

标签

+

标签

{allTags.length > 8 && ( diff --git a/client/src/pages/admin/settings.tsx b/client/src/pages/admin/settings.tsx index 0c05223..05134c6 100644 --- a/client/src/pages/admin/settings.tsx +++ b/client/src/pages/admin/settings.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { getToken } from "@/lib/api"; -import { Save, Globe, User, Link2, ToggleLeft, ToggleRight, Code, Rss } from "lucide-react"; +import { Save, Globe, User, Link2, ToggleLeft, ToggleRight, Code, Rss, Plus, Trash2, GripVertical } from "lucide-react"; type Settings = { site_title: string; @@ -13,6 +13,7 @@ type Settings = { github_url: string; twitter_url: string; email: string; + social_links: string; footer_text: string; rss_enabled: string; custom_header: string; @@ -30,6 +31,7 @@ const defaultSettings: Settings = { github_url: "", twitter_url: "", email: "", + social_links: "", footer_text: "© 2026 Monolith. 使用 Hono + Vite 构建,部署于 Cloudflare 边缘。", rss_enabled: "true", custom_header: "", @@ -46,6 +48,95 @@ const TABS: TabDefinition[] = [ { id: "advanced", label: "扩展与注入", icon: Code }, ]; +type SocialIcon = "github" | "x" | "mail" | "rss" | "link"; + +type SocialLinkConfig = { + id: string; + label: string; + url: string; + icon: SocialIcon; + enabled: boolean; +}; + +const SOCIAL_ICON_OPTIONS: { value: SocialIcon; label: string }[] = [ + { value: "link", label: "链接" }, + { value: "github", label: "GitHub" }, + { value: "x", label: "X" }, + { value: "mail", label: "邮箱" }, + { value: "rss", label: "RSS" }, +]; + +function createSocialLink(link: Partial = {}): SocialLinkConfig { + return { + id: link.id || (typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `social-${Date.now()}`), + label: link.label || "", + url: link.url || "", + icon: link.icon || "link", + enabled: link.enabled ?? true, + }; +} + +function isSocialIcon(value: unknown): value is SocialIcon { + return typeof value === "string" && SOCIAL_ICON_OPTIONS.some((option) => option.value === value); +} + +function parseSocialLinks(value: string): SocialLinkConfig[] | null { + if (!value.trim()) return []; + try { + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed)) return null; + + return parsed + .filter((item): item is Record => typeof item === "object" && item !== null) + .map((item) => createSocialLink({ + id: typeof item.id === "string" ? item.id : undefined, + label: typeof item.label === "string" ? item.label : "", + url: typeof item.url === "string" ? item.url : "", + icon: isSocialIcon(item.icon) ? item.icon : "link", + enabled: typeof item.enabled === "boolean" ? item.enabled : true, + })); + } catch { + return null; + } +} + +function getLegacySocialLinks(settings: Settings): SocialLinkConfig[] { + const links: SocialLinkConfig[] = []; + if (settings.github_url) links.push(createSocialLink({ id: "legacy-github", label: "GitHub", url: settings.github_url, icon: "github" })); + if (settings.twitter_url) links.push(createSocialLink({ id: "legacy-x", label: "X", url: settings.twitter_url, icon: "x" })); + if (settings.email) links.push(createSocialLink({ id: "legacy-email", label: "邮箱", url: settings.email, icon: "mail" })); + return links; +} + +function getSocialLinks(settings: Settings): SocialLinkConfig[] { + if (!settings.social_links.trim()) return getLegacySocialLinks(settings); + const parsed = parseSocialLinks(settings.social_links); + return parsed === null ? getLegacySocialLinks(settings) : parsed; +} + +function serializeSocialLinks(links: SocialLinkConfig[]) { + return JSON.stringify(links.map((link) => ({ + id: link.id, + label: link.label.trim(), + url: link.url.trim(), + icon: link.icon, + enabled: link.enabled, + }))); +} + +function toLegacySocialFields(links: SocialLinkConfig[]) { + const enabledLinks = links.filter((link) => link.enabled); + const github = enabledLinks.find((link) => link.icon === "github"); + const x = enabledLinks.find((link) => link.icon === "x"); + const email = enabledLinks.find((link) => link.icon === "mail"); + + return { + github_url: github?.url.trim() || "", + twitter_url: x?.url.trim() || "", + email: email?.url.trim().replace(/^mailto:/i, "") || "", + }; +} + export function AdminSettings() { const [settings, setSettings] = useState(defaultSettings); const [saving, setSaving] = useState(false); @@ -89,6 +180,12 @@ export function AdminSettings() { const handleSave = async () => { setSaving(true); + const socialLinks = getSocialLinks(settings); + const nextSettings = { + ...settings, + ...toLegacySocialFields(socialLinks), + social_links: serializeSocialLinks(socialLinks), + }; try { const res = await fetch("/api/admin/settings", { method: "PUT", @@ -96,9 +193,10 @@ export function AdminSettings() { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}`, }, - body: JSON.stringify(settings), + body: JSON.stringify(nextSettings), }); if (!res.ok) throw new Error("保存失败"); + setSettings(nextSettings); showMsg("设置已保存", "success"); } catch { showMsg("保存失败", "error"); @@ -116,6 +214,23 @@ export function AdminSettings() { }, [settings.author_avatar]); const rssEnabled = settings.rss_enabled !== "false"; + const socialLinks = getSocialLinks(settings); + + const updateSocialLinks = (links: SocialLinkConfig[]) => { + setSettings((prev) => ({ ...prev, social_links: serializeSocialLinks(links) })); + }; + + const updateSocialLink = (id: string, patch: Partial) => { + updateSocialLinks(socialLinks.map((link) => link.id === id ? { ...link, ...patch } : link)); + }; + + const addSocialLink = () => { + updateSocialLinks([...socialLinks, createSocialLink({ label: "新链接", icon: "link" })]); + }; + + const removeSocialLink = (id: string) => { + updateSocialLinks(socialLinks.filter((link) => link.id !== id)); + }; if (loading) return
加载中...
; @@ -244,15 +359,90 @@ export function AdminSettings() { {activeTab === "social" && (
-

社交网络

-

提供连接外部平台与流量留存的入口。

-
- updateSetting("github_url", v)} placeholder="https://github.com/username" /> - updateSetting("twitter_url", v)} placeholder="https://x.com/username" /> - updateSetting("email", v)} placeholder="you@example.com" /> -
-
- 填入有效链接后将自动在首页侧边卡片中展示对应图标。 +
+
+

友链与社交入口

+

按需添加任意平台链接,启用后会展示在首页博主名片中。

+
+ +
+
+ {socialLinks.length > 0 ? ( +
+ {socialLinks.map((link) => ( +
+
+ +
+ + + + + +
+ ))} +
+ ) : ( +
+

还没有配置链接

+

添加 GitHub、邮箱、项目页或任意友链入口。

+
+ )} +
+
+ 旧版 GitHub、X、邮箱字段会自动迁移为列表项,保存后继续兼容旧接口。
diff --git a/client/src/pages/archive.tsx b/client/src/pages/archive.tsx index 08600ac..a2a2889 100644 --- a/client/src/pages/archive.tsx +++ b/client/src/pages/archive.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Link } from "wouter"; +import { Link, useSearch } from "wouter"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { fetchPosts, type PostMeta } from "@/lib/api"; @@ -11,39 +11,98 @@ function formatDate(dateStr: string): string { } export function ArchivePage() { + const searchString = useSearch(); const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + const loadPosts = () => { + setLoading(true); + setError(""); + fetchPosts() + .then((data) => { + setPosts(data); + setError(""); + }) + .catch((err: unknown) => { + console.error(err); + setError("归档加载失败,请检查网络后重试。"); + }) + .finally(() => setLoading(false)); + }; useEffect(() => { - fetchPosts().then(setPosts).catch(console.error).finally(() => setLoading(false)); + loadPosts(); }, []); + const selectedCategory = new URLSearchParams(searchString).get("category") || ""; + const visiblePosts = selectedCategory ? posts.filter((post) => post.category === selectedCategory) : posts; + const grouped = new Map(); - for (const post of posts) { + for (const post of visiblePosts) { const year = new Date(post.createdAt).getFullYear().toString(); if (!grouped.has(year)) grouped.set(year, []); grouped.get(year)!.push(post); } const years = Array.from(grouped.keys()).sort((a, b) => Number(b) - Number(a)); + const archiveTitle = selectedCategory ? `分类:${selectedCategory}` : "归档"; + const archiveDescription = selectedCategory + ? `共 ${visiblePosts.length} 篇 ${selectedCategory} 分类文章,按时间倒序排列。` + : `共 ${posts.length} 篇文章,按时间倒序排列。`; + const archiveUrl = selectedCategory ? `/archive?category=${encodeURIComponent(selectedCategory)}` : "/archive"; + const breadcrumbs = [ + { name: "首页", url: "/" }, + { name: "归档", url: "/archive" }, + ...(selectedCategory ? [{ name: `分类:${selectedCategory}`, url: archiveUrl }] : []), + ]; return ( -
- +
+
-

归档

-

共 {posts.length} 篇文章,按时间倒序排列。

+

{archiveTitle}

+
+

{archiveDescription}

+ {selectedCategory && ( + + 查看全部 + + )} +
- + {loading ? (
{[1, 2, 3, 4].map((i) =>
)}
+ ) : error ? ( +
+

{error}

+ +
+ ) : visiblePosts.length === 0 ? ( +
+

+ {selectedCategory ? "没有找到匹配文章" : "暂无文章"} +

+

+ {selectedCategory + ? "当前分类下暂无可见文章,可以返回全部归档继续浏览。" + : "博客暂无已发布文章,请稍后再来。"} +

+
) : ( years.map((year, yi) => (

{year}

-
+
{grouped.get(year)!.map((post) => ( - + {formatDate(post.createdAt).replace(/\d{4}年/, "")} {post.title}
diff --git a/client/src/pages/home.tsx b/client/src/pages/home.tsx index 5df4a41..ee4539f 100644 --- a/client/src/pages/home.tsx +++ b/client/src/pages/home.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react"; +import { Link } from "wouter"; import { Hero } from "@/components/hero"; import { ArticleCard } from "@/components/article-card"; @@ -6,7 +7,7 @@ import { Separator } from "@/components/ui/separator"; import { fetchPosts, fetchCategories, type PostMeta, type CategoryInfo } from "@/lib/api"; import { AnimateIn } from "@/hooks/use-animate"; import { SeoHead } from "@/components/seo-head"; -import { ExternalLink, Mail, Rss, Eye, FolderOpen, Hash, ChevronDown } from "lucide-react"; +import { ExternalLink, Mail, Rss, Eye, FolderOpen, Hash, ChevronDown, Link2 } from "lucide-react"; type PublicSettings = { site_title: string; @@ -19,6 +20,7 @@ type PublicSettings = { github_url: string; twitter_url: string; email: string; + social_links: string; rss_enabled: string; }; @@ -28,15 +30,107 @@ type TrafficData = { chart: { date: string; count: number }[]; }; +type SocialIcon = "github" | "x" | "mail" | "rss" | "link"; + +type SocialLinkConfig = { + id: string; + label: string; + url: string; + icon: SocialIcon; + enabled: boolean; +}; + +const SOCIAL_ICON_MAP: Record = { + github: ExternalLink, + x: ExternalLink, + mail: Mail, + rss: Rss, + link: Link2, +}; + +function isSocialIcon(value: unknown): value is SocialIcon { + return typeof value === "string" && ["github", "x", "mail", "rss", "link"].includes(value); +} + +function parseSocialLinks(value: string): SocialLinkConfig[] { + if (!value.trim()) return []; + try { + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed)) return []; + + return parsed + .filter((item): item is Record => typeof item === "object" && item !== null) + .map((item, index) => ({ + id: typeof item.id === "string" ? item.id : `social-${index}`, + label: typeof item.label === "string" ? item.label : "", + url: typeof item.url === "string" ? item.url : "", + icon: isSocialIcon(item.icon) ? item.icon : "link", + enabled: typeof item.enabled === "boolean" ? item.enabled : true, + })) + .filter((link) => link.enabled && link.label.trim() && link.url.trim()); + } catch { + return []; + } +} + +function normalizeSocialHref(link: SocialLinkConfig) { + const url = link.url.trim(); + const href = link.icon === "mail" && !url.startsWith("mailto:") + ? `mailto:${url}` + : link.icon === "rss" && !url + ? "/rss.xml" + : url; + + if (!href) return ""; + if (href.startsWith("//")) return ""; + if (!/^[a-z][a-z0-9+.-]*:/i.test(href)) return href; + + try { + const protocol = new URL(href).protocol; + return ["http:", "https:", "mailto:"].includes(protocol) ? href : ""; + } catch { + return ""; + } +} + +function getPublicSocialLinks(settings: PublicSettings | null): { id: string; icon: React.ElementType; href: string; label: string }[] { + if (!settings) return []; + + const configuredLinks = settings.social_links.trim() ? parseSocialLinks(settings.social_links) : []; + const legacyLinks: SocialLinkConfig[] = []; + if (settings.github_url) legacyLinks.push({ id: "legacy-github", label: "GitHub", url: settings.github_url, icon: "github", enabled: true }); + if (settings.twitter_url) legacyLinks.push({ id: "legacy-x", label: "X", url: settings.twitter_url, icon: "x", enabled: true }); + if (settings.email) legacyLinks.push({ id: "legacy-email", label: "邮箱", url: settings.email, icon: "mail", enabled: true }); + + const sourceLinks = configuredLinks.length > 0 || settings.social_links.trim() ? configuredLinks : legacyLinks; + + const links = sourceLinks + .map((link) => ({ + id: link.id, + icon: SOCIAL_ICON_MAP[link.icon] || ExternalLink, + href: normalizeSocialHref(link), + label: link.label.trim(), + })) + .filter((link) => link.href); + + if (links.length > 0 && settings.rss_enabled !== "false" && !links.some((link) => link.href === "/rss.xml")) { + links.push({ id: "rss-feed", icon: Rss, href: "/rss.xml", label: "RSS" }); + } + + return links; +} + /* ── 紧凑标签云 ── */ const TAG_VISIBLE = 15; +const CATEGORY_VISIBLE = 5; + function TagCloud({ tags, maxCount }: { tags: [string, number][]; maxCount: number }) { const [expanded, setExpanded] = useState(false); const hasMore = tags.length > TAG_VISIBLE; const visible = expanded ? tags : tags.slice(0, TAG_VISIBLE); return ( -
-

+
+

标签 {tags.length} @@ -45,13 +139,13 @@ function TagCloud({ tags, maxCount }: { tags: [string, number][]; maxCount: numb {visible.map(([tag, count]) => { // 频率归一化 0~1 映射透明度与字号 const ratio = maxCount > 1 ? (count - 1) / (maxCount - 1) : 0; - const opacity = 0.35 + ratio * 0.55; // 0.35 ~ 0.90 + const weight = 42 + ratio * 44; // 42% ~ 86% const size = 11 + ratio * 3; // 11px ~ 14px return ( {tag} @@ -62,7 +156,7 @@ function TagCloud({ tags, maxCount }: { tags: [string, number][]; maxCount: numb {hasMore && !expanded && ( @@ -71,6 +165,45 @@ function TagCloud({ tags, maxCount }: { tags: [string, number][]; maxCount: numb ); } +function CategoryList({ categories }: { categories: CategoryInfo[] }) { + const [expanded, setExpanded] = useState(false); + const hasMore = categories.length > CATEGORY_VISIBLE; + const visibleCategories = expanded ? categories : categories.slice(0, CATEGORY_VISIBLE); + + return ( +
+

+ + 分类 + {categories.length} +

+
8 ? "max-h-[280px] overflow-y-auto pr-[4px]" : ""}`}> + {visibleCategories.map((cat) => ( + + {cat.name} + {cat.count} + + ))} +
+ {hasMore && ( + + )} +
+ ); +} + /* ── 纯 SVG 迷你折线图 ── */ function SparkLine({ data, width = 240, height = 48 }: { data: number[]; width?: number; height?: number }) { const gradId = `sparkGrad-${React.useId().replace(/:/g, "")}`; @@ -144,21 +277,25 @@ export function HomePage() { const authorBio = settings?.author_bio || "热衷于前端架构、设计系统与边缘计算。相信技术应当服务于人,而非反过来。"; const authorAvatar = settings?.author_avatar || ""; - // 社交链接(只在有实质性社交信息时显示此行) - const socialLinks: { icon: React.ElementType; href: string; label: string }[] = []; - if (settings?.github_url) socialLinks.push({ icon: ExternalLink, href: settings.github_url, label: "GitHub" }); - if (settings?.email) socialLinks.push({ icon: Mail, href: `mailto:${settings.email}`, label: "邮箱" }); - if (socialLinks.length > 0 && settings?.rss_enabled !== "false") socialLinks.push({ icon: Rss, href: "/rss.xml", label: "RSS" }); + // 社交链接(优先读取新版可扩展列表,旧字段作为兼容回退) + const socialLinks = getPublicSocialLinks(settings); return (
- -
+
-

最新文章

+
+
+

Latest Posts

+

最新文章

+
+ {!loading && ( + {posts.length} 篇可读内容 + )} +
{loading ? (
@@ -168,20 +305,29 @@ export function HomePage() {
) : (
- {posts.map((post, i) => ( - - - - ))} + {posts.length > 0 ? ( + posts.map((post, i) => ( + + + + )) + ) : ( +
+

还没有发布文章

+

+ 本地数据库初始化后,最新文章会直接出现在这里。 +

+
+ )}
)}
-