Skip to main content

Recipe: Review and approve

A review room over one board for the people who decide: a grid with each asset's state, a Start review button that walks through what still needs review, and a panel to approve or request changes. A note becomes a comment on the asset; quick replies and keyboard keys make a long review fast. Decisions and comments are written to Playbook as the signed-in visitor. Light and dark themes follow the visitor's system, with a toggle.

When to use it​

  • A team or a client approves work: a campaign, social posts, a photo selection, product shots.
  • The app must be members: only an app for the board's people may write. Invite a client without a Playbook account as a guest to the board; they sign in once. A public or password app can show the same grid but cannot record a decision.

How the board drives it​

  • A select field holds each asset's state. Create it once with create_custom_field: a select named Review with the options Needs review, Approved and Changes requested. An asset with no value counts as needing review.
  • An asset's title and description are the card's title and the caption in the panel.
  • Comments are read when an asset opens, as the visitor sees them in Playbook, team-only comments marked.
  • Approve sets the field to Approved; a note, when written, is added as a comment. Request changes asks for a note, then sets Changes requested and adds the note as a comment.

Who may decide is Playbook's choice, not the page's: someone who can only view the board sees everything, and their decision answers that their account may look but not change.

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>Review</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=Inter+Tight:wght@400;500;600&display=swap" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="top">
<p class="stats" id="stats"></p>
<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>
</header>

<section class="hero">
<p class="eyebrow">Review</p>
<h1 id="title">Loading…</h1>
<p class="subtitle" id="subtitle"></p>
<div class="controls">
<button type="button" class="solid" id="start" hidden></button>
<nav class="filters" id="filters" aria-label="Show"></nav>
</div>
</section>

<main class="grid" id="grid" aria-live="polite"></main>
<p class="notice" id="notice" hidden>The board did not load. Reload the page to try again.</p>

<div class="review" id="review" hidden>
<figure class="stage"><img id="r-image" alt="" /></figure>
<aside class="panel" aria-labelledby="r-title">
<div class="panel-top">
<button type="button" class="back" id="r-close">← All posts</button>
<div class="queue">
<button type="button" id="r-prev" aria-label="Previous">‹</button>
<span id="r-position"></span>
<button type="button" id="r-next" aria-label="Next">›</button>
</div>
</div>
<p class="kicker" id="r-kicker"></p>
<h2 id="r-title"></h2>
<p class="state" id="r-state"></p>
<div class="actions">
<button type="button" class="solid approve" id="r-approve">Approve</button>
<button type="button" class="ghost changes" id="r-changes">Request changes</button>
</div>
<p class="keys">A approve · R request changes · ← → move</p>
<h3 id="r-caption-head">Caption</h3>
<p class="caption" id="r-caption"></p>
<h3>Feedback <span id="r-count"></span></h3>
<ol class="thread" id="r-thread"></ol>
<form class="composer-wrap" id="r-composer">
<div class="chips" id="r-chips" aria-label="Quick feedback"></div>
<div class="composer">
<textarea id="r-draft" rows="2" placeholder="Leave feedback for the team…" aria-label="Your feedback"></textarea>
<button type="submit" class="solid" id="r-send">Send</button>
</div>
<p class="requesting" id="r-requesting" hidden>Say what should change, then send. <button type="button" id="r-cancel">Cancel</button></p>
<p class="error" id="r-error" role="alert"></p>
</form>
</aside>
</div>

<p class="toast" id="toast" role="status" hidden></p>

<script type="module" src="app.js"></script>
</body>
</html>

styles.css:

