Weiter an den Locations gearbeitet
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled

This commit is contained in:
2026-04-06 22:48:34 +02:00
parent 0f9f109067
commit 5ffb99c9b5
81 changed files with 2817 additions and 352 deletions

View File

@@ -8,6 +8,8 @@ import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.http.ResponseEntity;
@@ -47,9 +49,6 @@ import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestController
@RequestMapping("/feed")
public class FeedController {
@@ -365,7 +364,8 @@ public class FeedController {
p.getCreatedAt(),
likeCount, likedByMe, kommentarCount,
optionen, myVoteOptionIds,
p.isPublic()
p.isPublic(),
p.getTargetUrl()
);
}
@@ -402,7 +402,8 @@ public class FeedController {
b.getCreatedAt(),
likeCount, likedByMe, kommentarCount,
optionen, myVoteOptionIds,
false
false,
null
);
}
}

View File

@@ -24,5 +24,6 @@ public record FeedItemDto(
long kommentarCount,
List<UmfrageOptionDto> optionen,
List<UUID> myVoteOptionIds,
boolean isPublic
boolean isPublic,
String targetUrl
) {}

View File

@@ -42,4 +42,7 @@ public class FeedPostEntity {
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(columnDefinition = "TEXT")
private String targetUrl;
}

View File

