Files
xxx-sphere-web/bin/main/static/games/chastity/keyholder-finden.html
Mario 2b0ce62d33
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Menp überarbeitet
2026-04-08 16:52:43 +02:00

531 lines
27 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<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>Keyholder finden xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.offer-card {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 10px; padding: 1rem; margin-bottom: 0.75rem;
display: flex; align-items: center; gap: 0.85rem;
}
.offer-avatar {
width: 48px; height: 48px; border-radius: 50%;
background: var(--color-secondary); border: 1px solid rgba(255,255,255,0.08);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem; flex-shrink: 0; overflow: hidden;
}
.offer-avatar img { width: 100%; height: 100%; object-fit: cover; }
.offer-body { flex: 1; min-width: 0; }
.offer-name { font-weight: 700; font-size: 0.95rem; margin-bottom: 0.2rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.offer-sub { font-size: 0.78rem; color: var(--color-muted); margin-bottom: 0.3rem; }
.offer-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.offer-badge {
display: inline-block; font-size: 0.72rem; padding: 0.1rem 0.45rem;
border-radius: 4px; background: rgba(255,255,255,0.07); border: 1px solid var(--color-secondary);
}
.offer-badge.direct { background: rgba(46,204,113,0.12); border-color: rgba(46,204,113,0.3); color: #2ecc71; }
.offer-badge.confirm { background: rgba(230,126,34,0.12); border-color: rgba(230,126,34,0.3); color: #e67e22; }
.btn-join {
background: var(--color-primary); border: none; color: #fff;
border-radius: 7px; padding: 0.4rem 1rem; font-size: 0.85rem;
font-weight: 600; cursor: pointer; flex-shrink: 0; width: auto;
}
.btn-join:disabled { opacity: 0.45; cursor: default; }
/* Klickbarer Card-Bereich */
.offer-card-clickable { cursor: pointer; }
.offer-card-clickable:hover { background: var(--color-secondary); }
/* Detail-Dialog */
.detail-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.65); z-index: 500;
align-items: flex-start; justify-content: center;
overflow-y: auto; padding: 2rem 1rem;
}
.detail-backdrop.open { display: flex; }
.detail-box {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 14px; padding: 1.5rem; max-width: 520px; width: 100%;
display: flex; flex-direction: column; gap: 1rem; position: relative;
}
.detail-section { margin-bottom: 0.25rem; }
.detail-section-title {
font-size: 0.72rem; font-weight: 700; color: var(--color-primary);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.5rem;
}
.detail-row {
display: flex; justify-content: space-between; gap: 1rem;
padding: 0.25rem 0; border-bottom: 1px solid rgba(255,255,255,0.05);
font-size: 0.88rem;
}
.detail-row:last-child { border-bottom: none; }
.detail-row-label { color: var(--color-muted); flex-shrink: 0; }
.detail-row-val { color: var(--color-text); text-align: right; }
.detail-task-item {
font-size: 0.88rem; padding: 0.35rem 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.detail-task-item:last-child { border-bottom: none; }
.detail-wheel-entry {
display: inline-block; font-size: 0.8rem; padding: 0.15rem 0.5rem;
border-radius: 4px; background: rgba(255,255,255,0.07);
border: 1px solid var(--color-secondary); margin: 0.15rem 0.2rem 0.15rem 0;
}
.detail-footer {
display: flex; gap: 0.6rem; justify-content: flex-end;
border-top: 1px solid var(--color-secondary); padding-top: 1rem; margin-top: 0.25rem;
}
.btn-close-detail {
background: none; border: 1px solid var(--color-secondary);
color: var(--color-muted); padding: 0.5rem 1.1rem;
border-radius: 7px; cursor: pointer; font-size: 0.88rem; width: auto;
}
.btn-join-detail {
background: var(--color-primary); border: none; color: #fff;
border-radius: 7px; padding: 0.5rem 1.25rem; font-size: 0.88rem;
font-weight: 600; cursor: pointer; width: auto;
}
.btn-join-detail:disabled { opacity: 0.45; cursor: default; }
.detail-author-avatar {
width: 52px; height: 52px; border-radius: 50%;
background: var(--color-secondary); border: 1px solid rgba(255,255,255,0.1);
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; overflow: hidden; flex-shrink: 0;
}
.detail-author-avatar img { width: 100%; height: 100%; object-fit: cover; }
/* Join-Dialog */
.join-modal-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.65); z-index: 500;
align-items: center; justify-content: center;
}
.join-modal-backdrop.open { display: flex; }
.join-modal-box {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 14px; padding: 1.5rem; max-width: 400px; width: 92%;
display: flex; flex-direction: column; gap: 1rem; position: relative;
}
.form-group { display: flex; flex-direction: column; gap: 0.35rem; }
.form-label { font-size: 0.72rem; font-weight: 700; color: var(--color-primary);
text-transform: uppercase; letter-spacing: 0.06em; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h2 style="margin-bottom:1.25rem;">🔍 Keyholder finden</h2>
<p style="font-size:0.88rem;color:var(--color-muted);margin-bottom:1.25rem;line-height:1.5;">
Hier findest du Nutzer*innen, die sich als Keyholder für ein bestimmtes Lock-Template anbieten.
Die beliebtesten Angebote erscheinen ganz oben.
</p>
<div id="offerList"></div>
<p id="listEmpty" style="display:none;color:var(--color-muted);">Keine Keyholder-Angebote gefunden.</p>
<p id="listLoading" style="color:var(--color-muted);">Wird geladen…</p>
</div>
</div>
<!-- Detail-Dialog -->
<div class="detail-backdrop" id="detailModal" onclick="closeDetail()">
<div class="detail-box" onclick="event.stopPropagation()">
<button onclick="closeDetail()" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.5rem;line-height:1;"></button>
<div style="display:flex;align-items:flex-start;gap:0.85rem;">
<div class="detail-author-avatar" id="detailAvatar" style="display:none;"></div>
<div>
<h2 id="detailTitle" style="margin:0 0 0.25rem;font-size:1.2rem;"></h2>
<div id="detailMeta" style="font-size:0.82rem;color:var(--color-muted);"></div>
</div>
</div>
<div id="detailBody"></div>
<div class="detail-footer">
<button class="btn-close-detail" onclick="closeDetail()">Schließen</button>
<button class="btn-join-detail" id="detailJoinBtn" onclick="detailJoin()">🔒 Beitreten</button>
</div>
</div>
</div>
<!-- Join-Dialog -->
<div class="join-modal-backdrop" id="joinModal">
<div class="join-modal-box">
<button onclick="closeJoinModal()" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.5rem;line-height:1;"></button>
<h3 style="margin:0;font-size:1.05rem;">🔒 Angebot annehmen</h3>
<p id="joinModalDesc" style="margin:0;font-size:0.85rem;color:var(--color-muted);line-height:1.5;"></p>
<div class="form-group">
<div class="form-label">Schloss-Steuerung</div>
<select id="joinControllType" style="padding:0.5rem 0.75rem;border-radius:7px;border:1px solid var(--color-secondary);background:var(--color-secondary);color:var(--color-text);font-size:0.88rem;">
<option value=""> Bitte wählen </option>
<option value="UNLOCK_CODE">🔢 Entsperrcode (Standard)</option>
<option value="TRUST">🤝 Trust (kein Code)</option>
</select>
</div>
<div class="form-group" id="codeLenGroup">
<div class="form-label">Code-Länge</div>
<input type="number" id="joinCodeLen" value="5" min="1" max="10"
style="padding:0.5rem 0.75rem;border-radius:7px;border:1px solid var(--color-secondary);background:var(--color-secondary);color:var(--color-text);font-size:0.88rem;width:100%;box-sizing:border-box;">
</div>
<div id="joinError" style="display:none;font-size:0.85rem;color:#e74c3c;"></div>
<div style="display:flex;gap:0.6rem;justify-content:flex-end;margin-top:0.25rem;">
<button onclick="closeJoinModal()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);padding:0.5rem 1.1rem;border-radius:7px;cursor:pointer;font-size:0.88rem;width:auto;">Abbrechen</button>
<button id="joinConfirmBtn" onclick="confirmJoin()" style="padding:0.5rem 1.25rem;border-radius:7px;font-size:0.88rem;font-weight:600;width:auto;">Beitreten</button>
</div>
</div>
</div>
<!-- Ergebnis-Dialog (nach erfolgreichem Join) -->
<div class="join-modal-backdrop" id="joinResultModal">
<div class="join-modal-box" style="align-items:center;text-align:center;">
<div style="font-size:2.5rem;line-height:1;" id="joinResultIcon">🔒</div>
<h3 style="margin:0;" id="joinResultTitle"></h3>
<p style="margin:0;font-size:0.88rem;color:var(--color-muted);line-height:1.5;" id="joinResultText"></p>
<div id="joinResultCode" style="display:none;font-family:monospace;font-size:1.6rem;font-weight:700;letter-spacing:0.18em;padding:0.6rem 1.25rem;background:rgba(255,255,255,0.06);border-radius:8px;"></div>
<div style="display:flex;gap:0.6rem;justify-content:center;margin-top:0.5rem;flex-wrap:wrap;">
<button onclick="closeJoinResultModal()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);padding:0.5rem 1.1rem;border-radius:7px;cursor:pointer;font-size:0.88rem;width:auto;">Schließen</button>
<button id="btnGoToLock" onclick="goToActiveLock()" style="padding:0.5rem 1.25rem;border-radius:7px;font-size:0.88rem;font-weight:600;width:auto;display:none;">Zum aktiven Lock</button>
</div>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
const GENDER_LABELS = { WEIBLICH: 'Weiblich', MAENNLICH: 'Männlich', DIVERS: 'Divers' };
let _joinOfferId = null;
let _lastJoinOfferId = null;
let _joinLockId = null;
let _detailOfferId = null;
let _detailOffer = null;
let _allOffers = [];
// ── Laden ──────────────────────────────────────────────────────────────────
async function loadOffers() {
const res = await fetch('/keyholder-offers/public');
document.getElementById('listLoading').style.display = 'none';
if (!res.ok) return;
_allOffers = await res.json();
const list = document.getElementById('offerList');
if (_allOffers.length === 0) { document.getElementById('listEmpty').style.display = ''; return; }
_allOffers.forEach(o => list.appendChild(buildCard(o)));
}
function buildCard(o) {
const av = o.offererProfilePic
? `<div class="offer-avatar"><img src="data:image/png;base64,${o.offererProfilePic}" alt=""></div>`
: `<div class="offer-avatar">👤</div>`;
const genderTags = (o.targetGenders && o.targetGenders.length > 0)
? o.targetGenders.map(g => `<span class="offer-badge">${esc(GENDER_LABELS[g] || g)}</span>`).join('')
: '<span class="offer-badge">Alle</span>';
const modeBadge = o.directStart
? '<span class="offer-badge direct">Direktstart</span>'
: '<span class="offer-badge confirm">Mit Bestätigung</span>';
const typeBadge = o.templateType === 'TIMELOCK'
? '<span class="offer-badge">⏱ Zeit-Lock</span>'
: '<span class="offer-badge">🃏 Karten-Lock</span>';
const authorLine = o.offererName
? `von ${esc(o.offererName)} · `
: '';
const joinBtn = o.isOwn
? `<button class="btn-join" disabled title="Eigenes Angebot">Eigenes</button>`
: `<button class="btn-join" onclick="openJoinModal('${o.id}', event)">Beitreten</button>`;
const div = document.createElement('div');
div.className = 'offer-card';
div.dataset.offerId = o.id;
div.innerHTML = `
${av}
<div class="offer-body offer-card-clickable" onclick="openDetail('${o.id}')">
<div class="offer-name">${esc(o.templateName || 'Unbenannt')}</div>
<div class="offer-sub">${authorLine}${o.acceptanceCount}× angenommen</div>
<div class="offer-tags">${typeBadge} ${modeBadge} ${genderTags}</div>
</div>
${joinBtn}`;
return div;
}
// ── Join-Dialog ────────────────────────────────────────────────────────────
function openJoinModal(offerId, e) {
if (e) e.stopPropagation();
_joinOfferId = offerId;
const card = document.querySelector(`[data-offer-id="${offerId}"]`);
const name = card ? card.querySelector('.offer-name')?.textContent : 'dieses Lock';
const direct = card?.querySelector('.offer-badge.direct') != null;
document.getElementById('joinModalDesc').textContent = direct
? `Das Lock „${name}" wird sofort für dich gestartet. Bitte wähle deine bevorzugte Schloss-Steuerung.`
: `Du sendest eine Einladung an den Keyholder für das Lock „${name}". Nach Annahme kannst du loslegen.`;
document.getElementById('joinError').style.display = 'none';
document.getElementById('joinControllType').value = '';
document.getElementById('joinCodeLen').value = '5';
document.getElementById('joinConfirmBtn').disabled = true;
updateCodeLenVisibility();
document.getElementById('joinModal').classList.add('open');
}
function closeJoinModal() {
document.getElementById('joinModal').classList.remove('open');
_joinOfferId = null;
}
document.getElementById('joinControllType').addEventListener('change', function() {
updateCodeLenVisibility();
document.getElementById('joinConfirmBtn').disabled = !this.value;
});
function updateCodeLenVisibility() {
const val = document.getElementById('joinControllType').value;
document.getElementById('codeLenGroup').style.display = val === 'TRUST' ? 'none' : '';
}
async function confirmJoin() {
if (!_joinOfferId) return;
const controllType = document.getElementById('joinControllType').value;
if (!controllType) return;
const btn = document.getElementById('joinConfirmBtn');
btn.disabled = true;
const unlockCodeLength = parseInt(document.getElementById('joinCodeLen').value) || 5;
const res = await fetch(`/keyholder-offers/${_joinOfferId}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ controllType, unlockCodeLength })
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
let msg = 'Fehler beim Beitreten.';
if (d.error === 'active_lock_exists') msg = 'Du hast bereits ein aktives Lock.';
else if (d.error === 'own_offer') msg = 'Du kannst nicht deinem eigenen Angebot beitreten.';
else if (d.error === 'template_gone') msg = 'Die Vorlage existiert nicht mehr.';
document.getElementById('joinError').textContent = msg;
document.getElementById('joinError').style.display = '';
btn.disabled = false;
return;
}
const data = await res.json();
_lastJoinOfferId = _joinOfferId;
closeJoinModal();
showJoinResult(data);
}
function showJoinResult(data) {
_joinLockId = data.lockId;
const direct = !data.invitationSent;
document.getElementById('joinResultIcon').textContent = direct ? '🔒' : '✉️';
document.getElementById('joinResultTitle').textContent = direct ? 'Lock gestartet!' : 'Einladung gesendet';
document.getElementById('btnGoToLock').style.display = direct ? '' : 'none';
if (direct && data.unlockCode) {
document.getElementById('joinResultText').textContent = 'Dein aktueller Entsperrcode:';
document.getElementById('joinResultCode').textContent = data.unlockCode;
document.getElementById('joinResultCode').style.display = '';
} else if (direct) {
document.getElementById('joinResultText').textContent = 'Das Lock wurde erfolgreich gestartet.';
document.getElementById('joinResultCode').style.display = 'none';
} else {
document.getElementById('joinResultText').textContent =
'Die Einladung wurde an den Keyholder gesendet. Sobald dieser annimmt, startet das Lock.';
document.getElementById('joinResultCode').style.display = 'none';
}
document.getElementById('joinResultModal').classList.add('open');
}
function closeJoinResultModal() {
document.getElementById('joinResultModal').classList.remove('open');
_joinLockId = null;
}
function goToActiveLock() {
if (!_joinLockId) return;
const isTimelock = _allOffers.find(o => o.id === _lastJoinOfferId)?.templateType === 'TIMELOCK';
const page = isTimelock ? '/games/chastity/activetimelock.html' : '/games/chastity/activelock.html';
window.location.href = page + '?lockId=' + _joinLockId;
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
closeDetail();
closeJoinModal();
closeJoinResultModal();
}
});
// ── Detail-Dialog ──────────────────────────────────────────────────────────
async function openDetail(offerId) {
_detailOfferId = offerId;
const card = document.querySelector(`[data-offer-id="${offerId}"]`);
// Offer-Objekt aus den geladenen Daten holen
_detailOffer = _allOffers.find(o => o.id === offerId);
if (!_detailOffer) return;
// Autoren-Avatar
const avatarEl = document.getElementById('detailAvatar');
if (_detailOffer.offererProfilePic) {
avatarEl.innerHTML = `<img src="data:image/png;base64,${_detailOffer.offererProfilePic}" alt="">`;
avatarEl.style.display = '';
} else {
avatarEl.style.display = 'none';
}
const typeTxt = _detailOffer.templateType === 'TIMELOCK' ? '⏱ Zeit-Lock' : '🃏 Karten-Lock';
const modeTxt = _detailOffer.directStart ? 'Direktstart' : 'Mit Bestätigung';
const authorTxt = _detailOffer.offererName ? ' · von ' + _detailOffer.offererName : '';
document.getElementById('detailTitle').textContent = _detailOffer.templateName || 'Unbenannt';
document.getElementById('detailMeta').textContent = typeTxt + ' · ' + modeTxt + authorTxt;
// Join-Button ein/ausblenden
const joinBtn = document.getElementById('detailJoinBtn');
joinBtn.style.display = _detailOffer.isOwn ? 'none' : '';
// Template-Details laden
document.getElementById('detailBody').innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;">Lädt…</p>';
document.getElementById('detailModal').classList.add('open');
try {
const res = await fetch('/templates/' + _detailOffer.templateId + '/public');
if (res.ok) {
const tpl = await res.json();
document.getElementById('detailBody').innerHTML = buildDetailBody(tpl);
} else {
document.getElementById('detailBody').innerHTML = '';
}
} catch { document.getElementById('detailBody').innerHTML = ''; }
}
function closeDetail() {
document.getElementById('detailModal').classList.remove('open');
_detailOfferId = null;
_detailOffer = null;
}
function detailJoin() {
const id = _detailOfferId;
if (!id) return;
closeDetail();
openJoinModal(id, null);
}
// ── Detail-Body ────────────────────────────────────────────────────────────
function fmtMinutes(min) {
if (!min) return '0 Min.';
const d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
return [d ? d + 'd' : '', h ? h + 'h' : '', m ? m + 'min' : ''].filter(Boolean).join(' ') || '0 Min.';
}
function buildSection(title, rows) {
const rowsHtml = rows.map(([l, v]) =>
`<div class="detail-row"><span class="detail-row-label">${esc(l)}</span><span class="detail-row-val">${v}</span></div>`
).join('');
return `<div class="detail-section"><div class="detail-section-title">${title}</div>${rowsHtml}</div>`;
}
function buildDetailBody(t) {
const sections = [];
if (t.lockType === 'TIMELOCK') {
sections.push(buildSection('⏱ Zeit-Einstellungen', [
['Mindestdauer', fmtMinutes(t.minTimeInMinutes)],
['Maximaldauer', fmtMinutes(t.maxTimeInMinutes)],
['Endzeit sichtbar', t.endTimeVisible ? 'Ja' : 'Nein'],
]));
if (t.spinningWheelEntries && t.spinningWheelEntries.length) {
const WHEEL_LABELS = {
ADD_TIME: '+ Zeit', REMOVE_TIME: ' Zeit', FREEZE_TIME: '❄ Einfrieren für',
FREEZE: '🧊 Einfrieren (∞)', UNFREEZE: '🌊 Auftauen', TASK: '🎯 Aufgabe', TEXT: '💬 Text',
};
const entries = t.spinningWheelEntries.map(e => {
const label = WHEEL_LABELS[e.type] || e.type;
const extra = e.intVal ? ' ' + fmtMinutes(e.intVal) : (e.stringVal ? ' «' + e.stringVal + '»' : '');
return `<span class="detail-wheel-entry">${label}${extra}</span>`;
}).join('');
sections.push(`<div class="detail-section">
<div class="detail-section-title">🎡 Glücksrad (${t.spinningWheelEntries.length} Einträge${t.spinsEveryMinutes ? ', alle ' + fmtMinutes(t.spinsEveryMinutes) : ''})</div>
<div>${entries}</div>
</div>`);
}
if (t.penaltyType) {
const penaltyLabels = { ADD: 'Zeit hinzufügen', FREEZE: 'Einfrieren', PILLORY: 'Pranger' };
sections.push(buildSection('⚠ Strafmaß', [
['Typ', penaltyLabels[t.penaltyType] || t.penaltyType],
['Wert', t.penaltyValue ? fmtMinutes(t.penaltyValue) : ''],
]));
}
if (t.taskEveryMinutes || t.minTasksPerDay) {
sections.push(buildSection('🎯 Aufgaben-Timing', [
['Intervall', t.taskEveryMinutes ? fmtMinutes(t.taskEveryMinutes) : ''],
['Min./Tag', t.minTasksPerDay ? t.minTasksPerDay + ' Aufgabe(n)' : ''],
]));
}
}
if (t.lockType === 'CARDLOCK') {
const allKeys = new Set([
...Object.keys(t.cardCountsMin || {}),
...Object.keys(t.cardCountsMax || {}),
]);
const rows = [];
allKeys.forEach(k => {
const mn = (t.cardCountsMin || {})[k] ?? 0;
const mx = (t.cardCountsMax || {})[k] ?? 0;
if (mn > 0 || mx > 0) rows.push([k, `${mn} ${mx}`]);
});
if (rows.length) sections.push(buildSection('🃏 Karten', rows));
sections.push(buildSection('⚙ Karten-Einstellungen', [
['Zieh-Intervall', t.pickEveryMinute ? fmtMinutes(t.pickEveryMinute) : ''],
['Picks kumulieren', t.accumulatePicks ? 'Ja' : 'Nein'],
['Verbl. Karten zeigen', t.showRemainingCards ? 'Ja' : 'Nein'],
]));
}
sections.push(buildSection('⚙ Allgemein', [
['Hygiene-Öffnung', t.hygieneEnabled ? `alle ${fmtMinutes(t.hygineOpeningEveryMinites)}, ${fmtMinutes(t.hygineOpeningDurationMinutes)} offen` : 'Keine'],
['Verifikation', t.requiresVerification ? 'Erforderlich' : 'Keine'],
['Aufgaben-Modus', t.taskMode === 'KEYHOLDER' ? 'Keyholder' : t.taskMode === 'COMMUNITY' ? 'Community' : 'Zufällig'],
]));
if (t.tasks && t.tasks.length) {
const taskItems = t.tasks.map(task => {
const dur = task.durationMinutes ? ` <span style="color:var(--color-muted);font-size:0.8rem;">(${fmtMinutes(task.durationMinutes)})</span>` : '';
const desc = task.description ? `<div style="font-size:0.78rem;color:var(--color-muted);margin-top:0.1rem;">${esc(task.description)}</div>` : '';
return `<div class="detail-task-item">${esc(task.title || task.name || '')}${dur}${desc}</div>`;
}).join('');
sections.push(`<div class="detail-section">
<div class="detail-section-title">🎯 Aufgaben (${t.tasks.length})</div>${taskItems}
</div>`);
}
return sections.join('');
}
loadOffers();
</script>
</body>
</html>