Alles mögliche
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
||||
|
||||
# Ignore Gradle build output directory
|
||||
build
|
||||
.aider*
|
||||
|
||||
9
bin/main/application-dev.properties
Normal file
9
bin/main/application-dev.properties
Normal file
@@ -0,0 +1,9 @@
|
||||
# Lokale Entwicklung – überschreibt application.properties
|
||||
|
||||
# Cookies ohne Secure-Flag, da lokal kein HTTPS läuft
|
||||
app.cookie.secure=false
|
||||
|
||||
# Klartext-Credentials für lokale DB (kein Umgebungsvariablen-Zwang)
|
||||
spring.mail.username=local@dev.invalid
|
||||
spring.mail.password=unused
|
||||
jwt.keystore.password=XUR!Rv&f$j3UsqD&
|
||||
@@ -22,18 +22,19 @@ spring.jpa.properties.hibernate.type.preferred_uuid_jdbc_type=VARCHAR
|
||||
# Mailpit
|
||||
spring.mail.host=smtp-relay.brevo.com
|
||||
spring.mail.port=587
|
||||
spring.mail.username=a6b17a001@smtp-brevo.com
|
||||
spring.mail.password=xsmtpsib-77b691d562154574133d12b09d44a06e166d30091aac6642480771a0ae463a79-8yH3jHOd4nMMAwuS
|
||||
spring.mail.username=${MAIL_USERNAME}
|
||||
spring.mail.password=${MAIL_PASSWORD}
|
||||
spring.mail.properties.mail.smtp.auth=true
|
||||
spring.mail.properties.mail.smtp.starttls.enable=true
|
||||
|
||||
# JWT Keystore
|
||||
jwt.keystore.path=classpath:xxx.jks
|
||||
jwt.keystore.password=${JWT_KEYSTORE_PASSWORD:XUR!Rv&f$j3UsqD&}
|
||||
jwt.keystore.password=${JWT_KEYSTORE_PASSWORD}
|
||||
jwt.keystore.alias=xxx
|
||||
|
||||
# App
|
||||
app.base-url=http://localhost:8080
|
||||
app.cookie.secure=true
|
||||
|
||||
# Theme – alle Farben hier ändern, Email-Style passt sich automatisch an
|
||||
app.theme.color-bg=#1a1a2e
|
||||
|
||||
BIN
bin/main/de/oaa/xxx/config/CookieFactory.class
Normal file
BIN
bin/main/de/oaa/xxx/config/CookieFactory.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
bin/main/de/oaa/xxx/config/RateLimitFilter$Window.class
Normal file
BIN
bin/main/de/oaa/xxx/config/RateLimitFilter$Window.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/config/RateLimitFilter.class
Normal file
BIN
bin/main/de/oaa/xxx/config/RateLimitFilter.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
bin/main/de/oaa/xxx/config/TokenBlacklistService.class
Normal file
BIN
bin/main/de/oaa/xxx/config/TokenBlacklistService.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,121 +0,0 @@
|
||||
1-unlock by app
|
||||
|
||||
4-unlock by passcode
|
||||
|
||||
5-Rise the lock (for parking lock)
|
||||
|
||||
6-Lower the lock (for parking lock)
|
||||
|
||||
7-unlock by IC card
|
||||
|
||||
8-unlock by fingerprint
|
||||
|
||||
9-unlock by wrist strap
|
||||
|
||||
10-unlock by Mechanical key
|
||||
|
||||
11-lock by app
|
||||
|
||||
12-unlock by gateway
|
||||
|
||||
29-apply some force on the Lock
|
||||
|
||||
30-Door sensor closed
|
||||
|
||||
31-Door sensor open
|
||||
|
||||
32-open from inside
|
||||
|
||||
33-lock by fingerprint
|
||||
|
||||
34-lock by passcode
|
||||
|
||||
35-lock by IC card
|
||||
|
||||
36-lock by Mechanical key
|
||||
|
||||
37-Use APP button to control the lock (rise, fall, stop, lock), mostly used for roller shutter door
|
||||
|
||||
42-received new local mail
|
||||
|
||||
43-received new other cities' mail
|
||||
|
||||
44-Tamper alert
|
||||
|
||||
45-Auto Lock
|
||||
|
||||
46-unlock by unlock key
|
||||
|
||||
47-lock by lock key
|
||||
|
||||
48-System locked ( Caused by, for example: Using INVALID Passcode/Fingerprint/Card several times)
|
||||
|
||||
49-unlock by hotel card
|
||||
|
||||
50-Unlocked due to the high temperature
|
||||
|
||||
51-Try to unlock with a deleted card
|
||||
|
||||
52-Dead lock with APP
|
||||
|
||||
53-Dead lock with passcode
|
||||
|
||||
54-The car left (for parking lock)
|
||||
|
||||
55-Use remote control lock or unlock lock
|
||||
|
||||
57-Unlock with QR code success
|
||||
|
||||
58-Unlock with QR code failed, it's expired
|
||||
|
||||
59-Double locked
|
||||
|
||||
60-Cancel double lock
|
||||
|
||||
61-Lock with QR code success
|
||||
|
||||
62-Lock with QR code failed, the lock is double locked
|
||||
|
||||
63-Auto unlock at passage mode
|
||||
|
||||
64-Door unclosed alarm
|
||||
|
||||
65-Failed to unlock
|
||||
|
||||
66-Failed to lock
|
||||
|
||||
67-Face unlock success
|
||||
|
||||
68-Face unlock failed - door locked from inside
|
||||
|
||||
69-Lock with face
|
||||
|
||||
71-Face unlock failed - expired or ineffective
|
||||
|
||||
75-Unlocked by App granting
|
||||
|
||||
76-Unlocked by remote granting
|
||||
|
||||
77-Dual authentication Bluetooth unlock verification success, waiting for second user
|
||||
|
||||
78-Dual authentication password unlock verification success, waiting for second user
|
||||
|
||||
79-Dual authentication fingerprint unlock verification success, waiting for second user
|
||||
|
||||
80-Dual authentication IC card unlock verification success, waiting for second user
|
||||
|
||||
81-Dual authentication face card unlock verification success, waiting for second user
|
||||
|
||||
82-Dual authentication wireless key unlock verification success, waiting for second user
|
||||
|
||||
83-Dual authentication palm vein unlock verification success, waiting for second user
|
||||
|
||||
84-Palm vein unlock success
|
||||
|
||||
85-Palm vein unlock success
|
||||
|
||||
86-Lock with palm vein
|
||||
|
||||
88-Palm vein unlock failed - expired or ineffective
|
||||
|
||||
92-Administrator password to unlock
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
bin/main/de/oaa/xxx/user/UserController$NewMemberDto.class
Normal file
BIN
bin/main/de/oaa/xxx/user/UserController$NewMemberDto.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -182,10 +182,17 @@
|
||||
let composeBilderArr = [];
|
||||
|
||||
// ── Boot ──
|
||||
fetch('/login/me').then(r => r.ok ? r.json() : null).then(user => {
|
||||
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
|
||||
if (user) {
|
||||
myUserId = user.userId;
|
||||
loadFeed('mine');
|
||||
const raw = sessionStorage.getItem('feedOpenPost');
|
||||
if (raw) {
|
||||
sessionStorage.removeItem('feedOpenPost');
|
||||
loadFeed('mine');
|
||||
openLbWithData(JSON.parse(raw));
|
||||
} else {
|
||||
await loadFeed('mine');
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
@@ -387,7 +394,7 @@
|
||||
|
||||
async function submitPost() {
|
||||
const text = document.getElementById('composeText').value.trim();
|
||||
if (!text) return;
|
||||
if (!text && composeBilderArr.length === 0) return;
|
||||
const beitragTyp = document.querySelector('input[name="beitragTyp"]:checked').value;
|
||||
const multiChoice = document.getElementById('multiChoice').checked;
|
||||
const isPublic = document.getElementById('isPublic').checked;
|
||||
@@ -495,6 +502,20 @@
|
||||
document.getElementById('postLightbox').classList.add('open');
|
||||
}
|
||||
|
||||
function openLbWithData(p) {
|
||||
activeLbPostId = p.postId;
|
||||
activeLbPostType = p.postType || 'FEED';
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = renderPostCard(p, 'mine');
|
||||
const card = tempDiv.firstElementChild;
|
||||
if (card) {
|
||||
card.querySelectorAll('.post-actions').forEach(el => el.remove());
|
||||
document.getElementById('lbPostBody').innerHTML = card.innerHTML;
|
||||
}
|
||||
loadLbComments(p.postId, p.postType || 'FEED');
|
||||
document.getElementById('postLightbox').classList.add('open');
|
||||
}
|
||||
|
||||
function closeLb() {
|
||||
document.getElementById('postLightbox').classList.remove('open');
|
||||
activeLbPostId = null;
|
||||
|
||||
147
bin/main/static/dating/besucher.html
Normal file
147
bin/main/static/dating/besucher.html
Normal file
@@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Profilbesucher – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.profiles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
|
||||
@media (max-width: 500px) { .profiles-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
|
||||
.profile-card { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 12px; overflow: hidden; transition: border-color 0.15s, box-shadow 0.15s; text-decoration: none; color: var(--color-text); display: flex; flex-direction: column; }
|
||||
.profile-card:hover { border-color: var(--color-primary); box-shadow: 0 4px 18px rgba(0,0,0,0.35); }
|
||||
.profile-card-img-wrap { position: relative; width: 100%; aspect-ratio: 1; flex-shrink: 0; overflow: hidden; background: var(--color-secondary); display: flex; align-items: center; justify-content: center; font-size: 3rem; }
|
||||
.profile-card-img-wrap img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-card-body { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.3rem; flex: 1; }
|
||||
.profile-card-name { font-weight: 700; font-size: 1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.profile-card-meta { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.meta-chip { padding: 0.1rem 0.45rem; border-radius: 20px; background: var(--color-secondary); font-size: 0.73rem; color: var(--color-muted); }
|
||||
.meta-chip.time { color: var(--color-primary); }
|
||||
.profile-card-desc { font-size: 0.78rem; color: var(--color-muted); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; margin-top: 0.15rem; }
|
||||
.profile-card-skeleton { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.skeleton-img { width: 100%; aspect-ratio: 1; background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-card) 50%, var(--color-secondary) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
.skeleton-body { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.skeleton-line { height: 0.75rem; border-radius: 4px; background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-card) 50%, var(--color-secondary) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
@keyframes shimmer { to { background-position: -200% 0; } }
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--color-muted); grid-column: 1 / -1; }
|
||||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
|
||||
#sentinel { height: 1px; margin-top: 1rem; }
|
||||
.results-count { font-size: 0.85rem; color: var(--color-muted); display: block; margin-bottom: 0.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<h1 style="margin:0 0 0.15rem;">Profilbesucher</h1>
|
||||
<span class="results-count" id="resultsCount"></span>
|
||||
<div class="profiles-grid" id="profilesGrid"></div>
|
||||
<div id="sentinel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
const BATCH = 12;
|
||||
let allEntries = [];
|
||||
let loadedCount = 0;
|
||||
let loading = false;
|
||||
|
||||
function relativeTime(iso) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return 'gerade eben';
|
||||
if (m < 60) return `vor ${m} Min.`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `vor ${h} Std.`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `vor ${d} Tag${d !== 1 ? 'en' : ''}`;
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
function appendProfiles(profiles) {
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
profiles.forEach(p => {
|
||||
const entry = allEntries.find(e => e.userId === p.userId);
|
||||
const pic = p.profilePictureHq || p.profilePicture;
|
||||
const img = pic ? `<img src="data:image/png;base64,${pic}" alt="${esc(p.name)}" loading="lazy">` : '👤';
|
||||
const chips = [
|
||||
p.alter ? `<span class="meta-chip">${p.alter} J.</span>` : '',
|
||||
p.geschlecht ? `<span class="meta-chip">${esc(p.geschlecht)}</span>` : '',
|
||||
p.datingStadt ? `<span class="meta-chip">${esc(p.datingStadt)}</span>` : '',
|
||||
entry?.visitedAt ? `<span class="meta-chip time">${relativeTime(entry.visitedAt)}</span>` : '',
|
||||
].filter(Boolean).join('');
|
||||
const desc = p.beschreibung ? `<div class="profile-card-desc">${esc(p.beschreibung)}</div>` : '';
|
||||
const card = document.createElement('a');
|
||||
card.className = 'profile-card';
|
||||
card.href = `/community/benutzer.html?userId=${p.userId}`;
|
||||
card.innerHTML = `
|
||||
<div class="profile-card-img-wrap">${img}</div>
|
||||
<div class="profile-card-body">
|
||||
<div class="profile-card-name">${esc(p.name)}</div>
|
||||
<div class="profile-card-meta">${chips}</div>
|
||||
${desc}
|
||||
</div>`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadNextBatch() {
|
||||
if (loading || loadedCount >= allEntries.length) return;
|
||||
loading = true;
|
||||
const batch = allEntries.slice(loadedCount, loadedCount + BATCH);
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
const skelDiv = document.createElement('div');
|
||||
skelDiv.style.display = 'contents';
|
||||
skelDiv.innerHTML = Array.from({length: batch.length}, () => `
|
||||
<div class="profile-card-skeleton">
|
||||
<div class="skeleton-img"></div>
|
||||
<div class="skeleton-body">
|
||||
<div class="skeleton-line" style="width:70%"></div>
|
||||
<div class="skeleton-line" style="width:50%"></div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
grid.appendChild(skelDiv);
|
||||
try {
|
||||
const res = await fetch('/dating/profiles/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(batch.map(e => e.userId))
|
||||
});
|
||||
grid.removeChild(skelDiv);
|
||||
if (res.ok) appendProfiles(await res.json());
|
||||
} catch { grid.removeChild(skelDiv); }
|
||||
loadedCount += batch.length;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/social/profile-visits/my-visitors');
|
||||
if (!res.ok) throw new Error();
|
||||
const visitors = await res.json();
|
||||
allEntries = visitors.map(v => ({ userId: v.userId, visitedAt: v.visitedAt }));
|
||||
document.getElementById('resultsCount').textContent =
|
||||
allEntries.length === 0 ? 'Noch keine Besucher'
|
||||
: allEntries.length + (allEntries.length === 1 ? ' Besucher' : ' Besucher');
|
||||
if (!allEntries.length) {
|
||||
document.getElementById('profilesGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">👀</div><p>Noch niemand hat dein Profil besucht.</p></div>';
|
||||
return;
|
||||
}
|
||||
loadNextBatch();
|
||||
} catch {
|
||||
document.getElementById('profilesGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">⚠️</div><p>Fehler beim Laden.</p></div>';
|
||||
}
|
||||
})();
|
||||
|
||||
new IntersectionObserver(
|
||||
e => { if (e[0].isIntersecting) loadNextBatch(); },
|
||||
{ rootMargin: '300px' }
|
||||
).observe(document.getElementById('sentinel'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1038,7 +1038,7 @@
|
||||
<div style="font-size:2.5rem; margin-bottom:0.75rem;">🎉</div>
|
||||
<p>Keine weiteren Profile gefunden.<br>Schau später wieder vorbei!</p>
|
||||
</div>
|
||||
<div id="discoveryLoading" style="text-align:center; padding:3rem 1rem; color:var(--color-muted);">
|
||||
<div id="discoveryLoading" style="display:none; text-align:center; padding:3rem 1rem; color:var(--color-muted);">
|
||||
Wird geladen…
|
||||
</div>
|
||||
<div class="discovery-actions" id="discoveryActions" style="display:none;">
|
||||
@@ -1742,7 +1742,11 @@
|
||||
document.getElementById('discoveryEmpty').style.display = 'none';
|
||||
try {
|
||||
const res = await fetch('/dating/discovery');
|
||||
if (res.status === 403) return;
|
||||
if (res.status === 403) {
|
||||
document.getElementById('discoveryLoading').style.display = 'none';
|
||||
document.getElementById('discoveryEmpty').style.display = '';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error();
|
||||
discoveryQueue = await res.json();
|
||||
discoveryIdx = 0;
|
||||
168
bin/main/static/dating/likes.html
Normal file
168
bin/main/static/dating/likes.html
Normal file
@@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Likes – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.profiles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
|
||||
@media (max-width: 500px) { .profiles-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
|
||||
.profile-card { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 12px; overflow: hidden; transition: border-color 0.15s, box-shadow 0.15s; text-decoration: none; color: var(--color-text); display: flex; flex-direction: column; }
|
||||
.profile-card:hover { border-color: var(--color-primary); box-shadow: 0 4px 18px rgba(0,0,0,0.35); }
|
||||
.profile-card-img-wrap { position: relative; width: 100%; aspect-ratio: 1; flex-shrink: 0; overflow: hidden; background: var(--color-secondary); display: flex; align-items: center; justify-content: center; font-size: 3rem; }
|
||||
.profile-card-img-wrap img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-card-img-wrap.blurred img { filter: blur(12px); transform: scale(1.1); }
|
||||
.profile-card-img-wrap .lock-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 2rem; }
|
||||
.profile-card-body { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.3rem; flex: 1; }
|
||||
.profile-card-name { font-weight: 700; font-size: 1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.profile-card-meta { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.meta-chip { padding: 0.1rem 0.45rem; border-radius: 20px; background: var(--color-secondary); font-size: 0.73rem; color: var(--color-muted); }
|
||||
.meta-chip.accent { color: var(--color-primary); }
|
||||
.profile-card-desc { font-size: 0.78rem; color: var(--color-muted); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; margin-top: 0.15rem; }
|
||||
.profile-card-skeleton { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.skeleton-img { width: 100%; aspect-ratio: 1; background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-card) 50%, var(--color-secondary) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
.skeleton-body { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.skeleton-line { height: 0.75rem; border-radius: 4px; background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-card) 50%, var(--color-secondary) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
@keyframes shimmer { to { background-position: -200% 0; } }
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--color-muted); grid-column: 1 / -1; }
|
||||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
|
||||
#sentinel { height: 1px; margin-top: 1rem; }
|
||||
.results-count { font-size: 0.85rem; color: var(--color-muted); display: block; margin-bottom: 0.75rem; }
|
||||
.premium-banner { background: var(--color-card); border: 1px solid var(--color-primary); border-radius: 10px; padding: 0.85rem 1rem; margin-bottom: 1rem; font-size: 0.88rem; color: var(--color-muted); }
|
||||
.premium-banner strong { color: var(--color-primary); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<h1 style="margin:0 0 0.15rem;">Likes</h1>
|
||||
<span class="results-count" id="resultsCount"></span>
|
||||
<div id="premiumBanner" style="display:none;" class="premium-banner">
|
||||
<strong>Premium</strong> – Mit Premium siehst du, wer dich geliket hat.
|
||||
</div>
|
||||
<div class="profiles-grid" id="profilesGrid"></div>
|
||||
<div id="sentinel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
const BATCH = 12;
|
||||
let allEntries = []; // { userId } – nur für Premium; gesamt inkl. locked
|
||||
let loadedCount = 0;
|
||||
let loading = false;
|
||||
let isPremium = false;
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
function skeleton() {
|
||||
return `<div class="profile-card-skeleton"><div class="skeleton-img"></div><div class="skeleton-body"><div class="skeleton-line" style="width:70%"></div><div class="skeleton-line" style="width:50%"></div></div></div>`;
|
||||
}
|
||||
|
||||
function appendLockedCard() {
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
const card = document.createElement('div');
|
||||
card.className = 'profile-card';
|
||||
card.style.cursor = 'default';
|
||||
card.innerHTML = `
|
||||
<div class="profile-card-img-wrap blurred">
|
||||
<span style="font-size:3rem;">👤</span>
|
||||
<div class="lock-overlay">🔒</div>
|
||||
</div>
|
||||
<div class="profile-card-body">
|
||||
<div class="profile-card-name">???</div>
|
||||
<div class="profile-card-meta"><span class="meta-chip accent">Premium</span></div>
|
||||
</div>`;
|
||||
grid.appendChild(card);
|
||||
}
|
||||
|
||||
function appendProfiles(profiles) {
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
profiles.forEach(p => {
|
||||
const pic = p.profilePictureHq || p.profilePicture;
|
||||
const img = pic ? `<img src="data:image/png;base64,${pic}" alt="${esc(p.name)}" loading="lazy">` : '👤';
|
||||
const chips = [
|
||||
p.alter ? `<span class="meta-chip">${p.alter} J.</span>` : '',
|
||||
p.geschlecht ? `<span class="meta-chip">${esc(p.geschlecht)}</span>` : '',
|
||||
p.datingStadt ? `<span class="meta-chip">${esc(p.datingStadt)}</span>` : '',
|
||||
`<span class="meta-chip accent">❤️ mag dich</span>`,
|
||||
].filter(Boolean).join('');
|
||||
const desc = p.beschreibung ? `<div class="profile-card-desc">${esc(p.beschreibung)}</div>` : '';
|
||||
const card = document.createElement('a');
|
||||
card.className = 'profile-card';
|
||||
card.href = `/community/benutzer.html?userId=${p.userId}`;
|
||||
card.innerHTML = `
|
||||
<div class="profile-card-img-wrap">${img}</div>
|
||||
<div class="profile-card-body">
|
||||
<div class="profile-card-name">${esc(p.name)}</div>
|
||||
<div class="profile-card-meta">${chips}</div>
|
||||
${desc}
|
||||
</div>`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadNextBatch() {
|
||||
if (loading || loadedCount >= allEntries.length) return;
|
||||
loading = true;
|
||||
const batch = allEntries.slice(loadedCount, loadedCount + BATCH);
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
const skelDiv = document.createElement('div');
|
||||
skelDiv.style.display = 'contents';
|
||||
skelDiv.innerHTML = Array.from({length: batch.length}, () => skeleton()).join('');
|
||||
grid.appendChild(skelDiv);
|
||||
try {
|
||||
const res = await fetch('/dating/profiles/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(batch.map(e => e.userId))
|
||||
});
|
||||
grid.removeChild(skelDiv);
|
||||
if (res.ok) appendProfiles(await res.json());
|
||||
} catch { grid.removeChild(skelDiv); }
|
||||
loadedCount += batch.length;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/dating/who-likes-me');
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
isPremium = !!data.premium;
|
||||
|
||||
document.getElementById('resultsCount').textContent =
|
||||
data.total === 0 ? 'Noch keine Likes'
|
||||
: data.total + (data.total === 1 ? ' Person mag dich' : ' Personen mögen dich');
|
||||
|
||||
if (data.total === 0) {
|
||||
document.getElementById('profilesGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">❤️</div><p>Noch keine Likes.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPremium) {
|
||||
document.getElementById('premiumBanner').style.display = '';
|
||||
// Locked-Karten für alle anzeigen
|
||||
data.likers.forEach(() => appendLockedCard());
|
||||
return;
|
||||
}
|
||||
|
||||
allEntries = data.likers.map(l => ({ userId: l.userId }));
|
||||
loadNextBatch();
|
||||
} catch {
|
||||
document.getElementById('profilesGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">⚠️</div><p>Fehler beim Laden.</p></div>';
|
||||
}
|
||||
})();
|
||||
|
||||
new IntersectionObserver(
|
||||
e => { if (e[0].isIntersecting) loadNextBatch(); },
|
||||
{ rootMargin: '300px' }
|
||||
).observe(document.getElementById('sentinel'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
139
bin/main/static/dating/matches.html
Normal file
139
bin/main/static/dating/matches.html
Normal file
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Matches – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.profiles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
|
||||
@media (max-width: 500px) { .profiles-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
|
||||
.profile-card { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 12px; overflow: hidden; transition: border-color 0.15s, box-shadow 0.15s; text-decoration: none; color: var(--color-text); display: flex; flex-direction: column; }
|
||||
.profile-card:hover { border-color: var(--color-primary); box-shadow: 0 4px 18px rgba(0,0,0,0.35); }
|
||||
.profile-card-img-wrap { position: relative; width: 100%; aspect-ratio: 1; flex-shrink: 0; overflow: hidden; background: var(--color-secondary); display: flex; align-items: center; justify-content: center; font-size: 3rem; }
|
||||
.profile-card-img-wrap img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-card-body { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.3rem; flex: 1; }
|
||||
.profile-card-name { font-weight: 700; font-size: 1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.profile-card-meta { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.meta-chip { padding: 0.1rem 0.45rem; border-radius: 20px; background: var(--color-secondary); font-size: 0.73rem; color: var(--color-muted); }
|
||||
.meta-chip.accent { color: var(--color-primary); }
|
||||
.profile-card-desc { font-size: 0.78rem; color: var(--color-muted); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; margin-top: 0.15rem; }
|
||||
.profile-card-actions { padding: 0 0.75rem 0.75rem; display: flex; gap: 0.5rem; }
|
||||
.profile-card-actions .btn { flex: 1; padding: 0.4rem 0.5rem; font-size: 0.8rem; text-align: center; }
|
||||
.profile-card-skeleton { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.skeleton-img { width: 100%; aspect-ratio: 1; background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-card) 50%, var(--color-secondary) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
.skeleton-body { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.skeleton-line { height: 0.75rem; border-radius: 4px; background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-card) 50%, var(--color-secondary) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
@keyframes shimmer { to { background-position: -200% 0; } }
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--color-muted); grid-column: 1 / -1; }
|
||||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
|
||||
#sentinel { height: 1px; margin-top: 1rem; }
|
||||
.results-count { font-size: 0.85rem; color: var(--color-muted); display: block; margin-bottom: 0.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<h1 style="margin:0 0 0.15rem;">Matches</h1>
|
||||
<span class="results-count" id="resultsCount"></span>
|
||||
<div class="profiles-grid" id="profilesGrid"></div>
|
||||
<div id="sentinel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
const BATCH = 12;
|
||||
let allEntries = [];
|
||||
let loadedCount = 0;
|
||||
let loading = false;
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
function skeleton() {
|
||||
return `<div class="profile-card-skeleton"><div class="skeleton-img"></div><div class="skeleton-body"><div class="skeleton-line" style="width:70%"></div><div class="skeleton-line" style="width:50%"></div></div></div>`;
|
||||
}
|
||||
|
||||
function appendProfiles(profiles) {
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
profiles.forEach(p => {
|
||||
const pic = p.profilePictureHq || p.profilePicture;
|
||||
const img = pic ? `<img src="data:image/png;base64,${pic}" alt="${esc(p.name)}" loading="lazy">` : '👤';
|
||||
const chips = [
|
||||
p.alter ? `<span class="meta-chip">${p.alter} J.</span>` : '',
|
||||
p.geschlecht ? `<span class="meta-chip">${esc(p.geschlecht)}</span>` : '',
|
||||
p.datingStadt ? `<span class="meta-chip">${esc(p.datingStadt)}</span>` : '',
|
||||
`<span class="meta-chip accent">💕 Match</span>`,
|
||||
].filter(Boolean).join('');
|
||||
const desc = p.beschreibung ? `<div class="profile-card-desc">${esc(p.beschreibung)}</div>` : '';
|
||||
const card = document.createElement('div');
|
||||
card.className = 'profile-card';
|
||||
card.innerHTML = `
|
||||
<a href="/community/benutzer.html?userId=${p.userId}" style="text-decoration:none;color:inherit;display:contents;">
|
||||
<div class="profile-card-img-wrap">${img}</div>
|
||||
<div class="profile-card-body">
|
||||
<div class="profile-card-name">${esc(p.name)}</div>
|
||||
<div class="profile-card-meta">${chips}</div>
|
||||
${desc}
|
||||
</div>
|
||||
</a>
|
||||
<div class="profile-card-actions">
|
||||
<a class="btn" href="/community/nachrichten.html?userId=${p.userId}">Nachricht</a>
|
||||
<a class="btn" href="/community/benutzer.html?userId=${p.userId}" style="background:var(--color-secondary);color:var(--color-text);">Profil</a>
|
||||
</div>`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadNextBatch() {
|
||||
if (loading || loadedCount >= allEntries.length) return;
|
||||
loading = true;
|
||||
const batch = allEntries.slice(loadedCount, loadedCount + BATCH);
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
const skelDiv = document.createElement('div');
|
||||
skelDiv.style.display = 'contents';
|
||||
skelDiv.innerHTML = Array.from({length: batch.length}, () => skeleton()).join('');
|
||||
grid.appendChild(skelDiv);
|
||||
try {
|
||||
const res = await fetch('/dating/profiles/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(batch.map(e => e.userId))
|
||||
});
|
||||
grid.removeChild(skelDiv);
|
||||
if (res.ok) appendProfiles(await res.json());
|
||||
} catch { grid.removeChild(skelDiv); }
|
||||
loadedCount += batch.length;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/dating/matches');
|
||||
if (!res.ok) throw new Error();
|
||||
const matches = await res.json();
|
||||
allEntries = matches.map(m => ({ userId: m.userId }));
|
||||
document.getElementById('resultsCount').textContent =
|
||||
allEntries.length === 0 ? 'Noch keine Matches'
|
||||
: allEntries.length + (allEntries.length === 1 ? ' Match' : ' Matches');
|
||||
if (!allEntries.length) {
|
||||
document.getElementById('profilesGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">💕</div><p>Noch keine Matches. Schau öfter vorbei!</p></div>';
|
||||
return;
|
||||
}
|
||||
loadNextBatch();
|
||||
} catch {
|
||||
document.getElementById('profilesGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">⚠️</div><p>Fehler beim Laden.</p></div>';
|
||||
}
|
||||
})();
|
||||
|
||||
new IntersectionObserver(
|
||||
e => { if (e[0].isIntersecting) loadNextBatch(); },
|
||||
{ rootMargin: '300px' }
|
||||
).observe(document.getElementById('sentinel'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -314,6 +314,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bereits in einem Lock-Dialog -->
|
||||
<div class="modal-overlay" id="activeLockModal">
|
||||
<div class="modal-bg" onclick="document.getElementById('activeLockModal').classList.remove('open')"></div>
|
||||
<div class="modal-box">
|
||||
<div style="font-size:2rem;">🔒</div>
|
||||
<h3 style="margin:0;text-align:center;">Du bist bereits in einem Lock</h3>
|
||||
<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;margin:0;">
|
||||
Es ist bereits ein aktives Keuschheitslock für dein Konto vorhanden.
|
||||
Beende oder öffne zuerst das bestehende Lock.
|
||||
</p>
|
||||
<a id="activeLockLink" href="#" style="display:none;width:100%;">
|
||||
<button style="width:100%;">Zum aktiven Lock →</button>
|
||||
</a>
|
||||
<button class="btn-secondary" style="width:100%;"
|
||||
onclick="document.getElementById('activeLockModal').classList.remove('open')">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entsperrcode-Modal -->
|
||||
<div class="modal-overlay" id="unlockModal">
|
||||
<div class="modal-bg"></div>
|
||||
@@ -669,12 +687,29 @@
|
||||
el.style.display = '';
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
function showActiveLockError() {
|
||||
const el = document.getElementById('errorMsg');
|
||||
el.innerHTML = 'Du befindest dich bereits in einem aktiven Lock. '
|
||||
+ '<a href="/games/chastity/meine-locks.html" style="color:inherit;text-decoration:underline;">Zum aktiven Lock</a>';
|
||||
el.style.display = '';
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
async function showActiveLockError() {
|
||||
const modal = document.getElementById('activeLockModal');
|
||||
const linkEl = document.getElementById('activeLockLink');
|
||||
linkEl.style.display = 'none';
|
||||
|
||||
// Eigenes aktives Lock suchen (CardLock oder TimeLock)
|
||||
try {
|
||||
const [cardRes, timeRes] = await Promise.all([
|
||||
fetch('/keyholder/mylock'),
|
||||
fetch('/keyholder/timelock/mylock')
|
||||
]);
|
||||
if (cardRes.status === 200) {
|
||||
const d = await cardRes.json();
|
||||
linkEl.href = '/games/chastity/activelock.html?lockId=' + d.lockId;
|
||||
linkEl.style.display = '';
|
||||
} else if (timeRes.status === 200) {
|
||||
const d = await timeRes.json();
|
||||
linkEl.href = '/games/chastity/activetimelock.html?lockId=' + d.lockId;
|
||||
linkEl.style.display = '';
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
modal.classList.add('open');
|
||||
}
|
||||
function setFieldError(rowId, msg) {
|
||||
const row = document.getElementById(rowId);
|
||||
|
||||
@@ -80,9 +80,17 @@
|
||||
...socialLinks.map(navLink),
|
||||
].join('');
|
||||
|
||||
const datingActive = path === '/dating.html';
|
||||
const datingCls = datingActive ? ' class="active"' : '';
|
||||
const datingItem = `<li id="navDating"><a href="/konto/einstellungen.html#sec-dating"${datingCls}><span class="icon">${I('DATING') || '♥'}</span> Dating</a></li>`;
|
||||
const datingLinks = [
|
||||
{ href: '/dating/dating.html', icon: I('DATING') || '♥', label: 'Dating', id: 'navDating' },
|
||||
{ href: '/dating/besucher.html', icon: '👀', label: 'Besucher' },
|
||||
{ href: '/dating/likes.html', icon: '❤️', label: 'Likes' },
|
||||
{ href: '/dating/matches.html', icon: '💕', label: 'Matches' },
|
||||
];
|
||||
const datingItem = datingLinks.map(({ href, icon, label, id }) => {
|
||||
const cls = path === href ? ' class="active"' : '';
|
||||
const idAttr = id ? ` id="${id}"` : '';
|
||||
return `<li${idAttr}><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}</a></li>`;
|
||||
}).join('');
|
||||
|
||||
const fullHref = path + window.location.search;
|
||||
const nav = groups.map(({ label, icon, items }) => {
|
||||
@@ -152,6 +160,11 @@
|
||||
document.querySelectorAll('.sidebar-group-toggle').forEach(toggle => {
|
||||
toggle.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const href = toggle.getAttribute('href');
|
||||
if (href && href !== '#') {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
toggle.closest('.sidebar-group').classList.toggle('open');
|
||||
});
|
||||
});
|
||||
@@ -230,7 +243,7 @@
|
||||
const navDating = document.getElementById('navDating');
|
||||
if (navDating) {
|
||||
navDating.querySelector('a').href = user.datingAktiv
|
||||
? '/dating.html'
|
||||
? '/dating/dating.html'
|
||||
: '/konto/einstellungen.html#sec-dating';
|
||||
}
|
||||
|
||||
|
||||
@@ -36,77 +36,63 @@
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
}
|
||||
.visitors-strip {
|
||||
display: flex; flex-wrap: wrap; gap: 0.75rem;
|
||||
/* ── Aktivitäts-Grid (Besucher / Likes / Matches) ── */
|
||||
.activity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.visitor-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
|
||||
text-decoration: none; color: var(--color-text);
|
||||
width: 72px;
|
||||
@media (max-width: 680px) {
|
||||
.activity-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.visitor-card:hover .visitor-avatar { border-color: var(--color-primary); }
|
||||
.visitor-avatar {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid var(--color-secondary);
|
||||
.activity-col {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.85rem 0.85rem;
|
||||
}
|
||||
.activity-col-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.activity-col-title {
|
||||
font-size: 0.78rem; font-weight: 700; color: var(--color-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
.activity-col-link {
|
||||
font-size: 0.75rem; color: var(--color-primary);
|
||||
text-decoration: none; font-weight: 600;
|
||||
}
|
||||
.activity-col-link:hover { text-decoration: underline; }
|
||||
.activity-row {
|
||||
display: flex; gap: 0.5rem;
|
||||
}
|
||||
/* Avatar-Karte */
|
||||
.soc-card {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
|
||||
text-decoration: none; color: var(--color-text); cursor: pointer; min-width: 0;
|
||||
}
|
||||
.soc-card:hover .soc-avatar { border-color: var(--color-primary); }
|
||||
.soc-avatar {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
background: var(--color-secondary); border: 2px solid var(--color-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; overflow: hidden; flex-shrink: 0;
|
||||
transition: border-color 0.15s;
|
||||
font-size: 1.2rem; overflow: hidden; flex-shrink: 0;
|
||||
transition: border-color 0.15s; position: relative;
|
||||
}
|
||||
.visitor-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.visitor-name {
|
||||
font-size: 0.75rem; text-align: center;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
.soc-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.soc-lock {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 0.95rem;
|
||||
}
|
||||
.visitor-time { font-size: 0.68rem; color: var(--color-muted); text-align: center; }
|
||||
|
||||
/* ── Dating: Likes & Matches ── */
|
||||
.dating-strip { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.dating-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
|
||||
text-decoration: none; color: var(--color-text); width: 72px;
|
||||
}
|
||||
.dating-card:hover .dating-avatar { border-color: var(--color-primary); }
|
||||
.dating-avatar {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid var(--color-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; overflow: hidden; flex-shrink: 0;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.dating-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.dating-name {
|
||||
font-size: 0.75rem; text-align: center;
|
||||
.soc-name {
|
||||
font-size: 0.68rem; text-align: center;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%;
|
||||
}
|
||||
/* Verschwommene Karte für nicht-Premium */
|
||||
.dating-card-locked {
|
||||
width: 72px; display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
|
||||
cursor: default;
|
||||
}
|
||||
.dating-avatar-blurred {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid var(--color-secondary);
|
||||
overflow: hidden; flex-shrink: 0; position: relative;
|
||||
}
|
||||
.dating-avatar-blurred img {
|
||||
width: 100%; height: 100%; object-fit: cover; border-radius: 50%;
|
||||
filter: blur(6px); transform: scale(1.1);
|
||||
}
|
||||
.dating-avatar-blurred .lock-icon {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.premium-hint {
|
||||
font-size: 0.65rem; color: var(--color-primary); text-align: center; font-weight: 600;
|
||||
}
|
||||
.match-badge {
|
||||
font-size: 0.65rem; color: var(--color-primary); text-align: center; font-weight: 600;
|
||||
}
|
||||
.soc-sub { font-size: 0.62rem; color: var(--color-muted); text-align: center; }
|
||||
.soc-sub-accent { font-size: 0.62rem; color: var(--color-primary); font-weight: 600; text-align: center; }
|
||||
.activity-empty { font-size: 0.8rem; color: var(--color-muted); text-align: center; padding: 0.5rem 0; }
|
||||
|
||||
/* ── Location-Events ── */
|
||||
.loc-event-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
@@ -130,6 +116,159 @@
|
||||
.loc-event-title { font-size: 0.92rem; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.loc-event-date { font-size: 0.75rem; color: var(--color-primary); margin-top: 0.15rem; }
|
||||
|
||||
/* ── Aktive Spiele ── */
|
||||
.active-game-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.active-game-card {
|
||||
display: flex; gap: 0.75rem; align-items: center;
|
||||
background: var(--color-secondary); border: 1px solid var(--color-secondary);
|
||||
border-radius: 10px; padding: 0.65rem 0.85rem;
|
||||
text-decoration: none; color: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.active-game-card:hover { border-color: var(--color-primary); }
|
||||
.active-game-icon {
|
||||
width: 48px; height: 48px; border-radius: 8px; flex-shrink: 0;
|
||||
background: var(--color-card);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.6rem;
|
||||
}
|
||||
.active-game-body { flex: 1; min-width: 0; }
|
||||
.active-game-title { font-size: 0.92rem; font-weight: 600; }
|
||||
.active-game-sub { font-size: 0.75rem; color: var(--color-muted); margin-top: 0.1rem; }
|
||||
.active-game-action {
|
||||
font-size: 0.8rem; color: var(--color-primary); font-weight: 600; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Einladungen ── */
|
||||
.invite-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.invite-card {
|
||||
display: flex; gap: 0.75rem; align-items: center;
|
||||
background: var(--color-secondary); border: 1px solid var(--color-secondary);
|
||||
border-radius: 10px; padding: 0.65rem 0.85rem;
|
||||
text-decoration: none; color: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.invite-card:hover { border-color: var(--color-primary); }
|
||||
.invite-avatar {
|
||||
width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--color-card);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.invite-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.invite-body { flex: 1; min-width: 0; }
|
||||
.invite-from { font-size: 0.88rem; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.invite-type { font-size: 0.73rem; color: var(--color-muted); margin-top: 0.1rem; }
|
||||
.invite-action { font-size: 0.8rem; color: var(--color-primary); font-weight: 600; flex-shrink: 0; }
|
||||
|
||||
/* ── Freundschaftsanfragen ── */
|
||||
.friend-req-strip { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.friend-req-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
|
||||
text-decoration: none; color: var(--color-text); width: 72px;
|
||||
}
|
||||
.friend-req-card:hover .friend-req-avatar { border-color: var(--color-primary); }
|
||||
.friend-req-avatar {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid var(--color-primary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; overflow: hidden; flex-shrink: 0;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.friend-req-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.friend-req-name {
|
||||
font-size: 0.75rem; text-align: center;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%;
|
||||
}
|
||||
.friend-req-badge { font-size: 0.65rem; color: var(--color-primary); font-weight: 600; text-align: center; }
|
||||
|
||||
/* ── Compose ── */
|
||||
.post-compose { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:1rem; transition:border-color 0.15s; }
|
||||
.post-compose.drag-over { border-color:var(--color-primary); background:rgba(var(--color-primary-rgb,180,0,60),0.06); }
|
||||
.compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; }
|
||||
.compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; }
|
||||
.post-compose textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:70px; box-sizing:border-box; }
|
||||
.post-compose textarea:focus { border-color:var(--color-primary); }
|
||||
.compose-thumbs { display:none; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem; }
|
||||
.compose-thumb { position:relative; width:64px; height:64px; flex-shrink:0; }
|
||||
.compose-thumb img { width:64px; height:64px; object-fit:cover; border-radius:6px; display:block; }
|
||||
.compose-thumb-remove { position:absolute; top:-5px; right:-5px; background:rgba(0,0,0,0.7); border:none; color:#fff; border-radius:50%; font-size:0.65rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; width:auto; line-height:1; }
|
||||
.umfrage-options { margin-top:0.5rem; }
|
||||
.umfrage-option-row { display:flex; gap:0.5rem; margin-bottom:0.4rem; }
|
||||
.umfrage-option-row input { flex:1; }
|
||||
.umfrage-option-row button { width:auto; margin:0; padding:0.3rem 0.6rem; font-size:0.8rem; }
|
||||
.compose-footer { display:flex; justify-content:space-between; align-items:center; margin-top:0.75rem; flex-wrap:wrap; gap:0.5rem; }
|
||||
.multi-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
|
||||
.privacy-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
|
||||
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; transition:border-color 0.15s,color 0.15s; }
|
||||
.compose-action-btn:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
|
||||
label.compose-action-btn { display:inline-flex; align-items:center; }
|
||||
|
||||
/* ── Post Cards (1:1 wie Feed) ── */
|
||||
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; cursor:pointer; transition:border-color 0.15s; }
|
||||
.post-card:hover { border-color:var(--color-primary); }
|
||||
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
|
||||
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
|
||||
.post-avatar img { width:100%; height:100%; object-fit:cover; }
|
||||
.post-author { font-weight:600; font-size:0.9rem; }
|
||||
.post-meta { font-size:0.75rem; color:var(--color-muted); }
|
||||
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
|
||||
.post-bild { width:100%; max-height:400px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; }
|
||||
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; }
|
||||
.post-action-btn { background:none; border:none; color:var(--color-muted); font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; margin:0; width:auto; pointer-events:none; }
|
||||
.post-action-btn.active { color:var(--color-primary); }
|
||||
.gruppe-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; color:var(--color-muted); background:var(--color-secondary); border-radius:4px; padding:0.15rem 0.45rem; margin-left:0.3rem; }
|
||||
.umfrage-option-bar { margin:0.3rem 0; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; }
|
||||
.umfrage-option-bar.voted { border-color:var(--color-primary); }
|
||||
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); }
|
||||
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
|
||||
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
|
||||
|
||||
/* ── Neue Mitglieder ── */
|
||||
.new-members-strip {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.4rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-secondary) transparent;
|
||||
}
|
||||
.new-members-strip::-webkit-scrollbar { height: 4px; }
|
||||
.new-members-strip::-webkit-scrollbar-thumb { background: var(--color-secondary); border-radius: 2px; }
|
||||
.nm-card {
|
||||
flex: 0 0 160px;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.nm-card:hover { border-color: var(--color-primary); box-shadow: 0 4px 18px rgba(0,0,0,0.35); }
|
||||
.nm-card-img {
|
||||
width: 100%; aspect-ratio: 1; flex-shrink: 0;
|
||||
overflow: hidden; background: var(--color-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 2.5rem; position: relative;
|
||||
}
|
||||
.nm-card-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.nm-card-body { padding: 0.6rem 0.65rem; display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.nm-card-name { font-weight: 700; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nm-card-meta { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
.nm-card-chip {
|
||||
padding: 0.1rem 0.4rem; border-radius: 20px;
|
||||
background: var(--color-secondary); font-size: 0.7rem; color: var(--color-muted);
|
||||
}
|
||||
.nm-card-desc {
|
||||
font-size: 0.75rem; color: var(--color-muted); line-height: 1.35;
|
||||
overflow: hidden; display: -webkit-box;
|
||||
-webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
@@ -138,22 +277,62 @@
|
||||
<h1 style="margin:0 0 0.15rem;">Home</h1>
|
||||
<p class="welcome" id="greeting"></p>
|
||||
|
||||
<!-- Profilbesucher -->
|
||||
<div id="visitorsSection" style="display:none;">
|
||||
<div class="section-label">Profilbesucher 👀</div>
|
||||
<div class="visitors-strip" id="visitorsStrip"></div>
|
||||
<!-- Aktive Spiele -->
|
||||
<div id="activeGamesSection" style="display:none;">
|
||||
<div class="section-label">Aktive Spiele 🎮</div>
|
||||
<div class="active-game-list" id="activeGamesList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Wer hat mich geliked (Dating) -->
|
||||
<div id="likesSection" style="display:none;">
|
||||
<div class="section-label">Likes ❤️</div>
|
||||
<div class="dating-strip" id="likesStrip"></div>
|
||||
<!-- Aktiver Lock -->
|
||||
<div id="activeLockSection" style="display:none;">
|
||||
<div class="section-label">Aktiver Lock 🔒</div>
|
||||
<div class="active-game-list" id="activeLockList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Matches -->
|
||||
<div id="matchesSection" style="display:none;">
|
||||
<div class="section-label">Matches 💕</div>
|
||||
<div class="dating-strip" id="matchesStrip"></div>
|
||||
<!-- Einladungen -->
|
||||
<div id="invitesSection" style="display:none;">
|
||||
<div class="section-label">Einladungen 📨</div>
|
||||
<div class="invite-list" id="invitesList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Freundschaftsanfragen -->
|
||||
<div id="friendReqSection" style="display:none;">
|
||||
<div class="section-label">Freundschaftsanfragen 🤝</div>
|
||||
<div class="friend-req-strip" id="friendReqStrip"></div>
|
||||
</div>
|
||||
|
||||
<!-- Aktivitäts-Grid -->
|
||||
<div id="socialGridBlock" style="display:none;">
|
||||
<div class="section-label">Aktivität</div>
|
||||
<div class="activity-grid">
|
||||
<div class="activity-col" id="visitorsCol" style="display:none;">
|
||||
<div class="activity-col-header">
|
||||
<span class="activity-col-title">👀 Besucher</span>
|
||||
<a class="activity-col-link" href="/dating/besucher.html">Alle →</a>
|
||||
</div>
|
||||
<div class="activity-row" id="visitorsRow"></div>
|
||||
</div>
|
||||
<div class="activity-col" id="likesCol" style="display:none;">
|
||||
<div class="activity-col-header">
|
||||
<span class="activity-col-title">❤️ Likes</span>
|
||||
<a class="activity-col-link" href="/dating/likes.html">Alle →</a>
|
||||
</div>
|
||||
<div class="activity-row" id="likesRow"></div>
|
||||
</div>
|
||||
<div class="activity-col" id="matchesCol" style="display:none;">
|
||||
<div class="activity-col-header">
|
||||
<span class="activity-col-title">💕 Matches</span>
|
||||
<a class="activity-col-link" href="/dating/matches.html">Alle →</a>
|
||||
</div>
|
||||
<div class="activity-row" id="matchesRow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Neue Mitglieder -->
|
||||
<div id="newMembersSection" style="display:none;">
|
||||
<div class="section-label">Neue Mitglieder ✨</div>
|
||||
<div class="new-members-strip" id="newMembersStrip"></div>
|
||||
</div>
|
||||
|
||||
<!-- Meine angemeldeten Events -->
|
||||
@@ -167,9 +346,46 @@
|
||||
<div class="section-label">Nächste Veranstaltungen 📍</div>
|
||||
<div class="loc-event-list" id="locEventsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Feed Compose + Vorschau -->
|
||||
<div class="section-label">Feed 📰</div>
|
||||
<div class="post-compose" id="homeCompose">
|
||||
<div class="compose-type">
|
||||
<label><input type="radio" name="homeBeitragTyp" value="TEXT" checked onchange="homeToggleUmfrage()"> Text</label>
|
||||
<label><input type="radio" name="homeBeitragTyp" value="UMFRAGE" onchange="homeToggleUmfrage()"> Umfrage</label>
|
||||
</div>
|
||||
<textarea id="homeComposeText" placeholder="Was möchtest du teilen?" rows="3"></textarea>
|
||||
<div class="compose-thumbs" id="homeComposeThumbs"></div>
|
||||
<div class="umfrage-options" id="homeUmfrageOptions" style="display:none;">
|
||||
<div id="homeOptionList"></div>
|
||||
<button onclick="homeAddOption()" style="width:auto;margin:0;padding:0.3rem 0.75rem;font-size:0.8rem;margin-top:0.4rem;">+ Option</button>
|
||||
</div>
|
||||
<div class="compose-footer">
|
||||
<div style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;">
|
||||
<label class="multi-toggle" id="homeMultiChoiceRow" style="display:none;">
|
||||
<input type="checkbox" id="homeMultiChoice"> Multi-Choice
|
||||
</label>
|
||||
<label class="privacy-toggle">
|
||||
<input type="checkbox" id="homeIsPublic"> Öffentlich
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'homeComposeText')" title="Emoji einfügen">😊</button>
|
||||
<label class="compose-action-btn" title="Fotos hinzufügen">📷
|
||||
<input type="file" id="homeComposeBildFile" accept="image/*" multiple style="display:none;" onchange="homeSelectBilder(this)">
|
||||
</label>
|
||||
<button onclick="homeSubmitPost()" style="width:auto;margin:0;">Veröffentlichen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="feedSection" style="display:none;">
|
||||
<div id="feedList"></div>
|
||||
<a href="/community/feed.html"><button style="width:100%;margin-top:0.1rem;">Weiter zum Feed →</button></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/shared.js"></script>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
@@ -181,12 +397,18 @@
|
||||
.then(user => {
|
||||
if (user) {
|
||||
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
||||
loadActiveGames(user.userId);
|
||||
loadActiveLock();
|
||||
loadInvites();
|
||||
loadFriendRequests();
|
||||
loadVisitors();
|
||||
loadMyEvents();
|
||||
loadLocEvents();
|
||||
loadFeed();
|
||||
if (user.datingAktiv) {
|
||||
loadWhoLikesMe();
|
||||
loadMatches();
|
||||
loadNewDatingMembers();
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -200,6 +422,176 @@
|
||||
return 'vor ' + Math.floor(diff / 86400) + ' Tag' + (Math.floor(diff / 86400) === 1 ? '' : 'en');
|
||||
}
|
||||
|
||||
// ── Aktive Spiele ──────────────────────────────────────────────────────────
|
||||
|
||||
async function loadActiveGames(userId) {
|
||||
try {
|
||||
const items = [];
|
||||
const [vRes, bRes] = await Promise.all([
|
||||
fetch('/vanilla?userId=' + userId),
|
||||
fetch('/bdsm?userId=' + userId)
|
||||
]);
|
||||
if (vRes.ok) {
|
||||
const v = await vRes.json();
|
||||
items.push({
|
||||
icon: '🎭',
|
||||
title: 'Vanilla-Spiel',
|
||||
sub: 'Level ' + v.level + ' · gestartet ' + relativeTime(v.startZeit),
|
||||
href: '/games/vanilla/vanillaingame.html?sessionId=' + v.sessionId
|
||||
});
|
||||
}
|
||||
if (bRes.ok) {
|
||||
const b = await bRes.json();
|
||||
items.push({
|
||||
icon: '⛓',
|
||||
title: 'BDSM-Spiel',
|
||||
sub: 'Level ' + b.level + ' · gestartet ' + relativeTime(b.startZeit),
|
||||
href: '/games/bdsm/bdsmingame.html?sessionId=' + b.sessionId
|
||||
});
|
||||
}
|
||||
if (!items.length) return;
|
||||
const list = document.getElementById('activeGamesList');
|
||||
list.innerHTML = items.map(i => `
|
||||
<a class="active-game-card" href="${esc(i.href)}">
|
||||
<div class="active-game-icon">${i.icon}</div>
|
||||
<div class="active-game-body">
|
||||
<div class="active-game-title">${esc(i.title)}</div>
|
||||
<div class="active-game-sub">${esc(i.sub)}</div>
|
||||
</div>
|
||||
<span class="active-game-action">Weiterspielen →</span>
|
||||
</a>`).join('');
|
||||
document.getElementById('activeGamesSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Aktiver Lock ───────────────────────────────────────────────────────────
|
||||
|
||||
async function loadActiveLock() {
|
||||
try {
|
||||
const [cardRes, timeRes] = await Promise.all([
|
||||
fetch('/keyholder/mylock'),
|
||||
fetch('/keyholder/timelock/mylock')
|
||||
]);
|
||||
const items = [];
|
||||
if (cardRes.status === 200) {
|
||||
const d = await cardRes.json();
|
||||
items.push({ lockId: d.lockId, page: 'activelock' });
|
||||
}
|
||||
if (timeRes.status === 200) {
|
||||
const d = await timeRes.json();
|
||||
items.push({ lockId: d.lockId, page: 'activetimelock' });
|
||||
}
|
||||
if (!items.length) return;
|
||||
const list = document.getElementById('activeLockList');
|
||||
list.innerHTML = items.map(i => `
|
||||
<a class="active-game-card" href="/games/chastity/${i.page}.html?lockId=${esc(i.lockId)}">
|
||||
<div class="active-game-icon">🔒</div>
|
||||
<div class="active-game-body">
|
||||
<div class="active-game-title">Keuschheitslock aktiv</div>
|
||||
<div class="active-game-sub">${i.page === 'activetimelock' ? 'TimeLock' : 'CardLock'} · Tippen für Details</div>
|
||||
</div>
|
||||
<span class="active-game-action">Zum Lock →</span>
|
||||
</a>`).join('');
|
||||
document.getElementById('activeLockSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Einladungen ────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadInvites() {
|
||||
try {
|
||||
const [vRes, bRes, cRes] = await Promise.all([
|
||||
fetch('/vanilla/einladung/pending'),
|
||||
fetch('/bdsm/einladung/pending'),
|
||||
fetch('/lockee/invitations/mine')
|
||||
]);
|
||||
const items = [];
|
||||
|
||||
if (vRes.ok) {
|
||||
const list = await vRes.json();
|
||||
list.forEach(e => items.push({
|
||||
avatar: e.inviterAvatar,
|
||||
from: e.inviterName || 'Jemand',
|
||||
type: 'Vanilla-Spieleinladung',
|
||||
href: '/games/common/einladungen.html'
|
||||
}));
|
||||
}
|
||||
if (bRes.ok) {
|
||||
const list = await bRes.json();
|
||||
list.forEach(e => items.push({
|
||||
avatar: e.inviterAvatar,
|
||||
from: e.inviterName || 'Jemand',
|
||||
type: 'BDSM-Spieleinladung',
|
||||
href: '/games/common/einladungen.html'
|
||||
}));
|
||||
}
|
||||
if (cRes.ok) {
|
||||
const list = await cRes.json();
|
||||
list.forEach(e => items.push({
|
||||
avatar: e.keyholderProfilePic,
|
||||
from: e.keyholderName || 'Jemand',
|
||||
type: 'Keuschheitslock-Einladung: ' + esc(e.lockName),
|
||||
href: '/games/chastity/joinlock.html?token=' + esc(e.token)
|
||||
}));
|
||||
}
|
||||
|
||||
if (!items.length) return;
|
||||
const container = document.getElementById('invitesList');
|
||||
container.innerHTML = items.map(i => `
|
||||
<a class="invite-card" href="${i.href}">
|
||||
<div class="invite-avatar">
|
||||
${i.avatar
|
||||
? `<img src="data:image/png;base64,${i.avatar}" alt="">`
|
||||
: '◉'}
|
||||
</div>
|
||||
<div class="invite-body">
|
||||
<div class="invite-from">${esc(i.from)}</div>
|
||||
<div class="invite-type">${i.type}</div>
|
||||
</div>
|
||||
<span class="invite-action">Ansehen →</span>
|
||||
</a>`).join('');
|
||||
document.getElementById('invitesSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Freundschaftsanfragen ──────────────────────────────────────────────────
|
||||
|
||||
async function loadFriendRequests() {
|
||||
try {
|
||||
const res = await fetch('/social/friends/pending');
|
||||
if (!res.ok) return;
|
||||
const requests = await res.json();
|
||||
if (!requests.length) return;
|
||||
|
||||
const strip = document.getElementById('friendReqStrip');
|
||||
strip.innerHTML = requests.map(r => {
|
||||
const u = r.userProfile;
|
||||
return `
|
||||
<a class="friend-req-card" href="/community/freunde.html">
|
||||
<div class="friend-req-avatar">
|
||||
${u.profilePicture
|
||||
? `<img src="data:image/png;base64,${u.profilePicture}" alt="${esc(u.name)}">`
|
||||
: '◉'}
|
||||
</div>
|
||||
<span class="visitor-name">${esc(u.name)}</span>
|
||||
<span class="friend-req-badge">+ Anfrage</span>
|
||||
</a>`;
|
||||
}).join('');
|
||||
document.getElementById('friendReqSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Aktivitäts-Grid ───────────────────────────────────────────────────────
|
||||
|
||||
function socAvatarHtml(pic, blurred = false) {
|
||||
if (!pic) return '◉';
|
||||
return `<img src="data:image/png;base64,${pic}" alt=""${blurred ? ' style="filter:blur(5px);transform:scale(1.1)"' : ''}>`;
|
||||
}
|
||||
|
||||
function showSocialGrid() {
|
||||
document.getElementById('socialGridBlock').style.display = '';
|
||||
}
|
||||
|
||||
async function loadWhoLikesMe() {
|
||||
try {
|
||||
const res = await fetch('/dating/who-likes-me');
|
||||
@@ -207,31 +599,24 @@
|
||||
const data = await res.json();
|
||||
if (data.total === 0) return;
|
||||
|
||||
const strip = document.getElementById('likesStrip');
|
||||
strip.innerHTML = data.likers.map(l => {
|
||||
const pic = l.profilePicture
|
||||
? `<img src="data:image/png;base64,${l.profilePicture}" alt="">`
|
||||
: '◉';
|
||||
|
||||
document.getElementById('likesRow').innerHTML = data.likers.slice(0, 4).map(l => {
|
||||
if (data.premium && l.userId) {
|
||||
return `
|
||||
<a class="dating-card" href="/community/benutzer.html?userId=${l.userId}">
|
||||
<div class="dating-avatar">${pic}</div>
|
||||
<span class="dating-name">${esc(l.name)}</span>
|
||||
</a>`;
|
||||
} else {
|
||||
return `
|
||||
<div class="dating-card-locked">
|
||||
<div class="dating-avatar-blurred">
|
||||
${pic}
|
||||
<span class="lock-icon">🔒</span>
|
||||
</div>
|
||||
<span class="premium-hint">Premium</span>
|
||||
</div>`;
|
||||
return `<a class="soc-card" href="/community/benutzer.html?userId=${l.userId}">
|
||||
<div class="soc-avatar">${socAvatarHtml(l.profilePicture)}</div>
|
||||
<span class="soc-name">${esc(l.name)}</span>
|
||||
</a>`;
|
||||
}
|
||||
return `<div class="soc-card">
|
||||
<div class="soc-avatar">
|
||||
${socAvatarHtml(l.profilePicture, true)}
|
||||
<span class="soc-lock">🔒</span>
|
||||
</div>
|
||||
<span class="soc-sub-accent">Premium</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('likesSection').style.display = '';
|
||||
document.getElementById('likesCol').style.display = '';
|
||||
showSocialGrid();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -242,27 +627,237 @@
|
||||
const matches = await res.json();
|
||||
if (!matches.length) return;
|
||||
|
||||
const strip = document.getElementById('matchesStrip');
|
||||
strip.innerHTML = matches.map(m => `
|
||||
<a class="dating-card" href="/community/benutzer.html?userId=${m.userId}">
|
||||
<div class="dating-avatar">
|
||||
${m.profilePicture
|
||||
? `<img src="data:image/png;base64,${m.profilePicture}" alt="${esc(m.name)}">`
|
||||
: '◉'}
|
||||
</div>
|
||||
<span class="dating-name">${esc(m.name)}</span>
|
||||
<span class="match-badge">♥ Match</span>
|
||||
</a>
|
||||
`).join('');
|
||||
document.getElementById('matchesSection').style.display = '';
|
||||
document.getElementById('matchesRow').innerHTML = matches.slice(0, 4).map(m => `
|
||||
<a class="soc-card" href="/community/benutzer.html?userId=${m.userId}">
|
||||
<div class="soc-avatar">${socAvatarHtml(m.profilePicture)}</div>
|
||||
<span class="soc-name">${esc(m.name)}</span>
|
||||
<span class="soc-sub-accent">♥</span>
|
||||
</a>`).join('');
|
||||
|
||||
document.getElementById('matchesCol').style.display = '';
|
||||
showSocialGrid();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
// ── Neue Mitglieder ───────────────────────────────────────────────────────
|
||||
|
||||
async function loadNewDatingMembers() {
|
||||
try {
|
||||
const res = await fetch('/user/new-members');
|
||||
if (!res.ok) return;
|
||||
const members = await res.json();
|
||||
if (!members.length) return;
|
||||
|
||||
const strip = document.getElementById('newMembersStrip');
|
||||
strip.innerHTML = members.map(p => {
|
||||
const img = p.profilePicture
|
||||
? `<img src="data:image/png;base64,${p.profilePicture}" alt="${esc(p.name)}" loading="lazy">`
|
||||
: `<span>👤</span>`;
|
||||
const chips = [
|
||||
p.alter ? `<span class="nm-card-chip">${p.alter} J.</span>` : '',
|
||||
p.geschlecht ? `<span class="nm-card-chip">${esc(p.geschlecht)}</span>` : '',
|
||||
p.neigung ? `<span class="nm-card-chip">${esc(p.neigung)}</span>` : '',
|
||||
p.datingStadt ? `<span class="nm-card-chip">${esc(p.datingStadt)}</span>` : '',
|
||||
].filter(Boolean).join('');
|
||||
const desc = p.beschreibung
|
||||
? `<div class="nm-card-desc">${esc(p.beschreibung)}</div>` : '';
|
||||
return `<a class="nm-card" href="/community/benutzer.html?userId=${p.userId}">
|
||||
<div class="nm-card-img">${img}</div>
|
||||
<div class="nm-card-body">
|
||||
<div class="nm-card-name">${esc(p.name)}</div>
|
||||
<div class="nm-card-meta">${chips}</div>
|
||||
${desc}
|
||||
</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
document.getElementById('newMembersSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Feed Compose ──────────────────────────────────────────────────────────
|
||||
|
||||
let homeComposeBilder = [];
|
||||
|
||||
function homeToggleUmfrage() {
|
||||
const isUmfrage = document.querySelector('input[name="homeBeitragTyp"]:checked').value === 'UMFRAGE';
|
||||
document.getElementById('homeUmfrageOptions').style.display = isUmfrage ? '' : 'none';
|
||||
document.getElementById('homeMultiChoiceRow').style.display = isUmfrage ? '' : 'none';
|
||||
if (isUmfrage && document.getElementById('homeOptionList').children.length === 0) {
|
||||
homeAddOption(); homeAddOption();
|
||||
}
|
||||
}
|
||||
|
||||
function homeAddOption() {
|
||||
const list = document.getElementById('homeOptionList');
|
||||
const idx = list.children.length;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'umfrage-option-row';
|
||||
row.innerHTML = `<input type="text" placeholder="Option ${idx + 1}" maxlength="100">
|
||||
<button onclick="this.parentElement.remove()">✕</button>`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
function homeSelectBilder(input) {
|
||||
[...input.files].forEach(f => { if (f.type.startsWith('image/')) homeProcessImage(f); });
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function homeProcessImage(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const maxSize = 1024;
|
||||
const canvas = document.createElement('canvas');
|
||||
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
|
||||
canvas.width = Math.round(img.width * scale);
|
||||
canvas.height = Math.round(img.height * scale);
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
homeComposeBilder.push(canvas.toDataURL('image/jpeg', 0.85).split(',')[1]);
|
||||
homeRenderThumbs();
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function homeRenderThumbs() {
|
||||
const container = document.getElementById('homeComposeThumbs');
|
||||
container.innerHTML = '';
|
||||
homeComposeBilder.forEach((b, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'compose-thumb';
|
||||
div.innerHTML = `<img src="data:image/jpeg;base64,${b}" alt="">
|
||||
<button class="compose-thumb-remove" onclick="homeRemoveThumb(${i})">✕</button>`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
container.style.display = homeComposeBilder.length > 0 ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function homeRemoveThumb(idx) {
|
||||
homeComposeBilder.splice(idx, 1);
|
||||
homeRenderThumbs();
|
||||
}
|
||||
|
||||
async function homeSubmitPost() {
|
||||
const text = document.getElementById('homeComposeText').value.trim();
|
||||
if (!text && homeComposeBilder.length === 0) return;
|
||||
const beitragTyp = document.querySelector('input[name="homeBeitragTyp"]:checked').value;
|
||||
const multiChoice = document.getElementById('homeMultiChoice').checked;
|
||||
const isPublic = document.getElementById('homeIsPublic').checked;
|
||||
|
||||
let optionen = [];
|
||||
if (beitragTyp === 'UMFRAGE') {
|
||||
optionen = Array.from(document.getElementById('homeOptionList').querySelectorAll('input'))
|
||||
.map(i => i.value.trim()).filter(v => v);
|
||||
if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; }
|
||||
}
|
||||
|
||||
const res = await fetch('/feed/posts', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ beitragTyp, text, multiChoice, optionen, bilder: [...homeComposeBilder], isPublic })
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const post = await res.json();
|
||||
|
||||
// Reset
|
||||
document.getElementById('homeComposeText').value = '';
|
||||
homeComposeBilder = [];
|
||||
homeRenderThumbs();
|
||||
document.querySelector('input[name="homeBeitragTyp"][value="TEXT"]').checked = true;
|
||||
homeToggleUmfrage();
|
||||
document.getElementById('homeMultiChoice').checked = false;
|
||||
document.getElementById('homeIsPublic').checked = false;
|
||||
document.getElementById('homeOptionList').innerHTML = '';
|
||||
|
||||
// Prepend in Vorschau
|
||||
const feedList = document.getElementById('feedList');
|
||||
feedList.insertAdjacentHTML('afterbegin', renderHomePostCard(post));
|
||||
document.getElementById('feedSection').style.display = '';
|
||||
}
|
||||
|
||||
// Drag & Drop
|
||||
const homeCompose = document.getElementById('homeCompose');
|
||||
if (homeCompose) {
|
||||
homeCompose.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
if ([...e.dataTransfer.items].some(i => i.type.startsWith('image/')))
|
||||
homeCompose.classList.add('drag-over');
|
||||
});
|
||||
homeCompose.addEventListener('dragleave', e => {
|
||||
if (!homeCompose.contains(e.relatedTarget)) homeCompose.classList.remove('drag-over');
|
||||
});
|
||||
homeCompose.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
homeCompose.classList.remove('drag-over');
|
||||
[...e.dataTransfer.files].filter(f => f.type.startsWith('image/')).forEach(homeProcessImage);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Feed-Vorschau ──────────────────────────────────────────────────────────
|
||||
|
||||
const homePostCache = {};
|
||||
|
||||
function homeOpenPost(postId) {
|
||||
const p = homePostCache[postId];
|
||||
if (p) sessionStorage.setItem('feedOpenPost', JSON.stringify(p));
|
||||
window.location.href = '/community/feed.html';
|
||||
}
|
||||
|
||||
function renderHomePostCard(p) {
|
||||
homePostCache[p.postId] = p;
|
||||
const avatarHtml = p.authorPicture
|
||||
? `<img src="data:image/png;base64,${p.authorPicture}" alt="">`
|
||||
: '◉';
|
||||
const privacyLabel = !p.isPublic ? ' <span style="font-size:0.7rem;color:var(--color-muted);">🔒</span>' : '';
|
||||
const groupBadge = p.postType === 'GROUP' && p.gruppeId
|
||||
? `<span class="gruppe-badge">👥 ${esc(p.gruppeName)}</span>`
|
||||
: '';
|
||||
const bildHtml = bilderCarousel(p.bilder);
|
||||
let umfrageHtml = '';
|
||||
if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) {
|
||||
const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
|
||||
umfrageHtml = '<div style="margin-top:0.5rem;">' + p.optionen.map(o => {
|
||||
const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
|
||||
const voted = p.myVoteOptionIds && p.myVoteOptionIds.includes(o.optionId);
|
||||
return `<div class="umfrage-option-bar${voted ? ' voted' : ''}">
|
||||
<div class="umfrage-bar-fill" style="width:${pct}%"></div>
|
||||
<div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div>
|
||||
</div>`;
|
||||
}).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`;
|
||||
}
|
||||
return `<div class="post-card" onclick="homeOpenPost('${p.postId}')" style="cursor:pointer">
|
||||
<div class="post-header">
|
||||
<div class="post-avatar">${avatarHtml}</div>
|
||||
<div>
|
||||
<div class="post-author">${esc(p.authorName)}${privacyLabel}</div>
|
||||
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-text">${esc(p.text || '')}</div>
|
||||
${bildHtml}
|
||||
${umfrageHtml}
|
||||
<div class="post-actions">
|
||||
<button class="post-action-btn${p.likedByMe ? ' active' : ''}">♥ <span>${p.likeCount}</span></button>
|
||||
<button class="post-action-btn">💬 <span>${p.kommentarCount}</span></button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function loadFeed() {
|
||||
try {
|
||||
const res = await fetch('/feed/mine?size=3&page=0');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const posts = data.posts;
|
||||
if (!posts || !posts.length) return;
|
||||
document.getElementById('feedList').innerHTML = posts.map(renderHomePostCard).join('');
|
||||
document.getElementById('feedSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderEventCards(events, listId, sectionId) {
|
||||
if (!events.length) return;
|
||||
const list = document.getElementById(listId);
|
||||
@@ -304,6 +899,8 @@
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Profilbesucher ────────────────────────────────────────────────────────
|
||||
|
||||
async function loadVisitors() {
|
||||
try {
|
||||
const res = await fetch('/social/profile-visits/my-visitors');
|
||||
@@ -311,19 +908,15 @@
|
||||
const visitors = await res.json();
|
||||
if (!visitors.length) return;
|
||||
|
||||
const strip = document.getElementById('visitorsStrip');
|
||||
strip.innerHTML = visitors.map(v => `
|
||||
<a class="visitor-card" href="/community/benutzer.html?userId=${v.userId}">
|
||||
<div class="visitor-avatar">
|
||||
${v.profilePicture
|
||||
? `<img src="data:image/png;base64,${v.profilePicture}" alt="${v.name}">`
|
||||
: '◉'}
|
||||
</div>
|
||||
<span class="visitor-name">${v.name}</span>
|
||||
<span class="visitor-time">${relativeTime(v.visitedAt)}</span>
|
||||
</a>
|
||||
`).join('');
|
||||
document.getElementById('visitorsSection').style.display = '';
|
||||
document.getElementById('visitorsRow').innerHTML = visitors.slice(0, 4).map(v => `
|
||||
<a class="soc-card" href="/community/benutzer.html?userId=${v.userId}">
|
||||
<div class="soc-avatar">${socAvatarHtml(v.profilePicture)}</div>
|
||||
<span class="soc-name">${esc(v.name)}</span>
|
||||
<span class="soc-sub">${relativeTime(v.visitedAt)}</span>
|
||||
</a>`).join('');
|
||||
|
||||
document.getElementById('visitorsCol').style.display = '';
|
||||
showSocialGrid();
|
||||
} catch (_) {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,9 +5,11 @@ services:
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_DATABASE: xxx_sphere
|
||||
MYSQL_ROOT_PASSWORD: xxxsphere123!
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
ports:
|
||||
- "3306:3306" # <--- Jetzt steht es korrekt alleine!
|
||||
- "127.0.0.1:3306:3306"
|
||||
volumes:
|
||||
# Format: [Pfad auf dem Proxmox-Host]:[Pfad im Container]
|
||||
- /mnt/pve_nas/.mysql_data:/var/lib/mysql
|
||||
@@ -22,9 +24,11 @@ services:
|
||||
environment:
|
||||
# Wir biegen localhost auf den Service-Namen 'db' um
|
||||
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/xxx_sphere?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||
# Hier injizieren wir die Werte für deine Platzhalter
|
||||
- DB_USER=root
|
||||
- DB_PASSWORD=xxxsphere123!
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- MAIL_USERNAME=${MAIL_USERNAME}
|
||||
- MAIL_PASSWORD=${MAIL_PASSWORD}
|
||||
- JWT_KEYSTORE_PASSWORD=${JWT_KEYSTORE_PASSWORD}
|
||||
# Wartet kurz, bis die DB wirklich bereit ist (optional, aber empfohlen)
|
||||
restart: on-failure
|
||||
|
||||
|
||||
27
src/main/java/de/oaa/xxx/config/CookieFactory.java
Normal file
27
src/main/java/de/oaa/xxx/config/CookieFactory.java
Normal file
@@ -0,0 +1,27 @@
|
||||
package de.oaa.xxx.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Component
|
||||
public class CookieFactory {
|
||||
|
||||
private final boolean secure;
|
||||
|
||||
public CookieFactory(@Value("${app.cookie.secure:true}") boolean secure) {
|
||||
this.secure = secure;
|
||||
}
|
||||
|
||||
public ResponseCookie jwtCookie(String token, Duration maxAge) {
|
||||
return ResponseCookie.from("jwt", token)
|
||||
.httpOnly(true)
|
||||
.secure(secure)
|
||||
.sameSite("Strict")
|
||||
.path("/")
|
||||
.maxAge(maxAge)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,11 @@ import java.util.Collections;
|
||||
public class JwtFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final TokenBlacklistService tokenBlacklist;
|
||||
|
||||
public JwtFilter(JwtService jwtService) {
|
||||
public JwtFilter(JwtService jwtService, TokenBlacklistService tokenBlacklist) {
|
||||
this.jwtService = jwtService;
|
||||
this.tokenBlacklist = tokenBlacklist;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -32,10 +34,13 @@ public class JwtFilter extends OncePerRequestFilter {
|
||||
if ("jwt".equals(cookie.getName())) {
|
||||
try {
|
||||
Claims claims = jwtService.validateAndGetClaims(cookie.getValue());
|
||||
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
|
||||
claims.getSubject(), null, Collections.emptyList()
|
||||
);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
String jti = claims.getId();
|
||||
if (jti == null || !tokenBlacklist.isBlacklisted(jti)) {
|
||||
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
|
||||
claims.getSubject(), null, Collections.emptyList()
|
||||
);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Ungültiger oder abgelaufener Token – ohne Authentifizierung weiter
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.security.KeyStore;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class JwtService {
|
||||
@@ -33,12 +34,17 @@ public class JwtService {
|
||||
return Jwts.builder()
|
||||
.subject(email)
|
||||
.claim("name", name)
|
||||
.id(UUID.randomUUID().toString())
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
|
||||
.signWith(privateKey)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public long getExpirationMs() {
|
||||
return EXPIRATION_MS;
|
||||
}
|
||||
|
||||
public Claims validateAndGetClaims(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
|
||||
70
src/main/java/de/oaa/xxx/config/RateLimitFilter.java
Normal file
70
src/main/java/de/oaa/xxx/config/RateLimitFilter.java
Normal file
@@ -0,0 +1,70 @@
|
||||
package de.oaa.xxx.config;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Component
|
||||
public class RateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final int MAX_REQUESTS = 10;
|
||||
private static final long WINDOW_MS = 60_000;
|
||||
|
||||
private static final String[] RATE_LIMITED_PATHS = {
|
||||
"/login", "/registration", "/password-reset"
|
||||
};
|
||||
|
||||
private record Window(AtomicInteger count, long startMs) {}
|
||||
private final Map<String, Window> windows = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
String path = request.getRequestURI();
|
||||
boolean isRateLimited = false;
|
||||
for (String p : RATE_LIMITED_PATHS) {
|
||||
if (path.equals(p) || path.startsWith(p + "/")) {
|
||||
isRateLimited = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRateLimited) {
|
||||
String ip = getClientIp(request);
|
||||
String key = ip + ":" + path;
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
Window window = windows.compute(key, (k, w) -> {
|
||||
if (w == null || now - w.startMs() > WINDOW_MS) {
|
||||
return new Window(new AtomicInteger(1), now);
|
||||
}
|
||||
w.count().incrementAndGet();
|
||||
return w;
|
||||
});
|
||||
|
||||
if (window.count().get() > MAX_REQUESTS) {
|
||||
response.setStatus(429);
|
||||
response.getWriter().write("Too many requests");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (xff != null && !xff.isBlank()) {
|
||||
return xff.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,10 @@ public class SecurityConfig {
|
||||
.requestMatchers("/games/chastity/joinlock.html").authenticated()
|
||||
.requestMatchers("/community/benachrichtigungen.html").authenticated()
|
||||
.requestMatchers("/community/abonnements.html").authenticated()
|
||||
.requestMatchers("/dating/dating.html").authenticated()
|
||||
.requestMatchers("/dating/besucher.html").authenticated()
|
||||
.requestMatchers("/dating/likes.html").authenticated()
|
||||
.requestMatchers("/dating/matches.html").authenticated()
|
||||
.requestMatchers("/community/locations.html").authenticated()
|
||||
.requestMatchers("/community/location-detail.html").authenticated()
|
||||
.requestMatchers("/community/events.html").authenticated()
|
||||
@@ -80,7 +84,6 @@ public class SecurityConfig {
|
||||
.requestMatchers("/notifications/**").authenticated()
|
||||
.requestMatchers("/events/**").authenticated()
|
||||
.requestMatchers("/*.html").permitAll()
|
||||
.requestMatchers("/**/*.html").permitAll()
|
||||
.requestMatchers("/help/*.html").permitAll()
|
||||
.requestMatchers("/css/**").permitAll()
|
||||
.requestMatchers("/js/**").permitAll()
|
||||
|
||||
34
src/main/java/de/oaa/xxx/config/TokenBlacklistService.java
Normal file
34
src/main/java/de/oaa/xxx/config/TokenBlacklistService.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package de.oaa.xxx.config;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
public class TokenBlacklistService {
|
||||
|
||||
// jti -> Ablaufzeit in ms
|
||||
private final Map<String, Long> blacklist = new ConcurrentHashMap<>();
|
||||
|
||||
public void blacklist(String jti, long expiryMs) {
|
||||
blacklist.put(jti, expiryMs);
|
||||
}
|
||||
|
||||
public boolean isBlacklisted(String jti) {
|
||||
Long expiry = blacklist.get(jti);
|
||||
if (expiry == null) return false;
|
||||
if (System.currentTimeMillis() > expiry) {
|
||||
blacklist.remove(jti);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 3_600_000)
|
||||
public void cleanup() {
|
||||
long now = System.currentTimeMillis();
|
||||
blacklist.entrySet().removeIf(e -> now > e.getValue());
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
package de.oaa.xxx.emailchange;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import de.oaa.xxx.config.CookieFactory;
|
||||
import de.oaa.xxx.mail.Email;
|
||||
import de.oaa.xxx.mail.MailService;
|
||||
import de.oaa.xxx.mail.MailTemplateService;
|
||||
import de.oaa.xxx.registration.RegistrationRepository;
|
||||
import de.oaa.xxx.user.UserRepository;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/email-change")
|
||||
@@ -34,17 +40,20 @@ public class EmailChangeController {
|
||||
private final RegistrationRepository registrationRepository;
|
||||
private final MailService mailService;
|
||||
private final MailTemplateService mailTemplateService;
|
||||
private final CookieFactory cookieFactory;
|
||||
|
||||
public EmailChangeController(EmailChangeRepository emailChangeRepository,
|
||||
UserRepository userRepository,
|
||||
RegistrationRepository registrationRepository,
|
||||
MailService mailService,
|
||||
MailTemplateService mailTemplateService) {
|
||||
MailTemplateService mailTemplateService,
|
||||
CookieFactory cookieFactory) {
|
||||
this.emailChangeRepository = emailChangeRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.registrationRepository = registrationRepository;
|
||||
this.mailService = mailService;
|
||||
this.mailTemplateService = mailTemplateService;
|
||||
this.cookieFactory = cookieFactory;
|
||||
}
|
||||
|
||||
record EmailChangeRequest(String newEmail) {}
|
||||
@@ -113,13 +122,7 @@ public class EmailChangeController {
|
||||
emailChangeRepository.delete(entity.get());
|
||||
|
||||
// Clear JWT cookie so user must log in with new email
|
||||
ResponseCookie cookie = ResponseCookie.from("jwt", "")
|
||||
.httpOnly(true)
|
||||
.sameSite("Strict")
|
||||
.path("/")
|
||||
.maxAge(0)
|
||||
.build();
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.jwtCookie("", java.time.Duration.ZERO).toString());
|
||||
response.sendRedirect("/login.html?emailChanged=1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +264,8 @@ public class CardLockController {
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("lockId", lock.getLockId().toString(), "unlockCode", lock.getUnlockCode(),
|
||||
return ResponseEntity.ok(Map.of("lockId", lock.getLockId().toString(),
|
||||
"unlockCode", lock.getUnlockCode() != null ? lock.getUnlockCode() : "",
|
||||
"keyholderPending", keyholderPending));
|
||||
}
|
||||
|
||||
@@ -408,6 +409,26 @@ public class CardLockController {
|
||||
return ResponseEntity.ok(Map.of("lockId", activeLockId.get().toString()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/mylock")
|
||||
@Transactional
|
||||
public ResponseEntity<Void> deleteMyActiveLock(Principal principal) {
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
var lockOpt = cardlockRepository.findByLockee(myId).stream()
|
||||
.filter(l -> l.getStartTime() != null && l.getUnlockTime() == null)
|
||||
.findFirst();
|
||||
if (lockOpt.isEmpty())
|
||||
return ResponseEntity.noContent().build();
|
||||
var l = lockOpt.get();
|
||||
CardLockService service = cardLockServiceFactory.create(l);
|
||||
service.unlock(l.getUnlockCode());
|
||||
var verifications = verificationRepository.findByLockId(l.getLockId());
|
||||
verifications.forEach(v -> verificationVoteRepository.deleteAllByVerificationId(v.getDisplayId()));
|
||||
verificationRepository.deleteAll(verifications);
|
||||
invitationRepository.deleteByLockId(l.getLockId());
|
||||
cardlockRepository.deleteById(l.getLockId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/cardlock/{lockId}")
|
||||
public ResponseEntity<Map<String, Object>> getLock(@PathVariable("lockId") UUID lockId, Principal principal) {
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
|
||||
@@ -189,6 +189,14 @@ public class TimeLockController {
|
||||
|
||||
// ── State abrufen ────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/timelock/mylock")
|
||||
public ResponseEntity<Map<String, Object>> getMyActiveTimeLock(Principal principal) {
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
return timeLockRepository.findFirstByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(myId)
|
||||
.map(l -> ResponseEntity.ok(Map.<String, Object>of("lockId", l.getLockId().toString())))
|
||||
.orElse(ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@GetMapping("/timelock/{lockId}")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> getTimeLock(@PathVariable("lockId") UUID lockId, Principal principal) {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package de.oaa.xxx.games.chastity.ttlock;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import de.oaa.xxx.games.chastity.ttlock.TTLockService.TTLockDetailResponse;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/ttlock")
|
||||
public class TTLockTest {
|
||||
|
||||
private final TTAuthService auth;
|
||||
private final TTLockService lock;
|
||||
|
||||
private String clientId = "6e5077a84b6a4e1ba0fb6a8da21c6417";
|
||||
private String clientSecret = "a2c1d68c7905d52584fc29028937db11";
|
||||
private String username= "mario.stoermer@proton.me";
|
||||
private String password = "knall666.Halla";
|
||||
private int lockId = 30158446;
|
||||
|
||||
public TTLockTest(TTAuthService auth, TTLockService lock) {
|
||||
this.auth = auth;
|
||||
this.lock = lock;
|
||||
}
|
||||
|
||||
@GetMapping("/details")
|
||||
public ResponseEntity<TTLockDetailResponse> details() {
|
||||
String md5Hex = org.apache.commons.codec.digest.DigestUtils.md5Hex(password).toLowerCase();
|
||||
String token = auth.getAccessToken(clientId, clientSecret, username, md5Hex);
|
||||
return ResponseEntity.ok(lock.getLockDetail(clientId, token, lockId));
|
||||
}
|
||||
|
||||
@GetMapping("/add/{pin}")
|
||||
public ResponseEntity<Integer> add(@PathVariable String pin) {
|
||||
String md5Hex = org.apache.commons.codec.digest.DigestUtils.md5Hex(password).toLowerCase();
|
||||
String token = auth.getAccessToken(clientId, clientSecret, username, md5Hex);
|
||||
return ResponseEntity.ok(lock.addCustomPasscode(clientId, token, lockId, pin));
|
||||
}
|
||||
|
||||
@GetMapping("/delete/{id}")
|
||||
public ResponseEntity<String> remove(@PathVariable Integer id) {
|
||||
String md5Hex = org.apache.commons.codec.digest.DigestUtils.md5Hex(password).toLowerCase();
|
||||
String token = auth.getAccessToken(clientId, clientSecret, username, md5Hex);
|
||||
return ResponseEntity.ok(lock.deleteCustomPasscode(clientId, token, lockId, id));
|
||||
}
|
||||
|
||||
@GetMapping("/delete/all")
|
||||
public void removeAll() {
|
||||
String md5Hex = org.apache.commons.codec.digest.DigestUtils.md5Hex(password).toLowerCase();
|
||||
String token = auth.getAccessToken(clientId, clientSecret, username, md5Hex);
|
||||
lock.findAndDeleteLocksByName(clientId, token, lockId);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
1-unlock by app
|
||||
|
||||
4-unlock by passcode
|
||||
|
||||
5-Rise the lock (for parking lock)
|
||||
|
||||
6-Lower the lock (for parking lock)
|
||||
|
||||
7-unlock by IC card
|
||||
|
||||
8-unlock by fingerprint
|
||||
|
||||
9-unlock by wrist strap
|
||||
|
||||
10-unlock by Mechanical key
|
||||
|
||||
11-lock by app
|
||||
|
||||
12-unlock by gateway
|
||||
|
||||
29-apply some force on the Lock
|
||||
|
||||
30-Door sensor closed
|
||||
|
||||
31-Door sensor open
|
||||
|
||||
32-open from inside
|
||||
|
||||
33-lock by fingerprint
|
||||
|
||||
34-lock by passcode
|
||||
|
||||
35-lock by IC card
|
||||
|
||||
36-lock by Mechanical key
|
||||
|
||||
37-Use APP button to control the lock (rise, fall, stop, lock), mostly used for roller shutter door
|
||||
|
||||
42-received new local mail
|
||||
|
||||
43-received new other cities' mail
|
||||
|
||||
44-Tamper alert
|
||||
|
||||
45-Auto Lock
|
||||
|
||||
46-unlock by unlock key
|
||||
|
||||
47-lock by lock key
|
||||
|
||||
48-System locked ( Caused by, for example: Using INVALID Passcode/Fingerprint/Card several times)
|
||||
|
||||
49-unlock by hotel card
|
||||
|
||||
50-Unlocked due to the high temperature
|
||||
|
||||
51-Try to unlock with a deleted card
|
||||
|
||||
52-Dead lock with APP
|
||||
|
||||
53-Dead lock with passcode
|
||||
|
||||
54-The car left (for parking lock)
|
||||
|
||||
55-Use remote control lock or unlock lock
|
||||
|
||||
57-Unlock with QR code success
|
||||
|
||||
58-Unlock with QR code failed, it's expired
|
||||
|
||||
59-Double locked
|
||||
|
||||
60-Cancel double lock
|
||||
|
||||
61-Lock with QR code success
|
||||
|
||||
62-Lock with QR code failed, the lock is double locked
|
||||
|
||||
63-Auto unlock at passage mode
|
||||
|
||||
64-Door unclosed alarm
|
||||
|
||||
65-Failed to unlock
|
||||
|
||||
66-Failed to lock
|
||||
|
||||
67-Face unlock success
|
||||
|
||||
68-Face unlock failed - door locked from inside
|
||||
|
||||
69-Lock with face
|
||||
|
||||
71-Face unlock failed - expired or ineffective
|
||||
|
||||
75-Unlocked by App granting
|
||||
|
||||
76-Unlocked by remote granting
|
||||
|
||||
77-Dual authentication Bluetooth unlock verification success, waiting for second user
|
||||
|
||||
78-Dual authentication password unlock verification success, waiting for second user
|
||||
|
||||
79-Dual authentication fingerprint unlock verification success, waiting for second user
|
||||
|
||||
80-Dual authentication IC card unlock verification success, waiting for second user
|
||||
|
||||
81-Dual authentication face card unlock verification success, waiting for second user
|
||||
|
||||
82-Dual authentication wireless key unlock verification success, waiting for second user
|
||||
|
||||
83-Dual authentication palm vein unlock verification success, waiting for second user
|
||||
|
||||
84-Palm vein unlock success
|
||||
|
||||
85-Palm vein unlock success
|
||||
|
||||
86-Lock with palm vein
|
||||
|
||||
88-Palm vein unlock failed - expired or ineffective
|
||||
|
||||
92-Administrator password to unlock
|
||||
@@ -449,9 +449,9 @@ public class LocationEventController {
|
||||
.map(e -> {
|
||||
var loc = locationById.get(e.getLocationId());
|
||||
String locName = loc != null ? loc.getName() : "";
|
||||
double dist = (loc != null && loc.getLat() != null && loc.getLon() != null)
|
||||
? Math.round(LocationController.haversineKm(refLat, refLon, loc.getLat(), loc.getLon()) * 10.0) / 10.0
|
||||
: -1;
|
||||
// double dist = (loc != null && loc.getLat() != null && loc.getLon() != null)
|
||||
// ? Math.round(LocationController.haversineKm(refLat, refLon, loc.getLat(), loc.getLon()) * 10.0) / 10.0
|
||||
// : -1;
|
||||
return toPreview(e, locName, refLat, refLon, myId);
|
||||
})
|
||||
.toList();
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package de.oaa.xxx.user;
|
||||
|
||||
import de.oaa.xxx.admin.AdminRepository;
|
||||
import de.oaa.xxx.config.CookieFactory;
|
||||
import de.oaa.xxx.config.JwtService;
|
||||
import de.oaa.xxx.config.TokenBlacklistService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@@ -33,13 +36,17 @@ public class LoginController {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AdminRepository adminRepository;
|
||||
private final UserService userService;
|
||||
private final TokenBlacklistService tokenBlacklist;
|
||||
private final CookieFactory cookieFactory;
|
||||
|
||||
public LoginController(UserRepository userRepository, JwtService jwtService, PasswordEncoder passwordEncoder, AdminRepository adminRepository, UserService userService) {
|
||||
public LoginController(UserRepository userRepository, JwtService jwtService, PasswordEncoder passwordEncoder, AdminRepository adminRepository, UserService userService, TokenBlacklistService tokenBlacklist, CookieFactory cookieFactory) {
|
||||
this.userRepository = userRepository;
|
||||
this.jwtService = jwtService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.adminRepository = adminRepository;
|
||||
this.userService = userService;
|
||||
this.tokenBlacklist = tokenBlacklist;
|
||||
this.cookieFactory = cookieFactory;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@@ -49,13 +56,7 @@ public class LoginController {
|
||||
UserEntity user = userOpt.get();
|
||||
LOGGER.info("User erfolgreich angemeldet: {}", request.email());
|
||||
String token = jwtService.generateToken(user.getEmail(), user.getName());
|
||||
ResponseCookie cookie = ResponseCookie.from("jwt", token)
|
||||
.httpOnly(true)
|
||||
.sameSite("Strict")
|
||||
.path("/")
|
||||
.maxAge(Duration.ofHours(24))
|
||||
.build();
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.jwtCookie(token, Duration.ofHours(24)).toString());
|
||||
User u = user.toUser();
|
||||
u.setAdmin(adminRepository.existsByUserId(user.getUserId()));
|
||||
return ResponseEntity.ok(u);
|
||||
@@ -76,14 +77,22 @@ public class LoginController {
|
||||
}
|
||||
|
||||
@GetMapping("/logout")
|
||||
public void logout(HttpServletResponse response) throws java.io.IOException {
|
||||
ResponseCookie cookie = ResponseCookie.from("jwt", "")
|
||||
.httpOnly(true)
|
||||
.sameSite("Strict")
|
||||
.path("/")
|
||||
.maxAge(0)
|
||||
.build();
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||
public void logout(HttpServletRequest request, HttpServletResponse response) throws java.io.IOException {
|
||||
if (request.getCookies() != null) {
|
||||
Arrays.stream(request.getCookies())
|
||||
.filter(c -> "jwt".equals(c.getName()))
|
||||
.findFirst()
|
||||
.ifPresent(c -> {
|
||||
try {
|
||||
var claims = jwtService.validateAndGetClaims(c.getValue());
|
||||
String jti = claims.getId();
|
||||
if (jti != null) {
|
||||
tokenBlacklist.blacklist(jti, claims.getExpiration().getTime());
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
}
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookieFactory.jwtCookie("", Duration.ZERO).toString());
|
||||
response.sendRedirect("/");
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ public class User {
|
||||
private boolean admin;
|
||||
private String profilePicture;
|
||||
private LocalDate geburtsdatum;
|
||||
private LocalDate registrierungsdatum;
|
||||
private Integer groesse;
|
||||
private Integer gewicht;
|
||||
private Geschlecht geschlecht;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.oaa.xxx.user;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
@@ -13,8 +14,8 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.DigestUtils;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -24,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import de.oaa.xxx.config.CookieFactory;
|
||||
import de.oaa.xxx.games.bdsm.entity.BdsmDefaultsEntity;
|
||||
import de.oaa.xxx.games.bdsm.repository.BdsmDefaultsRepository;
|
||||
import de.oaa.xxx.games.chastity.common.BaseLockRepository;
|
||||
@@ -39,9 +41,6 @@ import de.oaa.xxx.registration.RegistrationRepository;
|
||||
import de.oaa.xxx.social.entity.MessageCause;
|
||||
import de.oaa.xxx.social.entity.NotificationPreferenceEntity;
|
||||
import de.oaa.xxx.social.repository.NotificationPreferenceRepository;
|
||||
import org.springframework.util.DigestUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/user")
|
||||
@@ -60,6 +59,7 @@ public class UserController {
|
||||
private final TTLockService ttLockService;
|
||||
private final BaseLockRepository baseLockRepository;
|
||||
private final BaseLockTemplateRepository baseLockTemplateRepository;
|
||||
private final CookieFactory cookieFactory;
|
||||
|
||||
public UserController(UserRepository userRepository,
|
||||
RegistrationRepository registrationRepository,
|
||||
@@ -71,7 +71,8 @@ public class UserController {
|
||||
TTAuthService ttAuthService,
|
||||
TTLockService ttLockService,
|
||||
BaseLockRepository baseLockRepository,
|
||||
BaseLockTemplateRepository baseLockTemplateRepository) {
|
||||
BaseLockTemplateRepository baseLockTemplateRepository,
|
||||
CookieFactory cookieFactory) {
|
||||
this.userRepository = userRepository;
|
||||
this.registrationRepository = registrationRepository;
|
||||
this.notificationPreferenceRepository = notificationPreferenceRepository;
|
||||
@@ -83,6 +84,7 @@ public class UserController {
|
||||
this.ttLockService = ttLockService;
|
||||
this.baseLockRepository = baseLockRepository;
|
||||
this.baseLockTemplateRepository = baseLockTemplateRepository;
|
||||
this.cookieFactory = cookieFactory;
|
||||
}
|
||||
|
||||
record ProfilePictureRequest(String picture, String pictureHq) {}
|
||||
@@ -348,14 +350,8 @@ public class UserController {
|
||||
|
||||
userService.deleteAccount(userId, email);
|
||||
|
||||
ResponseCookie cookie = ResponseCookie.from("jwt", "")
|
||||
.httpOnly(true)
|
||||
.sameSite("Strict")
|
||||
.path("/")
|
||||
.maxAge(0)
|
||||
.build();
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, cookie.toString())
|
||||
.header(HttpHeaders.SET_COOKIE, cookieFactory.jwtCookie("", java.time.Duration.ZERO).toString())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -501,6 +497,31 @@ public class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
record NewMemberDto(UUID userId, String name, String profilePicture,
|
||||
Integer alter, String geschlecht, String neigung,
|
||||
String datingStadt, String beschreibung) {}
|
||||
|
||||
@GetMapping("/new-members")
|
||||
public ResponseEntity<List<NewMemberDto>> getNewMembers(Principal principal) {
|
||||
UUID myId = principal != null ? userService.requireUser(principal).getUserId() : null;
|
||||
LocalDate since = LocalDate.now().minusDays(14);
|
||||
List<NewMemberDto> result = userRepository
|
||||
.findByRegistrierungsdatumAfterOrderByRegistrierungsdatumDesc(since)
|
||||
.stream()
|
||||
.filter(u -> !u.getUserId().equals(myId))
|
||||
.map(u -> new NewMemberDto(
|
||||
u.getUserId(),
|
||||
u.getName(),
|
||||
u.getProfilePictureHq() != null ? u.getProfilePictureHq() : u.getProfilePicture(),
|
||||
u.getAlter(),
|
||||
u.getGeschlecht() != null ? u.getGeschlecht().name() : null,
|
||||
u.getNeigung() != null ? u.getNeigung().name() : null,
|
||||
u.getDatingStadt(),
|
||||
u.getBeschreibung()))
|
||||
.collect(Collectors.toList());
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
record LocationFilterRequest(String filterCity, Double filterLat, Double filterLon, Integer filterMaxDistKm) {}
|
||||
|
||||
@PutMapping("/me/location-filter")
|
||||
|
||||
@@ -39,6 +39,9 @@ public class UserEntity {
|
||||
@Column
|
||||
private LocalDate geburtsdatum;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate registrierungsdatum;
|
||||
|
||||
@Column
|
||||
private Integer groesse;
|
||||
|
||||
@@ -163,6 +166,7 @@ public class UserEntity {
|
||||
user.setUserId(userId);
|
||||
user.setProfilePicture(profilePicture);
|
||||
user.setGeburtsdatum(geburtsdatum);
|
||||
user.setRegistrierungsdatum(registrierungsdatum);
|
||||
user.setGroesse(groesse);
|
||||
user.setGewicht(gewicht);
|
||||
user.setGeschlecht(geschlecht);
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.oaa.xxx.user;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -12,4 +13,5 @@ public interface UserRepository extends JpaRepository<UserEntity, UUID> {
|
||||
Optional<UserEntity> findByName(String name);
|
||||
List<UserEntity> findByNameContainingIgnoreCase(String name);
|
||||
List<UserEntity> findByDatingAktiv(boolean datingAktiv);
|
||||
List<UserEntity> findByRegistrierungsdatumAfterOrderByRegistrierungsdatumDesc(LocalDate since);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -220,6 +221,7 @@ public class UserService {
|
||||
entity.setName(registration.getName());
|
||||
entity.setPassword(registration.getPassword());
|
||||
entity.setGeburtsdatum(registration.getGeburtsdatum());
|
||||
entity.setRegistrierungsdatum(LocalDate.now());
|
||||
userRepository.save(entity);
|
||||
|
||||
for (MessageCause cause : MessageCause.values()) {
|
||||
|
||||
9
src/main/resources/application-dev.properties
Normal file
9
src/main/resources/application-dev.properties
Normal file
@@ -0,0 +1,9 @@
|
||||
# Lokale Entwicklung – überschreibt application.properties
|
||||
|
||||
# Cookies ohne Secure-Flag, da lokal kein HTTPS läuft
|
||||
app.cookie.secure=false
|
||||
|
||||
# Klartext-Credentials für lokale DB (kein Umgebungsvariablen-Zwang)
|
||||
spring.mail.username=local@dev.invalid
|
||||
spring.mail.password=unused
|
||||
jwt.keystore.password=XUR!Rv&f$j3UsqD&
|
||||
@@ -22,18 +22,19 @@ spring.jpa.properties.hibernate.type.preferred_uuid_jdbc_type=VARCHAR
|
||||
# Mailpit
|
||||
spring.mail.host=smtp-relay.brevo.com
|
||||
spring.mail.port=587
|
||||
spring.mail.username=a6b17a001@smtp-brevo.com
|
||||
spring.mail.password=xsmtpsib-77b691d562154574133d12b09d44a06e166d30091aac6642480771a0ae463a79-8yH3jHOd4nMMAwuS
|
||||
spring.mail.username=${MAIL_USERNAME}
|
||||
spring.mail.password=${MAIL_PASSWORD}
|
||||
spring.mail.properties.mail.smtp.auth=true
|
||||
spring.mail.properties.mail.smtp.starttls.enable=true
|
||||
|
||||
# JWT Keystore
|
||||
jwt.keystore.path=classpath:xxx.jks
|
||||
jwt.keystore.password=${JWT_KEYSTORE_PASSWORD:XUR!Rv&f$j3UsqD&}
|
||||
jwt.keystore.password=${JWT_KEYSTORE_PASSWORD}
|
||||
jwt.keystore.alias=xxx
|
||||
|
||||
# App
|
||||
app.base-url=http://localhost:8080
|
||||
app.cookie.secure=true
|
||||
|
||||
# Theme – alle Farben hier ändern, Email-Style passt sich automatisch an
|
||||
app.theme.color-bg=#1a1a2e
|
||||
|
||||
@@ -182,10 +182,17 @@
|
||||
let composeBilderArr = [];
|
||||
|
||||
// ── Boot ──
|
||||
fetch('/login/me').then(r => r.ok ? r.json() : null).then(user => {
|
||||
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
|
||||
if (user) {
|
||||
myUserId = user.userId;
|
||||
loadFeed('mine');
|
||||
const raw = sessionStorage.getItem('feedOpenPost');
|
||||
if (raw) {
|
||||
sessionStorage.removeItem('feedOpenPost');
|
||||
loadFeed('mine');
|
||||
openLbWithData(JSON.parse(raw));
|
||||
} else {
|
||||
await loadFeed('mine');
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
@@ -387,7 +394,7 @@
|
||||
|
||||
async function submitPost() {
|
||||
const text = document.getElementById('composeText').value.trim();
|
||||
if (!text) return;
|
||||
if (!text && composeBilderArr.length === 0) return;
|
||||
const beitragTyp = document.querySelector('input[name="beitragTyp"]:checked').value;
|
||||
const multiChoice = document.getElementById('multiChoice').checked;
|
||||
const isPublic = document.getElementById('isPublic').checked;
|
||||
@@ -495,6 +502,20 @@
|
||||
document.getElementById('postLightbox').classList.add('open');
|
||||
}
|
||||
|
||||
function openLbWithData(p) {
|
||||
activeLbPostId = p.postId;
|
||||
activeLbPostType = p.postType || 'FEED';
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = renderPostCard(p, 'mine');
|
||||
const card = tempDiv.firstElementChild;
|
||||
if (card) {
|
||||
card.querySelectorAll('.post-actions').forEach(el => el.remove());
|
||||
document.getElementById('lbPostBody').innerHTML = card.innerHTML;
|
||||
}
|
||||
loadLbComments(p.postId, p.postType || 'FEED');
|
||||
document.getElementById('postLightbox').classList.add('open');
|
||||
}
|
||||
|
||||
function closeLb() {
|
||||
document.getElementById('postLightbox').classList.remove('open');
|
||||
activeLbPostId = null;
|
||||
|
||||
147
src/main/resources/static/dating/besucher.html
Normal file
147
src/main/resources/static/dating/besucher.html
Normal file
@@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Profilbesucher – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.profiles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
|
||||
@media (max-width: 500px) { .profiles-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
|
||||
.profile-card { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 12px; overflow: hidden; transition: border-color 0.15s, box-shadow 0.15s; text-decoration: none; color: var(--color-text); display: flex; flex-direction: column; }
|
||||
.profile-card:hover { border-color: var(--color-primary); box-shadow: 0 4px 18px rgba(0,0,0,0.35); }
|
||||
.profile-card-img-wrap { position: relative; width: 100%; aspect-ratio: 1; flex-shrink: 0; overflow: hidden; background: var(--color-secondary); display: flex; align-items: center; justify-content: center; font-size: 3rem; }
|
||||
.profile-card-img-wrap img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-card-body { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.3rem; flex: 1; }
|
||||
.profile-card-name { font-weight: 700; font-size: 1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.profile-card-meta { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.meta-chip { padding: 0.1rem 0.45rem; border-radius: 20px; background: var(--color-secondary); font-size: 0.73rem; color: var(--color-muted); }
|
||||
.meta-chip.time { color: var(--color-primary); }
|
||||
.profile-card-desc { font-size: 0.78rem; color: var(--color-muted); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; margin-top: 0.15rem; }
|
||||
.profile-card-skeleton { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.skeleton-img { width: 100%; aspect-ratio: 1; background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-card) 50%, var(--color-secondary) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
.skeleton-body { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.skeleton-line { height: 0.75rem; border-radius: 4px; background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-card) 50%, var(--color-secondary) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
@keyframes shimmer { to { background-position: -200% 0; } }
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--color-muted); grid-column: 1 / -1; }
|
||||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
|
||||
#sentinel { height: 1px; margin-top: 1rem; }
|
||||
.results-count { font-size: 0.85rem; color: var(--color-muted); display: block; margin-bottom: 0.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<h1 style="margin:0 0 0.15rem;">Profilbesucher</h1>
|
||||
<span class="results-count" id="resultsCount"></span>
|
||||
<div class="profiles-grid" id="profilesGrid"></div>
|
||||
<div id="sentinel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
const BATCH = 12;
|
||||
let allEntries = [];
|
||||
let loadedCount = 0;
|
||||
let loading = false;
|
||||
|
||||
function relativeTime(iso) {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return 'gerade eben';
|
||||
if (m < 60) return `vor ${m} Min.`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `vor ${h} Std.`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `vor ${d} Tag${d !== 1 ? 'en' : ''}`;
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
function appendProfiles(profiles) {
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
profiles.forEach(p => {
|
||||
const entry = allEntries.find(e => e.userId === p.userId);
|
||||
const pic = p.profilePictureHq || p.profilePicture;
|
||||
const img = pic ? `<img src="data:image/png;base64,${pic}" alt="${esc(p.name)}" loading="lazy">` : '👤';
|
||||
const chips = [
|
||||
p.alter ? `<span class="meta-chip">${p.alter} J.</span>` : '',
|
||||
p.geschlecht ? `<span class="meta-chip">${esc(p.geschlecht)}</span>` : '',
|
||||
p.datingStadt ? `<span class="meta-chip">${esc(p.datingStadt)}</span>` : '',
|
||||
entry?.visitedAt ? `<span class="meta-chip time">${relativeTime(entry.visitedAt)}</span>` : '',
|
||||
].filter(Boolean).join('');
|
||||
const desc = p.beschreibung ? `<div class="profile-card-desc">${esc(p.beschreibung)}</div>` : '';
|
||||
const card = document.createElement('a');
|
||||
card.className = 'profile-card';
|
||||
card.href = `/community/benutzer.html?userId=${p.userId}`;
|
||||
card.innerHTML = `
|
||||
<div class="profile-card-img-wrap">${img}</div>
|
||||
<div class="profile-card-body">
|
||||
<div class="profile-card-name">${esc(p.name)}</div>
|
||||
<div class="profile-card-meta">${chips}</div>
|
||||
${desc}
|
||||
</div>`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadNextBatch() {
|
||||
if (loading || loadedCount >= allEntries.length) return;
|
||||
loading = true;
|
||||
const batch = allEntries.slice(loadedCount, loadedCount + BATCH);
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
const skelDiv = document.createElement('div');
|
||||
skelDiv.style.display = 'contents';
|
||||
skelDiv.innerHTML = Array.from({length: batch.length}, () => `
|
||||
<div class="profile-card-skeleton">
|
||||
<div class="skeleton-img"></div>
|
||||
<div class="skeleton-body">
|
||||
<div class="skeleton-line" style="width:70%"></div>
|
||||
<div class="skeleton-line" style="width:50%"></div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
grid.appendChild(skelDiv);
|
||||
try {
|
||||
const res = await fetch('/dating/profiles/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(batch.map(e => e.userId))
|
||||
});
|
||||
grid.removeChild(skelDiv);
|
||||
if (res.ok) appendProfiles(await res.json());
|
||||
} catch { grid.removeChild(skelDiv); }
|
||||
loadedCount += batch.length;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/social/profile-visits/my-visitors');
|
||||
if (!res.ok) throw new Error();
|
||||
const visitors = await res.json();
|
||||
allEntries = visitors.map(v => ({ userId: v.userId, visitedAt: v.visitedAt }));
|
||||
document.getElementById('resultsCount').textContent =
|
||||
allEntries.length === 0 ? 'Noch keine Besucher'
|
||||
: allEntries.length + (allEntries.length === 1 ? ' Besucher' : ' Besucher');
|
||||
if (!allEntries.length) {
|
||||
document.getElementById('profilesGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">👀</div><p>Noch niemand hat dein Profil besucht.</p></div>';
|
||||
return;
|
||||
}
|
||||
loadNextBatch();
|
||||
} catch {
|
||||
document.getElementById('profilesGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">⚠️</div><p>Fehler beim Laden.</p></div>';
|
||||
}
|
||||
})();
|
||||
|
||||
new IntersectionObserver(
|
||||
e => { if (e[0].isIntersecting) loadNextBatch(); },
|
||||
{ rootMargin: '300px' }
|
||||
).observe(document.getElementById('sentinel'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1038,7 +1038,7 @@
|
||||
<div style="font-size:2.5rem; margin-bottom:0.75rem;">🎉</div>
|
||||
<p>Keine weiteren Profile gefunden.<br>Schau später wieder vorbei!</p>
|
||||
</div>
|
||||
<div id="discoveryLoading" style="text-align:center; padding:3rem 1rem; color:var(--color-muted);">
|
||||
<div id="discoveryLoading" style="display:none; text-align:center; padding:3rem 1rem; color:var(--color-muted);">
|
||||
Wird geladen…
|
||||
</div>
|
||||
<div class="discovery-actions" id="discoveryActions" style="display:none;">
|
||||
@@ -1742,7 +1742,11 @@
|
||||
document.getElementById('discoveryEmpty').style.display = 'none';
|
||||
try {
|
||||
const res = await fetch('/dating/discovery');
|
||||
if (res.status === 403) return;
|
||||
if (res.status === 403) {
|
||||
document.getElementById('discoveryLoading').style.display = 'none';
|
||||
document.getElementById('discoveryEmpty').style.display = '';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error();
|
||||
discoveryQueue = await res.json();
|
||||
discoveryIdx = 0;
|
||||
168
src/main/resources/static/dating/likes.html
Normal file
168
src/main/resources/static/dating/likes.html
Normal file
@@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Likes – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.profiles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
|
||||
@media (max-width: 500px) { .profiles-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
|
||||
.profile-card { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 12px; overflow: hidden; transition: border-color 0.15s, box-shadow 0.15s; text-decoration: none; color: var(--color-text); display: flex; flex-direction: column; }
|
||||
.profile-card:hover { border-color: var(--color-primary); box-shadow: 0 4px 18px rgba(0,0,0,0.35); }
|
||||
.profile-card-img-wrap { position: relative; width: 100%; aspect-ratio: 1; flex-shrink: 0; overflow: hidden; background: var(--color-secondary); display: flex; align-items: center; justify-content: center; font-size: 3rem; }
|
||||
.profile-card-img-wrap img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-card-img-wrap.blurred img { filter: blur(12px); transform: scale(1.1); }
|
||||
.profile-card-img-wrap .lock-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 2rem; }
|
||||
.profile-card-body { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.3rem; flex: 1; }
|
||||
.profile-card-name { font-weight: 700; font-size: 1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.profile-card-meta { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.meta-chip { padding: 0.1rem 0.45rem; border-radius: 20px; background: var(--color-secondary); font-size: 0.73rem; color: var(--color-muted); }
|
||||
.meta-chip.accent { color: var(--color-primary); }
|
||||
.profile-card-desc { font-size: 0.78rem; color: var(--color-muted); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; margin-top: 0.15rem; }
|
||||
.profile-card-skeleton { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.skeleton-img { width: 100%; aspect-ratio: 1; background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-card) 50%, var(--color-secondary) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
.skeleton-body { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.skeleton-line { height: 0.75rem; border-radius: 4px; background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-card) 50%, var(--color-secondary) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
@keyframes shimmer { to { background-position: -200% 0; } }
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--color-muted); grid-column: 1 / -1; }
|
||||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
|
||||
#sentinel { height: 1px; margin-top: 1rem; }
|
||||
.results-count { font-size: 0.85rem; color: var(--color-muted); display: block; margin-bottom: 0.75rem; }
|
||||
.premium-banner { background: var(--color-card); border: 1px solid var(--color-primary); border-radius: 10px; padding: 0.85rem 1rem; margin-bottom: 1rem; font-size: 0.88rem; color: var(--color-muted); }
|
||||
.premium-banner strong { color: var(--color-primary); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<h1 style="margin:0 0 0.15rem;">Likes</h1>
|
||||
<span class="results-count" id="resultsCount"></span>
|
||||
<div id="premiumBanner" style="display:none;" class="premium-banner">
|
||||
<strong>Premium</strong> – Mit Premium siehst du, wer dich geliket hat.
|
||||
</div>
|
||||
<div class="profiles-grid" id="profilesGrid"></div>
|
||||
<div id="sentinel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
const BATCH = 12;
|
||||
let allEntries = []; // { userId } – nur für Premium; gesamt inkl. locked
|
||||
let loadedCount = 0;
|
||||
let loading = false;
|
||||
let isPremium = false;
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
function skeleton() {
|
||||
return `<div class="profile-card-skeleton"><div class="skeleton-img"></div><div class="skeleton-body"><div class="skeleton-line" style="width:70%"></div><div class="skeleton-line" style="width:50%"></div></div></div>`;
|
||||
}
|
||||
|
||||
function appendLockedCard() {
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
const card = document.createElement('div');
|
||||
card.className = 'profile-card';
|
||||
card.style.cursor = 'default';
|
||||
card.innerHTML = `
|
||||
<div class="profile-card-img-wrap blurred">
|
||||
<span style="font-size:3rem;">👤</span>
|
||||
<div class="lock-overlay">🔒</div>
|
||||
</div>
|
||||
<div class="profile-card-body">
|
||||
<div class="profile-card-name">???</div>
|
||||
<div class="profile-card-meta"><span class="meta-chip accent">Premium</span></div>
|
||||
</div>`;
|
||||
grid.appendChild(card);
|
||||
}
|
||||
|
||||
function appendProfiles(profiles) {
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
profiles.forEach(p => {
|
||||
const pic = p.profilePictureHq || p.profilePicture;
|
||||
const img = pic ? `<img src="data:image/png;base64,${pic}" alt="${esc(p.name)}" loading="lazy">` : '👤';
|
||||
const chips = [
|
||||
p.alter ? `<span class="meta-chip">${p.alter} J.</span>` : '',
|
||||
p.geschlecht ? `<span class="meta-chip">${esc(p.geschlecht)}</span>` : '',
|
||||
p.datingStadt ? `<span class="meta-chip">${esc(p.datingStadt)}</span>` : '',
|
||||
`<span class="meta-chip accent">❤️ mag dich</span>`,
|
||||
].filter(Boolean).join('');
|
||||
const desc = p.beschreibung ? `<div class="profile-card-desc">${esc(p.beschreibung)}</div>` : '';
|
||||
const card = document.createElement('a');
|
||||
card.className = 'profile-card';
|
||||
card.href = `/community/benutzer.html?userId=${p.userId}`;
|
||||
card.innerHTML = `
|
||||
<div class="profile-card-img-wrap">${img}</div>
|
||||
<div class="profile-card-body">
|
||||
<div class="profile-card-name">${esc(p.name)}</div>
|
||||
<div class="profile-card-meta">${chips}</div>
|
||||
${desc}
|
||||
</div>`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadNextBatch() {
|
||||
if (loading || loadedCount >= allEntries.length) return;
|
||||
loading = true;
|
||||
const batch = allEntries.slice(loadedCount, loadedCount + BATCH);
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
const skelDiv = document.createElement('div');
|
||||
skelDiv.style.display = 'contents';
|
||||
skelDiv.innerHTML = Array.from({length: batch.length}, () => skeleton()).join('');
|
||||
grid.appendChild(skelDiv);
|
||||
try {
|
||||
const res = await fetch('/dating/profiles/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(batch.map(e => e.userId))
|
||||
});
|
||||
grid.removeChild(skelDiv);
|
||||
if (res.ok) appendProfiles(await res.json());
|
||||
} catch { grid.removeChild(skelDiv); }
|
||||
loadedCount += batch.length;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/dating/who-likes-me');
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
isPremium = !!data.premium;
|
||||
|
||||
document.getElementById('resultsCount').textContent =
|
||||
data.total === 0 ? 'Noch keine Likes'
|
||||
: data.total + (data.total === 1 ? ' Person mag dich' : ' Personen mögen dich');
|
||||
|
||||
if (data.total === 0) {
|
||||
document.getElementById('profilesGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">❤️</div><p>Noch keine Likes.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPremium) {
|
||||
document.getElementById('premiumBanner').style.display = '';
|
||||
// Locked-Karten für alle anzeigen
|
||||
data.likers.forEach(() => appendLockedCard());
|
||||
return;
|
||||
}
|
||||
|
||||
allEntries = data.likers.map(l => ({ userId: l.userId }));
|
||||
loadNextBatch();
|
||||
} catch {
|
||||
document.getElementById('profilesGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">⚠️</div><p>Fehler beim Laden.</p></div>';
|
||||
}
|
||||
})();
|
||||
|
||||
new IntersectionObserver(
|
||||
e => { if (e[0].isIntersecting) loadNextBatch(); },
|
||||
{ rootMargin: '300px' }
|
||||
).observe(document.getElementById('sentinel'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
139
src/main/resources/static/dating/matches.html
Normal file
139
src/main/resources/static/dating/matches.html
Normal file
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Matches – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.profiles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
|
||||
@media (max-width: 500px) { .profiles-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
|
||||
.profile-card { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 12px; overflow: hidden; transition: border-color 0.15s, box-shadow 0.15s; text-decoration: none; color: var(--color-text); display: flex; flex-direction: column; }
|
||||
.profile-card:hover { border-color: var(--color-primary); box-shadow: 0 4px 18px rgba(0,0,0,0.35); }
|
||||
.profile-card-img-wrap { position: relative; width: 100%; aspect-ratio: 1; flex-shrink: 0; overflow: hidden; background: var(--color-secondary); display: flex; align-items: center; justify-content: center; font-size: 3rem; }
|
||||
.profile-card-img-wrap img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-card-body { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.3rem; flex: 1; }
|
||||
.profile-card-name { font-weight: 700; font-size: 1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.profile-card-meta { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.meta-chip { padding: 0.1rem 0.45rem; border-radius: 20px; background: var(--color-secondary); font-size: 0.73rem; color: var(--color-muted); }
|
||||
.meta-chip.accent { color: var(--color-primary); }
|
||||
.profile-card-desc { font-size: 0.78rem; color: var(--color-muted); line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; margin-top: 0.15rem; }
|
||||
.profile-card-actions { padding: 0 0.75rem 0.75rem; display: flex; gap: 0.5rem; }
|
||||
.profile-card-actions .btn { flex: 1; padding: 0.4rem 0.5rem; font-size: 0.8rem; text-align: center; }
|
||||
.profile-card-skeleton { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.skeleton-img { width: 100%; aspect-ratio: 1; background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-card) 50%, var(--color-secondary) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
.skeleton-body { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.skeleton-line { height: 0.75rem; border-radius: 4px; background: linear-gradient(90deg, var(--color-secondary) 25%, var(--color-card) 50%, var(--color-secondary) 75%); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
@keyframes shimmer { to { background-position: -200% 0; } }
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--color-muted); grid-column: 1 / -1; }
|
||||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
|
||||
#sentinel { height: 1px; margin-top: 1rem; }
|
||||
.results-count { font-size: 0.85rem; color: var(--color-muted); display: block; margin-bottom: 0.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<h1 style="margin:0 0 0.15rem;">Matches</h1>
|
||||
<span class="results-count" id="resultsCount"></span>
|
||||
<div class="profiles-grid" id="profilesGrid"></div>
|
||||
<div id="sentinel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
const BATCH = 12;
|
||||
let allEntries = [];
|
||||
let loadedCount = 0;
|
||||
let loading = false;
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
function skeleton() {
|
||||
return `<div class="profile-card-skeleton"><div class="skeleton-img"></div><div class="skeleton-body"><div class="skeleton-line" style="width:70%"></div><div class="skeleton-line" style="width:50%"></div></div></div>`;
|
||||
}
|
||||
|
||||
function appendProfiles(profiles) {
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
profiles.forEach(p => {
|
||||
const pic = p.profilePictureHq || p.profilePicture;
|
||||
const img = pic ? `<img src="data:image/png;base64,${pic}" alt="${esc(p.name)}" loading="lazy">` : '👤';
|
||||
const chips = [
|
||||
p.alter ? `<span class="meta-chip">${p.alter} J.</span>` : '',
|
||||
p.geschlecht ? `<span class="meta-chip">${esc(p.geschlecht)}</span>` : '',
|
||||
p.datingStadt ? `<span class="meta-chip">${esc(p.datingStadt)}</span>` : '',
|
||||
`<span class="meta-chip accent">💕 Match</span>`,
|
||||
].filter(Boolean).join('');
|
||||
const desc = p.beschreibung ? `<div class="profile-card-desc">${esc(p.beschreibung)}</div>` : '';
|
||||
const card = document.createElement('div');
|
||||
card.className = 'profile-card';
|
||||
card.innerHTML = `
|
||||
<a href="/community/benutzer.html?userId=${p.userId}" style="text-decoration:none;color:inherit;display:contents;">
|
||||
<div class="profile-card-img-wrap">${img}</div>
|
||||
<div class="profile-card-body">
|
||||
<div class="profile-card-name">${esc(p.name)}</div>
|
||||
<div class="profile-card-meta">${chips}</div>
|
||||
${desc}
|
||||
</div>
|
||||
</a>
|
||||
<div class="profile-card-actions">
|
||||
<a class="btn" href="/community/nachrichten.html?userId=${p.userId}">Nachricht</a>
|
||||
<a class="btn" href="/community/benutzer.html?userId=${p.userId}" style="background:var(--color-secondary);color:var(--color-text);">Profil</a>
|
||||
</div>`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadNextBatch() {
|
||||
if (loading || loadedCount >= allEntries.length) return;
|
||||
loading = true;
|
||||
const batch = allEntries.slice(loadedCount, loadedCount + BATCH);
|
||||
const grid = document.getElementById('profilesGrid');
|
||||
const skelDiv = document.createElement('div');
|
||||
skelDiv.style.display = 'contents';
|
||||
skelDiv.innerHTML = Array.from({length: batch.length}, () => skeleton()).join('');
|
||||
grid.appendChild(skelDiv);
|
||||
try {
|
||||
const res = await fetch('/dating/profiles/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(batch.map(e => e.userId))
|
||||
});
|
||||
grid.removeChild(skelDiv);
|
||||
if (res.ok) appendProfiles(await res.json());
|
||||
} catch { grid.removeChild(skelDiv); }
|
||||
loadedCount += batch.length;
|
||||
loading = false;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/dating/matches');
|
||||
if (!res.ok) throw new Error();
|
||||
const matches = await res.json();
|
||||
allEntries = matches.map(m => ({ userId: m.userId }));
|
||||
document.getElementById('resultsCount').textContent =
|
||||
allEntries.length === 0 ? 'Noch keine Matches'
|
||||
: allEntries.length + (allEntries.length === 1 ? ' Match' : ' Matches');
|
||||
if (!allEntries.length) {
|
||||
document.getElementById('profilesGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">💕</div><p>Noch keine Matches. Schau öfter vorbei!</p></div>';
|
||||
return;
|
||||
}
|
||||
loadNextBatch();
|
||||
} catch {
|
||||
document.getElementById('profilesGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">⚠️</div><p>Fehler beim Laden.</p></div>';
|
||||
}
|
||||
})();
|
||||
|
||||
new IntersectionObserver(
|
||||
e => { if (e[0].isIntersecting) loadNextBatch(); },
|
||||
{ rootMargin: '300px' }
|
||||
).observe(document.getElementById('sentinel'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -314,6 +314,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bereits in einem Lock-Dialog -->
|
||||
<div class="modal-overlay" id="activeLockModal">
|
||||
<div class="modal-bg" onclick="document.getElementById('activeLockModal').classList.remove('open')"></div>
|
||||
<div class="modal-box">
|
||||
<div style="font-size:2rem;">🔒</div>
|
||||
<h3 style="margin:0;text-align:center;">Du bist bereits in einem Lock</h3>
|
||||
<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;margin:0;">
|
||||
Es ist bereits ein aktives Keuschheitslock für dein Konto vorhanden.
|
||||
Beende oder öffne zuerst das bestehende Lock.
|
||||
</p>
|
||||
<a id="activeLockLink" href="#" style="display:none;width:100%;">
|
||||
<button style="width:100%;">Zum aktiven Lock →</button>
|
||||
</a>
|
||||
<button class="btn-secondary" style="width:100%;"
|
||||
onclick="document.getElementById('activeLockModal').classList.remove('open')">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entsperrcode-Modal -->
|
||||
<div class="modal-overlay" id="unlockModal">
|
||||
<div class="modal-bg"></div>
|
||||
@@ -669,12 +687,29 @@
|
||||
el.style.display = '';
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
function showActiveLockError() {
|
||||
const el = document.getElementById('errorMsg');
|
||||
el.innerHTML = 'Du befindest dich bereits in einem aktiven Lock. '
|
||||
+ '<a href="/games/chastity/meine-locks.html" style="color:inherit;text-decoration:underline;">Zum aktiven Lock</a>';
|
||||
el.style.display = '';
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
async function showActiveLockError() {
|
||||
const modal = document.getElementById('activeLockModal');
|
||||
const linkEl = document.getElementById('activeLockLink');
|
||||
linkEl.style.display = 'none';
|
||||
|
||||
// Eigenes aktives Lock suchen (CardLock oder TimeLock)
|
||||
try {
|
||||
const [cardRes, timeRes] = await Promise.all([
|
||||
fetch('/keyholder/mylock'),
|
||||
fetch('/keyholder/timelock/mylock')
|
||||
]);
|
||||
if (cardRes.status === 200) {
|
||||
const d = await cardRes.json();
|
||||
linkEl.href = '/games/chastity/activelock.html?lockId=' + d.lockId;
|
||||
linkEl.style.display = '';
|
||||
} else if (timeRes.status === 200) {
|
||||
const d = await timeRes.json();
|
||||
linkEl.href = '/games/chastity/activetimelock.html?lockId=' + d.lockId;
|
||||
linkEl.style.display = '';
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
modal.classList.add('open');
|
||||
}
|
||||
function setFieldError(rowId, msg) {
|
||||
const row = document.getElementById(rowId);
|
||||
|
||||
@@ -80,9 +80,17 @@
|
||||
...socialLinks.map(navLink),
|
||||
].join('');
|
||||
|
||||
const datingActive = path === '/dating.html';
|
||||
const datingCls = datingActive ? ' class="active"' : '';
|
||||
const datingItem = `<li id="navDating"><a href="/konto/einstellungen.html#sec-dating"${datingCls}><span class="icon">${I('DATING') || '♥'}</span> Dating</a></li>`;
|
||||
const datingLinks = [
|
||||
{ href: '/dating/dating.html', icon: I('DATING') || '♥', label: 'Dating', id: 'navDating' },
|
||||
{ href: '/dating/besucher.html', icon: '👀', label: 'Besucher' },
|
||||
{ href: '/dating/likes.html', icon: '❤️', label: 'Likes' },
|
||||
{ href: '/dating/matches.html', icon: '💕', label: 'Matches' },
|
||||
];
|
||||
const datingItem = datingLinks.map(({ href, icon, label, id }) => {
|
||||
const cls = path === href ? ' class="active"' : '';
|
||||
const idAttr = id ? ` id="${id}"` : '';
|
||||
return `<li${idAttr}><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}</a></li>`;
|
||||
}).join('');
|
||||
|
||||
const fullHref = path + window.location.search;
|
||||
const nav = groups.map(({ label, icon, items }) => {
|
||||
@@ -152,6 +160,11 @@
|
||||
document.querySelectorAll('.sidebar-group-toggle').forEach(toggle => {
|
||||
toggle.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const href = toggle.getAttribute('href');
|
||||
if (href && href !== '#') {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
toggle.closest('.sidebar-group').classList.toggle('open');
|
||||
});
|
||||
});
|
||||
@@ -230,7 +243,7 @@
|
||||
const navDating = document.getElementById('navDating');
|
||||
if (navDating) {
|
||||
navDating.querySelector('a').href = user.datingAktiv
|
||||
? '/dating.html'
|
||||
? '/dating/dating.html'
|
||||
: '/konto/einstellungen.html#sec-dating';
|
||||
}
|
||||
|
||||
|
||||
@@ -36,77 +36,63 @@
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
}
|
||||
.visitors-strip {
|
||||
display: flex; flex-wrap: wrap; gap: 0.75rem;
|
||||
/* ── Aktivitäts-Grid (Besucher / Likes / Matches) ── */
|
||||
.activity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.visitor-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
|
||||
text-decoration: none; color: var(--color-text);
|
||||
width: 72px;
|
||||
@media (max-width: 680px) {
|
||||
.activity-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.visitor-card:hover .visitor-avatar { border-color: var(--color-primary); }
|
||||
.visitor-avatar {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid var(--color-secondary);
|
||||
.activity-col {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.85rem 0.85rem;
|
||||
}
|
||||
.activity-col-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.activity-col-title {
|
||||
font-size: 0.78rem; font-weight: 700; color: var(--color-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
.activity-col-link {
|
||||
font-size: 0.75rem; color: var(--color-primary);
|
||||
text-decoration: none; font-weight: 600;
|
||||
}
|
||||
.activity-col-link:hover { text-decoration: underline; }
|
||||
.activity-row {
|
||||
display: flex; gap: 0.5rem;
|
||||
}
|
||||
/* Avatar-Karte */
|
||||
.soc-card {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
|
||||
text-decoration: none; color: var(--color-text); cursor: pointer; min-width: 0;
|
||||
}
|
||||
.soc-card:hover .soc-avatar { border-color: var(--color-primary); }
|
||||
.soc-avatar {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
background: var(--color-secondary); border: 2px solid var(--color-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; overflow: hidden; flex-shrink: 0;
|
||||
transition: border-color 0.15s;
|
||||
font-size: 1.2rem; overflow: hidden; flex-shrink: 0;
|
||||
transition: border-color 0.15s; position: relative;
|
||||
}
|
||||
.visitor-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.visitor-name {
|
||||
font-size: 0.75rem; text-align: center;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
.soc-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.soc-lock {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 0.95rem;
|
||||
}
|
||||
.visitor-time { font-size: 0.68rem; color: var(--color-muted); text-align: center; }
|
||||
|
||||
/* ── Dating: Likes & Matches ── */
|
||||
.dating-strip { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.dating-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
|
||||
text-decoration: none; color: var(--color-text); width: 72px;
|
||||
}
|
||||
.dating-card:hover .dating-avatar { border-color: var(--color-primary); }
|
||||
.dating-avatar {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid var(--color-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; overflow: hidden; flex-shrink: 0;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.dating-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.dating-name {
|
||||
font-size: 0.75rem; text-align: center;
|
||||
.soc-name {
|
||||
font-size: 0.68rem; text-align: center;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%;
|
||||
}
|
||||
/* Verschwommene Karte für nicht-Premium */
|
||||
.dating-card-locked {
|
||||
width: 72px; display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
|
||||
cursor: default;
|
||||
}
|
||||
.dating-avatar-blurred {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid var(--color-secondary);
|
||||
overflow: hidden; flex-shrink: 0; position: relative;
|
||||
}
|
||||
.dating-avatar-blurred img {
|
||||
width: 100%; height: 100%; object-fit: cover; border-radius: 50%;
|
||||
filter: blur(6px); transform: scale(1.1);
|
||||
}
|
||||
.dating-avatar-blurred .lock-icon {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.premium-hint {
|
||||
font-size: 0.65rem; color: var(--color-primary); text-align: center; font-weight: 600;
|
||||
}
|
||||
.match-badge {
|
||||
font-size: 0.65rem; color: var(--color-primary); text-align: center; font-weight: 600;
|
||||
}
|
||||
.soc-sub { font-size: 0.62rem; color: var(--color-muted); text-align: center; }
|
||||
.soc-sub-accent { font-size: 0.62rem; color: var(--color-primary); font-weight: 600; text-align: center; }
|
||||
.activity-empty { font-size: 0.8rem; color: var(--color-muted); text-align: center; padding: 0.5rem 0; }
|
||||
|
||||
/* ── Location-Events ── */
|
||||
.loc-event-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
@@ -130,6 +116,159 @@
|
||||
.loc-event-title { font-size: 0.92rem; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.loc-event-date { font-size: 0.75rem; color: var(--color-primary); margin-top: 0.15rem; }
|
||||
|
||||
/* ── Aktive Spiele ── */
|
||||
.active-game-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.active-game-card {
|
||||
display: flex; gap: 0.75rem; align-items: center;
|
||||
background: var(--color-secondary); border: 1px solid var(--color-secondary);
|
||||
border-radius: 10px; padding: 0.65rem 0.85rem;
|
||||
text-decoration: none; color: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.active-game-card:hover { border-color: var(--color-primary); }
|
||||
.active-game-icon {
|
||||
width: 48px; height: 48px; border-radius: 8px; flex-shrink: 0;
|
||||
background: var(--color-card);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.6rem;
|
||||
}
|
||||
.active-game-body { flex: 1; min-width: 0; }
|
||||
.active-game-title { font-size: 0.92rem; font-weight: 600; }
|
||||
.active-game-sub { font-size: 0.75rem; color: var(--color-muted); margin-top: 0.1rem; }
|
||||
.active-game-action {
|
||||
font-size: 0.8rem; color: var(--color-primary); font-weight: 600; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Einladungen ── */
|
||||
.invite-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.invite-card {
|
||||
display: flex; gap: 0.75rem; align-items: center;
|
||||
background: var(--color-secondary); border: 1px solid var(--color-secondary);
|
||||
border-radius: 10px; padding: 0.65rem 0.85rem;
|
||||
text-decoration: none; color: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.invite-card:hover { border-color: var(--color-primary); }
|
||||
.invite-avatar {
|
||||
width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--color-card);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.invite-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.invite-body { flex: 1; min-width: 0; }
|
||||
.invite-from { font-size: 0.88rem; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.invite-type { font-size: 0.73rem; color: var(--color-muted); margin-top: 0.1rem; }
|
||||
.invite-action { font-size: 0.8rem; color: var(--color-primary); font-weight: 600; flex-shrink: 0; }
|
||||
|
||||
/* ── Freundschaftsanfragen ── */
|
||||
.friend-req-strip { display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||
.friend-req-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.3rem;
|
||||
text-decoration: none; color: var(--color-text); width: 72px;
|
||||
}
|
||||
.friend-req-card:hover .friend-req-avatar { border-color: var(--color-primary); }
|
||||
.friend-req-avatar {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
border: 2px solid var(--color-primary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; overflow: hidden; flex-shrink: 0;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.friend-req-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.friend-req-name {
|
||||
font-size: 0.75rem; text-align: center;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%;
|
||||
}
|
||||
.friend-req-badge { font-size: 0.65rem; color: var(--color-primary); font-weight: 600; text-align: center; }
|
||||
|
||||
/* ── Compose ── */
|
||||
.post-compose { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:1rem; transition:border-color 0.15s; }
|
||||
.post-compose.drag-over { border-color:var(--color-primary); background:rgba(var(--color-primary-rgb,180,0,60),0.06); }
|
||||
.compose-type { display:flex; gap:1.5rem; margin-bottom:0.75rem; }
|
||||
.compose-type label { display:flex; align-items:center; gap:0.4rem; font-size:0.9rem; cursor:pointer; }
|
||||
.post-compose textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:70px; box-sizing:border-box; }
|
||||
.post-compose textarea:focus { border-color:var(--color-primary); }
|
||||
.compose-thumbs { display:none; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem; }
|
||||
.compose-thumb { position:relative; width:64px; height:64px; flex-shrink:0; }
|
||||
.compose-thumb img { width:64px; height:64px; object-fit:cover; border-radius:6px; display:block; }
|
||||
.compose-thumb-remove { position:absolute; top:-5px; right:-5px; background:rgba(0,0,0,0.7); border:none; color:#fff; border-radius:50%; font-size:0.65rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; width:auto; line-height:1; }
|
||||
.umfrage-options { margin-top:0.5rem; }
|
||||
.umfrage-option-row { display:flex; gap:0.5rem; margin-bottom:0.4rem; }
|
||||
.umfrage-option-row input { flex:1; }
|
||||
.umfrage-option-row button { width:auto; margin:0; padding:0.3rem 0.6rem; font-size:0.8rem; }
|
||||
.compose-footer { display:flex; justify-content:space-between; align-items:center; margin-top:0.75rem; flex-wrap:wrap; gap:0.5rem; }
|
||||
.multi-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
|
||||
.privacy-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
|
||||
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; transition:border-color 0.15s,color 0.15s; }
|
||||
.compose-action-btn:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
|
||||
label.compose-action-btn { display:inline-flex; align-items:center; }
|
||||
|
||||
/* ── Post Cards (1:1 wie Feed) ── */
|
||||
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; cursor:pointer; transition:border-color 0.15s; }
|
||||
.post-card:hover { border-color:var(--color-primary); }
|
||||
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
|
||||
.post-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.95rem; flex-shrink:0; overflow:hidden; }
|
||||
.post-avatar img { width:100%; height:100%; object-fit:cover; }
|
||||
.post-author { font-weight:600; font-size:0.9rem; }
|
||||
.post-meta { font-size:0.75rem; color:var(--color-muted); }
|
||||
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
|
||||
.post-bild { width:100%; max-height:400px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; }
|
||||
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; }
|
||||
.post-action-btn { background:none; border:none; color:var(--color-muted); font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; margin:0; width:auto; pointer-events:none; }
|
||||
.post-action-btn.active { color:var(--color-primary); }
|
||||
.gruppe-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; color:var(--color-muted); background:var(--color-secondary); border-radius:4px; padding:0.15rem 0.45rem; margin-left:0.3rem; }
|
||||
.umfrage-option-bar { margin:0.3rem 0; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; }
|
||||
.umfrage-option-bar.voted { border-color:var(--color-primary); }
|
||||
.umfrage-bar-fill { position:absolute; inset:0; background:rgba(var(--color-primary-rgb,180,0,60),0.15); }
|
||||
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
|
||||
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
|
||||
|
||||
/* ── Neue Mitglieder ── */
|
||||
.new-members-strip {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.4rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-secondary) transparent;
|
||||
}
|
||||
.new-members-strip::-webkit-scrollbar { height: 4px; }
|
||||
.new-members-strip::-webkit-scrollbar-thumb { background: var(--color-secondary); border-radius: 2px; }
|
||||
.nm-card {
|
||||
flex: 0 0 160px;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.nm-card:hover { border-color: var(--color-primary); box-shadow: 0 4px 18px rgba(0,0,0,0.35); }
|
||||
.nm-card-img {
|
||||
width: 100%; aspect-ratio: 1; flex-shrink: 0;
|
||||
overflow: hidden; background: var(--color-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 2.5rem; position: relative;
|
||||
}
|
||||
.nm-card-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.nm-card-body { padding: 0.6rem 0.65rem; display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.nm-card-name { font-weight: 700; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nm-card-meta { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
.nm-card-chip {
|
||||
padding: 0.1rem 0.4rem; border-radius: 20px;
|
||||
background: var(--color-secondary); font-size: 0.7rem; color: var(--color-muted);
|
||||
}
|
||||
.nm-card-desc {
|
||||
font-size: 0.75rem; color: var(--color-muted); line-height: 1.35;
|
||||
overflow: hidden; display: -webkit-box;
|
||||
-webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
@@ -138,22 +277,62 @@
|
||||
<h1 style="margin:0 0 0.15rem;">Home</h1>
|
||||
<p class="welcome" id="greeting"></p>
|
||||
|
||||
<!-- Profilbesucher -->
|
||||
<div id="visitorsSection" style="display:none;">
|
||||
<div class="section-label">Profilbesucher 👀</div>
|
||||
<div class="visitors-strip" id="visitorsStrip"></div>
|
||||
<!-- Aktive Spiele -->
|
||||
<div id="activeGamesSection" style="display:none;">
|
||||
<div class="section-label">Aktive Spiele 🎮</div>
|
||||
<div class="active-game-list" id="activeGamesList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Wer hat mich geliked (Dating) -->
|
||||
<div id="likesSection" style="display:none;">
|
||||
<div class="section-label">Likes ❤️</div>
|
||||
<div class="dating-strip" id="likesStrip"></div>
|
||||
<!-- Aktiver Lock -->
|
||||
<div id="activeLockSection" style="display:none;">
|
||||
<div class="section-label">Aktiver Lock 🔒</div>
|
||||
<div class="active-game-list" id="activeLockList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Matches -->
|
||||
<div id="matchesSection" style="display:none;">
|
||||
<div class="section-label">Matches 💕</div>
|
||||
<div class="dating-strip" id="matchesStrip"></div>
|
||||
<!-- Einladungen -->
|
||||
<div id="invitesSection" style="display:none;">
|
||||
<div class="section-label">Einladungen 📨</div>
|
||||
<div class="invite-list" id="invitesList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Freundschaftsanfragen -->
|
||||
<div id="friendReqSection" style="display:none;">
|
||||
<div class="section-label">Freundschaftsanfragen 🤝</div>
|
||||
<div class="friend-req-strip" id="friendReqStrip"></div>
|
||||
</div>
|
||||
|
||||
<!-- Aktivitäts-Grid -->
|
||||
<div id="socialGridBlock" style="display:none;">
|
||||
<div class="section-label">Aktivität</div>
|
||||
<div class="activity-grid">
|
||||
<div class="activity-col" id="visitorsCol" style="display:none;">
|
||||
<div class="activity-col-header">
|
||||
<span class="activity-col-title">👀 Besucher</span>
|
||||
<a class="activity-col-link" href="/dating/besucher.html">Alle →</a>
|
||||
</div>
|
||||
<div class="activity-row" id="visitorsRow"></div>
|
||||
</div>
|
||||
<div class="activity-col" id="likesCol" style="display:none;">
|
||||
<div class="activity-col-header">
|
||||
<span class="activity-col-title">❤️ Likes</span>
|
||||
<a class="activity-col-link" href="/dating/likes.html">Alle →</a>
|
||||
</div>
|
||||
<div class="activity-row" id="likesRow"></div>
|
||||
</div>
|
||||
<div class="activity-col" id="matchesCol" style="display:none;">
|
||||
<div class="activity-col-header">
|
||||
<span class="activity-col-title">💕 Matches</span>
|
||||
<a class="activity-col-link" href="/dating/matches.html">Alle →</a>
|
||||
</div>
|
||||
<div class="activity-row" id="matchesRow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Neue Mitglieder -->
|
||||
<div id="newMembersSection" style="display:none;">
|
||||
<div class="section-label">Neue Mitglieder ✨</div>
|
||||
<div class="new-members-strip" id="newMembersStrip"></div>
|
||||
</div>
|
||||
|
||||
<!-- Meine angemeldeten Events -->
|
||||
@@ -167,9 +346,46 @@
|
||||
<div class="section-label">Nächste Veranstaltungen 📍</div>
|
||||
<div class="loc-event-list" id="locEventsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Feed Compose + Vorschau -->
|
||||
<div class="section-label">Feed 📰</div>
|
||||
<div class="post-compose" id="homeCompose">
|
||||
<div class="compose-type">
|
||||
<label><input type="radio" name="homeBeitragTyp" value="TEXT" checked onchange="homeToggleUmfrage()"> Text</label>
|
||||
<label><input type="radio" name="homeBeitragTyp" value="UMFRAGE" onchange="homeToggleUmfrage()"> Umfrage</label>
|
||||
</div>
|
||||
<textarea id="homeComposeText" placeholder="Was möchtest du teilen?" rows="3"></textarea>
|
||||
<div class="compose-thumbs" id="homeComposeThumbs"></div>
|
||||
<div class="umfrage-options" id="homeUmfrageOptions" style="display:none;">
|
||||
<div id="homeOptionList"></div>
|
||||
<button onclick="homeAddOption()" style="width:auto;margin:0;padding:0.3rem 0.75rem;font-size:0.8rem;margin-top:0.4rem;">+ Option</button>
|
||||
</div>
|
||||
<div class="compose-footer">
|
||||
<div style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;">
|
||||
<label class="multi-toggle" id="homeMultiChoiceRow" style="display:none;">
|
||||
<input type="checkbox" id="homeMultiChoice"> Multi-Choice
|
||||
</label>
|
||||
<label class="privacy-toggle">
|
||||
<input type="checkbox" id="homeIsPublic"> Öffentlich
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'homeComposeText')" title="Emoji einfügen">😊</button>
|
||||
<label class="compose-action-btn" title="Fotos hinzufügen">📷
|
||||
<input type="file" id="homeComposeBildFile" accept="image/*" multiple style="display:none;" onchange="homeSelectBilder(this)">
|
||||
</label>
|
||||
<button onclick="homeSubmitPost()" style="width:auto;margin:0;">Veröffentlichen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="feedSection" style="display:none;">
|
||||
<div id="feedList"></div>
|
||||
<a href="/community/feed.html"><button style="width:100%;margin-top:0.1rem;">Weiter zum Feed →</button></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/shared.js"></script>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
@@ -181,12 +397,18 @@
|
||||
.then(user => {
|
||||
if (user) {
|
||||
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
||||
loadActiveGames(user.userId);
|
||||
loadActiveLock();
|
||||
loadInvites();
|
||||
loadFriendRequests();
|
||||
loadVisitors();
|
||||
loadMyEvents();
|
||||
loadLocEvents();
|
||||
loadFeed();
|
||||
if (user.datingAktiv) {
|
||||
loadWhoLikesMe();
|
||||
loadMatches();
|
||||
loadNewDatingMembers();
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -200,6 +422,176 @@
|
||||
return 'vor ' + Math.floor(diff / 86400) + ' Tag' + (Math.floor(diff / 86400) === 1 ? '' : 'en');
|
||||
}
|
||||
|
||||
// ── Aktive Spiele ──────────────────────────────────────────────────────────
|
||||
|
||||
async function loadActiveGames(userId) {
|
||||
try {
|
||||
const items = [];
|
||||
const [vRes, bRes] = await Promise.all([
|
||||
fetch('/vanilla?userId=' + userId),
|
||||
fetch('/bdsm?userId=' + userId)
|
||||
]);
|
||||
if (vRes.ok) {
|
||||
const v = await vRes.json();
|
||||
items.push({
|
||||
icon: '🎭',
|
||||
title: 'Vanilla-Spiel',
|
||||
sub: 'Level ' + v.level + ' · gestartet ' + relativeTime(v.startZeit),
|
||||
href: '/games/vanilla/vanillaingame.html?sessionId=' + v.sessionId
|
||||
});
|
||||
}
|
||||
if (bRes.ok) {
|
||||
const b = await bRes.json();
|
||||
items.push({
|
||||
icon: '⛓',
|
||||
title: 'BDSM-Spiel',
|
||||
sub: 'Level ' + b.level + ' · gestartet ' + relativeTime(b.startZeit),
|
||||
href: '/games/bdsm/bdsmingame.html?sessionId=' + b.sessionId
|
||||
});
|
||||
}
|
||||
if (!items.length) return;
|
||||
const list = document.getElementById('activeGamesList');
|
||||
list.innerHTML = items.map(i => `
|
||||
<a class="active-game-card" href="${esc(i.href)}">
|
||||
<div class="active-game-icon">${i.icon}</div>
|
||||
<div class="active-game-body">
|
||||
<div class="active-game-title">${esc(i.title)}</div>
|
||||
<div class="active-game-sub">${esc(i.sub)}</div>
|
||||
</div>
|
||||
<span class="active-game-action">Weiterspielen →</span>
|
||||
</a>`).join('');
|
||||
document.getElementById('activeGamesSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Aktiver Lock ───────────────────────────────────────────────────────────
|
||||
|
||||
async function loadActiveLock() {
|
||||
try {
|
||||
const [cardRes, timeRes] = await Promise.all([
|
||||
fetch('/keyholder/mylock'),
|
||||
fetch('/keyholder/timelock/mylock')
|
||||
]);
|
||||
const items = [];
|
||||
if (cardRes.status === 200) {
|
||||
const d = await cardRes.json();
|
||||
items.push({ lockId: d.lockId, page: 'activelock' });
|
||||
}
|
||||
if (timeRes.status === 200) {
|
||||
const d = await timeRes.json();
|
||||
items.push({ lockId: d.lockId, page: 'activetimelock' });
|
||||
}
|
||||
if (!items.length) return;
|
||||
const list = document.getElementById('activeLockList');
|
||||
list.innerHTML = items.map(i => `
|
||||
<a class="active-game-card" href="/games/chastity/${i.page}.html?lockId=${esc(i.lockId)}">
|
||||
<div class="active-game-icon">🔒</div>
|
||||
<div class="active-game-body">
|
||||
<div class="active-game-title">Keuschheitslock aktiv</div>
|
||||
<div class="active-game-sub">${i.page === 'activetimelock' ? 'TimeLock' : 'CardLock'} · Tippen für Details</div>
|
||||
</div>
|
||||
<span class="active-game-action">Zum Lock →</span>
|
||||
</a>`).join('');
|
||||
document.getElementById('activeLockSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Einladungen ────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadInvites() {
|
||||
try {
|
||||
const [vRes, bRes, cRes] = await Promise.all([
|
||||
fetch('/vanilla/einladung/pending'),
|
||||
fetch('/bdsm/einladung/pending'),
|
||||
fetch('/lockee/invitations/mine')
|
||||
]);
|
||||
const items = [];
|
||||
|
||||
if (vRes.ok) {
|
||||
const list = await vRes.json();
|
||||
list.forEach(e => items.push({
|
||||
avatar: e.inviterAvatar,
|
||||
from: e.inviterName || 'Jemand',
|
||||
type: 'Vanilla-Spieleinladung',
|
||||
href: '/games/common/einladungen.html'
|
||||
}));
|
||||
}
|
||||
if (bRes.ok) {
|
||||
const list = await bRes.json();
|
||||
list.forEach(e => items.push({
|
||||
avatar: e.inviterAvatar,
|
||||
from: e.inviterName || 'Jemand',
|
||||
type: 'BDSM-Spieleinladung',
|
||||
href: '/games/common/einladungen.html'
|
||||
}));
|
||||
}
|
||||
if (cRes.ok) {
|
||||
const list = await cRes.json();
|
||||
list.forEach(e => items.push({
|
||||
avatar: e.keyholderProfilePic,
|
||||
from: e.keyholderName || 'Jemand',
|
||||
type: 'Keuschheitslock-Einladung: ' + esc(e.lockName),
|
||||
href: '/games/chastity/joinlock.html?token=' + esc(e.token)
|
||||
}));
|
||||
}
|
||||
|
||||
if (!items.length) return;
|
||||
const container = document.getElementById('invitesList');
|
||||
container.innerHTML = items.map(i => `
|
||||
<a class="invite-card" href="${i.href}">
|
||||
<div class="invite-avatar">
|
||||
${i.avatar
|
||||
? `<img src="data:image/png;base64,${i.avatar}" alt="">`
|
||||
: '◉'}
|
||||
</div>
|
||||
<div class="invite-body">
|
||||
<div class="invite-from">${esc(i.from)}</div>
|
||||
<div class="invite-type">${i.type}</div>
|
||||
</div>
|
||||
<span class="invite-action">Ansehen →</span>
|
||||
</a>`).join('');
|
||||
document.getElementById('invitesSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Freundschaftsanfragen ──────────────────────────────────────────────────
|
||||
|
||||
async function loadFriendRequests() {
|
||||
try {
|
||||
const res = await fetch('/social/friends/pending');
|
||||
if (!res.ok) return;
|
||||
const requests = await res.json();
|
||||
if (!requests.length) return;
|
||||
|
||||
const strip = document.getElementById('friendReqStrip');
|
||||
strip.innerHTML = requests.map(r => {
|
||||
const u = r.userProfile;
|
||||
return `
|
||||
<a class="friend-req-card" href="/community/freunde.html">
|
||||
<div class="friend-req-avatar">
|
||||
${u.profilePicture
|
||||
? `<img src="data:image/png;base64,${u.profilePicture}" alt="${esc(u.name)}">`
|
||||
: '◉'}
|
||||
</div>
|
||||
<span class="visitor-name">${esc(u.name)}</span>
|
||||
<span class="friend-req-badge">+ Anfrage</span>
|
||||
</a>`;
|
||||
}).join('');
|
||||
document.getElementById('friendReqSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Aktivitäts-Grid ───────────────────────────────────────────────────────
|
||||
|
||||
function socAvatarHtml(pic, blurred = false) {
|
||||
if (!pic) return '◉';
|
||||
return `<img src="data:image/png;base64,${pic}" alt=""${blurred ? ' style="filter:blur(5px);transform:scale(1.1)"' : ''}>`;
|
||||
}
|
||||
|
||||
function showSocialGrid() {
|
||||
document.getElementById('socialGridBlock').style.display = '';
|
||||
}
|
||||
|
||||
async function loadWhoLikesMe() {
|
||||
try {
|
||||
const res = await fetch('/dating/who-likes-me');
|
||||
@@ -207,31 +599,24 @@
|
||||
const data = await res.json();
|
||||
if (data.total === 0) return;
|
||||
|
||||
const strip = document.getElementById('likesStrip');
|
||||
strip.innerHTML = data.likers.map(l => {
|
||||
const pic = l.profilePicture
|
||||
? `<img src="data:image/png;base64,${l.profilePicture}" alt="">`
|
||||
: '◉';
|
||||
|
||||
document.getElementById('likesRow').innerHTML = data.likers.slice(0, 4).map(l => {
|
||||
if (data.premium && l.userId) {
|
||||
return `
|
||||
<a class="dating-card" href="/community/benutzer.html?userId=${l.userId}">
|
||||
<div class="dating-avatar">${pic}</div>
|
||||
<span class="dating-name">${esc(l.name)}</span>
|
||||
</a>`;
|
||||
} else {
|
||||
return `
|
||||
<div class="dating-card-locked">
|
||||
<div class="dating-avatar-blurred">
|
||||
${pic}
|
||||
<span class="lock-icon">🔒</span>
|
||||
</div>
|
||||
<span class="premium-hint">Premium</span>
|
||||
</div>`;
|
||||
return `<a class="soc-card" href="/community/benutzer.html?userId=${l.userId}">
|
||||
<div class="soc-avatar">${socAvatarHtml(l.profilePicture)}</div>
|
||||
<span class="soc-name">${esc(l.name)}</span>
|
||||
</a>`;
|
||||
}
|
||||
return `<div class="soc-card">
|
||||
<div class="soc-avatar">
|
||||
${socAvatarHtml(l.profilePicture, true)}
|
||||
<span class="soc-lock">🔒</span>
|
||||
</div>
|
||||
<span class="soc-sub-accent">Premium</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('likesSection').style.display = '';
|
||||
document.getElementById('likesCol').style.display = '';
|
||||
showSocialGrid();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -242,27 +627,237 @@
|
||||
const matches = await res.json();
|
||||
if (!matches.length) return;
|
||||
|
||||
const strip = document.getElementById('matchesStrip');
|
||||
strip.innerHTML = matches.map(m => `
|
||||
<a class="dating-card" href="/community/benutzer.html?userId=${m.userId}">
|
||||
<div class="dating-avatar">
|
||||
${m.profilePicture
|
||||
? `<img src="data:image/png;base64,${m.profilePicture}" alt="${esc(m.name)}">`
|
||||
: '◉'}
|
||||
</div>
|
||||
<span class="dating-name">${esc(m.name)}</span>
|
||||
<span class="match-badge">♥ Match</span>
|
||||
</a>
|
||||
`).join('');
|
||||
document.getElementById('matchesSection').style.display = '';
|
||||
document.getElementById('matchesRow').innerHTML = matches.slice(0, 4).map(m => `
|
||||
<a class="soc-card" href="/community/benutzer.html?userId=${m.userId}">
|
||||
<div class="soc-avatar">${socAvatarHtml(m.profilePicture)}</div>
|
||||
<span class="soc-name">${esc(m.name)}</span>
|
||||
<span class="soc-sub-accent">♥</span>
|
||||
</a>`).join('');
|
||||
|
||||
document.getElementById('matchesCol').style.display = '';
|
||||
showSocialGrid();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
// ── Neue Mitglieder ───────────────────────────────────────────────────────
|
||||
|
||||
async function loadNewDatingMembers() {
|
||||
try {
|
||||
const res = await fetch('/user/new-members');
|
||||
if (!res.ok) return;
|
||||
const members = await res.json();
|
||||
if (!members.length) return;
|
||||
|
||||
const strip = document.getElementById('newMembersStrip');
|
||||
strip.innerHTML = members.map(p => {
|
||||
const img = p.profilePicture
|
||||
? `<img src="data:image/png;base64,${p.profilePicture}" alt="${esc(p.name)}" loading="lazy">`
|
||||
: `<span>👤</span>`;
|
||||
const chips = [
|
||||
p.alter ? `<span class="nm-card-chip">${p.alter} J.</span>` : '',
|
||||
p.geschlecht ? `<span class="nm-card-chip">${esc(p.geschlecht)}</span>` : '',
|
||||
p.neigung ? `<span class="nm-card-chip">${esc(p.neigung)}</span>` : '',
|
||||
p.datingStadt ? `<span class="nm-card-chip">${esc(p.datingStadt)}</span>` : '',
|
||||
].filter(Boolean).join('');
|
||||
const desc = p.beschreibung
|
||||
? `<div class="nm-card-desc">${esc(p.beschreibung)}</div>` : '';
|
||||
return `<a class="nm-card" href="/community/benutzer.html?userId=${p.userId}">
|
||||
<div class="nm-card-img">${img}</div>
|
||||
<div class="nm-card-body">
|
||||
<div class="nm-card-name">${esc(p.name)}</div>
|
||||
<div class="nm-card-meta">${chips}</div>
|
||||
${desc}
|
||||
</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
document.getElementById('newMembersSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Feed Compose ──────────────────────────────────────────────────────────
|
||||
|
||||
let homeComposeBilder = [];
|
||||
|
||||
function homeToggleUmfrage() {
|
||||
const isUmfrage = document.querySelector('input[name="homeBeitragTyp"]:checked').value === 'UMFRAGE';
|
||||
document.getElementById('homeUmfrageOptions').style.display = isUmfrage ? '' : 'none';
|
||||
document.getElementById('homeMultiChoiceRow').style.display = isUmfrage ? '' : 'none';
|
||||
if (isUmfrage && document.getElementById('homeOptionList').children.length === 0) {
|
||||
homeAddOption(); homeAddOption();
|
||||
}
|
||||
}
|
||||
|
||||
function homeAddOption() {
|
||||
const list = document.getElementById('homeOptionList');
|
||||
const idx = list.children.length;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'umfrage-option-row';
|
||||
row.innerHTML = `<input type="text" placeholder="Option ${idx + 1}" maxlength="100">
|
||||
<button onclick="this.parentElement.remove()">✕</button>`;
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
function homeSelectBilder(input) {
|
||||
[...input.files].forEach(f => { if (f.type.startsWith('image/')) homeProcessImage(f); });
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function homeProcessImage(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const maxSize = 1024;
|
||||
const canvas = document.createElement('canvas');
|
||||
const scale = Math.min(maxSize / img.width, maxSize / img.height, 1);
|
||||
canvas.width = Math.round(img.width * scale);
|
||||
canvas.height = Math.round(img.height * scale);
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
homeComposeBilder.push(canvas.toDataURL('image/jpeg', 0.85).split(',')[1]);
|
||||
homeRenderThumbs();
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function homeRenderThumbs() {
|
||||
const container = document.getElementById('homeComposeThumbs');
|
||||
container.innerHTML = '';
|
||||
homeComposeBilder.forEach((b, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'compose-thumb';
|
||||
div.innerHTML = `<img src="data:image/jpeg;base64,${b}" alt="">
|
||||
<button class="compose-thumb-remove" onclick="homeRemoveThumb(${i})">✕</button>`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
container.style.display = homeComposeBilder.length > 0 ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function homeRemoveThumb(idx) {
|
||||
homeComposeBilder.splice(idx, 1);
|
||||
homeRenderThumbs();
|
||||
}
|
||||
|
||||
async function homeSubmitPost() {
|
||||
const text = document.getElementById('homeComposeText').value.trim();
|
||||
if (!text && homeComposeBilder.length === 0) return;
|
||||
const beitragTyp = document.querySelector('input[name="homeBeitragTyp"]:checked').value;
|
||||
const multiChoice = document.getElementById('homeMultiChoice').checked;
|
||||
const isPublic = document.getElementById('homeIsPublic').checked;
|
||||
|
||||
let optionen = [];
|
||||
if (beitragTyp === 'UMFRAGE') {
|
||||
optionen = Array.from(document.getElementById('homeOptionList').querySelectorAll('input'))
|
||||
.map(i => i.value.trim()).filter(v => v);
|
||||
if (optionen.length < 2) { alert('Mindestens 2 Optionen erforderlich.'); return; }
|
||||
}
|
||||
|
||||
const res = await fetch('/feed/posts', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ beitragTyp, text, multiChoice, optionen, bilder: [...homeComposeBilder], isPublic })
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const post = await res.json();
|
||||
|
||||
// Reset
|
||||
document.getElementById('homeComposeText').value = '';
|
||||
homeComposeBilder = [];
|
||||
homeRenderThumbs();
|
||||
document.querySelector('input[name="homeBeitragTyp"][value="TEXT"]').checked = true;
|
||||
homeToggleUmfrage();
|
||||
document.getElementById('homeMultiChoice').checked = false;
|
||||
document.getElementById('homeIsPublic').checked = false;
|
||||
document.getElementById('homeOptionList').innerHTML = '';
|
||||
|
||||
// Prepend in Vorschau
|
||||
const feedList = document.getElementById('feedList');
|
||||
feedList.insertAdjacentHTML('afterbegin', renderHomePostCard(post));
|
||||
document.getElementById('feedSection').style.display = '';
|
||||
}
|
||||
|
||||
// Drag & Drop
|
||||
const homeCompose = document.getElementById('homeCompose');
|
||||
if (homeCompose) {
|
||||
homeCompose.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
if ([...e.dataTransfer.items].some(i => i.type.startsWith('image/')))
|
||||
homeCompose.classList.add('drag-over');
|
||||
});
|
||||
homeCompose.addEventListener('dragleave', e => {
|
||||
if (!homeCompose.contains(e.relatedTarget)) homeCompose.classList.remove('drag-over');
|
||||
});
|
||||
homeCompose.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
homeCompose.classList.remove('drag-over');
|
||||
[...e.dataTransfer.files].filter(f => f.type.startsWith('image/')).forEach(homeProcessImage);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Feed-Vorschau ──────────────────────────────────────────────────────────
|
||||
|
||||
const homePostCache = {};
|
||||
|
||||
function homeOpenPost(postId) {
|
||||
const p = homePostCache[postId];
|
||||
if (p) sessionStorage.setItem('feedOpenPost', JSON.stringify(p));
|
||||
window.location.href = '/community/feed.html';
|
||||
}
|
||||
|
||||
function renderHomePostCard(p) {
|
||||
homePostCache[p.postId] = p;
|
||||
const avatarHtml = p.authorPicture
|
||||
? `<img src="data:image/png;base64,${p.authorPicture}" alt="">`
|
||||
: '◉';
|
||||
const privacyLabel = !p.isPublic ? ' <span style="font-size:0.7rem;color:var(--color-muted);">🔒</span>' : '';
|
||||
const groupBadge = p.postType === 'GROUP' && p.gruppeId
|
||||
? `<span class="gruppe-badge">👥 ${esc(p.gruppeName)}</span>`
|
||||
: '';
|
||||
const bildHtml = bilderCarousel(p.bilder);
|
||||
let umfrageHtml = '';
|
||||
if (p.beitragTyp === 'UMFRAGE' && p.optionen && p.optionen.length > 0) {
|
||||
const totalVotes = p.optionen.reduce((s, o) => s + o.stimmenCount, 0);
|
||||
umfrageHtml = '<div style="margin-top:0.5rem;">' + p.optionen.map(o => {
|
||||
const pct = totalVotes > 0 ? Math.round(o.stimmenCount / totalVotes * 100) : 0;
|
||||
const voted = p.myVoteOptionIds && p.myVoteOptionIds.includes(o.optionId);
|
||||
return `<div class="umfrage-option-bar${voted ? ' voted' : ''}">
|
||||
<div class="umfrage-bar-fill" style="width:${pct}%"></div>
|
||||
<div class="umfrage-bar-content"><span>${esc(o.text)}</span><span>${pct}%</span></div>
|
||||
</div>`;
|
||||
}).join('') + `<div class="umfrage-total">${totalVotes} Stimme${totalVotes !== 1 ? 'n' : ''}</div></div>`;
|
||||
}
|
||||
return `<div class="post-card" onclick="homeOpenPost('${p.postId}')" style="cursor:pointer">
|
||||
<div class="post-header">
|
||||
<div class="post-avatar">${avatarHtml}</div>
|
||||
<div>
|
||||
<div class="post-author">${esc(p.authorName)}${privacyLabel}</div>
|
||||
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-text">${esc(p.text || '')}</div>
|
||||
${bildHtml}
|
||||
${umfrageHtml}
|
||||
<div class="post-actions">
|
||||
<button class="post-action-btn${p.likedByMe ? ' active' : ''}">♥ <span>${p.likeCount}</span></button>
|
||||
<button class="post-action-btn">💬 <span>${p.kommentarCount}</span></button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function loadFeed() {
|
||||
try {
|
||||
const res = await fetch('/feed/mine?size=3&page=0');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const posts = data.posts;
|
||||
if (!posts || !posts.length) return;
|
||||
document.getElementById('feedList').innerHTML = posts.map(renderHomePostCard).join('');
|
||||
document.getElementById('feedSection').style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderEventCards(events, listId, sectionId) {
|
||||
if (!events.length) return;
|
||||
const list = document.getElementById(listId);
|
||||
@@ -304,6 +899,8 @@
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Profilbesucher ────────────────────────────────────────────────────────
|
||||
|
||||
async function loadVisitors() {
|
||||
try {
|
||||
const res = await fetch('/social/profile-visits/my-visitors');
|
||||
@@ -311,19 +908,15 @@
|
||||
const visitors = await res.json();
|
||||
if (!visitors.length) return;
|
||||
|
||||
const strip = document.getElementById('visitorsStrip');
|
||||
strip.innerHTML = visitors.map(v => `
|
||||
<a class="visitor-card" href="/community/benutzer.html?userId=${v.userId}">
|
||||
<div class="visitor-avatar">
|
||||
${v.profilePicture
|
||||
? `<img src="data:image/png;base64,${v.profilePicture}" alt="${v.name}">`
|
||||
: '◉'}
|
||||
</div>
|
||||
<span class="visitor-name">${v.name}</span>
|
||||
<span class="visitor-time">${relativeTime(v.visitedAt)}</span>
|
||||
</a>
|
||||
`).join('');
|
||||
document.getElementById('visitorsSection').style.display = '';
|
||||
document.getElementById('visitorsRow').innerHTML = visitors.slice(0, 4).map(v => `
|
||||
<a class="soc-card" href="/community/benutzer.html?userId=${v.userId}">
|
||||
<div class="soc-avatar">${socAvatarHtml(v.profilePicture)}</div>
|
||||
<span class="soc-name">${esc(v.name)}</span>
|
||||
<span class="soc-sub">${relativeTime(v.visitedAt)}</span>
|
||||
</a>`).join('');
|
||||
|
||||
document.getElementById('visitorsCol').style.display = '';
|
||||
showSocialGrid();
|
||||
} catch (_) {}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user