Verschiebung nach anderem RePo - nun pro Projekt getrennt
This commit is contained in:
1749
bin/main/static/games/chastity/activelock.html
Normal file
1749
bin/main/static/games/chastity/activelock.html
Normal file
File diff suppressed because it is too large
Load Diff
1382
bin/main/static/games/chastity/activetimelock.html
Normal file
1382
bin/main/static/games/chastity/activetimelock.html
Normal file
File diff suppressed because it is too large
Load Diff
413
bin/main/static/games/chastity/communityvotes.html
Normal file
413
bin/main/static/games/chastity/communityvotes.html
Normal 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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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>
|
||||
528
bin/main/static/games/chastity/entdecken-vorlagen.html
Normal file
528
bin/main/static/games/chastity/entdecken-vorlagen.html
Normal 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>
|
||||
485
bin/main/static/games/chastity/entdecken.html
Normal file
485
bin/main/static/games/chastity/entdecken.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
21
bin/main/static/games/chastity/infochastity.html
Normal file
21
bin/main/static/games/chastity/infochastity.html
Normal 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>
|
||||
307
bin/main/static/games/chastity/joinlock.html
Normal file
307
bin/main/static/games/chastity/joinlock.html
Normal 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>
|
||||
530
bin/main/static/games/chastity/keyholder-finden.html
Normal file
530
bin/main/static/games/chastity/keyholder-finden.html
Normal 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>
|
||||
@@ -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>
|
||||
1923
bin/main/static/games/chastity/keyholder.html
Normal file
1923
bin/main/static/games/chastity/keyholder.html
Normal file
File diff suppressed because it is too large
Load Diff
1308
bin/main/static/games/chastity/meine-locks.html
Normal file
1308
bin/main/static/games/chastity/meine-locks.html
Normal file
File diff suppressed because it is too large
Load Diff
935
bin/main/static/games/chastity/neulock.html
Normal file
935
bin/main/static/games/chastity/neulock.html
Normal 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 (4–9 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>
|
||||
9
bin/main/static/games/chastity/sessionchastity.html
Normal file
9
bin/main/static/games/chastity/sessionchastity.html
Normal 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>
|
||||
90
bin/main/static/games/chastity/unlock-history.html
Normal file
90
bin/main/static/games/chastity/unlock-history.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user