Add registration flow and improve admin post management

This commit is contained in:
爱喝水的木子
2026-03-19 20:17:56 +08:00
parent 50a1e476c8
commit 17f5f6adcb
28 changed files with 799 additions and 192 deletions

View File

@@ -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<Post | null> {
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

View File

@@ -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<string, number>();
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 (
<div className="space-y-6">
<section className="grid gap-4 sm:grid-cols-3">
<div className="rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
<p className="text-sm text-slate-500"></p>
<div className={cardClass}>
<p className="text-sm text-slate-500"></p>
<p className="mt-1 text-3xl font-semibold">{stats.total}</p>
</div>
<div className="rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
<p className="text-sm text-slate-500"></p>
<div className={cardClass}>
<p className="text-sm text-slate-500"></p>
<p className="mt-1 text-base font-semibold">{stats.latest?.title ?? "暂无"}</p>
<p className="text-xs text-slate-500">
{stats.latest?.createdAt
@@ -52,20 +151,112 @@ export default async function AdminPage() {
: ""}
</p>
</div>
<div className="rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
<p className="text-sm text-slate-500">Top </p>
<div className={cardClass}>
<p className="text-sm text-slate-500">Top </p>
<ul className="mt-1 space-y-1 text-sm">
{stats.top.map((item: any) => (
<li key={item.slug} className="flex items-center justify-between">
<span className="truncate">{item.title}</span>
<span className="text-slate-500">{item.views ?? 0} </span>
<span className="text-slate-500">{item.views ?? 0} </span>
</li>
))}
</ul>
</div>
</section>
<CreatePostForm />
<section className="rounded-2xl bg-white/80 p-5 shadow-sm ring-1 ring-slate-100">
<h3 className="text-lg font-semibold"></h3>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className={cardClass}>
<p className="text-sm text-slate-500"></p>
<p className="mt-1 text-2xl font-semibold">{stats.totalViews}</p>
</div>
<div className={cardClass}>
<p className="text-sm text-slate-500"></p>
<p className="mt-1 text-2xl font-semibold">{stats.avgViews}</p>
</div>
<div className={cardClass}>
<p className="text-sm text-slate-500"></p>
<p className="mt-1 text-2xl font-semibold">{stats.tagCount}</p>
</div>
<div className={cardClass}>
<p className="text-sm text-slate-500"></p>
<p className="mt-1 text-2xl font-semibold">{stats.authorCount}</p>
</div>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
<h4 className="text-sm font-semibold text-slate-700"></h4>
<div className="mt-3 space-y-3">
{tagStats.length === 0 ? (
<p className="text-xs text-slate-500"></p>
) : (
tagStats.map((item) => (
<div key={item.tag} className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-600">
<span>#{item.tag}</span>
<span>{item.count}</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div
className="h-2 rounded-full bg-brand-500/80"
style={{ width: `${(item.count / tagMax) * 100}%` }}
/>
</div>
</div>
))
)}
</div>
</div>
<div className="rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
<h4 className="text-sm font-semibold text-slate-700"></h4>
<div className="mt-3 space-y-3">
{authorStats.length === 0 ? (
<p className="text-xs text-slate-500"></p>
) : (
authorStats.map((item) => (
<div key={item.author} className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-600">
<span>{item.author}</span>
<span>{item.count}</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div
className="h-2 rounded-full bg-indigo-500/80"
style={{ width: `${(item.count / authorMax) * 100}%` }}
/>
</div>
</div>
))
)}
</div>
</div>
</div>
<div className="mt-6 rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
<h4 className="text-sm font-semibold text-slate-700"> 7 </h4>
<div className="mt-3 space-y-3">
{dailyStats.map((item) => (
<div key={item.label} className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-600">
<span>{item.label}</span>
<span>{item.count}</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div
className="h-2 rounded-full bg-emerald-500/80"
style={{ width: `${(item.count / dailyMax) * 100}%` }}
/>
</div>
</div>
))}
</div>
</div>
</section>
<CreatePostForm availableTags={availableTags} />
<AdminPostList initialPosts={recentPosts} />
</div>

View File

@@ -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;

View File

@@ -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<string, unknown> = { $set: set };
if (Object.keys(unset).length > 0) update.$unset = unset;

View File

@@ -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,

59
app/api/register/route.ts Normal file
View File

@@ -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;
}

View File

@@ -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 (
<html lang="zh-CN">
<body className="text-slate-900 antialiased">
@@ -16,26 +22,40 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<header className="mb-8 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-white/70 p-4 shadow-sm ring-1 ring-slate-100 backdrop-blur">
<Link href="/" className="flex items-center gap-2 font-semibold text-slate-900">
<span className="rounded-xl bg-brand-100 px-2 py-1 text-xs font-bold uppercase text-brand-700">
Push
Solo
</span>
<span></span>
<span>solo-feed</span>
</Link>
<nav className="flex items-center gap-3 text-sm text-slate-600">
<Link href="/" className="hover:text-brand-600">
</Link>
<Link href="/tags" className="hover:text-brand-600">
</Link>
<Link href="/admin" className="hover:text-brand-600">
</Link>
</nav>
<div className="flex items-center gap-3 text-sm text-slate-600">
<nav className="flex items-center gap-3">
<Link href="/" className="hover:text-brand-600">
</Link>
<Link href="/tags" className="hover:text-brand-600">
</Link>
<Link href="/admin" className="hover:text-brand-600">
</Link>
</nav>
{session ? (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
{userName}
</span>
) : (
<Link
href="/login"
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 hover:text-brand-600"
>
</Link>
)}
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="mt-10 flex items-center justify-between border-t border-slate-200 pt-6 text-xs text-slate-500">
<span>img.020417.xyz</span>
<span>Made for friends · {new Date().getFullYear()}</span>
<span> img.020417.xyz</span>
<span> · {new Date().getFullYear()}</span>
</footer>
</div>
</body>

View File

@@ -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 (
<form onSubmit={handleSubmit} className="space-y-4">
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700"></span>
<span className="text-sm font-medium text-slate-700"></span>
<input
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
placeholder="请输入用户名"
/>
</label>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700"></span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
placeholder="输入 ADMIN_PASS"
placeholder="输入密码"
/>
</label>
{error ? <p className="text-sm text-red-500">{error}</p> : 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 ? "登录中..." : "登录"}
</button>
</form>
);

View File

@@ -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 (
<div className="mx-auto max-w-md rounded-2xl bg-white/90 p-8 shadow-sm ring-1 ring-slate-100">
<h1 className="text-2xl font-semibold text-slate-900"></h1>
<p className="mt-2 text-sm text-slate-600">
/ ADMIN_PASS
</p>
<p className="mt-2 text-sm text-slate-600">使</p>
<div className="mt-6">
<Suspense fallback={null}>
<LoginForm />
</Suspense>
<LoginForm />
</div>
<p className="mt-4 text-sm text-slate-500">
{" "}
<a href="/register" className="text-brand-600 hover:text-brand-700">
</a>
</p>
</div>
);
}

View File

@@ -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<Post | null> {
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) {
<SharePanel url={shareUrl} />
</div>
<p className="mt-2 text-sm text-slate-500">
{new Date(post.createdAt).toLocaleString("zh-CN", { hour12: false })}
{post.author || "佚名"} · {new Date(post.createdAt).toLocaleString("zh-CN", { hour12: false })}
</p>
{coverUrl ? (
<img

View File

@@ -35,6 +35,7 @@ async function fetchPosts(params: {
return {
posts: docs.map((d: any) => ({
...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 (
<div className="space-y-6">
<div className="rounded-2xl bg-gradient-to-r from-brand-500 to-brand-700 p-6 text-white shadow-lg">
<h1 className="text-2xl font-semibold"> · </h1>
<h1 className="text-2xl font-semibold">OPC </h1>
<p className="mt-2 text-sm text-white/80">
Markdown · · · MongoDB Atlas
Markdown
</p>
{tag ? (
<div className="mt-3 inline-flex items-center gap-2 rounded-full bg-white/15 px-3 py-1 text-xs font-medium">
<span>#{tag}</span>
<span> #{tag}</span>
<a
href="/"
href={clearTagHref}
className="rounded-full bg-white/20 px-2 py-1 text-white/90 hover:bg-white/30"
>
</a>
</div>
) : null}
</div>
<form action="/" method="get" className="flex flex-wrap items-center gap-3 rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100">
<form
action="/"
method="get"
className="flex flex-wrap items-center gap-3 rounded-2xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-100"
>
{tag ? <input type="hidden" name="tag" value={tag} /> : null}
<input
name="q"
defaultValue={q || ""}
placeholder="搜索标题或正文"
placeholder="搜索标题或内容"
className="min-w-[200px] flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
/>
<button
@@ -97,10 +116,7 @@ export default async function HomePage({
</button>
{q ? (
<a
href={tag ? `/?tag=${encodeURIComponent(tag)}` : "/"}
className="text-sm text-slate-500 hover:text-brand-600"
>
<a href={clearSearchHref} className="text-sm text-slate-500 hover:text-brand-600">
</a>
) : null}
@@ -109,7 +125,7 @@ export default async function HomePage({
{posts.length === 0 ? (
<p className="rounded-xl bg-white/70 p-4 text-sm text-slate-500 ring-1 ring-slate-100">
</p>
) : (
<div className="grid gap-4">

23
app/register/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import RegisterForm from "./register-form";
export const metadata = {
title: "注册 - OPC Solo Feed"
};
export default function RegisterPage() {
return (
<div className="mx-auto max-w-md rounded-2xl bg-white/90 p-8 shadow-sm ring-1 ring-slate-100">
<h1 className="text-2xl font-semibold text-slate-900"></h1>
<p className="mt-2 text-sm text-slate-600"> 24 </p>
<div className="mt-6">
<RegisterForm />
</div>
<p className="mt-4 text-sm text-slate-500">
{" "}
<a href="/login" className="text-brand-600 hover:text-brand-700">
</a>
</p>
</div>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function RegisterForm() {
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
password,
displayName: displayName.trim() || undefined
})
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.error || "注册失败");
} else {
router.replace("/");
router.refresh();
}
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700"></span>
<input
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
placeholder="2-32 个字符"
/>
</label>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700"></span>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
placeholder="不填则使用用户名"
/>
</label>
<label className="block space-y-1">
<span className="text-sm font-medium text-slate-700"></span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
placeholder="至少 6 位"
/>
</label>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<button
type="submit"
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 ? "注册中..." : "注册"}
</button>
</form>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div className="rounded-2xl bg-white/80 p-6 shadow-sm ring-1 ring-slate-100">
<h1 className="text-2xl font-semibold">#{tag}</h1>
<p className="mt-2 text-sm text-slate-500"> {total} </p>
<h1 className="text-2xl font-semibold"> · {tag}</h1>
<p className="mt-2 text-sm text-slate-500"> {total} </p>
</div>
<form
@@ -75,7 +76,7 @@ export default async function TagDetailPage({
<input
name="q"
defaultValue={q || ""}
placeholder="在标签搜索"
placeholder="在当前标签搜索"
className="min-w-[200px] flex-1 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-inner focus:border-brand-500 focus:outline-none"
/>
<button
@@ -96,7 +97,7 @@ export default async function TagDetailPage({
{posts.length === 0 ? (
<p className="rounded-xl bg-white/70 p-4 text-sm text-slate-500 ring-1 ring-slate-100">
</p>
) : (
<div className="grid gap-4">

View File

@@ -18,13 +18,13 @@ export default async function TagsPage() {
return (
<div className="space-y-6">
<div className="rounded-2xl bg-white/80 p-6 shadow-sm ring-1 ring-slate-100">
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-2 text-sm text-slate-500"></p>
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-2 text-sm text-slate-500"></p>
</div>
{tags.length === 0 ? (
<p className="rounded-xl bg-white/70 p-4 text-sm text-slate-500 ring-1 ring-slate-100">
</p>
) : (
<div className="flex flex-wrap gap-3">