Spaces:
Sleeping
Sleeping
Revamp
#1
by Hoglet-33 - opened
- README.md +19 -1
- static/app.js +377 -65
- static/index.html +104 -19
- static/style.css +245 -1
- static/theme.js +38 -0
- tests/__pycache__/test_polls.cpython-314.pyc +0 -0
- tests/test_polls.py +160 -0
README.md
CHANGED
|
@@ -18,6 +18,20 @@ Sign in with Hugging Face to create polls, share `https://huggingface.co/spaces/
|
|
| 18 |
|
| 19 |
**HuggingPolls is an independent community project. It is not affiliated with, endorsed by, or sponsored by Hugging Face.**
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
## Run on a CPU Basic Docker Space
|
| 22 |
|
| 23 |
Upload this folder to a Docker Space. The metadata enables Hugging Face OAuth automatically. Set these Space secrets:
|
|
@@ -35,6 +49,10 @@ Install `requirements.txt`, then run `uvicorn app:app --host 0.0.0.0 --port 7860
|
|
| 35 |
|
| 36 |
## Voting and privacy
|
| 37 |
|
| 38 |
-
Each browser receives a signed anonymous identifier. Signed-in votes are also associated with a hashed HF identity. Results are available to the creator at any time
|
| 39 |
|
| 40 |
Open the app directly at its `hf.space` URL for sign-in: embedded browsers can block the cookies required by OAuth. Copied poll links use the normal `huggingface.co/spaces/OWNER/SPACE#POLL_ID` URL.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
**HuggingPolls is an independent community project. It is not affiliated with, endorsed by, or sponsored by Hugging Face.**
|
| 20 |
|
| 21 |
+
## Appearance and drafts
|
| 22 |
+
|
| 23 |
+
The app follows your system's light or dark appearance by default and updates when the system appearance changes. Use **Theme** at the bottom of any page to choose **System**, **Light**, or **Dark**. Your choice is remembered in this browser and applied before the page appears.
|
| 24 |
+
|
| 25 |
+
Poll drafts save automatically in the current tab while you type, including when you navigate away, reload, or return from sign-in. Use **Clear draft** to start over. Drafts are cleared after a poll is created and are not sent to the server until you submit. The form shows the question length and available option count, checks for blank or duplicate answers, and supports up to 12 options.
|
| 26 |
+
|
| 27 |
+
## Browse polls and voting deadlines
|
| 28 |
+
|
| 29 |
+
Open **See polls** to browse without signing in. Search questions, descriptions, or creators; filter open or closed polls; and sort by newest, most votes, or ending soon. Results appear in pages of 12 polls.
|
| 30 |
+
|
| 31 |
+
New polls are listed by default and expire after **7 days**. Choose **1 hour, 1 day, 3 days, 7 days, or 30 days** when creating a poll. Deadlines start at creation and are enforced by the server on every vote, including requests from an old browser tab. Expiry ends voting and keeps the poll and its final results available. Expired polls cannot reopen; manually closed polls can reopen until their deadline.
|
| 32 |
+
|
| 33 |
+
Turn off **List on See polls** for a poll that is only shared by link. Creators can later use **List publicly** or **Unlist poll** on their poll page. Existing polls keep their original links, have no scheduled deadline, and stay unlisted unless their creator lists them.
|
| 34 |
+
|
| 35 |
## Run on a CPU Basic Docker Space
|
| 36 |
|
| 37 |
Upload this folder to a Docker Space. The metadata enables Hugging Face OAuth automatically. Set these Space secrets:
|
|
|
|
| 49 |
|
| 50 |
## Voting and privacy
|
| 51 |
|
| 52 |
+
Each browser receives a signed anonymous identifier. Signed-in votes are also associated with a hashed HF identity. Results are available to the creator at any time, to voters after they vote, and to everyone once voting closes or expires, enforced on the server. CSV exports and management controls are restricted to the creator. No access tokens, email addresses, or raw account IDs of voters are saved. Creators' account IDs and usernames are saved for ownership. Anyone with the link can see the question and options. Listed polls also appear in public browsing; unlisted polls remain accessible to anyone with their link. Guest repeat-vote prevention is browser-based: clearing cookies or using a different browser can bypass it. These are casual polls, not verified elections.
|
| 53 |
|
| 54 |
Open the app directly at its `hf.space` URL for sign-in: embedded browsers can block the cookies required by OAuth. Copied poll links use the normal `huggingface.co/spaces/OWNER/SPACE#POLL_ID` URL.
|
| 55 |
+
|
| 56 |
+
## Checks
|
| 57 |
+
|
| 58 |
+
With the app's requirements installed, run `python -m unittest discover -s tests -v`. The tests use disposable local storage and cover expiry boundaries, late votes, reopening, listing permissions, browsing, filtering, and pagination.
|
static/app.js
CHANGED
|
@@ -1,65 +1,377 @@
|
|
| 1 |
-
const app = document.querySelector('#app');
|
| 2 |
-
let currentUser = null, loginAvailable = false, timer, renderVersion = 0;
|
| 3 |
-
|
| 4 |
-
const
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
}
|
| 11 |
-
|
| 12 |
-
if (!
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
document.querySelector('#
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
try {
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
document.querySelector('#
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const app = document.querySelector('#app');
|
| 2 |
+
let currentUser = null, loginAvailable = false, timer, renderVersion = 0;
|
| 3 |
+
let expiryTimer;
|
| 4 |
+
const browseState = {q: '', status: 'open', sort: 'newest', page: 1};
|
| 5 |
+
const esc = value => String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
| 6 |
+
const toast = message => { const box = document.querySelector('#toast'); box.textContent = message; box.hidden = false; clearTimeout(timer); timer = setTimeout(() => box.hidden = true, 5000); };
|
| 7 |
+
async function api(url, options = {}) {
|
| 8 |
+
let response;
|
| 9 |
+
try { response = await fetch(url, { ...options, headers: {'Content-Type':'application/json', 'X-HuggingPolls':'1', ...options.headers} }); }
|
| 10 |
+
catch { throw new Error('Could not connect. Check your connection and try again.'); }
|
| 11 |
+
let data; try { data = await response.json(); } catch { throw new Error('The server is unavailable. Please try again.'); }
|
| 12 |
+
if (!response.ok) {
|
| 13 |
+
const error = new Error(typeof data.detail === 'string' ? data.detail : 'Please check your question and answer options.');
|
| 14 |
+
error.status = response.status;
|
| 15 |
+
throw error;
|
| 16 |
+
}
|
| 17 |
+
return data;
|
| 18 |
+
}
|
| 19 |
+
function login() {
|
| 20 |
+
if (!loginAvailable) return toast('HF sign-in is available when this app runs on its Hugging Face Space.');
|
| 21 |
+
// Leave the Space iframe so the OAuth session uses first-party cookies.
|
| 22 |
+
const url = '/auth/login?poll=' + encodeURIComponent(location.hash.slice(1));
|
| 23 |
+
if (window.self !== window.top) window.open(url, '_blank', 'noopener');
|
| 24 |
+
else location.href = url;
|
| 25 |
+
}
|
| 26 |
+
async function loadAccount() {
|
| 27 |
+
const result = await api('/api/me'); currentUser = result.user; loginAvailable = result.login_available;
|
| 28 |
+
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>';
|
| 29 |
+
document.querySelector('#login')?.addEventListener('click', login);
|
| 30 |
+
document.querySelector('#logout')?.addEventListener('click', async () => {try {await api('/api/logout', {method:'POST'}); await loadAccount(); route();} catch(e) {toast(e.message);}});
|
| 31 |
+
}
|
| 32 |
+
function readDraft() {
|
| 33 |
+
return {
|
| 34 |
+
question: document.querySelector('#question').value,
|
| 35 |
+
description: document.querySelector('#description').value,
|
| 36 |
+
options: [...document.querySelectorAll('.option-input')].map(input => input.value),
|
| 37 |
+
duration_hours: Number(document.querySelector('#duration').value),
|
| 38 |
+
listed: document.querySelector('#listed').checked
|
| 39 |
+
};
|
| 40 |
+
}
|
| 41 |
+
function storedDraft() {
|
| 42 |
+
try {
|
| 43 |
+
const draft = JSON.parse(sessionStorage.getItem('draft'));
|
| 44 |
+
if (draft && typeof draft.question === 'string' && typeof draft.description === 'string'
|
| 45 |
+
&& Array.isArray(draft.options) && draft.options.length >= 2 && draft.options.length <= 12
|
| 46 |
+
&& draft.options.every(option => typeof option === 'string' && option.length <= 160)
|
| 47 |
+
&& draft.question.length <= 240 && draft.description.length <= 2000
|
| 48 |
+
&& (draft.duration_hours === undefined || [1, 24, 72, 168, 720].includes(draft.duration_hours))
|
| 49 |
+
&& (draft.listed === undefined || typeof draft.listed === 'boolean')) return draft;
|
| 50 |
+
} catch {}
|
| 51 |
+
return null;
|
| 52 |
+
}
|
| 53 |
+
function saveDraft() {
|
| 54 |
+
const draft = readDraft();
|
| 55 |
+
const hasContent = Boolean(draft.question || draft.description || draft.options.some(Boolean)
|
| 56 |
+
|| draft.options.length > 2 || draft.duration_hours !== 168 || !draft.listed);
|
| 57 |
+
document.querySelector('#clear-draft').hidden = !hasContent;
|
| 58 |
+
try {
|
| 59 |
+
if (hasContent) sessionStorage.setItem('draft', JSON.stringify(draft));
|
| 60 |
+
else sessionStorage.removeItem('draft');
|
| 61 |
+
document.querySelector('#draft-status').textContent = hasContent ? 'Draft saved in this tab.' : 'Drafts are saved in this tab as you type.';
|
| 62 |
+
} catch {
|
| 63 |
+
document.querySelector('#draft-status').textContent = 'Draft saving is unavailable in this browser.';
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
function clearValidation() {
|
| 67 |
+
document.querySelectorAll('#create-form input').forEach(input => {
|
| 68 |
+
input.setCustomValidity('');
|
| 69 |
+
input.removeAttribute('aria-invalid');
|
| 70 |
+
});
|
| 71 |
+
}
|
| 72 |
+
function validateDraft() {
|
| 73 |
+
clearValidation();
|
| 74 |
+
const question = document.querySelector('#question');
|
| 75 |
+
if (!question.value.trim()) question.setCustomValidity('Please enter a question.');
|
| 76 |
+
const seen = new Set();
|
| 77 |
+
document.querySelectorAll('.option-input').forEach(input => {
|
| 78 |
+
const value = input.value.trim().toLowerCase();
|
| 79 |
+
if (!value) input.setCustomValidity('Please enter an answer option.');
|
| 80 |
+
else if (seen.has(value)) input.setCustomValidity('Please use a different answer for each option.');
|
| 81 |
+
seen.add(value);
|
| 82 |
+
});
|
| 83 |
+
document.querySelectorAll('#create-form input').forEach(input => {
|
| 84 |
+
if (!input.validity.valid) input.setAttribute('aria-invalid', 'true');
|
| 85 |
+
});
|
| 86 |
+
return document.querySelector('#create-form').reportValidity();
|
| 87 |
+
}
|
| 88 |
+
function optionRow(value = '') {
|
| 89 |
+
const row = document.createElement('div');
|
| 90 |
+
row.className = 'option-row';
|
| 91 |
+
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>`;
|
| 92 |
+
row.querySelector('button').onclick = () => {
|
| 93 |
+
if (document.querySelectorAll('.option-row').length <= 2) return;
|
| 94 |
+
const neighbor = row.nextElementSibling || row.previousElementSibling;
|
| 95 |
+
row.remove();
|
| 96 |
+
numberOptions();
|
| 97 |
+
clearValidation();
|
| 98 |
+
saveDraft();
|
| 99 |
+
neighbor?.querySelector('input').focus();
|
| 100 |
+
};
|
| 101 |
+
document.querySelector('#options').append(row);
|
| 102 |
+
numberOptions();
|
| 103 |
+
return row;
|
| 104 |
+
}
|
| 105 |
+
function numberOptions() {
|
| 106 |
+
const rows = document.querySelectorAll('.option-row');
|
| 107 |
+
rows.forEach((row, i) => {
|
| 108 |
+
row.querySelector('.option-index').textContent = String(i + 1);
|
| 109 |
+
row.querySelector('input').placeholder = `Option ${i + 1}`;
|
| 110 |
+
row.querySelector('input').setAttribute('aria-label', `Option ${i + 1}`);
|
| 111 |
+
const remove = row.querySelector('button');
|
| 112 |
+
remove.disabled = rows.length <= 2;
|
| 113 |
+
remove.setAttribute('aria-label', `Remove option ${i + 1}`);
|
| 114 |
+
remove.title = rows.length <= 2 ? 'A poll needs at least two options' : `Remove option ${i + 1}`;
|
| 115 |
+
});
|
| 116 |
+
document.querySelector('#add-option').disabled = rows.length >= 12;
|
| 117 |
+
document.querySelector('#options-count').textContent = `${rows.length} of 12 options`;
|
| 118 |
+
}
|
| 119 |
+
function home(restore = true) {
|
| 120 |
+
app.replaceChildren(document.querySelector('#home-template').content.cloneNode(true));
|
| 121 |
+
document.title = 'Create a poll · HuggingPolls';
|
| 122 |
+
const draft = restore ? storedDraft() : null;
|
| 123 |
+
document.querySelector('#question').value = draft?.question || '';
|
| 124 |
+
document.querySelector('#description').value = draft?.description || '';
|
| 125 |
+
document.querySelector('#duration').value = String(draft?.duration_hours ?? 168);
|
| 126 |
+
document.querySelector('#listed').checked = draft?.listed ?? true;
|
| 127 |
+
(draft?.options || ['', '']).forEach(optionRow);
|
| 128 |
+
document.querySelector('#question-count').textContent = `${document.querySelector('#question').value.length} / 240`;
|
| 129 |
+
saveDraft();
|
| 130 |
+
if (draft) document.querySelector('#draft-status').textContent = 'Draft restored. Saved in this tab.';
|
| 131 |
+
if (currentUser) document.querySelector('#create-note').innerHTML = `Creating as <b>${esc(currentUser.name)}</b>.`;
|
| 132 |
+
document.querySelector('#add-option').onclick = () => {
|
| 133 |
+
if (document.querySelectorAll('.option-row').length >= 12) return;
|
| 134 |
+
const row = optionRow();
|
| 135 |
+
saveDraft();
|
| 136 |
+
row.querySelector('input').focus();
|
| 137 |
+
};
|
| 138 |
+
document.querySelector('#clear-draft').onclick = () => {
|
| 139 |
+
try { sessionStorage.removeItem('draft'); } catch {}
|
| 140 |
+
home(false);
|
| 141 |
+
document.querySelector('#question').focus();
|
| 142 |
+
toast('Draft cleared.');
|
| 143 |
+
};
|
| 144 |
+
const form = document.querySelector('#create-form');
|
| 145 |
+
form.addEventListener('input', () => {
|
| 146 |
+
clearValidation();
|
| 147 |
+
document.querySelector('#question-count').textContent = `${document.querySelector('#question').value.length} / 240`;
|
| 148 |
+
saveDraft();
|
| 149 |
+
});
|
| 150 |
+
form.onsubmit = async event => {
|
| 151 |
+
event.preventDefault();
|
| 152 |
+
if (!validateDraft()) return;
|
| 153 |
+
saveDraft();
|
| 154 |
+
if (!currentUser) return login();
|
| 155 |
+
const button = document.querySelector('#create-button');
|
| 156 |
+
button.disabled = true;
|
| 157 |
+
button.setAttribute('aria-busy', 'true');
|
| 158 |
+
button.textContent = 'Creating…';
|
| 159 |
+
try {
|
| 160 |
+
const poll = await api('/api/polls', {method: 'POST', body: JSON.stringify(readDraft())});
|
| 161 |
+
try { sessionStorage.removeItem('draft'); } catch {}
|
| 162 |
+
location.hash = poll.id;
|
| 163 |
+
toast('Poll created.');
|
| 164 |
+
} catch (e) {
|
| 165 |
+
toast(e.message);
|
| 166 |
+
button.disabled = false;
|
| 167 |
+
button.removeAttribute('aria-busy');
|
| 168 |
+
button.textContent = 'Create poll';
|
| 169 |
+
}
|
| 170 |
+
};
|
| 171 |
+
}
|
| 172 |
+
function pollBadge(poll) {
|
| 173 |
+
return `<span class="tag ${poll.closed ? '' : 'tag-open'}">${poll.expired ? 'Expired' : poll.closed ? 'Closed' : 'Open'}</span>`;
|
| 174 |
+
}
|
| 175 |
+
function deadlineText(poll) {
|
| 176 |
+
if (!poll.expires_at) return poll.closed ? 'Voting closed' : 'No scheduled end';
|
| 177 |
+
const date = new Date(poll.expires_at).toLocaleString(undefined, {
|
| 178 |
+
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
|
| 179 |
+
});
|
| 180 |
+
return `${poll.expired ? 'Expired' : poll.closed ? 'Scheduled end:' : 'Closes'} ${date}`;
|
| 181 |
+
}
|
| 182 |
+
function watchExpiry(polls, version, refresh = route) {
|
| 183 |
+
clearTimeout(expiryTimer);
|
| 184 |
+
const delays = polls.filter(poll => poll.expires_at && !poll.expired)
|
| 185 |
+
.map(poll => new Date(poll.expires_at).getTime() - Date.now());
|
| 186 |
+
if (!delays.length) return;
|
| 187 |
+
const delay = Math.min(...delays);
|
| 188 |
+
// Recheck with the server if the browser clock is ahead; avoid a request loop.
|
| 189 |
+
expiryTimer = setTimeout(() => {
|
| 190 |
+
if (version === renderVersion) refresh();
|
| 191 |
+
}, delay > 0 ? Math.min(delay + 250, 2147483647) : 30000);
|
| 192 |
+
}
|
| 193 |
+
async function browsePolls(version) {
|
| 194 |
+
document.title = 'See polls \u00b7 HuggingPolls';
|
| 195 |
+
app.replaceChildren(document.querySelector('#browse-template').content.cloneNode(true));
|
| 196 |
+
const form = document.querySelector('#browse-form');
|
| 197 |
+
const search = document.querySelector('#poll-search');
|
| 198 |
+
const status = document.querySelector('#poll-status');
|
| 199 |
+
const sort = document.querySelector('#poll-sort');
|
| 200 |
+
const results = document.querySelector('#browse-results');
|
| 201 |
+
const count = document.querySelector('#browse-count');
|
| 202 |
+
const pager = document.querySelector('#browse-pagination');
|
| 203 |
+
const clear = document.querySelector('#clear-filters');
|
| 204 |
+
search.value = browseState.q;
|
| 205 |
+
status.value = browseState.status;
|
| 206 |
+
sort.value = browseState.sort;
|
| 207 |
+
let requestVersion = 0;
|
| 208 |
+
|
| 209 |
+
async function load() {
|
| 210 |
+
const request = ++requestVersion;
|
| 211 |
+
clearTimeout(expiryTimer);
|
| 212 |
+
results.setAttribute('aria-busy', 'true');
|
| 213 |
+
document.querySelector('#previous-page').disabled = true;
|
| 214 |
+
document.querySelector('#next-page').disabled = true;
|
| 215 |
+
count.textContent = 'Finding polls\u2026';
|
| 216 |
+
clear.hidden = !browseState.q && browseState.status === 'open' && browseState.sort === 'newest';
|
| 217 |
+
try {
|
| 218 |
+
const data = await api('/api/browse?' + new URLSearchParams(browseState));
|
| 219 |
+
if (version !== renderVersion || request !== requestVersion) return;
|
| 220 |
+
browseState.page = data.page;
|
| 221 |
+
count.textContent = `${data.total} poll${data.total === 1 ? '' : 's'}${browseState.q ? ` matching \u201c${browseState.q}\u201d` : ''}`;
|
| 222 |
+
results.innerHTML = data.items.length ? `<div class="poll-grid">${data.items.map(poll => `
|
| 223 |
+
<a class="browse-card" href="#${esc(poll.id)}">
|
| 224 |
+
${pollBadge(poll)}<h2>${esc(poll.question)}</h2>
|
| 225 |
+
${poll.description ? `<p class="browse-description">${esc(poll.description)}</p>` : ''}
|
| 226 |
+
<div class="card-meta"><p class="subtle">By ${esc(poll.owner)} · ${poll.total} vote${poll.total === 1 ? '' : 's'} · ${poll.option_count} options</p>
|
| 227 |
+
<p class="subtle deadline">${esc(deadlineText(poll))}</p></div>
|
| 228 |
+
</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>`;
|
| 229 |
+
const pages = Math.max(1, Math.ceil(data.total / data.page_size));
|
| 230 |
+
pager.hidden = pages <= 1;
|
| 231 |
+
document.querySelector('#browse-page').textContent = `Page ${data.page} of ${pages}`;
|
| 232 |
+
document.querySelector('#previous-page').disabled = data.page <= 1;
|
| 233 |
+
document.querySelector('#next-page').disabled = data.page >= pages;
|
| 234 |
+
watchExpiry(data.items, version, load);
|
| 235 |
+
} catch (error) {
|
| 236 |
+
if (version !== renderVersion || request !== requestVersion) return;
|
| 237 |
+
count.textContent = 'Polls could not be loaded.';
|
| 238 |
+
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>`;
|
| 239 |
+
pager.hidden = true;
|
| 240 |
+
document.querySelector('#retry-browse').onclick = load;
|
| 241 |
+
} finally {
|
| 242 |
+
if (version === renderVersion && request === requestVersion) results.removeAttribute('aria-busy');
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
form.onsubmit = event => {
|
| 246 |
+
event.preventDefault();
|
| 247 |
+
Object.assign(browseState, {q: search.value.trim(), status: status.value, sort: sort.value, page: 1});
|
| 248 |
+
load();
|
| 249 |
+
};
|
| 250 |
+
status.onchange = sort.onchange = () => form.requestSubmit();
|
| 251 |
+
clear.onclick = () => {
|
| 252 |
+
search.value = '';
|
| 253 |
+
status.value = 'open';
|
| 254 |
+
sort.value = 'newest';
|
| 255 |
+
form.requestSubmit();
|
| 256 |
+
};
|
| 257 |
+
document.querySelector('#previous-page').onclick = () => { browseState.page--; load(); };
|
| 258 |
+
document.querySelector('#next-page').onclick = () => { browseState.page++; load(); };
|
| 259 |
+
await load();
|
| 260 |
+
}
|
| 261 |
+
async function myPolls(version) {
|
| 262 |
+
document.title = 'My polls \u00b7 HuggingPolls';
|
| 263 |
+
if (!currentUser) {
|
| 264 |
+
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>';
|
| 265 |
+
document.querySelector('#list-login').onclick = login;
|
| 266 |
+
return;
|
| 267 |
+
}
|
| 268 |
+
const polls = await api('/api/polls');
|
| 269 |
+
if (version !== renderVersion) return;
|
| 270 |
+
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 => `
|
| 271 |
+
<a class="list-poll" href="#${esc(poll.id)}"><div><strong>${esc(poll.question)}</strong>
|
| 272 |
+
<span class="subtle">${poll.total} vote${poll.total === 1 ? '' : 's'} · ${poll.listed ? 'Listed' : 'Unlisted'} · ${new Date(poll.created_at).toLocaleDateString()}</span>
|
| 273 |
+
<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>'}
|
| 274 |
+
<div class="actions"><a class="primary" href="#">+ Create poll</a></div></section>`;
|
| 275 |
+
watchExpiry(polls, version);
|
| 276 |
+
}
|
| 277 |
+
async function pollPage(id, version) {
|
| 278 |
+
const p = await api('/api/polls/' + encodeURIComponent(id));
|
| 279 |
+
if (version !== renderVersion) return;
|
| 280 |
+
document.title = `${p.question} \u00b7 HuggingPolls`;
|
| 281 |
+
const total = p.results?.reduce((a, b) => a + b, 0) || 0;
|
| 282 |
+
const results = p.results ? `
|
| 283 |
+
<div class="section-heading"><h2>${p.closed ? 'Final results' : 'Results'}</h2><span class="small-pill">${total} vote${total === 1 ? '' : 's'}</span></div>
|
| 284 |
+
${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('')}
|
| 285 |
+
<div class="actions"><button class="secondary" id="refresh-results">Refresh</button>${p.is_owner ? `
|
| 286 |
+
<a class="secondary" href="/api/polls/${p.id}/export">Export CSV</a>
|
| 287 |
+
${!p.expired ? `<button class="secondary" id="toggle-status">${p.closed ? 'Reopen voting' : 'Close voting'}</button>` : ''}
|
| 288 |
+
<button class="secondary" id="toggle-listing">${p.listed ? 'Unlist poll' : 'List publicly'}</button>
|
| 289 |
+
<button class="quiet danger" id="delete-poll">Delete poll</button>` : ''}</div>` : '';
|
| 290 |
+
app.innerHTML = `<a class="back" href="#${p.is_owner ? 'my-polls' : 'see-polls'}">← ${p.is_owner ? 'My polls' : 'See polls'}</a>
|
| 291 |
+
<section class="card poll-card"><div class="poll-meta">${pollBadge(p)}${!p.listed ? '<span class="tag">Unlisted</span>' : ''}</div>
|
| 292 |
+
<h1>${esc(p.question)}</h1><p class="subtle">Asked by ${esc(p.owner)} · ${new Date(p.created_at).toLocaleDateString()}</p>
|
| 293 |
+
<p class="subtle deadline">${esc(deadlineText(p))}</p>
|
| 294 |
+
${p.description ? `<p class="description">${esc(p.description)}</p>` : ''}
|
| 295 |
+
${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>` : ''}
|
| 296 |
+
${p.voted ? `<p class="notice">Vote recorded: <b>${esc(p.options[p.choice])}</b>.</p>` : ''}
|
| 297 |
+
${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>` : ''}
|
| 298 |
+
${results}
|
| 299 |
+
${!p.voted && !p.closed ? `${p.is_owner ? '<hr class="vote-divider"><h2>Vote</h2>' : ''}
|
| 300 |
+
<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('')}
|
| 301 |
+
<div class="actions"><button type="submit" class="primary" id="vote-button">Submit vote</button></div>
|
| 302 |
+
<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>`;
|
| 303 |
+
document.querySelector('#copy-link')?.addEventListener('click', async () => {
|
| 304 |
+
try { await navigator.clipboard.writeText(p.url); toast('Poll link copied.'); }
|
| 305 |
+
catch { document.querySelector('#share-url').select(); toast('Select and copy the poll link above.'); }
|
| 306 |
+
});
|
| 307 |
+
document.querySelector('#refresh-results')?.addEventListener('click', () => route());
|
| 308 |
+
for (const [selector, change, message] of [
|
| 309 |
+
['#toggle-status', {closed: !p.closed}, p.closed ? 'Voting reopened.' : 'Voting closed.'],
|
| 310 |
+
['#toggle-listing', {listed: !p.listed}, p.listed ? 'Poll unlisted.' : 'Poll is now listed on See polls.']
|
| 311 |
+
]) {
|
| 312 |
+
document.querySelector(selector)?.addEventListener('click', async event => {
|
| 313 |
+
const button = event.currentTarget;
|
| 314 |
+
button.disabled = true;
|
| 315 |
+
try { await api('/api/polls/' + p.id, {method: 'PATCH', body: JSON.stringify(change)}); await route(); toast(message); }
|
| 316 |
+
catch (error) { toast(error.message); button.disabled = false; if (error.status === 409) route(); }
|
| 317 |
+
});
|
| 318 |
+
}
|
| 319 |
+
document.querySelector('#delete-poll')?.addEventListener('click', async () => {
|
| 320 |
+
if (!confirm('Delete this poll and all its votes? This cannot be undone.')) return;
|
| 321 |
+
try { await api('/api/polls/' + p.id, {method: 'DELETE'}); location.hash = 'my-polls'; toast('Poll deleted.'); }
|
| 322 |
+
catch (error) { toast(error.message); }
|
| 323 |
+
});
|
| 324 |
+
document.querySelector('#vote-form')?.addEventListener('submit', async event => {
|
| 325 |
+
event.preventDefault();
|
| 326 |
+
const button = document.querySelector('#vote-button');
|
| 327 |
+
button.disabled = true;
|
| 328 |
+
button.setAttribute('aria-busy', 'true');
|
| 329 |
+
button.textContent = 'Sending your vote\u2026';
|
| 330 |
+
try {
|
| 331 |
+
await api('/api/polls/' + p.id + '/vote', {method: 'POST', body: JSON.stringify({choice: Number(new FormData(event.target).get('choice'))})});
|
| 332 |
+
await route();
|
| 333 |
+
} catch (error) {
|
| 334 |
+
toast(error.message);
|
| 335 |
+
button.disabled = false;
|
| 336 |
+
button.removeAttribute('aria-busy');
|
| 337 |
+
button.textContent = 'Submit vote';
|
| 338 |
+
if (error.status === 409) route();
|
| 339 |
+
}
|
| 340 |
+
});
|
| 341 |
+
watchExpiry([p], version);
|
| 342 |
+
}
|
| 343 |
+
async function route() {
|
| 344 |
+
const version = ++renderVersion;
|
| 345 |
+
clearTimeout(expiryTimer);
|
| 346 |
+
const id = location.hash.slice(1);
|
| 347 |
+
for (const [selector, active] of [['#new-poll', !id], ['#see-polls', id === 'see-polls'], ['#my-polls', id === 'my-polls']]) {
|
| 348 |
+
const tab = document.querySelector(selector);
|
| 349 |
+
tab.classList.toggle('active', active);
|
| 350 |
+
if (active) tab.setAttribute('aria-current', 'page');
|
| 351 |
+
else tab.removeAttribute('aria-current');
|
| 352 |
+
}
|
| 353 |
+
app.setAttribute('aria-busy', 'true');
|
| 354 |
+
try {
|
| 355 |
+
if (!id) home();
|
| 356 |
+
else {
|
| 357 |
+
document.title = 'Loading · HuggingPolls';
|
| 358 |
+
app.innerHTML = '<p class="empty subtle">Loading…</p>';
|
| 359 |
+
if (id === 'see-polls') await browsePolls(version);
|
| 360 |
+
else if (id === 'my-polls') await myPolls(version);
|
| 361 |
+
else await pollPage(id, version);
|
| 362 |
+
}
|
| 363 |
+
} catch (e) {
|
| 364 |
+
if (version === renderVersion) {
|
| 365 |
+
document.title = 'Unable to load · HuggingPolls';
|
| 366 |
+
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>`;
|
| 367 |
+
document.querySelector('#retry-page').onclick = () => route();
|
| 368 |
+
}
|
| 369 |
+
} finally {
|
| 370 |
+
if (version === renderVersion) app.removeAttribute('aria-busy');
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
document.querySelector('#my-polls').onclick=()=>location.hash='my-polls';
|
| 374 |
+
document.querySelector('.skip-link').onclick = event => { event.preventDefault(); app.focus(); };
|
| 375 |
+
window.addEventListener('hashchange', async () => { await route(); app.focus({preventScroll: true}); });
|
| 376 |
+
window.addEventListener('pageshow',event=>{if(event.persisted)loadAccount().then(route).catch(e=>toast(e.message));});
|
| 377 |
+
loadAccount().catch(e => toast(e.message)).then(route);
|
static/index.html
CHANGED
|
@@ -1,19 +1,104 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8">
|
| 5 |
-
<meta name="
|
| 6 |
-
<
|
| 7 |
-
<
|
| 8 |
-
<
|
| 9 |
-
<
|
| 10 |
-
<
|
| 11 |
-
<
|
| 12 |
-
<
|
| 13 |
-
<
|
| 14 |
-
<
|
| 15 |
-
<
|
| 16 |
-
<
|
| 17 |
-
</
|
| 18 |
-
<
|
| 19 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
+
<meta name="theme-color" content="#ffffff">
|
| 7 |
+
<meta name="color-scheme" content="light dark">
|
| 8 |
+
<meta name="description" content="Create polls, share a link, and view your results. Sign in with Hugging Face to create a poll. Anyone can vote.">
|
| 9 |
+
<title>HuggingPolls</title>
|
| 10 |
+
<script src="/static/theme.js"></script>
|
| 11 |
+
<link rel="icon" href="/static/logo.png">
|
| 12 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<a class="skip-link" href="#app">Skip to content</a>
|
| 16 |
+
<header>
|
| 17 |
+
<a class="brand" href="#"><img src="/static/logo.png" alt="">HuggingPolls</a>
|
| 18 |
+
<span class="header-description">Community polls</span>
|
| 19 |
+
<span id="account"></span>
|
| 20 |
+
</header>
|
| 21 |
+
<div class="page-header">
|
| 22 |
+
<div class="project-title">
|
| 23 |
+
<img src="/static/logo.png" alt="Judge Huggy">
|
| 24 |
+
<div><h1>HuggingPolls</h1><p>Create a poll and share it with anyone.</p></div>
|
| 25 |
+
</div>
|
| 26 |
+
<nav aria-label="Poll navigation">
|
| 27 |
+
<a id="new-poll" class="tab" href="#">
|
| 28 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M12 8v8M8 12h8"/></svg>
|
| 29 |
+
Create poll
|
| 30 |
+
</a>
|
| 31 |
+
<a id="see-polls" class="tab" href="#see-polls">
|
| 32 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
| 33 |
+
See polls
|
| 34 |
+
</a>
|
| 35 |
+
<a id="my-polls" class="tab" href="#my-polls">
|
| 36 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M5 20V10h3v10M11 20V4h3v16M17 20v-7h3v7"/></svg>
|
| 37 |
+
My polls
|
| 38 |
+
</a>
|
| 39 |
+
</nav>
|
| 40 |
+
</div>
|
| 41 |
+
<main id="app" tabindex="-1" aria-live="polite"></main>
|
| 42 |
+
<footer>
|
| 43 |
+
<p>HuggingPolls is an independent community project. Not affiliated with, endorsed by, or sponsored by Hugging Face.</p>
|
| 44 |
+
<div class="theme-control">
|
| 45 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/></svg>
|
| 46 |
+
<label for="theme-select">Theme</label>
|
| 47 |
+
<select id="theme-select" aria-describedby="theme-status">
|
| 48 |
+
<option value="system">System</option>
|
| 49 |
+
<option value="light">Light</option>
|
| 50 |
+
<option value="dark">Dark</option>
|
| 51 |
+
</select>
|
| 52 |
+
<span id="theme-status" class="sr-only" role="status"></span>
|
| 53 |
+
</div>
|
| 54 |
+
</footer>
|
| 55 |
+
<div id="toast" role="status" hidden></div>
|
| 56 |
+
<template id="home-template">
|
| 57 |
+
<div class="workspace">
|
| 58 |
+
<section class="create-section">
|
| 59 |
+
<div class="section-heading"><h2>Create a poll</h2><p>Add a question and at least two answer options.</p></div>
|
| 60 |
+
<form id="create-form">
|
| 61 |
+
<div class="label-row"><label for="question">Question <span aria-hidden="true">*</span></label><small id="question-count">0 / 240</small></div>
|
| 62 |
+
<input id="question" maxlength="240" placeholder="What would you like to ask?" aria-describedby="question-count" required>
|
| 63 |
+
<label for="description">Description <small>optional</small></label>
|
| 64 |
+
<textarea id="description" maxlength="2000" placeholder="Add context for voters" rows="3"></textarea>
|
| 65 |
+
<div class="label-row"><label id="options-label">Answer options <span aria-hidden="true">*</span></label><small id="options-count">2–12 options</small></div>
|
| 66 |
+
<div id="options" role="group" aria-labelledby="options-label"></div>
|
| 67 |
+
<button type="button" id="add-option" class="secondary add-option">+ Add option</button>
|
| 68 |
+
<div class="poll-settings">
|
| 69 |
+
<label for="duration">Voting duration</label>
|
| 70 |
+
<select id="duration" aria-describedby="duration-help">
|
| 71 |
+
<option value="1">1 hour</option>
|
| 72 |
+
<option value="24">1 day</option>
|
| 73 |
+
<option value="72">3 days</option>
|
| 74 |
+
<option value="168" selected>7 days</option>
|
| 75 |
+
<option value="720">30 days</option>
|
| 76 |
+
</select>
|
| 77 |
+
<p id="duration-help" class="subtle">Starts when you create the poll. Voting closes automatically; results stay available.</p>
|
| 78 |
+
<label class="listing-choice" for="listed"><input id="listed" type="checkbox" checked><span><strong>List on See polls</strong><small>Let anyone discover your poll. Turn off to share it only by link.</small></span></label>
|
| 79 |
+
</div>
|
| 80 |
+
<div class="draft-row"><span id="draft-status" class="subtle" role="status"></span><button type="button" id="clear-draft" class="quiet" hidden>Clear draft</button></div>
|
| 81 |
+
<div class="form-bottom"><p id="create-note" class="subtle">Sign in with Hugging Face to create a poll.</p><button class="primary" id="create-button" type="submit">Create poll</button></div>
|
| 82 |
+
</form>
|
| 83 |
+
</section>
|
| 84 |
+
<aside>
|
| 85 |
+
<h3>Discover and share</h3><p>Browse community questions in <a class="text-link" href="#see-polls">See polls</a>, or share a poll using its link. Anyone can vote, with or without an account.</p>
|
| 86 |
+
<h3>Results and expiry</h3><p>Results appear after voting or once a poll closes. Choose how long voting stays open, from 1 hour to 30 days. Creators can view results and export votes at any time.</p>
|
| 87 |
+
<h3>Voting</h3><p>Each voter selects one answer. Guest votes are limited by browser; signed-in votes are also checked by account.</p>
|
| 88 |
+
</aside>
|
| 89 |
+
</div>
|
| 90 |
+
</template>
|
| 91 |
+
<template id="browse-template">
|
| 92 |
+
<div class="page-title browse-title"><div><h1>See polls</h1><p class="subtle">Explore community questions and add your vote. No account needed.</p></div><a class="primary" href="#">+ Create poll</a></div>
|
| 93 |
+
<form id="browse-form" class="browse-filters" role="search" aria-label="Find polls">
|
| 94 |
+
<div class="browse-search"><label for="poll-search">Search polls</label><div class="search-line"><input type="search" id="poll-search" maxlength="240" placeholder="Question, topic, or creator"><button type="submit" class="primary">Search</button></div></div>
|
| 95 |
+
<div><label for="poll-status">Status</label><select id="poll-status"><option value="open">Open for voting</option><option value="closed">Closed & expired</option><option value="all">All polls</option></select></div>
|
| 96 |
+
<div><label for="poll-sort">Sort by</label><select id="poll-sort"><option value="newest">Newest first</option><option value="popular">Most votes</option><option value="ending">Ending soon</option></select></div>
|
| 97 |
+
</form>
|
| 98 |
+
<div class="browse-summary"><p id="browse-count" class="subtle" role="status">Finding polls…</p><button id="clear-filters" class="quiet" hidden>Clear filters</button></div>
|
| 99 |
+
<section id="browse-results" aria-label="Polls" aria-busy="true"></section>
|
| 100 |
+
<nav id="browse-pagination" class="pagination" aria-label="Browse pages" hidden><button id="previous-page" class="secondary">Previous</button><span id="browse-page" class="subtle"></span><button id="next-page" class="secondary">Next</button></nav>
|
| 101 |
+
</template>
|
| 102 |
+
<script src="/static/app.js" defer></script>
|
| 103 |
+
</body>
|
| 104 |
+
</html>
|
static/style.css
CHANGED
|
@@ -1 +1,245 @@
|
|
| 1 |
-
:root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: light;
|
| 3 |
+
--ink: #182230;
|
| 4 |
+
--muted: #667085;
|
| 5 |
+
--line: #e4e7ec;
|
| 6 |
+
--bg: #ffffff;
|
| 7 |
+
--surface: #ffffff;
|
| 8 |
+
--soft: #f8fafc;
|
| 9 |
+
--hover: #f2f4f7;
|
| 10 |
+
--border: #cbd2dc;
|
| 11 |
+
--strong-border: #98a2b3;
|
| 12 |
+
--placeholder: #767f8f;
|
| 13 |
+
--link: #2563eb;
|
| 14 |
+
--primary: #182230;
|
| 15 |
+
--primary-hover: #344054;
|
| 16 |
+
--primary-ink: #ffffff;
|
| 17 |
+
--accent: #d99a00;
|
| 18 |
+
--accent-soft: #fffcf0;
|
| 19 |
+
--danger: #b42318;
|
| 20 |
+
--focus: #3b82f6;
|
| 21 |
+
--focus-ring: #bfdbfe;
|
| 22 |
+
--shadow: 0 4px 20px #10182814;
|
| 23 |
+
}
|
| 24 |
+
:root[data-theme="dark"] {
|
| 25 |
+
color-scheme: dark;
|
| 26 |
+
--ink: #e8edf5;
|
| 27 |
+
--muted: #a3afc2;
|
| 28 |
+
--line: #2c3545;
|
| 29 |
+
--bg: #11151d;
|
| 30 |
+
--surface: #181e29;
|
| 31 |
+
--soft: #171d27;
|
| 32 |
+
--hover: #232c3b;
|
| 33 |
+
--border: #414e63;
|
| 34 |
+
--strong-border: #75849b;
|
| 35 |
+
--placeholder: #92a0b5;
|
| 36 |
+
--link: #93b4ff;
|
| 37 |
+
--primary: #f5cf56;
|
| 38 |
+
--primary-hover: #ffdf80;
|
| 39 |
+
--primary-ink: #20200f;
|
| 40 |
+
--accent: #f5cf56;
|
| 41 |
+
--accent-soft: #2a271c;
|
| 42 |
+
--danger: #ff9b91;
|
| 43 |
+
--focus: #93b4ff;
|
| 44 |
+
--focus-ring: #344b70;
|
| 45 |
+
--shadow: 0 4px 24px #0005;
|
| 46 |
+
}
|
| 47 |
+
/* Respect system appearance even before JavaScript loads or if it is disabled. */
|
| 48 |
+
@media (prefers-color-scheme: dark) {
|
| 49 |
+
:root:not([data-theme]) {
|
| 50 |
+
color-scheme: dark;
|
| 51 |
+
--ink: #e8edf5; --muted: #a3afc2; --line: #2c3545;
|
| 52 |
+
--bg: #11151d; --surface: #181e29; --soft: #171d27; --hover: #232c3b;
|
| 53 |
+
--border: #414e63; --strong-border: #75849b; --placeholder: #92a0b5;
|
| 54 |
+
--link: #93b4ff; --primary: #f5cf56; --primary-hover: #ffdf80;
|
| 55 |
+
--primary-ink: #20200f; --accent: #f5cf56; --accent-soft: #2a271c;
|
| 56 |
+
--danger: #ff9b91; --focus: #93b4ff; --focus-ring: #344b70;
|
| 57 |
+
--shadow: 0 4px 24px #0005;
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
* { box-sizing: border-box; }
|
| 61 |
+
[hidden] { display: none !important; }
|
| 62 |
+
body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
| 63 |
+
a { color: inherit; text-decoration: none; }
|
| 64 |
+
button, input, textarea, select { font: inherit; }
|
| 65 |
+
button, a, input, textarea, select { -webkit-tap-highlight-color: transparent; }
|
| 66 |
+
button, select { cursor: pointer; }
|
| 67 |
+
button:disabled { opacity: .5; cursor: not-allowed; }
|
| 68 |
+
button[aria-busy="true"] { cursor: wait; }
|
| 69 |
+
a:focus-visible, button:focus-visible, select:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
|
| 70 |
+
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
| 71 |
+
.skip-link { position: fixed; top: 8px; left: 16px; padding: 10px 16px; background: var(--primary); color: var(--primary-ink); border-radius: 6px; transform: translateY(-150%); z-index: 20; }
|
| 72 |
+
.skip-link:focus { transform: translateY(0); }
|
| 73 |
+
header { max-width: 1200px; margin: auto; min-height: 70px; display: flex; align-items: center; gap: 24px; padding: 16px 32px; }
|
| 74 |
+
.brand { display: flex; align-items: center; gap: 9px; font-size: 18px; font-weight: 650; white-space: nowrap; }
|
| 75 |
+
.brand img { width: 30px; height: 30px; object-fit: contain; }
|
| 76 |
+
.header-description { font-size: 14px; color: var(--muted); }
|
| 77 |
+
#account { margin-left: auto; min-width: 0; }
|
| 78 |
+
.sign-in, .secondary, .primary { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 38px; padding: 8px 14px; line-height: 20px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--ink); font-weight: 500; font-size: 14px; white-space: nowrap; }
|
| 79 |
+
.sign-in:hover, .secondary:hover { background: var(--hover); border-color: var(--strong-border); }
|
| 80 |
+
.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-ink); }
|
| 81 |
+
.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
| 82 |
+
.quiet { border: 0; background: none; padding: 7px 8px; color: var(--muted); border-radius: 5px; }
|
| 83 |
+
.quiet:hover { color: var(--ink); background: var(--hover); }
|
| 84 |
+
.page-header { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); background: var(--soft); }
|
| 85 |
+
.project-title { max-width: 1136px; margin: auto; display: flex; align-items: center; gap: 14px; padding: 25px 0 22px; }
|
| 86 |
+
.project-title img { width: 45px; height: 45px; object-fit: contain; }
|
| 87 |
+
.project-title h1 { font-size: 22px; line-height: 1.3; font-weight: 600; margin: 0 0 4px; }
|
| 88 |
+
.project-title p { margin: 0; color: var(--muted); }
|
| 89 |
+
nav { max-width: 1136px; margin: auto; display: flex; gap: 24px; }
|
| 90 |
+
.tab { display: inline-flex; align-items: center; gap: 7px; padding: 10px 1px 12px; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
| 91 |
+
.tab svg { width: 17px; height: 17px; }
|
| 92 |
+
.tab:hover { color: var(--ink); }
|
| 93 |
+
.tab.active { color: var(--ink); border-bottom-color: var(--accent); }
|
| 94 |
+
main { max-width: 1136px; margin: 0 auto; min-height: calc(100vh - 302px); padding: 32px 0 56px; }
|
| 95 |
+
main:focus { outline: none; }
|
| 96 |
+
.workspace { display: grid; grid-template-columns: minmax(0, 730px) minmax(220px, 1fr); gap: 56px; }
|
| 97 |
+
.create-section { min-width: 0; }
|
| 98 |
+
.section-heading { margin: 0 0 24px; }
|
| 99 |
+
.section-heading h2, h2 { font-size: 18px; font-weight: 600; line-height: 1.4; margin: 0 0 6px; }
|
| 100 |
+
.section-heading p { margin: 0; color: var(--muted); }
|
| 101 |
+
label { display: block; font-weight: 500; margin: 22px 0 8px; }
|
| 102 |
+
label span[aria-hidden] { color: var(--muted); }
|
| 103 |
+
small { font-size: 12px; color: var(--muted); font-weight: 400; }
|
| 104 |
+
label small { margin-left: 8px; }
|
| 105 |
+
input, textarea, select { display: block; width: 100%; border: 1px solid var(--border); border-radius: 7px; padding: 10px 12px; color: var(--ink); background: var(--surface); line-height: 1.5; min-width: 0; }
|
| 106 |
+
input::placeholder, textarea::placeholder { color: var(--placeholder); }
|
| 107 |
+
input:focus, textarea:focus { outline: 2px solid var(--focus-ring); outline-offset: 1px; border-color: var(--focus); }
|
| 108 |
+
input[aria-invalid="true"] { border-color: var(--danger); }
|
| 109 |
+
textarea { resize: vertical; min-height: 92px; }
|
| 110 |
+
.label-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
| 111 |
+
.label-row small { margin-top: 14px; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
| 112 |
+
.option-row { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
|
| 113 |
+
.option-index { width: 18px; flex-shrink: 0; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; text-align: center; }
|
| 114 |
+
.remove-option { display: grid; place-items: center; border: 0; border-radius: 6px; background: transparent; color: var(--muted); width: 36px; height: 38px; flex-shrink: 0; font-size: 21px; }
|
| 115 |
+
.remove-option:hover:not(:disabled) { color: var(--danger); background: var(--hover); }
|
| 116 |
+
.add-option { margin-top: 2px; margin-left: 28px; }
|
| 117 |
+
.draft-row { min-height: 36px; margin: 12px 0 -12px 28px; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
|
| 118 |
+
.draft-row .subtle { font-size: 12px; }
|
| 119 |
+
.draft-row .quiet { font-size: 12px; }
|
| 120 |
+
.poll-settings { border-top: 1px solid var(--line); margin-top: 24px; }
|
| 121 |
+
.poll-settings select { max-width: 250px; }
|
| 122 |
+
.poll-settings p { margin: 8px 0 0; }
|
| 123 |
+
.listing-choice { display: flex; align-items: flex-start; gap: 10px; margin: 20px 0 6px; cursor: pointer; }
|
| 124 |
+
.listing-choice input { width: 18px; height: 18px; flex-shrink: 0; margin: 2px 0 0; accent-color: var(--primary); }
|
| 125 |
+
.listing-choice strong { font-size: 13px; font-weight: 500; }
|
| 126 |
+
.listing-choice small { display: block; margin: 4px 0 0; line-height: 1.6; }
|
| 127 |
+
.text-link { color: var(--link); text-decoration: underline; text-underline-offset: 3px; }
|
| 128 |
+
.form-bottom { border-top: 1px solid var(--line); margin-top: 28px; padding-top: 20px; display: flex; gap: 20px; align-items: center; justify-content: space-between; }
|
| 129 |
+
.form-bottom p { margin: 0; }
|
| 130 |
+
.subtle { font-size: 13px; line-height: 1.6; color: var(--muted); }
|
| 131 |
+
aside { border-left: 1px solid var(--line); padding-left: 28px; }
|
| 132 |
+
aside h3 { font-size: 14px; font-weight: 600; margin: 0 0 9px; }
|
| 133 |
+
aside p { font-size: 13px; color: var(--muted); margin: 0 0 27px; line-height: 1.7; }
|
| 134 |
+
footer { border-top: 1px solid var(--line); max-width: 1136px; margin: auto; padding: 22px 0 28px; color: var(--muted); font-size: 12px; display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
| 135 |
+
footer p { margin: 0; max-width: 720px; }
|
| 136 |
+
.theme-control { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
| 137 |
+
.theme-control svg { width: 17px; height: 17px; }
|
| 138 |
+
.theme-control label { margin: 0; font-size: 12px; font-weight: 400; }
|
| 139 |
+
.theme-control select { width: auto; min-height: 36px; padding: 6px 28px 6px 10px; font-size: 12px; }
|
| 140 |
+
#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); border: 1px solid var(--border); background: var(--surface); color: var(--ink); padding: 12px 18px; border-radius: 8px; max-width: calc(100vw - 32px); box-shadow: var(--shadow); z-index: 10; }
|
| 141 |
+
.page-title { margin-bottom: 25px; }
|
| 142 |
+
.page-title h1 { font-size: 22px; font-weight: 600; line-height: 1.4; margin: 0 0 7px; }
|
| 143 |
+
.page-title p { margin: 0; }
|
| 144 |
+
.card { border: 1px solid var(--line); border-radius: 10px; padding: 24px; background: var(--surface); }
|
| 145 |
+
.poll-card { max-width: 760px; margin: 20px auto 0; }
|
| 146 |
+
.poll-card h1 { font-size: 24px; font-weight: 600; line-height: 1.4; margin: 18px 0 8px; overflow-wrap: anywhere; }
|
| 147 |
+
.description { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.7; margin: 20px 0; }
|
| 148 |
+
.tag { display: inline-flex; align-items: center; padding: 2px 8px; font-size: 12px; color: var(--muted); border: 1px solid var(--line); background: var(--soft); border-radius: 5px; white-space: nowrap; }
|
| 149 |
+
.tag-open { color: var(--ink); border-color: var(--accent); background: var(--accent-soft); }
|
| 150 |
+
.share { background: var(--soft); border: 1px solid var(--line); border-radius: 7px; padding: 16px; margin: 24px 0; }
|
| 151 |
+
.share label { margin: 0 0 8px; font-size: 13px; }
|
| 152 |
+
.share-line { display: flex; gap: 8px; }
|
| 153 |
+
.share-line input { font-size: 13px; }
|
| 154 |
+
.poll-card .section-heading { display: flex; align-items: center; justify-content: space-between; margin-top: 27px; margin-bottom: 5px; }
|
| 155 |
+
.small-pill { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
| 156 |
+
.result { margin: 20px 0; }
|
| 157 |
+
.result-label { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 8px; overflow-wrap: anywhere; }
|
| 158 |
+
.result-label b { font-size: 12px; font-weight: 500; white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; }
|
| 159 |
+
.bar { height: 8px; background: var(--hover); border-radius: 4px; overflow: hidden; }
|
| 160 |
+
.bar span { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
|
| 161 |
+
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 24px; }
|
| 162 |
+
.danger, .error { color: var(--danger); }
|
| 163 |
+
.vote-option { display: flex; align-items: center; gap: 11px; border: 1px solid var(--border); border-radius: 7px; padding: 13px 14px; cursor: pointer; font-weight: 400; margin: 12px 0; overflow-wrap: anywhere; }
|
| 164 |
+
.vote-option:hover { background: var(--hover); }
|
| 165 |
+
.vote-option:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
| 166 |
+
.vote-option input { width: 18px; height: 18px; flex-shrink: 0; padding: 0; accent-color: var(--accent); }
|
| 167 |
+
.vote-divider { border: 0; border-top: 1px solid var(--line); margin: 28px 0; }
|
| 168 |
+
.notice { font-size: 14px; background: var(--soft); border: 1px solid var(--line); border-radius: 7px; padding: 15px; line-height: 1.7; margin-top: 24px; }
|
| 169 |
+
.list-poll { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 18px 0; border-bottom: 1px solid var(--line); }
|
| 170 |
+
.list-poll:first-child { padding-top: 0; }
|
| 171 |
+
.list-poll strong { display: block; font-weight: 500; margin-bottom: 5px; overflow-wrap: anywhere; }
|
| 172 |
+
.list-poll:hover strong { color: var(--link); }
|
| 173 |
+
.empty { padding: 48px 20px; text-align: center; }
|
| 174 |
+
.empty h2 { font-size: 18px; }
|
| 175 |
+
.empty .actions { justify-content: center; }
|
| 176 |
+
.back { color: var(--muted); font-size: 13px; }
|
| 177 |
+
.back:hover { color: var(--ink); }
|
| 178 |
+
.poll-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
| 179 |
+
.deadline { margin-top: 6px; }
|
| 180 |
+
.share .subtle { margin-bottom: 0; }
|
| 181 |
+
.browse-title { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
| 182 |
+
.browse-filters { display: grid; grid-template-columns: minmax(0, 1fr) 180px 170px; gap: 16px; padding: 20px; border: 1px solid var(--line); border-radius: 10px; background: var(--soft); }
|
| 183 |
+
.browse-filters label { font-size: 13px; margin: 0 0 8px; }
|
| 184 |
+
.search-line { display: flex; gap: 8px; }
|
| 185 |
+
.search-line input { min-width: 0; }
|
| 186 |
+
.browse-summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 20px 0 14px; min-height: 36px; }
|
| 187 |
+
.browse-summary p { margin: 0; }
|
| 188 |
+
.poll-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
|
| 189 |
+
.browse-card { display: flex; flex-direction: column; align-items: flex-start; min-height: 235px; padding: 22px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
|
| 190 |
+
.browse-card:hover { border-color: var(--strong-border); background: var(--soft); }
|
| 191 |
+
.browse-card h2 { font-size: 17px; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
| 192 |
+
.browse-card:hover h2 { color: var(--link); }
|
| 193 |
+
.browse-description { color: var(--muted); font-size: 13px; margin: 0 0 18px; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
| 194 |
+
.browse-card .card-meta { margin-top: auto; width: 100%; }
|
| 195 |
+
.card-meta p { margin: 5px 0 0; overflow-wrap: anywhere; }
|
| 196 |
+
.card-meta .deadline { font-size: 12px; }
|
| 197 |
+
nav.pagination { max-width: none; margin: 28px 0 0; justify-content: center; align-items: center; gap: 16px; }
|
| 198 |
+
@media (max-width: 1000px) {
|
| 199 |
+
.poll-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
| 200 |
+
.browse-filters { grid-template-columns: 1fr 1fr; }
|
| 201 |
+
.browse-search { grid-column: 1 / -1; }
|
| 202 |
+
}
|
| 203 |
+
@media (max-width: 1200px) {
|
| 204 |
+
.project-title, nav, main, footer { margin-left: 32px; margin-right: 32px; }
|
| 205 |
+
}
|
| 206 |
+
@media (max-width: 760px) {
|
| 207 |
+
header { padding: 14px 20px; min-height: 62px; gap: 10px; }
|
| 208 |
+
.header-description { display: none; }
|
| 209 |
+
.brand { font-size: 16px; }
|
| 210 |
+
.brand img { width: 26px; height: 26px; }
|
| 211 |
+
.sign-in { font-size: 12px; padding: 6px 10px; max-width: 200px; white-space: normal; text-align: left; overflow-wrap: anywhere; }
|
| 212 |
+
.project-title, nav, main, footer { margin-left: 20px; margin-right: 20px; }
|
| 213 |
+
nav { gap: 12px; }
|
| 214 |
+
.tab { font-size: 13px; gap: 6px; white-space: nowrap; }
|
| 215 |
+
.tab svg { width: 16px; height: 16px; }
|
| 216 |
+
.project-title { padding: 21px 0; }
|
| 217 |
+
.project-title h1 { font-size: 20px; }
|
| 218 |
+
.project-title p { font-size: 13px; }
|
| 219 |
+
.project-title img { width: 38px; height: 38px; }
|
| 220 |
+
main { padding-top: 26px; padding-bottom: 36px; }
|
| 221 |
+
.workspace { grid-template-columns: 1fr; gap: 32px; }
|
| 222 |
+
aside { border-left: 0; border-top: 1px solid var(--line); padding: 23px 0 0; }
|
| 223 |
+
.form-bottom { align-items: flex-start; flex-direction: column; gap: 14px; }
|
| 224 |
+
.form-bottom .primary { width: 100%; }
|
| 225 |
+
input, textarea, select { font-size: 16px; }
|
| 226 |
+
.remove-option { width: 40px; height: 44px; }
|
| 227 |
+
.card { padding: 18px; }
|
| 228 |
+
.poll-card h1 { font-size: 22px; }
|
| 229 |
+
.share-line { flex-wrap: wrap; }
|
| 230 |
+
.share-line input { flex-basis: 100%; font-size: 16px; }
|
| 231 |
+
.actions .secondary { font-size: 12px; padding: 8px 10px; }
|
| 232 |
+
.list-poll { gap: 16px; }
|
| 233 |
+
footer { font-size: 11px; padding-top: 20px; flex-direction: column; align-items: flex-start; gap: 16px; }
|
| 234 |
+
.theme-control select { font-size: 16px; }
|
| 235 |
+
.browse-title { align-items: flex-start; flex-direction: column; gap: 16px; }
|
| 236 |
+
.browse-filters { padding: 16px; gap: 14px 10px; }
|
| 237 |
+
.browse-filters select { padding-left: 8px; font-size: 14px; }
|
| 238 |
+
.browse-filters .search-line { flex-wrap: wrap; }
|
| 239 |
+
.browse-filters .search-line input { flex: 1 1 150px; }
|
| 240 |
+
.browse-summary { gap: 8px; }
|
| 241 |
+
.poll-grid { grid-template-columns: 1fr; gap: 14px; }
|
| 242 |
+
.browse-card { min-height: 0; padding: 20px; }
|
| 243 |
+
.browse-card .card-meta { margin-top: 8px; }
|
| 244 |
+
.poll-settings select { max-width: none; }
|
| 245 |
+
}
|
static/theme.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Run before the stylesheet so a saved theme is applied before the first paint.
|
| 2 |
+
(() => {
|
| 3 |
+
const key = 'huggingpolls-theme';
|
| 4 |
+
const system = window.matchMedia('(prefers-color-scheme: dark)');
|
| 5 |
+
const valid = value => ['system', 'light', 'dark'].includes(value);
|
| 6 |
+
let preference = 'system';
|
| 7 |
+
try {
|
| 8 |
+
const saved = localStorage.getItem(key);
|
| 9 |
+
if (valid(saved)) preference = saved;
|
| 10 |
+
} catch { /* The system theme still works when browser storage is blocked. */ }
|
| 11 |
+
|
| 12 |
+
function apply() {
|
| 13 |
+
const theme = preference === 'system' ? (system.matches ? 'dark' : 'light') : preference;
|
| 14 |
+
document.documentElement.dataset.theme = theme;
|
| 15 |
+
document.querySelector('meta[name="theme-color"]').content = theme === 'dark' ? '#11151d' : '#ffffff';
|
| 16 |
+
const select = document.querySelector('#theme-select');
|
| 17 |
+
if (select) select.value = preference;
|
| 18 |
+
const status = document.querySelector('#theme-status');
|
| 19 |
+
if (status) status.textContent = `Using ${theme} appearance${preference === 'system' ? ' to match your system' : ''}.`;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
apply();
|
| 23 |
+
system.addEventListener('change', apply);
|
| 24 |
+
window.addEventListener('storage', event => {
|
| 25 |
+
if (event.key === key || event.key === null) {
|
| 26 |
+
preference = valid(event.newValue) ? event.newValue : 'system';
|
| 27 |
+
apply();
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 31 |
+
apply();
|
| 32 |
+
document.querySelector('#theme-select').addEventListener('change', event => {
|
| 33 |
+
preference = event.target.value;
|
| 34 |
+
try { localStorage.setItem(key, preference); } catch {}
|
| 35 |
+
apply();
|
| 36 |
+
});
|
| 37 |
+
});
|
| 38 |
+
})();
|
tests/__pycache__/test_polls.cpython-314.pyc
ADDED
|
Binary file (20.7 kB). View file
|
|
|
tests/test_polls.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import importlib
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import tempfile
|
| 7 |
+
import unittest
|
| 8 |
+
from datetime import datetime, timedelta, timezone
|
| 9 |
+
from unittest.mock import patch
|
| 10 |
+
|
| 11 |
+
from fastapi.testclient import TestClient
|
| 12 |
+
from itsdangerous import TimestampSigner
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class PollTests(unittest.TestCase):
|
| 16 |
+
@classmethod
|
| 17 |
+
def setUpClass(cls):
|
| 18 |
+
cls.test_root = Path(__file__).resolve().parent
|
| 19 |
+
cls.data = tempfile.TemporaryDirectory(prefix='.test-data-', dir=cls.test_root)
|
| 20 |
+
environment = os.environ.copy()
|
| 21 |
+
for key in ['DATA_REPO', 'DATA_TOKEN', 'SPACE_HOST', 'OAUTH_CLIENT_ID', 'OAUTH_CLIENT_SECRET']:
|
| 22 |
+
environment.pop(key, None)
|
| 23 |
+
environment.update(DATA_DIR=cls.data.name, SESSION_SECRET='test-only-secret', BASE_URL='http://testserver')
|
| 24 |
+
with patch.dict(os.environ, environment, clear=True):
|
| 25 |
+
cls.module = importlib.import_module('app')
|
| 26 |
+
|
| 27 |
+
@classmethod
|
| 28 |
+
def tearDownClass(cls):
|
| 29 |
+
if not Path(cls.data.name).resolve().is_relative_to(cls.test_root):
|
| 30 |
+
raise RuntimeError('Test data must remain inside the tests directory')
|
| 31 |
+
cls.data.cleanup()
|
| 32 |
+
|
| 33 |
+
def setUp(self):
|
| 34 |
+
self.module.state.clear()
|
| 35 |
+
self.module.state.update(polls={})
|
| 36 |
+
self.at = datetime(2026, 10, 28, 12, tzinfo=timezone.utc)
|
| 37 |
+
self.clock = patch.object(self.module, 'utc_now', return_value=self.at).start()
|
| 38 |
+
self.addCleanup(patch.stopall)
|
| 39 |
+
self.owner = TestClient(self.module.app)
|
| 40 |
+
self.guest = TestClient(self.module.app)
|
| 41 |
+
self.addCleanup(self.owner.close)
|
| 42 |
+
self.addCleanup(self.guest.close)
|
| 43 |
+
session = base64.b64encode(json.dumps({'user': {'id': 'owner', 'name': 'Tester'}}).encode())
|
| 44 |
+
self.owner.cookies.set('session', TimestampSigner(self.module.SECRET).sign(session).decode())
|
| 45 |
+
self.headers = {'X-HuggingPolls': '1'}
|
| 46 |
+
|
| 47 |
+
def create(self, **overrides):
|
| 48 |
+
body = {'question': 'Favorite snack?', 'description': 'Choose for Friday.', 'options': ['Fruit', 'Cookies']}
|
| 49 |
+
body.update(overrides)
|
| 50 |
+
response = self.owner.post('/api/polls', json=body, headers=self.headers)
|
| 51 |
+
self.assertEqual(response.status_code, 200, response.text)
|
| 52 |
+
return response.json()['id']
|
| 53 |
+
|
| 54 |
+
def test_default_duration_and_public_summary(self):
|
| 55 |
+
poll_id = self.create()
|
| 56 |
+
poll = self.guest.get(f'/api/polls/{poll_id}').json()
|
| 57 |
+
self.assertEqual(datetime.fromisoformat(poll['expires_at']), self.at + timedelta(days=7))
|
| 58 |
+
self.assertTrue(poll['listed'])
|
| 59 |
+
self.assertFalse(poll['closed'])
|
| 60 |
+
self.assertIsNone(poll['results'])
|
| 61 |
+
summary = self.guest.get('/api/browse').json()['items'][0]
|
| 62 |
+
self.assertEqual(summary['id'], poll_id)
|
| 63 |
+
self.assertEqual(summary['option_count'], 2)
|
| 64 |
+
self.assertTrue({'votes', 'owner_id', 'options', 'results'}.isdisjoint(summary))
|
| 65 |
+
|
| 66 |
+
def test_expiry_boundary_enforces_votes_and_prevents_reopen(self):
|
| 67 |
+
poll_id = self.create(duration_hours=1)
|
| 68 |
+
original = self.module.path.read_bytes()
|
| 69 |
+
self.clock.return_value = self.at + timedelta(hours=1) - timedelta(microseconds=1)
|
| 70 |
+
self.assertFalse(self.guest.get(f'/api/polls/{poll_id}').json()['closed'])
|
| 71 |
+
self.clock.return_value = self.at + timedelta(hours=1)
|
| 72 |
+
response = self.guest.post(f'/api/polls/{poll_id}/vote', json={'choice': 0}, headers=self.headers)
|
| 73 |
+
self.assertEqual(response.status_code, 409)
|
| 74 |
+
self.assertIn('expired', response.json()['detail'])
|
| 75 |
+
poll = self.guest.get(f'/api/polls/{poll_id}').json()
|
| 76 |
+
self.assertTrue(poll['expired'])
|
| 77 |
+
self.assertTrue(poll['closed'])
|
| 78 |
+
self.assertEqual(poll['results'], [0, 0])
|
| 79 |
+
self.assertEqual(self.guest.get('/api/browse').json()['total'], 0)
|
| 80 |
+
self.assertEqual(self.guest.get('/api/browse?status=closed').json()['total'], 1)
|
| 81 |
+
self.assertTrue(self.owner.get('/api/polls').json()[0]['expired'])
|
| 82 |
+
response = self.owner.patch(f'/api/polls/{poll_id}', json={'closed': False}, headers=self.headers)
|
| 83 |
+
self.assertEqual(response.status_code, 409)
|
| 84 |
+
self.assertEqual(self.module.path.read_bytes(), original, 'Expiry reads must not write storage')
|
| 85 |
+
self.assertEqual(self.module.state['polls'][poll_id]['votes'], [])
|
| 86 |
+
|
| 87 |
+
def test_manual_close_and_reopen_before_expiry(self):
|
| 88 |
+
poll_id = self.create(duration_hours=24)
|
| 89 |
+
self.assertEqual(self.owner.patch(f'/api/polls/{poll_id}', json={'closed': True}, headers=self.headers).status_code, 200)
|
| 90 |
+
self.assertEqual(self.guest.post(f'/api/polls/{poll_id}/vote', json={'choice': 1}, headers=self.headers).status_code, 409)
|
| 91 |
+
self.assertEqual(self.guest.get(f'/api/polls/{poll_id}').json()['results'], [0, 0])
|
| 92 |
+
self.assertEqual(self.owner.patch(f'/api/polls/{poll_id}', json={'closed': False}, headers=self.headers).status_code, 200)
|
| 93 |
+
self.assertEqual(self.guest.post(f'/api/polls/{poll_id}/vote', json={'choice': 1}, headers=self.headers).status_code, 200)
|
| 94 |
+
self.assertEqual(self.guest.get(f'/api/polls/{poll_id}').json()['results'], [0, 1])
|
| 95 |
+
|
| 96 |
+
def test_unlisted_and_legacy_polls_do_not_leak_into_browse(self):
|
| 97 |
+
hidden_id = self.create(listed=False, question='Unlisted topic')
|
| 98 |
+
legacy_id = self.create(question='Old link-only poll')
|
| 99 |
+
legacy = self.module.state['polls'][legacy_id]
|
| 100 |
+
del legacy['listed']
|
| 101 |
+
del legacy['expires_at']
|
| 102 |
+
self.assertEqual(self.guest.get('/api/browse?status=all').json()['total'], 0)
|
| 103 |
+
self.assertEqual(self.guest.get('/api/browse?q=unlisted&status=all').json()['total'], 0)
|
| 104 |
+
self.assertEqual(self.guest.get(f'/api/polls/{hidden_id}').status_code, 200)
|
| 105 |
+
self.clock.return_value = self.at + timedelta(days=365)
|
| 106 |
+
self.assertFalse(self.guest.get(f'/api/polls/{legacy_id}').json()['expired'])
|
| 107 |
+
self.assertEqual(self.owner.patch(f'/api/polls/{legacy_id}', json={'listed': True}, headers=self.headers).status_code, 200)
|
| 108 |
+
self.assertEqual(self.guest.get('/api/browse').json()['items'][0]['id'], legacy_id)
|
| 109 |
+
self.assertEqual(self.owner.patch(f'/api/polls/{legacy_id}', json={'listed': False}, headers=self.headers).status_code, 200)
|
| 110 |
+
self.assertEqual(self.guest.get('/api/browse').json()['total'], 0)
|
| 111 |
+
|
| 112 |
+
def test_search_status_and_sorting(self):
|
| 113 |
+
first = self.create(question='A tea break', description='Community choice', duration_hours=24)
|
| 114 |
+
self.clock.return_value = self.at + timedelta(minutes=1)
|
| 115 |
+
popular = self.create(question='Coffee time', duration_hours=72)
|
| 116 |
+
self.clock.return_value = self.at + timedelta(minutes=2)
|
| 117 |
+
newest = self.create(question='Lunch menu', duration_hours=1)
|
| 118 |
+
self.guest.post(f'/api/polls/{popular}/vote', json={'choice': 0}, headers=self.headers)
|
| 119 |
+
self.assertEqual(self.guest.get('/api/browse?sort=newest').json()['items'][0]['id'], newest)
|
| 120 |
+
self.assertEqual(self.guest.get('/api/browse?sort=popular').json()['items'][0]['id'], popular)
|
| 121 |
+
self.assertEqual(self.guest.get('/api/browse?sort=ending').json()['items'][0]['id'], newest)
|
| 122 |
+
self.assertEqual(self.guest.get('/api/browse?q=COMMUNITY').json()['items'][0]['id'], first)
|
| 123 |
+
self.assertEqual(self.guest.get('/api/browse?q=Tester').json()['total'], 3)
|
| 124 |
+
self.owner.patch(f'/api/polls/{first}', json={'closed': True}, headers=self.headers)
|
| 125 |
+
self.assertEqual(self.guest.get('/api/browse').json()['total'], 2)
|
| 126 |
+
self.assertEqual(self.guest.get('/api/browse?status=closed').json()['items'][0]['id'], first)
|
| 127 |
+
self.assertEqual(self.guest.get('/api/browse?status=all').json()['total'], 3)
|
| 128 |
+
|
| 129 |
+
def test_pagination_and_empty_results(self):
|
| 130 |
+
for index in range(14):
|
| 131 |
+
self.clock.return_value = self.at + timedelta(seconds=index)
|
| 132 |
+
self.create(question=f'Community poll {index}')
|
| 133 |
+
first = self.guest.get('/api/browse').json()
|
| 134 |
+
second = self.guest.get('/api/browse?page=2').json()
|
| 135 |
+
self.assertEqual(first['total'], 14)
|
| 136 |
+
self.assertEqual(len(first['items']), 12)
|
| 137 |
+
self.assertEqual(len(second['items']), 2)
|
| 138 |
+
self.assertTrue({p['id'] for p in first['items']}.isdisjoint(p['id'] for p in second['items']))
|
| 139 |
+
self.assertEqual(self.guest.get('/api/browse?page=999').json()['page'], 2)
|
| 140 |
+
empty = self.guest.get('/api/browse?q=does-not-exist&page=2').json()
|
| 141 |
+
self.assertEqual(empty['page'], 1)
|
| 142 |
+
self.assertEqual(empty['items'], [])
|
| 143 |
+
|
| 144 |
+
def test_invalid_requests_and_owner_permissions(self):
|
| 145 |
+
for duration in [0, -1, 721]:
|
| 146 |
+
response = self.owner.post('/api/polls', json={'question': 'Q', 'options': ['A', 'B'], 'duration_hours': duration}, headers=self.headers)
|
| 147 |
+
self.assertEqual(response.status_code, 422)
|
| 148 |
+
for query in ['status=invalid', 'sort=invalid', 'page=0', 'q=' + 'x' * 241]:
|
| 149 |
+
self.assertEqual(self.guest.get('/api/browse?' + query).status_code, 422)
|
| 150 |
+
poll_id = self.create()
|
| 151 |
+
self.assertEqual(self.guest.patch(f'/api/polls/{poll_id}', json={'listed': False}, headers=self.headers).status_code, 401)
|
| 152 |
+
with TestClient(self.module.app) as other:
|
| 153 |
+
session = base64.b64encode(json.dumps({'user': {'id': 'other', 'name': 'Another user'}}).encode())
|
| 154 |
+
other.cookies.set('session', TimestampSigner(self.module.SECRET).sign(session).decode())
|
| 155 |
+
self.assertEqual(other.patch(f'/api/polls/{poll_id}', json={'listed': False}, headers=self.headers).status_code, 403)
|
| 156 |
+
self.assertEqual(self.owner.patch(f'/api/polls/{poll_id}', json={}, headers=self.headers).status_code, 422)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
if __name__ == '__main__':
|
| 160 |
+
unittest.main()
|