Verschiebung nach anderem RePo - nun pro Projekt getrennt

This commit is contained in:
2026-04-01 10:41:19 +02:00
commit 7b9eda1d62
1048 changed files with 93351 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
<!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>Aktivierung xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="card">
<h1>XXX The Game</h1>
<p class="subtitle">E-Mail-Adresse bestätigen</p>
<p style="font-size:0.9rem; color:#aaa; margin-top:0.5rem; line-height:1.5;">
Du hast eine E-Mail mit einem Aktivierungslink erhalten.<br>
Falls der Link nicht funktioniert, gib hier deinen Aktivierungscode ein.
</p>
<label for="uuid" style="margin-top:1.5rem;">Aktivierungscode</label>
<input type="text" id="uuid" placeholder="6-stelliger Code" autocomplete="off" inputmode="numeric" maxlength="6" />
<button class="full-width" id="activateBtn" onclick="activate()">Jetzt aktivieren</button>
<div class="message" id="message"></div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(window.location.search);
const email = params.get('email');
if (email) {
showMessage(`Eine Bestätigungs-E-Mail wurde an ${email} gesendet.`, 'success');
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Enter') activate();
});
async function activate() {
const uuid = document.getElementById('uuid').value.trim();
const btn = document.getElementById('activateBtn');
if (!uuid) {
showMessage('Bitte den Aktivierungscode eingeben.', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Wird aktiviert…';
hideMessage();
try {
const response = await fetch(`/activation/${encodeURIComponent(uuid)}`, {
method: 'GET',
redirect: 'follow'
});
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.href = '/login.html';
} else if (response.status === 204) {
showMessage('Code ungültig oder bereits verwendet.', 'error');
btn.disabled = false;
btn.textContent = 'Jetzt aktivieren';
} else {
showMessage(`Fehler: HTTP ${response.status}`, 'error');
btn.disabled = false;
btn.textContent = 'Jetzt aktivieren';
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
btn.disabled = false;
btn.textContent = 'Jetzt aktivieren';
console.error(err);
}
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,38 @@
<!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>Abonnements xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.page-hint { font-size:0.85rem; color:var(--color-muted); margin:0.25rem 0 1.5rem; }
.coming-soon {
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:1rem; padding:3rem 1rem; color:var(--color-muted); text-align:center;
}
.coming-soon .icon { font-size:3rem; }
.coming-soon h2 { font-size:1.2rem; font-weight:600; color:var(--color-text); margin:0; }
.coming-soon p { font-size:0.9rem; max-width:360px; margin:0; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin:0 0 0.25rem;">⭐ Abonnements</h1>
<p class="page-hint">Übersicht der verfügbaren Abo-Modelle</p>
<div class="coming-soon">
<span class="icon">🚧</span>
<h2>Demnächst verfügbar</h2>
<p>Hier werden bald die verschiedenen Abo-Modelle beschrieben und abschließbar sein.</p>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,252 @@
<!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>Benachrichtigungen xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.notif-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.notif-item {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 0.75rem 1rem;
display: flex;
gap: 0.75rem;
align-items: flex-start;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
text-decoration: none;
color: inherit;
}
.notif-item:hover {
border-color: var(--color-primary);
background: rgba(255,255,255,0.03);
}
.notif-item.unread {
border-left: 3px solid var(--color-primary);
background: rgba(var(--color-primary-rgb, 200,0,0), 0.05);
}
.notif-item.unread:hover {
background: rgba(var(--color-primary-rgb, 200,0,0), 0.09);
}
.notif-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
overflow: hidden;
}
.notif-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.notif-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
margin-top: 0.45rem;
}
.notif-dot.read { background: transparent; }
.notif-body { flex: 1; min-width: 0; }
.notif-text {
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.notif-time {
font-size: 0.75rem;
color: var(--color-muted);
margin-top: 0.2rem;
}
.notif-arrow {
font-size: 0.75rem;
color: var(--color-muted);
flex-shrink: 0;
align-self: center;
}
.notif-empty {
color: var(--color-muted);
font-size: 0.9rem;
margin-top: 0.5rem;
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="notif-header">
<h1 style="margin:0;">🔔 Benachrichtigungen</h1>
<button id="markAllBtn" onclick="markAllRead()"
style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);
padding:0.35rem 0.85rem;border-radius:7px;cursor:pointer;font-size:0.82rem;width:auto;display:none;">
Alle als gelesen markieren
</button>
</div>
<div class="notif-list" id="notifList">
<p class="notif-empty" id="notifEmpty" style="display:none;">Keine Benachrichtigungen vorhanden.</p>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function fmtRelTime(isoStr) {
const diff = Date.now() - new Date(isoStr).getTime();
const min = Math.floor(diff / 60000);
const h = Math.floor(min / 60);
const d = Math.floor(h / 24);
if (d > 0) return `vor ${d} Tag${d > 1 ? 'en' : ''}`;
if (h > 0) return `vor ${h} Std.`;
if (min > 0) return `vor ${min} Min.`;
return 'gerade eben';
}
function esc(s) {
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
async function handleClick(id, targetUrl) {
// Als gelesen markieren
await fetch(`/notifications/${id}/read`, { method: 'POST' }).catch(() => {});
// Dot des angeklickten Items auf "gelesen" setzen
const dot = document.querySelector(`[data-notif-id="${id}"] .notif-dot`);
if (dot) dot.classList.add('read');
const item = document.querySelector(`[data-notif-id="${id}"]`);
if (item) item.classList.remove('unread');
// Badge aktualisieren
const remaining = document.querySelectorAll('.notif-item.unread').length;
['socialNotifBadge','socialMobileNotifBadge'].forEach(bid => {
const el = document.getElementById(bid);
if (!el) return;
el.textContent = remaining;
el.style.display = remaining > 0 ? '' : 'none';
});
if (remaining === 0) document.getElementById('markAllBtn').style.display = 'none';
// Navigieren wenn Zielseite vorhanden
if (targetUrl) window.location.href = targetUrl;
}
async function markAllRead() {
await fetch('/notifications/read-all', { method: 'POST' }).catch(() => {});
document.querySelectorAll('.notif-item.unread').forEach(el => {
el.classList.remove('unread');
const dot = el.querySelector('.notif-dot');
if (dot) dot.classList.add('read');
});
document.getElementById('markAllBtn').style.display = 'none';
['socialNotifBadge','socialMobileNotifBadge'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.textContent = '0'; el.style.display = 'none'; }
});
}
async function loadNotifications() {
const list = document.getElementById('notifList');
const empty = document.getElementById('notifEmpty');
const btn = document.getElementById('markAllBtn');
// Vorhandene Einträge (außer Empty-Hint) entfernen
list.querySelectorAll('.notif-item').forEach(el => el.remove());
try {
const res = await fetch('/notifications');
if (!res.ok) return;
const items = await res.json();
if (items.length === 0) {
empty.style.display = '';
btn.style.display = 'none';
return;
}
empty.style.display = 'none';
const hasUnread = items.some(n => !n.read);
btn.style.display = hasUnread ? '' : 'none';
items.forEach(n => {
const div = document.createElement('div');
div.className = 'notif-item' + (n.read ? '' : ' unread');
div.dataset.notifId = n.id;
div.setAttribute('role', 'button');
div.setAttribute('tabindex', '0');
div.onclick = () => handleClick(n.id, n.targetUrl);
div.onkeydown = e => { if (e.key === 'Enter') handleClick(n.id, n.targetUrl); };
const arrow = n.targetUrl
? `<span class="notif-arrow"></span>`
: '';
const avatarInner = n.senderAvatar
? `<img src="data:image/png;base64,${n.senderAvatar}" alt="${esc(n.senderName || '')}">`
: `<span>🔔</span>`;
div.innerHTML = `
<div class="notif-avatar">${avatarInner}</div>
<div class="notif-dot${n.read ? ' read' : ''}"></div>
<div class="notif-body">
<div class="notif-text">${esc(n.text)}</div>
<div class="notif-time">${fmtRelTime(n.sentAt)}</div>
</div>
${arrow}`;
list.appendChild(div);
});
} catch(e) {
console.error(e);
}
}
// SSE: Neue Benachrichtigung → sofort neu laden
window.__sseOnNotification = () => loadNotifications();
loadNotifications();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,541 @@
<!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>Feed xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.tabs { display:flex; gap:0; margin-bottom:1.5rem; border-bottom:1px solid var(--color-secondary); }
.tab-btn { background:none; border:none; border-bottom:3px solid transparent; border-radius:0; padding:0.6rem 1.25rem; font-size:0.95rem; font-weight:600; color:var(--color-muted); cursor:pointer; margin-bottom:-1px; transition:color 0.15s,border-color 0.15s; }
.tab-btn:hover { color:var(--color-text); background:none; }
.tab-btn.active { color:var(--color-primary); border-bottom-color:var(--color-primary); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
.post-compose { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:1rem; transition:border-color 0.15s; }
.post-compose.drag-over { border-color:var(--color-primary); background:rgba(var(--color-primary-rgb,180,0,60),0.06); }
.compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; }
.compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; }
.post-compose textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:70px; box-sizing:border-box; }
.post-compose textarea:focus { border-color:var(--color-primary); }
.compose-thumbs { display:none; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem; }
.compose-thumb { position:relative; width:64px; height:64px; flex-shrink:0; }
.compose-thumb img { width:64px; height:64px; object-fit:cover; border-radius:6px; display:block; }
.compose-thumb-remove { position:absolute; top:-5px; right:-5px; background:rgba(0,0,0,0.7); border:none; color:#fff; width:18px; height:18px; border-radius:50%; font-size:0.65rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; width:auto; line-height:1; }
.umfrage-options { margin-top:0.5rem; }
.umfrage-option-row { display:flex; gap:0.5rem; margin-bottom:0.4rem; }
.umfrage-option-row input { flex:1; }
.umfrage-option-row button { width:auto; margin:0; padding:0.3rem 0.6rem; font-size:0.8rem; }
.compose-footer { display:flex; justify-content:space-between; align-items:center; margin-top:0.75rem; flex-wrap:wrap; gap:0.5rem; }
.multi-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.privacy-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; transition:border-color 0.15s,color 0.15s; }
.compose-action-btn:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
label.compose-action-btn { display:inline-flex; align-items:center; }
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; }
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
.post-avatar img { width:100%; height:100%; object-fit:cover; }
.post-author { font-weight:600; font-size:0.9rem; }
.post-meta { font-size:0.75rem; color:var(--color-muted); }
.post-date { font-size:0.75rem; color:var(--color-muted); margin-left:auto; }
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.post-bild { width:100%; max-height:400px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; }
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; }
.post-action-btn { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; margin:0; width:auto; }
.post-action-btn:hover { color:var(--color-primary); background:none; }
.post-action-btn.active { color:var(--color-primary); }
.post-delete { margin-left:auto; }
.post-delete:hover { color:#c0392b !important; }
/* Carousel Stile kommen aus shared.js */
.umfrage-option-bar { margin:0.3rem 0; cursor:pointer; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; transition:border-color 0.15s; }
.umfrage-option-bar:hover { border-color:var(--color-primary); }
.umfrage-option-bar.voted { border-color:var(--color-primary); }
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); transition:width 0.4s; }
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
.gruppe-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; color:var(--color-muted); background:var(--color-secondary); border-radius:4px; padding:0.15rem 0.45rem; margin-top:0.1rem; }
.gruppe-badge a { color:inherit; text-decoration:none; }
.gruppe-badge a:hover { color:var(--color-primary); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
.sentinel { height:1px; }
/* Lightbox */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
.lightbox.open { display:flex; }
.lb-layout { display:flex; max-width:920px; width:95vw; max-height:90vh; background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-header { font-size:0.78rem; font-weight:600; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.06em; padding:0.7rem 1rem; border-bottom:1px solid var(--color-secondary); flex-shrink:0; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) { .lb-layout { flex-direction:column; max-height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
/* Comment + Like-Stile kommen aus shared.js */
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="tabs">
<button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button>
<button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button>
</div>
<!-- Mein Feed -->
<div class="tab-panel active" id="tab-mine">
<div class="post-compose" id="compose">
<div class="compose-type">
<label><input type="radio" name="beitragTyp" value="TEXT" checked onchange="toggleUmfrage()"> Text</label>
<label><input type="radio" name="beitragTyp" value="UMFRAGE" onchange="toggleUmfrage()"> Umfrage</label>
</div>
<textarea id="composeText" placeholder="Was möchtest du teilen?" rows="3"></textarea>
<div class="compose-thumbs" id="composeThumbs"></div>
<div class="umfrage-options" id="umfrageOptions" style="display:none;">
<div id="optionList"></div>
<button onclick="addOption()" style="width:auto; margin:0; padding:0.3rem 0.75rem; font-size:0.8rem; margin-top:0.4rem;">+ Option</button>
</div>
<div class="compose-footer">
<div style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;">
<label class="multi-toggle" id="multiChoiceRow" style="display:none;">
<input type="checkbox" id="multiChoice"> Multi-Choice
</label>
<label class="privacy-toggle">
<input type="checkbox" id="isPublic"> Öffentlich
</label>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'composeText')" title="Emoji einfügen">😊</button>
<label class="compose-action-btn" title="Fotos hinzufügen">📷
<input type="file" id="composeBildFile" accept="image/*" multiple style="display:none;" onchange="selectComposeBilder(this)">
</label>
<button onclick="submitPost()" style="width:auto; margin:0;">Veröffentlichen</button>
</div>
</div>
</div>
<div id="mineFeed"></div>
<p class="empty-hint" id="mineEmpty" style="display:none;">Noch keine Beiträge. Schreib den ersten!</p>
<div class="sentinel" id="mineSentinel"></div>
</div>
<!-- Öffentlicher Feed -->
<div class="tab-panel" id="tab-public">
<div id="publicFeed"></div>
<p class="empty-hint" id="publicEmpty" style="display:none;">Noch keine öffentlichen Beiträge.</p>
<div class="sentinel" id="publicSentinel"></div>
</div>
</div>
</div>
<!-- Post Lightbox -->
<div class="lightbox" id="postLightbox">
<div class="lb-layout">
<button class="lb-close" onclick="closeLb()"></button>
<div class="lb-post-side" id="lbPostBody"></div>
<div class="lb-comments-panel">
<div class="lb-comments-header">Kommentare</div>
<div class="lb-comments-list" id="lbCommentsList"></div>
<div class="lb-comment-compose">
<textarea id="lbCommentInput" placeholder="Kommentar schreiben…" maxlength="500" rows="3"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();postLbComment()}"></textarea>
<div class="lb-comment-compose-actions">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button>
<button onclick="postLbComment()">Senden</button>
</div>
</div>
</div>
</div>
</div>
<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 src="/js/meldung.js"></script>
<script>
// ── State ──
let myUserId = null;
let activeLbPostId = null;
let activeLbPostType = null;
const feedState = {
mine: { page:0, hasMore:true, loading:false, loaded:false },
public: { page:0, hasMore:true, loading:false, loaded:false }
};
let composeBilderArr = [];
// ── Boot ──
fetch('/login/me').then(r => r.ok ? r.json() : null).then(user => {
if (user) {
myUserId = user.userId;
loadFeed('mine');
}
}).catch(() => {});
// ── Tab switching ──
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
localStorage.setItem('tab_feed', name);
if (!feedState[name].loaded) loadFeed(name);
}
const _savedFeedTab = localStorage.getItem('tab_feed');
if (_savedFeedTab) {
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedFeedTab}"]`);
if (_btn) switchTab(_savedFeedTab, _btn);
}
// ── Feed loading ──
async function loadFeed(tab) {
const state = feedState[tab];
if (state.loading || !state.hasMore) return;
state.loading = true;
state.loaded = true;
try {
const endpoint = tab === 'mine' ? '/feed/mine' : '/feed/public';
const res = await fetch(`${endpoint}?page=${state.page}&size=10`);
if (!res.ok) return;
const data = await res.json();
const feedEl = document.getElementById(tab + 'Feed');
if (state.page === 0 && data.posts.length === 0) {
document.getElementById(tab + 'Empty').style.display = '';
}
data.posts.forEach(p => feedEl.insertAdjacentHTML('beforeend', renderPostCard(p, tab)));
state.hasMore = data.hasMore;
state.page++;
} finally {
state.loading = false;
}
}
// ── Infinite Scroll ──
const observer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
if (e.target.id === 'mineSentinel') loadFeed('mine');
if (e.target.id === 'publicSentinel') loadFeed('public');
});
}, { threshold: 0.5 });
observer.observe(document.getElementById('mineSentinel'));
observer.observe(document.getElementById('publicSentinel'));
// bilderCarousel und carNav kommen aus shared.js
// ── Render post card ──
function renderPostCard(p, tab) {
const avatarHtml = p.authorPicture
? `<img src="data:image/png;base64,${p.authorPicture}" alt="">`
: '◉';
const privacyLabel = !p.isPublic ? ' <span style="font-size:0.7rem;color:var(--color-muted);">🔒</span>' : '';
const groupBadge = p.postType === 'GROUP' && p.gruppeId
? `<span class="gruppe-badge" onclick="event.stopPropagation()">👥 <a href="/community/gruppe.html?id=${p.gruppeId}" onclick="event.stopPropagation()">${esc(p.gruppeName)}</a></span>`
: '';
const bildHtml = bilderCarousel(p.bilder, p.postId);
let umfrageHtml = '';
if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) {
const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
umfrageHtml = '<div style="margin-top:0.5rem;">' + p.optionen.map(o => {
const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
const voted = p.myVoteOptionIds && p.myVoteOptionIds.includes(o.optionId);
return `<div class="umfrage-option-bar${voted ? ' voted' : ''}" onclick="event.stopPropagation(); votePost('${p.postId}','${o.optionId}','${tab}','${p.postType}')">
<div class="umfrage-bar-fill" style="width:${pct}%"></div>
<div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div>
</div>`;
}).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`;
}
const canDelete = p.postType === 'FEED' && p.authorId === myUserId;
const deleteBtn = canDelete
? `<button class="post-action-btn post-delete" onclick="event.stopPropagation(); deletePost('${p.postId}')">🗑</button>`
: '';
const meldenBtn = p.authorId !== myUserId
? `<button class="post-action-btn" onclick="event.stopPropagation(); openMeldungDialog('POST','${p.postId}')" title="Melden" style="color:var(--color-muted)">⚑</button>`
: '';
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
return `<div class="post-card" id="pc-${p.postId}"${gruppeIdAttr} onclick="openLb('${p.postId}','${p.postType}')" style="cursor:pointer;">
<div class="post-header">
<div class="post-avatar">${avatarHtml}</div>
<div>
<div class="post-author"><a href="/community/benutzer.html?userId=${p.authorId}" style="color:inherit;text-decoration:none;" onclick="event.stopPropagation()">${esc(p.authorName)}</a>${privacyLabel}</div>
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
</div>
${deleteBtn}
</div>
<div class="post-text">${esc(p.text)}</div>
${bildHtml}
${umfrageHtml}
<div class="post-actions">
<button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="lk-${p.postId}" onclick="event.stopPropagation(); likePost('${p.postId}','${p.postType}')">
♥ <span id="lkc-${p.postId}">${p.likeCount}</span>
</button>
<button class="post-action-btn" onclick="event.stopPropagation(); openLb('${p.postId}','${p.postType}')">
💬 <span id="kc-${p.postId}">${p.kommentarCount}</span>
</button>
${meldenBtn}
</div>
</div>`;
}
// ── Compose ──
function toggleUmfrage() {
const isUmfrage = document.querySelector('input[name="beitragTyp"]:checked').value === 'UMFRAGE';
document.getElementById('umfrageOptions').style.display = isUmfrage ? '' : 'none';
document.getElementById('multiChoiceRow').style.display = isUmfrage ? '' : 'none';
if (isUmfrage && document.getElementById('optionList').children.length === 0) {
addOption(); addOption();
}
}
function addOption() {
const list = document.getElementById('optionList');
const idx = list.children.length;
const row = document.createElement('div');
row.className = 'umfrage-option-row';
row.innerHTML = `<input type="text" placeholder="Option ${idx + 1}" maxlength="100">
<button onclick="this.parentElement.remove()">✕</button>`;
list.appendChild(row);
}
function selectComposeBilder(input) {
[...input.files].forEach(f => { if (f.type.startsWith('image/')) processImageFile(f); });
input.value = '';
}
function processImageFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const maxSize = 1024;
const canvas = document.createElement('canvas');
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
const data = canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
composeBilderArr.push(data);
renderComposeThumbs();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function renderComposeThumbs() {
const container = document.getElementById('composeThumbs');
container.innerHTML = '';
composeBilderArr.forEach((b, i) => {
const div = document.createElement('div');
div.className = 'compose-thumb';
div.innerHTML = `<img src="data:image/jpeg;base64,${b}" alt="">
<button class="compose-thumb-remove" onclick="removeThumb(${i})" title="Entfernen">✕</button>`;
container.appendChild(div);
});
container.style.display = composeBilderArr.length > 0 ? 'flex' : 'none';
}
function removeThumb(idx) {
composeBilderArr.splice(idx, 1);
renderComposeThumbs();
}
// ── Drag & Drop ──
const compose = document.getElementById('compose');
compose.addEventListener('dragover', e => {
e.preventDefault();
if ([...e.dataTransfer.items].some(i => i.type.startsWith('image/')))
compose.classList.add('drag-over');
});
compose.addEventListener('dragleave', e => {
if (!compose.contains(e.relatedTarget)) compose.classList.remove('drag-over');
});
compose.addEventListener('drop', e => {
e.preventDefault();
compose.classList.remove('drag-over');
[...e.dataTransfer.files]
.filter(f => f.type.startsWith('image/'))
.forEach(f => processImageFile(f));
});
async function submitPost() {
const text = document.getElementById('composeText').value.trim();
if (!text) return;
const beitragTyp = document.querySelector('input[name="beitragTyp"]:checked').value;
const multiChoice = document.getElementById('multiChoice').checked;
const isPublic = document.getElementById('isPublic').checked;
let optionen = [];
if (beitragTyp === 'UMFRAGE') {
optionen = Array.from(document.getElementById('optionList').querySelectorAll('input'))
.map(i => i.value.trim()).filter(v => v);
if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; }
}
const res = await fetch('/feed/posts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ beitragTyp, text, multiChoice, optionen, bilder: [...composeBilderArr], isPublic })
});
if (!res.ok) return;
const post = await res.json();
// Reset compose
document.getElementById('composeText').value = '';
composeBilderArr = [];
renderComposeThumbs();
document.querySelector('input[name="beitragTyp"][value="TEXT"]').checked = true;
toggleUmfrage();
document.getElementById('multiChoice').checked = false;
document.getElementById('isPublic').checked = false;
document.getElementById('optionList').innerHTML = '';
// Prepend to mine feed
document.getElementById('mineEmpty').style.display = 'none';
document.getElementById('mineFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'mine'));
if (isPublic) {
document.getElementById('publicEmpty').style.display = 'none';
document.getElementById('publicFeed').insertAdjacentHTML('afterbegin', renderPostCard(post, 'public'));
}
}
// ── Like ──
async function likePost(postId, postType) {
let likeEndpoint;
if (postType === 'GROUP') {
const card = document.getElementById('pc-' + postId);
const gruppeId = card?.dataset?.gruppeId;
if (!gruppeId) return;
likeEndpoint = `/gruppen/${gruppeId}/posts/${postId}/like`;
} else {
likeEndpoint = `/feed/posts/${postId}/like`;
}
await fetch(likeEndpoint, { method: 'POST' });
const btn = document.getElementById('lk-' + postId);
const lc = document.getElementById('lkc-' + postId);
const was = btn.classList.contains('active');
btn.classList.toggle('active', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
}
// ── Vote ──
async function votePost(postId, optionId, tab, postType) {
if (postType === 'GROUP') {
const card = document.getElementById('pc-' + postId);
const gruppeId = card?.dataset?.gruppeId;
if (!gruppeId) return;
await fetch(`/gruppen/${gruppeId}/posts/${postId}/vote`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ optionId })
});
} else {
await fetch('/feed/posts/' + postId + '/vote', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ optionId })
});
}
reloadPost(postId, tab);
}
async function reloadPost(postId, tab) {
const state = feedState[tab];
state.page = 0; state.hasMore = true; state.loaded = false;
document.getElementById(tab + 'Feed').innerHTML = '';
document.getElementById(tab + 'Empty').style.display = 'none';
await loadFeed(tab);
}
// ── Delete ──
async function deletePost(postId) {
if (!confirm('Post löschen?')) return;
const res = await fetch('/feed/posts/' + postId, { method: 'DELETE' });
if (res.ok) {
document.getElementById('pc-' + postId)?.remove();
}
}
// ── Lightbox ──
function openLb(postId, postType) {
activeLbPostId = postId;
activeLbPostType = postType;
const card = document.getElementById('pc-' + postId);
if (card) {
const clone = card.cloneNode(true);
clone.querySelectorAll('.post-actions').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = clone.innerHTML;
}
loadLbComments(postId, postType);
document.getElementById('postLightbox').classList.add('open');
}
function closeLb() {
document.getElementById('postLightbox').classList.remove('open');
activeLbPostId = null;
activeLbPostType = null;
}
document.getElementById('postLightbox').addEventListener('click', e => {
if (e.target === document.getElementById('postLightbox')) closeLb();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('postLightbox').classList.contains('open')) closeLb();
});
async function loadLbComments(postId, postType) {
const targetType = postType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
const res = await fetch(`/social/kommentare?targetType=${targetType}&targetId=${postId}`);
const comments = await res.json();
document.getElementById('lbCommentsList').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, targetType, postId, { myUserId })).join('');
}
async function postLbComment() {
if (!activeLbPostId) return;
const input = document.getElementById('lbCommentInput');
const text = input.value.trim();
if (!text) return;
const targetType = activeLbPostType === 'GROUP' ? 'GROUP_POST' : 'FEED_POST';
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType, targetId: activeLbPostId, text })
});
input.value = '';
await loadLbComments(activeLbPostId, activeLbPostType);
const kcEl = document.getElementById('kc-' + activeLbPostId);
if (kcEl) kcEl.textContent = parseInt(kcEl.textContent) + 1;
}
// renderKommentarHtml und toggleKommentarLike kommen aus shared.js
async function deleteKommentar(kommentarId, targetType, targetId) {
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments(targetId, activeLbPostType);
}
// toggleEmojiPicker, insertEmoji kommen aus shared.js
// esc, fmtDate kommen aus shared.js
</script>
</body>
</html>

View File

@@ -0,0 +1,351 @@
<!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>Freunde xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.tabs {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--color-secondary);
}
.tab-btn {
background: none;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
padding: 0.6rem 1.25rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-muted);
cursor: pointer;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
}
.tab-btn:hover { color: var(--color-text); background: none; }
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.user-list { list-style: none; margin: 0; padding: 0; }
.user-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-secondary);
}
.user-item:last-child { border-bottom: none; }
.user-avatar {
width: 42px; height: 42px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid var(--color-secondary);
}
.user-avatar img { width: 100%; height: 100%; object-fit: cover; }
.user-name { font-weight: 600; flex: 1; }
.user-actions { display: flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.user-actions button, .user-actions a.btn {
margin-top: 0;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
width: auto;
}
.user-profile-link {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
text-decoration: none;
color: inherit;
}
.user-profile-link:hover .user-name { color: var(--color-primary); }
.btn-reject {
background: transparent;
border: 1px solid var(--color-secondary);
color: var(--color-muted);
font-size: 0.8rem;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-reject:hover { background: #3d0f1a; border-color: var(--color-primary); color: var(--color-primary); }
.empty-hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.5rem; }
/* ── Confirm dialog ── */
.dialog-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.dialog-backdrop.visible { display: flex; }
.dialog {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 1.75rem;
width: 100%;
max-width: 340px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
}
.dialog h3 { color: var(--color-primary); font-size: 1.05rem; margin-bottom: 0.75rem; }
.dialog p { color: var(--color-muted); font-size: 0.88rem; margin-bottom: 1.25rem; }
.dialog-actions { display: flex; gap: 0.75rem; }
.dialog-actions button { flex: 1; margin-top: 0; }
.tab-badge {
display: inline-block;
background: var(--color-primary);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.05rem 0.35rem;
margin-left: 0.35rem;
vertical-align: middle;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom: 1.25rem;">Freunde</h1>
<div class="tabs">
<button class="tab-btn active" data-tab="friends" onclick="switchTab('friends', this)">Freunde</button>
<button class="tab-btn" data-tab="pending" onclick="switchTab('pending', this)" id="pendingTabBtn">
Anfragen<span class="tab-badge" id="pendingBadge" style="display:none;"></span>
</button>
</div>
<!-- Friends tab -->
<div class="tab-panel active" id="tab-friends">
<ul class="user-list" id="friendsList"></ul>
<p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></p>
</div>
<!-- Pending tab -->
<div class="tab-panel" id="tab-pending">
<ul class="user-list" id="pendingList"></ul>
<p class="empty-hint" id="pendingEmpty" style="display:none;">Keine offenen Anfragen.</p>
</div>
</div>
</div>
<!-- Confirm remove dialog -->
<div class="dialog-backdrop" id="removeDialog">
<div class="dialog">
<h3>Freundschaft beenden</h3>
<p id="removeDialogText">Möchtest du diese Freundschaft wirklich beenden?</p>
<div class="dialog-actions">
<button class="secondary" onclick="closeRemoveDialog()">Abbrechen</button>
<button id="removeConfirmBtn" style="background:#c0392b;" onclick="confirmRemove()">Entfernen</button>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
localStorage.setItem('tab_freunde', name);
}
const _savedFreundeTab = localStorage.getItem('tab_freunde');
if (_savedFreundeTab) {
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedFreundeTab}"]`);
if (_btn) switchTab(_savedFreundeTab, _btn);
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function avatar(u) {
return u.profilePicture
? `<img src="data:image/png;base64,${u.profilePicture}" alt="">`
: '◉';
}
async function loadFriends() {
try {
const res = await fetch('/social/friends');
if (!res.ok) return;
const friends = await res.json();
const list = document.getElementById('friendsList');
list.innerHTML = '';
if (friends.length === 0) {
document.getElementById('friendsEmpty').style.display = '';
return;
}
document.getElementById('friendsEmpty').style.display = 'none';
friends.forEach(f => {
list.insertAdjacentHTML('beforeend', `
<li class="user-item" id="friend-${f.friendshipId}">
<a href="/community/benutzer.html?userId=${f.user.userId}" class="user-profile-link">
<div class="user-avatar">${avatar(f.user)}</div>
<div class="user-name">${esc(f.user.name)}</div>
</a>
<div class="user-actions">
<a href="/community/nachrichten.html?userId=${f.user.userId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>
<button class="btn-reject" onclick="removeFriend('${f.friendshipId}', this)">Entfernen</button>
</div>
</li>`);
});
} catch (e) { console.error(e); }
}
async function loadPending() {
try {
const res = await fetch('/social/friends/pending');
if (!res.ok) return;
const pending = await res.json();
const list = document.getElementById('pendingList');
list.innerHTML = '';
const badge = document.getElementById('pendingBadge');
if (pending.length > 0) {
badge.textContent = pending.length;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
if (pending.length === 0) {
document.getElementById('pendingEmpty').style.display = '';
return;
}
document.getElementById('pendingEmpty').style.display = 'none';
pending.forEach(f => {
list.insertAdjacentHTML('beforeend', `
<li class="user-item" id="pending-${f.friendshipId}">
<a href="/community/benutzer.html?userId=${f.user.userId}" class="user-profile-link">
<div class="user-avatar">${avatar(f.user)}</div>
<div class="user-name">${esc(f.user.name)}</div>
</a>
<div class="user-actions">
<button onclick="accept('${f.friendshipId}', this)">✓ Annehmen</button>
<button class="btn-reject" onclick="reject('${f.friendshipId}', this)">✕ Ablehnen</button>
</div>
</li>`);
});
} catch (e) { console.error(e); }
}
async function accept(friendshipId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/social/friends/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId })
});
if (res.ok) {
document.getElementById('pending-' + friendshipId)?.remove();
await loadFriends();
await loadPending();
} else {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
} catch (e) {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
}
async function reject(friendshipId, btn) {
btn.disabled = true;
try {
await fetch('/social/friends/reject', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId })
});
document.getElementById('pending-' + friendshipId)?.remove();
await loadPending();
} catch (e) {
btn.disabled = false;
}
}
let pendingRemoveFriendshipId = null;
let pendingRemoveBtn = null;
function removeFriend(friendshipId, btn) {
pendingRemoveFriendshipId = friendshipId;
pendingRemoveBtn = btn;
document.getElementById('removeDialogText').textContent =
'Möchtest du ' + (btn.closest('.user-item').querySelector('.user-name')?.textContent || 'diese Person') + ' wirklich aus deiner Freundesliste entfernen?';
document.getElementById('removeDialog').classList.add('visible');
}
function closeRemoveDialog() {
document.getElementById('removeDialog').classList.remove('visible');
pendingRemoveFriendshipId = null;
pendingRemoveBtn = null;
}
async function confirmRemove() {
if (!pendingRemoveFriendshipId) return;
const btn = document.getElementById('removeConfirmBtn');
btn.disabled = true;
btn.textContent = '…';
try {
await fetch('/social/friends/reject', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId: pendingRemoveFriendshipId })
});
document.getElementById('friend-' + pendingRemoveFriendshipId)?.remove();
const list = document.getElementById('friendsList');
if (list.children.length === 0) {
document.getElementById('friendsEmpty').style.display = '';
}
closeRemoveDialog();
} catch (e) {
if (pendingRemoveBtn) pendingRemoveBtn.disabled = false;
} finally {
btn.disabled = false;
btn.textContent = 'Entfernen';
}
}
document.getElementById('removeDialog').addEventListener('click', e => {
if (e.target === document.getElementById('removeDialog')) closeRemoveDialog();
});
loadFriends();
loadPending();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,411 @@
<!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>Gruppen xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.tabs { display:flex; gap:0; margin-bottom:1.5rem; border-bottom:1px solid var(--color-secondary); }
.tab-btn { background:none; border:none; border-bottom:3px solid transparent; border-radius:0; padding:0.6rem 1.25rem; font-size:0.95rem; font-weight:600; color:var(--color-muted); cursor:pointer; margin-bottom:-1px; transition:color 0.15s,border-color 0.15s; }
.tab-btn:hover { color:var(--color-text); background:none; }
.tab-btn.active { color:var(--color-primary); border-bottom-color:var(--color-primary); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
.gruppe-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:1rem; margin-top:0.5rem; }
.gruppe-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
.gruppe-card-img { width:100%; height:110px; object-fit:cover; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:2.5rem; }
.gruppe-card-img img { width:100%; height:100%; object-fit:cover; }
.gruppe-card-body { padding:0.85rem; flex:1; display:flex; flex-direction:column; gap:0.4rem; }
.gruppe-card-name { font-weight:700; font-size:1rem; }
.gruppe-card-meta { font-size:0.78rem; color:var(--color-muted); }
.gruppe-card-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:auto; padding-top:0.5rem; }
.gruppe-card-actions button, .gruppe-card-actions a.btn { margin-top:0; padding:0.3rem 0.7rem; font-size:0.8rem; width:auto; }
.role-badge { font-size:0.7rem; font-weight:700; padding:0.1rem 0.4rem; border-radius:4px; background:var(--color-primary); color:#fff; display:inline-block; }
.role-badge.mitglied { background:var(--color-secondary); color:var(--color-text); }
.search-row { display:flex; gap:0.75rem; margin-bottom:1rem; }
.search-row input { flex:1; }
.search-row button { white-space:nowrap; width:auto; margin-top:0; }
.anfrage-list { list-style:none; margin:0; padding:0; }
.anfrage-item { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:0.75rem 0; border-bottom:1px solid var(--color-secondary); }
.anfrage-item:last-child { border-bottom:none; }
.anfrage-name { font-weight:600; }
.anfrage-status { font-size:0.8rem; color:var(--color-muted); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
/* Dialog */
.dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; }
.dialog-backdrop.visible { display:flex; }
.dialog { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:12px; padding:1.75rem; width:100%; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,0.6); max-height:90vh; overflow-y:auto; }
.dialog h3 { color:var(--color-primary); font-size:1.1rem; margin-bottom:1.25rem; }
.dialog label { display:block; font-size:0.8rem; color:#aaa; margin-bottom:0.3rem; margin-top:1rem; }
.dialog input, .dialog textarea { width:100%; box-sizing:border-box; }
.dialog textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:80px; box-sizing:border-box; }
.dialog textarea:focus { border-color:var(--color-primary); }
.dialog-actions { display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.5rem; }
.dialog-actions button { flex:none; margin:0; padding:0.55rem 1.1rem; font-size:0.9rem; width:auto; }
.toggle-row { display:flex; align-items:center; gap:0.75rem; margin-top:0.75rem; }
.toggle-row label { margin:0; font-size:0.9rem; color:var(--color-text); }
.img-preview { width:100%; max-height:140px; object-fit:cover; border-radius:6px; margin-top:0.5rem; display:none; }
.card-notif { font-size:0.75rem; font-weight:700; color:#fff; background:#e67e22; border-radius:4px; padding:0.15rem 0.45rem; display:none; width:fit-content; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
<h1 style="margin:0;">Gruppen</h1>
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="mine" onclick="switchTab('mine', this)">Meine Gruppen</button>
<button class="tab-btn" data-tab="discover" onclick="switchTab('discover', this)">Entdecken</button>
<button class="tab-btn" data-tab="requests" onclick="switchTab('requests', this)">Meine Anfragen</button>
</div>
<!-- Meine Gruppen -->
<div class="tab-panel active" id="tab-mine">
<div class="gruppe-grid" id="mineGrid"></div>
<p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p>
</div>
<!-- Entdecken -->
<div class="tab-panel" id="tab-discover">
<div class="search-row">
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" onkeydown="if(event.key==='Enter')doSearch()">
<button onclick="doSearch()">Suchen</button>
</div>
<div class="gruppe-grid" id="discoverGrid"></div>
<p class="empty-hint" id="discoverHint">Gib einen Suchbegriff ein.</p>
</div>
<!-- Meine Anfragen -->
<div class="tab-panel" id="tab-requests">
<ul class="anfrage-list" id="requestsList"></ul>
<p class="empty-hint" id="requestsEmpty" style="display:none;">Keine ausstehenden Anfragen.</p>
</div>
</div>
</div>
<!-- Gruppe erstellen Dialog -->
<div class="dialog-backdrop" id="createDialog">
<div class="dialog">
<h3>Gruppe erstellen</h3>
<label>Name *</label>
<input type="text" id="createName" maxlength="100" placeholder="Gruppenname">
<label>Beschreibung</label>
<textarea id="createDesc" maxlength="1000" placeholder="Worum geht es in dieser Gruppe?"></textarea>
<label>Bild (optional)</label>
<input type="file" id="createBildFile" accept="image/*" onchange="previewBild(this,'createBildPreview','createBildData')">
<img id="createBildPreview" class="img-preview" alt="">
<input type="hidden" id="createBildData">
<div class="toggle-row">
<input type="checkbox" id="createPrivate">
<label for="createPrivate">Private Gruppe (Beitritt nur per Anfrage)</label>
</div>
<p class="message error" id="createError" style="display:none; margin-top:0.75rem;"></p>
<div class="dialog-actions">
<button class="secondary" onclick="closeCreateDialog()">Abbrechen</button>
<button onclick="createGruppe()">Erstellen</button>
</div>
</div>
</div>
<!-- Beitrittsanfrage Dialog -->
<div class="dialog-backdrop" id="joinDialog">
<div class="dialog">
<h3>Beitrittsanfrage senden</h3>
<p id="joinDialogGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
<label>Nachricht (optional)</label>
<textarea id="joinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
<p class="message error" id="joinError" style="display:none; margin-top:0.75rem;"></p>
<div class="dialog-actions">
<button class="secondary" onclick="closeJoinDialog()">Abbrechen</button>
<button onclick="sendJoinRequest()">Anfrage senden</button>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
localStorage.setItem('tab_gruppen', name);
if (name === 'mine') loadMine();
if (name === 'requests') loadRequests();
}
const _savedGruppenTab = localStorage.getItem('tab_gruppen');
if (_savedGruppenTab) {
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedGruppenTab}"]`);
if (_btn) switchTab(_savedGruppenTab, _btn);
}
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
function gruppeCard(g, showJoin = false) {
const img = g.bild
? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></div>`
: `<div class="gruppe-card-img">👥</div>`;
const roleBadge = g.myRole
? `<span class="role-badge ${g.myRole === 'ADMIN' ? '' : 'mitglied'}">${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}</span>`
: '';
const privBadge = g.isPrivate ? ' 🔒' : '';
let actions = '';
if (showJoin && !g.myRole) {
if (g.myRequestStatus === 'AUSSTEHEND') {
actions = `<button disabled style="opacity:0.6;" onclick="event.stopPropagation()">Anfrage ausstehend</button>`;
} else if (g.isPrivate) {
actions = `<button onclick="event.stopPropagation(); openJoinDialog('${g.gruppeId}','${esc(g.name)}')">Anfrage senden</button>`;
} else {
actions = `<button onclick="event.stopPropagation(); joinGruppe('${g.gruppeId}', this)">Beitreten</button>`;
}
}
return `
<div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/community/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;">
${img}
<div class="gruppe-card-body">
<div class="gruppe-card-name">${esc(g.name)}${privBadge} ${roleBadge}</div>
<div class="gruppe-card-meta">${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge</div>
${g.beschreibung ? `<div style="font-size:0.82rem;color:var(--color-muted);">${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}</div>` : ''}
<div class="card-notif" id="notif-${g.gruppeId}"></div>
${actions ? `<div class="gruppe-card-actions">${actions}</div>` : ''}
</div>
</div>`;
}
async function loadMine() {
try {
const res = await fetch('/gruppen/mine');
if (!res.ok) return;
const data = await res.json();
const grid = document.getElementById('mineGrid');
grid.innerHTML = '';
if (data.length === 0) { document.getElementById('mineEmpty').style.display = ''; return; }
document.getElementById('mineEmpty').style.display = 'none';
data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g)));
loadAdminBadges(data);
} catch(e) { console.error(e); }
}
async function loadAdminBadges(groups) {
const adminGroups = groups.filter(g => g.myRole === 'ADMIN');
await Promise.all(adminGroups.map(async g => {
const [reqRes, repRes] = await Promise.all([
fetch('/gruppen/' + g.gruppeId + '/requests'),
fetch('/gruppen/' + g.gruppeId + '/reports')
]);
const reqs = reqRes.ok ? await reqRes.json() : [];
const reps = repRes.ok ? await repRes.json() : [];
const total = reqs.length + reps.length;
if (total === 0) return;
const el = document.getElementById('notif-' + g.gruppeId);
if (!el) return;
const parts = [];
if (reqs.length > 0) parts.push(reqs.length + ' Anfrage' + (reqs.length !== 1 ? 'n' : ''));
if (reps.length > 0) parts.push(reps.length + ' Meldung' + (reps.length !== 1 ? 'en' : ''));
el.textContent = '🔔 ' + parts.join(' · ');
el.style.display = '';
}));
}
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
if (!q) return;
try {
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
if (!res.ok) return;
const data = await res.json();
const grid = document.getElementById('discoverGrid');
const hint = document.getElementById('discoverHint');
grid.innerHTML = '';
if (data.length === 0) { hint.textContent = 'Keine Gruppen gefunden.'; hint.style.display = ''; return; }
hint.style.display = 'none';
data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g, true)));
} catch(e) { console.error(e); }
}
async function joinGruppe(gruppeId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/gruppen/' + gruppeId + '/join', { method: 'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
if (res.ok || res.status === 201) {
btn.textContent = 'Beigetreten ✓';
setTimeout(loadMine, 500);
} else {
btn.disabled = false;
btn.textContent = 'Beitreten';
}
} catch(e) { btn.disabled = false; btn.textContent = 'Beitreten'; }
}
async function loadRequests() {
try {
const reqRes = await fetch('/gruppen/requests/mine');
if (!reqRes.ok) { document.getElementById('requestsEmpty').style.display = ''; return; }
const data = await reqRes.json();
const list = document.getElementById('requestsList');
list.innerHTML = '';
if (data.length === 0) { document.getElementById('requestsEmpty').style.display = ''; return; }
document.getElementById('requestsEmpty').style.display = 'none';
data.forEach(r => {
list.insertAdjacentHTML('beforeend', `
<li class="anfrage-item" id="req-${r.anfrageId}">
<div>
<div class="anfrage-name">${esc(r.gruppeName)}</div>
<div class="anfrage-status">Ausstehend seit ${fmtDate(r.angefragtAt)}</div>
${r.nachricht ? `<div style="font-size:0.82rem;color:var(--color-muted);margin-top:0.2rem;">"${esc(r.nachricht)}"</div>` : ''}
</div>
<button class="secondary" onclick="withdrawRequest('${r.gruppeId}', '${r.anfrageId}', this)">Zurückziehen</button>
</li>`);
});
} catch(e) { console.error(e); }
}
async function withdrawRequest(gruppeId, anfrageId, btn) {
btn.disabled = true;
try {
await fetch('/gruppen/' + gruppeId + '/requests/mine', { method: 'DELETE' });
document.getElementById('req-' + anfrageId)?.remove();
if (document.getElementById('requestsList').children.length === 0)
document.getElementById('requestsEmpty').style.display = '';
} catch(e) { btn.disabled = false; }
}
function fmtDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' });
}
// ── Create dialog ──
function openCreateDialog() {
document.getElementById('createName').value = '';
document.getElementById('createDesc').value = '';
document.getElementById('createPrivate').checked = false;
document.getElementById('createBildData').value = '';
document.getElementById('createBildPreview').style.display = 'none';
document.getElementById('createBildFile').value = '';
document.getElementById('createDialog').classList.add('visible');
}
function closeCreateDialog() { document.getElementById('createDialog').classList.remove('visible'); document.getElementById('createError').style.display = 'none'; }
function showCreateError(text) {
const el = document.getElementById('createError');
el.textContent = text;
el.style.display = 'block';
}
async function createGruppe() {
const name = document.getElementById('createName').value.trim();
if (!name) { showCreateError('Bitte einen Namen eingeben.'); return; }
document.getElementById('createError').style.display = 'none';
const body = {
name,
beschreibung: document.getElementById('createDesc').value.trim() || null,
bild: document.getElementById('createBildData').value || null,
isPrivate: document.getElementById('createPrivate').checked
};
try {
const res = await fetch('/gruppen', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
if (res.ok || res.status === 201) {
const g = await res.json();
closeCreateDialog();
window.location.href = '/community/gruppe.html?gruppeId=' + g.gruppeId;
} else {
showCreateError('Fehler beim Erstellen der Gruppe.');
}
} catch(e) { showCreateError('Fehler: ' + e.message); }
}
// ── Join dialog ──
let pendingJoinGruppeId = null;
function openJoinDialog(gruppeId, name) {
pendingJoinGruppeId = gruppeId;
document.getElementById('joinDialogGroupName').textContent = name;
document.getElementById('joinNachricht').value = '';
document.getElementById('joinDialog').classList.add('visible');
}
function closeJoinDialog() { document.getElementById('joinDialog').classList.remove('visible'); pendingJoinGruppeId = null; }
async function sendJoinRequest() {
if (!pendingJoinGruppeId) return;
document.getElementById('joinError').style.display = 'none';
const nachricht = document.getElementById('joinNachricht').value.trim() || null;
try {
const res = await fetch('/gruppen/' + pendingJoinGruppeId + '/join', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ nachricht })
});
if (res.ok || res.status === 201) {
closeJoinDialog();
doSearch();
} else {
const el = document.getElementById('joinError');
el.textContent = 'Fehler beim Senden der Anfrage.';
el.style.display = 'block';
}
} catch(e) {
const el = document.getElementById('joinError');
el.textContent = 'Fehler: ' + e.message;
el.style.display = 'block';
}
}
// ── Image preview ──
function previewBild(input, previewId, dataId) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const original = new Image();
original.onload = () => {
const MAX = 256;
let w = original.width, h = original.height;
if (w > MAX || h > MAX) {
if (w > h) { h = Math.round(h * MAX / w); w = MAX; }
else { w = Math.round(w * MAX / h); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(original, 0, 0, w, h);
const scaled = canvas.toDataURL('image/jpeg', 0.85);
const img = document.getElementById(previewId);
img.src = scaled;
img.style.display = 'block';
document.getElementById(dataId).value = scaled.split(',')[1];
};
original.src = e.target.result;
};
reader.readAsDataURL(file);
}
document.getElementById('createDialog').addEventListener('click', e => {
if (e.target === document.getElementById('createDialog')) closeCreateDialog();
});
document.getElementById('joinDialog').addEventListener('click', e => {
if (e.target === document.getElementById('joinDialog')) closeJoinDialog();
});
loadMine();
</script>
</body>
</html>

View File

@@ -0,0 +1,671 @@
<!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>Nachrichten xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* Override .main and .content for full-height chat layout */
.main { overflow: hidden; }
.msg-layout {
display: flex;
height: calc(100vh - 3rem);
overflow: hidden;
}
/* Left: conversation list */
.conv-list-pane {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--color-secondary);
display: flex;
flex-direction: column;
overflow: hidden;
}
.conv-list-header {
padding: 1.25rem 1rem 0.75rem;
font-weight: 700;
font-size: 1rem;
border-bottom: 1px solid var(--color-secondary);
flex-shrink: 0;
}
.conv-list {
flex: 1;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 0.25rem 0;
}
.conv-item {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.7rem 1rem;
cursor: pointer;
border-left: 3px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.conv-item:hover { background: var(--color-secondary); }
.conv-item.active { background: var(--color-secondary); border-left-color: var(--color-primary); }
.conv-avatar {
width: 38px; height: 38px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.1);
}
.conv-avatar img { width: 100%; height: 100%; object-fit: cover; }
.conv-info { flex: 1; min-width: 0; }
.conv-name { font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.conv-preview { font-size: 0.78rem; color: var(--color-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.conv-unread {
background: var(--color-primary);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.1rem 0.35rem;
flex-shrink: 0;
}
/* Right: thread */
.thread-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.thread-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-secondary);
font-weight: 700;
font-size: 1rem;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.thread-back {
display: none;
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
font-size: 1.1rem;
padding: 0;
margin: 0;
}
.thread-messages {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.thread-placeholder {
color: var(--color-muted);
text-align: center;
margin-top: 3rem;
font-size: 0.95rem;
}
.bubble-wrap {
display: flex;
flex-direction: column;
}
.bubble-wrap.me { align-items: flex-end; }
.bubble-wrap.them { align-items: flex-start; }
.bubble {
max-width: 70%;
padding: 0.5rem 0.9rem;
border-radius: 14px;
font-size: 0.9rem;
line-height: 1.45;
word-break: break-word;
}
.bubble-wrap.me .bubble {
background: var(--color-primary);
color: #fff;
border-bottom-right-radius: 4px;
}
.bubble-wrap.them .bubble {
background: var(--color-secondary);
color: var(--color-text);
border-bottom-left-radius: 4px;
}
.bubble-time {
font-size: 0.7rem;
color: var(--color-muted);
margin-top: 0.15rem;
padding: 0 0.25rem;
}
.thread-input-wrap {
flex-shrink: 0;
position: relative;
}
.thread-input-area {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.65rem 1rem;
border-top: 1px solid var(--color-secondary);
}
.thread-input-area input {
flex: 1;
}
.btn-icon {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 0.3rem 0.4rem;
width: auto;
margin-top: 0;
flex-shrink: 0;
opacity: 0.65;
border-radius: 6px;
transition: opacity 0.15s, background 0.15s;
line-height: 1;
}
.btn-icon:hover { opacity: 1; background: var(--color-secondary); }
.btn-send {
width: auto;
margin-top: 0;
padding: 0.55rem 1rem;
flex-shrink: 0;
}
.emoji-picker {
position: absolute;
bottom: 100%;
left: 0; right: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px 10px 0 0;
padding: 0.5rem;
display: none;
flex-wrap: wrap;
gap: 2px;
max-height: 160px;
overflow-y: auto;
box-shadow: 0 -4px 16px rgba(0,0,0,0.4);
z-index: 50;
}
.emoji-picker.open { display: flex; }
.emoji-picker button {
background: none;
border: none;
font-size: 1.3rem;
cursor: pointer;
padding: 0.2rem 0.3rem;
border-radius: 4px;
width: auto;
margin-top: 0;
line-height: 1;
}
.emoji-picker button:hover { background: var(--color-secondary); }
.bubble-img {
max-width: 220px;
max-height: 220px;
border-radius: 8px;
display: block;
cursor: zoom-in;
object-fit: contain;
}
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 500;
align-items: center;
justify-content: center;
}
.lightbox.open { display: flex; }
.lightbox img {
max-width: 90vw;
max-height: 90vh;
border-radius: 6px;
object-fit: contain;
box-shadow: 0 8px 40px rgba(0,0,0,0.7);
}
.lightbox-close {
position: fixed !important;
top: 1rem !important;
right: 1rem !important;
background: rgba(0,0,0,0.55) !important;
border: 1px solid rgba(255,255,255,0.2) !important;
color: #fff !important;
font-size: 1.1rem !important;
width: 2.2rem !important;
height: 2.2rem !important;
border-radius: 50% !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
margin: 0 !important;
line-height: 1 !important;
transition: background 0.15s !important;
z-index: 501;
}
.lightbox-close:hover { background: rgba(0,0,0,0.85) !important; }
/* Mobile */
@media (max-width: 768px) {
.msg-layout { height: auto; flex-direction: column; }
.conv-list-pane { width: 100%; border-right: none; border-bottom: 1px solid var(--color-secondary); max-height: 240px; }
.conv-list-pane.hidden { display: none; }
.thread-pane.hidden { display: none; }
.thread-back { display: block; }
}
</style>
</head>
<body class="app">
<div class="main">
<div class="msg-layout">
<!-- Left pane: conversation list -->
<div class="conv-list-pane" id="convListPane">
<div class="conv-list-header">Nachrichten</div>
<ul class="conv-list" id="convList">
<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Wird geladen…</li>
</ul>
</div>
<!-- Right pane: thread -->
<div class="thread-pane" id="threadPane">
<div class="thread-header" id="threadHeader">
<button class="thread-back" id="backBtn" onclick="showList()" aria-label="Zurück"></button>
<div class="conv-avatar" id="threadPartnerAvatar" style="display:none;"></div>
<span id="threadPartnerName">Konversation auswählen</span>
</div>
<div class="thread-messages" id="threadMessages">
<div class="thread-placeholder" id="threadPlaceholder">Wähle eine Konversation aus oder schreibe jemanden direkt an.</div>
</div>
<div class="thread-input-wrap" id="threadInputWrap" style="display:none;">
<div class="emoji-picker" id="emojiPicker"></div>
<div class="thread-input-area">
<button class="btn-icon" id="emojiBtn" onclick="toggleEmoji()" title="Emoji">😊</button>
<input type="text" id="msgInput" placeholder="Nachricht eingeben…" autocomplete="off">
<input type="file" id="imgFile" accept="image/*" style="display:none;">
<button class="btn-icon" onclick="document.getElementById('imgFile').click()" title="Bild senden">📷</button>
<button class="btn-send" onclick="sendMsg()"></button>
</div>
</div>
</div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<img id="lightboxImg" src="" alt="" onclick="event.stopPropagation()">
<button class="lightbox-close" onclick="closeLightbox()" aria-label="Schließen"></button>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
const SUPPORT_USER_ID = 'dbf1e35a-e331-3211-9889-d0d21f386028';
let myId = null;
let activePartnerId = null;
let pollTimer = null;
// Pagination state
let oldestSentAt = null; // ISO string of oldest visible message
let newestSentAt = null; // ISO string of newest visible message
let hasMoreOlder = false;
let isLoadingOlder = false;
// Load current user
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
if (!user) return;
myId = user.userId;
loadConversations();
const urlPartnerId = new URLSearchParams(window.location.search).get('userId');
if (urlPartnerId) openThread(urlPartnerId);
})
.catch(() => {});
async function loadConversations() {
try {
const res = await fetch('/social/messages');
if (!res.ok) return;
const convs = await res.json();
renderConvList(convs);
} catch (e) { console.error(e); }
}
function renderConvList(convs) {
const list = document.getElementById('convList');
list.innerHTML = '';
if (convs.length === 0) {
list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></li>';
return;
}
convs.forEach(c => {
const av = c.partner.profilePicture
? `<img src="data:image/png;base64,${c.partner.profilePicture}" alt="" style="cursor:zoom-in;" onclick="event.stopPropagation();openLightbox(this.src)">`
: '◉';
const unreadHtml = c.unreadCount > 0
? `<span class="conv-unread">${c.unreadCount}</span>`
: '';
const preview = c.lastMessage
? (c.lastMessage.text.startsWith('data:image/') ? '📷 Bild' : esc(c.lastMessage.text.substring(0, 40)))
: '';
const li = document.createElement('li');
li.className = 'conv-item' + (c.partner.userId === activePartnerId ? ' active' : '');
li.dataset.partnerId = c.partner.userId;
li.innerHTML = `
<div class="conv-avatar">${av}</div>
<div class="conv-info">
<div class="conv-name">${esc(c.partner.name)}</div>
<div class="conv-preview">${preview}</div>
</div>
${unreadHtml}`;
li.addEventListener('click', () => openThread(c.partner.userId, c.partner.name, c.partner.profilePicture));
list.appendChild(li);
});
}
async function openThread(partnerId, partnerName, partnerPic) {
activePartnerId = partnerId;
oldestSentAt = null;
newestSentAt = null;
hasMoreOlder = false;
isLoadingOlder = false;
document.querySelectorAll('.conv-item').forEach(li => {
li.classList.toggle('active', li.dataset.partnerId === partnerId);
});
if (!partnerName) {
const convItem = document.querySelector(`.conv-item[data-partner-id="${partnerId}"]`);
partnerName = convItem ? convItem.querySelector('.conv-name').textContent : '…';
}
document.getElementById('threadPartnerName').innerHTML =
`<a href="/community/benutzer.html?userId=${partnerId}" style="color:inherit;text-decoration:none;">${esc(partnerName)}</a>`;
const avatarEl = document.getElementById('threadPartnerAvatar');
if (partnerPic) {
avatarEl.innerHTML = `<img src="data:image/png;base64,${partnerPic}" alt="" style="cursor:zoom-in;" onclick="openLightbox(this.src)">`;
avatarEl.style.display = '';
} else {
avatarEl.style.display = 'none';
}
const isSupport = partnerId === SUPPORT_USER_ID;
document.getElementById('threadInputWrap').style.display = isSupport ? 'none' : '';
if (!isSupport) document.getElementById('msgInput').focus();
if (window.innerWidth <= 768) showThread();
// Clear and load latest messages
const container = document.getElementById('threadMessages');
container.innerHTML = '';
await loadInitialThread();
startPolling();
}
async function loadInitialThread() {
if (!activePartnerId) return;
try {
const res = await fetch('/social/messages/' + activePartnerId);
if (!res.ok) return;
const { messages, hasMore } = await res.json();
const container = document.getElementById('threadMessages');
container.innerHTML = '';
if (messages.length === 0) {
container.appendChild(Object.assign(document.createElement('div'), {
className: 'thread-placeholder',
textContent: 'Noch keine Nachrichten. Schreib als Erster!'
}));
oldestSentAt = null;
newestSentAt = null;
hasMoreOlder = false;
} else {
// messages are oldest-first from backend
messages.forEach(m => container.appendChild(buildBubble(m)));
oldestSentAt = messages[0].sentAt;
newestSentAt = messages[messages.length - 1].sentAt;
hasMoreOlder = hasMore;
}
// Zweifaches rAF stellt sicher, dass der Browser das Layout vollständig berechnet hat
// bevor gescrollt wird (wichtig bei Bild-Nachrichten)
requestAnimationFrame(() => requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
}));
loadConversations();
} catch (e) { console.error(e); }
}
async function loadOlderMessages() {
if (!activePartnerId || !hasMoreOlder || isLoadingOlder || !oldestSentAt) return;
isLoadingOlder = true;
try {
const res = await fetch('/social/messages/' + activePartnerId + '?before=' + encodeURIComponent(oldestSentAt));
if (!res.ok) return;
const { messages, hasMore } = await res.json();
if (messages.length === 0) { hasMoreOlder = false; return; }
const container = document.getElementById('threadMessages');
const prevHeight = container.scrollHeight;
// Prepend older messages (oldest-first order)
const frag = document.createDocumentFragment();
messages.forEach(m => frag.appendChild(buildBubble(m)));
container.prepend(frag);
// Restore scroll position
container.scrollTop = container.scrollHeight - prevHeight;
oldestSentAt = messages[0].sentAt;
hasMoreOlder = hasMore;
} catch (e) { console.error(e); }
finally { isLoadingOlder = false; }
}
async function pollNewMessages() {
if (!activePartnerId || !newestSentAt) return;
try {
const res = await fetch('/social/messages/' + activePartnerId + '?after=' + encodeURIComponent(newestSentAt));
if (!res.ok) return;
const { messages } = await res.json();
if (messages.length === 0) return;
const container = document.getElementById('threadMessages');
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 60;
// Remove placeholder if present
const ph = container.querySelector('.thread-placeholder');
if (ph) ph.remove();
messages.forEach(m => container.appendChild(buildBubble(m)));
newestSentAt = messages[messages.length - 1].sentAt;
if (atBottom) container.scrollTop = container.scrollHeight;
loadConversations();
} catch (e) { console.error(e); }
}
function buildBubble(m) {
const isMe = m.senderId === myId;
const time = new Date(m.sentAt).toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
const wrap = document.createElement('div');
wrap.className = 'bubble-wrap ' + (isMe ? 'me' : 'them');
wrap.dataset.sentAt = m.sentAt;
const isImg = m.text.startsWith('data:image/');
const content = isImg
? `<img src="${m.text}" class="bubble-img" onclick="openLightbox(this.src)" alt="Bild">`
: linkify(m.text);
wrap.innerHTML = `
<div class="bubble">${content}</div>
<div class="bubble-time">${time}</div>`;
return wrap;
}
// Scroll-up → load older messages
document.getElementById('threadMessages').addEventListener('scroll', function () {
if (this.scrollTop < 80 && hasMoreOlder && !isLoadingOlder) {
loadOlderMessages();
}
});
async function sendMsg() {
if (!activePartnerId) return;
const input = document.getElementById('msgInput');
const text = input.value.trim();
if (!text) return;
input.value = '';
try {
await fetch('/social/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId: activePartnerId, text })
});
await pollNewMessages();
} catch (e) { console.error(e); }
}
document.getElementById('msgInput').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMsg(); }
});
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollNewMessages, 10000);
}
function showThread() {
document.getElementById('convListPane').classList.add('hidden');
document.getElementById('threadPane').classList.remove('hidden');
}
function showList() {
document.getElementById('convListPane').classList.remove('hidden');
document.getElementById('threadPane').classList.add('hidden');
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
activePartnerId = null;
}
function openLightbox(src) {
document.getElementById('lightboxImg').src = src;
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeLightbox();
});
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function linkify(text) {
// HTML-escapen, Zeilenumbrüche als <br>, URLs als klickbare Links
const escaped = esc(text)
.replace(/\n/g, '<br>');
return escaped.replace(
/(https?:\/\/[^\s<>"]+)/g,
url => `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:underline;word-break:break-all;">${url}</a>`
);
}
// ── Emoji-Picker ──
const EMOJIS = [
'😀','😂','🤣','😅','😊','😍','🥰','😘','😎','🤩',
'😉','🙂','😐','😏','😒','😢','😭','😤','😡','🤬',
'🤔','🥺','😳','🤭','😈','💀','🙈','🙉','🙊','😴',
'👍','👎','👏','🙏','💪','🫶','🤗','🫠','✌️','🤞',
'❤️','🧡','💛','💚','💙','💜','🖤','💕','💖','💘',
'🔥','✨','⚡','🌟','💯','🎉','🎊','🎀','🌹','🌸',
'🍕','🍔','🍟','🌮','🍜','🍣','🍩','🍪','☕','🍷',
'🐱','🐶','🦊','🐼','🐨','🐸','🦄','🐝','🦋','🐬',
];
(function initEmojis() {
const picker = document.getElementById('emojiPicker');
EMOJIS.forEach(emoji => {
const btn = document.createElement('button');
btn.textContent = emoji;
btn.title = emoji;
btn.onclick = () => {
const input = document.getElementById('msgInput');
const pos = input.selectionStart ?? input.value.length;
const val = input.value;
input.value = val.slice(0, pos) + emoji + val.slice(pos);
input.selectionStart = input.selectionEnd = pos + [...emoji].length;
input.focus();
};
picker.appendChild(btn);
});
})();
function toggleEmoji() {
document.getElementById('emojiPicker').classList.toggle('open');
}
document.addEventListener('click', e => {
if (!e.target.closest('#emojiPicker') && !e.target.closest('#emojiBtn')) {
document.getElementById('emojiPicker').classList.remove('open');
}
});
// ── Bild-Upload ──
(function attachImgHandler() {
document.getElementById('imgFile').addEventListener('change', async function () {
const file = this.files[0];
if (!file || !activePartnerId) return;
const fresh = this.cloneNode(false);
this.replaceWith(fresh);
attachImgHandler();
try {
const dataUrl = await resizeImage(file);
await fetch('/social/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId: activePartnerId, text: dataUrl })
});
await pollNewMessages();
} catch (e) { console.error(e); }
});
})();
function resizeImage(file) {
const MAX = 800;
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > MAX || h > MAX) {
if (w >= h) { h = Math.max(1, Math.round(MAX * h / w)); w = MAX; }
else { w = Math.max(1, Math.round(MAX * w / h)); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', 0.72));
};
img.onerror = reject;
img.src = url;
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,206 @@
<!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>Personen suchen xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.search-bar input { flex: 1; }
.search-bar button { width: auto; margin-top: 0; padding: 0.65rem 1.25rem; }
.user-list { list-style: none; margin: 0; padding: 0; }
.user-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-secondary);
}
.user-item:last-child { border-bottom: none; }
.user-avatar {
width: 42px; height: 42px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid var(--color-secondary);
}
.user-avatar img { width: 100%; height: 100%; object-fit: cover; }
.user-name { font-weight: 600; flex: 1; }
.user-actions { display: flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.user-actions button, .user-actions a.btn {
margin-top: 0;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
width: auto;
}
.user-profile-link {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
text-decoration: none;
color: inherit;
}
.user-profile-link:hover .user-name { color: var(--color-primary); }
.hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.5rem; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom: 1.25rem;">Personen suchen</h1>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Name eingeben (mind. 2 Zeichen)…" autocomplete="off">
<button onclick="doSearch()">Suchen</button>
</div>
<ul class="user-list" id="resultList"></ul>
<p class="hint" id="hint">Gib mindestens 2 Zeichen ein, um zu suchen.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
let debounceTimer;
document.getElementById('searchInput').addEventListener('input', function () {
clearTimeout(debounceTimer);
const q = this.value.trim();
if (q.length < 2) {
document.getElementById('resultList').innerHTML = '';
document.getElementById('hint').textContent = 'Gib mindestens 2 Zeichen ein, um zu suchen.';
document.getElementById('hint').style.display = '';
return;
}
document.getElementById('hint').style.display = 'none';
debounceTimer = setTimeout(doSearch, 400);
});
document.getElementById('searchInput').addEventListener('keydown', e => {
if (e.key === 'Enter') { clearTimeout(debounceTimer); doSearch(); }
});
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
if (q.length < 2) return;
try {
const res = await fetch('/social/users/search?q=' + encodeURIComponent(q));
if (!res.ok) return;
renderResults(await res.json());
} catch (e) { console.error(e); }
}
function renderResults(users) {
const list = document.getElementById('resultList');
const hint = document.getElementById('hint');
list.innerHTML = '';
if (users.length === 0) {
hint.textContent = 'Keine Ergebnisse gefunden.';
hint.style.display = '';
return;
}
hint.style.display = 'none';
users.forEach(u => {
const avatar = u.profilePicture
? `<img src="data:image/png;base64,${u.profilePicture}" alt="">`
: '◉';
list.insertAdjacentHTML('beforeend', `
<li class="user-item" data-user-id="${u.userId}">
<a href="/community/benutzer.html?userId=${u.userId}" class="user-profile-link">
<div class="user-avatar">${avatar}</div>
<div class="user-name">${esc(u.name)}</div>
</a>
<div class="user-actions">${buildActions(u)}</div>
</li>`);
});
}
function buildActions(u) {
if (u.friendStatus === 'FRIEND') {
return `<a href="/community/nachrichten.html?userId=${u.userId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>`;
}
if (u.friendStatus === 'PENDING_SENT') {
return `<button disabled>Anfrage gesendet</button>`;
}
if (u.friendStatus === 'PENDING_RECEIVED') {
return `<button onclick="acceptByUserId('${u.userId}', this)">✓ Annehmen</button>`;
}
return `<button onclick="sendRequest('${u.userId}', this)">+ Freund hinzufügen</button>`;
}
async function sendRequest(receiverId, btn) {
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
try {
const res = await fetch('/social/friends/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId })
});
btn.textContent = (res.ok || res.status === 201 || res.status === 409) ? 'Anfrage gesendet' : 'Fehler';
} catch (e) {
btn.disabled = false;
btn.textContent = '+ Freund hinzufügen';
}
}
async function acceptByUserId(senderId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const pendingRes = await fetch('/social/friends/pending');
const pending = await pendingRes.json();
const f = pending.find(p => p.user.userId === senderId);
if (!f) { btn.textContent = 'Fehler'; return; }
const res = await fetch('/social/friends/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId: f.friendshipId })
});
if (res.ok) {
btn.textContent = '✓ Freund';
const item = btn.closest('.user-item');
if (item) {
item.querySelector('.user-actions').innerHTML =
`<a href="/community/nachrichten.html?userId=${senderId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>`;
}
} else {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
} catch (e) {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,967 @@
/* ── Reset ── */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Base ── */
body {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--color-bg);
font-family: 'Segoe UI', sans-serif;
color: var(--color-text);
gap: 2rem;
}
h1 {
color: var(--color-primary);
}
p {
color: var(--color-muted);
font-size: 1rem;
}
/* ── Card ── */
.card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 380px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
gap: 0;
}
.card h1 {
text-align: center;
font-size: 1.6rem;
margin-bottom: 0.25rem;
}
.subtitle {
text-align: center;
font-size: 0.85rem;
color: var(--color-muted);
margin-bottom: 2rem;
}
/* ── Form elements ── */
label {
display: block;
font-size: 0.8rem;
color: #aaa;
margin-bottom: 0.3rem;
margin-top: 1rem;
}
input {
width: 100%;
padding: 0.65rem 0.9rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
color: var(--color-text);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
input:focus {
border-color: var(--color-primary);
}
/* ── Buttons ── */
button, .btn {
display: inline-block;
padding: 0.75rem 2.5rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: background 0.2s;
}
button:hover:not(:disabled), .btn:hover {
background: #c73652;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.full-width {
width: 100%;
margin-top: 1.75rem;
padding: 0.75rem;
}
button.secondary {
background: var(--color-secondary);
font-weight: normal;
padding: 0.3rem 0.7rem;
font-size: 0.75rem;
width: auto;
margin-top: 0.5rem;
}
button.secondary:hover {
background: #1a4a8a;
}
/* ── Messages ── */
.message {
margin-top: 1.25rem;
padding: 0.65rem 0.9rem;
border-radius: 6px;
font-size: 0.85rem;
display: none;
word-break: break-all;
}
.message.error {
background: #3d0f1a;
border: 2px solid var(--color-primary);
color: var(--color-primary);
}
.message.warning {
background: #3a2c0a;
border: 2px solid #f5c518;
color: #f5c518;
}
.message.success {
background: #0f3d1a;
border: 1px solid var(--color-success);
color: var(--color-success);
}
/* ── App layout ── */
body.app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--color-bg);
padding: 1.5rem 1.5rem 0;
gap: 0;
}
.app-wrapper {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
gap: 1.5rem;
align-items: stretch;
padding-bottom: 1.5rem;
width: 100%;
max-width: calc(240px + 1.5rem + 93.75rem);
margin-left: auto;
margin-right: auto;
}
.main {
flex: 1;
min-width: 0;
overflow-y: auto;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.content { padding: 2rem 1.5rem; flex: 1; }
/* ── Sidebar ── */
.sidebar-wrapper {
width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-self: stretch;
gap: 0.75rem;
z-index: 10;
transition: transform 0.25s ease;
}
.sidebar {
flex: 1;
min-height: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-scroll-area {
flex: 1;
overflow-y: auto;
}
.sidebar-logo-area {
padding: 1rem 1rem 0.5rem;
flex-shrink: 0;
}
.sidebar-logo-area a { display: block; line-height: 0; }
.sidebar-logo-area img {
width: 100%;
height: auto;
object-fit: contain;
display: block;
}
.sidebar-desktop-profile { flex-shrink: 0; padding: 0.25rem 0; }
.sidebar-desktop-profile a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 1.25rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.9rem;
font-weight: 600;
border-left: 3px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.sidebar-desktop-profile a:hover {
background: var(--color-secondary);
color: var(--color-primary);
border-left-color: var(--color-primary);
}
.sidebar-desktop-profile .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; }
.social-sidebar-logo-area {
padding: 1rem 1rem 0.5rem;
flex-shrink: 0;
}
.social-sidebar-logo-area img {
width: 100%;
height: auto;
object-fit: contain;
display: block;
}
.sidebar-mobile-only { display: none; }
.sidebar ul { list-style: none; padding: 0.5rem 0; }
.sidebar ul li a,
.sidebar-footer ul li a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1.25rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.95rem;
border-left: 3px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.sidebar ul li a:hover,
.sidebar ul li a.active,
.sidebar-footer ul li a:hover,
.sidebar-footer ul li a.active {
background: var(--color-secondary);
color: var(--color-primary);
border-left-color: var(--color-primary);
}
.sidebar ul li a .icon,
.sidebar-footer ul li a .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; }
.sidebar-profile-img {
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--color-secondary);
}
/* ── Burger (mobile only) ── */
.burger {
display: none;
position: fixed;
top: 0.75rem; right: 0.75rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 6px;
cursor: pointer;
color: var(--color-text);
padding: 0.35rem 0.5rem;
z-index: 110;
transition: background 0.15s;
}
.burger:hover { background: var(--color-secondary); }
.burger-icon { display: flex; flex-direction: column; gap: 5px; width: 22px; }
.burger-icon span {
display: block;
height: 2px;
background: var(--color-text);
border-radius: 2px;
transition: transform 0.25s, opacity 0.25s;
}
.burger.open .burger-icon span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.burger.open .burger-icon span:nth-child(2) { opacity: 0; }
.burger.open .burger-icon span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
/* ── Sidebar overlay ── */
.sidebar-overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 90;
}
.sidebar-overlay.visible { display: block; }
/* ── Mobile ── */
@media (max-width: 768px) {
body.app {
height: auto;
min-height: 100vh;
overflow: visible;
padding: 0;
}
.app-wrapper {
flex-direction: column;
gap: 0;
padding-bottom: 0;
overflow: visible;
}
.sidebar-wrapper {
position: fixed;
top: 0; right: 0;
width: 240px;
height: 100vh;
gap: 0;
background: var(--color-bg);
border-left: 1px solid var(--color-secondary);
transform: translateX(100%);
align-self: auto;
z-index: 100;
padding: 0;
overflow-y: auto;
}
.sidebar-wrapper.open { transform: translateX(0); box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5); }
.sidebar {
flex: none;
border-radius: 0;
border: none;
box-shadow: none;
border-bottom: 1px solid var(--color-secondary);
}
.sidebar-footer {
border-radius: 0;
box-shadow: none;
border: none;
border-top: 1px solid var(--color-secondary);
}
.sidebar-logo-area { display: none; }
.sidebar-desktop-profile { display: none; }
.main {
border-radius: 0;
box-shadow: none;
border: none;
min-height: 100vh;
overflow-y: visible;
}
.burger { display: flex; }
.sidebar-mobile-only { display: block; }
}
/* ── Social Sidebar ── */
.social-sidebar {
width: 260px;
flex-shrink: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-self: flex-start;
position: sticky;
top: 1.5rem;
max-height: calc(100vh - 3rem);
overflow-y: auto;
}
.social-sidebar ul { list-style: none; padding: 0.5rem 0; display: flex; flex-direction: column; flex: 1; }
.social-sidebar ul li a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1.25rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.95rem;
border-left: 3px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.social-sidebar ul li a:hover,
.social-sidebar ul li a.active {
background: var(--color-secondary);
color: var(--color-primary);
border-left-color: var(--color-primary);
}
.social-sidebar ul li a .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; }
.social-sidebar-title {
padding: 1rem 1.25rem 0.25rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-muted);
text-transform: uppercase;
flex-shrink: 0;
}
.social-badge {
margin-left: auto;
background: var(--color-primary);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.1rem 0.4rem;
line-height: 1.4;
flex-shrink: 0;
}
@media (max-width: 768px) {
.social-sidebar {
position: static;
width: 100%;
max-height: none;
border-radius: 0;
border: none;
border-top: 1px solid var(--color-secondary);
box-shadow: none;
align-self: auto;
}
}
/* ── Token box ── */
.token-box {
margin-top: 1.25rem;
padding: 0.65rem 0.9rem;
background: #0f1e3d;
border: 1px solid var(--color-secondary);
border-radius: 6px;
font-size: 0.75rem;
color: #aaa;
word-break: break-all;
display: none;
}
.token-box span {
display: block;
font-size: 0.7rem;
color: #666;
margin-bottom: 0.4rem;
}
/* ── Sidebar groups ── */
.sidebar-footer {
flex-shrink: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
padding: 0.5rem 0;
}
.sidebar-footer ul {
list-style: none;
padding: 0;
}
.sidebar-group-toggle {
cursor: pointer;
justify-content: space-between;
}
.sidebar-arrow {
margin-left: auto;
font-size: 0.7rem;
flex-shrink: 0;
transition: transform 0.2s;
}
.sidebar-group.open > a .sidebar-arrow {
transform: rotate(90deg);
}
.sidebar-sub {
list-style: none;
padding: 0;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.sidebar-group.open > .sidebar-sub {
max-height: 500px;
}
.sidebar .sidebar-sub li a {
padding: 0.55rem 1.25rem 0.55rem 2.5rem;
font-size: 0.9rem;
color: var(--color-muted);
}
/* ═══════════════════════════════════════════
TOP BAR
═══════════════════════════════════════════ */
.topbar {
flex-shrink: 0;
width: 100%;
max-width: calc(240px + 1.5rem + 93.75rem);
margin: 0 auto 1rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 1rem;
}
/* Linker Bereich Banner, gleiche Breite wie Sidebar */
.topbar-left {
width: 240px;
flex-shrink: 0;
align-self: stretch;
margin: -0.55rem 0 -0.55rem -1rem;
overflow: hidden;
border-radius: 11px 0 0 11px;
display: flex;
align-items: center;
padding: 5px 0 0 5px;
justify-content: center;
}
.topbar-left a {
display: flex;
align-items: center;
height: 100%;
}
.topbar-banner {
height: 3.5rem;
width: auto;
display: block;
}
/* ── Suche ── */
.topbar-search-wrap {
flex: 1;
max-width: 520px;
margin: 0 auto;
position: relative;
}
.topbar-search-wrap input {
background: var(--color-secondary);
border: 1px solid transparent;
border-radius: 8px;
padding: 0.46rem 0.9rem 0.46rem 2.2rem;
width: 100%;
font-size: 0.9rem;
transition: border-color 0.2s;
}
.topbar-search-wrap input:focus {
border-color: var(--color-primary);
}
.topbar-search-icon {
position: absolute;
left: 0.7rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-muted);
font-size: 0.85rem;
pointer-events: none;
line-height: 1;
}
.topbar-search-overlay {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
z-index: 600;
max-height: 360px;
overflow-y: auto;
display: none;
}
.topbar-search-overlay.open { display: block; }
.topbar-search-hint {
padding: 0.75rem 1rem;
color: var(--color-muted);
font-size: 0.88rem;
}
.topbar-search-result {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
text-decoration: none;
color: var(--color-text);
transition: background 0.15s;
border-bottom: 1px solid var(--color-secondary);
}
.topbar-search-result:last-child { border-bottom: none; }
.topbar-search-result:hover { background: var(--color-secondary); }
.topbar-search-avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.topbar-search-avatar--placeholder {
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
/* ── Rechter Bereich ── */
.topbar-right {
display: flex;
align-items: center;
gap: 0.2rem;
flex-shrink: 0;
margin-left: auto;
}
.topbar-btn {
position: relative;
background: none;
border: none;
padding: 0.4rem 0.55rem;
border-radius: 8px;
cursor: pointer;
font-size: 1.25rem;
color: var(--color-text);
display: flex;
align-items: center;
gap: 0.4rem;
transition: background 0.15s;
width: auto;
font-weight: normal;
line-height: 1;
}
.topbar-btn:hover { background: var(--color-secondary); }
.topbar-badge {
position: absolute;
top: 1px;
right: 1px;
background: var(--color-primary);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.05rem 0.3rem;
min-width: 1rem;
text-align: center;
line-height: 1.6;
display: none;
pointer-events: none;
}
.topbar-avatar {
width: 1.9rem;
height: 1.9rem;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--color-secondary);
display: block;
}
.topbar-avatar-placeholder {
font-size: 1.2rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.topbar-username {
font-size: 0.88rem;
font-weight: 600;
max-width: 130px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar-profile-btn { gap: 0.5rem; padding: 0.3rem 0.55rem; }
/* ═══════════════════════════════════════════
TOPBAR PANELS (Overlays)
═══════════════════════════════════════════ */
.topbar-panel {
position: fixed;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.65);
z-index: 550;
width: 360px;
max-height: 500px;
display: none;
flex-direction: column;
overflow: hidden;
}
.topbar-panel.open { display: flex; }
.topbar-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.7rem 1rem;
font-weight: 700;
font-size: 0.92rem;
border-bottom: 1px solid var(--color-secondary);
flex-shrink: 0;
background: var(--color-card);
}
.topbar-panel-close {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
width: auto;
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.topbar-panel-close:hover { background: var(--color-secondary); color: var(--color-text); }
.topbar-panel-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.topbar-panel-footer {
border-top: 1px solid var(--color-secondary);
padding: 0.55rem 1rem;
flex-shrink: 0;
text-align: center;
background: var(--color-card);
}
.topbar-panel-footer a {
color: var(--color-primary);
font-size: 0.85rem;
text-decoration: none;
}
.topbar-panel-footer a:hover { text-decoration: underline; }
.topbar-panel-hint {
padding: 0.9rem 1rem;
color: var(--color-muted);
font-size: 0.88rem;
}
/* Einzel-Eintrag in Panel */
.topbar-panel-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--color-secondary);
text-decoration: none;
color: var(--color-text);
transition: background 0.15s;
cursor: pointer;
}
.topbar-panel-item:last-child { border-bottom: none; }
.topbar-panel-item:hover { background: var(--color-secondary); }
.topbar-item-avatar {
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.topbar-item-avatar--placeholder {
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
}
.topbar-panel-item-body {
flex: 1;
min-width: 0;
}
.topbar-panel-item-sub {
font-size: 0.75rem;
color: var(--color-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.1rem;
}
.topbar-item-badge {
background: var(--color-primary);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.1rem 0.4rem;
flex-shrink: 0;
}
/* Benachrichtigungen */
.topbar-notif-item--unread { background: rgba(var(--color-primary-rgb, 231,57,84), 0.07); border-left: 3px solid var(--color-primary); }
.topbar-notif-item--unread:hover { background: rgba(var(--color-primary-rgb, 231,57,84), 0.12); }
.topbar-notif-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
margin-top: 0.25rem;
align-self: flex-start;
}
.topbar-mark-all-btn {
background: none;
border: none;
color: var(--color-primary);
font-size: 0.8rem;
cursor: pointer;
width: auto;
padding: 0.2rem 0.4rem;
border-radius: 4px;
transition: background 0.15s;
}
.topbar-mark-all-btn:hover { background: var(--color-secondary); }
/* Einladungen */
.topbar-inv-card { align-items: center; }
.topbar-inv-btn {
padding: 0.3rem 0.6rem;
font-size: 0.78rem;
border-radius: 6px;
border: none;
cursor: pointer;
width: auto;
margin: 0;
text-decoration: none;
display: inline-block;
font-weight: 600;
line-height: 1.5;
transition: opacity 0.15s;
}
.topbar-inv-btn:hover { opacity: 0.85; }
.topbar-inv-btn--decline { background: #c0392b; color: #fff; }
.topbar-inv-btn--accept { background: var(--color-success, #27ae60); color: #fff; }
/* Profil-Panel */
.topbar-profile-body { display: flex; flex-direction: column; }
.topbar-profile-card {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 1rem 1rem 0.75rem;
}
.topbar-profile-nav {
display: flex;
flex-direction: column;
padding: 0.4rem 0;
}
.topbar-profile-link {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.6rem 1rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.9rem;
transition: background 0.15s;
}
.topbar-profile-link:hover { background: var(--color-secondary); }
.topbar-profile-link--danger { color: var(--color-primary); }
/* ── Mobile: Topbar ausblenden ── */
@media (max-width: 768px) {
.topbar { display: none; }
}

View File

@@ -0,0 +1,113 @@
<!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>Passwort vergessen xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100;
align-items: center;
justify-content: center;
}
.overlay.active {
display: flex;
}
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
max-width: 340px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
text-align: center;
}
.modal p {
color: var(--color-text);
font-size: 0.95rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="card">
<img src="icon.png" alt="Logo">
<h1>Passwort vergessen</h1>
<p class="subtitle">Gib deine E-Mail-Adresse ein. Falls sie bei uns registriert ist, erhältst du einen Link zum Zurücksetzen.</p>
<label for="email">E-Mail</label>
<input type="email" id="email" placeholder="deine@email.de" autocomplete="email" />
<button class="full-width" id="submitBtn" onclick="submit()">Link anfordern</button>
<div class="message" id="message"></div>
<p style="text-align:center; margin-top:1.25rem; font-size:0.85rem;">
<a href="/login.html" style="color:var(--color-primary);">Zurück zum Login</a>
</p>
</div>
<div class="overlay" id="overlay">
<div class="modal">
<p>Falls diese E-Mail-Adresse bei uns registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen deines Passworts.</p>
<button class="full-width" onclick="goToLogin()">Zum Login</button>
</div>
</div>
<script>
document.addEventListener('keydown', e => {
if (e.key === 'Enter') submit();
});
async function submit() {
const email = document.getElementById('email').value.trim();
const btn = document.getElementById('submitBtn');
if (!email) {
showMessage('Bitte E-Mail-Adresse eingeben.', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
hideMessage();
try {
await fetch('/password-reset/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
} catch (err) {
console.error(err);
}
document.getElementById('overlay').classList.add('active');
}
function goToLogin() {
window.location.href = '/login.html';
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,128 @@
<!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>BDSM Game 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: 14px;
padding: 2rem;
text-align: center;
width: 100%;
}
.invite-icon { font-size: 2.5rem; margin-bottom: 1rem; }
.invite-title { font-size: 1.2rem; font-weight: 700; margin-bottom: 0.5rem; }
.invite-sub { font-size: 0.9rem; color: var(--color-muted); margin-bottom: 2rem; line-height: 1.6; }
.invite-actions { display: flex; flex-direction: column; gap: 0.75rem; }
.invite-actions button { width: 100%; padding: 0.85rem; }
.decline-btn {
background: transparent;
border: none;
color: var(--color-muted);
font-size: 0.82rem;
cursor: pointer;
text-decoration: underline;
padding: 0.25rem;
margin-top: 0.5rem;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div id="loading" style="text-align:center;color:var(--color-muted);padding:3rem 0;">Einladung wird geladen…</div>
<div class="invite-card" id="card" style="display:none;">
<div class="invite-icon">⛓️</div>
<div class="invite-title" id="title"></div>
<div class="invite-sub" id="sub"></div>
<div class="message" id="message" style="display:none;margin-bottom:1rem;"></div>
<div class="invite-actions" id="actions"></div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
const params = new URLSearchParams(location.search);
const einladungId = params.get('id');
if (!einladungId) window.location.replace('/userhome.html');
let einladung = null;
async function laden() {
try {
const res = await fetch(`/bdsm/einladung/${einladungId}`);
if (!res.ok) { zeigeFehler('Einladung nicht gefunden.'); return; }
einladung = await res.json();
document.getElementById('loading').style.display = 'none';
document.getElementById('card').style.display = '';
if (einladung.status === 'ACCEPTED_OWN' || einladung.status === 'ACCEPTED_HOST') {
zeigeBestaetigt();
return;
}
if (einladung.status === 'DECLINED' || einladung.status === 'CANCELLED') {
zeigeFehler('Diese Einladung ist nicht mehr gültig.');
return;
}
document.getElementById('title').textContent = `${einladung.inviterName || 'Jemand'} lädt dich ein`;
document.getElementById('sub').textContent = 'Du wurdest zu einem BDSM Game eingeladen. Wie möchtest du mitspielen?';
const actions = document.getElementById('actions');
actions.innerHTML = `
<button onclick="antworten(true, 'OWN_DEVICE')">Am eigenen Gerät mitspielen</button>
<button class="secondary" onclick="antworten(true, 'HOST_DEVICE')">Am Gerät von ${einladung.inviterName || 'der einladenden Person'}</button>
<button class="decline-btn" onclick="antworten(false, null)">Einladung ablehnen</button>`;
} catch (e) {
zeigeFehler('Fehler beim Laden der Einladung.');
}
}
async function antworten(accepted, mode) {
document.getElementById('actions').innerHTML = '<div style="color:var(--color-muted);font-size:0.9rem;">Wird gespeichert…</div>';
try {
const res = await fetch(`/bdsm/einladung/${einladungId}/antwort`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted, mode }),
});
if (!res.ok) throw new Error();
if (!accepted) {
document.getElementById('title').textContent = 'Einladung abgelehnt';
document.getElementById('sub').textContent = 'Du hast die Einladung abgelehnt.';
document.getElementById('actions').innerHTML = '<button onclick="window.location.href=\'/userhome.html\'">Zur Startseite</button>';
} else if (mode === 'OWN_DEVICE') {
window.location.replace(`/games/bdsm/neubdsm.html`);
} else {
zeigeBestaetigt();
}
} catch (_) {
document.getElementById('actions').innerHTML = '';
zeigeFehler('Fehler beim Speichern der Antwort.');
}
}
function zeigeBestaetigt() {
document.getElementById('title').textContent = 'Einladung angenommen';
document.getElementById('sub').textContent = 'Du spielst am Gerät der einladenden Person mit. Das Spiel wird dort von ihr gestartet.';
document.getElementById('actions').innerHTML = '<button onclick="window.location.href=\'/userhome.html\'">Zur Startseite</button>';
}
function zeigeFehler(text) {
document.getElementById('loading').style.display = 'none';
document.getElementById('card').style.display = '';
document.getElementById('title').textContent = 'Hinweis';
document.getElementById('sub').textContent = text;
document.getElementById('actions').innerHTML = '<button onclick="window.location.href=\'/userhome.html\'">Zur Startseite</button>';
}
laden();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/games/bdsm/neubdsm.html">
<title>BDSM Game xXx Sphere</title>
</head>
<body>
<script>window.location.replace('/games/bdsm/neubdsm.html');</script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BDSM 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>BDSM Game</h1>
<p>Informationen zum BDSM Game folgen hier.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chastity Game Info xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="app">
<div class="main">
<div class="content">
<h1>Chastity Game</h1>
<p>Informationen zum Chastity Game folgen hier.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,982 @@
<!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>Einladungen xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* Tabs */
.tabs-bar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-secondary);
margin-bottom: 1.5rem;
}
.tab-btn {
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
padding: 0.6rem 1.25rem;
font-size: 0.92rem;
font-weight: 600;
color: var(--color-muted);
cursor: pointer;
width: auto;
border-radius: 0;
transition: color 0.15s, border-color 0.15s;
}
.tab-btn:hover { color: var(--color-text); background: none; }
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Liste */
.inv-list { display: flex; flex-direction: column; gap: 0.5rem; }
.inv-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
display: flex; align-items: center; gap: 0.9rem;
padding: 0.75rem 1rem;
}
/* Avatar mit Typ-Badge */
.inv-avatar-wrap {
position: relative;
flex-shrink: 0;
}
.inv-avatar {
width: 52px; height: 52px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; overflow: hidden;
border: 1px solid rgba(255,255,255,0.08);
}
.inv-avatar img { width: 100%; height: 100%; object-fit: cover; }
.inv-type-badge {
position: absolute;
top: -6px; left: -6px;
width: 26px; height: 26px;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 1.08rem;
z-index: 1;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.inv-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
.inv-line1 { font-size: 0.78rem; color: var(--color-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inv-line2 { font-weight: 700; font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inv-line3 { font-size: 0.78rem; color: var(--color-muted); }
.empty-hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.25rem; }
/* Paging */
.paging-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 1rem;
font-size: 0.88rem;
color: var(--color-muted);
}
.paging-bar button {
width: auto;
padding: 0.4rem 0.9rem;
font-size: 0.85rem;
}
.paging-bar button:disabled {
opacity: 0.35;
cursor: default;
}
/* Lockee-Einladungs-Dialog */
.lockee-dialog-bg {
display: none; position: fixed; inset: 0; z-index: 400;
align-items: center; justify-content: center;
}
.lockee-dialog-bg.open { display: flex; }
.lockee-dialog-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.55); }
.lockee-dialog-box {
position: relative; background: var(--color-card);
border: 1px solid var(--color-secondary); border-radius: 12px;
padding: 1.75rem 1.5rem 1.5rem; max-width: 420px; width: 92%; z-index: 1;
display: flex; flex-direction: column; gap: 1rem;
max-height: 90vh; overflow-y: auto;
}
.lockee-dialog-header { display: flex; align-items: center; gap: 0.75rem; }
.lockee-dialog-avatar {
width: 48px; height: 48px; border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem; flex-shrink: 0; overflow: hidden;
border: 1px solid rgba(255,255,255,0.08);
}
.lockee-dialog-avatar img { width: 100%; height: 100%; object-fit: cover; }
.lockee-dialog-title { font-weight: 700; font-size: 1rem; }
.lockee-dialog-sub { font-size: 0.82rem; color: var(--color-muted); margin-top: 0.1rem; }
.lockee-dialog-detail {
background: var(--color-secondary); border-radius: 8px;
padding: 0.75rem 1rem; font-size: 0.88rem;
}
.lockee-dialog-detail dt { color: var(--color-muted); font-size: 0.75rem; margin-bottom: 0.1rem; }
.lockee-dialog-detail dd { font-weight: 600; margin: 0 0 0.5rem 0; }
.lockee-dialog-detail dd:last-child { margin-bottom: 0; }
.lockee-dialog-codelines { display: flex; align-items: center; gap: 0.6rem; }
.lockee-dialog-codelines label { font-size: 0.88rem; font-weight: 600; white-space: nowrap; }
.lockee-dialog-codelines input { width: 72px; text-align: center; }
.lockee-dialog-codelines span { font-size: 0.88rem; color: var(--color-muted); }
.lockee-dialog-actions { display: flex; gap: 0.6rem; justify-content: flex-end; }
.lockee-dialog-actions button { width: auto; padding: 0.6rem 1.3rem; font-size: 0.9rem; }
.btn-accept { background: var(--color-success, #27ae60) !important; }
.btn-accept:hover { background: #219150 !important; }
.btn-decline { background: #c0392b !important; }
.btn-decline:hover { background: #a93226 !important; }
.lockee-dialog-error { color: #e74c3c; font-size: 0.82rem; display: none; }
/* Lock-Details im Dialog */
.lock-details-section { display: flex; flex-direction: column; gap: 0.5rem; }
.lock-details-cards {
display: grid; grid-template-columns: repeat(auto-fill, minmax(68px, 1fr)); gap: 0.4rem;
}
.lock-details-card-item {
background: var(--color-secondary); border-radius: 6px;
padding: 0.4rem 0.3rem;
display: flex; flex-direction: column; align-items: center; gap: 0.2rem; text-align: center;
}
.lock-details-card-item img { width: 36px; height: auto; border-radius: 3px; }
.lock-details-card-item .ldc-count { font-weight: 700; font-size: 0.9rem; }
.lock-details-card-item .ldc-name { font-size: 0.65rem; color: var(--color-muted); line-height: 1.2; }
.lock-details-meta { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.lock-details-badge {
background: var(--color-secondary); border-radius: 20px;
padding: 0.2rem 0.6rem; font-size: 0.75rem; color: var(--color-muted);
}
.blind-hint {
background: var(--color-secondary); border-radius: 8px; padding: 0.9rem 1rem;
display: flex; gap: 0.6rem; align-items: flex-start;
font-size: 0.85rem; color: var(--color-muted); line-height: 1.5;
}
.blind-hint-icon { font-size: 1.4rem; flex-shrink: 0; }
/* Bestätigungs-Modal */
.confirm-modal-bg {
display: none; position: fixed; inset: 0; z-index: 600;
align-items: center; justify-content: center;
}
.confirm-modal-bg.open { display: flex; }
.confirm-modal-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); }
.confirm-modal-box {
position: relative; background: var(--color-card);
border: 1px solid var(--color-secondary); border-radius: 12px;
padding: 1.75rem 1.5rem 1.5rem; max-width: 380px; width: 92%; z-index: 1;
display: flex; flex-direction: column; gap: 1rem;
}
.confirm-modal-title {
font-weight: 700; font-size: 1rem; padding-right: 1.5rem;
}
.confirm-modal-text {
font-size: 0.9rem; color: var(--color-muted); line-height: 1.5;
}
.confirm-modal-actions {
display: flex; gap: 0.6rem; justify-content: flex-end; margin-top: 0.25rem;
}
.confirm-modal-actions button { width: auto; padding: 0.6rem 1.3rem; font-size: 0.9rem; }
.confirm-modal-cancel { background: var(--color-secondary) !important; color: var(--color-text) !important; }
.confirm-modal-ok { background: #c0392b !important; }
.confirm-modal-ok:hover { background: #a93226 !important; }
/* Entsperrcode-Modal */
.unlock-modal-bg {
display: none; position: fixed; inset: 0; z-index: 500;
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.6); }
.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%; z-index: 1;
display: flex; flex-direction: column; align-items: center; gap: 0.75rem; 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;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom:1.25rem;">Einladungen</h1>
<div class="tabs-bar">
<button class="tab-btn active" data-tab="empfangen" onclick="switchTab('empfangen')">Empfangen</button>
<button class="tab-btn" data-tab="gesendet" onclick="switchTab('gesendet')">Gesendet</button>
</div>
<!-- Tab: Empfangen -->
<div id="tab-empfangen" class="tab-panel active">
<div class="inv-list" id="recvList"></div>
<p class="empty-hint" id="recvEmpty" style="display:none;">Keine ausstehenden Einladungen.</p>
<div class="paging-bar" id="recvPaging" style="display:none;"></div>
</div>
<!-- Tab: Gesendet -->
<div id="tab-gesendet" class="tab-panel">
<div class="inv-list" id="sentList"></div>
<p class="empty-hint" id="sentEmpty" style="display:none;">Keine ausstehenden gesendeten Einladungen.</p>
<div class="paging-bar" id="sentPaging" style="display:none;"></div>
</div>
</div>
</div>
<!-- Bestätigungs-Modal -->
<div class="confirm-modal-bg" id="confirmModal">
<div class="confirm-modal-overlay" onclick="confirmCancel()"></div>
<div class="confirm-modal-box">
<button onclick="confirmCancel()" 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;" title="Schließen"></button>
<div class="confirm-modal-title" id="confirmTitle"></div>
<div class="confirm-modal-text" id="confirmText"></div>
<div class="confirm-modal-actions">
<button class="confirm-modal-cancel" onclick="confirmCancel()">Abbrechen</button>
<button class="confirm-modal-ok" id="confirmOkBtn">Bestätigen</button>
</div>
</div>
</div>
<!-- Vanilla-Einladungs-Dialog -->
<div class="lockee-dialog-bg" id="vanillaInviteDialog">
<div class="lockee-dialog-overlay" onclick="closeVanillaInviteDialog()"></div>
<div class="lockee-dialog-box">
<button onclick="closeVanillaInviteDialog()" 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;" title="Schließen"></button>
<div class="lockee-dialog-header">
<div class="lockee-dialog-avatar">🎲</div>
<div>
<div class="lockee-dialog-title" id="vanillaDialogTitle"></div>
<div class="lockee-dialog-sub">Vanilla Game Einladung</div>
</div>
</div>
<p style="font-size:0.88rem;color:var(--color-muted);line-height:1.5;margin:0;">
Du wurdest zu einem Vanilla Game eingeladen. Wie möchtest du mitspielen?
</p>
<div class="lockee-dialog-error" id="vanillaDialogError"></div>
<div class="lockee-dialog-actions" style="flex-direction:column;gap:0.5rem;">
<button class="btn-accept" style="width:100%;" onclick="acceptVanillaOwnDevice()">Am eigenen Gerät mitspielen</button>
<button class="btn-accept" style="width:100%;background:#1a5c8a!important;" onclick="acceptVanillaHostDevice()">Am Gerät des Hosts mitspielen</button>
<button class="btn-decline" style="width:100%;" onclick="declineVanillaFromDialog()">Einladung ablehnen</button>
</div>
</div>
</div>
<!-- BDSM-Einladungs-Dialog -->
<div class="lockee-dialog-bg" id="bdsmInviteDialog">
<div class="lockee-dialog-overlay" onclick="closeBdsmInviteDialog()"></div>
<div class="lockee-dialog-box">
<button onclick="closeBdsmInviteDialog()" 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;" title="Schließen"></button>
<div class="lockee-dialog-header">
<div class="lockee-dialog-avatar" id="bdsmDialogAvatar">⛓️</div>
<div>
<div class="lockee-dialog-title" id="bdsmDialogTitle"></div>
<div class="lockee-dialog-sub">BDSM Game Einladung</div>
</div>
</div>
<p style="font-size:0.88rem;color:var(--color-muted);line-height:1.5;margin:0;">
Du wurdest zu einem BDSM Game eingeladen. Wie möchtest du mitspielen?
</p>
<div class="lockee-dialog-error" id="bdsmDialogError"></div>
<div class="lockee-dialog-actions" style="flex-direction:column;gap:0.5rem;">
<button class="btn-accept" style="width:100%;" onclick="acceptBdsmOwnDevice()">Am eigenen Gerät mitspielen</button>
<button class="btn-accept" style="width:100%;background:#1a5c8a!important;" onclick="acceptBdsmHostDevice()">Am Gerät des Hosts mitspielen</button>
<button class="btn-decline" style="width:100%;" onclick="declineBdsmFromDialog()">Einladung ablehnen</button>
</div>
</div>
</div>
<!-- Lockee-Einladungs-Dialog -->
<div class="lockee-dialog-bg" id="lockeeInviteDialog">
<div class="lockee-dialog-overlay" onclick="closeLockeeInviteDialog()"></div>
<div class="lockee-dialog-box">
<button onclick="closeLockeeInviteDialog()" 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;" title="Schließen"></button>
<div class="lockee-dialog-header">
<div class="lockee-dialog-avatar" id="dialogAvatar">🔒</div>
<div>
<div class="lockee-dialog-title" id="dialogTitle"></div>
<div class="lockee-dialog-sub" id="dialogSub"></div>
</div>
</div>
<dl class="lockee-dialog-detail" id="dialogDetail"></dl>
<div id="dialogDetailsArea"></div>
<div>
<div class="lockee-dialog-codelines">
<label for="dialogCodeLines">Ziffern des Entsperrcodes:</label>
<input type="number" id="dialogCodeLines" min="1" max="20" value="5">
<span>Ziffern</span>
</div>
</div>
<div class="lockee-dialog-error" id="dialogError"></div>
<div class="lockee-dialog-actions">
<button class="btn-decline" onclick="declineLockeeInviteDialog()">✕ Ablehnen</button>
<button class="btn-accept" onclick="acceptLockeeInviteDialog()">✓ Annehmen</button>
</div>
</div>
</div>
<!-- Entsperrcode-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/card-defs.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
// ── Tabs ──
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-' + name));
history.replaceState(null, '', '?tab=' + name);
}
const urlTab = new URLSearchParams(window.location.search).get('tab');
if (urlTab === 'gesendet') switchTab('gesendet');
// ── Konstanten ──
const PAGE_SIZE = 10;
// ── State ──
let recvItems = [];
let sentItems = [];
let recvPage = 0;
let sentPage = 0;
// ── Hilfsfunktionen ──
function fmtDate(iso) {
const dt = new Date(iso);
return dt.toLocaleDateString('de-DE') + ', ' + dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr';
}
function buildAvatarHtml(picBase64, type) {
const badge = type === 'keyholder' ? '🔑' : type === 'bdsm' ? '⛓️' : type === 'vanilla' ? '🎲' : '🔒';
const inner = picBase64
? `<div class="inv-avatar"><img src="data:image/jpeg;base64,${picBase64}" alt=""></div>`
: `<div class="inv-avatar">👤</div>`;
return `<div class="inv-avatar-wrap"><span class="inv-type-badge">${badge}</span>${inner}</div>`;
}
function renderPaging(barId, page, total, onNav) {
const bar = document.getElementById(barId);
if (total <= 1) { bar.style.display = 'none'; return; }
bar.style.display = 'flex';
bar.innerHTML = `
<button onclick="${onNav}(${page - 1})" ${page === 0 ? 'disabled' : ''}> Zurück</button>
<span>Seite ${page + 1} von ${total}</span>
<button onclick="${onNav}(${page + 1})" ${page >= total - 1 ? 'disabled' : ''}>Weiter </button>`;
}
// ── Empfangen laden ──
async function loadReceivedInvitations() {
try {
const [lockeeRes, khRes, bdsmRes, vanillaRes] = await Promise.all([
fetch('/lockee/invitations/mine'),
fetch('/keyholder/invitations/mine'),
fetch('/bdsm/einladung/pending'),
fetch('/vanilla/einladung/pending'),
]);
const lockeeInvs = lockeeRes.ok ? await lockeeRes.json() : [];
const khInvs = khRes.ok ? await khRes.json() : [];
const bdsmInvs = bdsmRes.ok ? await bdsmRes.json() : [];
const vanillaInvs = vanillaRes.ok ? await vanillaRes.json() : [];
lockeeInvs.forEach(inv => { inv._type = 'lockee'; inv._key = inv.token; inv._otherName = inv.keyholderName; inv._otherPic = inv.keyholderProfilePic; });
khInvs.forEach(inv => { inv._type = 'keyholder'; inv._key = inv.token; inv._otherName = inv.lockeeName; inv._otherPic = inv.lockeeProfilePic; });
bdsmInvs.forEach(inv => { inv._type = 'bdsm'; inv._key = inv.einladungId; inv._otherName = inv.inviterName; inv._otherPic = inv.inviterAvatar; });
vanillaInvs.forEach(inv => { inv._type = 'vanilla'; inv._key = inv.einladungId; inv._otherName = inv.inviterName; inv._otherPic = inv.inviterAvatar || ''; });
recvItems = [...lockeeInvs, ...khInvs, ...bdsmInvs, ...vanillaInvs].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
recvPage = 0;
renderRecvPage();
} catch(e) { console.error(e); }
}
function renderRecvPage() {
const list = document.getElementById('recvList');
const empty = document.getElementById('recvEmpty');
list.innerHTML = '';
if (recvItems.length === 0) {
empty.style.display = '';
document.getElementById('recvPaging').style.display = 'none';
return;
}
empty.style.display = 'none';
const totalPages = Math.ceil(recvItems.length / PAGE_SIZE);
const start = recvPage * PAGE_SIZE;
const pageItems = recvItems.slice(start, start + PAGE_SIZE);
pageItems.forEach(inv => {
const av = buildAvatarHtml(inv._otherPic, inv._type);
const card = document.createElement('div');
card.className = 'inv-card';
card.id = 'recvinv-' + inv._key;
if (inv._type === 'lockee') card.dataset.detailsVisible = inv.detailsVisible ? '1' : '0';
let typeLabel, line2, actions;
if (inv._type === 'lockee') {
typeLabel = 'Lockee-Einladung';
line2 = 'Lockee: ' + esc(inv.lockName);
actions = `
<div style="display:flex;flex-direction:column;gap:0.4rem;flex-shrink:0;">
<button onclick="declineLockeeInvitation('${esc(inv.token)}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Ablehnen</button>
<button onclick="openLockeeInviteDialog('${esc(inv.token)}')" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:var(--color-success,#27ae60);border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✓ Details</button>
</div>`;
} else if (inv._type === 'keyholder') {
typeLabel = 'Keyholder-Einladung';
line2 = 'Keyholder: ' + esc(inv.lockName);
actions = `
<div style="display:flex;flex-direction:column;gap:0.4rem;flex-shrink:0;">
<button onclick="declineKhInvitation('${esc(inv.token)}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Ablehnen</button>
<a href="/keyholder/invitation/${esc(inv.token)}" style="display:block;text-align:center;padding:0.45rem 1rem;font-size:0.85rem;background:var(--color-success);color:#fff;border-radius:6px;text-decoration:none;font-weight:600;">✓ Annehmen</a>
</div>`;
} else if (inv._type === 'vanilla') {
typeLabel = 'Vanilla Game';
line2 = 'Spieleinladung';
actions = `
<div style="display:flex;flex-direction:column;gap:0.4rem;flex-shrink:0;">
<button onclick="openVanillaInviteDialog('${esc(inv.einladungId)}', '${esc(inv._otherName)}')" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:var(--color-success,#27ae60);border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">🎲 Details</button>
</div>`;
} else {
typeLabel = 'BDSM Game';
line2 = 'Spieleinladung';
actions = `
<div style="display:flex;flex-direction:column;gap:0.4rem;flex-shrink:0;">
<button onclick="openBdsmInviteDialog('${esc(inv.einladungId)}', '${esc(inv._otherName)}', '${esc(inv._otherPic || '')}')" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:var(--color-success,#27ae60);border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">⛓️ Details</button>
</div>`;
}
card.innerHTML = `
${av}
<div class="inv-body">
<div class="inv-line1">${esc(inv._otherName)}</div>
<div class="inv-line2">${line2}</div>
<div class="inv-line3">${typeLabel} · ${fmtDate(inv.createdAt)}</div>
</div>
${actions}`;
list.appendChild(card);
});
renderPaging('recvPaging', recvPage, totalPages, 'goRecvPage');
}
function goRecvPage(page) {
const total = Math.ceil(recvItems.length / PAGE_SIZE);
if (page < 0 || page >= total) return;
recvPage = page;
renderRecvPage();
document.getElementById('recvList').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function removeRecvItem(key) {
recvItems = recvItems.filter(i => i._key !== key);
const total = Math.ceil(recvItems.length / PAGE_SIZE);
if (recvPage >= total && recvPage > 0) recvPage = total - 1;
renderRecvPage();
}
// ── Gesendet laden ──
async function loadSentInvitations() {
try {
const [lockeeRes, khRes, vanillaRes] = await Promise.all([
fetch('/lockee/invitations/sent'),
fetch('/keyholder/invitations/sent'),
fetch('/vanilla/einladung/sent'),
]);
const lockeeInvs = lockeeRes.ok ? await lockeeRes.json() : [];
const khInvs = khRes.ok ? await khRes.json() : [];
const vanillaInvs = vanillaRes.ok ? await vanillaRes.json() : [];
lockeeInvs.forEach(inv => { inv._type = 'lockee'; inv._key = inv.token; inv._otherName = inv.lockeeName; inv._otherPic = inv.lockeeProfilePic; });
khInvs.forEach(inv => { inv._type = 'keyholder'; inv._key = inv.token; inv._otherName = inv.keyholderName; inv._otherPic = inv.keyholderProfilePic; });
vanillaInvs.forEach(inv => { inv._type = 'vanilla'; inv._key = inv.einladungId; inv._otherName = inv.inviteeName; inv._otherPic = ''; });
sentItems = [...lockeeInvs, ...khInvs, ...vanillaInvs].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
sentPage = 0;
renderSentPage();
} catch(e) { console.error(e); }
}
function renderSentPage() {
const list = document.getElementById('sentList');
const empty = document.getElementById('sentEmpty');
list.innerHTML = '';
if (sentItems.length === 0) {
empty.style.display = '';
document.getElementById('sentPaging').style.display = 'none';
return;
}
empty.style.display = 'none';
const totalPages = Math.ceil(sentItems.length / PAGE_SIZE);
const start = sentPage * PAGE_SIZE;
const pageItems = sentItems.slice(start, start + PAGE_SIZE);
pageItems.forEach(inv => {
const av = buildAvatarHtml(inv._otherPic, inv._type);
const card = document.createElement('div');
card.className = 'inv-card';
card.id = 'sentinv-' + inv._key;
let typeLabel, line2sent, extra = '';
if (inv._type === 'lockee') {
typeLabel = 'Lockee-Einladung';
line2sent = 'Lockee: ' + esc(inv.lockName);
extra = inv.detailsVisible
? ' &nbsp;<span style="font-size:0.72rem;">👁 Details sichtbar</span>'
: ' &nbsp;<span style="font-size:0.72rem;">🙈 Details verborgen</span>';
} else if (inv._type === 'vanilla') {
typeLabel = 'Vanilla Game';
line2sent = 'Spieleinladung';
} else {
typeLabel = 'Keyholder-Einladung';
line2sent = 'Keyholder: ' + esc(inv.lockName);
}
card.innerHTML = `
${av}
<div class="inv-body">
<div class="inv-line1">${esc(inv._otherName)}</div>
<div class="inv-line2">${line2sent}</div>
<div class="inv-line3">${typeLabel} · ${fmtDate(inv.createdAt)}${extra}</div>
</div>
<div style="flex-shrink:0;">
<button onclick="cancelSentInvitation('${esc(inv._key)}', '${inv._type}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Zurückziehen</button>
</div>`;
list.appendChild(card);
});
renderPaging('sentPaging', sentPage, totalPages, 'goSentPage');
}
function goSentPage(page) {
const total = Math.ceil(sentItems.length / PAGE_SIZE);
if (page < 0 || page >= total) return;
sentPage = page;
renderSentPage();
document.getElementById('sentList').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function removeSentItem(key) {
sentItems = sentItems.filter(i => i._key !== key);
const total = Math.ceil(sentItems.length / PAGE_SIZE);
if (sentPage >= total && sentPage > 0) sentPage = total - 1;
renderSentPage();
}
// ── Bestätigungs-Modal ──
let _confirmResolve = null;
function showConfirm(title, text) {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmText').textContent = text;
document.getElementById('confirmModal').classList.add('open');
document.querySelector('#confirmModal .confirm-modal-cancel').style.display = '';
return new Promise(resolve => {
_confirmResolve = resolve;
document.getElementById('confirmOkBtn').onclick = () => { confirmClose(true); };
});
}
function showInfo(title, text) {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmText').textContent = text;
document.getElementById('confirmModal').classList.add('open');
document.querySelector('#confirmModal .confirm-modal-cancel').style.display = 'none';
return new Promise(resolve => {
_confirmResolve = resolve;
document.getElementById('confirmOkBtn').onclick = () => { confirmClose(true); };
});
}
function confirmCancel() { confirmClose(false); }
function confirmClose(result) {
document.getElementById('confirmModal').classList.remove('open');
if (_confirmResolve) { _confirmResolve(result); _confirmResolve = null; }
}
// ── Aktionen: Empfangen ──
async function declineLockeeInvitation(token, btn) {
if (!await showConfirm('Einladung ablehnen', 'Bist du sicher, dass du diese Einladung ablehnen möchtest?')) return;
btn.disabled = true;
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token), { method: 'DELETE' });
if (res.ok || res.status === 204) { removeRecvItem(token); }
else { btn.disabled = false; }
} catch(e) { btn.disabled = false; }
}
async function declineKhInvitation(token, btn) {
if (!await showConfirm('Einladung ablehnen', 'Bist du sicher, dass du diese Einladung ablehnen möchtest?')) return;
btn.disabled = true;
try {
const res = await fetch('/keyholder/invitations/mine/' + encodeURIComponent(token), { method: 'DELETE' });
if (res.ok || res.status === 204) { removeRecvItem(token); }
else { btn.disabled = false; }
} catch(e) { btn.disabled = false; }
}
// ── Aktionen: Gesendet ──
async function cancelSentInvitation(key, type, btn) {
const title = 'Einladung zurückziehen';
const text = type === 'lockee'
? 'Das Lock wird gelöscht und der Lockee wird benachrichtigt.'
: type === 'vanilla'
? 'Der eingeladene Spieler wird benachrichtigt.'
: 'Der Keyholder wird benachrichtigt.';
if (!await showConfirm(title, text)) return;
btn.disabled = true;
const url = type === 'lockee'
? '/lockee/invitations/sent/' + encodeURIComponent(key)
: type === 'vanilla'
? '/vanilla/einladung/' + encodeURIComponent(key)
: '/keyholder/invitations/sent/' + encodeURIComponent(key);
try {
const res = await fetch(url, { method: 'DELETE' });
if (res.ok || res.status === 204) { removeSentItem(key); }
else { btn.disabled = false; }
} catch(e) { btn.disabled = false; }
}
// ── Lockee-Einladungs-Dialog ──
// CARD_DEFS wird von /js/card-defs.js bereitgestellt.
function fmtMinutes(min) {
if (!min) return '';
const d = Math.floor(min / (24 * 60));
const h = Math.floor((min % (24 * 60)) / 60);
const m = min % 60;
const parts = [];
if (d) parts.push(d + 'd');
if (h) parts.push(h + 'h');
if (m) parts.push(m + 'min');
return parts.join(' ') || '';
}
function renderLockDetails(inv) {
if (!inv.detailsVisible) {
return `<div class="blind-hint">
<span class="blind-hint-icon">🙈</span>
<span>Der Keyholder hat die Lock-Details nicht freigegeben. Du weißt nicht, worauf du dich einlässt.</span>
</div>`;
}
const cardCounts = inv.cardCounts || {};
const totalCards = Object.values(cardCounts).reduce((a, b) => a + b, 0);
const cardsHtml = CARD_DEFS
.filter(c => cardCounts[c.id] > 0)
.map(c => `<div class="lock-details-card-item">
<img src="${c.img}" alt="${c.name}">
<span class="ldc-count">${cardCounts[c.id]}×</span>
<span class="ldc-name">${c.name}</span>
</div>`).join('');
const badges = [];
badges.push(`🃏 ${totalCards} Karten`);
badges.push(`⏱ Ziehen alle ${fmtMinutes(inv.pickEveryMinute)}`);
if (inv.accumulatePicks) badges.push('📦 Picks akkumulieren');
if (inv.showRemainingCards) badges.push('👁 Karten sichtbar');
if (inv.hygineOpeningEveryMinites) badges.push(`🚿 Hygiene alle ${fmtMinutes(inv.hygineOpeningEveryMinites)} (${fmtMinutes(inv.hygineOpeningDurationMinutes)})`);
if (inv.taskCount > 0) badges.push(`${inv.taskCount} Aufgabe${inv.taskCount !== 1 ? 'n' : ''}`);
if (inv.requiresVerification) badges.push('🔍 Verifikation erforderlich');
return `<div class="lock-details-section">
<div class="lock-details-cards">${cardsHtml}</div>
<div class="lock-details-meta">${badges.map(b => `<span class="lock-details-badge">${b}</span>`).join('')}</div>
</div>`;
}
let activeDialogToken = null;
async function openLockeeInviteDialog(token) {
activeDialogToken = token;
document.getElementById('dialogError').style.display = 'none';
document.getElementById('dialogCodeLines').value = '5';
document.getElementById('dialogDetailsArea').innerHTML = '<div style="color:var(--color-muted);font-size:0.85rem;">Lade Details…</div>';
document.getElementById('lockeeInviteDialog').classList.add('open');
const card = document.getElementById('recvinv-' + token);
const line1 = card?.querySelector('.inv-line1')?.textContent || '';
const line2 = card?.querySelector('.inv-line2')?.textContent || '';
const line3 = card?.querySelector('.inv-line3')?.textContent || '';
const imgEl = card?.querySelector('.inv-avatar img');
const avatarEl = document.getElementById('dialogAvatar');
avatarEl.innerHTML = imgEl ? `<img src="${imgEl.src}" alt="">` : '👤';
document.getElementById('dialogTitle').textContent = line2;
document.getElementById('dialogSub').textContent = line1 + ' lädt dich als Lockee ein';
document.getElementById('dialogDetail').innerHTML =
`<dt>Keyholder</dt><dd>${esc(line1)}</dd>` +
`<dt>Lock-Name</dt><dd>${esc(line2)}</dd>` +
`<dt>Datum</dt><dd>${esc(line3)}</dd>`;
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token));
if (res.ok) {
document.getElementById('dialogDetailsArea').innerHTML = renderLockDetails(await res.json());
} else {
document.getElementById('dialogDetailsArea').innerHTML = '';
}
} catch(e) { document.getElementById('dialogDetailsArea').innerHTML = ''; }
}
function closeLockeeInviteDialog() {
document.getElementById('lockeeInviteDialog').classList.remove('open');
activeDialogToken = null;
}
async function acceptLockeeInviteDialog() {
if (!activeDialogToken) return;
const lines = parseInt(document.getElementById('dialogCodeLines').value);
if (!lines || lines < 1) { showDialogError('Bitte eine Ziffernanzahl eingeben.'); return; }
const acceptBtn = document.querySelector('.btn-accept');
acceptBtn.disabled = true;
document.getElementById('dialogError').style.display = 'none';
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(activeDialogToken) + '/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ unlockCodeLines: lines })
});
if (!res.ok) {
acceptBtn.disabled = false;
if (res.status === 409) {
const data = await res.json().catch(() => ({}));
showDialogError(data.error === 'active_lock_exists'
? 'Du hast bereits ein aktives Lock als Lockee. Erst das bestehende Lock beenden, bevor ein neues angenommen werden kann.'
: 'Diese Einladung wurde bereits angenommen.');
} else {
showDialogError('Fehler beim Annehmen der Einladung.');
}
return;
}
const data = await res.json();
document.getElementById('lockeeInviteDialog').classList.remove('open');
removeRecvItem(activeDialogToken);
showUnlockCodeModal(data.unlockCode, data.lockId);
} catch(e) {
acceptBtn.disabled = false;
showDialogError('Fehler beim Annehmen der Einladung.');
}
}
async function declineLockeeInviteDialog() {
if (!activeDialogToken) return;
if (!await showConfirm('Einladung ablehnen', 'Bist du sicher, dass du diese Einladung ablehnen möchtest? Das Lock wird gelöscht.')) return;
const declineBtn = document.querySelector('.btn-decline');
declineBtn.disabled = true;
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(activeDialogToken), { method: 'DELETE' });
if (res.ok || res.status === 204) {
removeRecvItem(activeDialogToken);
closeLockeeInviteDialog();
} else {
declineBtn.disabled = false;
showDialogError('Fehler beim Ablehnen der Einladung.');
}
} catch(e) { declineBtn.disabled = false; showDialogError('Fehler beim Ablehnen der Einladung.'); }
}
function showDialogError(msg) {
const el = document.getElementById('dialogError');
el.textContent = msg;
el.style.display = '';
}
// ── Entsperrcode-Modal ──
function showUnlockCodeModal(code, lockId) {
document.getElementById('unlockCodeDisplay').textContent = code;
const url = '/games/chastity/activelock.html?lockId=' + lockId;
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);
}
// ── BDSM-Einladungs-Dialog ──
let activeBdsmEinladungId = null;
function openBdsmInviteDialog(einladungId, inviterName, inviterPic) {
activeBdsmEinladungId = einladungId;
document.getElementById('bdsmDialogTitle').textContent = inviterName + ' lädt dich ein';
document.getElementById('bdsmDialogError').style.display = 'none';
const avatarEl = document.getElementById('bdsmDialogAvatar');
avatarEl.innerHTML = inviterPic
? `<img src="data:image/jpeg;base64,${inviterPic}" alt="" style="width:100%;height:100%;object-fit:cover;">`
: '⛓️';
document.getElementById('bdsmInviteDialog').classList.add('open');
}
function closeBdsmInviteDialog() {
document.getElementById('bdsmInviteDialog').classList.remove('open');
activeBdsmEinladungId = null;
}
async function _bdsmAntworten(mode) {
if (!activeBdsmEinladungId) return;
const accepted = mode !== null;
const errEl = document.getElementById('bdsmDialogError');
errEl.style.display = 'none';
try {
const res = await fetch(`/bdsm/einladung/${activeBdsmEinladungId}/antwort`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted, mode }),
});
if (!res.ok) throw new Error();
const key = activeBdsmEinladungId;
closeBdsmInviteDialog();
removeRecvItem(key);
if (mode === 'OWN_DEVICE') {
window.location.href = `/games/bdsm/neubdsm.html`;
} else if (mode === 'HOST_DEVICE') {
await showInfo('Einladung angenommen', 'Das Spiel findet am Gerät des Hosts statt. Du wirst zur Startseite weitergeleitet.');
window.location.href = '/userhome.html';
}
} catch (_) {
errEl.textContent = 'Fehler beim Speichern der Antwort.';
errEl.style.display = '';
}
}
function acceptBdsmOwnDevice() { _bdsmAntworten('OWN_DEVICE'); }
function acceptBdsmHostDevice() { _bdsmAntworten('HOST_DEVICE'); }
async function declineBdsmFromDialog() {
if (!await showConfirm('Einladung ablehnen', 'Möchtest du diese BDSM-Game-Einladung wirklich ablehnen?')) return;
_bdsmAntworten(null);
}
// ── Vanilla-Einladungs-Dialog ──
let activeVanillaEinladungId = null;
function openVanillaInviteDialog(einladungId, inviterName) {
activeVanillaEinladungId = einladungId;
document.getElementById('vanillaDialogTitle').textContent = inviterName + ' lädt dich ein';
document.getElementById('vanillaDialogError').style.display = 'none';
document.getElementById('vanillaInviteDialog').classList.add('open');
}
function closeVanillaInviteDialog() {
document.getElementById('vanillaInviteDialog').classList.remove('open');
activeVanillaEinladungId = null;
}
async function _vanillaAntworten(mode) {
if (!activeVanillaEinladungId) return;
const accepted = mode !== null;
const errEl = document.getElementById('vanillaDialogError');
errEl.style.display = 'none';
try {
const res = await fetch(`/vanilla/einladung/${activeVanillaEinladungId}/antwort`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted, mode }),
});
if (!res.ok) throw new Error();
const key = activeVanillaEinladungId;
closeVanillaInviteDialog();
removeRecvItem(key);
if (mode === 'OWN_DEVICE') {
window.location.href = '/games/vanilla/neuvanilla.html';
} else if (mode === 'HOST_DEVICE') {
await showInfo('Einladung angenommen', 'Das Spiel findet am Gerät des Hosts statt. Du wirst zur Startseite weitergeleitet.');
window.location.href = '/userhome.html';
}
} catch (_) {
errEl.textContent = 'Fehler beim Speichern der Antwort.';
errEl.style.display = '';
}
}
function acceptVanillaOwnDevice() { _vanillaAntworten('OWN_DEVICE'); }
function acceptVanillaHostDevice() { _vanillaAntworten('HOST_DEVICE'); }
async function declineVanillaFromDialog() {
if (!await showConfirm('Einladung ablehnen', 'Möchtest du diese Vanilla-Game-Einladung wirklich ablehnen?')) return;
_vanillaAntworten(null);
}
// ── Esc schließt Dialog ──
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
if (document.getElementById('vanillaInviteDialog').classList.contains('open')) closeVanillaInviteDialog();
if (document.getElementById('bdsmInviteDialog').classList.contains('open')) closeBdsmInviteDialog();
if (document.getElementById('lockeeInviteDialog').classList.contains('open')) closeLockeeInviteDialog();
}
});
// ── Alles laden ──
loadReceivedInvitations();
loadSentInvitations();
</script>
</body>
</html>

