Verschiebung nach anderem RePo - nun pro Projekt getrennt
This commit is contained in:
86
bin/main/static/js/card-defs.js
Normal file
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
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
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
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
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
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
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
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
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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user