diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts new file mode 100644 index 0000000..0f715be --- /dev/null +++ b/app/api/comments/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { getDb, ensureCommentsTable } from "@/lib/db"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const docId = searchParams.get("docId"); + if (!docId) { + return NextResponse.json( + { ok: false, error: "missing_docId" }, + { status: 400 }, + ); + } + const db = getDb(); + if (!db) { + return NextResponse.json( + { ok: false, error: "db_missing" }, + { status: 503 }, + ); + } + await ensureCommentsTable(); + const { rows } = await db.query( + `SELECT id, doc_id, content, user_id, user_name, user_image, parent_id, created_at + FROM comments WHERE doc_id = $1 ORDER BY created_at DESC LIMIT 200`, + [docId], + ); + return NextResponse.json({ ok: true, data: rows }); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user) { + return NextResponse.json( + { ok: false, error: "unauthorized" }, + { status: 401 }, + ); + } + const db = getDb(); + if (!db) { + return NextResponse.json( + { ok: false, error: "db_missing" }, + { status: 503 }, + ); + } + const body = (await req.json().catch(() => null)) as { + docId?: string; + content?: string; + parentId?: number | null; + } | null; + const docId = body?.docId?.trim(); + const content = body?.content?.trim(); + const parentId = body?.parentId ?? null; + if (!docId || !content) { + return NextResponse.json( + { ok: false, error: "missing_fields" }, + { status: 400 }, + ); + } + if (content.length > 4000) { + return NextResponse.json( + { ok: false, error: "content_too_long" }, + { status: 400 }, + ); + } + await ensureCommentsTable(); + const userId = (session.user as any).id ?? "unknown"; + const userName = session.user.name ?? null; + const userImage = session.user.image ?? null; + const { rows } = await db.query( + `INSERT INTO comments (doc_id, content, user_id, user_name, user_image, parent_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, doc_id, content, user_id, user_name, user_image, parent_id, created_at`, + [docId, content, String(userId), userName, userImage, parentId], + ); + return NextResponse.json({ ok: true, data: rows[0] }, { status: 201 }); +} diff --git a/app/components/SiteComments.tsx b/app/components/SiteComments.tsx new file mode 100644 index 0000000..d996037 --- /dev/null +++ b/app/components/SiteComments.tsx @@ -0,0 +1,153 @@ +"use client"; + +import * as React from "react"; + +interface SiteCommentsProps { + docId: string; + user?: { name?: string | null; image?: string | null } | null; +} + +type Comment = { + id: number; + doc_id: string; + content: string; + user_id: string; + user_name: string | null; + user_image: string | null; + parent_id: number | null; + created_at: string; +}; + +export default function SiteComments({ docId, user }: SiteCommentsProps) { + const [comments, setComments] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [content, setContent] = React.useState(""); + const [submitting, setSubmitting] = React.useState(false); + const [error, setError] = React.useState(null); + + const fetchComments = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch( + `/api/comments?docId=${encodeURIComponent(docId)}`, + { + cache: "no-store", + }, + ); + const data = await res.json(); + if (!data.ok) throw new Error(data.error || "加载失败"); + setComments(data.data as Comment[]); + } catch (e: any) { + setError(e?.message || "加载失败"); + } finally { + setLoading(false); + } + }, [docId]); + + React.useEffect(() => { + fetchComments(); + }, [fetchComments]); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + const text = content.trim(); + if (!text) return; + setSubmitting(true); + setError(null); + try { + const res = await fetch(`/api/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ docId, content: text }), + }); + const data = await res.json(); + if (!res.ok || !data.ok) throw new Error(data.error || "提交失败"); + setContent(""); + // 直接插入到顶部 + setComments((prev) => [data.data as Comment, ...prev]); + } catch (e: any) { + setError(e?.message || "提交失败"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
+ {user?.image ? ( + // eslint-disable-next-line @next/next/no-img-element + avatar + ) : ( +
+ )} + + {user?.name || "已登录用户"} + +
+