/* Dark by default, light 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: dark;
--bg: #050507;
--ink: #f4f3f7;
--ink-2: #e4e2ea;
--soft: #c9c7d2;
--muted: #9a98a6;
--line: rgba(255, 255, 255, 0.1);
--glass: rgba(14, 13, 19, 0.78);
--sheet: rgb(13 12 18 / 0.98);
--overlay: rgba(5, 5, 7, 0.94);
--field: rgba(0, 0, 0, 0.4);
--glow: rgba(200, 107, 255, 0.16);
--shadow: rgba(0, 0, 0, 0.6);
--solid: #ffffff;
--on-solid: #0b0b10;
--accent: linear-gradient(90deg, #ff7a9a, #c86bff 45%, #6aa8ff);
--focus: rgba(200, 107, 255, 0.75);
--waiting: #c9c7d2;
--changes: #ff6b7a;
--on-changes: #2a0509;
--approved: #8ef0b5;
--on-approved: #06260f;
--internal: #ffb547;
--panel: 440px;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) {
color-scheme: light;
--bg: #f6f5f9; --ink: #15141a; --ink-2: #2b2a33; --soft: #4a4856; --muted: #77758a; --line: rgba(20, 18, 30, 0.1);
--glass: rgba(255, 255, 255, 0.82); --sheet: rgb(255 255 255 / 0.98); --overlay: rgba(246, 245, 249, 0.94);
--field: rgba(20, 18, 30, 0.04); --glow: rgba(200, 107, 255, 0.12); --shadow: rgba(20, 18, 30, 0.18); --solid: #15141a; --on-solid: #ffffff;
--focus: rgba(140, 60, 220, 0.6); --waiting: #6b6880; --changes: #d63a4c; --on-changes: #ffffff;
--approved: #1d9a5b; --on-approved: #ffffff; --internal: #b7790f;
}
}
:root[data-theme='light'] {
color-scheme: light;
--bg: #f6f5f9; --ink: #15141a; --ink-2: #2b2a33; --soft: #4a4856; --muted: #77758a; --line: rgba(20, 18, 30, 0.1);
--glass: rgba(255, 255, 255, 0.82); --sheet: rgb(255 255 255 / 0.98); --overlay: rgba(246, 245, 249, 0.94);
--field: rgba(20, 18, 30, 0.04); --glow: rgba(200, 107, 255, 0.12); --shadow: rgba(20, 18, 30, 0.18); --solid: #15141a; --on-solid: #ffffff;
--focus: rgba(140, 60, 220, 0.6); --waiting: #6b6880; --changes: #d63a4c; --on-changes: #ffffff;
--approved: #1d9a5b; --on-approved: #ffffff; --internal: #b7790f;
}

* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(900px 500px at 15% -10%, var(--glow), transparent 70%), var(--bg);
color: var(--ink);
font: 15px/1.45 'Inter Tight', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
body.reviewing { overflow: hidden; }
button, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

.solid, .ghost { padding: 10px 18px; border-radius: 999px; font-weight: 600; white-space: nowrap; }
.solid { border: 0; background: var(--solid); color: var(--on-solid); }
.ghost { border: 1px solid var(--line); background: var(--glass); backdrop-filter: blur(14px); }
.ghost[aria-pressed='true'] { border-color: var(--solid); background: var(--solid); color: var(--on-solid); }
.theme { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: var(--glass); color: var(--soft); }
.theme svg { width: 16px; height: 16px; }
.solid:disabled, .ghost:disabled { opacity: 0.5; cursor: default; }

.dot { display: inline-block; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--waiting); }
.dot.changes { background: var(--changes); }
.dot.approved { background: var(--approved); }

/* Home */
.top { display: flex; justify-content: flex-end; align-items: center; gap: 16px; padding: 22px 28px; }
.stats { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px 16px; margin: 0; color: var(--muted); font-size: 14px; }
.stats span { display: inline-flex; align-items: center; gap: 6px; }
.stats b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }

.hero { padding: 56px 28px 28px; }
.eyebrow {
margin: 0 0 12px; font-size: 13px; letter-spacing: 0.14em; text-transform: uppercase;
background: var(--accent); -webkit-background-clip: text; background-clip: text; color: transparent;
}
h1 { max-width: 14ch; margin: 0; font-size: clamp(40px, 6.5vw, 76px); font-weight: 600; line-height: 0.98; letter-spacing: -0.035em; }
.subtitle { max-width: 56ch; margin: 14px 0 0; color: var(--soft); }
.subtitle:empty { display: none; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 28px; }
.filters { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }
.filters .ghost { padding: 7px 14px; font-size: 13px; font-weight: 500; }

