Hashtags eingeführt
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:
@@ -54,7 +54,6 @@ public class SecurityConfig {
|
||||
.requestMatchers("/sessionbdsmingame.html").authenticated()
|
||||
.requestMatchers("/games/bdsm/neubdsm.html").authenticated()
|
||||
.requestMatchers("/games/bdsm/bdsmingame.html").authenticated()
|
||||
.requestMatchers("/community/personen-suchen.html").authenticated()
|
||||
.requestMatchers("/community/freunde.html").authenticated()
|
||||
.requestMatchers("/community/nachrichten.html").authenticated()
|
||||
.requestMatchers("/community/benutzer.html").authenticated()
|
||||
@@ -82,6 +81,7 @@ public class SecurityConfig {
|
||||
.requestMatchers("/community/event-detail.html").authenticated()
|
||||
.requestMatchers("/gruppen/**").authenticated()
|
||||
.requestMatchers("/feed/**").authenticated()
|
||||
.requestMatchers("/hashtags/**").authenticated()
|
||||
.requestMatchers("/notifications/**").authenticated()
|
||||
.requestMatchers("/events/**").authenticated()
|
||||
.requestMatchers("/*.html").permitAll()
|
||||
|
||||
@@ -5,7 +5,9 @@ import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
@@ -25,6 +27,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import de.oaa.xxx.feed.dto.FeedItemDto;
|
||||
import de.oaa.xxx.feed.dto.FeedPostRequest;
|
||||
import de.oaa.xxx.feed.entity.FeedPostEntity;
|
||||
import de.oaa.xxx.hashtag.HashtagService;
|
||||
import de.oaa.xxx.hashtag.PostHashtagEntity;
|
||||
import de.oaa.xxx.feed.entity.FeedPostOptionEntity;
|
||||
import de.oaa.xxx.feed.entity.FeedPostVoteEntity;
|
||||
import de.oaa.xxx.feed.repository.FeedPostLikeRepository;
|
||||
@@ -33,6 +37,7 @@ import de.oaa.xxx.feed.repository.FeedPostRepository;
|
||||
import de.oaa.xxx.feed.repository.FeedPostVoteRepository;
|
||||
import de.oaa.xxx.gruppe.BeitragTyp;
|
||||
import de.oaa.xxx.gruppe.dto.UmfrageOptionDto;
|
||||
import de.oaa.xxx.gruppe.entity.GruppeEntity;
|
||||
import de.oaa.xxx.gruppe.entity.GruppenbeitragEntity;
|
||||
import de.oaa.xxx.gruppe.entity.UmfrageStimmeEntity;
|
||||
import de.oaa.xxx.gruppe.repository.GruppeRepository;
|
||||
@@ -70,6 +75,7 @@ public class FeedController {
|
||||
private final UserRepository userRepository;
|
||||
private final UserService userService;
|
||||
private final LikeService likeService;
|
||||
private final HashtagService hashtagService;
|
||||
|
||||
public FeedController(FeedPostRepository feedPostRepository,
|
||||
FeedPostLikeRepository feedPostLikeRepository,
|
||||
@@ -85,7 +91,8 @@ public class FeedController {
|
||||
KommentarRepository kommentarRepository,
|
||||
UserRepository userRepository,
|
||||
UserService userService,
|
||||
LikeService likeService) {
|
||||
LikeService likeService,
|
||||
HashtagService hashtagService) {
|
||||
this.feedPostRepository = feedPostRepository;
|
||||
this.feedPostLikeRepository = feedPostLikeRepository;
|
||||
this.feedPostOptionRepository = feedPostOptionRepository;
|
||||
@@ -101,6 +108,7 @@ public class FeedController {
|
||||
this.userRepository = userRepository;
|
||||
this.userService = userService;
|
||||
this.likeService = likeService;
|
||||
this.hashtagService = hashtagService;
|
||||
}
|
||||
|
||||
record FeedPage(List<FeedItemDto> posts, boolean hasMore) {}
|
||||
@@ -131,6 +139,7 @@ public class FeedController {
|
||||
post.setPublic(req.isPublic());
|
||||
post.setCreatedAt(LocalDateTime.now());
|
||||
feedPostRepository.save(post);
|
||||
hashtagService.saveForPost(post.getText(), "FEED", post.getPostId(), post.getCreatedAt());
|
||||
LOGGER.info("User {} hat Feed-Post {} erstellt (Typ: {}, public: {})", myId, post.getPostId(), typ, post.isPublic());
|
||||
|
||||
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
|
||||
@@ -251,6 +260,55 @@ public class FeedController {
|
||||
return ResponseEntity.ok(new FeedPage(items, !nextPage.isEmpty()));
|
||||
}
|
||||
|
||||
// ── GET /feed/hashtag?tag= ──
|
||||
|
||||
@GetMapping("/hashtag")
|
||||
public ResponseEntity<FeedPage> getHashtagFeed(@RequestParam String tag,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
Principal principal) {
|
||||
UUID myId = resolveMyId(principal);
|
||||
if (myId == null) return ResponseEntity.status(401).build();
|
||||
|
||||
List<PostHashtagEntity> refs = hashtagService.getPostRefs(tag);
|
||||
if (refs.isEmpty()) return ResponseEntity.ok(new FeedPage(List.of(), false));
|
||||
|
||||
Set<UUID> myGroupIds = mitgliedRepository.findByUserId(myId).stream()
|
||||
.map(m -> m.getGruppeId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<UUID> friendIds = friendshipRepository
|
||||
.findFriends(myId, FriendshipEntity.Status.ACCEPTED)
|
||||
.stream()
|
||||
.map(f -> f.getSenderId().equals(myId) ? f.getReceiverId() : f.getSenderId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<FeedItemDto> all = new ArrayList<>();
|
||||
for (PostHashtagEntity ref : refs) {
|
||||
if ("FEED".equals(ref.getPostType())) {
|
||||
feedPostRepository.findById(ref.getPostId()).ifPresent(p -> {
|
||||
boolean visible = p.isPublic()
|
||||
|| p.getAuthorId().equals(myId)
|
||||
|| friendIds.contains(p.getAuthorId());
|
||||
if (visible) all.add(toFeedItemDtoFromPost(p, myId));
|
||||
});
|
||||
} else if ("GROUP".equals(ref.getPostType())) {
|
||||
gruppenbeitragRepository.findById(ref.getPostId()).ifPresent(b -> {
|
||||
GruppeEntity gruppe = gruppeRepository.findById(b.getGruppeId()).orElse(null);
|
||||
if (gruppe != null && (!gruppe.isPrivate() || myGroupIds.contains(gruppe.getGruppeId()))) {
|
||||
all.add(toFeedItemDtoFromGruppe(b, myId));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
all.sort(Comparator.comparing(FeedItemDto::createdAt).reversed());
|
||||
int from = page * size;
|
||||
int to = Math.min(from + size, all.size());
|
||||
List<FeedItemDto> items = from < all.size() ? all.subList(from, to) : List.of();
|
||||
return ResponseEntity.ok(new FeedPage(items, to < all.size()));
|
||||
}
|
||||
|
||||
// ── POST /feed/posts/{id}/like ──
|
||||
|
||||
@PostMapping("/posts/{id}/like")
|
||||
@@ -316,6 +374,7 @@ public class FeedController {
|
||||
|
||||
if (!post.getAuthorId().equals(myId)) return ResponseEntity.status(403).build();
|
||||
|
||||
hashtagService.deleteForPost("FEED", id);
|
||||
feedPostVoteRepository.deleteByPostId(id);
|
||||
feedPostOptionRepository.deleteByPostId(id);
|
||||
feedPostLikeRepository.deleteByPostId(id);
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.oaa.xxx.gruppe;
|
||||
import de.oaa.xxx.gruppe.dto.*;
|
||||
import de.oaa.xxx.gruppe.entity.*;
|
||||
import de.oaa.xxx.gruppe.repository.*;
|
||||
import de.oaa.xxx.hashtag.HashtagService;
|
||||
import de.oaa.xxx.social.LikeService;
|
||||
import de.oaa.xxx.social.repository.KommentarRepository;
|
||||
import de.oaa.xxx.user.UserEntity;
|
||||
@@ -35,6 +36,7 @@ public class GruppenbeitragController {
|
||||
private final UserRepository userRepository;
|
||||
private final UserService userService;
|
||||
private final LikeService likeService;
|
||||
private final HashtagService hashtagService;
|
||||
|
||||
public GruppenbeitragController(GruppeRepository gruppeRepository,
|
||||
GruppenmitgliedRepository mitgliedRepository,
|
||||
@@ -46,7 +48,8 @@ public class GruppenbeitragController {
|
||||
KommentarRepository kommentarRepository,
|
||||
UserRepository userRepository,
|
||||
UserService userService,
|
||||
LikeService likeService) {
|
||||
LikeService likeService,
|
||||
HashtagService hashtagService) {
|
||||
this.gruppeRepository = gruppeRepository;
|
||||
this.mitgliedRepository = mitgliedRepository;
|
||||
this.beitragRepository = beitragRepository;
|
||||
@@ -58,6 +61,7 @@ public class GruppenbeitragController {
|
||||
this.userRepository = userRepository;
|
||||
this.userService = userService;
|
||||
this.likeService = likeService;
|
||||
this.hashtagService = hashtagService;
|
||||
}
|
||||
|
||||
record CreateBeitragRequest(String beitragTyp, String text, Boolean multiChoice, List<String> optionen, List<String> bilder) {}
|
||||
@@ -130,6 +134,7 @@ public class GruppenbeitragController {
|
||||
beitrag.setBilder(req.bilder() != null ? req.bilder() : List.of());
|
||||
beitrag.setCreatedAt(LocalDateTime.now());
|
||||
beitragRepository.save(beitrag);
|
||||
hashtagService.saveForPost(beitrag.getText(), "GROUP", beitrag.getBeitragId(), beitrag.getCreatedAt());
|
||||
LOGGER.debug("User {} hat Beitrag {} (Typ: {}) in Gruppe {} erstellt", myId, beitrag.getBeitragId(), typ, id);
|
||||
|
||||
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
|
||||
@@ -311,6 +316,7 @@ public class GruppenbeitragController {
|
||||
|
||||
private void deleteBeitragCascade(GruppenbeitragEntity beitrag) {
|
||||
UUID bid = beitrag.getBeitragId();
|
||||
hashtagService.deleteForPost("GROUP", bid);
|
||||
meldungRepository.deleteByBeitragId(bid);
|
||||
stimmeRepository.deleteByBeitragId(bid);
|
||||
optionRepository.deleteByBeitragId(bid);
|
||||
|
||||
35
src/main/java/de/oaa/xxx/hashtag/HashtagController.java
Normal file
35
src/main/java/de/oaa/xxx/hashtag/HashtagController.java
Normal file
@@ -0,0 +1,35 @@
|
||||
package de.oaa.xxx.hashtag;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/hashtags")
|
||||
public class HashtagController {
|
||||
|
||||
private final HashtagService hashtagService;
|
||||
|
||||
public HashtagController(HashtagService hashtagService) {
|
||||
this.hashtagService = hashtagService;
|
||||
}
|
||||
|
||||
/** Autocomplete: ?q=ki → ["kinky","kink",...]. Empty q returns popular. */
|
||||
@GetMapping("/suggest")
|
||||
public ResponseEntity<List<String>> suggest(
|
||||
@RequestParam(defaultValue = "") String q,
|
||||
@RequestParam(defaultValue = "6") int limit) {
|
||||
return ResponseEntity.ok(hashtagService.suggest(q.stripLeading().replaceFirst("^#", ""), limit));
|
||||
}
|
||||
|
||||
/** Popular hashtags of the last 30 days. */
|
||||
@GetMapping("/popular")
|
||||
public ResponseEntity<List<String>> popular(
|
||||
@RequestParam(defaultValue = "10") int limit) {
|
||||
return ResponseEntity.ok(hashtagService.getPopular(limit));
|
||||
}
|
||||
}
|
||||
21
src/main/java/de/oaa/xxx/hashtag/HashtagEntity.java
Normal file
21
src/main/java/de/oaa/xxx/hashtag/HashtagEntity.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package de.oaa.xxx.hashtag;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "hashtag")
|
||||
public class HashtagEntity {
|
||||
|
||||
@Id
|
||||
@Column
|
||||
private UUID hashtagId;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 100)
|
||||
private String name; // lowercase, without #
|
||||
}
|
||||
15
src/main/java/de/oaa/xxx/hashtag/HashtagRepository.java
Normal file
15
src/main/java/de/oaa/xxx/hashtag/HashtagRepository.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package de.oaa.xxx.hashtag;
|
||||
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface HashtagRepository extends JpaRepository<HashtagEntity, UUID> {
|
||||
|
||||
Optional<HashtagEntity> findByName(String name);
|
||||
|
||||
List<HashtagEntity> findByNameStartingWithOrderByNameAsc(String prefix, Pageable pageable);
|
||||
}
|
||||
100
src/main/java/de/oaa/xxx/hashtag/HashtagService.java
Normal file
100
src/main/java/de/oaa/xxx/hashtag/HashtagService.java
Normal file
@@ -0,0 +1,100 @@
|
||||
package de.oaa.xxx.hashtag;
|
||||
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
public class HashtagService {
|
||||
|
||||
private static final Pattern HASHTAG_PATTERN =
|
||||
Pattern.compile("#([\\wäöüÄÖÜß]{1,100})");
|
||||
|
||||
private final HashtagRepository hashtagRepository;
|
||||
private final PostHashtagRepository postHashtagRepository;
|
||||
|
||||
public HashtagService(HashtagRepository hashtagRepository,
|
||||
PostHashtagRepository postHashtagRepository) {
|
||||
this.hashtagRepository = hashtagRepository;
|
||||
this.postHashtagRepository = postHashtagRepository;
|
||||
}
|
||||
|
||||
/** Extract all unique hashtag names (lowercase, no #) from text. */
|
||||
public List<String> extractTags(String text) {
|
||||
if (text == null || text.isBlank()) return List.of();
|
||||
Matcher m = HASHTAG_PATTERN.matcher(text);
|
||||
return m.results()
|
||||
.map(r -> r.group(1).toLowerCase())
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Save hashtag relationships for a post. Idempotent per tag. */
|
||||
@Transactional
|
||||
public void saveForPost(String text, String postType, UUID postId, LocalDateTime createdAt) {
|
||||
for (String name : extractTags(text)) {
|
||||
HashtagEntity ht = hashtagRepository.findByName(name).orElseGet(() -> {
|
||||
HashtagEntity h = new HashtagEntity();
|
||||
h.setHashtagId(UUID.randomUUID());
|
||||
h.setName(name);
|
||||
return hashtagRepository.save(h);
|
||||
});
|
||||
PostHashtagEntity ph = new PostHashtagEntity();
|
||||
ph.setId(UUID.randomUUID());
|
||||
ph.setHashtagId(ht.getHashtagId());
|
||||
ph.setPostType(postType);
|
||||
ph.setPostId(postId);
|
||||
ph.setCreatedAt(createdAt);
|
||||
postHashtagRepository.save(ph);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove all hashtag relationships for a deleted post. */
|
||||
@Transactional
|
||||
public void deleteForPost(String postType, UUID postId) {
|
||||
postHashtagRepository.deleteByPostTypeAndPostId(postType, postId);
|
||||
}
|
||||
|
||||
/** Autocomplete: names starting with prefix, ordered alphabetically. */
|
||||
public List<String> suggest(String prefix, int limit) {
|
||||
String normalized = normalize(prefix);
|
||||
if (normalized.isEmpty()) return getPopular(limit);
|
||||
return hashtagRepository
|
||||
.findByNameStartingWithOrderByNameAsc(normalized, PageRequest.of(0, limit))
|
||||
.stream()
|
||||
.map(HashtagEntity::getName)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Most-used tags over the last 30 days. */
|
||||
public List<String> getPopular(int limit) {
|
||||
LocalDateTime since = LocalDateTime.now().minusDays(30);
|
||||
return postHashtagRepository
|
||||
.findPopularSince(since, PageRequest.of(0, limit))
|
||||
.stream()
|
||||
.map(row -> hashtagRepository.findById(row.getHashtagId())
|
||||
.map(HashtagEntity::getName).orElse(null))
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** All post references for a given hashtag name (normalised). */
|
||||
public List<PostHashtagEntity> getPostRefs(String name) {
|
||||
String normalized = normalize(name);
|
||||
return hashtagRepository.findByName(normalized)
|
||||
.map(ht -> postHashtagRepository.findByHashtagId(ht.getHashtagId()))
|
||||
.orElse(List.of());
|
||||
}
|
||||
|
||||
private static String normalize(String raw) {
|
||||
if (raw == null) return "";
|
||||
return raw.replaceAll("[^\\wäöüÄÖÜß]", "").toLowerCase();
|
||||
}
|
||||
}
|
||||
35
src/main/java/de/oaa/xxx/hashtag/PostHashtagEntity.java
Normal file
35
src/main/java/de/oaa/xxx/hashtag/PostHashtagEntity.java
Normal file
@@ -0,0 +1,35 @@
|
||||
package de.oaa.xxx.hashtag;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "post_hashtag", indexes = {
|
||||
@Index(name = "idx_ph_hashtag", columnList = "hashtagId"),
|
||||
@Index(name = "idx_ph_post", columnList = "postType, postId")
|
||||
})
|
||||
public class PostHashtagEntity {
|
||||
|
||||
@Id
|
||||
@Column
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID hashtagId;
|
||||
|
||||
/** "FEED" or "GROUP" */
|
||||
@Column(nullable = false, length = 10)
|
||||
private String postType;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID postId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
29
src/main/java/de/oaa/xxx/hashtag/PostHashtagRepository.java
Normal file
29
src/main/java/de/oaa/xxx/hashtag/PostHashtagRepository.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package de.oaa.xxx.hashtag;
|
||||
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface PostHashtagRepository extends JpaRepository<PostHashtagEntity, UUID> {
|
||||
|
||||
List<PostHashtagEntity> findByHashtagId(UUID hashtagId);
|
||||
|
||||
void deleteByPostTypeAndPostId(String postType, UUID postId);
|
||||
|
||||
interface HashtagFrequency {
|
||||
UUID getHashtagId();
|
||||
Long getCnt();
|
||||
}
|
||||
|
||||
@Query("SELECT ph.hashtagId AS hashtagId, COUNT(ph.id) AS cnt " +
|
||||
"FROM PostHashtagEntity ph " +
|
||||
"WHERE ph.createdAt >= :since " +
|
||||
"GROUP BY ph.hashtagId " +
|
||||
"ORDER BY cnt DESC")
|
||||
List<HashtagFrequency> findPopularSince(@Param("since") LocalDateTime since, Pageable pageable);
|
||||
}
|
||||
@@ -68,6 +68,11 @@
|
||||
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
|
||||
.sentinel { height:1px; }
|
||||
|
||||
.hashtag-banner { display:flex; align-items:center; gap:0.75rem; margin-bottom:1.25rem; padding:0.65rem 1rem; background:var(--color-card); border:1px solid var(--color-secondary); border-radius:8px; }
|
||||
.hashtag-banner-tag { font-size:1.05rem; font-weight:700; color:var(--color-primary); }
|
||||
.hashtag-banner-back { margin-left:auto; font-size:0.82rem; color:var(--color-muted); text-decoration:none; }
|
||||
.hashtag-banner-back:hover { color:var(--color-primary); }
|
||||
|
||||
/* Lightbox */
|
||||
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:300; align-items:center; justify-content:center; }
|
||||
.lightbox.open { display:flex; }
|
||||
@@ -92,7 +97,13 @@
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
|
||||
<div class="tabs">
|
||||
<!-- Hashtag-Banner (nur sichtbar wenn ?tag=… gesetzt) -->
|
||||
<div class="hashtag-banner" id="hashtagBanner" style="display:none;">
|
||||
<span>Posts mit </span><span class="hashtag-banner-tag" id="hashtagBannerLabel"></span>
|
||||
<a class="hashtag-banner-back" href="/community/feed.html">× Zurück zum Feed</a>
|
||||
</div>
|
||||
|
||||
<div class="tabs" id="feedTabs">
|
||||
<button class="tab-btn active" id="tabMine" data-tab="mine" onclick="switchTab('mine', this)">Mein Feed</button>
|
||||
<button class="tab-btn" id="tabPublic" data-tab="public" onclick="switchTab('public', this)">Öffentlicher Feed</button>
|
||||
</div>
|
||||
@@ -140,6 +151,13 @@
|
||||
<div class="sentinel" id="publicSentinel"></div>
|
||||
</div>
|
||||
|
||||
<!-- Hashtag-Feed (wird angezeigt wenn ?tag=… gesetzt) -->
|
||||
<div id="tab-hashtag" style="display:none;">
|
||||
<div id="hashtagFeed"></div>
|
||||
<p class="empty-hint" id="hashtagEmpty" style="display:none;">Keine Posts mit diesem Hashtag.</p>
|
||||
<div class="sentinel" id="hashtagSentinel"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -165,37 +183,68 @@
|
||||
|
||||
<script src="/js/shared.js"></script>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/social-sidebar.js"></script>
|
||||
<script src="/js/meldung.js"></script>
|
||||
<script src="/js/hashtag.js"></script>
|
||||
<script>
|
||||
// ── State ──
|
||||
let myUserId = null;
|
||||
let activeLbPostId = null;
|
||||
let activeLbPostId = null;
|
||||
let activeLbPostType = null;
|
||||
let activeHashtag = null; // set when ?tag=... is in URL
|
||||
|
||||
const feedState = {
|
||||
mine: { page:0, hasMore:true, loading:false, loaded:false },
|
||||
public: { page:0, hasMore:true, loading:false, loaded:false }
|
||||
mine: { page:0, hasMore:true, loading:false, loaded:false },
|
||||
public: { page:0, hasMore:true, loading:false, loaded:false },
|
||||
hashtag: { page:0, hasMore:true, loading:false, loaded:false }
|
||||
};
|
||||
|
||||
let composeBilderArr = [];
|
||||
|
||||
// ── Hashtag-Modus prüfen ──
|
||||
const _urlTag = new URLSearchParams(window.location.search).get('tag');
|
||||
if (_urlTag) {
|
||||
activeHashtag = _urlTag.replace(/^#/, '').toLowerCase();
|
||||
document.getElementById('hashtagBanner').style.display = '';
|
||||
document.getElementById('hashtagBannerLabel').textContent = '#' + activeHashtag;
|
||||
document.getElementById('feedTabs').style.display = 'none';
|
||||
document.getElementById('tab-mine').style.display = 'none';
|
||||
document.getElementById('tab-public').style.display = 'none';
|
||||
document.getElementById('tab-hashtag').style.display = '';
|
||||
document.getElementById('compose').style.display = 'none';
|
||||
}
|
||||
|
||||
// ── Boot ──
|
||||
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
|
||||
if (user) {
|
||||
myUserId = user.userId;
|
||||
const raw = sessionStorage.getItem('feedOpenPost');
|
||||
if (raw) {
|
||||
sessionStorage.removeItem('feedOpenPost');
|
||||
loadFeed('mine');
|
||||
openLbWithData(JSON.parse(raw));
|
||||
if (activeHashtag) {
|
||||
await loadFeed('hashtag');
|
||||
} else {
|
||||
await loadFeed('mine');
|
||||
const raw = sessionStorage.getItem('feedOpenPost');
|
||||
if (raw) {
|
||||
sessionStorage.removeItem('feedOpenPost');
|
||||
loadFeed('mine');
|
||||
openLbWithData(JSON.parse(raw));
|
||||
} else {
|
||||
await loadFeed('mine');
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// ── Autocomplete für Compose ──
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const ta = document.getElementById('composeText');
|
||||
if (ta) attachHashtagAutocomplete(ta);
|
||||
});
|
||||
// Fallback falls DOMContentLoaded bereits gefeuert
|
||||
if (document.readyState !== 'loading') {
|
||||
const ta = document.getElementById('composeText');
|
||||
if (ta) attachHashtagAutocomplete(ta);
|
||||
}
|
||||
|
||||
// ── Tab switching ──
|
||||
function switchTab(name, btn) {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
@@ -218,8 +267,14 @@
|
||||
state.loading = true;
|
||||
state.loaded = true;
|
||||
try {
|
||||
const endpoint = tab === 'mine' ? '/feed/mine' : '/feed/public';
|
||||
const res = await fetch(`${endpoint}?page=${state.page}&size=10`);
|
||||
let url;
|
||||
if (tab === 'hashtag') {
|
||||
url = `/feed/hashtag?tag=${encodeURIComponent(activeHashtag)}&page=${state.page}&size=10`;
|
||||
} else {
|
||||
const base = tab === 'mine' ? '/feed/mine' : '/feed/public';
|
||||
url = `${base}?page=${state.page}&size=10`;
|
||||
}
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const feedEl = document.getElementById(tab + 'Feed');
|
||||
@@ -238,12 +293,14 @@
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(e => {
|
||||
if (!e.isIntersecting) return;
|
||||
if (e.target.id === 'mineSentinel') loadFeed('mine');
|
||||
if (e.target.id === 'publicSentinel') loadFeed('public');
|
||||
if (e.target.id === 'mineSentinel') loadFeed('mine');
|
||||
if (e.target.id === 'publicSentinel') loadFeed('public');
|
||||
if (e.target.id === 'hashtagSentinel') loadFeed('hashtag');
|
||||
});
|
||||
}, { threshold: 0.5 });
|
||||
observer.observe(document.getElementById('mineSentinel'));
|
||||
observer.observe(document.getElementById('publicSentinel'));
|
||||
observer.observe(document.getElementById('hashtagSentinel'));
|
||||
|
||||
// bilderCarousel und carNav kommen aus shared.js
|
||||
|
||||
@@ -297,7 +354,7 @@
|
||||
</div>
|
||||
${deleteBtn}
|
||||
</div>
|
||||
<div class="post-text">${esc(p.text)}</div>
|
||||
<div class="post-text">${renderTextWithHashtags(p.text)}</div>
|
||||
${bildHtml}
|
||||
${umfrageHtml}
|
||||
<div class="post-actions">
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
<!-- Friends tab -->
|
||||
<div class="tab-panel active" id="tab-friends">
|
||||
<ul class="user-list" id="friendsList"></ul>
|
||||
<p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></p>
|
||||
<p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/search.html" style="color:var(--color-primary);">Personen suchen</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Pending tab -->
|
||||
|
||||
@@ -297,8 +297,9 @@
|
||||
|
||||
<script src="/js/shared.js"></script>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/social-sidebar.js"></script>
|
||||
<script src="/js/hashtag.js"></script>
|
||||
<script>
|
||||
// ── Generic modal helpers ──
|
||||
|
||||
@@ -377,6 +378,9 @@
|
||||
await loadGruppe();
|
||||
await loadPosts();
|
||||
|
||||
const _composeText = document.getElementById('composeText');
|
||||
if (_composeText) attachHashtagAutocomplete(_composeText);
|
||||
|
||||
const _savedTab = localStorage.getItem('tab_gruppe_' + gruppeId);
|
||||
if (_savedTab) {
|
||||
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedTab}"]`);
|
||||
@@ -488,7 +492,7 @@
|
||||
}).join('');
|
||||
body = bars + `<div class="umfrage-total">${total} Stimme${total !== 1 ? 'n' : ''} gesamt${p.multiChoice?' · Multi-Choice':''}</div>`;
|
||||
} else {
|
||||
body = `<div class="post-text">${esc(p.text)}</div>${bildHtml}`;
|
||||
body = `<div class="post-text">${renderTextWithHashtags(p.text)}</div>${bildHtml}`;
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -500,7 +504,7 @@
|
||||
</div>
|
||||
<div class="post-date">${fmtDate(p.createdAt)}</div>
|
||||
</div>
|
||||
${p.beitragTyp === 'UMFRAGE' ? `<div style="font-weight:600;margin-bottom:0.5rem;">${esc(p.text)}</div>${bildHtml}` : ''}
|
||||
${p.beitragTyp === 'UMFRAGE' ? `<div style="font-weight:600;margin-bottom:0.5rem;">${renderTextWithHashtags(p.text)}</div>${bildHtml}` : ''}
|
||||
${body}
|
||||
<div class="post-actions">
|
||||
<button class="post-action-btn ${p.likedByMe?'active':''}" onclick="event.stopPropagation(); toggleLike('${p.beitragId}',this)" id="like-btn-${p.beitragId}">
|
||||
|
||||
@@ -22,16 +22,9 @@
|
||||
.gruppe-card-body { padding:0.85rem; flex:1; display:flex; flex-direction:column; gap:0.4rem; }
|
||||
.gruppe-card-name { font-weight:700; font-size:1rem; }
|
||||
.gruppe-card-meta { font-size:0.78rem; color:var(--color-muted); }
|
||||
.gruppe-card-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:auto; padding-top:0.5rem; }
|
||||
.gruppe-card-actions button, .gruppe-card-actions a.btn { margin-top:0; padding:0.3rem 0.7rem; font-size:0.8rem; width:auto; }
|
||||
|
||||
.role-badge { font-size:0.7rem; font-weight:700; padding:0.1rem 0.4rem; border-radius:4px; background:var(--color-primary); color:#fff; display:inline-block; }
|
||||
.role-badge.mitglied { background:var(--color-secondary); color:var(--color-text); }
|
||||
|
||||
.search-row { display:flex; gap:0.75rem; margin-bottom:1rem; }
|
||||
.search-row input { flex:1; }
|
||||
.search-row button { white-space:nowrap; width:auto; margin-top:0; }
|
||||
|
||||
.anfrage-list { list-style:none; margin:0; padding:0; }
|
||||
.anfrage-item { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:0.75rem 0; border-bottom:1px solid var(--color-secondary); }
|
||||
.anfrage-item:last-child { border-bottom:none; }
|
||||
@@ -64,12 +57,14 @@
|
||||
<div class="content">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.25rem;">
|
||||
<h1 style="margin:0;">Gruppen</h1>
|
||||
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button>
|
||||
<div style="display:flex; gap:0.5rem;">
|
||||
<button onclick="location.href='/search.html?tab=gruppen'" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">🔍 Suchen</button>
|
||||
<button onclick="openCreateDialog()" style="width:auto; padding:0.4rem 1rem; font-size:0.9rem; margin:0;">+ Erstellen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="mine" onclick="switchTab('mine', this)">Meine Gruppen</button>
|
||||
<button class="tab-btn" data-tab="discover" onclick="switchTab('discover', this)">Entdecken</button>
|
||||
<button class="tab-btn" data-tab="requests" onclick="switchTab('requests', this)">Meine Anfragen</button>
|
||||
</div>
|
||||
|
||||
@@ -79,16 +74,6 @@
|
||||
<p class="empty-hint" id="mineEmpty" style="display:none;">Du bist noch in keiner Gruppe.</p>
|
||||
</div>
|
||||
|
||||
<!-- Entdecken -->
|
||||
<div class="tab-panel" id="tab-discover">
|
||||
<div class="search-row">
|
||||
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" onkeydown="if(event.key==='Enter')doSearch()">
|
||||
<button onclick="doSearch()">Suchen</button>
|
||||
</div>
|
||||
<div class="gruppe-grid" id="discoverGrid"></div>
|
||||
<p class="empty-hint" id="discoverHint">Gib einen Suchbegriff ein.</p>
|
||||
</div>
|
||||
|
||||
<!-- Meine Anfragen -->
|
||||
<div class="tab-panel" id="tab-requests">
|
||||
<ul class="anfrage-list" id="requestsList"></ul>
|
||||
@@ -121,20 +106,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beitrittsanfrage Dialog -->
|
||||
<div class="dialog-backdrop" id="joinDialog">
|
||||
<div class="dialog">
|
||||
<h3>Beitrittsanfrage senden</h3>
|
||||
<p id="joinDialogGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
|
||||
<label>Nachricht (optional)</label>
|
||||
<textarea id="joinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
|
||||
<p class="message error" id="joinError" style="display:none; margin-top:0.75rem;"></p>
|
||||
<div class="dialog-actions">
|
||||
<button class="secondary" onclick="closeJoinDialog()">Abbrechen</button>
|
||||
<button onclick="sendJoinRequest()">Anfrage senden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
@@ -157,7 +128,7 @@
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
|
||||
|
||||
function gruppeCard(g, showJoin = false) {
|
||||
function gruppeCard(g) {
|
||||
const img = g.bild
|
||||
? `<div class="gruppe-card-img"><img src="data:image/jpeg;base64,${g.bild}" alt=""></div>`
|
||||
: `<div class="gruppe-card-img">👥</div>`;
|
||||
@@ -165,16 +136,6 @@
|
||||
? `<span class="role-badge ${g.myRole === 'ADMIN' ? '' : 'mitglied'}">${g.myRole === 'ADMIN' ? 'Admin' : 'Mitglied'}</span>`
|
||||
: '';
|
||||
const privBadge = g.isPrivate ? ' 🔒' : '';
|
||||
let actions = '';
|
||||
if (showJoin && !g.myRole) {
|
||||
if (g.myRequestStatus === 'AUSSTEHEND') {
|
||||
actions = `<button disabled style="opacity:0.6;" onclick="event.stopPropagation()">Anfrage ausstehend</button>`;
|
||||
} else if (g.isPrivate) {
|
||||
actions = `<button onclick="event.stopPropagation(); openJoinDialog('${g.gruppeId}','${esc(g.name)}')">Anfrage senden</button>`;
|
||||
} else {
|
||||
actions = `<button onclick="event.stopPropagation(); joinGruppe('${g.gruppeId}', this)">Beitreten</button>`;
|
||||
}
|
||||
}
|
||||
return `
|
||||
<div class="gruppe-card" id="gc-${g.gruppeId}" onclick="location.href='/community/gruppe.html?gruppeId=${g.gruppeId}'" style="cursor:pointer;">
|
||||
${img}
|
||||
@@ -183,7 +144,6 @@
|
||||
<div class="gruppe-card-meta">${g.memberCount} Mitglied${g.memberCount !== 1 ? 'er' : ''} · ${g.postCount} Beiträge</div>
|
||||
${g.beschreibung ? `<div style="font-size:0.82rem;color:var(--color-muted);">${esc(g.beschreibung.substring(0,80))}${g.beschreibung.length>80?'…':''}</div>` : ''}
|
||||
<div class="card-notif" id="notif-${g.gruppeId}"></div>
|
||||
${actions ? `<div class="gruppe-card-actions">${actions}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -223,37 +183,6 @@
|
||||
}));
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
const q = document.getElementById('searchInput').value.trim();
|
||||
if (!q) return;
|
||||
try {
|
||||
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const grid = document.getElementById('discoverGrid');
|
||||
const hint = document.getElementById('discoverHint');
|
||||
grid.innerHTML = '';
|
||||
if (data.length === 0) { hint.textContent = 'Keine Gruppen gefunden.'; hint.style.display = ''; return; }
|
||||
hint.style.display = 'none';
|
||||
data.forEach(g => grid.insertAdjacentHTML('beforeend', gruppeCard(g, true)));
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function joinGruppe(gruppeId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
try {
|
||||
const res = await fetch('/gruppen/' + gruppeId + '/join', { method: 'POST', headers:{'Content-Type':'application/json'}, body:'{}' });
|
||||
if (res.ok || res.status === 201) {
|
||||
btn.textContent = 'Beigetreten ✓';
|
||||
setTimeout(loadMine, 500);
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Beitreten';
|
||||
}
|
||||
} catch(e) { btn.disabled = false; btn.textContent = 'Beitreten'; }
|
||||
}
|
||||
|
||||
async function loadRequests() {
|
||||
try {
|
||||
const reqRes = await fetch('/gruppen/requests/mine');
|
||||
@@ -333,42 +262,6 @@
|
||||
} catch(e) { showCreateError('Fehler: ' + e.message); }
|
||||
}
|
||||
|
||||
// ── Join dialog ──
|
||||
|
||||
let pendingJoinGruppeId = null;
|
||||
function openJoinDialog(gruppeId, name) {
|
||||
pendingJoinGruppeId = gruppeId;
|
||||
document.getElementById('joinDialogGroupName').textContent = name;
|
||||
document.getElementById('joinNachricht').value = '';
|
||||
document.getElementById('joinDialog').classList.add('visible');
|
||||
}
|
||||
function closeJoinDialog() { document.getElementById('joinDialog').classList.remove('visible'); pendingJoinGruppeId = null; }
|
||||
|
||||
async function sendJoinRequest() {
|
||||
if (!pendingJoinGruppeId) return;
|
||||
document.getElementById('joinError').style.display = 'none';
|
||||
const nachricht = document.getElementById('joinNachricht').value.trim() || null;
|
||||
try {
|
||||
const res = await fetch('/gruppen/' + pendingJoinGruppeId + '/join', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ nachricht })
|
||||
});
|
||||
if (res.ok || res.status === 201) {
|
||||
closeJoinDialog();
|
||||
doSearch();
|
||||
} else {
|
||||
const el = document.getElementById('joinError');
|
||||
el.textContent = 'Fehler beim Senden der Anfrage.';
|
||||
el.style.display = 'block';
|
||||
}
|
||||
} catch(e) {
|
||||
const el = document.getElementById('joinError');
|
||||
el.textContent = 'Fehler: ' + e.message;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Image preview ──
|
||||
|
||||
function previewBild(input, previewId, dataId) {
|
||||
@@ -401,9 +294,6 @@
|
||||
document.getElementById('createDialog').addEventListener('click', e => {
|
||||
if (e.target === document.getElementById('createDialog')) closeCreateDialog();
|
||||
});
|
||||
document.getElementById('joinDialog').addEventListener('click', e => {
|
||||
if (e.target === document.getElementById('joinDialog')) closeJoinDialog();
|
||||
});
|
||||
|
||||
loadMine();
|
||||
</script>
|
||||
|
||||
@@ -360,7 +360,7 @@
|
||||
const list = document.getElementById('convList');
|
||||
list.innerHTML = '';
|
||||
if (convs.length === 0) {
|
||||
list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/community/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></li>';
|
||||
list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/search.html" style="color:var(--color-primary);">Personen suchen</a></li>';
|
||||
return;
|
||||
}
|
||||
convs.forEach(c => {
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Personen suchen – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.search-bar input { flex: 1; }
|
||||
.search-bar button { width: auto; margin-top: 0; padding: 0.65rem 1.25rem; }
|
||||
|
||||
.user-list { list-style: none; margin: 0; padding: 0; }
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
}
|
||||
.user-item:last-child { border-bottom: none; }
|
||||
|
||||
.user-avatar {
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-secondary);
|
||||
}
|
||||
.user-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.user-name { font-weight: 600; flex: 1; }
|
||||
|
||||
.user-actions { display: flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.user-actions button, .user-actions a.btn {
|
||||
margin-top: 0;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.user-profile-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.user-profile-link:hover .user-name { color: var(--color-primary); }
|
||||
|
||||
.hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<h1 style="margin-bottom: 1.25rem;">Personen suchen</h1>
|
||||
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" placeholder="Name eingeben (mind. 2 Zeichen)…" autocomplete="off">
|
||||
<button onclick="doSearch()">Suchen</button>
|
||||
</div>
|
||||
|
||||
<ul class="user-list" id="resultList"></ul>
|
||||
<p class="hint" id="hint">Gib mindestens 2 Zeichen ein, um zu suchen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script src="/js/social-sidebar.js"></script>
|
||||
<script>
|
||||
let debounceTimer;
|
||||
|
||||
document.getElementById('searchInput').addEventListener('input', function () {
|
||||
clearTimeout(debounceTimer);
|
||||
const q = this.value.trim();
|
||||
if (q.length < 2) {
|
||||
document.getElementById('resultList').innerHTML = '';
|
||||
document.getElementById('hint').textContent = 'Gib mindestens 2 Zeichen ein, um zu suchen.';
|
||||
document.getElementById('hint').style.display = '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('hint').style.display = 'none';
|
||||
debounceTimer = setTimeout(doSearch, 400);
|
||||
});
|
||||
|
||||
document.getElementById('searchInput').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') { clearTimeout(debounceTimer); doSearch(); }
|
||||
});
|
||||
|
||||
async function doSearch() {
|
||||
const q = document.getElementById('searchInput').value.trim();
|
||||
if (q.length < 2) return;
|
||||
try {
|
||||
const res = await fetch('/social/users/search?q=' + encodeURIComponent(q));
|
||||
if (!res.ok) return;
|
||||
renderResults(await res.json());
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
function renderResults(users) {
|
||||
const list = document.getElementById('resultList');
|
||||
const hint = document.getElementById('hint');
|
||||
list.innerHTML = '';
|
||||
if (users.length === 0) {
|
||||
hint.textContent = 'Keine Ergebnisse gefunden.';
|
||||
hint.style.display = '';
|
||||
return;
|
||||
}
|
||||
hint.style.display = 'none';
|
||||
users.forEach(u => {
|
||||
const avatar = u.profilePicture
|
||||
? `<img src="data:image/png;base64,${u.profilePicture}" alt="">`
|
||||
: '◉';
|
||||
list.insertAdjacentHTML('beforeend', `
|
||||
<li class="user-item" data-user-id="${u.userId}">
|
||||
<a href="/community/benutzer.html?userId=${u.userId}" class="user-profile-link">
|
||||
<div class="user-avatar">${avatar}</div>
|
||||
<div class="user-name">${esc(u.name)}</div>
|
||||
</a>
|
||||
<div class="user-actions">${buildActions(u)}</div>
|
||||
</li>`);
|
||||
});
|
||||
}
|
||||
|
||||
function buildActions(u) {
|
||||
if (u.friendStatus === 'FRIEND') {
|
||||
return `<a href="/community/nachrichten.html?userId=${u.userId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>`;
|
||||
}
|
||||
if (u.friendStatus === 'PENDING_SENT') {
|
||||
return `<button disabled>Anfrage gesendet</button>`;
|
||||
}
|
||||
if (u.friendStatus === 'PENDING_RECEIVED') {
|
||||
return `<button onclick="acceptByUserId('${u.userId}', this)">✓ Annehmen</button>`;
|
||||
}
|
||||
return `<button onclick="sendRequest('${u.userId}', this)">+ Freund hinzufügen</button>`;
|
||||
}
|
||||
|
||||
async function sendRequest(receiverId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Wird gesendet…';
|
||||
try {
|
||||
const res = await fetch('/social/friends/request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ receiverId })
|
||||
});
|
||||
btn.textContent = (res.ok || res.status === 201 || res.status === 409) ? 'Anfrage gesendet' : 'Fehler';
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '+ Freund hinzufügen';
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptByUserId(senderId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
try {
|
||||
const pendingRes = await fetch('/social/friends/pending');
|
||||
const pending = await pendingRes.json();
|
||||
const f = pending.find(p => p.user.userId === senderId);
|
||||
if (!f) { btn.textContent = 'Fehler'; return; }
|
||||
|
||||
const res = await fetch('/social/friends/accept', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ friendshipId: f.friendshipId })
|
||||
});
|
||||
if (res.ok) {
|
||||
btn.textContent = '✓ Freund';
|
||||
const item = btn.closest('.user-item');
|
||||
if (item) {
|
||||
item.querySelector('.user-actions').innerHTML =
|
||||
`<a href="/community/nachrichten.html?userId=${senderId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>`;
|
||||
}
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '✓ Annehmen';
|
||||
}
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '✓ Annehmen';
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
371
src/main/resources/static/games/common/einladungen.html
Normal file
371
src/main/resources/static/games/common/einladungen.html
Normal file
@@ -0,0 +1,371 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Einladungen – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.inv-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
}
|
||||
.inv-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
padding: 0.5rem 1.1rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
margin: 0 0 -1px;
|
||||
width: auto;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.inv-tab.active { color: var(--color-primary); border-bottom-color: var(--color-primary); }
|
||||
.inv-tab:hover:not(.active) { color: var(--color-text); background: none; }
|
||||
|
||||
.inv-section-label {
|
||||
font-size: 0.8rem; font-weight: 600; color: var(--color-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin: 1.5rem 0 0.65rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
}
|
||||
.inv-card {
|
||||
display: flex; gap: 0.85rem; align-items: center;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 0.6rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.inv-card:hover { border-color: var(--color-primary); }
|
||||
.inv-avatar {
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.2rem; overflow: hidden; flex-shrink: 0;
|
||||
}
|
||||
.inv-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
.inv-body { flex: 1; min-width: 0; }
|
||||
.inv-from { font-weight: 600; font-size: 0.95rem;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.inv-type { font-size: 0.78rem; color: var(--color-muted); margin-top: 0.15rem; }
|
||||
.inv-actions { display: flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; align-items: center; }
|
||||
.inv-btn {
|
||||
background: var(--color-primary);
|
||||
color: #fff; border: none; border-radius: 8px;
|
||||
padding: 0.38rem 0.9rem; font-size: 0.85rem; font-weight: 600;
|
||||
cursor: pointer; margin: 0; width: auto;
|
||||
text-decoration: none; display: inline-flex; align-items: center;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.inv-btn:hover { opacity: 0.85; }
|
||||
.inv-btn.outline {
|
||||
background: none; border: 1px solid var(--color-secondary);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.inv-btn.outline:hover { border-color: var(--color-primary); color: var(--color-primary); opacity: 1; }
|
||||
.inv-arrow { font-size: 0.85rem; color: var(--color-primary); font-weight: 600; flex-shrink: 0; }
|
||||
.inv-status { font-size: 0.8rem; color: var(--color-muted); }
|
||||
.inv-empty { text-align: center; color: var(--color-muted); padding: 3rem 1rem; font-size: 0.95rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<h1 style="margin:0 0 1.25rem;">Einladungen</h1>
|
||||
|
||||
<div class="inv-tabs">
|
||||
<button class="inv-tab" id="tabBtnErhalten" onclick="switchTab('erhalten')">Erhalten</button>
|
||||
<button class="inv-tab" id="tabBtnGesendet" onclick="switchTab('gesendet')">Gesendet</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Erhalten ─────────────────────────────────────────────────── -->
|
||||
<div id="panelErhalten">
|
||||
<div id="loadingErhalten" style="text-align:center;color:var(--color-muted);padding:2rem 0;">Wird geladen…</div>
|
||||
|
||||
<div id="secVanilla" style="display:none;">
|
||||
<div class="inv-section-label">🎭 Vanilla Game</div>
|
||||
<div id="listVanilla"></div>
|
||||
</div>
|
||||
|
||||
<div id="secBdsm" style="display:none;">
|
||||
<div class="inv-section-label">⛓ BDSM Game</div>
|
||||
<div id="listBdsm"></div>
|
||||
</div>
|
||||
|
||||
<div id="secChastity" style="display:none;">
|
||||
<div class="inv-section-label">🔒 Chastity Game</div>
|
||||
<div id="listChastity"></div>
|
||||
</div>
|
||||
|
||||
<div id="emptyErhalten" style="display:none;">
|
||||
<div class="inv-empty">Du hast aktuell keine offenen Einladungen.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Gesendet ─────────────────────────────────────────────────── -->
|
||||
<div id="panelGesendet" style="display:none;">
|
||||
<div id="loadingGesendet" style="text-align:center;color:var(--color-muted);padding:2rem 0;">Wird geladen…</div>
|
||||
|
||||
<div id="secSentVanilla" style="display:none;">
|
||||
<div class="inv-section-label">🎭 Vanilla Game</div>
|
||||
<div id="listSentVanilla"></div>
|
||||
</div>
|
||||
|
||||
<div id="secSentBdsm" style="display:none;">
|
||||
<div class="inv-section-label">⛓ BDSM Game</div>
|
||||
<div id="listSentBdsm"></div>
|
||||
</div>
|
||||
|
||||
<div id="secSentChastity" style="display:none;">
|
||||
<div class="inv-section-label">🔒 Chastity Game</div>
|
||||
<div id="listSentChastity"></div>
|
||||
</div>
|
||||
|
||||
<div id="emptySent" style="display:none;">
|
||||
<div class="inv-empty">Du hast aktuell keine gesendeten Einladungen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script>
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── Tab-Switching ────────────────────────────────────────────────────────
|
||||
function switchTab(tab) {
|
||||
const erhalten = tab === 'erhalten';
|
||||
document.getElementById('panelErhalten').style.display = erhalten ? '' : 'none';
|
||||
document.getElementById('panelGesendet').style.display = erhalten ? 'none' : '';
|
||||
document.getElementById('tabBtnErhalten').classList.toggle('active', erhalten);
|
||||
document.getElementById('tabBtnGesendet').classList.toggle('active', !erhalten);
|
||||
const url = new URL(location.href);
|
||||
erhalten ? url.searchParams.delete('tab') : url.searchParams.set('tab', tab);
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
const initTab = new URLSearchParams(location.search).get('tab') || 'erhalten';
|
||||
switchTab(initTab);
|
||||
|
||||
// ── Erhalten ──────────────────────────────────────────────────────────────
|
||||
async function loadErhalten() {
|
||||
let shown = 0;
|
||||
try {
|
||||
const [vRes, bRes, cRes] = await Promise.all([
|
||||
fetch('/vanilla/einladung/pending'),
|
||||
fetch('/bdsm/einladung/pending'),
|
||||
fetch('/lockee/invitations/mine'),
|
||||
]);
|
||||
|
||||
// Vanilla
|
||||
if (vRes.ok) {
|
||||
const list = await vRes.json();
|
||||
if (list.length) {
|
||||
document.getElementById('secVanilla').style.display = '';
|
||||
document.getElementById('listVanilla').innerHTML = list.map(e => `
|
||||
<div class="inv-card" id="vcard-${esc(e.id)}">
|
||||
<div class="inv-avatar">
|
||||
${e.inviterAvatar
|
||||
? `<img src="data:image/png;base64,${e.inviterAvatar}" alt="">`
|
||||
: '🎭'}
|
||||
</div>
|
||||
<div class="inv-body">
|
||||
<div class="inv-from">${esc(e.inviterName || 'Jemand')}</div>
|
||||
<div class="inv-type">Vanilla-Spieleinladung</div>
|
||||
</div>
|
||||
<div class="inv-actions">
|
||||
<button class="inv-btn" onclick="acceptVanilla(${esc(e.id)})">Annehmen</button>
|
||||
<button class="inv-btn outline" onclick="declineVanilla(${esc(e.id)})">Ablehnen</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
shown += list.length;
|
||||
}
|
||||
}
|
||||
|
||||
// BDSM
|
||||
if (bRes.ok) {
|
||||
const list = await bRes.json();
|
||||
if (list.length) {
|
||||
document.getElementById('secBdsm').style.display = '';
|
||||
document.getElementById('listBdsm').innerHTML = list.map(e => `
|
||||
<a class="inv-card" href="/games/bdsm/bdsm-einladung.html?id=${esc(e.id)}">
|
||||
<div class="inv-avatar">
|
||||
${e.inviterAvatar
|
||||
? `<img src="data:image/png;base64,${e.inviterAvatar}" alt="">`
|
||||
: '⛓'}
|
||||
</div>
|
||||
<div class="inv-body">
|
||||
<div class="inv-from">${esc(e.inviterName || 'Jemand')}</div>
|
||||
<div class="inv-type">BDSM-Spieleinladung</div>
|
||||
</div>
|
||||
<span class="inv-arrow">Ansehen →</span>
|
||||
</a>`).join('');
|
||||
shown += list.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Chastity
|
||||
if (cRes.ok) {
|
||||
const list = await cRes.json();
|
||||
if (list.length) {
|
||||
document.getElementById('secChastity').style.display = '';
|
||||
document.getElementById('listChastity').innerHTML = list.map(e => `
|
||||
<a class="inv-card" href="/games/chastity/joinlock.html?token=${esc(e.token)}">
|
||||
<div class="inv-avatar">
|
||||
${e.keyholderProfilePic
|
||||
? `<img src="data:image/png;base64,${e.keyholderProfilePic}" alt="">`
|
||||
: '🔒'}
|
||||
</div>
|
||||
<div class="inv-body">
|
||||
<div class="inv-from">${esc(e.keyholderName || 'Jemand')}</div>
|
||||
<div class="inv-type">Keuschheitslock: ${esc(e.lockName || '')}</div>
|
||||
</div>
|
||||
<span class="inv-arrow">Ansehen →</span>
|
||||
</a>`).join('');
|
||||
shown += list.length;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
document.getElementById('loadingErhalten').style.display = 'none';
|
||||
if (!shown) document.getElementById('emptyErhalten').style.display = '';
|
||||
}
|
||||
|
||||
// Vanilla inline annehmen / ablehnen
|
||||
async function acceptVanilla(id) {
|
||||
const card = document.getElementById('vcard-' + id);
|
||||
if (card) card.querySelector('.inv-actions').innerHTML = '<span class="inv-status">Wird gespeichert…</span>';
|
||||
try {
|
||||
const res = await fetch(`/vanilla/einladung/${id}/antwort`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accepted: true, mode: 'OWN_DEVICE' }),
|
||||
});
|
||||
if (res.ok) {
|
||||
window.location.href = '/games/vanilla/neuvanilla.html';
|
||||
} else {
|
||||
if (card) card.querySelector('.inv-actions').innerHTML = '<span class="inv-status" style="color:var(--color-primary)">Fehler beim Annehmen.</span>';
|
||||
}
|
||||
} catch (_) {
|
||||
if (card) card.querySelector('.inv-actions').innerHTML = '<span class="inv-status" style="color:var(--color-primary)">Fehler.</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function declineVanilla(id) {
|
||||
const card = document.getElementById('vcard-' + id);
|
||||
if (card) card.querySelector('.inv-actions').innerHTML = '<span class="inv-status">Wird gespeichert…</span>';
|
||||
try {
|
||||
await fetch(`/vanilla/einladung/${id}/antwort`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accepted: false, mode: null }),
|
||||
});
|
||||
if (card) card.remove();
|
||||
if (!document.getElementById('listVanilla').children.length) {
|
||||
document.getElementById('secVanilla').style.display = 'none';
|
||||
}
|
||||
checkEmptyErhalten();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function checkEmptyErhalten() {
|
||||
const anyVisible = ['secVanilla','secBdsm','secChastity']
|
||||
.some(id => document.getElementById(id).style.display !== 'none');
|
||||
document.getElementById('emptyErhalten').style.display = anyVisible ? 'none' : '';
|
||||
}
|
||||
|
||||
// ── Gesendet ──────────────────────────────────────────────────────────────
|
||||
async function loadGesendet() {
|
||||
let shown = 0;
|
||||
try {
|
||||
const [bRes, vRes, cRes] = await Promise.all([
|
||||
fetch('/bdsm/einladung/meine-aktive'),
|
||||
fetch('/vanilla/einladung/meine-aktive'),
|
||||
fetch('/keyholder/invitations/mine'),
|
||||
]);
|
||||
|
||||
// Vanilla gesendete Einladung
|
||||
if (vRes.ok) {
|
||||
const e = await vRes.json();
|
||||
if (e) {
|
||||
document.getElementById('secSentVanilla').style.display = '';
|
||||
document.getElementById('listSentVanilla').innerHTML = `
|
||||
<div class="inv-card">
|
||||
<div class="inv-avatar">🎭</div>
|
||||
<div class="inv-body">
|
||||
<div class="inv-from">Vanilla-Einladung gesendet</div>
|
||||
<div class="inv-type">Warte auf Antwort…</div>
|
||||
</div>
|
||||
<a class="inv-btn" href="/games/vanilla/neuvanilla.html">Zum Spiel →</a>
|
||||
</div>`;
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
|
||||
// BDSM gesendete Einladung
|
||||
if (bRes.ok) {
|
||||
const e = await bRes.json();
|
||||
if (e) {
|
||||
document.getElementById('secSentBdsm').style.display = '';
|
||||
document.getElementById('listSentBdsm').innerHTML = `
|
||||
<div class="inv-card">
|
||||
<div class="inv-avatar">⛓</div>
|
||||
<div class="inv-body">
|
||||
<div class="inv-from">BDSM-Einladung gesendet</div>
|
||||
<div class="inv-type">Warte auf Antwort…</div>
|
||||
</div>
|
||||
<a class="inv-btn" href="/games/bdsm/neubdsm.html">Zum Spiel →</a>
|
||||
</div>`;
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
|
||||
// Chastity gesendete Einladungen (als Keyholder)
|
||||
if (cRes.ok) {
|
||||
const list = await cRes.json();
|
||||
if (Array.isArray(list) && list.length) {
|
||||
document.getElementById('secSentChastity').style.display = '';
|
||||
document.getElementById('listSentChastity').innerHTML = list.map(e => `
|
||||
<div class="inv-card">
|
||||
<div class="inv-avatar">🔒</div>
|
||||
<div class="inv-body">
|
||||
<div class="inv-from">${esc(e.lockeeName || e.lockeeEmail || 'Eingeladene Person')}</div>
|
||||
<div class="inv-type">Lock: ${esc(e.lockName || '')} · ${esc(e.status || 'Ausstehend')}</div>
|
||||
</div>
|
||||
<a class="inv-btn outline" href="/games/chastity/keyholder.html">Keyholder →</a>
|
||||
</div>`).join('');
|
||||
shown += list.length;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
document.getElementById('loadingGesendet').style.display = 'none';
|
||||
if (!shown) document.getElementById('emptySent').style.display = '';
|
||||
}
|
||||
|
||||
// Auth-Check & Start
|
||||
fetch('/login/me')
|
||||
.then(r => { if (r.status === 401) window.location.href = '/login.html'; return r.ok ? r.json() : null; })
|
||||
.then(user => {
|
||||
if (user) { loadErhalten(); loadGesendet(); }
|
||||
})
|
||||
.catch(() => { window.location.href = '/login.html'; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1213,6 +1213,7 @@
|
||||
const id = addPlayer(p.name, i === 0, i === 0, false, false);
|
||||
if (id) { restorePlayer(id, p); if (p.userId) userIdToInfo[p.userId] = { playerId: id, name: p.name }; }
|
||||
});
|
||||
Object.entries(userIdToInfo).forEach(([userId, info]) => { pruefeChastityConstraint(info.playerId, userId); });
|
||||
await ladeEinladungenAusDb(userIdToInfo);
|
||||
restoredFromSetup = true;
|
||||
} else {
|
||||
@@ -1273,14 +1274,15 @@
|
||||
if (draft.gruppenJson) { sessionStorage.setItem('vanilla-session-gruppen', draft.gruppenJson); savedGruppen = new Set(JSON.parse(draft.gruppenJson)); }
|
||||
}
|
||||
}
|
||||
const selfGeschlecht = user?.geschlecht || null;
|
||||
const selfWerkzeuge = selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : [];
|
||||
const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
|
||||
const selfGeschlecht = defaults.geschlecht || user?.geschlecht || null;
|
||||
const selfWerkzeuge = defaults.werkzeuge?.length ? defaults.werkzeuge : (selfGeschlecht ? (WERKZEUGE_DEFAULTS[selfGeschlecht] || []) : []);
|
||||
const selfId = addPlayer(user ? user.name : '', true, true, !!selfGeschlecht, false);
|
||||
if (selfId) {
|
||||
if (user?.profilePicture) { playerProfilePics[selfId] = user.profilePicture; updatePlayerHeader(selfId, user.name); }
|
||||
if (playerIds.length < MAX_PLAYERS) addPlayer();
|
||||
restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: [], rollen: [], werkzeuge: selfWerkzeuge });
|
||||
pruefeChastityConstraint(selfId, myUserId);
|
||||
restorePlayer(selfId, { geschlecht: selfGeschlecht, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: selfWerkzeuge });
|
||||
if (myUserId) pruefeChastityConstraint(selfId, myUserId);
|
||||
}
|
||||
await ladeEinladungenAusDb(null);
|
||||
}
|
||||
@@ -1324,8 +1326,9 @@
|
||||
|
||||
if (user?.profilePicture) { playerProfilePics[guestOwnPlayerId] = user.profilePicture; updatePlayerHeader(guestOwnPlayerId, user?.name || ''); }
|
||||
|
||||
restorePlayer(guestOwnPlayerId, { geschlecht: user?.geschlecht || null, spieltMit: [], rollen: [], werkzeuge: [] });
|
||||
pruefeChastityConstraint(guestOwnPlayerId, myUserId);
|
||||
const defaults = await fetch('/user/me/bdsm-defaults').then(r => r.ok ? r.json() : {}).catch(() => ({}));
|
||||
restorePlayer(guestOwnPlayerId, { geschlecht: defaults.geschlecht || user?.geschlecht || null, spieltMit: defaults.spieltMit || [], rollen: defaults.rollen || [], werkzeuge: defaults.werkzeuge || [] });
|
||||
if (myUserId) pruefeChastityConstraint(guestOwnPlayerId, myUserId);
|
||||
|
||||
document.getElementById('acc-grundeinstellungen-btn').classList.add('is-open');
|
||||
document.getElementById('acc-grundeinstellungen-body').classList.add('is-open');
|
||||
|
||||
217
src/main/resources/static/js/hashtag.js
Normal file
217
src/main/resources/static/js/hashtag.js
Normal file
@@ -0,0 +1,217 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Styles ──────────────────────────────────────────────────────────────
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.post-hashtag {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.post-hashtag:hover { text-decoration: underline; }
|
||||
|
||||
.hashtag-dropdown {
|
||||
position: fixed;
|
||||
z-index: 600;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 8px;
|
||||
min-width: 170px;
|
||||
max-width: 260px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hashtag-dropdown-item {
|
||||
padding: 0.45rem 0.9rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.hashtag-dropdown-item:hover,
|
||||
.hashtag-dropdown-item.active {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.hashtag-dropdown-create {
|
||||
font-style: italic;
|
||||
border-top: 1px solid var(--color-secondary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.hashtag-dropdown-hint {
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// ── Escape helper (works even if shared.js not yet loaded) ───────────────
|
||||
function esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── renderTextWithHashtags ────────────────────────────────────────────────
|
||||
window.renderTextWithHashtags = function (text) {
|
||||
if (!text) return '';
|
||||
const PATTERN = /#([\wäöüÄÖÜß]{1,100})/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = PATTERN.exec(text)) !== null) {
|
||||
parts.push(esc(text.slice(lastIndex, match.index)));
|
||||
const tag = match[1].toLowerCase();
|
||||
parts.push(
|
||||
`<a href="/community/feed.html?tag=${encodeURIComponent(tag)}" ` +
|
||||
`class="post-hashtag" onclick="event.stopPropagation()">#${esc(match[1])}</a>`
|
||||
);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
parts.push(esc(text.slice(lastIndex)));
|
||||
return parts.join('');
|
||||
};
|
||||
|
||||
// ── attachHashtagAutocomplete ─────────────────────────────────────────────
|
||||
window.attachHashtagAutocomplete = function (textarea) {
|
||||
let dropdownEl = null;
|
||||
let suggestions = [];
|
||||
let selectedIdx = -1;
|
||||
let debounce = null;
|
||||
|
||||
// Returns { prefix, hashStart } if cursor is inside a #word, else null
|
||||
function getHashAtCursor() {
|
||||
const pos = textarea.selectionStart;
|
||||
const text = textarea.value;
|
||||
let i = pos - 1;
|
||||
while (i >= 0 && /[\wäöüÄÖÜß]/.test(text[i])) i--;
|
||||
if (i >= 0 && text[i] === '#') {
|
||||
return { prefix: text.slice(i + 1, pos), hashStart: i };
|
||||
}
|
||||
if (i === -1 && text.length > 0 && text[0] === '#' && pos > 0) {
|
||||
return { prefix: text.slice(1, pos), hashStart: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function positionDropdown() {
|
||||
if (!dropdownEl) return;
|
||||
const r = textarea.getBoundingClientRect();
|
||||
dropdownEl.style.top = (r.bottom + window.scrollY + 2) + 'px';
|
||||
dropdownEl.style.left = (r.left + window.scrollX) + 'px';
|
||||
}
|
||||
|
||||
function highlight() {
|
||||
if (!dropdownEl) return;
|
||||
dropdownEl.querySelectorAll('.hashtag-dropdown-item').forEach((el, i) => {
|
||||
el.classList.toggle('active', i === selectedIdx);
|
||||
});
|
||||
}
|
||||
|
||||
function removeDropdown() {
|
||||
dropdownEl?.remove();
|
||||
dropdownEl = null;
|
||||
suggestions = [];
|
||||
selectedIdx = -1;
|
||||
}
|
||||
|
||||
function showDropdown(items, prefix) {
|
||||
removeDropdown();
|
||||
const lPrefix = prefix ? prefix.toLowerCase() : '';
|
||||
const hasCreate = lPrefix.length > 0 && !items.some(t => t === lPrefix);
|
||||
|
||||
if (!items.length && !hasCreate) {
|
||||
if (prefix !== undefined && prefix.length === 0) {
|
||||
// Nur "#" getippt, noch keine populären Tags
|
||||
dropdownEl = document.createElement('div');
|
||||
dropdownEl.className = 'hashtag-dropdown';
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'hashtag-dropdown-hint';
|
||||
hint.textContent = 'Tippe weiter um einen Hashtag zu erstellen';
|
||||
dropdownEl.appendChild(hint);
|
||||
document.body.appendChild(dropdownEl);
|
||||
positionDropdown();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
suggestions = hasCreate ? [...items, lPrefix] : [...items];
|
||||
|
||||
dropdownEl = document.createElement('div');
|
||||
dropdownEl.className = 'hashtag-dropdown';
|
||||
|
||||
suggestions.forEach((tag, i) => {
|
||||
const isCreate = hasCreate && i === suggestions.length - 1;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'hashtag-dropdown-item' + (isCreate ? ' hashtag-dropdown-create' : '');
|
||||
item.textContent = (isCreate ? '+ #' : '#') + tag;
|
||||
item.addEventListener('mousedown', e => { e.preventDefault(); insertTag(tag); });
|
||||
item.addEventListener('mouseover', () => { selectedIdx = i; highlight(); });
|
||||
dropdownEl.appendChild(item);
|
||||
});
|
||||
|
||||
document.body.appendChild(dropdownEl);
|
||||
positionDropdown();
|
||||
}
|
||||
|
||||
function insertTag(tag) {
|
||||
const info = getHashAtCursor();
|
||||
if (!info) { removeDropdown(); return; }
|
||||
const v = textarea.value;
|
||||
const cursor = textarea.selectionStart;
|
||||
const before = v.slice(0, info.hashStart);
|
||||
const after = v.slice(cursor);
|
||||
const inserted = '#' + tag + ' ';
|
||||
textarea.value = before + inserted + after;
|
||||
const newPos = before.length + inserted.length;
|
||||
textarea.setSelectionRange(newPos, newPos);
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
removeDropdown();
|
||||
}
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
clearTimeout(debounce);
|
||||
const info = getHashAtCursor();
|
||||
if (!info) { removeDropdown(); return; }
|
||||
debounce = setTimeout(async () => {
|
||||
try {
|
||||
const url = info.prefix.length === 0
|
||||
? '/hashtags/popular?limit=6'
|
||||
: '/hashtags/suggest?q=' + encodeURIComponent(info.prefix) + '&limit=6';
|
||||
const res = await fetch(url);
|
||||
if (res.ok) showDropdown(await res.json(), info.prefix);
|
||||
else showDropdown([], info.prefix);
|
||||
} catch { removeDropdown(); }
|
||||
}, 150);
|
||||
});
|
||||
|
||||
textarea.addEventListener('keydown', e => {
|
||||
if (!dropdownEl) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIdx = Math.min(selectedIdx + 1, suggestions.length - 1);
|
||||
highlight();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIdx = Math.max(selectedIdx - 1, 0);
|
||||
highlight();
|
||||
} else if ((e.key === 'Enter' || e.key === 'Tab') && selectedIdx >= 0) {
|
||||
e.preventDefault();
|
||||
insertTag(suggestions[selectedIdx]);
|
||||
} else if (e.key === 'Escape') {
|
||||
removeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
textarea.addEventListener('blur', () => setTimeout(removeDropdown, 150));
|
||||
window.addEventListener('scroll', positionDropdown, { passive: true });
|
||||
window.addEventListener('resize', positionDropdown, { passive: true });
|
||||
};
|
||||
})();
|
||||
@@ -19,7 +19,7 @@
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
|
||||
z-index: 500;
|
||||
align-items: center;
|
||||
padding: 0 0.25rem;
|
||||
padding: 0 4px 0 0.25rem;
|
||||
}
|
||||
.mobile-topbar-logo {
|
||||
position: absolute;
|
||||
@@ -43,9 +43,9 @@
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
font-size: 1.725rem;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
padding: 0.75rem 0.675rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
'/community/gruppen.html', '/community/gruppe.html',
|
||||
'/community/locations.html', '/community/location-detail.html',
|
||||
'/community/events.html', '/community/event-detail.html',
|
||||
'/community/abonnements.html', '/community/personen-suchen.html',
|
||||
'/community/abonnements.html',
|
||||
'/community/benutzer.html',
|
||||
])}
|
||||
${column('colDating', 'Dating', col3Html, ['/dating/'])}
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
|
||||
// ── Bereichs-Definitionen ────────────────────────────────────────────────
|
||||
const SECTIONS = {
|
||||
common: {
|
||||
prefixes: ['/games/common/'],
|
||||
items: [
|
||||
{ href: '/games/vanilla/neuvanilla.html', icon: 'VANILLA', label: 'Vanilla Game' },
|
||||
{ href: '/games/bdsm/neubdsm.html', icon: 'BDSM', label: 'BDSM Game' },
|
||||
{ href: '/games/chastity/neulock.html', icon: 'CHASTITY', label: 'Chastity Game' },
|
||||
],
|
||||
},
|
||||
social: {
|
||||
prefixes: ['/community/'],
|
||||
exclude: [
|
||||
'/community/nachrichten.html',
|
||||
'/community/benachrichtigungen.html',
|
||||
'/community/einladungen.html',
|
||||
],
|
||||
items: [
|
||||
{ href: '/community/feed.html', icon: 'FEED', label: 'Feed' },
|
||||
|
||||
@@ -168,18 +168,26 @@
|
||||
|
||||
async function doSearch(q, overlay) {
|
||||
try {
|
||||
const res = await fetch('/search?q=' + encodeURIComponent(q) + '&limit=3');
|
||||
if (!res.ok) { overlay.innerHTML = '<div class="topbar-search-hint">Fehler bei der Suche.</div>'; return; }
|
||||
const data = await res.json();
|
||||
const { users = [], locations = [], events = [] } = data;
|
||||
const tagQuery = q.startsWith('#') ? q.slice(1) : q;
|
||||
const [searchRes, gruppenRes, hashtagRes] = await Promise.all([
|
||||
fetch('/search?q=' + encodeURIComponent(q) + '&limit=3'),
|
||||
fetch('/gruppen/search?q=' + encodeURIComponent(q)),
|
||||
fetch('/hashtags/suggest?q=' + encodeURIComponent(tagQuery) + '&limit=4')
|
||||
]);
|
||||
|
||||
if (!users.length && !locations.length && !events.length) {
|
||||
overlay.innerHTML = '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
|
||||
return;
|
||||
}
|
||||
const data = searchRes.ok ? await searchRes.json() : {};
|
||||
const gruppen = gruppenRes.ok ? await gruppenRes.json() : [];
|
||||
const hashtags = hashtagRes.ok ? await hashtagRes.json() : [];
|
||||
|
||||
const { users = [], locations = [], events = [] } = data;
|
||||
const gruppenSlice = gruppen.slice(0, 3);
|
||||
|
||||
let html = '';
|
||||
|
||||
if (!users.length && !locations.length && !events.length && !gruppenSlice.length && !hashtags.length) {
|
||||
html += '<div class="topbar-search-hint">Keine Ergebnisse.</div>';
|
||||
}
|
||||
|
||||
if (users.length) {
|
||||
html += `<div class="topbar-search-section">Personen</div>`;
|
||||
html += users.map(u => {
|
||||
@@ -213,6 +221,26 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
if (gruppenSlice.length) {
|
||||
html += `<div class="topbar-search-section">Gruppen</div>`;
|
||||
html += gruppenSlice.map(g => {
|
||||
const av = g.bild
|
||||
? `<img src="data:image/jpeg;base64,${esc(g.bild)}" class="topbar-search-avatar" alt="">`
|
||||
: `<span class="topbar-search-avatar topbar-search-avatar--placeholder">👥</span>`;
|
||||
return `<a href="/community/gruppe.html?gruppeId=${esc(g.gruppeId)}" class="topbar-search-result">
|
||||
${av}<span>${esc(g.name)}</span></a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
if (hashtags.length) {
|
||||
html += `<div class="topbar-search-section">Hashtags</div>`;
|
||||
html += `<div style="display:flex;flex-wrap:wrap;gap:0.4rem;padding:0.4rem 0.75rem;">`;
|
||||
html += hashtags.map(tag =>
|
||||
`<a href="/community/feed.html?tag=${encodeURIComponent(tag)}" style="display:inline-block;padding:0.25rem 0.65rem;background:var(--color-secondary);border-radius:14px;font-size:0.82rem;font-weight:600;color:var(--color-primary);text-decoration:none;">#${esc(tag)}</a>`
|
||||
).join('');
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `<a href="/search.html?q=${encodeURIComponent(q)}" class="topbar-search-all">Alle Ergebnisse anzeigen →</a>`;
|
||||
overlay.innerHTML = html;
|
||||
} catch (e) {
|
||||
|
||||
@@ -163,6 +163,33 @@
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.search-load-more:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; }
|
||||
|
||||
.hashtag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 20px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.hashtag-chip:hover { border-color: var(--color-primary); background: var(--color-secondary); }
|
||||
|
||||
/* Dialog (Gruppen-Beitritt) */
|
||||
.dialog-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; }
|
||||
.dialog-backdrop.visible { display:flex; }
|
||||
.dialog { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:12px; padding:1.75rem; width:100%; max-width:420px; box-shadow:0 8px 32px rgba(0,0,0,0.6); max-height:90vh; overflow-y:auto; }
|
||||
.dialog h3 { color:var(--color-primary); font-size:1.1rem; margin-bottom:1.25rem; }
|
||||
.dialog label { display:block; font-size:0.8rem; color:#aaa; margin-bottom:0.3rem; margin-top:1rem; }
|
||||
.dialog textarea { width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px; background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none; transition:border-color 0.2s; resize:vertical; min-height:80px; box-sizing:border-box; }
|
||||
.dialog textarea:focus { border-color:var(--color-primary); }
|
||||
.dialog-actions { display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.5rem; }
|
||||
.dialog-actions button { flex:none; margin:0; padding:0.55rem 1.1rem; font-size:0.9rem; width:auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
@@ -173,7 +200,7 @@
|
||||
<div class="search-hero">
|
||||
<div class="search-hero-input-wrap">
|
||||
<span class="search-hero-icon" id="searchIcon"></span>
|
||||
<input type="text" id="searchInput" placeholder="Suchen nach Personen, Locations, Veranstaltungen…"
|
||||
<input type="text" id="searchInput" placeholder="Suchen nach Personen, Locations, Veranstaltungen, Gruppen…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,6 +215,12 @@
|
||||
<button class="search-tab-btn" data-tab="events">
|
||||
Veranstaltungen <span class="search-tab-count" id="countEvents">0</span>
|
||||
</button>
|
||||
<button class="search-tab-btn" data-tab="gruppen">
|
||||
Gruppen <span class="search-tab-count" id="countGruppen">0</span>
|
||||
</button>
|
||||
<button class="search-tab-btn" data-tab="hashtags">
|
||||
Hashtags <span class="search-tab-count" id="countHashtags">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search-panel active" id="panel-users">
|
||||
@@ -207,6 +240,31 @@
|
||||
<div class="search-grid" id="gridEvents"></div>
|
||||
<button class="search-load-more" id="moreEvents" style="display:none;">Mehr laden</button>
|
||||
</div>
|
||||
|
||||
<div class="search-panel" id="panel-gruppen">
|
||||
<div class="search-loading" id="loadingGruppen" style="display:none;">Wird geladen…</div>
|
||||
<div class="search-grid" id="gridGruppen"></div>
|
||||
</div>
|
||||
|
||||
<div class="search-panel" id="panel-hashtags">
|
||||
<div class="search-loading" id="loadingHashtags" style="display:none;">Wird geladen…</div>
|
||||
<div id="gridHashtags" style="display:flex; flex-wrap:wrap; gap:0.6rem; padding-top:0.25rem;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beitrittsanfrage Dialog -->
|
||||
<div class="dialog-backdrop" id="searchJoinDialog">
|
||||
<div class="dialog">
|
||||
<h3>Beitrittsanfrage senden</h3>
|
||||
<p id="searchJoinGroupName" style="font-weight:600; margin-bottom:0.5rem;"></p>
|
||||
<label>Nachricht (optional)</label>
|
||||
<textarea id="searchJoinNachricht" placeholder="Warum möchtest du beitreten?"></textarea>
|
||||
<p class="message error" id="searchJoinError" style="display:none; margin-top:0.75rem;"></p>
|
||||
<div class="dialog-actions">
|
||||
<button class="secondary" id="searchJoinCancelBtn">Abbrechen</button>
|
||||
<button id="searchJoinSendBtn">Anfrage senden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -279,10 +337,12 @@
|
||||
document.getElementById('grid' + cap(t)).innerHTML = '';
|
||||
document.getElementById('more' + cap(t)).style.display = 'none';
|
||||
});
|
||||
// Alle drei Typen parallel laden
|
||||
// Alle Typen parallel laden
|
||||
loadChunk('users');
|
||||
loadChunk('locations');
|
||||
loadChunk('events');
|
||||
loadGruppen(q);
|
||||
loadHashtags(q);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
@@ -295,6 +355,12 @@
|
||||
document.getElementById('count' + cap(t)).textContent = '0';
|
||||
document.getElementById('loading' + cap(t)).style.display = 'none';
|
||||
});
|
||||
document.getElementById('gridGruppen').innerHTML = '';
|
||||
document.getElementById('countGruppen').textContent = '0';
|
||||
document.getElementById('loadingGruppen').style.display = 'none';
|
||||
document.getElementById('gridHashtags').innerHTML = '';
|
||||
document.getElementById('countHashtags').textContent = '0';
|
||||
document.getElementById('loadingHashtags').style.display = 'none';
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.delete('q');
|
||||
history.replaceState(null, '', url);
|
||||
@@ -374,14 +440,185 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Gruppen-Suche ──
|
||||
|
||||
async function loadGruppen(q) {
|
||||
const loadingEl = document.getElementById('loadingGruppen');
|
||||
const grid = document.getElementById('gridGruppen');
|
||||
loadingEl.style.display = '';
|
||||
grid.innerHTML = '';
|
||||
try {
|
||||
const res = await fetch('/gruppen/search?q=' + encodeURIComponent(q));
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
document.getElementById('countGruppen').textContent = data.length;
|
||||
if (!data.length) {
|
||||
grid.innerHTML = '<div class="search-empty" style="grid-column:1/-1;">Keine Ergebnisse.</div>';
|
||||
return;
|
||||
}
|
||||
data.forEach(g => grid.appendChild(buildGruppeCard(g)));
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
loadingEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function buildGruppeCard(g) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'search-card';
|
||||
card.style.cssText = 'justify-content:space-between; cursor:pointer;';
|
||||
card.addEventListener('click', () => { location.href = '/community/gruppe.html?gruppeId=' + g.gruppeId; });
|
||||
|
||||
const av = g.bild
|
||||
? `<div class="search-card-avatar search-card-avatar--square"><img src="data:image/jpeg;base64,${esc(g.bild)}" alt=""></div>`
|
||||
: `<div class="search-card-avatar search-card-avatar--square">👥</div>`;
|
||||
const privBadge = g.isPrivate ? ' 🔒' : '';
|
||||
const sub = g.memberCount + ' Mitglied' + (g.memberCount !== 1 ? 'er' : '');
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.style.cssText = 'display:flex; align-items:center; gap:0.75rem; min-width:0; flex:1;';
|
||||
info.innerHTML = `${av}<div class="search-card-body"><div class="search-card-name">${esc(g.name)}${privBadge}</div><div class="search-card-sub">${esc(sub)}</div></div>`;
|
||||
card.appendChild(info);
|
||||
|
||||
if (!g.myRole) {
|
||||
const btn = document.createElement('button');
|
||||
btn.style.cssText = 'font-size:0.78rem; padding:0.3rem 0.65rem; width:auto; margin:0; white-space:nowrap; flex-shrink:0; margin-left:0.5rem;';
|
||||
if (g.myRequestStatus === 'AUSSTEHEND') {
|
||||
btn.disabled = true;
|
||||
btn.style.opacity = '0.6';
|
||||
btn.textContent = 'Anfrage ausstehend';
|
||||
} else if (g.isPrivate) {
|
||||
btn.textContent = 'Anfrage senden';
|
||||
btn.addEventListener('click', e => { e.stopPropagation(); openSearchJoinDialog(g.gruppeId, g.name); });
|
||||
} else {
|
||||
btn.textContent = 'Beitreten';
|
||||
btn.addEventListener('click', e => { e.stopPropagation(); joinGruppeSearch(g.gruppeId, btn); });
|
||||
}
|
||||
card.appendChild(btn);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Hashtag-Suche ──
|
||||
|
||||
async function loadHashtags(q) {
|
||||
const loadingEl = document.getElementById('loadingHashtags');
|
||||
const grid = document.getElementById('gridHashtags');
|
||||
loadingEl.style.display = '';
|
||||
grid.innerHTML = '';
|
||||
try {
|
||||
const raw = q.startsWith('#') ? q.slice(1) : q;
|
||||
const res = await fetch('/hashtags/suggest?q=' + encodeURIComponent(raw) + '&limit=20');
|
||||
if (!res.ok) throw new Error();
|
||||
const tags = await res.json();
|
||||
document.getElementById('countHashtags').textContent = tags.length;
|
||||
if (!tags.length) {
|
||||
grid.innerHTML = '<div class="search-empty">Keine Hashtags gefunden.</div>';
|
||||
return;
|
||||
}
|
||||
tags.forEach(tag => {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'hashtag-chip';
|
||||
a.href = '/community/feed.html?tag=' + encodeURIComponent(tag);
|
||||
a.textContent = '#' + tag;
|
||||
grid.appendChild(a);
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
loadingEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function joinGruppeSearch(gruppeId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
try {
|
||||
const res = await fetch('/gruppen/' + gruppeId + '/join', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}'
|
||||
});
|
||||
if (res.ok || res.status === 201) {
|
||||
btn.textContent = 'Beigetreten ✓';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Beitreten';
|
||||
}
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Beitreten';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Join-Dialog für Gruppen ──
|
||||
|
||||
let searchJoinGruppeId = null;
|
||||
|
||||
function openSearchJoinDialog(gruppeId, name) {
|
||||
searchJoinGruppeId = gruppeId;
|
||||
document.getElementById('searchJoinGroupName').textContent = name;
|
||||
document.getElementById('searchJoinNachricht').value = '';
|
||||
document.getElementById('searchJoinError').style.display = 'none';
|
||||
document.getElementById('searchJoinDialog').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeSearchJoinDialog() {
|
||||
document.getElementById('searchJoinDialog').classList.remove('visible');
|
||||
searchJoinGruppeId = null;
|
||||
}
|
||||
|
||||
async function sendSearchJoinRequest() {
|
||||
if (!searchJoinGruppeId) return;
|
||||
document.getElementById('searchJoinError').style.display = 'none';
|
||||
const nachricht = document.getElementById('searchJoinNachricht').value.trim() || null;
|
||||
try {
|
||||
const res = await fetch('/gruppen/' + searchJoinGruppeId + '/join', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nachricht })
|
||||
});
|
||||
if (res.ok || res.status === 201) {
|
||||
closeSearchJoinDialog();
|
||||
if (currentQuery.length >= 2) loadGruppen(currentQuery);
|
||||
} else {
|
||||
const el = document.getElementById('searchJoinError');
|
||||
el.textContent = 'Fehler beim Senden der Anfrage.';
|
||||
el.style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
const el = document.getElementById('searchJoinError');
|
||||
el.textContent = 'Fehler: ' + e.message;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('searchJoinCancelBtn').addEventListener('click', closeSearchJoinDialog);
|
||||
document.getElementById('searchJoinSendBtn').addEventListener('click', sendSearchJoinRequest);
|
||||
document.getElementById('searchJoinDialog').addEventListener('click', e => {
|
||||
if (e.target === document.getElementById('searchJoinDialog')) closeSearchJoinDialog();
|
||||
});
|
||||
|
||||
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
||||
|
||||
// ── URL-Parameter beim Start auslesen ──
|
||||
const initQ = new URLSearchParams(window.location.search).get('q') || '';
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const initQ = params.get('q') || '';
|
||||
const initTab = params.get('tab') || '';
|
||||
|
||||
if (initTab) {
|
||||
const tabBtn = document.querySelector(`.search-tab-btn[data-tab="${initTab}"]`);
|
||||
if (tabBtn) tabBtn.click();
|
||||
}
|
||||
|
||||
if (initQ.length >= 2) {
|
||||
input.value = initQ;
|
||||
// Warten bis icons.js geladen ist
|
||||
setTimeout(() => startSearch(initQ), 100);
|
||||
} else if (initTab === 'hashtags') {
|
||||
// Populäre Tags zeigen wenn kein Suchbegriff
|
||||
setTimeout(() => loadHashtags(''), 100);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -243,6 +243,33 @@
|
||||
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
|
||||
@media (max-width:650px) { .lb-layout { flex-direction:column; height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
|
||||
|
||||
/* ── Spiel starten ── */
|
||||
.start-game-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.start-game-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem 1rem;
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
.start-game-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(var(--color-primary-rgb,233,69,96),0.06);
|
||||
}
|
||||
.start-game-icon { font-size: 2rem; line-height: 1; }
|
||||
.start-game-title { font-size: 0.9rem; font-weight: 600; }
|
||||
|
||||
/* ── Neue Mitglieder ── */
|
||||
.new-members-strip {
|
||||
display: flex;
|
||||
@@ -306,6 +333,25 @@
|
||||
<div class="active-game-list" id="activeLockList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Kein Spiel aktiv – Starten -->
|
||||
<div id="startGameSection" style="display:none;">
|
||||
<div class="section-label">Spiel starten 🎮</div>
|
||||
<div class="start-game-grid">
|
||||
<a href="/games/vanilla/neuvanilla.html" class="start-game-card">
|
||||
<div class="start-game-icon">🎭</div>
|
||||
<div class="start-game-title">Vanilla Game starten</div>
|
||||
</a>
|
||||
<a href="/games/bdsm/neubdsm.html" class="start-game-card">
|
||||
<div class="start-game-icon">⛓</div>
|
||||
<div class="start-game-title">BDSM-Game starten</div>
|
||||
</a>
|
||||
<a href="/games/chastity/neulock.html" class="start-game-card">
|
||||
<div class="start-game-icon">🔒</div>
|
||||
<div class="start-game-title">Chastity-Lock starten</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Einladungen -->
|
||||
<div id="invitesSection" style="display:none;">
|
||||
<div class="section-label">Einladungen 📨</div>
|
||||
@@ -423,6 +469,7 @@
|
||||
|
||||
<script src="/js/shared.js"></script>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/hashtag.js"></script>
|
||||
<script src="/js/nav.js"></script>
|
||||
<script>
|
||||
let myUserId = null;
|
||||
@@ -436,14 +483,20 @@
|
||||
if (user) {
|
||||
myUserId = user.userId;
|
||||
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
|
||||
loadActiveGames(user.userId);
|
||||
loadActiveLock();
|
||||
Promise.all([loadActiveGames(user.userId), loadActiveLock()]).then(() => {
|
||||
const hasGames = document.getElementById('activeGamesSection').style.display !== 'none';
|
||||
const hasLock = document.getElementById('activeLockSection').style.display !== 'none';
|
||||
if (!hasGames && !hasLock) {
|
||||
document.getElementById('startGameSection').style.display = '';
|
||||
}
|
||||
});
|
||||
loadInvites();
|
||||
loadFriendRequests();
|
||||
loadVisitors();
|
||||
loadMyEvents();
|
||||
loadLocEvents();
|
||||
loadFeed();
|
||||
attachHashtagAutocomplete(document.getElementById('homeComposeText'));
|
||||
if (user.datingAktiv) {
|
||||
loadWhoLikesMe();
|
||||
loadMatches();
|
||||
@@ -931,7 +984,7 @@
|
||||
<div class="post-meta">${fmtDate(p.createdAt)}${groupBadge}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-text">${esc(p.text || '')}</div>
|
||||
<div class="post-text">${renderTextWithHashtags(p.text || '')}</div>
|
||||
${bildHtml}
|
||||
${umfrageHtml}
|
||||
<div class="post-actions">
|
||||
|
||||
Reference in New Issue
Block a user