{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "poster-card",
  "title": "Poster Card",
  "author": "AniBT <https://anibt.net>",
  "description": "AniBT collectible poster card: a 2:3 cover in a glossy sleeve with a sheen on hover, a tilted airtime sticker, corner badges and a subscribe key; fixed height with a two-line title; srcSet / sizes for sharp covers; PosterGrid, rating, status and skeleton.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@anibt/theme"
  ],
  "files": [
    {
      "path": "registry/anibt/blocks/poster-card/poster-card.tsx",
      "content": "import * as React from \"react\"\nimport { StarIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * PosterCard — the AniBT 「贴纸手账」 collectible card: one show on a poster\n * wall (a timetable, a season, search results, a group's works).\n *\n * A paper face with the panel edge and 4px round a 2:3 cover. The cover sits\n * in a sleeve — a gloss across its top-left and a faint rim — and a sheen runs\n * across it under the pointer while the whole card lifts 4px and tips −0.6°.\n * A die-cut `sticker` (the airtime) is stuck on the cover's corner; corner\n * `badges` are square paper stickers; `coverAction` is the subscribe key in\n * the bottom-right corner.\n *\n * **Fixed height.** The title is at most two lines and always reserves two;\n * the full title rides in `title=`. The subtitle is one line, reserved even\n * when empty, and dropped on a narrow card; meta and status sit at the foot.\n * Every card in a row is the same height whatever it holds.\n *\n * **Sharp covers.** Pass `src` plus `srcSet` with real widths; the browser\n * picks from `sizes` × the device pixel ratio, so a 180px card on a 3× phone\n * wants a 540w source. `sizes` defaults to `POSTER_SIZES`, which matches\n * `PosterGrid`'s 3 / 4 / 6 / 8 columns — a card outside `PosterGrid` passes\n * its own. `posterSrcSet` builds the list from a\n * URL-per-width function. Or pass your own element (`next/image`) as `image`.\n *\n * **Links.** `href` (or `link={<Link href=\"…\" />}` for a router) makes the\n * title a link stretched over the whole card, so the card opens the show\n * while `coverAction` stays a separate control above it.\n *\n * `tone=\"next\" | \"airing\"` is the blue card (time is blue in A); `selected`\n * puts the pink ring on the open card.\n */\n\n/** `sizes` for a cover in `PosterGrid` (3 / 4 / 6 / 8 columns). */\nconst POSTER_SIZES = \"(min-width: 1440px) 176px, (min-width: 1024px) 17vw, (min-width: 600px) 25vw, 33vw\"\n\n/**\n * A `srcSet` from a URL-per-width function:\n * `posterSrcSet((w) => \\`/covers/42.webp?w=${w}\\`)` →\n * `/covers/42.webp?w=200 200w, …, /covers/42.webp?w=900 900w`.\n * The default widths cover a 3× phone and a 2× desktop wall.\n */\nfunction posterSrcSet(urlForWidth: (width: number) => string, widths: readonly number[] = [200, 300, 400, 600, 900]) {\n  return widths.map((width) => `${urlForWidth(width)} ${width}w`).join(\", \")\n}\n\ntype PosterCardProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  /** Cover URL. */\n  src?: string\n  /** Width descriptors (`… 400w, … 600w`); see `posterSrcSet`. */\n  srcSet?: string\n  /**\n   * The card's CSS width. Defaults to `POSTER_SIZES` when `srcSet` is set,\n   * which is right only inside `PosterGrid`: a card anywhere else needs its\n   * own (`\"176px\"`), or a phone picks a source too small for a 3× screen.\n   */\n  sizes?: string\n  /** The title already names the show, so the cover is decorative by default. */\n  alt?: string\n  /** Above the fold: load eagerly at high priority. */\n  priority?: boolean\n  /** Your own cover element instead of `src` (`next/image` with `fill`). */\n  image?: React.ReactNode\n  /** Under the cover: shows while it loads, and when it fails or is missing. */\n  imageFallback?: React.ReactNode\n  /** A die-cut sticker for the cover's top-left corner (the airtime). */\n  sticker?: React.ReactNode\n  /** `PosterCardBadge`s on the cover's top corners. */\n  badges?: React.ReactNode\n  /** A `PosterCardAction` in the cover's bottom-right corner. */\n  coverAction?: React.ReactNode\n  /** Anything over the whole cover (a \"no releases yet\" tag). */\n  coverOverlay?: React.ReactNode\n  title: React.ReactNode\n  /** The full title for the browser tooltip; defaults to `title` when it is a string. */\n  titleTooltip?: string\n  /** The `lang` of the title (`ja` for a Japanese name). */\n  titleLang?: string\n  /** The alternate name: one line, muted. */\n  subtitle?: React.ReactNode\n  /** One row of facts that never wraps: episode, format, score. */\n  meta?: React.ReactNode\n  /** A `PosterCardStatus` under the facts. */\n  status?: React.ReactNode\n  /** Makes the card a link (stretched from the title). */\n  href?: string\n  /** A link element for a router (`<Link href=\"…\" />`); wins over `href`. */\n  link?: React.ReactElement<{ className?: string; children?: React.ReactNode }>\n  /** `next` / `airing` paint the blue card. */\n  tone?: \"next\" | \"airing\"\n  /** The open card on a wall that expands one. */\n  selected?: boolean\n  headingLevel?: \"h2\" | \"h3\" | \"h4\"\n  titleClassName?: string\n  /** Extra rows under the foot: a command row in a back office. */\n  children?: React.ReactNode\n}\n\nfunction PosterCard({\n  src,\n  srcSet,\n  sizes,\n  alt = \"\",\n  priority = false,\n  image,\n  imageFallback,\n  sticker,\n  badges,\n  coverAction,\n  coverOverlay,\n  title,\n  titleTooltip,\n  titleLang,\n  subtitle,\n  meta,\n  status,\n  href,\n  link,\n  tone,\n  selected = false,\n  headingLevel: Heading = \"h3\",\n  className,\n  titleClassName,\n  children,\n  ...props\n}: PosterCardProps) {\n  const tooltip = titleTooltip ?? (typeof title === \"string\" ? title : undefined)\n  const stretched =\n    \"outline-hidden after:absolute after:inset-0 after:z-20 after:rounded-[14px] after:content-[''] @max-[10rem]/poster:after:rounded-[12px]\"\n  const linkedTitle = link\n    ? React.cloneElement(link, { className: cn(stretched, link.props.className), children: title })\n    : href\n      ? (\n          <a href={href} className={stretched}>\n            {title}\n          </a>\n        )\n      : title\n\n  return (\n    <div\n      data-slot=\"poster-card\"\n      data-tone={tone}\n      data-selected={selected ? \"true\" : undefined}\n      className={cn(\n        \"group/poster @container/poster relative flex h-full min-w-0 flex-col rounded-[14px] bg-card p-1 text-left text-card-foreground\",\n        \"shadow-[0_0_0_1px_var(--border),var(--anibt-shadow-panel)] transition-[translate,rotate,box-shadow,background-color] duration-300 ease-anibt-spring\",\n        \"hover:z-[2] hover:shadow-[0_0_0_1px_var(--anibt-wash-line),var(--anibt-shadow-lift)] focus-within:z-[2] motion-safe:hover:-translate-y-1 motion-safe:hover:-rotate-[0.6deg]\",\n        \"data-[tone]:bg-anibt-time-wash data-[tone]:shadow-[0_0_0_1.5px_var(--anibt-time-line),var(--anibt-shadow-panel)] data-[tone]:hover:shadow-[0_0_0_1.5px_var(--anibt-time-line),var(--anibt-shadow-lift)]\",\n        \"data-[selected=true]:shadow-[0_0_0_2px_var(--anibt-mark),var(--anibt-shadow-lift)] data-[selected=true]:hover:shadow-[0_0_0_2px_var(--anibt-mark),var(--anibt-shadow-lift)]\",\n        \"has-[[data-slot=poster-card-title]_:focus-visible]:outline-2 has-[[data-slot=poster-card-title]_:focus-visible]:outline-offset-2 has-[[data-slot=poster-card-title]_:focus-visible]:outline-ring has-[[data-slot=poster-card-title]_:focus-visible]:outline-solid\",\n        \"@max-[10rem]/poster:rounded-[12px] @max-[10rem]/poster:p-[3px]\",\n        className\n      )}\n      {...props}\n    >\n      <div data-slot=\"poster-card-art\" className=\"relative flex-none\">\n        <div\n          data-slot=\"poster-card-cover\"\n          className={cn(\n            \"group/cover @container/poster-cover relative aspect-[2/3] overflow-hidden rounded-[10px] @max-[10rem]/poster:rounded-[9px]\",\n            // The sleeve: a gloss across the top-left and a faint rim.\n            \"before:pointer-events-none before:absolute before:inset-0 before:z-[12] before:rounded-[inherit] before:bg-(image:--anibt-sleeve) before:shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--anibt-sticker-edge)_28%,transparent)] before:content-['']\",\n            // The sheen that runs across it under the pointer.\n            \"after:pointer-events-none after:absolute after:inset-0 after:z-[13] after:-translate-x-[120%] after:bg-(image:--anibt-sheen) after:content-[''] motion-safe:group-hover/poster:after:translate-x-[120%] motion-safe:group-hover/poster:after:transition-[translate] motion-safe:group-hover/poster:after:duration-700 motion-safe:group-hover/poster:after:ease-anibt-out\"\n          )}\n        >\n          {imageFallback ?? <PosterCardPlaceholder />}\n          {image ??\n            (src ? (\n              // eslint-disable-next-line @next/next/no-img-element -- a registry component: no framework image\n              <img\n                src={src}\n                srcSet={srcSet}\n                sizes={srcSet ? (sizes ?? POSTER_SIZES) : sizes}\n                alt={alt}\n                width={400}\n                height={600}\n                loading={priority ? \"eager\" : \"lazy\"}\n                fetchPriority={priority ? \"high\" : undefined}\n                decoding=\"async\"\n                className=\"absolute inset-0 block size-full object-cover\"\n              />\n            ) : null)}\n          {badges}\n          {coverOverlay}\n        </div>\n        {sticker ? (\n          <span\n            data-slot=\"poster-card-sticker\"\n            className=\"pointer-events-none absolute top-1.5 left-1.5 z-[25] flex origin-top-left [--anibt-tilt:var(--anibt-poster-tilt,-3deg)] group-hover/poster:[--anibt-tilt:calc(var(--anibt-poster-tilt,-3deg)*-1)] @max-[10rem]/poster:scale-90\"\n          >\n            {sticker}\n          </span>\n        ) : null}\n        {coverAction}\n      </div>\n\n      <div\n        data-slot=\"poster-card-body\"\n        className=\"flex min-w-0 flex-1 flex-col gap-0.5 px-1 pt-2 pb-0.5 @max-[10rem]/poster:px-[3px] @max-[10rem]/poster:pt-1.5\"\n      >\n        <Heading\n          data-slot=\"poster-card-title\"\n          title={tooltip}\n          lang={titleLang}\n          className={cn(\n            \"anibt-clamp2-fixed m-0 text-[14px] leading-[18px] font-bold text-foreground transition-colors duration-150 group-hover/poster:text-accent-foreground @max-[10rem]/poster:text-[13px] @max-[10rem]/poster:leading-[17px]\",\n            titleClassName\n          )}\n        >\n          {linkedTitle}\n        </Heading>\n        {/* Always drawn: an empty line still holds its height. */}\n        <p\n          data-slot=\"poster-card-subtitle\"\n          aria-hidden={subtitle ? undefined : true}\n          className=\"anibt-ell m-0 h-[18px] text-[12px] leading-[18px] text-muted-foreground @max-[10rem]/poster:hidden\"\n        >\n          {subtitle}\n        </p>\n        {meta || status ? (\n          <div data-slot=\"poster-card-foot\" className=\"mt-auto grid min-w-0 gap-0.5 pt-1\">\n            {meta ? (\n              <div className=\"flex min-w-0 items-center gap-1.5 overflow-hidden text-[12px] leading-[18px] whitespace-nowrap text-anibt-ink2 @max-[10rem]/poster:gap-1\">\n                {meta}\n              </div>\n            ) : null}\n            {status ? <div className=\"flex min-w-0 leading-[18px]\">{status}</div> : null}\n          </div>\n        ) : null}\n      </div>\n      {children}\n    </div>\n  )\n}\n\n/** The default cover placeholder: the sunk tone and a faint dot ground. */\nfunction PosterCardPlaceholder({ className }: { className?: string }) {\n  return (\n    <div\n      aria-hidden=\"true\"\n      data-slot=\"poster-card-placeholder\"\n      className={cn(\n        \"absolute inset-0 bg-anibt-deep2 bg-(image:--anibt-dots) bg-size-[16px_16px] bg-position-[0_0,8px_8px]\",\n        className\n      )}\n    />\n  )\n}\n\n/** A square paper sticker on a top corner of the cover: a format, a count. */\nfunction PosterCardBadge({\n  corner = \"start\",\n  className,\n  ...props\n}: React.ComponentProps<\"span\"> & { corner?: \"start\" | \"end\" }) {\n  return (\n    <span\n      data-slot=\"poster-card-badge\"\n      data-corner={corner}\n      className={cn(\n        \"absolute top-1.5 z-10 flex h-5 max-w-[calc(100%-16px)] items-center gap-[3px] overflow-hidden rounded-[6px] bg-card px-1.5 font-anibt-display text-[12px] leading-none font-semibold whitespace-nowrap text-foreground shadow-[0_0_0_2px_var(--anibt-sticker-edge),var(--anibt-shadow-sticker)] [&>svg]:size-3\",\n        corner === \"end\" ? \"right-1.5\" : \"left-1.5\",\n        // On a narrow cover two opposite badges no longer fit side by side:\n        // the end one drops under the start one.\n        corner === \"end\" &&\n          \"@max-[8.75rem]/poster-cover:group-has-[[data-corner=start]]/cover:top-8 @max-[8.75rem]/poster-cover:group-has-[[data-corner=start]]/cover:right-auto @max-[8.75rem]/poster-cover:group-has-[[data-corner=start]]/cover:left-1.5\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\n/**\n * The key in the cover's bottom-right corner (subscribe, favourite): a 28px\n * paper square drawn inside a 40px target. `pressed` is the pink wash — not a\n * candy fill, so a wall of subscribed cards never becomes a wall of buttons.\n * Give it an `aria-label`.\n */\nfunction PosterCardAction({\n  className,\n  pressed,\n  type = \"button\",\n  ...props\n}: React.ComponentProps<\"button\"> & { pressed?: boolean }) {\n  return (\n    <button\n      type={type}\n      data-slot=\"poster-card-action\"\n      aria-pressed={pressed}\n      className={cn(\n        \"absolute right-0 bottom-0 z-30 flex size-10 cursor-pointer items-center justify-center border-0 bg-transparent text-anibt-ink2 outline-hidden transition-colors duration-150\",\n        \"before:absolute before:top-1.5 before:left-1.5 before:size-7 before:rounded-[9px] before:bg-card before:shadow-[inset_0_0_0_1px_var(--input),var(--anibt-shadow-sticker)] before:transition-[background-color,box-shadow] before:duration-150 before:content-['']\",\n        \"hover:text-accent-foreground hover:before:shadow-[inset_0_0_0_1px_var(--anibt-wash-line),var(--anibt-shadow-sticker)]\",\n        \"aria-pressed:text-accent-foreground aria-pressed:before:bg-accent aria-pressed:before:shadow-[inset_0_0_0_1.5px_var(--anibt-wash-line),var(--anibt-shadow-sticker)]\",\n        \"focus-visible:before:outline-2 focus-visible:before:outline-offset-2 focus-visible:before:outline-ring focus-visible:before:outline-solid\",\n        \"[&>svg]:relative [&>svg]:size-3.5\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\n/** The 3px dot between two facts on the meta line. */\nfunction PosterCardSep({ className }: { className?: string }) {\n  return <i aria-hidden=\"true\" className={cn(\"size-[3px] flex-none rounded-full bg-muted-foreground\", className)} />\n}\n\n/** A score with A's pink star; `null` is an unrated show (a muted dash). */\nfunction PosterCardRating({ value, className, label = \"评分\" }: { value: number | null; className?: string; label?: string }) {\n  const none = value === null\n  return (\n    <span\n      data-slot=\"poster-card-rating\"\n      aria-label={none ? `${label}：暂无` : `${label} ${value.toFixed(1)}`}\n      className={cn(\n        \"inline-flex flex-none items-center gap-0.5 font-anibt-display font-medium tabular-nums\",\n        none ? \"text-muted-foreground\" : \"text-anibt-ink2\",\n        className\n      )}\n    >\n      <StarIcon aria-hidden=\"true\" className={cn(\"size-3 fill-current\", none ? \"text-muted-foreground\" : \"text-anibt-mark\")} />\n      {none ? \"–\" : value.toFixed(1)}\n    </span>\n  )\n}\n\n/**\n * The status line: a dot and a word. `ok` mint (seeded), `run` / `next` blue\n * with a blinking dot (airing, next up), `wait` ink, `none` a hollow muted\n * dot. The word is required — the colour never speaks alone.\n */\nfunction PosterCardStatus({\n  tone = \"wait\",\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"span\"> & { tone?: \"ok\" | \"run\" | \"next\" | \"wait\" | \"none\" }) {\n  return (\n    <span\n      data-slot=\"poster-card-status\"\n      data-tone={tone}\n      className={cn(\n        \"inline-flex min-w-0 items-center gap-1 text-[12px] leading-[18px] font-semibold whitespace-nowrap\",\n        \"before:size-1.5 before:flex-none before:rounded-full before:bg-current before:content-['']\",\n        tone === \"ok\" && \"text-anibt-ok\",\n        (tone === \"run\" || tone === \"next\") && \"text-anibt-time-ink before:animate-anibt-blink\",\n        tone === \"wait\" && \"text-anibt-ink2\",\n        tone === \"none\" && \"text-muted-foreground before:bg-transparent before:shadow-[inset_0_0_0_1.5px_currentColor]\",\n        className\n      )}\n      {...props}\n    >\n      {/* The word ellipsises on a narrow card; the dot never does. */}\n      <span className=\"anibt-ell\">{children}</span>\n    </span>\n  )\n}\n\n/** A card-shaped placeholder of the same height, for a loading wall. */\nfunction PosterCardSkeleton({ className, meta = true }: { className?: string; meta?: boolean }) {\n  return (\n    <div\n      data-slot=\"poster-card-skeleton\"\n      aria-hidden=\"true\"\n      className={cn(\n        \"@container/poster flex h-full min-w-0 flex-col rounded-[14px] bg-card p-1 shadow-[0_0_0_1px_var(--border),var(--anibt-shadow-panel)] @max-[10rem]/poster:p-[3px]\",\n        className\n      )}\n    >\n      <div className=\"aspect-[2/3] animate-pulse rounded-[10px] bg-anibt-deep2 motion-reduce:animate-none\" />\n      <div className=\"flex flex-col gap-1.5 px-1 pt-2.5 pb-1\">\n        <div className=\"h-3 w-11/12 animate-pulse rounded-[6px] bg-anibt-deep2 motion-reduce:animate-none\" />\n        <div className=\"h-3 w-2/3 animate-pulse rounded-[6px] bg-anibt-deep2 motion-reduce:animate-none\" />\n        {meta ? <div className=\"mt-2 h-2.5 w-1/2 animate-pulse rounded-[6px] bg-anibt-deep2 motion-reduce:animate-none\" /> : null}\n      </div>\n    </div>\n  )\n}\n\n/**\n * The poster wall: 3 columns under 600px of its own width, 4 from 600, 6 from\n * 960, 8 from 1360 (a container query — the wall counts its own width, not\n * the window's). Equal 1fr tracks; a short row stays on the left. It also\n * gives each card's sticker its tilt, so a row reads as stuck on by hand.\n */\nfunction PosterGrid({ className, children, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div data-slot=\"poster-grid\" className=\"@container/poster-grid w-full min-w-0\">\n      <div\n        className={cn(\n          \"grid grid-cols-3 gap-2 @min-[37.5rem]/poster-grid:grid-cols-4 @min-[37.5rem]/poster-grid:gap-3 @min-[60rem]/poster-grid:grid-cols-6 @min-[60rem]/poster-grid:gap-4 @min-[85rem]/poster-grid:grid-cols-8\",\n          \"[&>:nth-child(4n+1)]:[--anibt-poster-tilt:-4deg] [&>:nth-child(4n+2)]:[--anibt-poster-tilt:3deg] [&>:nth-child(4n+3)]:[--anibt-poster-tilt:-2deg] [&>:nth-child(4n+4)]:[--anibt-poster-tilt:4deg]\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n      </div>\n    </div>\n  )\n}\n\nexport {\n  PosterCard,\n  PosterCardAction,\n  PosterCardBadge,\n  PosterCardPlaceholder,\n  PosterCardRating,\n  PosterCardSep,\n  PosterCardSkeleton,\n  PosterCardStatus,\n  PosterGrid,\n  POSTER_SIZES,\n  posterSrcSet,\n}\nexport type { PosterCardProps }\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "blocks",
    "display"
  ],
  "type": "registry:block"
}