# 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](/docs/guides/hosted_apps.md#start-from-a-recipe) cover uploads against a brief and review with approvals.

## When to use it[​](#when-to-use-it "Direct link to When to use it")

* The visitor looks; nobody uploads or decides. Access is usually `public`, or `password` for 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[​](#how-the-board-drives-it "Direct link to 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[​](#the-files "Direct link to 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">&nbsp;</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/hls.js@1.5.20/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[​](#make-it-yours "Direct link to Make it yours")

Change:

* **Type.** One Google Fonts link in `index.html`, and `--display` and `--text` in `styles.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.** `--row` is 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_width` and `primary_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[​](#check-it "Direct link to 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.