.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; padding: 0 28px 64px; }
.tile { position: relative; aspect-ratio: 4 / 5; overflow: hidden; border-radius: 14px; color: #fff; text-decoration: none; }
.tile img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
.tile:hover img { transform: scale(1.04); }
.tile::after { content: ''; position: absolute; inset: 55% 0 0; background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); }
.tile-title { position: absolute; z-index: 1; left: 12px; right: 12px; bottom: 10px; font-weight: 500; font-size: 14px; line-height: 1.25; }
.badge {
position: absolute; z-index: 1; top: 10px; left: 10px; display: flex; align-items: center; gap: 6px;
padding: 3px 9px; border-radius: 999px; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(8px); font-size: 11px;
}
.empty, .notice { color: var(--muted); }
.notice { padding: 0 28px; }

/* Review: the asset on the stage, the decision in a glass panel. */
.review { position: fixed; inset: 0; z-index: 10; background: var(--overlay); backdrop-filter: blur(10px); animation: fade 0.3s ease; }
.stage { position: absolute; top: 0; bottom: 0; left: 0; right: calc(var(--panel) + 32px); display: flex; align-items: center; justify-content: center; margin: 0; padding: 40px; }
.stage img { display: block; max-width: 100%; max-height: 100%; border-radius: 14px; box-shadow: 0 30px 80px var(--shadow); }
.panel {
position: absolute; top: 16px; right: 16px; bottom: 16px; width: min(var(--panel), 100% - 32px);
display: flex; flex-direction: column; overflow-y: auto; padding: 20px 22px 0;
border: 1px solid var(--line); border-radius: 22px; background: var(--glass); backdrop-filter: blur(22px);
animation: slide 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.panel > * { flex-shrink: 0; }
@keyframes fade { from { opacity: 0; } }
@keyframes slide { from { opacity: 0; transform: translateX(24px); } }

.panel-top { display: flex; justify-content: space-between; align-items: center; }
.back { padding: 0; border: 0; background: none; color: var(--muted); }
.back:hover { color: var(--ink); }
.queue { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
.queue button { width: 28px; height: 28px; border: 1px solid var(--line); border-radius: 50%; background: none; }
.kicker { margin: 16px 0 4px; color: var(--muted); font-size: 13px; }
.panel h2 { margin: 0; font-size: 24px; font-weight: 600; line-height: 1.1; letter-spacing: -0.02em; overflow-wrap: anywhere; }
.state { margin: 10px 0 0; font-size: 13px; }
.state span { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border: 1px solid currentColor; border-radius: 999px; color: var(--waiting); }
.state span::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.state .changes { color: var(--changes); }
.state .approved { color: var(--approved); }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.actions .approve { background: var(--approved); color: var(--on-approved); }
.actions .changes { border-color: rgba(255, 107, 122, 0.6); color: var(--changes); }
.keys { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
.panel h3 { margin: 22px 0 8px; color: var(--muted); font-size: 12px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; }
.caption { margin: 0; color: var(--ink-2); white-space: pre-line; }

.thread { margin: 0; padding: 0; list-style: none; }
.thread .empty { padding: 4px 0; }
.comment { display: grid; grid-template-columns: 30px 1fr; gap: 2px 10px; padding: 8px 0; }
.avatar { grid-row: span 2; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; color: #0b0b10; font-size: 11px; font-weight: 600; }
.comment header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; font-size: 13px; }
.comment time { color: var(--muted); }
.tag { padding: 1px 7px; border: 1px solid currentColor; border-radius: 999px; color: var(--internal); font-size: 11px; }
.comment p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.comment ol { grid-column: 2; margin: 4px 0 0; padding: 0 0 0 10px; border-left: 1px solid var(--line); list-style: none; }

.composer-wrap { position: sticky; bottom: 0; margin: 16px -22px 0; padding: 12px 22px 18px; border-top: 1px solid var(--line); background: var(--sheet); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips button { padding: 5px 11px; border: 1px solid var(--line); border-radius: 999px; background: none; color: var(--soft); font-size: 12px; }
.chips button:hover { border-color: var(--focus); color: var(--ink); }
.composer { display: flex; align-items: flex-end; gap: 8px; margin-top: 10px; }
.composer textarea { flex: 1; min-width: 0; padding: 10px 14px; resize: none; border: 1px solid var(--line); border-radius: 16px; background: var(--field); outline: none; }
.composer textarea:focus { border-color: var(--focus); }
.is-requesting textarea { border-color: rgba(255, 107, 122, 0.8); }
.is-requesting .solid { background: var(--changes); color: var(--on-changes); }
.requesting { margin: 8px 0 0; color: var(--changes); font-size: 12px; }
.requesting button { padding: 0; border: 0; background: none; color: var(--muted); font-size: 12px; text-decoration: underline; }
.error { margin: 8px 0 0; color: var(--changes); font-size: 13px; }
.error:empty { display: none; }

.toast {
position: fixed; z-index: 20; top: 22px; left: 50%; margin: 0; padding: 9px 16px; transform: translateX(-50%);
border-radius: 999px; background: var(--solid); color: var(--on-solid); font-size: 14px; font-weight: 500; animation: fade 0.3s;
}

@media (max-width: 760px) {
.top { padding: 16px; }
.stats { font-size: 12px; gap: 4px 10px; }
.hero { padding: 28px 16px 20px; }
.controls .solid { flex: 1; }
.filters { width: calc(100% + 32px); margin: 0 -16px; padding: 0 16px; }
.grid { grid-template-columns: repeat(2, 1fr); gap: 6px; padding: 0 16px 48px; }
.tile { border-radius: 10px; }
.stage { right: 0; bottom: 58%; padding: 16px; }
.panel { top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; height: calc(58% - 8px); padding: 16px 16px 0; }
.composer-wrap { margin: 12px -16px 0; padding: 12px 16px 14px; }
}

@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}

app.js:

// A review room over one board, open to the board's people (access `members`).
// A select field holds each asset's review state. A decision is written to that
// field and feedback becomes a comment, both as the signed-in visitor.

const FIELD = 'Review'
const APPROVED = 'Approved'
const CHANGES = 'Changes requested'
const CHIPS = ['Love it', 'Brighter, please', 'Tighter crop', 'Try another option', 'Needs the logo']
const LABELS = { waiting: 'Needs review', changes: 'Changes requested', approved: 'Approved' }

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 dot = (state) => el('i', `dot ${state}`)

// Reads and changes on the app's own address. Busy or cold reads (429, 5xx)
// are retried twice; a sign-in that ended (401) starts again with a reload.
async function call(path, { method = 'GET', body, tries = 3 } = {}) {
const response = await fetch(`playbook/api/${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body && JSON.stringify(body),
}).catch(() => null)
const status = response?.status ?? 0
if (status === 401) location.reload()
if (method === 'GET' && 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 call(path, { method, body, tries: tries - 1 })
}
const answer = response ? await response.json().catch(() => ({})) : {}
if (response?.ok) return answer
if (status === 403) throw new Error('Your Playbook account may look at this board, not change it.')
throw new Error(answer.errors?.[0]?.message || 'That did not go through. Try again.')
}

let items = []
let filter = 'all'
let open = -1
let requesting = false
const threads = new Map()

function stateOf(asset) {
const value = asset.fields?.[FIELD]
if (value === APPROVED) return 'approved'
if (value === CHANGES) return 'changes'
return 'waiting'
}

function thumb(asset, width) {
const thumbs = [...asset.thumbnails].sort((a, b) => a.width - b.width)
return (thumbs.find((t) => t.width >= width) || thumbs.at(-1)).url
}

const day = (iso) => new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
const count = (state) => items.filter((asset) => stateOf(asset) === state).length

function toast(text) {
$('toast').textContent = text
$('toast').hidden = false
clearTimeout(toast.timer)
toast.timer = setTimeout(() => ($('toast').hidden = true), 2400)
}

function renderHome() {
$('stats').replaceChildren(
...Object.entries(LABELS).map(([state, label]) => {
const stat = el('span')
stat.append(dot(state), el('b', '', String(count(state))), ` ${label.toLowerCase()}`)
return stat
})
)
const waiting = count('waiting')
$('start').textContent = waiting ? `Start review · ${waiting}` : 'All reviewed'
$('start').disabled = !waiting
$('start').hidden = false

$('filters').replaceChildren(
...[['all', 'All'], ...Object.entries(LABELS)].map(([key, label]) => {
const button = el('button', 'ghost', label)
button.type = 'button'
button.setAttribute('aria-pressed', String(filter === key))
button.addEventListener('click', () => {
filter = key
renderHome()
})
return button
})
)

const shown = items.filter((asset) => filter === 'all' || stateOf(asset) === filter)
$('grid').replaceChildren(
...(shown.length
? shown.map((asset) => {
const tile = el('a', 'tile')
tile.href = `#/${asset.token}`
tile.style.backgroundColor = asset.average_color || ''
const image = el('img')
image.src = thumb(asset, 480)
image.alt = asset.title || ''
image.loading = 'lazy'
const badge = el('span', 'badge')
badge.append(dot(stateOf(asset)), LABELS[stateOf(asset)])
tile.append(image, badge, el('span', 'tile-title', asset.title))
return tile
})
: [el('p', 'empty', 'Nothing here.')])
)
}

