Recipe: Gallery
A gallery of one board: the board's title and description on top, each sub-board as a section in its order on the board, every image in justified rows that keep its shape, and a full-screen viewer with arrow keys, swipe and shareable links. Videos play in the viewer. Light and dark themes follow the visitor's system, with a toggle.
It is the default starting point for any app that shows a board: a portfolio, a lookbook, event photos, a catalogue. The other recipes cover uploads against a brief and review with approvals.
When to use it
- The visitor looks; nobody uploads or decides. Access is usually
public, orpasswordfor a client. - The board is already arranged the way it should read: sub-boards in order, with titles and descriptions.
How the board drives it
| On the board | In the app |
|---|---|
| The board's title and description | The heading and the line under it. |
| A sub-board | A section, with the sub-board's title and description, in its sort order. Sub-boards of sub-boards follow their parent. |
| Assets on the board itself | The first section, without a heading. |
| An asset's title | The image's alt text and its caption in the viewer. |
| A video | A tile marked Video; it plays in the viewer once Playbook has encoded it. |
| A group | Left out. Read its members with assets/{token}/children if the app should show them. |
Nothing about the content is written into the files, so the team rearranges and retitles the board and the app follows within five minutes, with no redeploy.
The files
index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gallery</title>
<script>
// The visitor's theme, set before the page paints.
try { const theme = localStorage.getItem(`theme:${location.pathname}`); if (theme) document.documentElement.dataset.theme = theme } catch {}
</script>
<link rel="icon" href="data:," />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Inter:wght@400;500;600&display=swap" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="head">
<button type="button" class="theme" id="theme" aria-label="Switch between light and dark"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M12 4a8 8 0 0 1 0 16z" fill="currentColor" /></svg></button>
<h1 id="title"> </h1>
<p class="intro" id="intro"></p>
<nav class="sections" id="sections" aria-label="Sections" hidden></nav>
</header>
<main class="main" id="main"></main>
<p class="notice" id="notice" hidden>The gallery did not load. Reload the page to try again.</p>
<dialog class="viewer" id="viewer" aria-label="Image">
<div class="stage" id="stage"></div>
<div class="bar">
<p class="caption"><span id="v-title"></span> <span class="count" id="v-count"></span></p>
<div class="controls">
<button type="button" id="v-prev" aria-label="Previous"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7" /></svg></button>
<button type="button" id="v-next" aria-label="Next"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7" /></svg></button>
<button type="button" id="v-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg></button>
</div>
</div>
</dialog>
<script type="module" src="app.js"></script>
</body>
</html>
styles.css:
/* Light by default, dark when the system asks for it, and either one when the
visitor picks it (data-theme on <html>, set by the toggle). */
:root {
color-scheme: light;
--bg: #f6f5f2;
--ink: #191816;
--muted: #6d6a64;
--line: #e2dfd8;
--row: 110px;
--gap: 6px;
--display: 'Instrument Serif', Georgia, serif;
--text: 'Inter', system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) { color-scheme: dark; --bg: #121211; --ink: #f1efea; --muted: #9b978f; --line: #2b2a27; }
}
:root[data-theme='dark'] { color-scheme: dark; --bg: #121211; --ink: #f1efea; --muted: #9b978f; --line: #2b2a27; }
@media (min-width: 720px) {
:root { --row: 230px; --gap: 10px; }
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 15px/1.55 var(--text);
-webkit-font-smoothing: antialiased;
}
.head, .main, .notice { width: min(1280px, 100% - 32px); margin: 0 auto; }
.head { position: relative; padding: 48px 0 8px; }
.theme { position: absolute; top: 14px; right: 0; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: none; color: var(--muted); cursor: pointer; }
.theme:hover { color: var(--ink); border-color: var(--ink); }
.theme svg { width: 16px; height: 16px; }
h1 {
margin: 0;
font: 400 clamp(44px, 9vw, 96px)/0.95 var(--display);
letter-spacing: -0.02em;
}
.intro { max-width: 58ch; margin: 14px 0 0; color: var(--muted); font-size: 16px; }
.intro:empty { display: none; }
/* Section links: one line that scrolls sideways on a phone. */
.sections {
display: flex;
gap: 6px;
margin: 28px -16px 0;
padding: 0 16px 4px;
overflow-x: auto;
scrollbar-width: none;
}
.sections a {
flex: none;
padding: 7px 14px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--ink);
font-size: 14px;
text-decoration: none;
}
.sections a:hover { border-color: var(--ink); }
.section { padding-top: 40px; scroll-margin-top: 16px; }
.section-title { margin: 0; font: 400 34px/1.05 var(--display); }
.section-about { max-width: 58ch; margin: 6px 0 0; color: var(--muted); }
.section-title + .grid, .section-about + .grid { margin-top: 18px; }
/* Justified rows: each tile grows in proportion to its width/height ratio. */
.grid { display: flex; flex-wrap: wrap; gap: var(--gap); }
.grid::after { content: ''; flex-grow: 1000000; }
.tile {
position: relative;
flex: calc(var(--r) * 100) 1 calc(var(--r) * var(--row));
aspect-ratio: var(--r);
overflow: hidden;
border-radius: 4px;
}
.tile img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
.tile:hover img { transform: scale(1.025); }
.badge {
position: absolute;
left: 8px;
bottom: 8px;
padding: 2px 8px;
border-radius: 999px;
background: rgb(0 0 0 / 0.55);
color: #fff;
font-size: 12px;
font-weight: 500;
}
.notice, .empty { padding: 24px 0; color: var(--muted); }
/* The viewer fills the screen; the image is never cropped. */
.viewer {
width: 100%;
max-width: none;
height: 100%;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: #0c0c0b;
color: #f1efea;
}
.viewer[open] { display: grid; grid-template-rows: 1fr auto; }
.viewer::backdrop { background: #0c0c0b; }
body:has(.viewer[open]) { overflow: hidden; }
.stage { position: relative; min-height: 0; }
.stage img, .stage video { position: absolute; inset: 0; width: 100%; height: 100%; padding: 16px; object-fit: contain; }
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom)) 20px;
}
.caption { margin: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.count { margin-left: 8px; color: #9b978f; font-variant-numeric: tabular-nums; }
.controls { display: flex; gap: 4px; flex: none; }
.controls button {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border: 0;
border-radius: 50%;
background: transparent;
color: inherit;
cursor: pointer;
}
.controls button:hover { background: rgb(255 255 255 / 0.1); }
.controls svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.viewer :focus-visible { outline-color: #f1efea; }
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
app.js:
// A gallery of one board. Each sub-board is a section, in its order on the
// board, with its title and description from Playbook; assets on the board
// itself come first. An open image lives at #/<token>, so its link can be shared.
async function api(path, tries = 3) {
const response = await fetch(`playbook/api/${path}`).catch(() => null)
if (response?.ok) return response.json()
const status = response?.status ?? 0
if (tries > 1 && (status === 0 || status === 429 || status >= 500)) {
const wait = Number(response?.headers.get('Retry-After')) || 2
await new Promise((resolve) => setTimeout(resolve, wait * 1000))
return api(path, tries - 1)
}
throw new Error(`Playbook answered ${status}`)
}
// Every page of a list.
async function all(path) {
const rows = []
for (let page = 1; ; page++) {
const { data, pagy } = await api(`${path}&per_page=100&page=${page}`)
rows.push(...data)
if (!pagy || pagy.current_page >= pagy.total_pages) return rows
}
}
const $ = (id) => document.getElementById(id)
const plain = (html) => new DOMParser().parseFromString(html || '', 'text/html').body.textContent.trim()
const el = (tag, className, text) => {
const node = document.createElement(tag)
if (className) node.className = className
if (text) node.textContent = text
return node
}
const isVideo = (asset) => asset.media_type?.startsWith('video/')
const showable = (asset) => !asset.is_group && (asset.thumbnails?.length || asset.display_url)
// The smallest render at least `width` pixels wide.
function render(asset, width) {
const thumbs = [...(asset.thumbnails || [])].sort((a, b) => a.width - b.width)
return (thumbs.find((t) => t.width >= width) || thumbs.at(-1))?.url || asset.display_url
}
let items = []
let open = -1
function tile(asset, index) {
const link = el('a', 'tile')
link.href = `#/${asset.token}`
link.style.backgroundColor = asset.average_color || ''
// Rows are justified by each image's shape (styles.css), so nothing is cropped.
link.style.setProperty('--r', asset.primary_width && asset.primary_height ? asset.primary_width / asset.primary_height : 1)
const image = el('img')
image.src = render(asset, 640)
image.alt = asset.title || ''
image.loading = index < 6 ? 'eager' : 'lazy'
link.append(image)
if (isVideo(asset)) link.append(el('span', 'badge', 'Video'))
return link
}
function section(board, assets, first) {
const node = el('section', 'section')
node.id = `s-${board.token}`
if (!first) {
node.append(el('h2', 'section-title', board.title))
const about = plain(board.description)
if (about) node.append(el('p', 'section-about', about))
}
const grid = el('div', 'grid')
grid.append(...assets.map((asset) => tile(asset, items.indexOf(asset))))
node.append(grid)
return node
}
let hls
async function play(video, asset) {
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = asset.stream_url
return
}
hls ||= import('https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.mjs').then((module) => module.default)
const Hls = await hls
const player = new Hls()
player.loadSource(asset.stream_url)
player.attachMedia(video)
video.addEventListener('emptied', () => player.destroy(), { once: true })
}
function show(index) {
open = index
const asset = items[index]
const stage = $('stage')
stage.querySelector('video')?.removeAttribute('src')
if (isVideo(asset) && asset.stream_url) {
const video = el('video')
video.controls = true
video.playsInline = true
video.poster = asset.display_url || ''
play(video, asset)
stage.replaceChildren(video)
} else {
const image = el('img')
image.src = asset.display_url || render(asset, 2000)
image.alt = asset.title || ''
stage.replaceChildren(image)
}
$('v-title').textContent = asset.title || ''
$('v-count').textContent = `${index + 1} / ${items.length}`
// The neighbours, ready before they are asked for.
for (const step of [-1, 1]) new Image().src = items[(index + step + items.length) % items.length].display_url || ''
if (!$('viewer').open) $('viewer').showModal()
}
function route() {
const token = location.hash.match(/^#\/(.+)$/)?.[1]
const index = items.findIndex((asset) => asset.token === token)
if (index >= 0) show(index)
else if ($('viewer').open) $('viewer').close()
}
function go(step) {
if (open < 0) return
history.replaceState(null, '', `#/${items[(open + step + items.length) % items.length].token}`)
route()
}
// Light or dark: the visitor's pick, kept per app, else the system's.
$('theme').addEventListener('click', () => {
const root = document.documentElement
const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches
root.dataset.theme = dark ? 'light' : 'dark'
try {
localStorage.setItem(`theme:${location.pathname}`, root.dataset.theme)
} catch {}
})
$('v-prev').addEventListener('click', () => go(-1))
$('v-next').addEventListener('click', () => go(1))
$('v-close').addEventListener('click', () => $('viewer').close())
$('viewer').addEventListener('close', () => {
open = -1
$('stage').replaceChildren()
if (location.hash) history.replaceState(null, '', location.pathname + location.search)
})
$('viewer').addEventListener('keydown', (event) => {
if (event.key === 'ArrowLeft') go(-1)
if (event.key === 'ArrowRight') go(1)
})
let touchX = null
$('stage').addEventListener('touchstart', (event) => { touchX = event.touches[0].clientX }, { passive: true })
$('stage').addEventListener('touchend', (event) => {
const dx = event.changedTouches[0].clientX - touchX
if (Math.abs(dx) > 50) go(dx < 0 ? 1 : -1)
})
window.addEventListener('hashchange', route)
try {
const app = await (await fetch('playbook/app')).json()
const [boards, assets] = await Promise.all([
all('boards?depth=all&displayable_assets=false'),
all('assets?nested_assets=true'),
])
const root = boards.find((board) => board.token === app.board)
// The board first, then its sub-boards depth first, each level by `sort`.
const children = (board) => boards.filter((b) => b.parent_id === board.id).sort((a, b) => a.sort - b.sort)
const order = []
const walk = (board) => {
order.push(board)
children(board).forEach(walk)
}
walk(root)
const shown = assets.filter(showable)
const groups = order
.map((board) => ({ board, assets: shown.filter((asset) => asset.collection_token === board.token) }))
.filter((group) => group.assets.length)
items = groups.flatMap((group) => group.assets)
document.title = root.title
$('title').textContent = root.title
$('intro').textContent = plain(root.description)
const named = groups.filter((group) => group.board !== root)
if (named.length > 1) {
$('sections').append(
...named.map(({ board }) => {
const link = el('a', '', board.title)
link.href = `#s-${board.token}`
link.addEventListener('click', (event) => {
event.preventDefault()
$(`s-${board.token}`).scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
})
return link
})
)
$('sections').hidden = false
}
$('main').append(...groups.map((group, i) => section(group.board, group.assets, i === 0 && group.board === root)))
if (!items.length) $('main').append(el('p', 'empty', 'Nothing here yet.'))
route()
} catch {
$('notice').hidden = false
}
Make it yours
Change:
- Type. One Google Fonts link in
index.html, and--displayand--textinstyles.css. - Colours. The tokens at the top of
styles.css: the light set on:root, the dark set twice below it (for the system setting and for the toggle). Change both sets together. - Row height.
--rowis the height a row aims for: 110 px on a phone, 230 px from 720 px up. Taller rows show fewer, larger images. - Thumbnail size.
render(asset, 640)picks the smallest render at least 640 px wide; raise it for fewer, wider tiles.
Keep:
- The width and height ratio on each tile, from
primary_widthandprimary_height: the rows are laid out before the images load, so nothing jumps. - Views in the hash (
#/token): a link to one image opens that image, and a path would answer 404. - hls.js loaded only when the browser cannot play HLS itself.
Check it
Open the app 390 px wide and 1280 px wide, in both themes. Open an image, go to the next with the arrow key and by swiping, close it, and open a shared #/token link in a new tab.