View File

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

View File

@@ -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>Vanilla 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>Vanilla Game</h1>
<p>Informationen zum Vanilla Game folgen hier.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vanilla Game Neue Session 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>Vanilla Game Neue Session</h1>
<p>Session-Setup für das Vanilla Game folgt hier.</p>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/games/vanilla/neuvanilla.html">
<title>Vanilla Game xXx Sphere</title>
</head>
<body>
<script>window.location.replace('/games/vanilla/neuvanilla.html');</script>
</body>
</html>

View File

@@ -0,0 +1,108 @@
<!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>Impressum xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header {
margin-bottom: 2rem;
}
.hilfe-header h1 {
font-size: 1.6rem;
margin: 0 0 0.4rem 0;
}
.hilfe-header p {
color: var(--color-muted);
font-size: 0.92rem;
margin: 0;
line-height: 1.6;
}
.impressum-block {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 0.75rem;
}
.impressum-block h2 {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-muted);
margin: 0 0 0.75rem 0;
}
.impressum-block p,
.impressum-block address {
font-size: 0.9rem;
color: var(--color-muted);
line-height: 1.8;
margin: 0;
font-style: normal;
}
.impressum-block a {
color: var(--color-muted);
text-decoration: none;
transition: color 0.15s;
}
.impressum-block a:hover {
color: var(--color-text);
}
.impressum-block + .impressum-block {
margin-top: 0;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="hilfe-header">
<h1>📄 Impressum</h1>
<p>Angaben gemäß § 5 TMG</p>
</div>
<div class="impressum-block">
<h2>Verantwortlich</h2>
<address>
Vorname Nachname<br>
Musterstraße 1<br>
12345 Musterstadt<br>
Deutschland
</address>
</div>
<div class="impressum-block">
<h2>Kontakt</h2>
<p>
E-Mail: <a href="mailto:kontakt@xxx-sphere.de">kontakt@xxx-sphere.de</a>
</p>
</div>
<div class="impressum-block">
<h2>Hinweis</h2>
<p>
xXx Sphere ist ein privat betriebenes Projekt ohne kommerzielle Absicht.
Die Plattform richtet sich ausschließlich an volljährige Personen.
</p>
</div>
<div class="impressum-block">
<h2>Haftungsausschluss</h2>
<p>
Trotz sorgfältiger inhaltlicher Kontrolle übernehmen wir keine Haftung für die Inhalte externer Links.
Für den Inhalt verlinkter Seiten sind ausschließlich deren Betreiber verantwortlich.
</p>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,265 @@
<!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>Kontakt & Feedback xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header {
margin-bottom: 2rem;
}
.hilfe-header h1 {
font-size: 1.6rem;
margin: 0 0 0.4rem 0;
}
.hilfe-header p {
color: var(--color-muted);
font-size: 0.92rem;
margin: 0;
line-height: 1.6;
}
/* ── E-Mail-Hinweis ── */
.mail-hint {
background: rgba(var(--color-primary-rgb, 120,80,200), 0.08);
border-left: 3px solid var(--color-primary);
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-bottom: 1.5rem;
}
.mail-hint strong { color: var(--color-text); }
.mail-hint a { color: var(--color-text); }
/* ── Formular-Card ── */
.feedback-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1.1rem;
}
.form-group:last-of-type {
margin-bottom: 1.5rem;
}
.form-group label {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-group input,
.form-group select,
.form-group textarea {
background: var(--color-secondary);
border: 1px solid transparent;
border-radius: 8px;
padding: 0.65rem 0.9rem;
color: var(--color-text);
font-size: 0.92rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: var(--color-primary);
}
.form-group input[readonly],
.form-group input:disabled {
opacity: 0.55;
cursor: default;
}
.form-group textarea {
resize: vertical;
min-height: 130px;
}
.form-group select option {
background: var(--color-card);
}
.char-counter {
font-size: 0.78rem;
color: var(--color-muted);
text-align: right;
}
.char-counter.warn { color: #e74c3c; }
.btn-send {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.5rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-send:hover { opacity: 0.85; }
.btn-send:disabled { opacity: 0.45; cursor: default; }
.feedback-success {
display: none;
background: rgba(39,174,96,0.1);
border-left: 3px solid #27ae60;
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.9rem;
color: var(--color-muted);
margin-top: 1rem;
}
.feedback-success strong { color: #27ae60; }
.feedback-error {
display: none;
background: rgba(231,76,60,0.08);
border-left: 3px solid #e74c3c;
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.9rem;
color: var(--color-muted);
margin-top: 1rem;
}
.feedback-error strong { color: #e74c3c; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="hilfe-header">
<h1>✉️ Kontakt &amp; Feedback</h1>
<p>Hast du Fragen, Ideen oder einen Fehler gefunden? Schreib uns!</p>
</div>
<div class="mail-hint">
<strong>Alternativ per E-Mail:</strong> Du kannst uns auch direkt schreiben
<a href="mailto:kontakt@xxx-sphere.de">kontakt@xxx-sphere.de</a>
</div>
<div class="feedback-card">
<div class="form-group">
<label for="fb-name">Name</label>
<input type="text" id="fb-name" readonly placeholder="Wird geladen…">
</div>
<div class="form-group">
<label for="fb-seite">Seite</label>
<input type="text" id="fb-seite" readonly>
</div>
<div class="form-group">
<label for="fb-grund">Kontaktgrund</label>
<select id="fb-grund">
<option value="Fehlermeldung">🐛 Fehlermeldung</option>
<option value="Feedback">💬 Feedback</option>
<option value="Idee & Verbesserungsvorschlag">💡 Idee &amp; Verbesserungsvorschlag</option>
<option value="Nachfrage">❓ Nachfrage</option>
</select>
</div>
<div class="form-group">
<label for="fb-text">Nachricht</label>
<textarea id="fb-text" maxlength="1000" placeholder="Beschreibe dein Anliegen…"></textarea>
<span class="char-counter" id="fb-counter">0 / 1000</span>
</div>
<button class="btn-send" id="fb-send" onclick="sendFeedback()">✉️ Absenden</button>
<div class="feedback-success" id="fb-success">
<strong>Vielen Dank!</strong> Deine Nachricht wurde erfolgreich übermittelt.
</div>
<div class="feedback-error" id="fb-error">
<strong>Fehler:</strong> Die Nachricht konnte nicht gesendet werden. Bitte versuche es später erneut.
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
// Name vorausfüllen
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
const field = document.getElementById('fb-name');
if (user && user.name) {
field.value = user.name;
} else {
field.value = 'Gast';
}
})
.catch(() => { document.getElementById('fb-name').value = 'Gast'; });
// Seite vorausfüllen
(function () {
const field = document.getElementById('fb-seite');
try {
const ref = document.referrer;
if (ref) {
const url = new URL(ref);
field.value = url.pathname;
} else {
field.value = 'Direkt aufgerufen';
}
} catch (_) {
field.value = 'Unbekannt';
}
})();
// Zeichenzähler
document.getElementById('fb-text').addEventListener('input', function () {
const len = this.value.length;
const counter = document.getElementById('fb-counter');
counter.textContent = len + ' / 1000';
counter.className = 'char-counter' + (len > 900 || len < 10 ? ' warn' : '');
});
async function sendFeedback() {
const btn = document.getElementById('fb-send');
const text = document.getElementById('fb-text').value.trim();
if (text.length < 10) { document.getElementById('fb-text').focus(); return; }
btn.disabled = true;
document.getElementById('fb-success').style.display = 'none';
document.getElementById('fb-error').style.display = 'none';
try {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('fb-name').value,
seite: document.getElementById('fb-seite').value,
grund: document.getElementById('fb-grund').value,
text: text
})
});
if (res.ok) {
document.getElementById('fb-success').style.display = 'block';
document.getElementById('fb-text').value = '';
document.getElementById('fb-counter').textContent = '0 / 1000';
} else {
document.getElementById('fb-error').style.display = 'block';
btn.disabled = false;
}
} catch (_) {
document.getElementById('fb-error').style.display = 'block';
btn.disabled = false;
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,221 @@
<!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>Hilfe-Übersicht xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header {
margin-bottom: 2rem;
}
.hilfe-header h1 {
font-size: 1.6rem;
margin: 0 0 0.4rem 0;
}
.hilfe-header p {
color: var(--color-muted);
font-size: 0.92rem;
margin: 0;
line-height: 1.6;
}
/* ── Kategorien-Grid ── */
.hilfe-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.hilfe-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.hilfe-card-title {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1rem;
font-weight: 700;
border-bottom: 1px solid var(--color-secondary);
padding-bottom: 0.6rem;
}
.hilfe-card-desc {
font-size: 0.85rem;
color: var(--color-muted);
line-height: 1.6;
}
.hilfe-card-links {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.25rem;
}
.hilfe-card-links a {
font-size: 0.85rem;
color: var(--color-muted);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.4rem;
transition: color 0.15s;
}
.hilfe-card-links a:hover {
color: var(--color-text);
}
.hilfe-card-links a::before {
content: '';
font-size: 1rem;
color: var(--color-primary);
flex-shrink: 0;
}
/* ── Abschnitt-Überschrift ── */
.hilfe-section-label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-muted);
margin: 1.75rem 0 0.75rem;
}
.hilfe-section-label:first-child {
margin-top: 0;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="hilfe-header">
<h1>❓ Hilfe-Übersicht</h1>
<p>Hier findest du Anleitungen und Erklärungen zu allen Bereichen von xXx Sphere.</p>
</div>
<!-- ── Einstellungen & Konto ── -->
<div class="hilfe-section-label">Einstellungen &amp; Konto</div>
<div class="hilfe-grid">
<div class="hilfe-card">
<div class="hilfe-card-title">⚙️ Allgemeine Einstellungen</div>
<div class="hilfe-card-desc">Profil, Benachrichtigungen, Datenschutz und weitere Kontoeinstellungen.</div>
<div class="hilfe-card-links">
<a href="#">Profil bearbeiten</a>
<a href="#">Benachrichtigungen konfigurieren</a>
<a href="#">Passwort ändern</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">🔒 TTLock-Integration</div>
<div class="hilfe-card-desc">Verbinde deine physische Schlüsselbox mit xXx Sphere für automatische Code-Verwaltung.</div>
<div class="hilfe-card-links">
<a href="/help/ttlock.html#sec-intro">Was ist TTLock?</a>
<a href="/help/ttlock.html#sec-table">Voraussetzungen</a>
<a href="/help/ttlock.html#sec-howto">TTLock einrichten</a>
<a href="/help/ttlock.html#sec-faq1">Warum nur für Abonnenten?</a>
<a href="/help/ttlock.html#sec-faq2">Notfall-Öffnung</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">💳 Abonnements</div>
<div class="hilfe-card-desc">Informationen zu Premium-Funktionen und wie du dein Abonnement verwaltest.</div>
<div class="hilfe-card-links">
<a href="#">Premium-Funktionen im Überblick</a>
<a href="#">Abonnement kündigen</a>
</div>
</div>
</div>
<!-- ── Spiele ── -->
<div class="hilfe-section-label">Spiele</div>
<div class="hilfe-grid">
<div class="hilfe-card">
<div class="hilfe-card-title">🔒 Chastity Game</div>
<div class="hilfe-card-desc">Alles rund um Schlösser, Keyholder, Karten und Aufgaben im Chastity Game.</div>
<div class="hilfe-card-links">
<a href="#">Neues Lock starten</a>
<a href="#">Die Rolle als Keyholder</a>
<a href="#">Karten und Aufgaben</a>
<a href="#">TimeLock erklärt</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">⛓️ BDSM Game</div>
<div class="hilfe-card-desc">Sessions erstellen, Spieler einladen und Aufgaben verwalten.</div>
<div class="hilfe-card-links">
<a href="#">Session starten</a>
<a href="#">Spieler einladen</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">⚪ Vanilla Game</div>
<div class="hilfe-card-desc">Leichtere Spiele ohne strenge Regeln für den entspannten Einstieg.</div>
<div class="hilfe-card-links">
<a href="#">Vanilla-Session starten</a>
</div>
</div>
</div>
<!-- ── Community ── -->
<div class="hilfe-section-label">Community</div>
<div class="hilfe-grid">
<div class="hilfe-card">
<div class="hilfe-card-title">👥 Gruppen</div>
<div class="hilfe-card-desc">Gruppen erstellen, beitreten und verwalten.</div>
<div class="hilfe-card-links">
<a href="#">Gruppe erstellen</a>
<a href="#">Mitglieder verwalten</a>
<a href="#">Beiträge und Abstimmungen</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">📰 Feed &amp; Profil</div>
<div class="hilfe-card-desc">Beiträge teilen, Profile entdecken und die Community kennenlernen.</div>
<div class="hilfe-card-links">
<a href="#">Feed nutzen</a>
<a href="#">Profil gestalten</a>
<a href="#">Personen suchen und folgen</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">🏆 Community Votes</div>
<div class="hilfe-card-desc">Verifikationen bewerten und an Community-Abstimmungen teilnehmen.</div>
<div class="hilfe-card-links">
<a href="#">Wie funktionieren Votes?</a>
</div>
</div>
</div>
<!-- ── Sonstiges ── -->
<div class="hilfe-section-label">Sonstiges</div>
<div class="hilfe-grid">
<div class="hilfe-card">
<div class="hilfe-card-title">🔐 Sicherheit &amp; Datenschutz</div>
<div class="hilfe-card-desc">Wie deine Daten gespeichert werden und welche Sicherheitsmaßnahmen wir treffen.</div>
<div class="hilfe-card-links">
<a href="#">Datenspeicherung</a>
<a href="#">Passwort-Hashing</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">🐛 Fehler melden</div>
<div class="hilfe-card-desc">Hast du einen Fehler gefunden oder einen Verbesserungsvorschlag?</div>
<div class="hilfe-card-links">
<a href="#">Feedback senden</a>
</div>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,345 @@
<!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>SEITENTITEL xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Hilfe-Seite Basis ── */
.hilfe-header {
margin-bottom: 2rem;
}
.hilfe-header h1 {
font-size: 1.6rem;
margin: 0 0 0.4rem 0;
}
.hilfe-header p {
color: var(--color-muted);
font-size: 0.92rem;
margin: 0;
line-height: 1.6;
}
/* ── Abschnitte (Accordion) ── */
.hilfe-section {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
margin-bottom: 0.75rem;
overflow: hidden;
}
.hilfe-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.hilfe-section-header:hover {
background: rgba(255,255,255,0.03);
}
.hilfe-section-title {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1rem;
font-weight: 600;
}
.hilfe-section-arrow {
font-size: 0.75rem;
color: var(--color-muted);
transition: transform 0.2s;
}
.hilfe-section.open .hilfe-section-arrow {
transform: rotate(90deg);
}
.hilfe-section-body {
display: none;
padding: 0 1.25rem 1.25rem;
border-top: 1px solid var(--color-secondary);
}
.hilfe-section.open .hilfe-section-body {
display: block;
}
/* ── Fließtext in Abschnitten ── */
.hilfe-section-body p {
font-size: 0.9rem;
color: var(--color-muted);
line-height: 1.7;
margin: 0.9rem 0 0;
}
.hilfe-section-body p:first-child {
margin-top: 1rem;
}
/* ── Schritt-für-Schritt Liste ── */
.hilfe-steps {
list-style: none;
padding: 0;
margin: 1rem 0 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.hilfe-steps li {
display: flex;
align-items: flex-start;
gap: 0.85rem;
font-size: 0.9rem;
color: var(--color-muted);
line-height: 1.6;
}
.hilfe-steps li .step-num {
flex-shrink: 0;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-top: 0.1rem;
}
/* ── Hinweis-Box ── */
.hilfe-hint {
background: rgba(var(--color-primary-rgb, 120,80,200), 0.08);
border-left: 3px solid var(--color-primary);
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-top: 1rem;
}
.hilfe-hint strong {
color: var(--color-text);
}
/* ── Warn-Box ── */
.hilfe-warn {
background: rgba(231,76,60,0.08);
border-left: 3px solid #e74c3c;
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-top: 1rem;
}
.hilfe-warn strong {
color: #e74c3c;
}
/* ── Info-Box (neutral) ── */
.hilfe-info {
background: var(--color-secondary);
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-top: 1rem;
}
/* ── Einfache Tabelle ── */
.hilfe-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
margin-top: 1rem;
}
.hilfe-table th {
text-align: left;
color: var(--color-text);
font-weight: 600;
padding: 0.4rem 0.75rem 0.4rem 0;
border-bottom: 1px solid var(--color-secondary);
}
.hilfe-table td {
color: var(--color-muted);
padding: 0.5rem 0.75rem 0.5rem 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
vertical-align: top;
line-height: 1.5;
}
.hilfe-table tr:last-child td {
border-bottom: none;
}
/* ── Trennlinie ── */
.hilfe-divider {
border: none;
border-top: 1px solid var(--color-secondary);
margin: 1.25rem 0 0;
}
/* ── Inline-Badge ── */
.hilfe-badge {
display: inline-block;
background: var(--color-secondary);
border-radius: 4px;
padding: 0.1rem 0.45rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-muted);
vertical-align: middle;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<!-- ── Kopfzeile ─────────────────────────────────────── -->
<div class="hilfe-header">
<h1>🔒 SEITENTITEL</h1>
<p>Kurze Beschreibung, worum es auf dieser Hilfeseite geht.</p>
</div>
<!-- ── Abschnitt: Einfacher Fließtext ───────────────── -->
<div class="hilfe-section open" id="sec-intro">
<div class="hilfe-section-header" onclick="toggleSection('sec-intro')">
<span class="hilfe-section-title">📖 Was ist das?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Hier steht ein einleitender Text. Du kannst mehrere Absätze verwenden,
um das Thema zu erklären.
</p>
<p>
Zweiter Absatz mit weiteren Informationen. Links gehen so:
<a href="/games/chastity/neulock.html">neues Lock starten</a>.
</p>
<!-- Hinweis-Box (lila) -->
<div class="hilfe-hint">
<strong>Hinweis:</strong> Hier steht ein wichtiger, aber freundlicher Hinweis.
</div>
</div>
</div>
<!-- ── Abschnitt: Schritt-für-Schritt ───────────────── -->
<div class="hilfe-section" id="sec-howto">
<div class="hilfe-section-header" onclick="toggleSection('sec-howto')">
<span class="hilfe-section-title">🚀 So funktioniert es</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>Führe diese Schritte der Reihe nach aus:</p>
<ol class="hilfe-steps">
<li>
<span class="step-num">1</span>
<span>Erster Schritt was der Nutzer hier tun muss.</span>
</li>
<li>
<span class="step-num">2</span>
<span>Zweiter Schritt weitere Aktion mit Erklärung.</span>
</li>
<li>
<span class="step-num">3</span>
<span>Dritter Schritt Abschluss oder Ergebnis.</span>
</li>
</ol>
<!-- Warn-Box (rot) -->
<div class="hilfe-warn">
<strong>Achtung:</strong> Hier steht eine Warnung, z. B. dass eine Aktion nicht rückgängig gemacht werden kann.
</div>
</div>
</div>
<!-- ── Abschnitt: Tabelle ────────────────────────────── -->
<div class="hilfe-section" id="sec-table">
<div class="hilfe-section-header" onclick="toggleSection('sec-table')">
<span class="hilfe-section-title">📋 Übersicht</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<table class="hilfe-table">
<thead>
<tr>
<th>Funktion</th>
<th>Beschreibung</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="hilfe-badge">Beispiel A</span></td>
<td>Erklärung zu Funktion A.</td>
</tr>
<tr>
<td><span class="hilfe-badge">Beispiel B</span></td>
<td>Erklärung zu Funktion B.</td>
</tr>
<tr>
<td><span class="hilfe-badge">Beispiel C</span></td>
<td>Erklärung zu Funktion C.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- ── Abschnitt: FAQ ────────────────────────────────── -->
<div class="hilfe-section" id="sec-faq1">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq1')">
<span class="hilfe-section-title">❓ Häufige Frage 1?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Antwort auf die erste häufige Frage. Kann auch mehrere Absätze haben.
</p>
<div class="hilfe-info">
Neutrale Info-Box für ergänzende Details ohne Wertung.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-faq2">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq2')">
<span class="hilfe-section-title">❓ Häufige Frage 2?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Antwort auf die zweite häufige Frage.
</p>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
function toggleSection(id) {
document.getElementById(id).classList.toggle('open');
}
function openFromHash() {
const hash = window.location.hash.slice(1);
if (!hash) return;
const el = document.getElementById(hash);
if (el && el.classList.contains('hilfe-section')) {
el.classList.add('open');
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
}
}
document.addEventListener('DOMContentLoaded', openFromHash);
window.addEventListener('hashchange', openFromHash);
</script>
</body>
</html>

View File

@@ -0,0 +1,360 @@
<!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>Hilfe TTLock xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Hilfe-Seite Basis ── */
.hilfe-header {
margin-bottom: 2rem;
}
.hilfe-header h1 {
font-size: 1.6rem;
margin: 0 0 0.4rem 0;
}
.hilfe-header p {
color: var(--color-muted);
font-size: 0.92rem;
margin: 0;
line-height: 1.6;
}
/* ── Abschnitte (Accordion) ── */
.hilfe-section {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
margin-bottom: 0.75rem;
overflow: hidden;
}
.hilfe-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1.25rem;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.hilfe-section-header:hover {
background: rgba(255,255,255,0.03);
}
.hilfe-section-title {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1rem;
font-weight: 600;
}
.hilfe-section-arrow {
font-size: 0.75rem;
color: var(--color-muted);
transition: transform 0.2s;
}
.hilfe-section.open .hilfe-section-arrow {
transform: rotate(90deg);
}
.hilfe-section-body {
display: none;
padding: 0 1.25rem 1.25rem;
border-top: 1px solid var(--color-secondary);
}
.hilfe-section.open .hilfe-section-body {
display: block;
}
/* ── Fließtext in Abschnitten ── */
.hilfe-section-body p {
font-size: 0.9rem;
color: var(--color-muted);
line-height: 1.7;
margin: 0.9rem 0 0;
}
.hilfe-section-body p:first-child {
margin-top: 1rem;
}
/* ── Schritt-für-Schritt Liste ── */
.hilfe-steps {
list-style: none;
padding: 0;
margin: 1rem 0 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.hilfe-steps li {
list-style: none;
display: flex;
align-items: flex-start;
gap: 0.85rem;
font-size: 0.9rem;
color: var(--color-muted);
line-height: 1.6;
}
.hilfe-steps li::before {
display: none;
}
.hilfe-steps li .step-num {
flex-shrink: 0;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: 0.75rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-top: 0.1rem;
}
.hilfe-steps li strong,
.hilfe-steps li em {
color: inherit;
}
/* ── Hinweis-Box ── */
.hilfe-hint {
background: rgba(var(--color-primary-rgb, 120,80,200), 0.08);
border-left: 3px solid var(--color-primary);
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-top: 1rem;
}
.hilfe-hint strong {
color: var(--color-text);
}
/* ── Warn-Box ── */
.hilfe-warn {
background: rgba(231,76,60,0.08);
border-left: 3px solid #e74c3c;
border-radius: 0 8px 8px 0;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-top: 1rem;
}
.hilfe-warn strong {
color: #e74c3c;
}
/* ── Info-Box (neutral) ── */
.hilfe-info {
background: var(--color-secondary);
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.88rem;
color: var(--color-muted);
line-height: 1.6;
margin-top: 1rem;
}
/* ── Einfache Tabelle ── */
.hilfe-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
margin-top: 1rem;
}
.hilfe-table th {
text-align: left;
color: var(--color-text);
font-weight: 600;
padding: 0.4rem 0.75rem 0.4rem 0;
border-bottom: 1px solid var(--color-secondary);
}
.hilfe-table td {
color: var(--color-muted);
padding: 0.5rem 0.75rem 0.5rem 0;
border-bottom: 1px solid rgba(255,255,255,0.05);
vertical-align: top;
line-height: 1.5;
}
.hilfe-table tr:last-child td {
border-bottom: none;
}
/* ── Trennlinie ── */
.hilfe-divider {
border: none;
border-top: 1px solid var(--color-secondary);
margin: 1.25rem 0 0;
}
/* ── Inline-Badge ── */
.hilfe-badge {
display: inline-block;
background: var(--color-secondary);
border-radius: 4px;
padding: 0.1rem 0.45rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-muted);
vertical-align: middle;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<!-- ── Kopfzeile ─────────────────────────────────────── -->
<div class="hilfe-header">
<h1>🔒 TTLock</h1>
<p>Hilfe zur Einrichtung der Kommunikation mit einer TTLock-Schlüsselbox</p>
</div>
<!-- ── Abschnitt: Einfacher Fließtext ───────────────── -->
<div class="hilfe-section open" id="sec-intro">
<div class="hilfe-section-header" onclick="toggleSection('sec-intro')">
<span class="hilfe-section-title">📖 Was ist das?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
<a href="https://ttlock.com/#/">TTLock</a> ist ein weit verbreitetes System für die Verwaltung von smarten Schlössern und Schlüsselboxen. Die Hardware kommuniziert in der Regel via Bluetooth, lässt sich aber über ein G2-Gateway auch aus der Ferne steuern.
</p>
<p>
Für Entwickler und Unternehmen bietet die TTLock Open Platform eine leistungsstarke REST-API. Damit lässt sich die Schlossverwaltung in eigene Anwendungen integrieren.
Diese API verwenden wir, um die Codes deiner Schlüsselbox zu steuern - kein nerviges manuelles Eintragen des generierten Schlüssels mehr.
</p>
<p>
TTLock steht allen Premium-Abonenten zur Verfügung.
</p>
</div>
</div>
<!-- ── Abschnitt: Tabelle ────────────────────────────── -->
<div class="hilfe-section" id="sec-table">
<div class="hilfe-section-header" onclick="toggleSection('sec-table')">
<span class="hilfe-section-title">📋 Voraussetzungen</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<div class="hilfe-warn">
<strong>Achtung:</strong> Für die Verwendung ist zwingend ein G2-Gateway für die Kommunikation von TTLock-Server zu Deiner Schlüsselbox notwendig.
</div>
<div class="hilfe-hint">
<strong>Hinweis:</strong> Für die Verwendung einer TTLock-Schlüsselbox in Spielen ist zwingend ein Premium-Abonement notwendig.
</div>
</div>
</div>
<!-- ── Abschnitt: Schritt-für-Schritt ───────────────── -->
<div class="hilfe-section" id="sec-howto">
<div class="hilfe-section-header" onclick="toggleSection('sec-howto')">
<span class="hilfe-section-title">🚀 So funktioniert es</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>Führe diese Schritte der Reihe nach aus:</p>
<ol class="hilfe-steps">
<li>
<span class="step-num">1</span>
<span>App-Setup: Verbinde deine Schlüsselbox in der TTLock-App. Wichtig: Für die Fernsteuerung muss ein Gateway (G2) eingerichtet und aktiv sein.</span>
</li>
<li>
<span class="step-num">2</span>
<span>Fernzugriff aktivieren: Aktiviere die Funktion in den App-Einstellungen. Tipp: Schalte das WLAN an deinem Handy aus und versuche, die Box über mobile Daten zu öffnen. Funktioniert das? Dann ist alles bereit.</span>
</li>
<li>
<span class="step-num">3</span>
<span>Accounts verknüpfen: Trage deine TTLock-Zugangsdaten unter Einstellungen &gt; TTLock ein.</span>
</li>
<li>
<span class="step-num">4</span>
<span>Lock-ID hinterlegen: Gib die ID deiner Box an (zu finden unter MAC/ID). Wichtig: Nur den Teil hinter dem Schrägstrich nutzen (z.B. bei 00:11.../123456 nur 123456).</span>
</li>
<li>
<span class="step-num">5</span>
<span>Verbindung testen: Klicke auf „Verbindung testen“. Erst nach einem grünen Licht ist das System aktiv.</span>
</li>
</ol>
<!-- Hinweis-Box (lila) -->
<div class="hilfe-hint">
<strong>Hinweis:</strong> Wir speichern dein Passwort nicht im Klartext in der Datenbank sondern nur als MD5-Hash.
</div>
</div>
</div>
<!-- ── Abschnitt: FAQ ────────────────────────────────── -->
<div class="hilfe-section" id="sec-faq1">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq1')">
<span class="hilfe-section-title">❓ Warum steht dieser Dienst nur für Abonennten zur Verfügung?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Die Verwendung der API von TTLock ist nur begrenzt kostenlos verwendbar. Ab bestimmten Kontingenten wird die Verwendung für uns kostenpflichtig.
</p>
<div class="hilfe-info">
Es gilt weiter der Grundsatz, XXX-Sphere soll niemanden reich machen - Die Abonemments dienen dazu die laufenden Kosten (Server, API-Schnittstellen etc.) zu decken.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-faq2">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq2')">
<span class="hilfe-section-title">❓ Hilfe - ich komme nicht mehr raus...Was machen Sachen?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Sollte sich der Schlüssel noch in der Box befinden und ihr nicht in einem aktiven Lock sein, besteht die Möglichkeit einen neuen Code für eine Notfallöffnung zu generieren:
</p>
<ol class="hilfe-steps">
<li>
<span class="step-num">1</span>
<span>Öffne die Einstellungen</span>
</li>
<li>
<span class="step-num">2</span>
<span>Navigiere zum Bereich '🔒 TTLock'</span>
</li>
<li>
<span class="step-num">3</span>
<span>Drücke '🔒 Öffnen'</span>
</li>
</ol>
<p>
Der temporäre Code zum Öffnen wird euch angezeigt - damit lässt sich die Box dann öffnen.
</p>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
function toggleSection(id) {
document.getElementById(id).classList.toggle('open');
}
function openFromHash() {
const hash = window.location.hash.slice(1);
if (!hash) return;
const el = document.getElementById(hash);
if (el && el.classList.contains('hilfe-section')) {
el.classList.add('open');
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
}
}
document.addEventListener('DOMContentLoaded', openFromHash);
window.addEventListener('hashchange', openFromHash);
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<link rel="icon" href="/img/icon.png" type="image/png">
</head>
<body>
<img src="img/icon.png" alt="Icon">
<h1>Kinky Games und Communities</h1>
<div id="authButtons" style="display:flex; gap:1rem;">
<a class="btn" href="/login.html">Anmelden</a>
<a class="btn" href="/registration.html" style="background:#0f3460;">Registrieren</a>
</div>
<div id="fillerMsg" class="message" style="max-width:320px;"></div>
<script>
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
if (!user) return;
const btns = document.getElementById('authButtons');
btns.insertAdjacentHTML('beforebegin',
`<p style="text-align:center;">Willkommen zurück, <strong style="color:var(--color-text);">${user.name}</strong></p>`
);
btns.innerHTML = '<a class="btn" href="/userhome.html">Betreten</a>';
})
.catch(() => {});
</script>
</body>
</html>

