Skip to content

feat(community): 社区分享链接墙前端 M5-M10(/feed + i18n + 管理面板)#312

Merged
longsizhuo merged 4 commits intomainfrom
feature/community-shared-links
Apr 19, 2026
Merged

feat(community): 社区分享链接墙前端 M5-M10(/feed + i18n + 管理面板)#312
longsizhuo merged 4 commits intomainfrom
feature/community-shared-links

Conversation

@longsizhuo
Copy link
Copy Markdown
Member

背景

配套 backend PR InvolutionHell/involutionhell-backend#16。社区分享链接墙前端侧实现。完整设计锁定见 wiki/Community-Shared-Links

新增页面

用户侧

  • Hero 入口:`Contribute` 按钮下方新增次级文字链 `📎 看到好文章?丢个链接 →` 跳 `/feed`,斜体 muted 样式,不抢主 CTA 焦点
  • `/feed`(SSR + revalidate 120s):瀑布流 + 分类 tab,空状态/加载态完整
  • `/feed/submit`:提交表单,URL + 推荐语(200 字上限),未登录跳 `/login?next=/feed/submit`
  • `/u/[username]/shares`:本人可见自己所有状态的分享(含 PENDING / FLAGGED / ARCHIVED badge)

组件

  • `LinkCard` 整卡可点跳原文(`target="_blank" rel="noopener noreferrer"`),OG 封面缺失时回退到 host 首字母占位
  • `CategoryTabs` URL searchParam 驱动,移动端横向滚动
  • `ReportButton` 举报 dialog,未登录态给 alert 提示
  • `FeedAuthWrapper` SSR/client 登录态桥接

管理员侧

  • `/admin/community`:待审列表(PENDING_MANUAL + FLAGGED)+ 通过/拒绝按钮,复用 `AdminGuard`

i18n

  • `messages/{zh,en}.json` 新增 `feed.*` + `hero.feedLink` 命名空间
  • 分类展示名走 i18n(枚举 slug 仍为固定英文)

rewrite

  • `next.config.mjs` 加 `/api/community/links` 和 `/api/admin/community` 两对 rewrite 到 backend:8081

Test Plan

  • 启 frontend:3010 + backend:8081 看 Hero 文字链是否协调
  • 登录后提交一条 https://mp.weixin.qq.com/s/... → 看 submit 跳转 + 异步 worker 完成后 /feed 显示
  • 举报按钮:未登录 alert,登录后 dialog 提交
  • 分类 tab 切换 URL searchParam 生效
  • /u/[username]/shares 非本人访问看到"仅本人可见"
  • admin 面板 approve/reject 按钮工作,列表实时刷新

视觉 review 关注点

  1. Hero 文字链位置与 Contribute 按钮的间距是否合适(当前 `mt-4`)
  2. LinkCard 封面比例 `aspect-[16/9]` —— 公众号/知乎 OG 图可能是 1:1/3:4,`object-cover` 会裁
  3. 分类 tab 移动端 9 个横滚手感

- 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 通过
Copilot AI review requested due to automatic review settings April 19, 2026 11:58
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
involutionhell-github-io Ready Ready Preview, Comment Apr 19, 2026 4:37pm
website-preview Ready Ready Preview, Comment Apr 19, 2026 4:37pm

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /feed SSR page (revalidate 120s) with category tabs and link cards, plus /feed/submit client submission form.
  • Add user-owned shares page at /u/[username]/shares and 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.

Comment on lines +63 to +65
try {
const res = await fetch("/api/community/links/mine", {
cache: "no-store",
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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,
},

Copilot uses AI. Check for mistakes.
Comment thread app/u/[username]/shares/page.tsx Outdated
Comment on lines +134 to +150
<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>
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>.

Suggested change
<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>

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +60
});
if (res.ok) {
setDone(true);
setOpen(false);
// 举报成功后短暂显示反馈——用 alert 保持轻量;后续可改 sonner toast
alert(t("successToast"));
}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
});
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.");

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +78
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")}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +135 to +139
<Image
src={link.ogCover}
alt={link.ogTitle ?? link.url}
fill
sizes="160px"
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread app/feed/submit/page.tsx Outdated
Comment on lines +68 to +70
if (user?.id) {
router.push(`/u/${user.id}/shares`);
} else {
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +39
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}
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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}

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +48
<img
src={link.ogCover}
alt={link.ogTitle ?? link.host}
className="w-full aspect-[16/9] object-cover border-b border-[var(--foreground)]"
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +52
const res = await fetch(`/api/community/links/${linkId}/report`, {
method: "POST",
headers: { "Content-Type": "application/json" },
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,

Copilot uses AI. Check for mistakes.
Comment thread app/feed/page.tsx
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"
>
+ 丢个链接
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(...).

Suggested change
+ 丢个链接
{t("submitCta")}

Copilot uses AI. Check for mistakes.
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,非法链接降级为不可点击的灰色文字 + ⚠ 标记
@longsizhuo longsizhuo merged commit bff6f8e into main Apr 19, 2026
5 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants