Verschiebung nach anderem RePo - nun pro Projekt getrennt

This commit is contained in:
2026-04-01 10:41:19 +02:00
commit 7b9eda1d62
1048 changed files with 93351 additions and 0 deletions

View File

@@ -0,0 +1,530 @@
<!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/sidebar.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>