An den Verantaltungen und 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:
@@ -1,3 +1,5 @@
|
||||
Slomo und Speedup Card
|
||||
|
||||
Sammeln von Erfahrung
|
||||
|
||||
TODO: Im Time Lock, wenn im Spinning Wheel tasks drin sind, dürfen keine sonst keine Tasks gefordert sein und umgekehrt
|
||||
@@ -33,4 +35,8 @@ Ich kann Spieler einladen zu spielen, dann kriegt die Person eine E-Mail und mus
|
||||
|
||||
Die interessantesten wären wohl Würfel und Countdown, da sie mehr Spannung erzeugen ohne den Ablauf zu sehr zu unterbrechen.
|
||||
|
||||
|
||||
|
||||
|
||||
wenn ich dates erfasse kann ich diese auch zu einer Verantstaltung machen,
|
||||
hier kann ich die auswählen, zu denen ich "Ich bin dabei" gedrückt habe, das
|
||||
Date wird dann auf den Standort und Zeitpunkt festgelegt. fragen?
|
||||
|
||||
@@ -71,11 +71,15 @@ public class SecurityConfig {
|
||||
.requestMatchers("/games/chastity/joinlock.html").authenticated()
|
||||
.requestMatchers("/community/benachrichtigungen.html").authenticated()
|
||||
.requestMatchers("/community/abonnements.html").authenticated()
|
||||
.requestMatchers("/community/locations.html").authenticated()
|
||||
.requestMatchers("/community/location-detail.html").authenticated()
|
||||
.requestMatchers("/community/events.html").authenticated()
|
||||
.requestMatchers("/community/event-detail.html").authenticated()
|
||||
.requestMatchers("/gruppen/**").authenticated()
|
||||
.requestMatchers("/feed/**").authenticated()
|
||||
.requestMatchers("/notifications/**").authenticated()
|
||||
.requestMatchers("/events/**").authenticated()
|
||||
.requestMatchers("/*.html").permitAll()
|
||||
.requestMatchers("/*.html").permitAll()
|
||||
.requestMatchers("/**/*.html").permitAll()
|
||||
.requestMatchers("/help/*.html").permitAll()
|
||||
.requestMatchers("/css/**").permitAll()
|
||||
|
||||
425
src/main/java/de/oaa/xxx/location/LocationController.java
Normal file
425
src/main/java/de/oaa/xxx/location/LocationController.java
Normal file
@@ -0,0 +1,425 @@
|
||||
package de.oaa.xxx.location;
|
||||
|
||||
import de.oaa.xxx.location.entity.*;
|
||||
import de.oaa.xxx.location.repository.*;
|
||||
import de.oaa.xxx.user.UserService;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/locations")
|
||||
public class LocationController {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(LocationController.class);
|
||||
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 final LocationOpeningHoursRepository hoursRepo;
|
||||
private final LocationEventRepository eventRepo;
|
||||
private final LocationEventAttendeeRepository attendeeRepo;
|
||||
private final LocationFollowRepository followRepo;
|
||||
private final UserService userService;
|
||||
|
||||
public LocationController(LocationRepository locationRepo,
|
||||
LocationImageRepository imageRepo,
|
||||
LocationOpeningHoursRepository hoursRepo,
|
||||
LocationEventRepository eventRepo,
|
||||
LocationEventAttendeeRepository attendeeRepo,
|
||||
LocationFollowRepository followRepo,
|
||||
UserService userService) {
|
||||
this.locationRepo = locationRepo;
|
||||
this.imageRepo = imageRepo;
|
||||
this.hoursRepo = hoursRepo;
|
||||
this.eventRepo = eventRepo;
|
||||
this.attendeeRepo = attendeeRepo;
|
||||
this.followRepo = followRepo;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
record IdsResult(List<UUID> ids, int total) {}
|
||||
|
||||
record LocationPreviewDto(UUID locationId, String name, String profilePictureLq, double distanzKm) {}
|
||||
|
||||
record OpeningHourDto(int dayOfWeek, String openTime, String closeTime, boolean closed) {}
|
||||
|
||||
record GalleryImageDto(UUID imageId, String imageData) {}
|
||||
|
||||
record LocationDetailDto(
|
||||
UUID locationId,
|
||||
UUID ownerId,
|
||||
String name,
|
||||
String description,
|
||||
String profilePictureHq,
|
||||
String profilePictureLq,
|
||||
Double lat,
|
||||
Double lon,
|
||||
String street,
|
||||
String city,
|
||||
boolean ownershipConfirmed,
|
||||
LocalDateTime createdAt,
|
||||
List<GalleryImageDto> gallery,
|
||||
List<OpeningHourDto> openingHours,
|
||||
boolean following
|
||||
) {}
|
||||
|
||||
record CreateRequest(
|
||||
String name,
|
||||
String description,
|
||||
String profilePictureLq,
|
||||
String profilePictureHq,
|
||||
Double lat,
|
||||
Double lon,
|
||||
String street,
|
||||
String city,
|
||||
boolean ownershipConfirmed
|
||||
) {}
|
||||
|
||||
record UpdateRequest(
|
||||
String name,
|
||||
String description,
|
||||
String profilePictureLq,
|
||||
String profilePictureHq,
|
||||
Double lat,
|
||||
Double lon,
|
||||
String street,
|
||||
String city
|
||||
) {}
|
||||
|
||||
record GalleryUploadRequest(String imageData) {}
|
||||
|
||||
// ── Suche / IDs ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gibt alle Location-IDs zurück, sortiert nach Entfernung vom angegebenen Punkt.
|
||||
*/
|
||||
@GetMapping("/ids")
|
||||
public ResponseEntity<IdsResult> getIds(
|
||||
@RequestParam double lat,
|
||||
@RequestParam double lon,
|
||||
@RequestParam(defaultValue = "50") int maxDistanceKm,
|
||||
Principal principal) {
|
||||
|
||||
userService.requireUser(principal);
|
||||
|
||||
List<UUID> sorted = locationRepo.findByLatIsNotNullAndLonIsNotNull().stream()
|
||||
.filter(l -> haversineKm(lat, lon, l.getLat(), l.getLon()) <= maxDistanceKm)
|
||||
.sorted(Comparator.comparingDouble(l -> haversineKm(lat, lon, l.getLat(), l.getLon())))
|
||||
.map(LocationEntity::getLocationId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(new IdsResult(sorted, sorted.size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-Laden von Location-Previews (Name + LQ-Bild + Distanz).
|
||||
* Benötigt lat/lon für die Distanzberechnung.
|
||||
*/
|
||||
@PostMapping("/batch")
|
||||
public ResponseEntity<List<LocationPreviewDto>> getBatch(
|
||||
@RequestBody BatchRequest request,
|
||||
Principal principal) {
|
||||
|
||||
userService.requireUser(principal);
|
||||
if (request.ids() == null || request.ids().isEmpty()) return ResponseEntity.ok(List.of());
|
||||
|
||||
List<UUID> ids = request.ids().stream().filter(Objects::nonNull).limit(MAX_BATCH_SIZE).toList();
|
||||
Map<UUID, LocationEntity> byId = locationRepo.findAllById(ids).stream()
|
||||
.collect(Collectors.toMap(LocationEntity::getLocationId, l -> l));
|
||||
|
||||
double refLat = request.lat() != null ? request.lat() : 0;
|
||||
double refLon = request.lon() != null ? request.lon() : 0;
|
||||
|
||||
List<LocationPreviewDto> result = ids.stream()
|
||||
.map(byId::get)
|
||||
.filter(Objects::nonNull)
|
||||
.map(l -> new LocationPreviewDto(
|
||||
l.getLocationId(),
|
||||
l.getName(),
|
||||
l.getProfilePictureLq(),
|
||||
l.getLat() != null && l.getLon() != null
|
||||
? Math.round(haversineKm(refLat, refLon, l.getLat(), l.getLon()) * 10.0) / 10.0
|
||||
: -1))
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
record BatchRequest(List<UUID> ids, Double lat, Double lon) {}
|
||||
|
||||
/**
|
||||
* Meine eigenen Locations (IDs, neueste zuerst).
|
||||
*/
|
||||
@GetMapping("/mine")
|
||||
public ResponseEntity<IdsResult> getMine(Principal principal) {
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
List<UUID> ids = locationRepo.findByOwnerIdOrderByCreatedAtDesc(myId).stream()
|
||||
.map(LocationEntity::getLocationId)
|
||||
.toList();
|
||||
return ResponseEntity.ok(new IdsResult(ids, ids.size()));
|
||||
}
|
||||
|
||||
// ── Detail ───────────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/{locationId}")
|
||||
public ResponseEntity<LocationDetailDto> getDetail(
|
||||
@PathVariable UUID locationId,
|
||||
Principal principal) {
|
||||
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
return locationRepo.findById(locationId)
|
||||
.map(l -> ResponseEntity.ok(toDetail(l, myId)))
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
// ── CRUD ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<LocationDetailDto> create(
|
||||
@RequestBody CreateRequest req,
|
||||
Principal principal) {
|
||||
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
|
||||
if (req.name() == null || req.name().isBlank()) return ResponseEntity.badRequest().build();
|
||||
|
||||
LocationEntity loc = new LocationEntity();
|
||||
loc.setLocationId(UUID.randomUUID());
|
||||
loc.setOwnerId(myId);
|
||||
loc.setName(req.name().trim());
|
||||
loc.setDescription(req.description() != null ? req.description().trim() : null);
|
||||
loc.setProfilePictureLq(req.profilePictureLq());
|
||||
loc.setProfilePictureHq(req.profilePictureHq());
|
||||
loc.setLat(req.lat());
|
||||
loc.setLon(req.lon());
|
||||
loc.setStreet(req.street());
|
||||
loc.setCity(req.city());
|
||||
loc.setOwnershipConfirmed(req.ownershipConfirmed());
|
||||
loc.setCreatedAt(LocalDateTime.now());
|
||||
locationRepo.save(loc);
|
||||
|
||||
LOGGER.info("User {} hat Location {} angelegt", myId, loc.getLocationId());
|
||||
return ResponseEntity.status(201).body(toDetail(loc, myId));
|
||||
}
|
||||
|
||||
@PutMapping("/{locationId}")
|
||||
public ResponseEntity<LocationDetailDto> update(
|
||||
@PathVariable UUID locationId,
|
||||
@RequestBody UpdateRequest req,
|
||||
Principal principal) {
|
||||
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
var opt = locationRepo.findById(locationId);
|
||||
if (opt.isEmpty()) return ResponseEntity.notFound().build();
|
||||
LocationEntity loc = opt.get();
|
||||
if (!loc.getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
|
||||
|
||||
if (req.name() != null && !req.name().isBlank()) loc.setName(req.name().trim());
|
||||
if (req.description() != null) loc.setDescription(req.description().trim());
|
||||
if (req.profilePictureLq() != null) loc.setProfilePictureLq(req.profilePictureLq());
|
||||
if (req.profilePictureHq() != null) loc.setProfilePictureHq(req.profilePictureHq());
|
||||
if (req.lat() != null) loc.setLat(req.lat());
|
||||
if (req.lon() != null) loc.setLon(req.lon());
|
||||
if (req.street() != null) loc.setStreet(req.street());
|
||||
if (req.city() != null) loc.setCity(req.city());
|
||||
locationRepo.save(loc);
|
||||
|
||||
return ResponseEntity.ok(toDetail(loc, myId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@DeleteMapping("/{locationId}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable UUID locationId,
|
||||
Principal principal) {
|
||||
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
var opt = locationRepo.findById(locationId);
|
||||
if (opt.isEmpty()) return ResponseEntity.notFound().build();
|
||||
if (!opt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
|
||||
|
||||
// Kaskade: Events + Attendees + Bilder + Öffnungszeiten + Follows löschen
|
||||
eventRepo.findByLocationIdOrderByStartAtAsc(locationId).forEach(e -> {
|
||||
attendeeRepo.deleteByEventId(e.getEventId());
|
||||
});
|
||||
eventRepo.deleteByLocationId(locationId);
|
||||
imageRepo.deleteByLocationId(locationId);
|
||||
hoursRepo.deleteByLocationId(locationId);
|
||||
followRepo.deleteByLocationId(locationId);
|
||||
locationRepo.deleteById(locationId);
|
||||
|
||||
LOGGER.info("User {} hat Location {} gelöscht", myId, locationId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ── Galerie ───────────────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/{locationId}/gallery")
|
||||
public ResponseEntity<GalleryImageDto> uploadGalleryImage(
|
||||
@PathVariable UUID locationId,
|
||||
@RequestBody GalleryUploadRequest req,
|
||||
Principal principal) {
|
||||
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
var opt = locationRepo.findById(locationId);
|
||||
if (opt.isEmpty()) return ResponseEntity.notFound().build();
|
||||
if (!opt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
|
||||
if (req.imageData() == null || req.imageData().isBlank()) return ResponseEntity.badRequest().build();
|
||||
if (imageRepo.countByLocationId(locationId) >= MAX_GALLERY_IMAGES) return ResponseEntity.status(422).build();
|
||||
|
||||
LocationImageEntity img = new LocationImageEntity();
|
||||
img.setImageId(UUID.randomUUID());
|
||||
img.setLocationId(locationId);
|
||||
img.setImageData(req.imageData());
|
||||
img.setUploadedAt(LocalDateTime.now());
|
||||
imageRepo.save(img);
|
||||
|
||||
return ResponseEntity.status(201).body(new GalleryImageDto(img.getImageId(), img.getImageData()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@DeleteMapping("/{locationId}/gallery/{imageId}")
|
||||
public ResponseEntity<Void> deleteGalleryImage(
|
||||
@PathVariable UUID locationId,
|
||||
@PathVariable UUID imageId,
|
||||
Principal principal) {
|
||||
|
||||
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();
|
||||
|
||||
var imgOpt = imageRepo.findById(imageId);
|
||||
if (imgOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||
if (!imgOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
|
||||
|
||||
imageRepo.delete(imgOpt.get());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ── Öffnungszeiten ────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/{locationId}/opening-hours")
|
||||
public ResponseEntity<List<OpeningHourDto>> getOpeningHours(
|
||||
@PathVariable UUID locationId,
|
||||
Principal principal) {
|
||||
|
||||
userService.requireUser(principal);
|
||||
if (!locationRepo.existsById(locationId)) return ResponseEntity.notFound().build();
|
||||
|
||||
List<OpeningHourDto> dtos = hoursRepo.findByLocationIdOrderByDayOfWeek(locationId).stream()
|
||||
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
|
||||
.toList();
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Öffnungszeiten einer Location vollständig neu (kompletter Ersatz).
|
||||
*/
|
||||
@Transactional
|
||||
@PutMapping("/{locationId}/opening-hours")
|
||||
public ResponseEntity<List<OpeningHourDto>> setOpeningHours(
|
||||
@PathVariable UUID locationId,
|
||||
@RequestBody List<OpeningHourDto> hours,
|
||||
Principal principal) {
|
||||
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
var opt = locationRepo.findById(locationId);
|
||||
if (opt.isEmpty()) return ResponseEntity.notFound().build();
|
||||
if (!opt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
|
||||
|
||||
hoursRepo.deleteByLocationId(locationId);
|
||||
|
||||
List<LocationOpeningHoursEntity> saved = new ArrayList<>();
|
||||
for (OpeningHourDto dto : hours) {
|
||||
if (dto.dayOfWeek() < 1 || dto.dayOfWeek() > 7) continue;
|
||||
LocationOpeningHoursEntity e = new LocationOpeningHoursEntity();
|
||||
e.setHoursId(UUID.randomUUID());
|
||||
e.setLocationId(locationId);
|
||||
e.setDayOfWeek(dto.dayOfWeek());
|
||||
e.setOpenTime(dto.openTime());
|
||||
e.setCloseTime(dto.closeTime());
|
||||
e.setClosed(dto.closed());
|
||||
saved.add(hoursRepo.save(e));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(saved.stream()
|
||||
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
// ── Follow ────────────────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/{locationId}/follow")
|
||||
public ResponseEntity<Map<String, Object>> toggleFollow(
|
||||
@PathVariable UUID locationId,
|
||||
Principal principal) {
|
||||
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
if (!locationRepo.existsById(locationId)) return ResponseEntity.notFound().build();
|
||||
|
||||
var existing = followRepo.findByUserIdAndLocationId(myId, locationId);
|
||||
boolean following;
|
||||
if (existing.isPresent()) {
|
||||
followRepo.delete(existing.get());
|
||||
following = false;
|
||||
} else {
|
||||
var f = new de.oaa.xxx.location.entity.LocationFollowEntity();
|
||||
f.setFollowId(UUID.randomUUID());
|
||||
f.setLocationId(locationId);
|
||||
f.setUserId(myId);
|
||||
f.setFollowedAt(LocalDateTime.now());
|
||||
followRepo.save(f);
|
||||
following = true;
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("following", following));
|
||||
}
|
||||
|
||||
@GetMapping("/followed")
|
||||
public ResponseEntity<IdsResult> getFollowed(Principal principal) {
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
List<UUID> ids = followRepo.findByUserId(myId).stream()
|
||||
.map(de.oaa.xxx.location.entity.LocationFollowEntity::getLocationId)
|
||||
.toList();
|
||||
return ResponseEntity.ok(new IdsResult(ids, ids.size()));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private LocationDetailDto toDetail(LocationEntity l, UUID myId) {
|
||||
List<GalleryImageDto> gallery = imageRepo.findByLocationIdOrderByUploadedAtAsc(l.getLocationId()).stream()
|
||||
.map(i -> new GalleryImageDto(i.getImageId(), i.getImageData()))
|
||||
.toList();
|
||||
List<OpeningHourDto> hours = hoursRepo.findByLocationIdOrderByDayOfWeek(l.getLocationId()).stream()
|
||||
.map(h -> new OpeningHourDto(h.getDayOfWeek(), h.getOpenTime(), h.getCloseTime(), h.isClosed()))
|
||||
.toList();
|
||||
boolean following = followRepo.findByUserIdAndLocationId(myId, l.getLocationId()).isPresent();
|
||||
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);
|
||||
}
|
||||
|
||||
static double haversineKm(double lat1, double lon1, double lat2, double lon2) {
|
||||
final double R = 6371.0;
|
||||
double dLat = Math.toRadians(lat2 - lat1);
|
||||
double dLon = Math.toRadians(lon2 - lon1);
|
||||
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
|
||||
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
|
||||
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
}
|
||||
384
src/main/java/de/oaa/xxx/location/LocationEventController.java
Normal file
384
src/main/java/de/oaa/xxx/location/LocationEventController.java
Normal file
@@ -0,0 +1,384 @@
|
||||
package de.oaa.xxx.location;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
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.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.user.UserEntity;
|
||||
import de.oaa.xxx.user.UserRepository;
|
||||
import de.oaa.xxx.user.UserService;
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
@RestController
|
||||
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 LocationEventAttendeeRepository attendeeRepo;
|
||||
private final LocationFollowRepository followRepo;
|
||||
private final UserRepository userRepo;
|
||||
private final UserService userService;
|
||||
|
||||
public LocationEventController(LocationRepository locationRepo,
|
||||
LocationEventRepository eventRepo,
|
||||
LocationEventAttendeeRepository attendeeRepo,
|
||||
LocationFollowRepository followRepo,
|
||||
UserRepository userRepo,
|
||||
UserService userService) {
|
||||
this.locationRepo = locationRepo;
|
||||
this.eventRepo = eventRepo;
|
||||
this.attendeeRepo = attendeeRepo;
|
||||
this.followRepo = followRepo;
|
||||
this.userRepo = userRepo;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
// ── DTOs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
record IdsResult(List<UUID> ids, int total) {}
|
||||
|
||||
record EventPreviewDto(
|
||||
UUID eventId,
|
||||
UUID locationId,
|
||||
String locationName,
|
||||
String title,
|
||||
String imageData,
|
||||
LocalDateTime startAt,
|
||||
double distanzKm,
|
||||
long attendeeCount,
|
||||
boolean attendingMe
|
||||
) {}
|
||||
|
||||
record AttendeeDto(UUID userId, String name, String profilePictureLq, String geschlecht) {}
|
||||
|
||||
record EventDetailDto(
|
||||
UUID eventId,
|
||||
UUID locationId,
|
||||
String locationName,
|
||||
String title,
|
||||
String description,
|
||||
String imageData,
|
||||
LocalDateTime startAt,
|
||||
LocalDateTime createdAt,
|
||||
boolean attendingMe,
|
||||
List<AttendeeDto> attendees
|
||||
) {}
|
||||
|
||||
record CreateEventRequest(String title, String description, String imageData, LocalDateTime startAt) {}
|
||||
|
||||
record UpdateEventRequest(String title, String description, String imageData, LocalDateTime startAt) {}
|
||||
|
||||
record BatchRequest(List<UUID> ids, Double lat, Double lon) {}
|
||||
|
||||
// ── Events einer Location ─────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/locations/{locationId}/events")
|
||||
public ResponseEntity<List<EventPreviewDto>> getEventsForLocation(
|
||||
@PathVariable UUID locationId,
|
||||
Principal principal) {
|
||||
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
if (!locationRepo.existsById(locationId)) return ResponseEntity.notFound().build();
|
||||
|
||||
var location = locationRepo.findById(locationId).orElseThrow();
|
||||
double refLat = location.getLat() != null ? location.getLat() : 0;
|
||||
double refLon = location.getLon() != null ? location.getLon() : 0;
|
||||
|
||||
List<EventPreviewDto> dtos = eventRepo.findByLocationIdOrderByStartAtAsc(locationId).stream()
|
||||
.map(e -> toPreview(e, location.getName(), refLat, refLon, myId))
|
||||
.toList();
|
||||
return ResponseEntity.ok(dtos);
|
||||
}
|
||||
|
||||
@PostMapping("/locations/{locationId}/events")
|
||||
public ResponseEntity<EventDetailDto> createEvent(
|
||||
@PathVariable UUID locationId,
|
||||
@RequestBody CreateEventRequest req,
|
||||
Principal principal) {
|
||||
|
||||
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 (req.title() == null || req.title().isBlank()) return ResponseEntity.badRequest().build();
|
||||
if (req.startAt() == null) return ResponseEntity.badRequest().build();
|
||||
|
||||
LocationEventEntity event = new LocationEventEntity();
|
||||
event.setEventId(UUID.randomUUID());
|
||||
event.setLocationId(locationId);
|
||||
event.setTitle(req.title().trim());
|
||||
event.setDescription(req.description() != null ? req.description().trim() : null);
|
||||
event.setImageData(req.imageData());
|
||||
event.setStartAt(req.startAt());
|
||||
event.setCreatedAt(LocalDateTime.now());
|
||||
eventRepo.save(event);
|
||||
|
||||
LOGGER.info("Location {} hat Event {} angelegt", locationId, event.getEventId());
|
||||
return ResponseEntity.status(201).body(toDetail(event, locOpt.get().getName(), myId));
|
||||
}
|
||||
|
||||
@PutMapping("/locations/{locationId}/events/{eventId}")
|
||||
public ResponseEntity<EventDetailDto> updateEvent(
|
||||
@PathVariable UUID locationId,
|
||||
@PathVariable UUID eventId,
|
||||
@RequestBody UpdateEventRequest req,
|
||||
Principal principal) {
|
||||
|
||||
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();
|
||||
|
||||
var evtOpt = eventRepo.findById(eventId);
|
||||
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||
if (!evtOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).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());
|
||||
eventRepo.save(event);
|
||||
|
||||
return ResponseEntity.ok(toDetail(event, locOpt.get().getName(), myId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@DeleteMapping("/locations/{locationId}/events/{eventId}")
|
||||
public ResponseEntity<Void> deleteEvent(
|
||||
@PathVariable UUID locationId,
|
||||
@PathVariable UUID eventId,
|
||||
Principal principal) {
|
||||
|
||||
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();
|
||||
|
||||
var evtOpt = eventRepo.findById(eventId);
|
||||
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||
if (!evtOpt.get().getLocationId().equals(locationId)) return ResponseEntity.status(400).build();
|
||||
|
||||
attendeeRepo.deleteByEventId(eventId);
|
||||
eventRepo.delete(evtOpt.get());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ── Event-Detail und Teilnahme ────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/location-events/{eventId}")
|
||||
public ResponseEntity<EventDetailDto> getEvent(
|
||||
@PathVariable UUID eventId,
|
||||
Principal principal) {
|
||||
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
var evtOpt = eventRepo.findById(eventId);
|
||||
if (evtOpt.isEmpty()) return ResponseEntity.notFound().build();
|
||||
|
||||
String locationName = locationRepo.findById(evtOpt.get().getLocationId())
|
||||
.map(l -> l.getName()).orElse("");
|
||||
return ResponseEntity.ok(toDetail(evtOpt.get(), locationName, myId));
|
||||
}
|
||||
|
||||
@PostMapping("/location-events/{eventId}/attend")
|
||||
public ResponseEntity<Map<String, Object>> toggleAttend(
|
||||
@PathVariable UUID eventId,
|
||||
Principal principal) {
|
||||
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
if (!eventRepo.existsById(eventId)) return ResponseEntity.notFound().build();
|
||||
|
||||
var existing = attendeeRepo.findByEventIdAndUserId(eventId, myId);
|
||||
boolean attending;
|
||||
if (existing.isPresent()) {
|
||||
attendeeRepo.delete(existing.get());
|
||||
attending = false;
|
||||
} else {
|
||||
LocationEventAttendeeEntity a = new LocationEventAttendeeEntity();
|
||||
a.setAttendeeId(UUID.randomUUID());
|
||||
a.setEventId(eventId);
|
||||
a.setUserId(myId);
|
||||
a.setRegisteredAt(LocalDateTime.now());
|
||||
attendeeRepo.save(a);
|
||||
attending = true;
|
||||
}
|
||||
long count = attendeeRepo.countByEventId(eventId);
|
||||
return ResponseEntity.ok(Map.of("attending", attending, "attendeeCount", count));
|
||||
}
|
||||
|
||||
// ── Event-Suche (IDs + Batch) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Liefert Event-IDs sortiert nach Datum (nächste zuerst).
|
||||
* Immer enthalten: Events von abonnierten Locations.
|
||||
* Optional: Events im Umkreis von lat/lon (wenn angegeben).
|
||||
*/
|
||||
@GetMapping("/location-events/ids")
|
||||
public ResponseEntity<IdsResult> searchIds(
|
||||
@RequestParam(required = false) Double lat,
|
||||
@RequestParam(required = false) Double lon,
|
||||
@RequestParam(defaultValue = "50") int maxDistanceKm,
|
||||
@RequestParam(required = false) String from,
|
||||
@RequestParam(required = false) String to,
|
||||
Principal principal) {
|
||||
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
|
||||
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
|
||||
Set<UUID> followedLocationIds = followRepo.findByUserId(myId).stream()
|
||||
.map(LocationFollowEntity::getLocationId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<UUID, double[]> locationCoords = new HashMap<>();
|
||||
List<UUID> sorted = eventRepo.findInTimeRange(fromDt, toDt).stream()
|
||||
.filter(e -> {
|
||||
// Immer einschließen wenn Location abonniert
|
||||
if (followedLocationIds.contains(e.getLocationId())) return true;
|
||||
// Ohne Koordinaten kein Umkreis-Filter
|
||||
if (lat == null || lon == null) return false;
|
||||
double[] coords = locationCoords.computeIfAbsent(e.getLocationId(), locId ->
|
||||
locationRepo.findById(locId)
|
||||
.filter(l -> l.getLat() != null && l.getLon() != null)
|
||||
.map(l -> new double[]{l.getLat(), l.getLon()})
|
||||
.orElse(null));
|
||||
if (coords == null) return false;
|
||||
return LocationController.haversineKm(lat, lon, coords[0], coords[1]) <= maxDistanceKm;
|
||||
})
|
||||
.sorted(Comparator.comparing(LocationEventEntity::getStartAt))
|
||||
.map(LocationEventEntity::getEventId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(new IdsResult(sorted, sorted.size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-Laden von Event-Previews.
|
||||
*/
|
||||
@PostMapping("/location-events/batch")
|
||||
public ResponseEntity<List<EventPreviewDto>> getBatch(
|
||||
@RequestBody BatchRequest request,
|
||||
Principal principal) {
|
||||
|
||||
UUID myId = userService.requireUser(principal).getUserId();
|
||||
if (request.ids() == null || request.ids().isEmpty()) return ResponseEntity.ok(List.of());
|
||||
|
||||
List<UUID> ids = request.ids().stream().filter(Objects::nonNull).limit(MAX_BATCH_SIZE).toList();
|
||||
Map<UUID, LocationEventEntity> byId = eventRepo.findAllById(ids).stream()
|
||||
.collect(Collectors.toMap(LocationEventEntity::getEventId, Function.identity()));
|
||||
|
||||
double refLat = request.lat() != null ? request.lat() : 0;
|
||||
double refLon = request.lon() != null ? request.lon() : 0;
|
||||
|
||||
// Locations für Namens-Lookup und Distanzberechnung
|
||||
Set<UUID> locationIds = byId.values().stream()
|
||||
.map(LocationEventEntity::getLocationId).collect(Collectors.toSet());
|
||||
Map<UUID, de.oaa.xxx.location.entity.LocationEntity> locationById =
|
||||
locationRepo.findAllById(locationIds).stream()
|
||||
.collect(Collectors.toMap(
|
||||
de.oaa.xxx.location.entity.LocationEntity::getLocationId, Function.identity()));
|
||||
|
||||
List<EventPreviewDto> result = ids.stream()
|
||||
.map(byId::get)
|
||||
.filter(Objects::nonNull)
|
||||
.map(e -> {
|
||||
var loc = locationById.get(e.getLocationId());
|
||||
String locName = loc != null ? loc.getName() : "";
|
||||
double dist = (loc != null && loc.getLat() != null && loc.getLon() != null)
|
||||
? Math.round(LocationController.haversineKm(refLat, refLon, loc.getLat(), loc.getLon()) * 10.0) / 10.0
|
||||
: -1;
|
||||
return toPreview(e, locName, refLat, refLon, myId);
|
||||
})
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private EventPreviewDto toPreview(LocationEventEntity e, String locationName,
|
||||
double refLat, double refLon, UUID myId) {
|
||||
var loc = locationRepo.findById(e.getLocationId()).orElse(null);
|
||||
double dist = (loc != null && loc.getLat() != null && loc.getLon() != null)
|
||||
? Math.round(LocationController.haversineKm(refLat, refLon, loc.getLat(), loc.getLon()) * 10.0) / 10.0
|
||||
: -1;
|
||||
long count = attendeeRepo.countByEventId(e.getEventId());
|
||||
boolean mine = attendeeRepo.findByEventIdAndUserId(e.getEventId(), myId).isPresent();
|
||||
return new EventPreviewDto(e.getEventId(), e.getLocationId(), locationName,
|
||||
e.getTitle(), e.getImageData(), e.getStartAt(), dist, count, mine);
|
||||
}
|
||||
|
||||
private EventDetailDto toDetail(LocationEventEntity e, String locationName, UUID myId) {
|
||||
List<LocationEventAttendeeEntity> attendeeEntities =
|
||||
attendeeRepo.findByEventIdOrderByRegisteredAtAsc(e.getEventId());
|
||||
|
||||
Set<UUID> attendeeUserIds = attendeeEntities.stream()
|
||||
.map(LocationEventAttendeeEntity::getUserId).collect(Collectors.toSet());
|
||||
Map<UUID, UserEntity> userById = attendeeUserIds.isEmpty()
|
||||
? Map.of()
|
||||
: userRepo.findAllById(attendeeUserIds).stream()
|
||||
.collect(Collectors.toMap(UserEntity::getUserId, Function.identity()));
|
||||
|
||||
// Sortierung: Alphabetisch nach Geschlecht-Name, dann nach Registrierungszeit
|
||||
List<AttendeeDto> attendees = attendeeEntities.stream()
|
||||
.map(a -> {
|
||||
UserEntity u = userById.get(a.getUserId());
|
||||
if (u == null) return null;
|
||||
String geschlecht = u.getGeschlecht() != null ? u.getGeschlecht().name() : "UNBEKANNT";
|
||||
return new AttendeeDto(u.getUserId(), u.getName(), u.getProfilePicture(), geschlecht);
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.sorted(Comparator
|
||||
.comparing((AttendeeDto a) -> {
|
||||
// Sortierreihenfolge: WEIBLICH → MAENNLICH → DIVERS → rest
|
||||
return switch (a.geschlecht()) {
|
||||
case "WEIBLICH" -> 0;
|
||||
case "MAENNLICH" -> 1;
|
||||
case "DIVERS" -> 2;
|
||||
default -> 3;
|
||||
};
|
||||
})
|
||||
.thenComparing(AttendeeDto::name))
|
||||
.toList();
|
||||
|
||||
boolean attendingMe = attendeeRepo.findByEventIdAndUserId(e.getEventId(), myId).isPresent();
|
||||
|
||||
return new EventDetailDto(
|
||||
e.getEventId(), e.getLocationId(), locationName,
|
||||
e.getTitle(), e.getDescription(), e.getImageData(),
|
||||
e.getStartAt(), e.getCreatedAt(),
|
||||
attendingMe, attendees);
|
||||
}
|
||||
}
|
||||
55
src/main/java/de/oaa/xxx/location/entity/LocationEntity.java
Normal file
55
src/main/java/de/oaa/xxx/location/entity/LocationEntity.java
Normal file
@@ -0,0 +1,55 @@
|
||||
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")
|
||||
public class LocationEntity {
|
||||
|
||||
@Id
|
||||
@Column
|
||||
private UUID locationId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID ownerId;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
/** LQ-Profilbild (klein, für Listenansicht) – base64 */
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String profilePictureLq;
|
||||
|
||||
/** HQ-Profilbild (max 1024×1024) – base64 */
|
||||
@Column(columnDefinition = "MEDIUMTEXT")
|
||||
private String profilePictureHq;
|
||||
|
||||
@Column
|
||||
private Double lat;
|
||||
|
||||
@Column
|
||||
private Double lon;
|
||||
|
||||
@Column(length = 200)
|
||||
private String street;
|
||||
|
||||
@Column(length = 200)
|
||||
private String city;
|
||||
|
||||
/** Eigentümerschaft wurde vom Anleger bestätigt */
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private boolean ownershipConfirmed = false;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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_event_attendee",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"event_id", "user_id"}))
|
||||
public class LocationEventAttendeeEntity {
|
||||
|
||||
@Id
|
||||
@Column
|
||||
private UUID attendeeId;
|
||||
|
||||
@Column(nullable = false, name = "event_id")
|
||||
private UUID eventId;
|
||||
|
||||
@Column(nullable = false, name = "user_id")
|
||||
private UUID userId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime registeredAt;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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_event")
|
||||
public class LocationEventEntity {
|
||||
|
||||
@Id
|
||||
@Column
|
||||
private UUID eventId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID locationId;
|
||||
|
||||
@Column(nullable = false, length = 200)
|
||||
private String title;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
/** base64-Profilbild der Veranstaltung, max 1024px */
|
||||
@Column(columnDefinition = "MEDIUMTEXT")
|
||||
private String imageData;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime startAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -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_follow")
|
||||
public class LocationFollowEntity {
|
||||
|
||||
@Id
|
||||
@Column
|
||||
private UUID followId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID locationId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime followedAt;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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_image")
|
||||
public class LocationImageEntity {
|
||||
|
||||
@Id
|
||||
@Column
|
||||
private UUID imageId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private UUID locationId;
|
||||
|
||||
/** base64-kodiertes Bild, max 1024px */
|
||||
@Column(nullable = false, columnDefinition = "MEDIUMTEXT")
|
||||
private String imageData;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime uploadedAt;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.oaa.xxx.location.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "location_opening_hours",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"location_id", "day_of_week"}))
|
||||
public class LocationOpeningHoursEntity {
|
||||
|
||||
@Id
|
||||
@Column
|
||||
private UUID hoursId;
|
||||
|
||||
@Column(nullable = false, name = "location_id")
|
||||
private UUID locationId;
|
||||
|
||||
/**
|
||||
* Wochentag: 1 = Montag, 2 = Dienstag, ..., 7 = Sonntag
|
||||
*/
|
||||
@Column(nullable = false, name = "day_of_week")
|
||||
private int dayOfWeek;
|
||||
|
||||
/** Öffnungszeit als "HH:mm"-String, null = kein Eintrag */
|
||||
@Column(length = 5)
|
||||
private String openTime;
|
||||
|
||||
/** Schließzeit als "HH:mm"-String, null = kein Eintrag */
|
||||
@Column(length = 5)
|
||||
private String closeTime;
|
||||
|
||||
/** true = an diesem Tag geschlossen */
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private boolean closed = false;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.oaa.xxx.location.repository;
|
||||
|
||||
import de.oaa.xxx.location.entity.LocationEventAttendeeEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface LocationEventAttendeeRepository extends JpaRepository<LocationEventAttendeeEntity, UUID> {
|
||||
|
||||
List<LocationEventAttendeeEntity> findByEventIdOrderByRegisteredAtAsc(UUID eventId);
|
||||
|
||||
Optional<LocationEventAttendeeEntity> findByEventIdAndUserId(UUID eventId, UUID userId);
|
||||
|
||||
long countByEventId(UUID eventId);
|
||||
|
||||
void deleteByEventId(UUID eventId);
|
||||
|
||||
void deleteByUserId(UUID userId);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface LocationEventRepository extends JpaRepository<LocationEventEntity, UUID> {
|
||||
|
||||
List<LocationEventEntity> findByLocationIdOrderByStartAtAsc(UUID locationId);
|
||||
|
||||
/** Alle zukünftigen Events mit Koordinaten ihrer Location (für Umkreis-Suche) */
|
||||
@Query("""
|
||||
SELECT e FROM LocationEventEntity e
|
||||
WHERE e.startAt >= :from
|
||||
AND e.startAt <= :to
|
||||
""")
|
||||
List<LocationEventEntity> findInTimeRange(LocalDateTime from, LocalDateTime to);
|
||||
|
||||
void deleteByLocationId(UUID locationId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.oaa.xxx.location.repository;
|
||||
|
||||
import de.oaa.xxx.location.entity.LocationFollowEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface LocationFollowRepository extends JpaRepository<LocationFollowEntity, UUID> {
|
||||
Optional<LocationFollowEntity> findByUserIdAndLocationId(UUID userId, UUID locationId);
|
||||
List<LocationFollowEntity> findByUserId(UUID userId);
|
||||
void deleteByLocationId(UUID locationId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.oaa.xxx.location.repository;
|
||||
|
||||
import de.oaa.xxx.location.entity.LocationImageEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface LocationImageRepository extends JpaRepository<LocationImageEntity, UUID> {
|
||||
|
||||
List<LocationImageEntity> findByLocationIdOrderByUploadedAtAsc(UUID locationId);
|
||||
|
||||
long countByLocationId(UUID locationId);
|
||||
|
||||
void deleteByLocationId(UUID locationId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.oaa.xxx.location.repository;
|
||||
|
||||
import de.oaa.xxx.location.entity.LocationOpeningHoursEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface LocationOpeningHoursRepository extends JpaRepository<LocationOpeningHoursEntity, UUID> {
|
||||
|
||||
List<LocationOpeningHoursEntity> findByLocationIdOrderByDayOfWeek(UUID locationId);
|
||||
|
||||
void deleteByLocationId(UUID locationId);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.oaa.xxx.location.repository;
|
||||
|
||||
import de.oaa.xxx.location.entity.LocationEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface LocationRepository extends JpaRepository<LocationEntity, UUID> {
|
||||
|
||||
List<LocationEntity> findByOwnerIdOrderByCreatedAtDesc(UUID ownerId);
|
||||
|
||||
/** Alle Locations mit gesetzten Koordinaten (für Umkreissuche) */
|
||||
List<LocationEntity> findByLatIsNotNullAndLonIsNotNull();
|
||||
}
|
||||
@@ -33,6 +33,10 @@ public class User {
|
||||
private Integer datingMaxDistanzKm;
|
||||
private Integer datingMinAlter;
|
||||
private Integer datingMaxAlter;
|
||||
private String filterCity;
|
||||
private Double filterLat;
|
||||
private Double filterLon;
|
||||
private Integer filterMaxDistKm;
|
||||
|
||||
public Integer getAlter() {
|
||||
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;
|
||||
|
||||
@@ -500,4 +500,17 @@ public class UserController {
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
record LocationFilterRequest(String filterCity, Double filterLat, Double filterLon, Integer filterMaxDistKm) {}
|
||||
|
||||
@PutMapping("/me/location-filter")
|
||||
public ResponseEntity<Void> updateLocationFilter(@RequestBody LocationFilterRequest request, Principal principal) {
|
||||
var user = userService.requireUser(principal);
|
||||
if (request.filterCity() != null) user.setFilterCity(request.filterCity());
|
||||
if (request.filterLat() != null) user.setFilterLat(request.filterLat());
|
||||
if (request.filterLon() != null) user.setFilterLon(request.filterLon());
|
||||
if (request.filterMaxDistKm() != null) user.setFilterMaxDistKm(Math.max(1, Math.min(500, request.filterMaxDistKm())));
|
||||
userRepository.save(user);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,19 @@ public class UserEntity {
|
||||
@Column
|
||||
private Integer datingMaxAlter;
|
||||
|
||||
// ── Locations/Events-Filter ──
|
||||
@Column(length = 200)
|
||||
private String filterCity;
|
||||
|
||||
@Column
|
||||
private Double filterLat;
|
||||
|
||||
@Column
|
||||
private Double filterLon;
|
||||
|
||||
@Column
|
||||
private Integer filterMaxDistKm;
|
||||
|
||||
public Integer getAlter() {
|
||||
return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null;
|
||||
}
|
||||
@@ -167,6 +180,10 @@ public class UserEntity {
|
||||
user.setDatingMaxDistanzKm(datingMaxDistanzKm);
|
||||
user.setDatingMinAlter(datingMinAlter);
|
||||
user.setDatingMaxAlter(datingMaxAlter);
|
||||
user.setFilterCity(filterCity);
|
||||
user.setFilterLat(filterLat);
|
||||
user.setFilterLon(filterLon);
|
||||
user.setFilterMaxDistKm(filterMaxDistKm);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
177
src/main/resources/static/community/event-detail.html
Normal file
177
src/main/resources/static/community/event-detail.html
Normal file
@@ -0,0 +1,177 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Veranstaltung – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.back-link { display:inline-flex; align-items:center; gap:0.35rem; color:var(--color-muted); font-size:0.88rem; text-decoration:none; margin-bottom:1rem; }
|
||||
.back-link:hover { color:var(--color-primary); }
|
||||
|
||||
.evt-header { display:flex; gap:1rem; align-items:flex-start; margin-bottom:1.25rem; flex-wrap:wrap; }
|
||||
.evt-img { 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:3rem; overflow:hidden; border:2px solid var(--color-secondary); }
|
||||
.evt-img img { width:100%; height:100%; object-fit:cover; }
|
||||
.evt-meta { flex:1; min-width:0; }
|
||||
.evt-title { font-size:1.4rem; font-weight:700; margin:0 0 0.3rem; }
|
||||
.evt-location { color:var(--color-muted); font-size:0.88rem; margin-bottom:0.2rem; }
|
||||
.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; }
|
||||
|
||||
.section-title { font-size:1rem; font-weight:700; margin:1.5rem 0 0.75rem; }
|
||||
.gender-group { margin-bottom:1.25rem; }
|
||||
.gender-label { font-size:0.78rem; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; color:var(--color-muted); margin-bottom:0.5rem; }
|
||||
.attendee-list { display:flex; flex-wrap:wrap; gap:0.6rem; }
|
||||
.attendee-chip { display:flex; align-items:center; gap:0.5rem; background:var(--color-card); border:1px solid var(--color-secondary); border-radius:20px; padding:0.3rem 0.6rem 0.3rem 0.3rem; text-decoration:none; color:inherit; transition:border-color 0.15s; font-size:0.85rem; }
|
||||
.attendee-chip:hover { border-color:var(--color-primary); }
|
||||
.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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main">
|
||||
<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>
|
||||
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const eventId = params.get('id');
|
||||
let myUserId = null;
|
||||
|
||||
function escHtml(s) {
|
||||
return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function formatDate(dt) {
|
||||
if (!dt) return '';
|
||||
const d = new Date(dt);
|
||||
return d.toLocaleDateString('de-DE', { weekday:'long', day:'2-digit', month:'long', year:'numeric' })
|
||||
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
|
||||
}
|
||||
|
||||
const GENDER_LABELS = {
|
||||
WEIBLICH: 'Frauen',
|
||||
MAENNLICH: 'Männer',
|
||||
DIVERS: 'Divers',
|
||||
UNBEKANNT: 'Sonstiges'
|
||||
};
|
||||
|
||||
async function loadPage() {
|
||||
if (!eventId) { document.getElementById('content').innerHTML = '<p>Keine Event-ID angegeben.</p>'; return; }
|
||||
|
||||
const [meRes, evtRes] = await Promise.all([
|
||||
fetch('/login/me'),
|
||||
fetch(`/location-events/${eventId}`)
|
||||
]);
|
||||
|
||||
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();
|
||||
|
||||
// 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'}`;
|
||||
}
|
||||
document.title = `${evt.title} – xXx Sphere`;
|
||||
|
||||
renderPage(evt);
|
||||
}
|
||||
|
||||
function renderPage(evt) {
|
||||
const imgHtml = evt.imageData
|
||||
? `<img src="data:image/jpeg;base64,${evt.imageData}" alt="${escHtml(evt.title)}">`
|
||||
: '🗓';
|
||||
|
||||
// Teilnehmende nach Geschlecht gruppieren
|
||||
const byGender = {};
|
||||
(evt.attendees || []).forEach(a => {
|
||||
const g = a.geschlecht || 'UNBEKANNT';
|
||||
if (!byGender[g]) byGender[g] = [];
|
||||
byGender[g].push(a);
|
||||
});
|
||||
|
||||
const genderOrder = ['WEIBLICH', 'MAENNLICH', 'DIVERS', 'UNBEKANNT'];
|
||||
const attendeesHtml = genderOrder
|
||||
.filter(g => byGender[g] && byGender[g].length > 0)
|
||||
.map(g => {
|
||||
const chips = byGender[g].map(a => {
|
||||
const avatarHtml = a.profilePictureLq
|
||||
? `<img src="data:image/jpeg;base64,${a.profilePictureLq}" alt="${escHtml(a.name)}">`
|
||||
: a.name.charAt(0).toUpperCase();
|
||||
return `<a class="attendee-chip" href="/community/benutzer.html?userId=${a.userId}">
|
||||
<div class="attendee-avatar">${avatarHtml}</div>
|
||||
${escHtml(a.name)}
|
||||
</a>`;
|
||||
}).join('');
|
||||
return `<div class="gender-group">
|
||||
<div class="gender-label">${GENDER_LABELS[g] || g} <span class="count-badge">${byGender[g].length}</span></div>
|
||||
<div class="attendee-list">${chips}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const totalAttendees = (evt.attendees || []).length;
|
||||
const attending = evt.attendingMe;
|
||||
|
||||
document.getElementById('content').innerHTML = `
|
||||
<div class="evt-header">
|
||||
<div class="evt-img">${imgHtml}</div>
|
||||
<div class="evt-meta">
|
||||
<div class="evt-title">${escHtml(evt.title)}</div>
|
||||
${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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${totalAttendees > 0 ? `
|
||||
<div class="section-title">Teilnehmende</div>
|
||||
${attendeesHtml}
|
||||
` : '<p style="color:var(--color-muted);font-size:0.9rem;margin-top:1rem;">Noch keine Teilnehmenden.</p>'}
|
||||
`;
|
||||
}
|
||||
|
||||
async function toggleAttend() {
|
||||
const res = await fetch(`/location-events/${eventId}/attend`, { method: 'POST' });
|
||||
if (!res.ok) { alert('Fehler beim Aktualisieren.'); return; }
|
||||
const data = await res.json();
|
||||
|
||||
const btn = document.getElementById('attendBtn');
|
||||
const countEl = document.getElementById('attendCount');
|
||||
if (btn) {
|
||||
btn.textContent = data.attending ? '✓ Ich bin dabei' : '+ Ich bin dabei';
|
||||
btn.style.background = data.attending ? 'var(--color-secondary)' : '';
|
||||
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()); }
|
||||
}
|
||||
|
||||
loadPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
582
src/main/resources/static/community/events.html
Normal file
582
src/main/resources/static/community/events.html
Normal file
@@ -0,0 +1,582 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Veranstaltungen – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
/* ── Tab-Bar ── */
|
||||
.events-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.events-tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
padding: 0.6rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
cursor: default;
|
||||
margin-bottom: -1px;
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
.filter-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Filter-Drawer ── */
|
||||
.filter-overlay-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.55);
|
||||
z-index: 200;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.filter-overlay-bg.open { opacity: 1; pointer-events: all; }
|
||||
|
||||
.filter-drawer {
|
||||
position: fixed;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
width: min(320px, 92vw);
|
||||
background: var(--color-card);
|
||||
border-left: 1px solid var(--color-secondary);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.filter-drawer.open { transform: translateX(0); }
|
||||
|
||||
.filter-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-drawer-header h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
margin: 0;
|
||||
}
|
||||
.filter-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.filter-close-btn:hover { background: var(--color-secondary); color: var(--color-text); }
|
||||
|
||||
.filter-drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.filter-drawer-footer {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid var(--color-secondary);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.apply-btn { flex: 1; padding: 0.65rem; font-size: 0.9rem; }
|
||||
.reset-btn {
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-muted);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.reset-btn:hover { color: var(--color-text); }
|
||||
|
||||
/* ── Filter-Elemente ── */
|
||||
.filter-group { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.filter-group > label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.range-val { color: var(--color-text); }
|
||||
.filter-group input[type="range"] {
|
||||
padding: 0; background: none; border: none;
|
||||
accent-color: var(--color-primary); width: 100%;
|
||||
}
|
||||
|
||||
.suggest-list {
|
||||
position: absolute; top: 100%; left: 0; right: 0;
|
||||
background: var(--color-card); border: 1px solid var(--color-secondary);
|
||||
border-top: none; border-radius: 0 0 6px 6px;
|
||||
z-index: 210; display: none; list-style: none;
|
||||
margin: 0; padding: 0; max-height: 200px; overflow-y: auto;
|
||||
}
|
||||
|
||||
.filter-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted);
|
||||
background: rgba(var(--color-primary-rgb,180,0,60),.07);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ── Event-Liste ── */
|
||||
.result-count { font-size: 0.85rem; color: var(--color-muted); margin-bottom: 0.75rem; }
|
||||
|
||||
.event-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.event-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 10px;
|
||||
display: flex; gap: 0.75rem; padding: 0.75rem;
|
||||
text-decoration: none; color: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.event-card:hover { border-color: var(--color-primary); }
|
||||
.event-card-img {
|
||||
width: 72px; height: 72px; border-radius: 8px;
|
||||
background: var(--color-secondary); flex-shrink: 0;
|
||||
overflow: hidden; display: flex; align-items: center;
|
||||
justify-content: center; font-size: 1.6rem;
|
||||
}
|
||||
.event-card-img img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.event-card-body { flex: 1; min-width: 0; }
|
||||
.event-card-title { font-weight: 600; font-size: 0.95rem; margin: 0 0 0.2rem; }
|
||||
.event-card-sub { font-size: 0.78rem; color: var(--color-muted); margin-bottom: 0.1rem; }
|
||||
.event-card-dist { font-size: 0.78rem; color: var(--color-muted); }
|
||||
.attend-tag {
|
||||
display: inline-block; font-size: 0.72rem;
|
||||
background: rgba(var(--color-primary-rgb,180,0,60),.12);
|
||||
color: var(--color-primary); border-radius: 4px;
|
||||
padding: 0.1rem 0.4rem; margin-left: 0.4rem;
|
||||
}
|
||||
.follow-tag {
|
||||
display: inline-block; font-size: 0.72rem;
|
||||
background: rgba(var(--color-primary-rgb,180,0,60),.08);
|
||||
color: var(--color-muted); border-radius: 4px;
|
||||
padding: 0.1rem 0.4rem; margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.event-card-skeleton {
|
||||
height: 86px; background: var(--color-secondary);
|
||||
border-radius: 10px; animation: pulse 1.4s infinite;
|
||||
}
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
|
||||
|
||||
.empty-state {
|
||||
text-align: center; padding: 3rem 1rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.empty-state .icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
|
||||
|
||||
.sentinel { height: 1px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app">
|
||||
|
||||
<!-- ── Autocomplete (außerhalb des transformierten Drawers) ── -->
|
||||
<ul id="citySuggestions" 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>
|
||||
|
||||
<!-- ── Filter-Overlay ── -->
|
||||
<div class="filter-overlay-bg" id="filterBg"></div>
|
||||
<div class="filter-drawer" id="filterDrawer">
|
||||
<div class="filter-drawer-header">
|
||||
<h2>Filter</h2>
|
||||
<button class="filter-close-btn" id="filterCloseBtn" aria-label="Filter schließen">✕</button>
|
||||
</div>
|
||||
<div class="filter-drawer-body">
|
||||
<div class="filter-hint">
|
||||
Veranstaltungen von abonnierten Locations werden immer angezeigt, unabhängig vom Umkreis.
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Ort</label>
|
||||
<div style="position:relative;" id="cityRow">
|
||||
<input type="text" id="filterCity" placeholder="Stadt suchen und auswählen…" autocomplete="off"
|
||||
style="padding-right:2rem;" oninput="onCityInput()">
|
||||
<button id="filterCityClear" onclick="clearCity()" title="Auswahl aufheben"
|
||||
style="display:none;position:absolute;right:0.4rem;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--color-muted);font-size:1.1rem;cursor:pointer;padding:0.1rem 0.3rem;margin:0;width:auto;line-height:1;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>
|
||||
Umkreis
|
||||
<span class="range-val" id="distVal">50 km</span>
|
||||
</label>
|
||||
<input type="range" id="filterRadius" min="5" max="250" step="5" value="50"
|
||||
oninput="document.getElementById('distVal').textContent = this.value + ' km'">
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-drawer-footer">
|
||||
<button class="btn apply-btn" id="applyBtn">Anwenden</button>
|
||||
<button class="reset-btn" id="resetBtn">Zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<div class="events-tabs">
|
||||
<button class="events-tab-btn">Veranstaltungen</button>
|
||||
<div style="margin-left:auto;display:flex;align-items:center;gap:0.4rem;padding-bottom:1px;">
|
||||
<button id="filterOpenBtn" title="Filter"
|
||||
style="display:flex;align-items:center;justify-content:center;position:relative;width:2rem;height:2rem;border-radius:50%;background:var(--color-secondary);color:var(--color-muted);border:none;cursor:pointer;transition:background 0.15s,color 0.15s;padding:0;"
|
||||
onmouseover="this.style.background='var(--color-primary)';this.style.color='#fff';"
|
||||
onmouseout="this.style.background='var(--color-secondary)';this.style.color='var(--color-muted)';">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="11" y1="18" x2="13" y2="18"/>
|
||||
</svg>
|
||||
<span class="filter-badge" id="filterBadge" style="display:none;position:absolute;top:-3px;right:-3px;"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resultCount" class="result-count" style="display:none;"></div>
|
||||
<div class="event-list" id="eventList"></div>
|
||||
<p class="empty-state" id="emptyState" style="display:none;">
|
||||
<span class="icon">🗓</span><br>
|
||||
Keine Veranstaltungen gefunden.<br>
|
||||
<span style="font-size:0.85rem;">Passe den Filter an oder abonniere Locations.</span>
|
||||
</p>
|
||||
<div class="sentinel" id="sentinel"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
const BATCH = 20;
|
||||
let state = { allIds: [], loaded: 0, loading: false, lat: null, lon: null };
|
||||
|
||||
// Gespeicherter Filter aus Datenbank
|
||||
let savedCity = null;
|
||||
let savedLat = null;
|
||||
let savedLon = null;
|
||||
let savedRadius = 50;
|
||||
|
||||
// Aktuell angewendeter Filter (für Batch-Requests)
|
||||
let activeLat = null;
|
||||
let activeLon = null;
|
||||
let activeRadius = 50;
|
||||
|
||||
// Temporärer Eingabe-Zustand im Drawer
|
||||
let _inputLat = null;
|
||||
let _inputLon = null;
|
||||
let _cityTimer = null;
|
||||
|
||||
// Abonnierte Location-IDs (für follow-Tag in der Liste)
|
||||
let followedLocationIds = new Set();
|
||||
|
||||
// ── Auth & Init ───────────────────────────────────────────────────────────────
|
||||
fetch('/login/me').then(r => {
|
||||
if (r.status === 401) { window.location.href = '/login.html'; return null; }
|
||||
return r.ok ? r.json() : null;
|
||||
}).then(async user => {
|
||||
if (!user) return;
|
||||
|
||||
// Gespeicherten Filter laden
|
||||
if (user.filterCity) {
|
||||
savedCity = user.filterCity;
|
||||
savedLat = user.filterLat;
|
||||
savedLon = user.filterLon;
|
||||
savedRadius = user.filterMaxDistKm || 50;
|
||||
|
||||
document.getElementById('filterCity').value = savedCity;
|
||||
document.getElementById('filterCity').readOnly = true;
|
||||
document.getElementById('filterCityClear').style.display = '';
|
||||
document.getElementById('filterRadius').value = savedRadius;
|
||||
document.getElementById('distVal').textContent = savedRadius + ' km';
|
||||
_inputLat = savedLat;
|
||||
_inputLon = savedLon;
|
||||
}
|
||||
|
||||
// Abonnierte Locations laden
|
||||
try {
|
||||
const followRes = await fetch('/locations/followed');
|
||||
if (followRes.ok) {
|
||||
const followData = await followRes.json();
|
||||
followedLocationIds = new Set(followData.ids || []);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
updateFilterBadge();
|
||||
loadIds();
|
||||
}).catch(() => {});
|
||||
|
||||
// ── Filter-Drawer öffnen/schließen ───────────────────────────────────────────
|
||||
const filterDrawer = document.getElementById('filterDrawer');
|
||||
const filterBg = document.getElementById('filterBg');
|
||||
|
||||
function openFilter() {
|
||||
filterDrawer.classList.add('open');
|
||||
filterBg.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
function closeFilter() {
|
||||
filterDrawer.classList.remove('open');
|
||||
filterBg.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
document.getElementById('filterOpenBtn').addEventListener('click', openFilter);
|
||||
document.getElementById('filterCloseBtn').addEventListener('click', closeFilter);
|
||||
filterBg.addEventListener('click', closeFilter);
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && filterDrawer.classList.contains('open')) closeFilter();
|
||||
});
|
||||
|
||||
// ── Ort-Autocomplete ─────────────────────────────────────────────────────────
|
||||
function onCityInput() {
|
||||
const q = document.getElementById('filterCity').value.trim();
|
||||
_inputLat = null; _inputLon = null;
|
||||
document.getElementById('filterCityClear').style.display = 'none';
|
||||
clearTimeout(_cityTimer);
|
||||
if (q.length < 2) { document.getElementById('citySuggestions').style.display = 'none'; return; }
|
||||
_cityTimer = setTimeout(() => fetchCitySuggestions(q), 300);
|
||||
}
|
||||
|
||||
async function fetchCitySuggestions(q) {
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&addressdetails=1&limit=5&featuretype=city`;
|
||||
const res = await fetch(url, { headers: { 'Accept-Language': 'de' } });
|
||||
if (!res.ok) return;
|
||||
const results = await res.json();
|
||||
const ul = document.getElementById('citySuggestions');
|
||||
if (!results.length) { ul.style.display = 'none'; return; }
|
||||
ul.innerHTML = results.map(r => {
|
||||
const city = r.address.city || r.address.town || r.address.village || r.address.county || r.name;
|
||||
const country = r.address.country || '';
|
||||
const label = city + (country ? ', ' + country : '');
|
||||
return `<li style="padding:0.5rem 0.75rem;cursor:pointer;font-size:0.9rem;border-bottom:1px solid var(--color-secondary);"
|
||||
onmousedown="selectCity(event,'${label.replace(/'/g,"\\'")}',${parseFloat(r.lat)},${parseFloat(r.lon)})">${label}</li>`;
|
||||
}).join('');
|
||||
const rect = document.getElementById('filterCity').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 selectCity(e, label, lat, lon) {
|
||||
e.preventDefault();
|
||||
const inp = document.getElementById('filterCity');
|
||||
inp.value = label; inp.readOnly = true;
|
||||
_inputLat = lat; _inputLon = lon;
|
||||
document.getElementById('filterCityClear').style.display = '';
|
||||
document.getElementById('citySuggestions').style.display = 'none';
|
||||
}
|
||||
|
||||
function clearCity() {
|
||||
const inp = document.getElementById('filterCity');
|
||||
inp.value = ''; inp.readOnly = false;
|
||||
_inputLat = null; _inputLon = null;
|
||||
document.getElementById('filterCityClear').style.display = 'none';
|
||||
inp.focus();
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const ul = document.getElementById('citySuggestions');
|
||||
if (!e.target.closest('#cityRow') && !ul.contains(e.target)) ul.style.display = 'none';
|
||||
});
|
||||
|
||||
// ── Anwenden & Zurücksetzen ───────────────────────────────────────────────────
|
||||
document.getElementById('applyBtn').addEventListener('click', () => {
|
||||
closeFilter();
|
||||
|
||||
const city = document.getElementById('filterCity').value.trim();
|
||||
const radius = parseInt(document.getElementById('filterRadius').value);
|
||||
|
||||
// Filter in DB speichern
|
||||
saveFilterToDb(city || null, _inputLat, _inputLon, radius);
|
||||
|
||||
savedCity = city || null;
|
||||
savedLat = _inputLat;
|
||||
savedLon = _inputLon;
|
||||
savedRadius = radius;
|
||||
|
||||
updateFilterBadge();
|
||||
resetState();
|
||||
loadIds();
|
||||
});
|
||||
|
||||
document.getElementById('resetBtn').addEventListener('click', () => {
|
||||
clearCity();
|
||||
document.getElementById('filterRadius').value = 50;
|
||||
document.getElementById('distVal').textContent = '50 km';
|
||||
_inputLat = null; _inputLon = null;
|
||||
});
|
||||
|
||||
function saveFilterToDb(city, lat, lon, radius) {
|
||||
fetch('/user/me/location-filter', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filterCity: city, filterLat: lat, filterLon: lon, filterMaxDistKm: radius })
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function updateFilterBadge() {
|
||||
const active = savedCity != null;
|
||||
const badge = document.getElementById('filterBadge');
|
||||
badge.textContent = active ? '1' : '';
|
||||
badge.style.display = active ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
// ── Laden ─────────────────────────────────────────────────────────────────────
|
||||
function resetState() {
|
||||
state.allIds = [];
|
||||
state.loaded = 0;
|
||||
state.loading = false;
|
||||
activeLat = savedLat;
|
||||
activeLon = savedLon;
|
||||
activeRadius = savedRadius;
|
||||
document.getElementById('eventList').innerHTML = '';
|
||||
document.getElementById('emptyState').style.display = 'none';
|
||||
document.getElementById('resultCount').style.display = 'none';
|
||||
}
|
||||
|
||||
async function loadIds() {
|
||||
resetState();
|
||||
showSkeletons(4);
|
||||
|
||||
activeLat = savedLat;
|
||||
activeLon = savedLon;
|
||||
activeRadius = savedRadius;
|
||||
|
||||
let url = '/location-events/ids?maxDistanceKm=' + activeRadius;
|
||||
if (activeLat != null && activeLon != null) {
|
||||
url += '&lat=' + activeLat + '&lon=' + activeLon;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
state.allIds = data.ids || [];
|
||||
|
||||
document.getElementById('eventList').innerHTML = '';
|
||||
|
||||
if (state.allIds.length === 0) {
|
||||
document.getElementById('emptyState').style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const rc = document.getElementById('resultCount');
|
||||
rc.textContent = `${data.total} Veranstaltung${data.total !== 1 ? 'en' : ''} gefunden`;
|
||||
rc.style.display = '';
|
||||
|
||||
loadNextBatch();
|
||||
} catch (_) {
|
||||
document.getElementById('eventList').innerHTML =
|
||||
'<div class="empty-state"><div class="icon">⚠️</div><p>Fehler beim Laden.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNextBatch() {
|
||||
if (state.loading || state.loaded >= state.allIds.length) return;
|
||||
state.loading = true;
|
||||
|
||||
const slice = state.allIds.slice(state.loaded, state.loaded + BATCH);
|
||||
const body = { ids: slice, lat: activeLat ?? 0, lon: activeLon ?? 0 };
|
||||
|
||||
try {
|
||||
const res = await fetch('/location-events/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
const previews = await res.json();
|
||||
const list = document.getElementById('eventList');
|
||||
previews.forEach(e => {
|
||||
const imgHtml = e.imageData
|
||||
? `<img src="data:image/jpeg;base64,${e.imageData}" alt="${escHtml(e.title)}">`
|
||||
: '🗓';
|
||||
const attendTag = e.attendingMe ? `<span class="attend-tag">Ich bin dabei</span>` : '';
|
||||
const followTag = followedLocationIds.has(e.locationId) ? `<span class="follow-tag">★ Abonniert</span>` : '';
|
||||
const distText = e.distanzKm >= 0 ? `${e.distanzKm} km` : '';
|
||||
const a = document.createElement('a');
|
||||
a.className = 'event-card';
|
||||
a.href = `/community/event-detail.html?id=${e.eventId}`;
|
||||
a.innerHTML = `
|
||||
<div class="event-card-img">${imgHtml}</div>
|
||||
<div class="event-card-body">
|
||||
<div class="event-card-title">${escHtml(e.title)}${attendTag}${followTag}</div>
|
||||
<div class="event-card-sub">📍 ${escHtml(e.locationName)}</div>
|
||||
<div class="event-card-sub">🗓 ${formatDate(e.startAt)}</div>
|
||||
<div class="event-card-dist">${distText ? distText + ' entfernt · ' : ''}${e.attendeeCount} Teilnehmer*in(nen)</div>
|
||||
</div>`;
|
||||
list.appendChild(a);
|
||||
});
|
||||
state.loaded += slice.length;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
function showSkeletons(n) {
|
||||
document.getElementById('eventList').innerHTML =
|
||||
Array(n).fill('<div class="event-card-skeleton"></div>').join('');
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
||||
function escHtml(s) {
|
||||
return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function formatDate(dt) {
|
||||
if (!dt) return '';
|
||||
const d = new Date(dt);
|
||||
return d.toLocaleDateString('de-DE', { weekday:'short', day:'2-digit', month:'2-digit', year:'numeric' })
|
||||
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
|
||||
}
|
||||
|
||||
// ── IntersectionObserver für Scroll-Paging ────────────────────────────────────
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) loadNextBatch();
|
||||
}, { rootMargin: '300px' });
|
||||
observer.observe(document.getElementById('sentinel'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
628
src/main/resources/static/community/location-detail.html
Normal file
628
src/main/resources/static/community/location-detail.html
Normal file
@@ -0,0 +1,628 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Location – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.back-link { display:inline-flex; align-items:center; gap:0.35rem; color:var(--color-muted); font-size:0.88rem; text-decoration:none; margin-bottom:1rem; }
|
||||
.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 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; }
|
||||
.loc-city { color:var(--color-muted); font-size:0.88rem; margin-bottom:0.4rem; }
|
||||
.loc-desc { font-size:0.93rem; line-height:1.55; white-space:pre-wrap; word-break:break-word; margin-top:0.5rem; }
|
||||
|
||||
.section-title { font-size:1rem; font-weight:700; margin:1.5rem 0 0.75rem; display:flex; align-items:center; justify-content:space-between; }
|
||||
|
||||
.hours-table { width:100%; border-collapse:collapse; font-size:0.88rem; }
|
||||
.hours-table td { padding:0.3rem 0.5rem; border-bottom:1px solid var(--color-secondary); }
|
||||
.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-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; }
|
||||
.gallery-del-btn { position:absolute; top:4px; right:4px; background:rgba(0,0,0,.6); border:none; color:#fff; border-radius:50%; width:22px; height:22px; font-size:0.7rem; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
|
||||
.gallery-upload-btn { aspect-ratio:1; border:2px dashed var(--color-secondary); border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:1.5rem; color:var(--color-muted); cursor:pointer; transition:border-color 0.15s; background:none; }
|
||||
.gallery-upload-btn:hover { border-color:var(--color-primary); color:var(--color-primary); }
|
||||
|
||||
.event-list { display:flex; flex-direction:column; gap:0.75rem; }
|
||||
.event-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; display:flex; gap:0.75rem; padding:0.75rem; text-decoration:none; color:inherit; transition:border-color 0.15s; cursor:pointer; }
|
||||
.event-card:hover { border-color:var(--color-primary); }
|
||||
.event-card-img { width:64px; height:64px; border-radius:8px; object-fit:cover; background:var(--color-secondary); flex-shrink:0; overflow:hidden; display:flex; align-items:center; justify-content:center; font-size:1.4rem; }
|
||||
.event-card-img img { width:100%; height:100%; object-fit:cover; }
|
||||
.event-card-body { flex:1; min-width:0; }
|
||||
.event-card-title { font-weight:600; font-size:0.92rem; margin:0 0 0.2rem; }
|
||||
.event-card-date { font-size:0.78rem; color:var(--color-muted); }
|
||||
.event-card-attendees { font-size:0.78rem; color:var(--color-muted); }
|
||||
|
||||
/* 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; }
|
||||
.hours-grid { display:grid; grid-template-columns:auto 1fr 1fr auto; gap:0.4rem 0.5rem; align-items:center; font-size:0.85rem; margin-top:0.5rem; }
|
||||
.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; }
|
||||
.suggest-list { position:absolute; top:100%; left:0; right:0; background:var(--color-card); border:1px solid var(--color-secondary); border-top:none; border-radius:0 0 6px 6px; z-index:50; display:none; list-style:none; margin:0; padding:0; max-height:220px; overflow-y:auto; }
|
||||
|
||||
/* 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-close { position:absolute; top:1rem; right:1rem; background:none; border:none; color:#fff; font-size:1.5rem; cursor:pointer; }
|
||||
|
||||
.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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main">
|
||||
<a href="/community/locations.html" class="back-link">← Locations</a>
|
||||
|
||||
<div id="content">
|
||||
<p style="color:var(--color-muted);">Wird geladen…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Edit-Modal ──────────────────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="editModal">
|
||||
<div class="modal">
|
||||
<h3>Location 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;">
|
||||
Profilbild ändern
|
||||
<input type="file" id="editPicFile" accept="image/*" style="display:none;" onchange="onEditPicChange(this)">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label>Name *</label>
|
||||
<input type="text" id="editName" 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>Adresse</label>
|
||||
<div id="editStadtRow">
|
||||
<div style="position:relative;">
|
||||
<input type="text" id="editCity" placeholder="Straße, Hausnummer, Stadt…" autocomplete="off"
|
||||
style="width:100%;box-sizing:border-box;padding:0.55rem 2rem 0.55rem 0.8rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.95rem;outline:none;"
|
||||
oninput="onEditCityInput()">
|
||||
<button id="editCityClear" onclick="clearEditCity()" title="Auswahl aufheben"
|
||||
style="display:none;position:absolute;right:0.4rem;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--color-muted);font-size:1.1rem;cursor:pointer;padding:0.1rem 0.3rem;margin:0;width:auto;line-height:1;">×</button>
|
||||
<ul id="editCitySuggestions" style="display:none;position:absolute;top:100%;left:0;right:0;
|
||||
background:var(--color-card);border:1px solid var(--color-secondary);border-radius:6px;
|
||||
z-index:100;list-style:none;margin:0.2rem 0 0;padding:0;max-height:200px;overflow-y:auto;"></ul>
|
||||
</div>
|
||||
<div id="editLocMsg" style="font-size:0.82rem;color:var(--color-muted);margin-top:0.25rem;min-height:1.1em;"></div>
|
||||
</div>
|
||||
<label style="margin-top:0.75rem;display:block;">Öffnungszeiten</label>
|
||||
<div class="hours-grid" id="editHoursGrid"></div>
|
||||
<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>
|
||||
|
||||
<!-- ── Event erstellen/bearbeiten Modal ───────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="eventModal">
|
||||
<div class="modal">
|
||||
<h3 id="eventModalTitle">Veranstaltung erstellen</h3>
|
||||
<div class="img-row">
|
||||
<div class="img-preview" id="eventPicPreview">🗓</div>
|
||||
<div>
|
||||
<label class="btn" style="display:inline-block;cursor:pointer;font-size:0.85rem;">
|
||||
Bild wählen
|
||||
<input type="file" id="eventPicFile" accept="image/*" style="display:none;" onchange="onEventPicChange(this)">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<label>Datum & Uhrzeit *</label>
|
||||
<input type="datetime-local" id="eventStartAt">
|
||||
<div class="modal-footer">
|
||||
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeEventModal()">Abbrechen</button>
|
||||
<button class="btn" id="eventSubmitBtn" onclick="submitEvent()">Speichern</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="">
|
||||
</div>
|
||||
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/icons.js"></script>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const locationId = params.get('id');
|
||||
let locDetail = null;
|
||||
let myUserId = null;
|
||||
let isOwner = false;
|
||||
let isFollowing = false;
|
||||
|
||||
// ── Bild-Resize ───────────────────────────────────────────────────────────────
|
||||
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 escHtml(s) {
|
||||
return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
const DAY_NAMES = ['Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag','Sonntag'];
|
||||
|
||||
function formatDate(dt) {
|
||||
if (!dt) return '';
|
||||
const d = new Date(dt);
|
||||
return d.toLocaleDateString('de-DE', { weekday:'short', day:'2-digit', month:'2-digit', year:'numeric' })
|
||||
+ ', ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
|
||||
}
|
||||
|
||||
// ── Lade Seite ────────────────────────────────────────────────────────────────
|
||||
async function loadPage() {
|
||||
if (!locationId) { document.getElementById('content').innerHTML = '<p>Keine Location-ID angegeben.</p>'; return; }
|
||||
|
||||
const [meRes, locRes] = await Promise.all([
|
||||
fetch('/login/me'),
|
||||
fetch(`/locations/${locationId}`)
|
||||
]);
|
||||
if (!locRes.ok) { document.getElementById('content').innerHTML = '<p>Location nicht gefunden.</p>'; return; }
|
||||
|
||||
if (meRes.ok) {
|
||||
const me = await meRes.json();
|
||||
myUserId = me.userId;
|
||||
}
|
||||
|
||||
locDetail = await locRes.json();
|
||||
isOwner = locDetail.ownerId === myUserId;
|
||||
isFollowing = !!locDetail.following;
|
||||
|
||||
renderPage();
|
||||
loadEvents();
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const loc = locDetail;
|
||||
const imgHtml = loc.profilePictureHq || loc.profilePictureLq
|
||||
? `<img src="data:image/jpeg;base64,${loc.profilePictureHq || loc.profilePictureLq}" alt="${escHtml(loc.name)}">`
|
||||
: '📍';
|
||||
|
||||
const ownerActions = isOwner ? `
|
||||
<div class="owner-actions">
|
||||
<button class="btn" style="font-size:0.85rem;" onclick="openEditModal()">✎ Bearbeiten</button>
|
||||
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);font-size:0.85rem;" onclick="deleteLocation()">Löschen</button>
|
||||
</div>` : `
|
||||
<div class="owner-actions">
|
||||
<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>
|
||||
</div>`;
|
||||
|
||||
let hoursHtml = '';
|
||||
if (loc.openingHours && loc.openingHours.length > 0) {
|
||||
const rowsHtml = loc.openingHours.map(h => {
|
||||
const dayName = DAY_NAMES[h.dayOfWeek - 1] || '';
|
||||
const timeText = h.closed
|
||||
? '<span class="hours-closed">Geschlossen</span>'
|
||||
: `${h.openTime || '--:--'} – ${h.closeTime || '--:--'}`;
|
||||
return `<tr><td>${dayName}</td><td>${timeText}</td></tr>`;
|
||||
}).join('');
|
||||
hoursHtml = `
|
||||
<div class="section-title">Öffnungszeiten</div>
|
||||
<table class="hours-table"><tbody>${rowsHtml}</tbody></table>`;
|
||||
}
|
||||
|
||||
const galleryHtml = buildGalleryHtml(loc.gallery || []);
|
||||
|
||||
document.getElementById('content').innerHTML = `
|
||||
<div class="loc-header">
|
||||
<div class="loc-avatar">${imgHtml}</div>
|
||||
<div class="loc-meta">
|
||||
<div class="loc-name">${escHtml(loc.name)}</div>
|
||||
${(loc.street || loc.city) ? `<div class="loc-city">📍 ${escHtml([loc.street, loc.city].filter(Boolean).join(', '))}</div>` : ''}
|
||||
${loc.description ? `<div class="loc-desc">${escHtml(loc.description)}</div>` : ''}
|
||||
${ownerActions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${hoursHtml}
|
||||
|
||||
<div class="section-title">
|
||||
Galerie
|
||||
${isOwner ? `<label class="btn" style="font-size:0.8rem;cursor:pointer;">
|
||||
+ Bild hinzufügen
|
||||
<input type="file" accept="image/*" style="display:none;" onchange="uploadGalleryImage(this)">
|
||||
</label>` : ''}
|
||||
</div>
|
||||
<div class="gallery-grid" id="galleryGrid">${galleryHtml}</div>
|
||||
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildGalleryHtml(gallery) {
|
||||
return gallery.map(img => `
|
||||
<div class="gallery-img-wrap">
|
||||
<img src="data:image/jpeg;base64,${img.imageData}" alt="Galeriebild"
|
||||
onclick="openLightbox(this.src)">
|
||||
${isOwner ? `<button class="gallery-del-btn" onclick="deleteGalleryImage('${img.imageId}')">✕</button>` : ''}
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ── Galerie ────────────────────────────────────────────────────────────────────
|
||||
async function uploadGalleryImage(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const imageData = await resizeImage(file, 1024, 0.88);
|
||||
const res = await fetch(`/locations/${locationId}/gallery`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ imageData })
|
||||
});
|
||||
if (res.status === 422) { alert('Maximal 20 Galeriebilder erlaubt.'); return; }
|
||||
if (!res.ok) throw new Error();
|
||||
const img = await res.json();
|
||||
const grid = document.getElementById('galleryGrid');
|
||||
grid.insertAdjacentHTML('beforeend', `
|
||||
<div class="gallery-img-wrap">
|
||||
<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.'); }
|
||||
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; }
|
||||
await loadPage();
|
||||
}
|
||||
|
||||
// ── Events ─────────────────────────────────────────────────────────────────────
|
||||
async function loadEvents() {
|
||||
const res = await fetch(`/locations/${locationId}/events`);
|
||||
if (!res.ok) return;
|
||||
const events = await res.json();
|
||||
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;
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
// ── Lightbox ───────────────────────────────────────────────────────────────────
|
||||
function openLightbox(src) {
|
||||
document.getElementById('lbImg').src = src;
|
||||
document.getElementById('lightbox').classList.add('open');
|
||||
}
|
||||
function closeLightbox() { document.getElementById('lightbox').classList.remove('open'); }
|
||||
|
||||
// ── Edit Modal ─────────────────────────────────────────────────────────────────
|
||||
let _editLq = null, _editHq = null, _editLat = null, _editLon = null, _editStreet = null, _editCity = null, _editCityTimer = null;
|
||||
|
||||
function buildHoursGrid(gridId, existing) {
|
||||
const grid = document.getElementById(gridId);
|
||||
grid.innerHTML = '';
|
||||
const byDay = {};
|
||||
(existing || []).forEach(h => { byDay[h.dayOfWeek] = h; });
|
||||
DAY_NAMES.forEach((name, i) => {
|
||||
const day = i + 1;
|
||||
const h = byDay[day] || {};
|
||||
grid.insertAdjacentHTML('beforeend', `
|
||||
<span>${name}</span>
|
||||
<input type="time" id="open_${day}_${gridId}" value="${h.openTime || ''}" style="font-size:0.82rem;padding:0.25rem 0.4rem;">
|
||||
<input type="time" id="close_${day}_${gridId}" value="${h.closeTime || ''}" style="font-size:0.82rem;padding:0.25rem 0.4rem;">
|
||||
<label style="display:flex;align-items:center;gap:0.25rem;font-size:0.82rem;white-space:nowrap;">
|
||||
<input type="checkbox" id="closed_${day}_${gridId}" ${h.closed ? 'checked' : ''}> Geschlossen
|
||||
</label>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
function collectHours(gridId) {
|
||||
const result = [];
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
const open = document.getElementById(`open_${d}_${gridId}`)?.value;
|
||||
const close = document.getElementById(`close_${d}_${gridId}`)?.value;
|
||||
const closed = document.getElementById(`closed_${d}_${gridId}`)?.checked;
|
||||
if (open || close || closed) {
|
||||
result.push({ dayOfWeek: d, openTime: open || null, closeTime: close || null, closed: !!closed });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function openEditModal() {
|
||||
const loc = locDetail;
|
||||
_editLq = null; _editHq = null;
|
||||
_editLat = loc.lat; _editLon = loc.lon;
|
||||
_editStreet = loc.street || null; _editCity = loc.city || null;
|
||||
document.getElementById('editName').value = loc.name || '';
|
||||
document.getElementById('editDesc').value = loc.description || '';
|
||||
const cityInp = document.getElementById('editCity');
|
||||
const addressLabel = [loc.street, loc.city].filter(Boolean).join(', ');
|
||||
cityInp.value = addressLabel;
|
||||
cityInp.readOnly = !!addressLabel;
|
||||
document.getElementById('editCityClear').style.display = addressLabel ? '' : 'none';
|
||||
document.getElementById('editLocMsg').textContent = '';
|
||||
const picSrc = loc.profilePictureHq || loc.profilePictureLq;
|
||||
document.getElementById('editPicPreview').innerHTML = picSrc
|
||||
? `<img src="data:image/jpeg;base64,${picSrc}" alt="Vorschau">`
|
||||
: '📍';
|
||||
buildHoursGrid('editHoursGrid', loc.openingHours || []);
|
||||
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;
|
||||
_editLq = await resizeImage(file, 120, 0.75);
|
||||
_editHq = await resizeImage(file, 1024, 0.88);
|
||||
document.getElementById('editPicPreview').innerHTML = `<img src="data:image/jpeg;base64,${_editHq}" alt="Vorschau">`;
|
||||
}
|
||||
|
||||
function onEditCityInput() {
|
||||
const q = document.getElementById('editCity').value.trim();
|
||||
_editLat = null; _editLon = null; _editStreet = null; _editCity = null;
|
||||
document.getElementById('editCityClear').style.display = 'none';
|
||||
clearTimeout(_editCityTimer);
|
||||
if (q.length < 2) { document.getElementById('editCitySuggestions').style.display = 'none'; return; }
|
||||
_editCityTimer = setTimeout(() => fetchAddressSuggestions(q), 300);
|
||||
}
|
||||
|
||||
function fmtAddress(r) {
|
||||
const road = r.address.road || r.address.pedestrian || r.address.path || '';
|
||||
const hn = r.address.house_number || '';
|
||||
const street = (road + (hn ? ' ' + hn : '')).trim();
|
||||
const plz = r.address.postcode || '';
|
||||
const city = r.address.city || r.address.town || r.address.village || r.address.county || '';
|
||||
const parts = [];
|
||||
if (street) parts.push(street);
|
||||
const cityPart = (plz && city) ? plz + ' ' + city : (plz || city);
|
||||
if (cityPart) parts.push(cityPart);
|
||||
return { label: parts.join(', '), street, city };
|
||||
}
|
||||
|
||||
async function fetchAddressSuggestions(q) {
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&addressdetails=1&limit=5`;
|
||||
const res = await fetch(url, { headers: { 'Accept-Language': 'de' } });
|
||||
if (!res.ok) return;
|
||||
const results = await res.json();
|
||||
const ul = document.getElementById('editCitySuggestions');
|
||||
if (!results.length) { ul.style.display = 'none'; return; }
|
||||
ul.innerHTML = results.map(r => {
|
||||
const { label, street, city } = fmtAddress(r);
|
||||
const esc = s => s.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||||
return `<li style="padding:0.5rem 0.75rem;cursor:pointer;font-size:0.9rem;border-bottom:1px solid var(--color-secondary);"
|
||||
onmousedown="selectEditAddress(event,'${esc(label)}','${esc(street)}','${esc(city)}',${parseFloat(r.lat)},${parseFloat(r.lon)})">${label}</li>`;
|
||||
}).join('');
|
||||
ul.style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function selectEditAddress(e, label, street, city, lat, lon) {
|
||||
e.preventDefault();
|
||||
const inp = document.getElementById('editCity');
|
||||
inp.value = label; inp.readOnly = true;
|
||||
_editStreet = street || null;
|
||||
_editCity = city || null;
|
||||
_editLat = lat; _editLon = lon;
|
||||
document.getElementById('editCityClear').style.display = '';
|
||||
document.getElementById('editCitySuggestions').style.display = 'none';
|
||||
document.getElementById('editLocMsg').textContent = '';
|
||||
}
|
||||
|
||||
function clearEditCity() {
|
||||
const inp = document.getElementById('editCity');
|
||||
inp.value = ''; inp.readOnly = false;
|
||||
_editLat = null; _editLon = null; _editStreet = null; _editCity = null;
|
||||
document.getElementById('editCityClear').style.display = 'none';
|
||||
document.getElementById('editLocMsg').textContent = '';
|
||||
inp.focus();
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('#editStadtRow')) document.getElementById('editCitySuggestions').style.display = 'none';
|
||||
});
|
||||
|
||||
async function submitEdit() {
|
||||
const name = document.getElementById('editName').value.trim();
|
||||
if (!name) { alert('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.';
|
||||
document.getElementById('editCity').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('editSubmitBtn');
|
||||
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
|
||||
|
||||
try {
|
||||
const body = {
|
||||
name,
|
||||
description: document.getElementById('editDesc').value.trim() || null,
|
||||
street: _editStreet,
|
||||
city: _editCity,
|
||||
lat: _editLat,
|
||||
lon: _editLon
|
||||
};
|
||||
if (_editLq) body.profilePictureLq = _editLq;
|
||||
if (_editHq) body.profilePictureHq = _editHq;
|
||||
|
||||
const res = await fetch(`/locations/${locationId}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
const hours = collectHours('editHoursGrid');
|
||||
await fetch(`/locations/${locationId}/opening-hours`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(hours)
|
||||
});
|
||||
|
||||
locDetail = await (await fetch(`/locations/${locationId}`)).json();
|
||||
closeEditModal();
|
||||
renderPage();
|
||||
loadEvents();
|
||||
} catch { alert('Fehler beim Speichern.'); }
|
||||
finally { btn.disabled = false; btn.textContent = 'Speichern'; }
|
||||
}
|
||||
|
||||
async function toggleFollow() {
|
||||
const btn = document.getElementById('followBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/locations/${locationId}/follow`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
isFollowing = data.following;
|
||||
if (btn) {
|
||||
btn.textContent = isFollowing ? '★ Abonniert' : '☆ Abonnieren';
|
||||
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.'); }
|
||||
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; }
|
||||
window.location.href = '/community/locations.html';
|
||||
}
|
||||
|
||||
// ── Event Modal ────────────────────────────────────────────────────────────────
|
||||
let _evtImg = null, _editEventId = null;
|
||||
|
||||
function openEventModal(evtId) {
|
||||
_editEventId = evtId || null;
|
||||
_evtImg = null;
|
||||
document.getElementById('eventModalTitle').textContent = evtId ? 'Veranstaltung bearbeiten' : 'Veranstaltung erstellen';
|
||||
document.getElementById('eventTitle').value = '';
|
||||
document.getElementById('eventDesc').value = '';
|
||||
document.getElementById('eventStartAt').value = '';
|
||||
document.getElementById('eventPicPreview').innerHTML = '🗓';
|
||||
document.getElementById('eventModal').classList.add('open');
|
||||
}
|
||||
function closeEventModal() { document.getElementById('eventModal').classList.remove('open'); }
|
||||
|
||||
async function onEventPicChange(input) {
|
||||
const file = input.files[0]; if (!file) return;
|
||||
_evtImg = await resizeImage(file, 1024, 0.88);
|
||||
document.getElementById('eventPicPreview').innerHTML = `<img src="data:image/jpeg;base64,${_evtImg}" alt="Vorschau">`;
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
const btn = document.getElementById('eventSubmitBtn');
|
||||
btn.disabled = true; btn.textContent = 'Wird gespeichert…';
|
||||
|
||||
try {
|
||||
const body = {
|
||||
title,
|
||||
description: document.getElementById('eventDesc').value.trim() || null,
|
||||
imageData: _evtImg,
|
||||
startAt: startAt + ':00'
|
||||
};
|
||||
|
||||
const url = _editEventId
|
||||
? `/locations/${locationId}/events/${_editEventId}`
|
||||
: `/locations/${locationId}/events`;
|
||||
const method = _editEventId ? 'PUT' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
closeEventModal();
|
||||
loadEvents();
|
||||
} catch { alert('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();
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
loadPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
647
src/main/resources/static/community/locations.html
Normal file
647
src/main/resources/static/community/locations.html
Normal file
@@ -0,0 +1,647 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/img/icon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Locations – xXx Sphere</title>
|
||||
<link rel="stylesheet" href="/css/variables.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
.tabs { display:flex; gap:0; align-items:center; margin-bottom:1.25rem; border-bottom:1px solid var(--color-secondary); }
|
||||
.tab-btn { background:none; border:none; border-bottom:3px solid transparent; border-radius:0; padding:0.6rem 1.4rem; font-size:0.95rem; font-weight:600; color:var(--color-muted); cursor:pointer; margin-bottom:-1px; transition:color 0.15s,border-color 0.15s; width:auto; margin-top:0; }
|
||||
.tab-btn:hover { color:var(--color-text); background:none; }
|
||||
.tab-btn.active { color:var(--color-primary); border-bottom-color:var(--color-primary); }
|
||||
.tab-panel { display:none; }
|
||||
.tab-panel.active { display:block; }
|
||||
|
||||
/* ── Filter-Drawer ── */
|
||||
.filter-overlay-bg { position:fixed; inset:0; background:rgba(0,0,0,0.55); z-index:200; opacity:0; pointer-events:none; transition:opacity 0.2s; }
|
||||
.filter-overlay-bg.open { opacity:1; pointer-events:all; }
|
||||
.filter-drawer { position:fixed; top:0; right:0; bottom:0; width:min(320px,92vw); background:var(--color-card); border-left:1px solid var(--color-secondary); z-index:201; display:flex; flex-direction:column; transform:translateX(100%); transition:transform 0.25s ease; overflow:hidden; }
|
||||
.filter-drawer.open { transform:translateX(0); }
|
||||
.filter-drawer-header { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.25rem 0.75rem; border-bottom:1px solid var(--color-secondary); flex-shrink:0; }
|
||||
.filter-drawer-header h3 { font-size:1rem; font-weight:700; color:var(--color-primary); margin:0; }
|
||||
.filter-close-btn { background:none; border:none; color:var(--color-muted); font-size:1.3rem; cursor:pointer; padding:0.2rem 0.4rem; line-height:1; border-radius:4px; }
|
||||
.filter-close-btn:hover { background:var(--color-secondary); color:var(--color-text); }
|
||||
.filter-drawer-body { flex:1; overflow-y:auto; padding:1rem 1.25rem; display:flex; flex-direction:column; gap:1.25rem; }
|
||||
.filter-drawer-footer { padding:0.75rem 1.25rem; border-top:1px solid var(--color-secondary); display:flex; gap:0.5rem; flex-shrink:0; }
|
||||
.apply-btn { flex:1; padding:0.65rem; font-size:0.9rem; }
|
||||
.reset-btn { padding:0.65rem 1rem; font-size:0.9rem; background:var(--color-secondary); color:var(--color-muted); border:1px solid var(--color-secondary); border-radius:6px; cursor:pointer; }
|
||||
.reset-btn:hover { color:var(--color-text); }
|
||||
.filter-group { display:flex; flex-direction:column; gap:0.35rem; }
|
||||
.filter-group > label { font-size:0.78rem; color:var(--color-muted); margin:0; display:flex; justify-content:space-between; }
|
||||
.range-val { color:var(--color-text); }
|
||||
.filter-group input[type="range"] { padding:0; background:none; border:none; accent-color:var(--color-primary); width:100%; }
|
||||
.filter-badge { display:inline-flex; align-items:center; justify-content:center; background:var(--color-primary); color:#fff; border-radius:50%; width:1rem; height:1rem; font-size:0.62rem; font-weight:700; line-height:1; }
|
||||
|
||||
.search-bar { display:flex; gap:0.5rem; flex-wrap:wrap; align-items:flex-end; margin-bottom:1.25rem; }
|
||||
.search-bar .input-wrap { flex:1; min-width:160px; position:relative; }
|
||||
.search-bar input, .search-bar select { width:100%; box-sizing:border-box; }
|
||||
.suggest-list { position:absolute; top:100%; left:0; right:0; background:var(--color-card); border:1px solid var(--color-secondary); border-top:none; border-radius:0 0 6px 6px; z-index:50; display:none; list-style:none; margin:0; padding:0; max-height:220px; overflow-y:auto; }
|
||||
|
||||
.loc-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:1rem; }
|
||||
.loc-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; overflow:hidden; cursor:pointer; transition:border-color 0.15s, box-shadow 0.15s; text-decoration:none; color:inherit; display:block; }
|
||||
.loc-card:hover { border-color:var(--color-primary); box-shadow:0 2px 8px rgba(0,0,0,.15); }
|
||||
.loc-card-img { width:100%; aspect-ratio:1; object-fit:cover; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:2rem; }
|
||||
.loc-card-img img { width:100%; height:100%; object-fit:cover; }
|
||||
.loc-card-body { padding:0.6rem 0.75rem; }
|
||||
.loc-card-name { font-weight:600; font-size:0.9rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.loc-card-dist { font-size:0.75rem; color:var(--color-muted); margin-top:0.1rem; }
|
||||
|
||||
.loc-card-skeleton { background:var(--color-secondary); border-radius:10px; aspect-ratio:1; animation:pulse 1.4s infinite; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
|
||||
|
||||
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
|
||||
.sentinel { height:1px; }
|
||||
|
||||
/* 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; }
|
||||
.disclaimer-box { background:rgba(var(--color-primary-rgb,180,0,60),.08); border:1px solid var(--color-primary); border-radius:8px; padding:0.85rem 1rem; font-size:0.88rem; margin:0.75rem 0; }
|
||||
.disclaimer-box label { display:flex; gap:0.5rem; align-items:flex-start; cursor:pointer; margin:0; color:var(--color-text); font-size:0.88rem; }
|
||||
.disclaimer-box input[type="checkbox"] { width:auto; padding:0; border:none; background:none; flex-shrink:0; margin-top:0.15rem; cursor:pointer; }
|
||||
.hours-grid { display:grid; grid-template-columns:auto 1fr 1fr auto; gap:0.4rem 0.5rem; align-items:center; font-size:0.85rem; margin-top:0.5rem; }
|
||||
.hours-grid span { white-space:nowrap; }
|
||||
.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 class="app">
|
||||
|
||||
<!-- ── Autocomplete (außerhalb des transformierten Drawers) ── -->
|
||||
<ul id="filterCitySuggestions" 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>
|
||||
|
||||
<!-- ── Filter-Drawer ── -->
|
||||
<div class="filter-overlay-bg" id="filterBg"></div>
|
||||
<div class="filter-drawer" id="filterDrawer">
|
||||
<div class="filter-drawer-header">
|
||||
<h3>Filter</h3>
|
||||
<button class="filter-close-btn" id="filterCloseBtn" aria-label="Filter schließen">✕</button>
|
||||
</div>
|
||||
<div class="filter-drawer-body">
|
||||
<div class="filter-group">
|
||||
<label>Ort</label>
|
||||
<div style="position:relative;" id="filterCityRow">
|
||||
<input type="text" id="filterCity" placeholder="Stadt suchen und auswählen…" autocomplete="off"
|
||||
style="padding-right:2rem;" oninput="onFilterCityInput()">
|
||||
<button id="filterCityClear" onclick="clearFilterCity()" title="Auswahl aufheben"
|
||||
style="display:none;position:absolute;right:0.4rem;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--color-muted);font-size:1.1rem;cursor:pointer;padding:0.1rem 0.3rem;margin:0;width:auto;line-height:1;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Umkreis <span class="range-val" id="distVal">50 km</span></label>
|
||||
<input type="range" id="filterRadius" min="5" max="250" step="5" value="50"
|
||||
oninput="document.getElementById('distVal').textContent = this.value + ' km'">
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-drawer-footer">
|
||||
<button class="btn apply-btn" id="applyBtn">Anwenden</button>
|
||||
<button class="reset-btn" id="resetBtn">Zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" onclick="switchTab('search',this)">Suchen</button>
|
||||
<button class="tab-btn" onclick="switchTab('mine',this)">Meine Locations</button>
|
||||
<div style="margin-left:auto;display:flex;align-items:center;gap:0.4rem;padding-bottom:1px;">
|
||||
<button id="filterOpenBtn" title="Filter"
|
||||
style="display:flex;align-items:center;justify-content:center;position:relative;width:2rem;height:2rem;border-radius:50%;background:var(--color-secondary);color:var(--color-muted);border:none;cursor:pointer;transition:background 0.15s,color 0.15s;padding:0;"
|
||||
onmouseover="this.style.background='var(--color-primary)';this.style.color='#fff';"
|
||||
onmouseout="this.style.background='var(--color-secondary)';this.style.color='var(--color-muted)';">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="11" y1="18" x2="13" y2="18"/>
|
||||
</svg>
|
||||
<span class="filter-badge" id="filterBadge" style="display:none;position:absolute;top:-3px;right:-3px;"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Suche ────────────────────────────────────────────────────── -->
|
||||
<div id="paneSearch" class="tab-panel active">
|
||||
<div class="loc-grid" id="searchGrid"></div>
|
||||
<p class="empty-hint" id="searchEmpty" style="display:none;">Keine Locations in diesem Umkreis gefunden.</p>
|
||||
<p class="empty-hint" id="searchHint" style="display:none;">Wähle im Filter einen Ort, um Locations in deiner Nähe zu suchen.</p>
|
||||
<div class="sentinel" id="searchSentinel"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── 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>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Erstellen-Modal ──────────────────────────────────────────────── -->
|
||||
<div class="modal-overlay" id="createModal">
|
||||
<div class="modal">
|
||||
<h3>Location anlegen</h3>
|
||||
|
||||
<div class="img-row">
|
||||
<div class="img-preview" id="createPicPreview">📍</div>
|
||||
<div>
|
||||
<label class="btn" style="display:inline-block;cursor:pointer;font-size:0.85rem;">
|
||||
Profilbild wählen
|
||||
<input type="file" id="createPicFile" accept="image/*" style="display:none;" onchange="onCreatePicChange(this)">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Name *</label>
|
||||
<input type="text" id="createName" maxlength="200" placeholder="Name der Location">
|
||||
|
||||
<label>Beschreibung <span style="color:var(--color-muted);font-size:0.8rem;">(max. 1000 Zeichen)</span></label>
|
||||
<textarea id="createDesc" 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>Adresse *</label>
|
||||
<div id="createStadtRow">
|
||||
<div style="position:relative;">
|
||||
<input type="text" id="createCity" placeholder="Straße, Hausnummer, Stadt…" autocomplete="off"
|
||||
style="width:100%;box-sizing:border-box;padding:0.55rem 2rem 0.55rem 0.8rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.95rem;outline:none;"
|
||||
oninput="onCreateCityInput()">
|
||||
<button id="createCityClear" onclick="clearCreateCity()" title="Auswahl aufheben"
|
||||
style="display:none;position:absolute;right:0.4rem;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--color-muted);font-size:1.1rem;cursor:pointer;padding:0.1rem 0.3rem;margin:0;width:auto;line-height:1;">×</button>
|
||||
<ul id="createCitySuggestions" style="display:none;position:absolute;top:100%;left:0;right:0;
|
||||
background:var(--color-card);border:1px solid var(--color-secondary);border-radius:6px;
|
||||
z-index:100;list-style:none;margin:0.2rem 0 0;padding:0;max-height:200px;overflow-y:auto;"></ul>
|
||||
</div>
|
||||
<div id="createLocMsg" style="font-size:0.82rem;color:var(--color-muted);margin-top:0.25rem;min-height:1.1em;"></div>
|
||||
</div>
|
||||
|
||||
<label style="margin-top:0.75rem;display:block;">Öffnungszeiten <span style="color:var(--color-muted);font-size:0.8rem;">(optional)</span></label>
|
||||
<div class="hours-grid" id="createHoursGrid"></div>
|
||||
|
||||
<div class="disclaimer-box">
|
||||
<label>
|
||||
<input type="checkbox" id="createOwnership">
|
||||
<span>Ich bestätige, dass ich Eigentümer*in oder autorisierte*r Vertreter*in dieser Location bin und berechtigt bin, sie hier einzutragen.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn" style="background:var(--color-secondary);color:var(--color-text);" onclick="closeCreateModal()">Abbrechen</button>
|
||||
<button class="btn" id="createSubmitBtn" onclick="submitCreate()">Anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script>
|
||||
// ── Bild-Resize ──────────────────────────────────────────────────────────────
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
function switchTab(name, btn) {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
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');
|
||||
}
|
||||
|
||||
// ── Filter-Drawer ─────────────────────────────────────────────────────────────
|
||||
let savedFilterCity = null, savedFilterLat = null, savedFilterLon = null, savedFilterRadius = 50;
|
||||
let _inputLat = null, _inputLon = null, _cityTimer = null;
|
||||
|
||||
const filterDrawer = document.getElementById('filterDrawer');
|
||||
const filterBg = document.getElementById('filterBg');
|
||||
|
||||
function openFilter() { filterDrawer.classList.add('open'); filterBg.classList.add('open'); document.body.style.overflow = 'hidden'; }
|
||||
function closeFilter() { filterDrawer.classList.remove('open'); filterBg.classList.remove('open'); document.body.style.overflow = ''; }
|
||||
|
||||
document.getElementById('filterOpenBtn').addEventListener('click', openFilter);
|
||||
document.getElementById('filterCloseBtn').addEventListener('click', closeFilter);
|
||||
filterBg.addEventListener('click', closeFilter);
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape' && filterDrawer.classList.contains('open')) closeFilter(); });
|
||||
|
||||
function onFilterCityInput() {
|
||||
const q = document.getElementById('filterCity').value.trim();
|
||||
_inputLat = null; _inputLon = null;
|
||||
document.getElementById('filterCityClear').style.display = 'none';
|
||||
clearTimeout(_cityTimer);
|
||||
if (q.length < 2) { document.getElementById('filterCitySuggestions').style.display = 'none'; return; }
|
||||
_cityTimer = setTimeout(() => fetchCitySuggestions(q), 300);
|
||||
}
|
||||
|
||||
async function fetchCitySuggestions(q) {
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&addressdetails=1&limit=5&featuretype=city`;
|
||||
const res = await fetch(url, { headers: { 'Accept-Language': 'de' } });
|
||||
if (!res.ok) return;
|
||||
const results = await res.json();
|
||||
const ul = document.getElementById('filterCitySuggestions');
|
||||
if (!results.length) { ul.style.display = 'none'; return; }
|
||||
ul.innerHTML = results.map(r => {
|
||||
const city = r.address.city || r.address.town || r.address.village || r.address.county || r.name;
|
||||
const country = r.address.country || '';
|
||||
const label = city + (country ? ', ' + country : '');
|
||||
return `<li style="padding:0.5rem 0.75rem;cursor:pointer;font-size:0.9rem;border-bottom:1px solid var(--color-secondary);"
|
||||
onmousedown="selectFilterCity(event,'${label.replace(/'/g,"\\'")}',${parseFloat(r.lat)},${parseFloat(r.lon)})">${label}</li>`;
|
||||
}).join('');
|
||||
const rect = document.getElementById('filterCity').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 selectFilterCity(e, label, lat, lon) {
|
||||
e.preventDefault();
|
||||
const inp = document.getElementById('filterCity');
|
||||
inp.value = label; inp.readOnly = true;
|
||||
_inputLat = lat; _inputLon = lon;
|
||||
document.getElementById('filterCityClear').style.display = '';
|
||||
document.getElementById('filterCitySuggestions').style.display = 'none';
|
||||
}
|
||||
|
||||
function clearFilterCity() {
|
||||
const inp = document.getElementById('filterCity');
|
||||
inp.value = ''; inp.readOnly = false;
|
||||
_inputLat = null; _inputLon = null;
|
||||
document.getElementById('filterCityClear').style.display = 'none';
|
||||
inp.focus();
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const ul = document.getElementById('filterCitySuggestions');
|
||||
if (!e.target.closest('#filterCityRow') && !ul.contains(e.target)) ul.style.display = 'none';
|
||||
});
|
||||
|
||||
document.getElementById('applyBtn').addEventListener('click', () => {
|
||||
closeFilter();
|
||||
const city = document.getElementById('filterCity').value.trim();
|
||||
const radius = parseInt(document.getElementById('filterRadius').value);
|
||||
savedFilterCity = city || null;
|
||||
savedFilterLat = _inputLat;
|
||||
savedFilterLon = _inputLon;
|
||||
savedFilterRadius = radius;
|
||||
saveFilterToDb(savedFilterCity, savedFilterLat, savedFilterLon, savedFilterRadius);
|
||||
updateFilterBadge();
|
||||
runSearch();
|
||||
});
|
||||
|
||||
document.getElementById('resetBtn').addEventListener('click', () => {
|
||||
clearFilterCity();
|
||||
document.getElementById('filterRadius').value = 50;
|
||||
document.getElementById('distVal').textContent = '50 km';
|
||||
});
|
||||
|
||||
function saveFilterToDb(city, lat, lon, radius) {
|
||||
fetch('/user/me/location-filter', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filterCity: city, filterLat: lat, filterLon: lon, filterMaxDistKm: radius })
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function updateFilterBadge() {
|
||||
const active = savedFilterCity != null;
|
||||
const badge = document.getElementById('filterBadge');
|
||||
badge.textContent = active ? '1' : '';
|
||||
badge.style.display = active ? 'inline-flex' : 'none';
|
||||
}
|
||||
|
||||
// ── Suche ─────────────────────────────────────────────────────────────────────
|
||||
const searchState = { allIds: null, loaded: 0, loading: false, lat: 0, lon: 0 };
|
||||
const BATCH = 20;
|
||||
|
||||
async function runSearch() {
|
||||
if (savedFilterLat === null) {
|
||||
document.getElementById('searchGrid').innerHTML = '';
|
||||
document.getElementById('searchEmpty').style.display = 'none';
|
||||
document.getElementById('searchHint').style.display = '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('searchGrid').innerHTML = '';
|
||||
document.getElementById('searchEmpty').style.display = 'none';
|
||||
document.getElementById('searchHint').style.display = 'none';
|
||||
|
||||
const res = await fetch(`/locations/ids?lat=${savedFilterLat}&lon=${savedFilterLon}&maxDistanceKm=${savedFilterRadius}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
searchState.allIds = data.ids;
|
||||
searchState.loaded = 0;
|
||||
searchState.loading = false;
|
||||
searchState.lat = savedFilterLat;
|
||||
searchState.lon = savedFilterLon;
|
||||
|
||||
if (data.ids.length === 0) { document.getElementById('searchEmpty').style.display = ''; return; }
|
||||
loadNextBatch();
|
||||
}
|
||||
|
||||
async function loadNextBatch() {
|
||||
if (searchState.loading || searchState.allIds === null) return;
|
||||
if (searchState.loaded >= searchState.allIds.length) return;
|
||||
searchState.loading = true;
|
||||
const slice = searchState.allIds.slice(searchState.loaded, searchState.loaded + BATCH);
|
||||
const res = await fetch('/locations/batch', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ ids: slice, lat: searchState.lat, lon: searchState.lon })
|
||||
});
|
||||
if (res.ok) {
|
||||
const previews = await res.json();
|
||||
const grid = document.getElementById('searchGrid');
|
||||
previews.forEach(p => {
|
||||
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)}">`
|
||||
: '<span>📍</span>';
|
||||
a.innerHTML = `
|
||||
<div class="loc-card-img">${imgHtml}</div>
|
||||
<div class="loc-card-body">
|
||||
<div class="loc-card-name">${escHtml(p.name)}</div>
|
||||
${p.distanzKm >= 0 ? `<div class="loc-card-dist">${p.distanzKm} km</div>` : ''}
|
||||
</div>`;
|
||||
grid.appendChild(a);
|
||||
});
|
||||
searchState.loaded += slice.length;
|
||||
}
|
||||
searchState.loading = false;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) loadNextBatch();
|
||||
}, { rootMargin: '300px' });
|
||||
observer.observe(document.getElementById('searchSentinel'));
|
||||
|
||||
// ── Meine Locations ───────────────────────────────────────────────────────────
|
||||
let mineIds = [];
|
||||
async function loadMine() {
|
||||
const res = await fetch('/locations/mine');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
mineIds = data.ids;
|
||||
document.getElementById('mineEmpty').style.display = mineIds.length === 0 ? '' : 'none';
|
||||
if (mineIds.length === 0) return;
|
||||
const batchRes = await fetch('/locations/batch', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ ids: mineIds, lat: 0, lon: 0 })
|
||||
});
|
||||
if (!batchRes.ok) return;
|
||||
const previews = await batchRes.json();
|
||||
const grid = document.getElementById('mineGrid');
|
||||
grid.innerHTML = '';
|
||||
previews.forEach(p => {
|
||||
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)}">`
|
||||
: '<span>📍</span>';
|
||||
a.innerHTML = `
|
||||
<div class="loc-card-img">${imgHtml}</div>
|
||||
<div class="loc-card-body">
|
||||
<div class="loc-card-name">${escHtml(p.name)}</div>
|
||||
</div>`;
|
||||
grid.appendChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── Create Modal ──────────────────────────────────────────────────────────────
|
||||
let _createLq = null, _createHq = null;
|
||||
let _createLat = null, _createLon = null;
|
||||
let _createStreet = null, _createCity = null;
|
||||
let _createCityTimer = null;
|
||||
|
||||
const DAY_NAMES = ['Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag','Sonntag'];
|
||||
|
||||
function buildHoursGrid(gridId) {
|
||||
const grid = document.getElementById(gridId);
|
||||
grid.innerHTML = '';
|
||||
DAY_NAMES.forEach((name, i) => {
|
||||
const day = i + 1;
|
||||
grid.insertAdjacentHTML('beforeend', `
|
||||
<span>${name}</span>
|
||||
<input type="time" id="open_${day}" placeholder="--:--" style="font-size:0.82rem;padding:0.25rem 0.4rem;">
|
||||
<input type="time" id="close_${day}" placeholder="--:--" style="font-size:0.82rem;padding:0.25rem 0.4rem;">
|
||||
<label style="display:flex;align-items:center;gap:0.25rem;font-size:0.82rem;white-space:nowrap;"><input type="checkbox" id="closed_${day}"> Geschlossen</label>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
_createLq = null; _createHq = null;
|
||||
_createLat = null; _createLon = null; _createStreet = null; _createCity = null;
|
||||
document.getElementById('createName').value = '';
|
||||
document.getElementById('createDesc').value = '';
|
||||
document.getElementById('createCity').value = '';
|
||||
document.getElementById('createCity').readOnly = false;
|
||||
document.getElementById('createCityClear').style.display = 'none';
|
||||
document.getElementById('createLocMsg').textContent = '';
|
||||
document.getElementById('createPicPreview').innerHTML = '📍';
|
||||
document.getElementById('createOwnership').checked = false;
|
||||
buildHoursGrid('createHoursGrid');
|
||||
document.getElementById('createModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeCreateModal() { document.getElementById('createModal').classList.remove('open'); }
|
||||
|
||||
async function onCreatePicChange(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
_createLq = await resizeImage(file, 120, 0.75);
|
||||
_createHq = await resizeImage(file, 1024, 0.88);
|
||||
const preview = document.getElementById('createPicPreview');
|
||||
preview.innerHTML = `<img src="data:image/jpeg;base64,${_createHq}" alt="Vorschau">`;
|
||||
}
|
||||
|
||||
function onCreateCityInput() {
|
||||
const q = document.getElementById('createCity').value.trim();
|
||||
_createLat = null; _createLon = null; _createStreet = null; _createCity = null;
|
||||
document.getElementById('createCityClear').style.display = 'none';
|
||||
clearTimeout(_createCityTimer);
|
||||
if (q.length < 2) { document.getElementById('createCitySuggestions').style.display = 'none'; return; }
|
||||
_createCityTimer = setTimeout(() => fetchAddressSuggestions(q), 300);
|
||||
}
|
||||
|
||||
function fmtAddress(r) {
|
||||
const road = r.address.road || r.address.pedestrian || r.address.path || '';
|
||||
const hn = r.address.house_number || '';
|
||||
const street = (road + (hn ? ' ' + hn : '')).trim();
|
||||
const plz = r.address.postcode || '';
|
||||
const city = r.address.city || r.address.town || r.address.village || r.address.county || '';
|
||||
const parts = [];
|
||||
if (street) parts.push(street);
|
||||
const cityPart = (plz && city) ? plz + ' ' + city : (plz || city);
|
||||
if (cityPart) parts.push(cityPart);
|
||||
return { label: parts.join(', '), street, city };
|
||||
}
|
||||
|
||||
async function fetchAddressSuggestions(q) {
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&addressdetails=1&limit=5`;
|
||||
const res = await fetch(url, { headers: { 'Accept-Language': 'de' } });
|
||||
if (!res.ok) return;
|
||||
const results = await res.json();
|
||||
const ul = document.getElementById('createCitySuggestions');
|
||||
if (!results.length) { ul.style.display = 'none'; return; }
|
||||
ul.innerHTML = results.map(r => {
|
||||
const { label, street, city } = fmtAddress(r);
|
||||
const esc = s => s.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||||
return `<li style="padding:0.5rem 0.75rem;cursor:pointer;font-size:0.9rem;border-bottom:1px solid var(--color-secondary);"
|
||||
onmousedown="selectCreateAddress(event,'${esc(label)}','${esc(street)}','${esc(city)}',${parseFloat(r.lat)},${parseFloat(r.lon)})">${label}</li>`;
|
||||
}).join('');
|
||||
ul.style.display = '';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function selectCreateAddress(e, label, street, city, lat, lon) {
|
||||
e.preventDefault();
|
||||
const inp = document.getElementById('createCity');
|
||||
inp.value = label; inp.readOnly = true;
|
||||
_createStreet = street || null;
|
||||
_createCity = city || null;
|
||||
_createLat = lat; _createLon = lon;
|
||||
document.getElementById('createCityClear').style.display = '';
|
||||
document.getElementById('createCitySuggestions').style.display = 'none';
|
||||
document.getElementById('createLocMsg').textContent = '';
|
||||
}
|
||||
|
||||
function clearCreateCity() {
|
||||
const inp = document.getElementById('createCity');
|
||||
inp.value = ''; inp.readOnly = false;
|
||||
_createLat = null; _createLon = null; _createStreet = null; _createCity = null;
|
||||
document.getElementById('createCityClear').style.display = 'none';
|
||||
document.getElementById('createLocMsg').textContent = '';
|
||||
inp.focus();
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('#createStadtRow')) document.getElementById('createCitySuggestions').style.display = 'none';
|
||||
});
|
||||
|
||||
function collectHours() {
|
||||
const result = [];
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
const open = document.getElementById(`open_${d}`)?.value;
|
||||
const close = document.getElementById(`close_${d}`)?.value;
|
||||
const closed = document.getElementById(`closed_${d}`)?.checked;
|
||||
if (open || close || closed) {
|
||||
result.push({ dayOfWeek: d, openTime: open || null, closeTime: close || null, closed: !!closed });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const name = document.getElementById('createName').value.trim();
|
||||
const desc = document.getElementById('createDesc').value.trim();
|
||||
const confirmed = document.getElementById('createOwnership').checked;
|
||||
|
||||
if (!name) { alert('Bitte gib einen Namen ein.'); return; }
|
||||
if (!_createLat || !_createLon) {
|
||||
document.getElementById('createLocMsg').textContent = 'Bitte eine Adresse aus der Vorschlagsliste auswählen oder per GPS ermitteln.';
|
||||
document.getElementById('createCity').focus();
|
||||
return;
|
||||
}
|
||||
if (!confirmed) { alert('Bitte bestätige, dass du Eigentümer*in der Location bist.'); return; }
|
||||
|
||||
const btn = document.getElementById('createSubmitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Wird gespeichert…';
|
||||
|
||||
try {
|
||||
const res = await fetch('/locations', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: desc || null,
|
||||
profilePictureLq: _createLq,
|
||||
profilePictureHq: _createHq,
|
||||
lat: _createLat,
|
||||
lon: _createLon,
|
||||
street: _createStreet || null,
|
||||
city: _createCity || null,
|
||||
ownershipConfirmed: true
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Speichern');
|
||||
const loc = await res.json();
|
||||
|
||||
// Öffnungszeiten setzen
|
||||
const hours = collectHours();
|
||||
if (hours.length > 0) {
|
||||
await fetch(`/locations/${loc.locationId}/opening-hours`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(hours)
|
||||
});
|
||||
}
|
||||
|
||||
closeCreateModal();
|
||||
window.location.href = `/community/location-detail.html?id=${loc.locationId}`;
|
||||
} catch (err) {
|
||||
alert('Fehler beim Anlegen: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Anlegen';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
fetch('/login/me').then(r => r.ok ? r.json() : null).then(user => {
|
||||
if (!user) return;
|
||||
if (user.filterCity) {
|
||||
savedFilterCity = user.filterCity;
|
||||
savedFilterLat = user.filterLat;
|
||||
savedFilterLon = user.filterLon;
|
||||
savedFilterRadius = user.filterMaxDistKm || 50;
|
||||
document.getElementById('filterCity').value = savedFilterCity;
|
||||
document.getElementById('filterCity').readOnly = true;
|
||||
document.getElementById('filterCityClear').style.display = '';
|
||||
document.getElementById('filterRadius').value = savedFilterRadius;
|
||||
document.getElementById('distVal').textContent = savedFilterRadius + ' km';
|
||||
_inputLat = savedFilterLat;
|
||||
_inputLon = savedFilterLon;
|
||||
updateFilterBadge();
|
||||
runSearch();
|
||||
} else {
|
||||
document.getElementById('searchHint').style.display = '';
|
||||
}
|
||||
}).catch(() => { document.getElementById('searchHint').style.display = ''; });
|
||||
loadMine();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -52,6 +52,8 @@
|
||||
{ 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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user