From 17f5f6adcb6426d24aa5c3a7763918f64090dccc 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: Thu, 19 Mar 2026 20:17:56 +0800 Subject: [PATCH] Add registration flow and improve admin post management --- README.md | 66 ++++------ app/admin/edit/[slug]/page.tsx | 4 +- app/admin/page.tsx | 213 +++++++++++++++++++++++++++++++-- app/api/login/route.ts | 42 +++++-- app/api/posts/[slug]/route.ts | 6 +- app/api/posts/route.ts | 12 +- app/api/register/route.ts | 59 +++++++++ app/layout.tsx | 54 ++++++--- app/login/login-form.tsx | 26 ++-- app/login/page.tsx | 17 +-- app/p/[slug]/page.tsx | 6 +- app/page.tsx | 40 +++++-- app/register/page.tsx | 23 ++++ app/register/register-form.tsx | 88 ++++++++++++++ app/tags/[tag]/page.tsx | 9 +- app/tags/page.tsx | 6 +- components/AdminPostList.tsx | 60 +++++++--- components/CreatePostForm.tsx | 101 +++++++++++----- components/EditPostForm.tsx | 51 +++++--- components/MarkdownPreview.tsx | 12 ++ components/PostCard.tsx | 5 +- components/SharePanel.tsx | 2 +- lib/auth.ts | 33 ++++- lib/opc.ts | 23 ++++ lib/password.ts | 20 ++++ package-lock.json | 11 ++ test_cn.txt | 1 + types/post.ts | 1 + 28 files changed, 799 insertions(+), 192 deletions(-) create mode 100644 app/api/register/route.ts create mode 100644 app/register/page.tsx create mode 100644 app/register/register-form.tsx create mode 100644 components/MarkdownPreview.tsx create mode 100644 lib/opc.ts create mode 100644 lib/password.ts create mode 100644 test_cn.txt diff --git a/README.md b/README.md index ec7da97..b0fdd49 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,42 @@ -# OPC 信息流平台 +# OPC Solo Feed 信息流 -面向 OPC(One Person Company,一人公司)的轻量信息流发布站。 -个人即公司:记录更新、发布进展、同步给关注你的人;简单、克制、能持续。 +OPC(One Person Company,一人即是公司)信息流项目,用于个人/小团队以“信息流平台”的方式发布动态、记录进展与沉淀内容。 -## 你会得到什么 -- 一个可公开访问的“个人公司动态墙” -- 支持 Markdown 的内容发布 -- 文章详情页可长期访问(稳定 URL) -- RSS 订阅与站点地图,方便搜索与关注 -- 后台口令发布与统计,维护成本低 - -## 典型使用场景 -- 个人产品更新、周报、交付日志 -- 公开路线图、里程碑、版本发布 -- 内容实验、灵感记录、对外同步 +## 功能概览 +- 信息流首页:搜索、标签筛选 +- 内容详情:Markdown 渲染、分享链接/二维码、阅读统计 +- 后台管理:登录、注册、发布/编辑/删除、统计面板 +- 登录有效期:24 小时,过期需重新登录 ## 技术栈 - Next.js 14(App Router) -- MongoDB Atlas(免费云数据库) +- MongoDB Atlas - Tailwind CSS -- Markdown 渲染 +- Markdown -## 快速开始 -1. 安装依赖 +## 本地开发 +1. 安装依赖 ```bash npm install ``` -2. 新建 `.env.local` +2. 配置环境变量:新建 `.env.local` ``` MONGODB_URI=你的Mongo连接串 MONGODB_DB=pushinfo -ADMIN_PASS=自定义后台口令 -SESSION_SECRET=任意长随机字符串 -NEXT_PUBLIC_SITE_URL=https://你的域名 +SESSION_SECRET=用于签名的随机长字符串 +NEXT_PUBLIC_SITE_URL=https://你的站点域名 ``` -3. 开发运行 +3. 启动开发服务器 ```bash npm run dev ``` -## 功能清单 -- 首页信息流:最新发布置顶 -- 文章详情页:Markdown 渲染 -- 后台:口令登录、发布、编辑/删除、统计 -- 标签聚合页与标签详情页 -- 搜索与分页 -- RSS:`/rss`(标题:不务正业的木子;文案:less is more) -- Sitemap:`/sitemap.xml` +4. 打开页面并注册账号 +- 注册:`/register` +- 登录:`/login` -## 部署建议 -- 前端:Vercel -- 数据库:MongoDB Atlas 免费套餐 -- 图片:图床(如 `https://img.020417.xyz`) - -## 设计原则 -- 低摩擦发布 -- 少即是多 -- 长期可维护 - -## TODO -- 草稿与置顶 -- 私密分享链接 -- 全文索引(MongoDB Text Index) +## 说明 +- 后台入口:`/admin` +- 登录后右上角显示当前用户名 diff --git a/app/admin/edit/[slug]/page.tsx b/app/admin/edit/[slug]/page.tsx index 1f0092f..e06a9e2 100644 --- a/app/admin/edit/[slug]/page.tsx +++ b/app/admin/edit/[slug]/page.tsx @@ -2,6 +2,7 @@ import { getDb } from "@/lib/mongo"; import { notFound } from "next/navigation"; import { EditPostForm } from "@/components/EditPostForm"; import { Post } from "@/types/post"; +import { DEFAULT_OPC_SIGNAL } from "@/lib/opc"; export const dynamic = "force-dynamic"; @@ -16,7 +17,8 @@ async function fetchPost(slug: string): Promise { markdown: post.markdown ?? "", cover: post.cover, tags: post.tags ?? [], - author: post.author ?? "admin", + signal: post.signal ?? DEFAULT_OPC_SIGNAL, + author: post.author ?? "佚名", createdAt: post.createdAt ?? new Date().toISOString(), updatedAt: post.updatedAt ?? post.createdAt ?? new Date().toISOString(), views: post.views ?? 0 diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 80acda6..58ab2e9 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -4,14 +4,40 @@ import { getDb } from "@/lib/mongo"; export const dynamic = "force-dynamic"; +const cardClass = + "rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100 transition-[transform,box-shadow] duration-300 will-change-transform transform-gpu hover:shadow-lg hover:[transform:perspective(900px)_translateY(-4px)_rotateX(2deg)_rotateY(-2deg)]"; + 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(); + 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(); + const viewsAgg = await collection + .aggregate([{ $group: { _id: null, totalViews: { $sum: { $ifNull: ["$views", 0] } } } }]) + .toArray(); + const totalViews = viewsAgg[0]?.totalViews ?? 0; + const avgViews = total > 0 ? Math.round(totalViews / total) : 0; + const tagCount = (await collection.distinct("tags")).filter(Boolean).length; + const authorCount = (await collection.distinct("author")).filter(Boolean).length; - return { total, latest: latest[0] || null, top }; + return { + total, + latest: latest[0] || null, + top, + totalViews, + avgViews, + tagCount, + authorCount + }; } async function fetchRecentPosts() { @@ -25,6 +51,7 @@ async function fetchRecentPosts() { return posts.map((p: any) => ({ ...p, _id: p._id?.toString(), + author: p.author ?? "佚名", createdAtText: new Date(p.createdAt).toLocaleString("zh-CN", { hour12: false, timeZone: "Asia/Shanghai" @@ -32,19 +59,91 @@ async function fetchRecentPosts() { })); } +async function fetchAllTags() { + 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(); + return tags.map((t: any) => t._id).filter(Boolean); +} + +async function fetchTagStats() { + const db = await getDb(); + const tags = await db + .collection("posts") + .aggregate([ + { $unwind: "$tags" }, + { $group: { _id: "$tags", count: { $sum: 1 } } }, + { $sort: { count: -1, _id: 1 } }, + { $limit: 12 } + ]) + .toArray(); + return tags.map((t: any) => ({ tag: t._id, count: t.count })); +} + +async function fetchAuthorStats() { + const db = await getDb(); + const authors = await db + .collection("posts") + .aggregate([ + { $group: { _id: { $ifNull: ["$author", "佚名"] }, count: { $sum: 1 } } }, + { $sort: { count: -1, _id: 1 } }, + { $limit: 10 } + ]) + .toArray(); + return authors.map((a: any) => ({ author: a._id, count: a.count })); +} + +async function fetchDailyStats() { + const db = await getDb(); + const now = new Date(); + const days: { key: string; label: string }[] = []; + for (let i = 6; i >= 0; i -= 1) { + const d = new Date(now); + d.setDate(now.getDate() - i); + const key = d.toISOString().slice(0, 10); + const label = `${d.getMonth() + 1}/${d.getDate()}`; + days.push({ key, label }); + } + const since = `${days[0].key}T00:00:00.000Z`; + const raw = await db + .collection("posts") + .aggregate([ + { $match: { createdAt: { $gte: since } } }, + { $addFields: { day: { $dateToString: { format: "%Y-%m-%d", date: { $toDate: "$createdAt" } } } } }, + { $group: { _id: "$day", count: { $sum: 1 } } } + ]) + .toArray(); + const map = new Map(); + raw.forEach((item: any) => map.set(item._id, item.count)); + return days.map((d) => ({ label: d.label, count: map.get(d.key) ?? 0 })); +} + export default async function AdminPage() { const stats = await fetchStats(); const recentPosts = await fetchRecentPosts(); + const availableTags = await fetchAllTags(); + const tagStats = await fetchTagStats(); + const authorStats = await fetchAuthorStats(); + const dailyStats = await fetchDailyStats(); + const tagMax = Math.max(...tagStats.map((t) => t.count), 1); + const authorMax = Math.max(...authorStats.map((a) => a.count), 1); + const dailyMax = Math.max(...dailyStats.map((d) => d.count), 1); return (
-
-