function avatar(name) {
const hue = [...(name || '?')].reduce((sum, ch) => sum + ch.charCodeAt(0), 0) % 360
const node = el('span', 'avatar', (name || '?').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase())
node.style.background = `hsl(${hue} 70% 72%)`
return node
}

function comment(item) {
const node = el('li', 'comment')
const head = el('header')
head.append(el('b', '', item.user_name || 'Someone'), el('time', '', day(item.created_at)))
if (item.internal) head.append(el('span', 'tag', 'Team only'))
node.append(avatar(item.user_name), head, el('p', '', plain(item.description)))
if (item.replies?.length) {
const replies = el('ol')
replies.append(...item.replies.map(comment))
node.append(replies)
}
return node
}

function renderThread(asset) {
const thread = threads.get(asset.token)
$('r-count').textContent = asset.comment_count ? `· ${asset.comment_count}` : ''
if (!thread) $('r-thread').replaceChildren(el('li', 'empty', 'Loading…'))
else if (!thread.length) $('r-thread').replaceChildren(el('li', 'empty', 'No feedback yet.'))
else $('r-thread').replaceChildren(...thread.map(comment))
}

// Comments are read when an asset is opened, never for the whole grid.
async function loadThread(asset) {
if (!threads.has(asset.token)) {
const { data } = asset.comment_count ? await call(`assets/${asset.token}/comments?per_page=100`) : { data: [] }
threads.set(asset.token, data)
}
if (items[open] === asset) renderThread(asset)
}

