Files
xxx-sphere-web/bin/main/static/games/chastity/entdecken-vorlagen.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

529 lines
24 KiB
HTML
Raw 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>Vorlagen entdecken xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Suche ── */
.search-bar {
display: flex; gap: 0.6rem; margin-bottom: 1.5rem; align-items: center;
}
.search-bar input {
flex: 1; padding: 0.55rem 0.85rem; border-radius: 8px;
border: 1px solid var(--color-secondary); background: var(--color-card);
color: var(--color-text); font-size: 0.95rem;
}
.search-bar button {
width: auto; padding: 0.55rem 1.2rem; font-size: 0.9rem;
}
/* ── Template-Karte ── */
.tpl-card {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 10px; padding: 1rem; margin-bottom: 0.75rem;
cursor: pointer; transition: border-color 0.15s;
}
.tpl-card:hover { border-color: var(--color-primary); }
.tpl-card.own-template { border-left: 3px solid #3498db; }
.tpl-card-header {
display: flex; align-items: flex-start;
justify-content: space-between; gap: 0.75rem;
}
.tpl-icon {
width: 2.4rem; height: 2.4rem; flex-shrink: 0;
border-radius: 8px; background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
}
.tpl-name { font-weight: 700; font-size: 1rem; margin-bottom: 0.2rem; }
.tpl-meta { font-size: 0.78rem; color: var(--color-muted); line-height: 1.5; }
.tpl-badges {
display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem;
}
.tpl-badge {
font-size: 0.7rem; border-radius: 5px; padding: 0.18rem 0.55rem;
border: 1px solid var(--color-secondary); color: var(--color-muted);
background: var(--color-secondary);
}
.tpl-badge.blue { background: rgba(52,152,219,0.12); border-color: rgba(52,152,219,0.35); color: #3498db; }
.tpl-badge.green { background: rgba(46,204,113,0.12); border-color: rgba(46,204,113,0.35); color: #2ecc71; }
.tpl-badge.orange { background: rgba(231,152,52,0.12); border-color: rgba(231,152,52,0.35); color: #e67e22; }
.tpl-badge.own { background: rgba(52,152,219,0.15); border-color: rgba(52,152,219,0.5); color: #3498db; font-weight: 600; }
/* ── Abonnieren-Button ── */
.btn-sub {
white-space: nowrap; width: auto; padding: 0.4rem 0.9rem; font-size: 0.82rem;
font-weight: 600; border-radius: 7px; cursor: pointer; flex-shrink: 0;
border: 1px solid var(--color-secondary);
background: none; color: var(--color-muted);
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-sub:hover:not(:disabled) {
background: rgba(52,152,219,0.12); border-color: rgba(52,152,219,0.45); color: #3498db;
}
.btn-sub.subscribed {
background: rgba(46,204,113,0.12); border-color: rgba(46,204,113,0.4); color: #2ecc71;
}
.btn-sub.subscribed:hover:not(:disabled) {
background: rgba(231,76,60,0.1); border-color: rgba(231,76,60,0.35); color: #e74c3c;
}
.btn-sub:disabled { opacity: 0.45; cursor: not-allowed; }
/* ── Detail-Modal ── */
.detail-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.65); z-index: 400;
align-items: flex-start; justify-content: center;
padding: 2rem 1rem; overflow-y: auto;
}
.detail-backdrop.open { display: flex; }
.detail-box {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 14px; padding: 1.75rem 1.5rem 1.5rem;
max-width: 500px; width: 100%; position: relative;
display: flex; flex-direction: column; gap: 1rem;
}
.detail-section {
background: var(--color-secondary); border-radius: 8px;
padding: 0.85rem 1rem;
}
.detail-section-title {
font-size: 0.72rem; font-weight: 700; color: var(--color-muted);
text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 0.5rem;
}
.detail-row {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 0.88rem; padding: 0.2rem 0; gap: 1rem;
}
.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.85rem; color: var(--color-text); padding: 0.3rem 0;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.detail-task-item:last-child { border-bottom: none; }
.detail-wheel-entry {
display: inline-flex; align-items: center; gap: 0.3rem;
font-size: 0.78rem; background: var(--color-card);
border: 1px solid var(--color-secondary); border-radius: 5px;
padding: 0.2rem 0.55rem; margin: 0.2rem;
}
.detail-footer {
display: flex; gap: 0.75rem; justify-content: flex-end; flex-wrap: wrap;
border-top: 1px solid var(--color-secondary); padding-top: 1rem;
}
.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-subscribe-detail {
padding: 0.5rem 1.25rem; border-radius: 7px; cursor: pointer;
font-size: 0.88rem; font-weight: 600; width: auto; border: none;
background: var(--color-primary); color: #fff;
}
.btn-subscribe-detail.subscribed {
background: rgba(46,204,113,0.15); border: 1px solid rgba(46,204,113,0.4); color: #2ecc71;
}
.detail-author-avatar {
width: 52px; height: 52px; border-radius: 50%;
border: 2px solid var(--color-secondary);
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; color: var(--color-muted);
overflow: hidden; flex-shrink: 0;
}
.detail-author-avatar img { width: 100%; height: 100%; object-fit: cover; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h2 style="margin-bottom:1.25rem;">🔍 Vorlagen entdecken</h2>
<!-- Suchleiste -->
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Nach Namen suchen…"
onkeydown="if(event.key==='Enter') doSearch()">
<button onclick="doSearch()">Suchen</button>
</div>
<!-- Ergebnisliste -->
<div id="templateList"></div>
<div id="scrollSentinel" style="height:1px;"></div>
<p id="listLoading" style="display:none;text-align:center;color:var(--color-muted);padding:1rem;">Laden…</p>
<p id="listEmpty" style="display:none;color:var(--color-muted);">Keine öffentlichen Vorlagen gefunden.</p>
</div>
</div>
<!-- Detail-Modal -->
<div class="detail-backdrop" id="detailModal" onclick="closeDetail()">
<div class="detail-box" onclick="event.stopPropagation()">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;">
<div style="display:flex;align-items:flex-start;gap:0.85rem;">
<div class="detail-author-avatar" id="detailAuthorAvatar" 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>
<button onclick="closeDetail()" style="background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0;flex-shrink:0;"></button>
</div>
<div id="detailBody"></div>
<div class="detail-footer">
<button class="btn-close-detail" onclick="closeDetail()">Schließen</button>
<button class="btn-subscribe-detail" id="detailSubscribeBtn" onclick="toggleSubscribeDetail()"></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; }
let page = 0;
let isLastPage = false;
let isLoading = false;
let currentSearch = '';
let _detailTemplate = null;
function fmtMinutes(min) {
if (!min) return '';
const d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
return [d && d + 'T', h && h + 'Std', m && m + 'Min'].filter(Boolean).join(' ') || '0Min';
}
// ── Laden ──────────────────────────────────────────────────────────────────
async function loadNextPage() {
if (isLoading || isLastPage) return;
isLoading = true;
document.getElementById('listLoading').style.display = '';
try {
const q = encodeURIComponent(currentSearch);
const res = await fetch(`/templates/public?page=${page}&size=10&q=${q}`);
if (!res.ok) return;
const data = await res.json();
data.content.forEach(t => appendCard(t));
isLastPage = !data.hasMore;
page = data.page + 1;
if (page === 1 && data.content.length === 0) {
document.getElementById('listEmpty').style.display = '';
}
} catch(e) { console.error(e); }
finally {
isLoading = false;
document.getElementById('listLoading').style.display = 'none';
}
}
function resetList() {
page = 0; isLastPage = false; isLoading = false;
document.getElementById('templateList').innerHTML = '';
document.getElementById('listEmpty').style.display = 'none';
loadNextPage();
}
function doSearch() {
currentSearch = document.getElementById('searchInput').value.trim();
resetList();
}
// ── Karte ─────────────────────────────────────────────────────────────────
function appendCard(t) {
const list = document.getElementById('templateList');
const isCard = t.lockType === 'CARDLOCK';
const card = document.createElement('div');
card.className = 'tpl-card' + (t.isOwnTemplate ? ' own-template' : '');
card.dataset.templateId = t.templateId;
const metaParts = [
isCard ? '🃏 Karten-Lock' : '⏱ Zeit-Lock',
t.authorName ? 'von ' + esc(t.authorName) : null,
t.subscriberCount + ' Abo(s)',
].filter(Boolean);
const badges = buildBadges(t);
const subBtnCls = t.isOwnTemplate ? '' : (t.isSubscribed ? 'subscribed' : '');
const subBtnLabel = t.isOwnTemplate ? 'Eigene' : (t.isSubscribed ? '✓ Abonniert' : '+ Abonnieren');
card.innerHTML = `
<div class="tpl-card-header">
<div class="tpl-icon">${isCard ? '🃏' : '⏱'}</div>
<div style="flex:1;min-width:0;">
<div class="tpl-name">${esc(t.name || 'Ohne Namen')}</div>
<div class="tpl-meta">${metaParts.join(' · ')}</div>
</div>
<button class="btn-sub ${subBtnCls}" ${t.isOwnTemplate ? 'disabled' : ''}
onclick="event.stopPropagation();toggleSubscribe('${t.templateId}',this)">
${subBtnLabel}
</button>
</div>
<div class="tpl-badges">${badges}</div>`;
card.addEventListener('click', () => openDetail(t));
list.appendChild(card);
}
function buildBadges(t) {
const b = [];
if (t.lockType === 'TIMELOCK') {
if (t.minTimeInMinutes || t.maxTimeInMinutes) {
const min = fmtMinutes(t.minTimeInMinutes), max = fmtMinutes(t.maxTimeInMinutes);
b.push(`<span class="tpl-badge blue">⏱ ${min} ${max}</span>`);
}
if (t.spinningWheelEntries && t.spinningWheelEntries.length)
b.push(`<span class="tpl-badge orange">🎡 Glücksrad (${t.spinningWheelEntries.length})</span>`);
if (t.penaltyType)
b.push(`<span class="tpl-badge orange">⚠ Strafe</span>`);
}
if (t.taskCount > 0)
b.push(`<span class="tpl-badge">🎯 ${t.taskCount} Aufgabe(n)</span>`);
if (t.hygieneEnabled)
b.push(`<span class="tpl-badge">🚿 Hygiene</span>`);
if (t.requiresVerification)
b.push(`<span class="tpl-badge">📷 Verifikation</span>`);
if (t.isOwnTemplate)
b.push(`<span class="tpl-badge own">Meine Vorlage</span>`);
return b.join('');
}
// ── Abonnieren (Listenansicht) ─────────────────────────────────────────────
async function toggleSubscribe(id, btn) {
const isSubscribed = btn.classList.contains('subscribed');
btn.disabled = true;
try {
const method = isSubscribed ? 'DELETE' : 'POST';
const res = await fetch(`/templates/${id}/subscribe`, { method });
if (res.ok || res.status === 204) {
if (isSubscribed) {
btn.classList.remove('subscribed');
btn.textContent = '+ Abonnieren';
// Update card data
const card = btn.closest('.tpl-card');
updateCardSubscriberCount(card, -1);
} else {
btn.classList.add('subscribed');
btn.textContent = '✓ Abonniert';
const card = btn.closest('.tpl-card');
updateCardSubscriberCount(card, +1);
}
}
} catch(e) { /* ignore */ }
btn.disabled = false;
}
function updateCardSubscriberCount(card, delta) {
// Update the meta text - find the "X Abo(s)" part
const meta = card.querySelector('.tpl-meta');
if (!meta) return;
meta.innerHTML = meta.innerHTML.replace(/(\d+) Abo\(s\)/, (_, n) => `${Math.max(0, parseInt(n) + delta)} Abo(s)`);
}
// ── Detail-Modal ───────────────────────────────────────────────────────────
function openDetail(t) {
_detailTemplate = t;
document.getElementById('detailTitle').textContent = t.name || 'Ohne Namen';
const avatarEl = document.getElementById('detailAuthorAvatar');
if (t.authorProfilePicture) {
avatarEl.innerHTML = `<img src="data:image/png;base64,${t.authorProfilePicture}" alt="${esc(t.authorName || '')}">`;
avatarEl.style.display = '';
} else {
avatarEl.innerHTML = '◉';
avatarEl.style.display = 'none';
}
const metaParts = [
t.lockType === 'CARDLOCK' ? '🃏 Karten-Lock' : '⏱ Zeit-Lock',
t.authorName ? 'von ' + t.authorName : null,
t.subscriberCount + ' Abonnent(en)',
].filter(Boolean);
document.getElementById('detailMeta').textContent = metaParts.join(' · ');
document.getElementById('detailBody').innerHTML = buildDetailBody(t);
const btn = document.getElementById('detailSubscribeBtn');
if (t.isOwnTemplate) {
btn.style.display = 'none';
} else {
btn.style.display = '';
btn.className = 'btn-subscribe-detail' + (t.isSubscribed ? ' subscribed' : '');
btn.textContent = t.isSubscribed ? '✓ Abonniert' : '+ Abonnieren';
}
document.getElementById('detailModal').classList.add('open');
}
function closeDetail() {
document.getElementById('detailModal').classList.remove('open');
_detailTemplate = null;
}
async function toggleSubscribeDetail() {
if (!_detailTemplate) return;
const t = _detailTemplate;
const btn = document.getElementById('detailSubscribeBtn');
btn.disabled = true;
const isSubscribed = t.isSubscribed;
try {
const method = isSubscribed ? 'DELETE' : 'POST';
const res = await fetch(`/templates/${t.templateId}/subscribe`, { method });
if (res.ok || res.status === 204) {
t.isSubscribed = !isSubscribed;
t.subscriberCount = Math.max(0, (t.subscriberCount || 0) + (isSubscribed ? -1 : 1));
if (isSubscribed) {
btn.className = 'btn-subscribe-detail';
btn.textContent = '+ Abonnieren';
} else {
btn.className = 'btn-subscribe-detail subscribed';
btn.textContent = '✓ Abonniert';
}
// Update card in list
const card = document.querySelector(`.tpl-card[data-template-id="${t.templateId}"]`);
if (card) {
const subBtn = card.querySelector('.btn-sub');
if (subBtn) {
if (isSubscribed) { subBtn.classList.remove('subscribed'); subBtn.textContent = '+ Abonnieren'; }
else { subBtn.classList.add('subscribed'); subBtn.textContent = '✓ Abonniert'; }
}
updateCardSubscriberCount(card, isSubscribed ? -1 : 1);
}
}
} catch(e) { /* ignore */ }
btn.disabled = false;
}
// ── Detail-Body aufbauen ───────────────────────────────────────────────────
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 rows = [];
const allKeys = new Set([
...Object.keys(t.cardCountsMin || {}),
...Object.keys(t.cardCountsMax || {}),
]);
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'],
]));
}
// Gemeinsame Einstellungen
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('');
}
function buildSection(title, rows) {
const rowsHtml = rows.map(([label, val]) =>
`<div class="detail-row">
<span class="detail-row-label">${label}</span>
<span class="detail-row-val">${val}</span>
</div>`
).join('');
return `<div class="detail-section">
<div class="detail-section-title">${title}</div>
${rowsHtml}
</div>`;
}
// ── Infinite Scroll ────────────────────────────────────────────────────────
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadNextPage();
}, { rootMargin: '200px' });
observer.observe(document.getElementById('scrollSentinel'));
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeDetail();
});
fetch('/login/me').then(r => r.ok ? r.json() : null).then(user => {
if (!user) { window.location.href = '/login.html'; return; }
loadNextPage();
});
</script>
</body>
</html>