Skip to content

Commit 5868366

Browse files
feat(community): Hero 双 CTA + /share 极简路由 + 个人主页我的分享 + 修 Next16 layout 错
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(被并排按钮替代)
1 parent 1f391a9 commit 5868366

13 files changed

Lines changed: 799 additions & 216 deletions

File tree

app/components/Contribute.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,8 @@ export function Contribute() {
162162
}
163163
}}
164164
>
165-
<div className="relative mt-12 inline-flex w-full sm:w-auto">
165+
{/* mt 由外层容器控制;本组件只负责按钮 + 徽章的相对定位 */}
166+
<div className="relative inline-flex w-full sm:w-auto">
166167
<DialogTrigger asChild>
167168
<Button
168169
variant="hero"

app/components/Hero.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Suspense } from "react";
33
import { getTranslations } from "next-intl/server";
44
import ZoteroFeedLazy from "@/app/components/ZoteroFeedLazy";
55
import { Contribute } from "@/app/components/Contribute";
6+
import { ShareLink } from "@/app/components/ShareLink";
67
import Image from "next/image";
78
import { ActivityTicker } from "@/app/components/ActivityTicker";
89
import { cn } from "@/lib/utils";
@@ -64,18 +65,11 @@ export async function Hero() {
6465
{t("mission")}
6566
</p>
6667

67-
<div className="mt-12">
68+
{/* 双 CTA 并排:Contribute(正式投稿 Fumadocs)+ ShareLink(随手分享外部文章到 /feed)
69+
两者视觉平级,移动端堆叠(flex-wrap),桌面并排(gap-6) */}
70+
<div className="mt-12 flex flex-wrap items-start gap-x-6 gap-y-8">
6871
<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>
72+
<ShareLink />
7973
</div>
8074
</div>
8175
</div>

app/components/ShareLink.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"use client";
2+
3+
/**
4+
* Hero 区的"分享链接"按钮。
5+
*
6+
* 视觉与样式**完全复制** Contribute 主 CTA,和它并排形成双 CTA:
7+
* - Contribute → 正式投稿 Fumadocs 知识库(走 GitHub PR)
8+
* - ShareLink → 随手分享公众号/知乎等文章到社区墙(/feed)
9+
*
10+
* 两者语义平级,视觉也平级——这是用户拍板的设计(之前尝试的次级文字链 UI 不够突出)。
11+
* 按钮点击跳 /feed(先看一眼再决定是否提交),右上角徽章保留与 Contribute 对称的图标位。
12+
*/
13+
14+
import Link from "next/link";
15+
import { useTranslations } from "next-intl";
16+
import { Button } from "@/components/ui/button";
17+
import { Link2, Plus } from "lucide-react";
18+
19+
export function ShareLink() {
20+
const t = useTranslations("shareLink");
21+
22+
return (
23+
<div className="relative inline-flex w-full sm:w-auto">
24+
{/* 主按钮跳 /feed(社区分享墙),样式与 Contribute 主按钮完全同构 */}
25+
<Link
26+
href="/feed"
27+
className="w-full sm:w-auto"
28+
data-umami-event="share_link_trigger"
29+
data-umami-event-location="hero"
30+
>
31+
<Button
32+
variant="hero"
33+
size="lg"
34+
className="relative isolate w-full sm:w-auto h-20 px-14 rounded-none
35+
text-2xl font-serif font-black uppercase italic tracking-tighter
36+
bg-[var(--foreground)] text-[var(--background)] border border-[var(--foreground)]
37+
hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all duration-300"
38+
>
39+
<span className="relative z-10 flex items-center gap-4">
40+
<Link2 className="h-6 w-6" />
41+
<span>{t("button")}</span>
42+
</span>
43+
</Button>
44+
</Link>
45+
{/* 右上角徽章:跳 /feed/submit 直接开提交表单,对应 Contribute 的指南 "?" 徽章 */}
46+
<Link
47+
href="/feed/submit"
48+
aria-label={t("submitAriaLabel")}
49+
title={t("submitAriaLabel")}
50+
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"
51+
data-umami-event="share_link_submit_shortcut"
52+
>
53+
<Plus className="h-4 w-4" strokeWidth={3} />
54+
<span className="sr-only">{t("submitAriaLabel")}</span>
55+
</Link>
56+
</div>
57+
);
58+
}

app/feed/submit/layout.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { ReactNode } from "react";
2+
import { Header } from "@/app/components/Header";
3+
import { Footer } from "@/app/components/Footer";
4+
5+
/**
6+
* /feed/submit 的 Server Component layout。
7+
*
8+
* 把 Header / Footer(依赖 next-intl/server.getTranslations 的 async Server Component)
9+
* 放在这一层,让子 page 可以保持 "use client",不再触发
10+
* "async Client Component is not supported" 报错。
11+
*/
12+
export default function SubmitLayout({ children }: { children: ReactNode }) {
13+
return (
14+
<>
15+
<Header />
16+
{children}
17+
<Footer />
18+
</>
19+
);
20+
}

app/feed/submit/page.tsx

Lines changed: 100 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ import { useState, useEffect } from "react";
1212
import { useRouter } from "next/navigation";
1313
import { useTranslations } from "next-intl";
1414
import { useAuth } from "@/lib/use-auth";
15-
import { Header } from "@/app/components/Header";
16-
import { Footer } from "@/app/components/Footer";
1715
import { Button } from "@/components/ui/button";
1816
import { Input } from "@/components/ui/input";
1917
import { Label } from "@/components/ui/label";
@@ -44,6 +42,10 @@ export default function SubmitPage() {
4442
setSubmitting(true);
4543
setError(null);
4644

45+
// 15s 超时保护,避免 fetch 永远 hang 卡在 "提交中..."
46+
const ctrl = new AbortController();
47+
const timer = setTimeout(() => ctrl.abort(), 15_000);
48+
4749
try {
4850
// satoken 从 localStorage 读取,随请求头发送(与其他需鉴权的接口一致)
4951
const token =
@@ -59,14 +61,16 @@ export default function SubmitPage() {
5961
url: url.trim(),
6062
recommendation: recommendation.trim() || undefined,
6163
}),
64+
signal: ctrl.signal,
6265
});
6366

