MDX/LinkCards
Default
English
Single Link
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;
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"
/>
);