Takazudo Modular Styleguide

MDX/LinkCards

Default
English
Single Link

Component Source

link-cards.tsx

/**
 * LinkCards — rich internal-link nav cards for MDX authoring (#1225, Wave 2 of
 * epic #1223).
 *
 * Authors write `<LinkCards links={[{ href, label }, ...]} />` and each entry
 * is resolved via `resolveLinkTarget` (#1224, Wave 1) into a full card: square
 * thumb on the left (photo / SVG glyph / NewsThumb / empty frame), badge +
 * meta + date + title vertically centered against it, and an optional
 * description. An unresolvable href (`resolveLinkTarget` returns `null`)
 * degrades to a plain `<a>` using the authored `label` — the graceful path for
 * explicit hand-authoring; the Wave 3 auto-convert path never feeds this
 * component an unresolvable set.
 *
 * Layout/CSS matches the locked prototype `_temp-resource/1223-link-nav/polished-c.html`
 * 1:1 (grid tokens, breakpoints `md`=740px/`lg`=980px from
 * `sub-packages/design-system/theme.css`, `zd-invert-color-link` hover). Two
 * load-bearing details ported from the prototype:
 *   - The divider border sits on the TEXT side only (`.tx`'s `border-t`), never
 *     spanning the thumb column — so it doesn't visually read as an `<h2>` rule
 *     (h2's border-top is full-width + an 8px bold segment; see the prototype's
 *     own `.article h2::before` comment).
 *   - The description column position flips at `lg` (980px): below the thumb+
 *     title row (full width) under `lg`, then moves to sit under the title
 *     inside the text column (indented) at `lg`+. This is the prototype's
 *     `.textside { display: contents }` → `lg:block` flip: at narrow widths the
 *     text-side wrapper "disappears" so its `tx`/description children become
 *     direct siblings of the thumb in the outer grid (description spans full
 *     width on row 2); at `lg`+ the wrapper becomes a real block box pinned to
 *     the text column, so its children stack normally inside it.
 *
 * SSR-safe: no hooks, bare `<a>` elements only.
 */
import type { ComponentType, FunctionComponent } from 'preact';

import { resolveLinkTarget } from '@/lib/link-nav/resolve-link-target';
import type { LinkNavSvgKey, ResolvedLinkTarget, ThumbSpec } from '@/lib/link-nav/types';
import { ResponsiveImage, RESPONSIVE_IMAGE_COVER_CLASS } from '@/components/responsive-image';
import { isDirectImagePath } from '@/src/lib/page-helpers';
import NewsThumb from '@/src/components/shared/news-thumb';
import CategoryBadge from '@/src/components/shared/category-badge';
import { getCuratedTagByKey } from '@/src/lib/home/curated-tags';
import AboutThumb from '@/components/shared/about-thumb';
import ContactThumb from '@/components/shared/contact-thumb';
import SupportThumb from '@/components/shared/support-thumb';
import ManualsThumb from '@/components/shared/manuals-thumb';
import AirpayThumb from '@/components/shared/airpay-thumb';
import WarrantyThumb from '@/components/shared/warranty-thumb';
import TokushohoThumb from '@/components/shared/tokushoho-thumb';
import type { Locale } from '@/lib/i18n/types';

export interface LinkCardsLink {
  href: string;
  label: string;
}

export interface LinkCardsProps {
  links: LinkCardsLink[];
  locale?: Locale;
}

/**
 * SVG glyph key → thumb component. Mirrors `getStandaloneThumb`
 * (`src/components/shared/standalone-thumb.tsx`) plus the three built-page
 * glyphs (`contact` / `support` / `manuals`) that resolver has no
 * content-collection page for, so they aren't in `getStandaloneThumb`'s switch.
 * `reservation` intentionally shares the `about` glyph — no dedicated glyph by
 * design, same as `getStandaloneThumb`.
 */
const SVG_THUMB_COMPONENTS: Record<LinkNavSvgKey, ComponentType> = {
  about: AboutThumb,
  contact: ContactThumb,
  support: SupportThumb,
  manuals: ManualsThumb,
  airpay: AirpayThumb,
  warranty: WarrantyThumb,
  tokushoho: TokushohoThumb,
  reservation: AboutThumb,
};

/** Square thumb, left column. Photo thumbs get the frame here; SVG/NewsThumb already carry their own. */
function renderThumb(thumb: ThumbSpec) {
  switch (thumb.kind) {
    case 'image': {
      // A resolver "image" slug is usually an image-pipeline slug, but standalone
      // pages can carry a direct asset path (e.g. /s/discord/ → /svgs/discord-thumb.svg).
      // Direct paths MUST render as a plain <img> — feeding one to ResponsiveImage
      // 404s on /images/p/<path>/1200w.webp (epic #1223 review).
      const inner = isDirectImagePath(thumb.slug) ? (
        <img
          src={thumb.slug}
          alt=""
          loading="lazy"
          decoding="async"
          className={RESPONSIVE_IMAGE_COVER_CLASS}
        />
      ) : (
        <ResponsiveImage
          slug={thumb.slug}
          alt=""
          className={RESPONSIVE_IMAGE_COVER_CLASS}
          sizes="(max-width: 740px) 100px, 140px"
        />
      );
      return (
        <div className="aspect-square overflow-hidden border border-zd-white group-hover:border-zd-literal-black group-focus:border-zd-literal-black">
          {inner}
        </div>
      );
    }
    case 'svg': {
      const SvgThumb = SVG_THUMB_COMPONENTS[thumb.key];
      // Defensive: an unmapped key (a future getStandaloneThumb glyph not added
      // to SVG_THUMB_COMPONENTS) would otherwise render <undefined/>. Fall back
      // to an empty framed square (epic #1223 review).
      if (!SvgThumb) {
        return <div className="aspect-square overflow-hidden border border-zd-white" />;
      }
      return <SvgThumb />;
    }
    case 'news':
      return <NewsThumb volume={thumb.volume} type="list" />;
    case 'none':
      return <div className="aspect-square overflow-hidden border border-zd-white" />;
  }
}

