zzzUI

Post card

React 帖子卡片组件,用于社区动态:封面带浏览量,作者头像压在封面边缘,斜体标题前标有帖子类型,下附一行正文摘要。

安装

terminal
# once per project: where @zzzui items come frompnpm dlx shadcn@latest registry add @zzzui=https://zzzui.catforest.dev/r/{name}.jsonpnpm dlx shadcn@latest add @zzzui/post-card

示例

帖子墙

Lost
Seen 5,150 times
JUJuniper

Question

Grey, sealed, one corner dented. Last seen at the rail yards.

Two streets
Seen 3,301 times
B-B-Side

Commission

Pays well. Leave before midnight.

Gate shuts
Seen 199,861 times
DIDispatch

Notice

Anything not through by then waits until morning.

Ranked
Seen 27,014 times
NONoodleCartographer

Post

Thirty-one stalls, eleven nights, one map.

Swap
Seen 880 times
RORook

Post

Bring two, take two. No spares, no trades.

Pick a post.

post-card-feed.tsx
import { type ComponentType, useState } from "react";import { Avatar, AvatarFallback } from "@/components/zero/avatar";import { CellIcon } from "@/components/zero/icons/cell";import { CrateIcon } from "@/components/zero/icons/crate";import { FlareIcon } from "@/components/zero/icons/flare";import { KeyIcon } from "@/components/zero/icons/key";import { KitIcon } from "@/components/zero/icons/kit";import { Masonry } from "@/components/zero/masonry";import {  PostCard,  PostCardAuthor,  PostCardCover,  PostCardExcerpt,  PostCardTitle,} from "@/components/zero/post-card";/** A poster for a cover: a ground, a big leaning headline, an item icon. */function Poster({  tone,  ink,  icon: Icon,  line,  tall,}: {  tone: string;  ink: string;  icon: ComponentType<{ className?: string }>;  line: string;  tall?: boolean;}) {  return (    <div      className={`grid grid-rows-[auto_1fr] justify-items-center gap-2 overflow-hidden bg-(image:--zero-hatch) px-4 pt-10 pb-7 ${tall ? "aspect-[4/5]" : "aspect-[16/10]"}`}      style={{ backgroundColor: tone, color: ink }}    >      <span className="justify-self-stretch origin-left rotate-(--zero-tilt) font-black font-zero text-zero-xl uppercase italic leading-none">        {line}      </span>      <Icon className="size-16 self-center opacity-90" />    </div>  );}const posts = [  {    id: 1,    kind: "Question",    title: "Has anyone seen this crate?",    excerpt: "Grey, sealed, one corner dented. Last seen at the rail yards.",    author: "Juniper",    views: 5150,    poster: {      tone: "var(--color-zero-rarity-legendary)",      ink: "var(--color-zero-ink)",      icon: CrateIcon,      line: "Lost",      tall: true,    },  },  {    id: 2,    kind: "Commission",    title: "Need a courier: one box, two streets, no questions",    excerpt: "Pays well. Leave before midnight.",    author: "B-Side",    views: 3301,    poster: {      tone: "var(--color-zero-rarity-rare)",      ink: "var(--color-zero-ink)",      icon: KeyIcon,      line: "Two streets",    },  },  {    id: 3,    kind: "Notice",    title: "The north gate shuts at two tonight",    excerpt: "Anything not through by then waits until morning.",    author: "Dispatch",    views: 199861,    poster: {      tone: "var(--color-zero-accent)",      ink: "var(--color-zero-ink)",      icon: FlareIcon,      line: "Gate shuts",      tall: true,    },  },  {    id: 4,    kind: "Post",    title: "Every power cell vendor after midnight, ranked",    excerpt: "Thirty-one stalls, eleven nights, one map.",    author: "NoodleCartographer",    views: 27014,    poster: {      tone: "var(--color-zero-raised)",      ink: "var(--color-zero-rarity-uncommon)",      icon: CellIcon,      line: "Ranked",    },  },  {    id: 5,    kind: "Post",    title: "Kit swap at the old market, Thursday",    excerpt: "Bring two, take two. No spares, no trades.",    author: "Rook",    views: 880,    poster: {      tone: "var(--color-zero-rarity-epic)",      ink: "var(--color-zero-ink)",      icon: KitIcon,      line: "Swap",    },  },];export default function PostCardFeed() {  const [open, setOpen] = useState<string | null>(null);  return (    <div className="grid w-full gap-4">      <Masonry columnWidth="15rem">        {posts.map((post) => (          <PostCard key={post.id}>            <PostCardCover views={post.views}>              <Poster {...post.poster} />            </PostCardCover>            <PostCardAuthor              avatar={                <Avatar>                  <AvatarFallback>                    {post.author.slice(0, 2).toUpperCase()}                  </AvatarFallback>                </Avatar>              }            >              {post.author}            </PostCardAuthor>            <PostCardTitle kind={post.kind}>              <button type="button" onClick={() => setOpen(post.title)}>                {post.title}              </button>            </PostCardTitle>            <PostCardExcerpt>{post.excerpt}</PostCardExcerpt>          </PostCard>        ))}      </Masonry>      <p className="font-medium font-zero-text text-zero-sm text-zero-text-2">        {open ? `Opened: ${open}` : "Pick a post."}      </p>    </div>  );}

行为

图片铺满卡片宽度,角落显示浏览量;作者头像压在图片下边缘,旁边是作者名。标题为斜体,前面的小斜切块标出帖子类型(「Question」「Commission」),其后是一行正文摘要,超出部分以省略号截断。

在 PostCardTitle 中放入链接或按钮:它的点击区域会覆盖整张卡片。悬停时卡片上浮并显示强调色边框,聚焦时显示黄色焦点框;整张卡片在 Tab 顺序中只占一个位置,并以标题作为可访问名称。多张卡片可用 Masonry 排布。开启「减少动态效果」时,卡片不上浮。

属性

PostCardCover

属性类型默认值说明
viewsnumber—浏览量,显示在角落。
viewsLabel(views: string) => string"Seen {views} times"读屏软件为浏览次数读出的文字,参数是已格式化的数字。

PostCardAuthor

属性类型默认值说明
avatarReactNode—一个 Avatar,压在封面下边缘。

PostCardTitle

属性类型默认值说明
kindstring—帖子类型,以小斜切块显示在标题前。

PostCard, PostCardExcerpt