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,528 @@
<!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/sidebar.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>