Spaces:
Sleeping
Sleeping
Download static/app.js from Banaxi-Tech/HuggingPolls: direct link, hf CLI and curl.
- Browser
- Download file 22.5 kB
-
https://huggingface.co/spaces/Banaxi-Tech/HuggingPolls/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/Banaxi-Tech/HuggingPolls/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/Banaxi-Tech/HuggingPolls/resolve/main/static/app.js
22.5 kB
| 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 ? `<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)} · ${poll.total} vote${poll.total === 1 ? '' : 's'} · ${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'} · ${poll.listed ? 'Listed' : 'Unlisted'} · ${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 ? ' · Your vote' : ''}</span><b>${p.results[i]} · ${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'}">← ${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)} · ${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); | |