File size: 22,474 Bytes
c8876d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
const app = document.querySelector('#app');
let currentUser = null, loginAvailable = false, timer, renderVersion = 0;
let expiryTimer;
const browseState = {q: '', status: 'open', sort: 'newest', page: 1};
const esc = value => String(value).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const toast = message => { const box = document.querySelector('#toast'); box.textContent = message; box.hidden = false; clearTimeout(timer); timer = setTimeout(() => box.hidden = true, 5000); };
async function api(url, options = {}) {
  let response;
  try { response = await fetch(url, { ...options, headers: {'Content-Type':'application/json', 'X-HuggingPolls':'1', ...options.headers} }); }
  catch { throw new Error('Could not connect. Check your connection and try again.'); }
  let data; try { data = await response.json(); } catch { throw new Error('The server is unavailable. Please try again.'); }
  if (!response.ok) {
    const error = new Error(typeof data.detail === 'string' ? data.detail : 'Please check your question and answer options.');
    error.status = response.status;
    throw error;
  }
  return data;
}
function login() {
  if (!loginAvailable) return toast('HF sign-in is available when this app runs on its Hugging Face Space.');
  // Leave the Space iframe so the OAuth session uses first-party cookies.
  const url = '/auth/login?poll=' + encodeURIComponent(location.hash.slice(1));
  if (window.self !== window.top) window.open(url, '_blank', 'noopener');
  else location.href = url;
}
async function loadAccount() {
  const result = await api('/api/me'); currentUser = result.user; loginAvailable = result.login_available;
  document.querySelector('#account').innerHTML = currentUser ? `<button class="sign-in" id="logout">${esc(currentUser.name)} · Sign out</button>` : '<button class="sign-in" id="login">🤗 Sign in with HF</button>';
  document.querySelector('#login')?.addEventListener('click', login);
  document.querySelector('#logout')?.addEventListener('click', async () => {try {await api('/api/logout', {method:'POST'}); await loadAccount(); route();} catch(e) {toast(e.message);}});
}
function readDraft() {
  return {
    question: document.querySelector('#question').value,
    description: document.querySelector('#description').value,
    options: [...document.querySelectorAll('.option-input')].map(input => input.value),
    duration_hours: Number(document.querySelector('#duration').value),
    listed: document.querySelector('#listed').checked
  };
}
function storedDraft() {
  try {
    const draft = JSON.parse(sessionStorage.getItem('draft'));
    if (draft && typeof draft.question === 'string' && typeof draft.description === 'string'
        && Array.isArray(draft.options) && draft.options.length >= 2 && draft.options.length <= 12
        && draft.options.every(option => typeof option === 'string' && option.length <= 160)
        && draft.question.length <= 240 && draft.description.length <= 2000
        && (draft.duration_hours === undefined || [1, 24, 72, 168, 720].includes(draft.duration_hours))
        && (draft.listed === undefined || typeof draft.listed === 'boolean')) return draft;
  } catch {}
  return null;
}
function saveDraft() {
  const draft = readDraft();
  const hasContent = Boolean(draft.question || draft.description || draft.options.some(Boolean)
    || draft.options.length > 2 || draft.duration_hours !== 168 || !draft.listed);
  document.querySelector('#clear-draft').hidden = !hasContent;
  try {
    if (hasContent) sessionStorage.setItem('draft', JSON.stringify(draft));
    else sessionStorage.removeItem('draft');
    document.querySelector('#draft-status').textContent = hasContent ? 'Draft saved in this tab.' : 'Drafts are saved in this tab as you type.';
  } catch {
    document.querySelector('#draft-status').textContent = 'Draft saving is unavailable in this browser.';
  }
}
function clearValidation() {
  document.querySelectorAll('#create-form input').forEach(input => {
    input.setCustomValidity('');
    input.removeAttribute('aria-invalid');
  });
}
function validateDraft() {
  clearValidation();
  const question = document.querySelector('#question');
  if (!question.value.trim()) question.setCustomValidity('Please enter a question.');
  const seen = new Set();
  document.querySelectorAll('.option-input').forEach(input => {
    const value = input.value.trim().toLowerCase();
    if (!value) input.setCustomValidity('Please enter an answer option.');
    else if (seen.has(value)) input.setCustomValidity('Please use a different answer for each option.');
    seen.add(value);
  });
  document.querySelectorAll('#create-form input').forEach(input => {
    if (!input.validity.valid) input.setAttribute('aria-invalid', 'true');
  });
  return document.querySelector('#create-form').reportValidity();
}
function optionRow(value = '') {
  const row = document.createElement('div');
  row.className = 'option-row';
  row.innerHTML = `<span class="option-index" aria-hidden="true"></span><input class="option-input" maxlength="160" required value="${esc(value)}"><button type="button" class="remove-option" aria-label="Remove option">×</button>`;
  row.querySelector('button').onclick = () => {
    if (document.querySelectorAll('.option-row').length <= 2) return;
    const neighbor = row.nextElementSibling || row.previousElementSibling;
    row.remove();
    numberOptions();
    clearValidation();
    saveDraft();
    neighbor?.querySelector('input').focus();
  };
  document.querySelector('#options').append(row);
  numberOptions();
  return row;
}
function numberOptions() {
  const rows = document.querySelectorAll('.option-row');
  rows.forEach((row, i) => {
    row.querySelector('.option-index').textContent = String(i + 1);
    row.querySelector('input').placeholder = `Option ${i + 1}`;
    row.querySelector('input').setAttribute('aria-label', `Option ${i + 1}`);
    const remove = row.querySelector('button');
    remove.disabled = rows.length <= 2;
    remove.setAttribute('aria-label', `Remove option ${i + 1}`);
    remove.title = rows.length <= 2 ? 'A poll needs at least two options' : `Remove option ${i + 1}`;
  });
  document.querySelector('#add-option').disabled = rows.length >= 12;
  document.querySelector('#options-count').textContent = `${rows.length} of 12 options`;
}
function home(restore = true) {
  app.replaceChildren(document.querySelector('#home-template').content.cloneNode(true));
  document.title = 'Create a poll · HuggingPolls';
  const draft = restore ? storedDraft() : null;
  document.querySelector('#question').value = draft?.question || '';
  document.querySelector('#description').value = draft?.description || '';
  document.querySelector('#duration').value = String(draft?.duration_hours ?? 168);
  document.querySelector('#listed').checked = draft?.listed ?? true;
  (draft?.options || ['', '']).forEach(optionRow);
  document.querySelector('#question-count').textContent = `${document.querySelector('#question').value.length} / 240`;
  saveDraft();
  if (draft) document.querySelector('#draft-status').textContent = 'Draft restored. Saved in this tab.';
  if (currentUser) document.querySelector('#create-note').innerHTML = `Creating as <b>${esc(currentUser.name)}</b>.`;
  document.querySelector('#add-option').onclick = () => {
    if (document.querySelectorAll('.option-row').length >= 12) return;
    const row = optionRow();
    saveDraft();
    row.querySelector('input').focus();
  };
  document.querySelector('#clear-draft').onclick = () => {
    try { sessionStorage.removeItem('draft'); } catch {}
    home(false);
    document.querySelector('#question').focus();
    toast('Draft cleared.');
  };
  const form = document.querySelector('#create-form');
  form.addEventListener('input', () => {
    clearValidation();
    document.querySelector('#question-count').textContent = `${document.querySelector('#question').value.length} / 240`;
    saveDraft();
  });
  form.onsubmit = async event => {
    event.preventDefault();
    if (!validateDraft()) return;
    saveDraft();
    if (!currentUser) return login();
    const button = document.querySelector('#create-button');
    button.disabled = true;
    button.setAttribute('aria-busy', 'true');
    button.textContent = 'Creating…';
    try {
      const poll = await api('/api/polls', {method: 'POST', body: JSON.stringify(readDraft())});
      try { sessionStorage.removeItem('draft'); } catch {}
      location.hash = poll.id;
      toast('Poll created.');
    } catch (e) {
      toast(e.message);
      button.disabled = false;
      button.removeAttribute('aria-busy');
      button.textContent = 'Create poll';
    }
  };
}
function pollBadge(poll) {
  return `<span class="tag ${poll.closed ? '' : 'tag-open'}">${poll.expired ? 'Expired' : poll.closed ? 'Closed' : 'Open'}</span>`;
}
function deadlineText(poll) {
  if (!poll.expires_at) return poll.closed ? 'Voting closed' : 'No scheduled end';
  const date = new Date(poll.expires_at).toLocaleString(undefined, {
    month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
  });
  return `${poll.expired ? 'Expired' : poll.closed ? 'Scheduled end:' : 'Closes'} ${date}`;
}
function watchExpiry(polls, version, refresh = route) {
  clearTimeout(expiryTimer);
  const delays = polls.filter(poll => poll.expires_at && !poll.expired)
    .map(poll => new Date(poll.expires_at).getTime() - Date.now());
  if (!delays.length) return;
  const delay = Math.min(...delays);
  // Recheck with the server if the browser clock is ahead; avoid a request loop.
  expiryTimer = setTimeout(() => {
    if (version === renderVersion) refresh();
  }, delay > 0 ? Math.min(delay + 250, 2147483647) : 30000);
}
async function browsePolls(version) {
  document.title = 'See polls \u00b7 HuggingPolls';
  app.replaceChildren(document.querySelector('#browse-template').content.cloneNode(true));
  const form = document.querySelector('#browse-form');
  const search = document.querySelector('#poll-search');
  const status = document.querySelector('#poll-status');
  const sort = document.querySelector('#poll-sort');
  const results = document.querySelector('#browse-results');
  const count = document.querySelector('#browse-count');
  const pager = document.querySelector('#browse-pagination');
  const clear = document.querySelector('#clear-filters');
  search.value = browseState.q;
  status.value = browseState.status;
  sort.value = browseState.sort;
  let requestVersion = 0;

  async function load() {
    const request = ++requestVersion;
    clearTimeout(expiryTimer);
    results.setAttribute('aria-busy', 'true');
    document.querySelector('#previous-page').disabled = true;
    document.querySelector('#next-page').disabled = true;
    count.textContent = 'Finding polls\u2026';
    clear.hidden = !browseState.q && browseState.status === 'open' && browseState.sort === 'newest';
    try {
      const data = await api('/api/browse?' + new URLSearchParams(browseState));
      if (version !== renderVersion || request !== requestVersion) return;
      browseState.page = data.page;
      count.textContent = `${data.total} poll${data.total === 1 ? '' : 's'}${browseState.q ? ` matching \u201c${browseState.q}\u201d` : ''}`;
      results.innerHTML = data.items.length ? `<div class="poll-grid">${data.items.map(poll => `

        <a class="browse-card" href="#${esc(poll.id)}">

          ${pollBadge(poll)}<h2>${esc(poll.question)}</h2>

          ${poll.description ? `<p class="browse-description">${esc(poll.description)}</p>` : ''}

          <div class="card-meta"><p class="subtle">By ${esc(poll.owner)} &middot; ${poll.total} vote${poll.total === 1 ? '' : 's'} &middot; ${poll.option_count} options</p>

          <p class="subtle deadline">${esc(deadlineText(poll))}</p></div>

        </a>`).join('')}</div>` : `<div class="card empty"><h2>${browseState.q ? 'No matching polls' : browseState.status === 'open' ? 'No open polls yet' : 'No polls here yet'}</h2><p class="subtle">${browseState.q ? 'Try another question, topic, or creator, or clear your filters.' : 'Try a different filter or create a poll for the community.'}</p><div class="actions"><a class="primary" href="#">+ Create poll</a></div></div>`;
      const pages = Math.max(1, Math.ceil(data.total / data.page_size));
      pager.hidden = pages <= 1;
      document.querySelector('#browse-page').textContent = `Page ${data.page} of ${pages}`;
      document.querySelector('#previous-page').disabled = data.page <= 1;
      document.querySelector('#next-page').disabled = data.page >= pages;
      watchExpiry(data.items, version, load);
    } catch (error) {
      if (version !== renderVersion || request !== requestVersion) return;
      count.textContent = 'Polls could not be loaded.';
      results.innerHTML = `<div class="card empty"><h2>Unable to browse polls</h2><p class="subtle">${esc(error.message)}</p><button class="secondary" id="retry-browse">Try again</button></div>`;
      pager.hidden = true;
      document.querySelector('#retry-browse').onclick = load;
    } finally {
      if (version === renderVersion && request === requestVersion) results.removeAttribute('aria-busy');
    }
  }
  form.onsubmit = event => {
    event.preventDefault();
    Object.assign(browseState, {q: search.value.trim(), status: status.value, sort: sort.value, page: 1});
    load();
  };
  status.onchange = sort.onchange = () => form.requestSubmit();
  clear.onclick = () => {
    search.value = '';
    status.value = 'open';
    sort.value = 'newest';
    form.requestSubmit();
  };
  document.querySelector('#previous-page').onclick = () => { browseState.page--; load(); };
  document.querySelector('#next-page').onclick = () => { browseState.page++; load(); };
  await load();
}
async function myPolls(version) {
  document.title = 'My polls \u00b7 HuggingPolls';
  if (!currentUser) {
    app.innerHTML = '<div class="empty"><h2>My polls</h2><p class="subtle">Sign in with your HF account to create and manage polls.</p><button id="list-login" class="primary">Sign in with HF</button></div>';
    document.querySelector('#list-login').onclick = login;
    return;
  }
  const polls = await api('/api/polls');
  if (version !== renderVersion) return;
  app.innerHTML = `<div class="page-title"><h1>My polls</h1><p class="subtle">View results and manage your polls.</p></div><section class="card">${polls.length ? polls.map(poll => `

    <a class="list-poll" href="#${esc(poll.id)}"><div><strong>${esc(poll.question)}</strong>

    <span class="subtle">${poll.total} vote${poll.total === 1 ? '' : 's'} &middot; ${poll.listed ? 'Listed' : 'Unlisted'} &middot; ${new Date(poll.created_at).toLocaleDateString()}</span>

    <p class="subtle deadline">${esc(deadlineText(poll))}</p></div>${pollBadge(poll)}</a>`).join('') : '<div class="empty"><h2>No polls yet</h2><p class="subtle">Create a poll to get started.</p></div>'}

    <div class="actions"><a class="primary" href="#">+ Create poll</a></div></section>`;
  watchExpiry(polls, version);
}
async function pollPage(id, version) {
  const p = await api('/api/polls/' + encodeURIComponent(id));
  if (version !== renderVersion) return;
  document.title = `${p.question} \u00b7 HuggingPolls`;
  const total = p.results?.reduce((a, b) => a + b, 0) || 0;
  const results = p.results ? `

    <div class="section-heading"><h2>${p.closed ? 'Final results' : 'Results'}</h2><span class="small-pill">${total} vote${total === 1 ? '' : 's'}</span></div>

    ${p.options.map((label, i) => `<div class="result"><div class="result-label"><span>${esc(label)}${p.choice === i ? ' &middot; Your vote' : ''}</span><b>${p.results[i]} &middot; ${total ? Math.round(p.results[i] / total * 100) : 0}%</b></div><div class="bar" aria-hidden="true"><span style="width:${total ? p.results[i] / total * 100 : 0}%"></span></div></div>`).join('')}

    <div class="actions"><button class="secondary" id="refresh-results">Refresh</button>${p.is_owner ? `

      <a class="secondary" href="/api/polls/${p.id}/export">Export CSV</a>

      ${!p.expired ? `<button class="secondary" id="toggle-status">${p.closed ? 'Reopen voting' : 'Close voting'}</button>` : ''}

      <button class="secondary" id="toggle-listing">${p.listed ? 'Unlist poll' : 'List publicly'}</button>

      <button class="quiet danger" id="delete-poll">Delete poll</button>` : ''}</div>` : '';
  app.innerHTML = `<a class="back" href="#${p.is_owner ? 'my-polls' : 'see-polls'}">&larr; ${p.is_owner ? 'My polls' : 'See polls'}</a>

    <section class="card poll-card"><div class="poll-meta">${pollBadge(p)}${!p.listed ? '<span class="tag">Unlisted</span>' : ''}</div>

    <h1>${esc(p.question)}</h1><p class="subtle">Asked by ${esc(p.owner)} &middot; ${new Date(p.created_at).toLocaleDateString()}</p>

    <p class="subtle deadline">${esc(deadlineText(p))}</p>

    ${p.description ? `<p class="description">${esc(p.description)}</p>` : ''}

    ${p.is_owner ? `<div class="share"><label for="share-url">Share link</label><div class="share-line"><input id="share-url" readonly value="${esc(p.url)}"><button class="primary" id="copy-link">Copy link</button></div><p class="subtle">${p.listed ? 'Listed on See polls. Anyone can discover this poll.' : 'Unlisted. Share this link with anyone you want to invite.'}</p></div>` : ''}

    ${p.voted ? `<p class="notice">Vote recorded: <b>${esc(p.options[p.choice])}</b>.</p>` : ''}

    ${p.closed ? `<p class="notice" id="poll-closed-notice">${p.expired ? 'This poll has expired. Voting has ended.' : 'This poll is closed.'} Final results are available below.</p>` : ''}

    ${results}

    ${!p.voted && !p.closed ? `${p.is_owner ? '<hr class="vote-divider"><h2>Vote</h2>' : ''}

      <form id="vote-form">${p.options.map((label, i) => `<label class="vote-option"><input type="radio" name="choice" value="${i}" required><span>${esc(label)}</span></label>`).join('')}

      <div class="actions"><button type="submit" class="primary" id="vote-button">Submit vote</button></div>

      <p class="subtle">${currentUser ? 'Voting while signed in.' : 'No account needed to vote.'} Results appear after voting or once the poll closes.</p></form>` : ''}</section>`;
  document.querySelector('#copy-link')?.addEventListener('click', async () => {
    try { await navigator.clipboard.writeText(p.url); toast('Poll link copied.'); }
    catch { document.querySelector('#share-url').select(); toast('Select and copy the poll link above.'); }
  });
  document.querySelector('#refresh-results')?.addEventListener('click', () => route());
  for (const [selector, change, message] of [
    ['#toggle-status', {closed: !p.closed}, p.closed ? 'Voting reopened.' : 'Voting closed.'],
    ['#toggle-listing', {listed: !p.listed}, p.listed ? 'Poll unlisted.' : 'Poll is now listed on See polls.']
  ]) {
    document.querySelector(selector)?.addEventListener('click', async event => {
      const button = event.currentTarget;
      button.disabled = true;
      try { await api('/api/polls/' + p.id, {method: 'PATCH', body: JSON.stringify(change)}); await route(); toast(message); }
      catch (error) { toast(error.message); button.disabled = false; if (error.status === 409) route(); }
    });
  }
  document.querySelector('#delete-poll')?.addEventListener('click', async () => {
    if (!confirm('Delete this poll and all its votes? This cannot be undone.')) return;
    try { await api('/api/polls/' + p.id, {method: 'DELETE'}); location.hash = 'my-polls'; toast('Poll deleted.'); }
    catch (error) { toast(error.message); }
  });
  document.querySelector('#vote-form')?.addEventListener('submit', async event => {
    event.preventDefault();
    const button = document.querySelector('#vote-button');
    button.disabled = true;
    button.setAttribute('aria-busy', 'true');
    button.textContent = 'Sending your vote\u2026';
    try {
      await api('/api/polls/' + p.id + '/vote', {method: 'POST', body: JSON.stringify({choice: Number(new FormData(event.target).get('choice'))})});
      await route();
    } catch (error) {
      toast(error.message);
      button.disabled = false;
      button.removeAttribute('aria-busy');
      button.textContent = 'Submit vote';
      if (error.status === 409) route();
    }
  });
  watchExpiry([p], version);
}
async function route() {
  const version = ++renderVersion;
  clearTimeout(expiryTimer);
  const id = location.hash.slice(1);
  for (const [selector, active] of [['#new-poll', !id], ['#see-polls', id === 'see-polls'], ['#my-polls', id === 'my-polls']]) {
    const tab = document.querySelector(selector);
    tab.classList.toggle('active', active);
    if (active) tab.setAttribute('aria-current', 'page');
    else tab.removeAttribute('aria-current');
  }
  app.setAttribute('aria-busy', 'true');
  try {
    if (!id) home();
    else {
      document.title = 'Loading · HuggingPolls';
      app.innerHTML = '<p class="empty subtle">Loading…</p>';
      if (id === 'see-polls') await browsePolls(version);
      else if (id === 'my-polls') await myPolls(version);
      else await pollPage(id, version);
    }
  } catch (e) {
    if (version === renderVersion) {
      document.title = 'Unable to load · HuggingPolls';
      app.innerHTML = `<div class="empty"><h2>Unable to load ${id === 'my-polls' ? 'your polls' : 'poll'}</h2><p class="subtle">${esc(e.message)}</p><div class="actions"><button class="secondary" id="retry-page">Try again</button><a class="primary" href="#">Create a poll</a></div></div>`;
      document.querySelector('#retry-page').onclick = () => route();
    }
  } finally {
    if (version === renderVersion) app.removeAttribute('aria-busy');
  }
}
document.querySelector('#my-polls').onclick=()=>location.hash='my-polls';
document.querySelector('.skip-link').onclick = event => { event.preventDefault(); app.focus(); };
window.addEventListener('hashchange', async () => { await route(); app.focus({preventScroll: true}); });
window.addEventListener('pageshow',event=>{if(event.persisted)loadAccount().then(route).catch(e=>toast(e.message));});
loadAccount().catch(e => toast(e.message)).then(route);