commit bfdf4843e1653f3d4ea86785e6235778762a13bc Author: 爱喝水的木子 Date: Fri Mar 13 16:28:51 2026 +0800 OPC diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3127f19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# dependencies +node_modules +.pnp +.pnp.js + +# production +.next +out + +# debug +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env +.env +.env.local +.env.*.local + +# misc +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec7da97 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# OPC 信息流平台 + +面向 OPC(One Person Company,一人公司)的轻量信息流发布站。 +个人即公司:记录更新、发布进展、同步给关注你的人;简单、克制、能持续。 + +## 你会得到什么 +- 一个可公开访问的“个人公司动态墙” +- 支持 Markdown 的内容发布 +- 文章详情页可长期访问(稳定 URL) +- RSS 订阅与站点地图,方便搜索与关注 +- 后台口令发布与统计,维护成本低 + +## 典型使用场景 +- 个人产品更新、周报、交付日志 +- 公开路线图、里程碑、版本发布 +- 内容实验、灵感记录、对外同步 + +## 技术栈 +- Next.js 14(App Router) +- MongoDB Atlas(免费云数据库) +- Tailwind CSS +- Markdown 渲染 + +## 快速开始 +1. 安装依赖 +```bash +npm install +``` + +2. 新建 `.env.local` +``` +MONGODB_URI=你的Mongo连接串 +MONGODB_DB=pushinfo +ADMIN_PASS=自定义后台口令 +SESSION_SECRET=任意长随机字符串 +NEXT_PUBLIC_SITE_URL=https://你的域名 +``` + +3. 开发运行 +```bash +npm run dev +``` + +## 功能清单 +- 首页信息流:最新发布置顶 +- 文章详情页:Markdown 渲染 +- 后台:口令登录、发布、编辑/删除、统计 +- 标签聚合页与标签详情页 +- 搜索与分页 +- RSS:`/rss`(标题:不务正业的木子;文案:less is more) +- Sitemap:`/sitemap.xml` + +## 部署建议 +- 前端:Vercel +- 数据库:MongoDB Atlas 免费套餐 +- 图片:图床(如 `https://img.020417.xyz`) + +## 设计原则 +- 低摩擦发布 +- 少即是多 +- 长期可维护 + +## TODO +- 草稿与置顶 +- 私密分享链接 +- 全文索引(MongoDB Text Index) diff --git a/app/admin/edit/[slug]/page.tsx b/app/admin/edit/[slug]/page.tsx new file mode 100644 index 0000000..11e1409 --- /dev/null +++ b/app/admin/edit/[slug]/page.tsx @@ -0,0 +1,28 @@ +import { getDb } from "@/lib/mongo"; +import { notFound } from "next/navigation"; +import { EditPostForm } from "@/components/EditPostForm"; + +export const dynamic = "force-dynamic"; + +async function fetchPost(slug: string) { + const db = await getDb(); + const post = await db.collection("posts").findOne({ slug }); + if (!post) return null; + return { + ...post, + _id: post._id?.toString() + }; +} + +export default async function EditPostPage({ params }: { params: { slug: string } }) { + const post = await fetchPost(params.slug); + if (!post) { + notFound(); + } + + return ( +
+ +
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..80acda6 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,73 @@ +import { CreatePostForm } from "@/components/CreatePostForm"; +import { AdminPostList } from "@/components/AdminPostList"; +import { getDb } from "@/lib/mongo"; + +export const dynamic = "force-dynamic"; + +async function fetchStats() { + const db = await getDb(); + const collection = db.collection("posts"); + const total = await collection.countDocuments(); + const latest = await collection.find({}, { projection: { title: 1, createdAt: 1 } }).sort({ createdAt: -1 }).limit(1).toArray(); + const top = await collection.find({}, { projection: { title: 1, views: 1, slug: 1 } }).sort({ views: -1 }).limit(3).toArray(); + + return { total, latest: latest[0] || null, top }; +} + +async function fetchRecentPosts() { + const db = await getDb(); + const posts = await db + .collection("posts") + .find({}, { projection: { markdown: 0 } }) + .sort({ createdAt: -1 }) + .limit(20) + .toArray(); + return posts.map((p: any) => ({ + ...p, + _id: p._id?.toString(), + createdAtText: new Date(p.createdAt).toLocaleString("zh-CN", { + hour12: false, + timeZone: "Asia/Shanghai" + }) + })); +} + +export default async function AdminPage() { + const stats = await fetchStats(); + const recentPosts = await fetchRecentPosts(); + + return ( +
+
+
+

累计发布

+

{stats.total}

+
+
+

最新一条

+

{stats.latest?.title ?? "暂无"}

+

+ {stats.latest?.createdAt + ? new Date(stats.latest.createdAt).toLocaleString("zh-CN", { hour12: false }) + : ""} +

+
+
+

Top 热度

+
    + {stats.top.map((item: any) => ( +
  • + {item.title} + {item.views ?? 0} 次 +
  • + ))} +
+
+
+ + + + +
+ ); +} diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts new file mode 100644 index 0000000..ca6b186 --- /dev/null +++ b/app/api/admin/stats/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getDb } from "@/lib/mongo"; + +export async function GET() { + const db = await getDb(); + const collection = db.collection("posts"); + const total = await collection.countDocuments(); + const latest = await collection.find({}, { projection: { title: 1, createdAt: 1 } }).sort({ createdAt: -1 }).limit(1).toArray(); + const top = await collection.find({}, { projection: { title: 1, views: 1, slug: 1 } }).sort({ views: -1 }).limit(3).toArray(); + + return NextResponse.json({ + total, + latest: latest[0] || null, + top + }); +} diff --git a/app/api/login/route.ts b/app/api/login/route.ts new file mode 100644 index 0000000..49bed81 --- /dev/null +++ b/app/api/login/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { signSession, cookieName } from "@/lib/auth"; + +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const password = body.password as string | undefined; + const target = process.env.ADMIN_PASS; + + if (!target) { + return NextResponse.json({ error: "ADMIN_PASS is not set on server" }, { status: 500 }); + } + + if (!password || password !== target) { + return NextResponse.json({ error: "密码错误" }, { status: 401 }); + } + + const token = await signSession({ role: "admin", iat: Date.now() }); + const res = NextResponse.json({ ok: true }); + res.cookies.set(cookieName, token, { + httpOnly: true, + sameSite: "lax", + secure: true, + maxAge: 60 * 60 * 24 * 30, + path: "/" + }); + return res; +} diff --git a/app/api/posts/[slug]/route.ts b/app/api/posts/[slug]/route.ts new file mode 100644 index 0000000..afb5070 --- /dev/null +++ b/app/api/posts/[slug]/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/mongo"; +import { ObjectId } from "mongodb"; +import { z } from "zod"; + +export async function GET(_: NextRequest, { params }: { params: { slug: string } }) { + const db = await getDb(); + const post = await db.collection("posts").findOneAndUpdate( + { slug: params.slug }, + { $inc: { views: 1 } }, + { returnDocument: "after" } + ); + + if (!post.value) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + return NextResponse.json({ + ...post.value, + _id: (post.value._id as ObjectId)?.toString() + }); +} + +export async function PATCH(req: NextRequest, { params }: { params: { slug: string } }) { + const body = await req.json().catch(() => ({})); + const schema = z.object({ + title: z.string().min(2).max(80).optional(), + markdown: z.string().min(5).optional(), + cover: z.string().url().nullable().optional(), + tags: z.array(z.string().trim()).optional(), + author: z.string().optional() + }); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + const data = parsed.data; + const set: Record = { + updatedAt: new Date().toISOString() + }; + const unset: Record = {}; + + if (typeof data.title === "string") set.title = data.title; + if (typeof data.markdown === "string") set.markdown = data.markdown; + if (data.cover === null) { + unset.cover = ""; + } else if (typeof data.cover === "string") { + set.cover = data.cover; + } + if (Array.isArray(data.tags)) set.tags = data.tags; + if (typeof data.author === "string") set.author = data.author; + + const update: Record = { $set: set }; + if (Object.keys(unset).length > 0) update.$unset = unset; + + const db = await getDb(); + const result = await db.collection("posts").updateOne({ slug: params.slug }, update); + if (result.matchedCount === 0) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json({ ok: true }); +} + +export async function DELETE(_: NextRequest, { params }: { params: { slug: string } }) { + const db = await getDb(); + const result = await db.collection("posts").deleteOne({ slug: params.slug }); + if (result.deletedCount === 0) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts new file mode 100644 index 0000000..b51397f --- /dev/null +++ b/app/api/posts/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/mongo"; +import { z } from "zod"; +import { generateSlug } from "@/lib/slug"; + +const postSchema = z.object({ + title: z.string().min(2).max(80), + markdown: z.string().min(5), + cover: z.string().url().optional(), + tags: z.array(z.string().trim()).optional(), + author: z.string().default("admin") +}); + +export async function GET() { + const db = await getDb(); + const posts = await db + .collection("posts") + .find({}, { projection: { markdown: 0 } }) + .sort({ createdAt: -1 }) + .limit(50) + .toArray(); + + return NextResponse.json( + posts.map((p) => ({ + ...p, + _id: p._id?.toString() + })) + ); +} + +export async function POST(req: NextRequest) { + const json = await req.json().catch(() => ({})); + const parsed = postSchema.safeParse(json); + + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + } + + const data = parsed.data; + const now = new Date().toISOString(); + let slug = generateSlug(); + const db = await getDb(); + + for (let attempt = 0; attempt < 5; attempt += 1) { + const exists = await db.collection("posts").findOne({ slug }); + if (!exists) break; + slug = generateSlug(); + } + const existsAfter = await db.collection("posts").findOne({ slug }); + if (existsAfter) { + return NextResponse.json({ error: "Failed to allocate slug" }, { status: 500 }); + } + + const doc = { + ...data, + slug, + createdAt: now, + updatedAt: now, + views: 0 + }; + + await db.collection("posts").insertOne(doc); + return NextResponse.json({ ok: true, slug }); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..c3bffd2 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,23 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: light; +} + +body { + background: radial-gradient(circle at 10% 20%, #f5f7ff, #ffffff 35%, #f7faff); + min-height: 100vh; + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +.prose img { + border-radius: 0.75rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.07); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..2055554 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,44 @@ +import "./globals.css"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Push Info", + description: "轻量信息流发布平台,支持 Markdown 与多端浏览。" +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + +
+
+ + + Push + + 信息流 + + +
+
{children}
+
+ 云图床:img.020417.xyz + Made for friends · {new Date().getFullYear()} +
+
+ + + ); +} diff --git a/app/login/login-form.tsx b/app/login/login-form.tsx new file mode 100644 index 0000000..4f478d4 --- /dev/null +++ b/app/login/login-form.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +export default function LoginForm() { + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const router = useRouter(); + const params = useSearchParams(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(""); + try { + const res = await fetch("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }) + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error || "登录失败"); + } else { + const next = params.get("next") || "/admin"; + router.push(next); + router.refresh(); + } + } finally { + setLoading(false); + } + } + + return ( +
+ + {error ?

{error}

: null} + +
+ ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..f249786 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,19 @@ +import LoginForm from "./login-form"; + +export const metadata = { + title: "登录 - Push Info" +}; + +export default function LoginPage() { + return ( +
+

管理员登录

+

+ 输入后台口令即可发布/统计。口令保存在服务器的环境变量 ADMIN_PASS。 +

+
+ +
+
+ ); +} diff --git a/app/p/[slug]/page.tsx b/app/p/[slug]/page.tsx new file mode 100644 index 0000000..e99d15a --- /dev/null +++ b/app/p/[slug]/page.tsx @@ -0,0 +1,52 @@ +import { getDb } from "@/lib/mongo"; +import { MarkdownViewer } from "@/components/MarkdownViewer"; +import { notFound } from "next/navigation"; +import { normalizeImageUrl } from "@/lib/normalize"; + +export const dynamic = "force-dynamic"; + +type Props = { + params: { slug: string }; +}; + +async function fetchPost(slug: string) { + const db = await getDb(); + const doc = await db.collection("posts").findOne({ slug }); + if (!doc) return null; + // fire-and-forget view increment + db.collection("posts").updateOne({ slug }, { $inc: { views: 1 } }).catch(() => {}); + return { + ...doc, + _id: doc._id?.toString() + }; +} + +export default async function PostPage({ params }: Props) { + const post = await fetchPost(params.slug); + + if (!post) { + notFound(); + } + const coverUrl = normalizeImageUrl(post.cover); + + return ( +
+
+

{post.title}

+

+ {new Date(post.createdAt).toLocaleString("zh-CN", { hour12: false })} +

+ {coverUrl ? ( + + ) : null} +
+ +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..77bf4b6 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,143 @@ +import { getDb } from "@/lib/mongo"; +import { PostCard } from "@/components/PostCard"; +import { Post } from "@/types/post"; +import { buildSearchFilter } from "@/lib/search"; + +export const dynamic = "force-dynamic"; + +const PAGE_SIZE = 10; + +async function fetchPosts(params: { + tag?: string; + q?: string; + page: number; +}): Promise<{ posts: Post[]; total: number; page: number; totalPages: number }> { + const db = await getDb(); + const filters: Record[] = []; + if (params.tag) { + filters.push({ tags: params.tag }); + } + const searchFilter = buildSearchFilter(params.q); + if (searchFilter) { + filters.push(searchFilter as Record); + } + const filter = filters.length > 0 ? { $and: filters } : {}; + const total = await db.collection("posts").countDocuments(filter); + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const page = Math.min(Math.max(params.page, 1), totalPages); + const docs = await db + .collection("posts") + .find(filter, { projection: { markdown: 0 } }) + .sort({ createdAt: -1 }) + .skip((page - 1) * PAGE_SIZE) + .limit(PAGE_SIZE) + .toArray(); + return { + posts: docs.map((d: any) => ({ + ...d, + _id: d._id?.toString() + })), + total, + page, + totalPages + }; +} + +export default async function HomePage({ + searchParams +}: { + searchParams?: { tag?: string; q?: string; page?: string }; +}) { + 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 buildHref = (targetPage: number) => { + const params = new URLSearchParams(); + if (tag) params.set("tag", tag); + if (q) params.set("q", q); + if (targetPage > 1) params.set("page", String(targetPage)); + const qs = params.toString(); + return qs ? `/?${qs}` : "/"; + }; + + return ( +
+
+

朋友圈 · 信息流

+

+ Markdown 排版 · 云图床 · 响应式 · 免费 MongoDB Atlas 存储。 +

+ {tag ? ( +
+ 筛选标签:#{tag} + + 清除 + +
+ ) : null} +
+ +
+ {tag ? : null} + + + {q ? ( + + 清除搜索 + + ) : null} + 共 {total} 条 +
+ + {posts.length === 0 ? ( +

+ 暂无匹配内容。 +

+ ) : ( +
+ {posts.map((post) => ( + + ))} +
+ )} + + +
+ ); +} diff --git a/app/rss/route.ts b/app/rss/route.ts new file mode 100644 index 0000000..47ec14f --- /dev/null +++ b/app/rss/route.ts @@ -0,0 +1,71 @@ +import { getDb } from "@/lib/mongo"; +import { getSiteUrl } from "@/lib/site"; + +export const dynamic = "force-dynamic"; + +function escapeXml(input: string): string { + return input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function stripMarkdown(input: string): string { + return input + .replace(/```[\s\S]*?```/g, " ") + .replace(/`[^`]*`/g, " ") + .replace(/!\[[^\]]*]\([^)]+\)/g, " ") + .replace(/\[[^\]]*]\([^)]+\)/g, "$1") + .replace(/[#>*_~\-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export async function GET() { + const db = await getDb(); + const posts = await db + .collection("posts") + .find({}, { projection: { title: 1, slug: 1, markdown: 1, createdAt: 1, updatedAt: 1 } }) + .sort({ createdAt: -1 }) + .limit(50) + .toArray(); + + const siteUrl = getSiteUrl(); + const now = new Date().toUTCString(); + + const items = posts + .map((post: any) => { + const link = `${siteUrl}/p/${post.slug}`; + const description = stripMarkdown(post.markdown || "").slice(0, 200); + const pubDate = new Date(post.createdAt || post.updatedAt || Date.now()).toUTCString(); + return [ + "", + `${escapeXml(post.title || "Untitled")}`, + `${escapeXml(link)}`, + `${escapeXml(link)}`, + `${pubDate}`, + `${escapeXml(description)}`, + "" + ].join(""); + }) + .join(""); + + const xml = ` + + +不务正业的木子 +${escapeXml(siteUrl)} +less is more +${now} +${items} + +`; + + return new Response(xml, { + headers: { + "Content-Type": "application/rss+xml; charset=utf-8" + } + }); +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..c96938a --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,32 @@ +import type { MetadataRoute } from "next"; +import { getDb } from "@/lib/mongo"; +import { getSiteUrl } from "@/lib/site"; + +export const dynamic = "force-dynamic"; + +export default async function sitemap(): Promise { + const db = await getDb(); + const posts = await db + .collection("posts") + .find({}, { projection: { slug: 1, updatedAt: 1, createdAt: 1 } }) + .sort({ createdAt: -1 }) + .limit(1000) + .toArray(); + + const siteUrl = getSiteUrl(); + const items: MetadataRoute.Sitemap = [ + { + url: `${siteUrl}/`, + lastModified: new Date() + } + ]; + + for (const post of posts) { + items.push({ + url: `${siteUrl}/p/${post.slug}`, + lastModified: new Date(post.updatedAt || post.createdAt || Date.now()) + }); + } + + return items; +} diff --git a/app/tags/[tag]/page.tsx b/app/tags/[tag]/page.tsx new file mode 100644 index 0000000..09d4c73 --- /dev/null +++ b/app/tags/[tag]/page.tsx @@ -0,0 +1,130 @@ +import { getDb } from "@/lib/mongo"; +import { PostCard } from "@/components/PostCard"; +import { Post } from "@/types/post"; +import { buildSearchFilter } from "@/lib/search"; + +export const dynamic = "force-dynamic"; + +const PAGE_SIZE = 10; + +async function fetchTagPosts(params: { + tag: string; + q?: string; + page: number; +}): Promise<{ posts: Post[]; total: number; page: number; totalPages: number }> { + const db = await getDb(); + const filters: Record[] = [{ tags: params.tag }]; + const searchFilter = buildSearchFilter(params.q); + if (searchFilter) { + filters.push(searchFilter as Record); + } + const filter = { $and: filters }; + const total = await db.collection("posts").countDocuments(filter); + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const page = Math.min(Math.max(params.page, 1), totalPages); + const docs = await db + .collection("posts") + .find(filter, { projection: { markdown: 0 } }) + .sort({ createdAt: -1 }) + .skip((page - 1) * PAGE_SIZE) + .limit(PAGE_SIZE) + .toArray(); + return { + posts: docs.map((d: any) => ({ + ...d, + _id: d._id?.toString() + })), + total, + page, + totalPages + }; +} + +export default async function TagDetailPage({ + params, + searchParams +}: { + params: { tag: string }; + searchParams?: { q?: string; page?: string }; +}) { + const tag = params.tag; + const q = searchParams?.q?.trim(); + const page = Number.parseInt(searchParams?.page || "1", 10) || 1; + const { posts, total, totalPages, page: currentPage } = await fetchTagPosts({ tag, q, page }); + + const buildHref = (targetPage: number) => { + const params = new URLSearchParams(); + if (q) params.set("q", q); + if (targetPage > 1) params.set("page", String(targetPage)); + const qs = params.toString(); + return qs ? `/tags/${encodeURIComponent(tag)}?${qs}` : `/tags/${encodeURIComponent(tag)}`; + }; + + return ( +
+
+

标签:#{tag}

+

共 {total} 条内容。

+
+ +
+ + + {q ? ( + + 清除搜索 + + ) : null} +
+ + {posts.length === 0 ? ( +

+ 暂无匹配内容。 +

+ ) : ( +
+ {posts.map((post) => ( + + ))} +
+ )} + + +
+ ); +} diff --git a/app/tags/page.tsx b/app/tags/page.tsx new file mode 100644 index 0000000..865c99b --- /dev/null +++ b/app/tags/page.tsx @@ -0,0 +1,44 @@ +import { getDb } from "@/lib/mongo"; + +export const dynamic = "force-dynamic"; + +type TagItem = { _id: string; count: number }; + +export default async function TagsPage() { + const db = await getDb(); + const tags = (await db + .collection("posts") + .aggregate([ + { $unwind: "$tags" }, + { $group: { _id: "$tags", count: { $sum: 1 } } }, + { $sort: { count: -1, _id: 1 } } + ]) + .toArray()) as TagItem[]; + + return ( +
+
+

标签聚合

+

按标签浏览所有文章。

+
+ + {tags.length === 0 ? ( +

+ 暂无标签内容。 +

+ ) : ( +
+ {tags.map((tag) => ( + + #{tag._id} ({tag.count}) + + ))} +
+ )} +
+ ); +} diff --git a/components/AdminPostList.tsx b/components/AdminPostList.tsx new file mode 100644 index 0000000..06c52af --- /dev/null +++ b/components/AdminPostList.tsx @@ -0,0 +1,87 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { Post } from "@/types/post"; + +type AdminPost = Post & { createdAtText?: string }; + +export function AdminPostList({ initialPosts }: { initialPosts: AdminPost[] }) { + const [posts, setPosts] = useState(initialPosts); + + async function handleDelete(slug: string) { + if (!window.confirm("确定要删除这篇文章吗?此操作不可恢复。")) return; + const res = await fetch(`/api/posts/${slug}`, { method: "DELETE" }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(data.error || "删除失败"); + return; + } + setPosts((prev) => prev.filter((post) => post.slug !== slug)); + } + + if (posts.length === 0) { + return ( +
+ 暂无文章。 +
+ ); + } + + return ( +
+
+

最近发布

+ 共 {posts.length} 条 +
+
+ {posts.map((post) => ( +
+
+ + {post.title} + +

+ {post.createdAtText || + new Date(post.createdAt).toLocaleString("zh-CN", { + hour12: false, + timeZone: "Asia/Shanghai" + })} +

+ {post.tags && post.tags.length > 0 ? ( +
+ {post.tags.map((tag) => ( + + #{tag} + + ))} +
+ ) : null} +
+
+ + 编辑 + + +
+
+ ))} +
+
+ ); +} diff --git a/components/CreatePostForm.tsx b/components/CreatePostForm.tsx new file mode 100644 index 0000000..7eee7c6 --- /dev/null +++ b/components/CreatePostForm.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; +import { normalizeImageUrl } from "@/lib/normalize"; + +const defaultIntro = `## 新帖内容 + +- 这里支持 **Markdown** +- 图片请上传到 img.020417.xyz 后填入链接 +`; + +export function CreatePostForm() { + const [title, setTitle] = useState(""); + const [cover, setCover] = useState(""); + const [tags, setTags] = useState(""); + const [markdown, setMarkdown] = useState(defaultIntro); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setLoading(true); + try { + const normalizedCover = normalizeImageUrl(cover); + const res = await fetch("/api/posts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + markdown, + cover: normalizedCover, + tags: Array.from( + new Set( + tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + ) + ) + }) + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + alert(data.error ? JSON.stringify(data.error) : "发布失败"); + } else { + const data = await res.json(); + router.push(`/p/${data.slug}`); + router.refresh(); + } + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

新建一条信息

+

填写后提交,立刻出现在首页最上方。

+
+ +
+ + + + + + + +