Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions app/components/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,31 @@ export async function Hero() {
<p className="font-body text-sm mb-6 opacity-80">
{t("join.body")}
</p>
<Link
href="/docs/learn/ai"
className="block w-full"
data-umami-event="navigation_click"
data-umami-event-region="hero_cta"
data-umami-event-label="Access Articles"
>
<button className="w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer">
{t("cta.access")}
</button>
</Link>
{/* 双阅读入口:严肃文档 + 社区随手分享,视觉同构;投稿动作已在 Hero 左侧 Contribute/ShareLink */}
<div className="flex flex-col gap-3">
<Link
href="/docs/learn/ai"
className="block w-full"
data-umami-event="navigation_click"
data-umami-event-region="hero_cta"
data-umami-event-label="Access Articles"
>
<button className="w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer">
{t("cta.access")}
</button>
</Link>
<Link
href="/feed"
className="block w-full"
data-umami-event="navigation_click"
data-umami-event-region="hero_cta"
data-umami-event-label="Community Feed"
>
<button className="w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer">
{t("cta.feed")}
</button>
Comment on lines +101 to +119
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.

These Link blocks wrap a native <button> inside an anchor, which is invalid HTML (nested interactive elements) and can cause accessibility / keyboard issues. Prefer rendering the link itself as the styled control (or use the shared <Button asChild> pattern used elsewhere, e.g. app/not-found.tsx:31) to avoid nested interactive elements.

Suggested change
className="block w-full"
data-umami-event="navigation_click"
data-umami-event-region="hero_cta"
data-umami-event-label="Access Articles"
>
<button className="w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer">
{t("cta.access")}
</button>
</Link>
<Link
href="/feed"
className="block w-full"
data-umami-event="navigation_click"
data-umami-event-region="hero_cta"
data-umami-event-label="Community Feed"
>
<button className="w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer">
{t("cta.feed")}
</button>
className="block w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest text-center hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer"
data-umami-event="navigation_click"
data-umami-event-region="hero_cta"
data-umami-event-label="Access Articles"
>
{t("cta.access")}
</Link>
<Link
href="/feed"
className="block w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest text-center hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer"
data-umami-event="navigation_click"
data-umami-event-region="hero_cta"
data-umami-event-label="Community Feed"
>
{t("cta.feed")}

Copilot uses AI. Check for mistakes.
</Link>
</div>
</div>
</div>
</div>
Expand Down
64 changes: 26 additions & 38 deletions app/components/ShareLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,56 +3,44 @@
/**
* Hero 区的"分享链接"按钮。
*
* 视觉与样式**完全复制** Contribute 主 CTA,和它并排形成双 CTA
* 视觉与 Contribute 主 CTA 完全同构,并排形成双投稿入口
* - Contribute → 正式投稿 Fumadocs 知识库(走 GitHub PR)
* - ShareLink → 随手分享公众号/知乎等文章到社区墙(/feed)
* - ShareLink → 随手丢一篇外部文章到社区分享墙(/feed/submit
*
* 两者语义平级,视觉也平级——这是用户拍板的设计(之前尝试的次级文字链 UI 不够突出)。
* 按钮点击跳 /feed(先看一眼再决定是否提交),右上角徽章保留与 Contribute 对称的图标位。
* 两者语义平级:都是"投稿"动作。对应的"阅读"入口在右侧 Join the Resistance
* 卡片里(访问文章 / 看看最近在读),不放在 Hero 主 CTA 区,避免混淆。
*
* 之前本按钮跳 /feed 并带一个 "+" 徽章跳 /feed/submit——语义错位,已修正。
*/

import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Link2, Plus } from "lucide-react";
import { Link2 } from "lucide-react";

