From bbaa38b3b3277046d4fc9f75873d0654b182de0c Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 15 Mar 2026 04:25:45 -0500 Subject: [PATCH] 's' --- app/api/community/attachments/route.ts | 42 ++ app/api/community/comments/[id]/route.ts | 42 ++ .../community/memos/[id]/comments/route.ts | 78 +++ .../community/memos/[id]/reactions/route.ts | 44 ++ app/api/community/memos/[id]/route.ts | 125 ++++ app/api/community/memos/route.ts | 82 +++ app/api/community/stats/route.ts | 36 ++ app/api/community/upload/route.ts | 49 ++ app/community/archived/page.tsx | 70 ++ app/community/attachments/page.tsx | 166 +++++ .../components/CommunityCommentThread.tsx | 179 ++++++ .../components/CommunityMemoCard.tsx | 224 +++++++ .../components/CommunityMemoForm.tsx | 319 ++++++++++ .../components/CommunityPageHero.tsx | 55 ++ app/community/components/CommunitySidebar.tsx | 257 ++++++++ .../components/CommunityTimeline.tsx | 414 ++++++++++++ app/community/components/EmojiPicker.tsx | 86 +++ app/community/components/MemoExplorer.tsx | 92 +++ app/community/components/community.module.css | 601 ++++++++++++++++++ app/community/components/communityEvents.ts | 8 + app/community/components/memos.module.css | 559 ++++++++++++++++ app/community/components/renderMarkdown.ts | 19 + app/community/explore/page.tsx | 102 +++ app/community/layout.tsx | 92 +++ app/community/page.tsx | 157 +++++ app/community/post/[id]/page.tsx | 283 +++++++++ app/data/vip.mock.ts | 64 +- app/vip/components/dashboard/TopicCards.tsx | 24 +- app/vip/components/dashboard/WelcomeBlock.tsx | 6 +- .../components/landing/TopicEbookShowcase.tsx | 153 ++--- app/vip/components/landing/TopicEntries.tsx | 24 +- config/community.config.ts | 12 + 32 files changed, 4312 insertions(+), 152 deletions(-) create mode 100644 app/api/community/attachments/route.ts create mode 100644 app/api/community/comments/[id]/route.ts create mode 100644 app/api/community/memos/[id]/comments/route.ts create mode 100644 app/api/community/memos/[id]/reactions/route.ts create mode 100644 app/api/community/memos/[id]/route.ts create mode 100644 app/api/community/memos/route.ts create mode 100644 app/api/community/stats/route.ts create mode 100644 app/api/community/upload/route.ts create mode 100644 app/community/archived/page.tsx create mode 100644 app/community/attachments/page.tsx create mode 100644 app/community/components/CommunityCommentThread.tsx create mode 100644 app/community/components/CommunityMemoCard.tsx create mode 100644 app/community/components/CommunityMemoForm.tsx create mode 100644 app/community/components/CommunityPageHero.tsx create mode 100644 app/community/components/CommunitySidebar.tsx create mode 100644 app/community/components/CommunityTimeline.tsx create mode 100644 app/community/components/EmojiPicker.tsx create mode 100644 app/community/components/MemoExplorer.tsx create mode 100644 app/community/components/community.module.css create mode 100644 app/community/components/communityEvents.ts create mode 100644 app/community/components/memos.module.css create mode 100644 app/community/components/renderMarkdown.ts create mode 100644 app/community/explore/page.tsx create mode 100644 app/community/layout.tsx create mode 100644 app/community/page.tsx create mode 100644 app/community/post/[id]/page.tsx create mode 100644 config/community.config.ts diff --git a/app/api/community/attachments/route.ts b/app/api/community/attachments/route.ts new file mode 100644 index 0000000..4132dcf --- /dev/null +++ b/app/api/community/attachments/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; + +function resolvePayApiUrl(request: NextRequest): string { + const payConfig = getPaymentConfig(); + const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ((hostHeader ? getApiUrlFromRequest(hostHeader) : null) || payConfig.apiUrl).replace(/\/$/, ""); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const user_id = searchParams.get("user_id")?.trim() || ""; + const page = Math.max(1, Number.parseInt(searchParams.get("page") || "1", 10)); + const per_page = Math.min(60, Math.max(1, Number.parseInt(searchParams.get("per_page") || "24", 10))); + + if (!user_id) { + return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 401 }); + } + + try { + const apiUrl = resolvePayApiUrl(request); + const params = new URLSearchParams({ + user_id, + page: String(page), + per_page: String(per_page), + }); + const response = await fetch(`${apiUrl}/community/attachments?${params.toString()}`, { cache: "no-store" }); + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + return NextResponse.json( + { ok: false, error: data?.detail || data?.error || "请求失败" }, + { status: response.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error("[community/attachments] GET error:", error); + return NextResponse.json({ ok: false, error: "服务异常" }, { status: 500 }); + } +} diff --git a/app/api/community/comments/[id]/route.ts b/app/api/community/comments/[id]/route.ts new file mode 100644 index 0000000..c09ae51 --- /dev/null +++ b/app/api/community/comments/[id]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; + +function resolvePayApiUrl(request: NextRequest): string { + const payConfig = getPaymentConfig(); + const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ((hostHeader ? getApiUrlFromRequest(hostHeader) : null) || payConfig.apiUrl).replace(/\/$/, ""); +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = new URL(request.url); + const user_id = searchParams.get("user_id")?.trim() || ""; + + if (!user_id) { + return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 401 }); + } + + try { + const apiUrl = resolvePayApiUrl(request); + const res = await fetch( + `${apiUrl}/community/comments/${encodeURIComponent(id)}?user_id=${encodeURIComponent(user_id)}`, + { method: "DELETE" } + ); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return NextResponse.json( + { ok: false, error: data?.detail || "删除评论失败" }, + { status: res.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error("[community/comments/:id] DELETE error:", error); + return NextResponse.json({ ok: false, error: "服务异常" }, { status: 500 }); + } +} diff --git a/app/api/community/memos/[id]/comments/route.ts b/app/api/community/memos/[id]/comments/route.ts new file mode 100644 index 0000000..b684833 --- /dev/null +++ b/app/api/community/memos/[id]/comments/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; + +function resolvePayApiUrl(request: NextRequest): string { + const payConfig = getPaymentConfig(); + const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ((hostHeader ? getApiUrlFromRequest(hostHeader) : null) || payConfig.apiUrl).replace(/\/$/, ""); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = new URL(request.url); + const user_id = searchParams.get("user_id")?.trim() || ""; + + if (!user_id) { + return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 401 }); + } + + try { + const apiUrl = resolvePayApiUrl(request); + const res = await fetch( + `${apiUrl}/community/memos/${encodeURIComponent(id)}/comments?user_id=${encodeURIComponent(user_id)}`, + { cache: "no-store" } + ); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return NextResponse.json( + { ok: false, error: data?.detail || "加载评论失败" }, + { status: res.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error("[community/comments] GET error:", error); + return NextResponse.json({ ok: false, error: "服务异常" }, { status: 500 }); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const body = await request.json().catch(() => ({})); + const user_id = String(body?.user_id ?? body?.userId ?? "").trim(); + const content = String(body?.content ?? "").trim(); + + if (!user_id) { + return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 401 }); + } + + try { + const apiUrl = resolvePayApiUrl(request); + const res = await fetch(`${apiUrl}/community/memos/${encodeURIComponent(id)}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id, content }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return NextResponse.json( + { ok: false, error: data?.detail || "评论发布失败" }, + { status: res.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error("[community/comments] POST error:", error); + return NextResponse.json({ ok: false, error: "服务异常" }, { status: 500 }); + } +} diff --git a/app/api/community/memos/[id]/reactions/route.ts b/app/api/community/memos/[id]/reactions/route.ts new file mode 100644 index 0000000..469cb0d --- /dev/null +++ b/app/api/community/memos/[id]/reactions/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; + +function resolvePayApiUrl(request: NextRequest): string { + const payConfig = getPaymentConfig(); + const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ((hostHeader ? getApiUrlFromRequest(hostHeader) : null) || payConfig.apiUrl).replace(/\/$/, ""); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const body = await request.json().catch(() => ({})); + const user_id = String(body?.user_id ?? body?.userId ?? "").trim(); + const reaction_type = String(body?.reaction_type ?? "UPVOTE").trim(); + + if (!user_id) { + return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 401 }); + } + + try { + const apiUrl = resolvePayApiUrl(request); + const res = await fetch(`${apiUrl}/community/memos/${encodeURIComponent(id)}/reactions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id, reaction_type }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return NextResponse.json( + { ok: false, error: data?.detail || "点赞失败" }, + { status: res.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error("[community/reactions] POST error:", error); + return NextResponse.json({ ok: false, error: "服务异常" }, { status: 500 }); + } +} diff --git a/app/api/community/memos/[id]/route.ts b/app/api/community/memos/[id]/route.ts new file mode 100644 index 0000000..2e7999c --- /dev/null +++ b/app/api/community/memos/[id]/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; + +function resolvePayApiUrl(request: NextRequest): string { + const payConfig = getPaymentConfig(); + const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ((hostHeader ? getApiUrlFromRequest(hostHeader) : null) || payConfig.apiUrl).replace(/\/$/, ""); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = new URL(request.url); + const user_id = searchParams.get("user_id")?.trim() || ""; + + if (!user_id) { + return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 401 }); + } + + try { + const apiUrl = resolvePayApiUrl(request); + const res = await fetch( + `${apiUrl}/community/memos/${encodeURIComponent(id)}?user_id=${encodeURIComponent(user_id)}`, + { cache: "no-store" } + ); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return NextResponse.json( + { ok: false, error: data?.detail || "加载帖子失败" }, + { status: res.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error("[community/memos/:id] GET error:", error); + return NextResponse.json({ ok: false, error: "服务异常" }, { status: 500 }); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const body = await request.json().catch(() => ({})); + const user_id = String(body?.user_id ?? body?.userId ?? "").trim(); + + if (!user_id) { + return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 401 }); + } + + const payload: Record = { user_id }; + if (body?.pinned !== undefined) { + payload.pinned = Boolean(body.pinned); + } + if (body?.content !== undefined) { + payload.content = String(body.content ?? ""); + } + if (body?.state !== undefined) { + payload.state = String(body.state ?? ""); + } + if (body?.visibility !== undefined) { + payload.visibility = String(body.visibility ?? ""); + } + + try { + const apiUrl = resolvePayApiUrl(request); + const res = await fetch(`${apiUrl}/community/memos/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return NextResponse.json( + { ok: false, error: data?.detail || "更新帖子失败" }, + { status: res.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error("[community/memos/:id] PATCH error:", error); + return NextResponse.json({ ok: false, error: "服务异常" }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = new URL(request.url); + const user_id = searchParams.get("user_id")?.trim() || ""; + + if (!user_id) { + return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 401 }); + } + + try { + const apiUrl = resolvePayApiUrl(request); + const res = await fetch( + `${apiUrl}/community/memos/${encodeURIComponent(id)}?user_id=${encodeURIComponent(user_id)}`, + { method: "DELETE" } + ); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return NextResponse.json( + { ok: false, error: data?.detail || "删除帖子失败" }, + { status: res.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error("[community/memos/:id] DELETE error:", error); + return NextResponse.json({ ok: false, error: "服务异常" }, { status: 500 }); + } +} diff --git a/app/api/community/memos/route.ts b/app/api/community/memos/route.ts new file mode 100644 index 0000000..adc7375 --- /dev/null +++ b/app/api/community/memos/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; + +function resolvePayApiUrl(request: NextRequest): string { + const payConfig = getPaymentConfig(); + const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ((hostHeader ? getApiUrlFromRequest(hostHeader) : null) || payConfig.apiUrl).replace(/\/$/, ""); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const user_id = searchParams.get("user_id")?.trim() || ""; + const page = Math.max(1, Number.parseInt(searchParams.get("page") || "1", 10)); + const per_page = Math.min(100, Math.max(1, Number.parseInt(searchParams.get("per_page") || "20", 10))); + const state = (searchParams.get("state") || "NORMAL").toUpperCase(); + const query = searchParams.get("query")?.trim() || ""; + + if (!user_id) { + return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 401 }); + } + + try { + const apiUrl = resolvePayApiUrl(request); + const params = new URLSearchParams({ + user_id, + page: String(page), + per_page: String(per_page), + state, + }); + if (query) { + params.set("query", query); + } + + const response = await fetch(`${apiUrl}/community/memos?${params.toString()}`, { cache: "no-store" }); + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + return NextResponse.json( + { ok: false, error: data?.detail || data?.error || "请求失败" }, + { status: response.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error("[community/memos] GET error:", error); + return NextResponse.json({ ok: false, error: "服务异常" }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => ({})); + const user_id = String(body?.user_id ?? body?.userId ?? "").trim(); + const content = String(body?.content ?? "").trim(); + const pinned = Boolean(body?.pinned); + + if (!user_id) { + return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 401 }); + } + + try { + const apiUrl = resolvePayApiUrl(request); + const response = await fetch(`${apiUrl}/community/memos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id, content, pinned }), + }); + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + return NextResponse.json( + { ok: false, error: data?.detail || data?.error || data?.message || "发布失败" }, + { status: response.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error("[community/memos] POST error:", error); + return NextResponse.json({ ok: false, error: "服务异常" }, { status: 500 }); + } +} diff --git a/app/api/community/stats/route.ts b/app/api/community/stats/route.ts new file mode 100644 index 0000000..fb8f2e4 --- /dev/null +++ b/app/api/community/stats/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; + +function resolvePayApiUrl(request: NextRequest): string { + const payConfig = getPaymentConfig(); + const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ((hostHeader ? getApiUrlFromRequest(hostHeader) : null) || payConfig.apiUrl).replace(/\/$/, ""); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const user_id = searchParams.get("user_id")?.trim() || ""; + + if (!user_id) { + return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 401 }); + } + + try { + const apiUrl = resolvePayApiUrl(request); + const params = new URLSearchParams({ user_id }); + const response = await fetch(`${apiUrl}/community/stats?${params.toString()}`, { cache: "no-store" }); + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + return NextResponse.json( + { ok: false, error: data?.detail || data?.error || "请求失败" }, + { status: response.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error("[community/stats] GET error:", error); + return NextResponse.json({ ok: false, error: "服务异常" }, { status: 500 }); + } +} diff --git a/app/api/community/upload/route.ts b/app/api/community/upload/route.ts new file mode 100644 index 0000000..35d8a2a --- /dev/null +++ b/app/api/community/upload/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; + +function resolvePayApiUrl(request: NextRequest): string { + const payConfig = getPaymentConfig(); + const hostHeader = request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ((hostHeader ? getApiUrlFromRequest(hostHeader) : null) || payConfig.apiUrl).replace(/\/$/, ""); +} + +export async function POST(request: NextRequest) { + const formData = await request.formData().catch(() => null); + if (!formData) { + return NextResponse.json({ ok: false, error: "无效请求" }, { status: 400 }); + } + + const user_id = (formData.get("user_id") as string)?.trim() || ""; + const file = formData.get("file") as File | null; + + if (!user_id) { + return NextResponse.json({ ok: false, error: "缺少 user_id" }, { status: 401 }); + } + if (!file || !(file instanceof Blob)) { + return NextResponse.json({ ok: false, error: "请选择图片" }, { status: 400 }); + } + + try { + const apiUrl = resolvePayApiUrl(request); + const body = new FormData(); + body.append("user_id", user_id); + body.append("file", file, file.name || "image"); + + const res = await fetch(`${apiUrl}/community/upload`, { + method: "POST", + body, + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return NextResponse.json( + { ok: false, error: data?.detail || "上传失败" }, + { status: res.status } + ); + } + return NextResponse.json(data); + } catch (error) { + console.error("[community/upload] error:", error); + return NextResponse.json({ ok: false, error: "服务异常" }, { status: 500 }); + } +} diff --git a/app/community/archived/page.tsx b/app/community/archived/page.tsx new file mode 100644 index 0000000..b1ae704 --- /dev/null +++ b/app/community/archived/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { CommunityTimeline } from "../components/CommunityTimeline"; +import { CommunityPageHero } from "../components/CommunityPageHero"; +import styles from "../components/memos.module.css"; + +function getStorage(key: string): string | null { + if (typeof window === "undefined") { + return null; + } + return localStorage.getItem(key); +} + +export default function ArchivedPage() { + const [userId, setUserId] = useState(null); + const [isVip, setIsVip] = useState(false); + const [showLoginPrompt, setShowLoginPrompt] = useState(false); + + useEffect(() => { + const uid = getStorage("userId") || getStorage("memosAccount"); + const type = getStorage("paidType"); + setUserId(uid || null); + setIsVip(type === "vip"); + }, []); + + return ( +
+ + + {showLoginPrompt ? ( +
+

归档页仅对会员开放。

+ + 立即加入会员 + +
+ ) : null} + + {isVip && userId ? ( + setShowLoginPrompt(true)} + state="ARCHIVED" + emptyText="目前还没有归档内容。" + /> + ) : ( +
+
+ Members Only +

归档页需要会员权限

+

这里会存放长期有价值、但不需要持续顶在首页的内容。

+
+
+ + 去开通会员 + +
+
+ )} +
+ ); +} diff --git a/app/community/attachments/page.tsx b/app/community/attachments/page.tsx new file mode 100644 index 0000000..e2e7af7 --- /dev/null +++ b/app/community/attachments/page.tsx @@ -0,0 +1,166 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { CommunityPageHero } from "../components/CommunityPageHero"; +import { COMMUNITY_CHANGED_EVENT } from "../components/communityEvents"; +import styles from "../components/memos.module.css"; + +type AttachmentItem = { + id: string; + url: string; + original_name: string; + created: string; + size: number; +}; + +function getStorage(key: string): string | null { + if (typeof window === "undefined") { + return null; + } + return localStorage.getItem(key); +} + +function formatFileSize(size: number) { + if (size < 1024) { + return `${size} B`; + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB`; + } + return `${(size / 1024 / 1024).toFixed(1)} MB`; +} + +export default function AttachmentsPage() { + const [userId, setUserId] = useState(null); + const [isVip, setIsVip] = useState(false); + const [showLoginPrompt, setShowLoginPrompt] = useState(false); + const [items, setItems] = useState([]); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const uid = getStorage("userId") || getStorage("memosAccount"); + const type = getStorage("paidType"); + setUserId(uid || null); + setIsVip(type === "vip"); + }, []); + + const fetchAttachments = useCallback( + async (pageNum: number, append: boolean) => { + if (!userId || !isVip) { + return; + } + setLoading(true); + try { + const response = await fetch( + `/api/community/attachments?user_id=${encodeURIComponent(userId)}&page=${pageNum}&per_page=24`, + { cache: "no-store" } + ); + const data = await response.json().catch(() => null); + + if (response.status === 403) { + setShowLoginPrompt(true); + return; + } + if (!response.ok) { + return; + } + + const nextItems = Array.isArray(data?.items) ? (data.items as AttachmentItem[]) : []; + setItems((current) => (append ? [...current, ...nextItems] : nextItems)); + setHasMore(Boolean(data?.next_page_token)); + setPage(pageNum); + } finally { + setLoading(false); + } + }, + [isVip, userId] + ); + + useEffect(() => { + void fetchAttachments(1, false); + }, [fetchAttachments]); + + useEffect(() => { + const handler = () => { + void fetchAttachments(1, false); + }; + window.addEventListener(COMMUNITY_CHANGED_EVENT, handler); + return () => window.removeEventListener(COMMUNITY_CHANGED_EVENT, handler); + }, [fetchAttachments]); + + return ( +
+ + + {showLoginPrompt ? ( +
+

附件资料库仅对会员开放。

+ + 立即加入会员 + +
+ ) : null} + + {isVip && userId ? ( + items.length ? ( + <> +
+ {items.map((item) => ( + + {item.original_name} +
+ {item.original_name} + + {formatFileSize(item.size)} · {new Date(item.created).toLocaleDateString("zh-CN")} + +
+
+ ))} +
+ + {hasMore ? ( + + ) : null} + + ) : ( +
+
+ Attachment Library +

还没有附件

+

在首页动态中上传图片后,这里会自动生成资料缩略图墙。

+
+
+ ) + ) : ( +
+
+ Members Only +

附件资料库需要会员权限

+

加入后即可查看所有上传图片与资料沉淀。

+
+
+ + 去开通会员 + +
+
+ )} +
+ ); +} diff --git a/app/community/components/CommunityCommentThread.tsx b/app/community/components/CommunityCommentThread.tsx new file mode 100644 index 0000000..d6f65bf --- /dev/null +++ b/app/community/components/CommunityCommentThread.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + buildCommunityAvatarText, + formatCommunityTime, + maskCommunityUserId, + type CommunityComment, +} from "./CommunityMemoCard"; +import { renderCommunityMarkdown } from "./renderMarkdown"; +import { emitCommunityChanged } from "./communityEvents"; +import styles from "./community.module.css"; + +type CommunityCommentThreadProps = { + memoId: string; + userId: string; + memoOwnerId: string; + comments?: CommunityComment[]; + onCommentsChanged?: (comments: CommunityComment[]) => void; +}; + +export function CommunityCommentThread({ + memoId, + userId, + memoOwnerId, + comments = [], + onCommentsChanged, +}: CommunityCommentThreadProps) { + const [items, setItems] = useState(comments); + const [content, setContent] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + setItems(comments); + }, [comments]); + + const syncComments = (nextItems: CommunityComment[]) => { + setItems(nextItems); + onCommentsChanged?.(nextItems); + }; + + const canSubmit = useMemo(() => Boolean(content.trim()) && !submitting, [content, submitting]); + + const handleSubmit = async () => { + const trimmed = content.trim(); + if (!trimmed || submitting) { + return; + } + + setSubmitting(true); + setError(null); + try { + const response = await fetch(`/api/community/memos/${memoId}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user_id: userId, content: trimmed }), + }); + const data = await response.json().catch(() => null); + + if (!response.ok || !data?.comment) { + setError(data?.error || "回复发布失败,请稍后重试。"); + return; + } + + const nextItems = [...items, data.comment as CommunityComment]; + syncComments(nextItems); + setContent(""); + emitCommunityChanged(); + } catch { + setError("网络异常,请稍后重试。"); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async (commentId: string) => { + if (!window.confirm("确定删除这条评论吗?")) { + return; + } + + setDeletingId(commentId); + setError(null); + try { + const response = await fetch( + `/api/community/comments/${commentId}?user_id=${encodeURIComponent(userId)}`, + { method: "DELETE" } + ); + const data = await response.json().catch(() => null); + + if (!response.ok) { + setError(data?.error || "评论删除失败,请稍后重试。"); + return; + } + + const nextItems = items.filter((item) => item.id !== commentId); + syncComments(nextItems); + emitCommunityChanged(); + } catch { + setError("网络异常,请稍后重试。"); + } finally { + setDeletingId(null); + } + }; + + return ( +
+ Discussion +

评论区

+

把补充说明、追问和实践反馈继续沉淀在这里。

+ +
+