Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbaa38b3b3 |
42
app/api/community/attachments/route.ts
Normal file
42
app/api/community/attachments/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
42
app/api/community/comments/[id]/route.ts
Normal file
42
app/api/community/comments/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
78
app/api/community/memos/[id]/comments/route.ts
Normal file
78
app/api/community/memos/[id]/comments/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
44
app/api/community/memos/[id]/reactions/route.ts
Normal file
44
app/api/community/memos/[id]/reactions/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
125
app/api/community/memos/[id]/route.ts
Normal file
125
app/api/community/memos/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
82
app/api/community/memos/route.ts
Normal file
82
app/api/community/memos/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
36
app/api/community/stats/route.ts
Normal file
36
app/api/community/stats/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
49
app/api/community/upload/route.ts
Normal file
49
app/api/community/upload/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
70
app/community/archived/page.tsx
Normal file
70
app/community/archived/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
app/community/attachments/page.tsx
Normal file
166
app/community/attachments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
app/community/components/CommunityCommentThread.tsx
Normal file
179
app/community/components/CommunityCommentThread.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
app/community/components/CommunityMemoCard.tsx
Normal file
224
app/community/components/CommunityMemoCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
319
app/community/components/CommunityMemoForm.tsx
Normal file
319
app/community/components/CommunityMemoForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
app/community/components/CommunityPageHero.tsx
Normal file
55
app/community/components/CommunityPageHero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
257
app/community/components/CommunitySidebar.tsx
Normal file
257
app/community/components/CommunitySidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
414
app/community/components/CommunityTimeline.tsx
Normal file
414
app/community/components/CommunityTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
app/community/components/EmojiPicker.tsx
Normal file
86
app/community/components/EmojiPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
app/community/components/MemoExplorer.tsx
Normal file
92
app/community/components/MemoExplorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
601
app/community/components/community.module.css
Normal file
601
app/community/components/community.module.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
8
app/community/components/communityEvents.ts
Normal file
8
app/community/components/communityEvents.ts
Normal 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));
|
||||
}
|
||||
559
app/community/components/memos.module.css
Normal file
559
app/community/components/memos.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
19
app/community/components/renderMarkdown.ts
Normal file
19
app/community/components/renderMarkdown.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { marked } from "marked";
|
||||
|
||||
function escapeHtml(source: string): string {
|
||||
return source
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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=""');
|
||||
}
|
||||
102
app/community/explore/page.tsx
Normal file
102
app/community/explore/page.tsx
Normal 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
92
app/community/layout.tsx
Normal 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
157
app/community/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
283
app/community/post/[id]/page.tsx
Normal file
283
app/community/post/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: "3月10日", 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: "3 月 10 日", 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
config/community.config.ts
Normal file
12
config/community.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 私密社群配置
|
||||
* 参考 memos 风格,组件化/模块化
|
||||
*/
|
||||
export const COMMUNITY_CONFIG = {
|
||||
/** 是否启用社群功能 */
|
||||
enabled: process.env.NEXT_PUBLIC_COMMUNITY_ENABLED !== "false",
|
||||
/** 每页条数 */
|
||||
pageSize: 20,
|
||||
/** 最大内容长度 */
|
||||
maxContentLength: 10000,
|
||||
} as const;
|
||||
Reference in New Issue
Block a user