export function ShareLink() {
const t = useTranslations("shareLink");

return (
<div className="relative inline-flex w-full sm:w-auto">
{/* 主按钮跳 /feed(社区分享墙),样式与 Contribute 主按钮完全同构 */}
<Link
href="/feed"
className="w-full sm:w-auto"
data-umami-event="share_link_trigger"
data-umami-event-location="hero"
>
<Button
variant="hero"
size="lg"
className="relative isolate w-full sm:w-auto h-20 px-14 rounded-none
text-2xl font-serif font-black uppercase italic tracking-tighter
bg-[var(--foreground)] text-[var(--background)] border border-[var(--foreground)]
hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all duration-300"
>
<span className="relative z-10 flex items-center gap-4">
<Link2 className="h-6 w-6" />
<span>{t("button")}</span>
</span>
</Button>
</Link>
{/* 右上角徽章:跳 /feed/submit 直接开提交表单,对应 Contribute 的指南 "?" 徽章 */}
<Link
href="/feed/submit"
aria-label={t("submitAriaLabel")}
title={t("submitAriaLabel")}
className="absolute top-0 right-0 flex h-10 w-10 translate-x-1/2 -translate-y-1/2 items-center justify-center border border-[var(--foreground)] bg-[var(--background)] text-[var(--foreground)] font-mono hover:bg-[#CC0000] hover:text-white transition-colors z-20"
data-umami-event="share_link_submit_shortcut"
<Link
href="/feed/submit"
className="inline-flex w-full sm:w-auto"
data-umami-event="share_link_trigger"
data-umami-event-location="hero"
>
<Button
variant="hero"
size="lg"
className="relative isolate w-full sm:w-auto h-20 px-14 rounded-none
text-2xl font-serif font-black uppercase italic tracking-tighter
bg-[var(--foreground)] text-[var(--background)] border border-[var(--foreground)]
hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all duration-300"
>
<Plus className="h-4 w-4" strokeWidth={3} />
<span className="sr-only">{t("submitAriaLabel")}</span>
</Link>
</div>
<span className="relative z-10 flex items-center gap-4">
<Link2 className="h-6 w-6" />
<span>{t("button")}</span>
</span>
</Button>
</Link>
);
}
13 changes: 7 additions & 6 deletions app/feed/components/FeedAuthWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
* 本组件在 client 端读取 useAuth() 后,把 isLoggedIn 传给 LinkCard,
* 使举报按钮可以区分已登录 / 未登录行为。
*
* 接收 server 端已预计算好的 links 和 categoryLabel 函数,
* 只负责登录态桥接,不做额外数据请求。
* 接收 server 端已预计算好的 links 和**分类标签映射表**(纯数据),
* 不接收函数 prop —— Next 16 对 server→client 边界严格禁止函数 prop
* (会报 "Functions cannot be passed directly to Client Components")。
*/

import { useAuth } from "@/lib/use-auth";
Expand All @@ -17,13 +18,13 @@ import type { SharedLinkView, CategorySlug } from "@/app/feed/types";

interface FeedAuthWrapperProps {
links: SharedLinkView[];
/** server 端传入的分类标签计算函数(已含 i18n 翻译) */
getCategoryLabel: (slug: CategorySlug | null) => string;
/** server 端预翻译好的 slug → 中文显示名 map */
categoryLabels: Partial<Record<CategorySlug, string>>;
}

export function FeedAuthWrapper({
links,
getCategoryLabel,
categoryLabels,
}: FeedAuthWrapperProps) {
const { status } = useAuth();
// loading 阶段默认视为未登录,避免 UI 闪烁
Expand All @@ -36,7 +37,7 @@ export function FeedAuthWrapper({
<LinkCard
key={link.id}
link={link}
categoryLabel={getCategoryLabel(link.category)}
categoryLabel={(link.category && categoryLabels[link.category]) ?? ""}
isLoggedIn={isLoggedIn}
/>
))}
Expand Down
118 changes: 84 additions & 34 deletions app/feed/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,40 +28,93 @@ export const metadata: Metadata = {
};

