Skip to content

Commit 4cd0d00

Browse files
committed
feat(web): expand community tab experience
1 parent a7a670f commit 4cd0d00

11 files changed

Lines changed: 513 additions & 190 deletions

File tree

‎apps/web/app/components/Sidebar.tsx‎

Lines changed: 79 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
55
import { Button } from "./ui/button";
66
import { Skeleton } from "./ui/skeleton";
77
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
8+
import {
9+
Item,
10+
ItemContent,
11+
ItemDescription,
12+
ItemGroup,
13+
ItemMedia,
14+
ItemTitle,
15+
} from "./ui/item";
816
import { TimeAgo } from "./TimeAgo";
917
import { getAvatarFallback, getAvatarUrl, getTabLabel } from "~/lib/brand";
1018
import { ArrowUpRight, Handshake, MessageCircle, Trophy } from "lucide-react";
19+
import { TopicTabInfoCard } from "./TopicTabInfo";
1120

1221
type SidebarData = {
1322
latest_replies: Array<{
@@ -24,7 +33,13 @@ type SidebarData = {
2433
resources: Array<{ name: string; url: string }>;
2534
};
2635

27-
export function Sidebar() {
36+
export function Sidebar({
37+
tab = "all",
38+
tabs = [],
39+
}: {
40+
tab?: string;
41+
tabs?: Array<{ key: string; label: string }>;
42+
}) {
2843
const [data, setData] = useState<SidebarData | null>(null);
2944

3045
useEffect(() => {
@@ -35,25 +50,7 @@ export function Sidebar() {
3550

3651
return (
3752
<aside className="flex flex-col gap-5 md:gap-6">
38-
<Card size="sm">
39-
<CardHeader>
40-
<CardTitle>社区合作</CardTitle>
41-
</CardHeader>
42-
<CardContent className="flex flex-col gap-3">
43-
<p className="text-sm text-muted-foreground">
44-
面向开源项目、开发者工具与 Node.js 技术活动的社区展示位。
45-
</p>
46-
<Button
47-
render={<Link to="/about#cooperation" />}
48-
variant="secondary"
49-
className="w-full"
50-
size="sm"
51-
>
52-
<Handshake />
53-
了解合作方式
54-
</Button>
55-
</CardContent>
56-
</Card>
53+
<SidebarContextCard tab={tab} label={tabs.find((item) => item.key === tab)?.label} />
5754

5855
{data ? <LatestReplies replies={data.latest_replies} /> : <SidebarSkeleton />}
5956
{data ? <Leaderboard users={data.top_users} /> : <SidebarSkeleton />}
@@ -67,6 +64,33 @@ export function Sidebar() {
6764
);
6865
}
6966

67+
function SidebarContextCard({ tab, label }: { tab: string; label?: string }) {
68+
if (tab !== "all" && tab !== "good") {
69+
return <TopicTabInfoCard tab={tab} label={label} />;
70+
}
71+
return (
72+
<Card size="sm">
73+
<CardHeader>
74+
<CardTitle>社区合作</CardTitle>
75+
</CardHeader>
76+
<CardContent className="flex flex-col gap-3">
77+
<p className="text-sm text-muted-foreground">
78+
面向开源项目、开发者工具与 Node.js 技术活动的社区展示位。
79+
</p>
80+
<Button
81+
render={<Link to="/about#cooperation" />}
82+
variant="secondary"
83+
className="w-full"
84+
size="sm"
85+
>
86+
<Handshake />
87+
了解合作方式
88+
</Button>
89+
</CardContent>
90+
</Card>
91+
);
92+
}
93+
7094
function SidebarSkeleton() {
7195
return (
7296
<Card size="sm">
@@ -86,34 +110,37 @@ function LatestReplies({ replies }: { replies: SidebarData["latest_replies"] })
86110
<CardHeader>
87111
<CardTitle>最新回复</CardTitle>
88112
</CardHeader>
89-
<CardContent className="flex flex-col gap-3">
113+
<CardContent>
114+
<ItemGroup>
90115
{replies.map((reply) => {
91116
const author = reply.author;
92117
return (
93-
<Link
118+
<Item
94119
key={reply.id}
95-
to={`/topic/${reply.topic_id}#${reply.id}`}
96-
className="group flex gap-3"
120+
render={<Link to={`/topic/${reply.topic_id}#${reply.id}`} />}
121+
variant="muted"
122+
size="xs"
97123
>
98-
<Avatar className="mt-0.5 size-8">
99-
<AvatarImage
100-
src={getAvatarUrl(author?.avatar_url, 32)}
101-
alt={author?.loginname || "CNode"}
102-
/>
103-
<AvatarFallback>{getAvatarFallback(author?.loginname)}</AvatarFallback>
104-
</Avatar>
105-
<span className="min-w-0 flex-1 text-xs">
106-
<span className="block truncate font-medium text-foreground group-hover:text-primary">
107-
{reply.topic_title}
108-
</span>
109-
<span className="mt-0.5 block truncate text-muted-foreground">
124+
<ItemMedia>
125+
<Avatar className="size-8">
126+
<AvatarImage
127+
src={getAvatarUrl(author?.avatar_url, 32)}
128+
alt={author?.loginname || "CNode"}
129+
/>
130+
<AvatarFallback>{getAvatarFallback(author?.loginname)}</AvatarFallback>
131+
</Avatar>
132+
</ItemMedia>
133+
<ItemContent>
134+
<ItemTitle className="line-clamp-2 w-full text-xs">{reply.topic_title}</ItemTitle>
135+
<ItemDescription>
110136
{author?.loginname || "社区成员"} ·{" "}
111137
{reply.create_at ? <TimeAgo date={reply.create_at} /> : null}
112-
</span>
113-
</span>
114-
</Link>
138+
</ItemDescription>
139+
</ItemContent>
140+
</Item>
115141
);
116142
})}
143+
</ItemGroup>
117144
</CardContent>
118145
</Card>
119146
);
@@ -128,22 +155,22 @@ function NoReplyTopics({ topics }: { topics: SidebarData["no_reply_topics"] }) {
128155
<CardTitle>无人回复的话题</CardTitle>
129156
</CardHeader>
130157
<CardContent>
131-
<ul className="flex flex-col gap-2 text-xs">
158+
<ItemGroup>
132159
{topics.map((t) => (
133-
<li key={t.id}>
134-
<Link
135-
to={`/topic/${t.id}`}
136-
className="block truncate font-medium text-foreground hover:text-primary"
137-
>
138-
{t.title}
139-
</Link>
140-
<div className="mt-0.5 flex items-center gap-2 text-muted-foreground">
160+
<Item key={t.id} render={<Link to={`/topic/${t.id}`} />} variant="muted" size="xs">
161+
<ItemMedia variant="icon">
162+
<MessageCircle />
163+
</ItemMedia>
164+
<ItemContent>
165+
<ItemTitle className="line-clamp-2 w-full text-xs">{t.title}</ItemTitle>
166+
<ItemDescription>
141167
<span>{getTabLabel(t.tab)}</span>
142-
{t.create_at && <TimeAgo date={t.create_at} />}
143-
</div>
144-
</li>
168+
{t.create_at ? <> · <TimeAgo date={t.create_at} /></> : null}
169+
</ItemDescription>
170+
</ItemContent>
171+
</Item>
145172
))}
146-
</ul>
173+
</ItemGroup>
147174
</CardContent>
148175
</Card>
149176
);

‎apps/web/app/components/TagBadge.tsx‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
1-
const TABS: Record<string, string> = {
2-
share: "分享",
3-
ask: "问答",
4-
job: "招聘",
5-
good: "精华",
6-
};
1+
import { useRouteLoaderData } from "react-router";
2+
import { getTabLabel } from "~/lib/brand";
3+
import { Badge } from "./ui/badge";
74

85
export function TagBadge({ tab }: { tab: string }) {
9-
const label = TABS[tab] || tab;
6+
const rootData = useRouteLoaderData("root") as
7+
| { tabs?: Array<{ key: string; label: string }> }
8+
| undefined;
9+
const label = getTabLabel(tab, rootData?.tabs);
1010
if (!label) return null;
1111
return <Badge variant="secondary">{label}</Badge>;
1212
}
1313

14-
import { Badge } from "./ui/badge";
15-
1614
export function StatusBadge({ type }: { type: "top" | "good" | "lock" | "muted" | "archived" }) {
1715
const config = {
1816
top: { label: "置顶", variant: "outline" as const },
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { Link } from "react-router";
2+
import {
3+
Card,
4+
CardContent,
5+
CardDescription,
6+
CardFooter,
7+
CardHeader,
8+
CardTitle,
9+
} from "~/components/ui/card";
10+
import { Button } from "~/components/ui/button";
11+
import {
12+
Item,
13+
ItemContent,
14+
ItemDescription,
15+
ItemGroup,
16+
ItemMedia,
17+
ItemTitle,
18+
} from "~/components/ui/item";
19+
import { cn } from "~/lib/utils";
20+
import { getDefaultTopicTabLabel, getTopicTabPresentation } from "~/lib/topic-tab-presentation";
21+
import { ArrowRight, BadgeInfo, Compass, LockKeyhole, ShieldCheck } from "lucide-react";
22+
23+
export function TopicTabInfoCard({
24+
tab,
25+
label,
26+
className,
27+
}: {
28+
tab: string;
29+
label?: string;
30+
className?: string;
31+
}) {
32+
const info = getTopicTabPresentation(tab);
33+
if (!info) return null;
34+
return (
35+
<Card size="sm" className={className}>
36+
<CardHeader>
37+
<CardTitle>{info.title || label || getDefaultTopicTabLabel(tab)}</CardTitle>
38+
<CardDescription>了解板块范围与内容边界。</CardDescription>
39+
</CardHeader>
40+
<CardContent>
41+
<ItemGroup>
42+
<Item variant="muted" size="sm">
43+
<ItemMedia variant="icon">
44+
<Compass />
45+
</ItemMedia>
46+
<ItemContent>
47+
<ItemTitle>板块范围</ItemTitle>
48+
<ItemDescription className="line-clamp-none">{info.description}</ItemDescription>
49+
</ItemContent>
50+
</Item>
51+
<Item variant="muted" size="sm">
52+
<ItemMedia variant="icon">
53+
<BadgeInfo />
54+
</ItemMedia>
55+
<ItemContent>
56+
<ItemTitle>内容边界</ItemTitle>
57+
<ItemDescription className="line-clamp-none">{info.details}</ItemDescription>
58+
</ItemContent>
59+
</Item>
60+
</ItemGroup>
61+
</CardContent>
62+
</Card>
63+
);
64+
}
65+
66+
export function TopicTabSummary({ tab, className }: { tab: string; className?: string }) {
67+
const info = getTopicTabPresentation(tab);
68+
if (!info) return null;
69+
return (
70+
<p className={cn("rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground", className)}>
71+
<span className="font-medium text-foreground">
72+
{info.title || getDefaultTopicTabLabel(tab)}:
73+
</span>
74+
{info.description}
75+
</p>
76+
);
77+
}
78+
79+
export function PublishingRulesCard({ className }: { className?: string }) {
80+
return (
81+
<Card size="sm" className={className}>
82+
<CardHeader>
83+
<CardTitle>发布规范</CardTitle>
84+
<CardDescription>发布前请确认内容符合以下要求。</CardDescription>
85+
</CardHeader>
86+
<CardContent>
87+
<ItemGroup>
88+
<Item variant="muted" size="sm">
89+
<ItemMedia variant="icon">
90+
<ShieldCheck />
91+
</ItemMedia>
92+
<ItemContent>
93+
<ItemTitle>保持合法与友善</ItemTitle>
94+
<ItemDescription className="line-clamp-none">
95+
禁止违法内容、人身攻击、垃圾信息、重复灌水和无关广告。
96+
</ItemDescription>
97+
</ItemContent>
98+
</Item>
99+
<Item variant="muted" size="sm">
100+
<ItemMedia variant="icon">
101+
<LockKeyhole />
102+
</ItemMedia>
103+
<ItemContent>
104+
<ItemTitle>保护敏感信息</ItemTitle>
105+
<ItemDescription className="line-clamp-none">
106+
不得公开密码、Token、私钥、个人隐私或其他敏感信息。
107+
</ItemDescription>
108+
</ItemContent>
109+
</Item>
110+
<Item variant="muted" size="sm">
111+
<ItemMedia variant="icon">
112+
<BadgeInfo />
113+
</ItemMedia>
114+
<ItemContent>
115+
<ItemTitle>标注来源与关系</ItemTitle>
116+
<ItemDescription className="line-clamp-none">
117+
引用必须注明来源;商业推广、招聘和付费活动必须披露相关关系。
118+
</ItemDescription>
119+
</ItemContent>
120+
</Item>
121+
</ItemGroup>
122+
</CardContent>
123+
<CardFooter>
124+
<Button
125+
render={<Link to="/about#discussion" />}
126+
variant="ghost"
127+
size="sm"
128+
className="w-full justify-between"
129+
>
130+
查看完整社区规则
131+
<ArrowRight data-icon="inline-end" />
132+
</Button>
133+
</CardFooter>
134+
</Card>
135+
);
136+
}

‎apps/web/app/lib/brand.ts‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { getDefaultTopicTabLabel } from "./topic-tab-presentation";
2+
13
export const CNODE_LOGO_LIGHT = "/cnodejs_light.svg";
24
export const CNODE_LOGO_DARK = "/cnodejs.svg";
35

@@ -17,14 +19,6 @@ export function getAvatarFallback(name?: string | null) {
1719
return value.slice(0, 1).toUpperCase();
1820
}
1921

20-
const DEFAULT_TAB_LABELS: Record<string, string> = {
21-
all: "全部",
22-
share: "分享",
23-
ask: "问答",
24-
job: "招聘",
25-
good: "精华",
26-
};
27-
2822
export function getTabLabel(
2923
tab?: string | null,
3024
tabs?: Array<{ key: string; label: string }>,
@@ -33,5 +27,5 @@ export function getTabLabel(
3327
const found = tabs.find((t) => t.key === tab);
3428
if (found) return found.label;
3529
}
36-
return DEFAULT_TAB_LABELS[tab || ""] || tab || "社区";
30+
return getDefaultTopicTabLabel(tab);
3731
}

0 commit comments

Comments
 (0)