Verschiebung nach anderem RePo - nun pro Projekt getrennt

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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