6467
if (res.ok) {
6568
// 提交成功:toast 反馈 + 跳用户分享列表页
6669
// 使用 alert 保持轻量(与 ReportButton 一致,无额外 toast provider 依赖)
6770
alert(t("successToast"));
68-
if (user?.id) {
69-
router.push(`/u/${user.id}/shares`);
71+
// 路由 param 是 [username] 不是 id,必须用 user.username 而不是 user.id
72+
if (user?.username) {
73+
router.push(`/u/${user.username}/shares`);
7074
} else {
7175
router.push("/feed");
7276
}
@@ -78,26 +82,27 @@ export default function SubmitPage() {
7882
`提交失败(HTTP ${res.status})`,
7983
);
8084
}
81-
} catch {
82-
setError("网络错误,请稍后重试");
85+
} catch (e) {
86+
if (e instanceof DOMException && e.name === "AbortError") {
87+
setError("请求超时(15 秒未响应),请稍后重试");
88+
} else {
89+
setError("网络错误,请稍后重试");
90+
}
8391
} finally {
92+
clearTimeout(timer);
8493
setSubmitting(false);
8594
}
8695
}
8796

8897
// 认证加载中:渲染骨架避免布局跳动
8998
if (status === "loading") {
9099
return (
91-
<>
92-
<Header />
93-
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">
94-
<div className="max-w-xl mx-auto px-6 lg:px-8 animate-pulse">
95-
<div className="h-8 bg-neutral-100 dark:bg-neutral-900 rounded mb-4" />
96-
<div className="h-4 bg-neutral-100 dark:bg-neutral-900 rounded w-2/3" />
97-
</div>
98-
</main>
99-
<Footer />
100-
</>
100+
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">
101+
<div className="max-w-xl mx-auto px-6 lg:px-8 animate-pulse">
102+
<div className="h-8 bg-neutral-100 dark:bg-neutral-900 rounded mb-4" />
103+
<div className="h-4 bg-neutral-100 dark:bg-neutral-900 rounded w-2/3" />
104+
</div>
105+
</main>
101106
);
102107
}
103108

@@ -107,96 +112,90 @@ export default function SubmitPage() {
107112
}
108113