@@ -0,0 +1,176 @@
package de.oaa.xxx.location;
import de.oaa.xxx.location.entity.LocationAdminEntity;
import de.oaa.xxx.location.repository.LocationAdminRepository;
import de.oaa.xxx.location.repository.LocationRepository;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
import jakarta.transaction.Transactional;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/locations/{locationId}/admins")
public class LocationAdminController {
record AdminDto(UUID userId, String name, String profilePicture, boolean isOwner) {}
record AddAdminRequest(UUID userId) {}
record TransferOwnerRequest(UUID userId) {}
private final LocationRepository locationRepo;
private final LocationAdminRepository adminRepo;
private final UserRepository userRepo;
private final UserService userService;
public LocationAdminController(LocationRepository locationRepo,
LocationAdminRepository adminRepo,
UserRepository userRepo,
UserService userService) {
this.locationRepo = locationRepo;
this.adminRepo = adminRepo;
this.userRepo = userRepo;
this.userService = userService;
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
private boolean isAdmin(UUID locationId, UUID userId, de.oaa.xxx.location.entity.LocationEntity loc) {
return loc.getOwnerId().equals(userId)
|| adminRepo.existsByLocationIdAndUserId(locationId, userId);
}
private AdminDto toDto(UUID userId, de.oaa.xxx.location.entity.LocationEntity loc) {
return userRepo.findById(userId).map(u -> new AdminDto(
u.getUserId(), u.getName(), u.getProfilePicture(),
u.getUserId().equals(loc.getOwnerId())))
.orElse(null);
}
// ── Admins auflisten ─────────────────────────────────────────────────────
@GetMapping
public ResponseEntity<List<AdminDto>> list(
@PathVariable UUID locationId,
Principal principal) {
userService.requireUser(principal);
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
var loc = locOpt.get();
// Inhaber ist immer erster Eintrag
List<AdminDto> admins = new java.util.ArrayList<>();
userRepo.findById(loc.getOwnerId()).ifPresent(owner ->
admins.add(new AdminDto(owner.getUserId(), owner.getName(), owner.getProfilePicture(), true)));
adminRepo.findByLocationId(locationId).stream()
.filter(a -> !a.getUserId().equals(loc.getOwnerId())) // Inhaber nicht doppelt
.map(a -> toDto(a.getUserId(), loc))
.filter(java.util.Objects::nonNull)
.forEach(admins::add);
return ResponseEntity.ok(admins);
}
// ── Admin hinzufügen ──────────────────────────────────────────────────────
@PostMapping
public ResponseEntity<AdminDto> add(
@PathVariable UUID locationId,
@RequestBody AddAdminRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
var loc = locOpt.get();
if (!isAdmin(locationId, myId, loc)) return ResponseEntity.status(403).build();
if (req.userId() == null || !userRepo.existsById(req.userId()))
return ResponseEntity.badRequest().build();
// Inhaber muss nicht eingetragen werden
if (req.userId().equals(loc.getOwnerId()))
return ResponseEntity.badRequest().build();
// Bereits Admin?
if (adminRepo.existsByLocationIdAndUserId(locationId, req.userId()))
return ResponseEntity.status(409).build();
LocationAdminEntity entity = new LocationAdminEntity();
entity.setAdminId(UUID.randomUUID());
entity.setLocationId(locationId);
entity.setUserId(req.userId());
entity.setAddedAt(LocalDateTime.now());
adminRepo.save(entity);
return ResponseEntity.status(201).body(toDto(req.userId(), loc));
}
// ── Admin entfernen ───────────────────────────────────────────────────────
@Transactional
@DeleteMapping("/{userId}")
public ResponseEntity<Void> remove(
@PathVariable UUID locationId,
@PathVariable UUID userId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
var loc = locOpt.get();
if (!isAdmin(locationId, myId, loc)) return ResponseEntity.status(403).build();
// Inhaber darf nicht entfernt werden
if (userId.equals(loc.getOwnerId())) return ResponseEntity.status(403).build();
adminRepo.deleteByLocationIdAndUserId(locationId, userId);
return ResponseEntity.noContent().build();
}
// ── Inhaberwechsel ────────────────────────────────────────────────────────
@Transactional
@PutMapping("/transfer-owner")
public ResponseEntity<Map<String, Object>> transferOwner(
@PathVariable UUID locationId,
@RequestBody TransferOwnerRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
var loc = locOpt.get();
// Nur der aktuelle Inhaber darf übertragen
if (!loc.getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
if (req.userId() == null || !userRepo.existsById(req.userId()))
return ResponseEntity.badRequest().build();
if (req.userId().equals(myId)) return ResponseEntity.badRequest().build();
// Neuer Inhaber als Admin eintragen (falls noch nicht), alter Inhaber wird normaler Admin
if (!adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
LocationAdminEntity a = new LocationAdminEntity();
a.setAdminId(UUID.randomUUID());
a.setLocationId(locationId);
a.setUserId(myId);
a.setAddedAt(LocalDateTime.now());
adminRepo.save(a);
}
// Neuen Inhaber aus Admin-Liste entfernen (er ist jetzt Owner)
adminRepo.deleteByLocationIdAndUserId(locationId, req.userId());
loc.setOwnerId(req.userId());
locationRepo.save(loc);
return ResponseEntity.ok(Map.of("newOwnerId", req.userId()));
}
}

View File

@@ -0,0 +1,73 @@
package de.oaa.xxx.location;
import de.oaa.xxx.location.entity.LocationEntity;
import de.oaa.xxx.location.repository.LocationInboxLockRepository;
import de.oaa.xxx.location.repository.LocationRepository;
import de.oaa.xxx.social.entity.MessageEntity;
import de.oaa.xxx.social.repository.MessageRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
@Service
public class LocationChatCleanupService {
private static final Logger LOGGER = LoggerFactory.getLogger(LocationChatCleanupService.class);
private final LocationRepository locationRepo;
private final MessageRepository messageRepo;
private final LocationInboxLockRepository lockRepo;
public LocationChatCleanupService(LocationRepository locationRepo,
MessageRepository messageRepo,
LocationInboxLockRepository lockRepo) {
this.locationRepo = locationRepo;
this.messageRepo = messageRepo;
this.lockRepo = lockRepo;
}
/** Täglich um 03:00 Uhr: Location-Chats löschen, die seit mehr als einem Monat inaktiv sind. */
@Scheduled(cron = "0 0 3 * * *")
@Transactional
public void cleanupInactiveChats() {
LocalDateTime cutoff = LocalDateTime.now().minusMonths(1);
List<LocationEntity> locations = locationRepo.findByVirtualUserIdIsNotNull();
int deleted = 0;
for (LocationEntity loc : locations) {
UUID virtualId = loc.getVirtualUserId();
// Alle Nachrichten dieser Location (in beide Richtungen)
List<MessageEntity> allMessages = messageRepo.findAllByUser(virtualId);
if (allMessages.isEmpty()) continue;
// Neueste Nachricht pro Gesprächspartner (Besucher)
Map<UUID, LocalDateTime> latestByVisitor = new HashMap<>();
for (MessageEntity m : allMessages) {
UUID visitor = m.getSenderId().equals(virtualId) ? m.getReceiverId() : m.getSenderId();
latestByVisitor.merge(visitor, m.getSentAt(),
(a, b) -> a.isAfter(b) ? a : b);
}
for (Map.Entry<UUID, LocalDateTime> entry : latestByVisitor.entrySet()) {
if (entry.getValue().isBefore(cutoff)) {
UUID visitorId = entry.getKey();
messageRepo.deleteConversation(virtualId, visitorId);
lockRepo.findByLocationIdAndVisitorId(loc.getLocationId(), visitorId)
.ifPresent(lockRepo::delete);
deleted++;
LOGGER.info("Inaktiver Location-Chat gelöscht: location={} visitor={}", loc.getLocationId(), visitorId);
}
}
}
if (deleted > 0) {
LOGGER.info("Location-Chat-Cleanup abgeschlossen: {} Konversation(en) gelöscht.", deleted);
}
}
}

View File

@@ -1,7 +1,13 @@
package de.oaa.xxx.location;
import de.oaa.xxx.location.entity.*;
import de.oaa.xxx.location.entity.LocationInboxLockEntity;
import de.oaa.xxx.location.repository.*;
import de.oaa.xxx.location.repository.LocationInboxLockRepository;
import de.oaa.xxx.social.SseService;
import de.oaa.xxx.social.entity.MessageEntity;
import de.oaa.xxx.social.repository.MessageRepository;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
import jakarta.transaction.Transactional;
import org.slf4j.Logger;
@@ -12,6 +18,7 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.Collections;
import java.util.stream.Collectors;
@RestController
@@ -22,13 +29,20 @@ public class LocationController {
private static final int MAX_GALLERY_IMAGES = 20;
private static final int MAX_BATCH_SIZE = 50;
private final LocationRepository locationRepo;
private final LocationImageRepository imageRepo;
private static final int LOCK_TIMEOUT_MINUTES = 60;
private final LocationRepository locationRepo;
private final LocationImageRepository imageRepo;
private final LocationOpeningHoursRepository hoursRepo;
private final LocationEventRepository eventRepo;
private final LocationEventRepository eventRepo;
private final LocationEventAttendeeRepository attendeeRepo;
private final LocationFollowRepository followRepo;
private final UserService userService;
private final LocationFollowRepository followRepo;
private final LocationAdminRepository adminRepo;
private final UserRepository userRepo;
private final UserService userService;
private final MessageRepository messageRepo;
private final SseService sseService;
private final LocationInboxLockRepository lockRepo;
public LocationController(LocationRepository locationRepo,
LocationImageRepository imageRepo,
@@ -36,26 +50,38 @@ public class LocationController {
LocationEventRepository eventRepo,
LocationEventAttendeeRepository attendeeRepo,
LocationFollowRepository followRepo,
UserService userService) {
LocationAdminRepository adminRepo,
UserRepository userRepo,
UserService userService,
MessageRepository messageRepo,
SseService sseService,
LocationInboxLockRepository lockRepo) {
this.locationRepo = locationRepo;
this.imageRepo = imageRepo;
this.hoursRepo = hoursRepo;
this.eventRepo = eventRepo;
this.attendeeRepo = attendeeRepo;
this.followRepo = followRepo;
this.adminRepo = adminRepo;
this.userRepo = userRepo;
this.userService = userService;
this.messageRepo = messageRepo;
this.sseService = sseService;
this.lockRepo = lockRepo;
}
// ── DTOs ─────────────────────────────────────────────────────────────────
record IdsResult(List<UUID> ids, int total) {}
record LocationPreviewDto(UUID locationId, String name, String profilePictureLq, double distanzKm) {}
record LocationPreviewDto(UUID locationId, String name, String profilePictureHq, double distanzKm) {}
record OpeningHourDto(int dayOfWeek, String openTime, String closeTime, boolean closed) {}
record GalleryImageDto(UUID imageId, String imageData) {}
record AdminDto(UUID userId, String name, String profilePicture, boolean isOwner) {}
record LocationDetailDto(
UUID locationId,
UUID ownerId,
@@ -71,7 +97,10 @@ public class LocationController {
LocalDateTime createdAt,
List<GalleryImageDto> gallery,
List<OpeningHourDto> openingHours,
boolean following
boolean following,
List<AdminDto> admins,
boolean isAdmin,
UUID virtualUserId
) {}
record CreateRequest(
@@ -99,6 +128,21 @@ public class LocationController {
record GalleryUploadRequest(String imageData) {}
record LocationVirtualInfoDto(UUID locationId, UUID virtualUserId, String name,
String profilePictureLq, String profilePictureHq) {}
/** Löst eine virtuelle Benutzer-ID zur Location-Info auf (für Nachrichtenfenster) */
@GetMapping("/virtual/{virtualUserId}")
public ResponseEntity<LocationVirtualInfoDto> getByVirtualUserId(
@PathVariable UUID virtualUserId, Principal principal) {
userService.requireUser(principal);
return locationRepo.findByVirtualUserId(virtualUserId)
.map(l -> ResponseEntity.ok(new LocationVirtualInfoDto(
l.getLocationId(), l.getVirtualUserId(), l.getName(),
l.getProfilePictureLq(), l.getProfilePictureHq())))
.orElse(ResponseEntity.notFound().build());
}
// ── Suche / IDs ──────────────────────────────────────────────────────────
/**
@@ -147,7 +191,7 @@ public class LocationController {
.map(l -> new LocationPreviewDto(
l.getLocationId(),
l.getName(),
l.getProfilePictureLq(),
l.getProfilePictureHq(),
l.getLat() != null && l.getLon() != null
? Math.round(haversineKm(refLat, refLon, l.getLat(), l.getLon()) * 10.0) / 10.0
: -1))
@@ -207,6 +251,7 @@ public class LocationController {
loc.setCity(req.city());
loc.setOwnershipConfirmed(req.ownershipConfirmed());
loc.setCreatedAt(LocalDateTime.now());
loc.setVirtualUserId(UUID.randomUUID());
locationRepo.save(loc);
LOGGER.info("User {} hat Location {} angelegt", myId, loc.getLocationId());
@@ -257,6 +302,7 @@ public class LocationController {
imageRepo.deleteByLocationId(locationId);
hoursRepo.deleteByLocationId(locationId);
followRepo.deleteByLocationId(locationId);
adminRepo.deleteByLocationId(locationId);
locationRepo.deleteById(locationId);
LOGGER.info("User {} hat Location {} gelöscht", myId, locationId);
@@ -398,6 +444,12 @@ public class LocationController {
// ── Helpers ───────────────────────────────────────────────────────────────
private LocationDetailDto toDetail(LocationEntity l, UUID myId) {
// Lazy-Init virtualUserId für Bestandsdaten ohne virtuelle ID
if (l.getVirtualUserId() == null) {
l.setVirtualUserId(UUID.randomUUID());
locationRepo.save(l);
}
List<GalleryImageDto> gallery = imageRepo.findByLocationIdOrderByUploadedAtAsc(l.getLocationId()).stream()
.map(i -> new GalleryImageDto(i.getImageId(), i.getImageData()))
.toList();
@@ -405,12 +457,241 @@ public class LocationController {
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
.toList();
boolean following = followRepo.findByUserIdAndLocationId(myId, l.getLocationId()).isPresent();
boolean isAdmin = l.getOwnerId().equals(myId)
|| adminRepo.existsByLocationIdAndUserId(l.getLocationId(), myId);
// Inhaber zuerst, dann weitere Admins
List<AdminDto> admins = new ArrayList<>();
userRepo.findById(l.getOwnerId()).ifPresent(owner ->
admins.add(new AdminDto(owner.getUserId(), owner.getName(), owner.getProfilePicture(), true)));
adminRepo.findByLocationId(l.getLocationId()).stream()
.filter(a -> !a.getUserId().equals(l.getOwnerId()))
.forEach(a -> userRepo.findById(a.getUserId()).ifPresent(u ->
admins.add(new AdminDto(u.getUserId(), u.getName(), u.getProfilePicture(), false))));
return new LocationDetailDto(
l.getLocationId(), l.getOwnerId(), l.getName(), l.getDescription(),
l.getProfilePictureHq(), l.getProfilePictureLq(),
l.getLat(), l.getLon(), l.getStreet(), l.getCity(),
l.isOwnershipConfirmed(), l.getCreatedAt(),
gallery, hours, following);
gallery, hours, following, admins, isAdmin, l.getVirtualUserId());
}
// ── Location-Posteingang (Admin) ─────────────────────────────────────────────
record InboxSummaryDto(UUID senderId, String senderName, String senderPicture,
String lastMessage, LocalDateTime sentAt, long unreadCount) {}
record InboxConversationDto(
List<de.oaa.xxx.social.dto.MessageDto> messages,
boolean canReply,
boolean lockedByMe,
String lockedByName // null wenn frei oder von mir gesperrt
) {}
record ReplyRequest(String text) {}
/** Alle Konversationen, die Besucher mit dieser Location geführt haben */
@GetMapping("/{locationId}/inbox")
public ResponseEntity<List<InboxSummaryDto>> getInbox(
@PathVariable UUID locationId, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
LocationEntity loc = locOpt.get();
if (!loc.getOwnerId().equals(myId) && !adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
return ResponseEntity.status(403).build();
}
UUID virtualId = loc.getVirtualUserId();
if (virtualId == null) return ResponseEntity.ok(List.of());
List<MessageEntity> allMessages = messageRepo.findAllByUser(virtualId);
// Neueste Nachricht pro Gesprächspartner (Besucher)
Map<UUID, MessageEntity> latestByPartner = new LinkedHashMap<>();
for (MessageEntity m : allMessages) {
UUID partnerId = m.getSenderId().equals(virtualId) ? m.getReceiverId() : m.getSenderId();
latestByPartner.putIfAbsent(partnerId, m);
}
List<InboxSummaryDto> summaries = new ArrayList<>();
for (Map.Entry<UUID, MessageEntity> entry : latestByPartner.entrySet()) {
UUID partnerId = entry.getKey();
MessageEntity lastMsg = entry.getValue();
var userOpt = userRepo.findById(partnerId);
if (userOpt.isEmpty()) continue;
var user = userOpt.get();
long unread = allMessages.stream()
.filter(m -> m.getSenderId().equals(partnerId)
&& m.getReceiverId().equals(virtualId)
&& m.getReadAt() == null)
.count();
String preview = lastMsg.getText().startsWith("data:image/")
? "📷 Bild"
: lastMsg.getText().substring(0, Math.min(80, lastMsg.getText().length()));
summaries.add(new InboxSummaryDto(partnerId, user.getName(), user.getProfilePicture(),
preview, lastMsg.getSentAt(), unread));
}
return ResponseEntity.ok(summaries);
}
/** Konversation zwischen Location und einem Besucher inkl. Lock-Status (Admin-Sicht) */
@GetMapping("/{locationId}/inbox/{userId}")
public ResponseEntity<InboxConversationDto> getInboxConversation(
@PathVariable UUID locationId,
@PathVariable UUID userId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
LocationEntity loc = locOpt.get();
if (!loc.getOwnerId().equals(myId) && !adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
return ResponseEntity.status(403).build();
}
UUID virtualId = loc.getVirtualUserId();
if (virtualId == null) return ResponseEntity.ok(new InboxConversationDto(List.of(), true, false, null));
List<MessageEntity> messages = new ArrayList<>(
messageRepo.findConversation(virtualId, userId, org.springframework.data.domain.PageRequest.of(0, 100)));
Collections.reverse(messages);
messageRepo.markAsRead(virtualId, userId, LocalDateTime.now());
List<de.oaa.xxx.social.dto.MessageDto> dtos = messages.stream()
.map(m -> {
String senderName = userRepo.findById(m.getSenderId())
.map(u -> u.getName())
.orElse(loc.getName());
return new de.oaa.xxx.social.dto.MessageDto(
m.getMessageId(), m.getSenderId(), senderName,
m.getReceiverId(), m.getText(), m.getSentAt(), m.getReadAt() != null);
})
.toList();
// Lock-Status ermitteln
var lockInfo = resolveLockStatus(locationId, userId, myId);
return ResponseEntity.ok(new InboxConversationDto(dtos, lockInfo[0].equals("true"), lockInfo[1].equals("true"), lockInfo[2]));
}
/** Sperre für eine Konversation anfordern (Admin beginnt zu antworten) */
@PostMapping("/{locationId}/inbox/{userId}/lock")
public ResponseEntity<Map<String, Object>> acquireLock(
@PathVariable UUID locationId,
@PathVariable UUID userId,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
LocationEntity loc = locOpt.get();
if (!loc.getOwnerId().equals(myId) && !adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
return ResponseEntity.status(403).build();
}
var existingOpt = lockRepo.findByLocationIdAndVisitorId(locationId, userId);
LocalDateTime now = LocalDateTime.now();
if (existingOpt.isPresent()) {
LocationInboxLockEntity lock = existingOpt.get();
boolean expired = lock.getLockedAt().isBefore(now.minusMinutes(LOCK_TIMEOUT_MINUTES));
if (!expired && !lock.getLockedByUserId().equals(myId)) {
// Aktiv gesperrt durch einen anderen Admin
String lockerName = userRepo.findById(lock.getLockedByUserId())
.map(u -> u.getName()).orElse("einem anderen Admin");
return ResponseEntity.status(409).body(Map.of("lockedByName", lockerName));
}
// Abgelaufen oder bereits meine Sperre → erneuern
lock.setLockedByUserId(myId);
lock.setLockedAt(now);
lockRepo.save(lock);
} else {
LocationInboxLockEntity lock = new LocationInboxLockEntity();
lock.setLockId(UUID.randomUUID());
lock.setLocationId(locationId);
lock.setVisitorId(userId);
lock.setLockedByUserId(myId);
lock.setLockedAt(now);
lockRepo.save(lock);
}
return ResponseEntity.ok(Map.of("acquired", true));
}
/** Antwort als Location an einen Besucher senden (mit Lock-Prüfung) */
@PostMapping("/{locationId}/inbox/{userId}/reply")
public ResponseEntity<Map<String, Object>> replyAsLocation(
@PathVariable UUID locationId,
@PathVariable UUID userId,
@RequestBody ReplyRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
LocationEntity loc = locOpt.get();
if (!loc.getOwnerId().equals(myId) && !adminRepo.existsByLocationIdAndUserId(locationId, myId)) {
return ResponseEntity.status(403).build();
}
if (req.text() == null || req.text().isBlank()) return ResponseEntity.badRequest().build();
UUID virtualId = loc.getVirtualUserId();
if (virtualId == null) return ResponseEntity.badRequest().build();
// Lock prüfen
LocalDateTime now = LocalDateTime.now();
var existingLockOpt = lockRepo.findByLocationIdAndVisitorId(locationId, userId);
if (existingLockOpt.isPresent()) {
LocationInboxLockEntity lock = existingLockOpt.get();
boolean expired = lock.getLockedAt().isBefore(now.minusMinutes(LOCK_TIMEOUT_MINUTES));
if (!expired && !lock.getLockedByUserId().equals(myId)) {
String lockerName = userRepo.findById(lock.getLockedByUserId())
.map(u -> u.getName()).orElse("einem anderen Admin");
return ResponseEntity.status(409).body(Map.of("lockedByName", lockerName));
}
// Abgelaufen oder meine Sperre → erneuern
lock.setLockedByUserId(myId);
lock.setLockedAt(now);
lockRepo.save(lock);
} else {
// Noch keine Sperre → automatisch erwerben
LocationInboxLockEntity lock = new LocationInboxLockEntity();
lock.setLockId(UUID.randomUUID());
lock.setLocationId(locationId);
lock.setVisitorId(userId);
lock.setLockedByUserId(myId);
lock.setLockedAt(now);
lockRepo.save(lock);
}
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(virtualId);
msg.setReceiverId(userId);
msg.setText(req.text().trim());
msg.setSentAt(now);
messageRepo.save(msg);
LOGGER.debug("Location {} hat Antwort an User {} gesendet", locationId, userId);
long unread = messageRepo.countUnread(userId);
sseService.push(userId, "DM", Map.of("unreadCount", unread, "senderId", virtualId.toString()));
return ResponseEntity.status(201).build();
}
/**
* Ermittelt den Lock-Status für eine Konversation.
* Gibt ein String-Array zurück: [canReply, lockedByMe, lockedByName|null]
*/
private String[] resolveLockStatus(UUID locationId, UUID visitorId, UUID myId) {
var lockOpt = lockRepo.findByLocationIdAndVisitorId(locationId, visitorId);
if (lockOpt.isEmpty()) return new String[]{"true", "false", null};
LocationInboxLockEntity lock = lockOpt.get();
boolean expired = lock.getLockedAt().isBefore(LocalDateTime.now().minusMinutes(LOCK_TIMEOUT_MINUTES));
if (expired) return new String[]{"true", "false", null};
if (lock.getLockedByUserId().equals(myId)) return new String[]{"true", "true", null};
String lockerName = userRepo.findById(lock.getLockedByUserId())
.map(u -> u.getName()).orElse("einem anderen Admin");
return new String[]{"false", "false", lockerName};
}
static double haversineKm(double lat1, double lon1, double lat2, double lon2) {

View File

@@ -27,10 +27,16 @@ import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.location.entity.LocationEventAttendeeEntity;
import de.oaa.xxx.location.entity.LocationEventEntity;
import de.oaa.xxx.location.entity.LocationFollowEntity;
import de.oaa.xxx.location.repository.LocationAdminRepository;
import de.oaa.xxx.location.repository.LocationEventAttendeeRepository;
import de.oaa.xxx.location.repository.LocationEventRepository;
import de.oaa.xxx.location.repository.LocationFollowRepository;
import de.oaa.xxx.location.repository.LocationRepository;
import de.oaa.xxx.feed.entity.FeedPostEntity;
import de.oaa.xxx.feed.repository.FeedPostRepository;
import de.oaa.xxx.gruppe.BeitragTyp;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.social.entity.MessageCause;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
@@ -42,25 +48,41 @@ public class LocationEventController {
private static final Logger LOGGER = LoggerFactory.getLogger(LocationEventController.class);
private static final int MAX_BATCH_SIZE = 50;
private final LocationRepository locationRepo;
private final LocationEventRepository eventRepo;
private final LocationRepository locationRepo;
private final LocationEventRepository eventRepo;
private final LocationEventAttendeeRepository attendeeRepo;
private final LocationFollowRepository followRepo;
private final UserRepository userRepo;
private final UserService userService;
private final LocationFollowRepository followRepo;
private final LocationAdminRepository adminRepo;
private final UserRepository userRepo;
private final UserService userService;
private final SystemMessageService systemMessageService;
private final FeedPostRepository feedPostRepo;
public LocationEventController(LocationRepository locationRepo,
LocationEventRepository eventRepo,
LocationEventAttendeeRepository attendeeRepo,
LocationFollowRepository followRepo,
LocationAdminRepository adminRepo,
UserRepository userRepo,
UserService userService) {
this.locationRepo = locationRepo;
this.eventRepo = eventRepo;
this.attendeeRepo = attendeeRepo;
this.followRepo = followRepo;
this.userRepo = userRepo;
this.userService = userService;
UserService userService,
SystemMessageService systemMessageService,
FeedPostRepository feedPostRepo) {
this.locationRepo = locationRepo;
this.eventRepo = eventRepo;
this.attendeeRepo = attendeeRepo;
this.followRepo = followRepo;
this.adminRepo = adminRepo;
this.userRepo = userRepo;
this.userService = userService;
this.systemMessageService = systemMessageService;
this.feedPostRepo = feedPostRepo;
}
private boolean isLocationAdmin(UUID locationId, UUID userId) {
return locationRepo.findById(locationId)
.map(l -> l.getOwnerId().equals(userId)
|| adminRepo.existsByLocationIdAndUserId(locationId, userId))
.orElse(false);
}
// ── DTOs ─────────────────────────────────────────────────────────────────
@@ -91,6 +113,7 @@ public class LocationEventController {
LocalDateTime startAt,
LocalDateTime createdAt,
boolean attendingMe,
boolean isAdmin,
List<AttendeeDto> attendees
) {}
@@ -143,6 +166,25 @@ public class LocationEventController {
event.setCreatedAt(LocalDateTime.now());
eventRepo.save(event);
// Feed-Post automatisch anlegen
try {
String locationName = locOpt.get().getName();
String dateStr = event.getStartAt().format(
java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy 'um' HH:mm 'Uhr'"));
FeedPostEntity feedPost = new FeedPostEntity();
feedPost.setPostId(UUID.randomUUID());
feedPost.setAuthorId(myId);
feedPost.setText("📍 Neue Veranstaltung bei " + locationName + ": \"" + event.getTitle() + "\" - " + dateStr);
feedPost.setBilder(event.getImageData() != null ? List.of(event.getImageData()) : List.of());
feedPost.setBeitragTyp(BeitragTyp.TEXT);
feedPost.setPublic(true);
feedPost.setCreatedAt(LocalDateTime.now());
feedPost.setTargetUrl("/community/event-detail.html?id=" + event.getEventId());
feedPostRepo.save(feedPost);
} catch (Exception ex) {
LOGGER.warn("Feed-Post für Event {} konnte nicht angelegt werden: {}", event.getEventId(), ex.getMessage());
}
LOGGER.info("Location {} hat Event {} angelegt", locationId, event.getEventId());
return ResponseEntity.status(201).body(toDetail(event, locOpt.get().getName(), myId));
}
@@ -157,17 +199,23 @@ public class LocationEventController {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!locOpt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
if (!isLocationAdmin(locationId, myId)) return ResponseEntity.status(403).build();
var evtOpt = eventRepo.findById(eventId);
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!evtOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
// Veranstaltungen in der Vergangenheit dürfen nicht bearbeitet werden
if (evtOpt.get().getStartAt().isBefore(LocalDateTime.now())) return ResponseEntity.status(422).build();
LocationEventEntity event = evtOpt.get();
if (req.title() != null && !req.title().isBlank()) event.setTitle(req.title().trim());
if (req.description() != null) event.setDescription(req.description().trim());
if (req.imageData() != null) event.setImageData(req.imageData());
if (req.startAt() != null) event.setStartAt(req.startAt());
if (req.startAt() != null) {
if (req.startAt().isBefore(LocalDateTime.now())) return ResponseEntity.status(422).build();
event.setStartAt(req.startAt());
}
eventRepo.save(event);
return ResponseEntity.ok(toDetail(event, locOpt.get().getName(), myId));
@@ -183,14 +231,27 @@ public class LocationEventController {
UUID myId = userService.requireUser(principal).getUserId();
var locOpt = locationRepo.findById(locationId);
if (locOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!locOpt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
if (!isLocationAdmin(locationId, myId)) return ResponseEntity.status(403).build();
var evtOpt = eventRepo.findById(eventId);
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!evtOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
var evt = evtOpt.get();
if (!evt.getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
// Alle Teilnehmenden sammeln und benachrichtigen
List<LocationEventAttendeeEntity> attendees = attendeeRepo.findByEventIdOrderByRegisteredAtAsc(eventId);
String locationName = locOpt.get().getName();
String notifyText = "Die Veranstaltung \"" + evt.getTitle() + "\" bei " + locationName + " wurde abgesagt.";
String targetUrl = "/community/location-detail.html?id=" + locationId;
attendeeRepo.deleteByEventId(eventId);
eventRepo.delete(evtOpt.get());
eventRepo.delete(evt);
attendees.stream()
.map(LocationEventAttendeeEntity::getUserId)
.filter(uid -> !uid.equals(myId))
.forEach(uid -> systemMessageService.send(myId, uid, notifyText, targetUrl, MessageCause.EVENT_CANCELLED));
return ResponseEntity.noContent().build();
}
@@ -236,6 +297,78 @@ public class LocationEventController {
return ResponseEntity.ok(Map.of("attending", attending, "attendeeCount", count));
}
// ── Meine angemeldeten Events (für Home) ─────────────────────────────────
@GetMapping("/location-events/attending-next")
public ResponseEntity<List<EventPreviewDto>> getAttendingNext(Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
List<UUID> myEventIds = attendeeRepo.findByUserId(myId).stream()
.map(LocationEventAttendeeEntity::getEventId)
.toList();
if (myEventIds.isEmpty()) return ResponseEntity.ok(List.of());
Map<UUID, de.oaa.xxx.location.entity.LocationEntity> locationById = new java.util.HashMap<>();
List<EventPreviewDto> result = eventRepo
.findUpcomingByEventIds(myEventIds, LocalDateTime.now())
.stream()
.map(e -> {
var loc = locationById.computeIfAbsent(e.getLocationId(),
id -> locationRepo.findById(id).orElse(null));
String locName = loc != null ? loc.getName() : "";
return toPreview(e, locName, 0, 0, myId);
})
.toList();
return ResponseEntity.ok(result);
}
// ── Nächste Events je abonnierter Location (für Home) ────────────────────
@GetMapping("/location-events/followed-next")
public ResponseEntity<List<EventPreviewDto>> getFollowedNext(Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
List<UUID> followedIds = followRepo.findByUserId(myId).stream()
.map(LocationFollowEntity::getLocationId)
.toList();
if (followedIds.isEmpty()) return ResponseEntity.ok(List.of());
// Events ausschließen, bei denen der User bereits angemeldet ist
Set<UUID> attendingEventIds = attendeeRepo.findByUserId(myId).stream()
.map(LocationEventAttendeeEntity::getEventId)
.collect(Collectors.toSet());
Map<UUID, de.oaa.xxx.location.entity.LocationEntity> locationById =
locationRepo.findAllById(followedIds).stream()
.collect(Collectors.toMap(
de.oaa.xxx.location.entity.LocationEntity::getLocationId,
l -> l));
// Ein Event pro Location: je das nächste, das noch nicht begonnen hat und nicht attending
List<EventPreviewDto> result = eventRepo
.findUpcomingByLocationIds(followedIds, LocalDateTime.now())
.stream()
.filter(e -> !attendingEventIds.contains(e.getEventId()))
.collect(Collectors.toMap(
LocationEventEntity::getLocationId,
e -> e,
(existing, replacement) -> existing))
.values().stream()
.sorted(Comparator.comparing(LocationEventEntity::getStartAt))
.map(e -> {
var loc = locationById.get(e.getLocationId());
String locName = loc != null ? loc.getName() : "";
return toPreview(e, locName, 0, 0, myId);
})
.toList();
return ResponseEntity.ok(result);
}
// ── Event-Suche (IDs + Batch) ─────────────────────────────────────────────
/**
@@ -257,7 +390,7 @@ public class LocationEventController {
LocalDateTime fromDt = from != null ? LocalDateTime.parse(from) : LocalDateTime.now();
LocalDateTime toDt = to != null ? LocalDateTime.parse(to) : fromDt.plusMonths(3);
// Abonnierte Locations deren Events werden immer eingeschlossen
// Abonnierte Locations - deren Events werden immer eingeschlossen
Set<UUID> followedLocationIds = followRepo.findByUserId(myId).stream()
.map(LocationFollowEntity::getLocationId)
.collect(Collectors.toSet());
@@ -374,11 +507,12 @@ public class LocationEventController {
.toList();
boolean attendingMe = attendeeRepo.findByEventIdAndUserId(e.getEventId(), myId).isPresent();
boolean isAdmin = isLocationAdmin(e.getLocationId(), myId);
return new EventDetailDto(
e.getEventId(), e.getLocationId(), locationName,
e.getTitle(), e.getDescription(), e.getImageData(),
e.getStartAt(), e.getCreatedAt(),
attendingMe, attendees);
attendingMe, isAdmin, attendees);
}
}

View File

@@ -0,0 +1,31 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location_admin")
public class LocationAdminEntity {
@Id
@Column
private UUID adminId;
@Column(nullable = false)
private UUID locationId;
@Column(nullable = false)
private UUID userId;
@Column(nullable = false)
private LocalDateTime addedAt;
}

View File

@@ -52,4 +52,8 @@ public class LocationEntity {
@Column(nullable = false)
private LocalDateTime createdAt;
/** Virtuelle Benutzer-ID für das Nachrichtensystem (einmalig generiert, unveränderlich) */
@Column(unique = true)
private UUID virtualUserId;
}

View File

@@ -0,0 +1,35 @@
package de.oaa.xxx.location.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "location_inbox_lock",
uniqueConstraints = @UniqueConstraint(columnNames = {"location_id", "visitor_id"}))
public class LocationInboxLockEntity {
@Id
@Column
private UUID lockId;
@Column(name = "location_id", nullable = false)
private UUID locationId;
/** Die kontaktierende Person */
@Column(name = "visitor_id", nullable = false)
private UUID visitorId;
/** Der Admin/Inhaber, der gerade antwortet */
@Column(name = "locked_by_user_id", nullable = false)
private UUID lockedByUserId;
/** Letzte Aktivität des Lock-Inhabers (wird bei jeder Antwort erneuert) */
@Column(nullable = false)
private LocalDateTime lockedAt;
}

View File

@@ -0,0 +1,17 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationAdminEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface LocationAdminRepository extends JpaRepository<LocationAdminEntity, UUID> {
List<LocationAdminEntity> findByLocationId(UUID locationId);
Optional<LocationAdminEntity> findByLocationIdAndUserId(UUID locationId, UUID userId);
boolean existsByLocationIdAndUserId(UUID locationId, UUID userId);
void deleteByLocationId(UUID locationId);
void deleteByLocationIdAndUserId(UUID locationId, UUID userId);
}

View File

@@ -11,6 +11,8 @@ public interface LocationEventAttendeeRepository extends JpaRepository<LocationE
List<LocationEventAttendeeEntity> findByEventIdOrderByRegisteredAtAsc(UUID eventId);
List<LocationEventAttendeeEntity> findByUserId(UUID userId);
Optional<LocationEventAttendeeEntity> findByEventIdAndUserId(UUID eventId, UUID userId);
long countByEventId(UUID eventId);

View File

@@ -3,8 +3,10 @@ package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationEventEntity;
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.Collection;
import java.util.List;
import java.util.UUID;
@@ -12,6 +14,12 @@ public interface LocationEventRepository extends JpaRepository<LocationEventEnti
List<LocationEventEntity> findByLocationIdOrderByStartAtAsc(UUID locationId);
@Query("SELECT e FROM LocationEventEntity e WHERE e.locationId IN :locationIds AND e.startAt >= :from ORDER BY e.startAt ASC")
List<LocationEventEntity> findUpcomingByLocationIds(@Param("locationIds") Collection<UUID> locationIds, @Param("from") LocalDateTime from);
@Query("SELECT e FROM LocationEventEntity e WHERE e.eventId IN :eventIds AND e.startAt >= :from ORDER BY e.startAt ASC")
List<LocationEventEntity> findUpcomingByEventIds(@Param("eventIds") Collection<UUID> eventIds, @Param("from") LocalDateTime from);
/** Alle zukünftigen Events mit Koordinaten ihrer Location (für Umkreis-Suche) */
@Query("""
SELECT e FROM LocationEventEntity e

View File

@@ -0,0 +1,12 @@
package de.oaa.xxx.location.repository;
import de.oaa.xxx.location.entity.LocationInboxLockEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface LocationInboxLockRepository extends JpaRepository<LocationInboxLockEntity, UUID> {
Optional<LocationInboxLockEntity> findByLocationIdAndVisitorId(UUID locationId, UUID visitorId);
}

View File

@@ -4,6 +4,7 @@ import de.oaa.xxx.location.entity.LocationEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface LocationRepository extends JpaRepository<LocationEntity, UUID> {
@@ -12,4 +13,8 @@ public interface LocationRepository extends JpaRepository<LocationEntity, UUID>
/** Alle Locations mit gesetzten Koordinaten (für Umkreissuche) */
List<LocationEntity> findByLatIsNotNullAndLonIsNotNull();
Optional<LocationEntity> findByVirtualUserId(UUID virtualUserId);
List<LocationEntity> findByVirtualUserIdIsNotNull();
}

View File

@@ -1,6 +1,9 @@
package de.oaa.xxx.social;
import de.oaa.xxx.dating.DatingMatchRepository;
import de.oaa.xxx.location.entity.LocationEntity;
import de.oaa.xxx.location.repository.LocationAdminRepository;
import de.oaa.xxx.location.repository.LocationRepository;
import de.oaa.xxx.social.dto.ConversationSummary;
import de.oaa.xxx.social.dto.FriendshipDto;
import de.oaa.xxx.social.dto.MessageDto;
@@ -43,6 +46,8 @@ public class SocialController {
private final SseService sseService;
private final SystemMessageService systemMessageService;
private final UserService userService;
private final LocationRepository locationRepository;
private final LocationAdminRepository locationAdminRepository;
public SocialController(UserRepository userRepository,
FriendshipRepository friendshipRepository,
@@ -52,7 +57,9 @@ public class SocialController {
SubscriptionLimitService subscriptionLimitService,
SseService sseService,
SystemMessageService systemMessageService,
UserService userService) {
UserService userService,
LocationRepository locationRepository,
LocationAdminRepository locationAdminRepository) {
this.userRepository = userRepository;
this.friendshipRepository = friendshipRepository;
this.messageRepository = messageRepository;
@@ -62,6 +69,8 @@ public class SocialController {
this.sseService = sseService;
this.systemMessageService = systemMessageService;
this.userService = userService;
this.locationRepository = locationRepository;
this.locationAdminRepository = locationAdminRepository;
}
record FriendRequestBody(UUID receiverId) {}
@@ -216,7 +225,8 @@ public class SocialController {
@PostMapping("/messages")
public ResponseEntity<Map<String, String>> sendMessage(@RequestBody SendMessageBody body, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var me = userService.requireUser(principal);
UUID myId = me.getUserId();
if (body.text() == null || body.text().isBlank()) return ResponseEntity.badRequest().build();
@@ -225,6 +235,31 @@ public class SocialController {
return ResponseEntity.status(403).build();
}
// Location-Nachricht: keine Freundschafts-/Abo-Prüfung, alle Admins benachrichtigen
var locationOpt = locationRepository.findByVirtualUserId(body.receiverId());
if (locationOpt.isPresent()) {
LocationEntity loc = locationOpt.get();
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(myId);
msg.setReceiverId(body.receiverId());
msg.setText(body.text().trim());
msg.setSentAt(LocalDateTime.now());
messageRepository.save(msg);
LOGGER.debug("User {} hat Location {} (virtualId {}) kontaktiert", myId, loc.getLocationId(), body.receiverId());
// Alle Admins + Inhaber per Systembenachrichtigung informieren
Set<UUID> adminIds = new java.util.LinkedHashSet<>();
adminIds.add(loc.getOwnerId());
locationAdminRepository.findByLocationId(loc.getLocationId()).forEach(a -> adminIds.add(a.getUserId()));
String notifText = me.getName() + " hat deine Location \"" + loc.getName() + "\" kontaktiert.";
String targetUrl = "/community/location-detail.html?id=" + loc.getLocationId() + "&chatWith=" + myId;
for (UUID adminId : adminIds) {
systemMessageService.send(myId, adminId, notifText, targetUrl, MessageCause.LOCATION_MESSAGE);
}
return ResponseEntity.status(201).build();
}
// Blockiert? (in beide Richtungen)
if (blockRepository.existsBlock(myId, body.receiverId())) {
return ResponseEntity.status(403).body(Map.of("reason", "BLOCKED"));
@@ -272,10 +307,20 @@ public class SocialController {
for (Map.Entry<UUID, MessageEntity> entry : latestByPartner.entrySet()) {
UUID partnerId = entry.getKey();
MessageEntity lastMsg = entry.getValue();
var partnerOpt = userRepository.findById(partnerId);
if (partnerOpt.isEmpty()) continue;
UserProfile partnerProfile = toUserProfileWithStatus(partnerOpt.get(), myId);
UserProfile partnerProfile;
var userOpt = userRepository.findById(partnerId);
if (userOpt.isPresent()) {
partnerProfile = toUserProfileWithStatus(userOpt.get(), myId);
} else {
// Kein User → prüfen ob es eine Location-virtualUserId ist
var locOpt = locationRepository.findByVirtualUserId(partnerId);
if (locOpt.isEmpty()) continue;
LocationEntity loc = locOpt.get();
partnerProfile = new UserProfile(partnerId, loc.getName(),
loc.getProfilePictureLq(), loc.getProfilePictureHq(), "LOCATION");
}
MessageDto lastMsgDto = toMessageDto(lastMsg);
long unreadCount = allMessages.stream()
.filter(m -> m.getSenderId().equals(partnerId)
@@ -433,7 +478,9 @@ public class SocialController {
private MessageDto toMessageDto(MessageEntity m) {
String senderName = userRepository.findById(m.getSenderId())
.map(UserEntity::getName)
.orElse("Unbekannt");
.orElseGet(() -> locationRepository.findByVirtualUserId(m.getSenderId())
.map(LocationEntity::getName)
.orElse("Unbekannt"));
return new MessageDto(
m.getMessageId(), m.getSenderId(), senderName,
m.getReceiverId(), m.getText(), m.getSentAt(), m.getReadAt() != null);

View File

@@ -57,9 +57,10 @@ public class SystemMessageService {
.findByUserIdAndCause(receiverId, cause)
.orElseGet(() -> NotificationPreferenceEntity.defaultFor(receiverId, cause));
// FRIENDREQUEST, INVITATION und DATE_INTEREST sind immer nur in-app, kein E-Mail
// Diese Causes sind immer nur in-app, kein E-Mail
boolean sendInApp = cause == MessageCause.FRIENDREQUEST || cause == MessageCause.INVITATION
|| cause == MessageCause.DATE_INTEREST || pref.isInApp();
|| cause == MessageCause.DATE_INTEREST || cause == MessageCause.LOCATION_MESSAGE
|| pref.isInApp();
if (sendInApp) {
MessageEntity msg = new MessageEntity();
@@ -103,12 +104,14 @@ public class SystemMessageService {
private String causeTitel(MessageCause cause) {
return switch (cause) {
case INVITATION -> "XXX The Game Neue Einladung";
case GAME_STATE -> "XXX The Game Spielstatus-Änderung";
case EMERGENCY -> "XXX The Game ⚠️ Notfall";
case FRIENDREQUEST -> "XXX The Game Neue Freundschaftsanfrage";
case SUPPORT -> "xXx Sphere Nachricht vom Support";
case DATE_INTEREST -> "xXx Sphere Interesse an deinem Date";
case INVITATION -> "XXX The Game Neue Einladung";
case GAME_STATE -> "XXX The Game Spielstatus-Änderung";
case EMERGENCY -> "XXX The Game ⚠️ Notfall";
case FRIENDREQUEST -> "XXX The Game Neue Freundschaftsanfrage";
case SUPPORT -> "xXx Sphere Nachricht vom Support";
case DATE_INTEREST -> "xXx Sphere Interesse an deinem Date";
case EVENT_CANCELLED -> "xXx Sphere Veranstaltung abgesagt";
case LOCATION_MESSAGE -> "xXx Sphere Neue Nachricht an deine Location";
};
}

View File

@@ -6,5 +6,7 @@ public enum MessageCause {
EMERGENCY,
FRIENDREQUEST,
SUPPORT,
DATE_INTEREST
DATE_INTEREST,
EVENT_CANCELLED,
LOCATION_MESSAGE
}

View File

@@ -20,7 +20,7 @@
.evt-date { font-size:0.88rem; color:var(--color-muted); margin-bottom:0.5rem; }
.evt-desc { font-size:0.93rem; line-height:1.55; white-space:pre-wrap; word-break:break-word; margin-top:0.5rem; }
.attend-btn { display:inline-flex; align-items:center; gap:0.4rem; margin-top:0.75rem; }
.attend-btn { display:inline-flex; align-items:center; gap:0.4rem; margin-top:0.75rem; flex-wrap:wrap; }
.section-title { font-size:1rem; font-weight:700; margin:1.5rem 0 0.75rem; }
.gender-group { margin-bottom:1.25rem; }
@@ -31,23 +31,92 @@
.attendee-avatar { width:28px; height:28px; border-radius:50%; background:var(--color-secondary); object-fit:cover; flex-shrink:0; overflow:hidden; display:flex; align-items:center; justify-content:center; font-size:0.8rem; }
.attendee-avatar img { width:100%; height:100%; object-fit:cover; }
.count-badge { background:var(--color-secondary); border-radius:12px; padding:0.15rem 0.6rem; font-size:0.78rem; color:var(--color-muted); margin-left:0.25rem; display:inline-block; }
/* Modal */
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:200; align-items:center; justify-content:center; }
.modal-overlay.open { display:flex; }
.modal { background:var(--color-card); border-radius:12px; width:min(520px,95vw); max-height:90vh; overflow-y:auto; padding:1.5rem; }
.modal h3 { margin:0 0 1rem; }
.modal-footer { display:flex; gap:0.75rem; justify-content:flex-end; margin-top:1.25rem; flex-wrap:wrap; }
.img-preview { width:80px; height:80px; border-radius:8px; object-fit:cover; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:1.5rem; flex-shrink:0; overflow:hidden; border:1px solid var(--color-secondary); }
.img-preview img { width:100%; height:100%; object-fit:cover; }
.img-row { display:flex; gap:0.75rem; align-items:center; margin-bottom:0.5rem; }
</style>
</head>
<body>
<div class="main">
<a id="backLink" href="/community/events.html" class="back-link">← Veranstaltungen</a>
<body class="app">
<div id="content">
<p style="color:var(--color-muted);">Wird geladen…</p>
<!-- ── Hinweis-Modal ──────────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="alertModal">
<div class="modal" style="width:min(380px,95vw);">
<p id="alertMessage" style="margin:0 0 1.25rem;font-size:0.95rem;"></p>
<div class="modal-footer">
<button class="btn" onclick="document.getElementById('alertModal').classList.remove('open')">OK</button>
</div>
</div>
</div>
<!-- ── Bestätigungs-Modal ─────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="width:min(380px,95vw);">
<h3 id="confirmTitle" style="margin:0 0 0.75rem;"></h3>
<p id="confirmMessage" style="margin:0 0 1.25rem;font-size:0.95rem;color:var(--color-muted);"></p>
<div class="modal-footer">
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeConfirm()">Abbrechen</button>
<button class="btn" id="confirmOkBtn" style="background:#c0392b;">Löschen</button>
</div>
</div>
</div>
<!-- ── Edit-Modal ────────────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="editModal">
<div class="modal">
<h3>Veranstaltung bearbeiten</h3>
<div class="img-row">
<div class="img-preview" id="editPicPreview">🗓</div>
<div>
<label class="btn" style="display:inline-block;cursor:pointer;font-size:0.85rem;">
Bild ändern
<input type="file" id="editPicFile" accept="image/*" style="display:none;" onchange="onEditPicChange(this)">
</label>
</div>
</div>
<label>Titel *</label>
<input type="text" id="editTitle" maxlength="200">
<label>Beschreibung <span style="color:var(--color-muted);font-size:0.8rem;">(max. 1000 Zeichen)</span></label>
<textarea id="editDesc" maxlength="1000" rows="4"
style="resize:vertical;width:100%;box-sizing:border-box;padding:0.65rem 0.9rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:1rem;outline:none;font-family:inherit;transition:border-color 0.2s;"
onfocus="this.style.borderColor='var(--color-primary)'" onblur="this.style.borderColor='var(--color-secondary)'"></textarea>
<label>Datum &amp; Uhrzeit *</label>
<input type="datetime-local" id="editStartAt">
<div class="modal-footer">
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeEditModal()">Abbrechen</button>
<button class="btn" id="editSubmitBtn" onclick="submitEdit()">Speichern</button>
</div>
</div>
</div>
<div class="main">
<div class="content">
<a id="backLink" href="/community/events.html" class="back-link">← Veranstaltungen</a>
<div id="content">
<p style="color:var(--color-muted);">Wird geladen…</p>
</div>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
const params = new URLSearchParams(location.search);
const eventId = params.get('id');
let myUserId = null;
let _evtData = null;
let _editImg = null;
function showAlert(msg) {
document.getElementById('alertMessage').textContent = msg;
document.getElementById('alertModal').classList.add('open');
}
function escHtml(s) {
return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
@@ -78,17 +147,16 @@ async function loadPage() {
if (!evtRes.ok) { document.getElementById('content').innerHTML = '<p>Veranstaltung nicht gefunden.</p>'; return; }
if (meRes.ok) { const me = await meRes.json(); myUserId = me.userId; }
const evt = await evtRes.json();
_evtData = await evtRes.json();
// Rücklink zur Location
const backLink = document.getElementById('backLink');
if (evt.locationId) {
backLink.href = `/community/location-detail.html?id=${evt.locationId}`;
backLink.textContent = `${escHtml(evt.locationName) || 'Location'}`;
if (_evtData.locationId) {
backLink.href = `/community/location-detail.html?id=${_evtData.locationId}`;
backLink.textContent = `${_evtData.locationName || 'Location'}`;
}
document.title = `${evt.title} xXx Sphere`;
document.title = `${_evtData.title} xXx Sphere`;
renderPage(evt);
renderPage(_evtData);
}
function renderPage(evt) {
@@ -96,7 +164,9 @@ function renderPage(evt) {
? `<img src="data:image/jpeg;base64,${evt.imageData}" alt="${escHtml(evt.title)}">`
: '🗓';
// Teilnehmende nach Geschlecht gruppieren
const isFuture = new Date(evt.startAt) > new Date();
const canEdit = !!evt.isAdmin && isFuture;
const byGender = {};
(evt.attendees || []).forEach(a => {
const g = a.geschlecht || 'UNBEKANNT';
@@ -134,13 +204,20 @@ function renderPage(evt) {
${evt.locationName ? `<div class="evt-location">📍 <a href="/community/location-detail.html?id=${evt.locationId}" style="color:inherit;text-decoration:none;">${escHtml(evt.locationName)}</a></div>` : ''}
<div class="evt-date">🗓 ${formatDate(evt.startAt)}</div>
${evt.description ? `<div class="evt-desc">${escHtml(evt.description)}</div>` : ''}
<div class="attend-btn">
<button class="btn" id="attendBtn"
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
onclick="toggleAttend()">
${attending ? '✓ Ich bin dabei' : '+ Ich bin dabei'}
</button>
<span style="color:var(--color-muted);font-size:0.85rem;" id="attendCount">${totalAttendees} Teilnehmer*in(nen)</span>
<div style="display:flex;flex-direction:column;gap:0.5rem;margin-top:0.75rem;">
${canEdit ? `
<div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;">
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);font-size:0.85rem;" onclick="openEditModal()">✎ Bearbeiten</button>
<button class="btn" style="background:#c0392b;font-size:0.85rem;" onclick="openDeleteConfirm()">Löschen</button>
</div>` : ''}
<div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;">
<button class="btn" id="attendBtn"
style="${attending ? 'background:var(--color-secondary);color:var(--color-text);' : ''}"
onclick="toggleAttend()">
${attending ? '✓ Ich bin dabei' : '+ Ich bin dabei'}
</button>
<span style="color:var(--color-muted);font-size:0.85rem;" id="attendCount">${totalAttendees} Teilnehmer*in(nen)</span>
</div>
</div>
</div>
</div>
@@ -154,9 +231,8 @@ function renderPage(evt) {
async function toggleAttend() {
const res = await fetch(`/location-events/${eventId}/attend`, { method: 'POST' });
if (!res.ok) { alert('Fehler beim Aktualisieren.'); return; }
if (!res.ok) { showAlert('Fehler beim Aktualisieren.'); return; }
const data = await res.json();
const btn = document.getElementById('attendBtn');
const countEl = document.getElementById('attendCount');
if (btn) {
@@ -165,10 +241,107 @@ async function toggleAttend() {
btn.style.color = data.attending ? 'var(--color-text)' : '';
}
if (countEl) countEl.textContent = `${data.attendeeCount} Teilnehmer*in(nen)`;
// Teilnehmendenliste neu laden
const evtRes = await fetch(`/location-events/${eventId}`);
if (evtRes.ok) { renderPage(await evtRes.json()); }
if (evtRes.ok) { _evtData = await evtRes.json(); renderPage(_evtData); }
}
// ── Edit Modal ────────────────────────────────────────────────────────────────
function resizeImage(file, maxPx, quality) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > maxPx || h > maxPx) {
if (w >= h) { h = Math.max(1, Math.round(maxPx * h / w)); w = maxPx; }
else { w = Math.max(1, Math.round(maxPx * w / h)); h = maxPx; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', quality || 0.85).split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
function openEditModal() {
if (!_evtData) return;
_editImg = null;
document.getElementById('editTitle').value = _evtData.title || '';
document.getElementById('editDesc').value = _evtData.description || '';
const nowLocal = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
const startVal = _evtData.startAt ? _evtData.startAt.slice(0, 16) : '';
document.getElementById('editStartAt').min = nowLocal;
document.getElementById('editStartAt').value = startVal;
const preview = document.getElementById('editPicPreview');
preview.innerHTML = _evtData.imageData
? `<img src="data:image/jpeg;base64,${_evtData.imageData}" alt="">`
: '🗓';
document.getElementById('editModal').classList.add('open');
}
function closeEditModal() { document.getElementById('editModal').classList.remove('open'); }
async function onEditPicChange(input) {
const file = input.files[0]; if (!file) return;
_editImg = await resizeImage(file, 1024, 0.88);
document.getElementById('editPicPreview').innerHTML = `<img src="data:image/jpeg;base64,${_editImg}" alt="">`;
}
async function submitEdit() {
const title = document.getElementById('editTitle').value.trim();
const desc = document.getElementById('editDesc').value;
const startAt = document.getElementById('editStartAt').value;
if (!title) { showAlert('Bitte gib einen Titel ein.'); return; }
if (!startAt) { showAlert('Bitte wähle Datum und Uhrzeit.'); return; }
if (new Date(startAt) <= new Date()) { showAlert('Der Termin muss in der Zukunft liegen.'); return; }
const btn = document.getElementById('editSubmitBtn');
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
try {
const body = { title, description: desc };
if (startAt) body.startAt = startAt + ':00';
if (_editImg) body.imageData = _editImg;
const res = await fetch(`/locations/${_evtData.locationId}/events/${eventId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.status === 422) { showAlert('Der Termin muss in der Zukunft liegen.'); return; }
if (!res.ok) { showAlert('Fehler beim Speichern.'); return; }
_evtData = await res.json();
closeEditModal();
renderPage(_evtData);
document.title = `${_evtData.title} xXx Sphere`;
} catch { showAlert('Fehler beim Speichern.'); }
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
}
// ── Confirm / Delete ──────────────────────────────────────────────────────────
let _confirmCallback = null;
function openConfirm(title, message, onOk) {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmMessage').textContent = message;
_confirmCallback = onOk;
document.getElementById('confirmModal').classList.add('open');
}
function closeConfirm() { document.getElementById('confirmModal').classList.remove('open'); _confirmCallback = null; }
document.getElementById('confirmOkBtn').addEventListener('click', () => { if (_confirmCallback) _confirmCallback(); closeConfirm(); });
function openDeleteConfirm() {
openConfirm(
'Veranstaltung löschen',
'Soll diese Veranstaltung wirklich gelöscht werden? Alle angemeldeten Teilnehmenden werden benachrichtigt.',
async () => {
const res = await fetch(`/locations/${_evtData.locationId}/events/${eventId}`, { method: 'DELETE' });
if (!res.ok) { showAlert('Fehler beim Löschen.'); return; }
location.href = `/community/location-detail.html?id=${_evtData.locationId}`;
}
);
}
loadPage();

View File

@@ -273,7 +273,15 @@
: '';
const gruppeIdAttr = p.gruppeId ? ` data-gruppe-id="${p.gruppeId}"` : '';
return `<div class="post-card" id="pc-${p.postId}"${gruppeIdAttr} onclick="openLb('${p.postId}','${p.postType}')" style="cursor:pointer;">
const hasTarget = !!p.targetUrl;
const cardClick = hasTarget
? `window.location.href='${p.targetUrl}'`
: `openLb('${p.postId}','${p.postType}')`;
const commentBtn = hasTarget ? '' : `
<button class="post-action-btn" onclick="event.stopPropagation(); openLb('${p.postId}','${p.postType}')">
💬 <span id="kc-${p.postId}">${p.kommentarCount}</span>
</button>`;
return `<div class="post-card" id="pc-${p.postId}"${gruppeIdAttr} onclick="${cardClick}" style="cursor:pointer;">
<div class="post-header">
<div class="post-avatar">${avatarHtml}</div>
<div>
@@ -289,9 +297,7 @@
<button class="post-action-btn${p.likedByMe ? ' active' : ''}" id="lk-${p.postId}" onclick="event.stopPropagation(); likePost('${p.postId}','${p.postType}')">
♥ <span id="lkc-${p.postId}">${p.likeCount}</span>
</button>
<button class="post-action-btn" onclick="event.stopPropagation(); openLb('${p.postId}','${p.postType}')">
💬 <span id="kc-${p.postId}">${p.kommentarCount}</span>
</button>
${commentBtn}
${meldenBtn}
</div>
</div>`;

View File

@@ -12,7 +12,7 @@
.back-link:hover { color:var(--color-primary); }
.loc-header { display:flex; gap:1rem; align-items:flex-start; margin-bottom:1.25rem; flex-wrap:wrap; }
.loc-avatar { width:96px; height:96px; border-radius:12px; background:var(--color-secondary); object-fit:cover; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:2.5rem; overflow:hidden; border:2px solid var(--color-secondary); }
.loc-avatar { width:120px; height:120px; border-radius:12px; background:var(--color-secondary); object-fit:cover; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:2.5rem; overflow:hidden; border:2px solid var(--color-secondary); }
.loc-avatar img { width:100%; height:100%; object-fit:cover; }
.loc-meta { flex:1; min-width:0; }
.loc-name { font-size:1.4rem; font-weight:700; margin:0 0 0.3rem; }
@@ -26,7 +26,7 @@
.hours-table td:first-child { font-weight:500; width:100px; }
.hours-closed { color:var(--color-muted); }
.gallery-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:0.6rem; }
.gallery-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:0.6rem; }
.gallery-img-wrap { position:relative; aspect-ratio:1; border-radius:8px; overflow:hidden; background:var(--color-secondary); }
.gallery-img-wrap img { width:100%; height:100%; object-fit:cover; cursor:pointer; transition:opacity 0.15s; }
.gallery-img-wrap img:hover { opacity:0.88; }
@@ -59,19 +59,72 @@
/* Lightbox */
.lb { display:none; position:fixed; inset:0; background:rgba(0,0,0,.9); z-index:300; align-items:center; justify-content:center; }
.lb.open { display:flex; }
.lb img { max-width:95vw; max-height:95vh; border-radius:8px; object-fit:contain; }
.lb img { max-width:85vw; max-height:90vh; border-radius:8px; object-fit:contain; }
.lb-close { position:absolute; top:1rem; right:1rem; background:none; border:none; color:#fff; font-size:1.5rem; cursor:pointer; }
.lb-nav { position:absolute; top:50%; transform:translateY(-50%); background:rgba(255,255,255,.15); border:none; color:#fff; font-size:2.5rem; line-height:1; cursor:pointer; padding:0.3rem 0.8rem; border-radius:8px; transition:background 0.15s; user-select:none; }
.lb-nav:hover { background:rgba(255,255,255,.3); }
.lb-nav:disabled { opacity:0.2; cursor:default; }
.lb-prev { left:1rem; }
.lb-next { right:1rem; }
.owner-badge { display:inline-flex; align-items:center; gap:0.3rem; font-size:0.75rem; background:var(--color-secondary); border-radius:4px; padding:0.2rem 0.5rem; color:var(--color-muted); margin-top:0.3rem; }
.owner-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.75rem; }
/* Tabs */
.tab-bar { display:flex; gap:0; border-bottom:2px solid var(--color-secondary); margin-bottom:1.25rem; flex-wrap:wrap; }
.tab-btn { background:none; border:none; border-bottom:2px solid transparent; color:var(--color-muted); cursor:pointer; font-size:0.88rem; font-weight:600; padding:0.65rem 1rem; margin-bottom:-2px; border-radius:0; width:auto; margin-top:0; transition:color 0.15s, border-color 0.15s; white-space:nowrap; }
.tab-btn:hover { color:var(--color-text); background:none; }
.tab-btn.active { color:var(--color-text); border-bottom-color:var(--color-primary); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
/* Posteingang */
.inbox-list { display:flex; flex-direction:column; gap:0.5rem; }
.inbox-item { display:flex; align-items:center; gap:0.65rem; padding:0.65rem 0.75rem; border-radius:8px; background:var(--color-secondary); cursor:pointer; transition:opacity 0.15s; }
.inbox-item:hover { opacity:0.85; }
.inbox-avatar { width:36px; height:36px; border-radius:50%; background:var(--color-card); display:flex; align-items:center; justify-content:center; font-size:0.9rem; flex-shrink:0; overflow:hidden; }
.inbox-avatar img { width:100%; height:100%; object-fit:cover; }
.inbox-info { flex:1; min-width:0; }
.inbox-name { font-weight:600; font-size:0.88rem; }
.inbox-preview { font-size:0.78rem; color:var(--color-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.inbox-unread { background:var(--color-primary); color:#fff; font-size:0.65rem; font-weight:700; border-radius:9999px; padding:0.1rem 0.35rem; flex-shrink:0; }
/* Posteingang Chat */
.inbox-chat { display:none; flex-direction:column; gap:0; margin-top:0.75rem; border:1px solid var(--color-secondary); border-radius:10px; overflow:hidden; }
.inbox-chat.open { display:flex; }
.inbox-chat-header { display:flex; align-items:center; gap:0.5rem; padding:0.65rem 0.9rem; background:var(--color-secondary); font-weight:600; font-size:0.88rem; }
.inbox-chat-back { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:1.1rem; padding:0; margin:0; width:auto; line-height:1; }
.inbox-chat-close { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:1rem; padding:0.15rem 0.35rem; margin:0 0 0 auto; width:auto; line-height:1; border-radius:4px; transition:background 0.15s, color 0.15s; }
.inbox-chat-close:hover { background:var(--color-card); color:var(--color-text); }
.inbox-chat-messages { max-height:320px; overflow-y:auto; padding:0.75rem 1rem; display:flex; flex-direction:column; gap:0.4rem; }
.inbox-bubble-wrap { display:flex; flex-direction:column; }
.inbox-bubble-wrap.me { align-items:flex-end; }
.inbox-bubble-wrap.them { align-items:flex-start; }
.inbox-bubble { max-width:75%; padding:0.45rem 0.8rem; border-radius:12px; font-size:0.88rem; line-height:1.4; word-break:break-word; }
.inbox-bubble-wrap.me .inbox-bubble { background:var(--color-primary); color:#fff; border-bottom-right-radius:4px; }
.inbox-bubble-wrap.them .inbox-bubble { background:var(--color-secondary); color:var(--color-text); border-bottom-left-radius:4px; }
.inbox-bubble-time { font-size:0.68rem; color:var(--color-muted); margin-top:0.1rem; padding:0 0.2rem; }
.inbox-reply-area { display:flex; gap:0.5rem; padding:0.6rem 0.9rem; border-top:1px solid var(--color-secondary); align-items:center; }
.inbox-reply-area input { flex:1; }
.inbox-reply-btn { width:auto; margin-top:0; padding:0.5rem 1rem; flex-shrink:0; }
.inbox-lock-hint { font-size:0.8rem; color:var(--color-muted); padding:0.5rem 0.9rem; border-top:1px solid var(--color-secondary); background:var(--color-secondary); }
.inbox-reply-trigger { padding:0.6rem 0.9rem; border-top:1px solid var(--color-secondary); }
.inbox-reply-trigger .btn { font-size:0.85rem; }
</style>
</head>
<body>
<div class="main">
<a href="/community/locations.html" class="back-link">← Locations</a>
<body class="app">
<div id="content">
<p style="color:var(--color-muted);">Wird geladen…</p>
<!-- ── Admin-Autocomplete (außerhalb .main, damit overflow-y:auto nicht clippt) ── -->
<ul id="adminSearchList" style="position:fixed;display:none;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:6px;z-index:400;list-style:none;margin:0;padding:0;max-height:200px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.15);"></ul>
<ul id="ownerSearchList" style="position:fixed;display:none;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:6px;z-index:400;list-style:none;margin:0;padding:0;max-height:200px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.15);"></ul>
<div class="main">
<div class="content">
<a href="/community/locations.html" class="back-link">← Locations</a>
<div id="content">
<p style="color:var(--color-muted);">Wird geladen…</p>
</div>
</div>
</div>
@@ -131,7 +184,7 @@
<label>Titel *</label>
<input type="text" id="eventTitle" maxlength="200">
<label>Beschreibung <span style="color:var(--color-muted);font-size:0.8rem;">(max. 1000 Zeichen)</span></label>
<textarea id="eventDesc" maxlength="1000" rows="4" style="resize:vertical;"></textarea>
<textarea id="eventDesc" maxlength="1000" rows="4" style="resize:vertical;width:100%;box-sizing:border-box;padding:0.65rem 0.9rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:1rem;outline:none;font-family:inherit;transition:border-color 0.2s;" onfocus="this.style.borderColor='var(--color-primary)'" onblur="this.style.borderColor='var(--color-secondary)'"></textarea>
<label>Datum &amp; Uhrzeit *</label>
<input type="datetime-local" id="eventStartAt">
<div class="modal-footer">
@@ -141,20 +194,45 @@
</div>
</div>
<!-- ── Hinweis-Modal ──────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="alertModal">
<div class="modal" style="width:min(380px,95vw);">
<p id="alertMessage" style="margin:0 0 1.25rem;font-size:0.95rem;"></p>
<div class="modal-footer">
<button class="btn" onclick="document.getElementById('alertModal').classList.remove('open')">OK</button>
</div>
</div>
</div>
<!-- ── Bestätigungs-Modal ─────────────────────────────────────────────────── -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="width:min(380px,95vw);">
<h3 id="confirmTitle">Bestätigung</h3>
<p id="confirmMessage" style="color:var(--color-muted);font-size:0.92rem;margin:0 0 0.25rem;"></p>
<div class="modal-footer">
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeConfirm()">Abbrechen</button>
<button class="btn" id="confirmOkBtn" style="background:#c0392b;">Löschen</button>
</div>
</div>
</div>
<!-- ── Galerie Lightbox ───────────────────────────────────────────────────── -->
<div class="lb" id="lightbox" onclick="closeLightbox()">
<button class="lb-close" onclick="closeLightbox()"></button>
<img id="lbImg" src="" alt="">
<button class="lb-nav lb-prev" id="lbPrev" onclick="event.stopPropagation();lbNav(-1)">&#8249;</button>
<img id="lbImg" src="" alt="" onclick="event.stopPropagation()">
<button class="lb-nav lb-next" id="lbNext" onclick="event.stopPropagation();lbNav(1)">&#8250;</button>
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
const params = new URLSearchParams(location.search);
const locationId = params.get('id');
let locDetail = null;
let myUserId = null;
let isOwner = false;
let isAdmin = false;
let isFollowing = false;
// ── Bild-Resize ───────────────────────────────────────────────────────────────
@@ -192,6 +270,18 @@ function formatDate(dt) {
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
const VALID_TABS = ['grunddaten', 'admins', 'posteingang', 'veranstaltungen'];
function switchTab(name) {
if (!VALID_TABS.includes(name)) name = 'grunddaten';
document.querySelectorAll('.tab-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(panel =>
panel.classList.toggle('active', panel.id === 'tab-' + name));
history.replaceState(null, '', location.pathname + location.search + '#' + name);
}
// ── Lade Seite ────────────────────────────────────────────────────────────────
async function loadPage() {
if (!locationId) { document.getElementById('content').innerHTML = '<p>Keine Location-ID angegeben.</p>'; return; }
@@ -207,12 +297,24 @@ async function loadPage() {
myUserId = me.userId;
}
locDetail = await locRes.json();
isOwner = locDetail.ownerId === myUserId;
locDetail = await locRes.json();
isOwner = locDetail.ownerId === myUserId;
isAdmin = isOwner || !!locDetail.isAdmin;
isFollowing = !!locDetail.following;
renderPage();
loadEvents();
if (isAdmin) {
const chatWithId = new URLSearchParams(location.search).get('chatWith');
const hash = location.hash.replace('#', '');
const initialTab = chatWithId ? 'posteingang' : (VALID_TABS.includes(hash) ? hash : 'grunddaten');
switchTab(initialTab);
renderAdminList();
loadInbox();
loadEvents();
} else {
loadEvents();
}
}
function renderPage() {
@@ -230,6 +332,7 @@ function renderPage() {
<button class="btn" id="followBtn" style="font-size:0.85rem;${isFollowing ? 'background:var(--color-primary);color:#fff;' : 'background:var(--color-secondary);color:var(--color-text);'}" onclick="toggleFollow()">
${isFollowing ? '★ Abonniert' : '☆ Abonnieren'}
</button>
${loc.virtualUserId && myUserId && !isAdmin ? `<button class="btn" style="font-size:0.85rem;" onclick="contactLocation('${loc.virtualUserId}')">✉ Kontaktieren</button>` : ''}
</div>`;
let hoursHtml = '';
@@ -248,7 +351,7 @@ function renderPage() {
const galleryHtml = buildGalleryHtml(loc.gallery || []);
document.getElementById('content').innerHTML = `
const locHeaderHtml = `
<div class="loc-header">
<div class="loc-avatar">${imgHtml}</div>
<div class="loc-meta">
@@ -257,10 +360,9 @@ function renderPage() {
${loc.description ? `<div class="loc-desc">${escHtml(loc.description)}</div>` : ''}
${ownerActions}
</div>
</div>
${hoursHtml}
</div>`;
const gallerySection = `
<div class="section-title">
Galerie
${isOwner ? `<label class="btn" style="font-size:0.8rem;cursor:pointer;">
@@ -268,14 +370,87 @@ function renderPage() {
<input type="file" accept="image/*" style="display:none;" onchange="uploadGalleryImage(this)">
</label>` : ''}
</div>
<div class="gallery-grid" id="galleryGrid">${galleryHtml}</div>
<div class="gallery-grid" id="galleryGrid">${galleryHtml}</div>`;
const eventsSection = `
<div class="section-title">
Veranstaltungen
${isOwner ? `<button class="btn" style="font-size:0.8rem;" onclick="openEventModal()">+ Veranstaltung erstellen</button>` : ''}
</div>
<div class="event-list" id="eventList"><p style="color:var(--color-muted);font-size:0.9rem;">Wird geladen…</p></div>
`;
<div id="pastEventsSection" style="display:none;">
<div class="section-title" style="margin-top:1.5rem;">Vergangene Veranstaltungen</div>
<div class="event-list" id="pastEventList"></div>
</div>`;
if (isAdmin) {
document.getElementById('content').innerHTML = `
<div class="tab-bar">
<button class="tab-btn" data-tab="grunddaten" onclick="switchTab('grunddaten')">Grunddaten</button>
<button class="tab-btn" data-tab="admins" onclick="switchTab('admins')">Administrator*Innen</button>
<button class="tab-btn" data-tab="posteingang" onclick="switchTab('posteingang')">Posteingang</button>
<button class="tab-btn" data-tab="veranstaltungen" onclick="switchTab('veranstaltungen')">Veranstaltungen</button>
</div>
<div class="tab-panel" id="tab-grunddaten">
${locHeaderHtml}
${hoursHtml}
${gallerySection}
</div>
<div class="tab-panel" id="tab-admins">
<div id="adminList" style="display:flex;flex-direction:column;gap:0.5rem;margin-bottom:0.75rem;"></div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<div style="flex:1;min-width:180px;">
<input type="text" id="adminSearchInput" placeholder="Mitglied suchen…" autocomplete="off"
oninput="onAdminSearch()" style="width:100%;box-sizing:border-box;">
</div>
<button class="btn" style="font-size:0.85rem;white-space:nowrap;" onclick="addAdminFromSearch()">+ Admin hinzufügen</button>
</div>
${isOwner ? `
<div style="margin-top:1rem;padding-top:1rem;border-top:1px solid var(--color-secondary);">
<div style="font-size:0.82rem;color:var(--color-muted);margin-bottom:0.5rem;">Inhaberrechte übertragen</div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<div style="flex:1;min-width:180px;">
<input type="text" id="ownerSearchInput" placeholder="Mitglied suchen…" autocomplete="off"
oninput="onOwnerSearch()" style="width:100%;box-sizing:border-box;">
</div>
<button class="btn" style="font-size:0.85rem;white-space:nowrap;background:#c0392b;" onclick="transferOwner()">Inhaberwechsel</button>
</div>
</div>` : ''}
</div>
<div class="tab-panel" id="tab-posteingang">
<div id="inboxList" class="inbox-list"><p style="color:var(--color-muted);font-size:0.9rem;">Wird geladen…</p></div>
<div class="inbox-chat" id="inboxChat">
<div class="inbox-chat-header">
<button class="inbox-chat-back" onclick="closeInboxChat()" aria-label="Zurück"></button>
<span id="inboxChatName" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
<button class="inbox-chat-close" onclick="closeInboxChat()" aria-label="Schließen">✕</button>
</div>
<div class="inbox-chat-messages" id="inboxChatMessages"></div>
<div class="inbox-lock-hint" id="inboxLockHint" style="display:none;"></div>
<div class="inbox-reply-trigger" id="inboxReplyTrigger" style="display:none;">
<button class="btn" onclick="startReplying()">✎ Antworten</button>
</div>
<div class="inbox-reply-area" id="inboxReplyArea" style="display:none;">
<input type="text" id="inboxReplyInput" placeholder="Antwort eingeben…" autocomplete="off"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendInboxReply();}">
<button class="btn inbox-reply-btn" onclick="sendInboxReply()">↑</button>
</div>
</div>
</div>
<div class="tab-panel" id="tab-veranstaltungen">
${eventsSection}
</div>`;
} else {
document.getElementById('content').innerHTML = `
${locHeaderHtml}
${hoursHtml}
${gallerySection}
${eventsSection}`;
}
}
function buildGalleryHtml(gallery) {
@@ -298,7 +473,7 @@ async function uploadGalleryImage(input) {
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ imageData })
});
if (res.status === 422) { alert('Maximal 20 Galeriebilder erlaubt.'); return; }
if (res.status === 422) { showAlert('Maximal 20 Galeriebilder erlaubt.'); return; }
if (!res.ok) throw new Error();
const img = await res.json();
const grid = document.getElementById('galleryGrid');
@@ -307,18 +482,34 @@ async function uploadGalleryImage(input) {
<img src="data:image/jpeg;base64,${img.imageData}" alt="Galeriebild" onclick="openLightbox(this.src)">
<button class="gallery-del-btn" onclick="deleteGalleryImage('${img.imageId}')">✕</button>
</div>`);
} catch { alert('Fehler beim Hochladen.'); }
} catch { showAlert('Fehler beim Hochladen.'); }
input.value = '';
}
async function deleteGalleryImage(imageId) {
if (!confirm('Bild löschen?')) return;
const res = await fetch(`/locations/${locationId}/gallery/${imageId}`, { method: 'DELETE' });
if (!res.ok) { alert('Fehler beim Löschen.'); return; }
if (!res.ok) { showAlert('Fehler beim Löschen.'); return; }
await loadPage();
}
// ── Events ─────────────────────────────────────────────────────────────────────
function buildEventCard(e, isPast) {
const imgHtml = e.imageData
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="${escHtml(e.title)}">`
: '🗓';
const opacity = isPast ? 'opacity:0.6;' : '';
return `
<a class="event-card" href="/community/event-detail.html?id=${e.eventId}" style="${opacity}">
<div class="event-card-img">${imgHtml}</div>
<div class="event-card-body">
<div class="event-card-title">${escHtml(e.title)}</div>
<div class="event-card-date">${formatDate(e.startAt)}</div>
<div class="event-card-attendees">${e.attendeeCount} Teilnehmer*in(nen)${e.attendingMe ? ' · Du nimmst teil' : ''}</div>
</div>
</a>`;
}
async function loadEvents() {
const res = await fetch(`/locations/${locationId}/events`);
if (!res.ok) return;
@@ -326,37 +517,50 @@ async function loadEvents() {
const list = document.getElementById('eventList');
if (!list) return;
if (events.length === 0) {
list.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Noch keine Veranstaltungen.</p>';
return;
}
const now = new Date();
const future = events.filter(e => new Date(e.startAt) >= now);
const past = events.filter(e => new Date(e.startAt) < now)
.slice(-5) // letzte 5
.reverse(); // neueste zuerst
list.innerHTML = events.map(e => {
const imgHtml = e.imageData
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="${escHtml(e.title)}">`
: '🗓';
const deleteBtn = isOwner
? `<button class="btn" style="font-size:0.75rem;margin-top:0.3rem;background:var(--color-secondary);color:var(--color-text);padding:0.2rem 0.5rem;" onclick="event.preventDefault();deleteEvent('${e.eventId}')">Löschen</button>`
: '';
return `
<a class="event-card" href="/community/event-detail.html?id=${e.eventId}">
<div class="event-card-img">${imgHtml}</div>
<div class="event-card-body">
<div class="event-card-title">${escHtml(e.title)}</div>
<div class="event-card-date">${formatDate(e.startAt)}</div>
<div class="event-card-attendees">${e.attendeeCount} Teilnehmer*in(nen)${e.attendingMe ? ' · Du nimmst teil' : ''}</div>
${deleteBtn}
</div>
</a>`;
}).join('');
list.innerHTML = future.length
? future.map(e => buildEventCard(e, false)).join('')
: '<p style="color:var(--color-muted);font-size:0.9rem;">Keine bevorstehenden Veranstaltungen.</p>';
const pastSection = document.getElementById('pastEventsSection');
if (past.length && pastSection) {
document.getElementById('pastEventList').innerHTML = past.map(e => buildEventCard(e, true)).join('');
pastSection.style.display = '';
} else if (pastSection) {
pastSection.style.display = 'none';
}
}
// ── Lightbox ───────────────────────────────────────────────────────────────────
let lbSrcs = [], lbIdx = 0;
function openLightbox(src) {
document.getElementById('lbImg').src = src;
lbSrcs = Array.from(document.querySelectorAll('#galleryGrid .gallery-img-wrap img')).map(i => i.src);
lbIdx = Math.max(0, lbSrcs.indexOf(src));
lbShow();
document.getElementById('lightbox').classList.add('open');
}
function lbShow() {
document.getElementById('lbImg').src = lbSrcs[lbIdx];
document.getElementById('lbPrev').disabled = lbIdx === 0;
document.getElementById('lbNext').disabled = lbIdx === lbSrcs.length - 1;
}
function lbNav(dir) {
lbIdx = Math.max(0, Math.min(lbSrcs.length - 1, lbIdx + dir));
lbShow();
}
function closeLightbox() { document.getElementById('lightbox').classList.remove('open'); }
document.addEventListener('keydown', e => {
if (!document.getElementById('lightbox').classList.contains('open')) return;
if (e.key === 'Escape') closeLightbox();
else if (e.key === 'ArrowLeft') lbNav(-1);
else if (e.key === 'ArrowRight') lbNav(1);
});
// ── Edit Modal ─────────────────────────────────────────────────────────────────
let _editLq = null, _editHq = null, _editLat = null, _editLon = null, _editStreet = null, _editCity = null, _editCityTimer = null;
@@ -489,7 +693,7 @@ document.addEventListener('click', e => {
async function submitEdit() {
const name = document.getElementById('editName').value.trim();
if (!name) { alert('Name darf nicht leer sein.'); return; }
if (!name) { showAlert('Name darf nicht leer sein.'); return; }
const addrVal = document.getElementById('editCity').value.trim();
if (addrVal && _editLat == null) {
document.getElementById('editLocMsg').textContent = 'Bitte eine Adresse aus der Vorschlagsliste auswählen oder per GPS ermitteln.';
@@ -530,7 +734,7 @@ async function submitEdit() {
closeEditModal();
renderPage();
loadEvents();
} catch { alert('Fehler beim Speichern.'); }
} catch { showAlert('Fehler beim Speichern.'); }
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
}
@@ -547,14 +751,14 @@ async function toggleFollow() {
btn.style.background = isFollowing ? 'var(--color-primary)' : 'var(--color-secondary)';
btn.style.color = isFollowing ? '#fff' : 'var(--color-text)';
}
} catch (_) { alert('Fehler beim Aktualisieren des Abonnements.'); }
} catch (_) { showAlert('Fehler beim Aktualisieren des Abonnements.'); }
finally { if (btn) btn.disabled = false; }
}
async function deleteLocation() {
if (!confirm('Location wirklich löschen? Alle Veranstaltungen und Galeriebilder werden ebenfalls gelöscht.')) return;
const res = await fetch(`/locations/${locationId}`, { method: 'DELETE' });
if (!res.ok) { alert('Fehler beim Löschen.'); return; }
if (!res.ok) { showAlert('Fehler beim Löschen.'); return; }
window.location.href = '/community/locations.html';
}
@@ -568,6 +772,9 @@ function openEventModal(evtId) {
document.getElementById('eventTitle').value = '';
document.getElementById('eventDesc').value = '';
document.getElementById('eventStartAt').value = '';
// Nur Termine in der Zukunft erlauben
const nowLocal = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
document.getElementById('eventStartAt').min = nowLocal;
document.getElementById('eventPicPreview').innerHTML = '🗓';
document.getElementById('eventModal').classList.add('open');
}
@@ -582,8 +789,9 @@ async function onEventPicChange(input) {
async function submitEvent() {
const title = document.getElementById('eventTitle').value.trim();
const startAt = document.getElementById('eventStartAt').value;
if (!title) { alert('Bitte gib einen Titel ein.'); return; }
if (!startAt) { alert('Bitte wähle Datum und Uhrzeit.'); return; }
if (!title) { showAlert('Bitte gib einen Titel ein.'); return; }
if (!startAt) { showAlert('Bitte wähle Datum und Uhrzeit.'); return; }
if (new Date(startAt) <= new Date()) { showAlert('Der Termin muss in der Zukunft liegen.'); return; }
const btn = document.getElementById('eventSubmitBtn');
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
@@ -610,15 +818,321 @@ async function submitEvent() {
closeEventModal();
loadEvents();
} catch { alert('Fehler beim Speichern.'); }
} catch { showAlert('Fehler beim Speichern.'); }
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
}
async function deleteEvent(eventId) {
if (!confirm('Veranstaltung löschen?')) return;
const res = await fetch(`/locations/${locationId}/events/${eventId}`, { method: 'DELETE' });
if (!res.ok) { alert('Fehler beim Löschen.'); return; }
loadEvents();
function showAlert(message) {
document.getElementById('alertMessage').textContent = message;
document.getElementById('alertModal').classList.add('open');
}
function openConfirm(title, message, onOk) {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmMessage').textContent = message;
const btn = document.getElementById('confirmOkBtn');
btn.onclick = () => { closeConfirm(); onOk(); };
document.getElementById('confirmModal').classList.add('open');
}
function closeConfirm() { document.getElementById('confirmModal').classList.remove('open'); }
// ── Admin-Verwaltung ──────────────────────────────────────────────────────────
let _adminSearchSelected = null;
let _ownerSearchSelected = null;
let _adminSearchTimer = null;
let _ownerSearchTimer = null;
function renderAdminList() {
const list = document.getElementById('adminList');
if (!list || !locDetail.admins) return;
list.innerHTML = locDetail.admins.map(a => {
const pic = a.profilePicture
? `<img src="data:image/jpeg;base64,${a.profilePicture}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">`
: '◉';
const badge = a.isOwner
? `<span style="font-size:0.7rem;background:var(--color-primary);color:#fff;border-radius:4px;padding:0.1rem 0.4rem;margin-left:0.4rem;">Inhaber</span>`
: '';
const removeBtn = isAdmin && !a.isOwner
? `<button onclick="removeAdmin('${a.userId}')" style="margin-left:auto;background:none;border:none;color:var(--color-muted);cursor:pointer;font-size:1rem;padding:0.2rem 0.4rem;" title="Entfernen">✕</button>`
: '';
return `<div style="display:flex;align-items:center;gap:0.6rem;padding:0.4rem 0;">
<div style="width:32px;height:32px;border-radius:50%;background:var(--color-secondary);display:flex;align-items:center;justify-content:center;font-size:1rem;overflow:hidden;flex-shrink:0;">${pic}</div>
<span style="font-size:0.9rem;">${escHtml(a.name)}</span>${badge}${removeBtn}
</div>`;
}).join('');
}
async function removeAdmin(userId) {
const res = await fetch(`/locations/${locationId}/admins/${userId}`, { method: 'DELETE' });
if (!res.ok) { showAlert('Fehler beim Entfernen.'); return; }
locDetail.admins = locDetail.admins.filter(a => a.userId !== userId);
renderAdminList();
}
function onAdminSearch() {
const q = document.getElementById('adminSearchInput').value.trim();
_adminSearchSelected = null;
clearTimeout(_adminSearchTimer);
if (q.length < 2) { document.getElementById('adminSearchList').style.display = 'none'; return; }
_adminSearchTimer = setTimeout(() => fetchUserSuggestions(q, 'adminSearchList', sel => { _adminSearchSelected = sel; }), 250);
}
function onOwnerSearch() {
const q = document.getElementById('ownerSearchInput').value.trim();
_ownerSearchSelected = null;
clearTimeout(_ownerSearchTimer);
if (q.length < 2) { document.getElementById('ownerSearchList').style.display = 'none'; return; }
_ownerSearchTimer = setTimeout(() => fetchUserSuggestions(q, 'ownerSearchList', sel => { _ownerSearchSelected = sel; }), 250);
}
const _userCache = {};
async function fetchUserSuggestions(q, listId, onSelect) {
try {
const res = await fetch(`/social/users/search?q=${encodeURIComponent(q)}`);
if (!res.ok) return;
const users = await res.json();
const ul = document.getElementById(listId);
if (!users.length) { ul.style.display = 'none'; return; }
users.forEach(u => { _userCache[u.userId] = u; });
ul.innerHTML = users.map(u => `
<li style="padding:0.45rem 0.75rem;cursor:pointer;font-size:0.9rem;border-bottom:1px solid var(--color-secondary);"
onmousedown="event.preventDefault();selectUserSuggestion('${listId}','${u.userId}')">
${escHtml(u.name)}
</li>`).join('');
ul.__selectFn = onSelect;
// Dropdown unter dem zugehörigen Input positionieren
const inputId = listId === 'adminSearchList' ? 'adminSearchInput' : 'ownerSearchInput';
const rect = document.getElementById(inputId).getBoundingClientRect();
ul.style.top = (rect.bottom + 2) + 'px';
ul.style.left = rect.left + 'px';
ul.style.width = rect.width + 'px';
ul.style.display = '';
} catch(_) {}
}
function selectUserSuggestion(listId, userId) {
const ul = document.getElementById(listId);
const inputId = listId === 'adminSearchList' ? 'adminSearchInput' : 'ownerSearchInput';
const user = _userCache[userId];
if (!user) return;
document.getElementById(inputId).value = user.name;
ul.style.display = 'none';
if (ul.__selectFn) ul.__selectFn(user);
}
document.addEventListener('click', e => {
['adminSearchList','ownerSearchList'].forEach(id => {
const ul = document.getElementById(id);
if (ul && !e.target.closest('#' + id) && e.target.id !== (id === 'adminSearchList' ? 'adminSearchInput' : 'ownerSearchInput'))
ul.style.display = 'none';
});
});
async function addAdminFromSearch() {
if (!_adminSearchSelected) { showAlert('Bitte wähle ein Mitglied aus der Liste.'); return; }
const res = await fetch(`/locations/${locationId}/admins`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ userId: _adminSearchSelected.userId })
});
if (res.status === 409) { showAlert('Diese Person ist bereits Admin.'); return; }
if (!res.ok) { showAlert('Fehler beim Hinzufügen.'); return; }
const added = await res.json();
locDetail.admins = [...(locDetail.admins || []), added];
renderAdminList();
document.getElementById('adminSearchInput').value = '';
_adminSearchSelected = null;
}
async function transferOwner() {
if (!_ownerSearchSelected) { showAlert('Bitte wähle ein Mitglied aus der Liste.'); return; }
openConfirm(
'Inhaberwechsel',
`Inhaberrechte wirklich an „${_ownerSearchSelected.name}" übertragen? Diese Aktion kann nicht rückgängig gemacht werden.`,
async () => {
const res = await fetch(`/locations/${locationId}/admins/transfer-owner`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ userId: _ownerSearchSelected.userId })
});
if (!res.ok) { showAlert('Fehler beim Inhaberwechsel.'); return; }
// Seite neu laden aktueller User ist jetzt kein Inhaber mehr
await loadPage();
}
);
}
// ── Kontaktieren ──────────────────────────────────────────────────────────────
function contactLocation(virtualUserId) {
window.location.href = '/community/nachrichten.html?partnerId=' + virtualUserId;
}
// ── Posteingang (Admin) ───────────────────────────────────────────────────────
let _inboxPartnerId = null;
async function loadInbox() {
const el = document.getElementById('inboxList');
if (!el) return;
try {
const res = await fetch(`/locations/${locationId}/inbox`);
if (!res.ok) { el.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Nicht verfügbar.</p>'; return; }
const items = await res.json();
if (items.length === 0) {
el.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Noch keine Nachrichten.</p>';
return;
}
el.innerHTML = items.map(item => {
const av = item.senderPicture
? `<img src="data:image/png;base64,${item.senderPicture}" alt="">`
: '◉';
const unreadHtml = item.unreadCount > 0
? `<span class="inbox-unread">${item.unreadCount}</span>` : '';
return `<div class="inbox-item" onclick="openInboxChat('${item.senderId}','${escHtml(item.senderName)}')">
<div class="inbox-avatar">${av}</div>
<div class="inbox-info">
<div class="inbox-name">${escHtml(item.senderName)}</div>
<div class="inbox-preview">${escHtml(item.lastMessage)}</div>
</div>
${unreadHtml}
</div>`;
}).join('');
// URL-Parameter: Chat direkt öffnen (z.B. nach Klick auf Benachrichtigung)
const chatWithId = new URLSearchParams(location.search).get('chatWith');
if (chatWithId && !_inboxPartnerId) {
const match = items.find(i => i.senderId === chatWithId);
if (match) openInboxChat(match.senderId, match.senderName);
}
} catch { el.innerHTML = '<p style="color:var(--color-muted);font-size:0.9rem;">Fehler beim Laden.</p>'; }
}
async function openInboxChat(partnerId, partnerName) {
_inboxPartnerId = partnerId;
document.getElementById('inboxChatName').textContent = partnerName;
document.getElementById('inboxChatMessages').innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;">Wird geladen…</p>';
document.getElementById('inboxChat').classList.add('open');
document.getElementById('inboxReplyInput').value = '';
// Eingabebereich zurücksetzen
document.getElementById('inboxReplyTrigger').style.display = 'none';
document.getElementById('inboxReplyArea').style.display = 'none';
document.getElementById('inboxLockHint').style.display = 'none';
const virtualId = locDetail.virtualUserId;
try {
const res = await fetch(`/locations/${locationId}/inbox/${partnerId}`);
if (!res.ok) throw new Error();
const data = await res.json();
const messages = data.messages || [];
const container = document.getElementById('inboxChatMessages');
container.innerHTML = '';
if (messages.length === 0) {
container.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;">Noch keine Nachrichten.</p>';
} else {
messages.forEach(m => {
const isLocationSender = m.senderId === virtualId;
const wrap = document.createElement('div');
wrap.className = 'inbox-bubble-wrap ' + (isLocationSender ? 'me' : 'them');
const time = new Date(m.sentAt).toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
wrap.innerHTML = `<div class="inbox-bubble">${escHtml(m.text)}</div><div class="inbox-bubble-time">${time}</div>`;
container.appendChild(wrap);
});
}
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
// Lock-Status anzeigen
applyLockUi(data);
loadInbox();
} catch {
document.getElementById('inboxChatMessages').innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;text-align:center;">Fehler beim Laden.</p>';
}
}
function applyLockUi(data) {
const trigger = document.getElementById('inboxReplyTrigger');
const area = document.getElementById('inboxReplyArea');
const lockHint = document.getElementById('inboxLockHint');
if (data.lockedByMe) {
// Ich habe die Sperre → Eingabefeld direkt zeigen
trigger.style.display = 'none';
area.style.display = '';
lockHint.style.display = 'none';
document.getElementById('inboxReplyInput').focus();
} else if (data.canReply) {
// Frei → "Antworten"-Button zeigen
trigger.style.display = '';
area.style.display = 'none';
lockHint.style.display = 'none';
} else {
// Gesperrt durch anderen Admin
trigger.style.display = 'none';
area.style.display = 'none';
lockHint.style.display = '';
lockHint.textContent = `Wird gerade von ${data.lockedByName || 'einem anderen Admin'} beantwortet.`;
}
}
async function startReplying() {
if (!_inboxPartnerId) return;
try {
const res = await fetch(`/locations/${locationId}/inbox/${_inboxPartnerId}/lock`, {
method: 'POST'
});
if (res.status === 409) {
const body = await res.json();
const name = body.lockedByName || 'einem anderen Admin';
document.getElementById('inboxReplyTrigger').style.display = 'none';
document.getElementById('inboxLockHint').style.display = '';
document.getElementById('inboxLockHint').textContent =
`Wird gerade von ${name} beantwortet.`;
return;
}
if (!res.ok) { showAlert('Sperre konnte nicht erworben werden.'); return; }
document.getElementById('inboxReplyTrigger').style.display = 'none';
document.getElementById('inboxReplyArea').style.display = '';
document.getElementById('inboxReplyInput').focus();
} catch { showAlert('Fehler beim Sperren.'); }
}
function closeInboxChat() {
_inboxPartnerId = null;
document.getElementById('inboxChat').classList.remove('open');
document.getElementById('inboxReplyTrigger').style.display = 'none';
document.getElementById('inboxReplyArea').style.display = 'none';
document.getElementById('inboxLockHint').style.display = 'none';
loadInbox();
}
async function sendInboxReply() {
if (!_inboxPartnerId) return;
const input = document.getElementById('inboxReplyInput');
const text = input.value.trim();
if (!text) return;
input.value = '';
try {
const res = await fetch(`/locations/${locationId}/inbox/${_inboxPartnerId}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
if (res.status === 409) {
const body = await res.json().catch(() => ({}));
const name = body.lockedByName || 'einem anderen Admin';
input.value = text;
// Eingabe sperren, Hinweis zeigen
document.getElementById('inboxReplyArea').style.display = 'none';
document.getElementById('inboxLockHint').style.display = '';
document.getElementById('inboxLockHint').textContent =
`Wird gerade von ${name} beantwortet. Deine Nachricht wurde nicht gesendet.`;
return;
}
if (!res.ok) { showAlert('Fehler beim Senden.'); input.value = text; return; }
// Konversation neu laden (Lock ist nach Senden bei mir)
const partnerName = document.getElementById('inboxChatName').textContent;
await openInboxChat(_inboxPartnerId, partnerName);
} catch { showAlert('Fehler beim Senden.'); input.value = text; }
}
// ── Init ──────────────────────────────────────────────────────────────────────

View File

@@ -121,6 +121,8 @@
</svg>
<span class="filter-badge" id="filterBadge" style="display:none;position:absolute;top:-3px;right:-3px;"></span>
</button>
<button id="createLocBtn" class="btn" onclick="openCreateModal()"
style="display:none;padding:0.35rem 0.85rem;font-size:0.85rem;">+ Location anlegen</button>
</div>
</div>
@@ -134,9 +136,6 @@
<!-- ── Meine Locations ──────────────────────────────────────────── -->
<div id="paneMine" class="tab-panel">
<div style="display:flex; justify-content:flex-end; margin-bottom:1rem;">
<button class="btn" onclick="openCreateModal()">+ Location anlegen</button>
</div>
<div class="loc-grid" id="mineGrid"></div>
<p class="empty-hint" id="mineEmpty" style="display:none;">Du hast noch keine Locations angelegt.</p>
</div>
@@ -228,6 +227,8 @@ function switchTab(name, btn) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('pane' + name.charAt(0).toUpperCase() + name.slice(1)).classList.add('active');
document.getElementById('filterOpenBtn').style.display = name === 'search' ? '' : 'none';
document.getElementById('createLocBtn').style.display = name === 'mine' ? '' : 'none';
}
// ── Filter-Drawer ─────────────────────────────────────────────────────────────
@@ -378,8 +379,8 @@ async function loadNextBatch() {
const a = document.createElement('a');
a.className = 'loc-card';
a.href = `/community/location-detail.html?id=${p.locationId}`;
const imgHtml = p.profilePictureLq
? `<img src="data:image/jpeg;base64,${p.profilePictureLq}" alt="${escHtml(p.name)}">`
const imgHtml = p.profilePictureHq
? `<img src="data:image/jpeg;base64,${p.profilePictureHq}" alt="${escHtml(p.name)}">`
: '<span>📍</span>';
a.innerHTML = `
<div class="loc-card-img">${imgHtml}</div>
@@ -421,8 +422,8 @@ async function loadMine() {
const a = document.createElement('a');
a.className = 'loc-card';
a.href = `/community/location-detail.html?id=${p.locationId}`;
const imgHtml = p.profilePictureLq
? `<img src="data:image/jpeg;base64,${p.profilePictureLq}" alt="${escHtml(p.name)}">`
const imgHtml = p.profilePictureHq
? `<img src="data:image/jpeg;base64,${p.profilePictureHq}" alt="${escHtml(p.name)}">`
: '<span>📍</span>';
a.innerHTML = `
<div class="loc-card-img">${imgHtml}</div>

View File

@@ -341,7 +341,8 @@
if (!user) return;
myId = user.userId;
loadConversations();
const urlPartnerId = new URLSearchParams(window.location.search).get('userId');
const params = new URLSearchParams(window.location.search);
const urlPartnerId = params.get('partnerId') || params.get('userId');
if (urlPartnerId) openThread(urlPartnerId);
})
.catch(() => {});
@@ -363,31 +364,36 @@
return;
}
convs.forEach(c => {
const isLocation = c.partner.friendStatus === 'LOCATION';
const av = c.partner.profilePicture
? `<img src="data:image/png;base64,${c.partner.profilePicture}" alt="" style="cursor:zoom-in;" onclick="event.stopPropagation();openLightbox(this.src)">`
: '◉';
: (isLocation ? '📍' : '◉');
const unreadHtml = c.unreadCount > 0
? `<span class="conv-unread">${c.unreadCount}</span>`
: '';
const preview = c.lastMessage
? (c.lastMessage.text.startsWith('data:image/') ? '📷 Bild' : esc(c.lastMessage.text.substring(0, 40)))
: '';
const locationBadge = isLocation
? `<span style="font-size:0.6rem;background:var(--color-secondary);color:var(--color-muted);border-radius:4px;padding:0.1rem 0.3rem;margin-left:0.3rem;">Location</span>`
: '';
const li = document.createElement('li');
li.className = 'conv-item' + (c.partner.userId === activePartnerId ? ' active' : '');
li.dataset.partnerId = c.partner.userId;
li.dataset.isLocation = isLocation ? '1' : '';
li.innerHTML = `
<div class="conv-avatar">${av}</div>
<div class="conv-info">
<div class="conv-name">${esc(c.partner.name)}</div>
<div class="conv-name">${esc(c.partner.name)}${locationBadge}</div>
<div class="conv-preview">${preview}</div>
</div>
${unreadHtml}`;
li.addEventListener('click', () => openThread(c.partner.userId, c.partner.name, c.partner.profilePicture));
li.addEventListener('click', () => openThread(c.partner.userId, c.partner.name, c.partner.profilePicture, isLocation));
list.appendChild(li);
});
}
async function openThread(partnerId, partnerName, partnerPic) {
async function openThread(partnerId, partnerName, partnerPic, isLocation) {
activePartnerId = partnerId;
oldestSentAt = null;
newestSentAt = null;
@@ -398,17 +404,51 @@
li.classList.toggle('active', li.dataset.partnerId === partnerId);
});
// isLocation ggf. aus DOM ermitteln oder per API auflösen
if (isLocation === undefined) {
const convItem = document.querySelector(`.conv-item[data-partner-id="${partnerId}"]`);
if (convItem) {
isLocation = convItem.dataset.isLocation === '1';
} else {
// Noch kein Conv-Item (neue/unbekannte Konversation) → per API prüfen
try {
const locRes = await fetch('/locations/virtual/' + partnerId);
if (locRes.ok) {
isLocation = true;
const loc = await locRes.json();
if (!partnerName) partnerName = loc.name;
if (!partnerPic && loc.profilePictureLq) partnerPic = loc.profilePictureLq;
} else {
isLocation = false;
}
} catch { isLocation = false; }
}
}
if (!partnerName) {
const convItem = document.querySelector(`.conv-item[data-partner-id="${partnerId}"]`);
partnerName = convItem ? convItem.querySelector('.conv-name').textContent : '…';
// .conv-name enthält ggf. das Location-Badge nur Textinhalt nehmen
partnerName = convItem ? convItem.querySelector('.conv-name').firstChild.textContent.trim() : '…';
}
const locationBadge = isLocation
? ` <span style="font-size:0.65rem;background:var(--color-secondary);color:var(--color-muted);border-radius:4px;padding:0.1rem 0.35rem;vertical-align:middle;">Location</span>`
: '';
if (isLocation) {
document.getElementById('threadPartnerName').innerHTML =
`${esc(partnerName)}${locationBadge}`;
} else {
document.getElementById('threadPartnerName').innerHTML =
`<a href="/community/benutzer.html?userId=${partnerId}" style="color:inherit;text-decoration:none;">${esc(partnerName)}</a>`;
}
document.getElementById('threadPartnerName').innerHTML =
`<a href="/community/benutzer.html?userId=${partnerId}" style="color:inherit;text-decoration:none;">${esc(partnerName)}</a>`;
const avatarEl = document.getElementById('threadPartnerAvatar');
if (partnerPic) {
avatarEl.innerHTML = `<img src="data:image/png;base64,${partnerPic}" alt="" style="cursor:zoom-in;" onclick="openLightbox(this.src)">`;
avatarEl.style.display = '';
} else if (isLocation) {
avatarEl.innerHTML = '📍';
avatarEl.style.display = '';
} else {
avatarEl.style.display = 'none';
}
@@ -548,7 +588,12 @@
input.value = text;
return;
}
await pollNewMessages();
// War die Konversation leer, neu laden; sonst nur neue Nachrichten pollen
if (newestSentAt) {
await pollNewMessages();
} else {
await loadInitialThread();
}
} catch (e) { console.error(e); }
}

View File

@@ -516,6 +516,17 @@ body.app {
padding: 0;
}
.sidebar-cat-label {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-muted);
padding: 0.9rem 1.1rem 0.25rem;
pointer-events: none;
user-select: none;
}
.sidebar-group-toggle {
cursor: pointer;
justify-content: space-between;

View File

@@ -44,23 +44,41 @@
];
// ── Community-Links (immer sichtbar, oberhalb der Spiele) ──
const socialLinks = [
{ href: '/userhome.html', icon: I('HOME') || '⌂', label: 'Home', badgeId: null },
{ href: '/community/feed.html', icon: I('FEED'), label: 'Feed', badgeId: null },
{ href: '/community/freunde.html', icon: I('FRIENDS'), label: 'Freunde', badgeId: 'socialFriendsBadge'},
{ href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten', badgeId: null },
{ href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen', badgeId: null },
{ href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge'},
{ href: '/community/locations.html', icon: I('LOCATION') || '📍', label: 'Locations', badgeId: null },
{ href: '/community/events.html', icon: I('EVENT') || '🗓', label: 'Veranstaltungen', badgeId: null },
{ href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen', badgeId: null },
];
const socialNav = socialLinks.map(({ href, icon, label, badgeId }) => {
// ── Hilfsfunktion: einzelner Nav-Link ──
function navLink({ href, icon, label, badgeId }) {
const cls = path === href ? ' class="active"' : '';
const badge = badgeId ? `<span class="social-badge" id="${badgeId}" style="display:none;"></span>` : '';
return `<li><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}${badge}</a></li>`;
}).join('');
}
const sep = `<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>`;
const catLabel = label => `<li class="sidebar-cat-label">${label}</li>`;
// Home
const homeNav = navLink({ href: '/userhome.html', icon: I('HOME') || '⌂', label: 'Home' });
// Kommunikation
const commLinks = [
{ href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten' },
{ href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen' },
{ href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen' },
];
// Social
const socialLinks = [
{ href: '/community/feed.html', icon: I('FEED'), label: 'Feed' },
{ href: '/community/freunde.html', icon: I('FRIENDS'), label: 'Freunde', badgeId: 'socialFriendsBadge' },
{ href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge' },
{ href: '/community/locations.html', icon: I('LOCATION') || '📍', label: 'Locations' },
{ href: '/community/events.html', icon: I('EVENT') || '🗓', label: 'Veranstaltungen' },
];
const socialNav = [
homeNav,
sep,
...commLinks.map(navLink),
sep,
...socialLinks.map(navLink),
].join('');
const datingActive = path === '/dating.html';
const datingCls = datingActive ? ' class="active"' : '';
@@ -106,7 +124,7 @@
<div class="sidebar-scroll-area">
<ul>
${socialNav}
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>
${sep}
${datingItem}
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>
${nav}

View File

@@ -107,6 +107,29 @@
.match-badge {
font-size: 0.65rem; color: var(--color-primary); text-align: center; font-weight: 600;
}
/* ── Location-Events ── */
.loc-event-list { display: flex; flex-direction: column; gap: 0.6rem; }
.loc-event-card {
display: flex; gap: 0.75rem; align-items: center;
background: var(--color-secondary); border: 1px solid var(--color-secondary);
border-radius: 10px; padding: 0.65rem 0.85rem;
text-decoration: none; color: inherit;
transition: border-color 0.15s;
}
.loc-event-card:hover { border-color: var(--color-primary); }
.loc-event-thumb {
width: 48px; height: 48px; border-radius: 8px; flex-shrink: 0;
background: var(--color-card); overflow: hidden;
display: flex; align-items: center; justify-content: center; font-size: 1.4rem;
}
.loc-event-thumb img { width: 100%; height: 100%; object-fit: cover; }
.loc-event-body { flex: 1; min-width: 0; }
.loc-event-location { font-size: 0.75rem; color: var(--color-muted); margin-bottom: 0.1rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.loc-event-title { font-size: 0.92rem; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.loc-event-date { font-size: 0.75rem; color: var(--color-primary); margin-top: 0.15rem; }
</style>
</head>
<body class="app">
@@ -132,38 +155,19 @@
<div class="section-label">Matches 💕</div>
<div class="dating-strip" id="matchesStrip"></div>
</div>
<!-- Meine angemeldeten Events -->
<div id="myEventsSection" style="display:none;">
<div class="section-label">Meine Veranstaltungen 🎟</div>
<div class="loc-event-list" id="myEventsList"></div>
</div>
<!-- Nächste Events abonnierter Locations -->
<div id="locEventsSection" style="display:none;">
<div class="section-label">Nächste Veranstaltungen 📍</div>
<div class="loc-event-list" id="locEventsList"></div>
</div>
</div>
<div class="game-grid">
<div class="game-card">
<div class="game-card-icon">❤️</div>
<h2 class="game-card-title">Vanilla Game</h2>
<p class="game-card-desc">
</p>
<a href="/games/vanilla/sessionvanilla.html"><button class="game-card-btn">Neue Session starten</button></a>
</div>
<div class="game-card">
<div class="game-card-icon">⛓️</div>
<h2 class="game-card-title">BDSM Game</h2>
<p class="game-card-desc">
Tauche ein in strukturierte Sessions mit Aufgaben, Toys und klaren Rollen.
Definiere Grenzen, vergib Aufgaben und erlebe intensive Momente mit deinen Spielpartner*Innen.
</p>
<a href="/games/bdsm/neubdsm.html"><button class="game-card-btn">Neue Session starten</button></a>
</div>
<div class="game-card">
<div class="game-card-icon">🔒</div>
<h2 class="game-card-title">Chastity Game</h2>
<p class="game-card-desc">
Erlebe Keuschheit auf eine neue Art: Kartenbasierte Locks, Keyholder-System,
Community-Abstimmungen und tägliche Verifizierungen machen jedes Lock einzigartig.
</p>
<a href="/games/chastity/neulock.html"><button class="game-card-btn">Neues Lock erstellen</button></a>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
@@ -178,6 +182,8 @@
if (user) {
document.getElementById('greeting').textContent = 'Willkommen zurück, ' + user.name + '!';
loadVisitors();
loadMyEvents();
loadLocEvents();
if (user.datingAktiv) {
loadWhoLikesMe();
loadMatches();
@@ -257,6 +263,47 @@
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function renderEventCards(events, listId, sectionId) {
if (!events.length) return;
const list = document.getElementById(listId);
list.innerHTML = events.map(e => {
const thumb = e.imageData
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="">`
: '🗓';
const date = new Date(e.startAt);
const dateStr = date.toLocaleDateString('de-DE', { weekday:'short', day:'numeric', month:'short' })
+ ', ' + date.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
return `
<a class="loc-event-card" href="/community/event-detail.html?id=${e.eventId}">
<div class="loc-event-thumb">${thumb}</div>
<div class="loc-event-body">
<div class="loc-event-location">${esc(e.locationName)}</div>
<div class="loc-event-title">${esc(e.title)}</div>
<div class="loc-event-date">${dateStr}</div>
</div>
</a>`;
}).join('');
document.getElementById(sectionId).style.display = '';
}
async function loadMyEvents() {
try {
const res = await fetch('/location-events/attending-next');
if (!res.ok) return;
const events = await res.json();
renderEventCards(events, 'myEventsList', 'myEventsSection');
} catch (_) {}
}
async function loadLocEvents() {
try {
const res = await fetch('/location-events/followed-next');
if (!res.ok) return;
const events = await res.json();
renderEventCards(events, 'locEventsList', 'locEventsSection');
} catch (_) {}
}
async function loadVisitors() {
try {
const res = await fetch('/social/profile-visits/my-visitors');