Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions client/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const PostPage = lazy(() => import("@/pages/post").then((m) => ({ default: m.Pos
const ArchivePage = lazy(() => import("@/pages/archive").then((m) => ({ default: m.ArchivePage })));
const AboutPage = lazy(() => import("@/pages/about").then((m) => ({ default: m.AboutPage })));
const FriendsPage = lazy(() => import("@/pages/friends").then((m) => ({ default: m.FriendsPage })));
const GuestbookPage = lazy(() => import("@/pages/guestbook").then((m) => ({ default: m.GuestbookPage })));
const AdminLogin = lazy(() => import("@/pages/admin/login").then((m) => ({ default: m.AdminLogin })));
const AdminDashboard = lazy(() => import("@/pages/admin/dashboard").then((m) => ({ default: m.AdminDashboard })));
const AdminEditor = lazy(() => import("@/pages/admin/editor").then((m) => ({ default: m.AdminEditor })));
Expand All @@ -23,6 +24,7 @@ const AdminBackup = lazy(() => import("@/pages/admin/backup").then((m) => ({ def
const AdminPages = lazy(() => import("@/pages/admin/pages").then((m) => ({ default: m.AdminPages })));
const AdminComments = lazy(() => import("@/pages/admin/comments").then((m) => ({ default: m.AdminComments })));
const AdminFriends = lazy(() => import("@/pages/admin/friends").then((m) => ({ default: m.AdminFriends })));
const AdminGuestbook = lazy(() => import("@/pages/admin/guestbook").then((m) => ({ default: m.AdminGuestbook })));
const AdminMedia = lazy(() => import("@/pages/admin/media").then((m) => ({ default: m.AdminMedia })));
const AdminAnalytics = lazy(() => import("@/pages/admin/analytics").then((m) => ({ default: m.AdminAnalytics })));
const AdminSeo = lazy(() => import("@/pages/admin/seo").then((m) => ({ default: m.AdminSeo })));
Expand Down Expand Up @@ -169,6 +171,7 @@ export function App() {
<Route path="/archive" component={ArchivePage} />
<Route path="/about" component={AboutPage} />
<Route path="/friends" component={FriendsPage} />
<Route path="/guestbook" component={GuestbookPage} />
<Route path="/privacy" component={PrivacyPage} />
<Route path="/page/:slug" component={DynamicPage} />
<Route>
Expand Down Expand Up @@ -219,6 +222,7 @@ export function App() {
<Route path="/admin/pages"><AdminPages /></Route>
<Route path="/admin/comments"><AdminComments /></Route>
<Route path="/admin/friends"><AdminFriends /></Route>
<Route path="/admin/guestbook"><AdminGuestbook /></Route>
<Route path="/admin/media"><AdminMedia /></Route>
<Route path="/admin/analytics"><AdminAnalytics /></Route>
<Route path="/admin/seo"><AdminSeo /></Route>
Expand Down
1 change: 1 addition & 0 deletions client/src/components/admin-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const NAV_GROUPS = [
{ href: "/admin/pages", icon: StickyNote, label: "页面管理" },
{ href: "/admin/comments", icon: MessageCircle, label: "互动审核" },
{ href: "/admin/friends", icon: Link2, label: "友链管理" },
{ href: "/admin/guestbook", icon: MessageCircle, label: "留言板" },
],
},
{
Expand Down
149 changes: 86 additions & 63 deletions client/src/components/article-card.tsx
Original file line number Diff line number Diff line change
@@ -1,87 +1,110 @@
import { Link } from "wouter";
import { Badge } from "@/components/ui/badge";
import type { PostMeta } from "@/lib/api";
import { clampCardHeight, clampCardWidth, getArticleCardGridClass, getArticleCardImageMode } from "@/lib/card-layout";
import { ArrowRight, CalendarDays, FolderOpen, Pin } from "lucide-react";
import type { CSSProperties } from "react";

function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" });
}

/** 取标题首字作为无图占位 */
function getInitial(title: string): string {
if (!title) return "·";
const ch = title.trim().charAt(0);
return ch || "·";
}

export function ArticleCard({ post }: { post: PostMeta }) {
const cover = post.coverImage || "";
const gradient = post.coverColor || "from-gray-500/20 to-gray-600/20";
const width = clampCardWidth(post.cardWidth);
const height = clampCardHeight(post.cardHeight);
const cover = post.coverImage?.trim() || "";
const imageMode = getArticleCardImageMode(width, height, Boolean(cover));
const isBackground = imageMode === "background";
const compact = height < 190;
const style = {
"--article-card-height": `${height}px`,
} as CSSProperties;
const gridClass = getArticleCardGridClass(width);
const titleClass = "line-clamp-2 font-heading text-[20px] font-semibold leading-snug tracking-[-0.018em] text-foreground transition-colors duration-200 group-hover:text-foreground/90 lg:text-[23px]";
const excerptClass = compact
? "mt-[8px] line-clamp-1 text-[13px] leading-[1.65] text-muted-foreground"
: "mt-[10px] line-clamp-2 text-[14px] leading-[1.75] text-muted-foreground";

const meta = (
<div className="mb-[10px] flex flex-wrap items-center gap-[8px]">
{post.pinned && (
<Badge variant="outline" className="h-[24px] rounded-[4px] border-amber-500/30 bg-amber-500/10 px-[8px] text-[12px] font-normal tracking-normal text-amber-500/90">
<Pin className="h-[12px] w-[12px]" />
置顶
</Badge>
)}
{post.tags.slice(0, 2).map((tag) => (
<Badge key={tag} variant="secondary" className="h-[24px] rounded-[4px] px-[8px] text-[12px] font-normal tracking-normal">{tag}</Badge>
))}
<span className="inline-flex items-center gap-[4px] text-[12px] text-muted-foreground/55">
<CalendarDays className="h-[12px] w-[12px]" />
{formatDate(post.createdAt)}
</span>
</div>
);

const body = (
<>
{meta}
<h2 className={titleClass}>{post.title}</h2>
<p className={excerptClass}>{post.excerpt}</p>
<div className={`mt-auto flex min-h-[36px] items-end justify-between gap-[12px] ${compact ? "pt-[8px]" : "pt-[14px]"}`}>
<span className="inline-flex min-w-0 items-center gap-[4px] text-[12px] text-muted-foreground/35">
<FolderOpen className="h-[12px] w-[12px] shrink-0" />
<span className="truncate">{post.category || "未分类"}</span>
</span>
<span className="inline-flex items-center gap-[6px] text-[13px] text-muted-foreground/55 transition-colors duration-200 group-hover:text-foreground">
阅读全文
<ArrowRight className="h-[14px] w-[14px] transition-transform duration-200 group-hover:translate-x-[3px]" />
</span>
</div>
</>
);

return (
<Link
href={`/posts/${post.slug}`}
className="group block rounded-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"
className={`group block w-full rounded-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring ${gridClass}`}
style={style}
>
<article className="relative overflow-hidden rounded-md border border-border/20 bg-background/30 transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] hover:-translate-y-[2px] hover:border-border/55 hover:bg-card/28">
<div className="grid sm:grid-cols-[168px_minmax(0,1fr)] lg:grid-cols-[196px_minmax(0,1fr)]">
{/* 封面区 */}
<div className="relative overflow-hidden border-b border-border/16 bg-foreground/[0.03] sm:border-b-0 sm:border-r">
<div className="aspect-[16/9] sm:h-full sm:aspect-auto sm:min-h-[148px]">
{cover ? (
<img
src={cover}
alt={post.title}
loading="lazy"
decoding="async"
className="h-full w-full object-cover transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-[1.04]"
/>
) : (
<div className={`flex h-full w-full items-center justify-center bg-gradient-to-br ${gradient} grayscale`}>
<span className="select-none font-heading text-[48px] font-semibold tracking-[-0.04em] text-foreground/26 lg:text-[58px]">
{getInitial(post.title)}
</span>
</div>
)}
<article
className={`relative flex h-[var(--article-card-height)] overflow-hidden rounded-md border border-border/20 bg-background/30 transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] hover:-translate-y-[2px] hover:border-border/55 hover:bg-card/28 ${isBackground ? "bg-card/18" : ""}`}
>
{imageMode === "background" && (
<>
<img src={cover} alt="" loading="lazy" decoding="async" className="absolute inset-0 h-full w-full object-cover opacity-40 transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-[1.03]" />
<div className="absolute inset-0 bg-gradient-to-br from-background/92 via-background/74 to-background/44" />
<div className="relative flex min-h-0 w-full flex-col p-[16px] sm:p-[18px] lg:p-[20px]">{body}</div>
</>
)}
{imageMode === "side" && (
<div className="grid min-h-0 w-full md:grid-cols-[minmax(0,1fr)_34%]">
<div className="flex min-w-0 flex-col p-[16px] sm:p-[18px] lg:p-[20px]">{body}</div>
<div className="hidden border-l border-border/16 md:block">
<img src={cover} alt={post.title} loading="lazy" decoding="async" className="h-full w-full object-cover opacity-80 transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-[1.04]" />
</div>
</div>

{/* 内容区 */}
<div className="flex min-w-0 flex-1 flex-col p-[16px] sm:p-[18px] lg:p-[20px]">
<div className="mb-[10px] flex flex-wrap items-center gap-[8px]">
{post.pinned && (
<Badge variant="outline" className="h-[24px] rounded-[4px] border-amber-500/30 bg-amber-500/10 px-[8px] text-[12px] font-normal tracking-normal text-amber-500/90">
<Pin className="h-[12px] w-[12px]" />
置顶
</Badge>
)}
{post.tags.slice(0, 2).map((tag) => (
<Badge key={tag} variant="secondary" className="h-[24px] rounded-[4px] px-[8px] text-[12px] font-normal tracking-normal">{tag}</Badge>
))}
<span className="inline-flex items-center gap-[4px] text-[12px] text-muted-foreground/55">
<CalendarDays className="h-[12px] w-[12px]" />
{formatDate(post.createdAt)}
</span>
)}
{imageMode === "top" && (
<div className="flex min-h-0 w-full flex-col">
<div className="h-[132px] overflow-hidden border-b border-border/16">
<img src={cover} alt={post.title} loading="lazy" decoding="async" className="h-full w-full object-cover opacity-80 transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-[1.04]" />
</div>
<h2 className="font-heading text-[20px] font-semibold tracking-[-0.018em] leading-snug text-foreground transition-colors duration-200 group-hover:text-foreground/90 lg:text-[23px]">
{post.title}
</h2>
<p className="mt-[10px] text-[14px] leading-[1.75] text-muted-foreground line-clamp-2">
{post.excerpt}
</p>
<div className="mt-auto flex min-h-[36px] items-end justify-between gap-[12px] pt-[14px]">
<span className="inline-flex min-w-0 items-center gap-[4px] text-[12px] text-muted-foreground/35">
<FolderOpen className="h-[12px] w-[12px] shrink-0" />
<span className="truncate">{post.category || "未分类"}</span>
</span>
<span className="inline-flex items-center gap-[6px] text-[13px] text-muted-foreground/55 transition-colors duration-200 group-hover:text-foreground">
阅读全文
<ArrowRight className="h-[14px] w-[14px] transition-transform duration-200 group-hover:translate-x-[3px]" />
</span>
<div className="flex min-w-0 flex-1 flex-col p-[16px] sm:p-[18px] lg:p-[20px]">{body}</div>
</div>
)}
{imageMode === "thumbnail" && (
<div className="flex min-h-0 w-full gap-[14px] p-[16px] sm:p-[18px] lg:p-[20px]">
<div className="flex min-w-0 flex-1 flex-col">{body}</div>
<div className="hidden h-[76px] w-[116px] shrink-0 overflow-hidden rounded-md border border-border/16 sm:block">
<img src={cover} alt={post.title} loading="lazy" decoding="async" className="h-full w-full object-cover opacity-80 transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-[1.04]" />
</div>
</div>
</div>
)}
{imageMode === "text" && (
<div className="flex min-h-0 w-full flex-col p-[16px] sm:p-[18px] lg:p-[20px]">{body}</div>
)}
</article>
</Link>
);
Expand Down
1 change: 1 addition & 0 deletions client/src/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const fixedStart = [{ href: "/", label: "首页" }];
const fixedEnd = [
{ href: "/archive", label: "归档" },
{ href: "/friends", label: "友链" },
{ href: "/guestbook", label: "留言" },
{ href: "/about", label: "关于" },
];

Expand Down
63 changes: 63 additions & 0 deletions client/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export type PostMeta = {
excerpt: string | null;
coverColor: string | null;
coverImage: string | null;
cardWidth: number;
cardHeight: number;
createdAt: string;
tags: string[];
pinned: boolean;
Expand Down Expand Up @@ -242,6 +244,9 @@ export async function createPost(data: {
content: string;
excerpt?: string;
coverColor?: string;
coverImage?: string;
cardWidth?: number;
cardHeight?: number;
published?: boolean;
tags?: string[];
pinned?: boolean;
Expand Down Expand Up @@ -551,6 +556,20 @@ export type AdminComment = CommentData & {
postTitle: string;
};

export type GuestbookMessage = {
id: number;
authorName: string;
authorEmail?: string;
content: string;
approved: boolean;
createdAt: string;
};

export type GuestbookPage = {
items: GuestbookMessage[];
nextCursor: number | null;
};

export async function fetchComments(slug: string): Promise<CommentData[]> {
const res = await fetch(`${API_BASE}/api/posts/${slug}/comments`);
if (!res.ok) throw new Error("获取评论失败");
Expand All @@ -571,6 +590,25 @@ export async function submitComment(slug: string, data: {
return res.json();
}

export async function fetchGuestbookMessages(before?: number): Promise<GuestbookPage> {
const query = before ? `?before=${before}` : "";
return fetchJsonWithCache<GuestbookPage>(`/api/guestbook${query}`, 60_000);
}

export async function submitGuestbookMessage(data: {
authorName: string;
authorEmail?: string;
content: string;
_hp?: string;
}): Promise<{ success: boolean; message?: string; error?: string }> {
const res = await fetch(`${API_BASE}/api/guestbook`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
return res.json();
}

export async function fetchAdminComments(): Promise<AdminComment[]> {
const res = await fetch(`${API_BASE}/api/admin/comments`, {
headers: authHeaders(),
Expand All @@ -579,6 +617,15 @@ export async function fetchAdminComments(): Promise<AdminComment[]> {
return res.json();
}

export async function fetchAdminGuestbookMessages(before?: number): Promise<GuestbookPage> {
const query = before ? `?before=${before}` : "";
const res = await fetch(`${API_BASE}/api/admin/guestbook${query}`, {
headers: authHeaders(),
});
if (!res.ok) throw new Error("获取留言失败");
return res.json();
}

export async function fetchAdminFriends(): Promise<FriendLink[]> {
const res = await fetch(`${API_BASE}/api/admin/friends`, {
headers: authHeaders(),
Expand Down Expand Up @@ -656,6 +703,22 @@ export async function deleteComment(id: number): Promise<void> {
if (!res.ok) throw new Error("删除失败");
}

export async function approveGuestbookMessage(id: number): Promise<void> {
const res = await fetch(`${API_BASE}/api/admin/guestbook/${id}/approve`, {
method: "POST",
headers: authHeaders(),
});
if (!res.ok) throw new Error(await readError(res, "审核失败"));
}

export async function deleteGuestbookMessage(id: number): Promise<void> {
const res = await fetch(`${API_BASE}/api/admin/guestbook/${id}`, {
method: "DELETE",
headers: authHeaders(),
});
if (!res.ok) throw new Error(await readError(res, "删除失败"));
}

/* ── 媒体管理 ──────────────────────────────── */
export type MediaItem = {
key: string;
Expand Down
48 changes: 48 additions & 0 deletions client/src/lib/card-layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export type CardImageMode = "text" | "background" | "side" | "top" | "thumbnail";
export type CardGridSize = "full" | "half" | "third";

export const CARD_IMAGE_MODE_LABEL: Record<CardImageMode, string> = {
text: "纯文字",
background: "背景图",
side: "侧图",
top: "顶图",
thumbnail: "缩略图",
};

export function clampCardWidth(value: number | null | undefined) {
const next = Number.isFinite(value) ? Number(value) : 100;
return Math.min(100, Math.max(42, Math.round(next)));
}

export function clampCardHeight(value: number | null | undefined) {
const next = Number.isFinite(value) ? Number(value) : 220;
return Math.min(420, Math.max(156, Math.round(next)));
}

export function getCardGridSize(width: number): CardGridSize {
const normalized = clampCardWidth(width);
if (normalized >= 84) return "full";
if (normalized >= 58) return "half";
return "third";
}

export const CARD_GRID_SIZE_LABEL: Record<CardGridSize, string> = {
full: "整行",
half: "双列",
third: "三列",
};

export function getArticleCardGridClass(width: number): string {
const gridSize = getCardGridSize(width);
if (gridSize === "full") return "sm:col-span-6 md:col-span-12";
if (gridSize === "half") return "sm:col-span-3 md:col-span-6";
return "sm:col-span-2 md:col-span-4";
}

export function getArticleCardImageMode(width: number, height: number, hasCover: boolean): CardImageMode {
if (!hasCover) return "text";
if (width >= 82 && height >= 260) return "background";
if (width >= 68 && height >= 190) return "side";
if (height >= 250) return "top";
return "thumbnail";
}
Loading