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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 ? `` : ''; 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 = ``; 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 ${esc(currentUser.name)}.`; 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 `${poll.expired ? 'Expired' : poll.closed ? 'Closed' : 'Open'}`; } 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 ? `
${data.items.map(poll => ` ${pollBadge(poll)}

${esc(poll.question)}

${poll.description ? `

${esc(poll.description)}

` : ''}

By ${esc(poll.owner)} · ${poll.total} vote${poll.total === 1 ? '' : 's'} · ${poll.option_count} options

${esc(deadlineText(poll))}

`).join('')}
` : `

${browseState.q ? 'No matching polls' : browseState.status === 'open' ? 'No open polls yet' : 'No polls here yet'}

${browseState.q ? 'Try another question, topic, or creator, or clear your filters.' : 'Try a different filter or create a poll for the community.'}

+ Create poll
`; 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 = `

Unable to browse polls

${esc(error.message)}

`; 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 = '

My polls

Sign in with your HF account to create and manage polls.

'; document.querySelector('#list-login').onclick = login; return; } const polls = await api('/api/polls'); if (version !== renderVersion) return; app.innerHTML = `

My polls

View results and manage your polls.

${polls.length ? polls.map(poll => `
${esc(poll.question)} ${poll.total} vote${poll.total === 1 ? '' : 's'} · ${poll.listed ? 'Listed' : 'Unlisted'} · ${new Date(poll.created_at).toLocaleDateString()}

${esc(deadlineText(poll))}

${pollBadge(poll)}
`).join('') : '

No polls yet

Create a poll to get started.

'}
+ Create poll
`; 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 ? `

${p.closed ? 'Final results' : 'Results'}

${total} vote${total === 1 ? '' : 's'}
${p.options.map((label, i) => `
${esc(label)}${p.choice === i ? ' · Your vote' : ''}${p.results[i]} · ${total ? Math.round(p.results[i] / total * 100) : 0}%
`).join('')}
${p.is_owner ? ` Export CSV ${!p.expired ? `` : ''} ` : ''}
` : ''; app.innerHTML = `← ${p.is_owner ? 'My polls' : 'See polls'}
${pollBadge(p)}${!p.listed ? 'Unlisted' : ''}

${esc(p.question)}

Asked by ${esc(p.owner)} · ${new Date(p.created_at).toLocaleDateString()}

${esc(deadlineText(p))}

${p.description ? `

${esc(p.description)}

` : ''} ${p.is_owner ? `

${p.listed ? 'Listed on See polls. Anyone can discover this poll.' : 'Unlisted. Share this link with anyone you want to invite.'}

` : ''} ${p.voted ? `

Vote recorded: ${esc(p.options[p.choice])}.

` : ''} ${p.closed ? `

${p.expired ? 'This poll has expired. Voting has ended.' : 'This poll is closed.'} Final results are available below.

` : ''} ${results} ${!p.voted && !p.closed ? `${p.is_owner ? '

Vote

' : ''}
${p.options.map((label, i) => ``).join('')}

${currentUser ? 'Voting while signed in.' : 'No account needed to vote.'} Results appear after voting or once the poll closes.

` : ''}
`; 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 = '

Loading…

'; 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 = `

Unable to load ${id === 'my-polls' ? 'your polls' : 'poll'}

${esc(e.message)}

Create a poll
`; 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);