View File

@@ -0,0 +1,86 @@
/**
* Zentrale Kartendefinitionen für das Chastity Game.
*
* Exportiert (global):
* CARD_DEFS Array mit { id, img, name, desc, defMin, defMax }
* CARD_LABELS Object { ID: { name, img, desc } } (Lookup für card-display.js u.a.)
*/
const CARD_DEFS = [
{
id: 'RED',
img: '/img/card_red.png',
name: 'Rote Karte',
desc: 'Niete - Viel Erfolg beim nächsten Zug',
defMin: 5,
defMax: 10,
},
{
id: 'GREEN',
img: '/img/card_green.png',
name: 'Grüne Karte',
desc: 'Öffnet das Lock. Kann wieder ins Deck zurück gelegt werden',
defMin: 1,
defMax: 2,
},
{
id: 'YELLOW',
img: '/img/card_yellow.png',
name: 'Gelbe Karte',
desc: 'Per Zufall werden rote Karten entfernt oder hinzugefügt',
defMin: 1,
defMax: 2,
},
{
id: 'TASK',
img: '/img/card_task.png',
name: 'Aufgabe',
desc: 'Keyholder*In, Community oder der Zufall teilt eine Aufgabe zu.',
defMin: 0,
defMax: 0,
},
{
id: 'FREEZE',
img: '/img/card_freeze.png',
name: 'Freeze',
desc: 'Friert das Lock für eine festgelegte Zeit ein in diesem Zeitraum können keine Karten gezogen werden.',
defMin: 0,
defMax: 0,
},
{
id: 'RESET',
img: '/img/card_reset.png',
name: 'Reset',
desc: 'Setzt das Kartendeck auf den Ausgangszustand zurück. Alle bisher gezogenen Karten kommen wieder rein.',
defMin: 0,
defMax: 0,
},
{
id: 'DOUBLE_UP',
img: '/img/card_doubleup.png',
name: 'Double Up',
desc: 'Verdoppelt alle noch im Deck vorhandenen Karten.',
defMin: 0,
defMax: 0,
},
{
id: 'CUM',
img: '/img/card_cum.png',
name: 'Cum',
desc: 'Du wirst entsperrt, nutze diese Entsperrung um zu kommen. Je länger du brauchst, desto schlimmer.',
defMin: 0,
defMax: 0,
},
{
id: 'CUM_IN_CAGE',
img: '/img/card_cum_caged.png',
name: 'Cum in Cage',
desc: 'Komme in deinem Keuschheitsgürtel, wie du es anstellst ist deine Sache.',
defMin: 0,
defMax: 0,
},
];
/** Lookup-Objekt für Konsumenten, die nach ID auf Name/Bild/Beschreibung zugreifen. */
const CARD_LABELS = Object.fromEntries(
CARD_DEFS.map(c => [c.id, { name: c.name, img: c.img, desc: c.desc }])
);

