Weiter am Chastity Game gebastelt
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Community Votes – XXX The Game</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
@@ -18,21 +19,21 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.vote-grid {
|
||||
/* ── Unified feed ── */
|
||||
#feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ── Verifikations-Karte ── */
|
||||
.vote-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vote-card-media {
|
||||
position: relative;
|
||||
}
|
||||
.vote-card-media { position: relative; }
|
||||
.vote-card-img {
|
||||
width: 100%;
|
||||
max-height: 420px;
|
||||
@@ -64,15 +65,8 @@
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vote-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.vote-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.vote-meta { font-size: 0.8rem; color: var(--color-muted); }
|
||||
.vote-actions { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.vote-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -93,63 +87,29 @@
|
||||
color: var(--color-primary);
|
||||
background: none;
|
||||
}
|
||||
.vote-btn.voted-up {
|
||||
border-color: #2ecc71;
|
||||
color: #2ecc71;
|
||||
background: rgba(46,204,113,0.08);
|
||||
}
|
||||
.vote-btn.voted-down {
|
||||
border-color: #e74c3c;
|
||||
color: #e74c3c;
|
||||
background: rgba(231,76,60,0.08);
|
||||
}
|
||||
.vote-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vote-count {
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.vote-btn.voted-up { border-color: #2ecc71; color: #2ecc71; background: rgba(46,204,113,0.08); }
|
||||
.vote-btn.voted-down { border-color: #e74c3c; color: #e74c3c; background: rgba(231,76,60,0.08); }
|
||||
.vote-btn:disabled { opacity: 0.55; cursor: not-allowed; pointer-events: none; }
|
||||
.vote-count { font-weight: 600; font-size: 0.88rem; }
|
||||
|
||||
/* Task Vote Section */
|
||||
.task-vote-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.task-vote-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* ── Aufgaben-Abstimmungs-Karte ── */
|
||||
.task-vote-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid rgba(52,152,219,0.35);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.task-vote-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.task-vote-lockee {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.task-vote-expires {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.task-vote-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.task-vote-lockee { font-weight: 600; font-size: 0.92rem; }
|
||||
.task-vote-expires { font-size: 0.78rem; color: var(--color-muted); }
|
||||
.task-vote-options { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.5rem; }
|
||||
.task-vote-btn {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -174,9 +134,7 @@
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(52,152,219,0.18);
|
||||
}
|
||||
.task-vote-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.task-vote-btn:disabled { cursor: default; }
|
||||
.task-vote-count {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
@@ -184,6 +142,13 @@
|
||||
margin-left: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-vote-own-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
font-style: italic;
|
||||
margin-top: 0.4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: var(--color-muted);
|
||||
@@ -207,18 +172,9 @@
|
||||
<div class="page-title">Community Votes</div>
|
||||
<div class="page-subtitle">Verifikationen & Aufgaben-Abstimmungen</div>
|
||||
|
||||
<!-- Aktive Aufgaben-Abstimmungen -->
|
||||
<div class="task-vote-section" id="taskVoteSection" style="display:none;">
|
||||
<div class="task-vote-section-title">🃏 Aufgaben-Abstimmungen</div>
|
||||
<div id="taskVoteList"></div>
|
||||
</div>
|
||||
|
||||
<div class="page-title" style="font-size:1rem;margin-bottom:0.25rem;">Verifikationen</div>
|
||||
<div class="page-subtitle" style="margin-bottom:0.75rem;">Stimme ab, ob die Verifikation gültig ist</div>
|
||||
|
||||
<div class="vote-grid" id="voteGrid"></div>
|
||||
<div id="feed"></div>
|
||||
<div class="load-spinner" id="loadSpinner" style="display:none;">Lädt…</div>
|
||||
<div class="empty-hint" id="emptyHint" style="display:none;">Heute gibt es noch keine Verifikationen ohne Keyholder.</div>
|
||||
<div class="empty-hint" id="emptyHint" style="display:none;">Noch keine Community-Abstimmungen vorhanden.</div>
|
||||
<div class="sentinel" id="sentinel"></div>
|
||||
|
||||
</div>
|
||||
@@ -227,8 +183,6 @@
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/social-sidebar.js"></script>
|
||||
<script>
|
||||
// ── Aufgaben-Abstimmungen ──────────────────────────────────────────────────
|
||||
|
||||
function esc(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
@@ -237,64 +191,59 @@
|
||||
return new Date(isoStr).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
||||
}
|
||||
|
||||
async function loadTaskVotes() {
|
||||
try {
|
||||
const res = await fetch('/task-card/community/votes');
|
||||
if (!res.ok) return;
|
||||
const votes = await res.json();
|
||||
const section = document.getElementById('taskVoteSection');
|
||||
const list = document.getElementById('taskVoteList');
|
||||
list.innerHTML = '';
|
||||
if (votes.length === 0) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
section.style.display = '';
|
||||
votes.forEach(vote => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'task-vote-card';
|
||||
card.dataset.voteSessionId = vote.voteSessionId;
|
||||
// ── Task vote card builder ─────────────────────────────────────────────────
|
||||
|
||||
let optionsHtml = '';
|
||||
(vote.tasks || []).forEach((t, i) => {
|
||||
const count = (vote.voteCounts || [])[i] || 0;
|
||||
const isMyVote = vote.myVote === i;
|
||||
const alreadyVoted = vote.myVote !== null && vote.myVote !== undefined;
|
||||
const desc = t.description ? `<div style="font-size:0.75rem;color:var(--color-muted);margin-top:0.1rem;">${esc(t.description)}</div>` : '';
|
||||
const mins = t.minutes > 0 ? ` <span style="font-size:0.75rem;color:var(--color-muted);">⏱ ${t.minutes} Min.</span>` : '';
|
||||
optionsHtml += `<button class="task-vote-btn ${isMyVote ? 'my-vote' : ''}"
|
||||
id="tvbtn-${vote.voteSessionId}-${i}"
|
||||
${alreadyVoted ? 'disabled' : ''}
|
||||
onclick="castTaskVote('${vote.voteSessionId}', ${i})">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;">${esc(t.title)}${mins}</div>
|
||||
${desc}
|
||||
</div>
|
||||
<span class="task-vote-count" id="tvcount-${vote.voteSessionId}-${i}">${count} Stimme${count !== 1 ? 'n' : ''}</span>
|
||||
</button>`;
|
||||
});
|
||||
function buildTaskVoteCard(vote) {
|
||||
const isOwn = vote.isOwnLock;
|
||||
const alreadyVoted = vote.myVote !== null && vote.myVote !== undefined;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="task-vote-header">
|
||||
<span class="task-vote-lockee">🔒 ${esc(vote.lockeeName)}</span>
|
||||
<span class="task-vote-expires">Endet: ${fmtDateTime(vote.expiresAt)}</span>
|
||||
</div>
|
||||
<div class="task-vote-options">${optionsHtml}</div>`;
|
||||
list.appendChild(card);
|
||||
});
|
||||
} catch(e) { console.error(e); }
|
||||
let optionsHtml = '';
|
||||
(vote.tasks || []).forEach((t, i) => {
|
||||
const count = (vote.voteCounts || [])[i] || 0;
|
||||
const isMyVote = vote.myVote === i;
|
||||
const desc = t.description
|
||||
? `<div style="font-size:0.75rem;color:var(--color-muted);margin-top:0.1rem;">${esc(t.description)}</div>`
|
||||
: '';
|
||||
const mins = t.minutes > 0
|
||||
? ` <span style="font-size:0.75rem;color:var(--color-muted);">⏱ ${t.minutes} Min.</span>`
|
||||
: '';
|
||||
optionsHtml += `<button class="task-vote-btn ${isMyVote ? 'my-vote' : ''}"
|
||||
id="tvbtn-${vote.voteSessionId}-${i}"
|
||||
${(alreadyVoted || isOwn) ? 'disabled' : ''}
|
||||
onclick="castTaskVote('${vote.voteSessionId}', ${i})">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;">${esc(t.title)}${mins}</div>
|
||||
${desc}
|
||||
</div>
|
||||
<span class="task-vote-count" id="tvcount-${vote.voteSessionId}-${i}">${count} Stimme${count !== 1 ? 'n' : ''}</span>
|
||||
</button>`;
|
||||
});
|
||||
|
||||
const ownHint = isOwn
|
||||
? `<div class="task-vote-own-hint">Das ist dein eigenes Lock – du kannst hier nicht abstimmen.</div>`
|
||||
: '';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'task-vote-card';
|
||||
card.dataset.ts = vote.createdAt;
|
||||
card.innerHTML = `
|
||||
<div class="task-vote-header">
|
||||
<span class="task-vote-lockee">🃏 ${esc(vote.lockeeName)}</span>
|
||||
<span class="task-vote-expires">Endet: ${fmtDateTime(vote.expiresAt)}</span>
|
||||
</div>
|
||||
<div class="task-vote-options">${optionsHtml}</div>
|
||||
${ownHint}`;
|
||||
return card;
|
||||
}
|
||||
|
||||
async function castTaskVote(voteSessionId, taskIndex) {
|
||||
// Disable all buttons for this vote immediately
|
||||
document.querySelectorAll(`[id^="tvbtn-${voteSessionId}-"]`).forEach(btn => btn.disabled = true);
|
||||
|
||||
const res = await fetch(`/task-card/community/votes/${voteSessionId}/vote/${taskIndex}`, { method: 'POST' });
|
||||
if (res.ok || res.status === 204) {
|
||||
const countEl = document.getElementById(`tvcount-${voteSessionId}-${taskIndex}`);
|
||||
if (countEl) {
|
||||
const current = parseInt(countEl.textContent) || 0;
|
||||
const next = current + 1;
|
||||
const next = (parseInt(countEl.textContent) || 0) + 1;
|
||||
countEl.textContent = `${next} Stimme${next !== 1 ? 'n' : ''}`;
|
||||
}
|
||||
const btn = document.getElementById(`tvbtn-${voteSessionId}-${taskIndex}`);
|
||||
@@ -302,43 +251,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Verifikations-Votes ───────────────────────────────────────────────────
|
||||
// ── Verification card builder ──────────────────────────────────────────────
|
||||
|
||||
let page = 0;
|
||||
let loading = false;
|
||||
let exhausted = false;
|
||||
|
||||
function fmtTime(isoStr) {
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function buildCard(item) {
|
||||
const isOwn = item.myVote === 'own';
|
||||
const voted = isOwn || (item.myVote !== null && item.myVote !== undefined);
|
||||
const votedUp = !isOwn && item.myVote === true;
|
||||
const votedDn = !isOwn && item.myVote === false;
|
||||
function buildVerCard(item) {
|
||||
const isOwn = item.myVote === 'own';
|
||||
const voted = isOwn || (item.myVote !== null && item.myVote !== undefined);
|
||||
const votedUp = !isOwn && item.myVote === true;
|
||||
const votedDn = !isOwn && item.myVote === false;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'vote-card';
|
||||
card.dataset.id = item.verificationId;
|
||||
|
||||
card.dataset.ts = item.verificationTime;
|
||||
card.innerHTML = `
|
||||
<div class="vote-card-media">
|
||||
<img class="vote-card-img" src="data:image/jpeg;base64,${item.image}" alt="Verifikationsbild">
|
||||
<div class="vote-card-code">${item.code}</div>
|
||||
<div class="vote-card-code">${esc(item.code)}</div>
|
||||
</div>
|
||||
<div class="vote-card-body">
|
||||
<div class="vote-meta">Erstellt um ${fmtTime(item.verificationTime)}</div>
|
||||
<div class="vote-meta">Verifikation · ${fmtDateTime(item.verificationTime)}</div>
|
||||
<div class="vote-actions">
|
||||
<button class="vote-btn ${votedUp ? 'voted-up' : ''}" id="up-${item.verificationId}"
|
||||
${voted ? 'disabled' : ''}
|
||||
onclick="castVote('${item.verificationId}', true)">
|
||||
onclick="castVerVote('${item.verificationId}', true)">
|
||||
👍 <span class="vote-count" id="upcount-${item.verificationId}">${item.upvotes}</span>
|
||||
</button>
|
||||
<button class="vote-btn ${votedDn ? 'voted-down' : ''}" id="dn-${item.verificationId}"
|
||||
${voted ? 'disabled' : ''}
|
||||
onclick="castVote('${item.verificationId}', false)">
|
||||
onclick="castVerVote('${item.verificationId}', false)">
|
||||
👎 <span class="vote-count" id="dncount-${item.verificationId}">${item.downvotes}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -346,35 +285,7 @@
|
||||
return card;
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
if (loading || exhausted) return;
|
||||
loading = true;
|
||||
document.getElementById('loadSpinner').style.display = '';
|
||||
|
||||
const res = await fetch('/verification/community?page=' + page);
|
||||
document.getElementById('loadSpinner').style.display = 'none';
|
||||
loading = false;
|
||||
|
||||
if (!res.ok) return;
|
||||
const items = await res.json();
|
||||
|
||||
const grid = document.getElementById('voteGrid');
|
||||
if (items.length === 0 && page === 0) {
|
||||
document.getElementById('emptyHint').style.display = '';
|
||||
exhausted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach(item => grid.appendChild(buildCard(item)));
|
||||
|
||||
if (items.length < 10 || (items.length > 0 && !items[items.length - 1].hasMore)) {
|
||||
exhausted = true;
|
||||
} else {
|
||||
page++;
|
||||
}
|
||||
}
|
||||
|
||||
async function castVote(verificationId, upvote) {
|
||||
async function castVerVote(verificationId, upvote) {
|
||||
const upBtn = document.getElementById('up-' + verificationId);
|
||||
const dnBtn = document.getElementById('dn-' + verificationId);
|
||||
upBtn.disabled = true;
|
||||
@@ -385,24 +296,120 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ upvote })
|
||||
});
|
||||
|
||||
if (res.ok || res.status === 202) {
|
||||
const countEl = document.getElementById(upvote ? 'upcount-' + verificationId : 'dncount-' + verificationId);
|
||||
countEl.textContent = parseInt(countEl.textContent) + 1;
|
||||
(upvote ? upBtn : dnBtn).classList.add(upvote ? 'voted-up' : 'voted-down');
|
||||
} else {
|
||||
// Doppelter Vote oder Fehler – Buttons trotzdem disabled lassen
|
||||
}
|
||||
}
|
||||
|
||||
// Infinite Scroll via IntersectionObserver
|
||||
// ── Unified feed ──────────────────────────────────────────────────────────
|
||||
// Strategy: task votes are loaded once (small set); verifications are paginated.
|
||||
// When each verification page loads, we interleave the pending task votes that
|
||||
// belong chronologically before the oldest verification on this page.
|
||||
|
||||
let verPage = 0;
|
||||
let verExhausted = false;
|
||||
let loading = false;
|
||||
// Task votes sorted newest-first; items are removed as they get placed in feed
|
||||
let pendingTaskVotes = [];
|
||||
let taskVotesLoaded = false;
|
||||
let totalRendered = 0;
|
||||
|
||||
function getTs(isoStr) { return new Date(isoStr).getTime(); }
|
||||
|
||||
// Append cards to feed in the correct order.
|
||||
// `items` must already be sorted newest-first.
|
||||
function appendItems(items) {
|
||||
const feed = document.getElementById('feed');
|
||||
items.forEach(card => feed.appendChild(card));
|
||||
totalRendered += items.length;
|
||||
}
|
||||
|
||||
// Merge verItems (sorted newest-first) with the front of pendingTaskVotes
|
||||
// (also sorted newest-first). Only consume task votes that are >= cutoffMs
|
||||
// so that older task votes wait for later verification pages.
|
||||
// Pass cutoffMs = -Infinity to flush all remaining task votes.
|
||||
function mergeWithTaskVotes(verItems, cutoffMs) {
|
||||
const result = [];
|
||||
let vIdx = 0;
|
||||
while (vIdx < verItems.length || (pendingTaskVotes.length > 0 && getTs(pendingTaskVotes[0].createdAt) >= cutoffMs)) {
|
||||
const verTs = vIdx < verItems.length ? getTs(verItems[vIdx].verificationTime) : -Infinity;
|
||||
const tvTs = pendingTaskVotes.length > 0 && getTs(pendingTaskVotes[0].createdAt) >= cutoffMs
|
||||
? getTs(pendingTaskVotes[0].createdAt)
|
||||
: -Infinity;
|
||||
|
||||
if (tvTs >= verTs) {
|
||||
result.push(buildTaskVoteCard(pendingTaskVotes.shift()));
|
||||
} else if (vIdx < verItems.length) {
|
||||
result.push(buildVerCard(verItems[vIdx++]));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Remaining verifications (task votes exhausted or below cutoff)
|
||||
while (vIdx < verItems.length) {
|
||||
result.push(buildVerCard(verItems[vIdx++]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || verExhausted) return;
|
||||
loading = true;
|
||||
document.getElementById('loadSpinner').style.display = '';
|
||||
|
||||
// Load task votes on first call
|
||||
if (!taskVotesLoaded) {
|
||||
try {
|
||||
const r = await fetch('/task-card/community/votes');
|
||||
if (r.ok) {
|
||||
const votes = await r.json();
|
||||
pendingTaskVotes = votes.sort((a, b) => getTs(b.createdAt) - getTs(a.createdAt));
|
||||
}
|
||||
} catch(e) {}
|
||||
taskVotesLoaded = true;
|
||||
}
|
||||
|
||||
let verItems = [];
|
||||
try {
|
||||
const r = await fetch('/verification/community?page=' + verPage);
|
||||
if (r.ok) verItems = await r.json();
|
||||
} catch(e) {}
|
||||
|
||||
document.getElementById('loadSpinner').style.display = 'none';
|
||||
loading = false;
|
||||
|
||||
let batch;
|
||||
if (verItems.length === 0) {
|
||||
verExhausted = true;
|
||||
batch = mergeWithTaskVotes([], -Infinity);
|
||||
} else {
|
||||
const oldestVerTs = getTs(verItems[verItems.length - 1].verificationTime);
|
||||
if (verItems.length < 10) {
|
||||
verExhausted = true;
|
||||
// Last page: flush all remaining task votes too
|
||||
batch = mergeWithTaskVotes(verItems, -Infinity);
|
||||
} else {
|
||||
verPage++;
|
||||
// Only include task votes newer than the oldest item in this page
|
||||
batch = mergeWithTaskVotes(verItems, oldestVerTs);
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) appendItems(batch);
|
||||
|
||||
if (totalRendered === 0 && verExhausted) {
|
||||
document.getElementById('emptyHint').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) loadPage();
|
||||
if (entries[0].isIntersecting) loadMore();
|
||||
}, { rootMargin: '200px' });
|
||||
observer.observe(document.getElementById('sentinel'));
|
||||
|
||||
loadTaskVotes();
|
||||
loadPage();
|
||||
loadMore();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user