From c1f1dfb749207c5a967e0cf8c706974dadc1672d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:48:57 +0000 Subject: [PATCH 1/4] feat(community): M5+M6+M10 /feed page + Hero entry + i18n --- app/components/Hero.tsx | 10 ++ app/feed/components/CategoryTabs.tsx | 66 ++++++++ app/feed/components/FeedAuthWrapper.tsx | 45 ++++++ app/feed/components/LinkCard.tsx | 116 ++++++++++++++ app/feed/components/ReportButton.tsx | 128 +++++++++++++++ app/feed/page.tsx | 161 +++++++++++++++++++ app/feed/submit/page.tsx | 202 ++++++++++++++++++++++++ app/feed/types.ts | 59 +++++++ messages/en.json | 44 ++++++ messages/zh.json | 44 ++++++ next.config.mjs | 10 ++ 11 files changed, 885 insertions(+) create mode 100644 app/feed/components/CategoryTabs.tsx create mode 100644 app/feed/components/FeedAuthWrapper.tsx create mode 100644 app/feed/components/LinkCard.tsx create mode 100644 app/feed/components/ReportButton.tsx create mode 100644 app/feed/page.tsx create mode 100644 app/feed/submit/page.tsx create mode 100644 app/feed/types.ts diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index c03b596..e5f639f 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -66,6 +66,16 @@ export async function Hero() {
+ {/* 次级文字链:层级明显低于主 CTA,斜体小字,不抢夺视觉焦点 */} + + {t("feedLink")} +
diff --git a/app/feed/components/CategoryTabs.tsx b/app/feed/components/CategoryTabs.tsx new file mode 100644 index 0000000..34f4209 --- /dev/null +++ b/app/feed/components/CategoryTabs.tsx @@ -0,0 +1,66 @@ +"use client"; + +/** + * 分类 tab 导航组件。 + * 通过 URL searchParams(?category=)来控制当前选中项, + * 保持 SSR 可读且可书签化,避免纯 client state 无法分享链接。 + */ + +import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { type CategorySlug, CATEGORY_SLUGS } from "@/app/feed/types"; +import { cn } from "@/lib/utils"; + +export function CategoryTabs() { + const t = useTranslations("feed.category"); + const router = useRouter(); + const searchParams = useSearchParams(); + + // 当前选中的分类 slug,空字符串代表"全部" + const current = (searchParams.get("category") ?? "") as CategorySlug | ""; + + /** + * 点击分类时更新 URL query,不需要 push history stack—— + * 用 replace 避免用户反复点分类时返回键卡死。 + */ + function handleSelect(slug: CategorySlug | "") { + const params = new URLSearchParams(searchParams.toString()); + if (slug) { + params.set("category", slug); + } else { + params.delete("category"); + } + router.replace(`/feed?${params.toString()}`); + } + + const allTabs: Array<{ slug: CategorySlug | ""; label: string }> = [ + { slug: "", label: t("all") }, + ...CATEGORY_SLUGS.map((slug) => ({ + slug, + label: t(slug), + })), + ]; + + return ( + // 横向滚动容器,移动端展示全部分类时不截断 +
+ {allTabs.map(({ slug, label }) => ( + + ))} +
+ ); +} diff --git a/app/feed/components/FeedAuthWrapper.tsx b/app/feed/components/FeedAuthWrapper.tsx new file mode 100644 index 0000000..f27187b --- /dev/null +++ b/app/feed/components/FeedAuthWrapper.tsx @@ -0,0 +1,45 @@ +"use client"; + +/** + * FeedAuthWrapper —— client 组件桥接器。 + * + * /feed/page.tsx 是 SSR server component,无法感知 localStorage 登录态; + * 本组件在 client 端读取 useAuth() 后,把 isLoggedIn 传给 LinkCard, + * 使举报按钮可以区分已登录 / 未登录行为。 + * + * 接收 server 端已预计算好的 links 和 categoryLabel 函数, + * 只负责登录态桥接,不做额外数据请求。 + */ + +import { useAuth } from "@/lib/use-auth"; +import { LinkCard } from "@/app/feed/components/LinkCard"; +import type { SharedLinkView, CategorySlug } from "@/app/feed/types"; + +interface FeedAuthWrapperProps { + links: SharedLinkView[]; + /** 由 server 端传入的分类标签计算函数(已含 i18n 翻译) */ + getCategoryLabel: (slug: CategorySlug | null) => string; +} + +export function FeedAuthWrapper({ + links, + getCategoryLabel, +}: FeedAuthWrapperProps) { + const { status } = useAuth(); + // loading 阶段默认视为未登录,避免 UI 闪烁 + const isLoggedIn = status === "authenticated"; + + return ( + // 响应式 grid:桌面 3 列 / 平板 2 列 / 手机 1 列 + + ); +} diff --git a/app/feed/components/LinkCard.tsx b/app/feed/components/LinkCard.tsx new file mode 100644 index 0000000..72749ee --- /dev/null +++ b/app/feed/components/LinkCard.tsx @@ -0,0 +1,116 @@ +/** + * 社区分享链接卡片。 + * - 整卡可点击,跳转到原文(target="_blank") + * - OG 封面:有则显示,没有则渲染 host 首字母占位块 + * - 举报按钮由 ReportButton 组件负责,阻止冒泡不触发整卡跳转 + * - 服务端渲染(纯展示,无 client state),ReportButton 是 client 组件 + */ + +import { useTranslations } from "next-intl"; +import type { SharedLinkView } from "@/app/feed/types"; +import { ReportButton } from "@/app/feed/components/ReportButton"; +import { Badge } from "@/components/ui/badge"; + +interface LinkCardProps { + link: SharedLinkView; + /** 分类显示名(由父组件从 i18n 翻译后传入,避免在纯 server 组件里调 useTranslations) */ + categoryLabel: string; + /** 当前用户是否已登录(影响举报按钮行为) */ + isLoggedIn: boolean; +} + +/** 从 host 字符串提取首字母大写,作为封面占位符 */ +function getHostInitial(host: string): string { + const cleaned = host.replace(/^www\./, ""); + return (cleaned[0] ?? "?").toUpperCase(); +} + +export function LinkCard({ link, categoryLabel, isLoggedIn }: LinkCardProps) { + const t = useTranslations("feed.card"); + + return ( +
  • + {/* 整卡可点击区域,跳到原文 */} + + {/* OG 封面 / 占位块 */} + {link.ogCover && !link.ogFetchFailed ? ( + // next/image 全站 unoptimized:true,用 img 即可(与 events 页一致) + // eslint-disable-next-line @next/next/no-img-element + {link.ogTitle + ) : ( + // 无封面:显示 host 首字母占位 +
    + + {getHostInitial(link.host)} + + + {link.host} + + {link.ogFetchFailed && ( + // OG 抓取失败时给用户一个弱提示 + + {t("ogFallback")} + + )} +
    + )} + + {/* 卡片内容区 */} +
    + {/* 标题 */} +

    + {link.ogTitle ?? link.url} +

    + + {/* OG 描述 / 用户推荐语 */} + {(link.recommendation || link.ogDescription) && ( +

    + {/* 用户推荐语优先展示,没有则展示 OG description */} + {link.recommendation ?? link.ogDescription} +

    + )} + + {/* 分类 badge + 失效标记 */} +
    + {link.category && ( + + {categoryLabel} + + )} + {link.status === "ARCHIVED" && ( + + {t("archivedBadge")} + + )} +
    + + {/* 提交人 + host 来源 */} +
    + {link.host} +
    +
    +
    + + {/* 举报区:与整卡点击分离(ReportButton 内部阻止冒泡) */} +
    + +
    +
  • + ); +} diff --git a/app/feed/components/ReportButton.tsx b/app/feed/components/ReportButton.tsx new file mode 100644 index 0000000..e493f84 --- /dev/null +++ b/app/feed/components/ReportButton.tsx @@ -0,0 +1,128 @@ +"use client"; + +/** + * 举报按钮 + 举报 Dialog 组件。 + * - 未登录:点击时 toast 提示需要登录 + * - 已登录:弹出 Dialog,填写可选原因后提交 POST /api/community/links/{id}/report + * 参照 Contribute.tsx 的 Dialog 模式实现。 + */ + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Flag } from "lucide-react"; + +interface ReportButtonProps { + /** 被举报的链接 ID */ + linkId: number; + /** 当前用户是否已登录(由父组件/页面传入,避免在每张卡片都重新请求 session) */ + isLoggedIn: boolean; +} + +export function ReportButton({ linkId, isLoggedIn }: ReportButtonProps) { + const t = useTranslations("feed.report"); + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [done, setDone] = useState(false); // 举报成功后隐藏按钮 + + /** + * 未登录时点击举报,弹浏览器原生 alert(轻量,避免引入额外 toast provider 依赖)。 + * 后续如果需要引导跳登录页,可改成 router.push。 + */ + function handleUnauth(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); // 阻止冒泡,不触发整卡链接跳转 + alert(t("loginRequired")); + } + + async function handleSubmit() { + setSubmitting(true); + try { + const res = await fetch(`/api/community/links/${linkId}/report`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason: reason.trim() || undefined }), + }); + if (res.ok) { + setDone(true); + setOpen(false); + // 举报成功后短暂显示反馈——用 alert 保持轻量;后续可改 sonner toast + alert(t("successToast")); + } + } finally { + setSubmitting(false); + } + } + + // 已举报成功,不再显示按钮 + if (done) return null; + + // 未登录:直接用普通按钮,点击触发提示 + if (!isLoggedIn) { + return ( + + ); + } + + return ( + + + + + + e.stopPropagation()} + > + + {t("title")} + + +
    + +