interface LinkCardProps {
  target: ResolvedLinkTarget;
  locale: Locale;
}

/** One resolved rich nav card — thumb left, badge/meta/date/title + description right. */
const LinkCard: FunctionComponent<LinkCardProps> = ({ target, locale }) => {
  const isEn = locale === 'en';
  const curatedTag = target.curatedTagKey ? getCuratedTagByKey(target.curatedTagKey) : undefined;
  const hasMetaRow = !!(curatedTag || target.meta || target.createdDate);

  return (
    <a href={target.href} aria-label={target.title} className="group block zd-invert-color-link">
      <div className="grid grid-cols-[100px_1fr] md:grid-cols-[140px_1fr] gap-x-hgap-sm items-start">
        <div className="col-start-1 row-start-1 self-start">{renderThumb(target.thumb)}</div>

        {/* display:contents at narrow (children join the outer grid directly);
            becomes a normal block box pinned to the text column at lg+. */}
        <div className="contents lg:block lg:col-start-2 lg:row-start-1 lg:min-w-0">
          <div className="col-start-2 row-start-1 min-w-0 self-stretch border-t border-zd-white pt-vgap-sm flex flex-col justify-center">
            {hasMetaRow && (
              <div className="flex flex-wrap items-center gap-hgap-xs pb-vgap-2xs">
                {curatedTag && <CategoryBadge tag={curatedTag} locale={locale} size="sm" />}
                {target.meta && (
                  <span className="font-futura font-thin text-xs">{target.meta}</span>
                )}
                {target.createdDate && (
                  <span className="font-futura font-thin text-xs">
                    {isEn ? `Created: ${target.createdDate}` : `作成: ${target.createdDate}`}
                  </span>
                )}
              </div>
            )}
            <span className="font-futura text-base md:text-lg underline decoration-2 underline-offset-4 line-clamp-2">
              {target.title}
            </span>
          </div>
          {target.description && (
            <p className="col-span-full row-start-2 pt-vgap-xs font-thin text-xs md:text-sm leading-snug line-clamp-2 lg:line-clamp-3">
              {target.description}
            </p>
          )}
        </div>
      </div>
    </a>
  );
};

/**
 * Renders a stack of rich internal-link nav cards from `{ href, label }` pairs.
 * Each `href` is resolved through `resolveLinkTarget`; an unresolvable href
 * falls back to a plain link using the authored `label`.
 */
export const LinkCards: FunctionComponent<LinkCardsProps> = ({ links, locale = 'ja' }) => {
  const navLabel = locale === 'en' ? 'Related links' : '関連リンク';
  return (
    <nav className="grid gap-y-vgap-md max-w-[720px] mb-vgap-lg" aria-label={navLabel}>
      {links.map((link) => {
        const target = resolveLinkTarget(link.href, locale);
        if (!target) {
          return (
            <a key={link.href} href={link.href} className="zd-invert-color-link">
              {link.label}
            </a>
          );
        }
        return <LinkCard key={link.href} target={target} locale={locale} />;
      })}
    </nav>
  );
};

export default LinkCards;

Story Source

link-cards.stories.tsx

import { LinkCards } from './link-cards';

/**
 * LinkCards
 *
 * Rich internal-link nav cards, auto-derived from real hrefs via
 * `resolveLinkTarget` (#1224). Covers all thumb kinds against real site
 * content: `image` (product/guide/series/note), `svg` (built page + `/s/*`),
 * and `news` (highlights `news_no` fallback, no hero image). The last entry
 * is a deliberately unresolvable href to demonstrate the plain-link fallback.
 */
export const meta = {
  title: 'MDX/LinkCards',
};

const SAMPLE_LINKS = [
  // image thumb, no badge/date (product — resolver omits both for products)
  { href: '/products/oxi-coral-intro/', label: 'OXI Instruments: OXI Coral' },
  // image thumb + guide badge + date
  { href: '/guides/oxi-coral-guide-ep1/', label: 'OXI Coral 解説 EP.1' },
  // image thumb + guide badge + date + "全N回" meta
  { href: '/guides/series/oxi-coral-guide/', label: 'OXI Coralガイド' },
  // image thumb + news badge + date (notes collection)
  { href: '/notes/s32-announce/', label: 'Notes008: SHIK S32 発表!' },
  // news (NewsThumb) thumb + highlights badge + date — imgThumb: null, news_no set
  { href: '/notes/news-vol-16/', label: 'Highlights vol.16' },
  // svg thumb (built page, no content-collection backing) — no badge/date/description
  { href: '/contact/', label: 'お問い合わせ' },
  // svg thumb (/s/* dedicated glyph) + description, no badge/date
  { href: '/s/about/', label: 'About: Takazudo Modularについて' },
  // unresolvable — falls back to a plain link using the authored label
  { href: '/this-page-does-not-exist/', label: 'Example: unresolved fallback link' },
];

/** Japanese (default) */
export const Default = () => <LinkCards links={SAMPLE_LINKS} locale="ja" />;

/** English */
export const English = () => <LinkCards links={SAMPLE_LINKS} locale="en" />;

/** Single card — the most common authoring shape (one `- [text](/path/)` line) */
export const SingleLink = () => (
  <LinkCards
    links={[{ href: '/guides/series/oxi-coral-guide/', label: 'OXI Coralガイド' }]}
    locale="ja"
  />
);