View File

@@ -0,0 +1,60 @@
/**
* Gemeinsame Kartenanzeige für Chastity Game.
* Benötigt: /js/card-defs.js (CARD_LABELS muss bereits global verfügbar sein)
* Exportiert: cardTypeGridHtml(cardCounts)
*/
(function () {
const style = document.createElement('style');
style.textContent = `
.card-type-grid {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 0.4rem;
}
.card-type-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
width: calc((100% - 6 * 0.6rem) / 14);
min-width: 28px;
}
.card-type-item img {
width: 100%;
height: auto;
border-radius: 4px;
display: block;
}
.card-type-badge {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
line-height: 1.2;
}
`;
document.head.appendChild(style);
})();
/**
* Gibt HTML für ein Karten-Typ-Raster zurück (ein Bild pro Typ, Anzahl-Badge).
* @param {Object} cardCounts { RED: 3, GREEN: 1, … }
* @returns {string} HTML-String
*/
function cardTypeGridHtml(cardCounts) {
if (!cardCounts || Object.keys(cardCounts).length === 0) {
return '<span style="color:var(--color-muted);font-size:0.85rem;">Keine Karten mehr im Stapel.</span>';
}
const items = Object.entries(cardCounts)
.filter(([, n]) => n > 0)
.map(([type, n]) => {
const info = CARD_LABELS[type] || { img: '/img/card.png', name: type };
return `<div class="card-type-item">
<img src="${info.img}" alt="${info.name}">
<span class="card-type-badge">${n}</span>
</div>`;
}).join('');
return items
? `<div class="card-type-grid">${items}</div>`
: '<span style="color:var(--color-muted);font-size:0.85rem;">Keine Karten mehr im Stapel.</span>';
}

