This is an automated email from the ASF dual-hosted git repository. gstein pushed a commit to branch trunk in repository https://gitbox.apache.org/repos/asf/steve.git
commit b2e838cbf99475dae19d02958557bfa5e0fa9629 Author: Greg Stein <[email protected]> AuthorDate: Fri Feb 20 07:52:05 2026 -0600 Massive revamp of the voter page to enable STV. * Added/moved election metadata to a sticky card at top. * added STV modal dialog with SortableJS * added a Submit butt at top, in addition to bottom * revised the bulk voting --- v3/server/pages.py | 12 + v3/server/templates/vote-on.ezt | 624 ++++++++++++++++++++++++++++++++++------ 2 files changed, 556 insertions(+), 80 deletions(-) diff --git a/v3/server/pages.py b/v3/server/pages.py index 7b160bf..3bea64a 100644 --- a/v3/server/pages.py +++ b/v3/server/pages.py @@ -248,12 +248,24 @@ async def vote_on_page(election): result.election = election.get_metadata() result.e_title = result.election.title + ### need this from PDB + result.election.owner_name = 'unknown' + # Add more stuff into the Election instance. _ = postprocess_election(result.election) result.issues = election.list_issues() result.issue_count = len(result.issues) + ### fix these. scan the issues' vtype + result.has_yna_issues = 'yes' # EZT boolean + result.has_stv_issues = 'yes' # EZT boolean + + result.are_yna_plural = ezt.boolean(len(result.issues) > 1) + result.are_stv_plural = ezt.boolean(len(result.issues) > 1) + + result.has_voted = None # EZT boolean + return result diff --git a/v3/server/templates/vote-on.ezt b/v3/server/templates/vote-on.ezt index 9c1e4fd..6bee370 100644 --- a/v3/server/templates/vote-on.ezt +++ b/v3/server/templates/vote-on.ezt @@ -1,126 +1,590 @@ [include "header.ezt"] - <div class="container"> - <h1>[title]</h1> - [include "flashes.ezt"] - <p> - You have [issue_count] issues to vote upon, in this election. - </p> +<style> + /* ── Sticky election header ───────────────────────────────────────────── */ + #election-header { + position: sticky; + top: 0; + z-index: 1030; + background: #fff; + border-bottom: 2px solid #dee2e6; + box-shadow: 0 2px 8px rgba(0,0,0,.08); + } + #election-header .meta-row { + font-size: .82rem; + color: #6c757d; + gap: .5rem 1.25rem; + } + #election-header .meta-row span + span::before { + content: "·"; + margin-right: 1.25rem; + } + + /* ── Issue list ───────────────────────────────────────────────────────── */ + .issue-item { transition: background .15s; } + .issue-item:hover { background: #f8f9fa; } + .description { display: none; } + .description.show { display: block; } + + /* ── STV badge + button ───────────────────────────────────────────────── */ + .stv-action { min-width: 11rem; text-align: right; } + .stv-badge { + font-size: .75rem; + vertical-align: middle; + } + + /* ── STV Modal two-panel layout ───────────────────────────────────────── */ + .stv-panel { + min-height: 200px; + max-height: 420px; + overflow-y: auto; + border: 1px solid #dee2e6; + border-radius: .375rem; + padding: .5rem; + background: #fdfdfd; + } + .stv-panel .stv-item { + display: flex; + align-items: center; + gap: .5rem; + padding: .45rem .6rem; + margin-bottom: .3rem; + background: #fff; + border: 1px solid #dee2e6; + border-radius: .3rem; + cursor: grab; + user-select: none; + font-size: .9rem; + } + .stv-panel .stv-item:active { cursor: grabbing; } + .stv-panel .stv-item .drag-handle { + color: #adb5bd; + font-size: .8rem; + flex-shrink: 0; + } + /* rank number shown only in the ranked panel */ + #stv-ranked-list .stv-item::before { + content: attr(data-rank); + font-weight: 700; + color: #0d6efd; + font-size: .78rem; + min-width: 1.4rem; + flex-shrink: 0; + } + /* ghost while dragging */ + .sortable-ghost { opacity: .35; background: #e9f0ff !important; } + .sortable-chosen { box-shadow: 0 2px 8px rgba(13,110,253,.25); } + + /* empty-state hint */ + .stv-panel-empty { + color: #adb5bd; + font-size: .85rem; + text-align: center; + padding: 2rem 1rem; + pointer-events: none; + } +</style> + +<div id="election-header"> + <div class="container py-2"> - <!-- Bulk Vote and Clear Controls --> - <div class="mb-4"> - <h5>Bulk Vote (applies to unvoted issues only)</h5> - <div class="btn-group" role="group"> - <button type="button" class="btn btn-outline-primary" onclick="bulkVote('y')">Fill Yes</button> - <button type="button" class="btn btn-outline-primary" onclick="bulkVote('n')">Fill No</button> - <button type="button" class="btn btn-outline-primary" onclick="bulkVote('a')">Fill Abstain</button> + <!-- Row 1: title + submit button --> + <div class="d-flex justify-content-between align-items-start gap-2 flex-wrap"> + <div> + <h1 class="h4 mb-0 fw-semibold">[election.title]</h1> + <div class="meta-row d-flex flex-wrap mt-1"> + <span>Authority: <strong>[election.owner_name]</strong></span> + <span>Electorate: <strong>[election.authz]</strong></span> + <span>Opened: + <span title="[election.fmt_open_at_full]" + style="border-bottom:1px dotted currentColor;cursor:help" + >[election.fmt_open_at]</span> + </span> + [if-any election.fmt_close_at] + <span>Closes: + <span title="[election.fmt_close_at_full]" + style="border-bottom:1px dotted currentColor;cursor:help" + >[election.fmt_close_at]</span> + </span> + [end] + </div> + </div> + <button type="button" class="btn btn-primary btn-sm align-self-center" + id="submitVoteBtn" onclick="submitVotes()"> + Submit Votes + </button> + </div> + + <!-- Row 2: bulk controls --> + [if-any has_yna_issues] + <div class="d-flex align-items-center gap-2 flex-wrap mt-2 pt-2 border-top"> + <small class="text-muted me-1">Bulk fill unvoted motions:</small> + <div class="btn-group btn-group-sm" role="group"> + <button type="button" class="btn btn-outline-success" onclick="bulkVote('y')">Yes</button> + <button type="button" class="btn btn-outline-danger" onclick="bulkVote('n')">No</button> + <button type="button" class="btn btn-outline-secondary" onclick="bulkVote('a')">Abstain</button> </div> - <button type="button" class="btn btn-outline-danger ms-2" onclick="clearAllVotes()">Clear All</button> - <button type="button" class="btn btn-outline-secondary ms-2" onclick="toggleAllDescriptions()"> + <button type="button" class="btn btn-outline-danger btn-sm" onclick="clearYNAVotes()">Clear</button> + <button type="button" class="btn btn-outline-secondary btn-sm" onclick="toggleAllDescriptions()"> <span id="toggle-all-text">Expand All</span> </button> </div> + [end] - <form id="voteForm" method="POST" action="/do-vote/[eid]"> - <!-- CSRF token for form submission --> + </div> +</div><!-- /#election-header --> + +<div class="container mt-3"> + [include "flashes.ezt"] + + [if-any has_voted] + <div class="alert alert-info alert-dismissible fade show py-2" role="alert"> + <i class="bi bi-info-circle me-1"></i> + You have previously cast a ballot. Submitting will <strong>update</strong> your recorded votes. + <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> + </div> + [end] + + <p class="text-muted"> + You have <strong>[issue_count]</strong> issue[if-any are_yna_plural]s[end] to vote upon in this election. + [if-any has_stv_issues]STV election issue[if-any are_stv_plural]s[end] appear at the top.[end] + </p> + + <form id="voteForm" method="POST" action="/do-vote/[eid]"> <input type="hidden" name="csrf_token" value="[csrf_token]"> <div id="issues-list" class="list-group"> [for issues] - <div class="list-group-item issue-item"> + + [is issues.vtype "stv"] + <!-- ═══ STV ISSUE ════════════════════════════════════════════════ --> + <div class="list-group-item issue-item" data-vtype="stv" data-iid="[issues.iid]"> + + <!-- Hidden input that holds the final comma-separated ranking --> + <input type="hidden" name="vote-[issues.iid]" id="vote-[issues.iid]" value=""> + + <div class="d-flex justify-content-between align-items-center"> + <div> + <span class="twiddle bi bi-caret-right-fill me-2" + onclick="toggleDescription('[issues.iid]')"></span> + <strong>[issues.title]</strong> + <span class="badge bg-primary ms-2" title="Single Transferable Vote election">STV</span> + </div> + <div class="stv-action d-flex align-items-center gap-2 flex-wrap justify-content-end"> + <!-- Badge: shown once a ranking is saved --> + <span id="stv-badge-[issues.iid]" + class="badge bg-success stv-badge d-none"> + ✓ Ranked + </span> + <button type="button" + class="btn btn-outline-primary btn-sm" + id="stv-btn-[issues.iid]" + onclick="openSTVModal('[issues.iid]')"> + Rank Candidates + </button> + </div> + </div> + + <div id="description-[issues.iid]" class="description mt-2 text-muted small"> + [issues.description] + <div class="mt-1"> + <i class="bi bi-people me-1"></i>Seats available: <strong>[issues.seats]</strong> + </div> + </div> + + </div><!-- /STV issue --> + + [else][is issues.vtype "yna"] + <!-- ═══ YNA ISSUE ════════════════════════════════════════════════ --> + <div class="list-group-item issue-item" data-vtype="yna" data-iid="[issues.iid]"> <div class="d-flex justify-content-between align-items-center"> <div> - <span class="twiddle bi bi-caret-right-fill me-2" onclick="toggleDescription('[issues.iid]')"></span> + <span class="twiddle bi bi-caret-right-fill me-2" + onclick="toggleDescription('[issues.iid]')"></span> <strong>[issues.title]</strong> </div> <div class="vote-radio"> <div class="form-check form-check-inline"> - <input class="form-check-input" type="radio" name="vote-[issues.iid]" id="yes-[issues.iid]" value="y"> + <input class="form-check-input" type="radio" + name="vote-[issues.iid]" id="yes-[issues.iid]" value="y"> <label class="form-check-label" for="yes-[issues.iid]">Yes</label> </div> <div class="form-check form-check-inline"> - <input class="form-check-input" type="radio" name="vote-[issues.iid]" id="no-[issues.iid]" value="n"> + <input class="form-check-input" type="radio" + name="vote-[issues.iid]" id="no-[issues.iid]" value="n"> <label class="form-check-label" for="no-[issues.iid]">No</label> </div> <div class="form-check form-check-inline"> - <input class="form-check-input" type="radio" name="vote-[issues.iid]" id="abstain-[issues.iid]" value="a"> + <input class="form-check-input" type="radio" + name="vote-[issues.iid]" id="abstain-[issues.iid]" value="a"> <label class="form-check-label" for="abstain-[issues.iid]">Abstain</label> </div> </div> </div> - <div id="description-[issues.iid]" class="description mt-2">[issues.description]</div> + <div id="description-[issues.iid]" class="description mt-2 text-muted small"> + [issues.description] + </div> + </div><!-- /YNA issue --> + + [else] + <!-- ═══ UNKNOWN VTYPE ════════════════════════════════════════════ --> + <div class="list-group-item list-group-item-warning issue-item" data-vtype="unknown"> + <strong>[issues.title]</strong> + <span class="badge bg-warning text-dark ms-2">Unknown vote type: [issues.vtype]</span> </div> - [end] + + [end][end] + + [end]<!-- /for issues --> + </div><!-- /#issues-list --> + + <!-- Bottom submit (convenience for long ballots) --> + <div class="mt-4 mb-5"> + <button type="button" class="btn btn-primary" onclick="submitVotes()"> + Submit Votes + </button> </div> - <!-- Submit Button --> - <div class="mt-4"> - <button type="button" class="btn btn-primary" id="submitVoteBtn" onclick="submitVotes()">Submit Votes</button> + </form> +</div><!-- /.container --> + + +<!-- ═══════════════════════════════════════════════════════════════════════ + STV RANKING MODAL + Generated once; content swapped per issue when opened. + ═══════════════════════════════════════════════════════════════════════ --> +<div class="modal fade" id="stvModal" tabindex="-1" + aria-labelledby="stvModalLabel" aria-hidden="true"> + <div class="modal-dialog modal-lg modal-dialog-scrollable"> + <div class="modal-content"> + + <div class="modal-header"> + <div> + <h5 class="modal-title mb-0" id="stvModalLabel">Rank Candidates</h5> + <small class="text-muted" id="stv-modal-subtitle"></small> + </div> + <button type="button" class="btn-close" data-bs-dismiss="modal" + aria-label="Close"></button> + </div> + + <div class="modal-body"> + <div class="row g-3"> + + <!-- Left: Available candidates --> + <div class="col-12 col-md-6"> + <div class="d-flex justify-content-between align-items-center mb-1"> + <strong class="small text-uppercase text-muted ls-1">Candidates</strong> + <small class="text-muted">drag → or double-click to rank</small> + </div> + <div class="stv-panel" id="stv-available-list"> + <!-- populated by JS --> + </div> + </div> + + <!-- Right: Ranked list --> + <div class="col-12 col-md-6"> + <div class="d-flex justify-content-between align-items-center mb-1"> + <strong class="small text-uppercase text-muted">Your Ranking</strong> + <button type="button" class="btn btn-link btn-sm p-0 text-danger" + id="stv-clear-btn" onclick="clearSTVRanking()"> + Clear + </button> + </div> + <div class="stv-panel" id="stv-ranked-list"> + <div class="stv-panel-empty" id="stv-ranked-empty"> + Drag candidates here to rank them + </div> + </div> + </div> + + </div><!-- /.row --> + + <p class="text-muted small mt-3 mb-0"> + <i class="bi bi-info-circle me-1"></i> + Rank as many or as few candidates as you wish. Drag between panels to adjust. + Your ranking is not saved until you click <strong>Save Ranking</strong>. + </p> + </div><!-- /.modal-body --> + + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" + data-bs-dismiss="modal">Cancel</button> + <button type="button" class="btn btn-primary" + onclick="saveSTVRanking()">Save Ranking</button> + </div> + </div> - </form> </div> +</div><!-- /#stvModal --> - </div> -[include "footer.ezt"] - <!-- Bootstrap JS and Popper --> - <script> - // Toggle individual description - function toggleDescription(issueId) { - const desc = document.getElementById(`description-${issueId}`); - const twiddle = desc.previousElementSibling.querySelector('.twiddle'); - desc.classList.toggle('show'); - twiddle.classList.toggle('bi-caret-right-fill'); - twiddle.classList.toggle('bi-caret-down-fill'); +<!-- SortableJS --> +<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.2/Sortable.min.js" + integrity="sha512-Re8HTXL7IfXyPBPlWJMOFsUhxDJQkFMb1fTRhDHXbSon5NHKKVgUj0FIZEEMjJd0P9AhBRY+J6FfrqeGHmZQ==" + crossorigin="anonymous" referrerpolicy="no-referrer"></script> + +<script> +(function () { + 'use strict'; + + // ── Candidate data embedded by server ──────────────────────────────────── + // Each STV issue emits its candidates here so the modal can populate them + // without any XHR. Format: { iid: [{label, name}, ...], ... } + const STV_CANDIDATES = { +[for issues][is issues.vtype "stv"] "[issues.iid]": { + seats: [issues.seats], + title: "[issues.title]", + candidates: [ +[for issues.candidates] { label: "[issues.candidates.label]", name: "[issues.candidates.name]" }, +[end] ] + }, +[end][end] }; + + // ── State ───────────────────────────────────────────────────────────────── + let currentIID = null; + let sortAvail = null; + let sortRanked = null; + + // ── Open modal for a given STV issue ──────────────────────────────────── + window.openSTVModal = function (iid) { + currentIID = iid; + const issue = STV_CANDIDATES[[]iid]; + const hidden = document.getElementById('vote-' + iid); + + // Set modal title/subtitle + document.getElementById('stvModalLabel').textContent = issue.title; + document.getElementById('stv-modal-subtitle').textContent = + issue.seats + ' seat' + (issue.seats !== 1 ? 's' : '') + ' available'; + + // Determine which labels are already ranked (from a prior save) + const alreadyRanked = hidden.value + ? hidden.value.split(',').map(s => s.trim()).filter(Boolean) + : []; + const rankedSet = new Set(alreadyRanked); + + // Populate available panel (candidates not yet ranked) + const availPanel = document.getElementById('stv-available-list'); + const rankedPanel = document.getElementById('stv-ranked-list'); + availPanel.innerHTML = ''; + rankedPanel.innerHTML = ''; + + // Build ranked items first, preserving saved order + alreadyRanked.forEach((lbl, idx) => { + const c = issue.candidates.find(x => x.label === lbl); + if (c) rankedPanel.appendChild(makeItem(c, idx + 1)); + }); + + // Build available items (unranked candidates) + issue.candidates.forEach(c => { + if (!rankedSet.has(c.label)) availPanel.appendChild(makeItem(c, null)); + }); + + updateEmptyHint(); + updateRankNumbers(); + initSortable(); + + const modal = bootstrap.Modal.getOrCreate(document.getElementById('stvModal')); + modal.show(); + }; + + function makeItem(candidate, rank) { + const div = document.createElement('div'); + div.className = 'stv-item'; + div.dataset.label = candidate.label; + if (rank) div.dataset.rank = rank; + div.innerHTML = '<span class="drag-handle bi bi-grip-vertical"></span>' + + '<span class="cand-name">' + escapeHtml(candidate.name) + '</span>'; + // Double-click to move between panels + div.addEventListener('dblclick', () => moveItem(div)); + return div; + } + + function moveItem(el) { + const availPanel = document.getElementById('stv-available-list'); + const rankedPanel = document.getElementById('stv-ranked-list'); + if (el.parentElement === availPanel) { + rankedPanel.appendChild(el); + } else { + availPanel.appendChild(el); } + updateRankNumbers(); + updateEmptyHint(); + } + + function initSortable() { + if (sortAvail) { sortAvail.destroy(); sortAvail = null; } + if (sortRanked) { sortRanked.destroy(); sortRanked = null; } + + const sharedGroup = { name: 'stv', pull: true, put: true }; + + sortAvail = Sortable.create(document.getElementById('stv-available-list'), { + group: sharedGroup, + sort: true, + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + onEnd: onSortEnd, + }); + + sortRanked = Sortable.create(document.getElementById('stv-ranked-list'), { + group: sharedGroup, + sort: true, + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + onEnd: onSortEnd, + }); + } - // Expand/Collapse All descriptions - let allExpanded = false; - function toggleAllDescriptions() { - allExpanded = !allExpanded; - document.querySelectorAll('.description').forEach(desc => { - desc.classList.toggle('show', allExpanded); - }); - document.querySelectorAll('.twiddle').forEach(twiddle => { - twiddle.classList.toggle('bi-caret-right-fill', !allExpanded); - twiddle.classList.toggle('bi-caret-down-fill', allExpanded); - }); - document.getElementById('toggle-all-text').textContent = allExpanded ? 'Collapse All' : 'Expand All'; + function onSortEnd() { + // Remove the empty-hint element if it got sorted (it shouldn't, but guard) + const hint = document.getElementById('stv-ranked-empty'); + const rp = document.getElementById('stv-ranked-list'); + if (hint && hint.parentElement === rp && rp.children.length > 1) { + hint.remove(); } + updateRankNumbers(); + updateEmptyHint(); + } - // Bulk vote (only on unvoted issues) - function bulkVote(value) { - document.querySelectorAll('.issue-item').forEach(item => { - const radios = item.querySelectorAll('input[[]type="radio"]'); - const isVoted = Array.from(radios).some(r => r.checked); - if (!isVoted) { - const radioToCheck = item.querySelector(`input[[]value="${value}"]`); - if (radioToCheck) radioToCheck.checked = true; - } - }); + function updateRankNumbers() { + document.querySelectorAll('#stv-ranked-list .stv-item').forEach((el, i) => { + el.dataset.rank = i + 1; + }); + } + + function updateEmptyHint() { + const rp = document.getElementById('stv-ranked-list'); + let hint = document.getElementById('stv-ranked-empty'); + const items = rp.querySelectorAll('.stv-item'); + if (items.length === 0) { + if (!hint) { + hint = document.createElement('div'); + hint.className = 'stv-panel-empty'; + hint.id = 'stv-ranked-empty'; + hint.textContent = 'Drag candidates here to rank them'; + } + rp.appendChild(hint); + } else if (hint) { + hint.remove(); } + } + + // ── Save ranking back to hidden input ──────────────────────────────────── + window.saveSTVRanking = function () { + const rankedPanel = document.getElementById('stv-ranked-list'); + const labels = Array.from(rankedPanel.querySelectorAll('.stv-item')) + .map(el => el.dataset.label); + + const hidden = document.getElementById('vote-' + currentIID); + hidden.value = labels.join(','); + + updateSTVBadge(currentIID, labels.length); + + bootstrap.Modal.getInstance(document.getElementById('stvModal')).hide(); + }; - // Clear all votes - function clearAllVotes() { - document.querySelectorAll('.vote-radio input[[]type="radio"]').forEach(radio => { - radio.checked = false; - }); + window.clearSTVRanking = function () { + const availPanel = document.getElementById('stv-available-list'); + const rankedPanel = document.getElementById('stv-ranked-list'); + Array.from(rankedPanel.querySelectorAll('.stv-item')).forEach(el => { + delete el.dataset.rank; + availPanel.appendChild(el); + }); + updateEmptyHint(); + }; + + function updateSTVBadge(iid, count) { + const badge = document.getElementById('stv-badge-' + iid); + const btn = document.getElementById('stv-btn-' + iid); + if (count > 0) { + badge.textContent = '✓ ' + count + ' ranked'; + badge.classList.remove('d-none'); + btn.textContent = 'Edit Ranking'; + } else { + badge.classList.add('d-none'); + btn.textContent = 'Rank Candidates'; } + } - // Submit votes - function submitVotes() { - const votes = {}; - // careful! be wary of open-brackets within ezt - document.querySelectorAll('.vote-radio input[[]type="radio"]:checked').forEach(radio => { - const issueId = radio.name.split('-')[[]1]; - votes[[]issueId] = radio.value; - }); - - if (Object.keys(votes).length === 0) { - alert('No votes selected!'); - return; + // ── YNA helpers ────────────────────────────────────────────────────────── + window.bulkVote = function (value) { + document.querySelectorAll('.issue-item[[]data-vtype="yna"]').forEach(item => { + const radios = item.querySelectorAll('input[[]type="radio"]'); + const isVoted = Array.from(radios).some(r => r.checked); + if (!isVoted) { + const r = item.querySelector('input[[]value="' + value + '"]'); + if (r) r.checked = true; } + }); + }; + + window.clearYNAVotes = function () { + document.querySelectorAll('.issue-item[[]data-vtype="yna"] input[[]type="radio"]') + .forEach(r => { r.checked = false; }); + }; + + // ── Description toggle ──────────────────────────────────────────────────── + window.toggleDescription = function (issueId) { + const desc = document.getElementById('description-' + issueId); + const item = desc.closest('.issue-item'); + const twiddle = item.querySelector('.twiddle'); + desc.classList.toggle('show'); + twiddle.classList.toggle('bi-caret-right-fill'); + twiddle.classList.toggle('bi-caret-down-fill'); + }; + + let allExpanded = false; + window.toggleAllDescriptions = function () { + allExpanded = !allExpanded; + document.querySelectorAll('.description').forEach(d => { + d.classList.toggle('show', allExpanded); + }); + document.querySelectorAll('.twiddle').forEach(t => { + t.classList.toggle('bi-caret-right-fill', !allExpanded); + t.classList.toggle('bi-caret-down-fill', allExpanded); + }); + document.getElementById('toggle-all-text').textContent = + allExpanded ? 'Collapse All' : 'Expand All'; + }; + + // ── Submit ──────────────────────────────────────────────────────────────── + window.submitVotes = function () { + // Check all YNA issues have a vote + const ynamissing = []; + document.querySelectorAll('.issue-item[[]data-vtype="yna"]').forEach(item => { + const iid = item.dataset.iid; + const radios = item.querySelectorAll('input[[]type="radio"]'); + if (!Array.from(radios).some(r => r.checked)) ynamissing.push(iid); + }); + + // Check all STV issues have at least 1 candidate ranked + const stvmissing = []; + document.querySelectorAll('.issue-item[[]data-vtype="stv"]').forEach(item => { + const iid = item.dataset.iid; + const hidden = document.getElementById('vote-' + iid); + if (!hidden || !hidden.value) stvmissing.push(iid); + }); - submitFormWithLoading('voteForm', 'submitVoteBtn', 'Submitting Votes...'); + const problems = ynamissing.length + stvmissing.length; + if (problems > 0) { + const parts = []; + if (ynamissing.length) parts.push(ynamissing.length + ' motion(s) need a Y/N/A vote'); + if (stvmissing.length) parts.push(stvmissing.length + ' election(s) need at least one candidate ranked'); + if (!confirm('Some issues are unvoted:\n • ' + parts.join('\n • ') + + '\n\nSubmit anyway?')) return; } - </script> + + submitFormWithLoading('voteForm', 'submitVoteBtn', 'Submitting Votes…'); + }; + + // ── Utility ────────────────────────────────────────────────────────────── + function escapeHtml(s) { + return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + .replace(/"/g,'"'); + } + +})(); +</script> + +[include "footer.ezt"]
