Image, from the Building blocks group of the nertia component library.
npx shadcn@latest add https://ner.sh/r/block-media.jsonThe recipe carries this block's layout and variant; the install brings the component.
{
"registries": {
"@nertia": "https://ner.sh/r/{name}.json"
}
}npx shadcn@latest add @nertia/block-medianpm create nersh@latest my-site -- --site https://nertia.ai/components/building-blocks/element-block-media/site.json scaffolds a project from it.{
"slug": "element-block-media",
"mode": "builder",
"status": "draft",
"skinId": "zinc",
"composition": {
"id": "element-block-media",
"sections": [
{
"id": "block-media",
"instanceId": "stage-block-media"
}
]
},
"copy": {
"stage-block-media.image": "",
"stage-block-media.alt": "One sentence on who this is for and why it matters.",
"stage-block-media.caption": "One sentence on who this is for and why it matters.",
"stage-block-media.frame": "",
"stage-block-media.initials": "",
"stage-block-media.href": "#",
"stage-block-media.lightbox": "",
"stage-block-media.badge": "Trust point five"
}
}
import type { Section } from '../types';
import { Component } from './Component';
import { schema } from './schema';
import { meta } from './meta';
import { writeCopy } from './writeCopy';
export const blockMedia: Section = { meta, schema, Component, writeCopy };
import type { ReactNode } from 'react';
import type { SectionProps } from '../types';
import { Editable } from '@/components/site/EditableText';
import { compositionEditPath } from '@/lib/composition/compositionEditableCopy';
import { Lightbox, MediaPlaceholder } from '@/components/foundation';
import type { MediaPlaceholderAspect } from '@/lib/foundation/contract';
import { cn } from '@/lib/utils';
import { imageSlotProps } from '@/lib/composition/imageSlots';
const FRAME_CLASS: Record<string, string> = {
free: 'site-media-frame--free aspect-[4/3] w-full max-w-full',
hero: 'site-media-frame--hero site-media-frame--scrim w-full max-w-full',
tile: 'site-media-frame--tile w-full max-w-full',
avatar: 'site-media-frame--avatar site-media-frame--avatar-lg mx-auto',
// A brand mark: natural aspect, constrained, centred, no card chrome. Exists for a
// hero that stores its own badge (a circular mark above the copy).
badge: 'site-media-frame--badge',
panel: 'site-media-frame--panel h-full w-full',
};
// The placeholder takes each frame's own shape; a panel fills its column, so it covers.
const PLACEHOLDER_ASPECT: Record<string, MediaPlaceholderAspect> = {
free: '4/3',
hero: '16/9',
tile: '1/1',
avatar: '1/1',
badge: '1/1',
panel: 'cover',
};
/** With an `href`, whatever the frame draws becomes one link; the frame itself is unchanged. */
function MediaLink({ href, children }: { href: string; children: ReactNode }) {
if (!href) return <>{children}</>;
return (
<a href={href} data-media-link="" className="block h-full w-full">
{children}
</a>
);
}
export function Component({ copy, instanceId, builderInsert, editorMarkers }: SectionProps) {
const edit = (slotKey: string) => compositionEditPath(instanceId, slotKey);
const image = copy.image ?? '';
const alt = copy.alt || '';
const badge = (copy.badge ?? '').trim();
const frame = (copy.frame ?? 'free').trim().toLowerCase();
const frameClass = FRAME_CLASS[frame] ?? FRAME_CLASS.free;
const placeholderAspect = PLACEHOLDER_ASPECT[frame] ?? PLACEHOLDER_ASPECT.free;
const href = (copy.href ?? '').trim();
// A link wins: an image that goes somewhere does not also open in place.
const lightbox = !href && Boolean(image) && (copy.lightbox ?? '').trim().toLowerCase() === 'on';
const caption = (copy.caption ?? '').trim();
// A caption makes the block a figure, so the line under the frame is its figcaption.
const Block = caption ? 'figure' : 'div';
// An avatar with no photo draws its initials in place of the placeholder; other frames ignore them.
const initials = !image && frame === 'avatar' ? (copy.initials ?? '').trim() : '';
return (
<Block
data-section="block-media"
className={cn(
'site-block-media',
frame === 'avatar' && (caption ? 'flex flex-col items-center' : 'flex justify-center'),
)}
>
<div className={cn('site-media-frame relative', frameClass)} {...imageSlotProps(editorMarkers, instanceId, 'image')}>
{badge ? (
/* The image callout pill (NEW IN): accent-filled, riding the frame's
* corner so it works on any artwork. Blank renders nothing. */
<span className="site-media-badge site-type-xs absolute right-3 top-3 z-10 rounded-full bg-[var(--site-paper)] px-2.5 py-1 text-[var(--site-ink)]">
<Editable path={edit('badge')!}>{badge}</Editable>
</span>
) : null}
{image ? (
<MediaLink href={href}>
{lightbox ? (
<Lightbox src={image} alt={alt} className="h-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image} alt={alt} className="site-media-img" data-edit-path={edit('alt')} />
</Lightbox>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt={alt} className="site-media-img" data-edit-path={edit('alt')} />
)}
</MediaLink>
) : initials ? (
<>
<MediaLink href={href}>
<span
data-media-initials=""
aria-hidden
className="site-type-title flex h-full w-full select-none items-center justify-center rounded-full bg-[var(--accent)] text-[var(--accent-fg,var(--background))]"
>
<Editable path={edit('initials')!}>{initials}</Editable>
</span>
</MediaLink>
<span className="sr-only">
<Editable path={edit('alt')!}>{alt}</Editable>
</span>
</>
) : (
<>
{/* A blank slot draws the placeholder for every viewer; the builder labels it. */}
<MediaLink href={href}>
<MediaPlaceholder aspect={placeholderAspect} />
</MediaLink>
<span className="sr-only">
<Editable path={edit('alt')!}>{alt}</Editable>
</span>
{builderInsert ? (
<span className="site-type-xs absolute inset-x-0 bottom-2 text-center text-muted-foreground">
Add image
</span>
) : null}
</>
)}
</div>
{caption ? (
<figcaption className="site-type-sm mt-2 text-[var(--muted-fg)]">
<Editable path={edit('caption')!}>{caption}</Editable>
</figcaption>
) : null}
</Block>
);
}
import type { SectionCopySchemaField } from '../types';
export const schema: SectionCopySchemaField[] = [
{ key: 'image', label: 'Image', type: 'image', placeholder: 'Image URL' },
{ key: 'alt', label: 'Alt text', type: 'text', placeholder: 'Describe the image' },
{
key: 'caption',
label: 'Caption',
type: 'text',
placeholder: 'A line under the image',
maxLength: 80,
helpText: 'A short line shown under the image. Blank shows nothing.',
},
{
key: 'frame',
label: 'Frame',
type: 'text',
placeholder: 'free | hero | tile | avatar',
helpText: 'Free keeps the image at a wide 4:3 shape, Tile crops it to a square, and Avatar crops it to a circle. Hero, Badge and Panel size it to match those layouts.',
advanced: true,
options: [
{ value: '', label: 'Free' },
{ value: 'hero', label: 'Hero' },
{ value: 'tile', label: 'Tile' },
{ value: 'avatar', label: 'Avatar' },
{ value: 'badge', label: 'Badge' },
{ value: 'panel', label: 'Panel' },
],
},
{
key: 'initials',
label: 'Initials',
type: 'text',
placeholder: 'SC',
maxLength: 3,
helpText: 'Drawn in a circle when an Avatar frame has no image. Blank shows the placeholder.',
},
{
key: 'href',
label: 'Link',
type: 'link',
placeholder: 'https://',
helpText: 'Makes the image a link. Blank leaves it unlinked.',
},
{
key: 'lightbox',
label: 'Lightbox',
type: 'text',
placeholder: '',
maxLength: 2,
advanced: true,
helpText: 'On opens the image full size when a visitor clicks it. Off keeps it in place. A link, when one is set, wins.',
options: [
{ value: '', label: 'Off' },
{ value: 'on', label: 'On' },
],
},
{
key: 'badge',
label: 'Badge (optional)',
type: 'text',
placeholder: 'New in',
maxLength: 20,
},
];
import type { SectionMeta } from '../types';
export const meta: SectionMeta = {
id: 'block-media',
displayName: 'Media frame',
family: 'component',
tags: ['component', 'media', 'image'],
};
import type { BrandContext } from '@/lib/brandContext';
export function writeCopy(_ctx: BrandContext) {
return { image: '', alt: '' };
}