190
bin/main/static/js/icons.js Normal file
View File

@@ -0,0 +1,190 @@
/**
* Zentrale Icon-Verwaltung XXX The Game
*
* Typen:
* emoji Standard-Emoji oder Unicode-Zeichen (value: string)
* symbol Unicode-Symbol (value: string)
* image Pfad zu einer Bilddatei (value: string)
* compound Doppel-Icon: base-Icon + kleines Overlay-Icon (bottom-right)
* Felder: base { type, value }, overlay { type, value }
* base kann emoji, symbol oder image sein.
*/
window.ICONS = {
// ── Navigation / Sidebar ──────────────────────────────────────────────
HOME: { type: 'emoji', value: '🏠' },
VANILLA: { type: 'emoji', value: '⚪' },
BDSM: { type: 'emoji', value: '⛓️' },
CHASTITY: { type: 'emoji', value: '🔒' },
// ── Aktionen ──────────────────────────────────────────────────────────
PLAY_NEW: { type: 'emoji', value: '🆕' },
PLAY_ACTIVE: { type: 'emoji', value: '▶️' },
ACTIVE_LOCK: { type: 'emoji', value: '▶️' },
WAITING: { type: 'emoji', value: '⏳' },
CHECK: { type: 'emoji', value: '✅' },
DISCOVER: { type: 'emoji', value: '🗺️' },
ARROW: { type: 'emoji', value: '▶️' },
REFRESH: { type: 'emoji', value: '🔄' }, // Erneuern / Neu laden
START: { type: 'emoji', value: '🚀' }, // Starten / Los
CELEBRATE: { type: 'emoji', value: '🎉' }, // Erfolg / Abschluss
// ── UI-Symbole ────────────────────────────────────────────────────────
CLOSE: { type: 'symbol', value: '✕' }, // Schließen / Ablehnen / Löschen
CONFIRM: { type: 'symbol', value: '✓' }, // Bestätigen / Abschließen / Annehmen
LIKE: { type: 'symbol', value: '♥' }, // Like-Button
AVATAR: { type: 'symbol', value: '◉' }, // Avatar-Platzhalter (kein Bild)
ERROR: { type: 'emoji', value: '❌' }, // Fehlerzustand
TIMER: { type: 'emoji', value: '⏱️' }, // Zeitanzeige / Stoppuhr
LIGHTNING: { type: 'emoji', value: '⚡' }, // Aktion (z. B. Zeit entfernen)
EMOJI_PICKER: { type: 'emoji', value: '😊' }, // Emoji-Picker öffnen
REMOVE: { type: 'symbol', value: '⊗' }, // Eintrag/Spiel entfernen
EDIT: { type: 'symbol', value: '✎' }, // Bearbeiten-Button
TRASH: { type: 'emoji', value: '🗑' }, // Löschen-Button
WARNING: { type: 'emoji', value: '⚠️' }, // Warnung / Hinweis
REPORT: { type: 'symbol', value: '⚑' }, // Melden-Button (Flag)
VISIBILITY: { type: 'emoji', value: '👁' }, // Sichtbar / Details sichtbar
THUMBS_UP: { type: 'emoji', value: '👍' }, // Upvote / Zustimmung
THUMBS_DOWN: { type: 'emoji', value: '👎' }, // Downvote / Ablehnung
ARROW_UP: { type: 'symbol', value: '⬆' }, // Sortierung aufsteigend
ARROW_DOWN: { type: 'symbol', value: '⬇' }, // Sortierung absteigend
NAV_PREV: { type: 'symbol', value: '←' }, // Zurück / Vorheriges Bild
NAV_NEXT: { type: 'symbol', value: '→' }, // Weiter / Nächstes Bild
CAROUSEL_PREV: { type: 'symbol', value: '' }, // Karussell zurück
CAROUSEL_NEXT: { type: 'symbol', value: '' }, // Karussell weiter
TIP: { type: 'emoji', value: '💡' }, // Hinweis / Tipp
DOT_RED: { type: 'emoji', value: '🔴' }, // Status-Indikator rot
COMING_SOON: { type: 'emoji', value: '🚧' }, // In Entwicklung / Demnächst
// ── Chastity Game ─────────────────────────────────────────────────────
NEW_LOCK: { type: 'emoji', value: '🆕' },
LOCK: { type: 'emoji', value: '🔒' },
UNLOCK: { type: 'emoji', value: '🔓' }, // Entsperren
LOCKED_SECURE: { type: 'emoji', value: '🔐' }, // Sicher gesperrt (mit Schlüssel)
KEY: { type: 'emoji', value: '🔑' },
HISTORY: { type: 'emoji', value: '🔙' },
VOTES: { type: 'emoji', value: '🗳️' },
TRUST: { type: 'emoji', value: '🤝' }, // Trust-Lock
EMERGENCY: { type: 'emoji', value: '🆘' }, // Notfall-Entsperrung
HYGIENE: { type: 'emoji', value: '🚿' }, // Hygiene-Öffnung
FROZEN: { type: 'emoji', value: '❄️' }, // Eingefroren (zeitlich)
FROZEN_HARD: { type: 'emoji', value: '🧊' }, // Eingefroren (unlimitiert)
UNFREEZE: { type: 'emoji', value: '🌊' }, // Aufgetaut / Unfreeze
CODE_DIGITS: { type: 'emoji', value: '🔢' }, // Zahlenkombination / PIN-Länge
// ── CardLock ──────────────────────────────────────────────────────────
CARD: { type: 'emoji', value: '🃏' }, // Karte (standalone)
DICE: { type: 'emoji', value: '🎲' }, // Zufällig / Würfeln
// ── TimeLock / Spinning Wheel ──────────────────────────────────────────
SPINNING_WHEEL: { type: 'emoji', value: '🎡' }, // Glücksrad drehen
TASK_ACTIVE: { type: 'emoji', value: '🎯' }, // Aktuelle Aufgabe
CLOCK: { type: 'emoji', value: '🕐' }, // Uhr / Zeitpunkt
// ── Social ────────────────────────────────────────────────────────────
FEED: { type: 'emoji', value: '📰' },
SEARCH: { type: 'emoji', value: '🔍' },
FRIENDS: { type: 'emoji', value: '❤️' },
MESSAGES: { type: 'emoji', value: '💬' },
NOTIFICATIONS: { type: 'emoji', value: '🔔' },
GROUPS: { type: 'emoji', value: '👥' },
INVITATIONS: { type: 'emoji', value: '✨' },
SETTINGS: { type: 'emoji', value: '⚙️' },
LOGOUT: { type: 'emoji', value: '⏏️' },
PROFILE: { type: 'emoji', value: '👤' },
HELP: { type: 'emoji', value: '❓' },
CONTACT: { type: 'emoji', value: '✉️' }, // Kontakt / E-Mail
// ── Medien / Dateien ──────────────────────────────────────────────────
PHOTO: { type: 'emoji', value: '📷' }, // Foto / Kamera
FILE_UPLOAD: { type: 'emoji', value: '📁' }, // Datei auswählen / Upload
TEMPLATE: { type: 'emoji', value: '📋' }, // Vorlage / Template
DOCUMENT: { type: 'emoji', value: '📄' }, // Dokument / Impressum
GUIDE: { type: 'emoji', value: '📖' }, // Anleitung / Hilfeseite
STATS: { type: 'emoji', value: '📊' }, // Statistik / Umfrage-Ergebnis
PACKAGE: { type: 'emoji', value: '📦' }, // Paket / Einladung
MAILBOX: { type: 'emoji', value: '📬' }, // Posteingang (Admin)
// ── Abo / Premium ─────────────────────────────────────────────────────
PREMIUM: { type: 'emoji', value: '⭐' }, // Abonnement / Premium
TROPHY: { type: 'emoji', value: '🏆' }, // Auszeichnung / Erfolg
PAYMENT: { type: 'emoji', value: '💳' }, // Zahlung / Abonnement
// ── TTLock / Technik ──────────────────────────────────────────────────
MOBILE: { type: 'emoji', value: '📱' }, // TTLock-App / Mobilgerät
CONNECTION: { type: 'emoji', value: '🔌' }, // Verbindung / Integration
GAMEPAD: { type: 'emoji', value: '🕹️' }, // Spielsteuerung
SHIELD: { type: 'emoji', value: '🛡️' }, // Sicherheit / Datenschutz
ADMIN_TOOLS: { type: 'emoji', value: '🔧' }, // Admin / Werkzeuge
// ── Aufgaben / Items ──────────────────────────────────────────────────
TOYS: { type: 'emoji', value: '➰' },
// ── Spielhistorie Spieltypen ────────────────────────────────────────
GAME_BDSM: { type: 'emoji', value: '⛓️' },
GAME_VANILLA: { type: 'emoji', value: '❤️' },
// Doppel-Icons: großes Basis-Icon + kleines 🔒-Overlay
GAME_CARDLOCK: {
type: 'compound',
base: { type: 'image', value: '/img/card.png' },
overlay: { type: 'emoji', value: '🔒' }
},
GAME_TIMELOCK: {
type: 'compound',
base: { type: 'emoji', value: '⏰' },
overlay: { type: 'emoji', value: '🔒' }
},
// ── Spielhistorie Rollen-Badges ─────────────────────────────────────
ROLE_KEYHOLDER: { type: 'emoji', value: '🔑' },
ROLE_LOCKEE: { type: 'emoji', value: '🔒' },
};
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
/** Gibt den rohen Wert-String zurück (nur für einfache Icons; '' für compound). */
window.IC = function(key) {
const icon = window.ICONS[key];
return (icon && icon.type !== 'compound') ? (icon.value || '') : '';
};
/**
* Gibt ein fertiges HTML-Fragment zurück, das das Icon darstellt.
*
* @param {string} key Schlüssel aus window.ICONS
* @param {number} [size] Basisgröße in rem (Standard: 2.7)
* @returns {string} HTML-String
*/
window.IChtml = function(key, size) {
const icon = window.ICONS[key];
if (!icon) return '';
return _iconToHtml(icon, size != null ? size : 2.7);
};
function _iconToHtml(icon, size) {
switch (icon.type) {
case 'emoji':
case 'symbol':
return `<span style="font-size:${size}rem;line-height:1;">${icon.value}</span>`;
case 'image':
return `<img src="${icon.value}" style="width:${size}rem;height:${size}rem;object-fit:contain;display:block;" alt="">`;
case 'compound': {
const baseHtml = _compoundBase(icon.base, size);
const overlayHtml = _compoundOverlay(icon.overlay, size * 0.48);
return `<span style="position:relative;display:inline-block;line-height:1;">${baseHtml}${overlayHtml}</span>`;
}
default:
return '';
}
}
function _compoundBase(base, size) {
if (base.type === 'image') {
return `<img src="${base.value}" style="width:${size}rem;height:${size}rem;object-fit:contain;display:block;" alt="">`;
}
return `<span style="font-size:${size}rem;line-height:1;">${base.value}</span>`;
}
function _compoundOverlay(overlay, size) {
return `<span style="position:absolute;bottom:-2px;right:-4px;font-size:${size.toFixed(2)}rem;line-height:1;">${overlay.value}</span>`;
}

