1 Commits
test2 ... memos

Author SHA1 Message Date
eric
bbaa38b3b3 's' 2026-03-15 04:25:45 -05:00
32 changed files with 4312 additions and 152 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown> = { 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 });
}
}

View File

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

View File

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

View File

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

View File

@@ -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<string | null>(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 (
<div className={styles.pageStack}>
<CommunityPageHero
eyebrow="Archive"
title="归档但不丢失"
description="把已经沉淀完成的内容移入归档,保持首页干净,同时保留随时可查的历史记录。"
chips={["历史沉淀", "仍可检索", "适合经验总结"]}
actions={[{ href: "/community", label: "返回首页", secondary: true }]}
/>
{showLoginPrompt ? (
<section className={styles.vipBanner}>
<p></p>
<Link href="/" className={styles.ctaLink}>
</Link>
</section>
) : null}
{isVip && userId ? (
<CommunityTimeline
userId={userId}
onVipRequired={() => setShowLoginPrompt(true)}
state="ARCHIVED"
emptyText="目前还没有归档内容。"
/>
) : (
<section className={styles.lockedPanel}>
<div className={styles.lockedPanelCopy}>
<span className={styles.lockedPanelEyebrow}>Members Only</span>
<h2 className={styles.lockedPanelTitle}></h2>
<p className={styles.lockedPanelDesc}></p>
</div>
<div className={styles.lockedPanelActions}>
<Link href="/" className={styles.heroPrimaryAction}>
</Link>
</div>
</section>
)}
</div>
);
}

View File

@@ -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<string | null>(null);
const [isVip, setIsVip] = useState(false);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [items, setItems] = useState<AttachmentItem[]>([]);
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 (
<div className={styles.pageStack}>
<CommunityPageHero
eyebrow="Attachment Library"
title="图片和资料集中沉淀"
description="所有在动态里上传的图片都会自动汇总到这里,方便回看和二次引用。"
chips={["自动聚合", "适合资料沉淀", "支持快速回看"]}
actions={[{ href: "/community", label: "返回首页", secondary: true }]}
/>
{showLoginPrompt ? (
<section className={styles.vipBanner}>
<p></p>
<Link href="/" className={styles.ctaLink}>
</Link>
</section>
) : null}
{isVip && userId ? (
items.length ? (
<>
<section className={styles.attachmentGrid}>
{items.map((item) => (
<a key={item.id} href={item.url} target="_blank" rel="noreferrer" className={styles.attachmentCard}>
<img src={item.url} alt={item.original_name} className={styles.attachmentImage} />
<div className={styles.attachmentMeta}>
<span className={styles.attachmentName}>{item.original_name}</span>
<span className={styles.attachmentSubMeta}>
{formatFileSize(item.size)} · {new Date(item.created).toLocaleDateString("zh-CN")}
</span>
</div>
</a>
))}
</section>
{hasMore ? (
<button
type="button"
className={styles.loadMorePanelBtn}
onClick={() => void fetchAttachments(page + 1, true)}
disabled={loading}
>
{loading ? "加载中..." : "加载更多附件"}
</button>
) : null}
</>
) : (
<section className={styles.lockedPanel}>
<div className={styles.lockedPanelCopy}>
<span className={styles.lockedPanelEyebrow}>Attachment Library</span>
<h2 className={styles.lockedPanelTitle}></h2>
<p className={styles.lockedPanelDesc}></p>
</div>
</section>
)
) : (
<section className={styles.lockedPanel}>
<div className={styles.lockedPanelCopy}>
<span className={styles.lockedPanelEyebrow}>Members Only</span>
<h2 className={styles.lockedPanelTitle}></h2>
<p className={styles.lockedPanelDesc}></p>
</div>
<div className={styles.lockedPanelActions}>
<Link href="/" className={styles.heroPrimaryAction}>
</Link>
</div>
</section>
)}
</div>
);
}

View File

@@ -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<CommunityComment[]>(comments);
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<section className={styles.commentPanel}>
<span className={styles.commentEyebrow}>Discussion</span>
<h2 className={styles.commentTitle}></h2>
<p className={styles.commentHint}></p>
<div className={styles.commentForm}>
<textarea
className={styles.commentTextarea}
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder="写下你的补充、问题或结论..."
maxLength={3000}
disabled={submitting}
/>
<div className={styles.commentFormFooter}>
<span className={styles.commentCount}>{items.length} </span>
<button
type="button"
className={styles.commentSubmit}
onClick={() => void handleSubmit()}
disabled={!canSubmit}
>
{submitting ? "发送中..." : "发送评论"}
</button>
</div>
</div>
{error ? <p className={styles.commentStatus}>{error}</p> : null}
{items.length ? (
<div className={styles.commentList}>
{items.map((comment) => {
const canDelete = comment.user_id === userId || memoOwnerId === userId;
return (
<article key={comment.id} className={styles.commentItem}>
<div className={styles.commentHeader}>
<div className={styles.memoIdentity}>
<div className={styles.memoAvatar}>{buildCommunityAvatarText(comment.user_id)}</div>
<div className={styles.commentMeta}>
<span className={styles.commentAuthor}>{maskCommunityUserId(comment.user_id)}</span>
<span className={styles.commentTime}>
{formatCommunityTime(comment.created || comment.updated)}
</span>
</div>
</div>
{canDelete ? (
<div className={styles.commentActions}>
<button
type="button"
className={styles.commentDeleteBtn}
onClick={() => void handleDelete(comment.id)}
disabled={deletingId === comment.id}
>
{deletingId === comment.id ? "删除中..." : "删除"}
</button>
</div>
) : null}
</div>
<div
className={styles.commentContent}
dangerouslySetInnerHTML={{ __html: renderCommunityMarkdown(comment.content) }}
/>
</article>
);
})}
</div>
) : (
<p className={styles.commentEmpty}></p>
)}
</section>
);
}

View File

