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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,413 @@
<!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>Community Votes xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.page-title {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.35rem;
}
.page-subtitle {
font-size: 0.88rem;
color: var(--color-muted);
margin-bottom: 1.5rem;
}
#feed {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* ── Verifikations-Karte ── */
.vote-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
overflow: hidden;
}
.vote-card-media { position: relative; }
.vote-card-img {
width: 100%;
max-height: 420px;
object-fit: contain;
display: block;
background: #000;
}
.vote-card-code {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
font-family: monospace;
font-size: 2rem;
font-weight: 700;
letter-spacing: 0.25em;
color: #fff;
background: rgba(0,0,0,0.55);
padding: 0.4rem 1.1rem;
border-radius: 8px;
pointer-events: none;
white-space: nowrap;
}
.vote-card-body {
padding: 0.85rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.vote-meta { font-size: 0.8rem; color: var(--color-muted); }
.vote-actions { display: flex; align-items: center; gap: 0.75rem; }
.vote-btn {
display: flex;
align-items: center;
gap: 0.35rem;
background: none;
border: 1px solid var(--color-secondary);
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.9rem;
cursor: pointer;
color: var(--color-text);
margin: 0;
width: auto;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.vote-btn:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
background: none;
}
.vote-btn.voted-up { border-color: #2ecc71; color: #2ecc71; background: rgba(46,204,113,0.08); }
.vote-btn.voted-down { border-color: #e74c3c; color: #e74c3c; background: rgba(231,76,60,0.08); }
.vote-btn:disabled { opacity: 0.55; cursor: not-allowed; pointer-events: none; }
.vote-count { font-weight: 600; font-size: 0.88rem; }
/* ── Aufgaben-Abstimmungs-Karte ── */
.task-vote-card {
background: var(--color-card);
border: 1px solid rgba(52,152,219,0.35);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.task-vote-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
flex-wrap: wrap;
gap: 0.4rem;
}
.task-vote-lockee { font-weight: 600; font-size: 0.92rem; }
.task-vote-expires { font-size: 0.78rem; color: var(--color-muted); }
.task-vote-options { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.5rem; }
.task-vote-btn {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(52,152,219,0.08);
border: 1px solid rgba(52,152,219,0.25);
border-radius: 7px;
padding: 0.45rem 0.7rem;
cursor: pointer;
color: var(--color-text);
text-align: left;
font-size: 0.85rem;
transition: background 0.15s, border-color 0.15s;
width: 100%;
margin: 0;
}
.task-vote-btn:hover:not(:disabled) {
background: rgba(52,152,219,0.22);
border-color: rgba(52,152,219,0.5);
}
.task-vote-btn.my-vote {
border-color: var(--color-primary);
background: rgba(52,152,219,0.18);
}
.task-vote-btn:disabled { cursor: default; }
.task-vote-count {
font-size: 0.78rem;
color: var(--color-muted);
white-space: nowrap;
margin-left: 0.5rem;
flex-shrink: 0;
}
.task-vote-own-hint {
font-size: 0.78rem;
color: var(--color-muted);
font-style: italic;
margin-top: 0.4rem;
text-align: center;
}
/* ── Pranger-Karte ── */
.pillory-card {
background: var(--color-card);
border: 1px solid rgba(231,76,60,0.35);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.pillory-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
flex-wrap: wrap;
gap: 0.4rem;
}
.pillory-lockee { font-weight: 600; font-size: 0.92rem; }
.pillory-date { font-size: 0.78rem; color: var(--color-muted); }
.pillory-reason {
font-size: 0.82rem;
color: #e74c3c;
margin-bottom: 0.25rem;
}
.pillory-message { font-size: 0.88rem; }
.empty-hint {
color: var(--color-muted);
font-size: 0.9rem;
text-align: center;
padding: 2rem 0;
}
.load-spinner {
text-align: center;
color: var(--color-muted);
font-size: 0.85rem;
padding: 1rem 0;
}
.sentinel { height: 1px; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="page-title">Community Votes</div>
<div class="page-subtitle">Verifikationen, Aufgaben-Abstimmungen &amp; Pranger</div>
<div id="feed"></div>
<div class="load-spinner" id="loadSpinner" style="display:none;">Lädt…</div>
<div class="empty-hint" id="emptyHint" style="display:none;">Noch keine Community-Abstimmungen vorhanden.</div>
<div class="sentinel" id="sentinel"></div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtDateTime(isoStr) {
return new Date(isoStr).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
}
// ── Verifikations-Karte ────────────────────────────────────────────────────
function buildVerCard(base, detail) {
const voted = detail.isOwnLock || detail.myVote !== null && detail.myVote !== undefined;
const votedUp = !detail.isOwnLock && detail.myVote === true;
const votedDn = !detail.isOwnLock && detail.myVote === false;
const id = base.displayId;
const card = document.createElement('div');
card.className = 'vote-card';
card.innerHTML = `
<div class="vote-card-media">
<img class="vote-card-img" src="data:image/jpeg;base64,${detail.image}" alt="Verifikationsbild">
<div class="vote-card-code">${esc(detail.code)}</div>
</div>
<div class="vote-card-body">
<div class="vote-meta">Verifikation · ${esc(base.lockeeName)} · ${fmtDateTime(base.createdAt)}</div>
<div class="vote-actions">
<button class="vote-btn ${votedUp ? 'voted-up' : ''}" id="up-${id}"
${voted ? 'disabled' : ''}
onclick="castVerVote('${id}', true)">
👍 <span class="vote-count" id="upcount-${id}">${detail.upvotes}</span>
</button>
<button class="vote-btn ${votedDn ? 'voted-down' : ''}" id="dn-${id}"
${voted ? 'disabled' : ''}
onclick="castVerVote('${id}', false)">
👎 <span class="vote-count" id="dncount-${id}">${detail.downvotes}</span>
</button>
</div>
</div>`;
return card;
}
async function castVerVote(displayId, upvote) {
document.getElementById('up-' + displayId).disabled = true;
document.getElementById('dn-' + displayId).disabled = true;
const res = await fetch(`/games/chastity/community/verification/${displayId}/vote/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upvote })
});
if (res.ok || res.status === 202) {
const countEl = document.getElementById(upvote ? 'upcount-' + displayId : 'dncount-' + displayId);
countEl.textContent = parseInt(countEl.textContent) + 1;
document.getElementById((upvote ? 'up-' : 'dn-') + displayId)
.classList.add(upvote ? 'voted-up' : 'voted-down');
}
}
// ── Aufgaben-Abstimmungs-Karte ─────────────────────────────────────────────
function buildTaskVoteCard(base, detail) {
const isOwn = detail.isOwnLock;
const alreadyVoted = detail.entries.some(e => e.ownVote);
const id = base.displayId;
let optionsHtml = '';
(detail.entries || []).forEach((e, i) => {
const desc = e.description
? `<div style="font-size:0.75rem;color:var(--color-muted);margin-top:0.1rem;">${esc(e.description)}</div>`
: '';
const mins = e.minutes > 0
? ` <span style="font-size:0.75rem;color:var(--color-muted);">⏱ ${e.minutes} Min.</span>`
: '';
optionsHtml += `<button class="task-vote-btn ${e.ownVote ? 'my-vote' : ''}"
id="tvbtn-${id}-${i}"
${(alreadyVoted || isOwn) ? 'disabled' : ''}
onclick="castTaskVote('${id}', ${i})">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;">${esc(e.title)}${mins}</div>
${desc}
</div>
<span class="task-vote-count" id="tvcount-${id}-${i}">${e.votes} Stimme${e.votes !== 1 ? 'n' : ''}</span>
</button>`;
});
const ownHint = isOwn
? `<div class="task-vote-own-hint">Das ist dein eigenes Lock du kannst hier nicht abstimmen.</div>`
: '';
const card = document.createElement('div');
card.className = 'task-vote-card';
card.innerHTML = `
<div class="task-vote-header">
<span class="task-vote-lockee">🃏 ${esc(base.lockeeName)}</span>
<span class="task-vote-expires">Endet: ${fmtDateTime(detail.expiresAt)}</span>
</div>
<div class="task-vote-options">${optionsHtml}</div>
${ownHint}`;
return card;
}
async function castTaskVote(displayId, taskIndex) {
document.querySelectorAll(`[id^="tvbtn-${displayId}-"]`).forEach(btn => btn.disabled = true);
const res = await fetch(`/games/chastity/community/taskvote/${displayId}/vote/${taskIndex}`, { method: 'POST' });
if (res.ok || res.status === 204) {
const countEl = document.getElementById(`tvcount-${displayId}-${taskIndex}`);
if (countEl) {
const next = (parseInt(countEl.textContent) || 0) + 1;
countEl.textContent = `${next} Stimme${next !== 1 ? 'n' : ''}`;
}
document.getElementById(`tvbtn-${displayId}-${taskIndex}`)?.classList.add('my-vote');
}
}
// ── Pranger-Karte ──────────────────────────────────────────────────────────
const PILLORY_LABELS = {
HYGIENE_OPENING_EXEEDED: 'Hygiene-Öffnung überschritten',
KEYHOLDER_DESCESSION: 'Keyholder hat aufgegeben'
};
function buildPilloryCard(base, detail) {
const card = document.createElement('div');
card.className = 'pillory-card';
card.innerHTML = `
<div class="pillory-header">
<span class="pillory-lockee">🔒 ${esc(base.lockeeName)}</span>
<span class="pillory-date">${fmtDateTime(base.createdAt)}</span>
</div>
<div class="pillory-reason">⚠️ ${esc(PILLORY_LABELS[detail.reason] || detail.reason)}</div>
${detail.message ? `<div class="pillory-message">${esc(detail.message)}</div>` : ''}`;
return card;
}
// ── Unified feed mit Paging ────────────────────────────────────────────────
let page = 0;
let exhausted = false;
let loading = false;
let rendered = 0;
async function fetchDetail(base) {
const urls = {
VERIFICATION: `/games/chastity/community/verification/${base.displayId}`,
TASK_VOTE: `/games/chastity/community/taskvote/${base.displayId}`,
PILLORY: `/games/chastity/community/pillory/${base.displayId}`
};
const url = urls[base.type];
if (!url) return null;
try {
const r = await fetch(url);
return r.ok ? await r.json() : null;
} catch(e) { return null; }
}
function buildCard(base, detail) {
if (!detail) return null;
if (base.type === 'VERIFICATION') return buildVerCard(base, detail);
if (base.type === 'TASK_VOTE') return buildTaskVoteCard(base, detail);
if (base.type === 'PILLORY') return buildPilloryCard(base, detail);
return null;
}
async function loadMore() {
if (loading || exhausted) return;
loading = true;
document.getElementById('loadSpinner').style.display = '';
let pageData;
try {
const r = await fetch(`/games/chastity/community?page=${page}&sort=createdAt,desc`);
if (!r.ok) { loading = false; document.getElementById('loadSpinner').style.display = 'none'; return; }
pageData = await r.json();
} catch(e) { loading = false; document.getElementById('loadSpinner').style.display = 'none'; return; }
const items = pageData.content || [];
if (pageData.last) exhausted = true;
page++;
const details = await Promise.all(items.map(fetchDetail));
document.getElementById('loadSpinner').style.display = 'none';
loading = false;
const feed = document.getElementById('feed');
items.forEach((base, i) => {
const card = buildCard(base, details[i]);
if (card) { feed.appendChild(card); rendered++; }
});
if (rendered === 0 && exhausted) {
document.getElementById('emptyHint').style.display = '';
}
}
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadMore();
}, { rootMargin: '200px' });
observer.observe(document.getElementById('sentinel'));
loadMore();
</script>
</body>
</html>

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>

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/sidebar.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,21 @@
<!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>Chastity Game Info xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="app">
<div class="main">
<div class="content">
<h1>Chastity Game</h1>
<p>Informationen zum Chastity Game folgen hier.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,307 @@
<!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>Lock-Einladung xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.invite-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem 1.5rem;
width: 100%;
text-align: center;
}
.invite-icon { font-size: 3rem; margin-bottom: 1rem; }
.invite-title { font-size: 1.3rem; font-weight: 700; margin-bottom: 0.4rem; }
.invite-sub { color: var(--color-muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
.invite-detail {
background: var(--color-secondary);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
text-align: left;
font-size: 0.9rem;
}
.invite-detail dt { color: var(--color-muted); font-size: 0.78rem; margin-bottom: 0.1rem; }
.invite-detail dd { font-weight: 600; margin: 0 0 0.5rem 0; }
.invite-detail dd:last-child { margin-bottom: 0; }
.invite-actions { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
.invite-actions button { width: auto; padding: 0.65rem 1.5rem; }
.btn-danger { background: #c0392b !important; }
.btn-danger:hover { background: #a93226 !important; }
.code-lines-row {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
margin-bottom: 1.25rem;
}
.code-lines-row input { width: 80px; text-align: center; }
.code-lines-row span { color: var(--color-text); font-size: 0.9rem; }
/* Unlock-Code-Modal */
.unlock-modal-bg {
display: none;
position: fixed;
inset: 0;
z-index: 400;
align-items: center;
justify-content: center;
}
.unlock-modal-bg.open { display: flex; }
.unlock-modal-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.55);
}
.unlock-modal-box {
position: relative;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 1.5rem 1.5rem 1.25rem;
max-width: 380px;
width: 90%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
z-index: 1;
text-align: center;
}
.unlock-code-display {
font-family: monospace;
font-size: 2rem;
letter-spacing: 0.3em;
background: var(--color-secondary);
border-radius: 8px;
padding: 1rem 1.5rem;
color: var(--color-primary);
line-height: 1.8;
word-break: break-all;
width: 100%;
box-sizing: border-box;
}
#stateLoading { display: none; }
#stateError { display: none; }
#stateAlready { display: none; }
#stateDeclined { display: none; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div id="stateLoading" style="text-align:center;padding:3rem 1rem;color:var(--color-muted);">Lade Einladung…</div>
<div id="stateError" style="text-align:center;padding:3rem 1rem;">
<div style="font-size:2.5rem;margin-bottom:1rem;">⚠️</div>
<h2 style="margin-bottom:0.5rem;">Einladung nicht gefunden</h2>
<p style="color:var(--color-muted);">Diese Einladung existiert nicht oder wurde bereits bearbeitet.</p>
<a href="/games/common/einladungen.html" style="display:inline-block;margin-top:1.5rem;padding:0.65rem 1.5rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Zu meinen Einladungen</a>
</div>
<div id="stateAlready" style="text-align:center;padding:3rem 1rem;">
<div style="font-size:2.5rem;margin-bottom:1rem;">🔒</div>
<h2 style="margin-bottom:0.5rem;">Lock bereits aktiv</h2>
<p style="color:var(--color-muted);">Diese Einladung wurde bereits angenommen.</p>
</div>
<div id="stateDeclined" style="text-align:center;padding:3rem 1rem;">
<div style="font-size:2.5rem;margin-bottom:1rem;"></div>
<h2 style="margin-bottom:0.5rem;">Einladung abgelehnt</h2>
<p style="color:var(--color-muted);">Du hast die Einladung abgelehnt. Der Keyholder wurde benachrichtigt.</p>
<a href="/games/common/einladungen.html" style="display:inline-block;margin-top:1.5rem;padding:0.65rem 1.5rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Zu meinen Einladungen</a>
</div>
<div id="stateInvite" style="display:none;">
<div class="invite-card">
<div class="invite-icon">🔒</div>
<div class="invite-title">Lock-Einladung</div>
<div class="invite-sub" id="invSubtitle"></div>
<dl class="invite-detail" id="invDetail"></dl>
<div id="acceptSection">
<p style="font-size:0.88rem;color:var(--color-muted);margin-bottom:0.75rem;">
Wie viele Ziffern soll dein Entsperrcode haben?
</p>
<div class="code-lines-row">
<input type="number" id="codeLines" min="1" max="20" value="5">
<span>Ziffern</span>
</div>
<div class="invite-actions">
<button class="btn-danger" onclick="declineInvitation()">✕ Ablehnen</button>
<button onclick="acceptInvitation()">✓ Annehmen</button>
</div>
</div>
<div id="errorMsg" style="color:#e74c3c;font-size:0.85rem;margin-top:0.5rem;display:none;"></div>
</div>
</div>
</div>
</div>
<!-- Unlock-Code-Modal -->
<div class="unlock-modal-bg" id="unlockModal">
<div class="unlock-modal-overlay"></div>
<div class="unlock-modal-box">
<div style="font-size:2rem;">🔒</div>
<h3 id="unlockModalTitle" style="margin:0;">Dein Entsperrcode</h3>
<p id="unlockModalHint" style="color:var(--color-muted);font-size:0.85rem;margin:0;">
Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem.
</p>
<div class="unlock-code-display" id="unlockCodeDisplay"></div>
<div id="unlockModalCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);font-family:monospace;"></div>
<button id="unlockModalBtn" style="width:100%;margin-top:0.25rem;">Weiter</button>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
let lockId = null;
document.getElementById('stateLoading').style.display = '';
async function load() {
if (!token) { showState('stateError'); return; }
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token));
if (res.status === 409) { showState('stateAlready'); return; }
if (!res.ok) { showState('stateError'); return; }
const inv = await res.json();
lockId = inv.lockId;
document.getElementById('invSubtitle').textContent =
inv.keyholderName + ' hat dich als Lockee eingeladen';
const dl = document.getElementById('invDetail');
dl.innerHTML = `
<dt>Lock-Name</dt><dd>${esc(inv.lockName)}</dd>
<dt>Keyholder</dt><dd>${esc(inv.keyholderName)}</dd>`;
showState('stateInvite');
} catch(e) {
showState('stateError');
}
}
function showState(id) {
document.getElementById('stateLoading').style.display = 'none';
['stateError','stateAlready','stateInvite','stateDeclined'].forEach(s => {
document.getElementById(s).style.display = s === id ? '' : 'none';
});
}
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
async function acceptInvitation() {
const lines = parseInt(document.getElementById('codeLines').value);
if (!lines || lines < 1) { showError('Bitte eine Ziffernanzahl eingeben.'); return; }
const btn = document.querySelector('#acceptSection button:last-child');
btn.disabled = true;
document.getElementById('errorMsg').style.display = 'none';
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token) + '/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ unlockCodeLines: lines })
});
if (!res.ok) { btn.disabled = false; showError('Fehler beim Annehmen der Einladung.'); return; }
const data = await res.json();
showUnlockCodeModal(data.unlockCode, data.lockId);
} catch(e) {
btn.disabled = false;
showError('Fehler beim Annehmen der Einladung.');
}
}
async function declineInvitation() {
if (!confirm('Bist du sicher, dass du diese Einladung ablehnen möchtest? Das Lock wird gelöscht.')) return;
const btn = document.querySelector('.btn-danger');
btn.disabled = true;
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token), { method: 'DELETE' });
if (res.ok || res.status === 204) {
showState('stateDeclined');
} else {
btn.disabled = false;
showError('Fehler beim Ablehnen der Einladung.');
}
} catch(e) {
btn.disabled = false;
showError('Fehler beim Ablehnen der Einladung.');
}
}
function showError(msg) {
const el = document.getElementById('errorMsg');
el.textContent = msg;
el.style.display = '';
}
function showUnlockCodeModal(code, lid) {
document.getElementById('unlockCodeDisplay').textContent = code;
const url = '/games/chastity/activelock.html?lockId=' + lid;
const btn = document.getElementById('unlockModalBtn');
btn.onclick = () => startCodeScramble(code, url);
document.getElementById('unlockModal').classList.add('open');
}
function startCodeScramble(realCode, url) {
const display = document.getElementById('unlockCodeDisplay');
const btn = document.getElementById('unlockModalBtn');
const hint = document.getElementById('unlockModalHint');
const countdown = document.getElementById('unlockModalCountdown');
const len = realCode.length;
const DURATION = 3 * 60;
let remaining = DURATION;
let stopped = false;
function randomCode() {
return Array.from({ length: len }, () => Math.floor(Math.random() * 10)).join('');
}
function finish() {
stopped = true;
clearInterval(scrambleInterval);
clearInterval(countdownInterval);
window.location.href = url;
}
if (hint) hint.style.display = 'none';
countdown.style.display = '';
document.getElementById('unlockModalTitle').textContent = 'Nun vergessen wir den Code…';
btn.textContent = 'Abbrechen';
btn.onclick = finish;
function updateCountdown() {
const m = Math.floor(remaining / 60);
const s = remaining % 60;
countdown.textContent = `${m}:${String(s).padStart(2, '0')}`;
}
updateCountdown();
const scrambleInterval = setInterval(() => { if (!stopped) display.textContent = randomCode(); }, 1000);
const countdownInterval = setInterval(() => {
if (stopped) return;
remaining--;
updateCountdown();
if (remaining <= 0) finish();
}, 1000);
}
load();
</script>
</body>
</html>

View File

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

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keyholder*In bestätigt xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="app">
<div class="main">
<div class="content" style="text-align:center;padding-top:3rem;">
<div id="msgOk" style="display:none;">
<div style="font-size:3rem;margin-bottom:1rem;">🔐</div>
<h2 style="margin-bottom:0.75rem;">Keyholder*In-Rolle angenommen!</h2>
<p style="color:var(--color-muted);margin-bottom:2rem;line-height:1.6;">
Du hast die Keyholder*In-Rolle erfolgreich bestätigt.<br>
Das Lock läuft ab sofort mit dir als Keyholder*In.
</p>
<a href="/userhome.html" style="display:inline-block;padding:0.65rem 1.75rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">
Zur Startseite
</a>
</div>
<div id="msgInvalid" style="display:none;">
<div style="font-size:3rem;margin-bottom:1rem;"></div>
<h2 style="margin-bottom:0.75rem;">Link ungültig</h2>
<p style="color:var(--color-muted);margin-bottom:2rem;line-height:1.6;">
Dieser Bestätigungslink ist abgelaufen oder wurde bereits verwendet.
</p>
<a href="/userhome.html" style="display:inline-block;padding:0.65rem 1.75rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">
Zur Startseite
</a>
</div>
</div>
</div>
<script>
const status = new URLSearchParams(window.location.search).get('status');
document.getElementById(status === 'ok' ? 'msgOk' : 'msgInvalid').style.display = '';
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,935 @@
<!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>Neues Lock xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.form-section {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.form-section-title {
font-size: 0.78rem;
font-weight: 700;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 1rem;
}
.form-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.9rem;
}
.form-row:last-child { margin-bottom: 0; }
.form-row label {
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text);
}
.form-hint {
font-size: 0.78rem;
color: var(--color-muted);
margin-top: 0.1rem;
}
.form-row input[type="text"],
.form-row input[type="number"],
.form-row input[type="datetime-local"] {
width: 100%;
box-sizing: border-box;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.75rem;
cursor: pointer;
}
.checkbox-row:last-child { margin-bottom: 0; }
.checkbox-row input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--color-primary);
}
.checkbox-row label {
font-size: 0.9rem;
color: var(--color-text);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.combo-wrap { position: relative; }
.combo-wrap input[type="text"] { width: 100%; box-sizing: border-box; }
.combo-dropdown {
display: none;
position: absolute;
top: calc(100% + 3px);
left: 0; right: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 8px;
max-height: 220px;
overflow-y: auto;
z-index: 200;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
.combo-dropdown.open { display: block; }
.combo-option {
padding: 0.55rem 0.85rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text);
}
.combo-option:hover, .combo-option.active { background: var(--color-secondary); }
.combo-option .combo-hint { font-size: 0.78rem; color: var(--color-muted); margin-left: 0.4rem; }
.combo-empty { padding: 0.55rem 0.85rem; font-size: 0.85rem; color: var(--color-muted); font-style: italic; }
.inline-number { display: flex; align-items: center; gap: 0.5rem; }
.inline-number input { width: 90px !important; flex-shrink: 0; }
.inline-number span { font-size: 0.9rem; color: var(--color-text); }
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
.form-actions button { width: auto; padding: 0.65rem 1.5rem; }
.error-msg { color: #e74c3c; font-size: 0.85rem; margin-top: 0.4rem; display: none; }
.required-star { color: #e74c3c; margin-left: 0.15em; }
.field-error input { border-color: #e74c3c !important; }
.field-error-msg { font-size: 0.78rem; color: #e74c3c; margin-top: 0.15rem; }
/* Zeitpicker */
.time-picker { display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; }
.tp-seg { display:flex; flex-direction:column; align-items:center; gap:0.15rem; }
.tp-seg-row { display:flex; align-items:center; gap:0.2rem; }
.tp-seg button {
width:24px; height:24px; background:var(--color-card);
border:1px solid var(--color-muted); border-radius:4px;
cursor:pointer; font-size:0.9rem; font-weight:700; color:var(--color-text);
display:flex; align-items:center; justify-content:center; padding:0; flex-shrink:0;
}
.tp-seg button:hover { background:var(--color-primary); color:#fff; border-color:var(--color-primary); }
.tp-seg input {
width:28px; text-align:center; background:var(--color-card);
border:1px solid var(--color-muted); border-radius:4px;
color:var(--color-text); font-size:0.9rem; font-weight:600;
font-family:monospace; padding:0.15rem 0; box-sizing:border-box;
}
.tp-seg .tp-label { font-size:0.62rem; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.04em; }
.tp-colon { font-size:1rem; font-weight:700; color:var(--color-muted); margin-bottom:0.9rem; }
/* LockControl-Auswahl */
.lockcontrol-options { display: flex; flex-direction: column; gap: 0.6rem; }
.lockcontrol-option {
display: flex; align-items: flex-start; gap: 0.7rem;
padding: 0.7rem 0.85rem;
border: 1px solid var(--color-secondary); border-radius: 8px;
cursor: pointer; transition: border-color 0.15s;
}
.lockcontrol-option:hover:not(.lc-disabled) { border-color: var(--color-primary); }
.lockcontrol-option.lc-selected { border-color: var(--color-primary); background: rgba(var(--color-primary-rgb, 180,80,255),0.06); }
.lockcontrol-option.lc-disabled { opacity: 0.55; cursor: not-allowed; }
.lockcontrol-option input[type="radio"] {
margin-top: 0.15rem; flex-shrink: 0;
accent-color: var(--color-primary); width: 1rem; height: 1rem; cursor: pointer;
}
.lockcontrol-option.lc-disabled input[type="radio"] { cursor: not-allowed; }
.lc-label { font-size: 0.9rem; font-weight: 600; color: var(--color-text); }
.lc-desc { font-size: 0.78rem; color: var(--color-muted); margin-top: 0.15rem; }
.lc-badge {
display: inline-block; font-size: 0.68rem; font-weight: 700;
padding: 0.15em 0.5em; border-radius: 4px;
background: var(--color-primary); color: #fff;
margin-left: 0.4rem; vertical-align: middle; letter-spacing: 0.03em;
}
/* Unlock-Code-Modal */
.modal-overlay {
display: none; position: fixed; inset: 0; z-index: 500;
align-items: center; justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal-bg { position: absolute; inset: 0; background: rgba(0,0,0,0.55); }
.modal-box {
position: relative; background: var(--color-card);
border: 1px solid var(--color-secondary); border-radius: 12px;
padding: 1.5rem 1.5rem 1.25rem; max-width: 380px; width: 90%;
display: flex; flex-direction: column; align-items: center; gap: 0.75rem; z-index: 1;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h2 style="margin-bottom:1.25rem;">🔒 Neues Lock</h2>
<!-- Vorlage (Pflichtfeld) -->
<div class="form-section">
<div class="form-section-title">Vorlage<span class="required-star">*</span></div>
<div class="form-row" id="rowTemplate">
<div class="combo-wrap" id="templateCombo">
<input type="text" id="templateInput" placeholder="Vorlage suchen…" autocomplete="off">
<div class="combo-dropdown" id="templateDropdown"></div>
<input type="hidden" id="templateValue">
</div>
</div>
</div>
<!-- Personen -->
<div class="form-section">
<div class="form-section-title">Personen</div>
<div class="form-row" id="rowLockee">
<label for="lockeeInput">Lockee<span class="required-star">*</span></label>
<div class="combo-wrap" id="lockeeCombo">
<input type="text" id="lockeeInput" placeholder="Suchen oder „Ich selbst"" autocomplete="off">
<div class="combo-dropdown" id="lockeeDropdown"></div>
<input type="hidden" id="lockeeValue">
</div>
<div class="form-hint">Wähle dich selbst oder einen Freund als Lockee.</div>
</div>
<div class="checkbox-row" id="rowDetailsVisible" style="display:none;">
<input type="checkbox" id="lockeeDetailsVisible" checked>
<label for="lockeeDetailsVisible">Details für Lockee sichtbar
<span class="form-hint">(Lockee sieht die Lock-Konfiguration vor dem Annehmen)</span>
</label>
</div>
<div class="form-row" id="rowKeyholder">
<label for="keyholderInput">Keyholder*In</label>
<div class="combo-wrap" id="keyholderCombo">
<input type="text" id="keyholderInput" placeholder="Freund suchen…" autocomplete="off">
<div class="combo-dropdown" id="keyholderDropdown"></div>
<input type="hidden" id="keyholderValue">
</div>
<div class="form-hint">Ohne Keyholder läuft das Lock als Self-Lock.</div>
</div>
</div>
<!-- Optionen -->
<div class="form-section">
<div class="form-section-title">Optionen</div>
<!-- CardLock: Längste Dauer -->
<div class="form-row" id="rowMaxDuration">
<label>Längste Dauer</label>
<div class="time-picker">
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('dur',-1,'d')"></button>
<input type="text" id="dur_d" value="0" readonly>
<button type="button" onclick="tpChange('dur',1,'d')">+</button>
</div>
<span class="tp-label">Tage</span>
</div>
<div class="tp-colon">:</div>
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('dur',-1,'h')"></button>
<input type="text" id="dur_h" value="00" readonly>
<button type="button" onclick="tpChange('dur',1,'h')">+</button>
</div>
<span class="tp-label">Std</span>
</div>
</div>
<div class="form-hint">Das Lock öffnet spätestens nach dieser Zeit automatisch. 0 : 00 = keine Begrenzung.</div>
</div>
<!-- TimeLock: Dauer-Info aus Vorlage -->
<div class="form-row" id="rowTimeLockInfo" style="display:none;">
<label>Sperrdauer</label>
<div id="timeLockDurationText" style="font-size:0.9rem;color:var(--color-text);padding:0.3rem 0;"></div>
<div class="form-hint">Die Dauer wird beim Lock-Start zufällig aus dem Bereich der Vorlage gewählt.</div>
</div>
<!-- LockControl-Auswahl -->
<div class="form-row" id="rowLockControl">
<label>Schloss-Steuerung</label>
<div class="lockcontrol-options">
<label class="lockcontrol-option lc-selected" id="lcOptUnlockCode" onclick="selectLockControl('UNLOCK_CODE')">
<input type="radio" name="lockControl" value="UNLOCK_CODE" checked>
<div>
<div class="lc-label">🔢 Unlock-Code</div>
<div class="lc-desc">Ein numerischer Code wird generiert, den du in deinen Tresor einstellst.</div>
</div>
</label>
<label class="lockcontrol-option" id="lcOptTrust" onclick="selectLockControl('TRUST')">
<input type="radio" name="lockControl" value="TRUST">
<div>
<div class="lc-label">🤝 Trust</div>
<div class="lc-desc">Kein technisches Schloss du vertraust dir selbst oder deiner Keyholder*in.</div>
</div>
</label>
<label class="lockcontrol-option lc-disabled" id="lcOptTtlock" onclick="selectLockControl('TTLOCK')">
<input type="radio" name="lockControl" value="TTLOCK" disabled>
<div>
<div class="lc-label">📱 TTLock <span class="lc-badge" id="lcTtlockBadge">ABO</span></div>
<div class="lc-desc" id="lcTtlockDesc">Steuert ein TTLock-Smartschloss direkt über die App-Integration. Erfordert ein aktives Abonnement.</div>
</div>
</label>
</div>
</div>
<div class="form-row" id="rowUnlockCodeLines">
<label for="unlockCodeLines">Anzahl Ziffern des Entsperrcodes</label>
<div class="inline-number">
<input type="number" id="unlockCodeLines" min="1" max="20" value="5">
<span>Ziffern</span>
</div>
</div>
<div class="checkbox-row" id="rowTestLock">
<input type="checkbox" id="testLock">
<label for="testLock">Test-Lock <span class="form-hint">(kein echter Lock, zum Ausprobieren)</span></label>
</div>
</div>
<div class="error-msg" id="errorMsg"></div>
<div class="form-actions">
<button onclick="history.back()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);">Abbrechen</button>
<button onclick="createSession()">🔒 Lock starten</button>
</div>
</div>
</div>
<!-- TTLock Lade-Overlay -->
<div class="modal-overlay" id="ttlLoadingOverlay">
<div class="modal-bg"></div>
<div class="modal-box" style="max-width:320px;text-align:center;gap:0.75rem;">
<div style="font-size:2rem;"></div>
<div style="font-weight:600;">TTLock-Kommunikation läuft…</div>
<div style="font-size:0.85rem;color:var(--color-muted);">Bitte warten, der TTLock-Server wird kontaktiert.</div>
</div>
</div>
<!-- Entsperrcode-Modal -->
<div class="modal-overlay" id="unlockModal">
<div class="modal-bg"></div>
<div class="modal-box">
<div style="font-size:2rem;">🔒</div>
<h3 id="unlockModalTitle" style="margin:0;">Dein Entsperrcode</h3>
<p id="unlockModalHint" style="color:var(--color-muted);font-size:0.85rem;text-align:center;margin:0;">
Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem.
</p>
<div id="unlockCodeDisplay" style="
font-family: monospace; font-size: 2rem; letter-spacing: 0.3em;
background: var(--color-secondary); border-radius: 8px;
padding: 1rem 1.5rem; text-align: center; color: var(--color-primary);
line-height: 1.8; word-break: break-all;
"></div>
<div id="unlockModalCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);text-align:center;font-family:monospace;"></div>
<div id="unlockKeyholderHint" style="display:none;background:var(--color-secondary);border-radius:8px;padding:0.75rem 1rem;font-size:0.85rem;color:var(--color-muted);text-align:center;line-height:1.5;">
⏳ Die eingetragene Keyholder*In wurde benachrichtigt und muss die Rolle noch bestätigen.
Bis zur Bestätigung läuft das Lock als Self-Lock.
</div>
<button id="unlockModalBtn" onclick="" style="width:100%;margin-top:0.25rem;">Weiter</button>
</div>
</div>
<script src="/js/card-defs.js"></script>
<script src="/js/shared.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
let myUserId = null;
let myUserName = null;
let allFriends = [];
let allTemplates = []; // combined; each entry has _type: 'cardlock'|'timelock'
let selectedTemplate = null;
let comboActiveIdx = -1;
let selectedLockControl = 'UNLOCK_CODE';
let hasPaidSubscription = false;
let ttlockReady = false;
// ── Boot ──
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
if (!user) { window.location.href = '/login.html'; return; }
myUserId = user.userId;
myUserName = user.name;
// Subscription + Templates (eigene + abonnierte) + TTLock-Config parallel laden
try {
const [ownTpls, subTpls, subData, ttlCfg] = await Promise.all([
fetch('/templates/mine').then(r => r.ok ? r.json() : []),
fetch('/templates/subscribed').then(r => r.ok ? r.json() : []),
fetch('/subscription/me').then(r => r.ok ? r.json() : null),
fetch('/user/me/ttlock').then(r => r.ok ? r.json() : null)
]);
const toEntry = t => ({ ...t, _type: t.lockType === 'TIMELOCK' ? 'timelock' : 'cardlock' });
const ownIds = new Set(ownTpls.map(t => t.templateId));
allTemplates = [
...ownTpls.map(toEntry),
...subTpls.filter(t => !ownIds.has(t.templateId)).map(toEntry)
];
hasPaidSubscription = !!(subData && subData.subscriptionType === 'PREMIUM');
ttlockReady = !!(ttlCfg && ttlCfg.testSuccessful);
const ttlockTestOk = ttlockReady;
if (hasPaidSubscription && ttlockTestOk) {
const opt = document.getElementById('lcOptTtlock');
opt.classList.remove('lc-disabled');
opt.querySelector('input').disabled = false;
document.getElementById('lcTtlockBadge').style.display = 'none';
document.getElementById('lcTtlockDesc').textContent =
'Steuert ein TTLock-Smartschloss direkt über die App-Integration.';
} else if (hasPaidSubscription && !ttlockTestOk) {
document.getElementById('lcTtlockBadge').textContent = 'KONFIG';
document.getElementById('lcTtlockDesc').textContent =
'TTLock ist noch nicht konfiguriert. Bitte teste die Verbindung zuerst in den Einstellungen.';
}
} catch { allTemplates = []; }
if (allTemplates.length === 0) {
document.querySelector('.content').innerHTML = `
<div style="text-align:center;padding:3rem 1rem;">
<div style="font-size:2.5rem;margin-bottom:1rem;">📋</div>
<h2 style="margin-bottom:0.75rem;">Keine Vorlagen vorhanden</h2>
<p style="color:var(--color-muted);margin-bottom:2rem;">
Du musst zuerst mindestens eine Lock-Vorlage erstellen,<br>
bevor du ein neues Lock starten kannst.
</p>
<a href="/games/chastity/meine-locks.html" style="display:inline-block;padding:0.7rem 1.8rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Vorlage erstellen</a>
</div>`;
return;
}
setupTemplateCombo();
await loadOptions(user.userId);
});
async function loadOptions(myId) {
try {
allFriends = await fetch('/social/friends/user/' + myId).then(r => r.ok ? r.json() : []);
} catch { allFriends = []; }
setupLockeeCombo();
setupKeyholderCombo();
document.getElementById('lockeeInput').value = 'Ich selbst';
document.getElementById('lockeeValue').value = myId;
}
// ── Template-Combobox ──
function setupTemplateCombo() {
const input = document.getElementById('templateInput');
const dropdown = document.getElementById('templateDropdown');
const hidden = document.getElementById('templateValue');
function renderDropdown(query) {
const q = query.toLowerCase().trim();
const filtered = q
? allTemplates.filter(t => (t.name || '').toLowerCase().includes(q))
: allTemplates;
dropdown.innerHTML = '';
if (filtered.length === 0) {
dropdown.innerHTML = `<div class="combo-empty">Keine Vorlagen gefunden.</div>`;
} else {
filtered.forEach(t => {
const badge = t._type === 'timelock' ? '⏱' : '🃏';
const label = (t.name || 'Unbenannte Vorlage');
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = t.templateId;
div.innerHTML = `${badge} ${label}`;
div.addEventListener('mousedown', e => {
e.preventDefault();
hidden.value = t.templateId;
input.value = badge + ' ' + label;
selectedTemplate = t;
dropdown.classList.remove('open');
clearFieldError('rowTemplate');
onTemplateChanged(t);
});
dropdown.appendChild(div);
});
}
dropdown.classList.add('open');
}
input.addEventListener('input', () => { hidden.value = ''; renderDropdown(input.value); });
input.addEventListener('focus', () => renderDropdown(input.value));
input.addEventListener('blur', () => {
setTimeout(() => {
dropdown.classList.remove('open');
if (!hidden.value) input.value = '';
}, 150);
});
}
// ── Lockee-Combobox ──
function setupLockeeCombo() {
const input = document.getElementById('lockeeInput');
const dropdown = document.getElementById('lockeeDropdown');
const hidden = document.getElementById('lockeeValue');
function renderDropdown(query) {
const q = query.toLowerCase().trim();
const selfMatch = 'ich selbst'.includes(q);
const filtered = allFriends.filter(f => f.name.toLowerCase().includes(q));
dropdown.innerHTML = '';
comboActiveIdx = -1;
if (!selfMatch && filtered.length === 0) {
dropdown.innerHTML = `<div class="combo-empty">${q ? 'Keine Treffer.' : 'Keine Freunde vorhanden.'}</div>`;
} else {
if (selfMatch) {
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = myUserId; div.dataset.name = 'Ich selbst';
div.innerHTML = 'Ich selbst<span class="combo-hint">(Self-Lock)</span>';
div.addEventListener('mousedown', e => { e.preventDefault(); selectLockee(myUserId, 'Ich selbst'); });
dropdown.appendChild(div);
}
filtered.forEach(f => {
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = f.userId; div.dataset.name = f.name;
div.textContent = f.name;
div.addEventListener('mousedown', e => { e.preventDefault(); selectLockee(f.userId, f.name); });
dropdown.appendChild(div);
});
}
dropdown.classList.add('open');
}
function selectLockee(id, name) {
hidden.value = id;
input.value = name;
dropdown.classList.remove('open');
onLockeeChanged(id);
}
input.addEventListener('input', () => { hidden.value = ''; renderDropdown(input.value); });
input.addEventListener('focus', () => renderDropdown(input.value));
input.addEventListener('blur', () => {
setTimeout(() => {
dropdown.classList.remove('open');
if (!hidden.value) { input.value = 'Ich selbst'; hidden.value = myUserId; onLockeeChanged(myUserId); }
}, 150);
});
}
function onLockeeChanged(lockeeId) {
const isFriend = lockeeId && lockeeId !== myUserId;
const khInput = document.getElementById('keyholderInput');
const khHidden = document.getElementById('keyholderValue');
if (isFriend) {
khInput.value = myUserName || 'Ich selbst';
khHidden.value = myUserId;
khInput.readOnly = true;
khInput.style.opacity = '0.6';
document.getElementById('rowTestLock').style.display = 'none';
document.getElementById('rowDetailsVisible').style.display = '';
} else {
khInput.readOnly = false;
khInput.style.opacity = '';
if (!khHidden.value) khInput.value = '';
document.getElementById('rowTestLock').style.display = '';
document.getElementById('rowDetailsVisible').style.display = 'none';
}
updateCodeLinesVisibility();
}
// Self-Lock-Felder beim Start ausblenden (werden durch onLockeeChanged gesetzt)
document.getElementById('rowTestLock').style.display = '';
// ── Keyholder-Combobox ──
function setupKeyholderCombo() {
const input = document.getElementById('keyholderInput');
const dropdown = document.getElementById('keyholderDropdown');
const hidden = document.getElementById('keyholderValue');
function renderDropdown(query) {
if (input.readOnly) return;
const q = query.toLowerCase().trim();
const filtered = q ? allFriends.filter(f => f.name.toLowerCase().includes(q)) : allFriends;
dropdown.innerHTML = '';
comboActiveIdx = -1;
if (filtered.length === 0) {
dropdown.innerHTML = `<div class="combo-empty">${q ? 'Keine Freunde gefunden.' : 'Keine Freunde vorhanden.'}</div>`;
} else {
filtered.forEach(f => {
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = f.userId; div.dataset.name = f.name;
div.textContent = f.name;
div.addEventListener('mousedown', e => { e.preventDefault(); hidden.value = f.userId; input.value = f.name; dropdown.classList.remove('open'); });
dropdown.appendChild(div);
});
}
dropdown.classList.add('open');
}
input.addEventListener('input', () => { hidden.value = ''; renderDropdown(input.value); });
input.addEventListener('focus', () => renderDropdown(input.value));
input.addEventListener('blur', () => { setTimeout(() => { dropdown.classList.remove('open'); if (!hidden.value) input.value = ''; }, 150); });
}
// ── Template-Typ: Sektionen umschalten ──
function onTemplateChanged(t) {
const isTimeLock = t._type === 'timelock';
document.getElementById('rowMaxDuration').style.display = isTimeLock ? 'none' : '';
document.getElementById('rowTimeLockInfo').style.display = isTimeLock ? '' : 'none';
if (isTimeLock) {
const minM = t.minTimeInMinutes || 0;
const maxM = t.maxTimeInMinutes || 0;
document.getElementById('timeLockDurationText').textContent =
`${fmtMinutes(minM)} ${fmtMinutes(maxM)}`;
}
}
function fmtMinutes(m) {
if (!m) return '0 Min.';
const d = Math.floor(m / 1440);
const h = Math.floor((m % 1440) / 60);
const min = m % 60;
const parts = [];
if (d) parts.push(d + ' Tag' + (d !== 1 ? 'e' : ''));
if (h) parts.push(h + ' Std');
if (min) parts.push(min + ' Min.');
return parts.join(' ') || '0 Min.';
}
// ── LockControl-Auswahl ──
function selectLockControl(type) {
const ids = { UNLOCK_CODE: 'lcOptUnlockCode', TRUST: 'lcOptTrust', TTLOCK: 'lcOptTtlock' };
if (type === 'TTLOCK' && (!hasPaidSubscription || !ttlockReady)) return;
selectedLockControl = type;
Object.entries(ids).forEach(([t, id]) => {
const el = document.getElementById(id);
if (!el) return;
el.classList.toggle('lc-selected', t === type);
el.querySelector('input').checked = (t === type);
});
updateCodeLinesVisibility();
}
function updateCodeLinesVisibility() {
const show = selectedLockControl === 'UNLOCK_CODE' || selectedLockControl === 'TTLOCK';
const lockeeIsFriend = document.getElementById('lockeeValue').value !== myUserId
&& !!document.getElementById('lockeeValue').value;
document.getElementById('rowUnlockCodeLines').style.display = (show && !lockeeIsFriend) ? '' : 'none';
// Label je nach Typ anpassen
const label = document.querySelector('#rowUnlockCodeLines > label');
if (label) {
label.textContent = selectedLockControl === 'TTLOCK'
? 'PIN-Länge (49 Ziffern)'
: 'Anzahl Ziffern des Entsperrcodes';
}
// Für TTLock: min=4, max=9; Standard: min=1, max=20
const input = document.getElementById('unlockCodeLines');
if (selectedLockControl === 'TTLOCK') {
input.min = 4; input.max = 9;
if (parseInt(input.value) < 4) input.value = 6;
if (parseInt(input.value) > 9) input.value = 9;
} else {
input.min = 1; input.max = 20;
}
}
// ── Zeitpicker ──
function tpChange(prefix, delta, seg) {
let d = parseInt(document.getElementById(prefix + '_d').value) || 0;
let h = parseInt(document.getElementById(prefix + '_h')?.value) || 0;
if (seg === 'h') h += delta;
else d += delta;
if (h >= 24) { d += Math.floor(h / 24); h %= 24; }
if (h < 0) { const b = Math.ceil(-h / 24); d -= b; h += b * 24; }
if (d < 0) d = 0;
document.getElementById(prefix + '_d').value = d;
if (document.getElementById(prefix + '_h'))
document.getElementById(prefix + '_h').value = String(h).padStart(2, '0');
}
// ── Längste Dauer → LocalDateTime ──
function durationToLatestOpening() {
const days = parseInt(document.getElementById('dur_d').value) || 0;
const hours = parseInt(document.getElementById('dur_h').value) || 0;
if (days === 0 && hours === 0) return null;
const ms = (days * 24 * 3600 + hours * 3600) * 1000;
const dt = new Date(Date.now() + ms);
const pad = n => String(n).padStart(2, '0');
return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
}
// ── Fehler ──
function showError(msg) {
const el = document.getElementById('errorMsg');
el.textContent = msg;
el.style.display = '';
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function showActiveLockError() {
const el = document.getElementById('errorMsg');
el.innerHTML = 'Du befindest dich bereits in einem aktiven Lock. '
+ '<a href="/games/chastity/meine-locks.html" style="color:inherit;text-decoration:underline;">Zum aktiven Lock</a>';
el.style.display = '';
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function setFieldError(rowId, msg) {
const row = document.getElementById(rowId);
if (!row) return;
row.classList.add('field-error');
let el = row.querySelector('.field-error-msg');
if (!el) { el = document.createElement('div'); el.className = 'field-error-msg'; row.appendChild(el); }
el.textContent = msg;
}
function clearFieldError(rowId) {
const row = document.getElementById(rowId);
if (!row) return;
row.classList.remove('field-error');
row.querySelector('.field-error-msg')?.remove();
}
// ── Karten aus Template aufbauen ──
function buildInitialCardsFromTemplate(t) {
const cards = [];
CARD_DEFS.forEach(c => {
const minVal = t.cardCountsMin?.[c.id] ?? 0;
const maxVal = t.cardCountsMax?.[c.id] ?? 0;
const n = minVal + Math.floor(Math.random() * (maxVal - minVal + 1));
for (let i = 0; i < n; i++) cards.push(c.id);
});
return cards;
}
// ── Plausibilitätsprüfung für TimeLock ──
function validateTimeLockPlausibility(t) {
const errors = [];
const hasTasks = t.tasks && t.tasks.length > 0;
const spinEntries = t.spinningWheelEntries || [];
// Spinning Wheel enthält Task-Felder, aber keine Aufgaben definiert
if (spinEntries.some(e => e.type === 'TASK') && !hasTasks) {
errors.push('Das Spinning Wheel enthält Aufgaben-Felder (TASK), aber die Vorlage hat keine Aufgaben definiert. Bitte die Vorlage bearbeiten.');
}
// Aufgaben-Häufigkeit konfiguriert, aber keine Aufgaben vorhanden
if ((t.taskEveryMinutes > 0 || t.minTasksPerDay > 0) && !hasTasks) {
errors.push('Aufgaben sind zeitlich konfiguriert, aber keine Aufgaben in der Vorlage definiert. Bitte die Vorlage bearbeiten.');
}
// Unbegrenztes Einfrieren ohne Auftau-Eintrag
if (spinEntries.some(e => e.type === 'FREEZE') && !spinEntries.some(e => e.type === 'UNFREEZE')) {
errors.push('Das Spinning Wheel enthält ein unbegrenztes Einfrieren (FREEZE), aber keinen Auftau-Eintrag (UNFREEZE). Das Lock könnte dauerhaft eingefroren bleiben.');
}
return errors;
}
function showPlausibilityErrors(errors) {
const el = document.getElementById('errorMsg');
if (errors.length === 1) {
el.textContent = errors[0];
} else {
el.innerHTML = 'Die Vorlage enthält inkonsistente Einstellungen:<ul style="margin:0.4rem 0 0 1.2rem;padding:0;">'
+ errors.map(e => `<li>${e}</li>`).join('')
+ '</ul>';
}
el.style.display = '';
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// ── Absenden ──
async function createSession() {
document.getElementById('errorMsg').style.display = 'none';
const templateId = document.getElementById('templateValue').value;
if (!templateId) {
setFieldError('rowTemplate', 'Bitte eine Vorlage wählen.');
document.getElementById('rowTemplate').scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
clearFieldError('rowTemplate');
const t = selectedTemplate || allTemplates.find(x => x.templateId === templateId);
if (!t) { showError('Vorlage nicht gefunden.'); return; }
if (t._type === 'timelock') {
const plausErrors = validateTimeLockPlausibility(t);
if (plausErrors.length > 0) {
showPlausibilityErrors(plausErrors);
return;
}
}
const lockeeVal = document.getElementById('lockeeValue').value;
const keyholderVal = document.getElementById('keyholderValue').value;
const isFriendLockee = lockeeVal && lockeeVal !== myUserId;
const unlockCodeLen = isFriendLockee ? null : (parseInt(document.getElementById('unlockCodeLines').value) || 5);
const isTestLock = isFriendLockee ? false : document.getElementById('testLock').checked;
let endpoint, body;
if (t._type === 'timelock') {
endpoint = '/keyholder/timelock';
body = {
templateId: t.templateId,
lockeeUserId: isFriendLockee ? lockeeVal : null,
lockeeDetailsVisible: isFriendLockee ? document.getElementById('lockeeDetailsVisible').checked : false,
keyholder: isFriendLockee ? null : (keyholderVal || null),
testLock: isTestLock,
unlockCodeLength: unlockCodeLen,
controllType: selectedLockControl,
};
} else {
// CardLock
const initialCards = buildInitialCardsFromTemplate(t);
if (initialCards.length === 0) {
showError('Die gewählte Vorlage enthält keine Karten. Bitte Vorlage prüfen.');
return;
}
endpoint = '/keyholder/cardlock';
body = {
name: t.name,
lockeeUserId: isFriendLockee ? lockeeVal : null,
lockeeDetailsVisible: isFriendLockee ? document.getElementById('lockeeDetailsVisible').checked : false,
keyholder: isFriendLockee ? null : (keyholderVal || null),
initialCards,
pickEveryMinute: t.pickEveryMinute,
accumulatePicks: t.accumulatePicks,
showRemainingCards: t.showRemainingCards,
latestOpeningtime: durationToLatestOpening(),
hygineOpeningEveryMinites: t.hygineOpeningEveryMinites || null,
hygineOpeningDurationMinutes: t.hygineOpeningDurationMinutes || null,
tasks: t.tasks || [],
taskCardMode: t.taskCardMode || 'RANDOM',
unlockCodeLines: unlockCodeLen,
requiresVerification: t.requiresVerification,
testLock: isTestLock,
controllType: selectedLockControl,
};
}
if (selectedLockControl === 'TTLOCK') {
document.getElementById('ttlLoadingOverlay').classList.add('open');
}
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
document.getElementById('ttlLoadingOverlay').classList.remove('open');
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
if (res.status === 409 && errData.error === 'active_lock_exists') {
showActiveLockError();
} else if (res.status === 403 && errData.error === 'subscription_required') {
showError('TTLock erfordert ein aktives Abonnement.');
} else if (res.status === 400) {
showError('Ungültige Eingabe. Bitte alle Felder prüfen.');
} else {
showError('Fehler beim Erstellen des Locks.');
}
return;
}
const data = await res.json();
if (data.lockeeInvitationSent) {
window.location.href = '/games/common/einladungen.html?tab=gesendet';
} else if (!data.unlockCode) {
// Trust: kein Code, direkt weiter
const isTimeLock = selectedTemplate && selectedTemplate._type === 'timelock';
window.location.href = (isTimeLock ? '/games/chastity/activetimelock.html' : '/games/chastity/activelock.html')
+ '?lockId=' + data.lockId + (data.keyholderPending ? '&keyholderPending=1' : '');
} else if (selectedLockControl === 'TTLOCK') {
showTtlockStartModal(data.unlockCode, data.lockId, data.keyholderPending);
} else {
showUnlockCodeModal(data.unlockCode, data.lockId, data.keyholderPending);
}
}
// ── TTLock-Startmodal (kein Scramble, stattdessen Relock im Hintergrund) ──
function showTtlockStartModal(code, lockId, keyholderPending) {
const isTimeLock = selectedTemplate && selectedTemplate._type === 'timelock';
const lockType = isTimeLock ? 'timelock' : 'cardlock';
const targetUrl = (isTimeLock ? '/games/chastity/activetimelock.html' : '/games/chastity/activelock.html')
+ '?lockId=' + lockId + (keyholderPending ? '&keyholderPending=1' : '');
document.getElementById('unlockCodeDisplay').textContent = code;
document.getElementById('unlockModalTitle').textContent = 'Dein Startcode';
document.getElementById('unlockModalHint').textContent =
"Öffne das TTLock mit dem Code, lege den Schlüssel in das TTLock und verschließe es anschließend wieder. Der Code verliert anschließend seine Gültigkeit";
if (keyholderPending) document.getElementById('unlockKeyholderHint').style.display = '';
const btn = document.getElementById('unlockModalBtn');
btn.textContent = "🔒 Los geht's";
btn.onclick = async () => {
btn.disabled = true;
document.getElementById('unlockModal').classList.remove('open');
document.getElementById('ttlLoadingOverlay').classList.add('open');
try {
await fetch(`/keyholder/${lockType}/${lockId}/relock`, { method: 'POST' });
} catch { /* Fehler ignorieren Weiterleitung trotzdem */ }
window.location.href = targetUrl;
};
document.getElementById('unlockModal').classList.add('open');
}
// ── Entsperrcode-Modal ──
function showUnlockCodeModal(code, lockId, keyholderPending) {
document.getElementById('unlockCodeDisplay').textContent = code;
if (keyholderPending) document.getElementById('unlockKeyholderHint').style.display = '';
const isTimeLock = selectedTemplate && selectedTemplate._type === 'timelock';
const targetPage = isTimeLock ? '/games/chastity/activetimelock.html' : '/games/chastity/activelock.html';
const url = targetPage + '?lockId=' + lockId + (keyholderPending ? '&keyholderPending=1' : '');
document.getElementById('unlockModalBtn').onclick = () => startCodeScramble(code, url);
document.getElementById('unlockModal').classList.add('open');
}
function startCodeScramble(realCode, url) {
const display = document.getElementById('unlockCodeDisplay');
const btn = document.getElementById('unlockModalBtn');
const hint = document.getElementById('unlockModalHint');
const countdown = document.getElementById('unlockModalCountdown');
const len = realCode.length;
const DURATION = 3 * 60;
let remaining = DURATION;
let stopped = false;
function randomCode() {
return Array.from({ length: len }, () => Math.floor(Math.random() * 10)).join('');
}
function finish() {
stopped = true;
clearInterval(scrambleInterval);
clearInterval(countdownInterval);
window.location.href = url;
}
if (hint) hint.style.display = 'none';
countdown.style.display = '';
document.getElementById('unlockModalTitle').textContent = 'Nun vergessen wir den Code…';
btn.textContent = 'Abbrechen';
btn.onclick = finish;
const scrambleInterval = setInterval(() => {
if (!stopped) display.textContent = randomCode();
}, 80);
const countdownInterval = setInterval(() => {
if (stopped) return;
remaining--;
const m = Math.floor(remaining / 60);
const s = remaining % 60;
countdown.textContent = `Weiterleitung in ${m}:${String(s).padStart(2,'0')}`;
if (remaining <= 0) finish();
}, 1000);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0; url=/games/chastity/neulock.html">
</head>
<body></body>
</html>

View File

@@ -0,0 +1,90 @@
<!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>Code-Historie xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.history-list { display:flex; flex-direction:column; gap:1rem; margin-top:1.25rem; }
.history-card {
background:var(--color-secondary); border:1px solid var(--color-secondary);
border-radius:10px; padding:1rem 1.2rem;
}
.history-header { display:flex; align-items:center; justify-content:space-between; gap:0.5rem; margin-bottom:0.6rem; }
.history-lock-name { font-weight:700; font-size:0.95rem; }
.history-source {
font-size:0.75rem; color:var(--color-muted);
background:var(--color-card); border-radius:6px;
padding:0.15rem 0.5rem; white-space:nowrap;
}
.history-code {
font-family: monospace; font-size:0.85rem;
background:var(--color-card); border-radius:6px;
padding:0.5rem 0.75rem; word-break:break-all; line-height:1.5;
margin-bottom:0.5rem;
}
.history-time { font-size:0.78rem; color:var(--color-muted); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.75rem; }
.page-hint { font-size:0.85rem; color:var(--color-muted); margin:0.25rem 0 0; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin:0 0 0.25rem;">🔙 Entsperrcode-Historie</h1>
<p class="page-hint">Die letzten 10 Entsperrcodes, die dir angezeigt wurden.</p>
<div class="history-list" id="historyList">
<span class="empty-hint">Wird geladen…</span>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
const SOURCE_LABELS = {
GREEN_CARD: 'Grüne Karte',
HYGIENE_OPEN: 'Hygiene-Öffnung',
HYGIENE_CLOSE: 'Hygiene-Öffnung (neu)',
KEYHOLDER_UNLOCK: 'Freigabe durch Keyholder',
};
async function load() {
const res = await fetch('/keyholder/cardlock/unlock-history');
const list = document.getElementById('historyList');
if (!res.ok) { list.innerHTML = '<span class="empty-hint">Fehler beim Laden.</span>'; return; }
const entries = await res.json();
if (!entries.length) { list.innerHTML = '<span class="empty-hint">Noch keine Entsperrcodes erhalten.</span>'; return; }
list.innerHTML = '';
for (const e of entries) {
const dt = new Date(e.receivedAt);
const formatted = dt.toLocaleString('de-DE', {
day:'2-digit', month:'2-digit', year:'numeric',
hour:'2-digit', minute:'2-digit'
});
const sourceLabel = SOURCE_LABELS[e.source] || e.source;
const card = document.createElement('div');
card.className = 'history-card';
card.innerHTML = `
<div class="history-header">
<span class="history-lock-name">${escHtml(e.lockName)}</span>
<span class="history-source">${escHtml(sourceLabel)}</span>
</div>
<div class="history-code">${escHtml(e.unlockCode)}</div>
<div class="history-time">Erhalten am ${escHtml(formatted)}</div>
`;
list.appendChild(card);
}
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
load();
</script>
</body>
</html>