View File

@@ -0,0 +1,237 @@
// ─────────────────────────────────────────────────────────────────────────────
// image-viewer.js Universelle Bild-Lightbox
//
// Einbinden: <script src="/js/shared.js"></script> (vorher)
// <script src="/js/image-viewer.js"></script>
//
// Zwei Modi:
// Modus A Nur Bild (kein Like, keine Kommentare):
// imageViewer.open({ images: [{ src }] })
//
// Modus B Galerie mit Like + Kommentare:
// imageViewer.open({
// images: [{ src, id, likedByMe, likeCount }],
// index: 0,
// showLike: true,
// showComments: true,
// myUserId: '...',
// onLike: async (img) => {} // optional; sonst POST /social/profile-images/{id}/like
// })
//
// Globale Instanz: window.imageViewer
// ─────────────────────────────────────────────────────────────────────────────
class ImageViewer {
constructor() {
this._cfg = null;
this._idx = 0;
this.isOpen = false;
this._injectStyles();
this._injectHTML();
this._bindEvents();
}
// ── Öffentliche API ───────────────────────────────────────────────────────
open(cfg) {
this._cfg = cfg;
this._idx = cfg.index || 0;
this.isOpen = true;
const multi = cfg.images.length > 1;
const showLike = !!cfg.showLike;
const showCom = !!cfg.showComments;
this._q('ivPrev').style.display = multi ? '' : 'none';
this._q('ivNext').style.display = multi ? '' : 'none';
this._q('ivCounter').style.display = multi ? '' : 'none';
this._q('ivLikeBtn').style.display = showLike ? '' : 'none';
this._q('ivComments').style.display = showCom ? '' : 'none';
this._render();
this._q('imageViewer').classList.add('open');
this._updateLayout();
}
close() {
this._q('imageViewer').classList.remove('open');
this.isOpen = false;
this._cfg = null;
}
/** Kommentare im offenen Viewer neu laden (z.B. nach externem Löschen) */
reloadComments() {
if (this.isOpen && this._cfg?.showComments) this._loadComments();
}
// ── Internes Rendering ────────────────────────────────────────────────────
_q(id) { return document.getElementById(id); }
_render() {
const img = this._cfg.images[this._idx];
this._q('ivImg').src = img.src;
const total = this._cfg.images.length;
this._q('ivCounter').textContent = `${this._idx + 1} / ${total}`;
this._q('ivPrev').disabled = this._idx === 0;
this._q('ivNext').disabled = this._idx === total - 1;
if (this._cfg.showLike) this._syncLike();
if (this._cfg.showComments) this._loadComments();
}
_syncLike() {
const img = this._cfg.images[this._idx];
const btn = this._q('ivLikeBtn');
btn.className = 'btn-like' + (img.likedByMe ? ' liked' : '');
this._q('ivLikeCount').textContent = img.likeCount;
}
async _loadComments() {
const img = this._cfg.images[this._idx];
const res = await fetch(`/social/kommentare?targetType=IMAGE&targetId=${img.id}`);
const comments = await res.json();
const myUserId = this._cfg.myUserId || null;
this._q('ivCommentsList').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin-bottom:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, 'IMAGE', img.id, { myUserId, showReplies: true })).join('');
}
async _postComment() {
const input = this._q('ivCommentInput');
const text = input.value.trim();
if (!text) return;
const img = this._cfg.images[this._idx];
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType: 'IMAGE', targetId: img.id, text })
});
input.value = '';
await this._loadComments();
}
async _toggleLike() {
const img = this._cfg.images[this._idx];
const onLike = this._cfg.onLike;
img.likedByMe = !img.likedByMe;
img.likeCount += img.likedByMe ? 1 : -1;
this._syncLike();
try {
if (onLike) await onLike(img);
else await fetch('/social/profile-images/' + img.id + '/like', { method: 'POST' });
} catch {
img.likedByMe = !img.likedByMe;
img.likeCount += img.likedByMe ? 1 : -1;
this._syncLike();
}
}
_prev() { if (this._idx > 0) { this._idx--; this._render(); } }
_next() { if (this._idx < this._cfg.images.length - 1) { this._idx++; this._render(); } }
_updateLayout() {
const el = this._q('ivLayout');
if (!el) return;
const bp = parseInt(getComputedStyle(document.documentElement)
.getPropertyValue('--breakpoint-mobile').trim()) || 768;
el.classList.toggle('iv-narrow', window.innerWidth <= bp);
}
// ── CSS + HTML Injection ──────────────────────────────────────────────────
_injectStyles() {
if (document.getElementById('iv-styles')) return;
const s = document.createElement('style');
s.id = 'iv-styles';
s.textContent = `
#imageViewer{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.88);z-index:500;align-items:center;justify-content:center;padding:2rem}
#imageViewer.open{display:flex}
#ivLayout{display:flex;flex-direction:row;gap:1rem;height:min(78vh,660px);max-width:calc(100vw - 4rem);align-items:stretch}
#ivImageSide{width:660px;flex-shrink:1;min-width:0;display:flex;flex-direction:column}
.iv-image-box{flex:1;position:relative;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;overflow:hidden;display:flex;align-items:center;justify-content:center}
#ivImg{width:100%;height:100%;object-fit:contain;display:block}
.iv-overlay{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,0.6));border-radius:0 0 12px 12px;padding:2rem 0.75rem 0.6rem;display:flex;align-items:center;justify-content:space-between;gap:0.5rem}
.iv-nav-btn{background:rgba(0,0,0,0.35);border:1px solid rgba(255,255,255,0.2);border-radius:6px;color:#fff;padding:0.3rem 0.75rem;cursor:pointer;margin:0;width:auto;font-size:1rem;flex-shrink:0;transition:background 0.15s}
.iv-nav-btn:hover{background:rgba(0,0,0,0.65)}
.iv-nav-btn:disabled{opacity:.25;cursor:default}
.iv-overlay-center{display:flex;align-items:center;gap:0.6rem;flex:1;justify-content:center}
#ivCounter{font-size:0.8rem;color:rgba(255,255,255,0.75)}
.iv-close{position:fixed;top:1rem;right:1rem;background:rgba(0,0,0,0.55);border:1px solid rgba(255,255,255,0.2);color:#fff;font-size:1.1rem;width:2.2rem;height:2.2rem;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;margin:0;z-index:502;transition:background 0.15s}
.iv-close:hover{background:rgba(180,30,30,0.8)}
#ivComments{background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;width:280px;flex-shrink:0;display:flex;flex-direction:column;overflow:hidden}
.iv-comments-header{font-size:0.78rem;font-weight:600;color:var(--color-muted);text-transform:uppercase;letter-spacing:0.06em;padding:0.7rem 1rem;border-bottom:1px solid var(--color-secondary);flex-shrink:0}
#ivCommentsList{flex:1;overflow-y:auto;padding:0.65rem 0.75rem;scrollbar-width:thin;scrollbar-color:var(--color-secondary) transparent}
.iv-comment-compose{display:flex;gap:0.4rem;padding:0.65rem 0.75rem;border-top:1px solid var(--color-secondary);flex-shrink:0;align-items:center}
.iv-comment-compose input{flex:1;padding:0.4rem 0.65rem;font-size:0.85rem}
.iv-comment-compose button{width:auto;padding:0.4rem 0.7rem;font-size:0.82rem;white-space:nowrap}
#ivLayout.iv-narrow{flex-direction:column;height:auto;max-height:90vh;overflow-y:auto;width:calc(100vw - 1rem);max-width:calc(100vw - 1rem)}
#ivLayout.iv-narrow #ivImageSide{width:100%;flex-shrink:0}
#ivLayout.iv-narrow .iv-image-box{height:min(45vh,360px);flex:none}
#ivLayout.iv-narrow #ivComments{width:100%;max-height:40vh;flex-shrink:0}
`;
document.head.appendChild(s);
}
_injectHTML() {
if (document.getElementById('imageViewer')) return;
const div = document.createElement('div');
div.id = 'imageViewer';
div.innerHTML = `
<button class="iv-close" id="ivClose">✕</button>
<div id="ivLayout">
<div id="ivImageSide">
<div class="iv-image-box">
<img id="ivImg" src="" alt="">
<div class="iv-overlay">
<button class="iv-nav-btn" id="ivPrev">&#8592;</button>
<div class="iv-overlay-center">
<span id="ivCounter"></span>
<button class="btn-like" id="ivLikeBtn">♥ <span id="ivLikeCount">0</span></button>
</div>
<button class="iv-nav-btn" id="ivNext">&#8594;</button>
</div>
</div>
</div>
<div id="ivComments">
<div class="iv-comments-header">Kommentare</div>
<div id="ivCommentsList"></div>
<div class="iv-comment-compose">
<input type="text" id="ivCommentInput" placeholder="Kommentar schreiben…" maxlength="500">
<button type="button" onclick="toggleEmojiPicker(this,'ivCommentInput')" title="Emoji"
style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.35rem 0.6rem;font-size:0.95rem;cursor:pointer;margin:0;width:auto;">😊</button>
<button id="ivCommentSend">Senden</button>
</div>
</div>
</div>`;
document.body.appendChild(div);
}
_bindEvents() {
const init = () => {
this._q('ivClose').addEventListener('click', () => this.close());
this._q('imageViewer').addEventListener('click', e => {
if (e.target === this._q('imageViewer')) this.close();
});
this._q('ivPrev').addEventListener('click', () => this._prev());
this._q('ivNext').addEventListener('click', () => this._next());
this._q('ivLikeBtn').addEventListener('click', () => this._toggleLike());
this._q('ivCommentSend').addEventListener('click', () => this._postComment());
this._q('ivCommentInput').addEventListener('keydown', e => {
if (e.key === 'Enter') this._postComment();
});
window.addEventListener('resize', () => this._updateLayout());
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
document.addEventListener('keydown', e => {
if (!this.isOpen) return;
if (e.key === 'Escape') this.close();
if (e.key === 'ArrowLeft') this._prev();
if (e.key === 'ArrowRight') this._next();
});
}
}
window.imageViewer = new ImageViewer();

View File

@@ -0,0 +1,87 @@
/**
* Wiederverwendbares Meldungs-Modul.
* Bietet openMeldungDialog(zielTyp, zielId) und renderMeldenBtn(zielTyp, zielId).
*/
(function () {
// Dialog einmalig in den DOM einfügen
if (!document.getElementById('meldungDialog')) {
document.body.insertAdjacentHTML('beforeend', `
<div id="meldungDialog" style="
display:none; position:fixed; inset:0; z-index:9999;
background:rgba(0,0,0,0.6); align-items:center; justify-content:center;">
<div style="background:var(--color-card);border:1px solid var(--color-secondary);
border-radius:12px;padding:1.5rem;width:min(420px,90vw);position:relative;">
<h3 style="margin:0 0 1rem 0;color:var(--color-primary)">Inhalt melden</h3>
<p id="meldungDialogLabel" style="color:var(--color-muted);font-size:0.9rem;margin:0 0 0.75rem 0;"></p>
<textarea id="meldungGrund" placeholder="Grund (optional)"
style="width:100%;box-sizing:border-box;padding:0.5rem;border-radius:6px;
border:1px solid var(--color-secondary);background:var(--color-card);
color:var(--color-text);resize:vertical;min-height:80px;font-family:inherit;"></textarea>
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1rem;">
<button id="meldungAbbrechen" style="padding:0.45rem 1rem;border-radius:6px;
border:1px solid var(--color-secondary);background:transparent;
color:var(--color-text);cursor:pointer;">Abbrechen</button>
<button id="meldungSenden" style="padding:0.45rem 1rem;border-radius:6px;
border:none;background:var(--color-primary);color:#fff;cursor:pointer;font-weight:600;">
Melden</button>
</div>
<p id="meldungMsg" style="margin:0.5rem 0 0 0;font-size:0.85rem;color:var(--color-primary);display:none;"></p>
</div>
</div>
`);
document.getElementById('meldungAbbrechen').addEventListener('click', () => closeMeldungDialog());
document.getElementById('meldungDialog').addEventListener('click', function (e) {
if (e.target === this) closeMeldungDialog();
});
}
let _zielTyp = null, _zielId = null;
window.openMeldungDialog = function (zielTyp, zielId) {
_zielTyp = zielTyp;
_zielId = zielId;
document.getElementById('meldungGrund').value = '';
document.getElementById('meldungMsg').style.display = 'none';
document.getElementById('meldungDialogLabel').textContent =
zielTyp === 'PROFIL' ? 'Profil melden' : 'Post melden';
document.getElementById('meldungDialog').style.display = 'flex';
document.getElementById('meldungSenden').onclick = async function () {
const grund = document.getElementById('meldungGrund').value.trim();
const r = await fetch('/meldung', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zielTyp: _zielTyp, zielId: _zielId, grund: grund || null })
});
const msg = document.getElementById('meldungMsg');
msg.style.display = 'block';
if (r.status === 201) {
msg.style.color = 'var(--color-success, #2ecc71)';
msg.textContent = 'Meldung wurde übermittelt.';
setTimeout(closeMeldungDialog, 1500);
} else if (r.status === 409) {
msg.style.color = 'var(--color-primary)';
msg.textContent = 'Du hast diesen Inhalt bereits gemeldet.';
} else {
msg.style.color = 'var(--color-primary)';
msg.textContent = 'Fehler beim Senden.';
}
};
};
window.closeMeldungDialog = function () {
document.getElementById('meldungDialog').style.display = 'none';
};
/**
* Erzeugt einen kleinen "Melden"-Button-HTML-String.
* Verwendung: in innerHTML-Templates, wo onclick genutzt werden kann.
*/
window.renderMeldenBtn = function (zielTyp, zielId) {
return `<button onclick="openMeldungDialog('${zielTyp}','${zielId}')"
style="background:none;border:none;color:var(--color-muted,#888);
font-size:0.8rem;cursor:pointer;padding:0.2rem 0.4rem;border-radius:4px;"
title="Melden">⚑ Melden</button>`;
};
})();

View File

@@ -0,0 +1,237 @@
// ─────────────────────────────────────────────────────────────────────────────
// shared.js Gemeinsame Helfer & Komponenten
// Einbinden: <script src="/js/shared.js"></script>
// (vor allen Seiten-Skripten, nach CSS-Links)
// ─────────────────────────────────────────────────────────────────────────────
// ── CSS-Injection (Comment + Carousel) ────────────────────────────────────────
(function injectSharedStyles() {
if (document.getElementById('shared-styles')) return;
const s = document.createElement('style');
s.id = 'shared-styles';
s.textContent = `
/* ── Karussell ── */
.post-carousel{position:relative;margin-top:0.5rem}
.car-slide{display:none}
.car-slide.active{display:block}
.car-btn{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.55);border:none;color:#fff;font-size:2.2rem;width:auto;min-width:2.4rem;height:3.2rem;border-radius:8px;cursor:pointer;z-index:5;display:flex;align-items:center;justify-content:center;padding:0 0.5rem;margin:0;line-height:1}
.car-prev{left:0.3rem}
.car-next{right:0.3rem}
.car-indicator{text-align:center;font-size:0.75rem;color:var(--color-muted);margin-top:0.25rem}
/* ── Like / Löschen-Buttons ── */
.btn-like{background:none;border:1px solid rgba(255,255,255,0.15);border-radius:20px;padding:0.2rem 0.65rem;color:var(--color-muted);font-size:0.78rem;cursor:pointer;display:inline-flex;align-items:center;gap:0.3rem;margin:0;width:auto;transition:border-color 0.15s,color 0.15s}
.btn-like:hover,.btn-like.liked{border-color:var(--color-primary);color:var(--color-primary)}
.btn-delete-small{background:none;border:none;color:rgba(200,50,50,0.6);font-size:0.78rem;cursor:pointer;margin:0;width:auto;padding:0}
.btn-delete-small:hover{color:var(--color-primary)}
.btn-text{background:none;border:none;color:var(--color-muted);font-size:0.78rem;cursor:pointer;margin:0;width:auto;padding:0;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.2)}
.btn-text:hover{color:var(--color-text)}
/* ── Kommentare ── */
.comment-item{display:flex;gap:0.5rem;margin-bottom:0.5rem}
.comment-avatar{width:28px;height:28px;border-radius:50%;background:var(--color-secondary);display:flex;align-items:center;justify-content:center;font-size:0.75rem;flex-shrink:0;overflow:hidden}
.comment-avatar img{width:100%;height:100%;object-fit:cover}
.comment-body{flex:1;background:rgba(255,255,255,0.04);border-radius:6px;padding:0.5rem 0.65rem}
.comment-author{font-size:0.8rem;font-weight:600;color:var(--color-text)}
.comment-date{font-size:0.72rem;color:var(--color-muted);margin-left:0.4rem}
.comment-text{font-size:0.85rem;color:rgba(255,255,255,0.75);margin-top:0.2rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
.comment-actions{display:flex;gap:0.4rem;margin-top:0.3rem;align-items:center}
.replies-section{margin-top:0.5rem;padding-left:0.5rem;border-left:2px solid rgba(255,255,255,0.06)}
.comment-write{display:flex;gap:0.4rem;margin-top:0.5rem}
.comment-write input{flex:1;padding:0.4rem 0.75rem;font-size:0.85rem}
.comment-write button{width:auto;padding:0.4rem 0.75rem;font-size:0.82rem;white-space:nowrap}
`;
document.head.appendChild(s);
})();
// ── HTML-Escape ────────────────────────────────────────────────────────────────
function esc(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/\n/g, '<br>');
}
// ── Datum-Format ──────────────────────────────────────────────────────────────
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
// ── Emoji-Picker ──────────────────────────────────────────────────────────────
const EMOJIS = ['😊','😂','❤️','😍','🔥','👍','🥰','😎','🤔','😘','💕','🎉','✨','💋','😈','🫦','🍑','🍆','🔞','🥵','😭','😢','😤','🙄','🤦','🤷','🙏','💪','😏','🤩'];
let _emojiTarget = null;
function toggleEmojiPicker(btn, targetId) {
_emojiTarget = document.getElementById(targetId);
let picker = document.getElementById('sharedEmojiPicker');
if (!picker) {
picker = document.createElement('div');
picker.id = 'sharedEmojiPicker';
picker.style.cssText = 'position:fixed;z-index:9000;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:0.5rem;display:flex;flex-wrap:wrap;gap:0.2rem;max-width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.5);';
EMOJIS.forEach(em => {
const b = document.createElement('button');
b.textContent = em;
b.style.cssText = 'background:none;border:none;font-size:1.3rem;cursor:pointer;padding:0.2rem;margin:0;width:auto;line-height:1;';
b.onclick = e => { e.stopPropagation(); insertEmoji(em); };
picker.appendChild(b);
});
document.body.appendChild(picker);
}
if (picker.style.display === 'flex') { picker.style.display = 'none'; return; }
picker.style.display = 'flex';
requestAnimationFrame(() => {
const rect = btn.getBoundingClientRect();
const ph = picker.offsetHeight, pw = picker.offsetWidth;
let top = rect.top - ph - 8;
let left = rect.left;
if (top < 8) top = rect.bottom + 8;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
picker.style.top = top + 'px';
picker.style.left = left + 'px';
});
}
function insertEmoji(emoji) {
if (!_emojiTarget) return;
const start = _emojiTarget.selectionStart ?? _emojiTarget.value.length;
const end = _emojiTarget.selectionEnd ?? start;
_emojiTarget.value = _emojiTarget.value.slice(0, start) + emoji + _emojiTarget.value.slice(end);
_emojiTarget.selectionStart = _emojiTarget.selectionEnd = start + emoji.length;
_emojiTarget.focus();
}
document.addEventListener('click', e => {
const picker = document.getElementById('sharedEmojiPicker');
if (picker && picker.style.display === 'flex'
&& !picker.contains(e.target)
&& !e.target.closest('[onclick*="toggleEmojiPicker"]')) {
picker.style.display = 'none';
}
});
// ── Bild-Karussell ────────────────────────────────────────────────────────────
function bilderCarousel(bilder) {
if (!bilder || bilder.length === 0) return '';
if (bilder.length === 1) {
return `<div style="margin-top:0.5rem;"><img class="post-bild" src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>`;
}
const slides = bilder.map((b, i) =>
`<div class="car-slide${i === 0 ? ' active' : ''}"><img class="post-bild" src="data:image/jpeg;base64,${b}" alt=""></div>`
).join('');
return `<div class="post-carousel">
${slides}
<button class="car-btn car-prev" onclick="event.stopPropagation();carNav(this,-1)">&#8249;</button>
<button class="car-btn car-next" onclick="event.stopPropagation();carNav(this,1)">&#8250;</button>
<div class="car-indicator"><span class="car-cur">1</span>/${bilder.length}</div>
</div>`;
}
function carNav(btn, dir) {
const car = btn.closest('.post-carousel');
const slides = Array.from(car.querySelectorAll('.car-slide'));
const cur = slides.findIndex(s => s.classList.contains('active'));
slides[cur].classList.remove('active');
const next = (cur + dir + slides.length) % slides.length;
slides[next].classList.add('active');
const ind = car.querySelector('.car-cur');
if (ind) ind.textContent = next + 1;
}
// ── Kommentar-Rendering ───────────────────────────────────────────────────────
// opts: { myUserId, showReplies }
// Seite muss definieren: deleteKommentar(kommentarId, targetType, targetId)
function renderKommentarHtml(k, targetType, targetId, opts) {
const { myUserId = null, showReplies = false } = opts || {};
const avatarHtml = k.authorPicture
? `<img src="data:image/png;base64,${k.authorPicture}" alt="">`
: '◉';
const canDelete = k.authorId === myUserId;
const replyLabel = k.replyCount > 0 ? `Antworten (${k.replyCount})` : 'Antworten';
return `<div class="comment-item" id="kom-${k.kommentarId}">
<div class="comment-avatar">${avatarHtml}</div>
<div class="comment-body">
<span class="comment-author">${esc(k.authorName)}</span>
<span class="comment-date">${fmtDate(k.createdAt)}</span>
<div class="comment-text">${esc(k.text)}</div>
<div class="comment-actions">
<button class="btn-like${k.likedByMe ? ' liked' : ''}" id="lk-kom-${k.kommentarId}"
onclick="toggleKommentarLike('${k.kommentarId}')">♥ <span id="lkc-kom-${k.kommentarId}">${k.likeCount}</span></button>
${showReplies ? `<button class="btn-text" onclick="toggleReplies('${k.kommentarId}')">${replyLabel}</button>` : ''}
${canDelete ? `<button class="btn-delete-small" onclick="deleteKommentar('${k.kommentarId}','${targetType}','${targetId}')">✕</button>` : ''}
</div>
${showReplies ? `<div class="replies-section" id="replies-${k.kommentarId}" style="display:none;"></div>` : ''}
</div>
</div>`;
}
function renderReplyHtml(r, parentId) {
const avatarHtml = r.authorPicture
? `<img src="data:image/png;base64,${r.authorPicture}" alt="">`
: '◉';
const canDelete = typeof window.myUserId !== 'undefined' && r.authorId === window.myUserId;
return `<div class="comment-item" id="kom-${r.kommentarId}" style="margin-bottom:0.35rem;">
<div class="comment-avatar" style="width:22px;height:22px;font-size:0.75rem;">${avatarHtml}</div>
<div class="comment-body" style="padding:0.35rem 0.55rem;">
<span class="comment-author">${esc(r.authorName)}</span>
<span class="comment-date">${fmtDate(r.createdAt)}</span>
<div class="comment-text">${esc(r.text)}</div>
<div class="comment-actions">
<button class="btn-like${r.likedByMe ? ' liked' : ''}" id="lk-kom-${r.kommentarId}"
onclick="toggleKommentarLike('${r.kommentarId}')">♥ <span id="lkc-kom-${r.kommentarId}">${r.likeCount}</span></button>
${canDelete ? `<button class="btn-delete-small" onclick="deleteReply('${r.kommentarId}','${parentId}')">✕</button>` : ''}
</div>
</div>
</div>`;
}
async function toggleKommentarLike(kommentarId) {
await fetch('/social/kommentare/' + kommentarId + '/like', { method: 'POST' });
const btn = document.getElementById('lk-kom-' + kommentarId);
const lc = document.getElementById('lkc-kom-' + kommentarId);
if (!btn || !lc) return;
const was = btn.classList.contains('liked');
btn.classList.toggle('liked', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
}
async function toggleReplies(kommentarId) {
const section = document.getElementById('replies-' + kommentarId);
if (section.style.display === 'none') {
section.style.display = '';
await loadReplies(kommentarId);
} else {
section.style.display = 'none';
}
}
async function loadReplies(kommentarId) {
const res = await fetch(`/social/kommentare?targetType=KOMMENTAR&targetId=${kommentarId}`);
const replies = await res.json();
const section = document.getElementById('replies-' + kommentarId);
section.innerHTML = (replies.length === 0
? '<p style="color:var(--color-muted);font-size:0.78rem;margin-bottom:0.35rem;">Noch keine Antworten.</p>'
: replies.map(r => renderReplyHtml(r, kommentarId)).join(''))
+ `<div class="comment-write">
<input type="text" id="ri-${kommentarId}" placeholder="Antwort schreiben…" maxlength="500"
onkeydown="if(event.key==='Enter') postReply('${kommentarId}')">
<button onclick="postReply('${kommentarId}')">Senden</button>
</div>`;
}
async function postReply(kommentarId) {
const input = document.getElementById('ri-' + kommentarId);
const text = input.value.trim();
if (!text) return;
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType: 'KOMMENTAR', targetId: kommentarId, text })
});
input.value = '';
await loadReplies(kommentarId);
}
async function deleteReply(replyId, parentId) {
await fetch('/social/kommentare/' + replyId, { method: 'DELETE' });
await loadReplies(parentId);
}

