Weiter an den Locations gearbeitet
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,5 +24,6 @@ public record FeedItemDto(
|
||||
long kommentarCount,
|
||||
List<UmfrageOptionDto> optionen,
|
||||
List<UUID> myVoteOptionIds,
|
||||
boolean isPublic
|
||||
boolean isPublic,
|
||||
String targetUrl
|
||||
) {}
|
||||
|
||||
@@ -42,4 +42,7 @@ public class FeedPostEntity {
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String targetUrl;
|
||||
}
|
||||
|
||||
176
src/main/java/de/oaa/xxx/location/LocationAdminController.java
Normal file
176
src/main/java/de/oaa/xxx/location/LocationAdminController.java
Normal 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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,5 +6,7 @@ public enum MessageCause {
|
||||
EMERGENCY,
|
||||
FRIENDREQUEST,
|
||||
SUPPORT,
|
||||
DATE_INTEREST
|
||||
DATE_INTEREST,
|
||||
EVENT_CANCELLED,
|
||||
LOCATION_MESSAGE
|
||||
}
|
||||
|
||||
@@ -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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
@@ -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();
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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 & 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)">‹</button>
|
||||
<img id="lbImg" src="" alt="" onclick="event.stopPropagation()">
|
||||
<button class="lb-nav lb-next" id="lbNext" onclick="event.stopPropagation();lbNav(1)">›</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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user