Verschiebung nach anderem RePo - nun pro Projekt getrennt
94
bin/main/static/activate.html
Normal 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>
|
||||
2604
bin/main/static/admin/admin.html
Normal file
BIN
bin/main/static/audio/alarm.mp3
Normal file
BIN
bin/main/static/audio/lvlup.mp3
Normal file
BIN
bin/main/static/audio/message.mp3
Normal file
BIN
bin/main/static/audio/notification.mp3
Normal file
BIN
bin/main/static/audio/ping.mp3
Normal file
BIN
bin/main/static/audio/release.mp3
Normal file
38
bin/main/static/community/abonnements.html
Normal 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>
|
||||
252
bin/main/static/community/benachrichtigungen.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
1410
bin/main/static/community/benutzer.html
Normal file
541
bin/main/static/community/feed.html
Normal 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>
|
||||
351
bin/main/static/community/freunde.html
Normal 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>
|
||||
1097
bin/main/static/community/gruppe.html
Normal file
411
bin/main/static/community/gruppen.html
Normal 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>
|
||||
671
bin/main/static/community/nachrichten.html
Normal 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>
|
||||
206
bin/main/static/community/personen-suchen.html
Normal 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>
|
||||
967
bin/main/static/css/style.css
Normal 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; }
|
||||
}
|
||||
113
bin/main/static/forgot-password.html
Normal 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>
|
||||
128
bin/main/static/games/bdsm/bdsm-einladung.html
Normal 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>
|
||||
1295
bin/main/static/games/bdsm/bdsmingame.html
Normal file
11
bin/main/static/games/bdsm/bdsmplayers.html
Normal 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>
|
||||
21
bin/main/static/games/bdsm/infobdsm.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>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>
|
||||
1485
bin/main/static/games/bdsm/neubdsm.html
Normal file
1749
bin/main/static/games/chastity/activelock.html
Normal file
1382
bin/main/static/games/chastity/activetimelock.html
Normal file
413
bin/main/static/games/chastity/communityvotes.html
Normal file
@@ -0,0 +1,413 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Community Votes – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.page-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.page-subtitle {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
#feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ── Verifikations-Karte ── */
|
||||
.vote-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vote-card-media { position: relative; }
|
||||
.vote-card-img {
|
||||
width: 100%;
|
||||
max-height: 420px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
.vote-card-code {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: monospace;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.25em;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.55);
|
||||
padding: 0.4rem 1.1rem;
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vote-card-body {
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vote-meta { font-size: 0.8rem; color: var(--color-muted); }
|
||||
.vote-actions { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.vote-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
width: auto;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.vote-btn:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: none;
|
||||
}
|
||||
.vote-btn.voted-up { border-color: #2ecc71; color: #2ecc71; background: rgba(46,204,113,0.08); }
|
||||
.vote-btn.voted-down { border-color: #e74c3c; color: #e74c3c; background: rgba(231,76,60,0.08); }
|
||||
.vote-btn:disabled { opacity: 0.55; cursor: not-allowed; pointer-events: none; }
|
||||
.vote-count { font-weight: 600; font-size: 0.88rem; }
|
||||
|
||||
/* ── Aufgaben-Abstimmungs-Karte ── */
|
||||
.task-vote-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid rgba(52,152,219,0.35);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.task-vote-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.task-vote-lockee { font-weight: 600; font-size: 0.92rem; }
|
||||
.task-vote-expires { font-size: 0.78rem; color: var(--color-muted); }
|
||||
.task-vote-options { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.5rem; }
|
||||
.task-vote-btn {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(52,152,219,0.08);
|
||||
border: 1px solid rgba(52,152,219,0.25);
|
||||
border-radius: 7px;
|
||||
padding: 0.45rem 0.7rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.task-vote-btn:hover:not(:disabled) {
|
||||
background: rgba(52,152,219,0.22);
|
||||
border-color: rgba(52,152,219,0.5);
|
||||
}
|
||||
.task-vote-btn.my-vote {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(52,152,219,0.18);
|
||||
}
|
||||
.task-vote-btn:disabled { cursor: default; }
|
||||
.task-vote-count {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
white-space: nowrap;
|
||||
margin-left: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-vote-own-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
font-style: italic;
|
||||
margin-top: 0.4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Pranger-Karte ── */
|
||||
.pillory-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid rgba(231,76,60,0.35);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.pillory-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.pillory-lockee { font-weight: 600; font-size: 0.92rem; }
|
||||
.pillory-date { font-size: 0.78rem; color: var(--color-muted); }
|
||||
.pillory-reason {
|
||||
font-size: 0.82rem;
|
||||
color: #e74c3c;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.pillory-message { font-size: 0.88rem; }
|
||||
|
||||
.empty-hint {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.load-spinner {
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.85rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.sentinel { height: 1px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
|
||||
<div class="page-title">Community Votes</div>
|
||||
<div class="page-subtitle">Verifikationen, Aufgaben-Abstimmungen & Pranger</div>
|
||||
|
||||
<div id="feed"></div>
|
||||
<div class="load-spinner" id="loadSpinner" style="display:none;">Lädt…</div>
|
||||
<div class="empty-hint" id="emptyHint" style="display:none;">Noch keine Community-Abstimmungen vorhanden.</div>
|
||||
<div class="sentinel" id="sentinel"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/social-sidebar.js"></script>
|
||||
<script>
|
||||
function esc(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function fmtDateTime(isoStr) {
|
||||
return new Date(isoStr).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
||||
}
|
||||
|
||||
// ── Verifikations-Karte ────────────────────────────────────────────────────
|
||||
|
||||
function buildVerCard(base, detail) {
|
||||
const voted = detail.isOwnLock || detail.myVote !== null && detail.myVote !== undefined;
|
||||
const votedUp = !detail.isOwnLock && detail.myVote === true;
|
||||
const votedDn = !detail.isOwnLock && detail.myVote === false;
|
||||
const id = base.displayId;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'vote-card';
|
||||
card.innerHTML = `
|
||||
<div class="vote-card-media">
|
||||
<img class="vote-card-img" src="data:image/jpeg;base64,${detail.image}" alt="Verifikationsbild">
|
||||
<div class="vote-card-code">${esc(detail.code)}</div>
|
||||
</div>
|
||||
<div class="vote-card-body">
|
||||
<div class="vote-meta">Verifikation · ${esc(base.lockeeName)} · ${fmtDateTime(base.createdAt)}</div>
|
||||
<div class="vote-actions">
|
||||
<button class="vote-btn ${votedUp ? 'voted-up' : ''}" id="up-${id}"
|
||||
${voted ? 'disabled' : ''}
|
||||
onclick="castVerVote('${id}', true)">
|
||||
👍 <span class="vote-count" id="upcount-${id}">${detail.upvotes}</span>
|
||||
</button>
|
||||
<button class="vote-btn ${votedDn ? 'voted-down' : ''}" id="dn-${id}"
|
||||
${voted ? 'disabled' : ''}
|
||||
onclick="castVerVote('${id}', false)">
|
||||
👎 <span class="vote-count" id="dncount-${id}">${detail.downvotes}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
return card;
|
||||
}
|
||||
|
||||
async function castVerVote(displayId, upvote) {
|
||||
document.getElementById('up-' + displayId).disabled = true;
|
||||
document.getElementById('dn-' + displayId).disabled = true;
|
||||
|
||||
const res = await fetch(`/games/chastity/community/verification/${displayId}/vote/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ upvote })
|
||||
});
|
||||
if (res.ok || res.status === 202) {
|
||||
const countEl = document.getElementById(upvote ? 'upcount-' + displayId : 'dncount-' + displayId);
|
||||
countEl.textContent = parseInt(countEl.textContent) + 1;
|
||||
document.getElementById((upvote ? 'up-' : 'dn-') + displayId)
|
||||
.classList.add(upvote ? 'voted-up' : 'voted-down');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aufgaben-Abstimmungs-Karte ─────────────────────────────────────────────
|
||||
|
||||
function buildTaskVoteCard(base, detail) {
|
||||
const isOwn = detail.isOwnLock;
|
||||
const alreadyVoted = detail.entries.some(e => e.ownVote);
|
||||
const id = base.displayId;
|
||||
|
||||
let optionsHtml = '';
|
||||
(detail.entries || []).forEach((e, i) => {
|
||||
const desc = e.description
|
||||
? `<div style="font-size:0.75rem;color:var(--color-muted);margin-top:0.1rem;">${esc(e.description)}</div>`
|
||||
: '';
|
||||
const mins = e.minutes > 0
|
||||
? ` <span style="font-size:0.75rem;color:var(--color-muted);">⏱ ${e.minutes} Min.</span>`
|
||||
: '';
|
||||
optionsHtml += `<button class="task-vote-btn ${e.ownVote ? 'my-vote' : ''}"
|
||||
id="tvbtn-${id}-${i}"
|
||||
${(alreadyVoted || isOwn) ? 'disabled' : ''}
|
||||
onclick="castTaskVote('${id}', ${i})">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;">${esc(e.title)}${mins}</div>
|
||||
${desc}
|
||||
</div>
|
||||
<span class="task-vote-count" id="tvcount-${id}-${i}">${e.votes} Stimme${e.votes !== 1 ? 'n' : ''}</span>
|
||||
</button>`;
|
||||
});
|
||||
|
||||
const ownHint = isOwn
|
||||
? `<div class="task-vote-own-hint">Das ist dein eigenes Lock – du kannst hier nicht abstimmen.</div>`
|
||||
: '';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'task-vote-card';
|
||||
card.innerHTML = `
|
||||
<div class="task-vote-header">
|
||||
<span class="task-vote-lockee">🃏 ${esc(base.lockeeName)}</span>
|
||||
<span class="task-vote-expires">Endet: ${fmtDateTime(detail.expiresAt)}</span>
|
||||
</div>
|
||||
<div class="task-vote-options">${optionsHtml}</div>
|
||||
${ownHint}`;
|
||||
return card;
|
||||
}
|
||||
|
||||
async function castTaskVote(displayId, taskIndex) {
|
||||
document.querySelectorAll(`[id^="tvbtn-${displayId}-"]`).forEach(btn => btn.disabled = true);
|
||||
|
||||
const res = await fetch(`/games/chastity/community/taskvote/${displayId}/vote/${taskIndex}`, { method: 'POST' });
|
||||
if (res.ok || res.status === 204) {
|
||||
const countEl = document.getElementById(`tvcount-${displayId}-${taskIndex}`);
|
||||
if (countEl) {
|
||||
const next = (parseInt(countEl.textContent) || 0) + 1;
|
||||
countEl.textContent = `${next} Stimme${next !== 1 ? 'n' : ''}`;
|
||||
}
|
||||
document.getElementById(`tvbtn-${displayId}-${taskIndex}`)?.classList.add('my-vote');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pranger-Karte ──────────────────────────────────────────────────────────
|
||||
|
||||
const PILLORY_LABELS = {
|
||||
HYGIENE_OPENING_EXEEDED: 'Hygiene-Öffnung überschritten',
|
||||
KEYHOLDER_DESCESSION: 'Keyholder hat aufgegeben'
|
||||
};
|
||||
|
||||
function buildPilloryCard(base, detail) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'pillory-card';
|
||||
card.innerHTML = `
|
||||
<div class="pillory-header">
|
||||
<span class="pillory-lockee">🔒 ${esc(base.lockeeName)}</span>
|
||||
<span class="pillory-date">${fmtDateTime(base.createdAt)}</span>
|
||||
</div>
|
||||
<div class="pillory-reason">⚠️ ${esc(PILLORY_LABELS[detail.reason] || detail.reason)}</div>
|
||||
${detail.message ? `<div class="pillory-message">${esc(detail.message)}</div>` : ''}`;
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Unified feed mit Paging ────────────────────────────────────────────────
|
||||
|
||||
let page = 0;
|
||||
let exhausted = false;
|
||||
let loading = false;
|
||||
let rendered = 0;
|
||||
|
||||
async function fetchDetail(base) {
|
||||
const urls = {
|
||||
VERIFICATION: `/games/chastity/community/verification/${base.displayId}`,
|
||||
TASK_VOTE: `/games/chastity/community/taskvote/${base.displayId}`,
|
||||
PILLORY: `/games/chastity/community/pillory/${base.displayId}`
|
||||
};
|
||||
const url = urls[base.type];
|
||||
if (!url) return null;
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
return r.ok ? await r.json() : null;
|
||||
} catch(e) { return null; }
|
||||
}
|
||||
|
||||
function buildCard(base, detail) {
|
||||
if (!detail) return null;
|
||||
if (base.type === 'VERIFICATION') return buildVerCard(base, detail);
|
||||
if (base.type === 'TASK_VOTE') return buildTaskVoteCard(base, detail);
|
||||
if (base.type === 'PILLORY') return buildPilloryCard(base, detail);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || exhausted) return;
|
||||
loading = true;
|
||||
document.getElementById('loadSpinner').style.display = '';
|
||||
|
||||
let pageData;
|
||||
try {
|
||||
const r = await fetch(`/games/chastity/community?page=${page}&sort=createdAt,desc`);
|
||||
if (!r.ok) { loading = false; document.getElementById('loadSpinner').style.display = 'none'; return; }
|
||||
pageData = await r.json();
|
||||
} catch(e) { loading = false; document.getElementById('loadSpinner').style.display = 'none'; return; }
|
||||
|
||||
const items = pageData.content || [];
|
||||
if (pageData.last) exhausted = true;
|
||||
page++;
|
||||
|
||||
const details = await Promise.all(items.map(fetchDetail));
|
||||
|
||||
document.getElementById('loadSpinner').style.display = 'none';
|
||||
loading = false;
|
||||
|
||||
const feed = document.getElementById('feed');
|
||||
items.forEach((base, i) => {
|
||||
const card = buildCard(base, details[i]);
|
||||
if (card) { feed.appendChild(card); rendered++; }
|
||||
});
|
||||
|
||||
if (rendered === 0 && exhausted) {
|
||||
document.getElementById('emptyHint').style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) loadMore();
|
||||
}, { rootMargin: '200px' });
|
||||
observer.observe(document.getElementById('sentinel'));
|
||||
|
||||
loadMore();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
528
bin/main/static/games/chastity/entdecken-vorlagen.html
Normal file
@@ -0,0 +1,528 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vorlagen entdecken – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
/* ── Suche ── */
|
||||
.search-bar {
|
||||
display: flex; gap: 0.6rem; margin-bottom: 1.5rem; align-items: center;
|
||||
}
|
||||
.search-bar input {
|
||||
flex: 1; padding: 0.55rem 0.85rem; border-radius: 8px;
|
||||
border: 1px solid var(--color-secondary); background: var(--color-card);
|
||||
color: var(--color-text); font-size: 0.95rem;
|
||||
}
|
||||
.search-bar button {
|
||||
width: auto; padding: 0.55rem 1.2rem; font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Template-Karte ── */
|
||||
.tpl-card {
|
||||
background: var(--color-card); border: 1px solid var(--color-secondary);
|
||||
border-radius: 10px; padding: 1rem; margin-bottom: 0.75rem;
|
||||
cursor: pointer; transition: border-color 0.15s;
|
||||
}
|
||||
.tpl-card:hover { border-color: var(--color-primary); }
|
||||
.tpl-card.own-template { border-left: 3px solid #3498db; }
|
||||
.tpl-card-header {
|
||||
display: flex; align-items: flex-start;
|
||||
justify-content: space-between; gap: 0.75rem;
|
||||
}
|
||||
.tpl-icon {
|
||||
width: 2.4rem; height: 2.4rem; flex-shrink: 0;
|
||||
border-radius: 8px; background: var(--color-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.tpl-name { font-weight: 700; font-size: 1rem; margin-bottom: 0.2rem; }
|
||||
.tpl-meta { font-size: 0.78rem; color: var(--color-muted); line-height: 1.5; }
|
||||
.tpl-badges {
|
||||
display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem;
|
||||
}
|
||||
.tpl-badge {
|
||||
font-size: 0.7rem; border-radius: 5px; padding: 0.18rem 0.55rem;
|
||||
border: 1px solid var(--color-secondary); color: var(--color-muted);
|
||||
background: var(--color-secondary);
|
||||
}
|
||||
.tpl-badge.blue { background: rgba(52,152,219,0.12); border-color: rgba(52,152,219,0.35); color: #3498db; }
|
||||
.tpl-badge.green { background: rgba(46,204,113,0.12); border-color: rgba(46,204,113,0.35); color: #2ecc71; }
|
||||
.tpl-badge.orange { background: rgba(231,152,52,0.12); border-color: rgba(231,152,52,0.35); color: #e67e22; }
|
||||
.tpl-badge.own { background: rgba(52,152,219,0.15); border-color: rgba(52,152,219,0.5); color: #3498db; font-weight: 600; }
|
||||
|
||||
/* ── Abonnieren-Button ── */
|
||||
.btn-sub {
|
||||
white-space: nowrap; width: auto; padding: 0.4rem 0.9rem; font-size: 0.82rem;
|
||||
font-weight: 600; border-radius: 7px; cursor: pointer; flex-shrink: 0;
|
||||
border: 1px solid var(--color-secondary);
|
||||
background: none; color: var(--color-muted);
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-sub:hover:not(:disabled) {
|
||||
background: rgba(52,152,219,0.12); border-color: rgba(52,152,219,0.45); color: #3498db;
|
||||
}
|
||||
.btn-sub.subscribed {
|
||||
background: rgba(46,204,113,0.12); border-color: rgba(46,204,113,0.4); color: #2ecc71;
|
||||
}
|
||||
.btn-sub.subscribed:hover:not(:disabled) {
|
||||
background: rgba(231,76,60,0.1); border-color: rgba(231,76,60,0.35); color: #e74c3c;
|
||||
}
|
||||
.btn-sub:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
/* ── Detail-Modal ── */
|
||||
.detail-backdrop {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.65); z-index: 400;
|
||||
align-items: flex-start; justify-content: center;
|
||||
padding: 2rem 1rem; overflow-y: auto;
|
||||
}
|
||||
.detail-backdrop.open { display: flex; }
|
||||
.detail-box {
|
||||
background: var(--color-card); border: 1px solid var(--color-secondary);
|
||||
border-radius: 14px; padding: 1.75rem 1.5rem 1.5rem;
|
||||
max-width: 500px; width: 100%; position: relative;
|
||||
display: flex; flex-direction: column; gap: 1rem;
|
||||
}
|
||||
.detail-section {
|
||||
background: var(--color-secondary); border-radius: 8px;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
.detail-section-title {
|
||||
font-size: 0.72rem; font-weight: 700; color: var(--color-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 0.5rem;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
font-size: 0.88rem; padding: 0.2rem 0; gap: 1rem;
|
||||
}
|
||||
.detail-row-label { color: var(--color-muted); flex-shrink: 0; }
|
||||
.detail-row-val { color: var(--color-text); text-align: right; }
|
||||
.detail-task-item {
|
||||
font-size: 0.85rem; color: var(--color-text); padding: 0.3rem 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.detail-task-item:last-child { border-bottom: none; }
|
||||
.detail-wheel-entry {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
font-size: 0.78rem; background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary); border-radius: 5px;
|
||||
padding: 0.2rem 0.55rem; margin: 0.2rem;
|
||||
}
|
||||
.detail-footer {
|
||||
display: flex; gap: 0.75rem; justify-content: flex-end; flex-wrap: wrap;
|
||||
border-top: 1px solid var(--color-secondary); padding-top: 1rem;
|
||||
}
|
||||
.btn-close-detail {
|
||||
background: none; border: 1px solid var(--color-secondary);
|
||||
color: var(--color-muted); padding: 0.5rem 1.1rem; border-radius: 7px;
|
||||
cursor: pointer; font-size: 0.88rem; width: auto;
|
||||
}
|
||||
.btn-subscribe-detail {
|
||||
padding: 0.5rem 1.25rem; border-radius: 7px; cursor: pointer;
|
||||
font-size: 0.88rem; font-weight: 600; width: auto; border: none;
|
||||
background: var(--color-primary); color: #fff;
|
||||
}
|
||||
.btn-subscribe-detail.subscribed {
|
||||
background: rgba(46,204,113,0.15); border: 1px solid rgba(46,204,113,0.4); color: #2ecc71;
|
||||
}
|
||||
.detail-author-avatar {
|
||||
width: 52px; height: 52px; border-radius: 50%;
|
||||
border: 2px solid var(--color-secondary);
|
||||
background: var(--color-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.5rem; color: var(--color-muted);
|
||||
overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
.detail-author-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
|
||||
<h2 style="margin-bottom:1.25rem;">🔍 Vorlagen entdecken</h2>
|
||||
|
||||
<!-- Suchleiste -->
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" placeholder="Nach Namen suchen…"
|
||||
onkeydown="if(event.key==='Enter') doSearch()">
|
||||
<button onclick="doSearch()">Suchen</button>
|
||||
</div>
|
||||
|
||||
<!-- Ergebnisliste -->
|
||||
<div id="templateList"></div>
|
||||
<div id="scrollSentinel" style="height:1px;"></div>
|
||||
<p id="listLoading" style="display:none;text-align:center;color:var(--color-muted);padding:1rem;">Laden…</p>
|
||||
<p id="listEmpty" style="display:none;color:var(--color-muted);">Keine öffentlichen Vorlagen gefunden.</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail-Modal -->
|
||||
<div class="detail-backdrop" id="detailModal" onclick="closeDetail()">
|
||||
<div class="detail-box" onclick="event.stopPropagation()">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;">
|
||||
<div style="display:flex;align-items:flex-start;gap:0.85rem;">
|
||||
<div class="detail-author-avatar" id="detailAuthorAvatar" style="display:none;">◉</div>
|
||||
<div>
|
||||
<h2 id="detailTitle" style="margin:0 0 0.25rem;font-size:1.2rem;"></h2>
|
||||
<div id="detailMeta" style="font-size:0.82rem;color:var(--color-muted);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="closeDetail()" style="background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0;flex-shrink:0;">✕</button>
|
||||
</div>
|
||||
|
||||
<div id="detailBody"></div>
|
||||
|
||||
<div class="detail-footer">
|
||||
<button class="btn-close-detail" onclick="closeDetail()">Schließen</button>
|
||||
<button class="btn-subscribe-detail" id="detailSubscribeBtn" onclick="toggleSubscribeDetail()"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/shared.js"></script>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
|
||||
|
||||
let page = 0;
|
||||
let isLastPage = false;
|
||||
let isLoading = false;
|
||||
let currentSearch = '';
|
||||
let _detailTemplate = null;
|
||||
|
||||
function fmtMinutes(min) {
|
||||
if (!min) return '–';
|
||||
const d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
|
||||
return [d && d + 'T', h && h + 'Std', m && m + 'Min'].filter(Boolean).join(' ') || '0Min';
|
||||
}
|
||||
|
||||
// ── Laden ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadNextPage() {
|
||||
if (isLoading || isLastPage) return;
|
||||
isLoading = true;
|
||||
document.getElementById('listLoading').style.display = '';
|
||||
try {
|
||||
const q = encodeURIComponent(currentSearch);
|
||||
const res = await fetch(`/templates/public?page=${page}&size=10&q=${q}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
data.content.forEach(t => appendCard(t));
|
||||
isLastPage = !data.hasMore;
|
||||
page = data.page + 1;
|
||||
if (page === 1 && data.content.length === 0) {
|
||||
document.getElementById('listEmpty').style.display = '';
|
||||
}
|
||||
} catch(e) { console.error(e); }
|
||||
finally {
|
||||
isLoading = false;
|
||||
document.getElementById('listLoading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function resetList() {
|
||||
page = 0; isLastPage = false; isLoading = false;
|
||||
document.getElementById('templateList').innerHTML = '';
|
||||
document.getElementById('listEmpty').style.display = 'none';
|
||||
loadNextPage();
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
currentSearch = document.getElementById('searchInput').value.trim();
|
||||
resetList();
|
||||
}
|
||||
|
||||
// ── Karte ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function appendCard(t) {
|
||||
const list = document.getElementById('templateList');
|
||||
const isCard = t.lockType === 'CARDLOCK';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'tpl-card' + (t.isOwnTemplate ? ' own-template' : '');
|
||||
card.dataset.templateId = t.templateId;
|
||||
|
||||
const metaParts = [
|
||||
isCard ? '🃏 Karten-Lock' : '⏱ Zeit-Lock',
|
||||
t.authorName ? 'von ' + esc(t.authorName) : null,
|
||||
t.subscriberCount + ' Abo(s)',
|
||||
].filter(Boolean);
|
||||
|
||||
const badges = buildBadges(t);
|
||||
const subBtnCls = t.isOwnTemplate ? '' : (t.isSubscribed ? 'subscribed' : '');
|
||||
const subBtnLabel = t.isOwnTemplate ? 'Eigene' : (t.isSubscribed ? '✓ Abonniert' : '+ Abonnieren');
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="tpl-card-header">
|
||||
<div class="tpl-icon">${isCard ? '🃏' : '⏱'}</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="tpl-name">${esc(t.name || 'Ohne Namen')}</div>
|
||||
<div class="tpl-meta">${metaParts.join(' · ')}</div>
|
||||
</div>
|
||||
<button class="btn-sub ${subBtnCls}" ${t.isOwnTemplate ? 'disabled' : ''}
|
||||
onclick="event.stopPropagation();toggleSubscribe('${t.templateId}',this)">
|
||||
${subBtnLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tpl-badges">${badges}</div>`;
|
||||
|
||||
card.addEventListener('click', () => openDetail(t));
|
||||
list.appendChild(card);
|
||||
}
|
||||
|
||||
function buildBadges(t) {
|
||||
const b = [];
|
||||
if (t.lockType === 'TIMELOCK') {
|
||||
if (t.minTimeInMinutes || t.maxTimeInMinutes) {
|
||||
const min = fmtMinutes(t.minTimeInMinutes), max = fmtMinutes(t.maxTimeInMinutes);
|
||||
b.push(`<span class="tpl-badge blue">⏱ ${min} – ${max}</span>`);
|
||||
}
|
||||
if (t.spinningWheelEntries && t.spinningWheelEntries.length)
|
||||
b.push(`<span class="tpl-badge orange">🎡 Glücksrad (${t.spinningWheelEntries.length})</span>`);
|
||||
if (t.penaltyType)
|
||||
b.push(`<span class="tpl-badge orange">⚠ Strafe</span>`);
|
||||
}
|
||||
if (t.taskCount > 0)
|
||||
b.push(`<span class="tpl-badge">🎯 ${t.taskCount} Aufgabe(n)</span>`);
|
||||
if (t.hygieneEnabled)
|
||||
b.push(`<span class="tpl-badge">🚿 Hygiene</span>`);
|
||||
if (t.requiresVerification)
|
||||
b.push(`<span class="tpl-badge">📷 Verifikation</span>`);
|
||||
if (t.isOwnTemplate)
|
||||
b.push(`<span class="tpl-badge own">Meine Vorlage</span>`);
|
||||
return b.join('');
|
||||
}
|
||||
|
||||
// ── Abonnieren (Listenansicht) ─────────────────────────────────────────────
|
||||
|
||||
async function toggleSubscribe(id, btn) {
|
||||
const isSubscribed = btn.classList.contains('subscribed');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const method = isSubscribed ? 'DELETE' : 'POST';
|
||||
const res = await fetch(`/templates/${id}/subscribe`, { method });
|
||||
if (res.ok || res.status === 204) {
|
||||
if (isSubscribed) {
|
||||
btn.classList.remove('subscribed');
|
||||
btn.textContent = '+ Abonnieren';
|
||||
// Update card data
|
||||
const card = btn.closest('.tpl-card');
|
||||
updateCardSubscriberCount(card, -1);
|
||||
} else {
|
||||
btn.classList.add('subscribed');
|
||||
btn.textContent = '✓ Abonniert';
|
||||
const card = btn.closest('.tpl-card');
|
||||
updateCardSubscriberCount(card, +1);
|
||||
}
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
function updateCardSubscriberCount(card, delta) {
|
||||
// Update the meta text - find the "X Abo(s)" part
|
||||
const meta = card.querySelector('.tpl-meta');
|
||||
if (!meta) return;
|
||||
meta.innerHTML = meta.innerHTML.replace(/(\d+) Abo\(s\)/, (_, n) => `${Math.max(0, parseInt(n) + delta)} Abo(s)`);
|
||||
}
|
||||
|
||||
// ── Detail-Modal ───────────────────────────────────────────────────────────
|
||||
|
||||
function openDetail(t) {
|
||||
_detailTemplate = t;
|
||||
document.getElementById('detailTitle').textContent = t.name || 'Ohne Namen';
|
||||
|
||||
const avatarEl = document.getElementById('detailAuthorAvatar');
|
||||
if (t.authorProfilePicture) {
|
||||
avatarEl.innerHTML = `<img src="data:image/png;base64,${t.authorProfilePicture}" alt="${esc(t.authorName || '')}">`;
|
||||
avatarEl.style.display = '';
|
||||
} else {
|
||||
avatarEl.innerHTML = '◉';
|
||||
avatarEl.style.display = 'none';
|
||||
}
|
||||
|
||||
const metaParts = [
|
||||
t.lockType === 'CARDLOCK' ? '🃏 Karten-Lock' : '⏱ Zeit-Lock',
|
||||
t.authorName ? 'von ' + t.authorName : null,
|
||||
t.subscriberCount + ' Abonnent(en)',
|
||||
].filter(Boolean);
|
||||
document.getElementById('detailMeta').textContent = metaParts.join(' · ');
|
||||
|
||||
document.getElementById('detailBody').innerHTML = buildDetailBody(t);
|
||||
|
||||
const btn = document.getElementById('detailSubscribeBtn');
|
||||
if (t.isOwnTemplate) {
|
||||
btn.style.display = 'none';
|
||||
} else {
|
||||
btn.style.display = '';
|
||||
btn.className = 'btn-subscribe-detail' + (t.isSubscribed ? ' subscribed' : '');
|
||||
btn.textContent = t.isSubscribed ? '✓ Abonniert' : '+ Abonnieren';
|
||||
}
|
||||
|
||||
document.getElementById('detailModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
document.getElementById('detailModal').classList.remove('open');
|
||||
_detailTemplate = null;
|
||||
}
|
||||
|
||||
async function toggleSubscribeDetail() {
|
||||
if (!_detailTemplate) return;
|
||||
const t = _detailTemplate;
|
||||
const btn = document.getElementById('detailSubscribeBtn');
|
||||
btn.disabled = true;
|
||||
const isSubscribed = t.isSubscribed;
|
||||
try {
|
||||
const method = isSubscribed ? 'DELETE' : 'POST';
|
||||
const res = await fetch(`/templates/${t.templateId}/subscribe`, { method });
|
||||
if (res.ok || res.status === 204) {
|
||||
t.isSubscribed = !isSubscribed;
|
||||
t.subscriberCount = Math.max(0, (t.subscriberCount || 0) + (isSubscribed ? -1 : 1));
|
||||
if (isSubscribed) {
|
||||
btn.className = 'btn-subscribe-detail';
|
||||
btn.textContent = '+ Abonnieren';
|
||||
} else {
|
||||
btn.className = 'btn-subscribe-detail subscribed';
|
||||
btn.textContent = '✓ Abonniert';
|
||||
}
|
||||
// Update card in list
|
||||
const card = document.querySelector(`.tpl-card[data-template-id="${t.templateId}"]`);
|
||||
if (card) {
|
||||
const subBtn = card.querySelector('.btn-sub');
|
||||
if (subBtn) {
|
||||
if (isSubscribed) { subBtn.classList.remove('subscribed'); subBtn.textContent = '+ Abonnieren'; }
|
||||
else { subBtn.classList.add('subscribed'); subBtn.textContent = '✓ Abonniert'; }
|
||||
}
|
||||
updateCardSubscriberCount(card, isSubscribed ? -1 : 1);
|
||||
}
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
// ── Detail-Body aufbauen ───────────────────────────────────────────────────
|
||||
|
||||
function buildDetailBody(t) {
|
||||
const sections = [];
|
||||
|
||||
if (t.lockType === 'TIMELOCK') {
|
||||
sections.push(buildSection('⏱ Zeit-Einstellungen', [
|
||||
['Mindestdauer', fmtMinutes(t.minTimeInMinutes)],
|
||||
['Maximaldauer', fmtMinutes(t.maxTimeInMinutes)],
|
||||
['Endzeit sichtbar', t.endTimeVisible ? 'Ja' : 'Nein'],
|
||||
]));
|
||||
|
||||
if (t.spinningWheelEntries && t.spinningWheelEntries.length) {
|
||||
const WHEEL_LABELS = {
|
||||
ADD_TIME: '+ Zeit', REMOVE_TIME: '− Zeit', FREEZE_TIME: '❄ Einfrieren für',
|
||||
FREEZE: '🧊 Einfrieren (∞)', UNFREEZE: '🌊 Auftauen', TASK: '🎯 Aufgabe', TEXT: '💬 Text',
|
||||
};
|
||||
const entries = t.spinningWheelEntries.map(e => {
|
||||
const label = WHEEL_LABELS[e.type] || e.type;
|
||||
const extra = e.intVal ? ' ' + fmtMinutes(e.intVal) : (e.stringVal ? ' «' + e.stringVal + '»' : '');
|
||||
return `<span class="detail-wheel-entry">${label}${extra}</span>`;
|
||||
}).join('');
|
||||
sections.push(`<div class="detail-section">
|
||||
<div class="detail-section-title">🎡 Glücksrad (${t.spinningWheelEntries.length} Einträge${t.spinsEveryMinutes ? ', alle ' + fmtMinutes(t.spinsEveryMinutes) : ''})</div>
|
||||
<div>${entries}</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
if (t.penaltyType) {
|
||||
const penaltyLabels = { ADD: 'Zeit hinzufügen', FREEZE: 'Einfrieren', PILLORY: 'Pranger' };
|
||||
sections.push(buildSection('⚠ Strafmaß', [
|
||||
['Typ', penaltyLabels[t.penaltyType] || t.penaltyType],
|
||||
['Wert', t.penaltyValue ? fmtMinutes(t.penaltyValue) : '–'],
|
||||
]));
|
||||
}
|
||||
|
||||
if (t.taskEveryMinutes || t.minTasksPerDay) {
|
||||
sections.push(buildSection('🎯 Aufgaben-Timing', [
|
||||
['Intervall', t.taskEveryMinutes ? fmtMinutes(t.taskEveryMinutes) : '–'],
|
||||
['Min./Tag', t.minTasksPerDay ? t.minTasksPerDay + ' Aufgabe(n)' : '–'],
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
if (t.lockType === 'CARDLOCK') {
|
||||
const rows = [];
|
||||
const allKeys = new Set([
|
||||
...Object.keys(t.cardCountsMin || {}),
|
||||
...Object.keys(t.cardCountsMax || {}),
|
||||
]);
|
||||
allKeys.forEach(k => {
|
||||
const mn = (t.cardCountsMin || {})[k] ?? 0;
|
||||
const mx = (t.cardCountsMax || {})[k] ?? 0;
|
||||
if (mn > 0 || mx > 0) rows.push([k, `${mn} – ${mx}`]);
|
||||
});
|
||||
if (rows.length)
|
||||
sections.push(buildSection('🃏 Karten', rows));
|
||||
|
||||
sections.push(buildSection('⚙ Karten-Einstellungen', [
|
||||
['Zieh-Intervall', t.pickEveryMinute ? fmtMinutes(t.pickEveryMinute) : '–'],
|
||||
['Picks kumulieren', t.accumulatePicks ? 'Ja' : 'Nein'],
|
||||
['Verbl. Karten zeigen', t.showRemainingCards ? 'Ja' : 'Nein'],
|
||||
]));
|
||||
}
|
||||
|
||||
// Gemeinsame Einstellungen
|
||||
sections.push(buildSection('⚙ Allgemein', [
|
||||
['Hygiene-Öffnung', t.hygieneEnabled ? `alle ${fmtMinutes(t.hygineOpeningEveryMinites)}, ${fmtMinutes(t.hygineOpeningDurationMinutes)} offen` : 'Keine'],
|
||||
['Verifikation', t.requiresVerification ? 'Erforderlich' : 'Keine'],
|
||||
['Aufgaben-Modus', t.taskMode === 'KEYHOLDER' ? 'Keyholder' : t.taskMode === 'COMMUNITY' ? 'Community' : 'Zufällig'],
|
||||
]));
|
||||
|
||||
if (t.tasks && t.tasks.length) {
|
||||
const taskItems = t.tasks.map(task => {
|
||||
const dur = task.durationMinutes ? ` <span style="color:var(--color-muted);font-size:0.8rem;">(${fmtMinutes(task.durationMinutes)})</span>` : '';
|
||||
const desc = task.description ? `<div style="font-size:0.78rem;color:var(--color-muted);margin-top:0.1rem;">${esc(task.description)}</div>` : '';
|
||||
return `<div class="detail-task-item">${esc(task.title || task.name || '–')}${dur}${desc}</div>`;
|
||||
}).join('');
|
||||
sections.push(`<div class="detail-section">
|
||||
<div class="detail-section-title">🎯 Aufgaben (${t.tasks.length})</div>
|
||||
${taskItems}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
return sections.join('');
|
||||
}
|
||||
|
||||
function buildSection(title, rows) {
|
||||
const rowsHtml = rows.map(([label, val]) =>
|
||||
`<div class="detail-row">
|
||||
<span class="detail-row-label">${label}</span>
|
||||
<span class="detail-row-val">${val}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
return `<div class="detail-section">
|
||||
<div class="detail-section-title">${title}</div>
|
||||
${rowsHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Infinite Scroll ────────────────────────────────────────────────────────
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) loadNextPage();
|
||||
}, { rootMargin: '200px' });
|
||||
observer.observe(document.getElementById('scrollSentinel'));
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') closeDetail();
|
||||
});
|
||||
|
||||
fetch('/login/me').then(r => r.ok ? r.json() : null).then(user => {
|
||||
if (!user) { window.location.href = '/login.html'; return; }
|
||||
loadNextPage();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
485
bin/main/static/games/chastity/entdecken.html
Normal file
@@ -0,0 +1,485 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Entdecken – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
/* ── Search ── */
|
||||
.search-bar {
|
||||
display: flex; gap: 0.6rem; margin-bottom: 1.5rem; align-items: center;
|
||||
}
|
||||
.search-bar input[type="text"] {
|
||||
flex: 1; padding: 0.55rem 0.85rem;
|
||||
border: 1px solid var(--color-secondary); border-radius: 6px;
|
||||
background: var(--color-card); color: var(--color-text);
|
||||
font-size: 0.95rem; outline: none; transition: border-color 0.2s;
|
||||
}
|
||||
.search-bar input[type="text"]:focus { border-color: var(--color-primary); }
|
||||
.search-bar input[type="text"]::placeholder { color: var(--color-muted); }
|
||||
.btn-search {
|
||||
background: var(--color-secondary); color: var(--color-text);
|
||||
border: none; border-radius: 6px; padding: 0.55rem 1rem;
|
||||
font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.btn-search:hover { background: var(--color-primary); color: #fff; }
|
||||
|
||||
/* ── Paging ── */
|
||||
.paging {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 0.75rem; margin-top: 1rem;
|
||||
}
|
||||
.paging button {
|
||||
background: var(--color-secondary); color: var(--color-text);
|
||||
border: none; border-radius: 6px; padding: 0.4rem 0.9rem;
|
||||
font-size: 0.85rem; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.paging button:hover:not(:disabled) { background: var(--color-primary); }
|
||||
.paging button:disabled { opacity: 0.35; cursor: default; }
|
||||
.paging .page-info { font-size: 0.85rem; color: var(--color-muted); }
|
||||
|
||||
.empty, .loading { color: var(--color-muted); font-size: 0.9rem; padding: 0.75rem 0; }
|
||||
|
||||
/* ── Gruppe card ── */
|
||||
.gruppe-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.gruppe-card {
|
||||
background: var(--color-card); border: 1px solid var(--color-secondary);
|
||||
border-radius: 10px; overflow: hidden; transition: border-color 0.15s;
|
||||
}
|
||||
.gruppe-card.open { border-color: rgba(233,69,96,0.35); }
|
||||
.gruppe-header {
|
||||
display: flex; align-items: center; gap: 0.9rem;
|
||||
padding: 0.85rem 1rem; cursor: pointer; user-select: none;
|
||||
}
|
||||
.gruppe-img {
|
||||
width: 48px; height: 48px; border-radius: 7px;
|
||||
object-fit: cover; flex-shrink: 0;
|
||||
}
|
||||
.gruppe-img-placeholder {
|
||||
width: 48px; height: 48px; border-radius: 7px;
|
||||
background: var(--color-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.3rem; flex-shrink: 0; color: var(--color-muted);
|
||||
}
|
||||
.gruppe-meta { flex: 1; min-width: 0; }
|
||||
.gruppe-name {
|
||||
font-size: 0.95rem; font-weight: 600; color: var(--color-text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.gruppe-info { font-size: 0.75rem; color: var(--color-muted); margin-top: 0.2rem; }
|
||||
.gruppe-badges { display: flex; gap: 0.3rem; margin-top: 0.25rem; flex-wrap: wrap; }
|
||||
.gruppe-badge {
|
||||
font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 20px;
|
||||
background: rgba(255,255,255,0.07); color: var(--color-muted);
|
||||
}
|
||||
.gruppe-badge-sub { background: rgba(46,204,113,0.15); color: var(--color-success); }
|
||||
.gruppe-toggle { font-size: 0.75rem; color: var(--color-muted); flex-shrink: 0; transition: transform 0.2s; }
|
||||
.gruppe-card.open .gruppe-toggle { transform: rotate(90deg); }
|
||||
|
||||
/* ── Subscribe button ── */
|
||||
.btn-sub {
|
||||
background: none; border: 1px solid var(--color-secondary); border-radius: 6px;
|
||||
color: var(--color-muted); font-size: 0.8rem; padding: 0.3rem 0.75rem;
|
||||
cursor: pointer; transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
flex-shrink: 0; white-space: nowrap;
|
||||
}
|
||||
.btn-sub:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-sub.subscribed {
|
||||
border-color: rgba(46,204,113,0.5); color: var(--color-success);
|
||||
}
|
||||
.btn-sub.subscribed:hover {
|
||||
border-color: var(--color-primary); color: var(--color-primary);
|
||||
background: rgba(233,69,96,0.08);
|
||||
}
|
||||
.btn-sub:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* ── Gruppe body ── */
|
||||
.gruppe-body { border-top: 1px solid var(--color-secondary); padding: 1rem 1rem 0.75rem; }
|
||||
.gruppe-desc { font-size: 0.82rem; color: var(--color-muted); margin-bottom: 0.85rem; line-height: 1.5; }
|
||||
|
||||
.sub-section + .sub-section { margin-top: 0.85rem; }
|
||||
.sub-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; }
|
||||
.sub-section-title {
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em;
|
||||
text-transform: uppercase; color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Items ── */
|
||||
.item-list { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.item { border-radius: 6px; background: var(--color-secondary); overflow: hidden; }
|
||||
.item-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 0.75rem; padding: 0.35rem 0.6rem;
|
||||
cursor: pointer; user-select: none; transition: background 0.12s;
|
||||
}
|
||||
.item-row:hover { background: rgba(255,255,255,0.04); }
|
||||
.item.open .item-row { background: rgba(233,69,96,0.08); }
|
||||
.item-text {
|
||||
color: var(--color-text); flex: 1; min-width: 0; font-size: 0.82rem;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.item-badges { display: flex; gap: 0.35rem; flex-shrink: 0; }
|
||||
.badge {
|
||||
font-size: 0.7rem; padding: 0.1rem 0.45rem; border-radius: 20px;
|
||||
background: rgba(233,69,96,0.15); color: var(--color-primary); white-space: nowrap;
|
||||
}
|
||||
.badge-neutral { background: rgba(255,255,255,0.07); color: var(--color-muted); }
|
||||
|
||||
/* ── Item detail ── */
|
||||
.item-detail {
|
||||
display: none; padding: 0.5rem 0.6rem 0.6rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
font-size: 0.8rem; color: var(--color-muted); line-height: 1.55;
|
||||
}
|
||||
.item.open .item-detail { display: block; }
|
||||
.item-detail-text { margin-bottom: 0.4rem; color: var(--color-text); white-space: pre-wrap; }
|
||||
.item-detail-row { display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; margin-top: 0.25rem; }
|
||||
.item-detail-label { font-size: 0.72rem; color: var(--color-muted); }
|
||||
.item-detail-chip {
|
||||
font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 20px;
|
||||
background: rgba(255,255,255,0.07); color: var(--color-text);
|
||||
}
|
||||
.item-detail-chip-toy { background: rgba(233,69,96,0.12); color: var(--color-primary); }
|
||||
.sub-empty { font-size: 0.78rem; color: var(--color-muted); padding: 0.2rem 0; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" maxlength="200">
|
||||
<button class="btn-search" id="searchBtn">Suchen</button>
|
||||
</div>
|
||||
<div id="loading" class="loading">Wird geladen…</div>
|
||||
<div id="groupList" class="gruppe-list"></div>
|
||||
<div class="paging" id="paging" style="display:none;">
|
||||
<button id="prevBtn">‹ Zurück</button>
|
||||
<span class="page-info" id="pageInfo"></span>
|
||||
<button id="nextBtn">Weiter ›</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
const PAGE_SIZE = 10;
|
||||
let currentPage = 0, totalPages = 1;
|
||||
let currentName = '';
|
||||
|
||||
// ── XSS ──
|
||||
function esc(str) {
|
||||
if (str == null) return '';
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── Auth ──
|
||||
fetch('/login/me')
|
||||
.then(r => { if (r.status === 401) { window.location.href = '/login.html'; return null; } return r.ok ? r.json() : null; })
|
||||
.then(user => { if (!user) return; loadGroups(); })
|
||||
.catch(() => { window.location.href = '/login.html'; });
|
||||
|
||||
// ── Load ──
|
||||
function loadGroups() {
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
document.getElementById('groupList').innerHTML = '';
|
||||
document.getElementById('paging').style.display = 'none';
|
||||
const nameParam = currentName ? `&name=${encodeURIComponent(currentName)}` : '';
|
||||
fetch(`/abo/discover?page=${currentPage}&size=${PAGE_SIZE}${nameParam}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
totalPages = data.totalPages || 1;
|
||||
renderGroups(data.content || []);
|
||||
updatePaging(currentPage, totalPages);
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
})
|
||||
.catch(() => { document.getElementById('loading').textContent = 'Fehler beim Laden.'; });
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
const WERKZEUG_LABEL = {
|
||||
MUND: 'Mund', VAGINA: 'Vagina', PENIS: 'Penis',
|
||||
ANUS: 'Anus', UMSCHNALLDILDO: 'Umschnall-Dildo'
|
||||
};
|
||||
|
||||
function werkzeugChips(list) {
|
||||
if (!list || list.length === 0) return '';
|
||||
return list.map(w => `<span class="item-detail-chip">${esc(WERKZEUG_LABEL[w] || w)}</span>`).join('');
|
||||
}
|
||||
function toyChips(list) {
|
||||
if (!list || list.length === 0) return '';
|
||||
return list.map(t => `<span class="item-detail-chip item-detail-chip-toy">${esc(t.name || t)}</span>`).join('');
|
||||
}
|
||||
function formatSek(von, bis) {
|
||||
if (von != null && bis != null) return `${von}–${bis} s`;
|
||||
if (von != null) return `ab ${von} s`;
|
||||
if (bis != null) return `bis ${bis} s`;
|
||||
return '';
|
||||
}
|
||||
function formatMin(von, bis) {
|
||||
if (von != null && bis != null) return `${von}–${bis} min`;
|
||||
if (von != null) return `ab ${von} min`;
|
||||
if (bis != null) return `bis ${bis} min`;
|
||||
return '';
|
||||
}
|
||||
|
||||
// Track which group card is open
|
||||
let openGroupId = null;
|
||||
// Track which item detail is open
|
||||
let openItemId = null;
|
||||
|
||||
function renderGroups(groups) {
|
||||
const list = document.getElementById('groupList');
|
||||
if (!groups || groups.length === 0) {
|
||||
list.innerHTML = '<p class="empty">Keine Gruppen gefunden.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = groups.map(g => {
|
||||
const aufgabenCount = (g.aufgaben || []).length;
|
||||
const strafeCount = (g.strafen || []).length;
|
||||
const sperreCount = (g.sperren || []).length;
|
||||
const counts = [
|
||||
aufgabenCount ? `${aufgabenCount} Aufgabe${aufgabenCount !== 1 ? 'n' : ''}` : '',
|
||||
strafeCount ? `${strafeCount} Strafe${strafeCount !== 1 ? 'n' : ''}` : '',
|
||||
sperreCount ? `${sperreCount} Zeitstrafe${sperreCount !== 1 ? 'n' : ''}` : ''
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
const subLabel = g.subscribed
|
||||
? `<span class="gruppe-badge gruppe-badge-sub">♥ Abonniert</span>`
|
||||
: '';
|
||||
const subCount = g.subscriberCount > 0
|
||||
? `<span class="gruppe-badge">♥ ${g.subscriberCount} Abo${g.subscriberCount !== 1 ? 's' : ''}</span>`
|
||||
: '';
|
||||
|
||||
const subBtnClass = g.subscribed ? 'btn-sub subscribed' : 'btn-sub';
|
||||
const subBtnText = g.subscribed ? '♥ Abonniert' : '♥ Abonnieren';
|
||||
|
||||
return `
|
||||
<div class="gruppe-card" id="dgroup-${esc(g.gruppenId)}">
|
||||
<div class="gruppe-header">
|
||||
<div style="cursor:pointer; display:flex; align-items:center; gap:0.9rem; flex:1; min-width:0;"
|
||||
onclick="toggleGroup('${esc(g.gruppenId)}')">
|
||||
${g.bild
|
||||
? `<img class="gruppe-img" src="data:image/png;base64,${g.bild}" alt="${esc(g.name)}">`
|
||||
: `<div class="gruppe-img-placeholder">⊙</div>`}
|
||||
<div class="gruppe-meta">
|
||||
<div class="gruppe-name">${esc(g.name)}</div>
|
||||
<div class="gruppe-info">${g.von ? esc(g.von) + (counts ? ' · ' : '') : ''}${counts || 'Keine Einträge'}</div>
|
||||
${(subLabel || subCount) ? `<div class="gruppe-badges">${subCount}${subLabel}</div>` : ''}
|
||||
</div>
|
||||
<span class="gruppe-toggle">▶</span>
|
||||
</div>
|
||||
<button class="${subBtnClass}" id="subbtn-${esc(g.gruppenId)}"
|
||||
onclick="toggleSubscribe('${esc(g.gruppenId)}', this)">
|
||||
${subBtnText}
|
||||
</button>
|
||||
</div>
|
||||
<div class="gruppe-body" id="dbody-${esc(g.gruppenId)}" style="display:none;">
|
||||
${g.beschreibung ? `<div class="gruppe-desc">${esc(g.beschreibung)}</div>` : ''}
|
||||
${renderSubSection('Aufgaben', sortByLevelThenName(g.aufgaben || []), renderAufgabe)}
|
||||
${renderSubSection('Strafen', sortByLevelThenName(g.strafen || []), renderStrafe)}
|
||||
${renderSubSection('Zeitstrafen', sortByName(g.sperren || []), renderZeitstrafe)}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
openItemId = null;
|
||||
}
|
||||
|
||||
function renderSubSection(title, items, renderFn) {
|
||||
return `<div class="sub-section">
|
||||
<div class="sub-section-header">
|
||||
<span class="sub-section-title">${esc(title)} (${items.length})</span>
|
||||
</div>
|
||||
${items.length === 0
|
||||
? '<div class="sub-empty">Keine Einträge</div>'
|
||||
: `<div class="item-list">${items.map(item => renderFn(item)).join('')}</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderAufgabe(a) {
|
||||
const badges = [];
|
||||
const zeit = formatSek(a.sekundenVon, a.sekundenBis);
|
||||
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
|
||||
if (a.level != null) badges.push(`<span class="badge">Level ${esc(String(a.level))}</span>`);
|
||||
|
||||
const detailRows = [];
|
||||
if (a.text) detailRows.push(`<div class="item-detail-text">${esc(a.text)}</div>`);
|
||||
if (a.benoetigtAktiv && a.benoetigtAktiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Aktiv:</span>${werkzeugChips(a.benoetigtAktiv)}</div>`);
|
||||
if (a.benoetigtPassiv && a.benoetigtPassiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Passiv:</span>${werkzeugChips(a.benoetigtPassiv)}</div>`);
|
||||
if (a.benoetigteToys && a.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(a.benoetigteToys)}</div>`);
|
||||
|
||||
return `<div class="item" id="ditem-${esc(a.aufgabeId)}">
|
||||
<div class="item-row" onclick="toggleItem('${esc(a.aufgabeId)}')">
|
||||
<span class="item-text">${esc(a.kurzText)}</span>
|
||||
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
|
||||
</div>
|
||||
${detailRows.length ? `<div class="item-detail">${detailRows.join('')}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderStrafe(s) {
|
||||
const badges = [];
|
||||
const zeit = formatSek(s.sekundenVon, s.sekundenBis);
|
||||
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
|
||||
if (s.level != null) badges.push(`<span class="badge">Level ${esc(String(s.level))}</span>`);
|
||||
|
||||
const detailRows = [];
|
||||
if (s.text) detailRows.push(`<div class="item-detail-text">${esc(s.text)}</div>`);
|
||||
if (s.benoetigtAktiv && s.benoetigtAktiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Aktiv:</span>${werkzeugChips(s.benoetigtAktiv)}</div>`);
|
||||
if (s.benoetigtPassiv && s.benoetigtPassiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Passiv:</span>${werkzeugChips(s.benoetigtPassiv)}</div>`);
|
||||
if (s.benoetigteToys && s.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(s.benoetigteToys)}</div>`);
|
||||
|
||||
return `<div class="item" id="ditem-${esc(s.strafeId)}">
|
||||
<div class="item-row" onclick="toggleItem('${esc(s.strafeId)}')">
|
||||
<span class="item-text">${esc(s.kurzText)}</span>
|
||||
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
|
||||
</div>
|
||||
${detailRows.length ? `<div class="item-detail">${detailRows.join('')}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderZeitstrafe(z) {
|
||||
const badges = [];
|
||||
const zeit = formatMin(z.minutenVon, z.minutenBis);
|
||||
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
|
||||
|
||||
const detailRows = [];
|
||||
if (z.text) detailRows.push(`<div class="item-detail-text">${esc(z.text)}</div>`);
|
||||
if (z.releaseText) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Bei Aufhebung:</span><span style="font-size:0.78rem; color:var(--color-text);">${esc(z.releaseText)}</span></div>`);
|
||||
if (z.sperreFuer && z.sperreFuer.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Sperrt:</span>${werkzeugChips(z.sperreFuer)}</div>`);
|
||||
if (z.benoetigteToys && z.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(z.benoetigteToys)}</div>`);
|
||||
|
||||
return `<div class="item" id="ditem-${esc(z.sperreId)}">
|
||||
<div class="item-row" onclick="toggleItem('${esc(z.sperreId)}')">
|
||||
<span class="item-text">${esc(z.kurzText)}</span>
|
||||
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
|
||||
</div>
|
||||
${detailRows.length ? `<div class="item-detail">${detailRows.join('')}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Sort ──
|
||||
function sortByLevelThenName(items) {
|
||||
return items.slice().sort((a, b) => {
|
||||
const la = a.level ?? 999, lb = b.level ?? 999;
|
||||
if (la !== lb) return la - lb;
|
||||
return (a.kurzText || '').localeCompare(b.kurzText || '', 'de');
|
||||
});
|
||||
}
|
||||
function sortByName(items) {
|
||||
return items.slice().sort((a, b) => (a.kurzText || '').localeCompare(b.kurzText || '', 'de'));
|
||||
}
|
||||
|
||||
// ── Group toggle ──
|
||||
function toggleGroup(gruppenId) {
|
||||
const card = document.getElementById('dgroup-' + gruppenId);
|
||||
const body = document.getElementById('dbody-' + gruppenId);
|
||||
if (!card) return;
|
||||
if (card.classList.contains('open')) {
|
||||
card.classList.remove('open');
|
||||
body.style.display = 'none';
|
||||
if (openGroupId === gruppenId) openGroupId = null;
|
||||
} else {
|
||||
if (openGroupId) {
|
||||
const prev = document.getElementById('dgroup-' + openGroupId);
|
||||
const prevBody = document.getElementById('dbody-' + openGroupId);
|
||||
if (prev) prev.classList.remove('open');
|
||||
if (prevBody) prevBody.style.display = 'none';
|
||||
}
|
||||
card.classList.add('open');
|
||||
body.style.display = 'block';
|
||||
openGroupId = gruppenId;
|
||||
openItemId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Item toggle ──
|
||||
function toggleItem(itemId) {
|
||||
if (openItemId === itemId) {
|
||||
const el = document.getElementById('ditem-' + itemId);
|
||||
if (el) el.classList.remove('open');
|
||||
openItemId = null;
|
||||
return;
|
||||
}
|
||||
if (openItemId) {
|
||||
const prev = document.getElementById('ditem-' + openItemId);
|
||||
if (prev) prev.classList.remove('open');
|
||||
}
|
||||
const el = document.getElementById('ditem-' + itemId);
|
||||
if (el) el.classList.add('open');
|
||||
openItemId = itemId;
|
||||
}
|
||||
|
||||
// ── Subscribe / Unsubscribe ──
|
||||
function toggleSubscribe(gruppenId, btn) {
|
||||
btn.disabled = true;
|
||||
const isSubscribed = btn.classList.contains('subscribed');
|
||||
const method = isSubscribed ? 'DELETE' : 'POST';
|
||||
fetch(`/abo/${gruppenId}`, { method })
|
||||
.then(r => {
|
||||
if (r.ok || r.status === 201 || r.status === 202) {
|
||||
if (isSubscribed) {
|
||||
btn.classList.remove('subscribed');
|
||||
btn.textContent = '♥ Abonnieren';
|
||||
updateBadge(gruppenId, false);
|
||||
} else {
|
||||
btn.classList.add('subscribed');
|
||||
btn.textContent = '♥ Abonniert';
|
||||
updateBadge(gruppenId, true);
|
||||
}
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(() => { btn.disabled = false; });
|
||||
}
|
||||
|
||||
function updateBadge(gruppenId, subscribed) {
|
||||
const card = document.getElementById('dgroup-' + gruppenId);
|
||||
if (!card) return;
|
||||
const badgesEl = card.querySelector('.gruppe-badges');
|
||||
if (!badgesEl) return;
|
||||
const subBadge = badgesEl.querySelector('.gruppe-badge-sub');
|
||||
if (subscribed && !subBadge) {
|
||||
badgesEl.insertAdjacentHTML('beforeend', `<span class="gruppe-badge gruppe-badge-sub">♥ Abonniert</span>`);
|
||||
} else if (!subscribed && subBadge) {
|
||||
subBadge.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search ──
|
||||
document.getElementById('searchBtn').addEventListener('click', () => {
|
||||
currentName = document.getElementById('searchInput').value.trim();
|
||||
currentPage = 0;
|
||||
loadGroups();
|
||||
});
|
||||
document.getElementById('searchInput').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') document.getElementById('searchBtn').click();
|
||||
});
|
||||
|
||||
// ── Paging ──
|
||||
function updatePaging(current, total) {
|
||||
const el = document.getElementById('paging');
|
||||
if (total <= 1) { el.style.display = 'none'; return; }
|
||||
el.style.display = 'flex';
|
||||
document.getElementById('prevBtn').disabled = current === 0;
|
||||
document.getElementById('nextBtn').disabled = current >= total - 1;
|
||||
document.getElementById('pageInfo').textContent = `Seite ${current + 1} von ${total}`;
|
||||
}
|
||||
|
||||
document.getElementById('prevBtn').addEventListener('click', () => {
|
||||
if (currentPage > 0) { currentPage--; loadGroups(); }
|
||||
});
|
||||
document.getElementById('nextBtn').addEventListener('click', () => {
|
||||
if (currentPage < totalPages - 1) { currentPage++; loadGroups(); }
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
21
bin/main/static/games/chastity/infochastity.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chastity Game – Info – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<h1>Chastity Game</h1>
|
||||
<p>Informationen zum Chastity Game folgen hier.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
307
bin/main/static/games/chastity/joinlock.html
Normal file
@@ -0,0 +1,307 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Lock-Einladung – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.invite-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 2rem 1.5rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.invite-icon { font-size: 3rem; margin-bottom: 1rem; }
|
||||
.invite-title { font-size: 1.3rem; font-weight: 700; margin-bottom: 0.4rem; }
|
||||
.invite-sub { color: var(--color-muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
|
||||
.invite-detail {
|
||||
background: var(--color-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.invite-detail dt { color: var(--color-muted); font-size: 0.78rem; margin-bottom: 0.1rem; }
|
||||
.invite-detail dd { font-weight: 600; margin: 0 0 0.5rem 0; }
|
||||
.invite-detail dd:last-child { margin-bottom: 0; }
|
||||
.invite-actions { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
|
||||
.invite-actions button { width: auto; padding: 0.65rem 1.5rem; }
|
||||
.btn-danger { background: #c0392b !important; }
|
||||
.btn-danger:hover { background: #a93226 !important; }
|
||||
|
||||
.code-lines-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.code-lines-row input { width: 80px; text-align: center; }
|
||||
.code-lines-row span { color: var(--color-text); font-size: 0.9rem; }
|
||||
|
||||
/* Unlock-Code-Modal */
|
||||
.unlock-modal-bg {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 400;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.unlock-modal-bg.open { display: flex; }
|
||||
.unlock-modal-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.55);
|
||||
}
|
||||
.unlock-modal-box {
|
||||
position: relative;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 1.5rem 1.25rem;
|
||||
max-width: 380px;
|
||||
width: 90%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.unlock-code-display {
|
||||
font-family: monospace;
|
||||
font-size: 2rem;
|
||||
letter-spacing: 0.3em;
|
||||
background: var(--color-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.5rem;
|
||||
color: var(--color-primary);
|
||||
line-height: 1.8;
|
||||
word-break: break-all;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#stateLoading { display: none; }
|
||||
#stateError { display: none; }
|
||||
#stateAlready { display: none; }
|
||||
#stateDeclined { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
|
||||
<div id="stateLoading" style="text-align:center;padding:3rem 1rem;color:var(--color-muted);">Lade Einladung…</div>
|
||||
|
||||
<div id="stateError" style="text-align:center;padding:3rem 1rem;">
|
||||
<div style="font-size:2.5rem;margin-bottom:1rem;">⚠️</div>
|
||||
<h2 style="margin-bottom:0.5rem;">Einladung nicht gefunden</h2>
|
||||
<p style="color:var(--color-muted);">Diese Einladung existiert nicht oder wurde bereits bearbeitet.</p>
|
||||
<a href="/games/common/einladungen.html" style="display:inline-block;margin-top:1.5rem;padding:0.65rem 1.5rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Zu meinen Einladungen</a>
|
||||
</div>
|
||||
|
||||
<div id="stateAlready" style="text-align:center;padding:3rem 1rem;">
|
||||
<div style="font-size:2.5rem;margin-bottom:1rem;">🔒</div>
|
||||
<h2 style="margin-bottom:0.5rem;">Lock bereits aktiv</h2>
|
||||
<p style="color:var(--color-muted);">Diese Einladung wurde bereits angenommen.</p>
|
||||
</div>
|
||||
|
||||
<div id="stateDeclined" style="text-align:center;padding:3rem 1rem;">
|
||||
<div style="font-size:2.5rem;margin-bottom:1rem;">✕</div>
|
||||
<h2 style="margin-bottom:0.5rem;">Einladung abgelehnt</h2>
|
||||
<p style="color:var(--color-muted);">Du hast die Einladung abgelehnt. Der Keyholder wurde benachrichtigt.</p>
|
||||
<a href="/games/common/einladungen.html" style="display:inline-block;margin-top:1.5rem;padding:0.65rem 1.5rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Zu meinen Einladungen</a>
|
||||
</div>
|
||||
|
||||
<div id="stateInvite" style="display:none;">
|
||||
<div class="invite-card">
|
||||
<div class="invite-icon">🔒</div>
|
||||
<div class="invite-title">Lock-Einladung</div>
|
||||
<div class="invite-sub" id="invSubtitle"></div>
|
||||
<dl class="invite-detail" id="invDetail"></dl>
|
||||
|
||||
<div id="acceptSection">
|
||||
<p style="font-size:0.88rem;color:var(--color-muted);margin-bottom:0.75rem;">
|
||||
Wie viele Ziffern soll dein Entsperrcode haben?
|
||||
</p>
|
||||
<div class="code-lines-row">
|
||||
<input type="number" id="codeLines" min="1" max="20" value="5">
|
||||
<span>Ziffern</span>
|
||||
</div>
|
||||
<div class="invite-actions">
|
||||
<button class="btn-danger" onclick="declineInvitation()">✕ Ablehnen</button>
|
||||
<button onclick="acceptInvitation()">✓ Annehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="errorMsg" style="color:#e74c3c;font-size:0.85rem;margin-top:0.5rem;display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unlock-Code-Modal -->
|
||||
<div class="unlock-modal-bg" id="unlockModal">
|
||||
<div class="unlock-modal-overlay"></div>
|
||||
<div class="unlock-modal-box">
|
||||
<div style="font-size:2rem;">🔒</div>
|
||||
<h3 id="unlockModalTitle" style="margin:0;">Dein Entsperrcode</h3>
|
||||
<p id="unlockModalHint" style="color:var(--color-muted);font-size:0.85rem;margin:0;">
|
||||
Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem.
|
||||
</p>
|
||||
<div class="unlock-code-display" id="unlockCodeDisplay"></div>
|
||||
<div id="unlockModalCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);font-family:monospace;"></div>
|
||||
<button id="unlockModalBtn" style="width:100%;margin-top:0.25rem;">Weiter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/social-sidebar.js"></script>
|
||||
<script>
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get('token');
|
||||
let lockId = null;
|
||||
|
||||
document.getElementById('stateLoading').style.display = '';
|
||||
|
||||
async function load() {
|
||||
if (!token) { showState('stateError'); return; }
|
||||
try {
|
||||
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token));
|
||||
if (res.status === 409) { showState('stateAlready'); return; }
|
||||
if (!res.ok) { showState('stateError'); return; }
|
||||
const inv = await res.json();
|
||||
lockId = inv.lockId;
|
||||
|
||||
document.getElementById('invSubtitle').textContent =
|
||||
inv.keyholderName + ' hat dich als Lockee eingeladen';
|
||||
const dl = document.getElementById('invDetail');
|
||||
dl.innerHTML = `
|
||||
<dt>Lock-Name</dt><dd>${esc(inv.lockName)}</dd>
|
||||
<dt>Keyholder</dt><dd>${esc(inv.keyholderName)}</dd>`;
|
||||
showState('stateInvite');
|
||||
} catch(e) {
|
||||
showState('stateError');
|
||||
}
|
||||
}
|
||||
|
||||
function showState(id) {
|
||||
document.getElementById('stateLoading').style.display = 'none';
|
||||
['stateError','stateAlready','stateInvite','stateDeclined'].forEach(s => {
|
||||
document.getElementById(s).style.display = s === id ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
|
||||
|
||||
async function acceptInvitation() {
|
||||
const lines = parseInt(document.getElementById('codeLines').value);
|
||||
if (!lines || lines < 1) { showError('Bitte eine Ziffernanzahl eingeben.'); return; }
|
||||
|
||||
const btn = document.querySelector('#acceptSection button:last-child');
|
||||
btn.disabled = true;
|
||||
document.getElementById('errorMsg').style.display = 'none';
|
||||
|
||||
try {
|
||||
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token) + '/accept', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ unlockCodeLines: lines })
|
||||
});
|
||||
if (!res.ok) { btn.disabled = false; showError('Fehler beim Annehmen der Einladung.'); return; }
|
||||
const data = await res.json();
|
||||
showUnlockCodeModal(data.unlockCode, data.lockId);
|
||||
} catch(e) {
|
||||
btn.disabled = false;
|
||||
showError('Fehler beim Annehmen der Einladung.');
|
||||
}
|
||||
}
|
||||
|
||||
async function declineInvitation() {
|
||||
if (!confirm('Bist du sicher, dass du diese Einladung ablehnen möchtest? Das Lock wird gelöscht.')) return;
|
||||
const btn = document.querySelector('.btn-danger');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token), { method: 'DELETE' });
|
||||
if (res.ok || res.status === 204) {
|
||||
showState('stateDeclined');
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
showError('Fehler beim Ablehnen der Einladung.');
|
||||
}
|
||||
} catch(e) {
|
||||
btn.disabled = false;
|
||||
showError('Fehler beim Ablehnen der Einladung.');
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('errorMsg');
|
||||
el.textContent = msg;
|
||||
el.style.display = '';
|
||||
}
|
||||
|
||||
function showUnlockCodeModal(code, lid) {
|
||||
document.getElementById('unlockCodeDisplay').textContent = code;
|
||||
const url = '/games/chastity/activelock.html?lockId=' + lid;
|
||||
const btn = document.getElementById('unlockModalBtn');
|
||||
btn.onclick = () => startCodeScramble(code, url);
|
||||
document.getElementById('unlockModal').classList.add('open');
|
||||
}
|
||||
|
||||
function startCodeScramble(realCode, url) {
|
||||
const display = document.getElementById('unlockCodeDisplay');
|
||||
const btn = document.getElementById('unlockModalBtn');
|
||||
const hint = document.getElementById('unlockModalHint');
|
||||
const countdown = document.getElementById('unlockModalCountdown');
|
||||
|
||||
const len = realCode.length;
|
||||
const DURATION = 3 * 60;
|
||||
let remaining = DURATION;
|
||||
let stopped = false;
|
||||
|
||||
function randomCode() {
|
||||
return Array.from({ length: len }, () => Math.floor(Math.random() * 10)).join('');
|
||||
}
|
||||
function finish() {
|
||||
stopped = true;
|
||||
clearInterval(scrambleInterval);
|
||||
clearInterval(countdownInterval);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
if (hint) hint.style.display = 'none';
|
||||
countdown.style.display = '';
|
||||
document.getElementById('unlockModalTitle').textContent = 'Nun vergessen wir den Code…';
|
||||
btn.textContent = 'Abbrechen';
|
||||
btn.onclick = finish;
|
||||
|
||||
function updateCountdown() {
|
||||
const m = Math.floor(remaining / 60);
|
||||
const s = remaining % 60;
|
||||
countdown.textContent = `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
updateCountdown();
|
||||
|
||||
const scrambleInterval = setInterval(() => { if (!stopped) display.textContent = randomCode(); }, 1000);
|
||||
const countdownInterval = setInterval(() => {
|
||||
if (stopped) return;
|
||||
remaining--;
|
||||
updateCountdown();
|
||||
if (remaining <= 0) finish();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
530
bin/main/static/games/chastity/keyholder-finden.html
Normal file
@@ -0,0 +1,530 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Keyholder finden – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.offer-card {
|
||||
background: var(--color-card); border: 1px solid var(--color-secondary);
|
||||
border-radius: 10px; padding: 1rem; margin-bottom: 0.75rem;
|
||||
display: flex; align-items: center; gap: 0.85rem;
|
||||
}
|
||||
.offer-avatar {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
background: var(--color-secondary); border: 1px solid rgba(255,255,255,0.08);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.3rem; flex-shrink: 0; overflow: hidden;
|
||||
}
|
||||
.offer-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.offer-body { flex: 1; min-width: 0; }
|
||||
.offer-name { font-weight: 700; font-size: 0.95rem; margin-bottom: 0.2rem;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.offer-sub { font-size: 0.78rem; color: var(--color-muted); margin-bottom: 0.3rem; }
|
||||
.offer-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
.offer-badge {
|
||||
display: inline-block; font-size: 0.72rem; padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px; background: rgba(255,255,255,0.07); border: 1px solid var(--color-secondary);
|
||||
}
|
||||
.offer-badge.direct { background: rgba(46,204,113,0.12); border-color: rgba(46,204,113,0.3); color: #2ecc71; }
|
||||
.offer-badge.confirm { background: rgba(230,126,34,0.12); border-color: rgba(230,126,34,0.3); color: #e67e22; }
|
||||
.btn-join {
|
||||
background: var(--color-primary); border: none; color: #fff;
|
||||
border-radius: 7px; padding: 0.4rem 1rem; font-size: 0.85rem;
|
||||
font-weight: 600; cursor: pointer; flex-shrink: 0; width: auto;
|
||||
}
|
||||
.btn-join:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
/* Klickbarer Card-Bereich */
|
||||
.offer-card-clickable { cursor: pointer; }
|
||||
.offer-card-clickable:hover { background: var(--color-secondary); }
|
||||
|
||||
/* Detail-Dialog */
|
||||
.detail-backdrop {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.65); z-index: 500;
|
||||
align-items: flex-start; justify-content: center;
|
||||
overflow-y: auto; padding: 2rem 1rem;
|
||||
}
|
||||
.detail-backdrop.open { display: flex; }
|
||||
.detail-box {
|
||||
background: var(--color-card); border: 1px solid var(--color-secondary);
|
||||
border-radius: 14px; padding: 1.5rem; max-width: 520px; width: 100%;
|
||||
display: flex; flex-direction: column; gap: 1rem; position: relative;
|
||||
}
|
||||
.detail-section { margin-bottom: 0.25rem; }
|
||||
.detail-section-title {
|
||||
font-size: 0.72rem; font-weight: 700; color: var(--color-primary);
|
||||
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.5rem;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex; justify-content: space-between; gap: 1rem;
|
||||
padding: 0.25rem 0; border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
.detail-row-label { color: var(--color-muted); flex-shrink: 0; }
|
||||
.detail-row-val { color: var(--color-text); text-align: right; }
|
||||
.detail-task-item {
|
||||
font-size: 0.88rem; padding: 0.35rem 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.detail-task-item:last-child { border-bottom: none; }
|
||||
.detail-wheel-entry {
|
||||
display: inline-block; font-size: 0.8rem; padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px; background: rgba(255,255,255,0.07);
|
||||
border: 1px solid var(--color-secondary); margin: 0.15rem 0.2rem 0.15rem 0;
|
||||
}
|
||||
.detail-footer {
|
||||
display: flex; gap: 0.6rem; justify-content: flex-end;
|
||||
border-top: 1px solid var(--color-secondary); padding-top: 1rem; margin-top: 0.25rem;
|
||||
}
|
||||
.btn-close-detail {
|
||||
background: none; border: 1px solid var(--color-secondary);
|
||||
color: var(--color-muted); padding: 0.5rem 1.1rem;
|
||||
border-radius: 7px; cursor: pointer; font-size: 0.88rem; width: auto;
|
||||
}
|
||||
.btn-join-detail {
|
||||
background: var(--color-primary); border: none; color: #fff;
|
||||
border-radius: 7px; padding: 0.5rem 1.25rem; font-size: 0.88rem;
|
||||
font-weight: 600; cursor: pointer; width: auto;
|
||||
}
|
||||
.btn-join-detail:disabled { opacity: 0.45; cursor: default; }
|
||||
.detail-author-avatar {
|
||||
width: 52px; height: 52px; border-radius: 50%;
|
||||
background: var(--color-secondary); border: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
.detail-author-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* Join-Dialog */
|
||||
.join-modal-backdrop {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.65); z-index: 500;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.join-modal-backdrop.open { display: flex; }
|
||||
.join-modal-box {
|
||||
background: var(--color-card); border: 1px solid var(--color-secondary);
|
||||
border-radius: 14px; padding: 1.5rem; max-width: 400px; width: 92%;
|
||||
display: flex; flex-direction: column; gap: 1rem; position: relative;
|
||||
}
|
||||
.form-group { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.form-label { font-size: 0.72rem; font-weight: 700; color: var(--color-primary);
|
||||
text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<h2 style="margin-bottom:1.25rem;">🔍 Keyholder finden</h2>
|
||||
<p style="font-size:0.88rem;color:var(--color-muted);margin-bottom:1.25rem;line-height:1.5;">
|
||||
Hier findest du Nutzer*innen, die sich als Keyholder für ein bestimmtes Lock-Template anbieten.
|
||||
Die beliebtesten Angebote erscheinen ganz oben.
|
||||
</p>
|
||||
|
||||
<div id="offerList"></div>
|
||||
<p id="listEmpty" style="display:none;color:var(--color-muted);">Keine Keyholder-Angebote gefunden.</p>
|
||||
<p id="listLoading" style="color:var(--color-muted);">Wird geladen…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail-Dialog -->
|
||||
<div class="detail-backdrop" id="detailModal" onclick="closeDetail()">
|
||||
<div class="detail-box" onclick="event.stopPropagation()">
|
||||
<button onclick="closeDetail()" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.5rem;line-height:1;">✕</button>
|
||||
<div style="display:flex;align-items:flex-start;gap:0.85rem;">
|
||||
<div class="detail-author-avatar" id="detailAvatar" style="display:none;">◉</div>
|
||||
<div>
|
||||
<h2 id="detailTitle" style="margin:0 0 0.25rem;font-size:1.2rem;"></h2>
|
||||
<div id="detailMeta" style="font-size:0.82rem;color:var(--color-muted);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="detailBody"></div>
|
||||
<div class="detail-footer">
|
||||
<button class="btn-close-detail" onclick="closeDetail()">Schließen</button>
|
||||
<button class="btn-join-detail" id="detailJoinBtn" onclick="detailJoin()">🔒 Beitreten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Join-Dialog -->
|
||||
<div class="join-modal-backdrop" id="joinModal">
|
||||
<div class="join-modal-box">
|
||||
<button onclick="closeJoinModal()" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.5rem;line-height:1;">✕</button>
|
||||
<h3 style="margin:0;font-size:1.05rem;">🔒 Angebot annehmen</h3>
|
||||
<p id="joinModalDesc" style="margin:0;font-size:0.85rem;color:var(--color-muted);line-height:1.5;"></p>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-label">Schloss-Steuerung</div>
|
||||
<select id="joinControllType" style="padding:0.5rem 0.75rem;border-radius:7px;border:1px solid var(--color-secondary);background:var(--color-secondary);color:var(--color-text);font-size:0.88rem;">
|
||||
<option value="">– Bitte wählen –</option>
|
||||
<option value="UNLOCK_CODE">🔢 Entsperrcode (Standard)</option>
|
||||
<option value="TRUST">🤝 Trust (kein Code)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="codeLenGroup">
|
||||
<div class="form-label">Code-Länge</div>
|
||||
<input type="number" id="joinCodeLen" value="5" min="1" max="10"
|
||||
style="padding:0.5rem 0.75rem;border-radius:7px;border:1px solid var(--color-secondary);background:var(--color-secondary);color:var(--color-text);font-size:0.88rem;width:100%;box-sizing:border-box;">
|
||||
</div>
|
||||
|
||||
<div id="joinError" style="display:none;font-size:0.85rem;color:#e74c3c;"></div>
|
||||
|
||||
<div style="display:flex;gap:0.6rem;justify-content:flex-end;margin-top:0.25rem;">
|
||||
<button onclick="closeJoinModal()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);padding:0.5rem 1.1rem;border-radius:7px;cursor:pointer;font-size:0.88rem;width:auto;">Abbrechen</button>
|
||||
<button id="joinConfirmBtn" onclick="confirmJoin()" style="padding:0.5rem 1.25rem;border-radius:7px;font-size:0.88rem;font-weight:600;width:auto;">Beitreten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ergebnis-Dialog (nach erfolgreichem Join) -->
|
||||
<div class="join-modal-backdrop" id="joinResultModal">
|
||||
<div class="join-modal-box" style="align-items:center;text-align:center;">
|
||||
<div style="font-size:2.5rem;line-height:1;" id="joinResultIcon">🔒</div>
|
||||
<h3 style="margin:0;" id="joinResultTitle"></h3>
|
||||
<p style="margin:0;font-size:0.88rem;color:var(--color-muted);line-height:1.5;" id="joinResultText"></p>
|
||||
<div id="joinResultCode" style="display:none;font-family:monospace;font-size:1.6rem;font-weight:700;letter-spacing:0.18em;padding:0.6rem 1.25rem;background:rgba(255,255,255,0.06);border-radius:8px;"></div>
|
||||
<div style="display:flex;gap:0.6rem;justify-content:center;margin-top:0.5rem;flex-wrap:wrap;">
|
||||
<button onclick="closeJoinResultModal()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);padding:0.5rem 1.1rem;border-radius:7px;cursor:pointer;font-size:0.88rem;width:auto;">Schließen</button>
|
||||
<button id="btnGoToLock" onclick="goToActiveLock()" style="padding:0.5rem 1.25rem;border-radius:7px;font-size:0.88rem;font-weight:600;width:auto;display:none;">Zum aktiven Lock</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/shared.js"></script>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
|
||||
|
||||
const GENDER_LABELS = { WEIBLICH: 'Weiblich', MAENNLICH: 'Männlich', DIVERS: 'Divers' };
|
||||
|
||||
let _joinOfferId = null;
|
||||
let _lastJoinOfferId = null;
|
||||
let _joinLockId = null;
|
||||
let _detailOfferId = null;
|
||||
let _detailOffer = null;
|
||||
let _allOffers = [];
|
||||
|
||||
// ── Laden ──────────────────────────────────────────────────────────────────
|
||||
async function loadOffers() {
|
||||
const res = await fetch('/keyholder-offers/public');
|
||||
document.getElementById('listLoading').style.display = 'none';
|
||||
if (!res.ok) return;
|
||||
_allOffers = await res.json();
|
||||
const list = document.getElementById('offerList');
|
||||
if (_allOffers.length === 0) { document.getElementById('listEmpty').style.display = ''; return; }
|
||||
_allOffers.forEach(o => list.appendChild(buildCard(o)));
|
||||
}
|
||||
|
||||
function buildCard(o) {
|
||||
const av = o.offererProfilePic
|
||||
? `<div class="offer-avatar"><img src="data:image/png;base64,${o.offererProfilePic}" alt=""></div>`
|
||||
: `<div class="offer-avatar">👤</div>`;
|
||||
|
||||
const genderTags = (o.targetGenders && o.targetGenders.length > 0)
|
||||
? o.targetGenders.map(g => `<span class="offer-badge">${esc(GENDER_LABELS[g] || g)}</span>`).join('')
|
||||
: '<span class="offer-badge">Alle</span>';
|
||||
|
||||
const modeBadge = o.directStart
|
||||
? '<span class="offer-badge direct">Direktstart</span>'
|
||||
: '<span class="offer-badge confirm">Mit Bestätigung</span>';
|
||||
|
||||
const typeBadge = o.templateType === 'TIMELOCK'
|
||||
? '<span class="offer-badge">⏱ Zeit-Lock</span>'
|
||||
: '<span class="offer-badge">🃏 Karten-Lock</span>';
|
||||
|
||||
const authorLine = o.offererName
|
||||
? `von ${esc(o.offererName)} · `
|
||||
: '';
|
||||
|
||||
const joinBtn = o.isOwn
|
||||
? `<button class="btn-join" disabled title="Eigenes Angebot">Eigenes</button>`
|
||||
: `<button class="btn-join" onclick="openJoinModal('${o.id}', event)">Beitreten</button>`;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'offer-card';
|
||||
div.dataset.offerId = o.id;
|
||||
div.innerHTML = `
|
||||
${av}
|
||||
<div class="offer-body offer-card-clickable" onclick="openDetail('${o.id}')">
|
||||
<div class="offer-name">${esc(o.templateName || 'Unbenannt')}</div>
|
||||
<div class="offer-sub">${authorLine}✓ ${o.acceptanceCount}× angenommen</div>
|
||||
<div class="offer-tags">${typeBadge} ${modeBadge} ${genderTags}</div>
|
||||
</div>
|
||||
${joinBtn}`;
|
||||
return div;
|
||||
}
|
||||
|
||||
// ── Join-Dialog ────────────────────────────────────────────────────────────
|
||||
function openJoinModal(offerId, e) {
|
||||
if (e) e.stopPropagation();
|
||||
_joinOfferId = offerId;
|
||||
const card = document.querySelector(`[data-offer-id="${offerId}"]`);
|
||||
const name = card ? card.querySelector('.offer-name')?.textContent : 'dieses Lock';
|
||||
const direct = card?.querySelector('.offer-badge.direct') != null;
|
||||
|
||||
document.getElementById('joinModalDesc').textContent = direct
|
||||
? `Das Lock „${name}" wird sofort für dich gestartet. Bitte wähle deine bevorzugte Schloss-Steuerung.`
|
||||
: `Du sendest eine Einladung an den Keyholder für das Lock „${name}". Nach Annahme kannst du loslegen.`;
|
||||
|
||||
document.getElementById('joinError').style.display = 'none';
|
||||
document.getElementById('joinControllType').value = '';
|
||||
document.getElementById('joinCodeLen').value = '5';
|
||||
document.getElementById('joinConfirmBtn').disabled = true;
|
||||
updateCodeLenVisibility();
|
||||
document.getElementById('joinModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeJoinModal() {
|
||||
document.getElementById('joinModal').classList.remove('open');
|
||||
_joinOfferId = null;
|
||||
}
|
||||
|
||||
document.getElementById('joinControllType').addEventListener('change', function() {
|
||||
updateCodeLenVisibility();
|
||||
document.getElementById('joinConfirmBtn').disabled = !this.value;
|
||||
});
|
||||
function updateCodeLenVisibility() {
|
||||
const val = document.getElementById('joinControllType').value;
|
||||
document.getElementById('codeLenGroup').style.display = val === 'TRUST' ? 'none' : '';
|
||||
}
|
||||
|
||||
async function confirmJoin() {
|
||||
if (!_joinOfferId) return;
|
||||
const controllType = document.getElementById('joinControllType').value;
|
||||
if (!controllType) return;
|
||||
const btn = document.getElementById('joinConfirmBtn');
|
||||
btn.disabled = true;
|
||||
const unlockCodeLength = parseInt(document.getElementById('joinCodeLen').value) || 5;
|
||||
|
||||
const res = await fetch(`/keyholder-offers/${_joinOfferId}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ controllType, unlockCodeLength })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
let msg = 'Fehler beim Beitreten.';
|
||||
if (d.error === 'active_lock_exists') msg = 'Du hast bereits ein aktives Lock.';
|
||||
else if (d.error === 'own_offer') msg = 'Du kannst nicht deinem eigenen Angebot beitreten.';
|
||||
else if (d.error === 'template_gone') msg = 'Die Vorlage existiert nicht mehr.';
|
||||
document.getElementById('joinError').textContent = msg;
|
||||
document.getElementById('joinError').style.display = '';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
_lastJoinOfferId = _joinOfferId;
|
||||
closeJoinModal();
|
||||
showJoinResult(data);
|
||||
}
|
||||
|
||||
function showJoinResult(data) {
|
||||
_joinLockId = data.lockId;
|
||||
const direct = !data.invitationSent;
|
||||
|
||||
document.getElementById('joinResultIcon').textContent = direct ? '🔒' : '✉️';
|
||||
document.getElementById('joinResultTitle').textContent = direct ? 'Lock gestartet!' : 'Einladung gesendet';
|
||||
document.getElementById('btnGoToLock').style.display = direct ? '' : 'none';
|
||||
|
||||
if (direct && data.unlockCode) {
|
||||
document.getElementById('joinResultText').textContent = 'Dein aktueller Entsperrcode:';
|
||||
document.getElementById('joinResultCode').textContent = data.unlockCode;
|
||||
document.getElementById('joinResultCode').style.display = '';
|
||||
} else if (direct) {
|
||||
document.getElementById('joinResultText').textContent = 'Das Lock wurde erfolgreich gestartet.';
|
||||
document.getElementById('joinResultCode').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('joinResultText').textContent =
|
||||
'Die Einladung wurde an den Keyholder gesendet. Sobald dieser annimmt, startet das Lock.';
|
||||
document.getElementById('joinResultCode').style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('joinResultModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeJoinResultModal() {
|
||||
document.getElementById('joinResultModal').classList.remove('open');
|
||||
_joinLockId = null;
|
||||
}
|
||||
|
||||
function goToActiveLock() {
|
||||
if (!_joinLockId) return;
|
||||
const isTimelock = _allOffers.find(o => o.id === _lastJoinOfferId)?.templateType === 'TIMELOCK';
|
||||
const page = isTimelock ? '/games/chastity/activetimelock.html' : '/games/chastity/activelock.html';
|
||||
window.location.href = page + '?lockId=' + _joinLockId;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') {
|
||||
closeDetail();
|
||||
closeJoinModal();
|
||||
closeJoinResultModal();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Detail-Dialog ──────────────────────────────────────────────────────────
|
||||
async function openDetail(offerId) {
|
||||
_detailOfferId = offerId;
|
||||
const card = document.querySelector(`[data-offer-id="${offerId}"]`);
|
||||
|
||||
// Offer-Objekt aus den geladenen Daten holen
|
||||
_detailOffer = _allOffers.find(o => o.id === offerId);
|
||||
if (!_detailOffer) return;
|
||||
|
||||
// Autoren-Avatar
|
||||
const avatarEl = document.getElementById('detailAvatar');
|
||||
if (_detailOffer.offererProfilePic) {
|
||||
avatarEl.innerHTML = `<img src="data:image/png;base64,${_detailOffer.offererProfilePic}" alt="">`;
|
||||
avatarEl.style.display = '';
|
||||
} else {
|
||||
avatarEl.style.display = 'none';
|
||||
}
|
||||
|
||||
const typeTxt = _detailOffer.templateType === 'TIMELOCK' ? '⏱ Zeit-Lock' : '🃏 Karten-Lock';
|
||||
const modeTxt = _detailOffer.directStart ? 'Direktstart' : 'Mit Bestätigung';
|
||||
const authorTxt = _detailOffer.offererName ? ' · von ' + _detailOffer.offererName : '';
|
||||
document.getElementById('detailTitle').textContent = _detailOffer.templateName || 'Unbenannt';
|
||||
document.getElementById('detailMeta').textContent = typeTxt + ' · ' + modeTxt + authorTxt;
|
||||
|
||||
// Join-Button ein/ausblenden
|
||||
const joinBtn = document.getElementById('detailJoinBtn');
|
||||
joinBtn.style.display = _detailOffer.isOwn ? 'none' : '';
|
||||
|
||||
// Template-Details laden
|
||||
document.getElementById('detailBody').innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;">Lädt…</p>';
|
||||
document.getElementById('detailModal').classList.add('open');
|
||||
|
||||
try {
|
||||
const res = await fetch('/templates/' + _detailOffer.templateId + '/public');
|
||||
if (res.ok) {
|
||||
const tpl = await res.json();
|
||||
document.getElementById('detailBody').innerHTML = buildDetailBody(tpl);
|
||||
} else {
|
||||
document.getElementById('detailBody').innerHTML = '';
|
||||
}
|
||||
} catch { document.getElementById('detailBody').innerHTML = ''; }
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
document.getElementById('detailModal').classList.remove('open');
|
||||
_detailOfferId = null;
|
||||
_detailOffer = null;
|
||||
}
|
||||
|
||||
function detailJoin() {
|
||||
const id = _detailOfferId;
|
||||
if (!id) return;
|
||||
closeDetail();
|
||||
openJoinModal(id, null);
|
||||
}
|
||||
|
||||
// ── Detail-Body ────────────────────────────────────────────────────────────
|
||||
function fmtMinutes(min) {
|
||||
if (!min) return '0 Min.';
|
||||
const d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
|
||||
return [d ? d + 'd' : '', h ? h + 'h' : '', m ? m + 'min' : ''].filter(Boolean).join(' ') || '0 Min.';
|
||||
}
|
||||
|
||||
function buildSection(title, rows) {
|
||||
const rowsHtml = rows.map(([l, v]) =>
|
||||
`<div class="detail-row"><span class="detail-row-label">${esc(l)}</span><span class="detail-row-val">${v}</span></div>`
|
||||
).join('');
|
||||
return `<div class="detail-section"><div class="detail-section-title">${title}</div>${rowsHtml}</div>`;
|
||||
}
|
||||
|
||||
function buildDetailBody(t) {
|
||||
const sections = [];
|
||||
|
||||
if (t.lockType === 'TIMELOCK') {
|
||||
sections.push(buildSection('⏱ Zeit-Einstellungen', [
|
||||
['Mindestdauer', fmtMinutes(t.minTimeInMinutes)],
|
||||
['Maximaldauer', fmtMinutes(t.maxTimeInMinutes)],
|
||||
['Endzeit sichtbar', t.endTimeVisible ? 'Ja' : 'Nein'],
|
||||
]));
|
||||
|
||||
if (t.spinningWheelEntries && t.spinningWheelEntries.length) {
|
||||
const WHEEL_LABELS = {
|
||||
ADD_TIME: '+ Zeit', REMOVE_TIME: '− Zeit', FREEZE_TIME: '❄ Einfrieren für',
|
||||
FREEZE: '🧊 Einfrieren (∞)', UNFREEZE: '🌊 Auftauen', TASK: '🎯 Aufgabe', TEXT: '💬 Text',
|
||||
};
|
||||
const entries = t.spinningWheelEntries.map(e => {
|
||||
const label = WHEEL_LABELS[e.type] || e.type;
|
||||
const extra = e.intVal ? ' ' + fmtMinutes(e.intVal) : (e.stringVal ? ' «' + e.stringVal + '»' : '');
|
||||
return `<span class="detail-wheel-entry">${label}${extra}</span>`;
|
||||
}).join('');
|
||||
sections.push(`<div class="detail-section">
|
||||
<div class="detail-section-title">🎡 Glücksrad (${t.spinningWheelEntries.length} Einträge${t.spinsEveryMinutes ? ', alle ' + fmtMinutes(t.spinsEveryMinutes) : ''})</div>
|
||||
<div>${entries}</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
if (t.penaltyType) {
|
||||
const penaltyLabels = { ADD: 'Zeit hinzufügen', FREEZE: 'Einfrieren', PILLORY: 'Pranger' };
|
||||
sections.push(buildSection('⚠ Strafmaß', [
|
||||
['Typ', penaltyLabels[t.penaltyType] || t.penaltyType],
|
||||
['Wert', t.penaltyValue ? fmtMinutes(t.penaltyValue) : '–'],
|
||||
]));
|
||||
}
|
||||
|
||||
if (t.taskEveryMinutes || t.minTasksPerDay) {
|
||||
sections.push(buildSection('🎯 Aufgaben-Timing', [
|
||||
['Intervall', t.taskEveryMinutes ? fmtMinutes(t.taskEveryMinutes) : '–'],
|
||||
['Min./Tag', t.minTasksPerDay ? t.minTasksPerDay + ' Aufgabe(n)' : '–'],
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
if (t.lockType === 'CARDLOCK') {
|
||||
const allKeys = new Set([
|
||||
...Object.keys(t.cardCountsMin || {}),
|
||||
...Object.keys(t.cardCountsMax || {}),
|
||||
]);
|
||||
const rows = [];
|
||||
allKeys.forEach(k => {
|
||||
const mn = (t.cardCountsMin || {})[k] ?? 0;
|
||||
const mx = (t.cardCountsMax || {})[k] ?? 0;
|
||||
if (mn > 0 || mx > 0) rows.push([k, `${mn} – ${mx}`]);
|
||||
});
|
||||
if (rows.length) sections.push(buildSection('🃏 Karten', rows));
|
||||
|
||||
sections.push(buildSection('⚙ Karten-Einstellungen', [
|
||||
['Zieh-Intervall', t.pickEveryMinute ? fmtMinutes(t.pickEveryMinute) : '–'],
|
||||
['Picks kumulieren', t.accumulatePicks ? 'Ja' : 'Nein'],
|
||||
['Verbl. Karten zeigen', t.showRemainingCards ? 'Ja' : 'Nein'],
|
||||
]));
|
||||
}
|
||||
|
||||
sections.push(buildSection('⚙ Allgemein', [
|
||||
['Hygiene-Öffnung', t.hygieneEnabled ? `alle ${fmtMinutes(t.hygineOpeningEveryMinites)}, ${fmtMinutes(t.hygineOpeningDurationMinutes)} offen` : 'Keine'],
|
||||
['Verifikation', t.requiresVerification ? 'Erforderlich' : 'Keine'],
|
||||
['Aufgaben-Modus', t.taskMode === 'KEYHOLDER' ? 'Keyholder' : t.taskMode === 'COMMUNITY' ? 'Community' : 'Zufällig'],
|
||||
]));
|
||||
|
||||
if (t.tasks && t.tasks.length) {
|
||||
const taskItems = t.tasks.map(task => {
|
||||
const dur = task.durationMinutes ? ` <span style="color:var(--color-muted);font-size:0.8rem;">(${fmtMinutes(task.durationMinutes)})</span>` : '';
|
||||
const desc = task.description ? `<div style="font-size:0.78rem;color:var(--color-muted);margin-top:0.1rem;">${esc(task.description)}</div>` : '';
|
||||
return `<div class="detail-task-item">${esc(task.title || task.name || '–')}${dur}${desc}</div>`;
|
||||
}).join('');
|
||||
sections.push(`<div class="detail-section">
|
||||
<div class="detail-section-title">🎯 Aufgaben (${t.tasks.length})</div>${taskItems}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
return sections.join('');
|
||||
}
|
||||
|
||||
loadOffers();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Keyholder*In bestätigt – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content" style="text-align:center;padding-top:3rem;">
|
||||
|
||||
<div id="msgOk" style="display:none;">
|
||||
<div style="font-size:3rem;margin-bottom:1rem;">🔐</div>
|
||||
<h2 style="margin-bottom:0.75rem;">Keyholder*In-Rolle angenommen!</h2>
|
||||
<p style="color:var(--color-muted);margin-bottom:2rem;line-height:1.6;">
|
||||
Du hast die Keyholder*In-Rolle erfolgreich bestätigt.<br>
|
||||
Das Lock läuft ab sofort mit dir als Keyholder*In.
|
||||
</p>
|
||||
<a href="/userhome.html" style="display:inline-block;padding:0.65rem 1.75rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">
|
||||
Zur Startseite
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="msgInvalid" style="display:none;">
|
||||
<div style="font-size:3rem;margin-bottom:1rem;">❌</div>
|
||||
<h2 style="margin-bottom:0.75rem;">Link ungültig</h2>
|
||||
<p style="color:var(--color-muted);margin-bottom:2rem;line-height:1.6;">
|
||||
Dieser Bestätigungslink ist abgelaufen oder wurde bereits verwendet.
|
||||
</p>
|
||||
<a href="/userhome.html" style="display:inline-block;padding:0.65rem 1.75rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">
|
||||
Zur Startseite
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const status = new URLSearchParams(window.location.search).get('status');
|
||||
document.getElementById(status === 'ok' ? 'msgOk' : 'msgInvalid').style.display = '';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1923
bin/main/static/games/chastity/keyholder.html
Normal file
1308
bin/main/static/games/chastity/meine-locks.html
Normal file
935
bin/main/static/games/chastity/neulock.html
Normal file
@@ -0,0 +1,935 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Neues Lock – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.form-section {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.form-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.form-row:last-child { margin-bottom: 0; }
|
||||
.form-row label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.form-row input[type="text"],
|
||||
.form-row input[type="number"],
|
||||
.form-row input[type="datetime-local"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-row:last-child { margin-bottom: 0; }
|
||||
.checkbox-row input[type="checkbox"] {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
.checkbox-row label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.combo-wrap { position: relative; }
|
||||
.combo-wrap input[type="text"] { width: 100%; box-sizing: border-box; }
|
||||
.combo-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 3px);
|
||||
left: 0; right: 0;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 8px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
z-index: 200;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||
}
|
||||
.combo-dropdown.open { display: block; }
|
||||
.combo-option {
|
||||
padding: 0.55rem 0.85rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.combo-option:hover, .combo-option.active { background: var(--color-secondary); }
|
||||
.combo-option .combo-hint { font-size: 0.78rem; color: var(--color-muted); margin-left: 0.4rem; }
|
||||
.combo-empty { padding: 0.55rem 0.85rem; font-size: 0.85rem; color: var(--color-muted); font-style: italic; }
|
||||
|
||||
.inline-number { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.inline-number input { width: 90px !important; flex-shrink: 0; }
|
||||
.inline-number span { font-size: 0.9rem; color: var(--color-text); }
|
||||
|
||||
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
|
||||
.form-actions button { width: auto; padding: 0.65rem 1.5rem; }
|
||||
.error-msg { color: #e74c3c; font-size: 0.85rem; margin-top: 0.4rem; display: none; }
|
||||
.required-star { color: #e74c3c; margin-left: 0.15em; }
|
||||
.field-error input { border-color: #e74c3c !important; }
|
||||
.field-error-msg { font-size: 0.78rem; color: #e74c3c; margin-top: 0.15rem; }
|
||||
|
||||
/* Zeitpicker */
|
||||
.time-picker { display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; }
|
||||
.tp-seg { display:flex; flex-direction:column; align-items:center; gap:0.15rem; }
|
||||
.tp-seg-row { display:flex; align-items:center; gap:0.2rem; }
|
||||
.tp-seg button {
|
||||
width:24px; height:24px; background:var(--color-card);
|
||||
border:1px solid var(--color-muted); border-radius:4px;
|
||||
cursor:pointer; font-size:0.9rem; font-weight:700; color:var(--color-text);
|
||||
display:flex; align-items:center; justify-content:center; padding:0; flex-shrink:0;
|
||||
}
|
||||
.tp-seg button:hover { background:var(--color-primary); color:#fff; border-color:var(--color-primary); }
|
||||
.tp-seg input {
|
||||
width:28px; text-align:center; background:var(--color-card);
|
||||
border:1px solid var(--color-muted); border-radius:4px;
|
||||
color:var(--color-text); font-size:0.9rem; font-weight:600;
|
||||
font-family:monospace; padding:0.15rem 0; box-sizing:border-box;
|
||||
}
|
||||
.tp-seg .tp-label { font-size:0.62rem; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.04em; }
|
||||
.tp-colon { font-size:1rem; font-weight:700; color:var(--color-muted); margin-bottom:0.9rem; }
|
||||
|
||||
/* LockControl-Auswahl */
|
||||
.lockcontrol-options { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.lockcontrol-option {
|
||||
display: flex; align-items: flex-start; gap: 0.7rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid var(--color-secondary); border-radius: 8px;
|
||||
cursor: pointer; transition: border-color 0.15s;
|
||||
}
|
||||
.lockcontrol-option:hover:not(.lc-disabled) { border-color: var(--color-primary); }
|
||||
.lockcontrol-option.lc-selected { border-color: var(--color-primary); background: rgba(var(--color-primary-rgb, 180,80,255),0.06); }
|
||||
.lockcontrol-option.lc-disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.lockcontrol-option input[type="radio"] {
|
||||
margin-top: 0.15rem; flex-shrink: 0;
|
||||
accent-color: var(--color-primary); width: 1rem; height: 1rem; cursor: pointer;
|
||||
}
|
||||
.lockcontrol-option.lc-disabled input[type="radio"] { cursor: not-allowed; }
|
||||
.lc-label { font-size: 0.9rem; font-weight: 600; color: var(--color-text); }
|
||||
.lc-desc { font-size: 0.78rem; color: var(--color-muted); margin-top: 0.15rem; }
|
||||
.lc-badge {
|
||||
display: inline-block; font-size: 0.68rem; font-weight: 700;
|
||||
padding: 0.15em 0.5em; border-radius: 4px;
|
||||
background: var(--color-primary); color: #fff;
|
||||
margin-left: 0.4rem; vertical-align: middle; letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Unlock-Code-Modal */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; inset: 0; z-index: 500;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal-bg { position: absolute; inset: 0; background: rgba(0,0,0,0.55); }
|
||||
.modal-box {
|
||||
position: relative; background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary); border-radius: 12px;
|
||||
padding: 1.5rem 1.5rem 1.25rem; max-width: 380px; width: 90%;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.75rem; z-index: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<h2 style="margin-bottom:1.25rem;">🔒 Neues Lock</h2>
|
||||
|
||||
<!-- Vorlage (Pflichtfeld) -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">Vorlage<span class="required-star">*</span></div>
|
||||
<div class="form-row" id="rowTemplate">
|
||||
<div class="combo-wrap" id="templateCombo">
|
||||
<input type="text" id="templateInput" placeholder="Vorlage suchen…" autocomplete="off">
|
||||
<div class="combo-dropdown" id="templateDropdown"></div>
|
||||
<input type="hidden" id="templateValue">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personen -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">Personen</div>
|
||||
|
||||
<div class="form-row" id="rowLockee">
|
||||
<label for="lockeeInput">Lockee<span class="required-star">*</span></label>
|
||||
<div class="combo-wrap" id="lockeeCombo">
|
||||
<input type="text" id="lockeeInput" placeholder="Suchen oder „Ich selbst"…" autocomplete="off">
|
||||
<div class="combo-dropdown" id="lockeeDropdown"></div>
|
||||
<input type="hidden" id="lockeeValue">
|
||||
</div>
|
||||
<div class="form-hint">Wähle dich selbst oder einen Freund als Lockee.</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-row" id="rowDetailsVisible" style="display:none;">
|
||||
<input type="checkbox" id="lockeeDetailsVisible" checked>
|
||||
<label for="lockeeDetailsVisible">Details für Lockee sichtbar
|
||||
<span class="form-hint">(Lockee sieht die Lock-Konfiguration vor dem Annehmen)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row" id="rowKeyholder">
|
||||
<label for="keyholderInput">Keyholder*In</label>
|
||||
<div class="combo-wrap" id="keyholderCombo">
|
||||
<input type="text" id="keyholderInput" placeholder="Freund suchen…" autocomplete="off">
|
||||
<div class="combo-dropdown" id="keyholderDropdown"></div>
|
||||
<input type="hidden" id="keyholderValue">
|
||||
</div>
|
||||
<div class="form-hint">Ohne Keyholder läuft das Lock als Self-Lock.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optionen -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">Optionen</div>
|
||||
|
||||
<!-- CardLock: Längste Dauer -->
|
||||
<div class="form-row" id="rowMaxDuration">
|
||||
<label>Längste Dauer</label>
|
||||
<div class="time-picker">
|
||||
<div class="tp-seg">
|
||||
<div class="tp-seg-row">
|
||||
<button type="button" onclick="tpChange('dur',-1,'d')">−</button>
|
||||
<input type="text" id="dur_d" value="0" readonly>
|
||||
<button type="button" onclick="tpChange('dur',1,'d')">+</button>
|
||||
</div>
|
||||
<span class="tp-label">Tage</span>
|
||||
</div>
|
||||
<div class="tp-colon">:</div>
|
||||
<div class="tp-seg">
|
||||
<div class="tp-seg-row">
|
||||
<button type="button" onclick="tpChange('dur',-1,'h')">−</button>
|
||||
<input type="text" id="dur_h" value="00" readonly>
|
||||
<button type="button" onclick="tpChange('dur',1,'h')">+</button>
|
||||
</div>
|
||||
<span class="tp-label">Std</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-hint">Das Lock öffnet spätestens nach dieser Zeit automatisch. 0 : 00 = keine Begrenzung.</div>
|
||||
</div>
|
||||
|
||||
<!-- TimeLock: Dauer-Info aus Vorlage -->
|
||||
<div class="form-row" id="rowTimeLockInfo" style="display:none;">
|
||||
<label>Sperrdauer</label>
|
||||
<div id="timeLockDurationText" style="font-size:0.9rem;color:var(--color-text);padding:0.3rem 0;"></div>
|
||||
<div class="form-hint">Die Dauer wird beim Lock-Start zufällig aus dem Bereich der Vorlage gewählt.</div>
|
||||
</div>
|
||||
|
||||
<!-- LockControl-Auswahl -->
|
||||
<div class="form-row" id="rowLockControl">
|
||||
<label>Schloss-Steuerung</label>
|
||||
<div class="lockcontrol-options">
|
||||
<label class="lockcontrol-option lc-selected" id="lcOptUnlockCode" onclick="selectLockControl('UNLOCK_CODE')">
|
||||
<input type="radio" name="lockControl" value="UNLOCK_CODE" checked>
|
||||
<div>
|
||||
<div class="lc-label">🔢 Unlock-Code</div>
|
||||
<div class="lc-desc">Ein numerischer Code wird generiert, den du in deinen Tresor einstellst.</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="lockcontrol-option" id="lcOptTrust" onclick="selectLockControl('TRUST')">
|
||||
<input type="radio" name="lockControl" value="TRUST">
|
||||
<div>
|
||||
<div class="lc-label">🤝 Trust</div>
|
||||
<div class="lc-desc">Kein technisches Schloss – du vertraust dir selbst oder deiner Keyholder*in.</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="lockcontrol-option lc-disabled" id="lcOptTtlock" onclick="selectLockControl('TTLOCK')">
|
||||
<input type="radio" name="lockControl" value="TTLOCK" disabled>
|
||||
<div>
|
||||
<div class="lc-label">📱 TTLock <span class="lc-badge" id="lcTtlockBadge">ABO</span></div>
|
||||
<div class="lc-desc" id="lcTtlockDesc">Steuert ein TTLock-Smartschloss direkt über die App-Integration. Erfordert ein aktives Abonnement.</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" id="rowUnlockCodeLines">
|
||||
<label for="unlockCodeLines">Anzahl Ziffern des Entsperrcodes</label>
|
||||
<div class="inline-number">
|
||||
<input type="number" id="unlockCodeLines" min="1" max="20" value="5">
|
||||
<span>Ziffern</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-row" id="rowTestLock">
|
||||
<input type="checkbox" id="testLock">
|
||||
<label for="testLock">Test-Lock <span class="form-hint">(kein echter Lock, zum Ausprobieren)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-msg" id="errorMsg"></div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button onclick="history.back()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);">Abbrechen</button>
|
||||
<button onclick="createSession()">🔒 Lock starten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTLock Lade-Overlay -->
|
||||
<div class="modal-overlay" id="ttlLoadingOverlay">
|
||||
<div class="modal-bg"></div>
|
||||
<div class="modal-box" style="max-width:320px;text-align:center;gap:0.75rem;">
|
||||
<div style="font-size:2rem;">⏳</div>
|
||||
<div style="font-weight:600;">TTLock-Kommunikation läuft…</div>
|
||||
<div style="font-size:0.85rem;color:var(--color-muted);">Bitte warten, der TTLock-Server wird kontaktiert.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entsperrcode-Modal -->
|
||||
<div class="modal-overlay" id="unlockModal">
|
||||
<div class="modal-bg"></div>
|
||||
<div class="modal-box">
|
||||
<div style="font-size:2rem;">🔒</div>
|
||||
<h3 id="unlockModalTitle" style="margin:0;">Dein Entsperrcode</h3>
|
||||
<p id="unlockModalHint" style="color:var(--color-muted);font-size:0.85rem;text-align:center;margin:0;">
|
||||
Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem.
|
||||
</p>
|
||||
<div id="unlockCodeDisplay" style="
|
||||
font-family: monospace; font-size: 2rem; letter-spacing: 0.3em;
|
||||
background: var(--color-secondary); border-radius: 8px;
|
||||
padding: 1rem 1.5rem; text-align: center; color: var(--color-primary);
|
||||
line-height: 1.8; word-break: break-all;
|
||||
"></div>
|
||||
<div id="unlockModalCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);text-align:center;font-family:monospace;"></div>
|
||||
<div id="unlockKeyholderHint" style="display:none;background:var(--color-secondary);border-radius:8px;padding:0.75rem 1rem;font-size:0.85rem;color:var(--color-muted);text-align:center;line-height:1.5;">
|
||||
⏳ Die eingetragene Keyholder*In wurde benachrichtigt und muss die Rolle noch bestätigen.
|
||||
Bis zur Bestätigung läuft das Lock als Self-Lock.
|
||||
</div>
|
||||
<button id="unlockModalBtn" onclick="" style="width:100%;margin-top:0.25rem;">Weiter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/card-defs.js"></script>
|
||||
<script src="/js/shared.js"></script>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/social-sidebar.js"></script>
|
||||
<script>
|
||||
let myUserId = null;
|
||||
let myUserName = null;
|
||||
let allFriends = [];
|
||||
let allTemplates = []; // combined; each entry has _type: 'cardlock'|'timelock'
|
||||
let selectedTemplate = null;
|
||||
let comboActiveIdx = -1;
|
||||
let selectedLockControl = 'UNLOCK_CODE';
|
||||
let hasPaidSubscription = false;
|
||||
let ttlockReady = false;
|
||||
|
||||
// ── Boot ──
|
||||
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
|
||||
if (!user) { window.location.href = '/login.html'; return; }
|
||||
myUserId = user.userId;
|
||||
myUserName = user.name;
|
||||
|
||||
// Subscription + Templates (eigene + abonnierte) + TTLock-Config parallel laden
|
||||
try {
|
||||
const [ownTpls, subTpls, subData, ttlCfg] = await Promise.all([
|
||||
fetch('/templates/mine').then(r => r.ok ? r.json() : []),
|
||||
fetch('/templates/subscribed').then(r => r.ok ? r.json() : []),
|
||||
fetch('/subscription/me').then(r => r.ok ? r.json() : null),
|
||||
fetch('/user/me/ttlock').then(r => r.ok ? r.json() : null)
|
||||
]);
|
||||
const toEntry = t => ({ ...t, _type: t.lockType === 'TIMELOCK' ? 'timelock' : 'cardlock' });
|
||||
const ownIds = new Set(ownTpls.map(t => t.templateId));
|
||||
allTemplates = [
|
||||
...ownTpls.map(toEntry),
|
||||
...subTpls.filter(t => !ownIds.has(t.templateId)).map(toEntry)
|
||||
];
|
||||
hasPaidSubscription = !!(subData && subData.subscriptionType === 'PREMIUM');
|
||||
ttlockReady = !!(ttlCfg && ttlCfg.testSuccessful);
|
||||
const ttlockTestOk = ttlockReady;
|
||||
|
||||
if (hasPaidSubscription && ttlockTestOk) {
|
||||
const opt = document.getElementById('lcOptTtlock');
|
||||
opt.classList.remove('lc-disabled');
|
||||
opt.querySelector('input').disabled = false;
|
||||
document.getElementById('lcTtlockBadge').style.display = 'none';
|
||||
document.getElementById('lcTtlockDesc').textContent =
|
||||
'Steuert ein TTLock-Smartschloss direkt über die App-Integration.';
|
||||
} else if (hasPaidSubscription && !ttlockTestOk) {
|
||||
document.getElementById('lcTtlockBadge').textContent = 'KONFIG';
|
||||
document.getElementById('lcTtlockDesc').textContent =
|
||||
'TTLock ist noch nicht konfiguriert. Bitte teste die Verbindung zuerst in den Einstellungen.';
|
||||
}
|
||||
} catch { allTemplates = []; }
|
||||
|
||||
if (allTemplates.length === 0) {
|
||||
document.querySelector('.content').innerHTML = `
|
||||
<div style="text-align:center;padding:3rem 1rem;">
|
||||
<div style="font-size:2.5rem;margin-bottom:1rem;">📋</div>
|
||||
<h2 style="margin-bottom:0.75rem;">Keine Vorlagen vorhanden</h2>
|
||||
<p style="color:var(--color-muted);margin-bottom:2rem;">
|
||||
Du musst zuerst mindestens eine Lock-Vorlage erstellen,<br>
|
||||
bevor du ein neues Lock starten kannst.
|
||||
</p>
|
||||
<a href="/games/chastity/meine-locks.html" style="display:inline-block;padding:0.7rem 1.8rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Vorlage erstellen</a>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
setupTemplateCombo();
|
||||
await loadOptions(user.userId);
|
||||
});
|
||||
|
||||
async function loadOptions(myId) {
|
||||
try {
|
||||
allFriends = await fetch('/social/friends/user/' + myId).then(r => r.ok ? r.json() : []);
|
||||
} catch { allFriends = []; }
|
||||
setupLockeeCombo();
|
||||
setupKeyholderCombo();
|
||||
document.getElementById('lockeeInput').value = 'Ich selbst';
|
||||
document.getElementById('lockeeValue').value = myId;
|
||||
}
|
||||
|
||||
// ── Template-Combobox ──
|
||||
function setupTemplateCombo() {
|
||||
const input = document.getElementById('templateInput');
|
||||
const dropdown = document.getElementById('templateDropdown');
|
||||
const hidden = document.getElementById('templateValue');
|
||||
|
||||
function renderDropdown(query) {
|
||||
const q = query.toLowerCase().trim();
|
||||
const filtered = q
|
||||
? allTemplates.filter(t => (t.name || '').toLowerCase().includes(q))
|
||||
: allTemplates;
|
||||
dropdown.innerHTML = '';
|
||||
if (filtered.length === 0) {
|
||||
dropdown.innerHTML = `<div class="combo-empty">Keine Vorlagen gefunden.</div>`;
|
||||
} else {
|
||||
filtered.forEach(t => {
|
||||
const badge = t._type === 'timelock' ? '⏱' : '🃏';
|
||||
const label = (t.name || 'Unbenannte Vorlage');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'combo-option';
|
||||
div.dataset.id = t.templateId;
|
||||
div.innerHTML = `${badge} ${label}`;
|
||||
div.addEventListener('mousedown', e => {
|
||||
e.preventDefault();
|
||||
hidden.value = t.templateId;
|
||||
input.value = badge + ' ' + label;
|
||||
selectedTemplate = t;
|
||||
dropdown.classList.remove('open');
|
||||
clearFieldError('rowTemplate');
|
||||
onTemplateChanged(t);
|
||||
});
|
||||
dropdown.appendChild(div);
|
||||
});
|
||||
}
|
||||
dropdown.classList.add('open');
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => { hidden.value = ''; renderDropdown(input.value); });
|
||||
input.addEventListener('focus', () => renderDropdown(input.value));
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => {
|
||||
dropdown.classList.remove('open');
|
||||
if (!hidden.value) input.value = '';
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Lockee-Combobox ──
|
||||
function setupLockeeCombo() {
|
||||
const input = document.getElementById('lockeeInput');
|
||||
const dropdown = document.getElementById('lockeeDropdown');
|
||||
const hidden = document.getElementById('lockeeValue');
|
||||
|
||||
function renderDropdown(query) {
|
||||
const q = query.toLowerCase().trim();
|
||||
const selfMatch = 'ich selbst'.includes(q);
|
||||
const filtered = allFriends.filter(f => f.name.toLowerCase().includes(q));
|
||||
dropdown.innerHTML = '';
|
||||
comboActiveIdx = -1;
|
||||
if (!selfMatch && filtered.length === 0) {
|
||||
dropdown.innerHTML = `<div class="combo-empty">${q ? 'Keine Treffer.' : 'Keine Freunde vorhanden.'}</div>`;
|
||||
} else {
|
||||
if (selfMatch) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'combo-option';
|
||||
div.dataset.id = myUserId; div.dataset.name = 'Ich selbst';
|
||||
div.innerHTML = 'Ich selbst<span class="combo-hint">(Self-Lock)</span>';
|
||||
div.addEventListener('mousedown', e => { e.preventDefault(); selectLockee(myUserId, 'Ich selbst'); });
|
||||
dropdown.appendChild(div);
|
||||
}
|
||||
filtered.forEach(f => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'combo-option';
|
||||
div.dataset.id = f.userId; div.dataset.name = f.name;
|
||||
div.textContent = f.name;
|
||||
div.addEventListener('mousedown', e => { e.preventDefault(); selectLockee(f.userId, f.name); });
|
||||
dropdown.appendChild(div);
|
||||
});
|
||||
}
|
||||
dropdown.classList.add('open');
|
||||
}
|
||||
|
||||
function selectLockee(id, name) {
|
||||
hidden.value = id;
|
||||
input.value = name;
|
||||
dropdown.classList.remove('open');
|
||||
onLockeeChanged(id);
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => { hidden.value = ''; renderDropdown(input.value); });
|
||||
input.addEventListener('focus', () => renderDropdown(input.value));
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => {
|
||||
dropdown.classList.remove('open');
|
||||
if (!hidden.value) { input.value = 'Ich selbst'; hidden.value = myUserId; onLockeeChanged(myUserId); }
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
function onLockeeChanged(lockeeId) {
|
||||
const isFriend = lockeeId && lockeeId !== myUserId;
|
||||
const khInput = document.getElementById('keyholderInput');
|
||||
const khHidden = document.getElementById('keyholderValue');
|
||||
|
||||
if (isFriend) {
|
||||
khInput.value = myUserName || 'Ich selbst';
|
||||
khHidden.value = myUserId;
|
||||
khInput.readOnly = true;
|
||||
khInput.style.opacity = '0.6';
|
||||
document.getElementById('rowTestLock').style.display = 'none';
|
||||
document.getElementById('rowDetailsVisible').style.display = '';
|
||||
} else {
|
||||
khInput.readOnly = false;
|
||||
khInput.style.opacity = '';
|
||||
if (!khHidden.value) khInput.value = '';
|
||||
document.getElementById('rowTestLock').style.display = '';
|
||||
document.getElementById('rowDetailsVisible').style.display = 'none';
|
||||
}
|
||||
updateCodeLinesVisibility();
|
||||
}
|
||||
|
||||
// Self-Lock-Felder beim Start ausblenden (werden durch onLockeeChanged gesetzt)
|
||||
document.getElementById('rowTestLock').style.display = '';
|
||||
|
||||
// ── Keyholder-Combobox ──
|
||||
function setupKeyholderCombo() {
|
||||
const input = document.getElementById('keyholderInput');
|
||||
const dropdown = document.getElementById('keyholderDropdown');
|
||||
const hidden = document.getElementById('keyholderValue');
|
||||
|
||||
function renderDropdown(query) {
|
||||
if (input.readOnly) return;
|
||||
const q = query.toLowerCase().trim();
|
||||
const filtered = q ? allFriends.filter(f => f.name.toLowerCase().includes(q)) : allFriends;
|
||||
dropdown.innerHTML = '';
|
||||
comboActiveIdx = -1;
|
||||
if (filtered.length === 0) {
|
||||
dropdown.innerHTML = `<div class="combo-empty">${q ? 'Keine Freunde gefunden.' : 'Keine Freunde vorhanden.'}</div>`;
|
||||
} else {
|
||||
filtered.forEach(f => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'combo-option';
|
||||
div.dataset.id = f.userId; div.dataset.name = f.name;
|
||||
div.textContent = f.name;
|
||||
div.addEventListener('mousedown', e => { e.preventDefault(); hidden.value = f.userId; input.value = f.name; dropdown.classList.remove('open'); });
|
||||
dropdown.appendChild(div);
|
||||
});
|
||||
}
|
||||
dropdown.classList.add('open');
|
||||
}
|
||||
|
||||
input.addEventListener('input', () => { hidden.value = ''; renderDropdown(input.value); });
|
||||
input.addEventListener('focus', () => renderDropdown(input.value));
|
||||
input.addEventListener('blur', () => { setTimeout(() => { dropdown.classList.remove('open'); if (!hidden.value) input.value = ''; }, 150); });
|
||||
}
|
||||
|
||||
// ── Template-Typ: Sektionen umschalten ──
|
||||
function onTemplateChanged(t) {
|
||||
const isTimeLock = t._type === 'timelock';
|
||||
document.getElementById('rowMaxDuration').style.display = isTimeLock ? 'none' : '';
|
||||
document.getElementById('rowTimeLockInfo').style.display = isTimeLock ? '' : 'none';
|
||||
if (isTimeLock) {
|
||||
const minM = t.minTimeInMinutes || 0;
|
||||
const maxM = t.maxTimeInMinutes || 0;
|
||||
document.getElementById('timeLockDurationText').textContent =
|
||||
`${fmtMinutes(minM)} – ${fmtMinutes(maxM)}`;
|
||||
}
|
||||
}
|
||||
function fmtMinutes(m) {
|
||||
if (!m) return '0 Min.';
|
||||
const d = Math.floor(m / 1440);
|
||||
const h = Math.floor((m % 1440) / 60);
|
||||
const min = m % 60;
|
||||
const parts = [];
|
||||
if (d) parts.push(d + ' Tag' + (d !== 1 ? 'e' : ''));
|
||||
if (h) parts.push(h + ' Std');
|
||||
if (min) parts.push(min + ' Min.');
|
||||
return parts.join(' ') || '0 Min.';
|
||||
}
|
||||
|
||||
// ── LockControl-Auswahl ──
|
||||
function selectLockControl(type) {
|
||||
const ids = { UNLOCK_CODE: 'lcOptUnlockCode', TRUST: 'lcOptTrust', TTLOCK: 'lcOptTtlock' };
|
||||
if (type === 'TTLOCK' && (!hasPaidSubscription || !ttlockReady)) return;
|
||||
selectedLockControl = type;
|
||||
Object.entries(ids).forEach(([t, id]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.classList.toggle('lc-selected', t === type);
|
||||
el.querySelector('input').checked = (t === type);
|
||||
});
|
||||
updateCodeLinesVisibility();
|
||||
}
|
||||
|
||||
function updateCodeLinesVisibility() {
|
||||
const show = selectedLockControl === 'UNLOCK_CODE' || selectedLockControl === 'TTLOCK';
|
||||
const lockeeIsFriend = document.getElementById('lockeeValue').value !== myUserId
|
||||
&& !!document.getElementById('lockeeValue').value;
|
||||
document.getElementById('rowUnlockCodeLines').style.display = (show && !lockeeIsFriend) ? '' : 'none';
|
||||
// Label je nach Typ anpassen
|
||||
const label = document.querySelector('#rowUnlockCodeLines > label');
|
||||
if (label) {
|
||||
label.textContent = selectedLockControl === 'TTLOCK'
|
||||
? 'PIN-Länge (4–9 Ziffern)'
|
||||
: 'Anzahl Ziffern des Entsperrcodes';
|
||||
}
|
||||
// Für TTLock: min=4, max=9; Standard: min=1, max=20
|
||||
const input = document.getElementById('unlockCodeLines');
|
||||
if (selectedLockControl === 'TTLOCK') {
|
||||
input.min = 4; input.max = 9;
|
||||
if (parseInt(input.value) < 4) input.value = 6;
|
||||
if (parseInt(input.value) > 9) input.value = 9;
|
||||
} else {
|
||||
input.min = 1; input.max = 20;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Zeitpicker ──
|
||||
function tpChange(prefix, delta, seg) {
|
||||
let d = parseInt(document.getElementById(prefix + '_d').value) || 0;
|
||||
let h = parseInt(document.getElementById(prefix + '_h')?.value) || 0;
|
||||
if (seg === 'h') h += delta;
|
||||
else d += delta;
|
||||
if (h >= 24) { d += Math.floor(h / 24); h %= 24; }
|
||||
if (h < 0) { const b = Math.ceil(-h / 24); d -= b; h += b * 24; }
|
||||
if (d < 0) d = 0;
|
||||
document.getElementById(prefix + '_d').value = d;
|
||||
if (document.getElementById(prefix + '_h'))
|
||||
document.getElementById(prefix + '_h').value = String(h).padStart(2, '0');
|
||||
}
|
||||
|
||||
// ── Längste Dauer → LocalDateTime ──
|
||||
function durationToLatestOpening() {
|
||||
const days = parseInt(document.getElementById('dur_d').value) || 0;
|
||||
const hours = parseInt(document.getElementById('dur_h').value) || 0;
|
||||
if (days === 0 && hours === 0) return null;
|
||||
const ms = (days * 24 * 3600 + hours * 3600) * 1000;
|
||||
const dt = new Date(Date.now() + ms);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
|
||||
}
|
||||
|
||||
// ── Fehler ──
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('errorMsg');
|
||||
el.textContent = msg;
|
||||
el.style.display = '';
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
function showActiveLockError() {
|
||||
const el = document.getElementById('errorMsg');
|
||||
el.innerHTML = 'Du befindest dich bereits in einem aktiven Lock. '
|
||||
+ '<a href="/games/chastity/meine-locks.html" style="color:inherit;text-decoration:underline;">Zum aktiven Lock</a>';
|
||||
el.style.display = '';
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
function setFieldError(rowId, msg) {
|
||||
const row = document.getElementById(rowId);
|
||||
if (!row) return;
|
||||
row.classList.add('field-error');
|
||||
let el = row.querySelector('.field-error-msg');
|
||||
if (!el) { el = document.createElement('div'); el.className = 'field-error-msg'; row.appendChild(el); }
|
||||
el.textContent = msg;
|
||||
}
|
||||
function clearFieldError(rowId) {
|
||||
const row = document.getElementById(rowId);
|
||||
if (!row) return;
|
||||
row.classList.remove('field-error');
|
||||
row.querySelector('.field-error-msg')?.remove();
|
||||
}
|
||||
|
||||
// ── Karten aus Template aufbauen ──
|
||||
function buildInitialCardsFromTemplate(t) {
|
||||
const cards = [];
|
||||
CARD_DEFS.forEach(c => {
|
||||
const minVal = t.cardCountsMin?.[c.id] ?? 0;
|
||||
const maxVal = t.cardCountsMax?.[c.id] ?? 0;
|
||||
const n = minVal + Math.floor(Math.random() * (maxVal - minVal + 1));
|
||||
for (let i = 0; i < n; i++) cards.push(c.id);
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
|
||||
// ── Plausibilitätsprüfung für TimeLock ──
|
||||
function validateTimeLockPlausibility(t) {
|
||||
const errors = [];
|
||||
const hasTasks = t.tasks && t.tasks.length > 0;
|
||||
const spinEntries = t.spinningWheelEntries || [];
|
||||
|
||||
// Spinning Wheel enthält Task-Felder, aber keine Aufgaben definiert
|
||||
if (spinEntries.some(e => e.type === 'TASK') && !hasTasks) {
|
||||
errors.push('Das Spinning Wheel enthält Aufgaben-Felder (TASK), aber die Vorlage hat keine Aufgaben definiert. Bitte die Vorlage bearbeiten.');
|
||||
}
|
||||
|
||||
// Aufgaben-Häufigkeit konfiguriert, aber keine Aufgaben vorhanden
|
||||
if ((t.taskEveryMinutes > 0 || t.minTasksPerDay > 0) && !hasTasks) {
|
||||
errors.push('Aufgaben sind zeitlich konfiguriert, aber keine Aufgaben in der Vorlage definiert. Bitte die Vorlage bearbeiten.');
|
||||
}
|
||||
|
||||
// Unbegrenztes Einfrieren ohne Auftau-Eintrag
|
||||
if (spinEntries.some(e => e.type === 'FREEZE') && !spinEntries.some(e => e.type === 'UNFREEZE')) {
|
||||
errors.push('Das Spinning Wheel enthält ein unbegrenztes Einfrieren (FREEZE), aber keinen Auftau-Eintrag (UNFREEZE). Das Lock könnte dauerhaft eingefroren bleiben.');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function showPlausibilityErrors(errors) {
|
||||
const el = document.getElementById('errorMsg');
|
||||
if (errors.length === 1) {
|
||||
el.textContent = errors[0];
|
||||
} else {
|
||||
el.innerHTML = 'Die Vorlage enthält inkonsistente Einstellungen:<ul style="margin:0.4rem 0 0 1.2rem;padding:0;">'
|
||||
+ errors.map(e => `<li>${e}</li>`).join('')
|
||||
+ '</ul>';
|
||||
}
|
||||
el.style.display = '';
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// ── Absenden ──
|
||||
async function createSession() {
|
||||
document.getElementById('errorMsg').style.display = 'none';
|
||||
|
||||
const templateId = document.getElementById('templateValue').value;
|
||||
if (!templateId) {
|
||||
setFieldError('rowTemplate', 'Bitte eine Vorlage wählen.');
|
||||
document.getElementById('rowTemplate').scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
return;
|
||||
}
|
||||
clearFieldError('rowTemplate');
|
||||
|
||||
const t = selectedTemplate || allTemplates.find(x => x.templateId === templateId);
|
||||
if (!t) { showError('Vorlage nicht gefunden.'); return; }
|
||||
|
||||
if (t._type === 'timelock') {
|
||||
const plausErrors = validateTimeLockPlausibility(t);
|
||||
if (plausErrors.length > 0) {
|
||||
showPlausibilityErrors(plausErrors);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lockeeVal = document.getElementById('lockeeValue').value;
|
||||
const keyholderVal = document.getElementById('keyholderValue').value;
|
||||
const isFriendLockee = lockeeVal && lockeeVal !== myUserId;
|
||||
const unlockCodeLen = isFriendLockee ? null : (parseInt(document.getElementById('unlockCodeLines').value) || 5);
|
||||
const isTestLock = isFriendLockee ? false : document.getElementById('testLock').checked;
|
||||
|
||||
let endpoint, body;
|
||||
|
||||
if (t._type === 'timelock') {
|
||||
endpoint = '/keyholder/timelock';
|
||||
body = {
|
||||
templateId: t.templateId,
|
||||
lockeeUserId: isFriendLockee ? lockeeVal : null,
|
||||
lockeeDetailsVisible: isFriendLockee ? document.getElementById('lockeeDetailsVisible').checked : false,
|
||||
keyholder: isFriendLockee ? null : (keyholderVal || null),
|
||||
testLock: isTestLock,
|
||||
unlockCodeLength: unlockCodeLen,
|
||||
controllType: selectedLockControl,
|
||||
};
|
||||
} else {
|
||||
// CardLock
|
||||
const initialCards = buildInitialCardsFromTemplate(t);
|
||||
if (initialCards.length === 0) {
|
||||
showError('Die gewählte Vorlage enthält keine Karten. Bitte Vorlage prüfen.');
|
||||
return;
|
||||
}
|
||||
endpoint = '/keyholder/cardlock';
|
||||
body = {
|
||||
name: t.name,
|
||||
lockeeUserId: isFriendLockee ? lockeeVal : null,
|
||||
lockeeDetailsVisible: isFriendLockee ? document.getElementById('lockeeDetailsVisible').checked : false,
|
||||
keyholder: isFriendLockee ? null : (keyholderVal || null),
|
||||
initialCards,
|
||||
pickEveryMinute: t.pickEveryMinute,
|
||||
accumulatePicks: t.accumulatePicks,
|
||||
showRemainingCards: t.showRemainingCards,
|
||||
latestOpeningtime: durationToLatestOpening(),
|
||||
hygineOpeningEveryMinites: t.hygineOpeningEveryMinites || null,
|
||||
hygineOpeningDurationMinutes: t.hygineOpeningDurationMinutes || null,
|
||||
tasks: t.tasks || [],
|
||||
taskCardMode: t.taskCardMode || 'RANDOM',
|
||||
unlockCodeLines: unlockCodeLen,
|
||||
requiresVerification: t.requiresVerification,
|
||||
testLock: isTestLock,
|
||||
controllType: selectedLockControl,
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedLockControl === 'TTLOCK') {
|
||||
document.getElementById('ttlLoadingOverlay').classList.add('open');
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
document.getElementById('ttlLoadingOverlay').classList.remove('open');
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
if (res.status === 409 && errData.error === 'active_lock_exists') {
|
||||
showActiveLockError();
|
||||
} else if (res.status === 403 && errData.error === 'subscription_required') {
|
||||
showError('TTLock erfordert ein aktives Abonnement.');
|
||||
} else if (res.status === 400) {
|
||||
showError('Ungültige Eingabe. Bitte alle Felder prüfen.');
|
||||
} else {
|
||||
showError('Fehler beim Erstellen des Locks.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.lockeeInvitationSent) {
|
||||
window.location.href = '/games/common/einladungen.html?tab=gesendet';
|
||||
} else if (!data.unlockCode) {
|
||||
// Trust: kein Code, direkt weiter
|
||||
const isTimeLock = selectedTemplate && selectedTemplate._type === 'timelock';
|
||||
window.location.href = (isTimeLock ? '/games/chastity/activetimelock.html' : '/games/chastity/activelock.html')
|
||||
+ '?lockId=' + data.lockId + (data.keyholderPending ? '&keyholderPending=1' : '');
|
||||
} else if (selectedLockControl === 'TTLOCK') {
|
||||
showTtlockStartModal(data.unlockCode, data.lockId, data.keyholderPending);
|
||||
} else {
|
||||
showUnlockCodeModal(data.unlockCode, data.lockId, data.keyholderPending);
|
||||
}
|
||||
}
|
||||
|
||||
// ── TTLock-Startmodal (kein Scramble, stattdessen Relock im Hintergrund) ──
|
||||
function showTtlockStartModal(code, lockId, keyholderPending) {
|
||||
const isTimeLock = selectedTemplate && selectedTemplate._type === 'timelock';
|
||||
const lockType = isTimeLock ? 'timelock' : 'cardlock';
|
||||
const targetUrl = (isTimeLock ? '/games/chastity/activetimelock.html' : '/games/chastity/activelock.html')
|
||||
+ '?lockId=' + lockId + (keyholderPending ? '&keyholderPending=1' : '');
|
||||
|
||||
document.getElementById('unlockCodeDisplay').textContent = code;
|
||||
document.getElementById('unlockModalTitle').textContent = 'Dein Startcode';
|
||||
document.getElementById('unlockModalHint').textContent =
|
||||
"Öffne das TTLock mit dem Code, lege den Schlüssel in das TTLock und verschließe es anschließend wieder. Der Code verliert anschließend seine Gültigkeit";
|
||||
if (keyholderPending) document.getElementById('unlockKeyholderHint').style.display = '';
|
||||
|
||||
const btn = document.getElementById('unlockModalBtn');
|
||||
btn.textContent = "🔒 Los geht's";
|
||||
btn.onclick = async () => {
|
||||
btn.disabled = true;
|
||||
document.getElementById('unlockModal').classList.remove('open');
|
||||
document.getElementById('ttlLoadingOverlay').classList.add('open');
|
||||
try {
|
||||
await fetch(`/keyholder/${lockType}/${lockId}/relock`, { method: 'POST' });
|
||||
} catch { /* Fehler ignorieren – Weiterleitung trotzdem */ }
|
||||
window.location.href = targetUrl;
|
||||
};
|
||||
|
||||
document.getElementById('unlockModal').classList.add('open');
|
||||
}
|
||||
|
||||
// ── Entsperrcode-Modal ──
|
||||
function showUnlockCodeModal(code, lockId, keyholderPending) {
|
||||
document.getElementById('unlockCodeDisplay').textContent = code;
|
||||
if (keyholderPending) document.getElementById('unlockKeyholderHint').style.display = '';
|
||||
const isTimeLock = selectedTemplate && selectedTemplate._type === 'timelock';
|
||||
const targetPage = isTimeLock ? '/games/chastity/activetimelock.html' : '/games/chastity/activelock.html';
|
||||
const url = targetPage + '?lockId=' + lockId + (keyholderPending ? '&keyholderPending=1' : '');
|
||||
document.getElementById('unlockModalBtn').onclick = () => startCodeScramble(code, url);
|
||||
document.getElementById('unlockModal').classList.add('open');
|
||||
}
|
||||
|
||||
function startCodeScramble(realCode, url) {
|
||||
const display = document.getElementById('unlockCodeDisplay');
|
||||
const btn = document.getElementById('unlockModalBtn');
|
||||
const hint = document.getElementById('unlockModalHint');
|
||||
const countdown = document.getElementById('unlockModalCountdown');
|
||||
const len = realCode.length;
|
||||
const DURATION = 3 * 60;
|
||||
let remaining = DURATION;
|
||||
let stopped = false;
|
||||
|
||||
function randomCode() {
|
||||
return Array.from({ length: len }, () => Math.floor(Math.random() * 10)).join('');
|
||||
}
|
||||
function finish() {
|
||||
stopped = true;
|
||||
clearInterval(scrambleInterval);
|
||||
clearInterval(countdownInterval);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
if (hint) hint.style.display = 'none';
|
||||
countdown.style.display = '';
|
||||
document.getElementById('unlockModalTitle').textContent = 'Nun vergessen wir den Code…';
|
||||
btn.textContent = 'Abbrechen';
|
||||
btn.onclick = finish;
|
||||
|
||||
const scrambleInterval = setInterval(() => {
|
||||
if (!stopped) display.textContent = randomCode();
|
||||
}, 80);
|
||||
|
||||
const countdownInterval = setInterval(() => {
|
||||
if (stopped) return;
|
||||
remaining--;
|
||||
const m = Math.floor(remaining / 60);
|
||||
const s = remaining % 60;
|
||||
countdown.textContent = `Weiterleitung in ${m}:${String(s).padStart(2,'0')}`;
|
||||
if (remaining <= 0) finish();
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
9
bin/main/static/games/chastity/sessionchastity.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta http-equiv="refresh" content="0; url=/games/chastity/neulock.html">
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
90
bin/main/static/games/chastity/unlock-history.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Code-Historie – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.history-list { display:flex; flex-direction:column; gap:1rem; margin-top:1.25rem; }
|
||||
.history-card {
|
||||
background:var(--color-secondary); border:1px solid var(--color-secondary);
|
||||
border-radius:10px; padding:1rem 1.2rem;
|
||||
}
|
||||
.history-header { display:flex; align-items:center; justify-content:space-between; gap:0.5rem; margin-bottom:0.6rem; }
|
||||
.history-lock-name { font-weight:700; font-size:0.95rem; }
|
||||
.history-source {
|
||||
font-size:0.75rem; color:var(--color-muted);
|
||||
background:var(--color-card); border-radius:6px;
|
||||
padding:0.15rem 0.5rem; white-space:nowrap;
|
||||
}
|
||||
.history-code {
|
||||
font-family: monospace; font-size:0.85rem;
|
||||
background:var(--color-card); border-radius:6px;
|
||||
padding:0.5rem 0.75rem; word-break:break-all; line-height:1.5;
|
||||
margin-bottom:0.5rem;
|
||||
}
|
||||
.history-time { font-size:0.78rem; color:var(--color-muted); }
|
||||
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.75rem; }
|
||||
.page-hint { font-size:0.85rem; color:var(--color-muted); margin:0.25rem 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<h1 style="margin:0 0 0.25rem;">🔙 Entsperrcode-Historie</h1>
|
||||
<p class="page-hint">Die letzten 10 Entsperrcodes, die dir angezeigt wurden.</p>
|
||||
<div class="history-list" id="historyList">
|
||||
<span class="empty-hint">Wird geladen…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
const SOURCE_LABELS = {
|
||||
GREEN_CARD: 'Grüne Karte',
|
||||
HYGIENE_OPEN: 'Hygiene-Öffnung',
|
||||
HYGIENE_CLOSE: 'Hygiene-Öffnung (neu)',
|
||||
KEYHOLDER_UNLOCK: 'Freigabe durch Keyholder',
|
||||
};
|
||||
|
||||
async function load() {
|
||||
const res = await fetch('/keyholder/cardlock/unlock-history');
|
||||
const list = document.getElementById('historyList');
|
||||
if (!res.ok) { list.innerHTML = '<span class="empty-hint">Fehler beim Laden.</span>'; return; }
|
||||
const entries = await res.json();
|
||||
if (!entries.length) { list.innerHTML = '<span class="empty-hint">Noch keine Entsperrcodes erhalten.</span>'; return; }
|
||||
list.innerHTML = '';
|
||||
for (const e of entries) {
|
||||
const dt = new Date(e.receivedAt);
|
||||
const formatted = dt.toLocaleString('de-DE', {
|
||||
day:'2-digit', month:'2-digit', year:'numeric',
|
||||
hour:'2-digit', minute:'2-digit'
|
||||
});
|
||||
const sourceLabel = SOURCE_LABELS[e.source] || e.source;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'history-card';
|
||||
card.innerHTML = `
|
||||
<div class="history-header">
|
||||
<span class="history-lock-name">${escHtml(e.lockName)}</span>
|
||||
<span class="history-source">${escHtml(sourceLabel)}</span>
|
||||
</div>
|
||||
<div class="history-code">${escHtml(e.unlockCode)}</div>
|
||||
<div class="history-time">Erhalten am ${escHtml(formatted)}</div>
|
||||
`;
|
||||
list.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1786
bin/main/static/games/common/aufgaben.html
Normal file
982
bin/main/static/games/common/einladungen.html
Normal 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
|
||||
? ' <span style="font-size:0.72rem;">👁 Details sichtbar</span>'
|
||||
: ' <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>
|
||||
642
bin/main/static/games/common/toys.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
21
bin/main/static/games/vanilla/infovanilla.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>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>
|
||||
1340
bin/main/static/games/vanilla/neuvanilla.html
Normal file
21
bin/main/static/games/vanilla/sessionvanilla.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>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>
|
||||
1019
bin/main/static/games/vanilla/vanillaingame.html
Normal file
11
bin/main/static/games/vanilla/vanillawarten.html
Normal 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>
|
||||
108
bin/main/static/help/impressum.html
Normal 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>
|
||||
265
bin/main/static/help/kontakt.html
Normal 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 & 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 & 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>
|
||||
221
bin/main/static/help/overview.html
Normal 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 & 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 & 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 & 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>
|
||||
345
bin/main/static/help/template.html
Normal 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>
|
||||
360
bin/main/static/help/ttlock.html
Normal 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 > 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>
|
||||
BIN
bin/main/static/img/banner.png
Normal file
|
After Width: | Height: | Size: 626 KiB |
BIN
bin/main/static/img/card.png
Normal file
|
After Width: | Height: | Size: 440 KiB |
BIN
bin/main/static/img/card_cum.png
Normal file
|
After Width: | Height: | Size: 299 KiB |
BIN
bin/main/static/img/card_cum_caged.png
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
bin/main/static/img/card_doubleup.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
bin/main/static/img/card_freeze.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
bin/main/static/img/card_green.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
bin/main/static/img/card_old.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
bin/main/static/img/card_red.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
bin/main/static/img/card_reset.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
bin/main/static/img/card_task.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
bin/main/static/img/card_yellow.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
bin/main/static/img/icon.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
bin/main/static/img/logo.png
Normal file
|
After Width: | Height: | Size: 459 KiB |
BIN
bin/main/static/img/logo_community.png
Normal file
|
After Width: | Height: | Size: 922 KiB |
BIN
bin/main/static/img/lvl1.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
bin/main/static/img/lvl2.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
bin/main/static/img/lvl3.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
bin/main/static/img/lvl4.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
bin/main/static/img/lvl5.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
bin/main/static/img/vorlieben/dunno.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
bin/main/static/img/vorlieben/negative.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
bin/main/static/img/vorlieben/neutral.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
bin/main/static/img/vorlieben/positiv.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
bin/main/static/img/vorlieben/verynegative.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
bin/main/static/img/vorlieben/verypositiv.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
35
bin/main/static/index.html
Normal 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>
|
||||
86
bin/main/static/js/card-defs.js
Normal 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 }])
|
||||
);
|
||||
60
bin/main/static/js/card-display.js
Normal 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
@@ -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>`;
|
||||
}
|
||||
237
bin/main/static/js/image-viewer.js
Normal 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">←</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">→</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();
|
||||
87
bin/main/static/js/meldung.js
Normal 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>`;
|
||||
};
|
||||
})();
|
||||
237
bin/main/static/js/shared.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').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)">‹</button>
|
||||
<button class="car-btn car-next" onclick="event.stopPropagation();carNav(this,1)">›</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);
|
||||
}
|
||||
254
bin/main/static/js/sidebar.js
Normal 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');
|
||||
})();
|
||||
109
bin/main/static/js/social-sidebar.js
Normal 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(() => {});
|
||||
})();
|
||||
405
bin/main/static/js/topbar.js
Normal file
@@ -0,0 +1,405 @@
|
||||
(function () {
|
||||
if (document.querySelector('.topbar')) return;
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
})();
|
||||
1323
bin/main/static/konto/einstellungen.html
Normal file
766
bin/main/static/konto/profile.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
107
bin/main/static/login.html
Normal 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>
|
||||
130
bin/main/static/registration.html
Normal 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>
|
||||
142
bin/main/static/reset-password.html
Normal 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>
|
||||
87
bin/main/static/userhome.html
Normal 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>
|
||||