function setRequesting(on) {
requesting = on
$('r-composer').classList.toggle('is-requesting', on)
$('r-requesting').hidden = !on
$('r-send').textContent = on ? 'Request changes' : 'Send'
if (on) $('r-draft').focus()
}

function show(index) {
open = index
const asset = items[index]
const state = stateOf(asset)
$('r-image').src = asset.display_url
$('r-image').alt = asset.title || ''
$('r-position').textContent = `${index + 1} of ${items.length}`
$('r-kicker').textContent = [asset.uploaded_by?.name, day(asset.created_at)].filter(Boolean).join(' · ')
$('r-title').textContent = asset.title
const pill = el('span', state, LABELS[state])
$('r-state').replaceChildren(pill)
const caption = plain(asset.description)
$('r-caption').textContent = caption
$('r-caption').hidden = $('r-caption-head').hidden = !caption
$('r-error').textContent = ''
$('r-draft').value = ''
setRequesting(false)
renderThread(asset)
loadThread(asset).catch(() => $('r-thread').replaceChildren(el('li', 'empty', 'Feedback did not load.')))
$('review').hidden = false
document.body.classList.add('reviewing')
}

function route() {
const token = location.hash.match(/^#\/(.+)$/)?.[1]
const index = items.findIndex((asset) => asset.token === token)
if (index >= 0) return show(index)
open = -1
$('review').hidden = true
document.body.classList.remove('reviewing')
renderHome()
}

function goTo(index) {
history.replaceState(null, '', `#/${items[index].token}`)
route()
}
const go = (step) => goTo((open + step + items.length) % items.length)
function close() {
history.replaceState(null, '', location.pathname + location.search)
route()
}

// After a decision: the next asset that still needs review, or home when none is left.
function advance() {
const after = [...items.slice(open + 1), ...items.slice(0, open)]
const next = after.find((asset) => stateOf(asset) === 'waiting')
if (next) goTo(items.indexOf(next))
else {
close()
toast('Everything is reviewed. Thank you!')
}
}

async function submit({ value, note }) {
const asset = items[open]
const buttons = [$('r-approve'), $('r-changes'), $('r-send')]
buttons.forEach((button) => (button.disabled = true))
$('r-error').textContent = ''
try {
// Each change answers with the result. Reads stay cached for minutes, so the
// page keeps the answer instead of reading the board again.
if (value) {
const { data } = await call(`assets/${asset.token}`, { method: 'PATCH', body: { asset: { fields: { [FIELD]: value } } } })
asset.fields = data.fields
}
if (note) {
const { data } = await call('comments', { method: 'POST', body: { asset_token: asset.token, comment: { description: note } } })
threads.set(asset.token, [...(threads.get(asset.token) || []), data])
asset.comment_count = (asset.comment_count || 0) + 1
}
if (value) {
toast(value === APPROVED ? 'Approved' : 'Changes requested')
advance()
} else {
$('r-draft').value = ''
renderThread(asset)
}
} catch (error) {
$('r-error').textContent = error.message
} finally {
buttons.forEach((button) => (button.disabled = false))
}
}

const approve = () => submit({ value: APPROVED, note: $('r-draft').value.trim() })
const requestChanges = () => ($('r-draft').value.trim() ? submit({ value: CHANGES, note: $('r-draft').value.trim() }) : setRequesting(true))

$('r-chips').append(
...CHIPS.map((text) => {
const chip = el('button', '', text)
chip.type = 'button'
chip.addEventListener('click', () => {
const draft = $('r-draft')
draft.value = draft.value.trim() ? `${draft.value.trim()} ${text}.` : `${text}.`
draft.focus()
})
return chip
})
)
$('r-composer').addEventListener('submit', (event) => {
event.preventDefault()
const note = $('r-draft').value.trim()
if (requesting && !note) return $('r-draft').focus()
if (note) submit({ value: requesting ? CHANGES : null, note })
})
$('r-draft').addEventListener('keydown', (event) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) $('r-composer').requestSubmit()
})
// 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 {}
})
$('r-approve').addEventListener('click', approve)
$('r-changes').addEventListener('click', requestChanges)
$('r-cancel').addEventListener('click', () => setRequesting(false))
$('r-prev').addEventListener('click', () => go(-1))
$('r-next').addEventListener('click', () => go(1))
$('r-close').addEventListener('click', close)
$('start').addEventListener('click', () => {
const first = items.findIndex((asset) => stateOf(asset) === 'waiting')
if (first >= 0) goTo(first)
})
const KEYS = { ArrowLeft: () => go(-1), ArrowRight: () => go(1), Escape: close, a: approve, r: requestChanges }
document.addEventListener('keydown', (event) => {
if (open < 0 || event.target.closest('textarea, input') || event.metaKey || event.ctrlKey || !KEYS[event.key]) return
// Without this, the "r" that focuses the note would also be typed into it.
event.preventDefault()
KEYS[event.key]()
})
window.addEventListener('hashchange', route)

