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

This commit is contained in:
2026-04-25 16:56:35 +02:00
parent e4b762f905
commit 4f2048bdc8
242 changed files with 14108 additions and 1770 deletions

View File

@@ -1,39 +1,11 @@
Slomo und Speedup Card
Umsetzung des Spiels:
Der Lockee hat eine Stunde Zeit das Spiel zu starten, dies geschieht per Knopfdruck
Wenn er dies nicht schafft -> bei Keyholder, benachrichtige Keyholder und lass sie/ihn entscheiden, ansonsten freeze wie bei freeze card
Übernimm die Logik des Spiels aus dem BDSM Game.
Falls eine Zeitstrafe eine temporäre Öffnung vor oder nach der Aufgabe benötigt, öffne das Lock für 5 Minuten. Überzogene Zeit wird addiert und am Ende des Locks gefreezed
Selbiges gilt, falls der finisher eine temporäre Öffnung danach erfoldert
Benötigt der Finisher eine Öffnung davor, verwende die Logik der Cum Card, und addiere diese Zeit auf die möglicherweise schon vorhandenen Freeze Zeit am Ende des Locks
Sammeln von Erfahrung
TODO: Im Time Lock, wenn im Spinning Wheel tasks drin sind, dürfen keine sonst keine Tasks gefordert sein und umgekehrt
Ich kann Spieler einladen zu spielen, dann kriegt die Person eine E-Mail und muss bestätigen, dass es diese PErson ist, sie wird dann ins spiel übernommen
-- Falls fall mit Chastity auftritt wird die Spielpartnerin als Keyholder eingetragen, diese Person darf entscheiden, was für ein Lock das wird.
Hier ein paar Ideen für neue Kartentypen:
Bestrafungskarten
- Straf-Karte Lockee muss eine vorher definierte Strafe erfüllen (ähnlich Task, aber negativer konnotiert)
- Extra-Rot Fügt sofort 2-3 rote Karten hinzu, kein Ziehen möglich
Belohnungskarten
- Bonus-Grün LatestOpeningTime wird auf jetzt gesetzt (sofortige Öffnungsmöglichkeit), aber nur kurz gültig (z.B. 30 Minuten Fenster)
- Karten entfernen Lockee darf eine bestimmte Anzahl roter Karten aus dem Deck entfernen
Ereigniskarten
- Würfel-Karte Zufällige Aktion: 1-2 = Freeze, 3-4 = Nichts, 5-6 = Grüne Karte
- Umkehr-Karte Die nächste Karte hat den umgekehrten Effekt (Rot → Grün, Freeze → Beschleunigung)
- Überraschungs-Karte Community, Keyholder oder Zufalls-Task, je nachdem was gerade konfiguriert ist
Zeitkarten
- Verlängerungs-Karte Verschiebt die latestOpeningtime nach hinten (nur bei Keyholder-Locks sinnvoll)
- Countdown-Karte Setzt einen Timer; wenn die Lockee innerhalb der Zeit eine Aufgabe erledigt, wird eine grüne Karte freigeschaltet
- Hygiene-Skip Nächste Hygiene-Öffnung wird übersprungen/gezählt ohne tatsächliche Öffnung
Soziale Karten
- Verifizierungs-Karte Erzwingt sofort eine Verifikations-Session
- Keyholder-Wahl Keyholder entscheidet frei was passiert (Freitext-Eingabe möglich)
- Community-Entscheid Community stimmt nicht über eine Aufgabe ab, sondern darüber was als nächstes passiert (z.B. Freeze vs. Aufgabe)
Die interessantesten wären wohl Würfel und Countdown, da sie mehr Spannung erzeugen ohne den Ablauf zu sehr zu unterbrechen.

View File

@@ -232,7 +232,7 @@ public class AdminController {
entity.setName(gruppe.getName());
entity.setBeschreibung(gruppe.getBeschreibung());
entity.setVon(gruppe.getVon());
entity.setVanillaAvailable(gruppe.isVanillaAvailable());
entity.setAvailableIn(gruppe.getAvailableIn());
if (gruppe.getBild() != null) {
entity.setBild(java.util.Base64.getDecoder().decode(gruppe.getBild()));
}

View File

@@ -1,11 +1,14 @@
package de.oaa.xxx.config;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@@ -17,6 +20,8 @@ import java.util.Collections;
@Component
public class JwtFilter extends OncePerRequestFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(JwtFilter.class);
private final JwtService jwtService;
private final TokenBlacklistService tokenBlacklist;
@@ -41,8 +46,10 @@ public class JwtFilter extends OncePerRequestFilter {
);
SecurityContextHolder.getContext().setAuthentication(auth);
}
} catch (Exception e) {
} catch (JwtException e) {
// Ungültiger oder abgelaufener Token ohne Authentifizierung weiter
} catch (Exception e) {
LOGGER.warn("Unexpected error processing JWT token", e);
}
break;
}

View File

@@ -11,6 +11,7 @@ import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
@Component
public class RateLimitFilter extends OncePerRequestFilter {
@@ -22,8 +23,11 @@ public class RateLimitFilter extends OncePerRequestFilter {
"/login", "/registration", "/password-reset"
};
private static final int CLEANUP_INTERVAL = 500;
private record Window(AtomicInteger count, long startMs) {}
private final Map<String, Window> windows = new ConcurrentHashMap<>();
private final AtomicLong requestCount = new AtomicLong();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
@@ -40,7 +44,7 @@ public class RateLimitFilter extends OncePerRequestFilter {
}
if (isRateLimited) {
String ip = getClientIp(request);
String ip = request.getRemoteAddr();
String key = ip + ":" + path;
long now = System.currentTimeMillis();
@@ -57,16 +61,13 @@ public class RateLimitFilter extends OncePerRequestFilter {
response.getWriter().write("Too many requests");
return;
}
if (requestCount.incrementAndGet() % CLEANUP_INTERVAL == 0) {
windows.entrySet().removeIf(e -> now - e.getValue().startMs() > WINDOW_MS);
}
}
chain.doFilter(request, response);
}
private String getClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
return xff.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}

View File

