feat(community): 社区分享链接墙前端 M5-M10(/feed + i18n + 管理面板)#312
Conversation
- app/admin/community/page.tsx: 待审列表 + 通过/拒绝按钮, 复用 AdminGuard - app/admin/community/lib.ts: listPendingLinks / approveLink / rejectLink - app/admin/community/layout.tsx: 透传 layout - app/u/[username]/shares/page.tsx: 本人可见自己所有状态的分享, 带状态 badge - next.config.mjs: 加 /api/admin/community 与 /api/admin/community/:path* rewrite - typecheck 通过
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Implements the frontend for the “Community Shared Links” feature set, adding a public feed, link submission flow, personal shares view, and an admin review panel, along with i18n strings and backend rewrites.
Changes:
- Add
/feedSSR page (revalidate 120s) with category tabs and link cards, plus/feed/submitclient submission form. - Add user-owned shares page at
/u/[username]/sharesand an admin moderation page at/admin/community. - Add i18n message namespaces (
hero.feedLink,feed.*) and Next.js rewrites for community endpoints.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| next.config.mjs | Adds rewrites for community public/admin API paths to the backend. |
| messages/zh.json | Adds Chinese UI strings for hero feed link + feed UI. |
| messages/en.json | Adds English UI strings for hero feed link + feed UI. |
| app/components/Hero.tsx | Adds a secondary hero link entry point to /feed. |
| app/feed/page.tsx | New SSR community feed page with category filtering + submit CTA. |
| app/feed/submit/page.tsx | New client submission form with login guard and POST submit. |
| app/feed/types.ts | New shared TS types for community shared links DTOs. |
| app/feed/components/ReportButton.tsx | New client report dialog/button for link reporting. |
| app/feed/components/LinkCard.tsx | New link card UI (cover fallback, archived badge, report action). |
| app/feed/components/FeedAuthWrapper.tsx | Bridges client auth state into feed cards for report behavior. |
| app/feed/components/CategoryTabs.tsx | Client category tabs driven by URL search params. |
| app/u/[username]/shares/page.tsx | New client-only “my submitted shares” page with status badges. |
| app/admin/community/page.tsx | New admin review list with approve/reject actions. |
| app/admin/community/lib.ts | Client API wrapper for admin community moderation endpoints. |
| app/admin/community/layout.tsx | Pass-through layout placeholder for the admin community route segment. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try { | ||
| const res = await fetch("/api/community/links/mine", { | ||
| cache: "no-store", |
There was a problem hiding this comment.
This request to /api/community/links/mine does not include the satoken header, even though the page comment says the list requires it. Other authenticated client calls in the repo (e.g. events interest, admin libs) always attach satoken from localStorage, so this is likely to return 401/empty unexpectedly. Attach the token header here (and handle the unauthenticated case explicitly).
| try { | |
| const res = await fetch("/api/community/links/mine", { | |
| cache: "no-store", | |
| try { | |
| const token = localStorage.getItem("satoken"); | |
| if (!token) { | |
| if (!aborted) setLoadError("未登录或登录已过期"); | |
| return; | |
| } | |
| const res = await fetch("/api/community/links/mine", { | |
| cache: "no-store", | |
| headers: { | |
| satoken: token, | |
| }, |
| <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> | ||
| {links.map((link) => ( | ||
| <div key={link.id} className="relative"> | ||
| {/* 状态 badge 叠在卡片左上角,本人才看得到(只有这页拉) */} | ||
| <span | ||
| className={`absolute left-3 top-3 z-10 rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[link.status].className}`} | ||
| > | ||
| {STATUS_BADGE[link.status].label} | ||
| </span> | ||
| <LinkCard | ||
| link={link} | ||
| categoryLabel={link.category ? tCategory(link.category) : ""} | ||
| isLoggedIn={true} | ||
| /> | ||
| </div> | ||
| ))} | ||
| </div> |
There was a problem hiding this comment.
LinkCard renders an <li>, but this page places it under a <div className="grid ..."> rather than a <ul>/<ol>, which produces invalid HTML semantics. Consider changing this container to a <ul> (grid classes can stay) or changing LinkCard’s root element to <div>/<article>.
| <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> | |
| {links.map((link) => ( | |
| <div key={link.id} className="relative"> | |
| {/* 状态 badge 叠在卡片左上角,本人才看得到(只有这页拉) */} | |
| <span | |
| className={`absolute left-3 top-3 z-10 rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[link.status].className}`} | |
| > | |
| {STATUS_BADGE[link.status].label} | |
| </span> | |
| <LinkCard | |
| link={link} | |
| categoryLabel={link.category ? tCategory(link.category) : ""} | |
| isLoggedIn={true} | |
| /> | |
| </div> | |
| ))} | |
| </div> | |
| <ul className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> | |
| {links.map((link) => ( | |
| <LinkCard | |
| key={link.id} | |
| link={link} | |
| categoryLabel={link.category ? tCategory(link.category) : ""} | |
| isLoggedIn={true} | |
| /> | |
| ))} | |
| </ul> |
| }); | ||
| if (res.ok) { | ||
| setDone(true); | ||
| setOpen(false); | ||
| // 举报成功后短暂显示反馈——用 alert 保持轻量;后续可改 sonner toast | ||
| alert(t("successToast")); | ||
| } |
There was a problem hiding this comment.
When the report endpoint returns a non-2xx response, the UI currently gives no feedback (no error and no state change). Check res.ok/decode the response and surface an error (alert/toast) so users know the report didn’t go through.
| }); | |
| if (res.ok) { | |
| setDone(true); | |
| setOpen(false); | |
| // 举报成功后短暂显示反馈——用 alert 保持轻量;后续可改 sonner toast | |
| alert(t("successToast")); | |
| } | |
| }); | |
| if (res.ok) { | |
| setDone(true); | |
| setOpen(false); | |
| // 举报成功后短暂显示反馈——用 alert 保持轻量;后续可改 sonner toast | |
| alert(t("successToast")); | |
| return; | |
| } | |
| let errorMessage = ""; | |
| const contentType = res.headers.get("content-type") || ""; | |
| if (contentType.includes("application/json")) { | |
| const data = await res.json().catch(() => null); | |
| if (data && typeof data === "object") { | |
| const message = | |
| "message" in data && typeof data.message === "string" | |
| ? data.message | |
| : "error" in data && typeof data.error === "string" | |
| ? data.error | |
| : ""; | |
| errorMessage = message; | |
| } | |
| } else { | |
| errorMessage = (await res.text().catch(() => "")).trim(); | |
| } | |
| alert(errorMessage || `Report failed (${res.status})`); | |
| } catch (error) { | |
| alert(error instanceof Error ? error.message : "Report failed. Please try again."); |
| className="flex items-center gap-1 text-[10px] font-mono uppercase tracking-widest text-neutral-400 hover:text-[#CC0000] transition-colors" | ||
| aria-label={t("submitButton")} | ||
| > | ||
| <Flag className="h-3 w-3" /> | ||
| {t("submitButton")} |
There was a problem hiding this comment.
The trigger button label uses feed.report.submitButton (e.g. “提交举报”/“Submit Report”), but you also added a card-level label feed.card.reportButton (“举报”/“Report”). Using the submit label for the trigger is confusing; use the card label for the trigger and reserve submitButton for the dialog submit action.
| <Image | ||
| src={link.ogCover} | ||
| alt={link.ogTitle ?? link.url} | ||
| fill | ||
| sizes="160px" |
There was a problem hiding this comment.
Using next/image with arbitrary OG cover hosts will likely fail because images.remotePatterns only allows a small set of domains. Since these covers can be from any site, prefer a plain <img> (as used elsewhere in the repo) or explicitly expand remotePatterns (avoid overly-broad wildcards).
| if (user?.id) { | ||
| router.push(`/u/${user.id}/shares`); | ||
| } else { |
There was a problem hiding this comment.
handleSubmit redirects to /u/${user.id}/shares, but the route you added is /u/[username]/shares. This will 404 for most users. Use user.username (or whatever identifier /u/[username] expects) when constructing the redirect URL, and update the header comment accordingly.
| export function LinkCard({ link, categoryLabel, isLoggedIn }: LinkCardProps) { | ||
| const t = useTranslations("feed.card"); | ||
|
|
||
| return ( | ||
| <li className="group border border-[var(--foreground)] hover:border-[#CC0000] transition-colors duration-150 flex flex-col"> | ||
| {/* 整卡可点击区域,跳到原文 */} | ||
| <a | ||
| href={link.url} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="flex flex-col flex-1" | ||
| aria-label={link.ogTitle ?? link.url} |
There was a problem hiding this comment.
User-submitted link.url is used directly as an <a href>. If the backend ever returns a javascript:/data: URL, this becomes an XSS vector. The repo already provides sanitizeExternalUrl; sanitize before rendering and fall back when invalid.
| export function LinkCard({ link, categoryLabel, isLoggedIn }: LinkCardProps) { | |
| const t = useTranslations("feed.card"); | |
| return ( | |
| <li className="group border border-[var(--foreground)] hover:border-[#CC0000] transition-colors duration-150 flex flex-col"> | |
| {/* 整卡可点击区域,跳到原文 */} | |
| <a | |
| href={link.url} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="flex flex-col flex-1" | |
| aria-label={link.ogTitle ?? link.url} | |
| /** 仅允许安全的外部链接协议,避免 javascript:/data: 注入到 href */ | |
| function sanitizeExternalUrl(url: string): string | null { | |
| try { | |
| const parsed = new URL(url); | |
| return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.toString() : null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| export function LinkCard({ link, categoryLabel, isLoggedIn }: LinkCardProps) { | |
| const t = useTranslations("feed.card"); | |
| const safeUrl = sanitizeExternalUrl(link.url) ?? "#"; | |
| return ( | |
| <li className="group border border-[var(--foreground)] hover:border-[#CC0000] transition-colors duration-150 flex flex-col"> | |
| {/* 整卡可点击区域,跳到原文 */} | |
| <a | |
| href={safeUrl} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="flex flex-col flex-1" | |
| aria-label={link.ogTitle ?? safeUrl} |
| <img | ||
| src={link.ogCover} | ||
| alt={link.ogTitle ?? link.host} | ||
| className="w-full aspect-[16/9] object-cover border-b border-[var(--foreground)]" |
There was a problem hiding this comment.
link.ogCover is used directly as an <img src> without scheme sanitization. For consistency with existing patterns (e.g. events covers), run it through sanitizeMediaUrl and fall back to the placeholder if invalid.
| const res = await fetch(`/api/community/links/${linkId}/report`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, |
There was a problem hiding this comment.
The report submission request does not include the satoken header. Elsewhere in the codebase, authenticated mutations attach satoken from localStorage; without it this endpoint may 401 (depending on backend cookie settings). Include the token header when isLoggedIn is true.
| const res = await fetch(`/api/community/links/${linkId}/report`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| const headers: Record<string, string> = { | |
| "Content-Type": "application/json", | |
| }; | |
| if (isLoggedIn) { | |
| const satoken = localStorage.getItem("satoken"); | |
| if (satoken) { | |
| headers.satoken = satoken; | |
| } | |
| } | |
| const res = await fetch(`/api/community/links/${linkId}/report`, { | |
| method: "POST", | |
| headers, |
| href="/feed/submit" | ||
| className="shrink-0 inline-block px-6 py-2.5 border border-[var(--foreground)] font-sans text-xs uppercase tracking-widest font-bold text-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-all duration-200" | ||
| > | ||
| + 丢个链接 |
There was a problem hiding this comment.
This submit CTA text is hard-coded (+ 丢个链接) while the rest of the page uses next-intl messages. It will show Chinese under the English locale; move it into messages/* and render via t(...).
| + 丢个链接 | |
| {t("submitCta")} |
UI / UX 迭代:
- Hero.tsx: Contribute 按钮旁并排加 ShareLink(风格与 Contribute 完全同构,
右上角 "+" 徽章跳 /feed/submit),替代之前的次级文字链
- 个人主页 Bento 空态改为 SharesOnProfile 区块(本人可见自己的最近 6 条分享 +
提交入口),通过父 grid stretch 与左侧 identity 卡同高;非本人返回 null
- 个人主页工具栏新增 SharesLinkIfOwner 入口("我的分享",本人可见)
- 新增 /share 独立极简路由:全屏居中卡片,无 Header/Footer,支持 ?url= / ?text=
预填,成功后停留本页方便连续分享,Bookmarklet 友好
Next 16 兼容修复:
- /feed/submit, /u/[username]/shares 之前是 client component 直接渲染 async
Server Component Header/Footer,Next 16 严格模式报错。提到各自的 Server Layout
- /feed/submit 提交成功后跳 /u/{username}/shares(之前误用 user.id 对不上路由 param)
- handleSubmit 加 15s AbortController 超时保护,防止 fetch hang 卡在 "提交中..."
i18n:
- 新增 shareLink.button / shareLink.submitAriaLabel
- 移除旧的 hero.feedLink(被并排按钮替代)
Copilot CR 反馈: - app/admin/community/page.tsx 直接把 link.url 渲染到 <a href>,虽然后端 UrlNormalizer 已拒非 http/https,但 defense-in-depth 要求前端也兜底, 防止后端某天放松校验导致 javascript:/data: XSS 改动: - 外链过 sanitizeExternalUrl,非法链接降级为不可点击的灰色文字 + ⚠ 标记
背景
配套 backend PR InvolutionHell/involutionhell-backend#16。社区分享链接墙前端侧实现。完整设计锁定见 wiki/Community-Shared-Links。
新增页面
用户侧
组件
管理员侧
i18n
rewrite
Test Plan
视觉 review 关注点