View File

@@ -0,0 +1,254 @@
(function () {
const path = window.location.pathname;
const I = window.IC || function() { return ''; };
const groups = [
{
label: 'Vanilla Game',
icon: I('VANILLA'),
items: [
{ href: '/games/vanilla/neuvanilla.html', icon: I('PLAY_NEW'), label: 'Neue Session', id: 'navVanillaNeu' },
{ href: '#', icon: I('WAITING'), label: 'Aktive Session', id: 'navVanillaAktiv' },
{ href: '/games/vanilla/vanillaingame.html', icon: I('PLAY_ACTIVE'), label: 'Im Spiel', id: 'navVanillaImSpiel' },
{ href: '/games/common/aufgaben.html', icon: I('CHECK'), label: 'Aufgaben' },
{ href: '/games/common/toys.html', icon: I('TOYS'), label: 'Toys' },
{ href: '/games/chastity/entdecken.html', icon: I('DISCOVER'), label: 'Entdecken' },
]
},
{
label: 'BDSM Game',
icon: I('BDSM'),
items: [
{ href: '/games/bdsm/neubdsm.html', icon: I('PLAY_NEW'), label: 'Neue Session', id: 'navBdsmNeu' },
{ href: '#', icon: I('WAITING'), label: 'Aktive Session', id: 'navBdsmAktiv' },
{ href: '/games/bdsm/bdsmingame.html', icon: I('PLAY_ACTIVE'), label: 'Im Spiel', id: 'navBdsmImSpiel' },
{ href: '/games/common/aufgaben.html?mode=bdsm', icon: I('CHECK'), label: 'Aufgaben' },
{ href: '/games/common/toys.html', icon: I('TOYS'), label: 'Toys' },
{ href: '/games/chastity/entdecken.html', icon: I('DISCOVER'), label: 'Entdecken' },
]
},
{
label: 'Chastity Game',
icon: I('CHASTITY'),
items: [
{ href: '/games/chastity/neulock.html', icon: I('NEW_LOCK'), label: 'Neues Lock', id: 'navChastityNeu' },
{ href: '#', icon: I('ACTIVE_LOCK'), label: 'Aktives Lock', id: 'navChastityAktiv' },
{ href: '/games/chastity/communityvotes.html', icon: I('VOTES'), label: 'Community Votes' },
{ href: '/games/chastity/meine-locks.html', icon: I('LOCK'), label: 'Meine Vorlagen' },
{ href: '/games/chastity/entdecken-vorlagen.html', icon: I('DISCOVER'), label: 'Entdecken' },
{ href: '/games/chastity/keyholder-finden.html', icon: I('FRIENDS'), label: 'Keyholder finden' },
{ href: '/games/chastity/keyholder.html', icon: I('KEY'), label: 'Keyholder' },
{ href: '/games/chastity/unlock-history.html', icon: I('HISTORY'), label: 'Code-Historie' },
]
},
];
const homeCls = path === '/userhome.html' ? ' class="active"' : '';
const homeItem = `
<li class="sidebar-mobile-only">
<a href="/userhome.html"${homeCls}><span class="icon">${I('HOME')}</span> Home</a>
</li>`;
// ── Community-Links (immer sichtbar, oberhalb der Spiele) ──
const socialLinks = [
{ href: '/community/feed.html', icon: I('FEED'), label: 'Feed', badgeId: null },
{ href: '/community/freunde.html', icon: I('FRIENDS'), label: 'Freunde', badgeId: 'socialFriendsBadge'},
{ href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten', badgeId: null },
{ href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen', badgeId: null },
{ href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge'},
{ href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen', badgeId: null },
];
const socialNav = socialLinks.map(({ href, icon, label, badgeId }) => {
const cls = path === href ? ' class="active"' : '';
const badge = badgeId ? `<span class="social-badge" id="${badgeId}" style="display:none;"></span>` : '';
return `<li><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}${badge}</a></li>`;
}).join('');
const fullHref = path + window.location.search;
const nav = groups.map(({ label, icon, items }) => {
const isOpen = items.some(item => item.href === path || item.href === fullHref);
const openCls = isOpen ? ' open' : '';
const subItems = items.map(({ href, icon: iIcon, label: iLabel, id: iId }) => {
const cls = (href === path || href === fullHref) ? ' class="active"' : '';
const idAt = iId ? ` id="${iId}"` : '';
return `<li${idAt}><a href="${href}"${cls}><span class="icon">${iIcon}</span> ${iLabel}</a></li>`;
}).join('');
return `
<li class="sidebar-group${openCls}">
<a class="sidebar-group-toggle"><span class="icon">${icon}</span> ${label}<span class="sidebar-arrow">${I('ARROW')}</span></a>
<ul class="sidebar-sub">
${subItems}
</ul>
</li>`;
}).join('');
const adminCls = path === '/admin/admin.html' ? ' class="active"' : '';
const adminItem = `<li id="navAdminLink" style="display:none"><a href="/admin/admin.html"${adminCls}><span class="icon">${I('ADMIN') || '⚙'}</span> Administration</a></li>`;
const footerLinks = [
{ href: '/help/kontakt.html', icon: '✉️', label: 'Kontakt & Feedback' },
{ href: '/help/impressum.html', icon: '📄', label: 'Impressum' },
];
const footerNav = footerLinks.map(({ href, icon, label }) => {
const cls = path === href ? ' class="active"' : '';
return `<li><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}</a></li>`;
}).join('');
document.body.insertAdjacentHTML('afterbegin', `
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<button class="burger" id="burgerBtn" aria-label="Menü öffnen">
<span class="burger-icon"><span></span><span></span><span></span></span>
</button>
<div class="sidebar-wrapper" id="sidebar">
<aside class="sidebar">
<div class="sidebar-scroll-area">
<ul>
${socialNav}
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>
${nav}
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;" id="navAdminDivider" style="display:none"></li>
${adminItem}
</ul>
</div>
</aside>
<div class="sidebar-footer">
<ul>${footerNav}</ul>
</div>
</div>
`);
// Sidebar und .main in einen zentrierten App-Wrapper verschieben
const appWrapper = document.createElement('div');
appWrapper.className = 'app-wrapper';
const sidebarEl = document.getElementById('sidebar');
const mainEl = document.querySelector('.main');
document.body.insertBefore(appWrapper, sidebarEl);
appWrapper.appendChild(sidebarEl);
if (mainEl) appWrapper.appendChild(mainEl);
// Group toggle
document.querySelectorAll('.sidebar-group-toggle').forEach(toggle => {
toggle.addEventListener('click', e => {
e.preventDefault();
toggle.closest('.sidebar-group').classList.toggle('open');
});
});
// "Im Spiel" und "Aktive Session" standardmäßig ausblenden; wird nach Session-Check ggf. wieder eingeblendet
const navNeu = document.getElementById('navBdsmNeu');
const navAktiv = document.getElementById('navBdsmAktiv');
const navImSpiel = document.getElementById('navBdsmImSpiel');
const navCAktiv = document.getElementById('navChastityAktiv');
const navVNeu = document.getElementById('navVanillaNeu');
const navVAktiv = document.getElementById('navVanillaAktiv');
const navVImSpiel = document.getElementById('navVanillaImSpiel');
if (navAktiv) navAktiv.style.display = 'none';
if (navImSpiel) navImSpiel.style.display = 'none';
if (navCAktiv) navCAktiv.style.display = 'none';
if (navVAktiv) navVAktiv.style.display = 'none';
if (navVImSpiel) navVImSpiel.style.display = 'none';
// Session-Status prüfen
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(async user => {
if (!user) return;
// BDSM Session-Status
try {
const aktivRes = await fetch('/bdsm/einladung/meine-aktive');
if (aktivRes.ok) {
const aktiv = await aktivRes.json();
if (navNeu) navNeu.style.display = 'none';
if (navImSpiel) navImSpiel.style.display = 'none';
if (navAktiv) {
navAktiv.style.display = '';
navAktiv.querySelector('a').href = aktiv.sessionId ? '/games/bdsm/bdsmingame.html' : '/games/bdsm/neubdsm.html';
}
} else {
const sessionRes = await fetch(`/bdsm?userId=${user.userId}`);
const hasSession = sessionRes.status === 200;
if (navNeu) navNeu.style.display = hasSession ? 'none' : '';
if (navImSpiel) navImSpiel.style.display = hasSession ? '' : 'none';
}
} catch (_) {}
// Vanilla Session-Status
try {
const vAktivRes = await fetch('/vanilla/einladung/meine-aktive');
if (vAktivRes.ok) {
const vAktiv = await vAktivRes.json();
if (navVNeu) navVNeu.style.display = 'none';
if (navVImSpiel) navVImSpiel.style.display = 'none';
if (navVAktiv) {
navVAktiv.style.display = '';
navVAktiv.querySelector('a').href = vAktiv.sessionId ? '/games/vanilla/vanillaingame.html' : '/games/vanilla/neuvanilla.html';
}
} else {
const vSessionRes = await fetch(`/vanilla?userId=${user.userId}`);
const vHasSession = vSessionRes.status === 200;
if (navVNeu) navVNeu.style.display = vHasSession ? 'none' : '';
if (navVImSpiel) navVImSpiel.style.display = vHasSession ? '' : 'none';
}
} catch (_) {}
// Chastity Lock-Status
try {
const lockRes = await fetch('/keyholder/mylock');
if (lockRes.ok) {
const lockData = await lockRes.json();
if (navCAktiv) {
navCAktiv.style.display = '';
navCAktiv.querySelector('a').href = '/games/chastity/activelock.html?lockId=' + lockData.lockId;
}
}
} catch (_) {}
// Admin-Link
if (user.admin) {
const navAdminLink = document.getElementById('navAdminLink');
const navAdminDivider = document.getElementById('navAdminDivider');
if (navAdminLink) navAdminLink.style.display = '';
if (navAdminDivider) navAdminDivider.style.display = '';
}
})
.catch(() => {});
const sidebar = document.getElementById('sidebar');
const burgerBtn = document.getElementById('burgerBtn');
const overlay = document.getElementById('sidebarOverlay');
function openMenu() {
sidebar.classList.add('open');
overlay.classList.add('visible');
burgerBtn.classList.add('open');
burgerBtn.setAttribute('aria-label', 'Menü schließen');
}
function closeMenu() {
sidebar.classList.remove('open');
overlay.classList.remove('visible');
burgerBtn.classList.remove('open');
burgerBtn.setAttribute('aria-label', 'Menü öffnen');
}
burgerBtn.addEventListener('click', () =>
sidebar.classList.contains('open') ? closeMenu() : openMenu()
);
overlay.addEventListener('click', closeMenu);
sidebar.querySelectorAll('a:not(.sidebar-group-toggle)').forEach(l =>
l.addEventListener('click', () => {
if (window.innerWidth <= (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--breakpoint-mobile').trim()) || 768))
closeMenu();
})
);
// Topbar und Social-Sidebar nachladen
function loadScript(src) {
const s = document.createElement('script');
s.src = src;
document.head.appendChild(s);
}
loadScript('/js/topbar.js');
loadScript('/js/social-sidebar.js');
})();

View File

@@ -0,0 +1,109 @@
(function () {
// Badge + SSE service (kein Sidebar-Rendering mehr)
// ── Badge-Zähler ──
function setBadge(ids, count, topbarType) {
ids.forEach(id => {
if (!id) return;
const el = document.getElementById(id);
if (!el) return;
el.textContent = count;
el.style.display = count > 0 ? '' : 'none';
});
if (topbarType && window.__topbarSetBadge) window.__topbarSetBadge(topbarType, count);
}
// ── Ton abspielen ──
let userHasInteracted = false;
document.addEventListener('click', () => { userHasInteracted = true; }, { passive: true });
document.addEventListener('keydown', () => { userHasInteracted = true; }, { passive: true });
document.addEventListener('touchstart', () => { userHasInteracted = true; }, { passive: true });
function playSound(src) {
if (!userHasInteracted) return;
try {
const audio = new Audio(src);
audio.volume = 0.6;
audio.play().catch(() => {});
} catch(e) {}
}
// ── Initiale Badge-Counts laden ──
fetch('/social/friends/pending/count')
.then(r => r.ok ? r.json() : 0)
.then(n => setBadge(['socialFriendsBadge'], n, null))
.catch(() => {});
fetch('/social/messages/unread/count')
.then(r => r.ok ? r.json() : 0)
.then(n => setBadge(['socialMsgBadge'], n, 'msg'))
.catch(() => {});
fetch('/notifications/unread/count')
.then(r => r.ok ? r.json() : 0)
.then(n => setBadge(['socialNotifBadge'], n, 'notif'))
.catch(() => {});
Promise.all([
fetch('/gruppen/requests/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/gruppen/reports/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0)
]).then(([joins, reports]) => setBadge(['socialGruppenBadge'], joins + reports, null))
.catch(() => {});
Promise.all([
fetch('/keyholder/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/lockee/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/bdsm/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/vanilla/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0)
]).then(([kh, lockee, bdsm, vanilla]) =>
setBadge(['socialInvBadge'], kh + lockee + bdsm + vanilla, 'inv')
).catch(() => {});
// ── SSE: Echtzeit-Push vom Server ──
function connectSse() {
const es = new EventSource('/events/stream');
es.addEventListener('DM', e => {
try {
const data = JSON.parse(e.data);
setBadge(['socialMsgBadge'], data.unreadCount || 0, 'msg');
if (window.location.pathname !== '/community/nachrichten.html') {
playSound('/audio/message.mp3');
}
if (typeof window.__sseOnDm === 'function') window.__sseOnDm(data);
} catch(ex) {}
});
es.addEventListener('NOTIFICATION', e => {
try {
const data = JSON.parse(e.data);
setBadge(['socialNotifBadge'], data.unreadCount || 0, 'notif');
if (window.location.pathname !== '/community/benachrichtigungen.html') {
playSound('/audio/notification.mp3');
}
if (typeof window.__sseOnNotification === 'function') window.__sseOnNotification(data);
} catch(ex) {}
});
es.addEventListener('INVITATION', () => {
try {
if (typeof window.__topbarReloadInvBadge === 'function') window.__topbarReloadInvBadge();
} catch(ex) {}
});
es.onerror = () => {
es.close();
// Vor dem Reconnect prüfen ob noch eingeloggt (verhindert Endlos-Schleife bei abgelaufener Session)
setTimeout(() => {
fetch('/login/me', { method: 'GET' })
.then(r => { if (r.ok) connectSse(); })
.catch(() => {});
}, 5000);
};
}
// SSE nur starten wenn authentifiziert verhindert Fehler-Spam bei nicht eingeloggten Seiten
fetch('/login/me', { method: 'GET' })
.then(r => { if (r.ok) connectSse(); })
.catch(() => {});
})();

View File

@@ -0,0 +1,405 @@
(function () {
if (document.querySelector('.topbar')) return;
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Warten bis app-wrapper existiert (sidebar.js läuft synchron davor) ──
function init() {
const appWrapper = document.querySelector('.app-wrapper');
if (!appWrapper) { setTimeout(init, 30); return; }
injectHTML(appWrapper);
loadProfile();
setupSearch();
setupOverlayButtons();
loadInitialBadges();
}
setTimeout(init, 0);
// ── HTML Struktur ──
function injectHTML(appWrapper) {
const topbar = document.createElement('div');
topbar.className = 'topbar';
topbar.id = 'topbar';
topbar.innerHTML = `
<div class="topbar-left">
<a href="/userhome.html"><img class="topbar-banner" src="/img/banner.png" alt="xXx Sphere"></a>
</div>
<div class="topbar-search-wrap">
<span class="topbar-search-icon">${IC('SEARCH')}</span>
<input type="text" id="topbarSearchInput" placeholder="Suchen…" autocomplete="off" spellcheck="false">
<div class="topbar-search-overlay" id="topbarSearchOverlay"></div>
</div>
<div class="topbar-right">
<button class="topbar-btn" id="topbarMsgBtn" title="Nachrichten">
${IC('MESSAGES')}
<span class="topbar-badge" id="topbarMsgBadge"></span>
</button>
<button class="topbar-btn" id="topbarNotifBtn" title="Benachrichtigungen">
${IC('NOTIFICATIONS')}
<span class="topbar-badge" id="topbarNotifBadge"></span>
</button>
<button class="topbar-btn" id="topbarInvBtn" title="Einladungen">
${IC('INVITATIONS')}
<span class="topbar-badge" id="topbarInvBadge"></span>
</button>
<button class="topbar-btn topbar-profile-btn" id="topbarProfileBtn">
<span class="topbar-avatar-placeholder" id="topbarAvatarWrap">${IC('PROFILE')}</span>
<span class="topbar-username" id="topbarUsername">…</span>
</button>
</div>`;
appWrapper.insertAdjacentElement('beforebegin', topbar);
// Panel-Overlays am Ende von body einfügen
document.body.insertAdjacentHTML('beforeend', `
<div class="topbar-panel" id="topbarMsgPanel">
<div class="topbar-panel-header">
<span>${IC('MESSAGES')} Nachrichten</span>
<button class="topbar-panel-close" onclick="window.__topbarCloseAll()">✕</button>
</div>
<div class="topbar-panel-body" id="topbarMsgBody"></div>
<div class="topbar-panel-footer"><a href="/community/nachrichten.html">Alle Nachrichten →</a></div>
</div>
<div class="topbar-panel" id="topbarNotifPanel">
<div class="topbar-panel-header">
<span>${IC('NOTIFICATIONS')} Benachrichtigungen</span>
<button class="topbar-panel-close" onclick="window.__topbarCloseAll()">✕</button>
</div>
<div class="topbar-panel-body" id="topbarNotifBody"></div>
<div class="topbar-panel-footer"><a href="/community/benachrichtigungen.html">Alle anzeigen →</a></div>
</div>
<div class="topbar-panel" id="topbarInvPanel">
<div class="topbar-panel-header">
<span>${IC('INVITATIONS')} Einladungen</span>
<button class="topbar-panel-close" onclick="window.__topbarCloseAll()">✕</button>
</div>
<div class="topbar-panel-body" id="topbarInvBody"></div>
<div class="topbar-panel-footer"><a href="/games/common/einladungen.html">Alle anzeigen →</a></div>
</div>
<div class="topbar-panel" id="topbarProfilePanel">
<div class="topbar-panel-header">
<span>Konto</span>
<button class="topbar-panel-close" onclick="window.__topbarCloseAll()">✕</button>
</div>
<div class="topbar-panel-body topbar-profile-body">
<div class="topbar-profile-card">
<span id="topbarPanelAvatarWrap" style="font-size:2.5rem;line-height:1;">${IC('PROFILE')}</span>
<div>
<div id="topbarPanelName" style="font-weight:700;font-size:1rem;"></div>
</div>
</div>
<hr style="border:none;border-top:1px solid var(--color-secondary);margin:0;">
<nav class="topbar-profile-nav">
<a id="topbarProfileLink" href="/community/benutzer.html" class="topbar-profile-link">
<span>${IC('PROFILE')}</span> Mein Profil
</a>
<a href="/konto/einstellungen.html" class="topbar-profile-link">
<span>${IC('SETTINGS')}</span> Einstellungen
</a>
<a href="/help/overview.html" class="topbar-profile-link">
<span>${IC('HELP')}</span> Hilfe
</a>
<hr style="border:none;border-top:1px solid var(--color-secondary);margin:0;">
<a href="/login/logout" class="topbar-profile-link topbar-profile-link--danger">
<span>${IC('LOGOUT')}</span> Abmelden
</a>
</nav>
</div>
</div>
`);
}
function IC(key) { return window.IC ? window.IC(key) : (window.ICONS?.[key]?.value || ''); }
// ── Profil laden ──
function loadProfile() {
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
if (!user) return;
const nameEl = document.getElementById('topbarUsername');
if (nameEl) nameEl.textContent = user.name;
const avatarWrap = document.getElementById('topbarAvatarWrap');
if (avatarWrap && user.profilePicture) {
avatarWrap.innerHTML = `<img src="data:image/png;base64,${user.profilePicture}" class="topbar-avatar" alt="">`;
}
const panelName = document.getElementById('topbarPanelName');
if (panelName) panelName.textContent = user.name;
const panelAvatar = document.getElementById('topbarPanelAvatarWrap');
if (panelAvatar && user.profilePicture) {
panelAvatar.innerHTML = `<img src="data:image/png;base64,${user.profilePicture}" style="width:3rem;height:3rem;border-radius:50%;object-fit:cover;" alt="">`;
}
const profileLink = document.getElementById('topbarProfileLink');
if (profileLink && user.userId) profileLink.href = '/community/benutzer.html?userId=' + user.userId;
})
.catch(() => {});
}
// ── Suche ──
function setupSearch() {
const input = document.getElementById('topbarSearchInput');
const overlay = document.getElementById('topbarSearchOverlay');
if (!input || !overlay) return;
let timer;
input.addEventListener('input', () => {
clearTimeout(timer);
const q = input.value.trim();
if (q.length < 2) { overlay.innerHTML = ''; overlay.classList.remove('open'); return; }
overlay.innerHTML = '<div class="topbar-search-hint">Suche…</div>';
overlay.classList.add('open');
timer = setTimeout(() => doSearch(q, overlay), 300);
});
document.addEventListener('click', e => {
if (!e.target.closest('.topbar-search-wrap')) {
overlay.classList.remove('open');
}
});
}
async function doSearch(q, overlay) {
try {
const res = await fetch('/social/users/search?q=' + encodeURIComponent(q));
if (!res.ok) { overlay.innerHTML = '<div class="topbar-search-hint">Fehler bei der Suche.</div>'; return; }
const users = await res.json();
if (!users || users.length === 0) {
overlay.innerHTML = '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
return;
}
overlay.innerHTML = users.map(u => {
const av = u.profilePicture
? `<img src="data:image/png;base64,${esc(u.profilePicture)}" class="topbar-search-avatar" alt="">`
: `<span class="topbar-search-avatar topbar-search-avatar--placeholder">${IC('PROFILE')}</span>`;
return `<a href="/community/benutzer.html?userId=${esc(u.userId)}" class="topbar-search-result">
${av}
<span style="font-size:0.92rem;font-weight:600;">${esc(u.name)}</span>
</a>`;
}).join('');
} catch (e) {
overlay.innerHTML = '<div class="topbar-search-hint">Fehler bei der Suche.</div>';
}
}
// ── Panel-Overlays ──
let _activePanel = null;
function positionPanel(panel, btn) {
const topbar = document.getElementById('topbar');
const tRect = topbar ? topbar.getBoundingClientRect() : btn.getBoundingClientRect();
panel.style.top = tRect.bottom + 'px';
panel.style.right = Math.max(4, window.innerWidth - tRect.right) + 'px';
panel.style.left = 'auto';
}
function openPanel(panelId, btnId, loadFn) {
const panel = document.getElementById(panelId);
const btn = document.getElementById(btnId);
if (!panel || !btn) return;
if (_activePanel === panel && panel.classList.contains('open')) {
closeAllPanels(); return;
}
closeAllPanels();
positionPanel(panel, btn);
panel.classList.add('open');
_activePanel = panel;
if (loadFn) loadFn();
}
function closeAllPanels() {
document.querySelectorAll('.topbar-panel.open').forEach(p => p.classList.remove('open'));
_activePanel = null;
}
window.__topbarCloseAll = closeAllPanels;
document.addEventListener('click', e => {
if (!e.target.closest('.topbar-panel') && !e.target.closest('.topbar-btn'))
closeAllPanels();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAllPanels(); });
function setupOverlayButtons() {
const msgBtn = document.getElementById('topbarMsgBtn');
const notifBtn = document.getElementById('topbarNotifBtn');
const invBtn = document.getElementById('topbarInvBtn');
const profileBtn = document.getElementById('topbarProfileBtn');
if (msgBtn) msgBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarMsgPanel', 'topbarMsgBtn', loadMessages); });
if (notifBtn) notifBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarNotifPanel', 'topbarNotifBtn', loadNotifications); });
if (invBtn) invBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarInvPanel', 'topbarInvBtn', loadInvitations); });
if (profileBtn) profileBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarProfilePanel', 'topbarProfileBtn', null); });
}
// ── Nachrichten ──
async function loadMessages() {
const body = document.getElementById('topbarMsgBody');
if (!body) return;
body.innerHTML = '<div class="topbar-panel-hint">Wird geladen…</div>';
try {
const res = await fetch('/social/messages');
if (!res.ok) { body.innerHTML = '<div class="topbar-panel-hint">Keine Nachrichten.</div>'; return; }
const convos = await res.json();
if (!convos.length) { body.innerHTML = '<div class="topbar-panel-hint">Noch keine Nachrichten.</div>'; return; }
body.innerHTML = convos.slice(0, 7).map(c => {
const av = c.partner?.profilePicture
? `<img src="data:image/png;base64,${esc(c.partner.profilePicture)}" class="topbar-item-avatar" alt="">`
: `<span class="topbar-item-avatar topbar-item-avatar--placeholder">${IC('PROFILE')}</span>`;
const bold = c.unreadCount > 0 ? 'font-weight:700;' : '';
const badge = c.unreadCount > 0
? `<span class="topbar-item-badge">${c.unreadCount > 99 ? '99+' : c.unreadCount}</span>` : '';
return `<a href="/community/nachrichten.html?userId=${esc(c.partner?.userId)}" class="topbar-panel-item">
${av}
<div class="topbar-panel-item-body">
<div style="${bold}font-size:0.88rem;">${esc(c.partner?.name || '')}</div>
<div class="topbar-panel-item-sub">${esc(c.lastMessage?.text || '')}</div>
</div>
${badge}
</a>`;
}).join('');
} catch (e) { body.innerHTML = '<div class="topbar-panel-hint">Fehler beim Laden.</div>'; }
}
// ── Benachrichtigungen ──
async function loadNotifications() {
const body = document.getElementById('topbarNotifBody');
if (!body) return;
body.innerHTML = '<div class="topbar-panel-hint">Wird geladen…</div>';
try {
const res = await fetch('/notifications');
if (!res.ok) { body.innerHTML = '<div class="topbar-panel-hint">Keine Benachrichtigungen.</div>'; return; }
const unread = (await res.json()).filter(n => !n.read);
if (!unread.length) { body.innerHTML = '<div class="topbar-panel-hint">Keine neuen Benachrichtigungen.</div>'; return; }
body.innerHTML = '';
unread.forEach(n => {
const el = document.createElement('div');
const tag = n.targetUrl ? 'a' : 'div';
const href = n.targetUrl ? `href="${esc(n.targetUrl)}"` : '';
const av = n.senderAvatar
? `<img src="data:image/png;base64,${esc(n.senderAvatar)}" class="topbar-item-avatar" alt="">`
: `<span class="topbar-item-avatar topbar-item-avatar--placeholder">${IC('PROFILE')}</span>`;
el.innerHTML = `<${tag} ${href} class="topbar-panel-item topbar-notif-item${n.read ? '' : ' topbar-notif-item--unread'}">
${av}
<div class="topbar-panel-item-body">
<div style="font-size:0.85rem;line-height:1.4;">${esc(n.text)}</div>
<div class="topbar-panel-item-sub">${n.sentAt ? new Date(n.sentAt).toLocaleString('de-DE',{dateStyle:'short',timeStyle:'short'}) : ''}</div>
</div>
</${tag}>`;
body.appendChild(el.firstElementChild);
});
// Alle als gelesen markieren
fetch('/notifications/read-all', { method: 'POST' }).then(() => setTopbarBadge('notif', 0)).catch(() => {});
} catch (e) { body.innerHTML = '<div class="topbar-panel-hint">Fehler beim Laden.</div>'; }
}
window.__topbarMarkNotifRead = async function (id) {
try {
await fetch('/notifications/' + id + '/read', { method: 'POST' });
const el = document.querySelector(`.topbar-notif-item--unread[onclick*="${id}"]`);
if (el) el.classList.remove('topbar-notif-item--unread');
const r = await fetch('/notifications/unread/count');
if (r.ok) setTopbarBadge('notif', await r.json());
} catch (e) {}
};
window.__topbarMarkAllRead = async function () {
try {
await fetch('/notifications/read-all', { method: 'POST' });
setTopbarBadge('notif', 0);
loadNotifications();
} catch (e) {}
};
// ── Einladungen ──
async function loadInvitations() {
const body = document.getElementById('topbarInvBody');
if (!body) return;
body.innerHTML = '<div class="topbar-panel-hint">Wird geladen…</div>';
try {
const [lr, kr, br, vr] = await Promise.all([
fetch('/lockee/invitations/mine'),
fetch('/keyholder/invitations/mine'),
fetch('/bdsm/einladung/pending'),
fetch('/vanilla/einladung/pending')
]);
const lockee = lr.ok ? await lr.json() : [];
const kh = kr.ok ? await kr.json() : [];
const bdsm = br.ok ? await br.json() : [];
const vanilla = vr.ok ? await vr.json() : [];
const all = [
...lockee.map(i => ({ ...i, _type: 'lockee' })),
...kh.map(i => ({ ...i, _type: 'keyholder' })),
...bdsm.map(i => ({ ...i, _type: 'bdsm' })),
...vanilla.map(i => ({ ...i, _type: 'vanilla' }))
];
if (!all.length) { body.innerHTML = '<div class="topbar-panel-hint">Keine offenen Einladungen.</div>'; return; }
body.innerHTML = '';
all.forEach(inv => body.appendChild(buildInvCard(inv)));
} catch (e) { body.innerHTML = '<div class="topbar-panel-hint">Fehler beim Laden.</div>'; }
}
function buildInvCard(inv) {
let typeIcon, typeName, line;
if (inv._type === 'lockee') {
typeIcon = IC('LOCK'); typeName = 'Lockee-Einladung'; line = inv.lockName || 'Lock';
} else if (inv._type === 'keyholder') {
typeIcon = IC('KEY'); typeName = 'Keyholder-Einladung'; line = inv.lockName || 'Lock';
} else if (inv._type === 'vanilla') {
typeIcon = IC('INVITATIONS'); typeName = 'Vanilla Game'; line = inv.inviterName || 'Einladung';
} else {
typeIcon = IC('BDSM'); typeName = 'BDSM Game'; line = inv.senderName || 'Einladung';
}
const senderPic = inv.senderAvatar || inv.lockOwnerAvatar || inv.inviterAvatar;
const av = senderPic
? `<img src="data:image/png;base64,${esc(senderPic)}" class="topbar-item-avatar" alt="">`
: `<span class="topbar-item-avatar topbar-item-avatar--placeholder">${IC('PROFILE')}</span>`;
const div = document.createElement('div');
div.className = 'topbar-panel-item topbar-inv-card';
div.style.cursor = 'pointer';
div.innerHTML = `${av}
<div class="topbar-panel-item-body">
<div class="topbar-panel-item-sub">${typeIcon} ${typeName}</div>
<div style="font-weight:600;font-size:0.88rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(line)}</div>
</div>`;
div.addEventListener('click', () => { window.location.href = '/games/common/einladungen.html'; });
return div;
}
// ── Badge-Verwaltung ──
function setTopbarBadge(type, count) {
const map = { msg: 'topbarMsgBadge', notif: 'topbarNotifBadge', inv: 'topbarInvBadge' };
const el = document.getElementById(map[type]);
if (!el) return;
el.textContent = count > 99 ? '99+' : count;
el.style.display = count > 0 ? 'inline-block' : 'none';
}
// Für social-sidebar.js zugänglich
window.__topbarSetBadge = setTopbarBadge;
function reloadInvBadge() {
Promise.all([
fetch('/lockee/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/keyholder/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/bdsm/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/vanilla/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0)
]).then(([l, k, b, v]) => setTopbarBadge('inv', l + k + b + v)).catch(() => {});
}
window.__topbarReloadInvBadge = reloadInvBadge;
function loadInitialBadges() {
fetch('/social/messages/unread/count').then(r => r.ok ? r.json() : 0).then(n => setTopbarBadge('msg', n)).catch(() => {});
fetch('/notifications/unread/count').then(r => r.ok ? r.json() : 0).then(n => setTopbarBadge('notif', n)).catch(() => {});
reloadInvBadge();
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,766 @@
<!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>Profil xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.profile-picture-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.profile-picture {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--color-secondary);
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: var(--color-muted);
flex-shrink: 0;
}
.profile-picture img {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
}
.profile-field {
margin-bottom: 1.25rem;
}
.profile-field label {
margin-top: 0;
}
.profile-field-row {
display: flex;
align-items: stretch;
gap: 0.5rem;
}
.profile-field-row p {
flex: 1;
padding: 0.65rem 0.9rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
font-size: 1rem;
color: var(--color-text);
margin: 0;
}
.profile-field-row button {
flex-shrink: 0;
width: 175px;
padding: 0.65rem 0.75rem;
margin-top: 0;
white-space: nowrap;
}
input[type="file"] {
font-size: 0.85rem;
color: var(--color-muted);
}
/* ── Gallery ── */
.gallery-section-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 1.75rem 0 0.75rem;
border-top: 1px solid var(--color-secondary);
padding-top: 1.5rem;
}
.gallery-upload-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
/* ── Vorlieben Tabs ── */
.vl-tabs { display: flex; gap: 0; flex-wrap: wrap;
border-bottom: 1px solid var(--color-secondary); margin-bottom: 1rem; }
.vl-tab-btn { background: none; border: none; border-bottom: 3px solid transparent;
border-radius: 0; padding: 0.5rem 1rem; font-size: 0.85rem; font-weight: 600;
color: var(--color-muted); cursor: pointer; margin-bottom: -1px;
transition: color .15s, border-color .15s; white-space: nowrap; }
.vl-tab-btn:hover { color: var(--color-text); background: none; }
.vl-tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); }
.vl-tab-panel { display: none; }
.vl-tab-panel.active { display: block; }
/* ── Vorlieben Items ── */
.vorliebe-row {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; padding: 0.55rem 0.75rem; border-radius: 8px;
background: var(--color-card); border: 1px solid var(--color-secondary);
margin-bottom: 0.45rem;
}
.vorliebe-row-name { font-size: 0.9rem; flex: 1; min-width: 0; }
.vorliebe-smileys { display: flex; gap: 0.25rem; flex-shrink: 0; }
.vl-smiley {
display: inline-flex; align-items: center; cursor: pointer;
border: 2px solid transparent; border-radius: 6px;
padding: 0.1rem 0.15rem;
transition: border-color .15s, transform .1s;
user-select: none;
}
.vl-smiley img { height: 1.6rem; width: auto; display: block; }
.vl-smiley:hover { transform: scale(1.15); }
.vl-smiley.active { border-color: var(--vl-color); }
.vorlieben-hint { font-size: 0.82rem; color: var(--color-muted); margin-bottom: 1rem; }
#ownGallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.4rem;
margin-bottom: 1.5rem;
}
.own-thumb {
aspect-ratio: 1;
overflow: hidden;
border-radius: 6px;
position: relative;
background: var(--color-secondary);
cursor: pointer;
}
.own-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.own-thumb-delete {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0,0,0,0.6);
border: none;
color: #fff;
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
font-size: 0.75rem;
line-height: 1;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
transition: background 0.15s;
}
.own-thumb:hover .own-thumb-delete { display: flex; }
.own-thumb-delete:hover { background: rgba(180,30,30,0.85); }
.btn-delete-row {
display: flex;
justify-content: flex-end;
margin-bottom: 1.25rem;
}
.btn-delete {
width: 175px;
padding: 0.65rem 0.75rem;
margin-top: 0;
background: transparent;
border: 1px solid var(--color-secondary);
border-radius: 6px;
color: var(--color-muted);
font-size: 0.85rem;
font-weight: normal;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-delete:hover {
background: #3d0f1a;
border-color: var(--color-primary);
color: var(--color-primary);
}
/* ── Profile extras ── */
.profile-extras-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1rem;
margin-bottom: 1rem;
}
.profile-extras-grid .full-col {
grid-column: 1 / -1;
}
select {
width: 100%;
padding: 0.65rem 0.9rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
color: var(--color-text);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
appearance: none;
-webkit-appearance: none;
}
select:focus { border-color: var(--color-primary); }
textarea {
width: 100%;
padding: 0.65rem 0.9rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
color: var(--color-text);
font-size: 1rem;
outline: none;
resize: vertical;
min-height: 100px;
transition: border-color 0.2s;
font-family: inherit;
}
textarea:focus { border-color: var(--color-primary); }
.char-count {
font-size: 0.75rem;
color: var(--color-muted);
text-align: right;
margin-top: 0.25rem;
}
.char-count.over { color: var(--color-primary); }
/* ── Modal ── */
.modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-backdrop.visible {
display: flex;
}
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
}
.modal h2 {
color: var(--color-primary);
font-size: 1.2rem;
margin-bottom: 1.25rem;
}
.modal-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.25rem;
}
.modal-actions button {
flex: 1;
margin-top: 0;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="profile-picture-wrap">
<div class="profile-picture" id="profilePicDisplay"></div>
<input type="file" id="picFile" accept="image/*">
</div>
<!-- Freiwillige Profilangaben -->
<div class="gallery-section-label" style="margin-top:1.25rem;">Freiwillige Angaben</div>
<div class="profile-extras-grid">
<div>
<label>Alter</label>
<input type="text" id="profileAlter" readonly style="background:transparent;cursor:default;color:var(--color-muted);" placeholder="—">
</div>
<div>
<label>Größe (cm)</label>
<input type="number" id="profileGroesse" min="100" max="250" placeholder="—">
</div>
<div>
<label>Gewicht (kg)</label>
<input type="number" id="profileGewicht" min="30" max="300" placeholder="—">
</div>
<div>
<label>Geschlecht</label>
<select id="profileGeschlecht">
<option value="">— keine Angabe —</option>
<option value="WEIBLICH">weiblich</option>
<option value="DIVERS">divers</option>
<option value="MAENNLICH">männlich</option>
</select>
</div>
<div class="full-col">
<label>Neigung</label>
<select id="profileNeigung">
<option value="">— keine Angabe —</option>
<option value="DEVOT">devot</option>
<option value="EHER_DEVOT">eher devot</option>
<option value="SWITCHER">Switcher</option>
<option value="EHER_DOMINANT">eher dominant</option>
<option value="DOMINANT">dominant</option>
<option value="KEINES">keines</option>
</select>
</div>
<div class="full-col">
<label>Beziehungsstatus</label>
<select id="profileBeziehungsstatus">
<option value="">— keine Angabe —</option>
<option value="SINGLE">single</option>
<option value="IN_EINER_BEZIEHUNG">in einer Beziehung</option>
<option value="VERHEIRATET">verheiratet</option>
<option value="IN_EINER_OFFENEN_BEZIEHUNG">in einer offenen Beziehung</option>
<option value="IN_EINER_OFFENEN_EHE">in einer offenen Ehe</option>
</select>
</div>
<div class="full-col">
<label>Über mich</label>
<textarea id="profileBeschreibung" maxlength="600" placeholder="Erzähl etwas über dich…" oninput="updateCharCount()"></textarea>
<div class="char-count" id="charCount">0 / 600</div>
</div>
</div>
<div class="message" id="message"></div>
<button class="full-width" id="saveBtn" onclick="saveProfile()">Profil speichern</button>
<div class="gallery-section-label" style="margin-top:1.5rem;">Vorlieben</div>
<p class="vorlieben-hint">Wähle für jede Vorliebe aus, wie du dazu stehst. Nicht ausgefüllte Einträge werden nicht angezeigt.</p>
<div id="vorliebenSection"><p style="color:var(--color-muted);font-size:0.85rem;">Wird geladen…</p></div>
<div class="message" id="vorliebenMessage" style="margin-top:0.75rem;display:none;"></div>
<button class="full-width" id="saveVorliebenBtn" onclick="saveVorlieben()" style="margin-bottom:1.5rem;">Vorlieben speichern</button>
<div class="gallery-section-label">Meine Bilder</div>
<div class="gallery-upload-row">
<input type="file" id="galleryFile" accept="image/*" multiple style="display:none;" onchange="handleGalleryUpload(this.files)">
<button onclick="document.getElementById('galleryFile').click()">+ Bilder hochladen</button>
<span id="galleryUploadStatus" style="font-size:0.85rem;color:var(--color-muted);"></span>
</div>
<div id="ownGallery"></div>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/image-viewer.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
let currentPicture = null;
let currentPictureHq = null;
let pictureDirty = false;
let allGalleryImages = [];
fetch('/login/me')
.then(r => {
if (r.status === 401) { window.location.href = '/login.html'; return null; }
return r.json();
})
.then(user => {
if (!user) return;
if (user.profilePicture) {
currentPicture = user.profilePicture;
renderPicture(currentPicture);
}
// Fill optional profile fields
if (user.geburtsdatum) {
const birth = new Date(user.geburtsdatum);
const today = new Date();
const age = today.getFullYear() - birth.getFullYear()
- (today < new Date(today.getFullYear(), birth.getMonth(), birth.getDate()) ? 1 : 0);
document.getElementById('profileAlter').value = age + ' Jahre';
}
if (user.groesse) document.getElementById('profileGroesse').value = user.groesse;
if (user.gewicht) document.getElementById('profileGewicht').value = user.gewicht;
if (user.geschlecht) document.getElementById('profileGeschlecht').value = user.geschlecht;
if (user.neigung) document.getElementById('profileNeigung').value = user.neigung;
if (user.beziehungsstatus) document.getElementById('profileBeziehungsstatus').value = user.beziehungsstatus;
if (user.beschreibung) {
document.getElementById('profileBeschreibung').value = user.beschreibung;
updateCharCount();
}
myUserId = user.userId;
loadOwnGallery();
loadVorliebenEdit();
})
.catch(() => { window.location.href = '/login.html'; });
// Check if redirected back after email change
if (new URLSearchParams(window.location.search).get('emailChanged')) {
// Already handled by login.html redirect — nothing to do here
}
document.getElementById('picFile').addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
[currentPicture, currentPictureHq] = await Promise.all([toBase64(file, 96), toBase64(file, 1024)]);
pictureDirty = true;
renderPicture(currentPicture);
});
function renderPicture(base64) {
const el = document.getElementById('profilePicDisplay');
el.innerHTML = `<img src="data:image/png;base64,${base64}" alt="Profilbild">`;
}
function updateCharCount() {
const ta = document.getElementById('profileBeschreibung');
const el = document.getElementById('charCount');
const len = ta.value.length;
el.textContent = len + ' / 600';
el.classList.toggle('over', len > 600);
}
async function saveProfile() {
const btn = document.getElementById('saveBtn');
btn.disabled = true;
btn.textContent = 'Wird gespeichert…';
hideMessage();
const beschreibung = document.getElementById('profileBeschreibung').value.trim();
if (beschreibung.length > 600) {
showMessage('Die Beschreibung darf maximal 600 Zeichen lang sein.', 'error');
btn.disabled = false;
btn.textContent = 'Profil speichern';
return;
}
const toNullOrInt = id => {
const v = document.getElementById(id).value;
return v ? parseInt(v, 10) : null;
};
const toNullOrStr = id => {
const v = document.getElementById(id).value;
return v || null;
};
try {
const [picRes, profileRes] = await Promise.all([
pictureDirty ? fetch('/user/me/picture', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ picture: currentPicture, pictureHq: currentPictureHq })
}) : Promise.resolve({ ok: true }),
fetch('/user/me/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
groesse: toNullOrInt('profileGroesse'),
gewicht: toNullOrInt('profileGewicht'),
geschlecht: toNullOrStr('profileGeschlecht'),
neigung: toNullOrStr('profileNeigung'),
beziehungsstatus: toNullOrStr('profileBeziehungsstatus'),
beschreibung: beschreibung || null
})
})
]);
if (picRes.ok && profileRes.ok) {
showMessage('Gespeichert!', 'success');
} else {
showMessage(`Fehler beim Speichern.`, 'error');
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
console.error(err);
} finally {
btn.disabled = false;
btn.textContent = 'Profil speichern';
}
}
// ── Gallery ──
let myUserId = null;
async function loadOwnGallery() {
if (!myUserId) return;
const res = await fetch('/social/profile-images?userId=' + myUserId);
if (!res.ok) return;
const images = await res.json();
renderOwnGallery(images);
}
function renderOwnGallery(images) {
allGalleryImages = images;
const grid = document.getElementById('ownGallery');
if (images.length === 0) {
grid.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;grid-column:1/-1;">Noch keine Bilder hochgeladen.</p>';
return;
}
grid.innerHTML = images.map((img, i) => `
<div class="own-thumb" onclick="openGalleryViewer(${i})" style="cursor:pointer;">
<img src="data:image/jpeg;base64,${img.imageData}" alt="Galerie-Bild">
<button class="own-thumb-delete" onclick="deleteGalleryImage('${img.imageId}', event)" title="Bild löschen">✕</button>
</div>
`).join('');
}
function openGalleryViewer(index) {
imageViewer.open({
images: allGalleryImages.map(img => ({
src: 'data:image/jpeg;base64,' + img.imageData,
id: img.imageId,
likedByMe: false,
likeCount: 0
})),
index,
showLike: false,
showComments: false
});
}
async function deleteGalleryImage(imageId, event) {
event.stopPropagation();
const res = await fetch('/social/profile-images/' + imageId, { method: 'DELETE' });
if (res.ok || res.status === 204) loadOwnGallery();
}
async function handleGalleryUpload(files) {
if (!files || files.length === 0) return;
const status = document.getElementById('galleryUploadStatus');
status.textContent = '0 / ' + files.length + ' hochgeladen…';
let done = 0;
for (const file of Array.from(files)) {
try {
const base64 = await toJpeg(file, 1024);
const res = await fetch('/social/profile-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageData: base64 })
});
if (res.status === 422) {
status.textContent = 'Limit von 20 Bildern erreicht.';
break;
}
} catch (e) {
console.error('Upload-Fehler:', e);
}
done++;
status.textContent = done + ' / ' + files.length + ' hochgeladen…';
}
status.textContent = done + ' Bild' + (done !== 1 ? 'er' : '') + ' hochgeladen.';
document.getElementById('galleryFile').value = '';
loadOwnGallery();
}
function toJpeg(file, max) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > max || h > max) {
if (w >= h) { h = Math.max(1, Math.round(max * h / w)); w = max; }
else { w = Math.max(1, Math.round(max * w / h)); h = max; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', 0.85).split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
function toBase64(file, max) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > max || h > max) {
if (w >= h) { h = Math.max(1, Math.round(max * h / w)); w = max; }
else { w = Math.max(1, Math.round(max * w / h)); h = max; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/png').split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
// ── Vorlieben ──────────────────────────────────────────────────────────────
const VL_SMILEYS = [
{ value: 'GEHT_GAR_NICHT', img: '/img/vorlieben/verynegative.png', color: '#e53935', title: 'Geht gar nicht' },
{ value: 'EHER_NICHT', img: '/img/vorlieben/negative.png', color: '#fb8c00', title: 'Eher nicht' },
{ value: 'NEUTRAL', img: '/img/vorlieben/neutral.png', color: '#fdd835', title: 'Neutral' },
{ value: 'MAG_ICH', img: '/img/vorlieben/positiv.png', color: '#81c784', title: 'Mag ich' },
{ value: 'UNBEDINGT', img: '/img/vorlieben/verypositiv.png', color: '#2e7d32', title: 'Unbedingt' },
{ value: 'WILL_AUSPROBIEREN', img: '/img/vorlieben/dunno.png', color: '#1e88e5', title: 'Will ich ausprobieren' },
];
let vorliebenRatings = {};
async function loadVorliebenEdit() {
const container = document.getElementById('vorliebenSection');
try {
const [itemsRes, meRes] = await Promise.all([
fetch('/vorlieben/items'),
fetch('/vorlieben/me'),
]);
if (!itemsRes.ok) {
container.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;">Keine Vorlieben konfiguriert.</p>';
return;
}
const kategorien = await itemsRes.json();
vorliebenRatings = meRes.ok ? await meRes.json() : {};
if (!kategorien.length) {
container.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;">Keine Vorlieben konfiguriert.</p>';
return;
}
const smileysHtml = VL_SMILEYS.map(s =>
`<span class="vl-smiley" data-val="${s.value}" title="${s.title}" style="--vl-color:${s.color}"><img src="${s.img}" alt="${s.title}"></span>`
).join('');
// Tab buttons
const tabBtns = kategorien.map((kat, i) =>
`<button class="vl-tab-btn${i === 0 ? ' active' : ''}" onclick="switchVlTab('vlkat-${kat.kategorieId}', this)">${escapeHtml(kat.name)}</button>`
).join('');
// Tab panels
const tabPanels = kategorien.map((kat, i) => `
<div class="vl-tab-panel${i === 0 ? ' active' : ''}" id="vlkat-${kat.kategorieId}">
${kat.items.map(item => `
<div class="vorliebe-row">
<span class="vorliebe-row-name">${escapeHtml(item.name)}</span>
<div class="vorliebe-smileys" data-item-id="${item.itemId}">
${smileysHtml}
</div>
</div>`).join('')}
</div>`).join('');
container.innerHTML = `<div class="vl-tabs">${tabBtns}</div>${tabPanels}`;
// Mark saved values
container.querySelectorAll('.vorliebe-smileys[data-item-id]').forEach(group => {
const saved = vorliebenRatings[group.dataset.itemId];
if (saved) {
const btn = group.querySelector(`.vl-smiley[data-val="${saved}"]`);
if (btn) btn.classList.add('active');
}
});
// Click handler
container.addEventListener('click', e => {
const btn = e.target.closest('.vl-smiley');
if (!btn) return;
const group = btn.closest('.vorliebe-smileys');
const itemId = group.dataset.itemId;
const isActive = btn.classList.contains('active');
// Deselect all in this group
group.querySelectorAll('.vl-smiley').forEach(s => s.classList.remove('active'));
if (!isActive) {
btn.classList.add('active');
vorliebenRatings[itemId] = btn.dataset.val;
} else {
delete vorliebenRatings[itemId];
}
});
} catch (e) {
container.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;">Fehler beim Laden.</p>';
}
}
function switchVlTab(panelId, btn) {
document.querySelectorAll('.vl-tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.vl-tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(panelId).classList.add('active');
}
async function saveVorlieben() {
const btn = document.getElementById('saveVorliebenBtn');
const msgEl = document.getElementById('vorliebenMessage');
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
// Collect: all known items → current rating or null
const ratings = {};
document.querySelectorAll('#vorliebenSection .vorliebe-smileys[data-item-id]').forEach(group => {
const active = group.querySelector('.vl-smiley.active');
ratings[group.dataset.itemId] = active ? active.dataset.val : null;
});
try {
const res = await fetch('/vorlieben/me', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ratings),
});
msgEl.textContent = res.ok ? 'Vorlieben gespeichert.' : 'Fehler beim Speichern.';
msgEl.className = `message ${res.ok ? 'success' : 'error'}`;
msgEl.style.display = 'block';
setTimeout(() => { msgEl.style.display = 'none'; }, 3000);
} catch (e) {
msgEl.textContent = 'Fehler beim Speichern.'; msgEl.className = 'message error'; msgEl.style.display = 'block';
} finally {
btn.disabled = false; btn.textContent = 'Vorlieben speichern';
}
}
function escapeHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
</body>
</html>

107
bin/main/static/login.html Normal file
View File

@@ -0,0 +1,107 @@
<!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>Login xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="card">
<img src="/img/icon.png" alt="Logo">
<h1>Bitte melde dich an</h1>
<label for="email">E-Mail</label>
<input type="email" id="email" placeholder="deine@email.de" autocomplete="username" />
<label for="password">Passwort</label>
<input type="password" id="password" placeholder="••••••••" autocomplete="current-password" />
<button class="full-width" id="loginBtn" onclick="login()">Anmelden</button>
<div class="message" id="message"></div>
<p style="text-align:center; margin-top:1.25rem; font-size:0.85rem;">
<a href="/forgot-password.html" style="color:var(--color-primary);">Passwort vergessen?</a>
</p>
<p style="text-align:center; margin-top:0.5rem; font-size:0.85rem;">
Noch kein Konto? <a href="/registration.html" style="color:var(--color-primary);">Registrieren</a>
</p>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(window.location.search);
const email = params.get('email');
if (email) {
document.getElementById('email').value = email;
document.getElementById('password').focus();
showMessage('E-Mail-Adresse bestätigt! Du kannst dich jetzt anmelden.', 'success');
} else if (params.get('emailChanged')) {
showMessage('E-Mail-Adresse erfolgreich geändert. Bitte melde dich mit deiner neuen Adresse an.', 'success');
} else if (params.get('accountDeleted')) {
showMessage('Dein Konto wurde gelöscht.', 'success');
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Enter') login();
});
async function login() {
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const btn = document.getElementById('loginBtn');
if (!email || !password) {
showMessage('Bitte E-Mail und Passwort eingeben.', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Wird geprüft…';
hideMessage();
try {
const response = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (response.status === 200) {
const user = await response.json();
sessionStorage.setItem('user', JSON.stringify(user));
window.location.href = '/userhome.html';
} else if (response.status === 204) {
showMessage('E-Mail oder Passwort falsch.', 'error');
btn.disabled = false;
btn.textContent = 'Anmelden';
} else {
showMessage(`Fehler: HTTP ${response.status}`, 'error');
btn.disabled = false;
btn.textContent = 'Anmelden';
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
btn.disabled = false;
btn.textContent = 'Anmelden';
console.error(err);
}
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,130 @@
<!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 Konto erstellen xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="card">
<img src="/img/icon.png" alt="Logo">
<h1>Neues Konto erstellen</h1>
<label for="name">Name</label>
<input type="text" id="name" placeholder="Dein Name" autocomplete="name" />
<label for="email">E-Mail</label>
<input type="email" id="email" placeholder="deine@email.de" autocomplete="email" />
<label for="geburtsdatum">Geburtsdatum</label>
<input type="date" id="geburtsdatum" autocomplete="bday" />
<label for="password">Passwort</label>
<input type="password" id="password" placeholder="••••••••" autocomplete="new-password" />
<label for="passwordConfirm">Passwort wiederholen</label>
<input type="password" id="passwordConfirm" placeholder="••••••••" autocomplete="new-password" />
<button class="full-width" id="registerBtn" onclick="register()">Registrieren</button>
<div class="message" id="message"></div>
<p style="text-align:center; margin-top:1.5rem; font-size:0.85rem;">
Bereits registriert? <a href="/login.html" style="color:#e94560;">Anmelden</a>
</p>
</div>
<script>
document.addEventListener('keydown', e => {
if (e.key === 'Enter') register();
});
async function register() {
const name = document.getElementById('name').value.trim();
const email = document.getElementById('email').value.trim();
const geburtsdatum = document.getElementById('geburtsdatum').value;
const password = document.getElementById('password').value;
const passwordConfirm = document.getElementById('passwordConfirm').value;
const btn = document.getElementById('registerBtn');
if (!name || !email || !geburtsdatum || !password || !passwordConfirm) {
showMessage('Bitte alle Felder ausfüllen.', 'error');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
showMessage('Bitte eine gültige E-Mail-Adresse eingeben.', 'error');
return;
}
const today = new Date();
const birth = new Date(geburtsdatum);
const age = today.getFullYear() - birth.getFullYear()
- (today < new Date(today.getFullYear(), birth.getMonth(), birth.getDate()) ? 1 : 0);
if (age < 18) {
showMessage('Du musst mindestens 18 Jahre alt sein, um dich zu registrieren.', 'error');
return;
}
if (password !== passwordConfirm) {
showMessage('Die Passwörter stimmen nicht überein.', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Wird verarbeitet…';
hideMessage();
try {
const response = await fetch('/registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password, geburtsdatum })
});
if (response.status === 202) {
window.location.href = `/activate.html?email=${encodeURIComponent(email)}`;
} else if (response.status === 422) {
const body = await response.text();
const msgs = {
'EMAIL_FORMAT': 'Bitte eine gültige E-Mail-Adresse eingeben.',
'PASSWORT_ZU_KURZ':'Das Passwort muss mindestens 8 Zeichen lang sein.',
'ALTER': 'Du musst mindestens 18 Jahre alt sein, um dich zu registrieren.'
};
showMessage(msgs[body] || 'Ungültige Eingabe.', 'error');
btn.disabled = false;
btn.textContent = 'Registrieren';
} else if (response.status === 400) {
showMessage('Diese E-Mail-Adresse ist bereits registriert.', 'error');
btn.disabled = false;
btn.textContent = 'Registrieren';
} else if (response.status === 409) {
showMessage('Dieser Name ist bereits vergeben.', 'error');
btn.disabled = false;
btn.textContent = 'Registrieren';
} else {
showMessage(`Fehler: HTTP ${response.status}`, 'error');
btn.disabled = false;
btn.textContent = 'Registrieren';
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
btn.disabled = false;
btn.textContent = 'Registrieren';
console.error(err);
}
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,142 @@
<!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 Passwort xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100;
align-items: center;
justify-content: center;
}
.overlay.active {
display: flex;
}
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
max-width: 340px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
text-align: center;
}
.modal p {
color: var(--color-text);
font-size: 0.95rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="card">
<img src="icon.png" alt="Logo">
<h1>Neues Passwort</h1>
<p class="subtitle">Gib dein neues Passwort ein.</p>
<label for="password">Neues Passwort</label>
<input type="password" id="password" placeholder="••••••••" autocomplete="new-password" />
<label for="passwordConfirm">Passwort wiederholen</label>
<input type="password" id="passwordConfirm" placeholder="••••••••" autocomplete="new-password" />
<button class="full-width" id="submitBtn" onclick="submit()">Passwort speichern</button>
<div class="message" id="message"></div>
</div>
<div class="overlay" id="overlay">
<div class="modal">
<p>Dein Passwort wurde erfolgreich geändert. Du kannst dich jetzt anmelden.</p>
<button class="full-width" onclick="goToLogin()">Zum Login</button>
</div>
</div>
<script>
document.addEventListener('keydown', e => {
if (e.key === 'Enter') submit();
});
function getToken() {
return new URLSearchParams(window.location.search).get('token');
}
document.addEventListener('DOMContentLoaded', () => {
if (!getToken()) {
showMessage('Ungültiger oder fehlender Reset-Link.', 'error');
document.getElementById('submitBtn').disabled = true;
}
});
async function submit() {
const password = document.getElementById('password').value;
const passwordConfirm = document.getElementById('passwordConfirm').value;
const btn = document.getElementById('submitBtn');
const token = getToken();
if (!password || !passwordConfirm) {
showMessage('Bitte beide Felder ausfüllen.', 'error');
return;
}
if (password !== passwordConfirm) {
showMessage('Die Passwörter stimmen nicht überein.', 'error');
return;
}
if (!token) {
showMessage('Ungültiger Reset-Link.', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Wird gespeichert…';
hideMessage();
try {
const response = await fetch('/password-reset/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password })
});
if (response.ok) {
document.getElementById('overlay').classList.add('active');
} else {
showMessage('Der Reset-Link ist ungültig oder abgelaufen.', 'error');
btn.disabled = false;
btn.textContent = 'Passwort speichern';
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
btn.disabled = false;
btn.textContent = 'Passwort speichern';
console.error(err);
}
}
function goToLogin() {
window.location.href = '/login.html';
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,87 @@
<!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>Home xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.game-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.25rem;
margin-top: 1.5rem;
}
.game-card {
background: var(--color-secondary);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 1.5rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.game-card-icon { font-size: 2rem; line-height: 1; }
.game-card-title { font-size: 1.1rem; font-weight: 700; margin: 0; }
.game-card-desc { font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; flex: 1; }
.game-card-btn { margin-top: 0.25rem; width: auto; align-self: flex-start; padding: 0.5rem 1.25rem; }
.welcome { font-size: 0.95rem; 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.15rem;">Home</h1>
<p class="welcome" id="greeting"></p>
<div class="game-grid">
<div class="game-card">
<div class="game-card-icon"></div>
<h2 class="game-card-title">Vanilla Game</h2>
<p class="game-card-desc">
Entdecke spielerische Rollenspiele und Aufgaben in einem entspannten Rahmen.
Ideal für den Einstieg ohne Regeln, nur Spaß zu zweit oder in der Gruppe.
</p>
<a href="/games/vanilla/sessionvanilla.html"><button class="game-card-btn">Neue Session starten</button></a>
</div>
<div class="game-card">
<div class="game-card-icon"></div>
<h2 class="game-card-title">BDSM Game</h2>
<p class="game-card-desc">
Tauche ein in strukturierte Sessions mit Aufgaben, Toys und klaren Rollen.
Definiere Grenzen, vergib Aufgaben und erlebe intensive Momente mit deinem Partner.
</p>
<a href="/games/bdsm/neubdsm.html"><button class="game-card-btn">Neue Session starten</button></a>
</div>
<div class="game-card">
<div class="game-card-icon"></div>
<h2 class="game-card-title">Chastity Game</h2>
<p class="game-card-desc">
Erlebe Keuschheit auf eine neue Art: Kartenbasierte Locks, Keyholder-System,
Community-Abstimmungen und tägliche Verifizierungen machen jedes Lock einzigartig.
</p>
<a href="/games/chastity/neulock.html"><button class="game-card-btn">Neues Lock erstellen</button></a>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
fetch('/login/me')
.then(r => {
if (r.status === 401) { window.location.href = '/login.html'; return null; }
return r.json();
})
.then(user => {
if (user) document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
})
.catch(() => { window.location.href = '/login.html'; });
</script>
</body>
</html>