@@ -0,0 +1,224 @@
"use client";
import Link from "next/link";
import styles from "./community.module.css";
import { renderCommunityMarkdown } from "./renderMarkdown";
export type CommunityAttachment = {
url: string;
alt?: string;
};
export type CommunityComment = {
id: string;
memo_id: string;
user_id: string;
content: string;
created?: string;
updated?: string;
};
export type CommunityMemo = {
id: string;
user_id: string;
content: string;
pinned?: boolean;
visibility?: string;
state?: "NORMAL" | "ARCHIVED";
created?: string;
updated?: string;
create_time?: string;
update_time?: string;
display_time?: string;
tags?: string[];
attachments?: CommunityAttachment[];
comment_count?: number;
reaction_count?: number;
liked_by_me?: boolean;
comments?: CommunityComment[];
};
export function parseTags(content: string): string[] {
const matches = content.match(/#[\w\u4e00-\u9fa5]+/g) || [];
return [...new Set(matches.map((match) => match.slice(1)))];
}
export function formatCommunityTime(iso?: string): string {
if (!iso) {
return "";
}
try {
const date = new Date(iso);
const diff = Date.now() - date.getTime();
if (diff < 60_000) {
return "刚刚";
}
if (diff < 3_600_000) {
return `${Math.floor(diff / 60_000)} 分钟前`;
}
if (diff < 86_400_000) {
return `${Math.floor(diff / 3_600_000)} 小时前`;
}
if (diff < 604_800_000) {
return `${Math.floor(diff / 86_400_000)} 天前`;
}
return date.toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "";
}
}
export function maskCommunityUserId(value: string): string {
if (!value) {
return "匿名成员";
}
if (/^user\d+$/i.test(value)) {
return `成员 ${value.slice(-4)}`;
}
return value.length > 12 ? `${value.slice(0, 8)}...` : value;
}
export function buildCommunityAvatarText(value: string): string {
if (!value) {
return "匿";
}
if (/^user\d+$/i.test(value)) {
return value.slice(-2);
}
return value.slice(0, 2).toUpperCase();
}
type CommunityMemoCardProps = {
memo: CommunityMemo;
isOwner?: boolean;
actionDisabled?: boolean;
hideDetailLink?: boolean;
onDelete?: (id: string) => void;
onPinToggle?: (id: string, pinned: boolean) => void;
onReactionToggle?: (id: string) => void;
onArchiveToggle?: (id: string, nextState: "NORMAL" | "ARCHIVED") => void;
onTagClick?: (tag: string) => void;
};
export function CommunityMemoCard({
memo,
isOwner,
actionDisabled = false,
hideDetailLink = false,
onDelete,
onPinToggle,
onReactionToggle,
onArchiveToggle,
onTagClick,
}: CommunityMemoCardProps) {
const tags = memo.tags?.length ? memo.tags : parseTags(memo.content);
const attachments = memo.attachments ?? [];
const nextArchiveState = memo.state === "ARCHIVED" ? "NORMAL" : "ARCHIVED";
return (
<article className={styles.memoCard}>
<div className={styles.memoHeader}>
<div className={styles.memoIdentity}>
<div className={styles.memoAvatar}>{buildCommunityAvatarText(memo.user_id)}</div>
<div className={styles.memoIdentityText}>
<span className={styles.memoAuthor}>{maskCommunityUserId(memo.user_id)}</span>
<span className={styles.memoMeta}>
{formatCommunityTime(
memo.display_time || memo.created || memo.updated || memo.create_time || memo.update_time
)}
</span>
</div>
</div>
<div className={styles.memoHeaderSide}>
{memo.pinned ? <span className={styles.pinnedBadge}></span> : null}
{memo.state === "ARCHIVED" ? <span className={styles.archivedBadge}></span> : null}
</div>
</div>
{tags.length ? (
<div className={styles.memoTags}>
{tags.map((tag) => (
<button key={tag} type="button" className={styles.tagBtn} onClick={() => onTagClick?.(tag)}>
#{tag}
</button>
))}
</div>
) : null}
<div
className={styles.memoContent}
dangerouslySetInnerHTML={{
__html: renderCommunityMarkdown(memo.content),
}}
/>
<div className={styles.memoFooter}>
<div className={styles.memoFooterStats}>
<span className={styles.memoFooterMeta}> {memo.reaction_count ?? 0}</span>
<span className={styles.memoFooterMeta}> {memo.comment_count ?? 0}</span>
{attachments.length ? <span className={styles.memoFooterMeta}> {attachments.length}</span> : null}
</div>
<div className={styles.memoFooterActions}>
{onReactionToggle ? (
<button
type="button"
className={memo.liked_by_me ? styles.footerActionBtnActive : styles.footerActionBtn}
onClick={() => onReactionToggle(memo.id)}
disabled={actionDisabled}
>
{memo.liked_by_me ? "已赞" : "点赞"}
</button>
) : null}
{!hideDetailLink ? (
<Link href={`/community/post/${memo.id}`} className={styles.detailLink}>
</Link>
) : null}
{isOwner && onPinToggle ? (
<button
type="button"
className={memo.pinned ? styles.pinBtnActive : styles.pinBtn}
onClick={() => onPinToggle(memo.id, !memo.pinned)}
disabled={actionDisabled}
>
{memo.pinned ? "取消置顶" : "置顶"}
</button>
) : null}
{isOwner && onArchiveToggle ? (
<button
type="button"
className={styles.footerActionBtn}
onClick={() => onArchiveToggle(memo.id, nextArchiveState)}
disabled={actionDisabled}
>
{memo.state === "ARCHIVED" ? "恢复" : "归档"}
</button>
) : null}
{isOwner && onDelete ? (
<button
type="button"
className={styles.deleteBtn}
onClick={() => onDelete(memo.id)}
disabled={actionDisabled}
>
</button>
) : null}
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,319 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type ClipboardEvent,
type DragEvent,
type FormEvent,
type KeyboardEvent,
} from "react";
import { EmojiPicker } from "./EmojiPicker";
import { renderCommunityMarkdown } from "./renderMarkdown";
import styles from "./community.module.css";
type CommunityMemoFormProps = {
onSubmit: (content: string) => Promise<void>;
disabled?: boolean;
maxLength?: number;
userId?: string | null;
restoreContent?: string | null;
onRestoreConsumed?: () => void;
};
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
const MAX_SIZE = 10 * 1024 * 1024;
const QUICK_TOPICS = ["#出海复盘", "#AI工具", "#远程工作", "#副业实验"];
export function CommunityMemoForm({
onSubmit,
disabled = false,
maxLength = 10000,
userId,
restoreContent,
onRestoreConsumed,
}: CommunityMemoFormProps) {
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [emojiOpen, setEmojiOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const emojiBtnRef = useRef<HTMLButtonElement>(null);
const insertAtCursor = useCallback(
(text: string) => {
const textarea = textareaRef.current;
if (!textarea) {
setContent((current) => (current + text).slice(0, maxLength));
return;
}
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const next = `${content.slice(0, start)}${text}${content.slice(end)}`.slice(0, maxLength);
setContent(next);
setTimeout(() => {
textarea.focus();
const nextCursor = Math.min(start + text.length, next.length);
textarea.setSelectionRange(nextCursor, nextCursor);
}, 0);
},
[content, maxLength]
);
const uploadFile = useCallback(
async (file: File): Promise<string | null> => {
if (!userId || !ALLOWED_TYPES.includes(file.type) || file.size > MAX_SIZE) {
return null;
}
setUploading(true);
try {
const body = new FormData();
body.append("user_id", userId);
body.append("file", file, file.name);
const response = await fetch("/api/community/upload", {
method: "POST",
body,
});
const data = await response.json().catch(() => null);
if (response.ok && data?.ok && data?.markdown) {
return data.markdown;
}
return null;
} finally {
setUploading(false);
}
},
[userId]
);
const handlePaste = useCallback(
async (event: ClipboardEvent) => {
const items = event.clipboardData?.items;
if (!items || !userId) {
return;
}
for (const item of Array.from(items)) {
if (item.kind !== "file" || !item.type.startsWith("image/")) {
continue;
}
const file = item.getAsFile();
if (!file || !ALLOWED_TYPES.includes(file.type) || file.size > MAX_SIZE) {
continue;
}
event.preventDefault();
const markdown = await uploadFile(file);
if (markdown) {
insertAtCursor(`${markdown}\n`);
}
break;
}
},
[insertAtCursor, uploadFile, userId]
);
const handleDrop = useCallback(
async (event: DragEvent) => {
event.preventDefault();
setDragOver(false);
if (!userId || disabled) {
return;
}
const file = event.dataTransfer?.files?.[0];
if (!file || !ALLOWED_TYPES.includes(file.type) || file.size > MAX_SIZE) {
return;
}
const markdown = await uploadFile(file);
if (markdown) {
insertAtCursor(`${markdown}\n`);
}
},
[disabled, insertAtCursor, uploadFile, userId]
);
const handleFileSelect = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !userId || !ALLOWED_TYPES.includes(file.type) || file.size > MAX_SIZE) {
return;
}
const markdown = await uploadFile(file);
if (markdown) {
insertAtCursor(`${markdown}\n`);
}
},
[insertAtCursor, uploadFile, userId]
);
const handleSubmit = useCallback(
async (event?: FormEvent) => {
event?.preventDefault();
const trimmed = content.trim();
if (!trimmed || submitting || disabled) {
return;
}
setSubmitting(true);
setContent("");
try {
await onSubmit(trimmed);
} catch {
setContent(trimmed);
} finally {
setSubmitting(false);
}
},
[content, disabled, onSubmit, submitting]
);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
void handleSubmit();
}
},
[handleSubmit]
);
useEffect(() => {
if (!restoreContent) {
return;
}
setContent(restoreContent);
onRestoreConsumed?.();
}, [onRestoreConsumed, restoreContent]);
return (
<form
onSubmit={handleSubmit}
className={styles.memoForm}
onDragOver={(event) => {
event.preventDefault();
if (userId && !disabled) {
setDragOver(true);
}
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
<div className={styles.memoFormHeader}>
<div>
<span className={styles.memoFormEyebrow}>Planet Post</span>
<h2 className={styles.memoFormTitle}></h2>
<p className={styles.memoFormHint}></p>
</div>
<div className={styles.memoQuickTopics}>
{QUICK_TOPICS.map((topic) => (
<button
key={topic}
type="button"
className={styles.quickTopicBtn}
onClick={() => insertAtCursor(`${topic} `)}
>
{topic}
</button>
))}
</div>
</div>
<div className={`${styles.textareaWrapper} ${dragOver ? styles.textareaDropActive : ""}`}>
<textarea
ref={textareaRef}
className={styles.memoTextarea}
value={content}
onChange={(event) => setContent(event.target.value.slice(0, maxLength))}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
placeholder="写下你的观察、复盘、问题或资源推荐。支持 Markdown、标签和图片。"
rows={5}
disabled={disabled || submitting}
maxLength={maxLength}
/>
{dragOver ? <div className={styles.dropOverlay}></div> : null}
</div>
<div className={styles.memoFormFooter}>
<div className={styles.footerTop}>
<div className={styles.charCountRow}>
<span className={styles.charCount}>
{content.length} / {maxLength}
{uploading ? " · 图片上传中..." : ""}
</span>
<div className={styles.charCountActions}>
<button
ref={emojiBtnRef}
type="button"
className={styles.emojiTrigger}
onClick={() => setEmojiOpen((open) => !open)}
disabled={disabled || submitting}
title="插入表情"
aria-label="插入表情"
>
🙂
</button>
<EmojiPicker
open={emojiOpen}
onClose={() => setEmojiOpen(false)}
onSelect={(emoji) => insertAtCursor(emoji)}
anchorRef={emojiBtnRef}
/>
</div>
</div>
<div className={styles.formActions}>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className={styles.hiddenInput}
onChange={handleFileSelect}
/>
<button
type="button"
className={styles.uploadBtn}
onClick={() => fileInputRef.current?.click()}
disabled={disabled || submitting || !userId || uploading}
>
</button>
<button type="submit" className={styles.submitBtn} disabled={!content.trim() || submitting || disabled}>
{submitting ? "发布中..." : "发布动态"}
</button>
</div>
</div>
{content.trim() ? (
<div className={styles.draftPreview}>
<span className={styles.draftLabel}></span>
<div
className={styles.draftContent}
dangerouslySetInnerHTML={{
__html: renderCommunityMarkdown(content),
}}
/>
</div>
) : (
<div className={styles.publishTip}>
<span></span>
<span> `Ctrl/Cmd + Enter` </span>
</div>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,55 @@
import Link from "next/link";
import styles from "./memos.module.css";
type HeroAction = {
href: string;
label: string;
secondary?: boolean;
};
type CommunityPageHeroProps = {
eyebrow: string;
title: string;
description: string;
chips?: string[];
actions?: HeroAction[];
};
export function CommunityPageHero({
eyebrow,
title,
description,
chips = [],
actions = [],
}: CommunityPageHeroProps) {
return (
<section className={styles.heroCard}>
<div className={styles.heroGlow} />
<span className={styles.heroEyebrow}>{eyebrow}</span>
<h1 className={styles.heroTitle}>{title}</h1>
<p className={styles.heroDesc}>{description}</p>
{chips.length ? (
<div className={styles.heroChips}>
{chips.map((chip) => (
<span key={chip} className={styles.heroChip}>
{chip}
</span>
))}
</div>
) : null}
{actions.length ? (
<div className={styles.heroActions}>
{actions.map((action) => (
<Link
key={`${action.href}-${action.label}`}
href={action.href}
className={action.secondary ? styles.heroSecondaryAction : styles.heroPrimaryAction}
>
{action.label}
</Link>
))}
</div>
) : null}
</section>
);
}

View File

@@ -0,0 +1,257 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { COMMUNITY_CHANGED_EVENT } from "./communityEvents";
import { maskCommunityUserId } from "./CommunityMemoCard";
import styles from "./memos.module.css";
type StatsPayload = {
stats: {
members: number | null;
total_memos: number;
normal_memos: number;
archived_memos: number;
pinned_memos: number;
contributors: number;
attachments: number;
comments: number;
reactions: number;
today_memos: number;
};
top_tags: Array<{ tag: string; count: number }>;
top_authors: Array<{ user_id: string; memo_count: number; latest_created: string }>;
latest_memos: Array<{ id: string; user_id: string; content: string }>;
};
type AttachmentItem = {
id: string;
url: string;
original_name: string;
created: string;
};
type CommunitySidebarProps = {
userId: string | null;
isVip: boolean;
};
function summarizeContent(content: string) {
const plain = content.replace(/[#>*`!\[\]()]/g, " ").replace(/\s+/g, " ").trim();
return plain.length > 42 ? `${plain.slice(0, 42)}...` : plain;
}
export function CommunitySidebar({ userId, isVip }: CommunitySidebarProps) {
const [stats, setStats] = useState<StatsPayload | null>(null);
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isVip || !userId) {
return;
}
let cancelled = false;
const load = async () => {
setLoading(true);
try {
const [statsRes, attachmentsRes] = await Promise.all([
fetch(`/api/community/stats?user_id=${encodeURIComponent(userId)}`, { cache: "no-store" }),
fetch(`/api/community/attachments?user_id=${encodeURIComponent(userId)}&per_page=6`, {
cache: "no-store",
}),
]);
const [statsData, attachmentsData] = await Promise.all([
statsRes.json().catch(() => null),
attachmentsRes.json().catch(() => null),
]);
if (cancelled) {
return;
}
if (statsRes.ok && statsData?.ok) {
setStats(statsData as StatsPayload);
}
if (attachmentsRes.ok && attachmentsData?.ok) {
setAttachments(attachmentsData.items ?? []);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void load();
const handler = () => {
void load();
};
window.addEventListener(COMMUNITY_CHANGED_EVENT, handler);
return () => {
cancelled = true;
window.removeEventListener(COMMUNITY_CHANGED_EVENT, handler);
};
}, [isVip, userId]);
const statCards = useMemo(() => {
if (!stats) {
return [];
}
return [
{
label: "动态",
value: stats.stats.normal_memos,
hint: `今日 +${stats.stats.today_memos}`,
},
{
label: "成员",
value: stats.stats.members ?? stats.stats.contributors,
hint: `${stats.stats.contributors} 位发言者`,
},
{
label: "评论",
value: stats.stats.comments,
hint: `${stats.stats.reactions} 次点赞`,
},
{
label: "附件",
value: stats.stats.attachments,
hint: `${stats.stats.pinned_memos} 条置顶`,
},
];
}, [stats]);
if (!isVip || !userId) {
return (
<div className={styles.sidebarStack}>
<section className={styles.sidebarCard}>
<span className={styles.sidebarEyebrow}>Private Planet</span>
<h3 className={styles.sidebarTitle}></h3>
<p className={styles.sidebarDesc}>
</p>
<div className={styles.sidebarFeatureList}>
<span className={styles.sidebarFeature}></span>
<span className={styles.sidebarFeature}></span>
<span className={styles.sidebarFeature}></span>
</div>
<Link href="/" className={styles.sidebarCTA}>
</Link>
</section>
</div>
);
}
return (
<div className={styles.sidebarStack}>
<section className={styles.sidebarCard}>
<span className={styles.sidebarEyebrow}>Planet Overview</span>
<h3 className={styles.sidebarTitle}></h3>
<p className={styles.sidebarDesc}> AI </p>
<div className={styles.sidebarStatsGrid}>
{statCards.map((card) => (
<div key={card.label} className={styles.sidebarStatCard}>
<span className={styles.sidebarStatLabel}>{card.label}</span>
<strong className={styles.sidebarStatValue}>{card.value}</strong>
<span className={styles.sidebarStatHint}>{card.hint}</span>
</div>
))}
</div>
{loading && !stats ? <p className={styles.sidebarLoading}>...</p> : null}
</section>
<section className={styles.sidebarCard}>
<div className={styles.sidebarSectionHeader}>
<h3 className={styles.sidebarTitle}></h3>
<Link href="/community/explore" className={styles.sidebarLink}>
</Link>
</div>
<div className={styles.sidebarTagList}>
{(stats?.top_tags ?? []).slice(0, 8).map((item) => (
<Link
key={item.tag}
href={`/community/explore?tag=${encodeURIComponent(item.tag)}`}
className={styles.sidebarTag}
>
#{item.tag}
<span>{item.count}</span>
</Link>
))}
{!stats?.top_tags?.length ? (
<p className={styles.sidebarEmpty}></p>
) : null}
</div>
</section>
<section className={styles.sidebarCard}>
<div className={styles.sidebarSectionHeader}>
<h3 className={styles.sidebarTitle}></h3>
<Link href="/community" className={styles.sidebarLink}>
</Link>
</div>
<div className={styles.sidebarList}>
{(stats?.top_authors ?? []).slice(0, 5).map((item) => (
<div key={item.user_id} className={styles.sidebarListItem}>
<div className={styles.sidebarAvatar}>{maskCommunityUserId(item.user_id).slice(-2)}</div>
<div>
<div className={styles.sidebarListTitle}>{maskCommunityUserId(item.user_id)}</div>
<div className={styles.sidebarListMeta}>{item.memo_count} </div>
</div>
</div>
))}
{!stats?.top_authors?.length ? (
<p className={styles.sidebarEmpty}></p>
) : null}
</div>
</section>
<section className={styles.sidebarCard}>
<div className={styles.sidebarSectionHeader}>
<h3 className={styles.sidebarTitle}></h3>
<Link href="/community" className={styles.sidebarLink}>
</Link>
</div>
<div className={styles.sidebarList}>
{(stats?.latest_memos ?? []).map((item) => (
<Link key={item.id} href={`/community/post/${item.id}`} className={styles.explorerLink}>
<div className={styles.sidebarAvatar}>{maskCommunityUserId(item.user_id).slice(-2)}</div>
<div>
<div className={styles.sidebarListTitle}>{maskCommunityUserId(item.user_id)}</div>
<div className={styles.sidebarListMeta}>{summarizeContent(item.content)}</div>
</div>
</Link>
))}
{!stats?.latest_memos?.length ? (
<p className={styles.sidebarEmpty}></p>
) : null}
</div>
</section>
<section className={styles.sidebarCard}>
<div className={styles.sidebarSectionHeader}>
<h3 className={styles.sidebarTitle}></h3>
<Link href="/community/attachments" className={styles.sidebarLink}>
</Link>
</div>
<div className={styles.sidebarAttachmentGrid}>
{attachments.map((item) => (
<a key={item.id} href={item.url} target="_blank" rel="noreferrer" className={styles.sidebarAttachment}>
<img src={item.url} alt={item.original_name} className={styles.sidebarAttachmentImage} />
</a>
))}
</div>
{!attachments.length ? <p className={styles.sidebarEmpty}></p> : null}
</section>
</div>
);
}

View File

@@ -0,0 +1,414 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
CommunityMemoCard,
parseTags,
type CommunityMemo,
} from "./CommunityMemoCard";
import { COMMUNITY_CHANGED_EVENT, emitCommunityChanged } from "./communityEvents";
import styles from "./community.module.css";
const CACHE_KEY_PREFIX = "community_memos_cache";
type CommunityTimelineProps = {
userId: string;
onVipRequired?: () => void;
refreshKey?: number | string;
prependedMemos?: CommunityMemo[];
state?: "NORMAL" | "ARCHIVED";
query?: string;
emptyText?: string;
};
function getCacheKey(state: string, query: string) {
return `${CACHE_KEY_PREFIX}_${state}_${query}`;
}
function loadCachedMemos(userId: string, state: string, query: string): CommunityMemo[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = sessionStorage.getItem(getCacheKey(state, query));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as { user?: string; items?: CommunityMemo[] };
if (parsed?.user === userId && Array.isArray(parsed.items)) {
return parsed.items;
}
} catch {
return [];
}
return [];
}
function saveCachedMemos(userId: string, state: string, query: string, items: CommunityMemo[]) {
if (typeof window === "undefined") {
return;
}
try {
sessionStorage.setItem(getCacheKey(state, query), JSON.stringify({ user: userId, items }));
} catch {
// ignore cache failure
}
}
export function CommunityTimeline({
userId,
onVipRequired,
refreshKey,
prependedMemos = [],
state = "NORMAL",
query = "",
emptyText = "还没有内容,先发第一条动态吧。",
}: CommunityTimelineProps) {
const normalizedQuery = query.trim().toLowerCase();
const [items, setItems] = useState<CommunityMemo[]>(() => loadCachedMemos(userId, state, normalizedQuery));
const [loading, setLoading] = useState(items.length === 0);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [mutatingId, setMutatingId] = useState<string | null>(null);
const [tagFilter, setTagFilter] = useState<string | null>(null);
const updateItems = useCallback(
(updater: (current: CommunityMemo[]) => CommunityMemo[]) => {
setItems((current) => {
const next = updater(current);
saveCachedMemos(userId, state, normalizedQuery, next);
return next;
});
},
[normalizedQuery, state, userId]
);
const fetchMemos = useCallback(
async (pageNum: number, append: boolean) => {
if (!userId) {
return;
}
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
}
setError(null);
try {
const params = new URLSearchParams({
user_id: userId,
page: String(pageNum),
per_page: "20",
state,
});
if (normalizedQuery) {
params.set("query", normalizedQuery);
}
const response = await fetch(`/api/community/memos?${params.toString()}`, { cache: "no-store" });
const data = await response.json().catch(() => null);
if (response.status === 403) {
onVipRequired?.();
setItems([]);
setError("仅会员可访问私密社群。");
return;
}
if (!response.ok) {
setError(data?.error || "加载失败,请稍后重试。");
return;
}
const nextItems = Array.isArray(data?.items) ? (data.items as CommunityMemo[]) : [];
updateItems((current) => {
if (!append) {
return nextItems;
}
const seen = new Set(current.map((item) => item.id));
return [...current, ...nextItems.filter((item) => !seen.has(item.id))];
});
setHasMore(Boolean(data?.next_page_token));
setPage(pageNum);
} catch {
setError("网络异常,请稍后重试。");
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[normalizedQuery, onVipRequired, state, updateItems, userId]
);
useEffect(() => {
setTagFilter(null);
void fetchMemos(1, false);
}, [fetchMemos, refreshKey]);
useEffect(() => {
const handler = () => {
void fetchMemos(1, false);
};
window.addEventListener(COMMUNITY_CHANGED_EVENT, handler);
return () => window.removeEventListener(COMMUNITY_CHANGED_EVENT, handler);
}, [fetchMemos]);
const handleLoadMore = useCallback(() => {
if (!hasMore || loadingMore) {
return;
}
void fetchMemos(page + 1, true);
}, [fetchMemos, hasMore, loadingMore, page]);
const handlePinToggle = useCallback(
async (memoId: string, pinned: boolean) => {
if (!userId || mutatingId) {
return;
}
setMutatingId(memoId);
try {
const response = await fetch(`/api/community/memos/${memoId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId, pinned }),
});
if (!response.ok) {
return;
}
updateItems((current) =>
current.map((item) => (item.id === memoId ? { ...item, pinned } : item))
);
emitCommunityChanged();
} finally {
setMutatingId(null);
}
},
[mutatingId, updateItems, userId]
);
const handleReactionToggle = useCallback(
async (memoId: string) => {
if (!userId || mutatingId) {
return;
}
setMutatingId(memoId);
try {
const response = await fetch(`/api/community/memos/${memoId}/reactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId }),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
return;
}
const reaction = data?.reaction;
updateItems((current) =>
current.map((item) =>
item.id === memoId
? {
...item,
liked_by_me: Boolean(reaction?.liked),
reaction_count: Number(reaction?.reaction_count ?? item.reaction_count ?? 0),
}
: item
)
);
emitCommunityChanged();
} finally {
setMutatingId(null);
}
},
[mutatingId, updateItems, userId]
);
const handleArchiveToggle = useCallback(
async (memoId: string, nextState: "NORMAL" | "ARCHIVED") => {
if (!userId || mutatingId) {
return;
}
setMutatingId(memoId);
try {
const response = await fetch(`/api/community/memos/${memoId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId, state: nextState }),
});
if (!response.ok) {
return;
}
updateItems((current) =>
current.flatMap((item) => {
if (item.id !== memoId) {
return [item];
}
if (nextState !== state) {
return [];
}
return [{ ...item, state: nextState }];
})
);
emitCommunityChanged();
} finally {
setMutatingId(null);
}
},
[mutatingId, state, updateItems, userId]
);
const handleDelete = useCallback(
async (memoId: string) => {
if (!userId || mutatingId || !window.confirm("确定删除这条动态吗?")) {
return;
}
setMutatingId(memoId);
try {
const response = await fetch(
`/api/community/memos/${memoId}?user_id=${encodeURIComponent(userId)}`,
{ method: "DELETE" }
);
if (!response.ok) {
return;
}
updateItems((current) => current.filter((item) => item.id !== memoId));
emitCommunityChanged();
} finally {
setMutatingId(null);
}
},
[mutatingId, updateItems, userId]
);
const displayItems = useMemo(() => {
const seen = new Set<string>();
return [...prependedMemos, ...items].filter((item) => {
if (seen.has(item.id)) {
return false;
}
seen.add(item.id);
return true;
});
}, [items, prependedMemos]);
const allTags = useMemo(() => {
const tagSet = new Set<string>();
displayItems.forEach((item) => {
const tags = item.tags?.length ? item.tags : parseTags(item.content);
tags.forEach((tag) => tagSet.add(tag.toLowerCase()));
});
return [...tagSet].sort();
}, [displayItems]);
const filteredItems = useMemo(() => {
if (!tagFilter) {
return displayItems;
}
return displayItems.filter((item) => {
const tags = item.tags?.length ? item.tags : parseTags(item.content);
return tags.some((tag) => tag.toLowerCase() === tagFilter);
});
}, [displayItems, tagFilter]);
if (loading && displayItems.length === 0) {
return (
<div className={styles.timelineLoading}>
<div className={styles.spinner} />
<span>...</span>
</div>
);
}
if (error) {
return (
<div className={styles.timelineError}>
<p>{error}</p>
</div>
);
}
if (displayItems.length === 0) {
return (
<div className={styles.timelineEmpty}>
<p>{emptyText}</p>
</div>
);
}
return (
<div className={styles.timeline}>
{allTags.length ? (
<div className={styles.tagFilter}>
<button
type="button"
className={!tagFilter ? styles.tagActive : styles.tagChip}
onClick={() => setTagFilter(null)}
>
</button>
{allTags.map((tag) => (
<button
key={tag}
type="button"
className={tagFilter === tag ? styles.tagActive : styles.tagChip}
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
>
#{tag}
</button>
))}
</div>
) : null}
{filteredItems.length ? (
filteredItems.map((memo) => (
<CommunityMemoCard
key={memo.id}
memo={memo}
isOwner={memo.user_id === userId}
actionDisabled={mutatingId === memo.id}
onDelete={handleDelete}
onPinToggle={handlePinToggle}
onReactionToggle={handleReactionToggle}
onArchiveToggle={handleArchiveToggle}
onTagClick={(tag) => setTagFilter(tag.toLowerCase())}
/>
))
) : (
<div className={styles.timelineEmpty}>
<p></p>
</div>
)}
{hasMore && !tagFilter ? (
<button
type="button"
className={styles.loadMoreBtn}
onClick={handleLoadMore}
disabled={loadingMore}
>
{loadingMore ? "加载中..." : "加载更多"}
</button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { useEffect, useRef, type RefObject } from "react";
import styles from "./community.module.css";
const EMOJI_LIST = [
"🙂",
"😉",
"😄",
"😀",
"😅",
"😍",
"🤝",
"🔥",
"🚀",
"💡",
"🎯",
"📌",
"✅",
"👍",
"🎉",
"🛠️",
"❤️",
"📚",
"🧠",
"🤖",
"🌍",
"💬",
"📎",
"🔍",
];
type EmojiPickerProps = {
open: boolean;
onClose: () => void;
onSelect: (emoji: string) => void;
anchorRef: RefObject<HTMLElement | null>;
};
export function EmojiPicker({ open, onClose, onSelect, anchorRef }: EmojiPickerProps) {
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
popoverRef.current &&
!popoverRef.current.contains(target) &&
anchorRef.current &&
!anchorRef.current.contains(target)
) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [anchorRef, onClose, open]);
if (!open) {
return null;
}
return (
<div ref={popoverRef} className={styles.emojiPicker} role="listbox" aria-label="选择表情">
{EMOJI_LIST.map((emoji) => (
<button
key={emoji}
type="button"
className={styles.emojiBtn}
onClick={() => {
onSelect(emoji);
onClose();
}}
role="option"
>
{emoji}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import Link from "next/link";
import type { MouseEvent } from "react";
import styles from "./memos.module.css";
type MemoExplorerProps = {
userId: string | null;
isVip: boolean;
pathname: string;
onVipRequired?: () => void;
};
const ROUTES = {
HOME: "/community",
EXPLORE: "/community/explore",
ARCHIVED: "/community/archived",
ATTACHMENTS: "/community/attachments",
} as const;
export function MemoExplorer({ userId, isVip, pathname, onVipRequired }: MemoExplorerProps) {
const navItems = [
{ href: ROUTES.HOME, label: "星球首页", icon: "主" },
{ href: ROUTES.EXPLORE, label: "发现话题", icon: "搜" },
{ href: ROUTES.ARCHIVED, label: "归档内容", icon: "档" },
{ href: ROUTES.ATTACHMENTS, label: "附件资料", icon: "图" },
];
const handleNavClick = (event: MouseEvent, href: string) => {
if (!isVip && href !== ROUTES.HOME) {
event.preventDefault();
onVipRequired?.();
}
};
return (
<nav className={styles.explorer}>
<div className={styles.explorerBrand}>
<Link href="/" className={styles.brandBackLink}>
</Link>
<h2 className={styles.explorerBrandTitle}></h2>
<p className={styles.explorerBrandDesc}></p>
</div>
<div className={styles.explorerSection}>
<h3 className={styles.explorerTitle}></h3>
<ul className={styles.explorerList}>
{navItems.map((item) => {
const isActive =
pathname === item.href || (item.href !== ROUTES.HOME && pathname.startsWith(item.href));
return (
<li key={item.href}>
<Link
href={item.href}
className={`${styles.explorerLink} ${isActive ? styles.explorerLinkActive : ""}`}
onClick={(event) => handleNavClick(event, item.href)}
>
<span className={styles.explorerIcon}>{item.icon}</span>
<span>{item.label}</span>
</Link>
</li>
);
})}
</ul>
</div>
<div className={styles.explorerSection}>
<h3 className={styles.explorerTitle}></h3>
<div className={styles.explorerRules}>
<span></span>
<span>便</span>
<span></span>
</div>
</div>
{userId ? (
<div className={styles.explorerUser}>
<span className={styles.userLabel}></span>
<span className={styles.userId}>{userId}</span>
<span className={styles.userState}>{isVip ? "VIP 已解锁完整社群" : "当前仅可预览社群结构"}</span>
</div>
) : (
<div className={styles.explorerUser}>
<span className={styles.userLabel}></span>
<span className={styles.userId}></span>
<span className={styles.userState}></span>
</div>
)}
</nav>
);
}

View File

@@ -0,0 +1,601 @@
.memoForm,
.memoCard,
.commentPanel {
border: 1px solid color-mix(in srgb, var(--border) 72%, #f59e0b 28%);
border-radius: 24px;
background: color-mix(in srgb, var(--card) 88%, #fff7e6 12%);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.06);
}
.memoForm,
.commentPanel {
padding: 1.25rem;
}
.memoFormHeader {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.memoFormEyebrow,
.commentEyebrow {
display: inline-flex;
padding: 0.28rem 0.6rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(180, 83, 9, 0.14);
color: #b45309;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.memoFormTitle,
.commentTitle {
margin: 0.8rem 0 0;
font-size: 1.3rem;
color: #231815;
}
.memoFormHint,
.commentHint,
.commentEmpty {
margin: 0.45rem 0 0;
color: var(--muted-foreground);
line-height: 1.65;
}
.memoQuickTopics {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.quickTopicBtn,
.tagBtn,
.tagChip,
.tagActive,
.pinBtn,
.pinBtnActive,
.deleteBtn,
.uploadBtn,
.submitBtn,
.emojiTrigger,
.loadMoreBtn,
.footerActionBtn,
.footerActionBtnActive,
.commentSubmit,
.commentDeleteBtn,
.detailLink {
border: none;
cursor: pointer;
transition: transform 0.18s ease, opacity 0.18s ease, background 0.18s ease, border-color 0.18s ease;
}
.quickTopicBtn {
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
color: #92400e;
font-size: 0.82rem;
}
.quickTopicBtn:hover,
.tagBtn:hover,
.tagChip:hover,
.emojiTrigger:hover,
.uploadBtn:hover,
.loadMoreBtn:hover,
.footerActionBtn:hover,
.detailLink:hover,
.commentSubmit:hover {
transform: translateY(-1px);
}
.textareaWrapper {
position: relative;
}
.textareaDropActive::after {
content: "";
position: absolute;
inset: 0;
border-radius: 18px;
border: 2px dashed rgba(245, 158, 11, 0.45);
pointer-events: none;
}
.memoTextarea,
.commentTextarea {
width: 100%;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.88);
color: var(--foreground);
font-size: 0.97rem;
line-height: 1.75;
resize: vertical;
}
.memoTextarea {
min-height: 12rem;
padding: 1rem 1rem 1.1rem;
}
.commentTextarea {
min-height: 7rem;
padding: 0.9rem 0.95rem;
}
.memoTextarea:focus,
.commentTextarea:focus {
outline: none;
border-color: rgba(245, 158, 11, 0.52);
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12);
}
.dropOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 18px;
background: rgba(245, 158, 11, 0.1);
color: #b45309;
font-weight: 700;
}
.memoFormFooter,
.commentForm {
display: flex;
flex-direction: column;
gap: 0.9rem;
margin-top: 0.9rem;
}
.footerTop,
.commentFormFooter,
.memoFooter,
.memoFooterActions,
.memoFooterStats,
.commentHeader,
.commentMeta,
.commentActions {
display: flex;
align-items: center;
gap: 0.7rem;
}
.footerTop,
.memoFooter,
.commentFormFooter,
.commentHeader {
justify-content: space-between;
}
.charCountRow,
.formActions,
.memoActions,
.charCountActions {
display: flex;
align-items: center;
gap: 0.55rem;
}
.charCount,
.commentCount,
.commentTime,
.memoFooterMeta {
color: var(--muted-foreground);
font-size: 0.82rem;
}
.charCountActions {
position: relative;
}
.emojiPicker {
position: absolute;
right: 0;
bottom: calc(100% + 0.4rem);
z-index: 20;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.25rem;
width: 15rem;
max-height: 12rem;
overflow-y: auto;
padding: 0.55rem;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
}
.emojiBtn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2rem;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
}
.emojiBtn:hover {
background: rgba(245, 158, 11, 0.1);
}
.emojiTrigger,
.uploadBtn,
.detailLink,
.footerActionBtn,
.commentDeleteBtn {
padding: 0.52rem 0.85rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.76);
color: #5b4636;
text-decoration: none;
}
.submitBtn,
.loadMoreBtn,
.commentSubmit,
.footerActionBtnActive {
min-height: 2.8rem;
padding: 0.75rem 1.1rem;
border-radius: 14px;
background: linear-gradient(135deg, #f59e0b, #fb923c);
color: #fffaf2;
font-weight: 700;
box-shadow: 0 12px 28px rgba(245, 158, 11, 0.26);
}
.submitBtn:disabled,
.uploadBtn:disabled,
.emojiTrigger:disabled,
.loadMoreBtn:disabled,
.pinBtn:disabled,
.deleteBtn:disabled,
.footerActionBtn:disabled,
.footerActionBtnActive:disabled,
.commentSubmit:disabled,
.commentDeleteBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.hiddenInput {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.draftPreview,
.publishTip {
padding: 0.95rem 1rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(148, 163, 184, 0.14);
}
.draftLabel {
display: inline-flex;
margin-bottom: 0.6rem;
color: var(--muted-foreground);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.draftContent,
.memoContent,
.commentContent {
color: var(--foreground);
font-size: 0.95rem;
line-height: 1.75;
}
.memoContent img,
.draftContent img,
.commentContent img {
max-width: 100%;
border-radius: 18px;
margin: 0.7rem 0;
}
.memoContent p,
.draftContent p,
.commentContent p {
margin: 0 0 0.7rem;
}
.memoContent pre,
.memoContent code,
.draftContent pre,
.draftContent code,
.commentContent pre,
.commentContent code {
font-family: ui-monospace, monospace;
background: rgba(15, 23, 42, 0.06);
border-radius: 12px;
}
.memoContent pre,
.draftContent pre,
.commentContent pre {
padding: 0.9rem;
overflow-x: auto;
}
.memoContent code,
.draftContent code,
.commentContent code {
padding: 0.15rem 0.35rem;
}
.memoContent a,
.draftContent a,
.commentContent a {
color: #b45309;
text-decoration: none;
}
.memoContent a:hover,
.draftContent a:hover,
.commentContent a:hover {
text-decoration: underline;
}
.publishTip {
display: flex;
gap: 0.5rem;
color: var(--muted-foreground);
font-size: 0.88rem;
}
.memoCard {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1.1rem 1.15rem;
}
.memoHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.memoIdentity {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.memoAvatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 999px;
background: linear-gradient(135deg, rgba(245, 158, 11, 0.16), rgba(251, 191, 36, 0.3));
color: #92400e;
font-weight: 700;
}
.memoIdentityText {
display: flex;
flex-direction: column;
gap: 0.18rem;
}
.commentMeta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.18rem;
}
.memoAuthor,
.commentAuthor {
color: #231815;
font-weight: 700;
}
.memoHeaderSide {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.pinnedBadge,
.archivedBadge {
display: inline-flex;
align-items: center;
padding: 0.32rem 0.55rem;
border-radius: 999px;
font-size: 0.76rem;
font-weight: 700;
}
.pinnedBadge {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.archivedBadge {
background: rgba(148, 163, 184, 0.15);
color: #475569;
}
.memoActions {
flex-wrap: wrap;
}
.pinBtn,
.pinBtnActive,
.deleteBtn {
padding: 0.42rem 0.68rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
color: #5b4636;
font-size: 0.82rem;
}
.pinBtnActive {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.deleteBtn,
.commentDeleteBtn {
color: #b91c1c;
}
.memoTags,
.tagFilter {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.memoTags {
margin-top: -0.15rem;
}
.tagBtn,
.tagChip {
padding: 0.38rem 0.72rem;
border-radius: 999px;
background: rgba(245, 158, 11, 0.1);
color: #b45309;
font-size: 0.82rem;
}
.tagActive {
padding: 0.38rem 0.72rem;
border-radius: 999px;
background: linear-gradient(135deg, #f59e0b, #fb923c);
color: #fffaf2;
font-size: 0.82rem;
font-weight: 700;
}
.memoFooter {
margin-top: 0.2rem;
flex-wrap: wrap;
}
.memoFooterStats,
.memoFooterActions {
flex-wrap: wrap;
}
.timeline {
display: flex;
flex-direction: column;
gap: 0.95rem;
}
.timelineLoading,
.timelineError,
.timelineEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 10rem;
padding: 2rem 1rem;
text-align: center;
color: var(--muted-foreground);
}
.spinner {
width: 2rem;
height: 2rem;
border-radius: 999px;
border: 3px solid rgba(148, 163, 184, 0.3);
border-top-color: #f59e0b;
animation: communitySpin 0.9s linear infinite;
margin-bottom: 0.8rem;
}
.commentList {
display: flex;
flex-direction: column;
gap: 0.9rem;
margin-top: 1rem;
}
.commentItem {
padding: 0.95rem 1rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(148, 163, 184, 0.14);
}
.commentHeader {
margin-bottom: 0.55rem;
}
.commentMeta {
min-width: 0;
}
.commentActions {
justify-content: flex-end;
}
.commentStatus {
color: var(--muted-foreground);
font-size: 0.84rem;
}
@keyframes communitySpin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 640px) {
.memoForm,
.memoCard,
.commentPanel {
border-radius: 20px;
}
.memoTextarea {
min-height: 10rem;
}
.memoHeader,
.memoFooter,
.footerTop,
.commentHeader,
.commentFormFooter {
flex-direction: column;
align-items: stretch;
}
.publishTip {
flex-direction: column;
}
.memoFooterActions {
width: 100%;
}
}

View File

@@ -0,0 +1,8 @@
export const COMMUNITY_CHANGED_EVENT = "community:changed";
export function emitCommunityChanged() {
if (typeof window === "undefined") {
return;
}
window.dispatchEvent(new Event(COMMUNITY_CHANGED_EVENT));
}

View File

@@ -0,0 +1,559 @@
.communityShell {
--community-accent: #f59e0b;
--community-accent-deep: #b45309;
--community-surface: color-mix(in srgb, var(--card) 88%, #fff7e6 12%);
--community-border: color-mix(in srgb, var(--border) 72%, #f59e0b 28%);
display: grid;
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(280px, 320px);
gap: 1.5rem;
align-items: start;
width: 100%;
}
.leftRail,
.rightRail {
position: sticky;
top: 5.5rem;
}
.centerRail {
min-width: 0;
}
.pageStack {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.main {
max-width: 720px;
margin: 0 auto;
padding: 1.5rem 0;
}
.pageDesc {
color: var(--muted-foreground);
}
.heroCard {
position: relative;
overflow: hidden;
padding: 1.5rem;
border: 1px solid var(--community-border);
border-radius: 28px;
background:
radial-gradient(circle at top right, rgba(245, 158, 11, 0.2), transparent 34%),
linear-gradient(145deg, rgba(255, 247, 230, 0.9), rgba(255, 255, 255, 0.75));
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
}
.heroGlow {
position: absolute;
right: -3rem;
top: -3rem;
width: 12rem;
height: 12rem;
border-radius: 999px;
background: radial-gradient(circle, rgba(245, 158, 11, 0.35), transparent 70%);
pointer-events: none;
}
.heroEyebrow,
.sidebarEyebrow,
.lockedPanelEyebrow {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.65rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(180, 83, 9, 0.14);
color: var(--community-accent-deep);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.heroTitle,
.lockedPanelTitle {
margin: 0.85rem 0 0;
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.02;
letter-spacing: -0.05em;
color: #231815;
}
.heroDesc,
.lockedPanelDesc,
.sidebarDesc,
.explorerBrandDesc,
.filterHint {
margin: 0.85rem 0 0;
font-size: 0.97rem;
line-height: 1.75;
color: var(--muted-foreground);
}
.heroChips,
.sidebarFeatureList {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin-top: 1rem;
}
.heroChip,
.sidebarFeature {
padding: 0.45rem 0.7rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(148, 163, 184, 0.25);
color: #3f3f46;
font-size: 0.82rem;
}
.heroActions,
.lockedPanelActions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.2rem;
}
.heroPrimaryAction,
.sidebarCTA,
.ctaLink,
.loadMorePanelBtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 2.8rem;
padding: 0.78rem 1.2rem;
border: none;
border-radius: 14px;
background: linear-gradient(135deg, #f59e0b, #fb923c);
color: #fffaf2;
text-decoration: none;
font-weight: 700;
box-shadow: 0 12px 28px rgba(245, 158, 11, 0.28);
cursor: pointer;
}
.heroSecondaryAction,
.sidebarLink,
.backLink {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.8rem;
padding: 0.78rem 1.1rem;
border-radius: 14px;
border: 1px solid var(--community-border);
color: #4b5563;
text-decoration: none;
background: rgba(255, 255, 255, 0.64);
font-weight: 600;
}
.vipBanner,
.lockedPanel,
.filterPanel,
.sidebarCard,
.explorer,
.attachmentCard {
border: 1px solid var(--community-border);
border-radius: 24px;
background: var(--community-surface);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.06);
}
.vipBanner {
padding: 1rem 1.1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.vipBanner p {
margin: 0;
color: #5b4636;
}
.lockedPanel {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
padding: 1.35rem;
}
.lockedPanelCopy {
max-width: 38rem;
}
.explorer {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.1rem;
}
.explorerBrand {
padding-bottom: 0.95rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
}
.brandBackLink {
color: var(--muted-foreground);
text-decoration: none;
font-size: 0.84rem;
}
.explorerBrandTitle,
.sidebarTitle {
margin: 0.8rem 0 0;
font-size: 1.15rem;
color: #231815;
}
.explorerSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.explorerTitle {
margin: 0;
color: var(--muted-foreground);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.explorerList {
display: flex;
flex-direction: column;
gap: 0.35rem;
list-style: none;
margin: 0;
padding: 0;
}
.explorerLink {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.75rem 0.85rem;
border-radius: 16px;
color: #3f3f46;
text-decoration: none;
transition: background 0.2s ease, transform 0.2s ease;
}
.explorerLink:hover {
background: rgba(245, 158, 11, 0.1);
transform: translateX(2px);
}
.explorerLinkActive {
background: rgba(245, 158, 11, 0.16);
color: var(--community-accent-deep);
font-weight: 700;
}
.explorerIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.45rem;
height: 1.45rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(245, 158, 11, 0.18);
font-size: 0.78rem;
}
.explorerRules {
display: flex;
flex-direction: column;
gap: 0.55rem;
color: var(--muted-foreground);
font-size: 0.9rem;
line-height: 1.6;
}
.explorerUser {
padding-top: 0.85rem;
border-top: 1px solid rgba(148, 163, 184, 0.18);
}
.userLabel {
display: block;
font-size: 0.78rem;
color: var(--muted-foreground);
}
.userId {
display: block;
margin-top: 0.35rem;
font-family: ui-monospace, monospace;
color: #292524;
word-break: break-all;
}
.userState {
display: block;
margin-top: 0.45rem;
color: var(--community-accent-deep);
font-size: 0.82rem;
}
.sidebarStack {
display: flex;
flex-direction: column;
gap: 1rem;
}
.sidebarCard {
padding: 1rem;
}
.sidebarStatsGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 1rem;
}
.sidebarStatCard {
padding: 0.9rem 0.8rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(148, 163, 184, 0.14);
}
.sidebarStatLabel,
.sidebarStatHint,
.attachmentSubMeta,
.filterHint,
.sidebarLoading,
.sidebarEmpty,
.sidebarListMeta {
color: var(--muted-foreground);
font-size: 0.78rem;
}
.sidebarStatValue {
display: block;
margin-top: 0.2rem;
font-size: 1.35rem;
line-height: 1;
color: #231815;
}
.sidebarSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.sidebarTagList {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin-top: 0.85rem;
}
.sidebarTag {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.7rem;
border-radius: 999px;
background: rgba(245, 158, 11, 0.1);
color: var(--community-accent-deep);
text-decoration: none;
font-size: 0.82rem;
}
.sidebarTag span {
color: var(--muted-foreground);
}
.sidebarList {
display: flex;
flex-direction: column;
gap: 0.8rem;
margin-top: 0.85rem;
}
.sidebarListItem {
display: flex;
align-items: center;
gap: 0.75rem;
}
.sidebarAvatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.3rem;
height: 2.3rem;
border-radius: 999px;
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(251, 191, 36, 0.35));
color: var(--community-accent-deep);
font-weight: 700;
}
.sidebarListTitle,
.attachmentName {
color: #231815;
font-weight: 600;
}
.sidebarAttachmentGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 0.85rem;
}
.sidebarAttachment,
.attachmentCard {
overflow: hidden;
text-decoration: none;
}
.sidebarAttachmentImage,
.attachmentImage {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.sidebarAttachment {
aspect-ratio: 1 / 1;
border-radius: 16px;
}
.filterPanel {
padding: 1rem 1.1rem;
}
.searchField {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.searchLabel {
font-size: 0.82rem;
color: var(--muted-foreground);
font-weight: 600;
}
.searchInput {
width: 100%;
min-height: 3rem;
padding: 0.8rem 0.95rem;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.86);
color: var(--foreground);
font-size: 0.95rem;
}
.searchInput:focus {
outline: none;
border-color: rgba(245, 158, 11, 0.5);
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12);
}
.attachmentGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.attachmentCard {
display: flex;
flex-direction: column;
background: var(--community-surface);
}
.attachmentImage {
aspect-ratio: 1 / 1;
}
.attachmentMeta {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.9rem 0.95rem 1rem;
}
.loadMorePanelBtn {
width: 100%;
}
@media (max-width: 1200px) {
.communityShell {
grid-template-columns: 250px minmax(0, 1fr);
}
.rightRail {
position: static;
grid-column: 1 / -1;
}
}
@media (max-width: 900px) {
.communityShell {
grid-template-columns: 1fr;
}
.leftRail,
.rightRail {
position: static;
}
.vipBanner {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 640px) {
.heroCard,
.vipBanner,
.lockedPanel,
.filterPanel,
.sidebarCard,
.explorer {
border-radius: 20px;
}
.heroCard {
padding: 1.2rem;
}
.heroTitle,
.lockedPanelTitle {
font-size: 1.8rem;
}
.sidebarStatsGrid,
.sidebarAttachmentGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.attachmentGrid {
grid-template-columns: 1fr 1fr;
}
}

View File

@@ -0,0 +1,19 @@
import { marked } from "marked";
function escapeHtml(source: string): string {
return source
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
export function renderCommunityMarkdown(source: string): string {
const html = marked.parse(escapeHtml(source), { breaks: true, gfm: true }) as string;
return html
.replaceAll("<a href=", '<a target="_blank" rel="noopener noreferrer" href=')
.replace(/href="(?:javascript|data):[^"]*"/gi, 'href="#"')
.replace(/src="(?:javascript):[^"]*"/gi, 'src=""');
}

View File

@@ -0,0 +1,102 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, 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 ExplorePage() {
const [userId, setUserId] = useState<string | null>(null);
const [isVip, setIsVip] = useState(false);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [query, setQuery] = useState("");
useEffect(() => {
const uid = getStorage("userId") || getStorage("memosAccount");
const type = getStorage("paidType");
setUserId(uid || null);
setIsVip(type === "vip");
}, []);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const tag = new URLSearchParams(window.location.search).get("tag");
if (tag) {
setQuery(`#${tag}`);
}
}, []);
const normalizedQuery = useMemo(() => query.trim(), [query]);
return (
<div className={styles.pageStack}>
<CommunityPageHero
eyebrow="Explore Topics"
title="发现高频问题与热门标签"
description="按关键词或标签搜索整个社群,把分散动态重新整理成可浏览的话题页。"
chips={["标签聚合", "关键词检索", "按时间流浏览"]}
actions={[{ href: "/community", label: "返回首页", secondary: true }]}
/>
<section className={styles.filterPanel}>
<div className={styles.searchField}>
<label htmlFor="community-search" className={styles.searchLabel}>
</label>
<input
id="community-search"
className={styles.searchInput}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="例如AI 工具、远程工作、自动化"
/>
</div>
<div className={styles.filterHint}>
`#标签`
</div>
</section>
{showLoginPrompt ? (
<section className={styles.vipBanner}>
<p></p>
<Link href="/" className={styles.ctaLink}>
</Link>
</section>
) : null}
{isVip && userId ? (
<CommunityTimeline
userId={userId}
onVipRequired={() => setShowLoginPrompt(true)}
state="NORMAL"
query={normalizedQuery}
emptyText="没有匹配当前关键词的动态,换个标签试试。"
/>
) : (
<section className={styles.lockedPanel}>
<div className={styles.lockedPanelCopy}>
<span className={styles.lockedPanelEyebrow}>Members Only</span>
<h2 className={styles.lockedPanelTitle}></h2>
<p className={styles.lockedPanelDesc}></p>
</div>
<div className={styles.lockedPanelActions}>
<Link href="/" className={styles.heroPrimaryAction}>
</Link>
</div>
</section>
)}
</div>
);
}

92
app/community/layout.tsx Normal file
View File

@@ -0,0 +1,92 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { VipLayout } from "@/app/vip/components/VipLayout";
import { COMMUNITY_CONFIG } from "@/config/community.config";
import { MemoExplorer } from "./components/MemoExplorer";
import { CommunitySidebar } from "./components/CommunitySidebar";
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 CommunityLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [userId, setUserId] = useState<string | null>(null);
const [isVip, setIsVip] = useState(false);
const [userName, setUserName] = useState<string | null>(null);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [showVipPrompt, setShowVipPrompt] = useState(false);
useEffect(() => {
const nextUserId = getStorage("userId") || getStorage("memosAccount");
const type = getStorage("paidType");
const name = getStorage("userName") || getStorage("userEmail");
const email = getStorage("userEmail");
setUserId(nextUserId || null);
setIsVip(type === "vip");
setUserName(name || null);
setUserEmail(email || null);
}, []);
const handleLogout = useCallback(async () => {
const { pbLogout } = await import("@/app/lib/pocketbase");
await pbLogout();
if (typeof window !== "undefined") {
localStorage.clear();
window.location.replace("/");
}
}, []);
if (!COMMUNITY_CONFIG.enabled) {
return (
<VipLayout isLoggedIn={false} onLogout={handleLogout}>
<div className={styles.main}>
<p className={styles.pageDesc}></p>
</div>
</VipLayout>
);
}
return (
<VipLayout
isLoggedIn={Boolean(userId)}
userName={userName}
userEmail={userEmail}
onLogout={handleLogout}
>
<div className={styles.communityShell}>
<aside className={styles.leftRail}>
<MemoExplorer
userId={userId}
isVip={isVip}
pathname={pathname}
onVipRequired={() => setShowVipPrompt(true)}
/>
</aside>
<main className={styles.centerRail}>
{showVipPrompt ? (
<div className={styles.vipBanner}>
<p></p>
<a href="/" className={styles.ctaLink}>
</a>
</div>
) : null}
{children}
</main>
<aside className={styles.rightRail}>
<CommunitySidebar userId={userId} isVip={isVip} />
</aside>
</div>
</VipLayout>
);
}

157
app/community/page.tsx Normal file
View File

@@ -0,0 +1,157 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { CommunityMemoForm } from "./components/CommunityMemoForm";
import { CommunityTimeline } from "./components/CommunityTimeline";
import { CommunityPageHero } from "./components/CommunityPageHero";
import { emitCommunityChanged } from "./components/communityEvents";
import type { CommunityMemo } from "./components/CommunityMemoCard";
import { COMMUNITY_CONFIG } from "@/config/community.config";
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 CommunityPage() {
const [userId, setUserId] = useState<string | null>(null);
const [isVip, setIsVip] = useState(false);
const [timelineKey, setTimelineKey] = useState(0);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [restoreContent, setRestoreContent] = useState<string | null>(null);
const [prependedMemos, setPrependedMemos] = useState<CommunityMemo[]>([]);
useEffect(() => {
const uid = getStorage("userId") || getStorage("memosAccount");
const type = getStorage("paidType");
setUserId(uid || null);
setIsVip(type === "vip");
}, []);
const handlePublish = useCallback(
async (content: string) => {
if (!userId) {
setShowLoginPrompt(true);
throw new Error("请先登录");
}
const tempMemo: CommunityMemo = {
id: `temp-${Date.now()}`,
user_id: userId,
content,
pinned: false,
state: "NORMAL",
created: new Date().toISOString(),
updated: new Date().toISOString(),
comment_count: 0,
reaction_count: 0,
liked_by_me: false,
};
setPrependedMemos([tempMemo]);
try {
const response = await fetch("/api/community/memos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId, content }),
});
const data = await response.json().catch(() => null);
if (response.status === 403) {
setShowLoginPrompt(true);
setPrependedMemos([]);
setRestoreContent(content);
throw new Error(data?.error || "仅会员可发布内容");
}
if (!response.ok) {
setPrependedMemos([]);
setRestoreContent(content);
throw new Error(data?.error || "发布失败,请稍后重试");
}
setPrependedMemos([]);
setTimelineKey((current) => current + 1);
emitCommunityChanged();
} catch (error) {
setPrependedMemos([]);
setRestoreContent(content);
throw error;
}
},
[userId]
);
if (!COMMUNITY_CONFIG.enabled) {
return (
<div className={styles.main}>
<p className={styles.pageDesc}></p>
</div>
);
}
return (
<div className={styles.pageStack}>
<CommunityPageHero
eyebrow="Private Community"
title="像知识星球一样持续沉淀内容"
description="围绕出海、AI、自动化和设备实践沉淀动态、评论、附件与可检索的话题索引。"
chips={["会员可见", "支持 Markdown", "图片自动沉淀", "可点赞评论"]}
actions={[
{ href: "/community/explore", label: "浏览话题", secondary: true },
{ href: "/community/attachments", label: "查看附件" },
]}
/>
{showLoginPrompt ? (
<section className={styles.vipBanner}>
<p></p>
<Link href="/" className={styles.ctaLink}>
</Link>
</section>
) : null}
{isVip && userId ? (
<>
<CommunityMemoForm
onSubmit={handlePublish}
disabled={false}
maxLength={COMMUNITY_CONFIG.maxContentLength}
userId={userId}
restoreContent={restoreContent}
onRestoreConsumed={() => setRestoreContent(null)}
/>
<CommunityTimeline
userId={userId}
refreshKey={timelineKey}
prependedMemos={prependedMemos}
onVipRequired={() => setShowLoginPrompt(true)}
/>
</>
) : (
<section className={styles.lockedPanel}>
<div className={styles.lockedPanelCopy}>
<span className={styles.lockedPanelEyebrow}>Members Only</span>
<h2 className={styles.lockedPanelTitle}></h2>
<p className={styles.lockedPanelDesc}>
</p>
</div>
<div className={styles.lockedPanelActions}>
<Link href="/" className={styles.heroPrimaryAction}>
</Link>
<Link href="/community/explore" className={styles.heroSecondaryAction}>
</Link>
</div>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,283 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import {
CommunityMemoCard,
type CommunityComment,
type CommunityMemo,
} from "../../components/CommunityMemoCard";
import { CommunityCommentThread } from "../../components/CommunityCommentThread";
import { CommunityPageHero } from "../../components/CommunityPageHero";
import { emitCommunityChanged } from "../../components/communityEvents";
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 CommunityPostPage() {
const params = useParams();
const router = useRouter();
const memoId = Array.isArray(params.id) ? params.id[0] : params.id;
const [userId, setUserId] = useState<string | null>(null);
const [isVip, setIsVip] = useState(false);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [memo, setMemo] = useState<CommunityMemo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [mutating, setMutating] = useState(false);
useEffect(() => {
const uid = getStorage("userId") || getStorage("memosAccount");
const type = getStorage("paidType");
setUserId(uid || null);
setIsVip(type === "vip");
}, []);
const loadMemo = useCallback(async () => {
if (!memoId || !userId || !isVip) {
return;
}
setLoading(true);
setError(null);
try {
const response = await fetch(
`/api/community/memos/${memoId}?user_id=${encodeURIComponent(userId)}`,
{ cache: "no-store" }
);
const data = await response.json().catch(() => null);
if (response.status === 403) {
setShowLoginPrompt(true);
setError("仅会员可访问完整讨论。");
return;
}
if (!response.ok || !data?.memo) {
setError(data?.error || "帖子不存在或已删除。");
return;
}
setMemo(data.memo as CommunityMemo);
} catch {
setError("网络异常,请稍后重试。");
} finally {
setLoading(false);
}
}, [isVip, memoId, userId]);
useEffect(() => {
void loadMemo();
}, [loadMemo]);
const handlePinToggle = useCallback(
async (_memoId: string, pinned: boolean) => {
if (!userId || !memo) {
return;
}
setMutating(true);
try {
const response = await fetch(`/api/community/memos/${memo.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId, pinned }),
});
const data = await response.json().catch(() => null);
if (!response.ok || !data?.memo) {
return;
}
setMemo(data.memo as CommunityMemo);
emitCommunityChanged();
} finally {
setMutating(false);
}
},
[memo, userId]
);
const handleReactionToggle = useCallback(
async (_memoId: string) => {
if (!userId || !memo) {
return;
}
setMutating(true);
try {
const response = await fetch(`/api/community/memos/${memo.id}/reactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId }),
});
const data = await response.json().catch(() => null);
if (!response.ok || !data?.reaction) {
return;
}
setMemo((current) =>
current
? {
...current,
liked_by_me: Boolean(data.reaction.liked),
reaction_count: Number(data.reaction.reaction_count ?? current.reaction_count ?? 0),
}
: current
);
emitCommunityChanged();
} finally {
setMutating(false);
}
},
[memo, userId]
);
const handleArchiveToggle = useCallback(
async (_memoId: string, nextState: "NORMAL" | "ARCHIVED") => {
if (!userId || !memo) {
return;
}
setMutating(true);
try {
const response = await fetch(`/api/community/memos/${memo.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId, state: nextState }),
});
const data = await response.json().catch(() => null);
if (!response.ok || !data?.memo) {
return;
}
setMemo(data.memo as CommunityMemo);
emitCommunityChanged();
} finally {
setMutating(false);
}
},
[memo, userId]
);
const handleDelete = useCallback(async () => {
if (!userId || !memo || !window.confirm("确定删除这条动态吗?")) {
return;
}
setMutating(true);
try {
const response = await fetch(
`/api/community/memos/${memo.id}?user_id=${encodeURIComponent(userId)}`,
{ method: "DELETE" }
);
if (!response.ok) {
return;
}
emitCommunityChanged();
router.replace("/community");
} finally {
setMutating(false);
}
}, [memo, router, userId]);
const handleCommentsChanged = useCallback((comments: CommunityComment[]) => {
setMemo((current) =>
current
? {
...current,
comments,
comment_count: comments.length,
}
: current
);
}, []);
return (
<div className={styles.pageStack}>
<CommunityPageHero
eyebrow="Post Detail"
title="完整讨论与评论"
description="这里保留这条动态的完整上下文,你可以继续点赞、评论、置顶或归档。"
chips={["完整上下文", "评论沉淀", "支持继续互动"]}
actions={[{ href: "/community", label: "返回动态流", secondary: true }]}
/>
{showLoginPrompt ? (
<section className={styles.vipBanner}>
<p></p>
<Link href="/" className={styles.ctaLink}>
</Link>
</section>
) : null}
{!isVip || !userId ? (
<section className={styles.lockedPanel}>
<div className={styles.lockedPanelCopy}>
<span className={styles.lockedPanelEyebrow}>Members Only</span>
<h2 className={styles.lockedPanelTitle}></h2>
<p className={styles.lockedPanelDesc}></p>
</div>
<div className={styles.lockedPanelActions}>
<Link href="/" className={styles.heroPrimaryAction}>
</Link>
</div>
</section>
) : null}
{isVip && userId ? (
loading ? (
<section className={styles.filterPanel}>
<p className={styles.filterHint}>...</p>
</section>
) : error || !memo ? (
<section className={styles.lockedPanel}>
<div className={styles.lockedPanelCopy}>
<span className={styles.lockedPanelEyebrow}>Unavailable</span>
<h2 className={styles.lockedPanelTitle}></h2>
<p className={styles.lockedPanelDesc}>{error || "帖子不存在或已被删除。"}</p>
</div>
<div className={styles.lockedPanelActions}>
<Link href="/community" className={styles.heroSecondaryAction}>
</Link>
</div>
</section>
) : (
<>
<CommunityMemoCard
memo={memo}
isOwner={memo.user_id === userId}
actionDisabled={mutating}
hideDetailLink
onDelete={() => void handleDelete()}
onPinToggle={handlePinToggle}
onReactionToggle={handleReactionToggle}
onArchiveToggle={handleArchiveToggle}
/>
<CommunityCommentThread
memoId={memo.id}
userId={userId}
memoOwnerId={memo.user_id}
comments={memo.comments ?? []}
onCommentsChanged={handleCommentsChanged}
/>
</>
)
) : null}
</div>
);
}

View File

@@ -1,12 +1,10 @@
/**
* VIP 首页 Mock 数据 - 集中管理
* 后续可替换为 api.hackrobot.cn 真实接口
* VIP 首页展示数据
*/
import { CURRICULUM_CONFIG } from "@/app/cloudphone/config/course";
/** 视频课程总数:从所有课程配置汇总计算 */
const TOTAL_VIDEOS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
const TOTAL_VIDEOS = CURRICULUM_CONFIG.modules.reduce((sum, module) => sum + module.lessons.length, 0);
export const ASSET_STATS = {
topics: 4,
@@ -18,28 +16,28 @@ export const ASSET_STATS = {
export const TOPICS = [
{
id: "ebook",
icon: "📖",
icon: "📙",
title: "电子书",
desc: "知识沉淀·随时阅读",
desc: "知识沉淀随时阅读",
href: "/ebook",
},
{
id: "cloudphone",
icon: "🎬",
icon: "🎞️",
title: "视频教程",
desc: "实战演练·即学即用",
desc: "实战演练即学即用",
href: "/cloudphone",
},
{
id: "community",
icon: "👥",
title: "私密社群",
desc: "抱团出海",
href: "https://qun.hackrobot.cn",
desc: "抱团出海,实时交流",
href: "/community",
},
{
id: "tools",
icon: "🛒",
icon: "🧰",
title: "设备自助下单",
desc: "开箱即用",
href: "https://store.hackrobot.cn/",
@@ -47,31 +45,28 @@ export const TOPICS = [
},
] as const;
/** 模块展示 - 首页展示用,电子书/视频教程点击滚动到同页对应区块 */
export const TOPICS_DISPLAY = [
{ id: "ebook", icon: "📖", title: "电子书", desc: "知识沉淀·随时阅读", href: "#ebooks" },
{ id: "cloudphone", icon: "🎬", title: "视频教程", desc: "实战演练·即学即用", href: "#video-courses" },
{ id: "community", icon: "👥", title: "私密社群", desc: "抱团出海", href: "https://qun.hackrobot.cn", locked: true },
{ id: "tools", icon: "🛒", title: "设备自助下单", desc: "开箱即用", href: "https://store.hackrobot.cn/", locked: true },
{ id: "ebook", icon: "📙", title: "电子书", desc: "知识沉淀随时阅读", href: "#ebooks" },
{ id: "cloudphone", icon: "🎞️", title: "视频教程", desc: "实战演练即学即用", href: "#video-courses" },
{ id: "community", icon: "👥", title: "私密社群", desc: "像知识星球一样沉淀内容与问答", href: "/community" },
{ id: "tools", icon: "🧰", title: "设备自助下单", desc: "开箱即用", href: "https://store.hackrobot.cn/", locked: true },
] as const;
/** 电子书展示 - 首页展示用 */
export const EBOOKS_DISPLAY = [
{ id: "1", title: "数字游民", desc: "地理套利与自动化杠杆", href: "/ebook", free: true },
{ id: "2", title: "云手机", desc: "低成本工作室搭建指南", href: "/ebook/cloudphone", locked: true },
] as const;
/** 视频教程展示 - 参考 digital.hackrobot.cn 资源导航专题,至少 4 个 */
export const VIDEO_COURSES_DISPLAY = [
{ id: "cloudphone", icon: "☁️", title: "云手机", desc: "低成本自建云手机TikTok/YouTube 运营搭建指南", href: "/cloudphone" },
{ id: "livestream", icon: "📺", title: "无人直播", desc: "TikTok/YouTube 无人直播24 小时自动化", href: "/topic/livestream" },
{ id: "indie", icon: "🛠️", title: "独立开发", desc: "从想法到产品,独立开发者全流程指南", href: "/topic/indie" },
{ id: "aiagent", icon: "🤖", title: "AI Agent", desc: "AIGC 与智能体Vibe Coding 跨界创造", href: "/topic/aiagent" },
{ id: "cloudphone", icon: "☁️", title: "云手机", desc: "低成本自建云手机与矩阵运营", href: "/cloudphone" },
{ id: "livestream", icon: "📵", title: "无人直播", desc: "TikTok / YouTube 无人直播实战", href: "/topic/livestream" },
{ id: "indie", icon: "🧭", title: "独立开发", desc: "从想法到产品的完整路径", href: "/topic/indie" },
{ id: "aiagent", icon: "🤖", title: "AI Agent", desc: "AIGC 与智能体工作流", href: "/topic/aiagent" },
] as const;
export const COMPARE_ITEMS = [
{ feature: "电子书", single: "按需开通", member: "全解锁" },
{ feature: "视频教程", single: "按需开通", member: "全解锁" },
{ feature: "电子书", single: "按需开通", member: "全解锁" },
{ feature: "视频教程", single: "按需开通", member: "全解锁" },
{ feature: "私密社群", single: "—", member: "✓" },
{ feature: "即时问答", single: "—", member: "✓" },
{ feature: "持续更新", single: "—", member: "✓" },
@@ -80,38 +75,37 @@ export const COMPARE_ITEMS = [
export const COMMUNITY_PREVIEW = [
{ icon: "👥", text: "私密社群,抱团出海" },
{ icon: "💬", text: "即时问答,技术咨询" },
{ icon: "🎪", text: "线下沙龙,面交对接" },
{ icon: "🗂️", text: "精选复盘、工具与附件沉淀" },
] as const;
export const FAQ_ITEMS = [
{
q: "会员包含哪些内容?",
a: "电子书、视频教程、私密社群、即时问答、设备自助下单等全部解锁,并享受持续更新。",
a: "电子书、视频教程、私密社群、即时问答、设备自助下单等内容会统一解锁,并持续更新。",
},
{
q: "如何加入?",
a: "点击立即加入完成支付即可,支付成功后自动开通,无需人工审核。",
a: "点击立即加入完成支付即可自动开通,无需人工审核。",
},
{
q: "支持退款吗?",
a: "本内容为虚拟数字交付,不支持退款购买前确认适合您的需求。",
a: "本内容为虚拟数字交付,不支持退款,请在购买前确认符合你的预期。",
},
] as const;
/** 已登录控制台用 */
export const RECENT_UPDATES = [
{ id: "1", title: "云手机·第3章更新", date: "310日", href: "/cloudphone/course/2/0" },
{ id: "2", title: "电子书·地理套利补充", date: "3月8日", href: "/ebook" },
{ id: "3", title: "模板库·RPA 脚本新增", date: "3月5日", href: null },
{ id: "1", title: "云手机第 3 章更新", date: "310 日", href: "/cloudphone/course/2/0" },
{ id: "2", title: "电子书补充:地理套利", date: "3 月 8 日", href: "/ebook" },
{ id: "3", title: "模板库新增:RPA 脚本", date: "3 月 6 日", href: null },
] as const;
export const COMMUNITY_HIGHLIGHTS = [
{ id: "1", text: "本周社群讨论:出海合规要点", href: "https://qun.hackrobot.cn" },
{ id: "1", text: "本周社群讨论:出海合规要点", href: "/community/explore" },
{ id: "2", text: "问答精选:云手机多开方案", href: "https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot" },
] as const;
export const WEEKLY_ACTIONS = [
{ id: "1", text: "完成云手机第2章", href: "/cloudphone/course/1/0", priority: 1 },
{ id: "1", text: "完成云手机第 2 章", href: "/cloudphone/course/1/0", priority: 1 },
{ id: "2", text: "阅读电子书地理套利篇", href: "/ebook", priority: 2 },
{ id: "3", text: "加入社群参与讨论", href: "https://qun.hackrobot.cn", priority: 3 },
{ id: "3", text: "加入社群参与讨论", href: "/community", priority: 3 },
] as const;

View File

@@ -14,29 +14,25 @@ export function TopicCards() {
className={`${styles.topicGrid} animate__animated animate__fadeInUp`}
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
{TOPICS.map((t: TopicItem) => {
const { id, href } = t;
{TOPICS.map((topic: TopicItem) => {
const content = (
<>
<span className={styles.topicIcon}>{t.icon}</span>
<span className={styles.topicTitle}>{t.title}</span>
<span className={styles.topicDesc}>{t.desc}</span>
<span className={styles.topicIcon}>{topic.icon}</span>
<span className={styles.topicTitle}>{topic.title}</span>
<span className={styles.topicDesc}>{topic.desc}</span>
</>
);
return href ? (
return (
<Link
key={id}
href={href}
key={topic.id}
href={topic.href}
className={styles.topicCard}
target={href.startsWith("http") ? "_blank" : undefined}
rel={href.startsWith("http") ? "noopener noreferrer" : undefined}
target={topic.href.startsWith("http") ? "_blank" : undefined}
rel={topic.href.startsWith("http") ? "noopener noreferrer" : undefined}
>
{content}
</Link>
) : (
<div key={id} className={styles.topicCard} style={{ cursor: "default", opacity: 0.7 }}>
{content}
</div>
);
})}
</div>

View File

@@ -303,11 +303,11 @@ export function WelcomeBlock({
<div className={styles.loginCardTitle}></div>
<div className={styles.loginCardRow}>
<span className={styles.loginCardLabel}></span>
<Copyable text="https://qun.hackrobot.cn">
qun.hackrobot.cn
<Copyable text="/community">
/community
</Copyable>
<a
href="https://qun.hackrobot.cn"
href="/community"
target="_blank"
rel="noopener noreferrer"
className={styles.loginCardLink}

View File

@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { TOPICS_DISPLAY, EBOOKS_DISPLAY, VIDEO_COURSES_DISPLAY } from "@/app/data/vip.mock";
import { EBOOKS_DISPLAY, TOPICS_DISPLAY, VIDEO_COURSES_DISPLAY } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
type TopicEbookShowcaseProps = {
@@ -11,29 +11,30 @@ type TopicEbookShowcaseProps = {
export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
return (
<section id="topics" className={`${styles.section} ${styles.topicsSection}`}>
{/* 模块展示 */}
<h2 className={styles.sectionTitle}></h2>
<h2 className={styles.sectionTitle}></h2>
<div
className={`${styles.topicGrid} animate__animated animate__fadeInUp`}
style={{ animationDelay: "0.2s", animationFillMode: "both", marginBottom: "1.5rem" }}
>
{TOPICS_DISPLAY.map((t) => {
const { id, href } = t;
const isLocked = "locked" in t && t.locked && onLockedClick;
{TOPICS_DISPLAY.map((topic) => {
const isLocked = "locked" in topic && topic.locked && onLockedClick;
const content = (
<span className={styles.topicCardInner}>
{isLocked && (
<span className={styles.topicLock} title="会员专享">🔒</span>
)}
<span className={styles.topicIcon}>{t.icon}</span>
<span className={styles.topicTitle}>{t.title}</span>
<span className={styles.topicDesc}>{t.desc}</span>
{isLocked ? (
<span className={styles.topicLock} title="会员专享">
🔒
</span>
) : null}
<span className={styles.topicIcon}>{topic.icon}</span>
<span className={styles.topicTitle}>{topic.title}</span>
<span className={styles.topicDesc}>{topic.desc}</span>
</span>
);
if (isLocked) {
return (
<button
key={id}
key={topic.id}
type="button"
className={styles.topicCard}
onClick={onLockedClick}
@@ -43,59 +44,48 @@ export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
</button>
);
}
if (href) {
if (topic.href) {
return (
<Link
key={id}
href={href}
key={topic.id}
href={topic.href}
className={styles.topicCard}
target={href.startsWith("http") ? "_blank" : undefined}
rel={href.startsWith("http") ? "noopener noreferrer" : undefined}
target={topic.href.startsWith("http") ? "_blank" : undefined}
rel={topic.href.startsWith("http") ? "noopener noreferrer" : undefined}
>
{content}
</Link>
);
}
return (
<div key={id} className={styles.topicCard} style={{ cursor: "default", opacity: 0.7 }}>
{content}
</div>
);
})}
</div>
{/* 视频教程展示 - 参考 digital 资源导航,至少 4 个 */}
<h2 id="video-courses" className={styles.sectionTitle}></h2>
<h2 id="video-courses" className={styles.sectionTitle}>
</h2>
<div
className={`${styles.topicGrid} animate__animated animate__fadeInUp`}
style={{ animationDelay: "0.22s", animationFillMode: "both", marginBottom: "1.5rem" }}
>
{VIDEO_COURSES_DISPLAY.map((v) => {
const { id, href, icon, title, desc } = v;
return href ? (
<Link
key={id}
href={href}
className={styles.topicCard}
target={href.startsWith("http") ? "_blank" : undefined}
rel={href.startsWith("http") ? "noopener noreferrer" : undefined}
>
<span className={styles.topicIcon}>{icon}</span>
<span className={styles.topicTitle}>{title}</span>
<span className={styles.topicDesc}>{desc}</span>
</Link>
) : (
<div key={id} className={styles.topicCard} style={{ cursor: "default", opacity: 0.9 }}>
<span className={styles.topicIcon}>{icon}</span>
<span className={styles.topicTitle}>{title}</span>
<span className={styles.topicDesc}>{desc}</span>
</div>
);
})}
{VIDEO_COURSES_DISPLAY.map((video) => (
<Link
key={video.id}
href={video.href}
className={styles.topicCard}
target={video.href.startsWith("http") ? "_blank" : undefined}
rel={video.href.startsWith("http") ? "noopener noreferrer" : undefined}
>
<span className={styles.topicIcon}>{video.icon}</span>
<span className={styles.topicTitle}>{video.title}</span>
<span className={styles.topicDesc}>{video.desc}</span>
</Link>
))}
</div>
{/* 电子书展示 */}
<h2 id="ebooks" className={styles.sectionTitle}></h2>
<h2 id="ebooks" className={styles.sectionTitle}>
</h2>
<div
className="animate__animated animate__fadeInUp"
style={{
@@ -106,55 +96,59 @@ export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
animationFillMode: "both",
}}
>
{EBOOKS_DISPLAY.map((e) => {
const { id, href } = e;
const isFree = "free" in e && e.free;
const isLocked = "locked" in e && e.locked && onLockedClick;
{EBOOKS_DISPLAY.map((ebook) => {
const isLocked = "locked" in ebook && ebook.locked && onLockedClick;
const cardContent = (
<>
<span style={{ fontSize: "1.5rem", marginBottom: "0.5rem", display: "block" }}>📖</span>
<div style={{ fontWeight: 600, color: "var(--foreground)", marginBottom: "0.25rem", display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
{e.title}
{isFree && (
<span className={styles.freeBadge}>
</span>
)}
{isLocked && (
<span title="VIP专享" style={{ fontSize: "1rem" }}>🔒</span>
)}
<span style={{ fontSize: "1.5rem", marginBottom: "0.5rem", display: "block" }}>📙</span>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
flexWrap: "wrap",
fontWeight: 600,
color: "var(--foreground)",
marginBottom: "0.25rem",
}}
>
{ebook.title}
{"free" in ebook && ebook.free ? <span className={styles.freeBadge}></span> : null}
{isLocked ? <span title="会员专享">🔒</span> : null}
</div>
<div style={{ fontSize: "0.9rem", color: "var(--muted-foreground)" }}>{e.desc}</div>
<div style={{ marginTop: "0.75rem", fontSize: "0.85rem", color: isLocked ? "var(--muted-foreground)" : "var(--muted-foreground)" }}>
{isLocked ? "开通VIP解锁 →" : "阅读 →"}
<div style={{ fontSize: "0.9rem", color: "var(--muted-foreground)" }}>{ebook.desc}</div>
<div style={{ marginTop: "0.75rem", fontSize: "0.85rem", color: "var(--muted-foreground)" }}>
{isLocked ? "开通会员解锁 →" : "立即阅读 →"}
</div>
</>
);
if (isLocked) {
return (
<button
key={id}
key={ebook.id}
type="button"
onClick={onLockedClick}
className={styles.card}
style={{
display: "block",
width: "100%",
padding: "1.25rem",
cursor: "pointer",
border: "none",
cursor: "pointer",
font: "inherit",
textAlign: "left",
width: "100%",
}}
>
{cardContent}
</button>
);
}
return href ? (
return (
<Link
key={id}
href={href}
key={ebook.id}
href={ebook.href}
className={styles.card}
style={{
display: "block",
@@ -165,19 +159,6 @@ export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
>
{cardContent}
</Link>
) : (
<div
key={id}
className={styles.card}
style={{
display: "block",
padding: "1.25rem",
cursor: "default",
opacity: 0.9,
}}
>
{cardContent}
</div>
);
})}
</div>

View File

@@ -12,29 +12,25 @@ export function TopicEntries() {
className={`${styles.topicGrid} animate__animated animate__fadeInUp`}
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
>
{TOPICS.map((t) => {
const { id, href } = t;
{TOPICS.map((topic) => {
const content = (
<>
<span className={styles.topicIcon}>{t.icon}</span>
<span className={styles.topicTitle}>{t.title}</span>
<span className={styles.topicDesc}>{t.desc}</span>
<span className={styles.topicIcon}>{topic.icon}</span>
<span className={styles.topicTitle}>{topic.title}</span>
<span className={styles.topicDesc}>{topic.desc}</span>
</>
);
return href ? (
return (
<Link
key={id}
href={href}
key={topic.id}
href={topic.href}
className={styles.topicCard}
target={href.startsWith("http") ? "_blank" : undefined}
rel={href.startsWith("http") ? "noopener noreferrer" : undefined}
target={topic.href.startsWith("http") ? "_blank" : undefined}
rel={topic.href.startsWith("http") ? "noopener noreferrer" : undefined}
>
{content}
</Link>
) : (
<div key={id} className={styles.topicCard} style={{ cursor: "default", opacity: 0.7 }}>
{content}
</div>
);
})}
</div>

View File

@@ -0,0 +1,12 @@
/**
* 私密社群配置
* 参考 memos 风格,组件化/模块化
*/
export const COMMUNITY_CONFIG = {
/** 是否启用社群功能 */
enabled: process.env.NEXT_PUBLIC_COMMUNITY_ENABLED !== "false",
/** 每页条数 */
pageSize: 20,
/** 最大内容长度 */
maxContentLength: 10000,
} as const;