try {
const app = await (await fetch('playbook/app')).json()
const assets = []
for (let page = 1; ; page++) {
const { data, pagy } = await call(`assets?per_page=100&page=${page}`)
assets.push(...data)
if (!pagy || pagy.current_page >= pagy.total_pages) break
}
const { data: board } = await call(`boards/${app.board}`)
items = assets.filter((asset) => !asset.is_group && asset.thumbnails?.length)
document.title = `${board.title} · Review`
$('title').textContent = board.title
$('subtitle').textContent = plain(board.description)
route()
} catch {
$('title').textContent = ''
$('notice').hidden = false
}

Make it yours​

Change:

  • FIELD, APPROVED and CHANGES in app.js: they must match the field and its options exactly, as list_custom_fields spells them.
  • CHIPS: the quick replies, in the words your reviewers use.
  • LABELS: how each state reads on the page.
  • Colours. The tokens at the top of styles.css: the dark set on :root, the light set twice below it. Change both together.
  • Type and the eyebrow. The Google Fonts link and the Review line in index.html.

Keep:

  • Comments read when an asset opens, never for the whole grid: each read is uncached and counts toward the visitor's limit.
  • The answer of each change kept on the page: reads are cached for up to five minutes.
  • event.preventDefault() in the key handler: without it, the R that opens the note is typed into it.
  • The reload on 401: it takes the visitor back through sign-in.

Check it​

Sign in as someone who can edit the board. Open the app 390 px wide and 1280 px wide, in both themes. Start the review, approve one asset with the A key, and check in Playbook that its Review field changed; set it back. Request changes on another with a note and check the comment arrived.