Weiter gebaut
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled

This commit is contained in:
2026-04-25 16:56:35 +02:00
parent e4b762f905
commit 4f2048bdc8
242 changed files with 14108 additions and 1770 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
<!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>Entdecken xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Search ── */
.search-bar {
display: flex; gap: 0.6rem; margin-bottom: 1.5rem; align-items: center;
}
.search-bar input[type="text"] {
flex: 1; padding: 0.55rem 0.85rem;
border: 1px solid var(--color-secondary); border-radius: 6px;
background: var(--color-card); color: var(--color-text);
font-size: 0.95rem; outline: none; transition: border-color 0.2s;
}
.search-bar input[type="text"]:focus { border-color: var(--color-primary); }
.search-bar input[type="text"]::placeholder { color: var(--color-muted); }
.btn-search {
background: var(--color-secondary); color: var(--color-text);
border: none; border-radius: 6px; padding: 0.55rem 1rem;
font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 0.15s;
}
.btn-search:hover { background: var(--color-primary); color: #fff; }
/* ── Paging ── */
.paging {
display: flex; align-items: center; justify-content: center;
gap: 0.75rem; margin-top: 1rem;
}
.paging button {
background: var(--color-secondary); color: var(--color-text);
border: none; border-radius: 6px; padding: 0.4rem 0.9rem;
font-size: 0.85rem; cursor: pointer; transition: background 0.15s;
}
.paging button:hover:not(:disabled) { background: var(--color-primary); }
.paging button:disabled { opacity: 0.35; cursor: default; }
.paging .page-info { font-size: 0.85rem; color: var(--color-muted); }
.empty, .loading { color: var(--color-muted); font-size: 0.9rem; padding: 0.75rem 0; }
/* ── Gruppe card ── */
.gruppe-list { display: flex; flex-direction: column; gap: 0.75rem; }
.gruppe-card {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 10px; overflow: hidden; transition: border-color 0.15s;
}
.gruppe-card.open { border-color: rgba(233,69,96,0.35); }
.gruppe-header {
display: flex; align-items: center; gap: 0.9rem;
padding: 0.85rem 1rem; cursor: pointer; user-select: none;
}
.gruppe-img {
width: 48px; height: 48px; border-radius: 7px;
object-fit: cover; flex-shrink: 0;
}
.gruppe-img-placeholder {
width: 48px; height: 48px; border-radius: 7px;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem; flex-shrink: 0; color: var(--color-muted);
}
.gruppe-meta { flex: 1; min-width: 0; }
.gruppe-name {
font-size: 0.95rem; font-weight: 600; color: var(--color-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gruppe-info { font-size: 0.75rem; color: var(--color-muted); margin-top: 0.2rem; }
.gruppe-badges { display: flex; gap: 0.3rem; margin-top: 0.25rem; flex-wrap: wrap; }
.gruppe-badge {
font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 20px;
background: rgba(255,255,255,0.07); color: var(--color-muted);
}
.gruppe-badge-sub { background: rgba(46,204,113,0.15); color: var(--color-success); }
.gruppe-toggle { font-size: 0.75rem; color: var(--color-muted); flex-shrink: 0; transition: transform 0.2s; }
.gruppe-card.open .gruppe-toggle { transform: rotate(90deg); }
/* ── Subscribe button ── */
.btn-sub {
background: none; border: 1px solid var(--color-secondary); border-radius: 6px;
color: var(--color-muted); font-size: 0.8rem; padding: 0.3rem 0.75rem;
cursor: pointer; transition: border-color 0.15s, color 0.15s, background 0.15s;
flex-shrink: 0; white-space: nowrap;
}
.btn-sub:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-sub.subscribed {
border-color: rgba(46,204,113,0.5); color: var(--color-success);
}
.btn-sub.subscribed:hover {
border-color: var(--color-primary); color: var(--color-primary);
background: rgba(233,69,96,0.08);
}
.btn-sub:disabled { opacity: 0.4; cursor: default; }
/* ── Gruppe body ── */
.gruppe-body { border-top: 1px solid var(--color-secondary); padding: 1rem 1rem 0.75rem; }
.gruppe-desc { font-size: 0.82rem; color: var(--color-muted); margin-bottom: 0.85rem; line-height: 1.5; }
.sub-section + .sub-section { margin-top: 0.85rem; }
.sub-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; }
.sub-section-title {
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--color-primary);
}
/* ── Items ── */
.item-list { display: flex; flex-direction: column; gap: 0.3rem; }
.item { border-radius: 6px; background: var(--color-secondary); overflow: hidden; }
.item-row {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; padding: 0.35rem 0.6rem;
cursor: pointer; user-select: none; transition: background 0.12s;
}
.item-row:hover { background: rgba(255,255,255,0.04); }
.item.open .item-row { background: rgba(233,69,96,0.08); }
.item-text {
color: var(--color-text); flex: 1; min-width: 0; font-size: 0.82rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.item-badges { display: flex; gap: 0.35rem; flex-shrink: 0; }
.badge {
font-size: 0.7rem; padding: 0.1rem 0.45rem; border-radius: 20px;
background: rgba(233,69,96,0.15); color: var(--color-primary); white-space: nowrap;
}
.badge-neutral { background: rgba(255,255,255,0.07); color: var(--color-muted); }
/* ── Item detail ── */
.item-detail {
display: none; padding: 0.5rem 0.6rem 0.6rem;
border-top: 1px solid rgba(255,255,255,0.06);
font-size: 0.8rem; color: var(--color-muted); line-height: 1.55;
}
.item.open .item-detail { display: block; }
.item-detail-text { margin-bottom: 0.4rem; color: var(--color-text); white-space: pre-wrap; }
.item-detail-row { display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; margin-top: 0.25rem; }
.item-detail-label { font-size: 0.72rem; color: var(--color-muted); }
.item-detail-chip {
font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 20px;
background: rgba(255,255,255,0.07); color: var(--color-text);
}
.item-detail-chip-toy { background: rgba(233,69,96,0.12); color: var(--color-primary); }
.sub-empty { font-size: 0.78rem; color: var(--color-muted); padding: 0.2rem 0; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" maxlength="200">
<button class="btn-search" id="searchBtn">Suchen</button>
</div>
<div id="loading" class="loading">Wird geladen…</div>
<div id="groupList" class="gruppe-list"></div>
<div class="paging" id="paging" style="display:none;">
<button id="prevBtn"> Zurück</button>
<span class="page-info" id="pageInfo"></span>
<button id="nextBtn">Weiter </button>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
const PAGE_SIZE = 10;
let currentPage = 0, totalPages = 1;
let currentName = '';
// ── XSS ──
function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Auth ──
fetch('/login/me')
.then(r => { if (r.status === 401) { window.location.href = '/login.html'; return null; } return r.ok ? r.json() : null; })
.then(user => { if (!user) return; loadGroups(); })
.catch(() => { window.location.href = '/login.html'; });
// ── Load ──
function loadGroups() {
document.getElementById('loading').style.display = 'block';
document.getElementById('groupList').innerHTML = '';
document.getElementById('paging').style.display = 'none';
const nameParam = currentName ? `&name=${encodeURIComponent(currentName)}` : '';
fetch(`/abo/discover?page=${currentPage}&size=${PAGE_SIZE}${nameParam}`)
.then(r => r.json())
.then(data => {
totalPages = data.totalPages || 1;
renderGroups(data.content || []);
updatePaging(currentPage, totalPages);
document.getElementById('loading').style.display = 'none';
})
.catch(() => { document.getElementById('loading').textContent = 'Fehler beim Laden.'; });
}
// ── Render ──
const WERKZEUG_LABEL = {
MUND: 'Mund', VAGINA: 'Vagina', PENIS: 'Penis',
ANUS: 'Anus', UMSCHNALLDILDO: 'Umschnall-Dildo'
};
function werkzeugChips(list) {
if (!list || list.length === 0) return '';
return list.map(w => `<span class="item-detail-chip">${esc(WERKZEUG_LABEL[w] || w)}</span>`).join('');
}
function toyChips(list) {
if (!list || list.length === 0) return '';
return list.map(t => `<span class="item-detail-chip item-detail-chip-toy">${esc(t.name || t)}</span>`).join('');
}
function formatSek(von, bis) {
if (von != null && bis != null) return `${von}${bis} s`;
if (von != null) return `ab ${von} s`;
if (bis != null) return `bis ${bis} s`;
return '';
}
function formatMin(von, bis) {
if (von != null && bis != null) return `${von}${bis} min`;
if (von != null) return `ab ${von} min`;
if (bis != null) return `bis ${bis} min`;
return '';
}
// Track which group card is open
let openGroupId = null;
// Track which item detail is open
let openItemId = null;
function renderGroups(groups) {
const list = document.getElementById('groupList');
if (!groups || groups.length === 0) {
list.innerHTML = '<p class="empty">Keine Gruppen gefunden.</p>';
return;
}
list.innerHTML = groups.map(g => {
const aufgabenCount = (g.aufgaben || []).length;
const strafeCount = (g.strafen || []).length;
const sperreCount = (g.sperren || []).length;
const counts = [
aufgabenCount ? `${aufgabenCount} Aufgabe${aufgabenCount !== 1 ? 'n' : ''}` : '',
strafeCount ? `${strafeCount} Strafe${strafeCount !== 1 ? 'n' : ''}` : '',
sperreCount ? `${sperreCount} Zeitstrafe${sperreCount !== 1 ? 'n' : ''}` : ''
].filter(Boolean).join(' · ');
const subLabel = g.subscribed
? `<span class="gruppe-badge gruppe-badge-sub">♥ Abonniert</span>`
: '';
const subCount = g.subscriberCount > 0
? `<span class="gruppe-badge">♥ ${g.subscriberCount} Abo${g.subscriberCount !== 1 ? 's' : ''}</span>`
: '';
const subBtnClass = g.subscribed ? 'btn-sub subscribed' : 'btn-sub';
const subBtnText = g.subscribed ? '♥ Abonniert' : '♥ Abonnieren';
return `
<div class="gruppe-card" id="dgroup-${esc(g.gruppenId)}">
<div class="gruppe-header">
<div style="cursor:pointer; display:flex; align-items:center; gap:0.9rem; flex:1; min-width:0;"
onclick="toggleGroup('${esc(g.gruppenId)}')">
${g.bild
? `<img class="gruppe-img" src="data:image/png;base64,${g.bild}" alt="${esc(g.name)}">`
: `<div class="gruppe-img-placeholder">⊙</div>`}
<div class="gruppe-meta">
<div class="gruppe-name">${esc(g.name)}</div>
<div class="gruppe-info">${g.von ? esc(g.von) + (counts ? ' · ' : '') : ''}${counts || 'Keine Einträge'}</div>
${(subLabel || subCount) ? `<div class="gruppe-badges">${subCount}${subLabel}</div>` : ''}
</div>
<span class="gruppe-toggle">▶</span>
</div>
<button class="${subBtnClass}" id="subbtn-${esc(g.gruppenId)}"
onclick="toggleSubscribe('${esc(g.gruppenId)}', this)">
${subBtnText}
</button>
</div>
<div class="gruppe-body" id="dbody-${esc(g.gruppenId)}" style="display:none;">
${g.beschreibung ? `<div class="gruppe-desc">${esc(g.beschreibung)}</div>` : ''}
${renderSubSection('Aufgaben', sortByLevelThenName(g.aufgaben || []), renderAufgabe)}
${renderSubSection('Strafen', sortByLevelThenName(g.strafen || []), renderStrafe)}
${renderSubSection('Zeitstrafen', sortByName(g.sperren || []), renderZeitstrafe)}
</div>
</div>`;
}).join('');
openItemId = null;
}
function renderSubSection(title, items, renderFn) {
return `<div class="sub-section">
<div class="sub-section-header">
<span class="sub-section-title">${esc(title)} (${items.length})</span>
</div>
${items.length === 0
? '<div class="sub-empty">Keine Einträge</div>'
: `<div class="item-list">${items.map(item => renderFn(item)).join('')}</div>`}
</div>`;
}
function renderAufgabe(a) {
const badges = [];
const zeit = formatSek(a.sekundenVon, a.sekundenBis);
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
if (a.level != null) badges.push(`<span class="badge">Level ${esc(String(a.level))}</span>`);
const detailRows = [];
if (a.text) detailRows.push(`<div class="item-detail-text">${esc(a.text)}</div>`);
if (a.benoetigtAktiv && a.benoetigtAktiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Aktiv:</span>${werkzeugChips(a.benoetigtAktiv)}</div>`);
if (a.benoetigtPassiv && a.benoetigtPassiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Passiv:</span>${werkzeugChips(a.benoetigtPassiv)}</div>`);
if (a.benoetigteToys && a.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(a.benoetigteToys)}</div>`);
return `<div class="item" id="ditem-${esc(a.aufgabeId)}">
<div class="item-row" onclick="toggleItem('${esc(a.aufgabeId)}')">
<span class="item-text">${esc(a.kurzText)}</span>
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
</div>
${detailRows.length ? `<div class="item-detail">${detailRows.join('')}</div>` : ''}
</div>`;
}
function renderStrafe(s) {
const badges = [];
const zeit = formatSek(s.sekundenVon, s.sekundenBis);
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
if (s.level != null) badges.push(`<span class="badge">Level ${esc(String(s.level))}</span>`);
const detailRows = [];
if (s.text) detailRows.push(`<div class="item-detail-text">${esc(s.text)}</div>`);
if (s.benoetigtAktiv && s.benoetigtAktiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Aktiv:</span>${werkzeugChips(s.benoetigtAktiv)}</div>`);
if (s.benoetigtPassiv && s.benoetigtPassiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Passiv:</span>${werkzeugChips(s.benoetigtPassiv)}</div>`);
if (s.benoetigteToys && s.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(s.benoetigteToys)}</div>`);
return `<div class="item" id="ditem-${esc(s.strafeId)}">
<div class="item-row" onclick="toggleItem('${esc(s.strafeId)}')">
<span class="item-text">${esc(s.kurzText)}</span>
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
</div>
${detailRows.length ? `<div class="item-detail">${detailRows.join('')}</div>` : ''}
</div>`;
}
function renderZeitstrafe(z) {
const badges = [];
const zeit = formatMin(z.minutenVon, z.minutenBis);
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
const detailRows = [];
if (z.text) detailRows.push(`<div class="item-detail-text">${esc(z.text)}</div>`);
if (z.releaseText) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Bei Aufhebung:</span><span style="font-size:0.78rem; color:var(--color-text);">${esc(z.releaseText)}</span></div>`);
if (z.sperreFuer && z.sperreFuer.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Sperrt:</span>${werkzeugChips(z.sperreFuer)}</div>`);
if (z.benoetigteToys && z.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(z.benoetigteToys)}</div>`);
return `<div class="item" id="ditem-${esc(z.sperreId)}">
<div class="item-row" onclick="toggleItem('${esc(z.sperreId)}')">
<span class="item-text">${esc(z.kurzText)}</span>
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
</div>
${detailRows.length ? `<div class="item-detail">${detailRows.join('')}</div>` : ''}
</div>`;
}
// ── Sort ──
function sortByLevelThenName(items) {
return items.slice().sort((a, b) => {
const la = a.level ?? 999, lb = b.level ?? 999;
if (la !== lb) return la - lb;
return (a.kurzText || '').localeCompare(b.kurzText || '', 'de');
});
}
function sortByName(items) {
return items.slice().sort((a, b) => (a.kurzText || '').localeCompare(b.kurzText || '', 'de'));
}
// ── Group toggle ──
function toggleGroup(gruppenId) {
const card = document.getElementById('dgroup-' + gruppenId);
const body = document.getElementById('dbody-' + gruppenId);
if (!card) return;
if (card.classList.contains('open')) {
card.classList.remove('open');
body.style.display = 'none';
if (openGroupId === gruppenId) openGroupId = null;
} else {
if (openGroupId) {
const prev = document.getElementById('dgroup-' + openGroupId);
const prevBody = document.getElementById('dbody-' + openGroupId);
if (prev) prev.classList.remove('open');
if (prevBody) prevBody.style.display = 'none';
}
card.classList.add('open');
body.style.display = 'block';
openGroupId = gruppenId;
openItemId = null;
}
}
// ── Item toggle ──
function toggleItem(itemId) {
if (openItemId === itemId) {
const el = document.getElementById('ditem-' + itemId);
if (el) el.classList.remove('open');
openItemId = null;
return;
}
if (openItemId) {
const prev = document.getElementById('ditem-' + openItemId);
if (prev) prev.classList.remove('open');
}
const el = document.getElementById('ditem-' + itemId);
if (el) el.classList.add('open');
openItemId = itemId;
}
// ── Subscribe / Unsubscribe ──
function toggleSubscribe(gruppenId, btn) {
btn.disabled = true;
const isSubscribed = btn.classList.contains('subscribed');
const method = isSubscribed ? 'DELETE' : 'POST';
fetch(`/abo/${gruppenId}`, { method })
.then(r => {
if (r.ok || r.status === 201 || r.status === 202) {
if (isSubscribed) {
btn.classList.remove('subscribed');
btn.textContent = '♥ Abonnieren';
updateBadge(gruppenId, false);
} else {
btn.classList.add('subscribed');
btn.textContent = '♥ Abonniert';
updateBadge(gruppenId, true);
}
btn.disabled = false;
} else {
btn.disabled = false;
}
})
.catch(() => { btn.disabled = false; });
}
function updateBadge(gruppenId, subscribed) {
const card = document.getElementById('dgroup-' + gruppenId);
if (!card) return;
const badgesEl = card.querySelector('.gruppe-badges');
if (!badgesEl) return;
const subBadge = badgesEl.querySelector('.gruppe-badge-sub');
if (subscribed && !subBadge) {
badgesEl.insertAdjacentHTML('beforeend', `<span class="gruppe-badge gruppe-badge-sub">♥ Abonniert</span>`);
} else if (!subscribed && subBadge) {
subBadge.remove();
}
}
// ── Search ──
document.getElementById('searchBtn').addEventListener('click', () => {
currentName = document.getElementById('searchInput').value.trim();
currentPage = 0;
loadGroups();
});
document.getElementById('searchInput').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('searchBtn').click();
});
// ── Paging ──
function updatePaging(current, total) {
const el = document.getElementById('paging');
if (total <= 1) { el.style.display = 'none'; return; }
el.style.display = 'flex';
document.getElementById('prevBtn').disabled = current === 0;
document.getElementById('nextBtn').disabled = current >= total - 1;
document.getElementById('pageInfo').textContent = `Seite ${current + 1} von ${total}`;
}
document.getElementById('prevBtn').addEventListener('click', () => {
if (currentPage > 0) { currentPage--; loadGroups(); }
});
document.getElementById('nextBtn').addEventListener('click', () => {
if (currentPage < totalPages - 1) { currentPage++; loadGroups(); }
});
</script>
</body>
</html>

View File

@@ -0,0 +1,642 @@
<!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>Toys xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Section ── */
.section + .section { margin-top: 2.5rem; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-secondary);
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary);
margin: 0;
}
.btn-add {
display: flex;
align-items: center;
gap: 0.4rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-add:hover { background: #c73652; }
/* ── Toy grid ── */
.toy-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 0.85rem;
}
/* ── Toy card ── */
.toy-card {
display: flex;
align-items: center;
gap: 0.85rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 0.8rem 0.9rem;
transition: border-color 0.15s;
position: relative;
}
.toy-card { cursor: pointer; }
.toy-card:hover { border-color: var(--color-primary); }
.toy-card.selected {
border-color: var(--color-primary);
background: rgba(233,69,96,0.06);
}
.toy-img {
width: 52px; height: 52px;
border-radius: 7px;
object-fit: cover;
flex-shrink: 0;
}
.toy-img-placeholder {
width: 52px; height: 52px;
border-radius: 7px;
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
flex-shrink: 0;
color: var(--color-muted);
}
.toy-info { flex: 1; min-width: 0; }
.toy-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toy-desc {
font-size: 0.78rem;
color: var(--color-muted);
margin-top: 0.2rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Section action buttons ── */
.section-actions { display: flex; align-items: center; gap: 0.5rem; }
.btn-action {
background: var(--color-secondary);
color: var(--color-text);
border: none;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s, opacity 0.15s;
}
.btn-action:disabled { opacity: 0.35; cursor: default; }
.btn-action:not(:disabled):hover { background: var(--color-primary); color: #fff; }
.btn-action-danger:not(:disabled):hover { background: rgba(233,69,96,0.18); color: var(--color-primary); }
.action-error {
font-size: 0.82rem;
color: var(--color-primary);
min-height: 1.1em;
margin-bottom: 0.4rem;
}
/* ── Empty / Loading ── */
.empty, .loading { color: var(--color-muted); font-size: 0.9rem; padding: 0.75rem 0; }
/* ── Inline-Fehler im Grid ── */
.grid-error {
font-size: 0.85rem;
color: var(--color-primary);
padding: 0.5rem 0;
}
/* ── Modal ── */
.modal-backdrop {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-backdrop.open { display: flex; }
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 420px;
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
}
.modal h2 {
color: var(--color-primary);
font-size: 1.1rem;
margin-bottom: 1.25rem;
}
.modal label {
display: block;
font-size: 0.8rem;
color: #aaa;
margin-top: 1rem;
margin-bottom: 0.3rem;
}
.modal input[type="text"],
.modal textarea {
width: 100%;
padding: 0.6rem 0.85rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
color: var(--color-text);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
resize: vertical;
}
.modal input[type="text"]:focus,
.modal textarea:focus { border-color: var(--color-primary); }
.modal input[type="file"] {
font-size: 0.85rem;
color: var(--color-muted);
margin-top: 0.25rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.modal-actions .btn-cancel {
background: var(--color-secondary);
color: var(--color-text);
border: none;
border-radius: 6px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s;
}
.modal-actions .btn-cancel:hover { background: #1a4a8a; }
.modal-actions .btn-save {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.modal-actions .btn-save:hover { background: #c73652; }
.modal-actions .btn-save:disabled { opacity: 0.5; cursor: default; }
.modal-error {
color: var(--color-primary);
font-size: 0.82rem;
margin-top: 0.75rem;
display: none;
}
@media (max-width: 768px) {
.toy-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body class="app">
<!-- Erstell-/Bearbeitungs-Modal -->
<div class="modal-backdrop" id="createModal">
<div class="modal">
<h2 id="modalTitle">Neues Toy</h2>
<label for="toyName">Name *</label>
<input type="text" id="toyName" placeholder="z.B. Vibrator" maxlength="100">
<label for="toyDesc">Beschreibung</label>
<textarea id="toyDesc" rows="3" placeholder="Kurze Beschreibung…" maxlength="500"></textarea>
<label>Bild (optional)</label>
<div id="currentImageWrap" style="display:none; align-items:center; gap:0.5rem; margin-bottom:0.4rem;">
<img id="currentImage" style="max-width:64px; max-height:64px; border-radius:6px;" src="" alt="">
<span style="font-size:0.78rem; color:var(--color-muted);">Aktuelles Bild neues Bild wählen zum Ersetzen</span>
</div>
<input type="file" id="toyBild" accept="image/*">
<div class="modal-error" id="modalError"></div>
<div class="modal-actions">
<button class="btn-cancel" id="cancelBtn">Abbrechen</button>
<button class="btn-save" id="saveBtn">Speichern</button>
</div>
</div>
</div>
<div class="main">
<div class="content">
<!-- Meine Toys -->
<div class="section">
<div class="section-header">
<h2 class="section-title">Meine Toys</h2>
<div class="section-actions">
<button class="btn-action" id="editBtn" disabled>✎ Bearbeiten</button>
<button class="btn-action btn-action-danger" id="deleteBtn" disabled>✕ Löschen</button>
<button class="btn-add" id="openCreateBtn">+ Neu</button>
</div>
</div>
<div class="action-error" id="actionError"></div>
<div class="toy-grid" id="userGrid"></div>
<div id="userLoading" class="loading" style="display:none;"></div>
<div id="userSentinel"></div>
</div>
<!-- System-Toys -->
<div class="section">
<div class="section-header">
<h2 class="section-title">System-Toys</h2>
<div class="section-actions">
<button class="btn-action" id="copyBtn" disabled>⊕ In meine Toys kopieren</button>
</div>
</div>
<div class="action-error" id="systemActionError"></div>
<div class="toy-grid" id="systemGrid"></div>
<div id="systemLoading" class="loading" style="display:none;"></div>
<div id="systemSentinel"></div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
const PAGE_SIZE = 12;
let userPage = 0, userTotalPages = 1, userLoading = false;
let systemPage = 0, systemTotalPages = 1, systemLoading = false;
// ── Infinite-scroll observers ──
const userObserver = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadUserPage();
}, { rootMargin: '200px' });
const systemObserver = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadSystemPage();
}, { rootMargin: '200px' });
// ── Auth + initial load ──
fetch('/login/me')
.then(r => { if (r.status === 401) { window.location.href = '/login.html'; return null; } return r.ok ? r.json() : null; })
.then(user => {
if (!user) return;
userObserver.observe(document.getElementById('userSentinel'));
systemObserver.observe(document.getElementById('systemSentinel'));
})
.catch(() => { window.location.href = '/login.html'; });
// ── Load user toys (append, füllt Viewport automatisch auf) ──
async function loadUserPage() {
if (userLoading || userPage >= userTotalPages) return;
userLoading = true;
const loadEl = document.getElementById('userLoading');
try {
do {
loadEl.textContent = 'Wird geladen…';
loadEl.style.display = 'block';
const r = await fetch(`/toy/list/user?page=${userPage}&size=${PAGE_SIZE}`);
const data = await r.json();
userTotalPages = data.totalPages || 1;
appendGrid('userGrid', data.content, 'selectToy');
userPage++;
loadEl.style.display = 'none';
} while (userPage < userTotalPages && sentinelVisible('userSentinel'));
} catch (_) {
loadEl.textContent = 'Fehler beim Laden.';
} finally {
userLoading = false;
}
}
function reloadUserToys() {
userPage = 0;
userTotalPages = 1;
resetSelection();
document.getElementById('userGrid').innerHTML = '';
loadUserPage();
}
// ── Load system toys (append, füllt Viewport automatisch auf) ──
async function loadSystemPage() {
if (systemLoading || systemPage >= systemTotalPages) return;
systemLoading = true;
const loadEl = document.getElementById('systemLoading');
try {
do {
loadEl.textContent = 'Wird geladen…';
loadEl.style.display = 'block';
const r = await fetch(`/toy/list/system?page=${systemPage}&size=${PAGE_SIZE}`);
const data = await r.json();
systemTotalPages = data.totalPages || 1;
appendGrid('systemGrid', data.content, 'selectSystemToy');
systemPage++;
loadEl.style.display = 'none';
} while (systemPage < systemTotalPages && sentinelVisible('systemSentinel'));
} catch (_) {
loadEl.textContent = 'Fehler beim Laden.';
} finally {
systemLoading = false;
}
}
function reloadSystemToys() {
systemPage = 0;
systemTotalPages = 1;
resetSystemSelection();
document.getElementById('systemGrid').innerHTML = '';
loadSystemPage();
}
// ── Prüft ob ein Sentinel noch im (erweiterten) Viewport liegt ──
function sentinelVisible(id) {
const el = document.getElementById(id);
return el ? el.getBoundingClientRect().top <= window.innerHeight + 200 : false;
}
// ── Append items to a grid ──
function appendGrid(gridId, toys, selectFn) {
const grid = document.getElementById(gridId);
if (!toys || toys.length === 0) {
if (!grid.querySelector('.toy-card')) {
grid.innerHTML = '<p class="empty">Keine Einträge vorhanden.</p>';
}
return;
}
const emptyEl = grid.querySelector('.empty');
if (emptyEl) emptyEl.remove();
grid.insertAdjacentHTML('beforeend', toys.map(toy => `
<div class="toy-card" data-id="${esc(toy.toyId)}"
${selectFn ? `onclick="${selectFn}('${esc(toy.toyId)}')"` : ''}>
${toy.bild
? `<img class="toy-img" src="data:image/png;base64,${toy.bild}" alt="${esc(toy.name)}">`
: `<div class="toy-img-placeholder">◈</div>`}
<div class="toy-info">
<div class="toy-name">${esc(toy.name)}</div>
${toy.beschreibung ? `<div class="toy-desc">${esc(toy.beschreibung)}</div>` : ''}
</div>
</div>
`).join(''));
}
// ── Selection ──
let selectedUserToyId = null;
function selectToy(toyId) {
const prev = document.querySelector('#userGrid .toy-card.selected');
if (prev) prev.classList.remove('selected');
if (selectedUserToyId === toyId) {
selectedUserToyId = null;
} else {
selectedUserToyId = toyId;
document.querySelector(`#userGrid .toy-card[data-id="${toyId}"]`).classList.add('selected');
}
const has = selectedUserToyId != null;
document.getElementById('editBtn').disabled = !has;
document.getElementById('deleteBtn').disabled = !has;
document.getElementById('actionError').textContent = '';
}
function resetSelection() {
selectedUserToyId = null;
document.getElementById('editBtn').disabled = true;
document.getElementById('deleteBtn').disabled = true;
document.getElementById('actionError').textContent = '';
}
// ── System-Toy selection ──
let selectedSystemToyId = null;
function selectSystemToy(toyId) {
const prev = document.querySelector('#systemGrid .toy-card.selected');
if (prev) prev.classList.remove('selected');
if (selectedSystemToyId === toyId) {
selectedSystemToyId = null;
} else {
selectedSystemToyId = toyId;
document.querySelector(`#systemGrid .toy-card[data-id="${toyId}"]`).classList.add('selected');
}
document.getElementById('copyBtn').disabled = selectedSystemToyId == null;
document.getElementById('systemActionError').textContent = '';
}
function resetSystemSelection() {
selectedSystemToyId = null;
document.getElementById('copyBtn').disabled = true;
document.getElementById('systemActionError').textContent = '';
}
// ── Copy system toy ──
document.getElementById('copyBtn').addEventListener('click', () => {
if (!selectedSystemToyId) return;
const btn = document.getElementById('copyBtn');
btn.disabled = true;
fetch(`/toy/copy/${selectedSystemToyId}`, { method: 'POST' })
.then(r => {
if (r.ok || r.status === 201) {
reloadUserToys();
document.getElementById('systemActionError').textContent = '';
} else if (r.status === 409 && r.headers.get('X-Error') === 'duplicate-name') {
document.getElementById('systemActionError').textContent =
'Du hast bereits ein Toy mit diesem Namen.';
btn.disabled = false;
} else {
document.getElementById('systemActionError').textContent =
'Fehler beim Kopieren (HTTP ' + r.status + ').';
btn.disabled = false;
}
})
.catch(() => {
document.getElementById('systemActionError').textContent = 'Verbindungsfehler.';
btn.disabled = false;
});
});
// ── Header action buttons ──
document.getElementById('editBtn').addEventListener('click', () => {
if (selectedUserToyId) openModal(selectedUserToyId);
});
document.getElementById('deleteBtn').addEventListener('click', () => {
if (!selectedUserToyId) return;
if (!confirm('Toy wirklich löschen?')) return;
const btn = document.getElementById('deleteBtn');
btn.disabled = true;
const toyId = selectedUserToyId;
fetch(`/toy/${toyId}`, { method: 'DELETE' })
.then(r => {
if (r.status === 409) {
showActionError('Wird in Aufgaben verwendet nicht löschbar.');
btn.disabled = false;
} else if (r.status === 403) {
showActionError('Keine Berechtigung.');
btn.disabled = false;
} else if (r.ok || r.status === 202) {
reloadUserToys();
} else {
showActionError('Fehler beim Löschen.');
btn.disabled = false;
}
})
.catch(() => { showActionError('Verbindungsfehler.'); btn.disabled = false; });
});
function showActionError(msg) {
const el = document.getElementById('actionError');
el.textContent = msg;
setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 4000);
}
// ── Create / Edit modal ──
const modal = document.getElementById('createModal');
const saveBtn = document.getElementById('saveBtn');
let currentEditId = null;
function openModal(editId) {
currentEditId = editId || null;
document.getElementById('modalError').style.display = 'none';
document.getElementById('toyBild').value = '';
if (currentEditId) {
fetch(`/toy/${currentEditId}`)
.then(r => r.ok ? r.json() : null)
.then(toy => {
if (!toy) return;
document.getElementById('modalTitle').textContent = 'Toy bearbeiten';
document.getElementById('toyName').value = toy.name || '';
document.getElementById('toyDesc').value = toy.beschreibung || '';
const imgWrap = document.getElementById('currentImageWrap');
if (toy.bild) {
document.getElementById('currentImage').src = 'data:image/png;base64,' + toy.bild;
imgWrap.style.display = 'flex';
} else {
imgWrap.style.display = 'none';
}
modal.classList.add('open');
document.getElementById('toyName').focus();
})
.catch(() => alert('Fehler beim Laden des Toys.'));
} else {
document.getElementById('modalTitle').textContent = 'Neues Toy';
document.getElementById('toyName').value = '';
document.getElementById('toyDesc').value = '';
document.getElementById('currentImageWrap').style.display = 'none';
modal.classList.add('open');
document.getElementById('toyName').focus();
}
}
document.getElementById('openCreateBtn').addEventListener('click', () => openModal(null));
document.getElementById('cancelBtn').addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
function closeModal() { modal.classList.remove('open'); }
function editToy(toyId) { openModal(toyId); }
saveBtn.addEventListener('click', async () => {
const name = document.getElementById('toyName').value.trim();
if (!name) {
showModalError('Bitte einen Namen eingeben.');
return;
}
saveBtn.disabled = true;
saveBtn.textContent = 'Speichert…';
let bildBase64 = null;
const fileInput = document.getElementById('toyBild');
if (fileInput.files.length > 0) {
bildBase64 = await toBase64(fileInput.files[0]);
}
const payload = {
name,
beschreibung: document.getElementById('toyDesc').value.trim() || null,
bild: bildBase64
};
const isEdit = currentEditId != null;
fetch(isEdit ? `/toy/${currentEditId}` : '/toy', {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(r => {
if (r.ok || r.status === 201) {
closeModal();
reloadUserToys();
} else if (r.status === 409 && r.headers.get('X-Error') === 'duplicate-name') {
showModalError('Ein Toy mit diesem Namen existiert bereits.');
} else {
showModalError('Fehler beim Speichern (HTTP ' + r.status + ').');
}
})
.catch(() => showModalError('Verbindungsfehler.'))
.finally(() => { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; });
});
function showModalError(msg) {
const el = document.getElementById('modalError');
el.textContent = msg;
el.style.display = 'block';
}
function toBase64(file) {
const MAX = 128;
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > MAX || h > MAX) {
if (w >= h) { h = Math.max(1, Math.round(MAX * h / w)); w = MAX; }
else { w = Math.max(1, Math.round(MAX * w / h)); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/png').split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
// ── XSS-Schutz ──
function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
</body>
</html>

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0;url=/games/aufgaben/aufgaben.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aufgaben BDSM xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
@@ -96,6 +97,7 @@
.gruppe-badge-private { background: rgba(233,69,96,0.15); color: var(--color-primary); }
.gruppe-badge-public { background: rgba(46,204,113,0.15); color: var(--color-success); }
.gruppe-badge-vanilla { background: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; }
.gruppe-badge-chastity { background: rgba(155,89,182,0.15); color: #9b59b6; border: 1px solid rgba(155,89,182,0.4); }
.gruppe-toggle { font-size: 0.75rem; color: var(--color-muted); flex-shrink: 0; transition: transform 0.2s; }
.gruppe-card.open .gruppe-toggle { transform: rotate(90deg); }
@@ -368,12 +370,14 @@
Gruppe veröffentlichen (für alle sichtbar)
</span>
</label>
<label>
<span class="modal-check">
<input type="checkbox" id="gVanilla">
Auch für Vanilla-Game verfügbar
</span>
</label>
<div style="margin-top:0.5rem;">
<label for="gAvailableIn" style="font-size:0.85rem;display:block;margin-bottom:0.3rem;">Verfügbar in</label>
<select id="gAvailableIn" style="width:100%;padding:0.5rem 0.75rem;border-radius:6px;border:1px solid var(--color-secondary);background:var(--color-secondary);color:var(--color-text);font-size:0.9rem;">
<option value="BDSM_ONLY">Nur BDSM</option>
<option value="BDSM_AND_VANILLA">BDSM &amp; Vanilla</option>
<option value="CHASTITY_ONLY">Nur Chastity</option>
</select>
</div>
<div class="modal-error" id="modalError"></div>
<div class="modal-actions">
<button class="btn-cancel" id="cancelBtn">Abbrechen</button>
@@ -636,8 +640,13 @@
.then(user => { if (!user) return; loadUserGruppen(); loadAboGruppen(); loadSystemGruppen(); })
.catch(() => { window.location.href = '/login.html'; });
// ── Cross-tab notification ──
let _notifyOnLoad = false;
const gruppenBc = new BroadcastChannel('bdsm-gruppen-updated');
// ── Load ──
function loadUserGruppen() {
if (_notifyOnLoad) { _notifyOnLoad = false; try { gruppenBc.postMessage(1); } catch (_) {} }
resetSelection();
document.getElementById('userLoading').style.display = 'block';
fetch(apiUrl(`/gruppe/list/user`) + `?page=${userPage}&size=${PAGE_SIZE}`)
@@ -722,7 +731,8 @@
if (g.privateGruppe) badges.push(`<span class="gruppe-badge gruppe-badge-private">Privat</span>`);
else badges.push(`<span class="gruppe-badge gruppe-badge-public">Öffentlich</span>`);
if (type === 'user' && g.subscriberCount > 0) badges.push(`<span class="gruppe-badge">♥ ${g.subscriberCount} Abo${g.subscriberCount !== 1 ? 's' : ''}</span>`);
if (g.vanillaAvailable) badges.push(`<span class="gruppe-badge gruppe-badge-vanilla">Vanilla</span>`);
if (g.availableIn === 'BDSM_AND_VANILLA') badges.push(`<span class="gruppe-badge gruppe-badge-vanilla">Vanilla</span>`);
if (g.availableIn === 'CHASTITY_ONLY') badges.push(`<span class="gruppe-badge gruppe-badge-chastity">Chastity</span>`);
return `
<div class="gruppe-card" id="gruppe-${esc(g.gruppenId)}">
@@ -936,7 +946,7 @@
openItemId = null;
pendingExpandId = gruppenId;
pendingExpandType = 'user';
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else {
document.getElementById('userActionError').textContent = 'Fehler beim Löschen (HTTP ' + r.status + ').';
}
@@ -1077,7 +1087,7 @@
pubCb.checked = !g.privateGruppe;
pubCb.disabled = g.privateGruppe; // Veröffentlichen nur über den Veröffentlichen-Button
document.getElementById('gPublicLabel').style.display = 'block';
document.getElementById('gVanilla').checked = g.vanillaAvailable || false;
document.getElementById('gAvailableIn').value = g.availableIn || 'BDSM_ONLY';
const imgWrap = document.getElementById('gCurrentImgWrap');
if (g.bild) {
document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild;
@@ -1095,7 +1105,7 @@
document.getElementById('gDesc').value = '';
document.getElementById('gPublic').checked = false;
document.getElementById('gPublicLabel').style.display = 'none';
document.getElementById('gVanilla').checked = false;
document.getElementById('gAvailableIn').value = 'BDSM_ONLY';
document.getElementById('gCurrentImgWrap').style.display = 'none';
gruppeModal.classList.add('open');
document.getElementById('gName').focus();
@@ -1129,7 +1139,7 @@
name,
beschreibung: document.getElementById('gDesc').value.trim() || null,
privateGruppe: isEdit ? !document.getElementById('gPublic').checked : true,
vanillaAvailable: document.getElementById('gVanilla').checked,
availableIn: document.getElementById('gAvailableIn').value,
bild: bildBase64
};
@@ -1146,7 +1156,7 @@
pendingExpandType = 'user';
}
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else if (r.status === 409) {
showModalError('Limit erreicht: maximal 10 eigene Aufgabengruppen möglich.');
} else {
@@ -1172,7 +1182,7 @@
.then(r => {
if (r.ok || r.status === 202) {
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else if (r.status === 403) {
document.getElementById('userActionError').textContent = 'Keine Berechtigung.';
btn.disabled = false;
@@ -1194,7 +1204,7 @@
.then(r => {
if (r.ok || r.status === 201) {
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
document.getElementById('systemActionError').textContent = '';
} else {
document.getElementById('systemActionError').textContent = 'Fehler beim Kopieren (HTTP ' + r.status + ').';
@@ -1213,7 +1223,7 @@
.then(r => {
if (r.ok || r.status === 201) {
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
document.getElementById('aboActionError').textContent = '';
} else if (r.status === 409) {
document.getElementById('aboActionError').textContent = 'Limit erreicht: maximal 10 eigene Aufgabengruppen möglich.';
@@ -1643,7 +1653,7 @@
pendingExpandId = currentItemGruppeId;
pendingExpandType = 'user';
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else if (r.status === 409) {
showItemError('Limit erreicht: maximal 100 Einträge pro Gruppe möglich.');
} else {
@@ -1739,7 +1749,7 @@
pendingExpandId = selectedGruppeId;
pendingExpandType = 'user';
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else {
const errEl = document.getElementById('publishError');
errEl.textContent = 'Fehler beim Veröffentlichen (HTTP ' + r.status + ').';

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0;url=/games/aufgaben/entdecken.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Entdecken xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">

View File

@@ -67,13 +67,36 @@
.card-field:last-child { margin-bottom: 0; }
.card-field > label { font-size: 0.8rem; color: #aaa; margin: 0 0 0.5rem 0; display: block; }
.check-group { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.check-group--two-col { display: grid; grid-template-columns: 1fr 1fr; }
.check-item { display: inline-flex; align-items: flex-start; gap: 0.45rem; background: var(--color-secondary); border: 1px solid transparent; border-radius: 6px; padding: 0.4rem 0.7rem; cursor: pointer; transition: border-color 0.15s; user-select: none; }
.check-group--two-col { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); }
.check-item { display: inline-flex; align-items: flex-start; gap: 0.45rem; background: var(--color-secondary); border: 1px solid transparent; border-radius: 6px; padding: 0.4rem 0.7rem; cursor: pointer; transition: border-color 0.15s; user-select: none; position: relative; }
.check-item.is-checked { border-color: var(--color-primary); }
.check-item.is-disabled { opacity: 0.5; pointer-events: none; cursor: default; }
.check-item input { accent-color: var(--color-primary); width: auto; margin-top: 0.15rem; cursor: pointer; flex-shrink: 0; }
.check-item-label { font-size: 0.88rem; color: var(--color-text); line-height: 1.3; }
.check-item-desc { display: block; font-size: 0.72rem; color: var(--color-muted); margin-top: 0.1rem; }
.check-item-label { font-size: 0.88rem; color: var(--color-text); line-height: 1.3; display: flex; align-items: center; gap: 0.2rem; flex-wrap: wrap; }
.check-item-desc { display: none; }
.check-item-tooltip {
display: none; position: absolute; bottom: calc(100% + 6px); left: 0;
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 6px; padding: 0.4rem 0.65rem;
font-size: 0.78rem; color: var(--color-muted); line-height: 1.4;
width: max-content; max-width: 210px;
z-index: 50; pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.35);
}
.check-item:hover .check-item-tooltip { display: block; }
.check-item-info-btn {
display: none; background: none; border: 1px solid var(--color-muted);
border-radius: 50%; width: 1.1rem; height: 1.1rem; font-size: 0.62rem;
color: var(--color-muted); cursor: pointer; padding: 0; line-height: 1;
flex-shrink: 0; font-style: normal; font-weight: normal;
align-items: center; justify-content: center;
}
.check-item-info-btn.active { border-color: var(--color-primary); color: var(--color-primary); }
.check-item-desc-mobile { display: none; font-size: 0.72rem; color: var(--color-muted); margin-top: 0.25rem; line-height: 1.4; }
@media (max-width: 679px) {
.check-item:hover .check-item-tooltip { display: none; }
.check-item-info-btn { display: inline-flex; }
}
.field-error { font-size: 0.78rem; color: var(--color-primary); margin-top: 0.3rem; display: none; }
.add-player-btn { width: 100%; background: transparent; border: 1px dashed var(--color-secondary); color: var(--color-muted); padding: 0.7rem; border-radius: 8px; font-size: 0.88rem; font-weight: normal; cursor: pointer; transition: border-color 0.15s, color 0.15s; margin-top: 0.5rem; }
.add-player-btn:hover { border-color: var(--color-primary); color: var(--color-text); background: transparent; }
@@ -160,8 +183,7 @@
<div class="main" id="setupView" style="display:none;">
<div class="content">
<h1>BDSM Game</h1>
<p id="pageSubtitle" style="margin-bottom:1.5rem;">Session einrichten</p>
<h1>BDSM Game - Session einrichten</h1>
<!-- Accordion 1: Grundeinstellungen -->
<div class="acc-item">
@@ -212,6 +234,9 @@
</button>
<div class="acc-body" id="acc-aufgaben-body">
<div id="guestAufgabenHint" class="guest-hint" style="display:none;">Aufgaben werden vom Host festgelegt nur zur Ansicht.</div>
<p style="font-size:0.85rem;color:var(--color-muted);margin-bottom:0.75rem;">
Gruppen verwalten: <a href="/games/bdsm/aufgaben.html" target="_blank" style="color:var(--color-primary);">Aufgaben-Verwaltung (BDSM)</a>
</p>
<div id="sectionOwn">
<div class="aufgaben-section-label"><label class="select-all-label"><input type="checkbox" class="select-all-cb" data-list="listOwn"> Eigene Gruppen</label></div>
<ul class="gruppe-list" id="listOwn"><li class="empty-hint">Wird geladen…</li></ul>
@@ -281,11 +306,11 @@
DIVERS: ['MUND','ANUS','UMSCHNALLDILDO'],
};
const WERKZEUGE = [
{ value: 'MUND', label: 'Mund', desc: 'Gewillt den Mund einzusetzen' },
{ value: 'VAGINA', label: 'Vagina', desc: 'Verfügt über eine Vagina und setzt sie ein' },
{ value: 'PENIS', label: 'Penis', desc: 'Verfügt über einen Penis und setzt ihn ein' },
{ value: 'ANUS', label: 'Anus', desc: 'Gewillt den Anus einzusetzen' },
{ value: 'VAGINA', label: 'Vagina', desc: 'Verfügt über eine Vagina' },
{ value: 'PENIS', label: 'Penis', desc: 'Verfügt über einen Penis' },
{ value: 'UMSCHNALLDILDO', label: 'Umschnall-Dildo', desc: 'Verfügt über einen Umschnall-Dildo' },
{ value: 'MUND', label: 'Oral', desc: 'Ist für aktiven Oral-Verkehr' },
{ value: 'ANUS', label: 'Anal', desc: 'Ist Bereit passiv Anal-Verkehr zu haben ' },
];
const ROLE_LABELS = {
AUFGABE_AKTIV: 'Aufgabe Aktiv', AUFGABE_PASSIV: 'Aufgabe Passiv',
@@ -417,10 +442,19 @@
return items.map(({ value, label, desc }) => `
<label class="check-item${disabled ? ' is-disabled' : ''}">
<input type="${type}" name="${name}" value="${value}"${disabled ? ' disabled' : ''}>
<span><span class="check-item-label">${label}</span>${desc ? `<span class="check-item-desc">${desc}</span>` : ''}</span>
<span><span class="check-item-label">${label}${desc ? `<button type="button" class="check-item-info-btn" onclick="event.stopPropagation();toggleCheckDesc(this);">ⓘ</button>` : ''}</span>${desc ? `<span class="check-item-tooltip">${desc}</span><span class="check-item-desc-mobile">${desc}</span>` : ''}</span>
</label>`).join('');
}
function toggleCheckDesc(btn) {
const mobile = btn.closest('.check-item')?.querySelector('.check-item-desc-mobile');
if (!mobile) return;
const isVisible = mobile.style.display === 'block';
document.querySelectorAll('.check-item-desc-mobile').forEach(el => { el.style.display = 'none'; });
document.querySelectorAll('.check-item-info-btn').forEach(el => el.classList.remove('active'));
if (!isVisible) { mobile.style.display = 'block'; btn.classList.add('active'); }
}
function buildPlayerBody(id, nameValue, nameReadOnly = false, genderDisabled = false, allDisabled = false) {
const globalDefault = document.getElementById('chkZeitstrafen')?.checked ?? true;
const isCheckedCls = globalDefault ? ' is-checked' : '';
@@ -650,15 +684,15 @@
const selectAllWrap = section?.querySelector('.select-all-label');
if (!gruppen.length) {
ul.innerHTML = '<li class="empty-hint">Keine Gruppen vorhanden.</li>';
if (selectAllWrap) selectAllWrap.style.visibility = 'hidden'; return;
if (selectAllWrap) { const cb = selectAllWrap.querySelector('input'); if (cb) cb.disabled = true; selectAllWrap.style.pointerEvents = 'none'; selectAllWrap.style.opacity = '0.4'; } return;
}
ul.innerHTML = gruppen.map(g => {
const checked = savedGruppen.has(g.gruppenId);
const vanillaBadge = g.vanillaAvailable ? '<span class="vanilla-badge">Vanilla</span>' : '';
const vanillaBadge = g.availableIn === 'BDSM_AND_VANILLA' ? '<span class="vanilla-badge">Vanilla</span>' : '';
return `<li><label class="gruppe-item${checked ? ' is-checked' : ''}">
<input type="checkbox" value="${g.gruppenId}"${checked ? ' checked' : ''}>
<span><span class="gruppe-item-name">${g.name}${vanillaBadge}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span>
${g.bild ? `<img class="item-img" src="data:image/png;base64,${g.bild}" alt="">` : ''}
<span><span class="gruppe-item-name">${g.name}${vanillaBadge}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span>
</label></li>`;
}).join('');
updateSelectAll(ul);
@@ -1033,8 +1067,7 @@
setFieldError(`p${id}-geschlecht-err`, geschlecht.length === 0);
setFieldError(`p${id}-spieltmit-err`, spieltMit.length === 0);
setFieldError(`p${id}-rollen-err`, rollen.length === 0);
setFieldError(`p${id}-werkzeuge-err`, werkzeuge.length === 0);
if (!name || !geschlecht.length || !spieltMit.length || !rollen.length || !werkzeuge.length) valid = false;
if (!name || !geschlecht.length || !spieltMit.length || !rollen.length) valid = false;
const sperre = document.getElementById(`p${id}-sperrenAufloesen`);
return { name, geschlecht: geschlecht[0] || null, spieltMit, rollen, werkzeuge,
userId: inv ? inv.inviteeId : (id === selfPlayerId ? myUserId : null),
@@ -1196,8 +1229,7 @@
setFieldError(`p${id}-geschlecht-err`, geschlecht.length === 0);
setFieldError(`p${id}-spieltmit-err`, spieltMit.length === 0);
setFieldError(`p${id}-rollen-err`, rollen.length === 0);
setFieldError(`p${id}-werkzeuge-err`, werkzeuge.length === 0);
if (!geschlecht.length || !spieltMit.length || !rollen.length || !werkzeuge.length) {
if (!geschlecht.length || !spieltMit.length || !rollen.length) {
showMessage('Bitte alle Felder ausfüllen.', 'error'); return;
}
const sperre = document.getElementById(`p${id}-sperrenAufloesen`);
@@ -1482,6 +1514,13 @@
}
init();
const _sessBc = new BroadcastChannel('bdsm-gruppen-updated');
_sessBc.onmessage = () => {
document.querySelectorAll('.gruppe-list input[type="checkbox"]:checked').forEach(cb => savedGruppen.add(cb.value));
document.querySelectorAll('.gruppe-list input[type="checkbox"]:not(:checked)').forEach(cb => savedGruppen.delete(cb.value));
ladeGruppenListen();
};
</script>
</body>
</html>

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0;url=/games/aufgaben/toys.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Toys xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">

View File

@@ -57,27 +57,90 @@
}
.nextcard-cards {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
gap: 0.5rem;
position: relative;
border-radius: 6px;
padding: 0.75rem 0.5rem 0.5rem;
overflow: visible;
padding: 0.75rem 0 1rem;
}
.nc-window {
flex: 1;
overflow-x: hidden;
overflow-y: visible;
min-width: 0;
padding-top: 14px;
position: relative;
}
.nc-slide-wrapper {
display: flex;
will-change: transform;
}
.nextcard-card-img {
width: calc((100% - 5 * 0.6rem) / 6);
flex-shrink: 0;
width: 130px;
height: auto;
border-radius: 6px;
position: relative;
z-index: 1;
margin-right: var(--nc-gap, 6px);
transition: transform 0.15s, box-shadow 0.15s;
}
.nextcard-card-img:last-child { margin-right: 0; }
.nextcard-panel.drawable .nextcard-card-img:hover {
transform: translateY(-8px) scale(1.06);
transform: translateY(-10px) scale(1.08);
box-shadow: 0 8px 20px rgba(0,0,0,0.4);
z-index: 10;
cursor: pointer;
}
.nc-nav-btn {
flex-shrink: 0;
width: 48px;
background: var(--color-secondary);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 8px;
color: var(--color-text);
font-size: 0.72rem;
font-weight: 600;
padding: 0.5rem 0.2rem;
cursor: pointer;
text-align: center;
line-height: 1.4;
align-self: stretch;
display: none;
transition: background 0.15s;
}
.nc-nav-btn:hover { background: var(--color-primary); }
/* ── Touch-Karussell ── */
.nextcard-cards.carousel-mode {
overflow: hidden;
height: 210px;
padding: 1rem 0 1rem;
justify-content: center;
}
.carousel-card {
position: absolute;
width: 100px;
border-radius: 8px;
box-shadow: 2px 4px 14px rgba(0,0,0,0.55);
transition: transform 0.18s cubic-bezier(.4,0,.2,1), opacity 0.18s;
user-select: none;
-webkit-user-drag: none;
}
.carousel-card.pos-center { transform: translateX(0) scale(1.18); opacity: 1; z-index: 5; }
.carousel-card.pos-left { transform: translateX(-120px) scale(0.84); opacity: 0.72; z-index: 3; }
.carousel-card.pos-right { transform: translateX(120px) scale(0.84); opacity: 0.72; z-index: 3; }
.carousel-card.pos-far-left { transform: translateX(-210px) scale(0.68); opacity: 0.4; z-index: 1; }
.carousel-card.pos-far-right { transform: translateX(210px) scale(0.68); opacity: 0.4; z-index: 1; }
.carousel-card.pos-exit-left {
transform: translateX(-400px) scale(0.4) rotate(-15deg);
opacity: 0; z-index: 0;
transition: transform 0.32s ease-in, opacity 0.32s ease-in;
}
.carousel-card.pos-exit-right {
transform: translateX(400px) scale(0.4) rotate(15deg);
opacity: 0; z-index: 0;
transition: transform 0.32s ease-in, opacity 0.32s ease-in;
}
.nextcard-panel.drawable .carousel-card.pos-center { cursor: pointer; }
.nextcard-overlay {
position: absolute;
inset: 0;
@@ -595,6 +658,12 @@
<button class="btn-hygiene" id="hygieneBtn" style="display:none;" onclick="openHygieneModal()">🚿 Hygiene-Öffnung</button>
</div>
<!-- Speed-Effekt-Panel -->
<div id="speedPanel" style="display:none;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;padding:0.85rem 1.1rem;gap:0.35rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--color-muted);" id="speedPanelTitle">Slow Motion aktiv</div>
<div style="font-size:0.9rem;font-weight:600;" id="speedPanelInfo"></div>
</div>
<!-- Verifikations-Panel -->
<div class="verification-panel" id="verificationPanel" style="display:none;">
<div class="verification-panel-title">Tägliche Verifikation</div>
@@ -724,6 +793,12 @@
<span id="drawTaskPendingText"></span>
</div>
<!-- Speed-Karte: Zeitpunkt wählen -->
<div id="drawSpeedPicker" style="display:none;margin-top:0.75rem;padding:0.75rem 1rem;border-radius:8px;background:rgba(100,149,237,0.10);border:1px solid rgba(100,149,237,0.3);gap:0.6rem;text-align:center;">
<div style="font-size:0.88rem;color:var(--color-text);">Wähle den Zeitpunkt, bis zu dem der Effekt aktiv sein soll:</div>
<input type="datetime-local" id="drawSpeedUntilInput" style="background:var(--color-secondary);border:1px solid var(--color-secondary);border-radius:7px;padding:0.45rem 0.75rem;color:var(--color-text);font-size:0.9rem;width:100%;box-sizing:border-box;">
</div>
<!-- Grüne Karte: Entscheidung -->
<div class="draw-green-choice" id="drawGreenChoice">
<p id="drawGreenText" style="text-align:center;font-size:0.88rem;color:var(--color-muted);margin:0;">
@@ -735,6 +810,8 @@
<div class="draw-modal-actions" id="drawModalActions" style="display:none;">
<!-- Non-green: OK -->
<button class="btn-draw-ok" id="btnDrawOk" onclick="closeDrawModal()">OK</button>
<!-- Speed-Karte: Bestätigen -->
<button class="btn-draw-ok" id="btnSpeedConfirm" style="display:none;" onclick="confirmSpeedCard()">✓ Bestätigen</button>
<!-- Green: zwei Optionen -->
<button class="btn-draw-unlock" id="btnDrawUnlock" style="display:none;" onclick="confirmUnlock()">🔓 Entsperren</button>
<button class="btn-draw-keep" id="btnDrawKeep" style="display:none;" onclick="keepGreenCard()">Zurücklegen</button>
@@ -833,6 +910,7 @@
renderAssignedTasks(lock);
renderNextCardPanel(lock);
renderHygienePanel(lock);
renderSpeedPanel(lock);
renderVerificationPanel(lock);
renderTempOpeningPanel(lock);
renderCardsPanel(lock);
@@ -1095,17 +1173,14 @@
panel.style.display = '';
panel.classList.remove('drawable');
// Karten-Bilder rendern (Overlay bleibt erhalten)
cardsDiv.querySelectorAll('.nextcard-card-img').forEach(el => el.remove());
const total = lock.totalCards || 0;
const show = Math.min(total, 36);
for (let i = 0; i < show; i++) {
const img = document.createElement('img');
img.className = 'nextcard-card-img';
img.src = '/img/card.png';
img.alt = 'Karte';
cardsDiv.insertBefore(img, overlay);
}
// Karten-Darstellung aufbauen
if (ncResizeObs) { ncResizeObs.disconnect(); ncResizeObs = null; }
cardsDiv.querySelectorAll('.nc-nav-btn, .nc-window, .carousel-card').forEach(el => el.remove());
cardsDiv.classList.remove('carousel-mode');
const total = lock.totalCards || 0;
const isTouch = window.matchMedia('(hover: none) and (pointer: coarse)').matches;
if (isTouch) initCarousel(cardsDiv, overlay, total);
else initCardWindow(cardsDiv, overlay, total);
// Overlay-Elemente
const timerBox = document.getElementById('nextcardTimerBox');
@@ -1314,21 +1389,254 @@
// ── Karte ziehen ──
let drawnUnlockCode = null;
function enableCardClick() {
document.querySelectorAll('.nextcard-card-img').forEach(img => {
img.addEventListener('click', onCardClick, { once: true });
// ── Touch-Karussell ──
const CAROUSEL_POS = ['pos-far-left', 'pos-left', 'pos-center', 'pos-right', 'pos-far-right'];
let carouselCards = [];
let carouselShifting = false;
let carouselTouchX = 0;
let carouselTouchT = 0;
let carouselAbort = null;
let carouselDrawable = false;
function initCarousel(cardsDiv, overlay, total) {
if (carouselAbort) carouselAbort.abort();
carouselAbort = new AbortController();
const signal = carouselAbort.signal;
carouselCards = [];
carouselShifting = false;
carouselDrawable = false;
cardsDiv.classList.add('carousel-mode');
for (let i = 0; i < 5; i++) {
const img = document.createElement('img');
img.className = 'carousel-card ' + CAROUSEL_POS[i];
img.src = '/img/card.png';
img.alt = 'Karte';
cardsDiv.insertBefore(img, overlay);
carouselCards.push(img);
}
cardsDiv.addEventListener('touchstart', e => {
carouselTouchX = e.touches[0].clientX;
carouselTouchT = Date.now();
}, { passive: true, signal });
cardsDiv.addEventListener('touchend', e => {
if (carouselShifting) return;
const touch = e.changedTouches[0];
const dx = touch.clientX - carouselTouchX;
const dt = Date.now() - carouselTouchT;
const absDx = Math.abs(dx);
if (absDx < 5) {
// Tap: Element unter dem Finger bestimmen
const el = document.elementFromPoint(touch.clientX, touch.clientY);
const idx = el ? carouselCards.indexOf(el) : -1;
if (idx < 0) return;
if (idx === 2 && carouselDrawable) {
// Mittlere Karte → Karte ziehen
e.preventDefault();
carouselDrawable = false;
carouselCards.forEach(c => c.style.pointerEvents = 'none');
openDrawModal();
} else if (idx < 2) {
_doCarouselShift('right', 1);
} else {
_doCarouselShift('left', 1);
}
return;
}
if (!carouselDrawable) return;
// Swipe erkannt: Geschwindigkeit bestimmt wie viele Karten wechseln
const velocity = absDx / dt;
const steps = velocity > 1.2 ? 3 : velocity > 0.6 ? 2 : 1;
dx < 0 ? _doCarouselShift('left', steps) : _doCarouselShift('right', steps);
}, { passive: false, signal });
}
function shiftCarouselLeft(steps = 1) { _doCarouselShift('left', steps); }
function shiftCarouselRight(steps = 1) { _doCarouselShift('right', steps); }
function _doCarouselShift(dir, remaining) {
if (carouselShifting && remaining === /* first call */ remaining) {/* skip guard for chained */}
carouselShifting = true;
if (dir === 'left') {
carouselCards[0].className = 'carousel-card pos-exit-left';
for (let i = 1; i < 5; i++) carouselCards[i].className = 'carousel-card ' + CAROUSEL_POS[i - 1];
} else {
carouselCards[4].className = 'carousel-card pos-exit-right';
for (let i = 3; i >= 0; i--) carouselCards[i].className = 'carousel-card ' + CAROUSEL_POS[i + 1];
}
setTimeout(() => {
if (dir === 'left') {
const ex = carouselCards.shift();
ex.style.transition = 'none';
ex.className = 'carousel-card pos-exit-right';
ex.getBoundingClientRect();
ex.style.transition = '';
ex.className = 'carousel-card pos-far-right';
carouselCards.push(ex);
} else {
const ex = carouselCards.pop();
ex.style.transition = 'none';
ex.className = 'carousel-card pos-exit-left';
ex.getBoundingClientRect();
ex.style.transition = '';
ex.className = 'carousel-card pos-far-left';
carouselCards.unshift(ex);
}
if (remaining > 1) _doCarouselShift(dir, remaining - 1);
else carouselShifting = false;
}, 180);
}
// ── Karten-Fenster ──
let ncTotal = 0;
let ncWindowStart = 0;
let ncVisibleCount = 0;
let ncDrawable = false;
let ncWindowEl = null;
let ncBtnLeft = null;
let ncBtnRight = null;
let ncResizeObs = null;
function initCardWindow(cardsDiv, overlay, total) {
ncTotal = total;
ncDrawable = false;
ncWindowStart = 0;
if (total === 0) return;
ncBtnLeft = document.createElement('button');
ncBtnRight = document.createElement('button');
ncWindowEl = document.createElement('div');
const wrapper = document.createElement('div');
ncBtnLeft.className = 'nc-nav-btn';
ncBtnRight.className = 'nc-nav-btn';
ncWindowEl.className = 'nc-window';
wrapper.className = 'nc-slide-wrapper';
ncBtnLeft.addEventListener('click', () => scrollCardWindow(-1));
ncBtnRight.addEventListener('click', () => scrollCardWindow(1));
ncWindowEl.appendChild(wrapper);
cardsDiv.insertBefore(ncBtnLeft, overlay);
cardsDiv.insertBefore(ncWindowEl, overlay);
cardsDiv.insertBefore(ncBtnRight, overlay);
// Klick auf beliebige Karte → Karte ziehen (per Delegation)
ncWindowEl.addEventListener('click', e => {
if (!ncDrawable) return;
if (e.target.classList.contains('nextcard-card-img')) {
ncDrawable = false;
ncWindowEl.style.pointerEvents = 'none';
openDrawModal();
}
});
if (ncResizeObs) ncResizeObs.disconnect();
ncResizeObs = new ResizeObserver(() => recalcCardWindow());
ncResizeObs.observe(ncWindowEl);
requestAnimationFrame(() => recalcCardWindow(true));
}
function recalcCardWindow(initialCenter = false) {
const slots = Math.round(ncWindowEl.offsetWidth / 50);
const newCount = Math.min(ncTotal, Math.min(20, Math.max(3, slots)));
if (newCount === ncVisibleCount && !initialCenter) {
// Breite hat sich nicht verändert genug → nur Gap neu berechnen
renderCardWindow(null);
return;
}
ncVisibleCount = newCount;
if (initialCenter) {
ncWindowStart = Math.max(0, Math.floor(ncTotal / 2) - Math.floor(ncVisibleCount / 2));
} else {
// Fensterstart so anpassen, dass die Mitte des sichtbaren Bereichs gleich bleibt
const mid = ncWindowStart + Math.floor(ncVisibleCount / 2);
ncWindowStart = Math.max(0, Math.min(ncTotal - ncVisibleCount, mid - Math.floor(ncVisibleCount / 2)));
}
renderCardWindow(null);
}
function renderCardWindow(slideDir) {
const wrapper = ncWindowEl.querySelector('.nc-slide-wrapper');
const cardW = 130;
// Karten neu befüllen
wrapper.innerHTML = '';
for (let i = 0; i < ncVisibleCount; i++) {
const img = document.createElement('img');
img.className = 'nextcard-card-img';
img.src = '/img/card.png';
img.alt = 'Karte';
wrapper.appendChild(img);
}
// Dynamischer Overlap: alle sichtbaren Karten füllen die Fensterbreite
const windowW = ncWindowEl.offsetWidth;
let gap = ncVisibleCount <= 1 ? 6 : (windowW - ncVisibleCount * cardW) / (ncVisibleCount - 1);
gap = Math.max(-105, Math.min(12, gap));
ncWindowEl.style.setProperty('--nc-gap', gap + 'px');
// Animation: alte Karten raus, neue rein
if (slideDir) {
const oldWrapper = wrapper.cloneNode(true);
oldWrapper.style.cssText = 'position:absolute;top:0;left:0;width:100%;pointer-events:none;';
ncWindowEl.appendChild(oldWrapper);
// Neue Karten von der Seite einblenden
wrapper.style.transition = 'none';
wrapper.style.transform = `translateX(${slideDir > 0 ? '100%' : '-100%'})`;
wrapper.getBoundingClientRect();
wrapper.style.transition = 'transform 0.28s ease';
wrapper.style.transform = 'translateX(0)';
// Alte Karten zur anderen Seite ausblenden
oldWrapper.style.transition = 'transform 0.28s ease';
oldWrapper.style.transform = `translateX(${slideDir > 0 ? '-100%' : '100%'})`;
setTimeout(() => oldWrapper.remove(), 300);
}
// Nav-Buttons aktualisieren
const leftCount = ncWindowStart;
const rightCount = ncTotal - ncWindowStart - ncVisibleCount;
ncBtnLeft.style.display = leftCount > 0 ? 'block' : 'none';
ncBtnRight.style.display = rightCount > 0 ? 'block' : 'none';
ncBtnLeft.textContent = `\n${leftCount}`;
ncBtnRight.textContent = `${rightCount}\n`;
ncWindowEl.style.pointerEvents = '';
}
function scrollCardWindow(dir) {
const step = Math.max(1, Math.floor(ncVisibleCount / 2));
if (dir < 0) ncWindowStart = Math.max(0, ncWindowStart - step);
else ncWindowStart = Math.min(ncTotal - ncVisibleCount, ncWindowStart + step);
renderCardWindow(dir);
}
function enableCardClick() {
if (carouselCards.length > 0) {
carouselDrawable = true;
} else if (ncWindowEl) {
ncDrawable = true;
}
}
async function onCardClick() {
// Alle weiteren Klicks blockieren
document.querySelectorAll('.nextcard-card-img').forEach(img => {
img.removeEventListener('click', onCardClick);
img.style.pointerEvents = 'none';
});
carouselDrawable = false;
carouselCards.forEach(c => c.style.pointerEvents = 'none');
if (ncWindowEl) ncWindowEl.style.pointerEvents = 'none';
document.querySelectorAll('.nextcard-card-img').forEach(img => img.style.pointerEvents = 'none');
openDrawModal();
}
let _pendingSpeedMode = null;
function openDrawModal() {
const modal = document.getElementById('drawModal');
const inner = document.getElementById('flipCardInner');
@@ -1343,11 +1651,14 @@
document.getElementById('drawGreenText').style.display = '';
document.getElementById('drawUnlockCode').style.display = 'none';
document.getElementById('drawTaskPendingHint').style.display = 'none';
document.getElementById('drawSpeedPicker').style.display = 'none';
document.getElementById('btnDrawOk').style.display = '';
document.getElementById('btnSpeedConfirm').style.display = 'none';
document.getElementById('btnDrawUnlock').style.display = 'none';
document.getElementById('btnDrawKeep').style.display = 'none';
actions.style.display = 'none';
drawnUnlockCode = null;
_pendingSpeedMode = null;
modal.classList.add('open');
// Karte serverseitig ziehen
@@ -1386,6 +1697,20 @@
document.getElementById('btnDrawUnlock').style.display = '';
document.getElementById('btnDrawKeep').style.display = '';
}
if (dto.card === 'SLOWMO_CARD' || dto.card === 'SPEEDUP_CARD') {
_pendingSpeedMode = dto.card === 'SLOWMO_CARD' ? 'SLOWMO' : 'SPEEDUP';
const picker = document.getElementById('drawSpeedPicker');
const input = document.getElementById('drawSpeedUntilInput');
// Minimum: jetzt + 1 Stunde, Standardwert: jetzt + 24 Stunden
const minDate = new Date(Date.now() + 60 * 60 * 1000);
const defDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
input.min = toLocalDatetimeInputValue(minDate);
input.value = toLocalDatetimeInputValue(defDate);
picker.style.display = 'flex';
document.getElementById('btnDrawOk').style.display = 'none';
document.getElementById('btnSpeedConfirm').style.display = '';
}
}, 700);
}, 1000);
})
@@ -1418,6 +1743,56 @@
closeDrawModal();
}
function toLocalDatetimeInputValue(date) {
const pad = n => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
async function confirmSpeedCard() {
if (!_pendingSpeedMode) return;
const input = document.getElementById('drawSpeedUntilInput');
if (!input.value) { alert('Bitte wähle einen Zeitpunkt.'); return; }
const until = new Date(input.value);
if (until <= new Date()) { alert('Der Zeitpunkt muss in der Zukunft liegen.'); return; }
const isoUntil = `${input.value}:00`; // datetime-local hat kein Sekunden-Teil
const res = await fetch('/keyholder/cardlock/' + lockId + '/speed/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: _pendingSpeedMode, until: isoUntil })
});
if (!res.ok) { alert('Fehler beim Aktivieren des Speed-Effekts.'); return; }
closeDrawModal();
}
let speedPanelTick = null;
function renderSpeedPanel(lock) {
if (speedPanelTick) { clearInterval(speedPanelTick); speedPanelTick = null; }
const panel = document.getElementById('speedPanel');
const now = new Date();
const slowmoUntil = lock.slowmoUntil ? new Date(lock.slowmoUntil) : null;
const speedupUntil = lock.speedupUntil ? new Date(lock.speedupUntil) : null;
const active = (slowmoUntil && slowmoUntil > now) ? { mode: 'slowmo', until: slowmoUntil }
: (speedupUntil && speedupUntil > now) ? { mode: 'speedup', until: speedupUntil }
: null;
if (!active) { panel.style.display = 'none'; return; }
panel.style.display = 'flex';
document.getElementById('speedPanelTitle').textContent =
active.mode === 'slowmo' ? '🐢 Slow Motion aktiv' : '⚡ Speed Up aktiv';
function tickSpeed() {
const diff = active.until - Date.now();
if (diff <= 0) {
panel.style.display = 'none';
clearInterval(speedPanelTick); speedPanelTick = null;
return;
}
document.getElementById('speedPanelInfo').textContent =
(active.mode === 'slowmo' ? 'Aktionen dauern 4× so lange noch ' : 'Aktionen dauern 4× so kurz noch ') + fmtCountdown(diff);
}
tickSpeed();
speedPanelTick = setInterval(tickSpeed, 1000);
}
// ── Hygiene-Öffnung ──
let hygieneTickInterval = null;

View File

@@ -76,7 +76,7 @@
/* ── Detail-Modal ── */
.detail-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.65); z-index: 400;
background: rgba(0,0,0,0.65); z-index: 500;
align-items: flex-start; justify-content: center;
padding: 2rem 1rem; overflow-y: auto;
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0;url=/games/aufgaben/aufgaben.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aufgaben Vanilla xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
@@ -629,8 +630,13 @@
.then(user => { if (!user) return; loadUserGruppen(); loadAboGruppen(); loadSystemGruppen(); })
.catch(() => { window.location.href = '/login.html'; });
// ── Cross-tab notification ──
let _notifyOnLoad = false;
const gruppenBc = new BroadcastChannel('vanilla-gruppen-updated');
// ── Load ──
function loadUserGruppen() {
if (_notifyOnLoad) { _notifyOnLoad = false; try { gruppenBc.postMessage(1); } catch (_) {} }
resetSelection();
document.getElementById('userLoading').style.display = 'block';
fetch(apiUrl(`/gruppe/list/user`) + `?page=${userPage}&size=${PAGE_SIZE}`)
@@ -924,7 +930,7 @@
openItemId = null;
pendingExpandId = gruppenId;
pendingExpandType = 'user';
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else {
document.getElementById('userActionError').textContent = 'Fehler beim Löschen (HTTP ' + r.status + ').';
}
@@ -1131,7 +1137,7 @@
pendingExpandType = 'user';
}
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else if (r.status === 409) {
showModalError('Limit erreicht: maximal 10 eigene Aufgabengruppen möglich.');
} else {
@@ -1157,7 +1163,7 @@
.then(r => {
if (r.ok || r.status === 202) {
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else if (r.status === 403) {
document.getElementById('userActionError').textContent = 'Keine Berechtigung.';
btn.disabled = false;
@@ -1179,7 +1185,7 @@
.then(r => {
if (r.ok || r.status === 201) {
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
document.getElementById('systemActionError').textContent = '';
} else {
document.getElementById('systemActionError').textContent = 'Fehler beim Kopieren (HTTP ' + r.status + ').';
@@ -1198,7 +1204,7 @@
.then(r => {
if (r.ok || r.status === 201) {
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
document.getElementById('aboActionError').textContent = '';
} else if (r.status === 409) {
document.getElementById('aboActionError').textContent = 'Limit erreicht: maximal 10 eigene Aufgabengruppen möglich.';
@@ -1628,7 +1634,7 @@
pendingExpandId = currentItemGruppeId;
pendingExpandType = 'user';
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else if (r.status === 409) {
showItemError('Limit erreicht: maximal 100 Einträge pro Gruppe möglich.');
} else {
@@ -1724,7 +1730,7 @@
pendingExpandId = selectedGruppeId;
pendingExpandType = 'user';
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else {
const errEl = document.getElementById('publishError');
errEl.textContent = 'Fehler beim Veröffentlichen (HTTP ' + r.status + ').';

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0;url=/games/aufgaben/entdecken.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Entdecken xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">

View File

@@ -69,13 +69,36 @@
.card-field:last-child { margin-bottom: 0; }
.card-field > label { font-size: 0.8rem; color: #aaa; margin: 0 0 0.5rem 0; display: block; }
.check-group { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.check-group--two-col { display: grid; grid-template-columns: 1fr 1fr; }
.check-item { display: inline-flex; align-items: flex-start; gap: 0.45rem; background: var(--color-secondary); border: 1px solid transparent; border-radius: 6px; padding: 0.4rem 0.7rem; cursor: pointer; transition: border-color 0.15s; user-select: none; }
.check-group--two-col { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); }
.check-item { display: inline-flex; align-items: flex-start; gap: 0.45rem; background: var(--color-secondary); border: 1px solid transparent; border-radius: 6px; padding: 0.4rem 0.7rem; cursor: pointer; transition: border-color 0.15s; user-select: none; position: relative; }
.check-item.is-checked { border-color: var(--color-primary); }
.check-item.is-disabled { opacity: 0.5; pointer-events: none; cursor: default; }
.check-item input { accent-color: var(--color-primary); width: auto; margin-top: 0.15rem; cursor: pointer; flex-shrink: 0; }
.check-item-label { font-size: 0.88rem; color: var(--color-text); line-height: 1.3; }
.check-item-desc { display: block; font-size: 0.72rem; color: var(--color-muted); margin-top: 0.1rem; }
.check-item-label { font-size: 0.88rem; color: var(--color-text); line-height: 1.3; display: flex; align-items: center; gap: 0.2rem; flex-wrap: wrap; }
.check-item-desc { display: none; }
.check-item-tooltip {
display: none; position: absolute; bottom: calc(100% + 6px); left: 0;
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 6px; padding: 0.4rem 0.65rem;
font-size: 0.78rem; color: var(--color-muted); line-height: 1.4;
width: max-content; max-width: 210px;
z-index: 50; pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.35);
}
.check-item:hover .check-item-tooltip { display: block; }
.check-item-info-btn {
display: none; background: none; border: 1px solid var(--color-muted);
border-radius: 50%; width: 1.1rem; height: 1.1rem; font-size: 0.62rem;
color: var(--color-muted); cursor: pointer; padding: 0; line-height: 1;
flex-shrink: 0; font-style: normal; font-weight: normal;
align-items: center; justify-content: center;
}
.check-item-info-btn.active { border-color: var(--color-primary); color: var(--color-primary); }
.check-item-desc-mobile { display: none; font-size: 0.72rem; color: var(--color-muted); margin-top: 0.25rem; line-height: 1.4; }
@media (max-width: 679px) {
.check-item:hover .check-item-tooltip { display: none; }
.check-item-info-btn { display: inline-flex; }
}
.field-error { font-size: 0.78rem; color: var(--color-primary); margin-top: 0.3rem; display: none; }
.add-player-btn { width: 100%; background: transparent; border: 1px dashed var(--color-secondary); color: var(--color-muted); padding: 0.7rem; border-radius: 8px; font-size: 0.88rem; font-weight: normal; cursor: pointer; transition: border-color 0.15s, color 0.15s; margin-top: 0.5rem; }
.add-player-btn:hover { border-color: var(--color-primary); color: var(--color-text); background: transparent; }
@@ -163,7 +186,6 @@
<div class="main" id="setupView" style="display:none;">
<div class="content">
<h1>Vanilla Game Session einrichten</h1>
<p id="pageSubtitle" style="margin-bottom:1.5rem;">Session einrichten</p>
<!-- Accordion 1: Grundeinstellungen -->
<div class="acc-item">
@@ -191,7 +213,7 @@
<div class="acc-body" id="acc-aufgaben-body">
<div id="guestAufgabenHint" class="guest-hint" style="display:none;">Aufgaben werden vom Host festgelegt nur zur Ansicht.</div>
<p style="font-size:0.85rem;color:var(--color-muted);margin-bottom:0.75rem;">
Gruppen verwalten: <a href="/games/vanilla/aufgaben.html" style="color:var(--color-primary);">Aufgaben-Verwaltung (Vanilla)</a>
Gruppen verwalten: <a href="/games/vanilla/aufgaben.html" target="_blank" style="color:var(--color-primary);">Aufgaben-Verwaltung (Vanilla)</a>
</p>
<div id="sectionOwn">
<div class="aufgaben-section-label"><label class="select-all-label"><input type="checkbox" class="select-all-cb" data-list="listOwn"> Eigene Gruppen</label></div>
@@ -262,11 +284,11 @@
DIVERS: ['MUND','ANUS','UMSCHNALLDILDO'],
};
const WERKZEUGE = [
{ value: 'MUND', label: 'Mund', desc: 'Gewillt den Mund einzusetzen' },
{ value: 'VAGINA', label: 'Vagina', desc: 'Verfügt über eine Vagina und setzt sie ein' },
{ value: 'PENIS', label: 'Penis', desc: 'Verfügt über einen Penis und setzt ihn ein' },
{ value: 'ANUS', label: 'Anus', desc: 'Gewillt den Anus einzusetzen' },
{ value: 'UMSCHNALLDILDO', label: 'Umschnall-Dildo', desc: 'Verfügt über einen Umschnall-Dildo' },
{ value: 'VAGINA', label: 'Vagina', desc: 'Verfügt über eine Vagina' },
{ value: 'PENIS', label: 'Penis', desc: 'Verfügt über einen Penis' },
{ value: 'UMSCHNALLDILDO', label: 'Umschnall-Dildo', desc: 'Verfügt über einen Umschnall-Dildo' },
{ value: 'MUND', label: 'Oral', desc: 'Ist für aktiven Oral-Verkehr' },
{ value: 'ANUS', label: 'Anal', desc: 'Ist Bereit passiv Anal-Verkehr zu haben ' },
];
const ROLE_LABELS = {
AUFGABE_AKTIV: 'Aufgabe Aktiv', AUFGABE_PASSIV: 'Aufgabe Passiv',
@@ -370,10 +392,19 @@
return items.map(({ value, label, desc }) => `
<label class="check-item${disabled ? ' is-disabled' : ''}">
<input type="${type}" name="${name}" value="${value}"${disabled ? ' disabled' : ''}>
<span><span class="check-item-label">${label}</span>${desc ? `<span class="check-item-desc">${desc}</span>` : ''}</span>
<span><span class="check-item-label">${label}${desc ? `<button type="button" class="check-item-info-btn" onclick="event.stopPropagation();toggleCheckDesc(this);">ⓘ</button>` : ''}</span>${desc ? `<span class="check-item-tooltip">${desc}</span><span class="check-item-desc-mobile">${desc}</span>` : ''}</span>
</label>`).join('');
}
function toggleCheckDesc(btn) {
const mobile = btn.closest('.check-item')?.querySelector('.check-item-desc-mobile');
if (!mobile) return;
const isVisible = mobile.style.display === 'block';
document.querySelectorAll('.check-item-desc-mobile').forEach(el => { el.style.display = 'none'; });
document.querySelectorAll('.check-item-info-btn').forEach(el => el.classList.remove('active'));
if (!isVisible) { mobile.style.display = 'block'; btn.classList.add('active'); }
}
function buildPlayerBody(id, nameValue, nameReadOnly = false, genderDisabled = false, allDisabled = false) {
const nameHtml = nameReadOnly
? `<input type="text" id="p${id}-name" value="${nameValue}" readonly style="background:transparent;cursor:default;color:var(--color-muted);">`
@@ -574,14 +605,14 @@
);
if (!filtered.length) {
ul.innerHTML = '<li class="empty-hint">Keine Gruppen vorhanden.</li>';
if (selectAllWrap) selectAllWrap.style.visibility = 'hidden'; return;
if (selectAllWrap) { const cb = selectAllWrap.querySelector('input'); if (cb) cb.disabled = true; selectAllWrap.style.pointerEvents = 'none'; selectAllWrap.style.opacity = '0.4'; } return;
}
ul.innerHTML = filtered.map(g => {
const checked = savedGruppen.has(g.gruppenId);
return `<li><label class="gruppe-item${checked ? ' is-checked' : ''}">
<input type="checkbox" value="${g.gruppenId}"${checked ? ' checked' : ''}>
<span><span class="gruppe-item-name">${g.name}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span>
${g.bild ? `<img class="item-img" src="data:image/png;base64,${g.bild}" alt="">` : ''}
<span><span class="gruppe-item-name">${g.name}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span>
</label></li>`;
}).join('');
updateSelectAll(ul);
@@ -905,8 +936,7 @@
const name = document.getElementById(`p${id}-name`)?.value.trim() || '';
const werkzeuge = getChecked(`p${id}-werkzeuge`);
setFieldError(`p${id}-name-err`, !name);
setFieldError(`p${id}-werkzeuge-err`, werkzeuge.length === 0);
if (!name || !werkzeuge.length) valid = false;
if (!name) valid = false;
return { name, geschlecht: null, spieltMit: [], rollen: [], werkzeuge,
userId: inv ? inv.inviteeId : (id === selfPlayerId ? myUserId : null),
eigenesGeraet: false };
@@ -1035,10 +1065,6 @@
async function bereitMachen() {
const id = guestOwnPlayerId; if (!id) return;
const werkzeuge = getChecked(`p${id}-werkzeuge`);
setFieldError(`p${id}-werkzeuge-err`, werkzeuge.length === 0);
if (!werkzeuge.length) {
showMessage('Bitte mindestens ein Werkzeug auswählen.', 'error'); return;
}
try {
const res = await fetch(`/vanilla/einladung/${guestEinladungId}/spielerdaten`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
@@ -1338,6 +1364,13 @@
}
init();
const _sessBc = new BroadcastChannel('vanilla-gruppen-updated');
_sessBc.onmessage = () => {
document.querySelectorAll('.gruppe-list input[type="checkbox"]:checked').forEach(cb => savedGruppen.add(cb.value));
document.querySelectorAll('.gruppe-list input[type="checkbox"]:not(:checked)').forEach(cb => savedGruppen.delete(cb.value));
ladeGruppenListen();
};
</script>
</body>
</html>

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0;url=/games/aufgaben/toys.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Toys xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">