openvibers/openvibe.shared quest.js
quest.js
| 1 | ; |
| 2 | /** |
| 3 | * openvibe-shared/quest — the daily quest card any OpenVibe site can show. |
| 4 | * |
| 5 | * const card = OpenVibeQuest.mount(document.getElementById('quest'), { |
| 6 | * load: () => fetch('/api/home/daily-quest').then((r) => r.json()), |
| 7 | * }); |
| 8 | * card.refresh(); // read again and redraw |
| 9 | * |
| 10 | * `load()` returns the same shape OpenVibe.Live's server reads from OpenVibe.Quest: |
| 11 | * { signed_in, quest, more: { quests, sites, mine: { badges, in_progress, completed } } } |
| 12 | * The site fetches it its own way, from its own server, with the person's Network token; the browser never calls |
| 13 | * Quest. `more` may be missing: the card still draws and the door falls back to its one line. |
| 14 | * |
| 15 | * Every field is data: set as text, links only when https. The card is built node by node and draws once into the |
| 16 | * host it is given. Its look is quest.css (scoped to .ovq- classes, built on the kit's tokens); link it beside this |
| 17 | * script. Without it the card still says everything, plain. |
| 18 | */ |
| 19 | (function (root) { |
| 20 | ; |
| 21 | if (typeof document === 'undefined' || root.OpenVibeQuest) return; |
| 22 | |
| 23 | const QUEST_URL = 'https://openvibe.quest/'; |
| 24 | let cards = 0; |
| 25 | // The card's small marks are its own SVG (a site may not load Font Awesome): aria-hidden, the text's colour. |
| 26 | const PATHS = { |
| 27 | 'chevron-down': 'M6 9l6 6 6-6', 'chevron-right': 'M9 6l6 6-6 6', 'arrow-right': 'M5 12h14M13 6l6 6-6 6', |
| 28 | fire: 'M12 2.8c.6 3 3.2 4.7 3.2 8.2a3.2 3.2 0 0 1-6.4 0c0-1.3.4-2.2 1.1-3C7.8 9.6 5.8 11.8 5.8 14.8a6.2 6.2 0 0 0 12.4 0C18.2 9 13.1 6.4 12 2.8z', |
| 29 | }; |
| 30 | const glyph = (name, cls) => { |
| 31 | const span = document.createElement('span'); |
| 32 | span.className = `ovq-g ${cls || ''}`.trim(); |
| 33 | span.setAttribute('aria-hidden', 'true'); |
| 34 | span.innerHTML = `<svg viewBox="0 0 24 24"><path d="${PATHS[name]}"/></svg>`; |
| 35 | return span; |
| 36 | }; |
| 37 | const el = (tag, cls, text) => { |
| 38 | const node = document.createElement(tag); |
| 39 | if (cls) node.className = cls; |
| 40 | if (text != null) node.textContent = String(text); |
| 41 | return node; |
| 42 | }; |
| 43 | const href = (value) => (/^https:\/\//.test(String(value || '')) ? String(value) : QUEST_URL); |
| 44 | /** The site's name from its host: openvibe.pics → OpenVibe.Pics. */ |
| 45 | function siteName(host) { |
| 46 | const part = String(host || '').split('.')[1] || ''; |
| 47 | return part ? `OpenVibe.${part.charAt(0).toUpperCase()}${part.slice(1)}` : ''; |
| 48 | } |
| 49 | const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`; |
| 50 | /** The one line at the door: what waits on OpenVibe.Quest, from whatever the site could read. */ |
| 51 | function moreLine(more) { |
| 52 | const own = more.mine || null; |
| 53 | const bits = []; |
| 54 | if (own) { |
| 55 | const badges = Number(own.badges) || 0; |
| 56 | const going = Number(own.in_progress) || 0; |
| 57 | if (badges) bits.push(plural(badges, 'badge', 'badges')); |
| 58 | if (going) bits.push(`${plural(going, 'quest', 'quests')} going`); |
| 59 | const left = (Number(more.quests) || 0) - (Number(own.completed) || 0) - going; |
| 60 | if (left > 0) bits.push(`${left} more to try`); |
| 61 | } |
| 62 | if (!bits.length && Number(more.quests)) { |
| 63 | bits.push(`${more.quests} quests${Number(more.sites) ? ` on ${more.sites} sites` : ''}, each with a badge`); |
| 64 | } |
| 65 | if (!bits.length) bits.push('Every quest, your badges and your streak'); |
| 66 | return bits.join(' · '); |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Put a quest card into `host`. `load()` reads the data shape above (async); `refresh()` reads and redraws it. |
| 71 | * One card per host: mounting twice returns the first one. |
| 72 | */ |
| 73 | function mount(host, { load } = {}) { |
| 74 | if (!host || typeof host.appendChild !== 'function') throw new TypeError('OpenVibeQuest.mount: pass the element to fill'); |
| 75 | if (typeof load !== 'function') throw new TypeError('OpenVibeQuest.mount: load() must return the quest data'); |
| 76 | if (host._ovq) return host._ovq; |
| 77 | |
| 78 | const card = el('div', 'ovq-quest'); |
| 79 | card.hidden = true; |
| 80 | card.dataset.progress = '0'; |
| 81 | const head = el('button', 'ovq-head'); |
| 82 | head.type = 'button'; |
| 83 | head.setAttribute('aria-expanded', 'false'); |
| 84 | const icon = el('span', 'ovq-icon ov-icon'); |
| 85 | icon.dataset.icon = 'quest'; |
| 86 | icon.setAttribute('aria-hidden', 'true'); |
| 87 | const titles = el('span', 'ovq-titles'); |
| 88 | const kicker = el('span', 'ovq-kicker'); |
| 89 | const title = el('span', 'ovq-title'); |
| 90 | const bar = el('span', 'ovq-bar'); |
| 91 | bar.setAttribute('aria-hidden', 'true'); |
| 92 | bar.append(el('i'), el('i'), el('i')); |
| 93 | titles.append(kicker, title, bar); |
| 94 | const chip = el('span', 'ovq-chip'); |
| 95 | chip.hidden = true; |
| 96 | const chev = glyph('chevron-down', 'ovq-chev'); |
| 97 | chev.setAttribute('aria-hidden', 'true'); |
| 98 | head.append(icon, titles, chip, chev); |
| 99 | |
| 100 | const body = el('div', 'ovq-body'); |
| 101 | body.id = `ovq-body-${++cards}`; |
| 102 | head.setAttribute('aria-controls', body.id); |
| 103 | body.hidden = true; |
| 104 | const story = el('p', 'ovq-story'); |
| 105 | const steps = el('ol', 'ovq-steps'); |
| 106 | const more = el('a', 'ovq-more'); |
| 107 | more.href = QUEST_URL; |
| 108 | const moreText = el('span', 'ovq-more-text'); |
| 109 | const moreTitle = el('b'); |
| 110 | moreTitle.textContent = 'More on OpenVibe.Quest'; |
| 111 | const moreLineEl = el('span'); |
| 112 | moreText.append(moreTitle, moreLineEl); |
| 113 | const arrow = glyph('arrow-right', 'ovq-arrow'); |
| 114 | arrow.setAttribute('aria-hidden', 'true'); |
| 115 | more.append(moreText, arrow); |
| 116 | body.append(story, steps, more); |
| 117 | card.append(head, body); |
| 118 | host.appendChild(card); |
| 119 | |
| 120 | head.addEventListener('click', () => { |
| 121 | body.hidden = !body.hidden; |
| 122 | head.setAttribute('aria-expanded', String(!body.hidden)); |
| 123 | card.classList.toggle('open', !body.hidden); |
| 124 | }); |
| 125 | |
| 126 | /** Draw one answer. A quest with anything but three steps is not this card. */ |
| 127 | function draw(data) { |
| 128 | const quest = data && data.quest; |
| 129 | if (!quest || !Array.isArray(quest.steps) || quest.steps.length !== 3) { card.hidden = true; return false; } |
| 130 | const mine = !!data.signed_in; |
| 131 | const progress = Math.max(0, Math.min(3, Number(quest.progress) || 0)); |
| 132 | const streak = Math.max(0, Number(quest.streak) || 0); |
| 133 | card.dataset.progress = mine ? String(progress) : '0'; |
| 134 | kicker.textContent = !mine ? "TODAY'S QUEST" |
| 135 | : quest.completed ? "TODAY'S QUEST · DONE" : `TODAY'S QUEST · ${progress} OF 3`; |
| 136 | title.textContent = String(quest.title || ''); |
| 137 | chip.replaceChildren(); |
| 138 | chip.hidden = !(mine && streak > 0); |
| 139 | if (!chip.hidden) { |
| 140 | const fire = glyph('fire', 'ovq-fire is-fill'); |
| 141 | fire.setAttribute('aria-hidden', 'true'); |
| 142 | chip.append(fire, ` ${streak}`); |
| 143 | chip.title = `${streak}-day streak`; |
| 144 | chip.setAttribute('aria-label', chip.title); |
| 145 | } |
| 146 | story.textContent = String(quest.story || ''); |
| 147 | const next = mine && !quest.completed ? quest.steps.find((step) => !step.done) : null; |
| 148 | steps.replaceChildren(); |
| 149 | for (const step of quest.steps) { |
| 150 | const row = el('li'); |
| 151 | const a = el('a'); |
| 152 | a.href = href(step.href); |
| 153 | const siteIcon = el('span', 'ovq-site-icon ov-appicon'); |
| 154 | siteIcon.dataset.icon = String(step.site || '').split('.')[1] || 'quest'; |
| 155 | siteIcon.dataset.size = '32'; |
| 156 | siteIcon.setAttribute('aria-hidden', 'true'); |
| 157 | const text = el('span', 'ovq-step'); |
| 158 | const sentence = el('span', null, step.sentence || ''); |
| 159 | const where = el('small', null, step === next ? `Next · ${siteName(step.site)}` : siteName(step.site)); |
| 160 | text.append(sentence, where); |
| 161 | a.append(siteIcon, text); |
| 162 | if (mine && step.done) { |
| 163 | row.classList.add('is-done'); |
| 164 | const done = el('span', 'ovq-check', '✓'); |
| 165 | done.setAttribute('aria-label', 'Done'); |
| 166 | a.append(done); |
| 167 | } else { |
| 168 | const go = glyph('chevron-right', 'ovq-go'); |
| 169 | go.setAttribute('aria-hidden', 'true'); |
| 170 | a.append(go); |
| 171 | } |
| 172 | if (step === next) row.classList.add('is-next'); |
| 173 | row.append(a); |
| 174 | steps.append(row); |
| 175 | } |
| 176 | moreLineEl.textContent = moreLine((data && data.more) || {}); |
| 177 | card.hidden = false; |
| 178 | return true; |
| 179 | } |
| 180 | |
| 181 | let seq = 0; |
| 182 | async function refresh() { |
| 183 | const mine = ++seq; |
| 184 | let data = null; |
| 185 | try { data = await load(); } catch { data = null; } |
| 186 | if (mine !== seq) return false; |
| 187 | return draw(data); |
| 188 | } |
| 189 | const api = { refresh, el: card }; |
| 190 | host._ovq = api; |
| 191 | void refresh(); |
| 192 | return api; |
| 193 | } |
| 194 | |
| 195 | /** Mount every explicit quest host in a page. The site supplies its authenticated reader. */ |
| 196 | function mountAll({ load, root: page = document } = {}) { |
| 197 | if (typeof load !== 'function') throw new TypeError('OpenVibeQuest.mountAll: load() must return the quest data'); |
| 198 | return [...page.querySelectorAll('[data-ov-quest]')].map((host) => mount(host, { load: () => load(host) })); |
| 199 | } |
| 200 | |
| 201 | root.OpenVibeQuest = { mount, mountAll }; |
| 202 | if (typeof module === 'object' && module.exports) module.exports = { mount, mountAll }; |
| 203 | })(typeof window !== 'undefined' ? window : globalThis); |
| 204 |