累计发布

+
+

总内容数

{stats.total}

-
-

最新一条

+
+

最新发布

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

{stats.latest?.createdAt @@ -52,20 +151,112 @@ export default async function AdminPage() { : ""}

-
-

Top 热度

+
+

Top 阅读

    {stats.top.map((item: any) => (
  • {item.title} - {item.views ?? 0} 次 + {item.views ?? 0} 阅读
  • ))}
- +
+

统计面板

+
+
+

总阅读

+

{stats.totalViews}

+
+
+

平均阅读

+

{stats.avgViews}

+
+
+

标签数量

+

{stats.tagCount}

+
+
+

作者数量

+

{stats.authorCount}

+
+
+ +
+
+

标签分布

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

暂无标签数据

+ ) : ( + tagStats.map((item) => ( +
+
+ #{item.tag} + {item.count} +
+
+
+
+
+ )) + )} +
+
+ +
+

作者分布

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

暂无作者数据

+ ) : ( + authorStats.map((item) => ( +
+
+ {item.author} + {item.count} +
+
+
+
+
+ )) + )} +
+
+
+ +
+

近 7 天发布

+
+ {dailyStats.map((item) => ( +
+
+ {item.label} + {item.count} +
+
+
+
+
+ ))} +
+
+
+ +
diff --git a/app/api/login/route.ts b/app/api/login/route.ts index 49bed81..77c0796 100644 --- a/app/api/login/route.ts +++ b/app/api/login/route.ts @@ -1,26 +1,48 @@ import { NextRequest, NextResponse } from "next/server"; import { signSession, cookieName } from "@/lib/auth"; +import { getDb } from "@/lib/mongo"; +import { verifyPassword } from "@/lib/password"; +import { z } from "zod"; 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; + const schema = z.object({ + username: z.string().trim().min(2).max(32), + password: z.string().min(6).max(128) + }); + const parsed = schema.safeParse(body); - if (!target) { - return NextResponse.json({ error: "ADMIN_PASS is not set on server" }, { status: 500 }); + if (!parsed.success) { + return NextResponse.json({ error: "请输入正确的用户名与密码" }, { status: 400 }); } - if (!password || password !== target) { - return NextResponse.json({ error: "密码错误" }, { status: 401 }); + const { username, password } = parsed.data; + const db = await getDb(); + const user = await db.collection("users").findOne({ usernameLower: username.toLowerCase() }); + if ( + !user || + typeof user.passwordSalt !== "string" || + typeof user.passwordHash !== "string" || + !verifyPassword(password, user.passwordSalt, user.passwordHash) + ) { + return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 }); } - const token = await signSession({ role: "admin", iat: Date.now() }); - const res = NextResponse.json({ ok: true }); + const name = user.displayName || user.username || username; + const exp = Date.now() + 24 * 60 * 60 * 1000; + const token = await signSession({ + role: "admin", + iat: Date.now(), + exp, + uid: user._id?.toString(), + name + }); + const res = NextResponse.json({ ok: true, name }); res.cookies.set(cookieName, token, { httpOnly: true, sameSite: "lax", - secure: true, - maxAge: 60 * 60 * 24 * 30, + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24, path: "/" }); return res; diff --git a/app/api/posts/[slug]/route.ts b/app/api/posts/[slug]/route.ts index 284b817..321be9f 100644 --- a/app/api/posts/[slug]/route.ts +++ b/app/api/posts/[slug]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getDb } from "@/lib/mongo"; import { ObjectId } from "mongodb"; import { z } from "zod"; +import { OPC_SIGNAL_VALUES } from "@/lib/opc"; export async function GET(_: NextRequest, { params }: { params: { slug: string } }) { const db = await getDb(); @@ -17,6 +18,7 @@ export async function GET(_: NextRequest, { params }: { params: { slug: string } return NextResponse.json({ ...post.value, + author: post.value.author ?? "佚名", _id: (post.value._id as ObjectId)?.toString() }); } @@ -28,7 +30,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { slug: stri markdown: z.string().min(5).optional(), cover: z.string().url().nullable().optional(), tags: z.array(z.string().trim()).optional(), - author: z.string().optional() + signal: z.enum(OPC_SIGNAL_VALUES).optional() }); const parsed = schema.safeParse(body); if (!parsed.success) { @@ -49,7 +51,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { slug: stri set.cover = data.cover; } if (Array.isArray(data.tags)) set.tags = data.tags; - if (typeof data.author === "string") set.author = data.author; + if (typeof data.signal === "string") set.signal = data.signal; const update: Record = { $set: set }; if (Object.keys(unset).length > 0) update.$unset = unset; diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index b51397f..3e382a6 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -2,13 +2,15 @@ import { NextRequest, NextResponse } from "next/server"; import { getDb } from "@/lib/mongo"; import { z } from "zod"; import { generateSlug } from "@/lib/slug"; +import { DEFAULT_OPC_SIGNAL, OPC_SIGNAL_VALUES } from "@/lib/opc"; +import { cookieName, verifySession } from "@/lib/auth"; 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") + signal: z.enum(OPC_SIGNAL_VALUES).default(DEFAULT_OPC_SIGNAL) }); export async function GET() { @@ -23,6 +25,7 @@ export async function GET() { return NextResponse.json( posts.map((p) => ({ ...p, + author: p.author ?? "佚名", _id: p._id?.toString() })) ); @@ -37,6 +40,12 @@ export async function POST(req: NextRequest) { } const data = parsed.data; + const token = req.cookies.get(cookieName)?.value; + const session = await verifySession(token); + if (!session) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + const author = session.name ?? "佚名"; const now = new Date().toISOString(); let slug = generateSlug(); const db = await getDb(); @@ -53,6 +62,7 @@ export async function POST(req: NextRequest) { const doc = { ...data, + author, slug, createdAt: now, updatedAt: now, diff --git a/app/api/register/route.ts b/app/api/register/route.ts new file mode 100644 index 0000000..a8b708b --- /dev/null +++ b/app/api/register/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/mongo"; +import { hashPassword } from "@/lib/password"; +import { signSession, cookieName } from "@/lib/auth"; +import { z } from "zod"; + +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const schema = z.object({ + username: z.string().trim().min(2).max(32), + password: z.string().min(6).max(128), + displayName: z.string().trim().min(2).max(32).optional() + }); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "请填写有效的用户名与密码" }, { status: 400 }); + } + + const { username, password, displayName } = parsed.data; + const usernameLower = username.toLowerCase(); + const db = await getDb(); + + const exists = await db.collection("users").findOne({ usernameLower }); + if (exists) { + return NextResponse.json({ error: "用户名已存在" }, { status: 409 }); + } + + const { hash, salt } = hashPassword(password); + const now = new Date().toISOString(); + const doc = { + username, + usernameLower, + displayName: displayName || username, + passwordHash: hash, + passwordSalt: salt, + createdAt: now + }; + + const result = await db.collection("users").insertOne(doc); + + const name = doc.displayName; + const exp = Date.now() + 24 * 60 * 60 * 1000; + const token = await signSession({ + role: "admin", + iat: Date.now(), + exp, + uid: result.insertedId?.toString(), + name + }); + const res = NextResponse.json({ ok: true, name }); + res.cookies.set(cookieName, token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24, + path: "/" + }); + return res; +} diff --git a/app/layout.tsx b/app/layout.tsx index 2055554..845e04c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,13 +2,19 @@ import "./globals.css"; import type { Metadata } from "next"; import Link from "next/link"; import { ReactNode } from "react"; +import { cookies } from "next/headers"; +import { cookieName, getAdminName, verifySession } from "@/lib/auth"; export const metadata: Metadata = { - title: "Push Info", + title: "OPC Solo Feed", description: "轻量信息流发布平台,支持 Markdown 与多端浏览。" }; -export default function RootLayout({ children }: { children: ReactNode }) { +export default async function RootLayout({ children }: { children: ReactNode }) { + const token = cookies().get(cookieName)?.value; + const session = await verifySession(token); + const userName = session?.name ?? getAdminName(); + return ( @@ -16,26 +22,40 @@ export default function RootLayout({ children }: { children: ReactNode }) {
- Push + Solo - 信息流 + solo-feed - +
+ + {session ? ( + + {userName} + + ) : ( + + 登录 + + )} +
{children}
- 云图床:img.020417.xyz - Made for friends · {new Date().getFullYear()} + 素材来自 img.020417.xyz + 为朋友制作 · {new Date().getFullYear()}
diff --git a/app/login/login-form.tsx b/app/login/login-form.tsx index 4f478d4..48853b8 100644 --- a/app/login/login-form.tsx +++ b/app/login/login-form.tsx @@ -1,14 +1,14 @@ "use client"; import { useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; export default function LoginForm() { + const [username, setUsername] = useState(""); 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(); @@ -18,14 +18,13 @@ export default function LoginForm() { const res = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }) + body: JSON.stringify({ username, password }) }); if (!res.ok) { const data = await res.json().catch(() => ({})); setError(data.error || "登录失败"); } else { - const next = params.get("next") || "/admin"; - router.push(next); + router.replace("/"); router.refresh(); } } finally { @@ -36,14 +35,25 @@ export default function LoginForm() { return (
+ {error ?

{error}

: null} @@ -52,7 +62,7 @@ export default function LoginForm() { disabled={loading} className="w-full rounded-full bg-brand-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60" > - {loading ? "登录中…" : "登录"} + {loading ? "登录中..." : "登录"}
); diff --git a/app/login/page.tsx b/app/login/page.tsx index d1ffefb..36bc05c 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,22 +1,23 @@ import LoginForm from "./login-form"; -import { Suspense } from "react"; export const metadata = { - title: "登录 - Push Info" + title: "登录 - OPC Solo Feed" }; export default function LoginPage() { return (

管理员登录

-

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

+

使用已注册账号登录。

- - - +
+

+ 还没有账号?{" "} + + 去注册 + +

); } diff --git a/app/p/[slug]/page.tsx b/app/p/[slug]/page.tsx index 7d013b5..d8f4c02 100644 --- a/app/p/[slug]/page.tsx +++ b/app/p/[slug]/page.tsx @@ -5,6 +5,7 @@ import { normalizeImageUrl } from "@/lib/normalize"; import { Post } from "@/types/post"; import { SharePanel } from "@/components/SharePanel"; import { getSiteUrl } from "@/lib/site"; +import { DEFAULT_OPC_SIGNAL } from "@/lib/opc"; export const dynamic = "force-dynamic"; @@ -25,7 +26,8 @@ async function fetchPost(slug: string): Promise { markdown: doc.markdown ?? "", cover: doc.cover, tags: doc.tags ?? [], - author: doc.author ?? "admin", + signal: doc.signal ?? DEFAULT_OPC_SIGNAL, + author: doc.author ?? "佚名", createdAt: doc.createdAt ?? new Date().toISOString(), updatedAt: doc.updatedAt ?? doc.createdAt ?? new Date().toISOString(), views: doc.views ?? 0 @@ -49,7 +51,7 @@ export default async function PostPage({ params }: Props) {

- {new Date(post.createdAt).toLocaleString("zh-CN", { hour12: false })} + {post.author || "佚名"} · {new Date(post.createdAt).toLocaleString("zh-CN", { hour12: false })}

{coverUrl ? ( ({ ...d, + author: d.author ?? "佚名", _id: d._id?.toString() })), total, @@ -62,32 +63,50 @@ export default async function HomePage({ return qs ? `/?${qs}` : "/"; }; + const clearSearchHref = (() => { + const params = new URLSearchParams(); + if (tag) params.set("tag", tag); + const qs = params.toString(); + return qs ? `/?${qs}` : "/"; + })(); + + const clearTagHref = (() => { + const params = new URLSearchParams(); + if (q) params.set("q", q); + const qs = params.toString(); + return qs ? `/?${qs}` : "/"; + })(); + return (
-

朋友圈 · 信息流

+

OPC 信息流

- Markdown 排版 · 云图床 · 响应式 · 免费 MongoDB Atlas 存储。 + 用 Markdown 记录进展、发布动态,让一人公司也有自己的信息流平台。

{tag ? (
- 筛选标签:#{tag} + 当前标签 #{tag} - 清除 + 清除标签
) : null}
-
+ {tag ? : null} +
+ ); +} diff --git a/app/tags/[tag]/page.tsx b/app/tags/[tag]/page.tsx index 09d4c73..1e515cc 100644 --- a/app/tags/[tag]/page.tsx +++ b/app/tags/[tag]/page.tsx @@ -32,6 +32,7 @@ async function fetchTagPosts(params: { return { posts: docs.map((d: any) => ({ ...d, + author: d.author ?? "佚名", _id: d._id?.toString() })), total, @@ -63,8 +64,8 @@ export default async function TagDetailPage({ return (
-

标签:#{tag}

-

共 {total} 条内容。

+

标签 · {tag}

+

共 {total} 条内容

+ ) : null} +
+
- {posts.map((post) => ( + {visiblePosts.map((post) => (

- {post.createdAtText || - new Date(post.createdAt).toLocaleString("zh-CN", { - hour12: false, - timeZone: "Asia/Shanghai" - })} + {(post.author || "佚名") + + " · " + + (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} ))} diff --git a/components/CreatePostForm.tsx b/components/CreatePostForm.tsx index 7eee7c6..0dbe838 100644 --- a/components/CreatePostForm.tsx +++ b/components/CreatePostForm.tsx @@ -3,21 +3,37 @@ import { FormEvent, useState } from "react"; import { useRouter } from "next/navigation"; import { normalizeImageUrl } from "@/lib/normalize"; +import { MarkdownPreview } from "@/components/MarkdownPreview"; -const defaultIntro = `## 新帖内容 +const defaultIntro = `## solo-feed 记录模板 -- 这里支持 **Markdown** -- 图片请上传到 img.020417.xyz 后填入链接 +- 今日目标: +- 产品/交付: +- 客户/收入: +- 增长/运营: +- 学习/复盘: `; -export function CreatePostForm() { +export function CreatePostForm({ availableTags = [] }: { availableTags?: string[] }) { const [title, setTitle] = useState(""); const [cover, setCover] = useState(""); const [tags, setTags] = useState(""); const [markdown, setMarkdown] = useState(defaultIntro); const [loading, setLoading] = useState(false); + const [preview, setPreview] = useState(false); const router = useRouter(); + const addTag = (tag: string) => { + const current = new Set( + tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + ); + current.add(tag); + setTags(Array.from(current).join(", ")); + }; + async function handleSubmit(e: FormEvent) { e.preventDefault(); setLoading(true); @@ -56,18 +72,27 @@ export function CreatePostForm() { return ( -
+
-

新建一条信息

-

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

+

发布新内容

+

以信息流的方式记录进展、沉淀经验。

+
+
+ +
-
-