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 ? `
${esc(poll.description)}
` : ''} `).join('')}${browseState.q ? 'Try another question, topic, or creator, or clear your filters.' : 'Try a different filter or create a poll for the community.'}
${esc(error.message)}
Sign in with your HF account to create and manage polls.
View results and manage your polls.
${esc(deadlineText(poll))}
Create a poll to get started.
Asked by ${esc(p.owner)} · ${new Date(p.created_at).toLocaleDateString()}
${esc(deadlineText(p))}
${p.description ? `${esc(p.description)}
` : ''} ${p.is_owner ? `` : ''} ${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 ? '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 = ``; 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);