HuggingPolls / static /app.js
Banaxi-Tech's picture Hoglet-33's picture
Revamp (#1)
c8876d8
Raw History Blame Contribute Delete
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 => ({'&':'&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);