openvibers/openvibe.shared quest.js

quest.js

main 9.6 KB · 204 lines · javascript

1'use strict';
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 'use strict';
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