Skip to content

Commit c1f1dfb

Browse files
feat(community): M5+M6+M10 /feed page + Hero entry + i18n
1 parent 58b68ce commit c1f1dfb

11 files changed

Lines changed: 885 additions & 0 deletions

File tree

app/components/Hero.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ export async function Hero() {
6666

6767
<div className="mt-12">
6868
<Contribute />
69+
{/* 次级文字链:层级明显低于主 CTA,斜体小字,不抢夺视觉焦点 */}
70+
<Link
71+
href="/feed"
72+
className="mt-4 inline-block text-sm italic text-muted-foreground hover:text-[var(--foreground)] transition-colors duration-200"
73+
data-umami-event="navigation_click"
74+
data-umami-event-region="hero_feed_entry"
75+
data-umami-event-label="feed link"
76+
>
77+
{t("feedLink")}
78+
</Link>
6979
</div>
7080
</div>
7181
</div>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"use client";
2+
3+
/**
4+
* 分类 tab 导航组件。
5+
* 通过 URL searchParams(?category=<slug>)来控制当前选中项,
6+
* 保持 SSR 可读且可书签化,避免纯 client state 无法分享链接。
7+
*/
8+
9+
import { useRouter, useSearchParams } from "next/navigation";
10+
import { useTranslations } from "next-intl";
11+
import { type CategorySlug, CATEGORY_SLUGS } from "@/app/feed/types";
12+
import { cn } from "@/lib/utils";
13+
14+
export function CategoryTabs() {
15+
const t = useTranslations("feed.category");
16+
const router = useRouter();
17+
const searchParams = useSearchParams();
18+
19+
// 当前选中的分类 slug,空字符串代表"全部"
20+
const current = (searchParams.get("category") ?? "") as CategorySlug | "";
21+
22+
/**
23+
* 点击分类时更新 URL query,不需要 push history stack——
24+
* 用 replace 避免用户反复点分类时返回键卡死。
25+
*/
26+
function handleSelect(slug: CategorySlug | "") {
27+
const params = new URLSearchParams(searchParams.toString());
28+
if (slug) {
29+
params.set("category", slug);
30+
} else {
31+
params.delete("category");
32+
}
33+
router.replace(`/feed?${params.toString()}`);
34+
}
35+
36+
const allTabs: Array<{ slug: CategorySlug | ""; label: string }> = [
37+
{ slug: "", label: t("all") },
38+
...CATEGORY_SLUGS.map((slug) => ({
39+
slug,
40+
label: t(slug),
41+
})),
42+
];
43+
44+
return (
45+
// 横向滚动容器,移动端展示全部分类时不截断
46+
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-none">
47+
{allTabs.map(({ slug, label }) => (
48+
<button
49+
key={slug || "__all__"}
50+
onClick={() => handleSelect(slug)}
51+
className={cn(
52+
// 基础样式:小号等宽字体,边框按钮形态
53+
"shrink-0 px-3 py-1.5 font-mono text-xs uppercase tracking-widest border transition-colors duration-150 whitespace-nowrap",
54+
current === slug
55+
? // 选中态:反色高亮
56+
"bg-[var(--foreground)] text-[var(--background)] border-[var(--foreground)]"
57+
: // 未选中态:透明背景,hover 轻高亮
58+
"border-[var(--foreground)]/40 text-neutral-500 hover:border-[var(--foreground)] hover:text-[var(--foreground)]",
59+
)}
60+
>
61+
{label}
62+
</button>
63+
))}
64+
</div>
65+
);
66+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"use client";
2+
3+
/**
4+
* FeedAuthWrapper —— client 组件桥接器。
5+
*
6+
* /feed/page.tsx 是 SSR server component,无法感知 localStorage 登录态;
7+
* 本组件在 client 端读取 useAuth() 后,把 isLoggedIn 传给 LinkCard,
8+
* 使举报按钮可以区分已登录 / 未登录行为。
9+
*
10+
* 接收 server 端已预计算好的 links 和 categoryLabel 函数,
11+
* 只负责登录态桥接,不做额外数据请求。
12+
*/
13+
14+
import { useAuth } from "@/lib/use-auth";
15+
import { LinkCard } from "@/app/feed/components/LinkCard";
16+
import type { SharedLinkView, CategorySlug } from "@/app/feed/types";
17+
18+
interface FeedAuthWrapperProps {
19+
links: SharedLinkView[];
20+
/** 由 server 端传入的分类标签计算函数(已含 i18n 翻译) */
21+
getCategoryLabel: (slug: CategorySlug | null) => string;
22+
}
23+
24+
export function FeedAuthWrapper({
25+
links,
26+
getCategoryLabel,
27+
}: FeedAuthWrapperProps) {
28+
const { status } = useAuth();
29+
// loading 阶段默认视为未登录,避免 UI 闪烁
30+
const isLoggedIn = status === "authenticated";
31+
32+
return (
33+
// 响应式 grid:桌面 3 列 / 平板 2 列 / 手机 1 列
34+
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
35+
{links.map((link) => (
36+
<LinkCard
37+
key={link.id}
38+
link={link}
39+
categoryLabel={getCategoryLabel(link.category)}
40+
isLoggedIn={isLoggedIn}
41+
/>
42+
))}
43+
</ul>
44+
);
45+
}

app/feed/components/LinkCard.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* 社区分享链接卡片。
3+
* - 整卡可点击,跳转到原文(target="_blank")
4+
* - OG 封面:有则显示,没有则渲染 host 首字母占位块
5+
* - 举报按钮由 ReportButton 组件负责,阻止冒泡不触发整卡跳转
6+
* - 服务端渲染(纯展示,无 client state),ReportButton 是 client 组件
7+
*/
8+
9+
import { useTranslations } from "next-intl";
10+
import type { SharedLinkView } from "@/app/feed/types";
11+
import { ReportButton } from "@/app/feed/components/ReportButton";
12+
import { Badge } from "@/components/ui/badge";
13+
14+
interface LinkCardProps {
15+
link: SharedLinkView;
16+
/** 分类显示名(由父组件从 i18n 翻译后传入,避免在纯 server 组件里调 useTranslations) */
17+
categoryLabel: string;
18+
/** 当前用户是否已登录(影响举报按钮行为) */
19+
isLoggedIn: boolean;
20+
}
21+
22+
/** 从 host 字符串提取首字母大写,作为封面占位符 */
23+
function getHostInitial(host: string): string {
24+
const cleaned = host.replace(/^www\./, "");
25+
return (cleaned[0] ?? "?").toUpperCase();
26+
}
27+
28+
export function LinkCard({ link, categoryLabel, isLoggedIn }: LinkCardProps) {
29+
const t = useTranslations("feed.card");
30+
31+
return (
32+
<li className="group border border-[var(--foreground)] hover:border-[#CC0000] transition-colors duration-150 flex flex-col">
33+
{/* 整卡可点击区域,跳到原文 */}
34+
<a
35+
href={link.url}
36+
target="_blank"
37+
rel="noopener noreferrer"
38+
className="flex flex-col flex-1"
39+
aria-label={link.ogTitle ?? link.url}
40+
>
41+
{/* OG 封面 / 占位块 */}
42+
{link.ogCover && !link.ogFetchFailed ? (
43+
// next/image 全站 unoptimized:true,用 img 即可(与 events 页一致)
44+
// eslint-disable-next-line @next/next/no-img-element
45+
<img
46+
src={link.ogCover}
47+
alt={link.ogTitle ?? link.host}
48+
className="w-full aspect-[16/9] object-cover border-b border-[var(--foreground)]"
49+
/>
50+
) : (
51+
// 无封面:显示 host 首字母占位
52+
<div className="w-full aspect-[16/9] bg-neutral-100 dark:bg-neutral-900 border-b border-[var(--foreground)] flex flex-col items-center justify-center gap-1">
53+
<span className="font-serif text-4xl font-black text-neutral-400 select-none">
54+
{getHostInitial(link.host)}
55+
</span>
56+
<span className="font-mono text-[9px] uppercase tracking-widest text-neutral-400">
57+
{link.host}
58+
</span>
59+
{link.ogFetchFailed && (
60+
// OG 抓取失败时给用户一个弱提示
61+
<span className="font-mono text-[9px] text-neutral-400 mt-1">
62+
{t("ogFallback")}
63+
</span>
64+
)}
65+
</div>
66+
)}
67+
68+
{/* 卡片内容区 */}
69+
<div className="p-4 flex flex-col gap-2 flex-1">
70+
{/* 标题 */}
71+
<h3 className="font-serif text-base font-black leading-snug group-hover:text-[#CC0000] transition-colors line-clamp-2 text-[var(--foreground)]">
72+
{link.ogTitle ?? link.url}
73+
</h3>
74+
75+
{/* OG 描述 / 用户推荐语 */}
76+
{(link.recommendation || link.ogDescription) && (
77+
<p className="text-sm text-neutral-600 dark:text-neutral-400 line-clamp-3 leading-relaxed">
78+
{/* 用户推荐语优先展示,没有则展示 OG description */}
79+
{link.recommendation ?? link.ogDescription}
80+
</p>
81+
)}
82+
83+
{/* 分类 badge + 失效标记 */}
84+
<div className="flex items-center gap-2 flex-wrap mt-auto pt-2">
85+
{link.category && (
86+
<Badge
87+
variant="outline"
88+
className="font-mono text-[9px] uppercase tracking-widest rounded-none border-[var(--foreground)]/40 text-neutral-500"
89+
>
90+
{categoryLabel}
91+
</Badge>
92+
)}
93+
{link.status === "ARCHIVED" && (
94+
<Badge
95+
variant="outline"
96+
className="font-mono text-[9px] uppercase tracking-widest rounded-none border-[#CC0000]/60 text-[#CC0000]"
97+
>
98+
{t("archivedBadge")}
99+
</Badge>
100+
)}
101+
</div>
102+
103+
{/* 提交人 + host 来源 */}
104+
<div className="flex items-center justify-between text-[10px] font-mono text-neutral-400 pt-1">
105+
<span className="truncate max-w-[60%]">{link.host}</span>
106+
</div>
107+
</div>
108+
</a>
109+
110+
{/* 举报区:与整卡点击分离(ReportButton 内部阻止冒泡) */}
111+
<div className="px-4 pb-3 border-t border-[var(--foreground)]/10 pt-2 flex justify-end">
112+
<ReportButton linkId={link.id} isLoggedIn={isLoggedIn} />
113+
</div>
114+
</li>
115+
);
116+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"use client";
2+
3+
/**
4+
* 举报按钮 + 举报 Dialog 组件。
5+
* - 未登录:点击时 toast 提示需要登录
6+
* - 已登录:弹出 Dialog,填写可选原因后提交 POST /api/community/links/{id}/report
7+
* 参照 Contribute.tsx 的 Dialog 模式实现。
8+
*/
9+
10+
import { useState } from "react";
11+
import { useTranslations } from "next-intl";
12+
import {
13+
Dialog,
14+
DialogContent,
15+
DialogFooter,
16+
DialogHeader,
17+
DialogTitle,
18+
DialogTrigger,
19+
} from "@/components/ui/dialog";
20+
import { Button } from "@/components/ui/button";
21+
import { Flag } from "lucide-react";
22+
23+
interface ReportButtonProps {
24+
/** 被举报的链接 ID */
25+
linkId: number;
26+
/** 当前用户是否已登录(由父组件/页面传入,避免在每张卡片都重新请求 session) */
27+
isLoggedIn: boolean;
28+
}
29+
30+
export function ReportButton({ linkId, isLoggedIn }: ReportButtonProps) {
31+
const t = useTranslations("feed.report");
32+
const [open, setOpen] = useState(false);
33+
const [reason, setReason] = useState("");
34+
const [submitting, setSubmitting] = useState(false);
35+
const [done, setDone] = useState(false); // 举报成功后隐藏按钮
36+
37+
/**
38+
* 未登录时点击举报,弹浏览器原生 alert(轻量,避免引入额外 toast provider 依赖)。
39+
* 后续如果需要引导跳登录页,可改成 router.push。
40+
*/
41+
function handleUnauth(e: React.MouseEvent) {
42+
e.preventDefault();
43+
e.stopPropagation(); // 阻止冒泡,不触发整卡链接跳转
44+
alert(t("loginRequired"));
45+
}
46+
47+
async function handleSubmit() {
48+
setSubmitting(true);
49+
try {
50+
const res = await fetch(`/api/community/links/${linkId}/report`, {
51+
method: "POST",
52+
headers: { "Content-Type": "application/json" },
53+
body: JSON.stringify({ reason: reason.trim() || undefined }),
54+
});
55+
if (res.ok) {
56+
setDone(true);
57+
setOpen(false);
58+
// 举报成功后短暂显示反馈——用 alert 保持轻量;后续可改 sonner toast
59+
alert(t("successToast"));
60+
}
61+
} finally {
62+
setSubmitting(false);
63+
}
64+
}
65+
66+
// 已举报成功,不再显示按钮
67+
if (done) return null;
68+
69+
// 未登录:直接用普通按钮,点击触发提示
70+
if (!isLoggedIn) {
71+
return (
72+
<button
73+
onClick={handleUnauth}
74+
className="flex items-center gap-1 text-[10px] font-mono uppercase tracking-widest text-neutral-400 hover:text-[#CC0000] transition-colors"
75+
aria-label={t("submitButton")}
76+
>
77+
<Flag className="h-3 w-3" />
78+
{t("submitButton")}
79+
</button>
80+
);
81+
}
82+
83+
return (
84+
<Dialog open={open} onOpenChange={setOpen}>
85+
<DialogTrigger asChild>
86+
<button
87+
// 阻止事件冒泡,避免触发整卡的 <a> 跳原文
88+
onClick={(e) => e.stopPropagation()}
89+
className="flex items-center gap-1 text-[10px] font-mono uppercase tracking-widest text-neutral-400 hover:text-[#CC0000] transition-colors"
90+
aria-label={t("submitButton")}
91+
>
92+
<Flag className="h-3 w-3" />
93+
{t("submitButton")}
94+
</button>
95+
</DialogTrigger>
96+
97+
<DialogContent
98+
className="sm:max-w-md"
99+
onClick={(e) => e.stopPropagation()}
100+
>
101+
<DialogHeader>
102+
<DialogTitle>{t("title")}</DialogTitle>
103+
</DialogHeader>
104+
105+
<div className="space-y-2">
106+
<label className="text-sm font-medium">{t("reasonLabel")}</label>
107+
<textarea
108+
className="w-full border border-[var(--foreground)]/30 rounded-none p-2 text-sm resize-none h-24 focus:outline-none focus:border-[var(--foreground)] bg-[var(--background)] text-[var(--foreground)]"
109+
placeholder={t("reasonPlaceholder")}
110+
value={reason}
111+
onChange={(e) => setReason(e.target.value)}
112+
maxLength={200}
113+
/>
114+
</div>
115+
116+
<DialogFooter>
117+
<Button
118+
onClick={handleSubmit}
119+
disabled={submitting}
120+
className="rounded-none"
121+
>
122+
{t("submitButton")}
123+
</Button>
124+
</DialogFooter>
125+
</DialogContent>
126+
</Dialog>
127+
);
128+
}

0 commit comments

Comments
 (0)