Recipe: Upload against a brief
A page for one person who sends files to the team: their name, the week and the due date, the brief, and one card per task. Each card has its own upload; a file shows at once with its progress, then waits for review. When the team asks for a redo, the card turns orange with the team's note, and a button always points at the next thing to do. Light and dark themes follow the visitor's system, with a toggle.
When to use it
- Someone outside the team sends files for a list of tasks: a creator filming scripts, a photographer delivering a shot list, a supplier sending product images.
- One board per person. Give each their own app, or point one app at each person's board.
- Not for showing a board (use the gallery) or for the team deciding on work (use review).
How the board drives it
Everything the person reads comes from their board, so the team changes the brief in Playbook and the page follows within five minutes, with no redeploy.
- The board's title is the person's name.
- The board's description is the brief, one item per line. Every line is optional; lines the page does not recognise become the introduction.
Week 12
Due: Friday, 20 March
Hi Sam! Four short pieces about the coast this week. Film vertical and keep each under 30 seconds.
Scripts: https://example.com/scripts/week-12
Script 1: Morning swim, in one take
Script 2: What the tide left behind
Script 3: Three spots I'd show a friend
Notes:
Daylight only, no ring light.
Leave two seconds of quiet at the start and the end.
- Uploads are titled
Script 2 – take-1.mp4by the page. The prefix is how a file finds its task, so the team should keep it when renaming. Files sent under Extras keep their own names. - Review happens in Playbook. The status
Changes requested, with the reason in the asset's description, turns the task into a redo and shows the reason as a note.Approvedmarks it done. Both statuses must exist in the workspace. - Uploads must be allowed: the board's share link has Allow uploads (no password, no email form), or the visitor signs in as someone who may upload. See uploads from visitors.
Use password access for one person, and tell them the password apart from the link.
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, viewport-fit=cover" />
<title>Your tasks</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=Manrope:wght@400;500;600;700;800&display=swap" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="page">
<header class="head">
<div class="head-top">
<p class="team" id="team"></p>
<p class="due" id="due" hidden></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>
</div>
<p class="week" id="week"></p>
<h1 id="name">Your tasks</h1>
<div class="meter" aria-hidden="true"><span id="meter"></span></div>
<p class="progress" id="progress"></p>
</header>
<section class="intro" id="intro"></section>
<a class="link" id="link" target="_blank" rel="noopener" hidden>Scripts <span aria-hidden="true">↗</span></a>
<h2>Tasks</h2>
<ol class="tasks" id="tasks"></ol>
<section class="notes" id="notes-section" hidden>
<h2>Good to know</h2>
<ul id="notes"></ul>
</section>
<p class="notice" id="notice" hidden>This page did not load. Reload to try again.</p>
</main>
<button type="button" class="next" id="next" hidden></button>
<script src="https://unpkg.com/[email protected]/dist/playbook-uploader.min.js" integrity="sha384-b5Wn9OnIWgIC9GXRziMnfqcsz1LE68hXvMMkT+8zurQM+HxWsQ87nSVOVzoWtsS2" crossorigin="anonymous"></script>
<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: #f5f4ef;
--card: #ffffff;
--ink: #16181d;
--soft: #4a4d57;
--muted: #868994;
--line: #e6e4dc;
--accent: #3b5bfd;
--accent-soft: #e8ecff;
--on-accent: #ffffff;
--ok: #1f9d6b;
--ok-soft: #e1f5ec;
--redo: #e0592a;
--redo-soft: #fdebe3;
--font: 'Manrope', system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
color-scheme: dark;
--bg: #111215; --card: #1a1c21; --ink: #f1f2f4; --soft: #b9bcc6; --muted: #80838e; --line: #2a2d34;
--accent: #7b93ff; --accent-soft: #1e2550; --on-accent: #0b1033;
--ok: #3fcf8e; --ok-soft: #12301f; --redo: #ff8a5c; --redo-soft: #3a1d12;
}
}
:root[data-theme='dark'] {
color-scheme: dark;
--bg: #111215; --card: #1a1c21; --ink: #f1f2f4; --soft: #b9bcc6; --muted: #80838e; --line: #2a2d34;
--accent: #7b93ff; --accent-soft: #1e2550; --on-accent: #0b1033;
--ok: #3fcf8e; --ok-soft: #12301f; --redo: #ff8a5c; --redo-soft: #3a1d12;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.55 var(--font);
-webkit-font-smoothing: antialiased;
}
button { font: inherit; color: inherit; cursor: pointer; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.page { max-width: 640px; margin: 0 auto; padding: 20px 16px 120px; }
/* Header: who, when, how far along. */
.head-top { display: flex; align-items: center; gap: 8px; }
.team { flex: 1; }
.theme { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: var(--card); color: var(--soft); }
.theme svg { width: 16px; height: 16px; }
.team { margin: 0; font-weight: 700; font-size: 14px; }
.due { margin: 0; padding: 5px 12px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-size: 13px; font-weight: 700; }
.week { margin: 40px 0 4px; color: var(--muted); font-size: 13px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(40px, 11vw, 64px); font-weight: 800; line-height: 1; letter-spacing: -0.04em; overflow-wrap: anywhere; }
.meter { height: 4px; margin-top: 24px; overflow: hidden; border-radius: 2px; background: var(--line); }
.meter span { display: block; width: 0; height: 100%; background: var(--accent); transition: width 0.5s ease; }
.progress { margin: 8px 0 0; color: var(--soft); font-size: 14px; font-weight: 600; }
.intro { margin-top: 28px; }
.intro p { max-width: 58ch; margin: 0 0 10px; color: var(--soft); font-size: 17px; }
.muted { color: var(--muted); }
.link { display: inline-block; margin-top: 4px; color: var(--accent); font-weight: 700; text-decoration: none; }
.link:hover { text-decoration: underline; }
h2 { margin: 40px 0 12px; font-size: 13px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
/* Tasks: one card each; the open one shows its uploads. */
.tasks { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.task { border: 1px solid var(--line); border-radius: 16px; background: var(--card); }
.task-head { display: grid; grid-template-columns: 32px 1fr auto; align-items: center; gap: 12px; width: 100%; padding: 14px 14px 14px 12px; border: 0; background: none; text-align: left; }
.num { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; background: var(--bg); font-size: 14px; font-weight: 800; }
.task-text { display: grid; min-width: 0; }
.task-title { font-weight: 600; line-height: 1.3; }
.count { color: var(--muted); font-size: 13px; }
.count:empty { display: none; }
.chip { padding: 3px 10px; border-radius: 999px; background: var(--bg); color: var(--soft); font-size: 12px; font-weight: 700; white-space: nowrap; }
.task[data-state='sent'] .chip { background: var(--accent-soft); color: var(--accent); }
.task[data-state='ok'] .chip, .task[data-state='ok'] .num { background: var(--ok-soft); color: var(--ok); }
.task[data-state='ok'] .task-title { color: var(--muted); }
.task[data-state='redo'] { border-color: var(--redo); }
.task[data-state='redo'] .chip, .task[data-state='redo'] .num { background: var(--redo-soft); color: var(--redo); }
.task[data-state='extra'] .chip { background: none; color: var(--muted); }
.note { margin: 0 14px 12px 56px; padding: 10px 12px; border-left: 3px solid var(--redo); border-radius: 0 10px 10px 0; background: var(--redo-soft); }
.note-from { margin: 0; color: var(--redo); font-size: 12px; font-weight: 700; }
.note-text { margin: 2px 0 0; font-size: 15px; line-height: 1.45; }
.task-body { padding: 0 14px 14px 56px; }
.strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: thin; }
.drop, .thumb { flex: none; width: 92px; aspect-ratio: 9 / 16; }
.thumb { position: relative; margin: 0; overflow: hidden; border-radius: 10px; background: var(--line); }
.thumb img, .thumb video { display: block; width: 100%; height: 100%; object-fit: cover; }
.thumb-text { position: absolute; inset: 0; display: grid; place-items: center; padding: 8px; color: var(--muted); font-size: 11px; line-height: 1.3; text-align: center; }
.thumb-mark { position: absolute; top: 6px; right: 6px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; color: #fff; font-size: 12px; font-weight: 800; }
.thumb-mark.ok { background: var(--ok); }
.thumb-mark.redo { background: var(--redo); }
.is-pending img, .is-pending video { opacity: 0.55; }
.thumb-bar { position: absolute; left: 0; bottom: 0; width: 0; height: 4px; background: var(--accent); transition: width 0.2s linear; }
.is-failed { background: var(--redo-soft); cursor: pointer; }
.is-failed .thumb-text { color: var(--redo); }
/* The Playbook uploader's button, drawn as the first tile of the strip; its own
file list is replaced by the tiles. */
.drop .pb-uploader-button {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
width: 100%; height: 100%; padding: 8px;
border: 1.5px dashed var(--accent); border-radius: 10px; background: var(--accent-soft);
color: var(--accent); font: 700 13px/1.2 var(--font);
}
.drop .pb-uploader-button::before { content: '↑'; font-size: 22px; line-height: 1; }
.drop .pb-uploader-button:hover { background: color-mix(in srgb, var(--accent) 18%, var(--card)); }
.drop .pb-uploader-list { display: none; }
.notes ul { margin: 0; padding: 0; list-style: none; }
.notes li { margin-bottom: 8px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 16px; background: var(--card); color: var(--soft); font-size: 15px; }
.notice { margin-top: 24px; padding: 14px 16px; border-radius: 12px; background: var(--redo-soft); color: var(--redo); }
/* The next thing to do, always one tap away. */
.next {
position: fixed; right: 16px; bottom: calc(16px + env(safe-area-inset-bottom)); z-index: 10;
padding: 14px 22px; border: 0; border-radius: 999px; background: var(--accent); color: var(--on-accent);
font-weight: 700; box-shadow: 0 10px 30px color-mix(in srgb, var(--accent) 35%, transparent);
}
.next:hover { filter: brightness(1.08); }
/* On a wide screen, on the right edge of the column, not of the window. */
@media (min-width: 700px) {
.next { right: calc(50% - 304px); bottom: 24px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
app.js:
// A contributor's task page. Everything on it comes from the board: its title is
// the person's name and its description is the brief, written as lines:
// Week 12
// Due: Friday, 20 March
// <intro paragraphs>
// Scripts: <link>
// Script 1: <task> (one line per task)
// Notes: (every line after it is a note)
// An upload belongs to task N when its title starts with "Script N –". The team
// sets the status "Changes requested" and writes why in the description;
// "Approved" marks it done.
const TEAM = 'Studio North'
const LABELS = { todo: 'To do', sent: 'In review', ok: 'Done', redo: 'Redo', extra: 'Optional' }
const $ = (id) => document.getElementById(id)
const el = (tag, className, text) => {
const node = document.createElement(tag)
if (className) node.className = className
if (text !== undefined) node.textContent = text
return node
}
const plain = (html) => new DOMParser().parseFromString(html || '', 'text/html').body.textContent.trim()
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}`)
}
async function post(path, body) {
const response = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const answer = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(answer.errors?.[0]?.message || answer.error || 'The upload did not go through.')
return answer.data
}
// Rich text or plain text, as lines.
function linesOf(html) {
const text = (html || '').replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|div|li|h[1-6])>/gi, '\n')
return new DOMParser().parseFromString(text, 'text/html').body.textContent.split('\n').map((line) => line.trim())
}
function parseBrief(html) {
const brief = { week: null, due: null, intro: [], link: null, tasks: [], notes: [] }
let inNotes = false
for (const line of linesOf(html)) {
if (!line) continue
let match
if (!brief.week && (match = line.match(/^week\s+(\d+)$/i))) brief.week = match[1]
else if (!brief.due && (match = line.match(/^due\s*:?\s*(.+)$/i))) brief.due = match[1]
else if ((match = line.match(/^script\s+(\d+)\s*[:.–—-]\s*(.+)$/i))) brief.tasks.push({ n: Number(match[1]), title: match[2] })
else if (/^notes?\s*:?$/i.test(line)) inNotes = true
else if (!brief.link && (match = line.match(/^[^:]{0,20}:?\s*(https?:\/\/\S+)$/))) brief.link = match[1]
else (inNotes ? brief.notes : brief.intro).push(line)
}
return brief
}
const taskOf = (title) => Number((title || '').match(/^script\s+(\d+)\s*[–—-]/i)?.[1]) || null
// The latest round decides: files sent after a redo request answer it.
function stateOf(uploads) {
if (!uploads.length) return { key: 'todo' }
const isRedo = (upload) => /change|reshoot|again|redo/i.test(upload.status || '')
const time = (upload) => (upload.localFile ? Infinity : Date.parse(upload.created_at) || 0)
const byTime = [...uploads].sort((a, b) => time(a) - time(b))
const lastRedo = byTime.findLastIndex(isRedo)
const since = byTime.slice(lastRedo + 1)
if (!since.length) return { key: 'redo', note: plain(byTime[lastRedo].description) }
if (since.every((upload) => /approv|done/i.test(upload.status || ''))) return { key: 'ok' }
return { key: 'sent' }
}
function preview(file) {
if (file.type.startsWith('image/')) return Object.assign(el('img'), { src: URL.createObjectURL(file), alt: '' })
return Object.assign(el('video'), { muted: true, playsInline: true, preload: 'metadata', src: `${URL.createObjectURL(file)}#t=0.1` })
}
function thumb(upload) {
const tile = el('figure', 'thumb')
if (upload.localFile) tile.append(preview(upload.localFile))
else {
const renders = [...(upload.thumbnails || [])].sort((a, b) => a.width - b.width)
const src = (renders.find((t) => t.width >= 320) || renders.at(-1))?.url || upload.display_url
if (src) tile.append(Object.assign(el('img'), { src, alt: '', loading: 'lazy' }))
else tile.append(el('figcaption', 'thumb-text', 'Processing…'))
}
const state = stateOf([upload]).key
if (state === 'ok' || state === 'redo') tile.append(el('span', `thumb-mark ${state}`, state === 'ok' ? '✓' : '↻'))
return tile
}
const tasks = []
function refresh(task) {
const state = task.n || task.uploads.length ? stateOf(task.uploads) : { key: 'extra' }
task.state = state.key
task.row.dataset.state = state.key
task.chip.textContent = LABELS[state.key]
task.noteText.textContent = state.note || ''
task.note.hidden = !state.note
const count = task.uploads.length + task.pending.size
task.count.textContent = count ? `${count} ${count === 1 ? 'file' : 'files'}` : ''
task.strip.replaceChildren(task.drop, ...[...task.pending.values()].map((p) => p.tile), ...task.uploads.map(thumb))
renderProgress()
}
function nextTask() {
return tasks.find((task) => task.state === 'redo') || tasks.find((task) => task.n && task.state === 'todo')
}
function renderProgress() {
const numbered = tasks.filter((task) => task.n)
if (!numbered.length) return
const sent = numbered.filter((task) => task.uploads.length).length
const done = numbered.filter((task) => task.state === 'ok').length
$('meter').style.width = `${(sent / numbered.length) * 100}%`
$('progress').textContent = sent === numbered.length
? `All ${sent} sent. Thank you!`
: `${sent} of ${numbered.length} sent${done ? ` · ${done} done` : ''}`
const next = nextTask()
$('next').hidden = !next
if (next) $('next').textContent = `${next.state === 'redo' ? 'Redo' : 'Upload'} task ${next.n}`
}
function pending(task, file) {
const tile = el('figure', 'thumb is-pending')
const bar = el('span', 'thumb-bar')
tile.append(preview(file), bar)
task.pending.set(file, { tile, bar })
}
function mountUploader(task) {
if (task.uploader || !window.PlaybookUploader) return
task.uploader = PlaybookUploader.init({
containerId: task.dropId,
variant: 'button',
accept: 'video/*,image/*',
// Playbook enforces the size limit and answers with a readable message.
maxFileSizeBytes: Number.MAX_SAFE_INTEGER,
labels: { button: 'Upload' },
getUploadTarget: (file) =>
post('playbook/upload', {
title: task.n ? `Script ${task.n} – ${file.name}` : file.name,
size: file.size,
media_type: file.type || undefined,
}),
finishUpload: (target) =>
post('playbook/upload/complete', {
signed_gcs_id: target.signed_gcs_id,
multipart_upload_id: target.multipart_upload_id || undefined,
}),
onSelect: (files) => {
files.forEach((file) => pending(task, file))
refresh(task)
},
onProgress: (file, pct) => task.pending.get(file)?.bar.style.setProperty('width', `${Math.round(pct)}%`),
// Reads are cached for minutes, so the answer is shown rather than read back.
onFileComplete: (asset, file) => {
task.pending.delete(file)
task.uploads.unshift({ ...asset, localFile: file })
refresh(task)
},
onError: (error, file) => {
if (!task.pending.has(file)) pending(task, file)
const { tile } = task.pending.get(file)
tile.className = 'thumb is-failed'
tile.replaceChildren(el('figcaption', 'thumb-text', `${error?.message || 'The upload did not go through.'} Tap to dismiss.`))
tile.addEventListener('click', () => {
task.pending.delete(file)
refresh(task)
})
refresh(task)
},
})
}
function setOpen(task, open) {
task.head.setAttribute('aria-expanded', String(open))
task.body.hidden = !open
if (open) mountUploader(task)
}
function buildTask({ n, title }) {
const task = { n, uploads: [], pending: new Map(), dropId: `drop-${n ?? 'extra'}` }
task.row = el('li', 'task')
task.head = el('button', 'task-head')
task.head.type = 'button'
task.chip = el('span', 'chip')
task.count = el('span', 'count')
const text = el('span', 'task-text')
text.append(el('span', 'task-title', title), task.count)
task.head.append(el('span', 'num', n ? String(n) : '+'), text, task.chip)
task.note = el('div', 'note')
task.note.append(el('p', 'note-from', `Note from ${TEAM}`))
task.noteText = el('p', 'note-text')
task.note.append(task.noteText)
task.body = el('div', 'task-body')
task.strip = el('div', 'strip')
task.drop = el('div', 'drop')
task.drop.id = task.dropId
task.body.append(task.strip)
task.row.append(task.head, task.note, task.body)
task.head.addEventListener('click', () => setOpen(task, task.body.hidden))
setOpen(task, false)
return task
}
async function loadAssets() {
const assets = []
for (let page = 1; ; page++) {
const { data, pagy } = await api(`assets?per_page=100&page=${page}`)
assets.push(...data)
if (!pagy || pagy.current_page >= pagy.total_pages) return assets
}
}
// 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 {}
})
$('next').addEventListener('click', () => {
const next = nextTask()
if (!next) return
setOpen(next, true)
next.row.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' })
next.drop.querySelector('.pb-uploader-button')?.click()
})
try {
const app = await (await fetch('playbook/app')).json()
const [{ data: board }, assets] = await Promise.all([api(`boards/${app.board}`), loadAssets()])
const brief = parseBrief(board.description)
document.title = `${board.title} · ${brief.week ? `Week ${brief.week}` : 'Tasks'}`
$('team').textContent = TEAM
$('name').textContent = board.title
$('week').textContent = brief.week ? `Week ${brief.week}` : 'This week'
if (brief.due) {
$('due').textContent = `Due ${brief.due.replace(/^[a-z]+,?\s*/i, '')}`
$('due').hidden = false
}
$('intro').append(...(brief.intro.length ? brief.intro.map((line) => el('p', '', line)) : [el('p', 'muted', 'Your brief will appear here soon.')]))
if (brief.link) {
$('link').href = brief.link
$('link').hidden = false
}
if (brief.notes.length) {
$('notes').append(...brief.notes.map((note) => el('li', '', note)))
$('notes-section').hidden = false
}
const known = new Set(brief.tasks.map((task) => task.n))
for (const item of [...brief.tasks, { n: null, title: brief.tasks.length ? 'Extras' : 'Your uploads' }]) tasks.push(buildTask(item))
for (const asset of assets) {
const n = taskOf(asset.title)
tasks.find((task) => task.n === (known.has(n) ? n : null)).uploads.push(asset)
}
$('tasks').append(...tasks.map((task) => task.row))
tasks.forEach(refresh)
const next = nextTask()
if (next) setOpen(next, true)
} catch {
$('notice').hidden = false
}
Make it yours
Change:
TEAMinapp.js: the name in the header and on notes.LABELS: the words on the task chips.- The word "Script". It appears in three places that must change together: the two patterns in
parseBriefandtaskOf, and the title ingetUploadTarget. - Colours. The tokens at the top of
styles.css: the light set on:root, the dark set twice below it. Change both together and keep text readable on--accent. - Type. The Google Fonts link in
index.htmland--font.
Keep:
maxFileSizeBytes: Number.MAX_SAFE_INTEGERand playbook-sdk 1.3.0: Playbook enforces its own limit (2 GB) with a readable message, and the uploader sends large files in parts and resumes them.onFileCompleteadding the new file to the task: reads are cached for up to five minutes, so reading the board again would not show it yet.- The title prefix on every upload, so the team can see which task a file is for.
Check it
Open the app 390 px wide and 1280 px wide, in both themes. Upload a small image to the first task: it should show in the task's strip at once, with a progress bar, and the task should turn In review. After five minutes, reload and see it come back from the board.