Weiter am Dating gearbeitet
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:
BIN
bin/main/de/oaa/xxx/dating/DatingController.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingController.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/dating/DatingLikeEntity.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingLikeEntity.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/dating/DatingLikeRepository.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingLikeRepository.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/dating/DatingMatchEntity.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingMatchEntity.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/dating/DatingMatchRepository.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingMatchRepository.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/dating/DatingService$DatingFilter.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingService$DatingFilter.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/dating/DatingService$DatingProfileDto.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingService$DatingProfileDto.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/dating/DatingService$IdsResult.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingService$IdsResult.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/dating/DatingService$LikeResult.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingService$LikeResult.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/dating/DatingService$LikerDto.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingService$LikerDto.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/dating/DatingService$MatchDto.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingService$MatchDto.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/dating/DatingService$WhoLikesMeResult.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingService$WhoLikesMeResult.class
Normal file
Binary file not shown.
BIN
bin/main/de/oaa/xxx/dating/DatingService.class
Normal file
BIN
bin/main/de/oaa/xxx/dating/DatingService.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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
9
bin/main/sql/fix_sichtbarkeit_niemand.sql
Normal file
9
bin/main/sql/fix_sichtbarkeit_niemand.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Ersetzt ungültigen Wert 'NIEMAND' durch 'NUR_ICH' in allen Sichtbarkeit-Spalten der user-Tabelle.
|
||||||
|
UPDATE `user` SET sichtbarkeit_grunddaten = 'NUR_ICH' WHERE sichtbarkeit_grunddaten = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_galerie = 'NUR_ICH' WHERE sichtbarkeit_galerie = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_freunde = 'NUR_ICH' WHERE sichtbarkeit_freunde = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_feed = 'NUR_ICH' WHERE sichtbarkeit_feed = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_pinnwand = 'NUR_ICH' WHERE sichtbarkeit_pinnwand = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_xp = 'NUR_ICH' WHERE sichtbarkeit_xp = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_lockhistorie = 'NUR_ICH' WHERE sichtbarkeit_lockhistorie = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_vorlieben = 'NUR_ICH' WHERE sichtbarkeit_vorlieben = 'NIEMAND';
|
||||||
25933
bin/main/sql/testuser_dating.sql
Normal file
25933
bin/main/sql/testuser_dating.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -700,6 +700,11 @@
|
|||||||
}
|
}
|
||||||
html += ` <button onclick="openMeldungDialog('PROFIL','${profile.userId}')" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.4rem 0.8rem;cursor:pointer;font-size:0.85rem;">⚑ Melden</button>`;
|
html += ` <button onclick="openMeldungDialog('PROFIL','${profile.userId}')" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.4rem 0.8rem;cursor:pointer;font-size:0.85rem;">⚑ Melden</button>`;
|
||||||
actions.innerHTML = html;
|
actions.innerHTML = html;
|
||||||
|
|
||||||
|
// Dating-Like-Button – nur anzeigen wenn Ziel-User Dating aktiviert hat
|
||||||
|
if (profile.datingAktiv) {
|
||||||
|
loadDatingLikeButton(profile.userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1410,6 +1415,60 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// esc, fmtDate, toggleEmojiPicker, insertEmoji kommen aus shared.js
|
// esc, fmtDate, toggleEmojiPicker, insertEmoji kommen aus shared.js
|
||||||
|
|
||||||
|
// ── Dating-Like auf Profilseite ──────────────────────────────────────────
|
||||||
|
async function loadDatingLikeButton(targetUserId) {
|
||||||
|
// Nur einblenden wenn eigener User Dating aktiviert hat
|
||||||
|
const meRes = await fetch('/login/me');
|
||||||
|
if (!meRes.ok) return;
|
||||||
|
const me = await meRes.json();
|
||||||
|
if (!me.datingAktiv) return;
|
||||||
|
|
||||||
|
let liked = false;
|
||||||
|
try {
|
||||||
|
const idsRes = await fetch('/dating/liked-by-me');
|
||||||
|
if (idsRes.ok) {
|
||||||
|
const ids = await idsRes.json();
|
||||||
|
liked = ids.includes(targetUserId);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'datingLikeBtn';
|
||||||
|
btn.title = liked ? 'Unlike' : 'Like';
|
||||||
|
btn.style.cssText = 'padding:0.4rem 0.9rem;font-size:0.9rem;' +
|
||||||
|
(liked ? 'background:var(--color-primary);color:#fff;' : 'background:none;border:1px solid var(--color-primary);color:var(--color-primary);') +
|
||||||
|
'border-radius:6px;cursor:pointer;';
|
||||||
|
btn.textContent = liked ? '♥ Liked' : '♥ Liken';
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/dating/like/' + targetUserId, { method: 'POST' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
liked = data.liked;
|
||||||
|
btn.textContent = liked ? '♥ Liked' : '♥ Liken';
|
||||||
|
btn.title = liked ? 'Unlike' : 'Like';
|
||||||
|
btn.style.background = liked ? 'var(--color-primary)' : 'none';
|
||||||
|
btn.style.color = liked ? '#fff' : 'var(--color-primary)';
|
||||||
|
btn.style.border = liked ? 'none' : '1px solid var(--color-primary)';
|
||||||
|
if (data.newMatch) {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.textContent = '🎉 Es ist ein Match!';
|
||||||
|
Object.assign(t.style, {
|
||||||
|
position:'fixed', bottom:'2rem', left:'50%', transform:'translateX(-50%)',
|
||||||
|
background:'var(--color-primary)', color:'#fff', padding:'0.75rem 1.5rem',
|
||||||
|
borderRadius:'8px', fontWeight:'700', zIndex:'999'
|
||||||
|
});
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 3500);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('profileActions').appendChild(btn);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,26 +7,814 @@
|
|||||||
<title>Dating – xXx Sphere</title>
|
<title>Dating – xXx Sphere</title>
|
||||||
<link rel="stylesheet" href="/css/variables.css">
|
<link rel="stylesheet" href="/css/variables.css">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<style>
|
||||||
|
/* ── Toolbar ── */
|
||||||
|
.dating-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.results-count { font-size: 0.85rem; color: var(--color-muted); }
|
||||||
|
.filter-open-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.filter-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Filter-Overlay ── */
|
||||||
|
.filter-overlay-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.55);
|
||||||
|
z-index: 200;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.filter-overlay-bg.open { opacity: 1; pointer-events: all; }
|
||||||
|
|
||||||
|
.filter-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; right: 0; bottom: 0;
|
||||||
|
width: min(320px, 92vw);
|
||||||
|
background: var(--color-card);
|
||||||
|
border-left: 1px solid var(--color-secondary);
|
||||||
|
z-index: 201;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.filter-drawer.open { transform: translateX(0); }
|
||||||
|
|
||||||
|
.filter-drawer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.filter-drawer-header h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.filter-close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.filter-close-btn:hover { background: var(--color-secondary); color: var(--color-text); }
|
||||||
|
|
||||||
|
.filter-drawer-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.filter-drawer-footer {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--color-secondary);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.apply-btn { flex: 1; padding: 0.65rem; font-size: 0.9rem; }
|
||||||
|
.reset-btn {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--color-secondary);
|
||||||
|
color: var(--color-muted);
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.reset-btn:hover { color: var(--color-text); }
|
||||||
|
|
||||||
|
/* ── Filter-Elemente ── */
|
||||||
|
.filter-group { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.filter-group label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.range-val { color: var(--color-text); }
|
||||||
|
.filter-group input[type="range"] {
|
||||||
|
padding: 0; background: none; border: none;
|
||||||
|
accent-color: var(--color-primary); width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dual-Handle Alters-Slider ── */
|
||||||
|
.dual-slider {
|
||||||
|
position: relative;
|
||||||
|
height: 20px;
|
||||||
|
margin: 0.5rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
.dual-slider-track {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%; left: 0; right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-secondary);
|
||||||
|
border-radius: 2px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.dual-slider-range {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; height: 100%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.dual-slider-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
cursor: grab;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||||
|
transition: box-shadow 0.1s;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.dual-slider-thumb:active { cursor: grabbing; box-shadow: 0 2px 8px rgba(0,0,0,0.5); }
|
||||||
|
.dual-slider-thumb:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
|
||||||
|
.filter-divider { border: none; border-top: 1px solid var(--color-secondary); margin: 0; }
|
||||||
|
.chip-group { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
|
.chip {
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
background: var(--color-secondary);
|
||||||
|
color: var(--color-muted);
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.chip.active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
|
||||||
|
.logic-toggle { display: flex; gap: 0.4rem; }
|
||||||
|
.logic-btn {
|
||||||
|
flex: 1; padding: 0.3rem; font-size: 0.78rem; font-weight: 600;
|
||||||
|
text-align: center; border-radius: 6px; cursor: pointer;
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
background: var(--color-secondary); color: var(--color-muted);
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.logic-btn.active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
|
||||||
|
.vorlieben-search { position: relative; }
|
||||||
|
.vorlieben-search input { padding: 0.4rem 0.7rem; font-size: 0.82rem; }
|
||||||
|
.vorlieben-dropdown {
|
||||||
|
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||||
|
background: var(--color-card); border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: 8px; max-height: 180px; overflow-y: auto; z-index: 210; display: none;
|
||||||
|
}
|
||||||
|
.vorlieben-dropdown.open { display: block; }
|
||||||
|
.vl-item { padding: 0.45rem 0.75rem; font-size: 0.82rem; cursor: pointer; color: var(--color-text); }
|
||||||
|
.vl-item:hover { background: var(--color-secondary); }
|
||||||
|
.selected-vorlieben { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||||
|
.vl-tag {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.55rem; background: var(--color-primary);
|
||||||
|
color: #fff; border-radius: 20px; font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.vl-tag button {
|
||||||
|
background: none; border: none; color: #fff; padding: 0;
|
||||||
|
font-size: 0.75rem; cursor: pointer; line-height: 1; min-width: unset;
|
||||||
|
}
|
||||||
|
.vl-tag button:hover { background: none; opacity: 0.7; }
|
||||||
|
|
||||||
|
/* ── Profilkarten-Grid ── */
|
||||||
|
.profiles-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.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 { display: none; } /* ersetzt durch .profile-card-img-wrap */
|
||||||
|
.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.dist { 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Skeleton-Karten beim Nachladen ── */
|
||||||
|
.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; } }
|
||||||
|
|
||||||
|
/* ── Like-Button auf Karten ── */
|
||||||
|
.profile-card-like {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background: rgba(0,0,0,0.55);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: background 0.15s, transform 0.1s;
|
||||||
|
padding: 0;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.profile-card-like:hover { background: rgba(0,0,0,0.75); transform: scale(1.1); }
|
||||||
|
.profile-card-like.liked { background: var(--color-primary); }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* ── Sentinel & Leer/Fehler ── */
|
||||||
|
#sentinel { height: 1px; margin-top: 1rem; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.profiles-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="app">
|
<body class="app">
|
||||||
|
|
||||||
|
<!-- ── Filter-Overlay ── -->
|
||||||
|
<div class="filter-overlay-bg" id="filterBg"></div>
|
||||||
|
<div class="filter-drawer" id="filterDrawer">
|
||||||
|
<div class="filter-drawer-header">
|
||||||
|
<h2>Filter</h2>
|
||||||
|
<button class="filter-close-btn" id="filterCloseBtn" aria-label="Filter schließen">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-drawer-body">
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Umkreis <span class="range-val" id="distVal">50 km</span></label>
|
||||||
|
<input type="range" id="maxDist" min="5" max="500" value="50" step="5"
|
||||||
|
oninput="document.getElementById('distVal').textContent=this.value+' km'">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Alter <span class="range-val" id="ageVal">18 – 60</span></label>
|
||||||
|
<div class="dual-slider" id="ageSlider" role="group" aria-label="Altersbereich">
|
||||||
|
<div class="dual-slider-track">
|
||||||
|
<div class="dual-slider-range" id="ageRange"></div>
|
||||||
|
</div>
|
||||||
|
<div class="dual-slider-thumb" id="thumbMin" tabindex="0" role="slider"
|
||||||
|
aria-label="Mindestalter" aria-valuemin="18" aria-valuemax="99" aria-valuenow="18"></div>
|
||||||
|
<div class="dual-slider-thumb" id="thumbMax" tabindex="0" role="slider"
|
||||||
|
aria-label="Höchstalter" aria-valuemin="18" aria-valuemax="99" aria-valuenow="60"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="filter-divider">
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Geschlecht</label>
|
||||||
|
<div class="chip-group" id="geschlechtChips">
|
||||||
|
<span class="chip" data-val="WEIBLICH">weiblich</span>
|
||||||
|
<span class="chip" data-val="MAENNLICH">männlich</span>
|
||||||
|
<span class="chip" data-val="DIVERS">divers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Neigung</label>
|
||||||
|
<div class="chip-group" id="neigungChips">
|
||||||
|
<span class="chip" data-val="DEVOT">devot</span>
|
||||||
|
<span class="chip" data-val="EHER_DEVOT">eher devot</span>
|
||||||
|
<span class="chip" data-val="SWITCHER">Switcher</span>
|
||||||
|
<span class="chip" data-val="EHER_DOMINANT">eher dominant</span>
|
||||||
|
<span class="chip" data-val="DOMINANT">dominant</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="filter-divider">
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label>Vorlieben</label>
|
||||||
|
<div class="logic-toggle" id="logicToggle">
|
||||||
|
<span class="logic-btn active" data-val="false">ODER</span>
|
||||||
|
<span class="logic-btn" data-val="true">UND</span>
|
||||||
|
</div>
|
||||||
|
<div class="vorlieben-search" style="margin-top:0.4rem;">
|
||||||
|
<input type="text" id="vlSearch" placeholder="Vorliebe suchen …" autocomplete="off">
|
||||||
|
<div class="vorlieben-dropdown" id="vlDropdown"></div>
|
||||||
|
</div>
|
||||||
|
<div class="selected-vorlieben" id="selectedVorlieben"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="filter-drawer-footer">
|
||||||
|
<button class="btn apply-btn" id="applyBtn">Suchen</button>
|
||||||
|
<button class="reset-btn" id="resetBtn">Zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h1>Dating</h1>
|
<h1>Dating</h1>
|
||||||
<p style="color:var(--color-muted);">Kommt bald.</p>
|
|
||||||
|
<div class="dating-toolbar">
|
||||||
|
<span class="results-count" id="resultsCount"></span>
|
||||||
|
<button class="btn filter-open-btn" id="filterOpenBtn">
|
||||||
|
Filter <span class="filter-badge" id="filterBadge" style="display:none;"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profiles-grid" id="profilesGrid"></div>
|
||||||
|
<div id="sentinel"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/icons.js"></script>
|
<script src="/js/icons.js"></script>
|
||||||
<script src="/js/sidebar.js"></script>
|
<script src="/js/sidebar.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
(function () {
|
||||||
|
const BATCH_SIZE = 20;
|
||||||
|
|
||||||
|
let allIds = []; // vollständig gefilterte, nach Entfernung sortierte ID-Liste
|
||||||
|
let loadedCount = 0;
|
||||||
|
let loading = false;
|
||||||
|
let vorliebenUnd = false;
|
||||||
|
const selectedVorlieben = new Map();
|
||||||
|
let allVorlieben = [];
|
||||||
|
|
||||||
|
// ── Auth-Check ────────────────────────────────────────────────────────────
|
||||||
fetch('/login/me').then(r => {
|
fetch('/login/me').then(r => {
|
||||||
if (r.status === 401) window.location.href = '/login.html';
|
if (r.status === 401) { window.location.href = '/login.html'; return null; }
|
||||||
return r.ok ? r.json() : null;
|
return r.ok ? r.json() : null;
|
||||||
}).then(user => {
|
}).then(user => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
if (!user.datingAktiv) window.location.href = '/konto/einstellungen.html#sec-dating';
|
if (!user.datingAktiv) { window.location.href = '/konto/einstellungen.html#sec-dating'; return; }
|
||||||
|
loadVorlieben();
|
||||||
|
if (user.datingGeschlechter && user.datingGeschlechter.length > 0) {
|
||||||
|
document.querySelectorAll('#geschlechtChips .chip').forEach(chip => {
|
||||||
|
chip.classList.toggle('active', user.datingGeschlechter.includes(chip.dataset.val));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
runSearch();
|
||||||
}).catch(() => { window.location.href = '/login.html'; });
|
}).catch(() => { window.location.href = '/login.html'; });
|
||||||
|
|
||||||
|
// ── IntersectionObserver für Infinite Scroll ──────────────────────────────
|
||||||
|
const sentinel = document.getElementById('sentinel');
|
||||||
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
if (entries[0].isIntersecting) loadNextBatch();
|
||||||
|
}, { rootMargin: '300px' });
|
||||||
|
observer.observe(sentinel);
|
||||||
|
|
||||||
|
// ── Overlay öffnen/schließen ──────────────────────────────────────────────
|
||||||
|
const filterDrawer = document.getElementById('filterDrawer');
|
||||||
|
const filterBg = document.getElementById('filterBg');
|
||||||
|
|
||||||
|
function openFilter() {
|
||||||
|
filterDrawer.classList.add('open');
|
||||||
|
filterBg.classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
function closeFilter() {
|
||||||
|
filterDrawer.classList.remove('open');
|
||||||
|
filterBg.classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('filterOpenBtn').addEventListener('click', openFilter);
|
||||||
|
document.getElementById('filterCloseBtn').addEventListener('click', closeFilter);
|
||||||
|
filterBg.addEventListener('click', closeFilter);
|
||||||
|
|
||||||
|
// ── Vorlieben laden ───────────────────────────────────────────────────────
|
||||||
|
function loadVorlieben() {
|
||||||
|
fetch('/vorlieben/items').then(r => r.json()).then(kategorien => {
|
||||||
|
allVorlieben = kategorien.flatMap(k => k.items.map(i => ({ itemId: i.itemId, name: i.name })));
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chip-Toggle ───────────────────────────────────────────────────────────
|
||||||
|
document.querySelectorAll('.chip-group').forEach(group => {
|
||||||
|
group.addEventListener('click', e => {
|
||||||
|
const chip = e.target.closest('.chip');
|
||||||
|
if (chip) chip.classList.toggle('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Logik-Toggle ─────────────────────────────────────────────────────────
|
||||||
|
document.querySelectorAll('#logicToggle .logic-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('#logicToggle .logic-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
vorliebenUnd = btn.dataset.val === 'true';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Dual-Handle Alters-Slider ─────────────────────────────────────────────
|
||||||
|
const AGE_MIN = 18, AGE_MAX = 99;
|
||||||
|
let ageFrom = 18, ageTo = 60;
|
||||||
|
|
||||||
|
const thumbMin = document.getElementById('thumbMin');
|
||||||
|
const thumbMax = document.getElementById('thumbMax');
|
||||||
|
const ageRange = document.getElementById('ageRange');
|
||||||
|
const ageSlider = document.getElementById('ageSlider');
|
||||||
|
|
||||||
|
function pct(val) { return (val - AGE_MIN) / (AGE_MAX - AGE_MIN) * 100; }
|
||||||
|
|
||||||
|
function updateAgeSlider() {
|
||||||
|
const lo = pct(ageFrom), hi = pct(ageTo);
|
||||||
|
thumbMin.style.left = lo + '%';
|
||||||
|
thumbMax.style.left = hi + '%';
|
||||||
|
ageRange.style.left = lo + '%';
|
||||||
|
ageRange.style.width = (hi - lo) + '%';
|
||||||
|
thumbMin.setAttribute('aria-valuenow', ageFrom);
|
||||||
|
thumbMax.setAttribute('aria-valuenow', ageTo);
|
||||||
|
document.getElementById('ageVal').textContent = ageFrom + ' – ' + ageTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDraggable(thumb, isMin) {
|
||||||
|
function onMove(clientX) {
|
||||||
|
const rect = ageSlider.getBoundingClientRect();
|
||||||
|
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
|
const raw = AGE_MIN + Math.round(ratio * (AGE_MAX - AGE_MIN));
|
||||||
|
if (isMin) {
|
||||||
|
ageFrom = Math.min(raw, ageTo - 1);
|
||||||
|
} else {
|
||||||
|
ageTo = Math.max(raw, ageFrom + 1);
|
||||||
|
}
|
||||||
|
updateAgeSlider();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse
|
||||||
|
thumb.addEventListener('mousedown', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const move = ev => onMove(ev.clientX);
|
||||||
|
const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
|
||||||
|
document.addEventListener('mousemove', move);
|
||||||
|
document.addEventListener('mouseup', up);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch
|
||||||
|
thumb.addEventListener('touchstart', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const move = ev => onMove(ev.touches[0].clientX);
|
||||||
|
const end = () => { document.removeEventListener('touchmove', move); document.removeEventListener('touchend', end); };
|
||||||
|
document.addEventListener('touchmove', move, { passive: false });
|
||||||
|
document.addEventListener('touchend', end);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Keyboard
|
||||||
|
thumb.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||||
|
if (isMin) ageFrom = Math.max(AGE_MIN, ageFrom - 1);
|
||||||
|
else ageTo = Math.max(ageFrom + 1, ageTo - 1);
|
||||||
|
updateAgeSlider(); e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||||
|
if (isMin) ageFrom = Math.min(ageTo - 1, ageFrom + 1);
|
||||||
|
else ageTo = Math.min(AGE_MAX, ageTo + 1);
|
||||||
|
updateAgeSlider(); e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
makeDraggable(thumbMin, true);
|
||||||
|
makeDraggable(thumbMax, false);
|
||||||
|
updateAgeSlider();
|
||||||
|
|
||||||
|
// ── Vorlieben-Suche ───────────────────────────────────────────────────────
|
||||||
|
const vlSearchEl = document.getElementById('vlSearch');
|
||||||
|
const vlDropdown = document.getElementById('vlDropdown');
|
||||||
|
|
||||||
|
vlSearchEl.addEventListener('input', () => {
|
||||||
|
const q = vlSearchEl.value.trim().toLowerCase();
|
||||||
|
if (!q) { vlDropdown.classList.remove('open'); return; }
|
||||||
|
const matches = allVorlieben
|
||||||
|
.filter(v => v.name.toLowerCase().includes(q) && !selectedVorlieben.has(v.itemId))
|
||||||
|
.slice(0, 15);
|
||||||
|
vlDropdown.innerHTML = matches.map(v =>
|
||||||
|
`<div class="vl-item" data-id="${v.itemId}" data-name="${escHtml(v.name)}">${escHtml(v.name)}</div>`
|
||||||
|
).join('');
|
||||||
|
vlDropdown.classList.toggle('open', matches.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
vlDropdown.addEventListener('click', e => {
|
||||||
|
const item = e.target.closest('.vl-item');
|
||||||
|
if (!item) return;
|
||||||
|
selectedVorlieben.set(item.dataset.id, item.dataset.name);
|
||||||
|
renderSelectedVorlieben();
|
||||||
|
vlSearchEl.value = '';
|
||||||
|
vlDropdown.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (!vlSearchEl.contains(e.target) && !vlDropdown.contains(e.target))
|
||||||
|
vlDropdown.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderSelectedVorlieben() {
|
||||||
|
document.getElementById('selectedVorlieben').innerHTML =
|
||||||
|
[...selectedVorlieben.entries()].map(([id, name]) =>
|
||||||
|
`<span class="vl-tag">${escHtml(name)}<button onclick="removeVorliebe('${id}')" title="Entfernen">✕</button></span>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeVorliebe = function (id) {
|
||||||
|
selectedVorlieben.delete(id);
|
||||||
|
renderSelectedVorlieben();
|
||||||
|
updateFilterBadge();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Reset ─────────────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('resetBtn').addEventListener('click', () => {
|
||||||
|
document.getElementById('maxDist').value = 50;
|
||||||
|
document.getElementById('distVal').textContent = '50 km';
|
||||||
|
ageFrom = 18; ageTo = 60;
|
||||||
|
updateAgeSlider();
|
||||||
|
document.querySelectorAll('.chip.active').forEach(c => c.classList.remove('active'));
|
||||||
|
document.querySelectorAll('#logicToggle .logic-btn').forEach((b, i) => b.classList.toggle('active', i === 0));
|
||||||
|
vorliebenUnd = false;
|
||||||
|
selectedVorlieben.clear();
|
||||||
|
renderSelectedVorlieben();
|
||||||
|
updateFilterBadge();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Suchen-Button ─────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('applyBtn').addEventListener('click', () => {
|
||||||
|
closeFilter();
|
||||||
|
updateFilterBadge();
|
||||||
|
runSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Filter-Badge ──────────────────────────────────────────────────────────
|
||||||
|
function updateFilterBadge() {
|
||||||
|
let count = 0;
|
||||||
|
if (parseInt(document.getElementById('maxDist').value) !== 50) count++;
|
||||||
|
if (ageFrom !== 18 || ageTo !== 60) count++;
|
||||||
|
if (document.querySelectorAll('#geschlechtChips .chip.active').length > 0) count++;
|
||||||
|
if (document.querySelectorAll('#neigungChips .chip.active').length > 0) count++;
|
||||||
|
if (selectedVorlieben.size > 0) count++;
|
||||||
|
const badge = document.getElementById('filterBadge');
|
||||||
|
badge.textContent = count;
|
||||||
|
badge.style.display = count > 0 ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schritt 1: IDs laden ──────────────────────────────────────────────────
|
||||||
|
async function runSearch() {
|
||||||
|
allIds = [];
|
||||||
|
loadedCount = 0;
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
document.getElementById('profilesGrid').innerHTML = skeletons(BATCH_SIZE);
|
||||||
|
document.getElementById('resultsCount').textContent = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/dating/profile-ids?' + buildParams());
|
||||||
|
if (res.status === 403) { window.location.href = '/konto/einstellungen.html#sec-dating'; return; }
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
allIds = data.ids;
|
||||||
|
document.getElementById('resultsCount').textContent =
|
||||||
|
data.total === 0
|
||||||
|
? 'Keine Ergebnisse'
|
||||||
|
: data.total + ' Person' + (data.total === 1 ? '' : 'en') + ' gefunden';
|
||||||
|
|
||||||
|
document.getElementById('profilesGrid').innerHTML = '';
|
||||||
|
|
||||||
|
if (allIds.length === 0) {
|
||||||
|
document.getElementById('profilesGrid').innerHTML =
|
||||||
|
'<div class="empty-state"><div class="icon">🔍</div><p>Niemand in deinem Umkreis mit diesen Filtern gefunden.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadNextBatch();
|
||||||
|
} catch {
|
||||||
|
document.getElementById('profilesGrid').innerHTML =
|
||||||
|
'<div class="empty-state"><div class="icon">⚠️</div><p>Fehler beim Laden.</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schritt 2: Profildetails batchweise nachladen ─────────────────────────
|
||||||
|
async function loadNextBatch() {
|
||||||
|
if (loading || loadedCount >= allIds.length) return;
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
const batchIds = allIds.slice(loadedCount, loadedCount + BATCH_SIZE);
|
||||||
|
showSkeletons(batchIds.length);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/dating/profiles/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(batchIds)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const profiles = await res.json();
|
||||||
|
removeSkeletons();
|
||||||
|
appendProfiles(profiles);
|
||||||
|
loadedCount += batchIds.length;
|
||||||
|
} catch {
|
||||||
|
removeSkeletons();
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
function appendProfiles(profiles) {
|
||||||
|
const grid = document.getElementById('profilesGrid');
|
||||||
|
profiles.forEach(p => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'profile-card';
|
||||||
|
|
||||||
|
const img = p.profilePicture
|
||||||
|
? `<img src="${p.profilePicture}" alt="${escHtml(p.name)}" loading="lazy">`
|
||||||
|
: `<span>👤</span>`;
|
||||||
|
|
||||||
|
const chips = [
|
||||||
|
p.alter ? `<span class="meta-chip">${p.alter} J.</span>` : '',
|
||||||
|
p.distanzKm != null ? `<span class="meta-chip dist">${p.distanzKm < 1 ? '< 1' : p.distanzKm} km</span>` : '',
|
||||||
|
p.geschlecht ? `<span class="meta-chip">${escHtml(p.geschlecht)}</span>` : '',
|
||||||
|
p.neigung ? `<span class="meta-chip">${escHtml(p.neigung)}</span>` : '',
|
||||||
|
p.datingStadt ? `<span class="meta-chip">${escHtml(p.datingStadt)}</span>` : '',
|
||||||
|
].filter(Boolean).join('');
|
||||||
|
|
||||||
|
const desc = p.beschreibung
|
||||||
|
? `<div class="profile-card-desc">${escHtml(p.beschreibung)}</div>` : '';
|
||||||
|
|
||||||
|
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}
|
||||||
|
<button class="profile-card-like${p.likedByMe ? ' liked' : ''}"
|
||||||
|
data-user-id="${p.userId}"
|
||||||
|
title="${p.likedByMe ? 'Unlike' : 'Like'}">♥</button>
|
||||||
|
</div>
|
||||||
|
<div class="profile-card-body">
|
||||||
|
<div class="profile-card-name">${escHtml(p.name)}</div>
|
||||||
|
<div class="profile-card-meta">${chips}</div>
|
||||||
|
${desc}
|
||||||
|
</div>
|
||||||
|
</a>`;
|
||||||
|
|
||||||
|
card.querySelector('.profile-card-like').addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleLike(e.currentTarget, p.userId);
|
||||||
|
});
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLike(btn, userId) {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/dating/like/' + userId, { method: 'POST' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
btn.classList.toggle('liked', data.liked);
|
||||||
|
btn.title = data.liked ? 'Unlike' : 'Like';
|
||||||
|
if (data.newMatch) showMatchToast();
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMatchToast() {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.textContent = '🎉 Es ist ein Match!';
|
||||||
|
Object.assign(t.style, {
|
||||||
|
position:'fixed', bottom:'2rem', left:'50%', transform:'translateX(-50%)',
|
||||||
|
background:'var(--color-primary)', color:'#fff', padding:'0.75rem 1.5rem',
|
||||||
|
borderRadius:'8px', fontWeight:'700', zIndex:'999', boxShadow:'0 4px 16px rgba(0,0,0,0.4)'
|
||||||
|
});
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Skeleton-Helpers ──────────────────────────────────────────────────────
|
||||||
|
function skeletons(n) {
|
||||||
|
return Array.from({ length: n }, () => `
|
||||||
|
<div class="profile-card-skeleton" data-skeleton>
|
||||||
|
<div class="skeleton-img"></div>
|
||||||
|
<div class="skeleton-body">
|
||||||
|
<div class="skeleton-line" style="width:60%"></div>
|
||||||
|
<div class="skeleton-line" style="width:80%"></div>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSkeletons(n) {
|
||||||
|
const grid = document.getElementById('profilesGrid');
|
||||||
|
grid.insertAdjacentHTML('beforeend', skeletons(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSkeletons() {
|
||||||
|
document.querySelectorAll('[data-skeleton]').forEach(el => el.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Params-Builder ────────────────────────────────────────────────────────
|
||||||
|
function buildParams() {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set('maxDistanceKm', document.getElementById('maxDist').value);
|
||||||
|
p.set('minAge', ageFrom);
|
||||||
|
p.set('maxAge', ageTo);
|
||||||
|
p.set('vorliebenUnd', vorliebenUnd);
|
||||||
|
document.querySelectorAll('#geschlechtChips .chip.active').forEach(c => p.append('geschlechter', c.dataset.val));
|
||||||
|
document.querySelectorAll('#neigungChips .chip.active').forEach(c => p.append('neigungen', c.dataset.val));
|
||||||
|
selectedVorlieben.forEach((_, id) => p.append('vorliebenIds', id));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utility ───────────────────────────────────────────────────────────────
|
||||||
|
function escHtml(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -488,6 +488,31 @@
|
|||||||
<input type="checkbox" id="datingAktiv" style="width:1.1rem;height:1.1rem;accent-color:var(--color-primary);cursor:pointer;" onchange="onDatingToggle()">
|
<input type="checkbox" id="datingAktiv" style="width:1.1rem;height:1.1rem;accent-color:var(--color-primary);cursor:pointer;" onchange="onDatingToggle()">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="datingSucheRow" style="display:none;">
|
||||||
|
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||||
|
<div class="settings-row-info">
|
||||||
|
<div class="settings-row-label">Interesse an</div>
|
||||||
|
<div class="settings-row-desc">Welche Geschlechter sollen standardmäßig in der Dating-Suche angezeigt werden?</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:1rem;flex-wrap:wrap;">
|
||||||
|
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;margin:0;font-size:0.9rem;color:var(--color-text);">
|
||||||
|
<input type="checkbox" id="sucheWeiblich" value="WEIBLICH"
|
||||||
|
style="width:1rem;height:1rem;accent-color:var(--color-primary);cursor:pointer;">
|
||||||
|
weiblich
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;margin:0;font-size:0.9rem;color:var(--color-text);">
|
||||||
|
<input type="checkbox" id="sucheMaennlich" value="MAENNLICH"
|
||||||
|
style="width:1rem;height:1rem;accent-color:var(--color-primary);cursor:pointer;">
|
||||||
|
männlich
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;margin:0;font-size:0.9rem;color:var(--color-text);">
|
||||||
|
<input type="checkbox" id="sucheDivers" value="DIVERS"
|
||||||
|
style="width:1rem;height:1rem;accent-color:var(--color-primary);cursor:pointer;">
|
||||||
|
divers
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="datingStadtRow" style="display:none;">
|
<div id="datingStadtRow" style="display:none;">
|
||||||
<div class="settings-row" style="flex-wrap:wrap;gap:0.5rem;">
|
<div class="settings-row" style="flex-wrap:wrap;gap:0.5rem;">
|
||||||
<div class="settings-row-info">
|
<div class="settings-row-info">
|
||||||
@@ -1201,12 +1226,19 @@
|
|||||||
}
|
}
|
||||||
if (user.datingLat != null) _datingLat = user.datingLat;
|
if (user.datingLat != null) _datingLat = user.datingLat;
|
||||||
if (user.datingLon != null) _datingLon = user.datingLon;
|
if (user.datingLon != null) _datingLon = user.datingLon;
|
||||||
document.getElementById('datingStadtRow').style.display = user.datingAktiv ? '' : 'none';
|
const show = user.datingAktiv ? '' : 'none';
|
||||||
|
document.getElementById('datingStadtRow').style.display = show;
|
||||||
|
document.getElementById('datingSucheRow').style.display = show;
|
||||||
|
const aktiveGeschlechter = user.datingGeschlechter || [];
|
||||||
|
document.getElementById('sucheWeiblich').checked = aktiveGeschlechter.includes('WEIBLICH');
|
||||||
|
document.getElementById('sucheMaennlich').checked = aktiveGeschlechter.includes('MAENNLICH');
|
||||||
|
document.getElementById('sucheDivers').checked = aktiveGeschlechter.includes('DIVERS');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDatingToggle() {
|
function onDatingToggle() {
|
||||||
const aktiv = document.getElementById('datingAktiv').checked;
|
const show = document.getElementById('datingAktiv').checked ? '' : 'none';
|
||||||
document.getElementById('datingStadtRow').style.display = aktiv ? '' : 'none';
|
document.getElementById('datingStadtRow').style.display = show;
|
||||||
|
document.getElementById('datingSucheRow').style.display = show;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _stadtSuggestTimer = null;
|
let _stadtSuggestTimer = null;
|
||||||
@@ -1312,7 +1344,15 @@
|
|||||||
const res = await fetch('/user/me/dating', {
|
const res = await fetch('/user/me/dating', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ datingAktiv: aktiv, datingStadt: stadt || null, datingLat: _datingLat, datingLon: _datingLon })
|
body: JSON.stringify({
|
||||||
|
datingAktiv: aktiv,
|
||||||
|
datingStadt: stadt || null,
|
||||||
|
datingLat: _datingLat,
|
||||||
|
datingLon: _datingLon,
|
||||||
|
datingGeschlechter: ['sucheWeiblich','sucheMaennlich','sucheDivers']
|
||||||
|
.filter(id => document.getElementById(id).checked)
|
||||||
|
.map(id => document.getElementById(id).value)
|
||||||
|
})
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast();
|
showToast();
|
||||||
|
|||||||
@@ -60,6 +60,53 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.visitor-time { font-size: 0.68rem; color: var(--color-muted); text-align: center; }
|
.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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="app">
|
<body class="app">
|
||||||
@@ -105,6 +152,18 @@
|
|||||||
<div class="section-label">Profilbesucher</div>
|
<div class="section-label">Profilbesucher</div>
|
||||||
<div class="visitors-strip" id="visitorsStrip"></div>
|
<div class="visitors-strip" id="visitorsStrip"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Wer hat mich geliked (Dating) -->
|
||||||
|
<div id="likesSection" style="display:none;">
|
||||||
|
<div class="section-label">Dating – Wer mag mich ♥</div>
|
||||||
|
<div class="dating-strip" id="likesStrip"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Matches -->
|
||||||
|
<div id="matchesSection" style="display:none;">
|
||||||
|
<div class="section-label">Dating – Matches 🎉</div>
|
||||||
|
<div class="dating-strip" id="matchesStrip"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,6 +179,10 @@
|
|||||||
if (user) {
|
if (user) {
|
||||||
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
||||||
loadVisitors();
|
loadVisitors();
|
||||||
|
if (user.datingAktiv) {
|
||||||
|
loadWhoLikesMe();
|
||||||
|
loadMatches();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { window.location.href = '/login.html'; });
|
.catch(() => { window.location.href = '/login.html'; });
|
||||||
@@ -132,6 +195,69 @@
|
|||||||
return 'vor ' + Math.floor(diff / 86400) + ' Tag' + (Math.floor(diff / 86400) === 1 ? '' : 'en');
|
return 'vor ' + Math.floor(diff / 86400) + ' Tag' + (Math.floor(diff / 86400) === 1 ? '' : 'en');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadWhoLikesMe() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/dating/who-likes-me');
|
||||||
|
if (!res.ok) return;
|
||||||
|
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="">`
|
||||||
|
: '◉';
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.getElementById('likesSection').style.display = '';
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMatches() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/dating/matches');
|
||||||
|
if (!res.ok) return;
|
||||||
|
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 = '';
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadVisitors() {
|
async function loadVisitors() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/social/profile-visits/my-visitors');
|
const res = await fetch('/social/profile-visits/my-visitors');
|
||||||
|
|||||||
194
src/main/java/de/oaa/xxx/dating/DatingController.java
Normal file
194
src/main/java/de/oaa/xxx/dating/DatingController.java
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package de.oaa.xxx.dating;
|
||||||
|
|
||||||
|
import de.oaa.xxx.social.SseService;
|
||||||
|
import de.oaa.xxx.subscription.SubscriptionLimitService;
|
||||||
|
import de.oaa.xxx.user.Geschlecht;
|
||||||
|
import de.oaa.xxx.user.Neigung;
|
||||||
|
import de.oaa.xxx.user.UserEntity;
|
||||||
|
import de.oaa.xxx.user.UserRepository;
|
||||||
|
import de.oaa.xxx.user.UserService;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/dating")
|
||||||
|
public class DatingController {
|
||||||
|
|
||||||
|
private final DatingService datingService;
|
||||||
|
private final UserService userService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final DatingPassRepository passRepository;
|
||||||
|
private final SubscriptionLimitService subscriptionLimitService;
|
||||||
|
private final SseService sseService;
|
||||||
|
|
||||||
|
public DatingController(DatingService datingService, UserService userService,
|
||||||
|
UserRepository userRepository,
|
||||||
|
DatingPassRepository passRepository,
|
||||||
|
SubscriptionLimitService subscriptionLimitService,
|
||||||
|
SseService sseService) {
|
||||||
|
this.datingService = datingService;
|
||||||
|
this.userService = userService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.passRepository = passRepository;
|
||||||
|
this.subscriptionLimitService = subscriptionLimitService;
|
||||||
|
this.sseService = sseService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Profilsuche ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/profile-ids")
|
||||||
|
public ResponseEntity<DatingService.IdsResult> getProfileIds(
|
||||||
|
@RequestParam(name = "maxDistanceKm", defaultValue = "50") int maxDistanceKm,
|
||||||
|
@RequestParam(name = "minAge", defaultValue = "18") int minAge,
|
||||||
|
@RequestParam(name = "maxAge", defaultValue = "99") int maxAge,
|
||||||
|
@RequestParam(name = "geschlechter", required = false) List<String> geschlechter,
|
||||||
|
@RequestParam(name = "neigungen", required = false) List<String> neigungen,
|
||||||
|
@RequestParam(name = "vorliebenIds", required = false) List<UUID> vorliebenIds,
|
||||||
|
@RequestParam(name = "vorliebenUnd", defaultValue = "false") boolean vorliebenUnd,
|
||||||
|
Principal principal) {
|
||||||
|
|
||||||
|
UserEntity me = requireDatingUser(principal);
|
||||||
|
|
||||||
|
DatingService.DatingFilter filter = new DatingService.DatingFilter(
|
||||||
|
Math.max(maxDistanceKm, 1),
|
||||||
|
Math.max(minAge, 0),
|
||||||
|
Math.min(maxAge, 120),
|
||||||
|
parseEnumList(geschlechter, Geschlecht.class),
|
||||||
|
parseEnumList(neigungen, Neigung.class),
|
||||||
|
vorliebenIds != null ? vorliebenIds : List.of(),
|
||||||
|
vorliebenUnd
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(datingService.findSortedIds(
|
||||||
|
me.getUserId(), filter, me.getDatingLat(), me.getDatingLon()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/profiles/batch")
|
||||||
|
public ResponseEntity<List<DatingService.DatingProfileDto>> getProfileBatch(
|
||||||
|
@RequestBody List<UUID> ids,
|
||||||
|
Principal principal) {
|
||||||
|
|
||||||
|
UserEntity me = requireDatingUser(principal);
|
||||||
|
if (ids == null || ids.isEmpty()) return ResponseEntity.ok(List.of());
|
||||||
|
List<UUID> capped = ids.stream().filter(Objects::nonNull).limit(50).toList();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(datingService.getProfilesByIds(
|
||||||
|
capped, me.getUserId(), me.getDatingLat(), me.getDatingLon()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/discovery")
|
||||||
|
public ResponseEntity<List<UUID>> getDiscovery(Principal principal) {
|
||||||
|
UserEntity me = requireDatingUser(principal);
|
||||||
|
return ResponseEntity.ok(datingService.findDiscoveryIds(me));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/pass/{targetId}")
|
||||||
|
public ResponseEntity<Void> passProfile(@PathVariable("targetId") UUID targetId, Principal principal) {
|
||||||
|
UserEntity me = userService.requireUser(principal);
|
||||||
|
if (me.getUserId().equals(targetId)) return ResponseEntity.badRequest().build();
|
||||||
|
if (!passRepository.existsByPasserIdAndPassedId(me.getUserId(), targetId)) {
|
||||||
|
DatingPassEntity pass = new DatingPassEntity();
|
||||||
|
pass.setPassId(java.util.UUID.randomUUID());
|
||||||
|
pass.setPasserId(me.getUserId());
|
||||||
|
pass.setPassedId(targetId);
|
||||||
|
pass.setPassedAt(java.time.LocalDateTime.now());
|
||||||
|
passRepository.save(pass);
|
||||||
|
}
|
||||||
|
return ResponseEntity.status(201).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Likes ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggelt einen Like auf ein Profil (Like wenn nicht vorhanden, Unlike wenn vorhanden).
|
||||||
|
* Gibt zurück ob jetzt geliked wird und ob ein neues Match entstanden ist.
|
||||||
|
* Setzt Dating-Aktiv beim eigenen User voraus; beim Ziel-User wird es nicht geprüft
|
||||||
|
* (damit auch Profil-Seiten außerhalb der Dating-Suche geliked werden können).
|
||||||
|
*/
|
||||||
|
@PostMapping("/like/{targetId}")
|
||||||
|
public ResponseEntity<DatingService.LikeResult> toggleLike(
|
||||||
|
@PathVariable("targetId") UUID targetId,
|
||||||
|
Principal principal) {
|
||||||
|
|
||||||
|
UserEntity me = userService.requireUser(principal);
|
||||||
|
if (!me.isDatingAktiv()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Dating nicht aktiviert");
|
||||||
|
}
|
||||||
|
if (me.getUserId().equals(targetId)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Eigenes Profil kann nicht geliked werden");
|
||||||
|
}
|
||||||
|
DatingService.LikeResult result = datingService.toggleLike(me.getUserId(), targetId);
|
||||||
|
if (result.liked() && result.newMatch()) {
|
||||||
|
userRepository.findById(targetId).ifPresent(target -> {
|
||||||
|
String myPic = me.getProfilePicture() != null ? me.getProfilePicture() : "";
|
||||||
|
String theirPic = target.getProfilePicture() != null ? target.getProfilePicture() : "";
|
||||||
|
sseService.push(targetId, "MATCH", Map.of("partnerId", me.getUserId().toString(), "partnerName", me.getName(), "partnerPicture", myPic));
|
||||||
|
sseService.push(me.getUserId(), "MATCH", Map.of("partnerId", targetId.toString(), "partnerName", target.getName(), "partnerPicture", theirPic));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle User-IDs, die der eingeloggte User geliked hat.
|
||||||
|
* Wird vom Profil-Frontend genutzt um den Like-Status initial zu setzen.
|
||||||
|
*/
|
||||||
|
@GetMapping("/liked-by-me")
|
||||||
|
public ResponseEntity<List<UUID>> getLikedByMe(Principal principal) {
|
||||||
|
UserEntity me = userService.requireUser(principal);
|
||||||
|
return ResponseEntity.ok(datingService.getLikedByMe(me.getUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wer hat mich geliked? Premium-geschützt:
|
||||||
|
* - Premium: volle Profildaten (userId, name, Bild)
|
||||||
|
* - Kein Premium: nur Profilbild (anonymisiert), userId + name = null
|
||||||
|
*/
|
||||||
|
@GetMapping("/who-likes-me")
|
||||||
|
public ResponseEntity<DatingService.WhoLikesMeResult> whoLikesMe(Principal principal) {
|
||||||
|
UserEntity me = userService.requireUser(principal);
|
||||||
|
boolean premium = subscriptionLimitService.hasActivePaidSubscription(me.getUserId());
|
||||||
|
return ResponseEntity.ok(datingService.whoLikesMe(me.getUserId(), premium));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Matches des eingeloggten Users (gegenseitige Likes).
|
||||||
|
*/
|
||||||
|
@GetMapping("/matches")
|
||||||
|
public ResponseEntity<List<DatingService.MatchDto>> getMatches(Principal principal) {
|
||||||
|
UserEntity me = userService.requireUser(principal);
|
||||||
|
return ResponseEntity.ok(datingService.getMatches(me.getUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private UserEntity requireDatingUser(Principal principal) {
|
||||||
|
UserEntity me = userService.requireUser(principal);
|
||||||
|
if (!me.isDatingAktiv()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Dating nicht aktiviert");
|
||||||
|
}
|
||||||
|
if (me.getDatingLat() == null || me.getDatingLon() == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Standort nicht gesetzt");
|
||||||
|
}
|
||||||
|
return me;
|
||||||
|
}
|
||||||
|
|
||||||
|
private <E extends Enum<E>> List<E> parseEnumList(List<String> values, Class<E> enumClass) {
|
||||||
|
if (values == null || values.isEmpty()) return List.of();
|
||||||
|
return values.stream()
|
||||||
|
.map(s -> {
|
||||||
|
try { return Enum.valueOf(enumClass, s); }
|
||||||
|
catch (IllegalArgumentException e) { return null; }
|
||||||
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/main/java/de/oaa/xxx/dating/DatingLikeEntity.java
Normal file
29
src/main/java/de/oaa/xxx/dating/DatingLikeEntity.java
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package de.oaa.xxx.dating;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "dating_like",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"liker_id", "liked_id"}))
|
||||||
|
public class DatingLikeEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column
|
||||||
|
private UUID likeId;
|
||||||
|
|
||||||
|
@Column(name = "liker_id", nullable = false)
|
||||||
|
private UUID likerId;
|
||||||
|
|
||||||
|
@Column(name = "liked_id", nullable = false)
|
||||||
|
private UUID likedId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime likedAt;
|
||||||
|
}
|
||||||
21
src/main/java/de/oaa/xxx/dating/DatingLikeRepository.java
Normal file
21
src/main/java/de/oaa/xxx/dating/DatingLikeRepository.java
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package de.oaa.xxx.dating;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface DatingLikeRepository extends JpaRepository<DatingLikeEntity, UUID> {
|
||||||
|
|
||||||
|
boolean existsByLikerIdAndLikedId(UUID likerId, UUID likedId);
|
||||||
|
|
||||||
|
void deleteByLikerIdAndLikedId(UUID likerId, UUID likedId);
|
||||||
|
|
||||||
|
/** Alle User-IDs, die der Liker geliked hat. */
|
||||||
|
List<DatingLikeEntity> findByLikerId(UUID likerId);
|
||||||
|
|
||||||
|
/** Alle Likes, die auf den User zeigen (wer hat ihn geliked). */
|
||||||
|
List<DatingLikeEntity> findByLikedId(UUID likedId);
|
||||||
|
|
||||||
|
void deleteByLikerIdOrLikedId(UUID likerId, UUID likedId);
|
||||||
|
}
|
||||||
28
src/main/java/de/oaa/xxx/dating/DatingMatchEntity.java
Normal file
28
src/main/java/de/oaa/xxx/dating/DatingMatchEntity.java
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package de.oaa.xxx.dating;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "dating_match")
|
||||||
|
public class DatingMatchEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column
|
||||||
|
private UUID matchId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private UUID user1Id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private UUID user2Id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime matchedAt;
|
||||||
|
}
|
||||||
27
src/main/java/de/oaa/xxx/dating/DatingMatchRepository.java
Normal file
27
src/main/java/de/oaa/xxx/dating/DatingMatchRepository.java
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package de.oaa.xxx.dating;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface DatingMatchRepository extends JpaRepository<DatingMatchEntity, UUID> {
|
||||||
|
|
||||||
|
@Query("SELECT m FROM DatingMatchEntity m WHERE m.user1Id = :userId OR m.user2Id = :userId")
|
||||||
|
List<DatingMatchEntity> findByUser(@Param("userId") UUID userId);
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(m) > 0 FROM DatingMatchEntity m WHERE (m.user1Id = :a AND m.user2Id = :b) OR (m.user1Id = :b AND m.user2Id = :a)")
|
||||||
|
boolean existsByUsers(@Param("a") UUID a, @Param("b") UUID b);
|
||||||
|
|
||||||
|
@Query("DELETE FROM DatingMatchEntity m WHERE (m.user1Id = :a AND m.user2Id = :b) OR (m.user1Id = :b AND m.user2Id = :a)")
|
||||||
|
@org.springframework.data.jpa.repository.Modifying
|
||||||
|
@org.springframework.transaction.annotation.Transactional
|
||||||
|
void deleteByUsers(@Param("a") UUID a, @Param("b") UUID b);
|
||||||
|
|
||||||
|
@Query("DELETE FROM DatingMatchEntity m WHERE m.user1Id = :userId OR m.user2Id = :userId")
|
||||||
|
@org.springframework.data.jpa.repository.Modifying
|
||||||
|
@org.springframework.transaction.annotation.Transactional
|
||||||
|
void deleteAllByUser(@Param("userId") UUID userId);
|
||||||
|
}
|
||||||
28
src/main/java/de/oaa/xxx/dating/DatingPassEntity.java
Normal file
28
src/main/java/de/oaa/xxx/dating/DatingPassEntity.java
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package de.oaa.xxx.dating;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "dating_pass", uniqueConstraints = @UniqueConstraint(columnNames = {"passerId", "passedId"}))
|
||||||
|
public class DatingPassEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column
|
||||||
|
private UUID passId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private UUID passerId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private UUID passedId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime passedAt;
|
||||||
|
}
|
||||||
16
src/main/java/de/oaa/xxx/dating/DatingPassRepository.java
Normal file
16
src/main/java/de/oaa/xxx/dating/DatingPassRepository.java
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package de.oaa.xxx.dating;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface DatingPassRepository extends JpaRepository<DatingPassEntity, UUID> {
|
||||||
|
|
||||||
|
boolean existsByPasserIdAndPassedId(UUID passerId, UUID passedId);
|
||||||
|
|
||||||
|
@Query("SELECT p.passedId FROM DatingPassEntity p WHERE p.passerId = :passerId")
|
||||||
|
Set<UUID> findPassedIdsByPasserId(@Param("passerId") UUID passerId);
|
||||||
|
}
|
||||||
347
src/main/java/de/oaa/xxx/dating/DatingService.java
Normal file
347
src/main/java/de/oaa/xxx/dating/DatingService.java
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
package de.oaa.xxx.dating;
|
||||||
|
|
||||||
|
import de.oaa.xxx.user.Geschlecht;
|
||||||
|
import de.oaa.xxx.user.Neigung;
|
||||||
|
import de.oaa.xxx.user.UserEntity;
|
||||||
|
import de.oaa.xxx.user.UserRepository;
|
||||||
|
import de.oaa.xxx.vorlieben.UserVorliebeEntity;
|
||||||
|
import de.oaa.xxx.vorlieben.UserVorliebeRepository;
|
||||||
|
import de.oaa.xxx.vorlieben.VorliebeBewertung;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DatingService {
|
||||||
|
|
||||||
|
private static final Set<VorliebeBewertung> POSITIVE_RATINGS = Set.of(
|
||||||
|
VorliebeBewertung.MAG_ICH,
|
||||||
|
VorliebeBewertung.UNBEDINGT,
|
||||||
|
VorliebeBewertung.WILL_AUSPROBIEREN
|
||||||
|
);
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final UserVorliebeRepository userVorliebeRepository;
|
||||||
|
private final DatingLikeRepository likeRepository;
|
||||||
|
private final DatingMatchRepository matchRepository;
|
||||||
|
private final DatingPassRepository passRepository;
|
||||||
|
|
||||||
|
public DatingService(UserRepository userRepository,
|
||||||
|
UserVorliebeRepository userVorliebeRepository,
|
||||||
|
DatingLikeRepository likeRepository,
|
||||||
|
DatingMatchRepository matchRepository,
|
||||||
|
DatingPassRepository passRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.userVorliebeRepository = userVorliebeRepository;
|
||||||
|
this.likeRepository = likeRepository;
|
||||||
|
this.matchRepository = matchRepository;
|
||||||
|
this.passRepository = passRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Records ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
record DatingFilter(
|
||||||
|
int maxDistanceKm,
|
||||||
|
int minAge,
|
||||||
|
int maxAge,
|
||||||
|
List<Geschlecht> geschlechter,
|
||||||
|
List<Neigung> neigungen,
|
||||||
|
List<UUID> vorliebenIds,
|
||||||
|
boolean vorliebenUnd
|
||||||
|
) {}
|
||||||
|
|
||||||
|
record DatingProfileDto(
|
||||||
|
UUID userId,
|
||||||
|
String name,
|
||||||
|
Integer alter,
|
||||||
|
double distanzKm,
|
||||||
|
String profilePicture,
|
||||||
|
String neigung,
|
||||||
|
String geschlecht,
|
||||||
|
String datingStadt,
|
||||||
|
String beschreibung,
|
||||||
|
boolean likedByMe
|
||||||
|
) {}
|
||||||
|
|
||||||
|
record LikeResult(boolean liked, boolean newMatch) {}
|
||||||
|
|
||||||
|
record LikerDto(UUID userId, String name, String profilePicture, LocalDateTime likedAt) {}
|
||||||
|
|
||||||
|
record WhoLikesMeResult(boolean premium, int total, List<LikerDto> likers) {}
|
||||||
|
|
||||||
|
record MatchDto(UUID userId, String name, String profilePicture, LocalDateTime matchedAt) {}
|
||||||
|
|
||||||
|
record IdsResult(List<UUID> ids, int total) {}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert alle passenden User-IDs, nach Entfernung sortiert.
|
||||||
|
* Kein Paging – die Liste wird komplett zurückgegeben, das Frontend
|
||||||
|
* holt Profildetails nachgelagert in Batches.
|
||||||
|
*/
|
||||||
|
public IdsResult findSortedIds(UUID currentUserId, DatingFilter filter,
|
||||||
|
double myLat, double myLon) {
|
||||||
|
List<UserEntity> candidates = userRepository.findByDatingAktiv(true).stream()
|
||||||
|
.filter(u -> !u.getUserId().equals(currentUserId))
|
||||||
|
.filter(u -> u.getDatingLat() != null && u.getDatingLon() != null)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
candidates = filterByDistance(candidates, myLat, myLon, filter.maxDistanceKm());
|
||||||
|
candidates = filterByAge(candidates, filter.minAge(), filter.maxAge());
|
||||||
|
|
||||||
|
if (!filter.geschlechter().isEmpty()) {
|
||||||
|
candidates = candidates.stream()
|
||||||
|
.filter(u -> u.getGeschlecht() != null && filter.geschlechter().contains(u.getGeschlecht()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (!filter.neigungen().isEmpty()) {
|
||||||
|
candidates = candidates.stream()
|
||||||
|
.filter(u -> u.getNeigung() != null && filter.neigungen().contains(u.getNeigung()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (!filter.vorliebenIds().isEmpty()) {
|
||||||
|
candidates = filterByVorlieben(candidates, filter.vorliebenIds(), filter.vorliebenUnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<UUID> sorted = candidates.stream()
|
||||||
|
.sorted(Comparator.comparingDouble(u ->
|
||||||
|
haversineKm(myLat, myLon, u.getDatingLat(), u.getDatingLon())))
|
||||||
|
.map(UserEntity::getUserId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new IdsResult(sorted, sorted.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt Profildetails für die übergebenen IDs und behält deren Reihenfolge bei.
|
||||||
|
*/
|
||||||
|
public List<DatingProfileDto> getProfilesByIds(List<UUID> ids, UUID currentUserId, double myLat, double myLon) {
|
||||||
|
if (ids == null || ids.isEmpty()) return List.of();
|
||||||
|
|
||||||
|
Map<UUID, UserEntity> byId = userRepository.findAllById(ids).stream()
|
||||||
|
.collect(Collectors.toMap(UserEntity::getUserId, Function.identity()));
|
||||||
|
|
||||||
|
Set<UUID> likedByMe = likeRepository.findByLikerId(currentUserId).stream()
|
||||||
|
.map(DatingLikeEntity::getLikedId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
return ids.stream()
|
||||||
|
.map(byId::get)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(u -> toDto(u, currentUserId, likedByMe, myLat, myLon))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Like / Match ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public LikeResult toggleLike(UUID likerId, UUID likedId) {
|
||||||
|
if (likeRepository.existsByLikerIdAndLikedId(likerId, likedId)) {
|
||||||
|
likeRepository.deleteByLikerIdAndLikedId(likerId, likedId);
|
||||||
|
matchRepository.deleteByUsers(likerId, likedId);
|
||||||
|
return new LikeResult(false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
DatingLikeEntity like = new DatingLikeEntity();
|
||||||
|
like.setLikeId(UUID.randomUUID());
|
||||||
|
like.setLikerId(likerId);
|
||||||
|
like.setLikedId(likedId);
|
||||||
|
like.setLikedAt(LocalDateTime.now());
|
||||||
|
likeRepository.save(like);
|
||||||
|
|
||||||
|
boolean mutual = likeRepository.existsByLikerIdAndLikedId(likedId, likerId);
|
||||||
|
boolean newMatch = false;
|
||||||
|
if (mutual && !matchRepository.existsByUsers(likerId, likedId)) {
|
||||||
|
DatingMatchEntity match = new DatingMatchEntity();
|
||||||
|
match.setMatchId(UUID.randomUUID());
|
||||||
|
match.setUser1Id(likerId);
|
||||||
|
match.setUser2Id(likedId);
|
||||||
|
match.setMatchedAt(LocalDateTime.now());
|
||||||
|
matchRepository.save(match);
|
||||||
|
newMatch = true;
|
||||||
|
}
|
||||||
|
return new LikeResult(true, newMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
public WhoLikesMeResult whoLikesMe(UUID userId, boolean isPremium) {
|
||||||
|
List<DatingLikeEntity> likes = likeRepository.findByLikedId(userId);
|
||||||
|
int total = likes.size();
|
||||||
|
|
||||||
|
if (total == 0) return new WhoLikesMeResult(isPremium, 0, List.of());
|
||||||
|
|
||||||
|
Map<UUID, UserEntity> byId = userRepository.findAllById(
|
||||||
|
likes.stream().map(DatingLikeEntity::getLikerId).toList()).stream()
|
||||||
|
.collect(Collectors.toMap(UserEntity::getUserId, Function.identity()));
|
||||||
|
|
||||||
|
List<LikerDto> likers = likes.stream()
|
||||||
|
.sorted(Comparator.comparing(DatingLikeEntity::getLikedAt).reversed())
|
||||||
|
.map(l -> {
|
||||||
|
UserEntity u = byId.get(l.getLikerId());
|
||||||
|
if (u == null) return null;
|
||||||
|
return new LikerDto(
|
||||||
|
isPremium ? u.getUserId() : null,
|
||||||
|
isPremium ? u.getName() : null,
|
||||||
|
u.getProfilePicture(),
|
||||||
|
l.getLikedAt());
|
||||||
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new WhoLikesMeResult(isPremium, total, likers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<MatchDto> getMatches(UUID userId) {
|
||||||
|
List<DatingMatchEntity> matches = matchRepository.findByUser(userId);
|
||||||
|
// PartnerId ermitteln
|
||||||
|
Map<UUID, UserEntity> byId = userRepository.findAllById(
|
||||||
|
matches.stream()
|
||||||
|
.map(m -> m.getUser1Id().equals(userId) ? m.getUser2Id() : m.getUser1Id())
|
||||||
|
.toList()).stream()
|
||||||
|
.collect(Collectors.toMap(UserEntity::getUserId, Function.identity()));
|
||||||
|
|
||||||
|
return matches.stream()
|
||||||
|
.sorted(Comparator.comparing(DatingMatchEntity::getMatchedAt).reversed())
|
||||||
|
.map(m -> {
|
||||||
|
UUID partnerId = m.getUser1Id().equals(userId) ? m.getUser2Id() : m.getUser1Id();
|
||||||
|
UserEntity u = byId.get(partnerId);
|
||||||
|
if (u == null) return null;
|
||||||
|
return new MatchDto(u.getUserId(), u.getName(), u.getProfilePicture(), m.getMatchedAt());
|
||||||
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<UUID> getLikedByMe(UUID userId) {
|
||||||
|
return likeRepository.findByLikerId(userId).stream()
|
||||||
|
.map(DatingLikeEntity::getLikedId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert zufällig sortierte IDs für den Discovery-Modus (Match-Tab).
|
||||||
|
* Bedingung: gegenseitige Geschlechter-Kompatibilität, Distanz, noch nicht geliked.
|
||||||
|
*/
|
||||||
|
public List<UUID> findDiscoveryIds(UserEntity me) {
|
||||||
|
Set<String> myGeschlechter = me.getDatingGeschlechter() != null && !me.getDatingGeschlechter().isBlank()
|
||||||
|
? Set.of(me.getDatingGeschlechter().split(","))
|
||||||
|
: Set.of();
|
||||||
|
|
||||||
|
List<UserEntity> candidates = userRepository.findByDatingAktiv(true).stream()
|
||||||
|
.filter(u -> !u.getUserId().equals(me.getUserId()))
|
||||||
|
.filter(u -> u.getDatingLat() != null && u.getDatingLon() != null)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Ich muss an deren Geschlecht interessiert sein
|
||||||
|
if (!myGeschlechter.isEmpty()) {
|
||||||
|
candidates = candidates.stream()
|
||||||
|
.filter(u -> u.getGeschlecht() != null
|
||||||
|
&& myGeschlechter.contains(u.getGeschlecht().name()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sie müssen an meinem Geschlecht interessiert sein (oder keine Einschränkung haben)
|
||||||
|
if (me.getGeschlecht() != null) {
|
||||||
|
final String myGender = me.getGeschlecht().name();
|
||||||
|
candidates = candidates.stream()
|
||||||
|
.filter(u -> {
|
||||||
|
if (u.getDatingGeschlechter() == null || u.getDatingGeschlechter().isBlank()) return true;
|
||||||
|
return Set.of(u.getDatingGeschlechter().split(",")).contains(myGender);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distanz: mein gespeicherter Max-Wert, Fallback 50 km
|
||||||
|
int maxDist = me.getDatingMaxDistanzKm() != null ? me.getDatingMaxDistanzKm() : 50;
|
||||||
|
candidates = filterByDistance(candidates, me.getDatingLat(), me.getDatingLon(), maxDist);
|
||||||
|
|
||||||
|
// Bereits gelikte und abgelehnte Profile ausschließen
|
||||||
|
Set<UUID> alreadyLiked = likeRepository.findByLikerId(me.getUserId()).stream()
|
||||||
|
.map(DatingLikeEntity::getLikedId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
Set<UUID> alreadyPassed = passRepository.findPassedIdsByPasserId(me.getUserId());
|
||||||
|
candidates = candidates.stream()
|
||||||
|
.filter(u -> !alreadyLiked.contains(u.getUserId()) && !alreadyPassed.contains(u.getUserId()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<UUID> ids = new ArrayList<>(candidates.stream().map(UserEntity::getUserId).toList());
|
||||||
|
Collections.shuffle(ids);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private List<UserEntity> filterByDistance(List<UserEntity> users, double myLat, double myLon, int maxKm) {
|
||||||
|
return users.stream()
|
||||||
|
.filter(u -> haversineKm(myLat, myLon, u.getDatingLat(), u.getDatingLon()) <= maxKm)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<UserEntity> filterByAge(List<UserEntity> users, int minAge, int maxAge) {
|
||||||
|
return users.stream()
|
||||||
|
.filter(u -> {
|
||||||
|
Integer age = u.getAlter();
|
||||||
|
if (age == null) return false;
|
||||||
|
return age >= minAge && age <= maxAge;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<UserEntity> filterByVorlieben(List<UserEntity> candidates,
|
||||||
|
List<UUID> requiredItemIds,
|
||||||
|
boolean andLogic) {
|
||||||
|
Set<UUID> candidateIds = candidates.stream()
|
||||||
|
.map(UserEntity::getUserId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
Map<UUID, Set<UUID>> positiveByUser = userVorliebeRepository.findByUserIdIn(candidateIds).stream()
|
||||||
|
.filter(uv -> POSITIVE_RATINGS.contains(uv.getBewertung()))
|
||||||
|
.collect(Collectors.groupingBy(
|
||||||
|
UserVorliebeEntity::getUserId,
|
||||||
|
Collectors.mapping(UserVorliebeEntity::getItemId, Collectors.toSet())
|
||||||
|
));
|
||||||
|
|
||||||
|
Set<UUID> required = new HashSet<>(requiredItemIds);
|
||||||
|
return candidates.stream()
|
||||||
|
.filter(u -> {
|
||||||
|
Set<UUID> userItems = positiveByUser.getOrDefault(u.getUserId(), Set.of());
|
||||||
|
return andLogic
|
||||||
|
? userItems.containsAll(required)
|
||||||
|
: required.stream().anyMatch(userItems::contains);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DatingProfileDto toDto(UserEntity u, UUID currentUserId, Set<UUID> likedByMe, double myLat, double myLon) {
|
||||||
|
double dist = Math.round(haversineKm(myLat, myLon, u.getDatingLat(), u.getDatingLon()) * 10.0) / 10.0;
|
||||||
|
return new DatingProfileDto(
|
||||||
|
u.getUserId(),
|
||||||
|
u.getName(),
|
||||||
|
u.getAlter(),
|
||||||
|
dist,
|
||||||
|
u.getProfilePicture(),
|
||||||
|
u.getNeigung() != null ? u.getNeigung().getLabel() : null,
|
||||||
|
u.getGeschlecht() != null ? u.getGeschlecht().getLabel() : null,
|
||||||
|
u.getDatingStadt(),
|
||||||
|
u.getBeschreibung(),
|
||||||
|
likedByMe.contains(u.getUserId())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haversine-Formel: Luftlinie zwischen zwei Koordinaten in Kilometern.
|
||||||
|
*/
|
||||||
|
static double haversineKm(double lat1, double lon1, double lat2, double lon2) {
|
||||||
|
final double R = 6371.0;
|
||||||
|
double dLat = Math.toRadians(lat2 - lat1);
|
||||||
|
double dLon = Math.toRadians(lon2 - lon1);
|
||||||
|
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
|
||||||
|
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
|
||||||
|
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package de.oaa.xxx.social;
|
|||||||
|
|
||||||
import de.oaa.xxx.social.dto.PinnwandEintragDto;
|
import de.oaa.xxx.social.dto.PinnwandEintragDto;
|
||||||
import de.oaa.xxx.social.entity.PinnwandEintragEntity;
|
import de.oaa.xxx.social.entity.PinnwandEintragEntity;
|
||||||
|
import de.oaa.xxx.social.repository.BlockRepository;
|
||||||
import de.oaa.xxx.social.repository.KommentarRepository;
|
import de.oaa.xxx.social.repository.KommentarRepository;
|
||||||
import de.oaa.xxx.social.repository.PinnwandEintragRepository;
|
import de.oaa.xxx.social.repository.PinnwandEintragRepository;
|
||||||
import de.oaa.xxx.social.repository.PinnwandLikeRepository;
|
import de.oaa.xxx.social.repository.PinnwandLikeRepository;
|
||||||
@@ -30,19 +31,22 @@ public class PinnwandController {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final LikeService likeService;
|
private final LikeService likeService;
|
||||||
|
private final BlockRepository blockRepository;
|
||||||
|
|
||||||
public PinnwandController(PinnwandEintragRepository eintragRepository,
|
public PinnwandController(PinnwandEintragRepository eintragRepository,
|
||||||
PinnwandLikeRepository likeRepository,
|
PinnwandLikeRepository likeRepository,
|
||||||
KommentarRepository kommentarRepository,
|
KommentarRepository kommentarRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
UserService userService,
|
UserService userService,
|
||||||
LikeService likeService) {
|
LikeService likeService,
|
||||||
|
BlockRepository blockRepository) {
|
||||||
this.eintragRepository = eintragRepository;
|
this.eintragRepository = eintragRepository;
|
||||||
this.likeRepository = likeRepository;
|
this.likeRepository = likeRepository;
|
||||||
this.kommentarRepository = kommentarRepository;
|
this.kommentarRepository = kommentarRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.likeService = likeService;
|
this.likeService = likeService;
|
||||||
|
this.blockRepository = blockRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
record CreateEintragRequest(UUID profilUserId, String text) {}
|
record CreateEintragRequest(UUID profilUserId, String text) {}
|
||||||
@@ -66,6 +70,11 @@ public class PinnwandController {
|
|||||||
if (request.text() == null || request.text().isBlank()) return ResponseEntity.badRequest().build();
|
if (request.text() == null || request.text().isBlank()) return ResponseEntity.badRequest().build();
|
||||||
if (request.text().length() > 1000) return ResponseEntity.badRequest().build();
|
if (request.text().length() > 1000) return ResponseEntity.badRequest().build();
|
||||||
|
|
||||||
|
// Blockiert? Profilinhaber kann keine Einträge von blockierten Personen erhalten
|
||||||
|
if (blockRepository.findByBlockerIdAndBlockedId(request.profilUserId(), myId).isPresent()) {
|
||||||
|
return ResponseEntity.status(403).build();
|
||||||
|
}
|
||||||
|
|
||||||
PinnwandEintragEntity entity = new PinnwandEintragEntity();
|
PinnwandEintragEntity entity = new PinnwandEintragEntity();
|
||||||
entity.setEintragId(UUID.randomUUID());
|
entity.setEintragId(UUID.randomUUID());
|
||||||
entity.setProfilUserId(request.profilUserId());
|
entity.setProfilUserId(request.profilUserId());
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
package de.oaa.xxx.social;
|
package de.oaa.xxx.social;
|
||||||
|
|
||||||
|
import de.oaa.xxx.dating.DatingMatchRepository;
|
||||||
import de.oaa.xxx.social.dto.ConversationSummary;
|
import de.oaa.xxx.social.dto.ConversationSummary;
|
||||||
import de.oaa.xxx.social.dto.FriendshipDto;
|
import de.oaa.xxx.social.dto.FriendshipDto;
|
||||||
import de.oaa.xxx.social.dto.MessageDto;
|
import de.oaa.xxx.social.dto.MessageDto;
|
||||||
import de.oaa.xxx.social.dto.UserProfile;
|
import de.oaa.xxx.social.dto.UserProfile;
|
||||||
|
import de.oaa.xxx.social.entity.BlockEntity;
|
||||||
import de.oaa.xxx.social.entity.FriendshipEntity;
|
import de.oaa.xxx.social.entity.FriendshipEntity;
|
||||||
import de.oaa.xxx.social.entity.FriendshipEntity.Status;
|
import de.oaa.xxx.social.entity.FriendshipEntity.Status;
|
||||||
import de.oaa.xxx.social.entity.MessageCause;
|
import de.oaa.xxx.social.entity.MessageCause;
|
||||||
import de.oaa.xxx.social.entity.MessageEntity;
|
import de.oaa.xxx.social.entity.MessageEntity;
|
||||||
|
import de.oaa.xxx.social.repository.BlockRepository;
|
||||||
import de.oaa.xxx.social.repository.FriendshipRepository;
|
import de.oaa.xxx.social.repository.FriendshipRepository;
|
||||||
import de.oaa.xxx.social.repository.MessageRepository;
|
import de.oaa.xxx.social.repository.MessageRepository;
|
||||||
|
import de.oaa.xxx.subscription.SubscriptionLimitService;
|
||||||
import de.oaa.xxx.support.SupportUserService;
|
import de.oaa.xxx.support.SupportUserService;
|
||||||
import de.oaa.xxx.user.UserEntity;
|
import de.oaa.xxx.user.UserEntity;
|
||||||
import de.oaa.xxx.user.UserRepository;
|
import de.oaa.xxx.user.UserRepository;
|
||||||
@@ -33,6 +37,9 @@ public class SocialController {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final FriendshipRepository friendshipRepository;
|
private final FriendshipRepository friendshipRepository;
|
||||||
private final MessageRepository messageRepository;
|
private final MessageRepository messageRepository;
|
||||||
|
private final BlockRepository blockRepository;
|
||||||
|
private final DatingMatchRepository datingMatchRepository;
|
||||||
|
private final SubscriptionLimitService subscriptionLimitService;
|
||||||
private final SseService sseService;
|
private final SseService sseService;
|
||||||
private final SystemMessageService systemMessageService;
|
private final SystemMessageService systemMessageService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
@@ -40,12 +47,18 @@ public class SocialController {
|
|||||||
public SocialController(UserRepository userRepository,
|
public SocialController(UserRepository userRepository,
|
||||||
FriendshipRepository friendshipRepository,
|
FriendshipRepository friendshipRepository,
|
||||||
MessageRepository messageRepository,
|
MessageRepository messageRepository,
|
||||||
|
BlockRepository blockRepository,
|
||||||
|
DatingMatchRepository datingMatchRepository,
|
||||||
|
SubscriptionLimitService subscriptionLimitService,
|
||||||
SseService sseService,
|
SseService sseService,
|
||||||
SystemMessageService systemMessageService,
|
SystemMessageService systemMessageService,
|
||||||
UserService userService) {
|
UserService userService) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.friendshipRepository = friendshipRepository;
|
this.friendshipRepository = friendshipRepository;
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
|
this.blockRepository = blockRepository;
|
||||||
|
this.datingMatchRepository = datingMatchRepository;
|
||||||
|
this.subscriptionLimitService = subscriptionLimitService;
|
||||||
this.sseService = sseService;
|
this.sseService = sseService;
|
||||||
this.systemMessageService = systemMessageService;
|
this.systemMessageService = systemMessageService;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
@@ -202,7 +215,7 @@ public class SocialController {
|
|||||||
// ── Messages ──
|
// ── Messages ──
|
||||||
|
|
||||||
@PostMapping("/messages")
|
@PostMapping("/messages")
|
||||||
public ResponseEntity<Void> sendMessage(@RequestBody SendMessageBody body, Principal principal) {
|
public ResponseEntity<Map<String, String>> sendMessage(@RequestBody SendMessageBody body, Principal principal) {
|
||||||
UUID myId = userService.requireUser(principal).getUserId();
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
|
||||||
if (body.text() == null || body.text().isBlank()) return ResponseEntity.badRequest().build();
|
if (body.text() == null || body.text().isBlank()) return ResponseEntity.badRequest().build();
|
||||||
@@ -212,6 +225,23 @@ public class SocialController {
|
|||||||
return ResponseEntity.status(403).build();
|
return ResponseEntity.status(403).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blockiert? (in beide Richtungen)
|
||||||
|
if (blockRepository.existsBlock(myId, body.receiverId())) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of("reason", "BLOCKED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erste Nachricht in dieser Konversation → Bedingungen prüfen
|
||||||
|
if (!messageRepository.conversationExists(myId, body.receiverId())) {
|
||||||
|
boolean areFriends = friendshipRepository.findExisting(myId, body.receiverId())
|
||||||
|
.filter(f -> f.getStatus() == Status.ACCEPTED).isPresent();
|
||||||
|
boolean haveMatch = datingMatchRepository.existsByUsers(myId, body.receiverId());
|
||||||
|
boolean hasPro = subscriptionLimitService.hasActivePaidSubscription(myId);
|
||||||
|
|
||||||
|
if (!areFriends && !haveMatch && !hasPro) {
|
||||||
|
return ResponseEntity.status(403).body(Map.of("reason", "FIRST_MESSAGE_RESTRICTED"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MessageEntity msg = new MessageEntity();
|
MessageEntity msg = new MessageEntity();
|
||||||
msg.setMessageId(UUID.randomUUID());
|
msg.setMessageId(UUID.randomUUID());
|
||||||
msg.setSenderId(myId);
|
msg.setSenderId(myId);
|
||||||
@@ -297,6 +327,54 @@ public class SocialController {
|
|||||||
return ResponseEntity.ok(Map.of("messages", messages.stream().map(this::toMessageDto).toList(), "hasMore", hasMore));
|
return ResponseEntity.ok(Map.of("messages", messages.stream().map(this::toMessageDto).toList(), "hasMore", hasMore));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Block ──
|
||||||
|
|
||||||
|
@PostMapping("/block/{userId}")
|
||||||
|
public ResponseEntity<Void> blockUser(@PathVariable("userId") UUID targetId, Principal principal) {
|
||||||
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
if (myId.equals(targetId)) return ResponseEntity.badRequest().build();
|
||||||
|
|
||||||
|
// Bereits blockiert?
|
||||||
|
if (blockRepository.findByBlockerIdAndBlockedId(myId, targetId).isPresent()) {
|
||||||
|
return ResponseEntity.status(409).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block speichern
|
||||||
|
BlockEntity block = new BlockEntity();
|
||||||
|
block.setBlockId(UUID.randomUUID());
|
||||||
|
block.setBlockerId(myId);
|
||||||
|
block.setBlockedId(targetId);
|
||||||
|
block.setBlockedAt(LocalDateTime.now());
|
||||||
|
blockRepository.save(block);
|
||||||
|
LOGGER.info("User {} hat User {} blockiert", myId, targetId);
|
||||||
|
|
||||||
|
// Gesamte Konversation löschen
|
||||||
|
messageRepository.deleteConversation(myId, targetId);
|
||||||
|
|
||||||
|
// Bestehende Freundschaft aufheben
|
||||||
|
friendshipRepository.findExisting(myId, targetId).ifPresent(friendshipRepository::delete);
|
||||||
|
|
||||||
|
return ResponseEntity.status(201).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/block/{userId}")
|
||||||
|
public ResponseEntity<Void> unblockUser(@PathVariable("userId") UUID targetId, Principal principal) {
|
||||||
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
if (blockRepository.findByBlockerIdAndBlockedId(myId, targetId).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
blockRepository.deleteByBlockerIdAndBlockedId(myId, targetId);
|
||||||
|
LOGGER.info("User {} hat User {} entblockt", myId, targetId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/block/{userId}")
|
||||||
|
public ResponseEntity<Map<String, Boolean>> getBlockStatus(@PathVariable("userId") UUID targetId, Principal principal) {
|
||||||
|
UUID myId = userService.requireUser(principal).getUserId();
|
||||||
|
boolean blockedByMe = blockRepository.findByBlockerIdAndBlockedId(myId, targetId).isPresent();
|
||||||
|
return ResponseEntity.ok(Map.of("blockedByMe", blockedByMe));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|
||||||
private UserProfile toUserProfileWithStatus(UserEntity user, UUID myId) {
|
private UserProfile toUserProfileWithStatus(UserEntity user, UUID myId) {
|
||||||
@@ -348,7 +426,8 @@ public class SocialController {
|
|||||||
user.getSichtbarkeitXp(),
|
user.getSichtbarkeitXp(),
|
||||||
user.getSichtbarkeitLockhistorie(),
|
user.getSichtbarkeitLockhistorie(),
|
||||||
user.getSichtbarkeitVorlieben(),
|
user.getSichtbarkeitVorlieben(),
|
||||||
user.isProfilBeiVeroeffentlichungenSichtbar());
|
user.isProfilBeiVeroeffentlichungenSichtbar(),
|
||||||
|
user.isDatingAktiv());
|
||||||
}
|
}
|
||||||
|
|
||||||
private MessageDto toMessageDto(MessageEntity m) {
|
private MessageDto toMessageDto(MessageEntity m) {
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ public record UserProfile(
|
|||||||
Sichtbarkeit sichtbarkeitXp,
|
Sichtbarkeit sichtbarkeitXp,
|
||||||
Sichtbarkeit sichtbarkeitLockhistorie,
|
Sichtbarkeit sichtbarkeitLockhistorie,
|
||||||
Sichtbarkeit sichtbarkeitVorlieben,
|
Sichtbarkeit sichtbarkeitVorlieben,
|
||||||
boolean profilBeiVeroeffentlichungenSichtbar
|
boolean profilBeiVeroeffentlichungenSichtbar,
|
||||||
|
boolean datingAktiv
|
||||||
) {
|
) {
|
||||||
/** Compact constructor for contexts where profile details are not needed (friend list etc.) */
|
/** Compact constructor for contexts where profile details are not needed (friend list etc.) */
|
||||||
public UserProfile(UUID userId, String name, String profilePicture, String profilePictureHq, String friendStatus) {
|
public UserProfile(UUID userId, String name, String profilePicture, String profilePictureHq, String friendStatus) {
|
||||||
this(userId, name, profilePicture, profilePictureHq, friendStatus,
|
this(userId, name, profilePicture, profilePictureHq, friendStatus,
|
||||||
null, null, null, null, null, null, null, 0, 0, 0,
|
null, null, null, null, null, null, null, 0, 0, 0,
|
||||||
null, null, null, null, null, null, null, null, false);
|
null, null, null, null, null, null, null, null, false, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/main/java/de/oaa/xxx/social/entity/BlockEntity.java
Normal file
28
src/main/java/de/oaa/xxx/social/entity/BlockEntity.java
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package de.oaa.xxx.social.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "user_block", uniqueConstraints = @UniqueConstraint(columnNames = {"blockerId", "blockedId"}))
|
||||||
|
public class BlockEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column
|
||||||
|
private UUID blockId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private UUID blockerId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private UUID blockedId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime blockedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.oaa.xxx.social.repository;
|
||||||
|
|
||||||
|
import de.oaa.xxx.social.entity.BlockEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface BlockRepository extends JpaRepository<BlockEntity, UUID> {
|
||||||
|
|
||||||
|
Optional<BlockEntity> findByBlockerIdAndBlockedId(UUID blockerId, UUID blockedId);
|
||||||
|
|
||||||
|
/** True if either user has blocked the other. */
|
||||||
|
@Query("SELECT COUNT(b) > 0 FROM BlockEntity b WHERE (b.blockerId = :a AND b.blockedId = :b) OR (b.blockerId = :b AND b.blockedId = :a)")
|
||||||
|
boolean existsBlock(@Param("a") UUID a, @Param("b") UUID b);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
@Query("DELETE FROM BlockEntity b WHERE b.blockerId = :blockerId AND b.blockedId = :blockedId")
|
||||||
|
void deleteByBlockerIdAndBlockedId(@Param("blockerId") UUID blockerId, @Param("blockedId") UUID blockedId);
|
||||||
|
}
|
||||||
@@ -36,6 +36,14 @@ public interface MessageRepository extends JpaRepository<MessageEntity, UUID> {
|
|||||||
@Query("UPDATE MessageEntity m SET m.readAt = :now WHERE m.senderId = :partnerId AND m.receiverId = :userId AND m.readAt IS NULL AND m.systemMessage = false")
|
@Query("UPDATE MessageEntity m SET m.readAt = :now WHERE m.senderId = :partnerId AND m.receiverId = :userId AND m.readAt IS NULL AND m.systemMessage = false")
|
||||||
void markAsRead(@Param("userId") UUID userId, @Param("partnerId") UUID partnerId, @Param("now") LocalDateTime now);
|
void markAsRead(@Param("userId") UUID userId, @Param("partnerId") UUID partnerId, @Param("now") LocalDateTime now);
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(m) > 0 FROM MessageEntity m WHERE ((m.senderId = :a AND m.receiverId = :b) OR (m.senderId = :b AND m.receiverId = :a)) AND m.systemMessage = false")
|
||||||
|
boolean conversationExists(@Param("a") UUID a, @Param("b") UUID b);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
@Query("DELETE FROM MessageEntity m WHERE (m.senderId = :a AND m.receiverId = :b) OR (m.senderId = :b AND m.receiverId = :a)")
|
||||||
|
void deleteConversation(@Param("a") UUID a, @Param("b") UUID b);
|
||||||
|
|
||||||
// ── Notification queries (systemMessage = true) ───────────────────────────
|
// ── Notification queries (systemMessage = true) ───────────────────────────
|
||||||
|
|
||||||
/** Ungelesene zuerst, dann nach sentAt absteigend, max. 10 Einträge. */
|
/** Ungelesene zuerst, dann nach sentAt absteigend, max. 10 Einträge. */
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ public enum Neigung {
|
|||||||
SWITCHER("Switcher"),
|
SWITCHER("Switcher"),
|
||||||
EHER_DOMINANT("eher dominant"),
|
EHER_DOMINANT("eher dominant"),
|
||||||
DOMINANT("dominant"),
|
DOMINANT("dominant"),
|
||||||
KEINES("keines");
|
KEINES("weder noch");
|
||||||
|
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import lombok.Setter;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.Period;
|
import java.time.Period;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@@ -28,6 +29,10 @@ public class User {
|
|||||||
private String datingStadt;
|
private String datingStadt;
|
||||||
private Double datingLat;
|
private Double datingLat;
|
||||||
private Double datingLon;
|
private Double datingLon;
|
||||||
|
private List<String> datingGeschlechter;
|
||||||
|
private Integer datingMaxDistanzKm;
|
||||||
|
private Integer datingMinAlter;
|
||||||
|
private Integer datingMaxAlter;
|
||||||
|
|
||||||
public Integer getAlter() {
|
public Integer getAlter() {
|
||||||
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;
|
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ public class UserController {
|
|||||||
record TtlockUserConfigRequest(String username, String password, Integer lockId) {}
|
record TtlockUserConfigRequest(String username, String password, Integer lockId) {}
|
||||||
record ProfileRequest(Integer groesse, Integer gewicht,
|
record ProfileRequest(Integer groesse, Integer gewicht,
|
||||||
Geschlecht geschlecht, Neigung neigung, Beziehungsstatus beziehungsstatus, String beschreibung) {}
|
Geschlecht geschlecht, Neigung neigung, Beziehungsstatus beziehungsstatus, String beschreibung) {}
|
||||||
record DatingRequest(boolean datingAktiv, String datingStadt, Double datingLat, Double datingLon) {}
|
record DatingRequest(boolean datingAktiv, String datingStadt, Double datingLat, Double datingLon,
|
||||||
|
List<String> datingGeschlechter,
|
||||||
|
Integer datingMaxDistanzKm, Integer datingMinAlter, Integer datingMaxAlter) {}
|
||||||
record PrivacyRequest(
|
record PrivacyRequest(
|
||||||
Sichtbarkeit sichtbarkeitGrunddaten,
|
Sichtbarkeit sichtbarkeitGrunddaten,
|
||||||
Sichtbarkeit sichtbarkeitGalerie,
|
Sichtbarkeit sichtbarkeitGalerie,
|
||||||
@@ -113,6 +115,17 @@ public class UserController {
|
|||||||
user.setDatingStadt(request.datingAktiv() ? request.datingStadt().trim() : null);
|
user.setDatingStadt(request.datingAktiv() ? request.datingStadt().trim() : null);
|
||||||
user.setDatingLat(request.datingAktiv() ? request.datingLat() : null);
|
user.setDatingLat(request.datingAktiv() ? request.datingLat() : null);
|
||||||
user.setDatingLon(request.datingAktiv() ? request.datingLon() : null);
|
user.setDatingLon(request.datingAktiv() ? request.datingLon() : null);
|
||||||
|
if (request.datingGeschlechter() != null && !request.datingGeschlechter().isEmpty()) {
|
||||||
|
String joined = request.datingGeschlechter().stream()
|
||||||
|
.filter(g -> { try { Geschlecht.valueOf(g); return true; } catch (IllegalArgumentException e) { return false; } })
|
||||||
|
.collect(Collectors.joining(","));
|
||||||
|
user.setDatingGeschlechter(joined.isBlank() ? null : joined);
|
||||||
|
} else {
|
||||||
|
user.setDatingGeschlechter(null);
|
||||||
|
}
|
||||||
|
if (request.datingMaxDistanzKm() != null) user.setDatingMaxDistanzKm(Math.max(1, Math.min(500, request.datingMaxDistanzKm())));
|
||||||
|
if (request.datingMinAlter() != null) user.setDatingMinAlter(Math.max(18, Math.min(99, request.datingMinAlter())));
|
||||||
|
if (request.datingMaxAlter() != null) user.setDatingMaxAlter(Math.max(18, Math.min(99, request.datingMaxAlter())));
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
LOGGER.info("User {} hat Dating-Einstellungen aktualisiert: aktiv={}", user.getUserId(), request.datingAktiv());
|
LOGGER.info("User {} hat Dating-Einstellungen aktualisiert: aktiv={}", user.getUserId(), request.datingAktiv());
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import lombok.Setter;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.Period;
|
import java.time.Period;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@@ -112,6 +114,22 @@ public class UserEntity {
|
|||||||
@Column
|
@Column
|
||||||
private Double datingLon;
|
private Double datingLon;
|
||||||
|
|
||||||
|
/** Komma-separierte Geschlecht-Enum-Namen, z.B. "WEIBLICH,MAENNLICH". Null = keine Einschränkung. */
|
||||||
|
@Column(length = 60)
|
||||||
|
private String datingGeschlechter;
|
||||||
|
|
||||||
|
/** Standard-Filter: maximale Entfernung in km. Null = kein Vorgabewert gespeichert. */
|
||||||
|
@Column
|
||||||
|
private Integer datingMaxDistanzKm;
|
||||||
|
|
||||||
|
/** Standard-Filter: Mindestalter. Null = kein Vorgabewert gespeichert. */
|
||||||
|
@Column
|
||||||
|
private Integer datingMinAlter;
|
||||||
|
|
||||||
|
/** Standard-Filter: Höchstalter. Null = kein Vorgabewert gespeichert. */
|
||||||
|
@Column
|
||||||
|
private Integer datingMaxAlter;
|
||||||
|
|
||||||
public Integer getAlter() {
|
public Integer getAlter() {
|
||||||
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;
|
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;
|
||||||
}
|
}
|
||||||
@@ -138,6 +156,13 @@ public class UserEntity {
|
|||||||
user.setDatingStadt(datingStadt);
|
user.setDatingStadt(datingStadt);
|
||||||
user.setDatingLat(datingLat);
|
user.setDatingLat(datingLat);
|
||||||
user.setDatingLon(datingLon);
|
user.setDatingLon(datingLon);
|
||||||
|
user.setDatingGeschlechter(
|
||||||
|
datingGeschlechter != null && !datingGeschlechter.isBlank()
|
||||||
|
? Arrays.asList(datingGeschlechter.split(","))
|
||||||
|
: null);
|
||||||
|
user.setDatingMaxDistanzKm(datingMaxDistanzKm);
|
||||||
|
user.setDatingMinAlter(datingMinAlter);
|
||||||
|
user.setDatingMaxAlter(datingMaxAlter);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ public interface UserRepository extends JpaRepository<UserEntity, UUID> {
|
|||||||
Optional<UserEntity> findByEmail(String email);
|
Optional<UserEntity> findByEmail(String email);
|
||||||
Optional<UserEntity> findByName(String name);
|
Optional<UserEntity> findByName(String name);
|
||||||
List<UserEntity> findByNameContainingIgnoreCase(String name);
|
List<UserEntity> findByNameContainingIgnoreCase(String name);
|
||||||
|
List<UserEntity> findByDatingAktiv(boolean datingAktiv);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ public class UserService {
|
|||||||
private final KommentarRepository kommentarRepository;
|
private final KommentarRepository kommentarRepository;
|
||||||
private final KommentarLikeRepository kommentarLikeRepository;
|
private final KommentarLikeRepository kommentarLikeRepository;
|
||||||
private final NotificationPreferenceRepository notificationPreferenceRepository;
|
private final NotificationPreferenceRepository notificationPreferenceRepository;
|
||||||
|
private final de.oaa.xxx.dating.DatingLikeRepository datingLikeRepository;
|
||||||
|
private final de.oaa.xxx.dating.DatingMatchRepository datingMatchRepository;
|
||||||
|
|
||||||
public UserService(UserRepository userRepository,
|
public UserService(UserRepository userRepository,
|
||||||
AufgabenGruppeRepository aufgabenGruppeRepository,
|
AufgabenGruppeRepository aufgabenGruppeRepository,
|
||||||
@@ -80,7 +82,9 @@ public class UserService {
|
|||||||
PinnwandLikeRepository pinnwandLikeRepository,
|
PinnwandLikeRepository pinnwandLikeRepository,
|
||||||
KommentarRepository kommentarRepository,
|
KommentarRepository kommentarRepository,
|
||||||
KommentarLikeRepository kommentarLikeRepository,
|
KommentarLikeRepository kommentarLikeRepository,
|
||||||
NotificationPreferenceRepository notificationPreferenceRepository) {
|
NotificationPreferenceRepository notificationPreferenceRepository,
|
||||||
|
de.oaa.xxx.dating.DatingLikeRepository datingLikeRepository,
|
||||||
|
de.oaa.xxx.dating.DatingMatchRepository datingMatchRepository) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.aufgabenGruppeRepository = aufgabenGruppeRepository;
|
this.aufgabenGruppeRepository = aufgabenGruppeRepository;
|
||||||
this.aufgabeRepository = aufgabeRepository;
|
this.aufgabeRepository = aufgabeRepository;
|
||||||
@@ -101,6 +105,8 @@ public class UserService {
|
|||||||
this.kommentarRepository = kommentarRepository;
|
this.kommentarRepository = kommentarRepository;
|
||||||
this.kommentarLikeRepository = kommentarLikeRepository;
|
this.kommentarLikeRepository = kommentarLikeRepository;
|
||||||
this.notificationPreferenceRepository = notificationPreferenceRepository;
|
this.notificationPreferenceRepository = notificationPreferenceRepository;
|
||||||
|
this.datingLikeRepository = datingLikeRepository;
|
||||||
|
this.datingMatchRepository = datingMatchRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,7 +193,11 @@ public class UserService {
|
|||||||
kommentarRepository.deleteByAuthorId(userId);
|
kommentarRepository.deleteByAuthorId(userId);
|
||||||
kommentarLikeRepository.deleteByUserId(userId);
|
kommentarLikeRepository.deleteByUserId(userId);
|
||||||
|
|
||||||
// 6. User löschen
|
// 6. Dating-Likes und -Matches löschen
|
||||||
|
datingLikeRepository.deleteByLikerIdOrLikedId(userId, userId);
|
||||||
|
datingMatchRepository.deleteAllByUser(userId);
|
||||||
|
|
||||||
|
// 7. User löschen
|
||||||
userRepository.delete(user);
|
userRepository.delete(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ import java.util.UUID;
|
|||||||
public interface UserVorliebeRepository extends JpaRepository<UserVorliebeEntity, UUID> {
|
public interface UserVorliebeRepository extends JpaRepository<UserVorliebeEntity, UUID> {
|
||||||
List<UserVorliebeEntity> findByUserId(UUID userId);
|
List<UserVorliebeEntity> findByUserId(UUID userId);
|
||||||
Optional<UserVorliebeEntity> findByUserIdAndItemId(UUID userId, UUID itemId);
|
Optional<UserVorliebeEntity> findByUserIdAndItemId(UUID userId, UUID itemId);
|
||||||
|
List<UserVorliebeEntity> findByUserIdIn(java.util.Collection<UUID> userIds);
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/main/resources/sql/fix_sichtbarkeit_niemand.sql
Normal file
9
src/main/resources/sql/fix_sichtbarkeit_niemand.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Ersetzt ungültigen Wert 'NIEMAND' durch 'NUR_ICH' in allen Sichtbarkeit-Spalten der user-Tabelle.
|
||||||
|
UPDATE `user` SET sichtbarkeit_grunddaten = 'NUR_ICH' WHERE sichtbarkeit_grunddaten = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_galerie = 'NUR_ICH' WHERE sichtbarkeit_galerie = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_freunde = 'NUR_ICH' WHERE sichtbarkeit_freunde = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_feed = 'NUR_ICH' WHERE sichtbarkeit_feed = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_pinnwand = 'NUR_ICH' WHERE sichtbarkeit_pinnwand = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_xp = 'NUR_ICH' WHERE sichtbarkeit_xp = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_lockhistorie = 'NUR_ICH' WHERE sichtbarkeit_lockhistorie = 'NIEMAND';
|
||||||
|
UPDATE `user` SET sichtbarkeit_vorlieben = 'NUR_ICH' WHERE sichtbarkeit_vorlieben = 'NIEMAND';
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -536,6 +536,7 @@
|
|||||||
let isOwnProfile = false;
|
let isOwnProfile = false;
|
||||||
let profileData = null;
|
let profileData = null;
|
||||||
let avatarSrc = null;
|
let avatarSrc = null;
|
||||||
|
let blockedByMe = false;
|
||||||
|
|
||||||
let allImages = [];
|
let allImages = [];
|
||||||
let galleryOffset = 0;
|
let galleryOffset = 0;
|
||||||
@@ -575,10 +576,11 @@
|
|||||||
|
|
||||||
async function loadProfile() {
|
async function loadProfile() {
|
||||||
try {
|
try {
|
||||||
const [me, profile, images] = await Promise.all([
|
const [me, profile, images, blockStatus] = await Promise.all([
|
||||||
fetch('/login/me').then(r => r.ok ? r.json() : null),
|
fetch('/login/me').then(r => r.ok ? r.json() : null),
|
||||||
fetch('/social/users/' + targetUserId).then(r => r.ok ? r.json() : null),
|
fetch('/social/users/' + targetUserId).then(r => r.ok ? r.json() : null),
|
||||||
fetch('/social/profile-images?userId=' + targetUserId).then(r => r.ok ? r.json() : [])
|
fetch('/social/profile-images?userId=' + targetUserId).then(r => r.ok ? r.json() : []),
|
||||||
|
fetch('/social/block/' + targetUserId).then(r => r.ok ? r.json() : { blockedByMe: false })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
document.getElementById('loadingHint').style.display = 'none';
|
document.getElementById('loadingHint').style.display = 'none';
|
||||||
@@ -592,6 +594,7 @@
|
|||||||
isOwnProfile = !previewMode && me && me.userId === profile.userId;
|
isOwnProfile = !previewMode && me && me.userId === profile.userId;
|
||||||
profileData = profile;
|
profileData = profile;
|
||||||
allImages = images;
|
allImages = images;
|
||||||
|
blockedByMe = blockStatus?.blockedByMe ?? false;
|
||||||
|
|
||||||
// Profilbesuch tracken (nur fremde Profile, kein Preview-Modus)
|
// Profilbesuch tracken (nur fremde Profile, kein Preview-Modus)
|
||||||
if (!isOwnProfile && !previewMode && myUserId) {
|
if (!isOwnProfile && !previewMode && myUserId) {
|
||||||
@@ -689,17 +692,26 @@
|
|||||||
actions.innerHTML = '';
|
actions.innerHTML = '';
|
||||||
} else {
|
} else {
|
||||||
let html = '';
|
let html = '';
|
||||||
if (profile.friendStatus === 'FRIEND') {
|
if (!blockedByMe) {
|
||||||
html += `<a href="/community/nachrichten.html?userId=${profile.userId}" class="btn">✉ Nachricht</a>`;
|
html += `<a href="/community/nachrichten.html?userId=${profile.userId}" class="btn">✉ Nachricht</a>`;
|
||||||
} else if (profile.friendStatus === 'PENDING_SENT') {
|
if (profile.friendStatus === 'PENDING_SENT') {
|
||||||
html += `<button disabled>Anfrage gesendet</button>`;
|
html += ` <button disabled>Anfrage gesendet</button>`;
|
||||||
} else if (profile.friendStatus === 'PENDING_RECEIVED') {
|
} else if (profile.friendStatus === 'PENDING_RECEIVED') {
|
||||||
html += `<button id="friendActionBtn" onclick="acceptFriend()">✓ Anfrage annehmen</button>`;
|
html += ` <button id="friendActionBtn" onclick="acceptFriend()">✓ Anfrage annehmen</button>`;
|
||||||
} else {
|
} else if (profile.friendStatus !== 'FRIEND') {
|
||||||
html += `<button id="friendActionBtn" onclick="addFriend()">+ Freund hinzufügen</button>`;
|
html += ` <button id="friendActionBtn" onclick="addFriend()">+ Freund hinzufügen</button>`;
|
||||||
}
|
}
|
||||||
html += ` <button onclick="openMeldungDialog('PROFIL','${profile.userId}')" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.4rem 0.8rem;cursor:pointer;font-size:0.85rem;">⚑ Melden</button>`;
|
html += ` <button onclick="openMeldungDialog('PROFIL','${profile.userId}')" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.4rem 0.8rem;cursor:pointer;font-size:0.85rem;">⚑ Melden</button>`;
|
||||||
|
html += ` <button onclick="confirmBlock('${profile.userId}','${esc(profile.name)}')" style="background:none;border:1px solid #7a1a1a;color:#c0392b;border-radius:6px;padding:0.4rem 0.8rem;cursor:pointer;font-size:0.85rem;">⊘ Blockieren</button>`;
|
||||||
|
} else {
|
||||||
|
html += `<button onclick="unblockUser('${profile.userId}')" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.4rem 0.8rem;cursor:pointer;font-size:0.85rem;">Blockierung aufheben</button>`;
|
||||||
|
}
|
||||||
actions.innerHTML = html;
|
actions.innerHTML = html;
|
||||||
|
|
||||||
|
// Dating-Like-Button – nur anzeigen wenn Ziel-User Dating aktiviert hat und nicht blockiert
|
||||||
|
if (profile.datingAktiv && !blockedByMe) {
|
||||||
|
loadDatingLikeButton(profile.userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,6 +995,11 @@
|
|||||||
|
|
||||||
// ── Pinnwand ──
|
// ── Pinnwand ──
|
||||||
async function loadPinnwand() {
|
async function loadPinnwand() {
|
||||||
|
// Schreibbereich ausblenden wenn wir die Person blockiert haben oder es das eigene Profil ist
|
||||||
|
if (blockedByMe || isOwnProfile) {
|
||||||
|
document.querySelector('.pinnwand-write').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch('/social/pinnwand?userId=' + targetUserId);
|
const res = await fetch('/social/pinnwand?userId=' + targetUserId);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const eintraege = await res.json();
|
const eintraege = await res.json();
|
||||||
@@ -1131,6 +1148,31 @@
|
|||||||
body: JSON.stringify({ profilUserId: targetUserId, text })
|
body: JSON.stringify({ profilUserId: targetUserId, text })
|
||||||
});
|
});
|
||||||
if (res.ok) { ta.value = ''; await loadPinnwand(); }
|
if (res.ok) { ta.value = ''; await loadPinnwand(); }
|
||||||
|
else if (res.status === 403) {
|
||||||
|
alert('Du kannst auf dieser Pinnwand keinen Eintrag hinterlassen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Blockieren ──
|
||||||
|
function confirmBlock(userId, userName) {
|
||||||
|
if (!confirm(`Möchtest du ${userName} wirklich blockieren?\n\nDer gesamte bisherige Chat wird gelöscht. Die Person kann dich nicht mehr kontaktieren und keine Pinnwand-Einträge hinterlassen.`)) return;
|
||||||
|
blockUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blockUser(userId) {
|
||||||
|
const res = await fetch('/social/block/' + userId, { method: 'POST' });
|
||||||
|
if (res.ok || res.status === 409) {
|
||||||
|
blockedByMe = true;
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unblockUser(userId) {
|
||||||
|
const res = await fetch('/social/block/' + userId, { method: 'DELETE' });
|
||||||
|
if (res.ok || res.status === 404) {
|
||||||
|
blockedByMe = false;
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEintrag(eintragId) {
|
async function deleteEintrag(eintragId) {
|
||||||
@@ -1410,6 +1452,60 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// esc, fmtDate, toggleEmojiPicker, insertEmoji kommen aus shared.js
|
// esc, fmtDate, toggleEmojiPicker, insertEmoji kommen aus shared.js
|
||||||
|
|
||||||
|
// ── Dating-Like auf Profilseite ──────────────────────────────────────────
|
||||||
|
async function loadDatingLikeButton(targetUserId) {
|
||||||
|
// Nur einblenden wenn eigener User Dating aktiviert hat
|
||||||
|
const meRes = await fetch('/login/me');
|
||||||
|
if (!meRes.ok) return;
|
||||||
|
const me = await meRes.json();
|
||||||
|
if (!me.datingAktiv) return;
|
||||||
|
|
||||||
|
let liked = false;
|
||||||
|
try {
|
||||||
|
const idsRes = await fetch('/dating/liked-by-me');
|
||||||
|
if (idsRes.ok) {
|
||||||
|
const ids = await idsRes.json();
|
||||||
|
liked = ids.includes(targetUserId);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'datingLikeBtn';
|
||||||
|
btn.title = liked ? 'Unlike' : 'Like';
|
||||||
|
btn.style.cssText = 'padding:0.4rem 0.9rem;font-size:0.9rem;' +
|
||||||
|
(liked ? 'background:var(--color-primary);color:#fff;' : 'background:none;border:1px solid var(--color-primary);color:var(--color-primary);') +
|
||||||
|
'border-radius:6px;cursor:pointer;';
|
||||||
|
btn.textContent = liked ? '♥ Liked' : '♥ Liken';
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/dating/like/' + targetUserId, { method: 'POST' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
liked = data.liked;
|
||||||
|
btn.textContent = liked ? '♥ Liked' : '♥ Liken';
|
||||||
|
btn.title = liked ? 'Unlike' : 'Like';
|
||||||
|
btn.style.background = liked ? 'var(--color-primary)' : 'none';
|
||||||
|
btn.style.color = liked ? '#fff' : 'var(--color-primary)';
|
||||||
|
btn.style.border = liked ? 'none' : '1px solid var(--color-primary)';
|
||||||
|
if (data.newMatch) {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.textContent = '🎉 Es ist ein Match!';
|
||||||
|
Object.assign(t.style, {
|
||||||
|
position:'fixed', bottom:'2rem', left:'50%', transform:'translateX(-50%)',
|
||||||
|
background:'var(--color-primary)', color:'#fff', padding:'0.75rem 1.5rem',
|
||||||
|
borderRadius:'8px', fontWeight:'700', zIndex:'999'
|
||||||
|
});
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 3500);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('profileActions').appendChild(btn);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -529,15 +529,41 @@
|
|||||||
if (!text) return;
|
if (!text) return;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
try {
|
try {
|
||||||
await fetch('/social/messages', {
|
const res = await fetch('/social/messages', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ receiverId: activePartnerId, text })
|
body: JSON.stringify({ receiverId: activePartnerId, text })
|
||||||
});
|
});
|
||||||
|
if (res.status === 403) {
|
||||||
|
let reason = '';
|
||||||
|
try { const body = await res.json(); reason = body.reason; } catch (_) {}
|
||||||
|
if (reason === 'FIRST_MESSAGE_RESTRICTED') {
|
||||||
|
showThreadNotice('Du kannst diese Person nur anschreiben, wenn ihr befreundet seid, ein Match habt oder du ein Pro-Abo hast.');
|
||||||
|
} else if (reason === 'BLOCKED') {
|
||||||
|
showThreadNotice('Diese Konversation ist nicht mehr möglich.');
|
||||||
|
} else {
|
||||||
|
showThreadNotice('Nachricht konnte nicht gesendet werden.');
|
||||||
|
}
|
||||||
|
// Text wieder zurücksetzen, damit der User ihn nicht verliert
|
||||||
|
input.value = text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
await pollNewMessages();
|
await pollNewMessages();
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showThreadNotice(msg) {
|
||||||
|
const existing = document.getElementById('threadNotice');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
const notice = document.createElement('div');
|
||||||
|
notice.id = 'threadNotice';
|
||||||
|
notice.style.cssText = 'background:rgba(180,0,60,0.12);border:1px solid rgba(180,0,60,0.35);border-radius:8px;padding:0.75rem 1rem;font-size:0.88rem;color:var(--color-text);margin:0.5rem 0;line-height:1.45;';
|
||||||
|
notice.textContent = msg;
|
||||||
|
const container = document.getElementById('threadMessages');
|
||||||
|
container.appendChild(notice);
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('msgInput').addEventListener('keydown', e => {
|
document.getElementById('msgInput').addEventListener('keydown', e => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMsg(); }
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMsg(); }
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,13 @@
|
|||||||
} catch(ex) {}
|
} catch(ex) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
es.addEventListener('MATCH', e => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (typeof window._onSseMatch === 'function') window._onSseMatch(data);
|
||||||
|
} catch(ex) {}
|
||||||
|
});
|
||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
es.close();
|
es.close();
|
||||||
// Vor dem Reconnect prüfen ob noch eingeloggt (verhindert Endlos-Schleife bei abgelaufener Session)
|
// Vor dem Reconnect prüfen ob noch eingeloggt (verhindert Endlos-Schleife bei abgelaufener Session)
|
||||||
|
|||||||
@@ -488,6 +488,63 @@
|
|||||||
<input type="checkbox" id="datingAktiv" style="width:1.1rem;height:1.1rem;accent-color:var(--color-primary);cursor:pointer;" onchange="onDatingToggle()">
|
<input type="checkbox" id="datingAktiv" style="width:1.1rem;height:1.1rem;accent-color:var(--color-primary);cursor:pointer;" onchange="onDatingToggle()">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="datingSucheRow" style="display:none;">
|
||||||
|
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||||
|
<div class="settings-row-info">
|
||||||
|
<div class="settings-row-label">Interesse an</div>
|
||||||
|
<div class="settings-row-desc">Welche Geschlechter sollen standardmäßig in der Dating-Suche angezeigt werden?</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:1rem;flex-wrap:wrap;">
|
||||||
|
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;margin:0;font-size:0.9rem;color:var(--color-text);">
|
||||||
|
<input type="checkbox" id="sucheWeiblich" value="WEIBLICH"
|
||||||
|
style="width:1rem;height:1rem;accent-color:var(--color-primary);cursor:pointer;">
|
||||||
|
weiblich
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;margin:0;font-size:0.9rem;color:var(--color-text);">
|
||||||
|
<input type="checkbox" id="sucheMaennlich" value="MAENNLICH"
|
||||||
|
style="width:1rem;height:1rem;accent-color:var(--color-primary);cursor:pointer;">
|
||||||
|
männlich
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;margin:0;font-size:0.9rem;color:var(--color-text);">
|
||||||
|
<input type="checkbox" id="sucheDivers" value="DIVERS"
|
||||||
|
style="width:1rem;height:1rem;accent-color:var(--color-primary);cursor:pointer;">
|
||||||
|
divers
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||||
|
<div class="settings-row-info">
|
||||||
|
<div class="settings-row-label">Standard-Umkreis</div>
|
||||||
|
<div class="settings-row-desc">Maximale Entfernung als Standardwert im Dating-Filter.</div>
|
||||||
|
</div>
|
||||||
|
<div style="width:100%;max-width:320px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:var(--color-muted);margin-bottom:0.25rem;">
|
||||||
|
<span>Umkreis</span><span id="datingDistValDisplay" style="color:var(--color-text);">50 km</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="datingMaxDistanz" min="5" max="500" value="50" step="5"
|
||||||
|
style="width:100%;accent-color:var(--color-primary);padding:0;background:none;border:none;"
|
||||||
|
oninput="document.getElementById('datingDistValDisplay').textContent=this.value+' km'">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
||||||
|
<div class="settings-row-info">
|
||||||
|
<div class="settings-row-label">Standard-Altersbereich</div>
|
||||||
|
<div class="settings-row-desc">Gesuchter Altersbereich als Standardwert im Dating-Filter.</div>
|
||||||
|
</div>
|
||||||
|
<div style="width:100%;max-width:320px;">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:var(--color-muted);margin-bottom:0.25rem;">
|
||||||
|
<span>Alter</span><span id="datingAgeValDisplay" style="color:var(--color-text);">18 – 60</span>
|
||||||
|
</div>
|
||||||
|
<div style="position:relative;height:20px;margin:0.25rem 0;" id="datingAgeSlider">
|
||||||
|
<div style="position:absolute;top:50%;left:0;right:0;height:4px;background:var(--color-secondary);border-radius:2px;transform:translateY(-50%);">
|
||||||
|
<div id="datingAgeRange" style="position:absolute;top:0;height:100%;background:var(--color-primary);border-radius:2px;"></div>
|
||||||
|
</div>
|
||||||
|
<div id="datingThumbMin" tabindex="0" style="position:absolute;top:50%;width:18px;height:18px;background:var(--color-primary);border:2px solid #fff;border-radius:50%;transform:translate(-50%,-50%);cursor:grab;box-shadow:0 1px 4px rgba(0,0,0,0.4);touch-action:none;"></div>
|
||||||
|
<div id="datingThumbMax" tabindex="0" style="position:absolute;top:50%;width:18px;height:18px;background:var(--color-primary);border:2px solid #fff;border-radius:50%;transform:translate(-50%,-50%);cursor:grab;box-shadow:0 1px 4px rgba(0,0,0,0.4);touch-action:none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="datingStadtRow" style="display:none;">
|
<div id="datingStadtRow" style="display:none;">
|
||||||
<div class="settings-row" style="flex-wrap:wrap;gap:0.5rem;">
|
<div class="settings-row" style="flex-wrap:wrap;gap:0.5rem;">
|
||||||
<div class="settings-row-info">
|
<div class="settings-row-info">
|
||||||
@@ -1201,12 +1258,85 @@
|
|||||||
}
|
}
|
||||||
if (user.datingLat != null) _datingLat = user.datingLat;
|
if (user.datingLat != null) _datingLat = user.datingLat;
|
||||||
if (user.datingLon != null) _datingLon = user.datingLon;
|
if (user.datingLon != null) _datingLon = user.datingLon;
|
||||||
document.getElementById('datingStadtRow').style.display = user.datingAktiv ? '' : 'none';
|
const show = user.datingAktiv ? '' : 'none';
|
||||||
|
document.getElementById('datingStadtRow').style.display = show;
|
||||||
|
document.getElementById('datingSucheRow').style.display = show;
|
||||||
|
const aktiveGeschlechter = user.datingGeschlechter || [];
|
||||||
|
document.getElementById('sucheWeiblich').checked = aktiveGeschlechter.includes('WEIBLICH');
|
||||||
|
document.getElementById('sucheMaennlich').checked = aktiveGeschlechter.includes('MAENNLICH');
|
||||||
|
document.getElementById('sucheDivers').checked = aktiveGeschlechter.includes('DIVERS');
|
||||||
|
|
||||||
|
if (user.datingMaxDistanzKm != null) {
|
||||||
|
document.getElementById('datingMaxDistanz').value = user.datingMaxDistanzKm;
|
||||||
|
document.getElementById('datingDistValDisplay').textContent = user.datingMaxDistanzKm + ' km';
|
||||||
|
}
|
||||||
|
if (user.datingMinAlter != null) _datingAgeFrom = user.datingMinAlter;
|
||||||
|
if (user.datingMaxAlter != null) _datingAgeTo = user.datingMaxAlter;
|
||||||
|
updateDatingAgeSlider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dating-Alters-Slider ──
|
||||||
|
const _DATING_AGE_MIN = 18, _DATING_AGE_MAX = 99;
|
||||||
|
let _datingAgeFrom = 18, _datingAgeTo = 60;
|
||||||
|
|
||||||
|
function _datingAgePct(v) { return (v - _DATING_AGE_MIN) / (_DATING_AGE_MAX - _DATING_AGE_MIN) * 100; }
|
||||||
|
|
||||||
|
function updateDatingAgeSlider() {
|
||||||
|
const lo = _datingAgePct(_datingAgeFrom), hi = _datingAgePct(_datingAgeTo);
|
||||||
|
document.getElementById('datingThumbMin').style.left = lo + '%';
|
||||||
|
document.getElementById('datingThumbMax').style.left = hi + '%';
|
||||||
|
document.getElementById('datingAgeRange').style.left = lo + '%';
|
||||||
|
document.getElementById('datingAgeRange').style.width = (hi - lo) + '%';
|
||||||
|
document.getElementById('datingAgeValDisplay').textContent = _datingAgeFrom + ' – ' + _datingAgeTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
(function initDatingAgeSlider() {
|
||||||
|
const slider = document.getElementById('datingAgeSlider');
|
||||||
|
function makeThumb(thumbId, isMin) {
|
||||||
|
const thumb = document.getElementById(thumbId);
|
||||||
|
function onMove(clientX) {
|
||||||
|
const rect = slider.getBoundingClientRect();
|
||||||
|
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
|
const raw = _DATING_AGE_MIN + Math.round(ratio * (_DATING_AGE_MAX - _DATING_AGE_MIN));
|
||||||
|
if (isMin) _datingAgeFrom = Math.min(raw, _datingAgeTo - 1);
|
||||||
|
else _datingAgeTo = Math.max(raw, _datingAgeFrom + 1);
|
||||||
|
updateDatingAgeSlider();
|
||||||
|
}
|
||||||
|
thumb.addEventListener('mousedown', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const move = ev => onMove(ev.clientX);
|
||||||
|
const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
|
||||||
|
document.addEventListener('mousemove', move);
|
||||||
|
document.addEventListener('mouseup', up);
|
||||||
|
});
|
||||||
|
thumb.addEventListener('touchstart', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const move = ev => onMove(ev.touches[0].clientX);
|
||||||
|
const end = () => { document.removeEventListener('touchmove', move); document.removeEventListener('touchend', end); };
|
||||||
|
document.addEventListener('touchmove', move, { passive: false });
|
||||||
|
document.addEventListener('touchend', end);
|
||||||
|
}, { passive: false });
|
||||||
|
thumb.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||||
|
if (isMin) _datingAgeFrom = Math.max(_DATING_AGE_MIN, _datingAgeFrom - 1);
|
||||||
|
else _datingAgeTo = Math.max(_datingAgeFrom + 1, _datingAgeTo - 1);
|
||||||
|
updateDatingAgeSlider(); e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||||
|
if (isMin) _datingAgeFrom = Math.min(_datingAgeTo - 1, _datingAgeFrom + 1);
|
||||||
|
else _datingAgeTo = Math.min(_DATING_AGE_MAX, _datingAgeTo + 1);
|
||||||
|
updateDatingAgeSlider(); e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
makeThumb('datingThumbMin', true);
|
||||||
|
makeThumb('datingThumbMax', false);
|
||||||
|
updateDatingAgeSlider();
|
||||||
|
})();
|
||||||
|
|
||||||
function onDatingToggle() {
|
function onDatingToggle() {
|
||||||
const aktiv = document.getElementById('datingAktiv').checked;
|
const show = document.getElementById('datingAktiv').checked ? '' : 'none';
|
||||||
document.getElementById('datingStadtRow').style.display = aktiv ? '' : 'none';
|
document.getElementById('datingStadtRow').style.display = show;
|
||||||
|
document.getElementById('datingSucheRow').style.display = show;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _stadtSuggestTimer = null;
|
let _stadtSuggestTimer = null;
|
||||||
@@ -1312,7 +1442,18 @@
|
|||||||
const res = await fetch('/user/me/dating', {
|
const res = await fetch('/user/me/dating', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ datingAktiv: aktiv, datingStadt: stadt || null, datingLat: _datingLat, datingLon: _datingLon })
|
body: JSON.stringify({
|
||||||
|
datingAktiv: aktiv,
|
||||||
|
datingStadt: stadt || null,
|
||||||
|
datingLat: _datingLat,
|
||||||
|
datingLon: _datingLon,
|
||||||
|
datingGeschlechter: ['sucheWeiblich','sucheMaennlich','sucheDivers']
|
||||||
|
.filter(id => document.getElementById(id).checked)
|
||||||
|
.map(id => document.getElementById(id).value),
|
||||||
|
datingMaxDistanzKm: parseInt(document.getElementById('datingMaxDistanz').value),
|
||||||
|
datingMinAlter: _datingAgeFrom,
|
||||||
|
datingMaxAlter: _datingAgeTo
|
||||||
|
})
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast();
|
showToast();
|
||||||
|
|||||||
@@ -60,6 +60,53 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.visitor-time { font-size: 0.68rem; color: var(--color-muted); text-align: center; }
|
.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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="app">
|
<body class="app">
|
||||||
@@ -105,6 +152,18 @@
|
|||||||
<div class="section-label">Profilbesucher</div>
|
<div class="section-label">Profilbesucher</div>
|
||||||
<div class="visitors-strip" id="visitorsStrip"></div>
|
<div class="visitors-strip" id="visitorsStrip"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Wer hat mich geliked (Dating) -->
|
||||||
|
<div id="likesSection" style="display:none;">
|
||||||
|
<div class="section-label">Dating – Wer mag mich ♥</div>
|
||||||
|
<div class="dating-strip" id="likesStrip"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Matches -->
|
||||||
|
<div id="matchesSection" style="display:none;">
|
||||||
|
<div class="section-label">Dating – Matches 🎉</div>
|
||||||
|
<div class="dating-strip" id="matchesStrip"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,6 +179,10 @@
|
|||||||
if (user) {
|
if (user) {
|
||||||
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
||||||
loadVisitors();
|
loadVisitors();
|
||||||
|
if (user.datingAktiv) {
|
||||||
|
loadWhoLikesMe();
|
||||||
|
loadMatches();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { window.location.href = '/login.html'; });
|
.catch(() => { window.location.href = '/login.html'; });
|
||||||
@@ -132,6 +195,69 @@
|
|||||||
return 'vor ' + Math.floor(diff / 86400) + ' Tag' + (Math.floor(diff / 86400) === 1 ? '' : 'en');
|
return 'vor ' + Math.floor(diff / 86400) + ' Tag' + (Math.floor(diff / 86400) === 1 ? '' : 'en');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadWhoLikesMe() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/dating/who-likes-me');
|
||||||
|
if (!res.ok) return;
|
||||||
|
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="">`
|
||||||
|
: '◉';
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.getElementById('likesSection').style.display = '';
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMatches() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/dating/matches');
|
||||||
|
if (!res.ok) return;
|
||||||
|
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 = '';
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadVisitors() {
|
async function loadVisitors() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/social/profile-visits/my-visitors');
|
const res = await fetch('/social/profile-visits/my-visitors');
|
||||||
|
|||||||
Reference in New Issue
Block a user