Lesson 02
Portfolio block
Add a portfolio block to Rayleigh: galleries the CMS edits, rendered by a Nuxt component, with a full-screen slider that needs no library.

The block
A block is one shape of content a page can hold. The portfolio block is a title and a gallery, a repeater of images with captions:
import { defineBlock, field } from 'ohnejs';
export default defineBlock({
description: 'A grid of images that open full screen on a click',
fields: {
title: field('text'),
// A repeater is a list of items that share one shape, here an image and its caption.
gallery: field('repeater', {
allowEmpty: false,
fields: {
// The slider shows the image large, so a small one is refused at upload.
image: field('image', { minWidth: 1024, minHeight: 1024 }),
caption: field('text', { nullable: true }),
},
layout: [{ row: ['image | 16rem', 'caption'] }],
}),
},
dashboard: { icon: 'layout-grid' },
});Let Pages hold it:
// The page's content, as a list of blocks. Each name here is a file in `blocks/`.
content: field('blocks', { allow: ['Hero', 'Prose', 'Portfolio'] }),The content
The seed fills an empty site with the demo content. It now uploads the gallery images to the media library, as an editor would, and builds the home page from them. See Boot files and Uploads.
Copy the images and their credits from this lesson's tag:
curl -sL https://github.com/ohnejs/rayleigh-nuxt/archive/refs/tags/lesson-02-portfolio-block.tar.gz \
| tar -xzf - --strip-components=1 \
rayleigh-nuxt-lesson-02-portfolio-block/apps/backend/seed/images \
rayleigh-nuxt-lesson-02-portfolio-block/apps/backend/seed/CREDITS.mdThe captions live beside them:
// The demo copy of Rayleigh, which `boot/seed.ts` puts into the CMS.
/**
* The two galleries of the home page, each image with its caption.
*/
export const galleries = [
{
title: 'Interior design',
directory: 'interior',
items: [
['modern-armchair', 'Modern armchair'],
['contemporary-coffee-table', 'Contemporary coffee table'],
['sleek-lamp', 'Sleek lamp'],
['elegant-vase', 'Elegant vase'],
['simple-bookshelf', 'Simple bookshelf'],
['stylish-sofa', 'Stylish sofa'],
],
},
{
title: 'Exterior design',
directory: 'exterior',
items: [
['luna-bench', 'Luna bench'],
['aurora-lamp', 'Aurora lamp'],
['serenity-fountain', 'Serenity fountain'],
['zephyr-sculpture', 'Zephyr sculpture'],
['harmony-seating', 'Harmony seating'],
['mystic-path', 'Mystic path'],
],
},
] as const;The seed replaces the starter's home page with a hero and both galleries:
import { openAsBlob } from 'node:fs';
import { hook, query, useEnv } from 'ohnejs';
import { putUpload, updateUpload } from 'ohnejs/uploads';
import { galleries } from '../seed/content.ts';
/**
* Uploads one image from `seed/images/` to the media library, as an editor would, and gives it its alt text.
* Returns the upload's `UUID`, which an image field stores.
*/
async function upload(path: string, description: string): Promise<string> {
const [directory, name] = path.includes('/') ? path.split('/') : ['', path];
const file = await openAsBlob(new URL(`../seed/images/${path}`, import.meta.url));
const { UUID } = await putUpload({ directory, name, body: file.stream(), size: file.size });
await updateUpload(UUID, { description });
return UUID;
}
// Boot files run before the schema sync creates the tables, so the seed waits for `schema:synced`.
// It fires on every start. See https://ohne.dev/docs/project/boot
hook('schema:synced', async () => {
if (useEnv().get('NODE_ENV') === 'production') return;
// Only an empty site gets the demo, so the seed never touches your edits.
if (await query('Pages').exists()) return;
const portfolio = [];
for (const { title, directory, items } of galleries) {
const gallery = [];
for (const [name, caption] of items) {
gallery.push({ image: await upload(`${directory}/${name}.webp`, caption), caption });
}
portfolio.push({ block: 'Portfolio' as const, fields: { title, gallery } });
}
await query('Pages').createOrThrow({
title: 'Homepage',
// `index` is the slug of the home page, shown at `/`.
slug: 'index',
status: 'published',
content: [
{
block: 'Hero',
fields: {
heading: 'Welcome to Rayleigh',
lead: [
{
kind: 'paragraph',
content: [{ text: 'A minimalistic portfolio template made with ohne.' }],
},
],
},
},
...portfolio,
],
});
// `Site` comes with the CMS: the site's name, which every page title ends with.
await query('Site').update({
name: 'Rayleigh',
description: 'Rayleigh is a minimalistic portfolio template made with ohne.',
});
});The seed runs only on an empty site. Stop pnpm dev, delete the data and start again:
rm -rf apps/backend/.data apps/backend/.uploads
pnpm devThe empty site asks for an admin account again. Create it in the dashboard.
The component
The component shows the gallery and opens the slider on a click:
<script setup lang="ts">
import type { Blocks } from '~~/ohne';
import type { Slide } from '../Slider.vue';
const props = defineProps<{ fields: Blocks['Portfolio'] }>();
// An image field may be empty, so the gallery shows only the items that have one.
const slides = computed(() =>
props.fields.gallery.filter((item): item is Slide => item.image !== null),
);
// The position of the image the slider shows, or `null` while it is closed.
const open = ref<number | null>(null);
let opener: HTMLElement | null = null;
function show(index: number, event: MouseEvent) {
opener = event.currentTarget as HTMLElement;
open.value = index;
}
// Focus returns to the image that opened the slider, so a keyboard visitor keeps their place.
function close() {
open.value = null;
opener?.focus();
}
</script>
<template>
<Container class="portfolio">
<h2 class="portfolio__title">{{ fields.title }}</h2>
<div class="portfolio__grid">
<button
v-for="(item, index) in slides"
:key="item.UUID"
type="button"
class="portfolio__item"
:aria-label="item.caption ? `Open ${item.caption}` : 'Open image'"
@click="show(index, $event)"
>
<!-- An upload carries its own size, so the grid keeps its shape while the image loads. -->
<img
:src="item.image.url"
:width="item.image.width ?? undefined"
:height="item.image.height ?? undefined"
:alt="item.image.description ?? ''"
loading="lazy"
decoding="async"
/>
<span class="portfolio__overlay">
<span v-if="item.caption" class="portfolio__caption">{{ item.caption }}</span>
<IconSearch />
</span>
</button>
</div>
<!-- `Lazy` loads the slider's code only when a visitor first opens it. -->
<LazySlider v-if="open !== null" :slides="slides" :start="open" @close="close" />
</Container>
</template>Styles and icons
The grid, the slider and the icons are plain CSS and SVG. Copy them:
curl -sL https://github.com/ohnejs/rayleigh-nuxt/archive/refs/tags/lesson-02-portfolio-block.tar.gz \
| tar -xzf - --strip-components=1 \
rayleigh-nuxt-lesson-02-portfolio-block/apps/web/app/assets/styles \
rayleigh-nuxt-lesson-02-portfolio-block/apps/web/app/components/icon/* Rayleigh's styles, in plain CSS. The order matters: tokens first, then the rules that use them. */
@import './fonts.css';
@import './tokens.css';
@import './base.css';
@import './layout.css';
@import './components.css';
@import './blocks.css';<script lang="ts">
import type { Blocks, Upload } from '~~/ohne';
/**
* A gallery item that has an image.
*/
export type Slide = Blocks['Portfolio']['gallery'][number] & { image: Upload };
</script>
<script setup lang="ts">
// A full-screen gallery on the browser's own `<dialog>`, which brings the backdrop, the focus trap and Esc.
// The slides sit side by side in a scroll-snapping track, so touch swipes work with no script.
const props = defineProps<{ slides: Slide[]; start: number }>();
// Esc closes a dialog natively, so its `close` event covers every way it closes.
const emit = defineEmits<{ close: [] }>();
const dialog = useTemplateRef('dialog');
const track = useTemplateRef('track');
const index = ref(props.start);
const imageWidth = ref(0);
function go(to: number, behavior: ScrollBehavior = 'smooth') {
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamped = Math.min(Math.max(to, 0), props.slides.length - 1);
track.value?.scrollTo({
left: clamped * track.value.clientWidth,
behavior: reduce ? 'instant' : behavior,
});
}
function onScroll() {
if (track.value) index.value = Math.round(track.value.scrollLeft / track.value.clientWidth);
}
// The arrows sit beside the image, so they need its width, which changes with each image and the window.
function measure() {
imageWidth.value = track.value?.children[index.value]?.querySelector('img')?.offsetWidth ?? 0;
}
function onKeydown(event: KeyboardEvent) {
const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
if (!step) return;
event.preventDefault();
go(index.value + step);
}
// A click beside the image, its caption and the buttons closes the slider, like a backdrop click.
function onClick(event: MouseEvent) {
if (!(event.target as Element).closest('img, figcaption, button')) dialog.value?.close();
}
function onResize() {
go(index.value, 'instant');
measure();
}
watch(index, measure);
onMounted(() => {
// `showModal` moves focus into the dialog and makes the page behind it inert.
dialog.value?.showModal();
go(props.start, 'instant');
measure();
addEventListener('resize', onResize);
// A disabled arrow drops the focus out of the dialog, so the window listens for the arrow keys.
addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
removeEventListener('resize', onResize);
removeEventListener('keydown', onKeydown);
});
</script>
<template>
<dialog
ref="dialog"
class="slider"
aria-label="Gallery"
:style="{ '--image-width': `${imageWidth}px` }"
@close="emit('close')"
@click="onClick"
>
<button type="button" class="slider__close" aria-label="Close" @click="dialog?.close()">
<IconX />
</button>
<div ref="track" class="slider__track" @scroll="onScroll">
<figure v-for="(slide, i) in slides" :key="slide.UUID" class="slider__slide">
<img
class="slider__image"
:src="slide.image.url"
:width="slide.image.width ?? undefined"
:height="slide.image.height ?? undefined"
:alt="slide.image.description ?? ''"
loading="lazy"
decoding="async"
@load="i === index && measure()"
/>
<figcaption v-if="slide.caption" class="slider__caption">{{ slide.caption }}</figcaption>
</figure>
</div>
<button
type="button"
class="slider__arrow slider__arrow--prev"
aria-label="Previous image"
:disabled="index === 0"
@click="go(index - 1)"
>
<IconChevronLeft />
</button>
<button
type="button"
class="slider__arrow slider__arrow--next"
aria-label="Next image"
:disabled="index === slides.length - 1"
@click="go(index + 1)"
>
<IconChevronRight />
</button>
</dialog>
</template>Click an image and the slider opens on it. The arrows, the arrow keys and a swipe move it. Esc or a click beside the image closes it.

Edit a caption in the dashboard and watch the preview follow. The next lesson, Header and footer, moves the logo and the menu into the CMS.
Checkpoint
The code at this lesson
Every lesson ends at a tag. Clone it to start from here, or compare it with yours.
git clone --branch lesson-02-portfolio-block https://github.com/ohnejs/rayleigh-nuxt rayleigh