/**
* 从后端拉取 APPROVED 的链接列表。
* category 为空时拉全部,否则按 slug 过滤。
* 从后端拉取 APPROVED 的链接列表,带 Cloudflare Managed Challenge 重试。
*
* 背景:Vercel SSR 出口偶发被 CF 403 挑战(同 fetchProfile 的坑)。
* 单次失败就 throw 会让首页/feed 显示 500。
*
* 策略(对齐 fetchProfile):
* - 第 1 次:走 Next Data Cache(revalidate: 120),命中快
* - 第 2/3 次:cache: no-store 绕过缓存,分别退避 300ms / 800ms
* - 全败返回 [] 而非抛错——让页面降级展示空态,不崩
* - 每次失败记录 status / cf-ray,便于 Vercel 日志定位
*/
async function fetchLinks(category?: string): Promise<SharedLinkView[]> {
const backendUrl = process.env.BACKEND_URL;
if (!backendUrl) {
// 配置缺失时给清晰错误,而非静默空列表
throw new Error("BACKEND_URL is not configured");
console.error("[feed/page] BACKEND_URL is not configured");
return [];
}

// 构造查询参数
const params = new URLSearchParams({ limit: "50", offset: "0" });
if (category) params.set("category", category);
const url = `${backendUrl}/api/community/links?${params.toString()}`;

const attempts: Array<{ revalidate: number } | { noStore: true }> = [
{ revalidate: 120 },
{ noStore: true },
{ noStore: true },
];

for (let i = 0; i < attempts.length; i++) {
const attempt = attempts[i];
const init: RequestInit & { next?: { revalidate: number } } =
"noStore" in attempt
? { cache: "no-store" }
: { next: { revalidate: attempt.revalidate } };
// 显式 UA 降低被 Cloudflare 误判 bot 的概率
init.headers = {
accept: "application/json",
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
};

let res: Response;
try {
res = await fetch(url, init);
} catch (err) {
console.warn("[feed/page] fetch network error", {
attempt: i,
error: String(err),
});
if (i === attempts.length - 1) return [];
await sleep(i === 0 ? 300 : 800);
continue;
}

const res = await fetch(
`${backendUrl}/api/community/links?${params.toString()}`,
{
next: { revalidate: 120 },
headers: {
accept: "application/json",
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
},
},
);
if (res.ok) {
try {
const json = (await res.json()) as ApiResponse<SharedLinkView[]>;
return json.success && json.data ? json.data : [];
} catch (err) {
// 2xx 但非 JSON(例如 CF 偶发返回 200 的 challenge HTML)
console.warn("[feed/page] non-JSON 2xx response", {
attempt: i,
cfRay: res.headers.get("cf-ray"),
contentType: res.headers.get("content-type"),
error: String(err),
});
if (i === attempts.length - 1) return [];
await sleep(i === 0 ? 300 : 800);
continue;
}
}

if (!res.ok) {
// 后端 5xx / 网络错误才抛,前端会走 error.tsx(如果有的话)
throw new Error(
`/api/community/links backend ${res.status} ${res.statusText}`,
);
// 非 2xx(含 403 CF challenge / 5xx):记录 + 重试
console.warn("[feed/page] backend non-2xx", {
attempt: i,
status: res.status,
cfRay: res.headers.get("cf-ray"),
cfMitigated: res.headers.get("cf-mitigated"),
});
if (i === attempts.length - 1) return [];
await sleep(i === 0 ? 300 : 800);
}

const json = (await res.json()) as ApiResponse<SharedLinkView[]>;
return json.success && json.data ? json.data : [];
return [];
}

function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

interface FeedPageProps {
Expand All @@ -85,16 +138,16 @@ export default async function FeedPage({ searchParams }: FeedPageProps) {
console.error("[feed/page] fetchLinks failed:", err);
}

/**
* 预计算每条链接的分类显示名(i18n)。
* 在 server 端翻译,避免 LinkCard(server component)里调 useTranslations(client hook)
*/
function getCategoryLabel(slug: CategorySlug | null): string {
if (!slug) return "";
// Server 端预计算 slug → 中文显示名 map。传给 FeedAuthWrapper(client)
// 时必须是纯数据(函数 prop 在 Next 16 会报 "Functions cannot be passed to
// Client Components")。8 个 slug 一次翻译完毕,零额外开销
const { CATEGORY_SLUGS } = await import("@/app/feed/types");
const categoryLabels: Partial<Record<CategorySlug, string>> = {};
for (const slug of CATEGORY_SLUGS) {
try {
return tCategory(slug);
categoryLabels[slug] = tCategory(slug);
} catch {
return slug;
categoryLabels[slug] = slug;
}
}

Expand Down Expand Up @@ -148,10 +201,7 @@ export default async function FeedPage({ searchParams }: FeedPageProps) {
</div>
) : (
// FeedAuthWrapper 是 client 组件,负责读取登录态后注入到 LinkCard
<FeedAuthWrapper
links={links}
getCategoryLabel={getCategoryLabel}
/>
<FeedAuthWrapper links={links} categoryLabels={categoryLabels} />
)}
</div>
</main>
Expand Down
6 changes: 3 additions & 3 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"mission": "A free, open learning community built by developers, for developers. No gatekeeping, no pointless grind — just real progress and the joy of building. Knowledge is a ladder to freedom, not a cage.",
"cta": {
"access": "Access Articles",
"guideAriaLabel": "Contribution Guide"
"guideAriaLabel": "Contribution Guide",
"feed": "What we're reading lately"
},
"archivesLabel": "Classified Archives",
"join": {
Expand Down Expand Up @@ -444,7 +445,6 @@
}
},
"shareLink": {
"button": "Share Link",
"submitAriaLabel": "Quick submit"
"button": "Share a Link"
}
}
6 changes: 3 additions & 3 deletions messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"mission": "一个由开发者自发组织、免费开放的学习社区。降低门槛,避免无意义内卷,专注真实进步与乐趣。我们相信知识不应成为枷锁,而应是通往自由的阶梯。",
"cta": {
"access": "访问文章",
"guideAriaLabel": "查看投稿指南"
"guideAriaLabel": "查看投稿指南",
"feed": "看看我们最近在读什么"
},
"archivesLabel": "归档分类",
"join": {
Expand Down Expand Up @@ -444,7 +445,6 @@
}
},
"shareLink": {
"button": "丢个链接",
"submitAriaLabel": "快速提交"
"button": "丢个链接"
}
}
Loading