109114
return (
110-
<>
111-
<Header />
112-
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">
113-
<div className="max-w-xl mx-auto px-6 lg:px-8">
114-
{/* 页头 */}
115-
<header className="border-t-4 border-[var(--foreground)] pt-6 mb-10">
116-
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">
117-
Community · Feed · Submit
118-
</div>
119-
<h1 className="font-serif text-4xl md:text-5xl font-black uppercase mt-2 tracking-tight text-[var(--foreground)]">
120-
{t("title")}
121-
</h1>
122-
</header>
123-
124-
{/* 提交表单 */}
125-
<form onSubmit={handleSubmit} className="space-y-6">
126-
{/* URL 输入 */}
127-
<div className="space-y-2">
115+
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">
116+
<div className="max-w-xl mx-auto px-6 lg:px-8">
117+
{/* 页头 */}
118+
<header className="border-t-4 border-[var(--foreground)] pt-6 mb-10">
119+
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">
120+
Community · Feed · Submit
121+
</div>
122+
<h1 className="font-serif text-4xl md:text-5xl font-black uppercase mt-2 tracking-tight text-[var(--foreground)]">
123+
{t("title")}
124+
</h1>
125+
</header>
126+
127+
{/* 提交表单 */}
128+
<form onSubmit={handleSubmit} className="space-y-6">
129+
{/* URL 输入 */}
130+
<div className="space-y-2">
131+
<Label
132+
htmlFor="url"
133+
className="font-mono text-xs uppercase tracking-widest"
134+
>
135+
{t("urlLabel")}
136+
</Label>
137+
<Input
138+
id="url"
139+
type="url"
140+
placeholder={t("urlPlaceholder")}
141+
value={url}
142+
onChange={(e) => setUrl(e.target.value)}
143+
required
144+
className="rounded-none border-[var(--foreground)] focus-visible:ring-0 focus-visible:border-[var(--foreground)] bg-[var(--background)] text-[var(--foreground)]"
145+
/>
146+
</div>
147+
148+
{/* 推荐语 textarea */}
149+
<div className="space-y-2">
150+
<div className="flex items-center justify-between">
128151
<Label
129-
htmlFor="url"
152+
htmlFor="recommendation"
130153
className="font-mono text-xs uppercase tracking-widest"
131154
>
132-
{t("urlLabel")}
155+
{t("recommendationLabel")}
133156
</Label>
134-
<Input
135-
id="url"
136-
type="url"
137-
placeholder={t("urlPlaceholder")}
138-
value={url}
139-
onChange={(e) => setUrl(e.target.value)}
140-
required
141-
className="rounded-none border-[var(--foreground)] focus-visible:ring-0 focus-visible:border-[var(--foreground)] bg-[var(--background)] text-[var(--foreground)]"
142-
/>
143-
</div>
144-
145-
{/* 推荐语 textarea */}
146-
<div className="space-y-2">
147-
<div className="flex items-center justify-between">
148-
<Label
149-
htmlFor="recommendation"
150-
className="font-mono text-xs uppercase tracking-widest"
151-
>
152-
{t("recommendationLabel")}
153-
</Label>
154-
{/* 字数计数 */}
155-
<span className="font-mono text-[10px] text-neutral-400">
156-
{t("charCount", { count: recommendation.length })}
157-
</span>
158-
</div>
159-
<textarea
160-
id="recommendation"
161-
className="w-full border border-[var(--foreground)] p-3 text-sm resize-none h-28 focus:outline-none focus:border-[var(--foreground)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-neutral-400"
162-
placeholder={t("recommendationPlaceholder")}
163-
value={recommendation}
164-
onChange={(e) => {
165-
// 限制最多 200 字
166-
if (e.target.value.length <= 200) {
167-
setRecommendation(e.target.value);
168-
}
169-
}}
170-
maxLength={200}
171-
/>
157+
{/* 字数计数 */}
158+
<span className="font-mono text-[10px] text-neutral-400">
159+
{t("charCount", { count: recommendation.length })}
160+
</span>
172161
</div>
162+
<textarea
163+
id="recommendation"
164+
className="w-full border border-[var(--foreground)] p-3 text-sm resize-none h-28 focus:outline-none focus:border-[var(--foreground)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-neutral-400"
165+
placeholder={t("recommendationPlaceholder")}
166+
value={recommendation}
167+
onChange={(e) => {
168+
// 限制最多 200 字
169+
if (e.target.value.length <= 200) {
170+
setRecommendation(e.target.value);
171+
}
172+
}}
173+
maxLength={200}
174+
/>
175+
</div>
173176

174-
{/* 错误提示 */}
175-
{error && (
176-
<p className="text-sm text-[#CC0000] font-mono">{error}</p>
177-
)}
178-
179-
{/* 提交按钮 */}
180-
<div className="flex items-center gap-4">
181-
<Button
182-
type="submit"
183-
disabled={submitting || !url.trim()}
184-
className="rounded-none px-8 py-3 font-sans text-xs uppercase tracking-widest font-bold bg-[var(--foreground)] text-[var(--background)] hover:bg-[var(--background)] hover:text-[var(--foreground)] border border-[var(--foreground)] transition-all duration-200 h-auto disabled:opacity-40"
185-
>
186-
{submitting ? t("submitting") : t("submitButton")}
187-
</Button>
188-
<button
189-
type="button"
190-
onClick={() => router.back()}
191-
className="font-mono text-xs uppercase tracking-widest text-neutral-500 hover:text-[var(--foreground)] transition-colors"
192-
>
193-
取消
194-
</button>
195-
</div>
196-
</form>
197-
</div>
198-
</main>
199-
<Footer />
200-
</>
177+
{/* 错误提示 */}
178+
{error && <p className="text-sm text-[#CC0000] font-mono">{error}</p>}
179+
180+
{/* 提交按钮 */}
181+
<div className="flex items-center gap-4">
182+
<Button
183+
type="submit"
184+
disabled={submitting || !url.trim()}
185+
className="rounded-none px-8 py-3 font-sans text-xs uppercase tracking-widest font-bold bg-[var(--foreground)] text-[var(--background)] hover:bg-[var(--background)] hover:text-[var(--foreground)] border border-[var(--foreground)] transition-all duration-200 h-auto disabled:opacity-40"
186+
>
187+
{submitting ? t("submitting") : t("submitButton")}
188+
</Button>
189+
<button
190+
type="button"
191+
onClick={() => router.back()}
192+
className="font-mono text-xs uppercase tracking-widest text-neutral-500 hover:text-[var(--foreground)] transition-colors"
193+
>
194+
取消
195+
</button>
196+
</div>
197+
</form>
198+
</div>
199+
</main>
201200
);
202201
}

0 commit comments

Comments
 (0)