Spaces:
Sleeping
Sleeping
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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);
|