@@ -3,8 +3,12 @@ package de.oaa.xxx.feed;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -17,9 +21,9 @@ import org.springframework.data.domain.Slice;
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.PutMapping;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -28,11 +32,9 @@ import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.feed.dto.FeedItemDto;
import de.oaa.xxx.feed.dto.FeedPostRequest;
import de.oaa.xxx.feed.entity.FeedPostEntity;
import de.oaa.xxx.feed.entity.PosterType;
import de.oaa.xxx.hashtag.HashtagService;
import de.oaa.xxx.hashtag.PostHashtagEntity;
import de.oaa.xxx.feed.entity.FeedPostOptionEntity;
import de.oaa.xxx.feed.entity.FeedPostVoteEntity;
import de.oaa.xxx.feed.entity.PosterType;
import de.oaa.xxx.feed.repository.FeedPostLikeRepository;
import de.oaa.xxx.feed.repository.FeedPostOptionRepository;
import de.oaa.xxx.feed.repository.FeedPostRepository;
@@ -48,7 +50,8 @@ import de.oaa.xxx.gruppe.repository.GruppenbeitragRepository;
import de.oaa.xxx.gruppe.repository.GruppenmitgliedRepository;
import de.oaa.xxx.gruppe.repository.UmfrageOptionRepository;
import de.oaa.xxx.gruppe.repository.UmfrageStimmeRepository;
import de.oaa.xxx.location.entity.LocationEntity;
import de.oaa.xxx.hashtag.HashtagService;
import de.oaa.xxx.hashtag.PostHashtagEntity;
import de.oaa.xxx.location.entity.LocationFollowEntity;
import de.oaa.xxx.location.repository.LocationAdminRepository;
import de.oaa.xxx.location.repository.LocationFollowRepository;
@@ -60,10 +63,11 @@ import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
import de.oaa.xxx.util.BaseController;
@RestController
@RequestMapping("/feed")
public class FeedController {
public class FeedController extends BaseController {
private static final Logger LOGGER = LoggerFactory.getLogger(FeedController.class);
@@ -80,7 +84,6 @@ public class FeedController {
private final GruppeRepository gruppeRepository;
private final KommentarRepository kommentarRepository;
private final UserRepository userRepository;
private final UserService userService;
private final LikeService likeService;
private final HashtagService hashtagService;
private final LocationRepository locationRepository;
@@ -106,6 +109,7 @@ public class FeedController {
LocationRepository locationRepository,
LocationFollowRepository locationFollowRepository,
LocationAdminRepository locationAdminRepository) {
super(userService);
this.feedPostRepository = feedPostRepository;
this.feedPostLikeRepository = feedPostLikeRepository;
this.feedPostOptionRepository = feedPostOptionRepository;
@@ -119,7 +123,6 @@ public class FeedController {
this.gruppeRepository = gruppeRepository;
this.kommentarRepository = kommentarRepository;
this.userRepository = userRepository;
this.userService = userService;
this.likeService = likeService;
this.hashtagService = hashtagService;
this.locationRepository = locationRepository;
@@ -138,7 +141,7 @@ public class FeedController {
public ResponseEntity<FeedItemDto> createPost(@RequestBody FeedPostRequest req, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (req.text() == null || req.text().isBlank()) return ResponseEntity.badRequest().build();
if (req.text() == null || req.text().isBlank() || req.text().length() > 5000) return ResponseEntity.badRequest().build();
BeitragTyp typ;
try {
@@ -193,7 +196,7 @@ public class FeedController {
|| locationAdminRepository.existsByLocationIdAndUserId(locationId, myId);
if (!isAdmin) return ResponseEntity.status(403).build();
if (req.text() == null || req.text().isBlank()) return ResponseEntity.badRequest().build();
if (req.text() == null || req.text().isBlank() || req.text().length() > 5000) return ResponseEntity.badRequest().build();
BeitragTyp typ;
try {
@@ -279,12 +282,10 @@ public class FeedController {
followedLocationIds, PosterType.LOCATION, since);
// Merge, convert, sort
List<FeedPostEntity> allFeedPosts = Stream.concat(feedPosts.stream(), locationPosts.stream()).toList();
List<FeedItemDto> merged = Stream.concat(
Stream.concat(
feedPosts.stream().map(p -> toFeedItemDtoFromPost(p, myId)),
gruppePosts.stream().map(b -> toFeedItemDtoFromGruppe(b, myId))
),
locationPosts.stream().map(p -> toFeedItemDtoFromPost(p, myId))
toDtosBatch(allFeedPosts, myId).stream(),
gruppePosts.stream().map(b -> toFeedItemDtoFromGruppe(b, myId))
).sorted(Comparator.comparing(FeedItemDto::createdAt).reversed()).toList();
int from = page * size;
@@ -307,9 +308,7 @@ public class FeedController {
Slice<FeedPostEntity> slice = feedPostRepository
.findByIsPublicTrueOrderByCreatedAtDesc(PageRequest.of(page, size));
List<FeedItemDto> items = slice.getContent().stream()
.map(p -> toFeedItemDtoFromPost(p, myId))
.toList();
List<FeedItemDto> items = toDtosBatch(slice.getContent(), myId);
return ResponseEntity.ok(new FeedPage(items, slice.hasNext()));
}
@@ -324,7 +323,7 @@ public class FeedController {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
PageRequest pageable = PageRequest.of(page, size);
PageRequest pageable = PageRequest.of(page, size + 1);
List<FeedPostEntity> posts;
if (myId.equals(userId)) {
posts = feedPostRepository.findByAuthorIdOrderByCreatedAtDesc(userId, pageable);
@@ -332,20 +331,10 @@ public class FeedController {
posts = feedPostRepository.findByAuthorIdAndIsPublicTrueOrderByCreatedAtDesc(userId, pageable);
}
// Check if there's a next page
PageRequest nextPageable = PageRequest.of(page + 1, size);
List<FeedPostEntity> nextPage;
if (myId.equals(userId)) {
nextPage = feedPostRepository.findByAuthorIdOrderByCreatedAtDesc(userId, nextPageable);
} else {
nextPage = feedPostRepository.findByAuthorIdAndIsPublicTrueOrderByCreatedAtDesc(userId, nextPageable);
}
boolean hasMore = posts.size() > size;
List<FeedItemDto> items = toDtosBatch(posts.stream().limit(size).toList(), myId);
List<FeedItemDto> items = posts.stream()
.map(p -> toFeedItemDtoFromPost(p, myId))
.toList();
return ResponseEntity.ok(new FeedPage(items, !nextPage.isEmpty()));
return ResponseEntity.ok(new FeedPage(items, hasMore));
}
// ── GET /feed/location/{locationId} ──
@@ -359,19 +348,14 @@ public class FeedController {
if (myId == null) return ResponseEntity.status(401).build();
if (!locationRepository.existsById(locationId)) return ResponseEntity.notFound().build();
PageRequest pageable = PageRequest.of(page, size);
PageRequest pageable = PageRequest.of(page, size + 1);
List<FeedPostEntity> posts = feedPostRepository
.findByAuthorIdAndPosterTypeOrderByCreatedAtDesc(locationId, PosterType.LOCATION, pageable);
PageRequest nextPageable = PageRequest.of(page + 1, size);
List<FeedPostEntity> nextPage = feedPostRepository
.findByAuthorIdAndPosterTypeOrderByCreatedAtDesc(locationId, PosterType.LOCATION, nextPageable);
boolean hasMore = posts.size() > size;
List<FeedItemDto> items = toDtosBatch(posts.stream().limit(size).toList(), myId);
List<FeedItemDto> items = posts.stream()
.map(p -> toFeedItemDtoFromPost(p, myId))
.toList();
return ResponseEntity.ok(new FeedPage(items, !nextPage.isEmpty()));
return ResponseEntity.ok(new FeedPage(items, hasMore));
}
// ── GET /feed/hashtag?tag= ──
@@ -485,7 +469,7 @@ public class FeedController {
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (req.text() == null || req.text().isBlank()) return ResponseEntity.badRequest().build();
if (req.text() == null || req.text().isBlank() || req.text().length() > 5000) return ResponseEntity.badRequest().build();
var postOpt = feedPostRepository.findById(id);
if (postOpt.isEmpty()) return ResponseEntity.notFound().build();
@@ -583,66 +567,115 @@ public class FeedController {
return false;
}
private UUID resolveMyId(Principal principal) {
if (principal == null) return null;
return userService.requireUser(principal).getUserId();
private List<FeedItemDto> toDtosBatch(List<FeedPostEntity> posts, UUID myId) {
if (posts.isEmpty()) return List.of();
List<UUID> postIds = posts.stream().map(FeedPostEntity::getPostId).toList();
Set<UUID> userAuthorIds = posts.stream()
.filter(p -> p.getPosterType() == null || p.getPosterType() == PosterType.USER)
.map(FeedPostEntity::getAuthorId).collect(Collectors.toSet());
Set<UUID> locationAuthorIds = posts.stream()
.filter(p -> p.getPosterType() == PosterType.LOCATION)
.map(FeedPostEntity::getAuthorId).collect(Collectors.toSet());
Map<UUID, UserEntity> userMap = userRepository.findAllById(userAuthorIds)
.stream().collect(Collectors.toMap(UserEntity::getUserId, u -> u));
Map<UUID, de.oaa.xxx.location.entity.LocationEntity> locationMap = locationRepository.findAllById(locationAuthorIds)
.stream().collect(Collectors.toMap(de.oaa.xxx.location.entity.LocationEntity::getLocationId, l -> l));
Map<UUID, Long> likeCountMap = new HashMap<>();
feedPostLikeRepository.countsByPostIds(postIds)
.forEach(r -> likeCountMap.put((UUID) r[0], (Long) r[1]));
Set<UUID> likedByMeSet = new HashSet<>(feedPostLikeRepository.likedPostIdsByUser(postIds, myId));
Map<UUID, Long> commentCountMap = new HashMap<>();
kommentarRepository.countsByTargetTypeAndTargetIds("FEED_POST", postIds)
.forEach(r -> commentCountMap.put((UUID) r[0], (Long) r[1]));
List<UUID> umfragePostIds = posts.stream()
.filter(p -> p.getBeitragTyp() == BeitragTyp.UMFRAGE)
.map(FeedPostEntity::getPostId).toList();
Map<UUID, List<FeedPostOptionEntity>> optionenMap = Collections.emptyMap();
Map<UUID, Long> voteCountMap = Collections.emptyMap();
Map<UUID, List<UUID>> myVotesMap = Collections.emptyMap();
if (!umfragePostIds.isEmpty()) {
List<FeedPostOptionEntity> allOptions = feedPostOptionRepository.findByPostIdInOrderByReihenfolgeAsc(umfragePostIds);
optionenMap = allOptions.stream().collect(Collectors.groupingBy(FeedPostOptionEntity::getPostId));
List<UUID> allOptionIds = allOptions.stream().map(FeedPostOptionEntity::getOptionId).toList();
if (!allOptionIds.isEmpty()) {
Map<UUID, Long> vc = new HashMap<>();
feedPostVoteRepository.countsByOptionIds(allOptionIds).forEach(r -> vc.put((UUID) r[0], (Long) r[1]));
voteCountMap = vc;
}
myVotesMap = feedPostVoteRepository.findByPostIdsAndUserId(umfragePostIds, myId)
.stream().collect(Collectors.groupingBy(FeedPostVoteEntity::getPostId,
Collectors.mapping(FeedPostVoteEntity::getOptionId, Collectors.toList())));
}
final Map<UUID, List<FeedPostOptionEntity>> finalOptionenMap = optionenMap;
final Map<UUID, Long> finalVoteCountMap = voteCountMap;
final Map<UUID, List<UUID>> finalMyVotesMap = myVotesMap;
return posts.stream().map(p -> {
PosterType pt = p.getPosterType() != null ? p.getPosterType() : PosterType.USER;
String authorName, authorPicture;
UUID locationId = null;
String locationName = null;
if (pt == PosterType.LOCATION) {
var loc = locationMap.get(p.getAuthorId());
authorName = loc != null ? loc.getName() : "Unbekannt";
authorPicture = loc != null ? loc.getProfilePictureLq() : null;
locationId = p.getAuthorId();
locationName = authorName;
} else {
var author = userMap.get(p.getAuthorId());
authorName = author != null ? author.getName() : "Unbekannt";
authorPicture = author != null ? author.getProfilePicture() : null;
}
long likeCount = likeCountMap.getOrDefault(p.getPostId(), 0L);
boolean likedByMe = likedByMeSet.contains(p.getPostId());
long kommentarCount = commentCountMap.getOrDefault(p.getPostId(), 0L);
List<UmfrageOptionDto> optionen = List.of();
List<UUID> myVoteOptionIds = List.of();
if (p.getBeitragTyp() == BeitragTyp.UMFRAGE) {
List<FeedPostOptionEntity> opts = finalOptionenMap.getOrDefault(p.getPostId(), List.of());
optionen = opts.stream()
.map(o -> new UmfrageOptionDto(o.getOptionId(), o.getText(), o.getReihenfolge(),
finalVoteCountMap.getOrDefault(o.getOptionId(), 0L)))
.toList();
myVoteOptionIds = finalMyVotesMap.getOrDefault(p.getPostId(), List.of());
}
return new FeedItemDto(
p.getPostId(), "FEED",
null, null,
p.getAuthorId(), authorName, authorPicture,
p.getBeitragTyp().name(), p.getText(), p.getMultiChoice(), p.getBilder(),
p.getCreatedAt(),
likeCount, likedByMe, kommentarCount,
optionen, myVoteOptionIds,
p.isPublic(),
p.getTargetUrl(),
p.getEditedAt(),
pt.name(),
locationId, locationName
);
}).toList();
}
private FeedItemDto toFeedItemDtoFromPost(FeedPostEntity p, UUID myId) {
PosterType pt = p.getPosterType() != null ? p.getPosterType() : PosterType.USER;
String authorName;
String authorPicture;
UUID locationId = null;
String locationName = null;
if (pt == PosterType.LOCATION) {
LocationEntity loc = locationRepository.findById(p.getAuthorId()).orElse(null);
authorName = loc != null ? loc.getName() : "Unbekannt";
authorPicture = loc != null ? loc.getProfilePictureLq() : null;
locationId = p.getAuthorId();
locationName = authorName;
} else {
UserEntity author = userRepository.findById(p.getAuthorId()).orElse(null);
authorName = author != null ? author.getName() : "Unbekannt";
authorPicture = author != null ? author.getProfilePicture() : null;
}
long likeCount = feedPostLikeRepository.countByPostId(p.getPostId());
boolean likedByMe = feedPostLikeRepository.findByPostIdAndUserId(p.getPostId(), myId).isPresent();
long kommentarCount = kommentarRepository.countByTargetTypeAndTargetId("FEED_POST", p.getPostId());
List<UmfrageOptionDto> optionen = List.of();
List<UUID> myVoteOptionIds = List.of();
if (p.getBeitragTyp() == BeitragTyp.UMFRAGE) {
optionen = feedPostOptionRepository.findByPostIdOrderByReihenfolge(p.getPostId())
.stream()
.map(o -> new UmfrageOptionDto(o.getOptionId(), o.getText(), o.getReihenfolge(),
feedPostVoteRepository.countByOptionId(o.getOptionId())))
.toList();
myVoteOptionIds = feedPostVoteRepository.findByPostIdAndUserId(p.getPostId(), myId)
.stream()
.map(FeedPostVoteEntity::getOptionId)
.toList();
}
return new FeedItemDto(
p.getPostId(), "FEED",
null, null,
p.getAuthorId(),
authorName,
authorPicture,
p.getBeitragTyp().name(), p.getText(), p.getMultiChoice(), p.getBilder(),
p.getCreatedAt(),
likeCount, likedByMe, kommentarCount,
optionen, myVoteOptionIds,
p.isPublic(),
p.getTargetUrl(),
p.getEditedAt(),
pt.name(),
locationId,
locationName
);
return toDtosBatch(List.of(p), myId).get(0);
}
private FeedItemDto toFeedItemDtoFromGruppe(GruppenbeitragEntity b, UUID myId) {

View File

@@ -2,8 +2,11 @@ package de.oaa.xxx.feed.repository;
import de.oaa.xxx.feed.entity.FeedPostLikeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -15,4 +18,10 @@ public interface FeedPostLikeRepository extends JpaRepository<FeedPostLikeEntity
@Transactional
void deleteByPostId(UUID postId);
@Query("SELECT l.postId, COUNT(l) FROM FeedPostLikeEntity l WHERE l.postId IN :postIds GROUP BY l.postId")
List<Object[]> countsByPostIds(@Param("postIds") List<UUID> postIds);
@Query("SELECT l.postId FROM FeedPostLikeEntity l WHERE l.postId IN :postIds AND l.userId = :userId")
List<UUID> likedPostIdsByUser(@Param("postIds") List<UUID> postIds, @Param("userId") UUID userId);
}

View File

@@ -11,6 +11,8 @@ public interface FeedPostOptionRepository extends JpaRepository<FeedPostOptionEn
List<FeedPostOptionEntity> findByPostIdOrderByReihenfolge(UUID postId);
List<FeedPostOptionEntity> findByPostIdInOrderByReihenfolgeAsc(List<UUID> postIds);
@Transactional
void deleteByPostId(UUID postId);
}

View File

@@ -2,6 +2,8 @@ package de.oaa.xxx.feed.repository;
import de.oaa.xxx.feed.entity.FeedPostVoteEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@@ -21,4 +23,10 @@ public interface FeedPostVoteRepository extends JpaRepository<FeedPostVoteEntity
@Transactional
void deleteByPostId(UUID postId);
@Query("SELECT v.optionId, COUNT(v) FROM FeedPostVoteEntity v WHERE v.optionId IN :optionIds GROUP BY v.optionId")
List<Object[]> countsByOptionIds(@Param("optionIds") List<UUID> optionIds);
@Query("SELECT v FROM FeedPostVoteEntity v WHERE v.postId IN :postIds AND v.userId = :userId")
List<FeedPostVoteEntity> findByPostIdsAndUserId(@Param("postIds") List<UUID> postIds, @Param("userId") UUID userId);
}

View File

@@ -64,6 +64,7 @@ public class FeedbackController {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
}
lastCallAt.put(rateLimitKey, now);
lastCallAt.entrySet().removeIf(e -> now - e.getValue() > RATE_LIMIT_SECONDS * 10);
// Eingeloggten User ermitteln (optional)
UUID userId = null;

View File

@@ -8,6 +8,7 @@ import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.xxx.games.common.AbstractGameDurchfuehren;
import de.oaa.xxx.games.common.aufgaben.Aufgabe;
import de.oaa.xxx.games.common.aufgaben.AufgabenList;
import de.oaa.xxx.games.common.aufgaben.Sperre;
@@ -16,31 +17,24 @@ import de.oaa.xxx.games.bdsm.entity.BdsmGameEntity;
import de.oaa.xxx.games.bdsm.sperre.SperreCallback;
import de.oaa.xxx.games.bdsm.sperre.SperrenVerlaengernCallback;
public class BdsmGameDurchfuehren {
public class BdsmGameDurchfuehren extends AbstractGameDurchfuehren {
private final AufgabenList aufgabenList;
private final List<BdsmMitspieler> mitspieler = new ArrayList<>();
private final List<AktiveSperre> aktiveSperren = new ArrayList<>();
private final Integer wahrscheinlichkeitSperre;
private final Integer wahrscheinlichkeitStrafe;
private int aufgabenProLevel;
private int level;
private int aufgabenAufAktuellemLevel;
public BdsmGameDurchfuehren(BdsmGameEntity entity) throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
aufgabenList = objectMapper.readValue(entity.getAufgaben(), AufgabenList.class);
super(new ObjectMapper().readValue(entity.getAufgaben(), AufgabenList.class),
entity.getAufgabenProLevel() != null ? entity.getAufgabenProLevel() : 5,
entity.getLevel() != null ? entity.getLevel() : 1,
entity.getAufgabenAufAktuellemLevel() != null ? entity.getAufgabenAufAktuellemLevel() : 0);
entity.getMitspieler().forEach(mitspielerEntity -> mitspieler.add(mitspielerEntity.toMitspieler()));
entity.getAktiveSperren().forEach(sperreEntity -> aktiveSperren.add(sperreEntity.toSperre(mitspieler)));
wahrscheinlichkeitSperre = entity.getWahrscheinlichkeitSperre();
wahrscheinlichkeitStrafe = entity.getWahrscheinlichkeitStrafe();
this.aufgabenProLevel = entity.getAufgabenProLevel() != null ? entity.getAufgabenProLevel() : 5;
this.level = entity.getLevel() != null ? entity.getLevel() : 1;
this.aufgabenAufAktuellemLevel = entity.getAufgabenAufAktuellemLevel() != null ? entity.getAufgabenAufAktuellemLevel() : 0;
}
public AufgabeAnzeige getNext() {
@@ -85,11 +79,6 @@ public class BdsmGameDurchfuehren {
return anzeige;
}
public void backToLvl5() {
this.level = 5;
this.aufgabenAufAktuellemLevel = 0;
}
public List<AufgabeAnzeige> getFinisher() {
var list = new ArrayList<AufgabeAnzeige>();
List.of(GeschlechtEnum.WEIBLICH, GeschlechtEnum.DIVERS, GeschlechtEnum.MAENNLICH).forEach(geschlecht -> {
@@ -117,13 +106,6 @@ public class BdsmGameDurchfuehren {
return list;
}
private void checkLevel() {
if (++aufgabenAufAktuellemLevel >= 1 + aufgabenProLevel) {
aufgabenAufAktuellemLevel = 0;
level++;
}
}
private void setMitspielerInfo(AufgabeAnzeige anzeige, BdsmMitspieler aktiv) {
if (aktiv != null) {
anzeige.setMitspielerId(aktiv.getId());
@@ -251,10 +233,6 @@ public class BdsmGameDurchfuehren {
return null;
}
private String getAnzeigeText(String textMitPlatzhaltern, String nameAktiv, String namePassiv) {
return textMitPlatzhaltern.replace("{AKTIV}", nameAktiv).replace("{PASSIV}", namePassiv);
}
private BdsmMitspieler findeMitspielerMitRolle(RolleEnum rolle) {
List<BdsmMitspieler> list = mitspieler.stream()
.filter(m -> m.getRollen().contains(rolle))
@@ -272,11 +250,4 @@ public class BdsmGameDurchfuehren {
return list.isEmpty() ? null : list.get(new Random().nextInt(list.size()));
}
public int getAufgabenAufAktuellemLevel() {
return aufgabenAufAktuellemLevel;
}
public int getLevel() {
return level;
}
}

View File

@@ -117,7 +117,7 @@ public class AufgabenGruppeController {
return ResponseEntity.ok(list);
}
/** Gibt eine einzelne Gruppe zurück typunabhängig, da BDSM-Spielstart auch vanillaAvailable-Gruppen laden muss. */
/** Gibt eine einzelne Gruppe zurück typunabhängig, da BDSM-Spielstart auch BDSM_AND_VANILLA-Gruppen laden muss. */
@GetMapping("/{gruppeId}")
public ResponseEntity<AufgabenGruppe> get(@PathVariable("gruppeId") UUID gruppeId) {
return gruppeRepository.findById(gruppeId)
@@ -142,7 +142,7 @@ public class AufgabenGruppeController {
AufgabenGruppeEntity entity = AufgabenGruppeEntity.create(gruppe);
entity.setUserId(user.getUserId());
entity.setPrivateGruppe(true);
// vanillaAvailable kommt aus dem Request-Body (Checkbox im Frontend)
// availableIn kommt aus dem Request-Body (Select im Frontend)
gruppeRepository.save(entity);
LOGGER.debug("User {} hat BDSM-AufgabenGruppe '{}' ({}) erstellt", user.getUserId(), entity.getName(), entity.getGruppenId());
return ResponseEntity.created(
@@ -170,7 +170,7 @@ public class AufgabenGruppeController {
entity.setBeschreibung(gruppe.getBeschreibung());
entity.setVon(gruppe.getVon());
entity.setPrivateGruppe(gruppe.isPrivateGruppe());
entity.setVanillaAvailable(gruppe.isVanillaAvailable());
entity.setAvailableIn(gruppe.getAvailableIn() != null ? gruppe.getAvailableIn() : de.oaa.xxx.games.common.aufgaben.AvailableIn.BDSM_ONLY);
if (gruppe.getBild() != null) {
entity.setBild(Base64.getDecoder().decode(gruppe.getBild()));
}

View File

@@ -55,11 +55,12 @@ import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.social.entity.MessageCause;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
import de.oaa.xxx.util.BaseController;
@RestController
@RequestMapping("/bdsm")
@Transactional
public class BdsmGameController {
public class BdsmGameController extends BaseController {
private static final Logger LOGGER = LoggerFactory.getLogger(BdsmGameController.class);
/**
@@ -77,13 +78,13 @@ public class BdsmGameController {
private final SystemMessageService systemMessageService;
private final CardlockRepository cardlockRepository;
private final BdsmGameService bdsmGameService;
private final UserService userService;
public BdsmGameController(BdsmGameRepository sessionRepository, MitspielerRepository mitspielerRepository,
AktiveSperreRepository aktiveSperreRepository, UserRepository userRepository,
BdsmEinladungRepository einladungRepository, ObjectMapper objectMapper,
SystemMessageService systemMessageService, CardlockRepository cardlockRepository,
BdsmGameService bdsmGameService, UserService userService) {
super(userService);
this.sessionRepository = sessionRepository;
this.mitspielerRepository = mitspielerRepository;
this.aktiveSperreRepository = aktiveSperreRepository;
@@ -93,18 +94,22 @@ public class BdsmGameController {
this.systemMessageService = systemMessageService;
this.cardlockRepository = cardlockRepository;
this.bdsmGameService = bdsmGameService;
this.userService = userService;
}
@GetMapping("/{sessionId}")
public ResponseEntity<BdsmGame> getBySessionId(@PathVariable("sessionId") UUID sessionId) {
return sessionRepository.findById(sessionId)
.map(entity -> ResponseEntity.ok(toSession(entity)))
.orElse(ResponseEntity.noContent().build());
public ResponseEntity<BdsmGame> getBySessionId(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmGameEntity entity = sessionRepository.findById(sessionId).orElse(null);
if (entity == null) return ResponseEntity.noContent().build();
if (!isParticipant(entity, userId)) return ResponseEntity.status(403).build();
return ResponseEntity.ok(toSession(entity));
}
@GetMapping
public ResponseEntity<BdsmGame> getByUserId(@RequestParam("userId") UUID userId) {
public ResponseEntity<BdsmGame> getMySession(Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
return sessionRepository.findByUserId(userId)
.map(entity -> ResponseEntity.ok(toSession(entity)))
.orElse(ResponseEntity.noContent().build());
@@ -146,21 +151,25 @@ public class BdsmGameController {
}
@DeleteMapping
public ResponseEntity<Void> deleteSession(@RequestBody BdsmGame session) {
return sessionRepository.findById(session.getSessionId())
.map(entity -> {
aktiveSperreRepository.deleteAll(entity.getAktiveSperren());
mitspielerRepository.deleteAll(entity.getMitspieler());
sessionRepository.delete(entity);
return ResponseEntity.accepted().<Void>build();
})
.orElse(ResponseEntity.noContent().build());
public ResponseEntity<Void> deleteSession(@RequestBody BdsmGame session, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmGameEntity entity = sessionRepository.findById(session.getSessionId()).orElse(null);
if (entity == null) return ResponseEntity.noContent().build();
if (!isOwner(entity, userId)) return ResponseEntity.status(403).build();
aktiveSperreRepository.deleteAll(entity.getAktiveSperren());
mitspielerRepository.deleteAll(entity.getMitspieler());
sessionRepository.delete(entity);
return ResponseEntity.accepted().build();
}
@PostMapping("/{sessionId}/abgeschlossen")
public ResponseEntity<Void> spielAbgeschlossen(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Void> spielAbgeschlossen(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmGameEntity entity = sessionRepository.findById(sessionId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
if (!isOwner(entity, userId)) return ResponseEntity.status(403).build();
ORDENTLICH_BEENDET.add(sessionId);
bdsmGameService.spielAbschliessen(entity);
return ResponseEntity.accepted().build();
@@ -168,7 +177,11 @@ public class BdsmGameController {
/** Prüft ob eine Session ordentlich (nicht abgebrochen) beendet wurde. */
@GetMapping("/{sessionId}/beendet")
public ResponseEntity<Void> istBeendet(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Void> istBeendet(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmGameEntity entity = sessionRepository.findById(sessionId).orElse(null);
if (entity != null && !isParticipant(entity, userId)) return ResponseEntity.status(403).build();
if (ORDENTLICH_BEENDET.remove(sessionId)) return ResponseEntity.ok().build();
return ResponseEntity.notFound().build();
}
@@ -200,7 +213,9 @@ public class BdsmGameController {
}
@PostMapping("/{sessionId}/aufgaben")
public ResponseEntity<Void> setAufgaben(@RequestBody AufgabenList list, @PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Void> setAufgaben(@RequestBody AufgabenList list, @PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
try {
if (list.size() > 1000) {
return ResponseEntity.badRequest().build();
@@ -210,6 +225,7 @@ public class BdsmGameController {
if (session == null) {
return ResponseEntity.badRequest().build();
}
if (!isOwner(session, userId)) return ResponseEntity.status(403).build();
session.setAufgaben(aufgaben);
sessionRepository.save(session);
// Erst jetzt Einladungen mit der Session verknüpfen Gäste werden nur weitergeleitet wenn aufgaben bereit sind
@@ -227,12 +243,15 @@ public class BdsmGameController {
}
@GetMapping("/{sessionId}/aufgaben/next")
public ResponseEntity<AufgabeAnzeige> getNextAufgabe(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<AufgabeAnzeige> getNextAufgabe(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
try {
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null || session.getAufgaben() == null) {
return ResponseEntity.badRequest().build();
}
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
session.setLetzteAktivitaet(LocalDateTime.now());
BdsmGameDurchfuehren durchfuehren = new BdsmGameDurchfuehren(session);
AufgabeAnzeige next = durchfuehren.getNext();
@@ -258,23 +277,25 @@ public class BdsmGameController {
}
@PostMapping("/{sessionId}/mitspieler")
public ResponseEntity<Void> addMitspieler(@RequestBody BdsmMitspieler mitspieler, @PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Void> addMitspieler(@RequestBody BdsmMitspieler mitspieler, @PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
if (mitspieler.getName() == null || mitspieler.getGeschlecht() == null || mitspieler.getRollen() == null
|| mitspieler.getRollen().isEmpty() || mitspieler.getSpieltMit() == null || mitspieler.getSpieltMit().isEmpty()
|| mitspieler.getVerfuegbareWerkzeuge() == null || mitspieler.getVerfuegbareWerkzeuge().isEmpty()) {
|| mitspieler.getRollen().isEmpty() || mitspieler.getSpieltMit() == null || mitspieler.getSpieltMit().isEmpty()) {
return ResponseEntity.badRequest().build();
}
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) {
return ResponseEntity.badRequest().build();
}
if (!isOwner(session, userId)) return ResponseEntity.status(403).build();
MitspielerEntity entity = new MitspielerEntity();
entity.setMitspielerId(UUID.randomUUID());
entity.setGeschlecht(mitspieler.getGeschlecht());
entity.setName(mitspieler.getName());
entity.setRollen(mitspieler.getRollen());
entity.setSpieltMit(mitspieler.getSpieltMit());
entity.setWerkzeuge(new ArrayList<>(mitspieler.getVerfuegbareWerkzeuge()));
entity.setWerkzeuge(mitspieler.getVerfuegbareWerkzeuge() != null ? new ArrayList<>(mitspieler.getVerfuegbareWerkzeuge()) : new ArrayList<>());
entity.setUserId(mitspieler.getUserId());
entity.setEigenesGeraet(mitspieler.isEigenesGeraet());
entity.setSperrenVorFinaleAufloesen(mitspieler.isSperrenVorFinaleAufloesen());
@@ -313,10 +334,13 @@ public class BdsmGameController {
}
@GetMapping("/{sessionId}/finisher")
public ResponseEntity<List<AufgabeAnzeige>> getFinisher(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<List<AufgabeAnzeige>> getFinisher(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
try {
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.badRequest().build();
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
BdsmGameDurchfuehren durchfuehren = new BdsmGameDurchfuehren(session);
return ResponseEntity.ok(durchfuehren.getFinisher());
} catch (Exception exception) {
@@ -326,10 +350,13 @@ public class BdsmGameController {
}
@PostMapping("/{sessionId}/backToLevel5")
public ResponseEntity<Void> backToLevel5(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Void> backToLevel5(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
try {
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.badRequest().build();
if (!isOwner(session, userId)) return ResponseEntity.status(403).build();
BdsmGameDurchfuehren durchfuehren = new BdsmGameDurchfuehren(session);
durchfuehren.backToLvl5();
session.setLevel(durchfuehren.getLevel());
@@ -366,9 +393,12 @@ public class BdsmGameController {
@PostMapping("/{sessionId}/active-task/abschliessen")
public ResponseEntity<AbschliessenResponse> activeTaskAbschliessen(
@PathVariable("sessionId") UUID sessionId, @RequestBody AbschliessenRequest req) {
@PathVariable("sessionId") UUID sessionId, @RequestBody AbschliessenRequest req, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
SperreVerarbeiten sperreVerarbeiten = new SperreVerarbeiten();
@@ -413,9 +443,12 @@ public class BdsmGameController {
record ActiveTaskResponse(String taskJson, Long elapsedSeconds) {}
@PutMapping("/{sessionId}/active-task")
public ResponseEntity<Void> setActiveTask(@PathVariable("sessionId") UUID sessionId, @RequestBody ActiveTaskRequest req) {
public ResponseEntity<Void> setActiveTask(@PathVariable("sessionId") UUID sessionId, @RequestBody ActiveTaskRequest req, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
session.setActiveTaskJson(req.taskJson());
session.setTaskStartedAt(req.timerStartedAt());
sessionRepository.save(session);
@@ -423,9 +456,12 @@ public class BdsmGameController {
}
@DeleteMapping("/{sessionId}/active-task")
public ResponseEntity<Void> clearActiveTask(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Void> clearActiveTask(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
session.setActiveTaskJson(null);
session.setTaskStartedAt(null);
sessionRepository.save(session);
@@ -433,9 +469,12 @@ public class BdsmGameController {
}
@GetMapping("/{sessionId}/active-task")
public ResponseEntity<ActiveTaskResponse> getActiveTask(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<ActiveTaskResponse> getActiveTask(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
if (session.getActiveTaskJson() == null) return ResponseEntity.noContent().build();
Long elapsed = null;
if (session.getTaskStartedAt() != null) {
@@ -446,9 +485,12 @@ public class BdsmGameController {
// ── Keyholder-Angebot: prüft ob am Ende eine VAGINA/PENIS-Sperre vorliegt ──
@GetMapping("/{sessionId}/keyholder-angebot")
public ResponseEntity<Map<String, Object>> keyholderAngebot(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Map<String, Object>> keyholderAngebot(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
if (!isOwner(session, userId)) return ResponseEntity.status(403).build();
// Alle noch in der DB vorhandenen VAGINA/PENIS-Sperren auch abgelaufene,
// da im Finale-Flow bereits abgelaufene Sperren noch nicht formal aufgehoben wurden.
@@ -484,9 +526,12 @@ public class BdsmGameController {
@GetMapping("/{sessionId}/keyholder-locks")
public ResponseEntity<List<Map<String, Object>>> keyholderLocks(
@PathVariable("sessionId") UUID sessionId, @RequestParam("keyholderUserId") UUID keyholderUserId) {
@PathVariable("sessionId") UUID sessionId, @RequestParam("keyholderUserId") UUID keyholderUserId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
List<Map<String, Object>> result = cardlockRepository.findByKeyholderAndUnlockTimeIsNull(keyholderUserId).stream()
.map(l -> {
@@ -508,7 +553,12 @@ public class BdsmGameController {
@PostMapping("/{sessionId}/zu-chastity")
public ResponseEntity<Map<String, Object>> zuChastity(
@PathVariable("sessionId") UUID sessionId, @RequestBody ZuChastityRequest req) {
@PathVariable("sessionId") UUID sessionId, @RequestBody ZuChastityRequest req, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmGameEntity zuChastitySession = sessionRepository.findById(sessionId).orElse(null);
if (zuChastitySession == null) return ResponseEntity.notFound().build();
if (!isOwner(zuChastitySession, userId)) return ResponseEntity.status(403).build();
try {
CardLockEntity newLock = bdsmGameService.zuChastity(
sessionId, req.lockId(), req.lockeeUserId(), req.keyholderUserId());
@@ -527,7 +577,8 @@ public class BdsmGameController {
/** Gibt zurück welches Werkzeug für einen User durch ein aktives Chastity-Lock blockiert ist. */
@GetMapping("/chastity-constraint")
public ResponseEntity<Map<String, Object>> chastityConstraint(@RequestParam("userId") UUID userId) {
public ResponseEntity<Map<String, Object>> chastityConstraint(@RequestParam("userId") UUID userId, Principal principal) {
if (resolveMyId(principal) == null) return ResponseEntity.status(401).build();
Map<String, Object> result = new LinkedHashMap<>();
if (!cardlockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(userId)) {
result.put("lockedWerkzeug", null);
@@ -552,9 +603,12 @@ public class BdsmGameController {
// ── Debug-Endpoint: vollständiger Entity-Zustand ──
@GetMapping("/{sessionId}/debug")
public ResponseEntity<Map<String, Object>> debug(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Map<String, Object>> debug(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmGameEntity entity = sessionRepository.findById(sessionId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
if (!isOwner(entity, userId)) return ResponseEntity.status(403).build();
Map<String, Object> session = new LinkedHashMap<>();
session.put("sessionId", entity.getSessionId());
@@ -607,6 +661,15 @@ public class BdsmGameController {
return ResponseEntity.ok(result);
}
private boolean isOwner(BdsmGameEntity session, UUID userId) {
return session.getUserId().equals(userId);
}
private boolean isParticipant(BdsmGameEntity session, UUID userId) {
if (isOwner(session, userId)) return true;
return session.getMitspieler().stream().anyMatch(m -> userId.equals(m.getUserId()));
}
private BdsmGame toSession(BdsmGameEntity entity) {
BdsmGame session = new BdsmGame();
session.setSessionId(entity.getSessionId());

View File

@@ -56,7 +56,27 @@ public enum CardEnum {
public Card get() {
return new CumInCageCard();
}
};
},
GAME_CARD {
@Override
public Card get() {
return new GameCard();
}
},
SLOWMO_CARD {
@Override
public Card get() {
// TODO Auto-generated method stub
return new SlowmoCard();
}
},
SPEEDUP_CARD {
@Override
public Card get() {
return new SpeedupCard();
}
}
;
public abstract Card get();

View File

@@ -233,6 +233,14 @@ public class CardLockController {
}
cardlockRepository.save(lock); // erst speichern, damit Lock-ID vorhanden ist
// Erste Karte: geplante Benachrichtigung anlegen
systemMessageService.sendScheduled(
myId, myId,
"🃏 Deine erste Karte ist bereit jetzt ziehen!",
"/games/chastity/activelock.html?lockId=" + lock.getLockId(),
de.oaa.xxx.social.entity.MessageCause.GAME_STATE,
now.plusMinutes(req.pickEveryMinute()));
// Initialen Unlock-Code / TTLock-PIN via LockControl setzen
CardLockService initService = cardLockServiceFactory.create(lock);
if (initService.getLockControl() != null) {
@@ -294,6 +302,17 @@ public class CardLockController {
if (taskPending != null)
result.put("taskPending", taskPending);
// Nächste Karte: geplante Benachrichtigung anlegen (echte nextCardIn aus Entity)
LocalDateTime nextCard = l.getNextCardIn() != null
? l.getNextCardIn()
: LocalDateTime.now().plusMinutes(l.getPickEveryMinute());
systemMessageService.sendScheduled(
myId, myId,
"🃏 Deine nächste Karte ist bereit jetzt ziehen!",
"/games/chastity/activelock.html?lockId=" + lockId,
de.oaa.xxx.social.entity.MessageCause.GAME_STATE,
nextCard);
// Grüne Karte → Entsperrcode-Historie speichern + Keyholder benachrichtigen
if (dto.unlockCode() != null && !dto.unlockCode().isBlank()) {
unlockCodeHistoryService.save(myId, l.getLockId(), l.getName(), dto.unlockCode(), "GREEN_CARD");
@@ -321,8 +340,9 @@ public class CardLockController {
return ResponseEntity.status(403).build();
cardLockServiceFactory.create(l).startHygieneOpening();
return ResponseEntity.ok(Map.of("unlockCode", l.getUnlockCode(), "durationMinutes",
l.getHygineOpeningDurationMinutes() != null ? l.getHygineOpeningDurationMinutes() : 30));
int actualDuration = l.getTempOpeningDuration() != null ? l.getTempOpeningDuration()
: (l.getHygineOpeningDurationMinutes() != null ? l.getHygineOpeningDurationMinutes() : 30);
return ResponseEntity.ok(Map.of("unlockCode", l.getUnlockCode(), "durationMinutes", actualDuration));
}
@PostMapping("/cardlock/{lockId}/hygiene/end")
@@ -602,6 +622,8 @@ public class CardLockController {
if (l.isTestLock()) {
result.put("unlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : "");
}
result.put("slowmoUntil", l.getSlowmoUntil() != null ? l.getSlowmoUntil().toString() : null);
result.put("speedupUntil", l.getSpeedupUntil() != null ? l.getSpeedupUntil().toString() : null);
return ResponseEntity.ok(result);
}
@@ -1024,6 +1046,8 @@ public class CardLockController {
result.put("emergencyUnlockRequested", l.getEmergencyUnlockRequestedAt() != null);
result.put("emergencyUnlockRequestedAt",
l.getEmergencyUnlockRequestedAt() != null ? l.getEmergencyUnlockRequestedAt().toString() : null);
result.put("slowmoUntil", l.getSlowmoUntil() != null ? l.getSlowmoUntil().toString() : null);
result.put("speedupUntil", l.getSpeedupUntil() != null ? l.getSpeedupUntil().toString() : null);
return ResponseEntity.ok(result);
}
@@ -1363,6 +1387,35 @@ public class CardLockController {
return ResponseEntity.noContent().build();
}
record SpeedConfirmRequest(String mode, String until) {}
@Transactional
@PostMapping("/cardlock/{lockId}/speed/confirm")
public ResponseEntity<?> confirmSpeedEffect(@PathVariable("lockId") UUID lockId,
@RequestBody SpeedConfirmRequest req, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var lockOpt = cardlockRepository.findById(lockId);
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
if (req.mode() == null || req.until() == null) return ResponseEntity.badRequest().build();
if (!"SLOWMO".equals(req.mode()) && !"SPEEDUP".equals(req.mode()))
return ResponseEntity.badRequest().body(Map.of("error", "Ungültiger Modus."));
LocalDateTime until;
try {
until = LocalDateTime.parse(req.until());
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", "Ungültiges Datumsformat."));
}
if (!until.isAfter(LocalDateTime.now()))
return ResponseEntity.badRequest().body(Map.of("error", "Zeitpunkt muss in der Zukunft liegen."));
cardLockServiceFactory.create(l).applySpeedEffect(req.mode(), until);
return ResponseEntity.noContent().build();
}
record FreezeRequest(String frozenUntil) {
}
@@ -1499,6 +1552,9 @@ public class CardLockController {
case DOUBLE_UP -> "Double Up";
case CUM -> "Kommen";
case CUM_IN_CAGE -> "Kommen im Käfig";
case GAME_CARD -> "Spiel-Karte";
case SLOWMO_CARD -> "Slow Motion";
case SPEEDUP_CARD -> "Speed up";
};
}

View File

@@ -44,4 +44,10 @@ public class CardLockEntity extends BaseLockEntity {
@Convert(converter = TaskListConverter.class)
@Column(columnDefinition = "TEXT")
private List<Task> tasksInQueue;
// Speed-Effekte
@Column
private LocalDateTime slowmoUntil;
@Column
private LocalDateTime speedupUntil;
}

View File

@@ -98,11 +98,12 @@ public class CardLockService extends BaseLockService implements LockControlCallb
@Override
protected void applyHygieneOvertime(Long overtime) {
LOGGER.debug("Apply {} Minutes Overtime");
long penalty = Math.round(overtime * 4 * getTimeMultiplier());
LOGGER.debug("Apply {} Minutes Overtime (penalty: {})", overtime, penalty);
if (lock.getFrozenUntil() != null) {
lock.setFrozenUntil(lock.getFrozenUntil().plusMinutes(overtime * 4));
lock.setFrozenUntil(lock.getFrozenUntil().plusMinutes(penalty));
} else {
lock.setFrozenUntil(LocalDateTime.now().plusMinutes(overtime * 4));
lock.setFrozenUntil(LocalDateTime.now().plusMinutes(penalty));
}
LOGGER.debug("Frozen until {}", lock.getFrozenUntil());
}
@@ -125,7 +126,7 @@ public class CardLockService extends BaseLockService implements LockControlCallb
}
} else {
if (lock.getNextCardIn().isBefore(LocalDateTime.now())) {
lock.setNextCardIn(LocalDateTime.now().plusMinutes(lock.getPickEveryMinute()));
lock.setNextCardIn(LocalDateTime.now().plusMinutes(Math.round(lock.getPickEveryMinute() * getTimeMultiplier())));
card = getRandomCard();
}
}
@@ -171,7 +172,7 @@ public class CardLockService extends BaseLockService implements LockControlCallb
}
public String freeze() {
var multiplier = lock.getPickEveryMinute() * new Random().nextDouble(1.0, 4.0);
var multiplier = lock.getPickEveryMinute() * new Random().nextDouble(1.0, 4.0) * getTimeMultiplier();
freeze(multiplier);
return "";
}
@@ -240,7 +241,9 @@ public class CardLockService extends BaseLockService implements LockControlCallb
}
public void startHygieneOpening() {
startTempOpening(TempOpeningReason.HYGIENE, lock.getHygineOpeningDurationMinutes());
int base = lock.getHygineOpeningDurationMinutes() != null ? lock.getHygineOpeningDurationMinutes() : 30;
int duration = (int) Math.round(base * getTimeMultiplier());
startTempOpening(TempOpeningReason.HYGIENE, duration);
}
// ── Cum cards ─────────────────────────────────────────────────────────────
@@ -272,4 +275,36 @@ public class CardLockService extends BaseLockService implements LockControlCallb
}
cardLockRepository.save(lock);
}
public String slowmo() {
return "";
}
public String speedup() {
return "";
}
public String game() {
return "";
}
public void applySpeedEffect(String mode, LocalDateTime until) {
if ("SLOWMO".equals(mode)) {
lock.setSlowmoUntil(until);
} else if ("SPEEDUP".equals(mode)) {
lock.setSpeedupUntil(until);
}
cardLockRepository.save(lock);
}
private double getTimeMultiplier() {
LocalDateTime now = LocalDateTime.now();
if (lock.getSpeedupUntil() != null && lock.getSpeedupUntil().isAfter(now)) {
return 0.25;
}
if (lock.getSlowmoUntil() != null && lock.getSlowmoUntil().isAfter(now)) {
return 4.0;
}
return 1.0;
}
}

View File

@@ -0,0 +1,88 @@
package de.oaa.xxx.games.chastity.cardlock;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import de.oaa.xxx.games.chastity.cardlock.CardlockTemplateController.TemplateRequest;
public class CardLockSimulation {
public Integer calculateMinutes(TemplateRequest request) {
var initialcards = getCards(request);
var currentCards = new ArrayList<>(initialcards);
var minutesPerCard = request.pickEveryMinute();
var minutes = minutesPerCard;
boolean locked = true;
while (locked) {
var nextCard = getRandomCard(currentCards);
currentCards.remove(nextCard);
switch (nextCard) {
case CUM:
minutes += 30; // Annahme, bei Cum card wird gefreezed, als Basis wird die Zeit verwendet, die der Lockee gebraucht hat
minutes += minutesPerCard;
break;
case DOUBLE_UP:
currentCards.addAll(currentCards);
case CUM_IN_CAGE:
case RED:
case TASK:
minutes += minutesPerCard;
break;
case FREEZE:
case SLOWMO_CARD:
minutes += (int) (minutesPerCard * new Random().nextDouble(1.0, 4.0));
break;
case RESET:
currentCards = new ArrayList<CardEnum>(initialcards);
minutes += minutesPerCard;
break;
case YELLOW:
Random random = new Random();
if (random.nextBoolean()) {
for (int i = 0; i < random.nextInt(1, 3); i++) {
currentCards.add(CardEnum.RED);
}
} else {
for (int i = 0; i < random.nextInt(1, 3); i++) {
currentCards.remove(CardEnum.RED);
}
}
minutes += minutesPerCard;
break;
case GREEN:
locked = false;
break;
case GAME_CARD:
minutes += 60;
case SPEEDUP_CARD:
minutes += (int) (minutesPerCard * new Random().nextDouble(0.25, 1.0));
break;
default:
break;
}
}
return minutes;
}
private List<CardEnum> getCards(TemplateRequest request) {
var result = new ArrayList<CardEnum>();
for (CardEnum card : CardEnum.values()) {
if (request.cardCountsMin().containsKey(card.toString())) {
var min = request.cardCountsMin().get(card.toString());
var max = request.cardCountsMax().get(card.toString());
var val = (min == max ? min : new Random().nextInt(min , max));
for (int i = 0; i < val; i++) {
result.add(card);
}
}
}
return result;
}
private CardEnum getRandomCard(List<CardEnum> currentCards) {
return currentCards.get(new Random().nextInt(currentCards.size()));
}
}

View File

@@ -1,11 +1,12 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.games.chastity.tasks.TaskMode;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.UserService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import de.oaa.xxx.games.chastity.timelock.TimeLockTemplateRepository;
@@ -41,9 +42,11 @@ public class CardlockTemplateController {
boolean showRemainingCards,
Integer hygineOpeningDurationMinutes,
Integer hygineOpeningEveryMinites,
List<Task> tasks,
UUID taskSetId,
boolean requiresVerification,
TaskMode taskMode
TaskMode taskMode,
UUID gameSetId,
Integer gameSpieldauerIdx
) {}
private Map<String, Object> toDto(CardlockTemplateEntity t) {
@@ -57,9 +60,11 @@ public class CardlockTemplateController {
dto.put("showRemainingCards", t.isShowRemainingCards());
dto.put("hygineOpeningEveryMinites", t.getHygineOpeningEveryMinites());
dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes());
dto.put("tasks", t.getTasks() != null ? t.getTasks() : List.of());
dto.put("taskSetId", t.getTaskSetId());
dto.put("requiresVerification", t.isRequiresVerification());
dto.put("taskCardMode", t.getTaskCardMode());
dto.put("gameSetId", t.getGameSetId());
dto.put("gameSpieldauerIdx", t.getGameSpieldauerIdx());
return dto;
}
@@ -125,6 +130,34 @@ public class CardlockTemplateController {
return ResponseEntity.noContent().build();
}
@PostMapping("/simulate")
public SseEmitter simulate(@RequestBody TemplateRequest req) {
SseEmitter emitter = new SseEmitter(120_000L);
CardLockSimulation simulation = new CardLockSimulation();
new Thread(() -> {
try {
int total = 100;
long min = Long.MAX_VALUE, max = Long.MIN_VALUE, sum = 0;
for (int i = 1; i <= total; i++) {
long minutes = simulation.calculateMinutes(req);
if (minutes < min) min = minutes;
if (minutes > max) max = minutes;
sum += minutes;
emitter.send(SseEmitter.event()
.name("progress")
.data(Map.of("done", i, "total", total), MediaType.APPLICATION_JSON));
}
emitter.send(SseEmitter.event()
.name("result")
.data(Map.of("min", min, "max", max, "avg", sum / total), MediaType.APPLICATION_JSON));
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
}).start();
return emitter;
}
private void applyRequest(CardlockTemplateEntity t, TemplateRequest req) {
t.setName(req.name());
t.setCardCountsMin(req.cardCountsMin());
@@ -134,8 +167,10 @@ public class CardlockTemplateController {
t.setShowRemainingCards(req.showRemainingCards());
t.setHygineOpeningEveryMinites(req.hygineOpeningEveryMinites());
t.setHygineOpeningDurationMinutes(req.hygineOpeningDurationMinutes());
t.setTasks(req.tasks() != null ? req.tasks() : List.of());
t.setTaskSetId(req.taskSetId());
t.setRequiresVerification(req.requiresVerification());
t.setTaskMode(req.taskMode() != null ? req.taskMode() : TaskMode.RANDOM);
t.setGameSetId(req.gameSetId());
t.setGameSpieldauerIdx(req.gameSpieldauerIdx());
}
}

View File

@@ -31,4 +31,10 @@ public class CardlockTemplateEntity extends BaseLockTemplateEntity {
@Column
private boolean requiresVerification;
@Column
private java.util.UUID gameSetId;
@Column
private Integer gameSpieldauerIdx;
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.cardlock;
public class GameCard implements Card{
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(CardEnum.GAME_CARD, lock.game());
}
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.cardlock;
public class SlowmoCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(CardEnum.SLOWMO_CARD, lock.slowmo());
}
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.cardlock;
public class SpeedupCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(CardEnum.SPEEDUP_CARD, lock.speedup());
}
}

View File

@@ -41,7 +41,7 @@ public class BaseLockTemplateController {
dto.put("lockType", t instanceof CardlockTemplateEntity ? "CARDLOCK" : "TIMELOCK");
dto.put("hygineOpeningEveryMinites", t.getHygineOpeningEveryMinites());
dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes());
dto.put("taskCount", t.getTasks() != null ? t.getTasks().size() : 0);
dto.put("taskSetId", t.getTaskSetId());
dto.put("requiresVerification", t.isRequiresVerification());
dto.put("published", t.isPublished());
dto.put("showAuthor", t.isShowAuthor());
@@ -85,7 +85,7 @@ public class BaseLockTemplateController {
dto.put("showRemainingCards", t.isShowRemainingCards());
dto.put("hygineOpeningEveryMinites", t.getHygineOpeningEveryMinites());
dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes());
dto.put("tasks", t.getTasks() != null ? t.getTasks() : List.of());
dto.put("taskSetId", t.getTaskSetId());
dto.put("requiresVerification", t.isRequiresVerification());
dto.put("taskCardMode", t.getTaskCardMode());
return dto;
@@ -101,7 +101,7 @@ public class BaseLockTemplateController {
dto.put("endTimeVisible", t.isEndTimeVisible());
dto.put("hygineOpeningEveryMinites", t.getHygineOpeningEveryMinites());
dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes());
dto.put("tasks", t.getTasks() != null ? t.getTasks() : List.of());
dto.put("taskSetId", t.getTaskSetId());
dto.put("taskEveryMinutes", t.getTaskEveryMinutes());
dto.put("minTasksPerDay", t.getMinTasksPerDay());
dto.put("spinningWheelEntries", t.getSpinningWheelEntries() != null ? t.getSpinningWheelEntries() : List.of());

View File

@@ -48,6 +48,9 @@ public class BaseLockTemplateEntity {
@Column(nullable = false)
private TaskMode taskMode = TaskMode.RANDOM;
@Column
private UUID taskSetId;
@Column(nullable = false)
private boolean published = false;

View File

@@ -0,0 +1,46 @@
package de.oaa.xxx.games.chastity.common;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "lock_game")
public class LockGameEntity {
@Id
@Column
private UUID sessionId;
@Column(unique = true)
private UUID userId;
@OneToMany(mappedBy = "lockId", fetch = FetchType.EAGER)
private List<LockGameLockEntity> activeLocks = new ArrayList<>();
@Column
private Integer aufgabenProLevel;
@Column
private Integer level;
@Column
private Integer aufgabenAufAktuellemLevel;
@Column(columnDefinition = "TEXT")
private String aufgaben;
@Column
private Double zeitfaktorZeitstrafen;
@Column(columnDefinition = "TEXT")
private String activeTaskJson;
@Column
private LocalDateTime taskStartedAt;
@Column
private UUID setupId;
}

View File

@@ -0,0 +1,39 @@
package de.oaa.xxx.games.chastity.common;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import de.oaa.xxx.games.common.aufgaben.Werkzeug;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "lock_game_lock")
public class LockGameLockEntity {
@Id
@Column
private UUID lockGameLockId;
@Column
private UUID lockId;
@Enumerated(EnumType.STRING)
@ElementCollection(targetClass = Werkzeug.class, fetch = FetchType.EAGER)
@CollectionTable(name = "aktiveSperre_fuer", joinColumns = @JoinColumn(name = "aktiveSperreId"))
@Column(name = "werkzeug")
private List<Werkzeug> lockFor = new ArrayList<>();
@Column
private String releaseText;
}

View File

@@ -0,0 +1,27 @@
package de.oaa.xxx.games.chastity.common;
import java.time.LocalDateTime;
import org.springframework.stereotype.Service;
import de.oaa.xxx.games.common.aufgaben.Werkzeug;
@Service
public class LockGameService {
// public LockGameService(LockGameEntity gamestate) {
//
// }
//
// public String getNextTask() {
//
// }
//
// public String getCurrentTask() {
//
// }
//
// public void applyLock(Werkzeug applyFor, LocalDateTime applyTill, ) {
//
// }
}

View File

@@ -0,0 +1,109 @@
package de.oaa.xxx.games.chastity.gameset;
import java.security.Principal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.user.UserService;
@RestController
@RequestMapping("/chastity/game-sets")
public class ChastityGameSetController {
private static final int MAX_SETS = 5;
private static final int MIN_AUFGABEN_PER_LEVEL = 3;
private final ChastityGameSetRepository repository;
private final UserService userService;
public ChastityGameSetController(ChastityGameSetRepository repository, UserService userService) {
this.repository = repository;
this.userService = userService;
}
record GameSetRequest(
String name,
List<GameSetAufgabe> aufgaben,
List<GameSetZeitstrafe> zeitstrafen,
List<GameSetFinisher> finisher) {}
private Map<String, Object> toDto(ChastityGameSetEntity e) {
Map<String, Object> dto = new LinkedHashMap<>();
dto.put("id", e.getId());
dto.put("name", e.getName());
dto.put("aufgaben", e.getAufgaben() != null ? e.getAufgaben() : List.of());
dto.put("zeitstrafen", e.getZeitstrafen() != null ? e.getZeitstrafen() : List.of());
dto.put("finisher", e.getFinisher() != null ? e.getFinisher() : List.of());
return dto;
}
private Optional<String> validate(GameSetRequest req) {
if (req.name() == null || req.name().isBlank()) return Optional.of("Name ist ein Pflichtfeld.");
return Optional.empty();
}
@GetMapping
public ResponseEntity<List<Map<String, Object>>> list(Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
return ResponseEntity.ok(
repository.findByOwnerIdOrderByName(myId).stream().map(this::toDto).toList());
}
@PostMapping
public ResponseEntity<?> create(@RequestBody GameSetRequest req, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (repository.countByOwnerId(myId) >= MAX_SETS)
return ResponseEntity.status(409).body(Map.of("error", "Maximal " + MAX_SETS + " Spiel-Sets erlaubt."));
var err = validate(req);
if (err.isPresent()) return ResponseEntity.badRequest().body(Map.of("error", err.get()));
ChastityGameSetEntity e = new ChastityGameSetEntity();
e.setOwnerId(myId);
applyRequest(e, req);
repository.save(e);
return ResponseEntity.ok(toDto(e));
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable UUID id, @RequestBody GameSetRequest req, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = repository.findById(id);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
var e = opt.get();
if (!e.getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
var err = validate(req);
if (err.isPresent()) return ResponseEntity.badRequest().body(Map.of("error", err.get()));
applyRequest(e, req);
repository.save(e);
return ResponseEntity.ok(toDto(e));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable UUID id, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = repository.findById(id);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
if (!opt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
repository.deleteById(id);
return ResponseEntity.noContent().build();
}
private void applyRequest(ChastityGameSetEntity e, GameSetRequest req) {
e.setName(req.name().trim());
e.setAufgaben(req.aufgaben() != null ? req.aufgaben() : List.of());
e.setZeitstrafen(req.zeitstrafen() != null ? req.zeitstrafen() : List.of());
e.setFinisher(req.finisher() != null ? req.finisher() : List.of());
}
}

View File

@@ -0,0 +1,38 @@
package de.oaa.xxx.games.chastity.gameset;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "chastity_game_set")
public class ChastityGameSetEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column
private UUID id;
@Column(nullable = false)
private UUID ownerId;
@Column(nullable = false)
private String name;
@Convert(converter = GameSetAufgabeListConverter.class)
@Column(columnDefinition = "TEXT")
private List<GameSetAufgabe> aufgaben;
@Convert(converter = GameSetZeitstrafeListConverter.class)
@Column(columnDefinition = "TEXT")
private List<GameSetZeitstrafe> zeitstrafen;
@Convert(converter = GameSetFinisherListConverter.class)
@Column(columnDefinition = "TEXT")
private List<GameSetFinisher> finisher;
}

View File

@@ -0,0 +1,11 @@
package de.oaa.xxx.games.chastity.gameset;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface ChastityGameSetRepository extends JpaRepository<ChastityGameSetEntity, UUID> {
List<ChastityGameSetEntity> findByOwnerIdOrderByName(UUID ownerId);
long countByOwnerId(UUID ownerId);
}

View File

@@ -0,0 +1,16 @@
package de.oaa.xxx.games.chastity.gameset;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GameSetAufgabe {
private String title;
private String description;
private Integer minutes;
private Integer level; // 1-5
private List<String> benoetigt;
}

View File

@@ -0,0 +1,28 @@
package de.oaa.xxx.games.chastity.gameset;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.ArrayList;
import java.util.List;
@Converter
public class GameSetAufgabeListConverter implements AttributeConverter<List<GameSetAufgabe>, String> {
private static final ObjectMapper mapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<GameSetAufgabe> list) {
if (list == null || list.isEmpty()) return null;
try { return mapper.writeValueAsString(list); } catch (Exception e) { return null; }
}
@Override
public List<GameSetAufgabe> convertToEntityAttribute(String json) {
if (json == null || json.isBlank()) return new ArrayList<>();
try { return new ArrayList<>(mapper.readValue(json, new TypeReference<List<GameSetAufgabe>>() {})); }
catch (Exception e) { return new ArrayList<>(); }
}
}

View File

@@ -0,0 +1,13 @@
package de.oaa.xxx.games.chastity.gameset;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GameSetFinisher {
private String title;
private String description;
private Boolean tempUnlockBeforeRequired;
private Boolean tempUnlockAfterRequired;
}

View File

@@ -0,0 +1,28 @@
package de.oaa.xxx.games.chastity.gameset;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.ArrayList;
import java.util.List;
@Converter
public class GameSetFinisherListConverter implements AttributeConverter<List<GameSetFinisher>, String> {
private static final ObjectMapper mapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<GameSetFinisher> list) {
if (list == null || list.isEmpty()) return null;
try { return mapper.writeValueAsString(list); } catch (Exception e) { return null; }
}
@Override
public List<GameSetFinisher> convertToEntityAttribute(String json) {
if (json == null || json.isBlank()) return new ArrayList<>();
try { return new ArrayList<>(mapper.readValue(json, new TypeReference<List<GameSetFinisher>>() {})); }
catch (Exception e) { return new ArrayList<>(); }
}
}

View File

@@ -0,0 +1,20 @@
package de.oaa.xxx.games.chastity.gameset;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class GameSetZeitstrafe {
private String title;
private String description;
private Integer level; // 1-5
private Integer minMinutes;
private Integer maxMinutes;
private Boolean tempUnlockBeforeRequired;
private Boolean tempUnlockAfterRequired;
private List<String> sperrt;
private String releaseText;
}

View File

@@ -0,0 +1,28 @@
package de.oaa.xxx.games.chastity.gameset;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.ArrayList;
import java.util.List;
@Converter
public class GameSetZeitstrafeListConverter implements AttributeConverter<List<GameSetZeitstrafe>, String> {
private static final ObjectMapper mapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<GameSetZeitstrafe> list) {
if (list == null || list.isEmpty()) return null;
try { return mapper.writeValueAsString(list); } catch (Exception e) { return null; }
}
@Override
public List<GameSetZeitstrafe> convertToEntityAttribute(String json) {
if (json == null || json.isBlank()) return new ArrayList<>();
try { return new ArrayList<>(mapper.readValue(json, new TypeReference<List<GameSetZeitstrafe>>() {})); }
catch (Exception e) { return new ArrayList<>(); }
}
}

View File

@@ -0,0 +1,77 @@
package de.oaa.xxx.games.chastity.tasks;
import de.oaa.xxx.user.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.*;
@RestController
@RequestMapping("/chastity/task-sets")
public class ChastityTaskSetController {
private final ChastityTaskSetRepository repository;
private final UserService userService;
public ChastityTaskSetController(ChastityTaskSetRepository repository, UserService userService) {
this.repository = repository;
this.userService = userService;
}
record TaskSetRequest(String name, List<Task> tasks) {}
private Map<String, Object> toDto(ChastityTaskSetEntity e) {
Map<String, Object> dto = new LinkedHashMap<>();
dto.put("id", e.getId());
dto.put("name", e.getName());
dto.put("tasks", e.getTasks() != null ? e.getTasks() : List.of());
return dto;
}
@GetMapping
public ResponseEntity<List<Map<String, Object>>> list(Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
return ResponseEntity.ok(
repository.findByOwnerIdOrderByName(myId).stream().map(this::toDto).toList()
);
}
@PostMapping
public ResponseEntity<Map<String, Object>> create(@RequestBody TaskSetRequest req, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (req.name() == null || req.name().isBlank()) return ResponseEntity.badRequest().build();
ChastityTaskSetEntity e = new ChastityTaskSetEntity();
e.setOwnerId(myId);
e.setName(req.name().trim());
e.setTasks(req.tasks() != null ? req.tasks() : List.of());
repository.save(e);
return ResponseEntity.ok(toDto(e));
}
@PutMapping("/{id}")
public ResponseEntity<Map<String, Object>> update(@PathVariable UUID id,
@RequestBody TaskSetRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = repository.findById(id);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
var e = opt.get();
if (!e.getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
if (req.name() == null || req.name().isBlank()) return ResponseEntity.badRequest().build();
e.setName(req.name().trim());
e.setTasks(req.tasks() != null ? req.tasks() : List.of());
repository.save(e);
return ResponseEntity.ok(toDto(e));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable UUID id, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = repository.findById(id);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
if (!opt.get().getOwnerId().equals(myId)) return ResponseEntity.status(403).build();
repository.deleteById(id);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,30 @@
package de.oaa.xxx.games.chastity.tasks;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "chastity_task_set")
public class ChastityTaskSetEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column
private UUID id;
@Column(nullable = false)
private UUID ownerId;
@Column(nullable = false)
private String name;
@Convert(converter = TaskListConverter.class)
@Column(columnDefinition = "TEXT")
private List<Task> tasks;
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.tasks;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface ChastityTaskSetRepository extends JpaRepository<ChastityTaskSetEntity, UUID> {
List<ChastityTaskSetEntity> findByOwnerIdOrderByName(UUID ownerId);
}

View File

@@ -3,7 +3,6 @@ package de.oaa.xxx.games.chastity.timelock;
import de.oaa.xxx.games.chastity.cardlock.CardlockTemplateRepository;
import de.oaa.xxx.games.chastity.common.PenaltyType;
import de.oaa.xxx.games.chastity.spinningwheel.SpinningWheelEntry;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.games.chastity.tasks.TaskMode;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.UserService;
@@ -40,7 +39,7 @@ public class TimeLockTemplateController {
boolean endTimeVisible,
Integer hygineOpeningDurationMinutes,
Integer hygineOpeningEveryMinites,
List<Task> tasks,
UUID taskSetId,
Integer taskEveryMinutes,
Integer minTasksPerDay,
List<SpinningWheelEntry> spinningWheelEntries,
@@ -61,7 +60,7 @@ public class TimeLockTemplateController {
dto.put("endTimeVisible", t.isEndTimeVisible());
dto.put("hygineOpeningEveryMinites", t.getHygineOpeningEveryMinites());
dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes());
dto.put("tasks", t.getTasks() != null ? t.getTasks() : List.of());
dto.put("taskSetId", t.getTaskSetId());
dto.put("taskEveryMinutes", t.getTaskEveryMinutes());
dto.put("minTasksPerDay", t.getMinTasksPerDay());
dto.put("spinningWheelEntries", t.getSpinningWheelEntries() != null ? t.getSpinningWheelEntries() : List.of());
@@ -139,7 +138,7 @@ public class TimeLockTemplateController {
t.setEndTimeVisible(req.endTimeVisible());
t.setHygineOpeningEveryMinites(req.hygineOpeningEveryMinites());
t.setHygineOpeningDurationMinutes(req.hygineOpeningDurationMinutes());
t.setTasks(req.tasks() != null ? req.tasks() : List.of());
t.setTaskSetId(req.taskSetId());
t.setTaskEveryMinutes(req.taskEveryMinutes());
t.setMinTasksPerDay(req.minTasksPerDay());
t.setSpinningWheelEntries(req.spinningWheelEntries() != null ? req.spinningWheelEntries() : List.of());

View File

@@ -0,0 +1,42 @@
package de.oaa.xxx.games.common;
import de.oaa.xxx.games.common.aufgaben.AufgabenList;
public abstract class AbstractGameDurchfuehren {
protected final AufgabenList aufgabenList;
protected int aufgabenProLevel;
protected int level;
protected int aufgabenAufAktuellemLevel;
protected AbstractGameDurchfuehren(AufgabenList aufgabenList,
int aufgabenProLevel,
int level,
int aufgabenAufAktuellemLevel) {
this.aufgabenList = aufgabenList;
this.aufgabenProLevel = aufgabenProLevel;
this.level = level;
this.aufgabenAufAktuellemLevel = aufgabenAufAktuellemLevel;
}
public int getLevel() { return level; }
public int getAufgabenAufAktuellemLevel() { return aufgabenAufAktuellemLevel; }
public void backToLvl5() {
this.level = 5;
this.aufgabenAufAktuellemLevel = 0;
}
protected void checkLevel() {
if (++aufgabenAufAktuellemLevel >= 1 + aufgabenProLevel) {
aufgabenAufAktuellemLevel = 0;
level++;
}
}
protected String getAnzeigeText(String text, String aktiv, String passiv) {
if (text == null) return "";
return text.replace("{AKTIV}", aktiv != null ? aktiv : "")
.replace("{PASSIV}", passiv != null ? passiv : "");
}
}

View File

@@ -23,5 +23,5 @@ public class AufgabenGruppe {
private String bild;
private long subscriberCount;
private boolean subscribed;
private boolean vanillaAvailable;
private AvailableIn availableIn;
}

View File

@@ -16,5 +16,5 @@ public class AufgabenGruppeDisplay {
private boolean privateGruppe;
private String bild;
private String von;
private boolean vanillaAvailable;
private AvailableIn availableIn;
}

View File

@@ -0,0 +1,7 @@
package de.oaa.xxx.games.common.aufgaben;
public enum AvailableIn {
BDSM_ONLY,
BDSM_AND_VANILLA,
CHASTITY_ONLY
}

View File

@@ -2,6 +2,8 @@ package de.oaa.xxx.games.common.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.OneToMany;
@@ -15,6 +17,7 @@ import java.util.UUID;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeDisplay;
import de.oaa.xxx.games.common.aufgaben.AvailableIn;
@Getter
@Setter
@@ -38,8 +41,9 @@ public class AufgabenGruppeEntity {
private byte[] bild;
@Column
private String von;
@Column(columnDefinition = "BOOLEAN DEFAULT FALSE NOT NULL")
private boolean vanillaAvailable = false;
@Enumerated(EnumType.STRING)
@Column(columnDefinition = "VARCHAR(50) DEFAULT 'BDSM_ONLY' NOT NULL")
private AvailableIn availableIn = AvailableIn.BDSM_ONLY;
@OneToMany(mappedBy = "aufgabenGruppe")
private List<AufgabeEntity> aufgaben;
@OneToMany(mappedBy = "aufgabenGruppe")
@@ -64,7 +68,7 @@ public class AufgabenGruppeEntity {
gruppe.setPrivateGruppe(privateGruppe);
gruppe.setBild(bild != null ? Base64.getEncoder().encodeToString(bild) : null);
gruppe.setVon(von);
gruppe.setVanillaAvailable(vanillaAvailable);
gruppe.setAvailableIn(availableIn);
gruppe.setAufgaben(aufgaben.stream().map(AufgabeEntity::toAufgabe).toList());
gruppe.setStrafen(strafen.stream().map(StrafeEntity::toStrafe).toList());
gruppe.setSperren(sperren.stream().map(SperreEntity::toSperre).toList());
@@ -81,7 +85,7 @@ public class AufgabenGruppeEntity {
entity.setPrivateGruppe(gruppe.isPrivateGruppe());
entity.setBild(gruppe.getBild() != null ? Base64.getDecoder().decode(gruppe.getBild()) : null);
entity.setVon(gruppe.getVon());
entity.setVanillaAvailable(gruppe.isVanillaAvailable());
entity.setAvailableIn(gruppe.getAvailableIn() != null ? gruppe.getAvailableIn() : AvailableIn.BDSM_ONLY);
return entity;
}
@@ -94,7 +98,7 @@ public class AufgabenGruppeEntity {
display.setPrivateGruppe(privateGruppe);
display.setBild(bild != null ? Base64.getEncoder().encodeToString(bild) : null);
display.setVon(von);
display.setVanillaAvailable(vanillaAvailable);
display.setAvailableIn(availableIn);
return display;
}
}

View File

@@ -26,21 +26,27 @@ public interface AufgabenGruppeRepository extends JpaRepository<AufgabenGruppeEn
Page<AufgabenGruppeEntity> findByUserIdIsNull(Pageable pageable);
// ── Vanilla-Verwaltung: nur vanillaAvailable=true, mit Inhalt ─────────────
// ── Vanilla-Verwaltung: nur BDSM_AND_VANILLA, mit Inhalt ─────────────────
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId = :userId AND g.vanillaAvailable = true AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)")
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId = :userId AND g.availableIn = de.oaa.xxx.games.common.aufgaben.AvailableIn.BDSM_AND_VANILLA AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)")
Page<AufgabenGruppeEntity> findByUserIdAndVanillaAvailableTrueWithContent(@Param("userId") UUID userId, Pageable pageable);
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId IS NULL AND g.vanillaAvailable = true AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)")
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId IS NULL AND g.availableIn = de.oaa.xxx.games.common.aufgaben.AvailableIn.BDSM_AND_VANILLA AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)")
Page<AufgabenGruppeEntity> findSystemGroupsByVanillaAvailableTrueWithContent(Pageable pageable);
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId = :userId AND g.availableIn = de.oaa.xxx.games.common.aufgaben.AvailableIn.BDSM_AND_VANILLA")
Page<AufgabenGruppeEntity> findByUserIdAndVanillaAvailableTrue(@Param("userId") UUID userId, Pageable pageable);
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId IS NULL AND g.availableIn = de.oaa.xxx.games.common.aufgaben.AvailableIn.BDSM_AND_VANILLA")
Page<AufgabenGruppeEntity> findSystemGroupsByVanillaAvailableTrue(Pageable pageable);
// ── Spielstart-Auswahl ────────────────────────────────────────────────────
/** Nur vanillaAvailable-Gruppen für Vanilla-Spielstart und Vanilla-Suche. */
@Query("select g from AufgabenGruppeEntity g where g.vanillaAvailable = true and (g.privateGruppe = false or g.userId = :userId) and (:search is null or g.name like :search)")
/** Nur BDSM_AND_VANILLA-Gruppen für Vanilla-Spielstart und Vanilla-Suche. */
@Query("select g from AufgabenGruppeEntity g where g.availableIn = de.oaa.xxx.games.common.aufgaben.AvailableIn.BDSM_AND_VANILLA and (g.privateGruppe = false or g.userId = :userId) and (:search is null or g.name like :search)")
List<AufgabenGruppeEntity> listVanillaAvailableWithUserAndSearch(@Param("userId") UUID userId, @Param("search") String search, PageRequest pageable);
/** Alle Gruppen für BDSM-Spielstart (vanillaAvailable-Gruppen werden im Frontend hervorgehoben). */
/** Alle Gruppen für BDSM-Spielstart (availableIn wird im Frontend hervorgehoben). */
@Query("select g from AufgabenGruppeEntity g where (g.privateGruppe = false or g.userId = :userId) and (:search is null or g.name like :search)")
List<AufgabenGruppeEntity> listAllWithUserAndSearch(@Param("userId") UUID userId, @Param("search") String search, PageRequest pageable);
}

View File

@@ -7,30 +7,23 @@ import java.util.Random;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.xxx.games.bdsm.GeschlechtEnum;
import de.oaa.xxx.games.common.AbstractGameDurchfuehren;
import de.oaa.xxx.games.common.aufgaben.Aufgabe;
import de.oaa.xxx.games.common.aufgaben.AufgabenList;
import de.oaa.xxx.games.vanilla.entity.VanillaGameEntity;
public class VanillaGameDurchfuehren {
public class VanillaGameDurchfuehren extends AbstractGameDurchfuehren {
private final AufgabenList aufgabenList;
private final List<VanillaMitspieler> mitspieler = new ArrayList<>();
private int aufgabenProLevel;
private int level;
private int aufgabenAufAktuellemLevel;
public VanillaGameDurchfuehren(VanillaGameEntity entity) throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
aufgabenList = objectMapper.readValue(entity.getAufgaben(), AufgabenList.class);
super(new ObjectMapper().readValue(entity.getAufgaben(), AufgabenList.class),
entity.getAufgabenProLevel() != null ? entity.getAufgabenProLevel() : 5,
entity.getLevel() != null ? entity.getLevel() : 1,
entity.getAufgabenAufAktuellemLevel() != null ? entity.getAufgabenAufAktuellemLevel() : 0);
entity.getMitspieler().forEach(m -> mitspieler.add(m.toMitspieler()));
this.aufgabenProLevel = entity.getAufgabenProLevel() != null ? entity.getAufgabenProLevel() : 5;
this.level = entity.getLevel() != null ? entity.getLevel() : 1;
this.aufgabenAufAktuellemLevel = entity.getAufgabenAufAktuellemLevel() != null ? entity.getAufgabenAufAktuellemLevel() : 0;
}
public int getLevel() { return level; }
public int getAufgabenAufAktuellemLevel() { return aufgabenAufAktuellemLevel; }
public VanillaAufgabeAnzeige getNext() {
checkLevel();
if (level == 6) return null;
@@ -50,8 +43,6 @@ public class VanillaGameDurchfuehren {
return fallback;
}
public void backToLvl5() { this.level = 5; this.aufgabenAufAktuellemLevel = 0; }
public List<VanillaAufgabeAnzeige> getFinisher() {
var list = new ArrayList<VanillaAufgabeAnzeige>();
List.of(GeschlechtEnum.WEIBLICH, GeschlechtEnum.DIVERS, GeschlechtEnum.MAENNLICH).forEach(geschlecht -> {
@@ -76,13 +67,6 @@ public class VanillaGameDurchfuehren {
return list;
}
private void checkLevel() {
if (++aufgabenAufAktuellemLevel >= 1 + aufgabenProLevel) {
aufgabenAufAktuellemLevel = 0;
level++;
}
}
private VanillaAufgabeAnzeige findeAufgabe(VanillaMitspieler aktiv, VanillaMitspieler passiv) {
List<Aufgabe> passende = aufgabenList.getAufgaben().stream()
.filter(a -> a.isAufgabePassend(level, aktiv, passiv))
@@ -112,8 +96,4 @@ public class VanillaGameDurchfuehren {
return kandidaten.get(new Random().nextInt(kandidaten.size()));
}
private String getAnzeigeText(String text, String aktiv, String passiv) {
if (text == null) return "";
return text.replace("{AKTIV}", aktiv != null ? aktiv : "").replace("{PASSIV}", passiv != null ? passiv : "");
}
}

View File

@@ -2,26 +2,19 @@ package de.oaa.xxx.games.vanilla.controller;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppePage;
import de.oaa.xxx.games.common.aufgaben.AvailableIn;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.entity.GruppenAboEntity;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import de.oaa.xxx.games.common.repository.GruppenAboRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserService;
@@ -31,24 +24,16 @@ import de.oaa.xxx.user.UserService;
@Transactional
public class VanillaAboController {
private static final Logger LOGGER = LoggerFactory.getLogger(VanillaAboController.class);
private static final int DEFAULT_PAGE_SIZE = 5;
private static final int DISCOVER_PAGE_SIZE = 10;
private final GruppenAboRepository aboRepository;
private final AufgabenGruppeRepository gruppeRepository;
private final UserService userService;
public VanillaAboController(GruppenAboRepository aboRepository,
AufgabenGruppeRepository gruppeRepository,
UserService userService) {
public VanillaAboController(GruppenAboRepository aboRepository, UserService userService) {
this.aboRepository = aboRepository;
this.gruppeRepository = gruppeRepository;
this.userService = userService;
}
// ── Abonnierte Gruppen laden (nur vanilla-safe) ──
@GetMapping("/list")
public ResponseEntity<AufgabenGruppePage> listSubscribed(
@RequestParam(name = "page", defaultValue = "0") int page,
@@ -58,10 +43,10 @@ public class VanillaAboController {
List<AufgabenGruppe> all = aboRepository.findByUserId(user.getUserId()).stream()
.map(GruppenAboEntity::getAufgabenGruppe)
.filter(g -> g.isVanillaAvailable()
.filter(g -> g.getAvailableIn() == AvailableIn.BDSM_AND_VANILLA
&& !g.isPrivateGruppe()
&& (!g.getAufgaben().isEmpty() || !g.getFinisher().isEmpty()))
.map(g -> enrich(g, user.getUserId(), true))
.map(this::enrich)
.sorted(java.util.Comparator.comparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER))
.toList();
int total = all.size();
@@ -74,84 +59,10 @@ public class VanillaAboController {
return ResponseEntity.ok(result);
}
// ── Entdecken (nur vanilla-safe Gruppen von anderen) ──
@GetMapping("/discover")
public ResponseEntity<AufgabenGruppePage> discover(
@RequestParam(required = false) String name,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "" + DISCOVER_PAGE_SIZE) int size,
Principal principal) {
UserEntity user = userService.requireUser(principal);
String namePattern = name != null && !name.isBlank() ? "%" + name.trim() + "%" : null;
List<AufgabenGruppe> dtos = gruppeRepository
.listVanillaAvailableWithUserAndSearch(user.getUserId(), namePattern, PageRequest.of(0, 500)).stream()
.filter(g -> !g.isPrivateGruppe() && g.getUserId() != null && !g.getUserId().equals(user.getUserId()))
.map(g -> enrich(g, user.getUserId(), aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), g)))
.sorted(java.util.Comparator.comparingLong(AufgabenGruppe::getSubscriberCount).reversed()
.thenComparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER))
.toList();
int total = dtos.size();
int start = page * size;
List<AufgabenGruppe> content = start >= total ? List.of() : dtos.subList(start, Math.min(start + size, total));
AufgabenGruppePage discoverPage = new AufgabenGruppePage();
discoverPage.setContent(content);
discoverPage.setCurrentPage(page);
discoverPage.setTotalPages(total == 0 ? 1 : (int) Math.ceil((double) total / size));
discoverPage.setTotalElements(total);
return ResponseEntity.ok(discoverPage);
}
// ── Abonnieren (nur vanilla-safe) ──
@PostMapping("/{gruppenId}")
public ResponseEntity<Void> subscribe(@PathVariable("gruppenId") UUID gruppenId, Principal principal) {
UserEntity user = userService.requireUser(principal);
AufgabenGruppeEntity gruppe = gruppeRepository.findById(gruppenId).orElse(null);
if (gruppe == null || gruppe.isPrivateGruppe() || user.getUserId().equals(gruppe.getUserId())) {
return ResponseEntity.badRequest().build();
}
if (!gruppe.isVanillaAvailable()) {
return ResponseEntity.status(403).build();
}
if (aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), gruppe)) {
return ResponseEntity.ok().build();
}
GruppenAboEntity abo = new GruppenAboEntity();
abo.setAboId(UUID.randomUUID());
abo.setUserId(user.getUserId());
abo.setAufgabenGruppe(gruppe);
aboRepository.save(abo);
LOGGER.info("User {} hat Vanilla-Gruppe {} abonniert", user.getUserId(), gruppenId);
return ResponseEntity.status(201).build();
}
// ── Abonnement kündigen ──
@DeleteMapping("/{gruppenId}")
public ResponseEntity<Void> unsubscribe(@PathVariable("gruppenId") UUID gruppenId, Principal principal) {
UserEntity user = userService.requireUser(principal);
AufgabenGruppeEntity gruppe = gruppeRepository.findById(gruppenId).orElse(null);
if (gruppe == null) return ResponseEntity.noContent().build();
aboRepository.deleteByUserIdAndAufgabenGruppe(user.getUserId(), gruppe);
LOGGER.info("User {} hat Vanilla-Abo auf Gruppe {} beendet", user.getUserId(), gruppenId);
return ResponseEntity.accepted().build();
}
// ── Hilfsmethoden ──
private AufgabenGruppe enrich(AufgabenGruppeEntity entity, UUID userId, boolean subscribed) {
private AufgabenGruppe enrich(AufgabenGruppeEntity entity) {
AufgabenGruppe g = entity.toAufgabenGruppe();
g.setSubscriberCount(aboRepository.countByAufgabenGruppe(entity));
g.setSubscribed(subscribed);
g.setSubscribed(true);
return g;
}
}

View File

@@ -1,39 +1,25 @@
package de.oaa.xxx.games.vanilla.controller;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeList;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppePage;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeService;
import de.oaa.xxx.games.common.aufgaben.AvailableIn;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.repository.AufgabeRepository;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import de.oaa.xxx.games.common.repository.FinisherRepository;
import de.oaa.xxx.games.common.repository.GruppenAboRepository;
import de.oaa.xxx.games.common.repository.SperreRepository;
import de.oaa.xxx.games.common.repository.StrafeRepository;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.security.Principal;
import java.util.Base64;
import java.util.UUID;
@RestController
@@ -41,41 +27,20 @@ import java.util.UUID;
@Transactional
public class VanillaAufgabenGruppeController {
private static final Logger LOGGER = LoggerFactory.getLogger(VanillaAufgabenGruppeController.class);
private static final int DEFAULT_PAGE_SIZE = 5;
private final AufgabenGruppeRepository gruppeRepository;
private final AufgabeRepository aufgabeRepository;
private final StrafeRepository strafeRepository;
private final SperreRepository sperreRepository;
private final FinisherRepository finisherRepository;
private final GruppenAboRepository aboRepository;
private final AufgabenGruppeService aufgabenGruppeService;
private final SubscriptionLimitService limitService;
private final UserService userService;
public VanillaAufgabenGruppeController(AufgabenGruppeRepository gruppeRepository,
AufgabeRepository aufgabeRepository,
StrafeRepository strafeRepository,
SperreRepository sperreRepository,
FinisherRepository finisherRepository,
GruppenAboRepository aboRepository,
AufgabenGruppeService aufgabenGruppeService,
SubscriptionLimitService limitService,
UserService userService) {
this.gruppeRepository = gruppeRepository;
this.aufgabeRepository = aufgabeRepository;
this.strafeRepository = strafeRepository;
this.sperreRepository = sperreRepository;
this.finisherRepository = finisherRepository;
this.aboRepository = aboRepository;
this.aufgabenGruppeService = aufgabenGruppeService;
this.limitService = limitService;
this.userService = userService;
}
// ── Paginierte Listen ──
@GetMapping("/list/user")
public ResponseEntity<AufgabenGruppePage> listUser(
@RequestParam(name = "page", defaultValue = "0") int page,
@@ -83,7 +48,7 @@ public class VanillaAufgabenGruppeController {
Principal principal) {
UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build();
Page<AufgabenGruppeEntity> dbPage = gruppeRepository.findByUserIdAndVanillaAvailableTrueWithContent(
Page<AufgabenGruppeEntity> dbPage = gruppeRepository.findByUserIdAndVanillaAvailableTrue(
user.getUserId(), PageRequest.of(page, size, Sort.by("name")));
AufgabenGruppePage result = new AufgabenGruppePage();
result.setContent(dbPage.getContent().stream().map(entity -> {
@@ -101,7 +66,7 @@ public class VanillaAufgabenGruppeController {
public ResponseEntity<AufgabenGruppePage> listSystem(
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "size", defaultValue = "" + DEFAULT_PAGE_SIZE) int size) {
Page<AufgabenGruppeEntity> dbPage = gruppeRepository.findSystemGroupsByVanillaAvailableTrueWithContent(
Page<AufgabenGruppeEntity> dbPage = gruppeRepository.findSystemGroupsByVanillaAvailableTrue(
PageRequest.of(page, size, Sort.by("name")));
AufgabenGruppePage r = new AufgabenGruppePage();
r.setContent(dbPage.getContent().stream().map(AufgabenGruppeEntity::toAufgabenGruppe).toList());
@@ -111,154 +76,14 @@ public class VanillaAufgabenGruppeController {
return ResponseEntity.ok(r);
}
// ── Bestehende Endpunkte ──
@GetMapping("/all")
public ResponseEntity<AufgabenGruppeList> getAll(@RequestParam(required = false) String search, Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
String searchPattern = search != null ? "%" + search + "%" : null;
AufgabenGruppeList list = new AufgabenGruppeList();
list.setGruppen(gruppeRepository.listVanillaAvailableWithUserAndSearch(
userId, searchPattern, PageRequest.of(0, 500))
.stream().map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList());
return ResponseEntity.ok(list);
}
@GetMapping("/own")
public ResponseEntity<AufgabenGruppeList> getOwn(@RequestParam("userId") UUID userId) {
AufgabenGruppeList list = new AufgabenGruppeList();
list.setGruppen(gruppeRepository.findByUserId(userId).stream()
.filter(AufgabenGruppeEntity::isVanillaAvailable)
.map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList());
return ResponseEntity.ok(list);
}
@GetMapping("/{gruppeId}")
public ResponseEntity<AufgabenGruppe> get(@PathVariable("gruppeId") UUID gruppeId) {
return gruppeRepository.findById(gruppeId)
.filter(AufgabenGruppeEntity::isVanillaAvailable)
.filter(e -> e.getAvailableIn() == AvailableIn.BDSM_AND_VANILLA)
.map(entity -> ResponseEntity.ok(entity.toAufgabenGruppe()))
.orElse(ResponseEntity.status(403).build());
}
// ── Anlegen ──
@PostMapping
public ResponseEntity<Void> create(@RequestBody AufgabenGruppe gruppe, Principal principal) {
if (gruppe.getName() == null || gruppe.getName().isBlank()) {
return ResponseEntity.badRequest().build();
}
UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build();
if (gruppeRepository.countByUserId(user.getUserId()) >= limitService.maxTaskGroups(user.getUserId())) {
return ResponseEntity.status(409).build();
}
AufgabenGruppeEntity entity = AufgabenGruppeEntity.create(gruppe);
entity.setUserId(user.getUserId());
entity.setPrivateGruppe(true);
entity.setVanillaAvailable(true);
gruppeRepository.save(entity);
LOGGER.debug("User {} hat Vanilla-AufgabenGruppe '{}' ({}) erstellt", user.getUserId(), entity.getName(), entity.getGruppenId());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getGruppenId()).toUri()
).build();
}
// ── Bearbeiten ──
@PutMapping("/{gruppeId}")
public ResponseEntity<Void> update(@PathVariable("gruppeId") UUID gruppeId,
@RequestBody AufgabenGruppe gruppe,
Principal principal) {
if (gruppe.getName() == null || gruppe.getName().isBlank()) {
return ResponseEntity.badRequest().build();
}
UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build();
AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build();
if (!entity.isVanillaAvailable()) return ResponseEntity.status(403).build();
entity.setName(gruppe.getName().trim());
entity.setBeschreibung(gruppe.getBeschreibung());
entity.setVon(gruppe.getVon());
entity.setPrivateGruppe(gruppe.isPrivateGruppe());
if (gruppe.getBild() != null) {
entity.setBild(Base64.getDecoder().decode(gruppe.getBild()));
}
gruppeRepository.save(entity);
LOGGER.debug("User {} hat Vanilla-AufgabenGruppe {} aktualisiert", user.getUserId(), gruppeId);
return ResponseEntity.ok().build();
}
// ── Kopieren (Systemgruppe → eigene) ──
@PostMapping("/copy/{gruppeId}")
public ResponseEntity<Void> copy(@PathVariable("gruppeId") UUID gruppeId, Principal principal) {
UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build();
AufgabenGruppeEntity source = gruppeRepository.findById(gruppeId).orElse(null);
if (source == null) return ResponseEntity.notFound().build();
if (!source.isVanillaAvailable()) return ResponseEntity.status(403).build();
try {
aufgabenGruppeService.copyGruppe(gruppeId, user.getUserId());
return ResponseEntity.status(201).build();
} catch (IllegalStateException e) {
return ResponseEntity.status(409).build();
} catch (IllegalArgumentException e) {
String msg = e.getMessage();
if (msg != null && msg.contains("nicht gefunden")) return ResponseEntity.notFound().build();
return ResponseEntity.status(403).build();
}
}
// ── Löschen ──
@DeleteMapping("/{gruppeId}")
public ResponseEntity<Void> deleteById(@PathVariable("gruppeId") UUID gruppeId, Principal principal) {
UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build();
AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null);
if (entity == null) return ResponseEntity.noContent().build();
if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build();
if (!entity.isVanillaAvailable()) return ResponseEntity.status(403).build();
try {
aboRepository.deleteByAufgabenGruppe(entity);
aufgabeRepository.deleteAll(entity.getAufgaben());
strafeRepository.deleteAll(entity.getStrafen());
sperreRepository.deleteAll(entity.getSperren());
finisherRepository.deleteAll(entity.getFinisher());
gruppeRepository.delete(entity);
return ResponseEntity.accepted().build();
} catch (Exception e) {
LOGGER.error(e.getMessage(), e);
return ResponseEntity.internalServerError().build();
}
}
@DeleteMapping
public ResponseEntity<Void> delete(@RequestBody AufgabenGruppe gruppe) {
try {
gruppeRepository.findById(gruppe.getGruppenId()).ifPresent(entity -> {
if (entity.isVanillaAvailable()) {
gruppeRepository.delete(entity);
}
});
return ResponseEntity.accepted().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
// ── Hilfsmethoden ──
private UserEntity resolveUser(Principal principal) {
if (principal == null) return null;
return userService.requireUser(principal);

View File

@@ -42,11 +42,12 @@ import de.oaa.xxx.games.vanilla.repository.VanillaMitspielerRepository;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.social.entity.MessageCause;
import de.oaa.xxx.user.UserService;
import de.oaa.xxx.util.BaseController;
@RestController
@RequestMapping("/vanilla")
@Transactional
public class VanillaGameController {
public class VanillaGameController extends BaseController {
private static final Logger LOGGER = LoggerFactory.getLogger(VanillaGameController.class);
/**
@@ -61,7 +62,6 @@ public class VanillaGameController {
private final ObjectMapper objectMapper;
private final SystemMessageService systemMessageService;
private final UserService userService;
public VanillaGameController(VanillaGameRepository sessionRepository,
VanillaMitspielerRepository mitspielerRepository,
@@ -69,23 +69,28 @@ public class VanillaGameController {
ObjectMapper objectMapper,
SystemMessageService systemMessageService,
UserService userService) {
super(userService);
this.sessionRepository = sessionRepository;
this.mitspielerRepository = mitspielerRepository;
this.einladungRepository = einladungRepository;
this.objectMapper = objectMapper;
this.systemMessageService = systemMessageService;
this.userService = userService;
}
@GetMapping("/{sessionId}")
public ResponseEntity<VanillaGame> getBySessionId(@PathVariable("sessionId") UUID sessionId) {
return sessionRepository.findById(sessionId)
.map(entity -> ResponseEntity.ok(toSession(entity)))
.orElse(ResponseEntity.noContent().build());
public ResponseEntity<VanillaGame> getBySessionId(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
VanillaGameEntity entity = sessionRepository.findById(sessionId).orElse(null);
if (entity == null) return ResponseEntity.noContent().build();
if (!isParticipant(entity, userId)) return ResponseEntity.status(403).build();
return ResponseEntity.ok(toSession(entity));
}
@GetMapping
public ResponseEntity<VanillaGame> getByUserId(@org.springframework.web.bind.annotation.RequestParam UUID userId) {
public ResponseEntity<VanillaGame> getMySession(Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
return sessionRepository.findByUserId(userId)
.map(entity -> ResponseEntity.ok(toSession(entity)))
.orElse(ResponseEntity.noContent().build());
@@ -121,20 +126,24 @@ public class VanillaGameController {
}
@DeleteMapping
public ResponseEntity<Void> deleteSession(@RequestBody VanillaGame session) {
return sessionRepository.findById(session.getSessionId())
.map(entity -> {
mitspielerRepository.deleteAll(entity.getMitspieler());
sessionRepository.delete(entity);
return ResponseEntity.accepted().<Void>build();
})
.orElse(ResponseEntity.noContent().build());
public ResponseEntity<Void> deleteSession(@RequestBody VanillaGame session, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
VanillaGameEntity entity = sessionRepository.findById(session.getSessionId()).orElse(null);
if (entity == null) return ResponseEntity.noContent().build();
if (!isOwner(entity, userId)) return ResponseEntity.status(403).build();
mitspielerRepository.deleteAll(entity.getMitspieler());
sessionRepository.delete(entity);
return ResponseEntity.accepted().build();
}
@PostMapping("/{sessionId}/abgeschlossen")
public ResponseEntity<Void> spielAbgeschlossen(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Void> spielAbgeschlossen(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
VanillaGameEntity entity = sessionRepository.findById(sessionId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
if (!isOwner(entity, userId)) return ResponseEntity.status(403).build();
ORDENTLICH_BEENDET.add(sessionId);
mitspielerRepository.deleteAll(entity.getMitspieler());
sessionRepository.delete(entity);
@@ -143,7 +152,11 @@ public class VanillaGameController {
/** Prüft ob eine Session ordentlich (nicht abgebrochen) beendet wurde. */
@GetMapping("/{sessionId}/beendet")
public ResponseEntity<Void> istBeendet(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Void> istBeendet(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
VanillaGameEntity entity = sessionRepository.findById(sessionId).orElse(null);
if (entity != null && !isParticipant(entity, userId)) return ResponseEntity.status(403).build();
if (ORDENTLICH_BEENDET.remove(sessionId)) return ResponseEntity.ok().build();
return ResponseEntity.notFound().build();
}
@@ -174,7 +187,9 @@ public class VanillaGameController {
}
@PostMapping("/{sessionId}/aufgaben")
public ResponseEntity<Void> setAufgaben(@RequestBody AufgabenList list, @PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Void> setAufgaben(@RequestBody AufgabenList list, @PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
try {
if (list.size() > 1000) {
return ResponseEntity.badRequest().build();
@@ -184,6 +199,7 @@ public class VanillaGameController {
if (session == null) {
return ResponseEntity.badRequest().build();
}
if (!isOwner(session, userId)) return ResponseEntity.status(403).build();
session.setAufgaben(aufgaben);
sessionRepository.save(session);
// Erst jetzt Einladungen mit der Session verknüpfen Gäste werden nur weitergeleitet wenn aufgaben bereit sind
@@ -201,12 +217,15 @@ public class VanillaGameController {
}
@GetMapping("/{sessionId}/aufgaben/next")
public ResponseEntity<VanillaAufgabeAnzeige> getNextAufgabe(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<VanillaAufgabeAnzeige> getNextAufgabe(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
try {
VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null || session.getAufgaben() == null) {
return ResponseEntity.badRequest().build();
}
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
session.setLetzteAktivitaet(LocalDateTime.now());
VanillaGameDurchfuehren durchfuehren = new VanillaGameDurchfuehren(session);
VanillaAufgabeAnzeige next = durchfuehren.getNext();
@@ -229,14 +248,17 @@ public class VanillaGameController {
}
@PostMapping("/{sessionId}/mitspieler")
public ResponseEntity<Void> addMitspieler(@RequestBody VanillaMitspieler mitspieler, @PathVariable("sessionId") UUID sessionId) {
if (mitspieler.getName() == null || mitspieler.getVerfuegbareWerkzeuge() == null || mitspieler.getVerfuegbareWerkzeuge().isEmpty()) {
public ResponseEntity<Void> addMitspieler(@RequestBody VanillaMitspieler mitspieler, @PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
if (mitspieler.getName() == null) {
return ResponseEntity.badRequest().build();
}
VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) {
return ResponseEntity.badRequest().build();
}
if (!isOwner(session, userId)) return ResponseEntity.status(403).build();
// Max 2 Mitspieler (1 Host + max 1 Gast)
if (session.getMitspieler().size() >= 2) {
return ResponseEntity.status(409).build();
@@ -244,7 +266,7 @@ public class VanillaGameController {
VanillaMitspielerEntity entity = new VanillaMitspielerEntity();
entity.setMitspielerId(UUID.randomUUID());
entity.setName(mitspieler.getName());
entity.setWerkzeuge(new ArrayList<>(mitspieler.getVerfuegbareWerkzeuge()));
entity.setWerkzeuge(mitspieler.getVerfuegbareWerkzeuge() != null ? new ArrayList<>(mitspieler.getVerfuegbareWerkzeuge()) : new ArrayList<>());
entity.setUserId(mitspieler.getUserId());
entity.setEigenesGeraet(mitspieler.isEigenesGeraet());
entity.setSession(session);
@@ -253,10 +275,13 @@ public class VanillaGameController {
}
@GetMapping("/{sessionId}/finisher")
public ResponseEntity<List<VanillaAufgabeAnzeige>> getFinisher(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<List<VanillaAufgabeAnzeige>> getFinisher(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
try {
VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.badRequest().build();
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
VanillaGameDurchfuehren durchfuehren = new VanillaGameDurchfuehren(session);
return ResponseEntity.ok(durchfuehren.getFinisher());
} catch (Exception exception) {
@@ -266,10 +291,13 @@ public class VanillaGameController {
}
@PostMapping("/{sessionId}/backToLevel5")
public ResponseEntity<Void> backToLevel5(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Void> backToLevel5(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
try {
VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.badRequest().build();
if (!isOwner(session, userId)) return ResponseEntity.status(403).build();
VanillaGameDurchfuehren durchfuehren = new VanillaGameDurchfuehren(session);
durchfuehren.backToLvl5();
session.setLevel(durchfuehren.getLevel());
@@ -305,9 +333,12 @@ public class VanillaGameController {
@PostMapping("/{sessionId}/active-task/abschliessen")
public ResponseEntity<AbschliessenResponse> activeTaskAbschliessen(
@PathVariable("sessionId") UUID sessionId, @RequestBody AbschliessenRequest req) {
@PathVariable("sessionId") UUID sessionId, @RequestBody AbschliessenRequest req, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
// Vanilla: keine Sperren/Strafen einfach activeTask löschen
session.setActiveTaskJson(null);
session.setTaskStartedAt(null);
@@ -319,9 +350,12 @@ public class VanillaGameController {
record ActiveTaskResponse(String taskJson, Long elapsedSeconds) {}
@PutMapping("/{sessionId}/active-task")
public ResponseEntity<Void> setActiveTask(@PathVariable("sessionId") UUID sessionId, @RequestBody ActiveTaskRequest req) {
public ResponseEntity<Void> setActiveTask(@PathVariable("sessionId") UUID sessionId, @RequestBody ActiveTaskRequest req, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
session.setActiveTaskJson(req.taskJson());
session.setTaskStartedAt(req.timerStartedAt());
sessionRepository.save(session);
@@ -329,9 +363,12 @@ public class VanillaGameController {
}
@DeleteMapping("/{sessionId}/active-task")
public ResponseEntity<Void> clearActiveTask(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Void> clearActiveTask(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
session.setActiveTaskJson(null);
session.setTaskStartedAt(null);
sessionRepository.save(session);
@@ -339,9 +376,12 @@ public class VanillaGameController {
}
@GetMapping("/{sessionId}/active-task")
public ResponseEntity<ActiveTaskResponse> getActiveTask(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<ActiveTaskResponse> getActiveTask(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
if (!isParticipant(session, userId)) return ResponseEntity.status(403).build();
if (session.getActiveTaskJson() == null) return ResponseEntity.noContent().build();
Long elapsed = null;
if (session.getTaskStartedAt() != null) {
@@ -352,9 +392,12 @@ public class VanillaGameController {
// ── Debug-Endpoint: vollständiger Entity-Zustand ──
@GetMapping("/{sessionId}/debug")
public ResponseEntity<Map<String, Object>> debug(@PathVariable("sessionId") UUID sessionId) {
public ResponseEntity<Map<String, Object>> debug(@PathVariable("sessionId") UUID sessionId, Principal principal) {
UUID userId = resolveMyId(principal);
if (userId == null) return ResponseEntity.status(401).build();
VanillaGameEntity entity = sessionRepository.findById(sessionId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
if (!isOwner(entity, userId)) return ResponseEntity.status(403).build();
Map<String, Object> session = new LinkedHashMap<>();
session.put("sessionId", entity.getSessionId());
@@ -385,6 +428,16 @@ public class VanillaGameController {
return ResponseEntity.ok(result);
}
private boolean isOwner(VanillaGameEntity session, UUID userId) {
return session.getUserId().equals(userId);
}
private boolean isParticipant(VanillaGameEntity session, UUID userId) {
if (isOwner(session, userId)) return true;
return session.getMitspieler().stream().anyMatch(m -> userId.equals(m.getUserId()));
}
private VanillaGame toSession(VanillaGameEntity entity) {
VanillaGame session = new VanillaGame();
session.setSessionId(entity.getSessionId());

View File

@@ -8,6 +8,7 @@ import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
import de.oaa.xxx.util.BaseController;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
@@ -19,7 +20,7 @@ import java.util.*;
@RestController
@RequestMapping("/gruppen")
public class GruppeController {
public class GruppeController extends BaseController {
private static final Logger LOGGER = LoggerFactory.getLogger(GruppeController.class);
@@ -33,7 +34,6 @@ public class GruppeController {
private final BeitragMeldungRepository meldungRepository;
private final KommentarRepository kommentarRepository;
private final UserRepository userRepository;
private final UserService userService;
public GruppeController(GruppeRepository gruppeRepository,
GruppenmitgliedRepository mitgliedRepository,
@@ -46,6 +46,7 @@ public class GruppeController {
KommentarRepository kommentarRepository,
UserRepository userRepository,
UserService userService) {
super(userService);
this.gruppeRepository = gruppeRepository;
this.mitgliedRepository = mitgliedRepository;
this.anfrageRepository = anfrageRepository;
@@ -56,7 +57,6 @@ public class GruppeController {
this.meldungRepository = meldungRepository;
this.kommentarRepository = kommentarRepository;
this.userRepository = userRepository;
this.userService = userService;
}
record CreateGruppeRequest(String name, String beschreibung, String bild, boolean isPrivate) {}
@@ -482,10 +482,6 @@ public class GruppeController {
return map;
}
private UUID resolveMyId(Principal principal) {
if (principal == null) return null;
return userService.requireUser(principal).getUserId();
}
private boolean isAdmin(UUID gruppeId, UUID userId) {
return mitgliedRepository.findFirstByGruppeIdAndUserId(gruppeId, userId)

View File

@@ -9,6 +9,7 @@ import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
import de.oaa.xxx.util.BaseController;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
@@ -22,7 +23,7 @@ import java.util.*;
import java.util.stream.Collectors;
@RestController
public class GruppenbeitragController {
public class GruppenbeitragController extends BaseController {
private static final Logger LOGGER = LoggerFactory.getLogger(GruppenbeitragController.class);
@@ -35,7 +36,6 @@ public class GruppenbeitragController {
private final BeitragMeldungRepository meldungRepository;
private final KommentarRepository kommentarRepository;
private final UserRepository userRepository;
private final UserService userService;
private final LikeService likeService;
private final HashtagService hashtagService;
@@ -51,6 +51,7 @@ public class GruppenbeitragController {
UserService userService,
LikeService likeService,
HashtagService hashtagService) {
super(userService);
this.gruppeRepository = gruppeRepository;
this.mitgliedRepository = mitgliedRepository;
this.beitragRepository = beitragRepository;
@@ -60,7 +61,6 @@ public class GruppenbeitragController {
this.meldungRepository = meldungRepository;
this.kommentarRepository = kommentarRepository;
this.userRepository = userRepository;
this.userService = userService;
this.likeService = likeService;
this.hashtagService = hashtagService;
}
@@ -237,6 +237,8 @@ public class GruppenbeitragController {
if (bOpt.isEmpty()) return ResponseEntity.notFound().build();
GruppenbeitragEntity beitrag = bOpt.get();
if (!beitrag.getGruppeId().equals(id)) return ResponseEntity.notFound().build();
boolean isAuthor = beitrag.getAuthorId().equals(myId);
boolean isAdmin = mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId)
.map(m -> m.getRolle() == GruppenRolle.ADMIN)
@@ -380,10 +382,6 @@ public class GruppenbeitragController {
// ── Helpers ──
private UUID resolveMyId(Principal principal) {
if (principal == null) return null;
return userService.requireUser(principal).getUserId();
}
private void deleteBeitragCascade(GruppenbeitragEntity beitrag) {
UUID bid = beitrag.getBeitragId();

View File

@@ -156,6 +156,7 @@ public class LocationController {
Principal principal) {
userService.requireUser(principal);
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return ResponseEntity.badRequest().build();
List<UUID> sorted = locationRepo.findByLatIsNotNullAndLonIsNotNull().stream()
.filter(l -> haversineKm(lat, lon, l.getLat(), l.getLon()) <= maxDistanceKm)

View File

@@ -2,6 +2,7 @@ package de.oaa.xxx.mail;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import org.slf4j.Logger;
@@ -20,12 +21,21 @@ public class MailService {
this.mailSender = mailSender;
}
private static final InternetAddress FROM_ADDRESS;
static {
try {
FROM_ADDRESS = new InternetAddress("noreply@xxx-sphere.de");
} catch (AddressException e) {
throw new ExceptionInInitializerError(e);
}
}
public boolean send(Email email) {
try {
MimeMessage message = mailSender.createMimeMessage();
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(email.getEmailAdresse()));
message.setSubject(email.getTitel());
message.setFrom(InternetAddress.parse("noreply@xxx-sphere.de")[0]);
message.setFrom(FROM_ADDRESS);
message.setContent(email.getText(), "text/html; charset=utf-8");
message.addHeader("X-Mailin-Tag", "no-tracking");
message.addHeader("X-Sib-Attributes", "{\"X-SIB-TRACKING\":\"0\"}");

View File

@@ -33,7 +33,7 @@ public class NotificationController {
UUID myId = userService.requireUser(principal).getUserId();
List<Map<String, Object>> result = messageRepository
.findNotificationsForUser(myId, PageRequest.of(0, 10))
.findNotificationsForUser(myId, LocalDateTime.now(), PageRequest.of(0, 10))
.stream()
.map(m -> {
Map<String, Object> n = new LinkedHashMap<>();
@@ -56,7 +56,7 @@ public class NotificationController {
public ResponseEntity<Long> getUnreadCount(Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
return ResponseEntity.ok(
messageRepository.countByReceiverIdAndSystemMessageAndReadAtIsNull(myId, true));
messageRepository.countUnreadDueNotifications(myId, LocalDateTime.now()));
}
@Transactional

View File

@@ -0,0 +1,38 @@
package de.oaa.xxx.social;
import de.oaa.xxx.social.repository.MessageRepository;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class ScheduledNotificationService {
private final MessageRepository messageRepository;
private final SseService sseService;
public ScheduledNotificationService(MessageRepository messageRepository, SseService sseService) {
this.messageRepository = messageRepository;
this.sseService = sseService;
}
@Scheduled(fixedDelay = 60_000)
public void pushDueNotifications() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime from = now.minusMinutes(1);
var due = messageRepository.findNewlyDueScheduled(from, now);
if (due.isEmpty()) return;
due.stream()
.collect(Collectors.groupingBy(m -> m.getReceiverId()))
.forEach((userId, messages) -> {
long unread = messageRepository.countUnreadDueNotifications(userId, now);
String text = messages.get(0).getText();
sseService.push(userId, "NOTIFICATION", Map.of("unreadCount", unread, "text", text));
});
}
}

View File

@@ -74,7 +74,7 @@ public class SystemMessageService {
if (targetUrl != null) msg.setTargetUrl(targetUrl);
messageRepository.save(msg);
long unread = messageRepository.countByReceiverIdAndSystemMessageAndReadAtIsNull(receiverId, true);
long unread = messageRepository.countUnreadDueNotifications(receiverId, LocalDateTime.now());
sseService.push(receiverId, "NOTIFICATION", Map.of("unreadCount", unread, "text", text));
}
@@ -93,6 +93,25 @@ public class SystemMessageService {
}
}
/**
* Erstellt eine geplante Benachrichtigung, die erst ab scheduledAt sichtbar ist.
* Es wird kein sofortiger SSE-Push ausgelöst der ScheduledNotificationService übernimmt das zum Fälligkeitszeitpunkt.
*/
public void sendScheduled(UUID senderId, UUID receiverId, String text, String targetUrl, MessageCause cause, LocalDateTime scheduledAt) {
if (senderId == null || receiverId == null || scheduledAt == null) return;
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(senderId);
msg.setReceiverId(receiverId);
msg.setText(text);
msg.setSentAt(LocalDateTime.now());
msg.setSystemMessage(true);
msg.setMessageCause(cause);
msg.setScheduledAt(scheduledAt);
if (targetUrl != null) msg.setTargetUrl(targetUrl);
messageRepository.save(msg);
}
/**
* Benachrichtigt den Empfänger per SSE, dass sich seine Einladungsliste geändert hat,
* ohne eine In-App-Nachricht oder E-Mail zu erstellen.

View File

@@ -41,4 +41,8 @@ public class MessageEntity {
@Column(length = 500)
private String targetUrl;
/** Wenn gesetzt, wird die Benachrichtigung erst ab diesem Zeitpunkt angezeigt. */
@Column
private LocalDateTime scheduledAt;
}

View File

@@ -2,6 +2,8 @@ package de.oaa.xxx.social.repository;
import de.oaa.xxx.social.entity.KommentarEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.UUID;
@@ -13,4 +15,7 @@ public interface KommentarRepository extends JpaRepository<KommentarEntity, UUID
long countByTargetTypeAndTargetId(String targetType, UUID targetId);
void deleteByAuthorId(UUID authorId);
@Query("SELECT k.targetId, COUNT(k) FROM KommentarEntity k WHERE k.targetType = :targetType AND k.targetId IN :targetIds GROUP BY k.targetId")
List<Object[]> countsByTargetTypeAndTargetIds(@Param("targetType") String targetType, @Param("targetIds") List<UUID> targetIds);
}

View File

@@ -46,12 +46,20 @@ public interface MessageRepository extends JpaRepository<MessageEntity, UUID> {
// ── Notification queries (systemMessage = true) ───────────────────────────
/** Ungelesene zuerst, dann nach sentAt absteigend, max. 10 Einträge. */
/** Ungelesene zuerst, dann nach sentAt absteigend, max. 10 Einträge. Geplante Nachrichten erst ab Fälligkeit. */
@Query("SELECT m FROM MessageEntity m WHERE m.receiverId = :receiverId AND m.systemMessage = true " +
"AND (m.scheduledAt IS NULL OR m.scheduledAt <= :now) " +
"ORDER BY CASE WHEN m.readAt IS NULL THEN 0 ELSE 1 END, m.sentAt DESC")
List<MessageEntity> findNotificationsForUser(@Param("receiverId") UUID receiverId, Pageable pageable);
List<MessageEntity> findNotificationsForUser(@Param("receiverId") UUID receiverId, @Param("now") LocalDateTime now, Pageable pageable);
long countByReceiverIdAndSystemMessageAndReadAtIsNull(UUID receiverId, boolean systemMessage);
@Query("SELECT COUNT(m) FROM MessageEntity m WHERE m.receiverId = :receiverId AND m.systemMessage = true " +
"AND m.readAt IS NULL AND (m.scheduledAt IS NULL OR m.scheduledAt <= :now)")
long countUnreadDueNotifications(@Param("receiverId") UUID receiverId, @Param("now") LocalDateTime now);
/** Findet geplante Benachrichtigungen, die seit dem letzten Scheduler-Lauf fällig geworden sind. */
@Query("SELECT m FROM MessageEntity m WHERE m.systemMessage = true AND m.scheduledAt IS NOT NULL " +
"AND m.scheduledAt <= :now AND m.scheduledAt > :from AND m.readAt IS NULL")
List<MessageEntity> findNewlyDueScheduled(@Param("from") LocalDateTime from, @Param("now") LocalDateTime now);
@Modifying
@Transactional

View File

@@ -97,7 +97,8 @@ public class LoginController {
}
@GetMapping("/{userId}")
public ResponseEntity<User> get(@PathVariable("userId") UUID userId) {
public ResponseEntity<User> get(@PathVariable("userId") UUID userId, Principal principal) {
if (principal == null) return ResponseEntity.status(401).build();
return userRepository.findById(userId)
.map(entity -> ResponseEntity.ok(entity.toUser()))
.orElse(ResponseEntity.noContent().build());

View File

@@ -384,6 +384,7 @@ public class UserController {
}
cfg.setUsername(body.username());
if (body.password() != null && !body.password().isBlank()) {
// MD5 is required by the TTLock API protocol — not a choice, it's the external API contract
cfg.setPasswordMd5(DigestUtils.md5DigestAsHex(body.password().getBytes(StandardCharsets.UTF_8)));
}
cfg.setLockId(body.lockId());

View File

@@ -0,0 +1,20 @@
package de.oaa.xxx.util;
import de.oaa.xxx.user.UserService;
import java.security.Principal;
import java.util.UUID;
public abstract class BaseController {
protected final UserService userService;
protected BaseController(UserService userService) {
this.userService = userService;
}
protected UUID resolveMyId(Principal principal) {
if (principal == null) return null;
return userService.requireUser(principal).getUserId();
}
}

View File

@@ -6,4 +6,4 @@ app.cookie.secure=false
# Klartext-Credentials für lokale DB (kein Umgebungsvariablen-Zwang)
spring.mail.username=local@dev.invalid
spring.mail.password=unused
jwt.keystore.password=XUR!Rv&f$j3UsqD&
jwt.keystore.password=${JWT_KEYSTORE_PASSWORD}

View File

@@ -6,6 +6,8 @@ spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# JPA / Hibernate
spring.jpa.hibernate.ddl-auto=update
spring.flyway.baseline-on-migrate=true
spring.flyway.locations=classpath:db/migration
spring.jpa.show-sql=false
spring.jpa.open-in-view=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect

View File

@@ -0,0 +1,8 @@
-- Baseline marker für Flyway.
-- Diese Migration wird auf bestehenden Datenbanken nicht ausgeführt
-- (spring.flyway.baseline-on-migrate=true markiert sie als bereits angewendet).
-- Für neue Datenbanken: Schema wird von Hibernate (ddl-auto=update) angelegt,
-- da kein vollständiges CREATE-Skript vorhanden ist.
-- Sobald das Schema stabil ist, diesen Inhalt durch ein vollständiges
-- mysqldump --no-data xxx_sphere > V1__baseline.sql ersetzen
-- und ddl-auto auf validate umstellen.

View File

@@ -0,0 +1,34 @@
-- Migration: vanillaAvailable (boolean) → availableIn (VARCHAR enum)
--
-- BDSM_AND_VANILLA = ehemals vanilla_available = TRUE
-- BDSM_ONLY = ehemals vanilla_available = FALSE (Default)
-- CHASTITY_ONLY = neuer Wert
--
-- Die Prozedur prüft zuerst, ob vanilla_available noch existiert, bevor
-- sie etwas tut dadurch ist die Migration auf leeren Datenbanken ein No-op.
DROP PROCEDURE IF EXISTS proc_migrate_available_in;
CREATE PROCEDURE proc_migrate_available_in()
BEGIN
DECLARE col_exists INT DEFAULT 0;
SELECT COUNT(*) INTO col_exists
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aufgaben_gruppe'
AND COLUMN_NAME = 'vanilla_available';
IF col_exists > 0 THEN
ALTER TABLE aufgaben_gruppe
ADD COLUMN available_in VARCHAR(50) NOT NULL DEFAULT 'BDSM_ONLY';
UPDATE aufgaben_gruppe
SET available_in = 'BDSM_AND_VANILLA'
WHERE vanilla_available = 1;
ALTER TABLE aufgaben_gruppe
DROP COLUMN vanilla_available;
END IF;
END;
CALL proc_migrate_available_in();
DROP PROCEDURE IF EXISTS proc_migrate_available_in;

View File

@@ -122,6 +122,7 @@
.gruppe-badge { font-size:0.65rem; padding:0.1rem 0.4rem; border-radius:20px; background:rgba(255,255,255,0.07); color:var(--color-muted); }
.gruppe-badge-public { background:rgba(46,204,113,0.15); color:var(--color-success); }
.gruppe-badge-vanilla { background:#e8f5e9; color:#2e7d32; border:1px solid #a5d6a7; }
.gruppe-badge-chastity { background:rgba(155,89,182,0.15); color:#9b59b6; border:1px solid rgba(155,89,182,0.4); }
.gruppe-toggle { font-size:0.75rem; color:var(--color-muted); flex-shrink:0; transition:transform 0.2s; }
.gruppe-card.open .gruppe-toggle { transform:rotate(90deg); }
.gruppe-body { border-top:1px solid var(--color-secondary); padding:1rem 1rem 0.75rem; }
@@ -278,11 +279,12 @@
<span style="font-size:0.78rem; color:var(--color-muted);">Aktuelles Bild neues wählen zum Ersetzen</span>
</div>
<input type="file" id="gBild" accept="image/*">
<label style="margin-top:0.5rem;">
<span class="modal-check">
<input type="checkbox" id="gVanilla">
Auch für Vanilla-Game verfügbar
</span>
<label style="margin-top:0.5rem;display:block;font-size:0.85rem;color:var(--color-muted);">Verfügbar in
<select id="gAvailableIn" style="margin-top:0.3rem;width:100%;padding:0.5rem 0.75rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:0.88rem;">
<option value="BDSM_ONLY">Nur BDSM</option>
<option value="BDSM_AND_VANILLA">BDSM &amp; Vanilla</option>
<option value="CHASTITY_ONLY">Nur Chastity</option>
</select>
</label>
<div class="modal-error" id="gruppeModalError"></div>
<div class="modal-actions">
@@ -993,7 +995,7 @@ function renderAdminGruppen(gruppen) {
<div class="gruppe-meta">
<div class="gruppe-name">${esc(g.name)}</div>
<div class="gruppe-info">${g.von ? esc(g.von) + (counts ? ' · ' : '') : ''}${counts || 'Keine Einträge'}</div>
<div class="gruppe-badges"><span class="gruppe-badge gruppe-badge-public">Öffentlich</span>${g.vanillaAvailable ? '<span class="gruppe-badge gruppe-badge-vanilla">Vanilla</span>' : ''}</div>
<div class="gruppe-badges"><span class="gruppe-badge gruppe-badge-public">Öffentlich</span>${g.availableIn === 'BDSM_AND_VANILLA' ? '<span class="gruppe-badge gruppe-badge-vanilla">Vanilla</span>' : ''}${g.availableIn === 'CHASTITY_ONLY' ? '<span class="gruppe-badge gruppe-badge-chastity">Chastity</span>' : ''}</div>
</div>
<span class="gruppe-toggle">▶</span>
</div>
@@ -1269,7 +1271,7 @@ function openGruppeModal(editId) {
document.getElementById('gName').value = g.name || '';
document.getElementById('gVon').value = g.von || '';
document.getElementById('gDesc').value = g.beschreibung || '';
document.getElementById('gVanilla').checked = g.vanillaAvailable || false;
document.getElementById('gAvailableIn').value = g.availableIn || 'BDSM_ONLY';
const imgWrap = document.getElementById('gCurrentImgWrap');
if (g.bild) { document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild; imgWrap.style.display = 'flex'; }
else imgWrap.style.display = 'none';
@@ -1278,7 +1280,7 @@ function openGruppeModal(editId) {
document.getElementById('gName').value = '';
document.getElementById('gVon').value = '';
document.getElementById('gDesc').value = '';
document.getElementById('gVanilla').checked = false;
document.getElementById('gAvailableIn').value = 'BDSM_ONLY';
document.getElementById('gCurrentImgWrap').style.display = 'none';
}
gruppeModal.classList.add('open');
@@ -1357,7 +1359,7 @@ gruppeModalSave.addEventListener('click', async () => {
let bildBase64 = null;
const fi = document.getElementById('gBild');
if (fi.files.length > 0) bildBase64 = await toBase64(fi.files[0]);
const payload = { name, von: document.getElementById('gVon').value.trim() || null, beschreibung: document.getElementById('gDesc').value.trim() || null, vanillaAvailable: document.getElementById('gVanilla').checked, bild: bildBase64 };
const payload = { name, von: document.getElementById('gVon').value.trim() || null, beschreibung: document.getElementById('gDesc').value.trim() || null, availableIn: document.getElementById('gAvailableIn').value, bild: bildBase64 };
const isEdit = currentEditGruppeId != null;
fetch(isEdit ? `/admin/aufgabengruppen/${currentEditGruppeId}` : '/admin/aufgabengruppen', {
method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
<!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>Entdecken xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Search ── */
.search-bar {
display: flex; gap: 0.6rem; margin-bottom: 1.5rem; align-items: center;
}
.search-bar input[type="text"] {
flex: 1; padding: 0.55rem 0.85rem;
border: 1px solid var(--color-secondary); border-radius: 6px;
background: var(--color-card); color: var(--color-text);
font-size: 0.95rem; outline: none; transition: border-color 0.2s;
}
.search-bar input[type="text"]:focus { border-color: var(--color-primary); }
.search-bar input[type="text"]::placeholder { color: var(--color-muted); }
.btn-search {
background: var(--color-secondary); color: var(--color-text);
border: none; border-radius: 6px; padding: 0.55rem 1rem;
font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 0.15s;
}
.btn-search:hover { background: var(--color-primary); color: #fff; }
/* ── Paging ── */
.paging {
display: flex; align-items: center; justify-content: center;
gap: 0.75rem; margin-top: 1rem;
}
.paging button {
background: var(--color-secondary); color: var(--color-text);
border: none; border-radius: 6px; padding: 0.4rem 0.9rem;
font-size: 0.85rem; cursor: pointer; transition: background 0.15s;
}
.paging button:hover:not(:disabled) { background: var(--color-primary); }
.paging button:disabled { opacity: 0.35; cursor: default; }
.paging .page-info { font-size: 0.85rem; color: var(--color-muted); }
.empty, .loading { color: var(--color-muted); font-size: 0.9rem; padding: 0.75rem 0; }
/* ── Gruppe card ── */
.gruppe-list { display: flex; flex-direction: column; gap: 0.75rem; }
.gruppe-card {
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 10px; overflow: hidden; transition: border-color 0.15s;
}
.gruppe-card.open { border-color: rgba(233,69,96,0.35); }
.gruppe-header {
display: flex; align-items: center; gap: 0.9rem;
padding: 0.85rem 1rem; cursor: pointer; user-select: none;
}
.gruppe-img {
width: 48px; height: 48px; border-radius: 7px;
object-fit: cover; flex-shrink: 0;
}
.gruppe-img-placeholder {
width: 48px; height: 48px; border-radius: 7px;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem; flex-shrink: 0; color: var(--color-muted);
}
.gruppe-meta { flex: 1; min-width: 0; }
.gruppe-name {
font-size: 0.95rem; font-weight: 600; color: var(--color-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gruppe-info { font-size: 0.75rem; color: var(--color-muted); margin-top: 0.2rem; }
.gruppe-badges { display: flex; gap: 0.3rem; margin-top: 0.25rem; flex-wrap: wrap; }
.gruppe-badge {
font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 20px;
background: rgba(255,255,255,0.07); color: var(--color-muted);
}
.gruppe-badge-sub { background: rgba(46,204,113,0.15); color: var(--color-success); }
.gruppe-toggle { font-size: 0.75rem; color: var(--color-muted); flex-shrink: 0; transition: transform 0.2s; }
.gruppe-card.open .gruppe-toggle { transform: rotate(90deg); }
/* ── Subscribe button ── */
.btn-sub {
background: none; border: 1px solid var(--color-secondary); border-radius: 6px;
color: var(--color-muted); font-size: 0.8rem; padding: 0.3rem 0.75rem;
cursor: pointer; transition: border-color 0.15s, color 0.15s, background 0.15s;
flex-shrink: 0; white-space: nowrap;
}
.btn-sub:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-sub.subscribed {
border-color: rgba(46,204,113,0.5); color: var(--color-success);
}
.btn-sub.subscribed:hover {
border-color: var(--color-primary); color: var(--color-primary);
background: rgba(233,69,96,0.08);
}
.btn-sub:disabled { opacity: 0.4; cursor: default; }
/* ── Gruppe body ── */
.gruppe-body { border-top: 1px solid var(--color-secondary); padding: 1rem 1rem 0.75rem; }
.gruppe-desc { font-size: 0.82rem; color: var(--color-muted); margin-bottom: 0.85rem; line-height: 1.5; }
.sub-section + .sub-section { margin-top: 0.85rem; }
.sub-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; }
.sub-section-title {
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em;
text-transform: uppercase; color: var(--color-primary);
}
/* ── Items ── */
.item-list { display: flex; flex-direction: column; gap: 0.3rem; }
.item { border-radius: 6px; background: var(--color-secondary); overflow: hidden; }
.item-row {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; padding: 0.35rem 0.6rem;
cursor: pointer; user-select: none; transition: background 0.12s;
}
.item-row:hover { background: rgba(255,255,255,0.04); }
.item.open .item-row { background: rgba(233,69,96,0.08); }
.item-text {
color: var(--color-text); flex: 1; min-width: 0; font-size: 0.82rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.item-badges { display: flex; gap: 0.35rem; flex-shrink: 0; }
.badge {
font-size: 0.7rem; padding: 0.1rem 0.45rem; border-radius: 20px;
background: rgba(233,69,96,0.15); color: var(--color-primary); white-space: nowrap;
}
.badge-neutral { background: rgba(255,255,255,0.07); color: var(--color-muted); }
/* ── Item detail ── */
.item-detail {
display: none; padding: 0.5rem 0.6rem 0.6rem;
border-top: 1px solid rgba(255,255,255,0.06);
font-size: 0.8rem; color: var(--color-muted); line-height: 1.55;
}
.item.open .item-detail { display: block; }
.item-detail-text { margin-bottom: 0.4rem; color: var(--color-text); white-space: pre-wrap; }
.item-detail-row { display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; margin-top: 0.25rem; }
.item-detail-label { font-size: 0.72rem; color: var(--color-muted); }
.item-detail-chip {
font-size: 0.7rem; padding: 0.1rem 0.5rem; border-radius: 20px;
background: rgba(255,255,255,0.07); color: var(--color-text);
}
.item-detail-chip-toy { background: rgba(233,69,96,0.12); color: var(--color-primary); }
.sub-empty { font-size: 0.78rem; color: var(--color-muted); padding: 0.2rem 0; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" maxlength="200">
<button class="btn-search" id="searchBtn">Suchen</button>
</div>
<div id="loading" class="loading">Wird geladen…</div>
<div id="groupList" class="gruppe-list"></div>
<div class="paging" id="paging" style="display:none;">
<button id="prevBtn"> Zurück</button>
<span class="page-info" id="pageInfo"></span>
<button id="nextBtn">Weiter </button>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
const PAGE_SIZE = 10;
let currentPage = 0, totalPages = 1;
let currentName = '';
// ── XSS ──
function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Auth ──
fetch('/login/me')
.then(r => { if (r.status === 401) { window.location.href = '/login.html'; return null; } return r.ok ? r.json() : null; })
.then(user => { if (!user) return; loadGroups(); })
.catch(() => { window.location.href = '/login.html'; });
// ── Load ──
function loadGroups() {
document.getElementById('loading').style.display = 'block';
document.getElementById('groupList').innerHTML = '';
document.getElementById('paging').style.display = 'none';
const nameParam = currentName ? `&name=${encodeURIComponent(currentName)}` : '';
fetch(`/abo/discover?page=${currentPage}&size=${PAGE_SIZE}${nameParam}`)
.then(r => r.json())
.then(data => {
totalPages = data.totalPages || 1;
renderGroups(data.content || []);
updatePaging(currentPage, totalPages);
document.getElementById('loading').style.display = 'none';
})
.catch(() => { document.getElementById('loading').textContent = 'Fehler beim Laden.'; });
}
// ── Render ──
const WERKZEUG_LABEL = {
MUND: 'Mund', VAGINA: 'Vagina', PENIS: 'Penis',
ANUS: 'Anus', UMSCHNALLDILDO: 'Umschnall-Dildo'
};
function werkzeugChips(list) {
if (!list || list.length === 0) return '';
return list.map(w => `<span class="item-detail-chip">${esc(WERKZEUG_LABEL[w] || w)}</span>`).join('');
}
function toyChips(list) {
if (!list || list.length === 0) return '';
return list.map(t => `<span class="item-detail-chip item-detail-chip-toy">${esc(t.name || t)}</span>`).join('');
}
function formatSek(von, bis) {
if (von != null && bis != null) return `${von}${bis} s`;
if (von != null) return `ab ${von} s`;
if (bis != null) return `bis ${bis} s`;
return '';
}
function formatMin(von, bis) {
if (von != null && bis != null) return `${von}${bis} min`;
if (von != null) return `ab ${von} min`;
if (bis != null) return `bis ${bis} min`;
return '';
}
// Track which group card is open
let openGroupId = null;
// Track which item detail is open
let openItemId = null;
function renderGroups(groups) {
const list = document.getElementById('groupList');
if (!groups || groups.length === 0) {
list.innerHTML = '<p class="empty">Keine Gruppen gefunden.</p>';
return;
}
list.innerHTML = groups.map(g => {
const aufgabenCount = (g.aufgaben || []).length;
const strafeCount = (g.strafen || []).length;
const sperreCount = (g.sperren || []).length;
const counts = [
aufgabenCount ? `${aufgabenCount} Aufgabe${aufgabenCount !== 1 ? 'n' : ''}` : '',
strafeCount ? `${strafeCount} Strafe${strafeCount !== 1 ? 'n' : ''}` : '',
sperreCount ? `${sperreCount} Zeitstrafe${sperreCount !== 1 ? 'n' : ''}` : ''
].filter(Boolean).join(' · ');
const subLabel = g.subscribed
? `<span class="gruppe-badge gruppe-badge-sub">♥ Abonniert</span>`
: '';
const subCount = g.subscriberCount > 0
? `<span class="gruppe-badge">♥ ${g.subscriberCount} Abo${g.subscriberCount !== 1 ? 's' : ''}</span>`
: '';
const subBtnClass = g.subscribed ? 'btn-sub subscribed' : 'btn-sub';
const subBtnText = g.subscribed ? '♥ Abonniert' : '♥ Abonnieren';
return `
<div class="gruppe-card" id="dgroup-${esc(g.gruppenId)}">
<div class="gruppe-header">
<div style="cursor:pointer; display:flex; align-items:center; gap:0.9rem; flex:1; min-width:0;"
onclick="toggleGroup('${esc(g.gruppenId)}')">
${g.bild
? `<img class="gruppe-img" src="data:image/png;base64,${g.bild}" alt="${esc(g.name)}">`
: `<div class="gruppe-img-placeholder">⊙</div>`}
<div class="gruppe-meta">
<div class="gruppe-name">${esc(g.name)}</div>
<div class="gruppe-info">${g.von ? esc(g.von) + (counts ? ' · ' : '') : ''}${counts || 'Keine Einträge'}</div>
${(subLabel || subCount) ? `<div class="gruppe-badges">${subCount}${subLabel}</div>` : ''}
</div>
<span class="gruppe-toggle">▶</span>
</div>
<button class="${subBtnClass}" id="subbtn-${esc(g.gruppenId)}"
onclick="toggleSubscribe('${esc(g.gruppenId)}', this)">
${subBtnText}
</button>
</div>
<div class="gruppe-body" id="dbody-${esc(g.gruppenId)}" style="display:none;">
${g.beschreibung ? `<div class="gruppe-desc">${esc(g.beschreibung)}</div>` : ''}
${renderSubSection('Aufgaben', sortByLevelThenName(g.aufgaben || []), renderAufgabe)}
${renderSubSection('Strafen', sortByLevelThenName(g.strafen || []), renderStrafe)}
${renderSubSection('Zeitstrafen', sortByName(g.sperren || []), renderZeitstrafe)}
</div>
</div>`;
}).join('');
openItemId = null;
}
function renderSubSection(title, items, renderFn) {
return `<div class="sub-section">
<div class="sub-section-header">
<span class="sub-section-title">${esc(title)} (${items.length})</span>
</div>
${items.length === 0
? '<div class="sub-empty">Keine Einträge</div>'
: `<div class="item-list">${items.map(item => renderFn(item)).join('')}</div>`}
</div>`;
}
function renderAufgabe(a) {
const badges = [];
const zeit = formatSek(a.sekundenVon, a.sekundenBis);
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
if (a.level != null) badges.push(`<span class="badge">Level ${esc(String(a.level))}</span>`);
const detailRows = [];
if (a.text) detailRows.push(`<div class="item-detail-text">${esc(a.text)}</div>`);
if (a.benoetigtAktiv && a.benoetigtAktiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Aktiv:</span>${werkzeugChips(a.benoetigtAktiv)}</div>`);
if (a.benoetigtPassiv && a.benoetigtPassiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Passiv:</span>${werkzeugChips(a.benoetigtPassiv)}</div>`);
if (a.benoetigteToys && a.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(a.benoetigteToys)}</div>`);
return `<div class="item" id="ditem-${esc(a.aufgabeId)}">
<div class="item-row" onclick="toggleItem('${esc(a.aufgabeId)}')">
<span class="item-text">${esc(a.kurzText)}</span>
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
</div>
${detailRows.length ? `<div class="item-detail">${detailRows.join('')}</div>` : ''}
</div>`;
}
function renderStrafe(s) {
const badges = [];
const zeit = formatSek(s.sekundenVon, s.sekundenBis);
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
if (s.level != null) badges.push(`<span class="badge">Level ${esc(String(s.level))}</span>`);
const detailRows = [];
if (s.text) detailRows.push(`<div class="item-detail-text">${esc(s.text)}</div>`);
if (s.benoetigtAktiv && s.benoetigtAktiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Aktiv:</span>${werkzeugChips(s.benoetigtAktiv)}</div>`);
if (s.benoetigtPassiv && s.benoetigtPassiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Passiv:</span>${werkzeugChips(s.benoetigtPassiv)}</div>`);
if (s.benoetigteToys && s.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(s.benoetigteToys)}</div>`);
return `<div class="item" id="ditem-${esc(s.strafeId)}">
<div class="item-row" onclick="toggleItem('${esc(s.strafeId)}')">
<span class="item-text">${esc(s.kurzText)}</span>
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
</div>
${detailRows.length ? `<div class="item-detail">${detailRows.join('')}</div>` : ''}
</div>`;
}
function renderZeitstrafe(z) {
const badges = [];
const zeit = formatMin(z.minutenVon, z.minutenBis);
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
const detailRows = [];
if (z.text) detailRows.push(`<div class="item-detail-text">${esc(z.text)}</div>`);
if (z.releaseText) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Bei Aufhebung:</span><span style="font-size:0.78rem; color:var(--color-text);">${esc(z.releaseText)}</span></div>`);
if (z.sperreFuer && z.sperreFuer.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Sperrt:</span>${werkzeugChips(z.sperreFuer)}</div>`);
if (z.benoetigteToys && z.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(z.benoetigteToys)}</div>`);
return `<div class="item" id="ditem-${esc(z.sperreId)}">
<div class="item-row" onclick="toggleItem('${esc(z.sperreId)}')">
<span class="item-text">${esc(z.kurzText)}</span>
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
</div>
${detailRows.length ? `<div class="item-detail">${detailRows.join('')}</div>` : ''}
</div>`;
}
// ── Sort ──
function sortByLevelThenName(items) {
return items.slice().sort((a, b) => {
const la = a.level ?? 999, lb = b.level ?? 999;
if (la !== lb) return la - lb;
return (a.kurzText || '').localeCompare(b.kurzText || '', 'de');
});
}
function sortByName(items) {
return items.slice().sort((a, b) => (a.kurzText || '').localeCompare(b.kurzText || '', 'de'));
}
// ── Group toggle ──
function toggleGroup(gruppenId) {
const card = document.getElementById('dgroup-' + gruppenId);
const body = document.getElementById('dbody-' + gruppenId);
if (!card) return;
if (card.classList.contains('open')) {
card.classList.remove('open');
body.style.display = 'none';
if (openGroupId === gruppenId) openGroupId = null;
} else {
if (openGroupId) {
const prev = document.getElementById('dgroup-' + openGroupId);
const prevBody = document.getElementById('dbody-' + openGroupId);
if (prev) prev.classList.remove('open');
if (prevBody) prevBody.style.display = 'none';
}
card.classList.add('open');
body.style.display = 'block';
openGroupId = gruppenId;
openItemId = null;
}
}
// ── Item toggle ──
function toggleItem(itemId) {
if (openItemId === itemId) {
const el = document.getElementById('ditem-' + itemId);
if (el) el.classList.remove('open');
openItemId = null;
return;
}
if (openItemId) {
const prev = document.getElementById('ditem-' + openItemId);
if (prev) prev.classList.remove('open');
}
const el = document.getElementById('ditem-' + itemId);
if (el) el.classList.add('open');
openItemId = itemId;
}
// ── Subscribe / Unsubscribe ──
function toggleSubscribe(gruppenId, btn) {
btn.disabled = true;
const isSubscribed = btn.classList.contains('subscribed');
const method = isSubscribed ? 'DELETE' : 'POST';
fetch(`/abo/${gruppenId}`, { method })
.then(r => {
if (r.ok || r.status === 201 || r.status === 202) {
if (isSubscribed) {
btn.classList.remove('subscribed');
btn.textContent = '♥ Abonnieren';
updateBadge(gruppenId, false);
} else {
btn.classList.add('subscribed');
btn.textContent = '♥ Abonniert';
updateBadge(gruppenId, true);
}
btn.disabled = false;
} else {
btn.disabled = false;
}
})
.catch(() => { btn.disabled = false; });
}
function updateBadge(gruppenId, subscribed) {
const card = document.getElementById('dgroup-' + gruppenId);
if (!card) return;
const badgesEl = card.querySelector('.gruppe-badges');
if (!badgesEl) return;
const subBadge = badgesEl.querySelector('.gruppe-badge-sub');
if (subscribed && !subBadge) {
badgesEl.insertAdjacentHTML('beforeend', `<span class="gruppe-badge gruppe-badge-sub">♥ Abonniert</span>`);
} else if (!subscribed && subBadge) {
subBadge.remove();
}
}
// ── Search ──
document.getElementById('searchBtn').addEventListener('click', () => {
currentName = document.getElementById('searchInput').value.trim();
currentPage = 0;
loadGroups();
});
document.getElementById('searchInput').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('searchBtn').click();
});
// ── Paging ──
function updatePaging(current, total) {
const el = document.getElementById('paging');
if (total <= 1) { el.style.display = 'none'; return; }
el.style.display = 'flex';
document.getElementById('prevBtn').disabled = current === 0;
document.getElementById('nextBtn').disabled = current >= total - 1;
document.getElementById('pageInfo').textContent = `Seite ${current + 1} von ${total}`;
}
document.getElementById('prevBtn').addEventListener('click', () => {
if (currentPage > 0) { currentPage--; loadGroups(); }
});
document.getElementById('nextBtn').addEventListener('click', () => {
if (currentPage < totalPages - 1) { currentPage++; loadGroups(); }
});
</script>
</body>
</html>

View File

@@ -0,0 +1,642 @@
<!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>Toys xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Section ── */
.section + .section { margin-top: 2.5rem; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-secondary);
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary);
margin: 0;
}
.btn-add {
display: flex;
align-items: center;
gap: 0.4rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-add:hover { background: #c73652; }
/* ── Toy grid ── */
.toy-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 0.85rem;
}
/* ── Toy card ── */
.toy-card {
display: flex;
align-items: center;
gap: 0.85rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 0.8rem 0.9rem;
transition: border-color 0.15s;
position: relative;
}
.toy-card { cursor: pointer; }
.toy-card:hover { border-color: var(--color-primary); }
.toy-card.selected {
border-color: var(--color-primary);
background: rgba(233,69,96,0.06);
}
.toy-img {
width: 52px; height: 52px;
border-radius: 7px;
object-fit: cover;
flex-shrink: 0;
}
.toy-img-placeholder {
width: 52px; height: 52px;
border-radius: 7px;
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
flex-shrink: 0;
color: var(--color-muted);
}
.toy-info { flex: 1; min-width: 0; }
.toy-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toy-desc {
font-size: 0.78rem;
color: var(--color-muted);
margin-top: 0.2rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Section action buttons ── */
.section-actions { display: flex; align-items: center; gap: 0.5rem; }
.btn-action {
background: var(--color-secondary);
color: var(--color-text);
border: none;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s, opacity 0.15s;
}
.btn-action:disabled { opacity: 0.35; cursor: default; }
.btn-action:not(:disabled):hover { background: var(--color-primary); color: #fff; }
.btn-action-danger:not(:disabled):hover { background: rgba(233,69,96,0.18); color: var(--color-primary); }
.action-error {
font-size: 0.82rem;
color: var(--color-primary);
min-height: 1.1em;
margin-bottom: 0.4rem;
}
/* ── Empty / Loading ── */
.empty, .loading { color: var(--color-muted); font-size: 0.9rem; padding: 0.75rem 0; }
/* ── Inline-Fehler im Grid ── */
.grid-error {
font-size: 0.85rem;
color: var(--color-primary);
padding: 0.5rem 0;
}
/* ── Modal ── */
.modal-backdrop {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-backdrop.open { display: flex; }
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 420px;
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
}
.modal h2 {
color: var(--color-primary);
font-size: 1.1rem;
margin-bottom: 1.25rem;
}
.modal label {
display: block;
font-size: 0.8rem;
color: #aaa;
margin-top: 1rem;
margin-bottom: 0.3rem;
}
.modal input[type="text"],
.modal textarea {
width: 100%;
padding: 0.6rem 0.85rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
color: var(--color-text);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
resize: vertical;
}
.modal input[type="text"]:focus,
.modal textarea:focus { border-color: var(--color-primary); }
.modal input[type="file"] {
font-size: 0.85rem;
color: var(--color-muted);
margin-top: 0.25rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.modal-actions .btn-cancel {
background: var(--color-secondary);
color: var(--color-text);
border: none;
border-radius: 6px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s;
}
.modal-actions .btn-cancel:hover { background: #1a4a8a; }
.modal-actions .btn-save {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.modal-actions .btn-save:hover { background: #c73652; }
.modal-actions .btn-save:disabled { opacity: 0.5; cursor: default; }
.modal-error {
color: var(--color-primary);
font-size: 0.82rem;
margin-top: 0.75rem;
display: none;
}
@media (max-width: 768px) {
.toy-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body class="app">
<!-- Erstell-/Bearbeitungs-Modal -->
<div class="modal-backdrop" id="createModal">
<div class="modal">
<h2 id="modalTitle">Neues Toy</h2>
<label for="toyName">Name *</label>
<input type="text" id="toyName" placeholder="z.B. Vibrator" maxlength="100">
<label for="toyDesc">Beschreibung</label>
<textarea id="toyDesc" rows="3" placeholder="Kurze Beschreibung…" maxlength="500"></textarea>
<label>Bild (optional)</label>
<div id="currentImageWrap" style="display:none; align-items:center; gap:0.5rem; margin-bottom:0.4rem;">
<img id="currentImage" style="max-width:64px; max-height:64px; border-radius:6px;" src="" alt="">
<span style="font-size:0.78rem; color:var(--color-muted);">Aktuelles Bild neues Bild wählen zum Ersetzen</span>
</div>
<input type="file" id="toyBild" accept="image/*">
<div class="modal-error" id="modalError"></div>
<div class="modal-actions">
<button class="btn-cancel" id="cancelBtn">Abbrechen</button>
<button class="btn-save" id="saveBtn">Speichern</button>
</div>
</div>
</div>
<div class="main">
<div class="content">
<!-- Meine Toys -->
<div class="section">
<div class="section-header">
<h2 class="section-title">Meine Toys</h2>
<div class="section-actions">
<button class="btn-action" id="editBtn" disabled>✎ Bearbeiten</button>
<button class="btn-action btn-action-danger" id="deleteBtn" disabled>✕ Löschen</button>
<button class="btn-add" id="openCreateBtn">+ Neu</button>
</div>
</div>
<div class="action-error" id="actionError"></div>
<div class="toy-grid" id="userGrid"></div>
<div id="userLoading" class="loading" style="display:none;"></div>
<div id="userSentinel"></div>
</div>
<!-- System-Toys -->
<div class="section">
<div class="section-header">
<h2 class="section-title">System-Toys</h2>
<div class="section-actions">
<button class="btn-action" id="copyBtn" disabled>⊕ In meine Toys kopieren</button>
</div>
</div>
<div class="action-error" id="systemActionError"></div>
<div class="toy-grid" id="systemGrid"></div>
<div id="systemLoading" class="loading" style="display:none;"></div>
<div id="systemSentinel"></div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
const PAGE_SIZE = 12;
let userPage = 0, userTotalPages = 1, userLoading = false;
let systemPage = 0, systemTotalPages = 1, systemLoading = false;
// ── Infinite-scroll observers ──
const userObserver = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadUserPage();
}, { rootMargin: '200px' });
const systemObserver = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadSystemPage();
}, { rootMargin: '200px' });
// ── Auth + initial load ──
fetch('/login/me')
.then(r => { if (r.status === 401) { window.location.href = '/login.html'; return null; } return r.ok ? r.json() : null; })
.then(user => {
if (!user) return;
userObserver.observe(document.getElementById('userSentinel'));
systemObserver.observe(document.getElementById('systemSentinel'));
})
.catch(() => { window.location.href = '/login.html'; });
// ── Load user toys (append, füllt Viewport automatisch auf) ──
async function loadUserPage() {
if (userLoading || userPage >= userTotalPages) return;
userLoading = true;
const loadEl = document.getElementById('userLoading');
try {
do {
loadEl.textContent = 'Wird geladen…';
loadEl.style.display = 'block';
const r = await fetch(`/toy/list/user?page=${userPage}&size=${PAGE_SIZE}`);
const data = await r.json();
userTotalPages = data.totalPages || 1;
appendGrid('userGrid', data.content, 'selectToy');
userPage++;
loadEl.style.display = 'none';
} while (userPage < userTotalPages && sentinelVisible('userSentinel'));
} catch (_) {
loadEl.textContent = 'Fehler beim Laden.';
} finally {
userLoading = false;
}
}
function reloadUserToys() {
userPage = 0;
userTotalPages = 1;
resetSelection();
document.getElementById('userGrid').innerHTML = '';
loadUserPage();
}
// ── Load system toys (append, füllt Viewport automatisch auf) ──
async function loadSystemPage() {
if (systemLoading || systemPage >= systemTotalPages) return;
systemLoading = true;
const loadEl = document.getElementById('systemLoading');
try {
do {
loadEl.textContent = 'Wird geladen…';
loadEl.style.display = 'block';
const r = await fetch(`/toy/list/system?page=${systemPage}&size=${PAGE_SIZE}`);
const data = await r.json();
systemTotalPages = data.totalPages || 1;
appendGrid('systemGrid', data.content, 'selectSystemToy');
systemPage++;
loadEl.style.display = 'none';
} while (systemPage < systemTotalPages && sentinelVisible('systemSentinel'));
} catch (_) {
loadEl.textContent = 'Fehler beim Laden.';
} finally {
systemLoading = false;
}
}
function reloadSystemToys() {
systemPage = 0;
systemTotalPages = 1;
resetSystemSelection();
document.getElementById('systemGrid').innerHTML = '';
loadSystemPage();
}
// ── Prüft ob ein Sentinel noch im (erweiterten) Viewport liegt ──
function sentinelVisible(id) {
const el = document.getElementById(id);
return el ? el.getBoundingClientRect().top <= window.innerHeight + 200 : false;
}
// ── Append items to a grid ──
function appendGrid(gridId, toys, selectFn) {
const grid = document.getElementById(gridId);
if (!toys || toys.length === 0) {
if (!grid.querySelector('.toy-card')) {
grid.innerHTML = '<p class="empty">Keine Einträge vorhanden.</p>';
}
return;
}
const emptyEl = grid.querySelector('.empty');
if (emptyEl) emptyEl.remove();
grid.insertAdjacentHTML('beforeend', toys.map(toy => `
<div class="toy-card" data-id="${esc(toy.toyId)}"
${selectFn ? `onclick="${selectFn}('${esc(toy.toyId)}')"` : ''}>
${toy.bild
? `<img class="toy-img" src="data:image/png;base64,${toy.bild}" alt="${esc(toy.name)}">`
: `<div class="toy-img-placeholder">◈</div>`}
<div class="toy-info">
<div class="toy-name">${esc(toy.name)}</div>
${toy.beschreibung ? `<div class="toy-desc">${esc(toy.beschreibung)}</div>` : ''}
</div>
</div>
`).join(''));
}
// ── Selection ──
let selectedUserToyId = null;
function selectToy(toyId) {
const prev = document.querySelector('#userGrid .toy-card.selected');
if (prev) prev.classList.remove('selected');
if (selectedUserToyId === toyId) {
selectedUserToyId = null;
} else {
selectedUserToyId = toyId;
document.querySelector(`#userGrid .toy-card[data-id="${toyId}"]`).classList.add('selected');
}
const has = selectedUserToyId != null;
document.getElementById('editBtn').disabled = !has;
document.getElementById('deleteBtn').disabled = !has;
document.getElementById('actionError').textContent = '';
}
function resetSelection() {
selectedUserToyId = null;
document.getElementById('editBtn').disabled = true;
document.getElementById('deleteBtn').disabled = true;
document.getElementById('actionError').textContent = '';
}
// ── System-Toy selection ──
let selectedSystemToyId = null;
function selectSystemToy(toyId) {
const prev = document.querySelector('#systemGrid .toy-card.selected');
if (prev) prev.classList.remove('selected');
if (selectedSystemToyId === toyId) {
selectedSystemToyId = null;
} else {
selectedSystemToyId = toyId;
document.querySelector(`#systemGrid .toy-card[data-id="${toyId}"]`).classList.add('selected');
}
document.getElementById('copyBtn').disabled = selectedSystemToyId == null;
document.getElementById('systemActionError').textContent = '';
}
function resetSystemSelection() {
selectedSystemToyId = null;
document.getElementById('copyBtn').disabled = true;
document.getElementById('systemActionError').textContent = '';
}
// ── Copy system toy ──
document.getElementById('copyBtn').addEventListener('click', () => {
if (!selectedSystemToyId) return;
const btn = document.getElementById('copyBtn');
btn.disabled = true;
fetch(`/toy/copy/${selectedSystemToyId}`, { method: 'POST' })
.then(r => {
if (r.ok || r.status === 201) {
reloadUserToys();
document.getElementById('systemActionError').textContent = '';
} else if (r.status === 409 && r.headers.get('X-Error') === 'duplicate-name') {
document.getElementById('systemActionError').textContent =
'Du hast bereits ein Toy mit diesem Namen.';
btn.disabled = false;
} else {
document.getElementById('systemActionError').textContent =
'Fehler beim Kopieren (HTTP ' + r.status + ').';
btn.disabled = false;
}
})
.catch(() => {
document.getElementById('systemActionError').textContent = 'Verbindungsfehler.';
btn.disabled = false;
});
});
// ── Header action buttons ──
document.getElementById('editBtn').addEventListener('click', () => {
if (selectedUserToyId) openModal(selectedUserToyId);
});
document.getElementById('deleteBtn').addEventListener('click', () => {
if (!selectedUserToyId) return;
if (!confirm('Toy wirklich löschen?')) return;
const btn = document.getElementById('deleteBtn');
btn.disabled = true;
const toyId = selectedUserToyId;
fetch(`/toy/${toyId}`, { method: 'DELETE' })
.then(r => {
if (r.status === 409) {
showActionError('Wird in Aufgaben verwendet nicht löschbar.');
btn.disabled = false;
} else if (r.status === 403) {
showActionError('Keine Berechtigung.');
btn.disabled = false;
} else if (r.ok || r.status === 202) {
reloadUserToys();
} else {
showActionError('Fehler beim Löschen.');
btn.disabled = false;
}
})
.catch(() => { showActionError('Verbindungsfehler.'); btn.disabled = false; });
});
function showActionError(msg) {
const el = document.getElementById('actionError');
el.textContent = msg;
setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 4000);
}
// ── Create / Edit modal ──
const modal = document.getElementById('createModal');
const saveBtn = document.getElementById('saveBtn');
let currentEditId = null;
function openModal(editId) {
currentEditId = editId || null;
document.getElementById('modalError').style.display = 'none';
document.getElementById('toyBild').value = '';
if (currentEditId) {
fetch(`/toy/${currentEditId}`)
.then(r => r.ok ? r.json() : null)
.then(toy => {
if (!toy) return;
document.getElementById('modalTitle').textContent = 'Toy bearbeiten';
document.getElementById('toyName').value = toy.name || '';
document.getElementById('toyDesc').value = toy.beschreibung || '';
const imgWrap = document.getElementById('currentImageWrap');
if (toy.bild) {
document.getElementById('currentImage').src = 'data:image/png;base64,' + toy.bild;
imgWrap.style.display = 'flex';
} else {
imgWrap.style.display = 'none';
}
modal.classList.add('open');
document.getElementById('toyName').focus();
})
.catch(() => alert('Fehler beim Laden des Toys.'));
} else {
document.getElementById('modalTitle').textContent = 'Neues Toy';
document.getElementById('toyName').value = '';
document.getElementById('toyDesc').value = '';
document.getElementById('currentImageWrap').style.display = 'none';
modal.classList.add('open');
document.getElementById('toyName').focus();
}
}
document.getElementById('openCreateBtn').addEventListener('click', () => openModal(null));
document.getElementById('cancelBtn').addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
function closeModal() { modal.classList.remove('open'); }
function editToy(toyId) { openModal(toyId); }
saveBtn.addEventListener('click', async () => {
const name = document.getElementById('toyName').value.trim();
if (!name) {
showModalError('Bitte einen Namen eingeben.');
return;
}
saveBtn.disabled = true;
saveBtn.textContent = 'Speichert…';
let bildBase64 = null;
const fileInput = document.getElementById('toyBild');
if (fileInput.files.length > 0) {
bildBase64 = await toBase64(fileInput.files[0]);
}
const payload = {
name,
beschreibung: document.getElementById('toyDesc').value.trim() || null,
bild: bildBase64
};
const isEdit = currentEditId != null;
fetch(isEdit ? `/toy/${currentEditId}` : '/toy', {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(r => {
if (r.ok || r.status === 201) {
closeModal();
reloadUserToys();
} else if (r.status === 409 && r.headers.get('X-Error') === 'duplicate-name') {
showModalError('Ein Toy mit diesem Namen existiert bereits.');
} else {
showModalError('Fehler beim Speichern (HTTP ' + r.status + ').');
}
})
.catch(() => showModalError('Verbindungsfehler.'))
.finally(() => { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; });
});
function showModalError(msg) {
const el = document.getElementById('modalError');
el.textContent = msg;
el.style.display = 'block';
}
function toBase64(file) {
const MAX = 128;
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 > MAX || h > MAX) {
if (w >= h) { h = Math.max(1, Math.round(MAX * h / w)); w = MAX; }
else { w = Math.max(1, Math.round(MAX * w / h)); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/png').split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
// ── XSS-Schutz ──
function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
</body>
</html>

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0;url=/games/aufgaben/aufgaben.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aufgaben BDSM xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
@@ -96,6 +97,7 @@
.gruppe-badge-private { background: rgba(233,69,96,0.15); color: var(--color-primary); }
.gruppe-badge-public { background: rgba(46,204,113,0.15); color: var(--color-success); }
.gruppe-badge-vanilla { background: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; }
.gruppe-badge-chastity { background: rgba(155,89,182,0.15); color: #9b59b6; border: 1px solid rgba(155,89,182,0.4); }
.gruppe-toggle { font-size: 0.75rem; color: var(--color-muted); flex-shrink: 0; transition: transform 0.2s; }
.gruppe-card.open .gruppe-toggle { transform: rotate(90deg); }
@@ -368,12 +370,14 @@
Gruppe veröffentlichen (für alle sichtbar)
</span>
</label>
<label>
<span class="modal-check">
<input type="checkbox" id="gVanilla">
Auch für Vanilla-Game verfügbar
</span>
</label>
<div style="margin-top:0.5rem;">
<label for="gAvailableIn" style="font-size:0.85rem;display:block;margin-bottom:0.3rem;">Verfügbar in</label>
<select id="gAvailableIn" style="width:100%;padding:0.5rem 0.75rem;border-radius:6px;border:1px solid var(--color-secondary);background:var(--color-secondary);color:var(--color-text);font-size:0.9rem;">
<option value="BDSM_ONLY">Nur BDSM</option>
<option value="BDSM_AND_VANILLA">BDSM &amp; Vanilla</option>
<option value="CHASTITY_ONLY">Nur Chastity</option>
</select>
</div>
<div class="modal-error" id="modalError"></div>
<div class="modal-actions">
<button class="btn-cancel" id="cancelBtn">Abbrechen</button>
@@ -636,8 +640,13 @@
.then(user => { if (!user) return; loadUserGruppen(); loadAboGruppen(); loadSystemGruppen(); })
.catch(() => { window.location.href = '/login.html'; });
// ── Cross-tab notification ──
let _notifyOnLoad = false;
const gruppenBc = new BroadcastChannel('bdsm-gruppen-updated');
// ── Load ──
function loadUserGruppen() {
if (_notifyOnLoad) { _notifyOnLoad = false; try { gruppenBc.postMessage(1); } catch (_) {} }
resetSelection();
document.getElementById('userLoading').style.display = 'block';
fetch(apiUrl(`/gruppe/list/user`) + `?page=${userPage}&size=${PAGE_SIZE}`)
@@ -722,7 +731,8 @@
if (g.privateGruppe) badges.push(`<span class="gruppe-badge gruppe-badge-private">Privat</span>`);
else badges.push(`<span class="gruppe-badge gruppe-badge-public">Öffentlich</span>`);
if (type === 'user' && g.subscriberCount > 0) badges.push(`<span class="gruppe-badge">♥ ${g.subscriberCount} Abo${g.subscriberCount !== 1 ? 's' : ''}</span>`);
if (g.vanillaAvailable) badges.push(`<span class="gruppe-badge gruppe-badge-vanilla">Vanilla</span>`);
if (g.availableIn === 'BDSM_AND_VANILLA') badges.push(`<span class="gruppe-badge gruppe-badge-vanilla">Vanilla</span>`);
if (g.availableIn === 'CHASTITY_ONLY') badges.push(`<span class="gruppe-badge gruppe-badge-chastity">Chastity</span>`);
return `
<div class="gruppe-card" id="gruppe-${esc(g.gruppenId)}">
@@ -936,7 +946,7 @@
openItemId = null;
pendingExpandId = gruppenId;
pendingExpandType = 'user';
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else {
document.getElementById('userActionError').textContent = 'Fehler beim Löschen (HTTP ' + r.status + ').';
}
@@ -1077,7 +1087,7 @@
pubCb.checked = !g.privateGruppe;
pubCb.disabled = g.privateGruppe; // Veröffentlichen nur über den Veröffentlichen-Button
document.getElementById('gPublicLabel').style.display = 'block';
document.getElementById('gVanilla').checked = g.vanillaAvailable || false;
document.getElementById('gAvailableIn').value = g.availableIn || 'BDSM_ONLY';
const imgWrap = document.getElementById('gCurrentImgWrap');
if (g.bild) {
document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild;
@@ -1095,7 +1105,7 @@
document.getElementById('gDesc').value = '';
document.getElementById('gPublic').checked = false;
document.getElementById('gPublicLabel').style.display = 'none';
document.getElementById('gVanilla').checked = false;
document.getElementById('gAvailableIn').value = 'BDSM_ONLY';
document.getElementById('gCurrentImgWrap').style.display = 'none';
gruppeModal.classList.add('open');
document.getElementById('gName').focus();
@@ -1129,7 +1139,7 @@
name,
beschreibung: document.getElementById('gDesc').value.trim() || null,
privateGruppe: isEdit ? !document.getElementById('gPublic').checked : true,
vanillaAvailable: document.getElementById('gVanilla').checked,
availableIn: document.getElementById('gAvailableIn').value,
bild: bildBase64
};
@@ -1146,7 +1156,7 @@
pendingExpandType = 'user';
}
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else if (r.status === 409) {
showModalError('Limit erreicht: maximal 10 eigene Aufgabengruppen möglich.');
} else {
@@ -1172,7 +1182,7 @@
.then(r => {
if (r.ok || r.status === 202) {
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else if (r.status === 403) {
document.getElementById('userActionError').textContent = 'Keine Berechtigung.';
btn.disabled = false;
@@ -1194,7 +1204,7 @@
.then(r => {
if (r.ok || r.status === 201) {
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
document.getElementById('systemActionError').textContent = '';
} else {
document.getElementById('systemActionError').textContent = 'Fehler beim Kopieren (HTTP ' + r.status + ').';
@@ -1213,7 +1223,7 @@
.then(r => {
if (r.ok || r.status === 201) {
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
document.getElementById('aboActionError').textContent = '';
} else if (r.status === 409) {
document.getElementById('aboActionError').textContent = 'Limit erreicht: maximal 10 eigene Aufgabengruppen möglich.';
@@ -1643,7 +1653,7 @@
pendingExpandId = currentItemGruppeId;
pendingExpandType = 'user';
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else if (r.status === 409) {
showItemError('Limit erreicht: maximal 100 Einträge pro Gruppe möglich.');
} else {
@@ -1739,7 +1749,7 @@
pendingExpandId = selectedGruppeId;
pendingExpandType = 'user';
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else {
const errEl = document.getElementById('publishError');
errEl.textContent = 'Fehler beim Veröffentlichen (HTTP ' + r.status + ').';

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0;url=/games/aufgaben/entdecken.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Entdecken xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">

View File

@@ -67,13 +67,36 @@
.card-field:last-child { margin-bottom: 0; }
.card-field > label { font-size: 0.8rem; color: #aaa; margin: 0 0 0.5rem 0; display: block; }
.check-group { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.check-group--two-col { display: grid; grid-template-columns: 1fr 1fr; }
.check-item { display: inline-flex; align-items: flex-start; gap: 0.45rem; background: var(--color-secondary); border: 1px solid transparent; border-radius: 6px; padding: 0.4rem 0.7rem; cursor: pointer; transition: border-color 0.15s; user-select: none; }
.check-group--two-col { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); }
.check-item { display: inline-flex; align-items: flex-start; gap: 0.45rem; background: var(--color-secondary); border: 1px solid transparent; border-radius: 6px; padding: 0.4rem 0.7rem; cursor: pointer; transition: border-color 0.15s; user-select: none; position: relative; }
.check-item.is-checked { border-color: var(--color-primary); }
.check-item.is-disabled { opacity: 0.5; pointer-events: none; cursor: default; }
.check-item input { accent-color: var(--color-primary); width: auto; margin-top: 0.15rem; cursor: pointer; flex-shrink: 0; }
.check-item-label { font-size: 0.88rem; color: var(--color-text); line-height: 1.3; }
.check-item-desc { display: block; font-size: 0.72rem; color: var(--color-muted); margin-top: 0.1rem; }
.check-item-label { font-size: 0.88rem; color: var(--color-text); line-height: 1.3; display: flex; align-items: center; gap: 0.2rem; flex-wrap: wrap; }
.check-item-desc { display: none; }
.check-item-tooltip {
display: none; position: absolute; bottom: calc(100% + 6px); left: 0;
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 6px; padding: 0.4rem 0.65rem;
font-size: 0.78rem; color: var(--color-muted); line-height: 1.4;
width: max-content; max-width: 210px;
z-index: 50; pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.35);
}
.check-item:hover .check-item-tooltip { display: block; }
.check-item-info-btn {
display: none; background: none; border: 1px solid var(--color-muted);
border-radius: 50%; width: 1.1rem; height: 1.1rem; font-size: 0.62rem;
color: var(--color-muted); cursor: pointer; padding: 0; line-height: 1;
flex-shrink: 0; font-style: normal; font-weight: normal;
align-items: center; justify-content: center;
}
.check-item-info-btn.active { border-color: var(--color-primary); color: var(--color-primary); }
.check-item-desc-mobile { display: none; font-size: 0.72rem; color: var(--color-muted); margin-top: 0.25rem; line-height: 1.4; }
@media (max-width: 679px) {
.check-item:hover .check-item-tooltip { display: none; }
.check-item-info-btn { display: inline-flex; }
}
.field-error { font-size: 0.78rem; color: var(--color-primary); margin-top: 0.3rem; display: none; }
.add-player-btn { width: 100%; background: transparent; border: 1px dashed var(--color-secondary); color: var(--color-muted); padding: 0.7rem; border-radius: 8px; font-size: 0.88rem; font-weight: normal; cursor: pointer; transition: border-color 0.15s, color 0.15s; margin-top: 0.5rem; }
.add-player-btn:hover { border-color: var(--color-primary); color: var(--color-text); background: transparent; }
@@ -160,8 +183,7 @@
<div class="main" id="setupView" style="display:none;">
<div class="content">
<h1>BDSM Game</h1>
<p id="pageSubtitle" style="margin-bottom:1.5rem;">Session einrichten</p>
<h1>BDSM Game - Session einrichten</h1>
<!-- Accordion 1: Grundeinstellungen -->
<div class="acc-item">
@@ -212,6 +234,9 @@
</button>
<div class="acc-body" id="acc-aufgaben-body">
<div id="guestAufgabenHint" class="guest-hint" style="display:none;">Aufgaben werden vom Host festgelegt nur zur Ansicht.</div>
<p style="font-size:0.85rem;color:var(--color-muted);margin-bottom:0.75rem;">
Gruppen verwalten: <a href="/games/bdsm/aufgaben.html" target="_blank" style="color:var(--color-primary);">Aufgaben-Verwaltung (BDSM)</a>
</p>
<div id="sectionOwn">
<div class="aufgaben-section-label"><label class="select-all-label"><input type="checkbox" class="select-all-cb" data-list="listOwn"> Eigene Gruppen</label></div>
<ul class="gruppe-list" id="listOwn"><li class="empty-hint">Wird geladen…</li></ul>
@@ -281,11 +306,11 @@
DIVERS: ['MUND','ANUS','UMSCHNALLDILDO'],
};
const WERKZEUGE = [
{ value: 'MUND', label: 'Mund', desc: 'Gewillt den Mund einzusetzen' },
{ value: 'VAGINA', label: 'Vagina', desc: 'Verfügt über eine Vagina und setzt sie ein' },
{ value: 'PENIS', label: 'Penis', desc: 'Verfügt über einen Penis und setzt ihn ein' },
{ value: 'ANUS', label: 'Anus', desc: 'Gewillt den Anus einzusetzen' },
{ value: 'VAGINA', label: 'Vagina', desc: 'Verfügt über eine Vagina' },
{ value: 'PENIS', label: 'Penis', desc: 'Verfügt über einen Penis' },
{ value: 'UMSCHNALLDILDO', label: 'Umschnall-Dildo', desc: 'Verfügt über einen Umschnall-Dildo' },
{ value: 'MUND', label: 'Oral', desc: 'Ist für aktiven Oral-Verkehr' },
{ value: 'ANUS', label: 'Anal', desc: 'Ist Bereit passiv Anal-Verkehr zu haben ' },
];
const ROLE_LABELS = {
AUFGABE_AKTIV: 'Aufgabe Aktiv', AUFGABE_PASSIV: 'Aufgabe Passiv',
@@ -417,10 +442,19 @@
return items.map(({ value, label, desc }) => `
<label class="check-item${disabled ? ' is-disabled' : ''}">
<input type="${type}" name="${name}" value="${value}"${disabled ? ' disabled' : ''}>
<span><span class="check-item-label">${label}</span>${desc ? `<span class="check-item-desc">${desc}</span>` : ''}</span>
<span><span class="check-item-label">${label}${desc ? `<button type="button" class="check-item-info-btn" onclick="event.stopPropagation();toggleCheckDesc(this);">ⓘ</button>` : ''}</span>${desc ? `<span class="check-item-tooltip">${desc}</span><span class="check-item-desc-mobile">${desc}</span>` : ''}</span>
</label>`).join('');
}
function toggleCheckDesc(btn) {
const mobile = btn.closest('.check-item')?.querySelector('.check-item-desc-mobile');
if (!mobile) return;
const isVisible = mobile.style.display === 'block';
document.querySelectorAll('.check-item-desc-mobile').forEach(el => { el.style.display = 'none'; });
document.querySelectorAll('.check-item-info-btn').forEach(el => el.classList.remove('active'));
if (!isVisible) { mobile.style.display = 'block'; btn.classList.add('active'); }
}
function buildPlayerBody(id, nameValue, nameReadOnly = false, genderDisabled = false, allDisabled = false) {
const globalDefault = document.getElementById('chkZeitstrafen')?.checked ?? true;
const isCheckedCls = globalDefault ? ' is-checked' : '';
@@ -650,15 +684,15 @@
const selectAllWrap = section?.querySelector('.select-all-label');
if (!gruppen.length) {
ul.innerHTML = '<li class="empty-hint">Keine Gruppen vorhanden.</li>';
if (selectAllWrap) selectAllWrap.style.visibility = 'hidden'; return;
if (selectAllWrap) { const cb = selectAllWrap.querySelector('input'); if (cb) cb.disabled = true; selectAllWrap.style.pointerEvents = 'none'; selectAllWrap.style.opacity = '0.4'; } return;
}
ul.innerHTML = gruppen.map(g => {
const checked = savedGruppen.has(g.gruppenId);
const vanillaBadge = g.vanillaAvailable ? '<span class="vanilla-badge">Vanilla</span>' : '';
const vanillaBadge = g.availableIn === 'BDSM_AND_VANILLA' ? '<span class="vanilla-badge">Vanilla</span>' : '';
return `<li><label class="gruppe-item${checked ? ' is-checked' : ''}">
<input type="checkbox" value="${g.gruppenId}"${checked ? ' checked' : ''}>
<span><span class="gruppe-item-name">${g.name}${vanillaBadge}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span>
${g.bild ? `<img class="item-img" src="data:image/png;base64,${g.bild}" alt="">` : ''}
<span><span class="gruppe-item-name">${g.name}${vanillaBadge}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span>
</label></li>`;
}).join('');
updateSelectAll(ul);
@@ -1033,8 +1067,7 @@
setFieldError(`p${id}-geschlecht-err`, geschlecht.length === 0);
setFieldError(`p${id}-spieltmit-err`, spieltMit.length === 0);
setFieldError(`p${id}-rollen-err`, rollen.length === 0);
setFieldError(`p${id}-werkzeuge-err`, werkzeuge.length === 0);
if (!name || !geschlecht.length || !spieltMit.length || !rollen.length || !werkzeuge.length) valid = false;
if (!name || !geschlecht.length || !spieltMit.length || !rollen.length) valid = false;
const sperre = document.getElementById(`p${id}-sperrenAufloesen`);
return { name, geschlecht: geschlecht[0] || null, spieltMit, rollen, werkzeuge,
userId: inv ? inv.inviteeId : (id === selfPlayerId ? myUserId : null),
@@ -1196,8 +1229,7 @@
setFieldError(`p${id}-geschlecht-err`, geschlecht.length === 0);
setFieldError(`p${id}-spieltmit-err`, spieltMit.length === 0);
setFieldError(`p${id}-rollen-err`, rollen.length === 0);
setFieldError(`p${id}-werkzeuge-err`, werkzeuge.length === 0);
if (!geschlecht.length || !spieltMit.length || !rollen.length || !werkzeuge.length) {
if (!geschlecht.length || !spieltMit.length || !rollen.length) {
showMessage('Bitte alle Felder ausfüllen.', 'error'); return;
}
const sperre = document.getElementById(`p${id}-sperrenAufloesen`);
@@ -1482,6 +1514,13 @@
}
init();
const _sessBc = new BroadcastChannel('bdsm-gruppen-updated');
_sessBc.onmessage = () => {
document.querySelectorAll('.gruppe-list input[type="checkbox"]:checked').forEach(cb => savedGruppen.add(cb.value));
document.querySelectorAll('.gruppe-list input[type="checkbox"]:not(:checked)').forEach(cb => savedGruppen.delete(cb.value));
ladeGruppenListen();
};
</script>
</body>
</html>

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0;url=/games/aufgaben/toys.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Toys xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">

View File

@@ -57,27 +57,90 @@
}
.nextcard-cards {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
gap: 0.5rem;
position: relative;
border-radius: 6px;
padding: 0.75rem 0.5rem 0.5rem;
overflow: visible;
padding: 0.75rem 0 1rem;
}
.nc-window {
flex: 1;
overflow-x: hidden;
overflow-y: visible;
min-width: 0;
padding-top: 14px;
position: relative;
}
.nc-slide-wrapper {
display: flex;
will-change: transform;
}
.nextcard-card-img {
width: calc((100% - 5 * 0.6rem) / 6);
flex-shrink: 0;
width: 130px;
height: auto;
border-radius: 6px;
position: relative;
z-index: 1;
margin-right: var(--nc-gap, 6px);
transition: transform 0.15s, box-shadow 0.15s;
}
.nextcard-card-img:last-child { margin-right: 0; }
.nextcard-panel.drawable .nextcard-card-img:hover {
transform: translateY(-8px) scale(1.06);
transform: translateY(-10px) scale(1.08);
box-shadow: 0 8px 20px rgba(0,0,0,0.4);
z-index: 10;
cursor: pointer;
}
.nc-nav-btn {
flex-shrink: 0;
width: 48px;
background: var(--color-secondary);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 8px;
color: var(--color-text);
font-size: 0.72rem;
font-weight: 600;
padding: 0.5rem 0.2rem;
cursor: pointer;
text-align: center;
line-height: 1.4;
align-self: stretch;
display: none;
transition: background 0.15s;
}
.nc-nav-btn:hover { background: var(--color-primary); }
/* ── Touch-Karussell ── */
.nextcard-cards.carousel-mode {
overflow: hidden;
height: 210px;
padding: 1rem 0 1rem;
justify-content: center;
}
.carousel-card {
position: absolute;
width: 100px;
border-radius: 8px;
box-shadow: 2px 4px 14px rgba(0,0,0,0.55);
transition: transform 0.18s cubic-bezier(.4,0,.2,1), opacity 0.18s;
user-select: none;
-webkit-user-drag: none;
}
.carousel-card.pos-center { transform: translateX(0) scale(1.18); opacity: 1; z-index: 5; }
.carousel-card.pos-left { transform: translateX(-120px) scale(0.84); opacity: 0.72; z-index: 3; }
.carousel-card.pos-right { transform: translateX(120px) scale(0.84); opacity: 0.72; z-index: 3; }
.carousel-card.pos-far-left { transform: translateX(-210px) scale(0.68); opacity: 0.4; z-index: 1; }
.carousel-card.pos-far-right { transform: translateX(210px) scale(0.68); opacity: 0.4; z-index: 1; }
.carousel-card.pos-exit-left {
transform: translateX(-400px) scale(0.4) rotate(-15deg);
opacity: 0; z-index: 0;
transition: transform 0.32s ease-in, opacity 0.32s ease-in;
}
.carousel-card.pos-exit-right {
transform: translateX(400px) scale(0.4) rotate(15deg);
opacity: 0; z-index: 0;
transition: transform 0.32s ease-in, opacity 0.32s ease-in;
}
.nextcard-panel.drawable .carousel-card.pos-center { cursor: pointer; }
.nextcard-overlay {
position: absolute;
inset: 0;
@@ -595,6 +658,12 @@
<button class="btn-hygiene" id="hygieneBtn" style="display:none;" onclick="openHygieneModal()">🚿 Hygiene-Öffnung</button>
</div>
<!-- Speed-Effekt-Panel -->
<div id="speedPanel" style="display:none;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;padding:0.85rem 1.1rem;gap:0.35rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--color-muted);" id="speedPanelTitle">Slow Motion aktiv</div>
<div style="font-size:0.9rem;font-weight:600;" id="speedPanelInfo"></div>
</div>
<!-- Verifikations-Panel -->
<div class="verification-panel" id="verificationPanel" style="display:none;">
<div class="verification-panel-title">Tägliche Verifikation</div>
@@ -724,6 +793,12 @@
<span id="drawTaskPendingText"></span>
</div>
<!-- Speed-Karte: Zeitpunkt wählen -->
<div id="drawSpeedPicker" style="display:none;margin-top:0.75rem;padding:0.75rem 1rem;border-radius:8px;background:rgba(100,149,237,0.10);border:1px solid rgba(100,149,237,0.3);gap:0.6rem;text-align:center;">
<div style="font-size:0.88rem;color:var(--color-text);">Wähle den Zeitpunkt, bis zu dem der Effekt aktiv sein soll:</div>
<input type="datetime-local" id="drawSpeedUntilInput" style="background:var(--color-secondary);border:1px solid var(--color-secondary);border-radius:7px;padding:0.45rem 0.75rem;color:var(--color-text);font-size:0.9rem;width:100%;box-sizing:border-box;">
</div>
<!-- Grüne Karte: Entscheidung -->
<div class="draw-green-choice" id="drawGreenChoice">
<p id="drawGreenText" style="text-align:center;font-size:0.88rem;color:var(--color-muted);margin:0;">
@@ -735,6 +810,8 @@
<div class="draw-modal-actions" id="drawModalActions" style="display:none;">
<!-- Non-green: OK -->
<button class="btn-draw-ok" id="btnDrawOk" onclick="closeDrawModal()">OK</button>
<!-- Speed-Karte: Bestätigen -->
<button class="btn-draw-ok" id="btnSpeedConfirm" style="display:none;" onclick="confirmSpeedCard()">✓ Bestätigen</button>
<!-- Green: zwei Optionen -->
<button class="btn-draw-unlock" id="btnDrawUnlock" style="display:none;" onclick="confirmUnlock()">🔓 Entsperren</button>
<button class="btn-draw-keep" id="btnDrawKeep" style="display:none;" onclick="keepGreenCard()">Zurücklegen</button>
@@ -833,6 +910,7 @@
renderAssignedTasks(lock);
renderNextCardPanel(lock);
renderHygienePanel(lock);
renderSpeedPanel(lock);
renderVerificationPanel(lock);
renderTempOpeningPanel(lock);
renderCardsPanel(lock);
@@ -1095,17 +1173,14 @@
panel.style.display = '';
panel.classList.remove('drawable');
// Karten-Bilder rendern (Overlay bleibt erhalten)
cardsDiv.querySelectorAll('.nextcard-card-img').forEach(el => el.remove());
const total = lock.totalCards || 0;
const show = Math.min(total, 36);
for (let i = 0; i < show; i++) {
const img = document.createElement('img');
img.className = 'nextcard-card-img';
img.src = '/img/card.png';
img.alt = 'Karte';
cardsDiv.insertBefore(img, overlay);
}
// Karten-Darstellung aufbauen
if (ncResizeObs) { ncResizeObs.disconnect(); ncResizeObs = null; }
cardsDiv.querySelectorAll('.nc-nav-btn, .nc-window, .carousel-card').forEach(el => el.remove());
cardsDiv.classList.remove('carousel-mode');
const total = lock.totalCards || 0;
const isTouch = window.matchMedia('(hover: none) and (pointer: coarse)').matches;
if (isTouch) initCarousel(cardsDiv, overlay, total);
else initCardWindow(cardsDiv, overlay, total);
// Overlay-Elemente
const timerBox = document.getElementById('nextcardTimerBox');
@@ -1314,21 +1389,254 @@
// ── Karte ziehen ──
let drawnUnlockCode = null;
function enableCardClick() {
document.querySelectorAll('.nextcard-card-img').forEach(img => {
img.addEventListener('click', onCardClick, { once: true });
// ── Touch-Karussell ──
const CAROUSEL_POS = ['pos-far-left', 'pos-left', 'pos-center', 'pos-right', 'pos-far-right'];
let carouselCards = [];
let carouselShifting = false;
let carouselTouchX = 0;
let carouselTouchT = 0;
let carouselAbort = null;
let carouselDrawable = false;
function initCarousel(cardsDiv, overlay, total) {
if (carouselAbort) carouselAbort.abort();
carouselAbort = new AbortController();
const signal = carouselAbort.signal;
carouselCards = [];
carouselShifting = false;
carouselDrawable = false;
cardsDiv.classList.add('carousel-mode');
for (let i = 0; i < 5; i++) {
const img = document.createElement('img');
img.className = 'carousel-card ' + CAROUSEL_POS[i];
img.src = '/img/card.png';
img.alt = 'Karte';
cardsDiv.insertBefore(img, overlay);
carouselCards.push(img);
}
cardsDiv.addEventListener('touchstart', e => {
carouselTouchX = e.touches[0].clientX;
carouselTouchT = Date.now();
}, { passive: true, signal });
cardsDiv.addEventListener('touchend', e => {
if (carouselShifting) return;
const touch = e.changedTouches[0];
const dx = touch.clientX - carouselTouchX;
const dt = Date.now() - carouselTouchT;
const absDx = Math.abs(dx);
if (absDx < 5) {
// Tap: Element unter dem Finger bestimmen
const el = document.elementFromPoint(touch.clientX, touch.clientY);
const idx = el ? carouselCards.indexOf(el) : -1;
if (idx < 0) return;
if (idx === 2 && carouselDrawable) {
// Mittlere Karte → Karte ziehen
e.preventDefault();
carouselDrawable = false;
carouselCards.forEach(c => c.style.pointerEvents = 'none');
openDrawModal();
} else if (idx < 2) {
_doCarouselShift('right', 1);
} else {
_doCarouselShift('left', 1);
}
return;
}
if (!carouselDrawable) return;
// Swipe erkannt: Geschwindigkeit bestimmt wie viele Karten wechseln
const velocity = absDx / dt;
const steps = velocity > 1.2 ? 3 : velocity > 0.6 ? 2 : 1;
dx < 0 ? _doCarouselShift('left', steps) : _doCarouselShift('right', steps);
}, { passive: false, signal });
}
function shiftCarouselLeft(steps = 1) { _doCarouselShift('left', steps); }
function shiftCarouselRight(steps = 1) { _doCarouselShift('right', steps); }
function _doCarouselShift(dir, remaining) {
if (carouselShifting && remaining === /* first call */ remaining) {/* skip guard for chained */}
carouselShifting = true;
if (dir === 'left') {
carouselCards[0].className = 'carousel-card pos-exit-left';
for (let i = 1; i < 5; i++) carouselCards[i].className = 'carousel-card ' + CAROUSEL_POS[i - 1];
} else {
carouselCards[4].className = 'carousel-card pos-exit-right';
for (let i = 3; i >= 0; i--) carouselCards[i].className = 'carousel-card ' + CAROUSEL_POS[i + 1];
}
setTimeout(() => {
if (dir === 'left') {
const ex = carouselCards.shift();
ex.style.transition = 'none';
ex.className = 'carousel-card pos-exit-right';
ex.getBoundingClientRect();
ex.style.transition = '';
ex.className = 'carousel-card pos-far-right';
carouselCards.push(ex);
} else {
const ex = carouselCards.pop();
ex.style.transition = 'none';
ex.className = 'carousel-card pos-exit-left';
ex.getBoundingClientRect();
ex.style.transition = '';
ex.className = 'carousel-card pos-far-left';
carouselCards.unshift(ex);
}
if (remaining > 1) _doCarouselShift(dir, remaining - 1);
else carouselShifting = false;
}, 180);
}
// ── Karten-Fenster ──
let ncTotal = 0;
let ncWindowStart = 0;
let ncVisibleCount = 0;
let ncDrawable = false;
let ncWindowEl = null;
let ncBtnLeft = null;
let ncBtnRight = null;
let ncResizeObs = null;
function initCardWindow(cardsDiv, overlay, total) {
ncTotal = total;
ncDrawable = false;
ncWindowStart = 0;
if (total === 0) return;
ncBtnLeft = document.createElement('button');
ncBtnRight = document.createElement('button');
ncWindowEl = document.createElement('div');
const wrapper = document.createElement('div');
ncBtnLeft.className = 'nc-nav-btn';
ncBtnRight.className = 'nc-nav-btn';
ncWindowEl.className = 'nc-window';
wrapper.className = 'nc-slide-wrapper';
ncBtnLeft.addEventListener('click', () => scrollCardWindow(-1));
ncBtnRight.addEventListener('click', () => scrollCardWindow(1));
ncWindowEl.appendChild(wrapper);
cardsDiv.insertBefore(ncBtnLeft, overlay);
cardsDiv.insertBefore(ncWindowEl, overlay);
cardsDiv.insertBefore(ncBtnRight, overlay);
// Klick auf beliebige Karte → Karte ziehen (per Delegation)
ncWindowEl.addEventListener('click', e => {
if (!ncDrawable) return;
if (e.target.classList.contains('nextcard-card-img')) {
ncDrawable = false;
ncWindowEl.style.pointerEvents = 'none';
openDrawModal();
}
});
if (ncResizeObs) ncResizeObs.disconnect();
ncResizeObs = new ResizeObserver(() => recalcCardWindow());
ncResizeObs.observe(ncWindowEl);
requestAnimationFrame(() => recalcCardWindow(true));
}
function recalcCardWindow(initialCenter = false) {
const slots = Math.round(ncWindowEl.offsetWidth / 50);
const newCount = Math.min(ncTotal, Math.min(20, Math.max(3, slots)));
if (newCount === ncVisibleCount && !initialCenter) {
// Breite hat sich nicht verändert genug → nur Gap neu berechnen
renderCardWindow(null);
return;
}
ncVisibleCount = newCount;
if (initialCenter) {
ncWindowStart = Math.max(0, Math.floor(ncTotal / 2) - Math.floor(ncVisibleCount / 2));
} else {
// Fensterstart so anpassen, dass die Mitte des sichtbaren Bereichs gleich bleibt
const mid = ncWindowStart + Math.floor(ncVisibleCount / 2);
ncWindowStart = Math.max(0, Math.min(ncTotal - ncVisibleCount, mid - Math.floor(ncVisibleCount / 2)));
}
renderCardWindow(null);
}
function renderCardWindow(slideDir) {
const wrapper = ncWindowEl.querySelector('.nc-slide-wrapper');
const cardW = 130;
// Karten neu befüllen
wrapper.innerHTML = '';
for (let i = 0; i < ncVisibleCount; i++) {
const img = document.createElement('img');
img.className = 'nextcard-card-img';
img.src = '/img/card.png';
img.alt = 'Karte';
wrapper.appendChild(img);
}
// Dynamischer Overlap: alle sichtbaren Karten füllen die Fensterbreite
const windowW = ncWindowEl.offsetWidth;
let gap = ncVisibleCount <= 1 ? 6 : (windowW - ncVisibleCount * cardW) / (ncVisibleCount - 1);
gap = Math.max(-105, Math.min(12, gap));
ncWindowEl.style.setProperty('--nc-gap', gap + 'px');
// Animation: alte Karten raus, neue rein
if (slideDir) {
const oldWrapper = wrapper.cloneNode(true);
oldWrapper.style.cssText = 'position:absolute;top:0;left:0;width:100%;pointer-events:none;';
ncWindowEl.appendChild(oldWrapper);
// Neue Karten von der Seite einblenden
wrapper.style.transition = 'none';
wrapper.style.transform = `translateX(${slideDir > 0 ? '100%' : '-100%'})`;
wrapper.getBoundingClientRect();
wrapper.style.transition = 'transform 0.28s ease';
wrapper.style.transform = 'translateX(0)';
// Alte Karten zur anderen Seite ausblenden
oldWrapper.style.transition = 'transform 0.28s ease';
oldWrapper.style.transform = `translateX(${slideDir > 0 ? '-100%' : '100%'})`;
setTimeout(() => oldWrapper.remove(), 300);
}
// Nav-Buttons aktualisieren
const leftCount = ncWindowStart;
const rightCount = ncTotal - ncWindowStart - ncVisibleCount;
ncBtnLeft.style.display = leftCount > 0 ? 'block' : 'none';
ncBtnRight.style.display = rightCount > 0 ? 'block' : 'none';
ncBtnLeft.textContent = `\n${leftCount}`;
ncBtnRight.textContent = `${rightCount}\n`;
ncWindowEl.style.pointerEvents = '';
}
function scrollCardWindow(dir) {
const step = Math.max(1, Math.floor(ncVisibleCount / 2));
if (dir < 0) ncWindowStart = Math.max(0, ncWindowStart - step);
else ncWindowStart = Math.min(ncTotal - ncVisibleCount, ncWindowStart + step);
renderCardWindow(dir);
}
function enableCardClick() {
if (carouselCards.length > 0) {
carouselDrawable = true;
} else if (ncWindowEl) {
ncDrawable = true;
}
}
async function onCardClick() {
// Alle weiteren Klicks blockieren
document.querySelectorAll('.nextcard-card-img').forEach(img => {
img.removeEventListener('click', onCardClick);
img.style.pointerEvents = 'none';
});
carouselDrawable = false;
carouselCards.forEach(c => c.style.pointerEvents = 'none');
if (ncWindowEl) ncWindowEl.style.pointerEvents = 'none';
document.querySelectorAll('.nextcard-card-img').forEach(img => img.style.pointerEvents = 'none');
openDrawModal();
}
let _pendingSpeedMode = null;
function openDrawModal() {
const modal = document.getElementById('drawModal');
const inner = document.getElementById('flipCardInner');
@@ -1343,11 +1651,14 @@
document.getElementById('drawGreenText').style.display = '';
document.getElementById('drawUnlockCode').style.display = 'none';
document.getElementById('drawTaskPendingHint').style.display = 'none';
document.getElementById('drawSpeedPicker').style.display = 'none';
document.getElementById('btnDrawOk').style.display = '';
document.getElementById('btnSpeedConfirm').style.display = 'none';
document.getElementById('btnDrawUnlock').style.display = 'none';
document.getElementById('btnDrawKeep').style.display = 'none';
actions.style.display = 'none';
drawnUnlockCode = null;
_pendingSpeedMode = null;
modal.classList.add('open');
// Karte serverseitig ziehen
@@ -1386,6 +1697,20 @@
document.getElementById('btnDrawUnlock').style.display = '';
document.getElementById('btnDrawKeep').style.display = '';
}
if (dto.card === 'SLOWMO_CARD' || dto.card === 'SPEEDUP_CARD') {
_pendingSpeedMode = dto.card === 'SLOWMO_CARD' ? 'SLOWMO' : 'SPEEDUP';
const picker = document.getElementById('drawSpeedPicker');
const input = document.getElementById('drawSpeedUntilInput');
// Minimum: jetzt + 1 Stunde, Standardwert: jetzt + 24 Stunden
const minDate = new Date(Date.now() + 60 * 60 * 1000);
const defDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
input.min = toLocalDatetimeInputValue(minDate);
input.value = toLocalDatetimeInputValue(defDate);
picker.style.display = 'flex';
document.getElementById('btnDrawOk').style.display = 'none';
document.getElementById('btnSpeedConfirm').style.display = '';
}
}, 700);
}, 1000);
})
@@ -1418,6 +1743,56 @@
closeDrawModal();
}
function toLocalDatetimeInputValue(date) {
const pad = n => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
async function confirmSpeedCard() {
if (!_pendingSpeedMode) return;
const input = document.getElementById('drawSpeedUntilInput');
if (!input.value) { alert('Bitte wähle einen Zeitpunkt.'); return; }
const until = new Date(input.value);
if (until <= new Date()) { alert('Der Zeitpunkt muss in der Zukunft liegen.'); return; }
const isoUntil = `${input.value}:00`; // datetime-local hat kein Sekunden-Teil
const res = await fetch('/keyholder/cardlock/' + lockId + '/speed/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: _pendingSpeedMode, until: isoUntil })
});
if (!res.ok) { alert('Fehler beim Aktivieren des Speed-Effekts.'); return; }
closeDrawModal();
}
let speedPanelTick = null;
function renderSpeedPanel(lock) {
if (speedPanelTick) { clearInterval(speedPanelTick); speedPanelTick = null; }
const panel = document.getElementById('speedPanel');
const now = new Date();
const slowmoUntil = lock.slowmoUntil ? new Date(lock.slowmoUntil) : null;
const speedupUntil = lock.speedupUntil ? new Date(lock.speedupUntil) : null;
const active = (slowmoUntil && slowmoUntil > now) ? { mode: 'slowmo', until: slowmoUntil }
: (speedupUntil && speedupUntil > now) ? { mode: 'speedup', until: speedupUntil }
: null;
if (!active) { panel.style.display = 'none'; return; }
panel.style.display = 'flex';
document.getElementById('speedPanelTitle').textContent =
active.mode === 'slowmo' ? '🐢 Slow Motion aktiv' : '⚡ Speed Up aktiv';
function tickSpeed() {
const diff = active.until - Date.now();
if (diff <= 0) {
panel.style.display = 'none';
clearInterval(speedPanelTick); speedPanelTick = null;
return;
}
document.getElementById('speedPanelInfo').textContent =
(active.mode === 'slowmo' ? 'Aktionen dauern 4× so lange noch ' : 'Aktionen dauern 4× so kurz noch ') + fmtCountdown(diff);
}
tickSpeed();
speedPanelTick = setInterval(tickSpeed, 1000);
}
// ── Hygiene-Öffnung ──
let hygieneTickInterval = null;

View File

@@ -76,7 +76,7 @@
/* ── Detail-Modal ── */
.detail-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.65); z-index: 400;
background: rgba(0,0,0,0.65); z-index: 500;
align-items: flex-start; justify-content: center;
padding: 2rem 1rem; overflow-y: auto;
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0;url=/games/aufgaben/aufgaben.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aufgaben Vanilla xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
@@ -629,8 +630,13 @@
.then(user => { if (!user) return; loadUserGruppen(); loadAboGruppen(); loadSystemGruppen(); })
.catch(() => { window.location.href = '/login.html'; });
// ── Cross-tab notification ──
let _notifyOnLoad = false;
const gruppenBc = new BroadcastChannel('vanilla-gruppen-updated');
// ── Load ──
function loadUserGruppen() {
if (_notifyOnLoad) { _notifyOnLoad = false; try { gruppenBc.postMessage(1); } catch (_) {} }
resetSelection();
document.getElementById('userLoading').style.display = 'block';
fetch(apiUrl(`/gruppe/list/user`) + `?page=${userPage}&size=${PAGE_SIZE}`)
@@ -924,7 +930,7 @@
openItemId = null;
pendingExpandId = gruppenId;
pendingExpandType = 'user';
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else {
document.getElementById('userActionError').textContent = 'Fehler beim Löschen (HTTP ' + r.status + ').';
}
@@ -1131,7 +1137,7 @@
pendingExpandType = 'user';
}
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else if (r.status === 409) {
showModalError('Limit erreicht: maximal 10 eigene Aufgabengruppen möglich.');
} else {
@@ -1157,7 +1163,7 @@
.then(r => {
if (r.ok || r.status === 202) {
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else if (r.status === 403) {
document.getElementById('userActionError').textContent = 'Keine Berechtigung.';
btn.disabled = false;
@@ -1179,7 +1185,7 @@
.then(r => {
if (r.ok || r.status === 201) {
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
document.getElementById('systemActionError').textContent = '';
} else {
document.getElementById('systemActionError').textContent = 'Fehler beim Kopieren (HTTP ' + r.status + ').';
@@ -1198,7 +1204,7 @@
.then(r => {
if (r.ok || r.status === 201) {
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
document.getElementById('aboActionError').textContent = '';
} else if (r.status === 409) {
document.getElementById('aboActionError').textContent = 'Limit erreicht: maximal 10 eigene Aufgabengruppen möglich.';
@@ -1628,7 +1634,7 @@
pendingExpandId = currentItemGruppeId;
pendingExpandType = 'user';
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else if (r.status === 409) {
showItemError('Limit erreicht: maximal 100 Einträge pro Gruppe möglich.');
} else {
@@ -1724,7 +1730,7 @@
pendingExpandId = selectedGruppeId;
pendingExpandType = 'user';
userPage = 0;
loadUserGruppen();
_notifyOnLoad = true; loadUserGruppen();
} else {
const errEl = document.getElementById('publishError');
errEl.textContent = 'Fehler beim Veröffentlichen (HTTP ' + r.status + ').';

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0;url=/games/aufgaben/entdecken.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Entdecken xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">

View File

@@ -69,13 +69,36 @@
.card-field:last-child { margin-bottom: 0; }
.card-field > label { font-size: 0.8rem; color: #aaa; margin: 0 0 0.5rem 0; display: block; }
.check-group { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.check-group--two-col { display: grid; grid-template-columns: 1fr 1fr; }
.check-item { display: inline-flex; align-items: flex-start; gap: 0.45rem; background: var(--color-secondary); border: 1px solid transparent; border-radius: 6px; padding: 0.4rem 0.7rem; cursor: pointer; transition: border-color 0.15s; user-select: none; }
.check-group--two-col { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); }
.check-item { display: inline-flex; align-items: flex-start; gap: 0.45rem; background: var(--color-secondary); border: 1px solid transparent; border-radius: 6px; padding: 0.4rem 0.7rem; cursor: pointer; transition: border-color 0.15s; user-select: none; position: relative; }
.check-item.is-checked { border-color: var(--color-primary); }
.check-item.is-disabled { opacity: 0.5; pointer-events: none; cursor: default; }
.check-item input { accent-color: var(--color-primary); width: auto; margin-top: 0.15rem; cursor: pointer; flex-shrink: 0; }
.check-item-label { font-size: 0.88rem; color: var(--color-text); line-height: 1.3; }
.check-item-desc { display: block; font-size: 0.72rem; color: var(--color-muted); margin-top: 0.1rem; }
.check-item-label { font-size: 0.88rem; color: var(--color-text); line-height: 1.3; display: flex; align-items: center; gap: 0.2rem; flex-wrap: wrap; }
.check-item-desc { display: none; }
.check-item-tooltip {
display: none; position: absolute; bottom: calc(100% + 6px); left: 0;
background: var(--color-card); border: 1px solid var(--color-secondary);
border-radius: 6px; padding: 0.4rem 0.65rem;
font-size: 0.78rem; color: var(--color-muted); line-height: 1.4;
width: max-content; max-width: 210px;
z-index: 50; pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.35);
}
.check-item:hover .check-item-tooltip { display: block; }
.check-item-info-btn {
display: none; background: none; border: 1px solid var(--color-muted);
border-radius: 50%; width: 1.1rem; height: 1.1rem; font-size: 0.62rem;
color: var(--color-muted); cursor: pointer; padding: 0; line-height: 1;
flex-shrink: 0; font-style: normal; font-weight: normal;
align-items: center; justify-content: center;
}
.check-item-info-btn.active { border-color: var(--color-primary); color: var(--color-primary); }
.check-item-desc-mobile { display: none; font-size: 0.72rem; color: var(--color-muted); margin-top: 0.25rem; line-height: 1.4; }
@media (max-width: 679px) {
.check-item:hover .check-item-tooltip { display: none; }
.check-item-info-btn { display: inline-flex; }
}
.field-error { font-size: 0.78rem; color: var(--color-primary); margin-top: 0.3rem; display: none; }
.add-player-btn { width: 100%; background: transparent; border: 1px dashed var(--color-secondary); color: var(--color-muted); padding: 0.7rem; border-radius: 8px; font-size: 0.88rem; font-weight: normal; cursor: pointer; transition: border-color 0.15s, color 0.15s; margin-top: 0.5rem; }
.add-player-btn:hover { border-color: var(--color-primary); color: var(--color-text); background: transparent; }
@@ -163,7 +186,6 @@
<div class="main" id="setupView" style="display:none;">
<div class="content">
<h1>Vanilla Game Session einrichten</h1>
<p id="pageSubtitle" style="margin-bottom:1.5rem;">Session einrichten</p>
<!-- Accordion 1: Grundeinstellungen -->
<div class="acc-item">
@@ -191,7 +213,7 @@
<div class="acc-body" id="acc-aufgaben-body">
<div id="guestAufgabenHint" class="guest-hint" style="display:none;">Aufgaben werden vom Host festgelegt nur zur Ansicht.</div>
<p style="font-size:0.85rem;color:var(--color-muted);margin-bottom:0.75rem;">
Gruppen verwalten: <a href="/games/vanilla/aufgaben.html" style="color:var(--color-primary);">Aufgaben-Verwaltung (Vanilla)</a>
Gruppen verwalten: <a href="/games/vanilla/aufgaben.html" target="_blank" style="color:var(--color-primary);">Aufgaben-Verwaltung (Vanilla)</a>
</p>
<div id="sectionOwn">
<div class="aufgaben-section-label"><label class="select-all-label"><input type="checkbox" class="select-all-cb" data-list="listOwn"> Eigene Gruppen</label></div>
@@ -262,11 +284,11 @@
DIVERS: ['MUND','ANUS','UMSCHNALLDILDO'],
};
const WERKZEUGE = [
{ value: 'MUND', label: 'Mund', desc: 'Gewillt den Mund einzusetzen' },
{ value: 'VAGINA', label: 'Vagina', desc: 'Verfügt über eine Vagina und setzt sie ein' },
{ value: 'PENIS', label: 'Penis', desc: 'Verfügt über einen Penis und setzt ihn ein' },
{ value: 'ANUS', label: 'Anus', desc: 'Gewillt den Anus einzusetzen' },
{ value: 'UMSCHNALLDILDO', label: 'Umschnall-Dildo', desc: 'Verfügt über einen Umschnall-Dildo' },
{ value: 'VAGINA', label: 'Vagina', desc: 'Verfügt über eine Vagina' },
{ value: 'PENIS', label: 'Penis', desc: 'Verfügt über einen Penis' },
{ value: 'UMSCHNALLDILDO', label: 'Umschnall-Dildo', desc: 'Verfügt über einen Umschnall-Dildo' },
{ value: 'MUND', label: 'Oral', desc: 'Ist für aktiven Oral-Verkehr' },
{ value: 'ANUS', label: 'Anal', desc: 'Ist Bereit passiv Anal-Verkehr zu haben ' },
];
const ROLE_LABELS = {
AUFGABE_AKTIV: 'Aufgabe Aktiv', AUFGABE_PASSIV: 'Aufgabe Passiv',
@@ -370,10 +392,19 @@
return items.map(({ value, label, desc }) => `
<label class="check-item${disabled ? ' is-disabled' : ''}">
<input type="${type}" name="${name}" value="${value}"${disabled ? ' disabled' : ''}>
<span><span class="check-item-label">${label}</span>${desc ? `<span class="check-item-desc">${desc}</span>` : ''}</span>
<span><span class="check-item-label">${label}${desc ? `<button type="button" class="check-item-info-btn" onclick="event.stopPropagation();toggleCheckDesc(this);">ⓘ</button>` : ''}</span>${desc ? `<span class="check-item-tooltip">${desc}</span><span class="check-item-desc-mobile">${desc}</span>` : ''}</span>
</label>`).join('');
}
function toggleCheckDesc(btn) {
const mobile = btn.closest('.check-item')?.querySelector('.check-item-desc-mobile');
if (!mobile) return;
const isVisible = mobile.style.display === 'block';
document.querySelectorAll('.check-item-desc-mobile').forEach(el => { el.style.display = 'none'; });
document.querySelectorAll('.check-item-info-btn').forEach(el => el.classList.remove('active'));
if (!isVisible) { mobile.style.display = 'block'; btn.classList.add('active'); }
}
function buildPlayerBody(id, nameValue, nameReadOnly = false, genderDisabled = false, allDisabled = false) {
const nameHtml = nameReadOnly
? `<input type="text" id="p${id}-name" value="${nameValue}" readonly style="background:transparent;cursor:default;color:var(--color-muted);">`
@@ -574,14 +605,14 @@
);
if (!filtered.length) {
ul.innerHTML = '<li class="empty-hint">Keine Gruppen vorhanden.</li>';
if (selectAllWrap) selectAllWrap.style.visibility = 'hidden'; return;
if (selectAllWrap) { const cb = selectAllWrap.querySelector('input'); if (cb) cb.disabled = true; selectAllWrap.style.pointerEvents = 'none'; selectAllWrap.style.opacity = '0.4'; } return;
}
ul.innerHTML = filtered.map(g => {
const checked = savedGruppen.has(g.gruppenId);
return `<li><label class="gruppe-item${checked ? ' is-checked' : ''}">
<input type="checkbox" value="${g.gruppenId}"${checked ? ' checked' : ''}>
<span><span class="gruppe-item-name">${g.name}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span>
${g.bild ? `<img class="item-img" src="data:image/png;base64,${g.bild}" alt="">` : ''}
<span><span class="gruppe-item-name">${g.name}</span>${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}</span>
</label></li>`;
}).join('');
updateSelectAll(ul);
@@ -905,8 +936,7 @@
const name = document.getElementById(`p${id}-name`)?.value.trim() || '';
const werkzeuge = getChecked(`p${id}-werkzeuge`);
setFieldError(`p${id}-name-err`, !name);
setFieldError(`p${id}-werkzeuge-err`, werkzeuge.length === 0);
if (!name || !werkzeuge.length) valid = false;
if (!name) valid = false;
return { name, geschlecht: null, spieltMit: [], rollen: [], werkzeuge,
userId: inv ? inv.inviteeId : (id === selfPlayerId ? myUserId : null),
eigenesGeraet: false };
@@ -1035,10 +1065,6 @@
async function bereitMachen() {
const id = guestOwnPlayerId; if (!id) return;
const werkzeuge = getChecked(`p${id}-werkzeuge`);
setFieldError(`p${id}-werkzeuge-err`, werkzeuge.length === 0);
if (!werkzeuge.length) {
showMessage('Bitte mindestens ein Werkzeug auswählen.', 'error'); return;
}
try {
const res = await fetch(`/vanilla/einladung/${guestEinladungId}/spielerdaten`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
@@ -1338,6 +1364,13 @@
}
init();
const _sessBc = new BroadcastChannel('vanilla-gruppen-updated');
_sessBc.onmessage = () => {
document.querySelectorAll('.gruppe-list input[type="checkbox"]:checked').forEach(cb => savedGruppen.add(cb.value));
document.querySelectorAll('.gruppe-list input[type="checkbox"]:not(:checked)').forEach(cb => savedGruppen.delete(cb.value));
ladeGruppenListen();
};
</script>
</body>
</html>

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta http-equiv="refresh" content="0;url=/games/aufgaben/toys.html">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Toys xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">

View File

@@ -0,0 +1,181 @@
<!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>Hilfe Abonnements xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header { margin-bottom: 2rem; }
.hilfe-header h1 { font-size: 1.6rem; margin: 0 0 0.4rem 0; }
.hilfe-header p { color: var(--color-muted); font-size: 0.92rem; margin: 0; line-height: 1.6; }
.hilfe-section { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 10px; margin-bottom: 0.75rem; overflow: hidden; }
.hilfe-section-header { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 1rem 1.25rem; cursor: pointer; user-select: none; transition: background 0.15s; }
.hilfe-section-header:hover { background: rgba(255,255,255,0.03); }
.hilfe-section-title { display: flex; align-items: center; gap: 0.6rem; font-size: 1rem; font-weight: 600; }
.hilfe-section-arrow { font-size: 0.75rem; color: var(--color-muted); transition: transform 0.2s; }
.hilfe-section.open .hilfe-section-arrow { transform: rotate(90deg); }
.hilfe-section-body { display: none; padding: 0 1.25rem 1.25rem; border-top: 1px solid var(--color-secondary); }
.hilfe-section.open .hilfe-section-body { display: block; }
.hilfe-section-body p { font-size: 0.9rem; color: var(--color-muted); line-height: 1.7; margin: 0.9rem 0 0; }
.hilfe-section-body p:first-child { margin-top: 1rem; }
.hilfe-hint { background: rgba(var(--color-primary-rgb, 120,80,200), 0.08); border-left: 3px solid var(--color-primary); border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-hint strong { color: var(--color-text); }
.hilfe-warn { background: rgba(231,76,60,0.08); border-left: 3px solid #e74c3c; border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-warn strong { color: #e74c3c; }
.hilfe-info { background: var(--color-secondary); border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.feature-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-top: 1rem; }
@media (max-width: 520px) { .feature-grid { grid-template-columns: 1fr; } }
.feature-col { background: var(--color-secondary); border-radius: 8px; padding: 0.85rem 1rem; }
.feature-col-title { font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.6rem; }
.feature-col-title.free { color: var(--color-muted); }
.feature-col-title.premium { color: var(--color-primary); }
.feature-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.35rem; }
.feature-list li { font-size: 0.85rem; color: var(--color-muted); display: flex; align-items: flex-start; gap: 0.5rem; }
.feature-list li::before { content: '•'; color: var(--color-primary); flex-shrink: 0; }
.hilfe-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin-top: 1rem; }
.hilfe-table th { text-align: left; color: var(--color-text); font-weight: 600; padding: 0.4rem 0.75rem 0.4rem 0; border-bottom: 1px solid var(--color-secondary); }
.hilfe-table td { color: var(--color-muted); padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); vertical-align: top; line-height: 1.5; }
.hilfe-table tr:last-child td { border-bottom: none; }
.hilfe-badge { display: inline-block; background: var(--color-secondary); border-radius: 4px; padding: 0.1rem 0.45rem; font-size: 0.78rem; font-weight: 600; color: var(--color-muted); vertical-align: middle; }
.back-link { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--color-muted); text-decoration: none; margin-bottom: 1.25rem; transition: color 0.15s; }
.back-link:hover { color: var(--color-text); }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<a href="/help/overview.html" class="back-link"> Zurück zur Hilfe-Übersicht</a>
<div class="hilfe-header">
<h1>💳 Abonnements</h1>
<p>Informationen zu Premium-Funktionen und wie du dein Abonnement verwaltest.</p>
</div>
<div class="hilfe-section open" id="sec-philosophy">
<div class="hilfe-section-header" onclick="toggleSection('sec-philosophy')">
<span class="hilfe-section-title">💡 Unser Ansatz</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
xXx Sphere soll niemanden reich machen. Die Abonnements dienen ausschließlich dazu, laufende Kosten wie Server, Datenbanken und externe API-Dienste zu decken.
</p>
<p>
Die wesentlichen Funktionen der Plattform Community, Spiele, Feed und Profile sind kostenlos nutzbar. Premium-Funktionen betreffen vor allem Hardware-Integrationen und erweiterte Automatisierungen.
</p>
</div>
</div>
<div class="hilfe-section" id="sec-vergleich">
<div class="hilfe-section-header" onclick="toggleSection('sec-vergleich')">
<span class="hilfe-section-title">📋 Free vs. Premium</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<div class="feature-grid">
<div class="feature-col">
<div class="feature-col-title free">Kostenlos</div>
<ul class="feature-list">
<li>Chastity, BDSM und Vanilla Game</li>
<li>Unbegrenzte Aufgaben und Karten</li>
<li>Community, Gruppen, Feed</li>
<li>Direktnachrichten</li>
<li>Dating-Funktionen</li>
<li>Profil und Freunde</li>
<li>Community Votes</li>
</ul>
</div>
<div class="feature-col">
<div class="feature-col-title premium">Premium</div>
<ul class="feature-list">
<li>TTLock-Integration (automatische Code-Verwaltung)</li>
<li>Automatische Lock-Öffnung via API</li>
<li>Erweiterte Spielstatistiken</li>
<li>Priorität beim Community-Support</li>
</ul>
</div>
</div>
<div class="hilfe-hint">
<strong>Hinweis:</strong> Der Funktionsumfang der Abonnements befindet sich noch im Aufbau und wird laufend erweitert.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-verwalten">
<div class="hilfe-section-header" onclick="toggleSection('sec-verwalten')">
<span class="hilfe-section-title">⚙️ Abonnement verwalten</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Dein aktives Abonnement findest und verwaltest du unter <strong>Community → Abonnements</strong>.
</p>
<table class="hilfe-table">
<thead><tr><th>Aktion</th><th>Beschreibung</th></tr></thead>
<tbody>
<tr><td><span class="hilfe-badge">Abschließen</span></td><td>Neues Abonnement aktivieren.</td></tr>
<tr><td><span class="hilfe-badge">Verlängern</span></td><td>Laufzeit manuell verlängern.</td></tr>
<tr><td><span class="hilfe-badge">Kündigen</span></td><td>Abonnement zum Ende der aktuellen Laufzeit kündigen.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="hilfe-section" id="sec-faq1">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq1')">
<span class="hilfe-section-title">❓ Was passiert nach einer Kündigung?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Nach einer Kündigung bleibt dein Premium-Zugang bis zum Ende der bezahlten Laufzeit aktiv. Danach fallen die Premium-Funktionen weg deine Daten und dein Account bleiben erhalten.
</p>
<div class="hilfe-warn">
<strong>Achtung:</strong> Aktive TTLock-Verbindungen werden nach Ablauf des Abonnements deaktiviert. Laufende Locks werden davon nicht sofort beeinflusst, können aber nach dem Ablauf nicht mehr automatisch geöffnet werden.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-faq2">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq2')">
<span class="hilfe-section-title">❓ Gibt es eine Testphase?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Derzeit gibt es keine automatische Testphase. Wende dich über <a href="/help/kontakt.html">Kontakt &amp; Feedback</a> an uns, wenn du Premium-Funktionen testen möchtest wir helfen gerne weiter.
</p>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
function toggleSection(id) {
document.getElementById(id).classList.toggle('open');
}
function openFromHash() {
const hash = window.location.hash.slice(1);
if (!hash) return;
const el = document.getElementById(hash);
if (el && el.classList.contains('hilfe-section')) {
el.classList.add('open');
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
}
}
document.addEventListener('DOMContentLoaded', openFromHash);
window.addEventListener('hashchange', openFromHash);
</script>
</body>
</html>

View File

@@ -0,0 +1,180 @@
<!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>Hilfe BDSM Game xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header { margin-bottom: 2rem; }
.hilfe-header h1 { font-size: 1.6rem; margin: 0 0 0.4rem 0; }
.hilfe-header p { color: var(--color-muted); font-size: 0.92rem; margin: 0; line-height: 1.6; }
.hilfe-section { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 10px; margin-bottom: 0.75rem; overflow: hidden; }
.hilfe-section-header { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 1rem 1.25rem; cursor: pointer; user-select: none; transition: background 0.15s; }
.hilfe-section-header:hover { background: rgba(255,255,255,0.03); }
.hilfe-section-title { display: flex; align-items: center; gap: 0.6rem; font-size: 1rem; font-weight: 600; }
.hilfe-section-arrow { font-size: 0.75rem; color: var(--color-muted); transition: transform 0.2s; }
.hilfe-section.open .hilfe-section-arrow { transform: rotate(90deg); }
.hilfe-section-body { display: none; padding: 0 1.25rem 1.25rem; border-top: 1px solid var(--color-secondary); }
.hilfe-section.open .hilfe-section-body { display: block; }
.hilfe-section-body p { font-size: 0.9rem; color: var(--color-muted); line-height: 1.7; margin: 0.9rem 0 0; }
.hilfe-section-body p:first-child { margin-top: 1rem; }
.hilfe-steps { list-style: none; padding: 0; margin: 1rem 0 0; display: flex; flex-direction: column; gap: 0.75rem; }
.hilfe-steps li { list-style: none; display: flex; align-items: flex-start; gap: 0.85rem; font-size: 0.9rem; color: var(--color-muted); line-height: 1.6; }
.hilfe-steps li::before { display: none; }
.hilfe-steps li .step-num { flex-shrink: 0; width: 1.6rem; height: 1.6rem; border-radius: 50%; background: var(--color-primary); color: #fff; font-size: 0.75rem; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-top: 0.1rem; }
.hilfe-hint { background: rgba(var(--color-primary-rgb, 120,80,200), 0.08); border-left: 3px solid var(--color-primary); border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-hint strong { color: var(--color-text); }
.hilfe-warn { background: rgba(231,76,60,0.08); border-left: 3px solid #e74c3c; border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-warn strong { color: #e74c3c; }
.hilfe-info { background: var(--color-secondary); border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin-top: 1rem; }
.hilfe-table th { text-align: left; color: var(--color-text); font-weight: 600; padding: 0.4rem 0.75rem 0.4rem 0; border-bottom: 1px solid var(--color-secondary); }
.hilfe-table td { color: var(--color-muted); padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); vertical-align: top; line-height: 1.5; }
.hilfe-table tr:last-child td { border-bottom: none; }
.hilfe-badge { display: inline-block; background: var(--color-secondary); border-radius: 4px; padding: 0.1rem 0.45rem; font-size: 0.78rem; font-weight: 600; color: var(--color-muted); vertical-align: middle; }
.back-link { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--color-muted); text-decoration: none; margin-bottom: 1.25rem; transition: color 0.15s; }
.back-link:hover { color: var(--color-text); }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<a href="/help/overview.html" class="back-link"> Zurück zur Hilfe-Übersicht</a>
<div class="hilfe-header">
<h1>⛓️ BDSM Game</h1>
<p>Sessions erstellen, Spieler einladen und Aufgaben verwalten.</p>
</div>
<div class="hilfe-section open" id="sec-intro">
<div class="hilfe-section-header" onclick="toggleSection('sec-intro')">
<span class="hilfe-section-title">📖 Was ist das BDSM Game?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Das BDSM Game ermöglicht strukturierte Spielsessions zwischen einem <strong>Dom</strong> (Dominant) und einem oder mehreren <strong>Subs</strong> (Submissive). Der Dom erstellt die Session, legt Regeln und Aufgaben fest und überwacht den Verlauf.
</p>
<p>
Aufgaben können zufällig aus dem Aufgaben-Pool gezogen oder manuell vergeben werden. Jede Session hat einen definierten Anfang und ein definiertes Ende.
</p>
<div class="hilfe-hint">
<strong>Safe Word:</strong> Lege vor jeder Session ein Safe Word fest. Das Safe Word beendet die Session sofort und unbedingt unabhängig vom Spielstand.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-session">
<div class="hilfe-section-header" onclick="toggleSection('sec-session')">
<span class="hilfe-section-title">🚀 Session starten</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>Als Dom startest du eine neue Session so:</p>
<ol class="hilfe-steps">
<li><span class="step-num">1</span><span>Navigiere zu <strong>BDSM → Neue Session</strong>.</span></li>
<li><span class="step-num">2</span><span>Vergib einen Session-Namen und lege die Intensität fest (<span class="hilfe-badge">Leicht</span> / <span class="hilfe-badge">Mittel</span> / <span class="hilfe-badge">Intensiv</span>).</span></li>
<li><span class="step-num">3</span><span>Wähle die Aufgaben-Sets, die während der Session aktiv sein sollen.</span></li>
<li><span class="step-num">4</span><span>Lade Mitspieler per Nutzername oder Einladungslink ein (siehe unten).</span></li>
<li><span class="step-num">5</span><span>Starte die Session. Alle eingeladenen Subs erhalten eine Benachrichtigung.</span></li>
</ol>
<div class="hilfe-warn">
<strong>Achtung:</strong> Eine laufende Session kann nur vom Dom beendet werden oder wenn das Safe Word ausgelöst wird.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-einladen">
<div class="hilfe-section-header" onclick="toggleSection('sec-einladen')">
<span class="hilfe-section-title">👥 Spieler einladen</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Spieler können auf zwei Wegen in eine Session eingeladen werden:
</p>
<table class="hilfe-table">
<thead><tr><th>Methode</th><th>Beschreibung</th></tr></thead>
<tbody>
<tr><td><span class="hilfe-badge">Nutzername</span></td><td>Gib den Nutzernamen direkt ein. Nur für registrierte Nutzer, die dir folgen oder mit dir befreundet sind.</td></tr>
<tr><td><span class="hilfe-badge">Einladungslink</span></td><td>Generiere einen einmalig verwendbaren Link. Jede Person mit dem Link kann beitreten (auch Nicht-Freunde).</td></tr>
</tbody>
</table>
<div class="hilfe-hint">
<strong>Tipp:</strong> Teile Einladungslinks nur über sichere Kanäle. Ein Link kann nur einmal verwendet werden und verfällt nach 24 Stunden.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-aufgaben">
<div class="hilfe-section-header" onclick="toggleSection('sec-aufgaben')">
<span class="hilfe-section-title">📋 Aufgaben verwalten</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Aufgaben sind das Herzstück des BDSM Games. Eigene Aufgaben kannst du unter <strong>BDSM → Aufgaben</strong> anlegen und bearbeiten.
</p>
<p>
Während einer aktiven Session kann der Dom jederzeit eine neue Aufgabe vergeben. Der Sub muss die Aufgabe bestätigen oder Einwände erheben. Nicht erfüllte Aufgaben werden protokolliert.
</p>
<div class="hilfe-info">
Aufgaben können öffentlich als Vorlagen geteilt werden. Andere Nutzer können diese Vorlagen in ihre eigenen Sammlungen übernehmen.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-faq1">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq1')">
<span class="hilfe-section-title">❓ Was passiert, wenn ein Sub die Session verlässt?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Verlässt ein Sub die Session vorzeitig (ohne Safe Word), wird dies als Verstoß protokolliert. Der Dom kann die Session dennoch fortsetzen, wenn weitere Subs teilnehmen. Bei nur einem Sub endet die Session automatisch.
</p>
</div>
</div>
<div class="hilfe-section" id="sec-faq2">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq2')">
<span class="hilfe-section-title">❓ Kann ich Aufgaben aus dem Community-Pool nutzen?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Ja. Unter <strong>BDSM → Entdecken</strong> findest du öffentlich geteilte Aufgaben-Sets der Community. Du kannst diese direkt in deine eigene Sammlung übernehmen und für Sessions verwenden.
</p>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
function toggleSection(id) {
document.getElementById(id).classList.toggle('open');
}
function openFromHash() {
const hash = window.location.hash.slice(1);
if (!hash) return;
const el = document.getElementById(hash);
if (el && el.classList.contains('hilfe-section')) {
el.classList.add('open');
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
}
}
document.addEventListener('DOMContentLoaded', openFromHash);
window.addEventListener('hashchange', openFromHash);
</script>
</body>
</html>

View File

@@ -0,0 +1,213 @@
<!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>Hilfe Chastity Game xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header { margin-bottom: 2rem; }
.hilfe-header h1 { font-size: 1.6rem; margin: 0 0 0.4rem 0; }
.hilfe-header p { color: var(--color-muted); font-size: 0.92rem; margin: 0; line-height: 1.6; }
.hilfe-section { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 10px; margin-bottom: 0.75rem; overflow: hidden; }
.hilfe-section-header { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 1rem 1.25rem; cursor: pointer; user-select: none; transition: background 0.15s; }
.hilfe-section-header:hover { background: rgba(255,255,255,0.03); }
.hilfe-section-title { display: flex; align-items: center; gap: 0.6rem; font-size: 1rem; font-weight: 600; }
.hilfe-section-arrow { font-size: 0.75rem; color: var(--color-muted); transition: transform 0.2s; }
.hilfe-section.open .hilfe-section-arrow { transform: rotate(90deg); }
.hilfe-section-body { display: none; padding: 0 1.25rem 1.25rem; border-top: 1px solid var(--color-secondary); }
.hilfe-section.open .hilfe-section-body { display: block; }
.hilfe-section-body p { font-size: 0.9rem; color: var(--color-muted); line-height: 1.7; margin: 0.9rem 0 0; }
.hilfe-section-body p:first-child { margin-top: 1rem; }
.hilfe-steps { list-style: none; padding: 0; margin: 1rem 0 0; display: flex; flex-direction: column; gap: 0.75rem; }
.hilfe-steps li { list-style: none; display: flex; align-items: flex-start; gap: 0.85rem; font-size: 0.9rem; color: var(--color-muted); line-height: 1.6; }
.hilfe-steps li::before { display: none; }
.hilfe-steps li .step-num { flex-shrink: 0; width: 1.6rem; height: 1.6rem; border-radius: 50%; background: var(--color-primary); color: #fff; font-size: 0.75rem; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-top: 0.1rem; }
.hilfe-hint { background: rgba(var(--color-primary-rgb, 120,80,200), 0.08); border-left: 3px solid var(--color-primary); border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-hint strong { color: var(--color-text); }
.hilfe-warn { background: rgba(231,76,60,0.08); border-left: 3px solid #e74c3c; border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-warn strong { color: #e74c3c; }
.hilfe-info { background: var(--color-secondary); border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin-top: 1rem; }
.hilfe-table th { text-align: left; color: var(--color-text); font-weight: 600; padding: 0.4rem 0.75rem 0.4rem 0; border-bottom: 1px solid var(--color-secondary); }
.hilfe-table td { color: var(--color-muted); padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); vertical-align: top; line-height: 1.5; }
.hilfe-table tr:last-child td { border-bottom: none; }
.hilfe-badge { display: inline-block; background: var(--color-secondary); border-radius: 4px; padding: 0.1rem 0.45rem; font-size: 0.78rem; font-weight: 600; color: var(--color-muted); vertical-align: middle; }
.back-link { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--color-muted); text-decoration: none; margin-bottom: 1.25rem; transition: color 0.15s; }
.back-link:hover { color: var(--color-text); }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<a href="/help/overview.html" class="back-link"> Zurück zur Hilfe-Übersicht</a>
<div class="hilfe-header">
<h1>🔒 Chastity Game</h1>
<p>Alles rund um Locks, Keyholder, Karten und Aufgaben im Chastity Game.</p>
</div>
<div class="hilfe-section open" id="sec-intro">
<div class="hilfe-section-header" onclick="toggleSection('sec-intro')">
<span class="hilfe-section-title">📖 Was ist das Chastity Game?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Das Chastity Game ist ein interaktives Rollenspiel für zwei Personen: eine Person übernimmt die Rolle des <strong>Wearers</strong> (trägt das Gerät), die andere die des <strong>Keyholders</strong> (verwaltet den Schlüssel).
</p>
<p>
Der Keyholder bestimmt, wann das Lock geöffnet wird entweder nach einem festen Datum, durch das Ziehen von Karten, oder durch Community-Votes. Der Wearer kann Aufgaben erhalten, die den Verlauf des Spiels beeinflussen.
</p>
<div class="hilfe-hint">
<strong>Hinweis:</strong> Für die automatische Steuerung einer physischen TTLock-Schlüsselbox ist ein Premium-Abonnement erforderlich. Mehr dazu unter <a href="/help/ttlock.html">TTLock-Hilfe</a>.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-newlock">
<div class="hilfe-section-header" onclick="toggleSection('sec-newlock')">
<span class="hilfe-section-title">🚀 Neues Lock starten</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>So startest du als Wearer ein neues Lock:</p>
<ol class="hilfe-steps">
<li><span class="step-num">1</span><span>Navigiere zu <strong>Chastity → Neues Lock</strong>.</span></li>
<li><span class="step-num">2</span><span>Vergib einen Namen für dein Lock (z. B. „Mein erstes Lock").</span></li>
<li><span class="step-num">3</span><span>Wähle einen Modus: <span class="hilfe-badge">Keyholder</span>, <span class="hilfe-badge">TimeLock</span> oder <span class="hilfe-badge">Community</span>.</span></li>
<li><span class="step-num">4</span><span>Lege optional Aufgaben und Karten fest, die den Verlauf beeinflussen.</span></li>
<li><span class="step-num">5</span><span>Bestätige mit <em>Lock starten</em>. Das Lock ist jetzt aktiv.</span></li>
</ol>
<div class="hilfe-warn">
<strong>Achtung:</strong> Ein aktives Lock kann nicht einfach abgebrochen werden. Stelle sicher, dass du und dein Keyholder sich über die Bedingungen einig sind.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-keyholder">
<div class="hilfe-section-header" onclick="toggleSection('sec-keyholder')">
<span class="hilfe-section-title">🗝️ Die Rolle als Keyholder</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Als Keyholder verwaltest du das Lock einer anderen Person. Du wirst per Einladungslink oder direkt über den Nutzernamen des Wearers zugewiesen.
</p>
<table class="hilfe-table">
<thead><tr><th>Aktion</th><th>Beschreibung</th></tr></thead>
<tbody>
<tr><td><span class="hilfe-badge">Öffnen</span></td><td>Gibt den Schlüssel / Öffnungscode frei. Bei TTLock wird der Code automatisch an die Box übermittelt.</td></tr>
<tr><td><span class="hilfe-badge">Verlängern</span></td><td>Fügt dem Lock zusätzliche Zeit hinzu.</td></tr>
<tr><td><span class="hilfe-badge">Aufgabe vergeben</span></td><td>Schickt dem Wearer eine neue Aufgabe. Nichterfüllung kann Strafzeit bedeuten.</td></tr>
<tr><td><span class="hilfe-badge">Verstoß melden</span></td><td>Protokolliert einen Regelverstoß und kann Zeit hinzufügen.</td></tr>
</tbody>
</table>
<div class="hilfe-hint">
<strong>Tipp:</strong> Unter <strong>Keyholder → Übersicht</strong> siehst du alle Locks, für die du verantwortlich bist, inklusive Laufzeit und offener Aufgaben.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-karten">
<div class="hilfe-section-header" onclick="toggleSection('sec-karten')">
<span class="hilfe-section-title">🃏 Karten und Aufgaben</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Karten sind ein zufälliges Element im Chastity Game. Beim Einrichten eines Locks kann ein Kartenstapel aktiviert werden. Der Wearer zieht in festgelegten Abständen eine Karte das Ergebnis kann die Laufzeit verlängern, verkürzen oder eine Aufgabe auslösen.
</p>
<table class="hilfe-table">
<thead><tr><th>Kartentyp</th><th>Auswirkung</th></tr></thead>
<tbody>
<tr><td><span class="hilfe-badge">+Zeit</span></td><td>Fügt dem Lock zusätzliche Zeit hinzu (z. B. +1 Tag).</td></tr>
<tr><td><span class="hilfe-badge">Zeit</span></td><td>Verkürzt die verbleibende Laufzeit.</td></tr>
<tr><td><span class="hilfe-badge">Aufgabe</span></td><td>Löst eine zufällige Aufgabe aus, die erfüllt werden muss.</td></tr>
<tr><td><span class="hilfe-badge">Freeze</span></td><td>Laufzeit wird für einen definierten Zeitraum eingefroren.</td></tr>
<tr><td><span class="hilfe-badge">Reset</span></td><td>Setzt die Laufzeit auf den ursprünglichen Wert zurück.</td></tr>
</tbody>
</table>
<p>
Eigene Karten und Aufgaben kannst du unter <strong>Chastity → Aufgaben</strong> verwalten und den Vorlagen-Pool nach Belieben erweitern.
</p>
</div>
</div>
<div class="hilfe-section" id="sec-timelock">
<div class="hilfe-section-header" onclick="toggleSection('sec-timelock')">
<span class="hilfe-section-title">⏱️ TimeLock erklärt</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Ein TimeLock läuft ohne Keyholder die Öffnung erfolgt automatisch, sobald der eingestellte Endzeitpunkt erreicht ist. Es gibt keinen manuellen Eingriff.
</p>
<div class="hilfe-warn">
<strong>Achtung:</strong> Bei einem TimeLock ohne Keyholder gibt es keine manuelle Freigabe vor Ablauf der Zeit. Plane daher immer eine Notfalloption ein (z. B. physischer Ersatzschlüssel an vertrauenswürdiger Person).
</div>
<div class="hilfe-hint">
<strong>Hinweis:</strong> Für die automatische TTLock-Öffnung zum Ablaufzeitpunkt muss die TTLock-Integration korrekt eingerichtet sein. Weitere Informationen: <a href="/help/ttlock.html">TTLock-Hilfe</a>.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-faq1">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq1')">
<span class="hilfe-section-title">❓ Kann ich ein Lock vorzeitig beenden?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Ein Lock kann nur dann vorzeitig beendet werden, wenn der Keyholder die Freigabe erteilt. Bei einem TimeLock ohne Keyholder ist dies nicht möglich außer über einen physischen Ersatzschlüssel.
</p>
<div class="hilfe-info">
Im Notfall steht die TTLock-Notfallöffnung zur Verfügung. Mehr dazu: <a href="/help/ttlock.html#sec-faq2">TTLock Notfall-Öffnung</a>.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-faq2">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq2')">
<span class="hilfe-section-title">❓ Wie finde ich einen Keyholder?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Unter <strong>Chastity → Keyholder finden</strong> kannst du nach registrierten Keyholdern suchen und eine Anfrage stellen. Der Keyholder muss die Anfrage bestätigen, bevor das Lock aktiv wird.
</p>
<p>
Alternativ kannst du deinen Keyholder direkt über seinen Nutzernamen einladen, wenn ihr euch bereits kennt.
</p>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
function toggleSection(id) {
document.getElementById(id).classList.toggle('open');
}
function openFromHash() {
const hash = window.location.hash.slice(1);
if (!hash) return;
const el = document.getElementById(hash);
if (el && el.classList.contains('hilfe-section')) {
el.classList.add('open');
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
}
}
document.addEventListener('DOMContentLoaded', openFromHash);
window.addEventListener('hashchange', openFromHash);
</script>
</body>
</html>

View File

@@ -0,0 +1,208 @@
<!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>Hilfe Community xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header { margin-bottom: 2rem; }
.hilfe-header h1 { font-size: 1.6rem; margin: 0 0 0.4rem 0; }
.hilfe-header p { color: var(--color-muted); font-size: 0.92rem; margin: 0; line-height: 1.6; }
.hilfe-section { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 10px; margin-bottom: 0.75rem; overflow: hidden; }
.hilfe-section-header { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 1rem 1.25rem; cursor: pointer; user-select: none; transition: background 0.15s; }
.hilfe-section-header:hover { background: rgba(255,255,255,0.03); }
.hilfe-section-title { display: flex; align-items: center; gap: 0.6rem; font-size: 1rem; font-weight: 600; }
.hilfe-section-arrow { font-size: 0.75rem; color: var(--color-muted); transition: transform 0.2s; }
.hilfe-section.open .hilfe-section-arrow { transform: rotate(90deg); }
.hilfe-section-body { display: none; padding: 0 1.25rem 1.25rem; border-top: 1px solid var(--color-secondary); }
.hilfe-section.open .hilfe-section-body { display: block; }
.hilfe-section-body p { font-size: 0.9rem; color: var(--color-muted); line-height: 1.7; margin: 0.9rem 0 0; }
.hilfe-section-body p:first-child { margin-top: 1rem; }
.hilfe-steps { list-style: none; padding: 0; margin: 1rem 0 0; display: flex; flex-direction: column; gap: 0.75rem; }
.hilfe-steps li { list-style: none; display: flex; align-items: flex-start; gap: 0.85rem; font-size: 0.9rem; color: var(--color-muted); line-height: 1.6; }
.hilfe-steps li::before { display: none; }
.hilfe-steps li .step-num { flex-shrink: 0; width: 1.6rem; height: 1.6rem; border-radius: 50%; background: var(--color-primary); color: #fff; font-size: 0.75rem; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-top: 0.1rem; }
.hilfe-hint { background: rgba(var(--color-primary-rgb, 120,80,200), 0.08); border-left: 3px solid var(--color-primary); border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-hint strong { color: var(--color-text); }
.hilfe-warn { background: rgba(231,76,60,0.08); border-left: 3px solid #e74c3c; border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-warn strong { color: #e74c3c; }
.hilfe-info { background: var(--color-secondary); border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin-top: 1rem; }
.hilfe-table th { text-align: left; color: var(--color-text); font-weight: 600; padding: 0.4rem 0.75rem 0.4rem 0; border-bottom: 1px solid var(--color-secondary); }
.hilfe-table td { color: var(--color-muted); padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); vertical-align: top; line-height: 1.5; }
.hilfe-table tr:last-child td { border-bottom: none; }
.hilfe-badge { display: inline-block; background: var(--color-secondary); border-radius: 4px; padding: 0.1rem 0.45rem; font-size: 0.78rem; font-weight: 600; color: var(--color-muted); vertical-align: middle; }
.back-link { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--color-muted); text-decoration: none; margin-bottom: 1.25rem; transition: color 0.15s; }
.back-link:hover { color: var(--color-text); }
.hilfe-section-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin: 1.75rem 0 0.75rem; }
.hilfe-section-label:first-of-type { margin-top: 0; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<a href="/help/overview.html" class="back-link"> Zurück zur Hilfe-Übersicht</a>
<div class="hilfe-header">
<h1>🌐 Community</h1>
<p>Gruppen, Feed, Profile und Community-Votes alles, was die xXx-Sphere-Community zusammenhält.</p>
</div>
<div class="hilfe-section-label">Gruppen</div>
<div class="hilfe-section open" id="sec-gruppen-intro">
<div class="hilfe-section-header" onclick="toggleSection('sec-gruppen-intro')">
<span class="hilfe-section-title">👥 Was sind Gruppen?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Gruppen sind private oder öffentliche Räume für Gleichgesinnte. Mitglieder können Beiträge teilen, Abstimmungen starten und sich austauschen. Jede Gruppe wird von einem oder mehreren Admins verwaltet.
</p>
</div>
</div>
<div class="hilfe-section" id="sec-gruppe-erstellen">
<div class="hilfe-section-header" onclick="toggleSection('sec-gruppe-erstellen')">
<span class="hilfe-section-title">🚀 Gruppe erstellen</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<ol class="hilfe-steps">
<li><span class="step-num">1</span><span>Navigiere zu <strong>Community → Gruppen</strong>.</span></li>
<li><span class="step-num">2</span><span>Klicke auf <em>Neue Gruppe</em> und vergib Name, Beschreibung und Bild.</span></li>
<li><span class="step-num">3</span><span>Wähle die Sichtbarkeit: <span class="hilfe-badge">Öffentlich</span> (jeder kann beitreten) oder <span class="hilfe-badge">Privat</span> (nur auf Einladung).</span></li>
<li><span class="step-num">4</span><span>Bestätige mit <em>Erstellen</em>. Du bist automatisch Admin der neuen Gruppe.</span></li>
</ol>
</div>
</div>
<div class="hilfe-section" id="sec-mitglieder">
<div class="hilfe-section-header" onclick="toggleSection('sec-mitglieder')">
<span class="hilfe-section-title">⚙️ Mitglieder verwalten</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>Als Admin einer Gruppe kannst du:</p>
<table class="hilfe-table">
<thead><tr><th>Aktion</th><th>Beschreibung</th></tr></thead>
<tbody>
<tr><td><span class="hilfe-badge">Einladen</span></td><td>Nutzer per Nutzername direkt einladen.</td></tr>
<tr><td><span class="hilfe-badge">Entfernen</span></td><td>Mitglieder aus der Gruppe ausschließen.</td></tr>
<tr><td><span class="hilfe-badge">Moderator</span></td><td>Mitglieder zu Moderatoren befördern (können Beiträge entfernen).</td></tr>
<tr><td><span class="hilfe-badge">Admin</span></td><td>Admin-Rechte an ein anderes Mitglied übertragen oder teilen.</td></tr>
</tbody>
</table>
<div class="hilfe-warn">
<strong>Achtung:</strong> Wenn du als letzter Admin eine Gruppe verlässt, wird die Gruppe aufgelöst.
</div>
</div>
</div>
<div class="hilfe-section-label">Feed &amp; Profil</div>
<div class="hilfe-section" id="sec-feed">
<div class="hilfe-section-header" onclick="toggleSection('sec-feed')">
<span class="hilfe-section-title">📰 Feed nutzen</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Der Feed zeigt dir Beiträge von Personen, denen du folgst, sowie Aktivitäten aus deinen Gruppen. Du kannst Beiträge liken, kommentieren und teilen.
</p>
<p>
Eigene Beiträge erstellst du über das -Symbol im Feed. Du kannst Text, Bilder und Links teilen. Beiträge können öffentlich, für Freunde oder nur für Gruppenmitglieder sichtbar sein.
</p>
<div class="hilfe-hint">
<strong>Tipp:</strong> Über die Filter-Option kannst du den Feed auf bestimmte Gruppen oder Personen einschränken.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-profil">
<div class="hilfe-section-header" onclick="toggleSection('sec-profil')">
<span class="hilfe-section-title">👤 Profil gestalten &amp; Personen folgen</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Dein Profil ist deine öffentliche Visitenkarte in der Community. Du kannst ein Profilbild, einen Anzeigenamen, eine kurze Bio und deine Interessen hinterlegen.
</p>
<p>
Um jemandem zu folgen, besuche sein Profil und klicke auf <em>Folgen</em>. Du siehst dann seine öffentlichen Beiträge in deinem Feed. Alternativ kannst du eine Freundschaftsanfrage senden, um auch nicht-öffentliche Inhalte zu sehen.
</p>
<div class="hilfe-info">
Andere Nutzer findest du über die Suchfunktion oder unter <strong>Community → Nutzer entdecken</strong>.
</div>
</div>
</div>
<div class="hilfe-section-label">Community Votes</div>
<div class="hilfe-section" id="sec-votes">
<div class="hilfe-section-header" onclick="toggleSection('sec-votes')">
<span class="hilfe-section-title">🏆 Wie funktionieren Community Votes?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Community Votes sind eine besondere Spielmechanik im Chastity Game: Wenn ein Lock im Modus <span class="hilfe-badge">Community</span> läuft, entscheidet die gesamte Community mit, ob das Lock geöffnet wird oder die Zeit verlängert wird.
</p>
<p>
Andere Nutzer können für <em>Öffnen</em> oder <em>Verlängern</em> abstimmen. Nach Ablauf der Abstimmungszeit gewinnt die Mehrheit das Ergebnis wird automatisch auf das Lock angewandt.
</p>
<div class="hilfe-hint">
<strong>Hinweis:</strong> Nur verifizierte Nutzer können an Community Votes teilnehmen. Die Verifikation erfolgt über dein Profil.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-nachrichten">
<div class="hilfe-section-header" onclick="toggleSection('sec-nachrichten')">
<span class="hilfe-section-title">✉️ Nachrichten</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Du kannst anderen Nutzern direkte Nachrichten senden. Navigiere dazu zu <strong>Community → Nachrichten</strong> oder klicke auf das Nachrichten-Symbol in einem Nutzerprofil.
</p>
<p>
Nachrichten sind Ende-zu-Ende zwischen dir und dem Empfänger sichtbar. Du kannst Konversationen stummschalten oder blockieren.
</p>
<div class="hilfe-warn">
<strong>Achtung:</strong> Unerwünschte Nachrichten bitte über den <em>Melden</em>-Button melden. Wiederholte Verstöße führen zu einer Account-Sperre.
</div>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
function toggleSection(id) {
document.getElementById(id).classList.toggle('open');
}
function openFromHash() {
const hash = window.location.hash.slice(1);
if (!hash) return;
const el = document.getElementById(hash);
if (el && el.classList.contains('hilfe-section')) {
el.classList.add('open');
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
}
}
document.addEventListener('DOMContentLoaded', openFromHash);
window.addEventListener('hashchange', openFromHash);
</script>
</body>
</html>

View File

@@ -0,0 +1,168 @@
<!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>Hilfe Sicherheit & Datenschutz xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header { margin-bottom: 2rem; }
.hilfe-header h1 { font-size: 1.6rem; margin: 0 0 0.4rem 0; }
.hilfe-header p { color: var(--color-muted); font-size: 0.92rem; margin: 0; line-height: 1.6; }
.hilfe-section { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 10px; margin-bottom: 0.75rem; overflow: hidden; }
.hilfe-section-header { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 1rem 1.25rem; cursor: pointer; user-select: none; transition: background 0.15s; }
.hilfe-section-header:hover { background: rgba(255,255,255,0.03); }
.hilfe-section-title { display: flex; align-items: center; gap: 0.6rem; font-size: 1rem; font-weight: 600; }
.hilfe-section-arrow { font-size: 0.75rem; color: var(--color-muted); transition: transform 0.2s; }
.hilfe-section.open .hilfe-section-arrow { transform: rotate(90deg); }
.hilfe-section-body { display: none; padding: 0 1.25rem 1.25rem; border-top: 1px solid var(--color-secondary); }
.hilfe-section.open .hilfe-section-body { display: block; }
.hilfe-section-body p { font-size: 0.9rem; color: var(--color-muted); line-height: 1.7; margin: 0.9rem 0 0; }
.hilfe-section-body p:first-child { margin-top: 1rem; }
.hilfe-hint { background: rgba(var(--color-primary-rgb, 120,80,200), 0.08); border-left: 3px solid var(--color-primary); border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-hint strong { color: var(--color-text); }
.hilfe-warn { background: rgba(231,76,60,0.08); border-left: 3px solid #e74c3c; border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-warn strong { color: #e74c3c; }
.hilfe-info { background: var(--color-secondary); border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin-top: 1rem; }
.hilfe-table th { text-align: left; color: var(--color-text); font-weight: 600; padding: 0.4rem 0.75rem 0.4rem 0; border-bottom: 1px solid var(--color-secondary); }
.hilfe-table td { color: var(--color-muted); padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); vertical-align: top; line-height: 1.5; }
.hilfe-table tr:last-child td { border-bottom: none; }
.hilfe-badge { display: inline-block; background: var(--color-secondary); border-radius: 4px; padding: 0.1rem 0.45rem; font-size: 0.78rem; font-weight: 600; color: var(--color-muted); vertical-align: middle; }
.back-link { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--color-muted); text-decoration: none; margin-bottom: 1.25rem; transition: color 0.15s; }
.back-link:hover { color: var(--color-text); }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<a href="/help/overview.html" class="back-link"> Zurück zur Hilfe-Übersicht</a>
<div class="hilfe-header">
<h1>🔐 Sicherheit &amp; Datenschutz</h1>
<p>Wie deine Daten gespeichert werden und welche Sicherheitsmaßnahmen wir treffen.</p>
</div>
<div class="hilfe-section open" id="sec-grundsaetze">
<div class="hilfe-section-header" onclick="toggleSection('sec-grundsaetze')">
<span class="hilfe-section-title">📖 Unsere Grundsätze</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
xXx Sphere verarbeitet nur Daten, die für den Betrieb der Plattform notwendig sind. Es gibt keine Weitergabe an Werbepartner und keine Vermarktung von Nutzerdaten.
</p>
<p>
Alle sensiblen Daten werden verschlüsselt gespeichert oder gehasht. Der Zugriff auf Produktionsdaten ist auf das absolut notwendige Minimum beschränkt.
</p>
</div>
</div>
<div class="hilfe-section" id="sec-gespeicherte-daten">
<div class="hilfe-section-header" onclick="toggleSection('sec-gespeicherte-daten')">
<span class="hilfe-section-title">🗄️ Was wird gespeichert?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<table class="hilfe-table">
<thead><tr><th>Datenkategorie</th><th>Speicherung</th></tr></thead>
<tbody>
<tr><td><span class="hilfe-badge">Account-Daten</span></td><td>Nutzername, E-Mail, Registrierungsdatum. E-Mail-Adresse wird nie öffentlich angezeigt.</td></tr>
<tr><td><span class="hilfe-badge">Profildaten</span></td><td>Anzeigename, Bio, Profilbild, Interessen. Sichtbarkeit ist konfigurierbar.</td></tr>
<tr><td><span class="hilfe-badge">Spielverläufe</span></td><td>Lock-Verläufe, Aufgabenprotokolle, Session-Daten. Werden nach Beendigung archiviert.</td></tr>
<tr><td><span class="hilfe-badge">Nachrichten</span></td><td>Direktnachrichten werden in der Datenbank gespeichert. Kein Ende-zu-Ende-Verschlüsselungsstandard aktuell.</td></tr>
<tr><td><span class="hilfe-badge">TTLock-Zugangsdaten</span></td><td>Benutzername im Klartext, Passwort als MD5-Hash (Anforderung der TTLock-API).</td></tr>
<tr><td><span class="hilfe-badge">Passwort</span></td><td>Wird als BCrypt-Hash gespeichert. Das Klartextpasswort ist für uns nicht einsehbar.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="hilfe-section" id="sec-passwort-hashing">
<div class="hilfe-section-header" onclick="toggleSection('sec-passwort-hashing')">
<span class="hilfe-section-title">🔒 Passwort-Hashing</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Dein Passwort wird beim Setzen sofort mit <strong>BCrypt</strong> gehasht. BCrypt ist ein adaptiver Hashing-Algorithmus mit integriertem Salt er macht Brute-Force-Angriffe rechnerisch aufwendig.
</p>
<p>
Das Klartextpasswort verlässt deinen Browser verschlüsselt per HTTPS und wird serverseitig niemals im Klartext gespeichert oder geloggt.
</p>
<div class="hilfe-hint">
<strong>Empfehlung:</strong> Verwende ein einzigartiges Passwort für xXx Sphere. Ein Passwort-Manager hilft dabei, starke Passwörter zu generieren und sicher zu speichern.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-verbindung">
<div class="hilfe-section-header" onclick="toggleSection('sec-verbindung')">
<span class="hilfe-section-title">🌐 Verbindungssicherheit</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Die gesamte Kommunikation zwischen deinem Browser und den Servern erfolgt ausschließlich über <strong>HTTPS</strong> (TLS). HTTP-Verbindungen werden automatisch auf HTTPS umgeleitet.
</p>
<div class="hilfe-info">
Session-Cookies sind als <em>HttpOnly</em> und <em>Secure</em> gesetzt, wodurch sie nicht per JavaScript ausgelesen werden können.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-faq1">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq1')">
<span class="hilfe-section-title">❓ Kann ich meine Daten exportieren?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Ein automatischer Datenexport ist noch nicht verfügbar. Du kannst einen manuellen Export über <a href="/help/kontakt.html">Kontakt &amp; Feedback</a> anfordern wir stellen dir deine Daten dann im JSON-Format zur Verfügung.
</p>
</div>
</div>
<div class="hilfe-section" id="sec-faq2">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq2')">
<span class="hilfe-section-title">❓ Was passiert mit meinen Daten nach einer Konto-Löschung?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Nach einer bestätigten Account-Löschung werden alle personenbezogenen Daten innerhalb von 30 Tagen endgültig aus unseren Datenbanken gelöscht. Dazu gehören Profil, Nachrichten, Spielprotokolle und Einstellungen.
</p>
<div class="hilfe-warn">
<strong>Achtung:</strong> Öffentlich geteilte Inhalte (z. B. Beiträge in Gruppen) können bis zum Ablauf der 30 Tage noch sichtbar sein und werden danach zusammen mit deinem Account gelöscht.
</div>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
function toggleSection(id) {
document.getElementById(id).classList.toggle('open');
}
function openFromHash() {
const hash = window.location.hash.slice(1);
if (!hash) return;
const el = document.getElementById(hash);
if (el && el.classList.contains('hilfe-section')) {
el.classList.add('open');
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
}
}
document.addEventListener('DOMContentLoaded', openFromHash);
window.addEventListener('hashchange', openFromHash);
</script>
</body>
</html>

View File

@@ -0,0 +1,194 @@
<!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>Hilfe Konto & Einstellungen xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header { margin-bottom: 2rem; }
.hilfe-header h1 { font-size: 1.6rem; margin: 0 0 0.4rem 0; }
.hilfe-header p { color: var(--color-muted); font-size: 0.92rem; margin: 0; line-height: 1.6; }
.hilfe-section { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 10px; margin-bottom: 0.75rem; overflow: hidden; }
.hilfe-section-header { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 1rem 1.25rem; cursor: pointer; user-select: none; transition: background 0.15s; }
.hilfe-section-header:hover { background: rgba(255,255,255,0.03); }
.hilfe-section-title { display: flex; align-items: center; gap: 0.6rem; font-size: 1rem; font-weight: 600; }
.hilfe-section-arrow { font-size: 0.75rem; color: var(--color-muted); transition: transform 0.2s; }
.hilfe-section.open .hilfe-section-arrow { transform: rotate(90deg); }
.hilfe-section-body { display: none; padding: 0 1.25rem 1.25rem; border-top: 1px solid var(--color-secondary); }
.hilfe-section.open .hilfe-section-body { display: block; }
.hilfe-section-body p { font-size: 0.9rem; color: var(--color-muted); line-height: 1.7; margin: 0.9rem 0 0; }
.hilfe-section-body p:first-child { margin-top: 1rem; }
.hilfe-steps { list-style: none; padding: 0; margin: 1rem 0 0; display: flex; flex-direction: column; gap: 0.75rem; }
.hilfe-steps li { list-style: none; display: flex; align-items: flex-start; gap: 0.85rem; font-size: 0.9rem; color: var(--color-muted); line-height: 1.6; }
.hilfe-steps li::before { display: none; }
.hilfe-steps li .step-num { flex-shrink: 0; width: 1.6rem; height: 1.6rem; border-radius: 50%; background: var(--color-primary); color: #fff; font-size: 0.75rem; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-top: 0.1rem; }
.hilfe-hint { background: rgba(var(--color-primary-rgb, 120,80,200), 0.08); border-left: 3px solid var(--color-primary); border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-hint strong { color: var(--color-text); }
.hilfe-warn { background: rgba(231,76,60,0.08); border-left: 3px solid #e74c3c; border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-warn strong { color: #e74c3c; }
.hilfe-info { background: var(--color-secondary); border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin-top: 1rem; }
.hilfe-table th { text-align: left; color: var(--color-text); font-weight: 600; padding: 0.4rem 0.75rem 0.4rem 0; border-bottom: 1px solid var(--color-secondary); }
.hilfe-table td { color: var(--color-muted); padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); vertical-align: top; line-height: 1.5; }
.hilfe-table tr:last-child td { border-bottom: none; }
.hilfe-badge { display: inline-block; background: var(--color-secondary); border-radius: 4px; padding: 0.1rem 0.45rem; font-size: 0.78rem; font-weight: 600; color: var(--color-muted); vertical-align: middle; }
.back-link { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--color-muted); text-decoration: none; margin-bottom: 1.25rem; transition: color 0.15s; }
.back-link:hover { color: var(--color-text); }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<a href="/help/overview.html" class="back-link"> Zurück zur Hilfe-Übersicht</a>
<div class="hilfe-header">
<h1>⚙️ Konto &amp; Einstellungen</h1>
<p>Profil, Benachrichtigungen, Passwort und Datenschutz-Einstellungen verwalten.</p>
</div>
<div class="hilfe-section open" id="sec-profil">
<div class="hilfe-section-header" onclick="toggleSection('sec-profil')">
<span class="hilfe-section-title">👤 Profil bearbeiten</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Dein Profil erreichst du unter <strong>Konto → Profil</strong>. Du kannst dort Anzeigename, Bio, Profilbild und Interessen anpassen.
</p>
<table class="hilfe-table">
<thead><tr><th>Feld</th><th>Beschreibung</th></tr></thead>
<tbody>
<tr><td><span class="hilfe-badge">Anzeigename</span></td><td>Sichtbarer Name in der Community. Kann jederzeit geändert werden.</td></tr>
<tr><td><span class="hilfe-badge">Bio</span></td><td>Kurze Beschreibung über dich (max. 300 Zeichen).</td></tr>
<tr><td><span class="hilfe-badge">Profilbild</span></td><td>JPG oder PNG, max. 5 MB. Wird kreisförmig zugeschnitten.</td></tr>
<tr><td><span class="hilfe-badge">Interessen</span></td><td>Tags, die anderen zeigen, was dich interessiert.</td></tr>
<tr><td><span class="hilfe-badge">Sichtbarkeit</span></td><td>Öffentlich, Freunde, oder Privat steuert, wer dein Profil sehen kann.</td></tr>
</tbody>
</table>
<div class="hilfe-hint">
<strong>Tipp:</strong> Ein vollständiges Profil erhöht deine Sichtbarkeit in der Community und erleichtert es anderen, dich zu finden.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-benachrichtigungen">
<div class="hilfe-section-header" onclick="toggleSection('sec-benachrichtigungen')">
<span class="hilfe-section-title">🔔 Benachrichtigungen konfigurieren</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Unter <strong>Einstellungen → Benachrichtigungen</strong> kannst du für jede Kategorie einzeln festlegen, ob du Benachrichtigungen erhalten möchtest.
</p>
<table class="hilfe-table">
<thead><tr><th>Kategorie</th><th>Beispiele</th></tr></thead>
<tbody>
<tr><td><span class="hilfe-badge">Spiele</span></td><td>Lock-Ablauf, neue Aufgabe, Keyholder-Aktion</td></tr>
<tr><td><span class="hilfe-badge">Community</span></td><td>Neue Follower, Likes, Kommentare, Gruppen-Einladungen</td></tr>
<tr><td><span class="hilfe-badge">Nachrichten</span></td><td>Neue Direktnachricht</td></tr>
<tr><td><span class="hilfe-badge">System</span></td><td>Sicherheitshinweise, Account-Aktivitäten</td></tr>
</tbody>
</table>
</div>
</div>
<div class="hilfe-section" id="sec-passwort">
<div class="hilfe-section-header" onclick="toggleSection('sec-passwort')">
<span class="hilfe-section-title">🔑 Passwort ändern</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>So änderst du dein Passwort:</p>
<ol class="hilfe-steps">
<li><span class="step-num">1</span><span>Navigiere zu <strong>Einstellungen → Konto → Passwort ändern</strong>.</span></li>
<li><span class="step-num">2</span><span>Gib dein aktuelles Passwort ein.</span></li>
<li><span class="step-num">3</span><span>Gib dein neues Passwort ein und bestätige es.</span></li>
<li><span class="step-num">4</span><span>Klicke auf <em>Speichern</em>. Du wirst automatisch neu angemeldet.</span></li>
</ol>
<div class="hilfe-hint">
<strong>Passwort vergessen?</strong> Auf der Login-Seite findest du den Link <em>Passwort vergessen</em>. Du erhältst dann eine E-Mail mit einem Zurücksetzen-Link.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-datenschutz">
<div class="hilfe-section-header" onclick="toggleSection('sec-datenschutz')">
<span class="hilfe-section-title">🛡️ Datenschutz-Einstellungen</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Unter <strong>Einstellungen → Datenschutz</strong> kannst du steuern, welche Daten sichtbar sind und wie andere mit dir interagieren können.
</p>
<table class="hilfe-table">
<thead><tr><th>Einstellung</th><th>Beschreibung</th></tr></thead>
<tbody>
<tr><td><span class="hilfe-badge">Profil-Sichtbarkeit</span></td><td>Öffentlich, nur Freunde, oder privat.</td></tr>
<tr><td><span class="hilfe-badge">Nachrichten empfangen</span></td><td>Von allen, nur Freunden, oder niemanden.</td></tr>
<tr><td><span class="hilfe-badge">Aktivitäts-Status</span></td><td>Zeige anderen, wann du zuletzt aktiv warst.</td></tr>
<tr><td><span class="hilfe-badge">Im Dating sichtbar</span></td><td>Ob dein Profil in der Dating-Suche erscheint.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="hilfe-section" id="sec-faq1">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq1')">
<span class="hilfe-section-title">❓ Wie kann ich meinen Account löschen?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Eine Account-Löschung kannst du über <strong>Einstellungen → Konto → Account löschen</strong> beantragen. Nach der Bestätigung werden alle deine Daten innerhalb von 30 Tagen endgültig gelöscht.
</p>
<div class="hilfe-warn">
<strong>Achtung:</strong> Aktive Locks und laufende Sessions werden beim Löschen sofort beendet. Diese Aktion ist nicht rückgängig zu machen.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-faq2">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq2')">
<span class="hilfe-section-title">❓ Ich wurde nicht aktiviert was tun?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Nach der Registrierung erhältst du eine Aktivierungs-E-Mail. Prüfe zuerst deinen Spam-Ordner. Ist die E-Mail nicht auffindbar, kannst du auf der Login-Seite unter <em>Aktivierungsmail erneut senden</em> eine neue anfordern.
</p>
<div class="hilfe-info">
Aktivierungslinks sind 24 Stunden gültig. Danach muss ein neuer Link angefordert werden.
</div>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
function toggleSection(id) {
document.getElementById(id).classList.toggle('open');
}
function openFromHash() {
const hash = window.location.hash.slice(1);
if (!hash) return;
const el = document.getElementById(hash);
if (el && el.classList.contains('hilfe-section')) {
el.classList.add('open');
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
}
}
document.addEventListener('DOMContentLoaded', openFromHash);
window.addEventListener('hashchange', openFromHash);
</script>
</body>
</html>

View File

@@ -107,9 +107,10 @@
<div class="hilfe-card-title">⚙️ Allgemeine Einstellungen</div>
<div class="hilfe-card-desc">Profil, Benachrichtigungen, Datenschutz und weitere Kontoeinstellungen.</div>
<div class="hilfe-card-links">
<a href="#">Profil bearbeiten</a>
<a href="#">Benachrichtigungen konfigurieren</a>
<a href="#">Passwort ändern</a>
<a href="/help/konto.html#sec-profil">Profil bearbeiten</a>
<a href="/help/konto.html#sec-benachrichtigungen">Benachrichtigungen konfigurieren</a>
<a href="/help/konto.html#sec-passwort">Passwort ändern</a>
<a href="/help/konto.html#sec-datenschutz">Datenschutz-Einstellungen</a>
</div>
</div>
<div class="hilfe-card">
@@ -127,8 +128,9 @@
<div class="hilfe-card-title">💳 Abonnements</div>
<div class="hilfe-card-desc">Informationen zu Premium-Funktionen und wie du dein Abonnement verwaltest.</div>
<div class="hilfe-card-links">
<a href="#">Premium-Funktionen im Überblick</a>
<a href="#">Abonnement kündigen</a>
<a href="/help/abonnements.html#sec-vergleich">Premium-Funktionen im Überblick</a>
<a href="/help/abonnements.html#sec-verwalten">Abonnement verwalten</a>
<a href="/help/abonnements.html#sec-faq1">Was passiert nach einer Kündigung?</a>
</div>
</div>
</div>
@@ -140,25 +142,28 @@
<div class="hilfe-card-title">🔒 Chastity Game</div>
<div class="hilfe-card-desc">Alles rund um Schlösser, Keyholder, Karten und Aufgaben im Chastity Game.</div>
<div class="hilfe-card-links">
<a href="#">Neues Lock starten</a>
<a href="#">Die Rolle als Keyholder</a>
<a href="#">Karten und Aufgaben</a>
<a href="#">TimeLock erklärt</a>
<a href="/help/chastity.html#sec-newlock">Neues Lock starten</a>
<a href="/help/chastity.html#sec-keyholder">Die Rolle als Keyholder</a>
<a href="/help/chastity.html#sec-karten">Karten und Aufgaben</a>
<a href="/help/chastity.html#sec-timelock">TimeLock erklärt</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">⛓️ BDSM Game</div>
<div class="hilfe-card-desc">Sessions erstellen, Spieler einladen und Aufgaben verwalten.</div>
<div class="hilfe-card-links">
<a href="#">Session starten</a>
<a href="#">Spieler einladen</a>
<a href="/help/bdsm.html#sec-session">Session starten</a>
<a href="/help/bdsm.html#sec-einladen">Spieler einladen</a>
<a href="/help/bdsm.html#sec-aufgaben">Aufgaben verwalten</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">⚪ Vanilla Game</div>
<div class="hilfe-card-desc">Leichtere Spiele ohne strenge Regeln für den entspannten Einstieg.</div>
<div class="hilfe-card-links">
<a href="#">Vanilla-Session starten</a>
<a href="/help/vanilla.html#sec-session">Vanilla-Session starten</a>
<a href="/help/vanilla.html#sec-karten">Karten und Aufgaben</a>
<a href="/help/vanilla.html#sec-faq2">Unterschied zu BDSM Game</a>
</div>
</div>
</div>
@@ -170,25 +175,24 @@
<div class="hilfe-card-title">👥 Gruppen</div>
<div class="hilfe-card-desc">Gruppen erstellen, beitreten und verwalten.</div>
<div class="hilfe-card-links">
<a href="#">Gruppe erstellen</a>
<a href="#">Mitglieder verwalten</a>
<a href="#">Beiträge und Abstimmungen</a>
<a href="/help/community.html#sec-gruppe-erstellen">Gruppe erstellen</a>
<a href="/help/community.html#sec-mitglieder">Mitglieder verwalten</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">📰 Feed &amp; Profil</div>
<div class="hilfe-card-desc">Beiträge teilen, Profile entdecken und die Community kennenlernen.</div>
<div class="hilfe-card-links">
<a href="#">Feed nutzen</a>
<a href="#">Profil gestalten</a>
<a href="#">Personen suchen und folgen</a>
<a href="/help/community.html#sec-feed">Feed nutzen</a>
<a href="/help/community.html#sec-profil">Profil gestalten &amp; folgen</a>
<a href="/help/community.html#sec-nachrichten">Nachrichten</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">🏆 Community Votes</div>
<div class="hilfe-card-desc">Verifikationen bewerten und an Community-Abstimmungen teilnehmen.</div>
<div class="hilfe-card-links">
<a href="#">Wie funktionieren Votes?</a>
<a href="/help/community.html#sec-votes">Wie funktionieren Votes?</a>
</div>
</div>
</div>
@@ -200,15 +204,16 @@
<div class="hilfe-card-title">🔐 Sicherheit &amp; Datenschutz</div>
<div class="hilfe-card-desc">Wie deine Daten gespeichert werden und welche Sicherheitsmaßnahmen wir treffen.</div>
<div class="hilfe-card-links">
<a href="#">Datenspeicherung</a>
<a href="#">Passwort-Hashing</a>
<a href="/help/datenschutz.html#sec-gespeicherte-daten">Was wird gespeichert?</a>
<a href="/help/datenschutz.html#sec-passwort-hashing">Passwort-Hashing</a>
<a href="/help/datenschutz.html#sec-verbindung">Verbindungssicherheit</a>
</div>
</div>
<div class="hilfe-card">
<div class="hilfe-card-title">🐛 Fehler melden</div>
<div class="hilfe-card-desc">Hast du einen Fehler gefunden oder einen Verbesserungsvorschlag?</div>
<div class="hilfe-card-links">
<a href="#">Feedback senden</a>
<a href="/help/kontakt.html">Feedback &amp; Kontakt</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,163 @@
<!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>Hilfe Vanilla Game xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.hilfe-header { margin-bottom: 2rem; }
.hilfe-header h1 { font-size: 1.6rem; margin: 0 0 0.4rem 0; }
.hilfe-header p { color: var(--color-muted); font-size: 0.92rem; margin: 0; line-height: 1.6; }
.hilfe-section { background: var(--color-card); border: 1px solid var(--color-secondary); border-radius: 10px; margin-bottom: 0.75rem; overflow: hidden; }
.hilfe-section-header { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 1rem 1.25rem; cursor: pointer; user-select: none; transition: background 0.15s; }
.hilfe-section-header:hover { background: rgba(255,255,255,0.03); }
.hilfe-section-title { display: flex; align-items: center; gap: 0.6rem; font-size: 1rem; font-weight: 600; }
.hilfe-section-arrow { font-size: 0.75rem; color: var(--color-muted); transition: transform 0.2s; }
.hilfe-section.open .hilfe-section-arrow { transform: rotate(90deg); }
.hilfe-section-body { display: none; padding: 0 1.25rem 1.25rem; border-top: 1px solid var(--color-secondary); }
.hilfe-section.open .hilfe-section-body { display: block; }
.hilfe-section-body p { font-size: 0.9rem; color: var(--color-muted); line-height: 1.7; margin: 0.9rem 0 0; }
.hilfe-section-body p:first-child { margin-top: 1rem; }
.hilfe-steps { list-style: none; padding: 0; margin: 1rem 0 0; display: flex; flex-direction: column; gap: 0.75rem; }
.hilfe-steps li { list-style: none; display: flex; align-items: flex-start; gap: 0.85rem; font-size: 0.9rem; color: var(--color-muted); line-height: 1.6; }
.hilfe-steps li::before { display: none; }
.hilfe-steps li .step-num { flex-shrink: 0; width: 1.6rem; height: 1.6rem; border-radius: 50%; background: var(--color-primary); color: #fff; font-size: 0.75rem; font-weight: 700; display: flex; align-items: center; justify-content: center; margin-top: 0.1rem; }
.hilfe-hint { background: rgba(var(--color-primary-rgb, 120,80,200), 0.08); border-left: 3px solid var(--color-primary); border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-hint strong { color: var(--color-text); }
.hilfe-warn { background: rgba(231,76,60,0.08); border-left: 3px solid #e74c3c; border-radius: 0 8px 8px 0; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-warn strong { color: #e74c3c; }
.hilfe-info { background: var(--color-secondary); border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.88rem; color: var(--color-muted); line-height: 1.6; margin-top: 1rem; }
.hilfe-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; margin-top: 1rem; }
.hilfe-table th { text-align: left; color: var(--color-text); font-weight: 600; padding: 0.4rem 0.75rem 0.4rem 0; border-bottom: 1px solid var(--color-secondary); }
.hilfe-table td { color: var(--color-muted); padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); vertical-align: top; line-height: 1.5; }
.hilfe-table tr:last-child td { border-bottom: none; }
.hilfe-badge { display: inline-block; background: var(--color-secondary); border-radius: 4px; padding: 0.1rem 0.45rem; font-size: 0.78rem; font-weight: 600; color: var(--color-muted); vertical-align: middle; }
.back-link { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--color-muted); text-decoration: none; margin-bottom: 1.25rem; transition: color 0.15s; }
.back-link:hover { color: var(--color-text); }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<a href="/help/overview.html" class="back-link"> Zurück zur Hilfe-Übersicht</a>
<div class="hilfe-header">
<h1>⚪ Vanilla Game</h1>
<p>Leichtere, verspielte Sessions ohne strenge Regeln für den entspannten Einstieg.</p>
</div>
<div class="hilfe-section open" id="sec-intro">
<div class="hilfe-section-header" onclick="toggleSection('sec-intro')">
<span class="hilfe-section-title">📖 Was ist das Vanilla Game?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Das Vanilla Game ist der entspannte Einstieg in die Spielwelt von xXx Sphere. Es gibt keine festen Rollen und keine strikten Regeln stattdessen ziehen beide Parteien abwechselnd Karten und erfüllen lockere Aufgaben.
</p>
<p>
Das Spiel eignet sich besonders für Paare, die etwas Neues ausprobieren möchten, ohne sich auf ein intensiveres Regelwerk einzulassen.
</p>
<div class="hilfe-hint">
<strong>Tipp:</strong> Du kannst jederzeit eigene Aufgaben erstellen und den Schwierigkeitsgrad für jede Session selbst bestimmen.
</div>
</div>
</div>
<div class="hilfe-section" id="sec-session">
<div class="hilfe-section-header" onclick="toggleSection('sec-session')">
<span class="hilfe-section-title">🚀 Session starten</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>So startest du eine Vanilla-Session:</p>
<ol class="hilfe-steps">
<li><span class="step-num">1</span><span>Navigiere zu <strong>Vanilla → Neue Session</strong>.</span></li>
<li><span class="step-num">2</span><span>Wähle einen Aufgaben-Pool (eigene Aufgaben oder Community-Vorlagen).</span></li>
<li><span class="step-num">3</span><span>Lege fest, ob ihr abwechselnd zieht oder eine Person die Aufgaben stellt.</span></li>
<li><span class="step-num">4</span><span>Lade deinen Mitspieler per Nutzername oder Einladungslink ein.</span></li>
<li><span class="step-num">5</span><span>Starte die Session der erste Spieler zieht die erste Karte.</span></li>
</ol>
</div>
</div>
<div class="hilfe-section" id="sec-karten">
<div class="hilfe-section-header" onclick="toggleSection('sec-karten')">
<span class="hilfe-section-title">🃏 Karten und Aufgaben</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Im Vanilla Game werden Karten aus einem gemeinsam gewählten Pool gezogen. Jede Karte beschreibt eine Aufgabe, die von einer oder beiden Personen erfüllt wird. Nach Erfüllung zieht die andere Person.
</p>
<table class="hilfe-table">
<thead><tr><th>Aufgabentyp</th><th>Beschreibung</th></tr></thead>
<tbody>
<tr><td><span class="hilfe-badge">Solo</span></td><td>Nur die ziehende Person führt die Aufgabe aus.</td></tr>
<tr><td><span class="hilfe-badge">Gemeinsam</span></td><td>Beide Personen führen die Aufgabe zusammen aus.</td></tr>
<tr><td><span class="hilfe-badge">Wahl</span></td><td>Die ziehende Person entscheidet, wer die Aufgabe übernimmt.</td></tr>
</tbody>
</table>
<p>
Eigene Aufgaben kannst du unter <strong>Vanilla → Aufgaben</strong> verwalten.
</p>
</div>
</div>
<div class="hilfe-section" id="sec-faq1">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq1')">
<span class="hilfe-section-title">❓ Kann ich eine Session pausieren?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Ja. Eine laufende Session kann von beiden Spielern jederzeit pausiert werden. Sie bleibt für 24 Stunden gespeichert und kann danach fortgesetzt werden. Nach 24 Stunden Inaktivität wird die Session automatisch beendet.
</p>
</div>
</div>
<div class="hilfe-section" id="sec-faq2">
<div class="hilfe-section-header" onclick="toggleSection('sec-faq2')">
<span class="hilfe-section-title">❓ Unterschied zwischen Vanilla und BDSM Game?</span>
<span class="hilfe-section-arrow"></span>
</div>
<div class="hilfe-section-body">
<p>
Das Vanilla Game hat keine festen Rollen, kein Protokoll und keine Strafmechanismen. Es eignet sich als Einstieg oder für entspannte Abende. Das BDSM Game hat explizite Rollen (Dom/Sub), ein Aufgaben- und Strafprotokoll sowie striktere Regeln.
</p>
<div class="hilfe-info">
Du kannst beide Spiele unabhängig voneinander nutzen deine Aufgaben-Sets lassen sich zwischen den Spielen teilen.
</div>
</div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/nav.js"></script>
<script>
function toggleSection(id) {
document.getElementById(id).classList.toggle('open');
}
function openFromHash() {
const hash = window.location.hash.slice(1);
if (!hash) return;
const el = document.getElementById(hash);
if (el && el.classList.contains('hilfe-section')) {
el.classList.add('open');
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
}
}
document.addEventListener('DOMContentLoaded', openFromHash);
window.addEventListener('hashchange', openFromHash);
</script>
</body>
</html>

View File

@@ -78,6 +78,30 @@ const CARD_DEFS = [
defMin: 0,
defMax: 0,
},
{
id: 'SLOWMO_CARD',
img: '/img/card_slowmo.png',
name: 'Slow Motion',
desc: 'Alle gestarteten Aktionen (Hygiene-Öffnung, Freeze, Kartenintervall) dauern bis zum gewählten Zeitpunkt viermal so lange.',
defMin: 0,
defMax: 0,
},
{
id: 'SPEEDUP_CARD',
img: '/img/card_speedup.png',
name: 'Speed Up',
desc: 'Alle gestarteten Aktionen (Hygiene-Öffnung, Freeze, Kartenintervall) dauern bis zum gewählten Zeitpunkt viermal so kurz.',
defMin: 0,
defMax: 0,
},
{
id: 'GAME_CARD',
img: '/img/card_game.png',
name: 'Spiel-Karte',
desc: 'Ein Minispiel wird gestartet.',
defMin: 0,
defMax: 0,
},
];
/** Lookup-Objekt für Konsumenten, die nach ID auf Name/Bild/Beschreibung zugreifen. */

View File

@@ -16,9 +16,9 @@
margin-right: 0.5rem;
line-height: 1;
}
.nav-burger:hover { border-color: var(--color-primary); color: var(--color-primary); }
.nav-burger:hover { border-color: var(--color-primary); color: #fff; }
.nav-burger-icon {
font-size: 1.05rem; line-height: 1;
font-size: 1.575rem; line-height: 1;
position: relative;
display: inline-flex; align-items: center; justify-content: center;
width: 1.2em; height: 1.2em;
@@ -91,16 +91,16 @@
}
.nav-col:last-child { border-right: none; }
/* Überschrift: auf Desktop ausgeblendet, auf Mobile als Accordion-Toggle */
.nav-col-header {
display: none;
display: flex;
align-items: center; justify-content: space-between;
padding: 0.75rem 1.1rem;
font-size: 0.85rem; font-weight: 600;
padding: 0.75rem 1.1rem 0.5rem;
font-size: 1.275rem; font-weight: 700;
color: var(--color-text);
cursor: pointer;
cursor: default;
border-bottom: 1px solid var(--color-secondary);
}
.nav-col-arrow { font-size: 0.65rem; transition: transform 0.2s; }
.nav-col-arrow { display: none; font-size: 0.65rem; transition: transform 0.2s; }
.nav-col-body { padding: 0.35rem 0; }
@@ -158,7 +158,8 @@
.nav-col { border-right: none; border-bottom: 1px solid var(--color-secondary); }
.nav-col:last-child { border-bottom: none; }
.nav-col-header { display: flex; }
.nav-col-header { font-size: 0.85rem; font-weight: 600; cursor: pointer; padding: 0.75rem 1.1rem; border-bottom: none; }
.nav-col-arrow { display: block; }
.nav-col.col-open .nav-col-arrow { transform: rotate(90deg); }
.nav-col-body { display: none; padding: 0; }
@@ -256,23 +257,18 @@
${link('/dating/matches.html', '', 'Matches' )}
`;
const bdsmActive = ['/games/bdsm/neubdsm.html', '/games/bdsm/bdsmingame.html', '/games/bdsm/bdsmplayers.html'].some(p => path.startsWith(p)) ? ' active' : '';
const vanillaActive = ['/games/vanilla/neuvanilla.html', '/games/vanilla/vanillaingame.html', '/games/vanilla/vanillawarten.html'].some(p => path.startsWith(p)) ? ' active' : '';
const col4Html = `
${gameGroup('VANILLA', 'Vanilla Game', [
{ href: '/games/vanilla/neuvanilla.html', icon: 'PLAY_NEW', label: 'Neue Session', id: 'navVanillaNeu' },
{ href: '#', icon: 'WAITING', label: 'Aktive Session', id: 'navVanillaAktiv' },
{ href: '/games/vanilla/vanillaingame.html', icon: 'PLAY_ACTIVE', label: 'Im Spiel', id: 'navVanillaImSpiel' },
{ href: '/games/vanilla/aufgaben.html', icon: 'CHECK', label: 'Aufgaben' },
{ href: '/games/vanilla/toys.html', icon: 'TOYS', label: 'Toys' },
{ href: '/games/vanilla/entdecken.html', icon: 'DISCOVER', label: 'Entdecken' },
])}
${gameGroup('BDSM', 'BDSM Game', [
{ href: '/games/bdsm/neubdsm.html', icon: 'PLAY_NEW', label: 'Neue Session', id: 'navBdsmNeu' },
{ href: '#', icon: 'WAITING', label: 'Aktive Session', id: 'navBdsmAktiv' },
{ href: '/games/bdsm/bdsmingame.html', icon: 'PLAY_ACTIVE', label: 'Im Spiel', id: 'navBdsmImSpiel' },
{ href: '/games/bdsm/aufgaben.html', icon: 'CHECK', label: 'Aufgaben' },
{ href: '/games/bdsm/toys.html', icon: 'TOYS', label: 'Toys' },
{ href: '/games/bdsm/entdecken.html', icon: 'DISCOVER', label: 'Entdecken' },
])}
<a href="/games/vanilla/neuvanilla.html" class="nav-link${vanillaActive}" id="navVanillaGame">
<span class="nav-icon">${I('VANILLA') || ''}</span>
<span>Vanilla Game</span>
</a>
<a href="/games/bdsm/neubdsm.html" class="nav-link${bdsmActive}" id="navBdsmGame">
<span class="nav-icon">${I('BDSM') || ''}</span>
<span>BDSM Game</span>
</a>
${gameGroup('CHASTITY', 'Chastity Game', [
{ href: '/games/chastity/neulock.html', icon: 'NEW_LOCK', label: 'Neues Lock', id: 'navChastityNeu' },
{ href: '#', icon: 'ACTIVE_LOCK', label: 'Aktives Lock', id: 'navChastityAktiv' },
@@ -283,6 +279,11 @@
{ href: '/games/chastity/keyholder.html', icon: 'KEY', label: 'Keyholder' },
{ href: '/games/chastity/unlock-history.html', icon: 'HISTORY', label: 'Code-Historie' },
])}
${gameGroup('CHECK', 'Aufgabenverwaltung', [
{ href: '/games/aufgaben/aufgaben.html', icon: 'CHECK', label: 'Aufgaben' },
{ href: '/games/aufgaben/toys.html', icon: 'TOYS', label: 'Toys' },
{ href: '/games/aufgaben/entdecken.html',icon: 'DISCOVER', label: 'Entdecken' },
])}
`;
// ── Dropdown-HTML ────────────────────────────────────────────────────────
@@ -306,7 +307,7 @@
])}
${column('colDating', 'Dating', col3Html, ['/dating/'])}
${column('colGames', 'Games', col4Html, [
'/games/vanilla/', '/games/bdsm/', '/games/chastity/',
'/games/vanilla/', '/games/bdsm/', '/games/chastity/', '/games/aufgaben/',
])}
</div>
<div class="nav-dropdown-footer">
@@ -417,21 +418,16 @@
const hide = id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; };
const show = id => { const el = document.getElementById(id); if (el) el.style.display = ''; };
const href = (id, h) => { const el = document.getElementById(id); if (el) el.href = h; };
hide('navVanillaAktiv'); hide('navVanillaImSpiel');
hide('navBdsmAktiv'); hide('navBdsmImSpiel');
hide('navChastityAktiv');
try {
const r = await fetch('/bdsm/einladung/meine-aktive');
if (r.ok) {
const aktiv = await r.json();
hide('navBdsmNeu'); hide('navBdsmImSpiel');
show('navBdsmAktiv');
href('navBdsmAktiv', aktiv.sessionId ? '/games/bdsm/bdsmingame.html' : '/games/bdsm/neubdsm.html');
href('navBdsmGame', aktiv.sessionId ? '/games/bdsm/bdsmingame.html' : '/games/bdsm/neubdsm.html');
} else {
const sr = await fetch(`/bdsm?userId=${user.userId}`);
if (sr.status === 200) { hide('navBdsmNeu'); show('navBdsmImSpiel'); }
else show('navBdsmNeu');
if (sr.status === 200) href('navBdsmGame', '/games/bdsm/bdsmingame.html');
}
} catch (_) {}
@@ -439,13 +435,10 @@
const r = await fetch('/vanilla/einladung/meine-aktive');
if (r.ok) {
const aktiv = await r.json();
hide('navVanillaNeu'); hide('navVanillaImSpiel');
show('navVanillaAktiv');
href('navVanillaAktiv', aktiv.sessionId ? '/games/vanilla/vanillaingame.html' : '/games/vanilla/neuvanilla.html');
href('navVanillaGame', aktiv.sessionId ? '/games/vanilla/vanillaingame.html' : '/games/vanilla/neuvanilla.html');
} else {
const sr = await fetch(`/vanilla?userId=${user.userId}`);
if (sr.status === 200) { hide('navVanillaNeu'); show('navVanillaImSpiel'); }
else show('navVanillaNeu');
if (sr.status === 200) href('navVanillaGame', '/games/vanilla/vanillaingame.html');
}
} catch (_) {}

View File

@@ -42,9 +42,6 @@
{ href: '/games/vanilla/neuvanilla.html', icon: 'PLAY_NEW', label: 'Neue Session', id: 'snavVanillaNeu' },
{ href: '#', icon: 'WAITING', label: 'Aktive Session', id: 'snavVanillaAktiv', hidden: true },
{ href: '/games/vanilla/vanillaingame.html', icon: 'PLAY_ACTIVE', label: 'Im Spiel', id: 'snavVanillaImSpiel', hidden: true },
{ href: '/games/vanilla/aufgaben.html', icon: 'CHECK', label: 'Aufgaben' },
{ href: '/games/vanilla/toys.html', icon: 'TOYS', label: 'Toys' },
{ href: '/games/vanilla/entdecken.html', icon: 'DISCOVER', label: 'Entdecken' },
],
},
bdsm: {
@@ -53,9 +50,14 @@
{ href: '/games/bdsm/neubdsm.html', icon: 'PLAY_NEW', label: 'Neue Session', id: 'snavBdsmNeu' },
{ href: '#', icon: 'WAITING', label: 'Aktive Session', id: 'snavBdsmAktiv', hidden: true },
{ href: '/games/bdsm/bdsmingame.html', icon: 'PLAY_ACTIVE', label: 'Im Spiel', id: 'snavBdsmImSpiel', hidden: true },
{ href: '/games/bdsm/aufgaben.html', icon: 'CHECK', label: 'Aufgaben' },
{ href: '/games/bdsm/toys.html', icon: 'TOYS', label: 'Toys' },
{ href: '/games/bdsm/entdecken.html', icon: 'DISCOVER', label: 'Entdecken' },
],
},
aufgaben: {
prefixes: ['/games/aufgaben/'],
items: [
{ href: '/games/aufgaben/aufgaben.html', icon: 'CHECK', label: 'Aufgaben' },
{ href: '/games/aufgaben/toys.html', icon: 'TOYS', label: 'Toys' },
{ href: '/games/aufgaben/entdecken.html',icon: 'DISCOVER', label: 'Entdecken' },
],
},
chastity: {

View File

@@ -3,30 +3,6 @@
const I = window.IC || function() { return ''; };
const groups = [
{
label: 'Vanilla Game',
icon: I('VANILLA'),
items: [
{ href: '/games/vanilla/neuvanilla.html', icon: I('PLAY_NEW'), label: 'Neue Session', id: 'navVanillaNeu' },
{ href: '#', icon: I('WAITING'), label: 'Aktive Session', id: 'navVanillaAktiv' },
{ href: '/games/vanilla/vanillaingame.html', icon: I('PLAY_ACTIVE'), label: 'Im Spiel', id: 'navVanillaImSpiel' },
{ href: '/games/vanilla/aufgaben.html', icon: I('CHECK'), label: 'Aufgaben' },
{ href: '/games/vanilla/toys.html', icon: I('TOYS'), label: 'Toys' },
{ href: '/games/vanilla/entdecken.html', icon: I('DISCOVER'), label: 'Entdecken' },
]
},
{
label: 'BDSM Game',
icon: I('BDSM'),
items: [
{ href: '/games/bdsm/neubdsm.html', icon: I('PLAY_NEW'), label: 'Neue Session', id: 'navBdsmNeu' },
{ href: '#', icon: I('WAITING'), label: 'Aktive Session', id: 'navBdsmAktiv' },
{ href: '/games/bdsm/bdsmingame.html', icon: I('PLAY_ACTIVE'), label: 'Im Spiel', id: 'navBdsmImSpiel' },
{ href: '/games/bdsm/aufgaben.html', icon: I('CHECK'), label: 'Aufgaben' },
{ href: '/games/bdsm/toys.html', icon: I('TOYS'), label: 'Toys' },
{ href: '/games/bdsm/entdecken.html', icon: I('DISCOVER'), label: 'Entdecken' },
]
},
{
label: 'Chastity Game',
icon: I('CHASTITY'),
@@ -41,8 +17,22 @@
{ href: '/games/chastity/unlock-history.html', icon: I('HISTORY'), label: 'Code-Historie' },
]
},
{
label: 'Aufgabenverwaltung',
icon: I('CHECK'),
items: [
{ href: '/games/aufgaben/aufgaben.html', icon: I('CHECK'), label: 'Aufgaben' },
{ href: '/games/aufgaben/toys.html', icon: I('TOYS'), label: 'Toys' },
{ href: '/games/aufgaben/entdecken.html',icon: I('DISCOVER'), label: 'Entdecken' },
]
},
];
const vanillaCls = path.startsWith('/games/vanilla/') ? ' class="active"' : '';
const bdsmCls = path.startsWith('/games/bdsm/') ? ' class="active"' : '';
const vanillaLink = `<li><a href="/games/vanilla/neuvanilla.html" id="navVanillaGame"${vanillaCls}><span class="icon">${I('VANILLA') || '⚪'}</span> Vanilla Game</a></li>`;
const bdsmLink = `<li><a href="/games/bdsm/neubdsm.html" id="navBdsmGame"${bdsmCls}><span class="icon">${I('BDSM') || '⛓️'}</span> BDSM Game</a></li>`;
// ── Hilfsfunktion: einzelner Nav-Link ──
function navLink({ href, icon, label, badgeId }) {
@@ -137,6 +127,8 @@
${sep}
${datingItem}
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>
${vanillaLink}
${bdsmLink}
${nav}
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;" id="navAdminDivider" style="display:none"></li>
${adminItem}
@@ -171,19 +163,9 @@
});
});
// "Im Spiel" und "Aktive Session" standardmäßig ausblenden; wird nach Session-Check ggf. wieder eingeblendet
const navNeu = document.getElementById('navBdsmNeu');
const navAktiv = document.getElementById('navBdsmAktiv');
const navImSpiel = document.getElementById('navBdsmImSpiel');
const navCAktiv = document.getElementById('navChastityAktiv');
const navVNeu = document.getElementById('navVanillaNeu');
const navVAktiv = document.getElementById('navVanillaAktiv');
const navVImSpiel = document.getElementById('navVanillaImSpiel');
if (navAktiv) navAktiv.style.display = 'none';
if (navImSpiel) navImSpiel.style.display = 'none';
if (navCAktiv) navCAktiv.style.display = 'none';
if (navVAktiv) navVAktiv.style.display = 'none';
if (navVImSpiel) navVImSpiel.style.display = 'none';
// "Aktives Lock" standardmäßig ausblenden; wird nach Session-Check ggf. wieder eingeblendet
const navCAktiv = document.getElementById('navChastityAktiv');
if (navCAktiv) navCAktiv.style.display = 'none';
// Session-Status prüfen
fetch('/login/me')
@@ -196,17 +178,14 @@
const aktivRes = await fetch('/bdsm/einladung/meine-aktive');
if (aktivRes.ok) {
const aktiv = await aktivRes.json();
if (navNeu) navNeu.style.display = 'none';
if (navImSpiel) navImSpiel.style.display = 'none';
if (navAktiv) {
navAktiv.style.display = '';
navAktiv.querySelector('a').href = aktiv.sessionId ? '/games/bdsm/bdsmingame.html' : '/games/bdsm/neubdsm.html';
}
const el = document.getElementById('navBdsmGame');
if (el) el.href = aktiv.sessionId ? '/games/bdsm/bdsmingame.html' : '/games/bdsm/neubdsm.html';
} else {
const sessionRes = await fetch(`/bdsm?userId=${user.userId}`);
const hasSession = sessionRes.status === 200;
if (navNeu) navNeu.style.display = hasSession ? 'none' : '';
if (navImSpiel) navImSpiel.style.display = hasSession ? '' : 'none';
if (sessionRes.status === 200) {
const el = document.getElementById('navBdsmGame');
if (el) el.href = '/games/bdsm/bdsmingame.html';
}
}
} catch (_) {}
@@ -215,17 +194,14 @@
const vAktivRes = await fetch('/vanilla/einladung/meine-aktive');
if (vAktivRes.ok) {
const vAktiv = await vAktivRes.json();
if (navVNeu) navVNeu.style.display = 'none';
if (navVImSpiel) navVImSpiel.style.display = 'none';
if (navVAktiv) {
navVAktiv.style.display = '';
navVAktiv.querySelector('a').href = vAktiv.sessionId ? '/games/vanilla/vanillaingame.html' : '/games/vanilla/neuvanilla.html';
}
const el = document.getElementById('navVanillaGame');
if (el) el.href = vAktiv.sessionId ? '/games/vanilla/vanillaingame.html' : '/games/vanilla/neuvanilla.html';
} else {
const vSessionRes = await fetch(`/vanilla?userId=${user.userId}`);
const vHasSession = vSessionRes.status === 200;
if (navVNeu) navVNeu.style.display = vHasSession ? 'none' : '';
if (navVImSpiel) navVImSpiel.style.display = vHasSession ? '' : 'none';
if (vSessionRes.status === 200) {
const el = document.getElementById('navVanillaGame');
if (el) el.href = '/games/vanilla/vanillaingame.html';
}
}
} catch (_) {}