großes refactoring

This commit is contained in:
2026-03-22 23:13:40 +01:00
parent 53e7bcbbcc
commit 409f003aec
99 changed files with 10124 additions and 4386 deletions

View File

@@ -19,7 +19,6 @@
margin-bottom: 1.5rem;
}
/* ── Unified feed ── */
#feed {
display: flex;
flex-direction: column;
@@ -87,7 +86,7 @@
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-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; }
@@ -107,7 +106,7 @@
flex-wrap: wrap;
gap: 0.4rem;
}
.task-vote-lockee { font-weight: 600; font-size: 0.92rem; }
.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 {
@@ -150,6 +149,30 @@
text-align: center;
}
/* ── Pranger-Karte ── */
.pillory-card {
background: var(--color-card);
border: 1px solid rgba(231,76,60,0.35);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.pillory-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
flex-wrap: wrap;
gap: 0.4rem;
}
.pillory-lockee { font-weight: 600; font-size: 0.92rem; }
.pillory-date { font-size: 0.78rem; color: var(--color-muted); }
.pillory-reason {
font-size: 0.82rem;
color: #e74c3c;
margin-bottom: 0.25rem;
}
.pillory-message { font-size: 0.88rem; }
.empty-hint {
color: var(--color-muted);
font-size: 0.9rem;
@@ -170,7 +193,7 @@
<div class="content">
<div class="page-title">Community Votes</div>
<div class="page-subtitle">Verifikationen &amp; Aufgaben-Abstimmungen</div>
<div class="page-subtitle">Verifikationen, Aufgaben-Abstimmungen &amp; Pranger</div>
<div id="feed"></div>
<div class="load-spinner" id="loadSpinner" style="display:none;">Lädt…</div>
@@ -186,36 +209,84 @@
function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtDateTime(isoStr) {
return new Date(isoStr).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
}
// ── Task vote card builder ─────────────────────────────────────────────────
// ── Verifikations-Karte ────────────────────────────────────────────────────
function buildTaskVoteCard(vote) {
const isOwn = vote.isOwnLock;
const alreadyVoted = vote.myVote !== null && vote.myVote !== undefined;
function buildVerCard(base, detail) {
const voted = detail.isOwnLock || detail.myVote !== null && detail.myVote !== undefined;
const votedUp = !detail.isOwnLock && detail.myVote === true;
const votedDn = !detail.isOwnLock && detail.myVote === false;
const id = base.displayId;
const card = document.createElement('div');
card.className = 'vote-card';
card.innerHTML = `
<div class="vote-card-media">
<img class="vote-card-img" src="data:image/jpeg;base64,${detail.image}" alt="Verifikationsbild">
<div class="vote-card-code">${esc(detail.code)}</div>
</div>
<div class="vote-card-body">
<div class="vote-meta">Verifikation · ${esc(base.lockeeName)} · ${fmtDateTime(base.createdAt)}</div>
<div class="vote-actions">
<button class="vote-btn ${votedUp ? 'voted-up' : ''}" id="up-${id}"
${voted ? 'disabled' : ''}
onclick="castVerVote('${id}', true)">
👍 <span class="vote-count" id="upcount-${id}">${detail.upvotes}</span>
</button>
<button class="vote-btn ${votedDn ? 'voted-down' : ''}" id="dn-${id}"
${voted ? 'disabled' : ''}
onclick="castVerVote('${id}', false)">
👎 <span class="vote-count" id="dncount-${id}">${detail.downvotes}</span>
</button>
</div>
</div>`;
return card;
}
async function castVerVote(displayId, upvote) {
document.getElementById('up-' + displayId).disabled = true;
document.getElementById('dn-' + displayId).disabled = true;
const res = await fetch(`/games/chastity/community/verification/${displayId}/vote/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upvote })
});
if (res.ok || res.status === 202) {
const countEl = document.getElementById(upvote ? 'upcount-' + displayId : 'dncount-' + displayId);
countEl.textContent = parseInt(countEl.textContent) + 1;
document.getElementById((upvote ? 'up-' : 'dn-') + displayId)
.classList.add(upvote ? 'voted-up' : 'voted-down');
}
}
// ── Aufgaben-Abstimmungs-Karte ─────────────────────────────────────────────
function buildTaskVoteCard(base, detail) {
const isOwn = detail.isOwnLock;
const alreadyVoted = detail.entries.some(e => e.ownVote);
const id = base.displayId;
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>`
(detail.entries || []).forEach((e, i) => {
const desc = e.description
? `<div style="font-size:0.75rem;color:var(--color-muted);margin-top:0.1rem;">${esc(e.description)}</div>`
: '';
const mins = t.minutes > 0
? ` <span style="font-size:0.75rem;color:var(--color-muted);">⏱ ${t.minutes} Min.</span>`
const mins = e.minutes > 0
? ` <span style="font-size:0.75rem;color:var(--color-muted);">⏱ ${e.minutes} Min.</span>`
: '';
optionsHtml += `<button class="task-vote-btn ${isMyVote ? 'my-vote' : ''}"
id="tvbtn-${vote.voteSessionId}-${i}"
optionsHtml += `<button class="task-vote-btn ${e.ownVote ? 'my-vote' : ''}"
id="tvbtn-${id}-${i}"
${(alreadyVoted || isOwn) ? 'disabled' : ''}
onclick="castTaskVote('${vote.voteSessionId}', ${i})">
onclick="castTaskVote('${id}', ${i})">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;">${esc(t.title)}${mins}</div>
<div style="font-weight:600;">${esc(e.title)}${mins}</div>
${desc}
</div>
<span class="task-vote-count" id="tvcount-${vote.voteSessionId}-${i}">${count} Stimme${count !== 1 ? 'n' : ''}</span>
<span class="task-vote-count" id="tvcount-${id}-${i}">${e.votes} Stimme${e.votes !== 1 ? 'n' : ''}</span>
</button>`;
});
@@ -225,181 +296,107 @@
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>
<span class="task-vote-lockee">🃏 ${esc(base.lockeeName)}</span>
<span class="task-vote-expires">Endet: ${fmtDateTime(detail.expiresAt)}</span>
</div>
<div class="task-vote-options">${optionsHtml}</div>
${ownHint}`;
return card;
}
async function castTaskVote(voteSessionId, taskIndex) {
document.querySelectorAll(`[id^="tvbtn-${voteSessionId}-"]`).forEach(btn => btn.disabled = true);
async function castTaskVote(displayId, taskIndex) {
document.querySelectorAll(`[id^="tvbtn-${displayId}-"]`).forEach(btn => btn.disabled = true);
const res = await fetch(`/task-card/community/votes/${voteSessionId}/vote/${taskIndex}`, { method: 'POST' });
const res = await fetch(`/games/chastity/community/taskvote/${displayId}/vote/${taskIndex}`, { method: 'POST' });
if (res.ok || res.status === 204) {
const countEl = document.getElementById(`tvcount-${voteSessionId}-${taskIndex}`);
const countEl = document.getElementById(`tvcount-${displayId}-${taskIndex}`);
if (countEl) {
const next = (parseInt(countEl.textContent) || 0) + 1;
countEl.textContent = `${next} Stimme${next !== 1 ? 'n' : ''}`;
}
const btn = document.getElementById(`tvbtn-${voteSessionId}-${taskIndex}`);
if (btn) btn.classList.add('my-vote');
document.getElementById(`tvbtn-${displayId}-${taskIndex}`)?.classList.add('my-vote');
}
}
// ── Verification card builder ──────────────────────────────────────────────
// ── Pranger-Karte ──────────────────────────────────────────────────────────
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 PILLORY_LABELS = {
HYGIENE_OPENING_EXEEDED: 'Hygiene-Öffnung überschritten',
KEYHOLDER_DESCESSION: 'Keyholder hat aufgegeben'
};
function buildPilloryCard(base, detail) {
const card = document.createElement('div');
card.className = 'vote-card';
card.dataset.ts = item.verificationTime;
card.className = 'pillory-card';
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">${esc(item.code)}</div>
<div class="pillory-header">
<span class="pillory-lockee">🔒 ${esc(base.lockeeName)}</span>
<span class="pillory-date">${fmtDateTime(base.createdAt)}</span>
</div>
<div class="vote-card-body">
<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="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="castVerVote('${item.verificationId}', false)">
👎 <span class="vote-count" id="dncount-${item.verificationId}">${item.downvotes}</span>
</button>
</div>
</div>`;
<div class="pillory-reason">⚠️ ${esc(PILLORY_LABELS[detail.reason] || detail.reason)}</div>
${detail.message ? `<div class="pillory-message">${esc(detail.message)}</div>` : ''}`;
return card;
}
async function castVerVote(verificationId, upvote) {
const upBtn = document.getElementById('up-' + verificationId);
const dnBtn = document.getElementById('dn-' + verificationId);
upBtn.disabled = true;
dnBtn.disabled = true;
// ── Unified feed mit Paging ────────────────────────────────────────────────
const res = await fetch('/verification/' + verificationId + '/vote/', {
method: 'POST',
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');
}
let page = 0;
let exhausted = false;
let loading = false;
let rendered = 0;
async function fetchDetail(base) {
const urls = {
VERIFICATION: `/games/chastity/community/verification/${base.displayId}`,
TASK_VOTE: `/games/chastity/community/taskvote/${base.displayId}`,
PILLORY: `/games/chastity/community/pillory/${base.displayId}`
};
const url = urls[base.type];
if (!url) return null;
try {
const r = await fetch(url);
return r.ok ? await r.json() : null;
} catch(e) { return null; }
}
// ── 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;
function buildCard(base, detail) {
if (!detail) return null;
if (base.type === 'VERIFICATION') return buildVerCard(base, detail);
if (base.type === 'TASK_VOTE') return buildTaskVoteCard(base, detail);
if (base.type === 'PILLORY') return buildPilloryCard(base, detail);
return null;
}
async function loadMore() {
if (loading || verExhausted) return;
if (loading || exhausted) 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 = [];
let pageData;
try {
const r = await fetch('/verification/community?page=' + verPage);
if (r.ok) verItems = await r.json();
} catch(e) {}
const r = await fetch(`/games/chastity/community?page=${page}&sort=createdAt,desc`);
if (!r.ok) { loading = false; document.getElementById('loadSpinner').style.display = 'none'; return; }
pageData = await r.json();
} catch(e) { loading = false; document.getElementById('loadSpinner').style.display = 'none'; return; }
const items = pageData.content || [];
if (pageData.last) exhausted = true;
page++;
const details = await Promise.all(items.map(fetchDetail));
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);
}
}
const feed = document.getElementById('feed');
items.forEach((base, i) => {
const card = buildCard(base, details[i]);
if (card) { feed.appendChild(card); rendered++; }
});
if (batch.length > 0) appendItems(batch);
if (totalRendered === 0 && verExhausted) {
if (rendered === 0 && exhausted) {
document.getElementById('emptyHint').style.display = '';
}
}