Bugfixes im Vanilla game und refactoring

This commit is contained in:
2026-03-30 22:53:54 +02:00
parent 9d6506b54c
commit cb8daf7fad
45 changed files with 3122 additions and 2238 deletions

View File

@@ -1,5 +1,5 @@
#Sun Mar 29 16:28:09 CEST 2026
#Mon Mar 30 07:33:01 CEST 2026
display=\:0
host=mario-mint
process-id=3723
process-id=2955
user=mario

View File

@@ -1335,3 +1335,47 @@ java.io.IOException: Stream closed
!ENTRY org.springframework.tooling.boot.ls 1 0 2026-03-29 22:58:21.587
!MESSAGE DelegatingStreamConnectionProvider - Stopping Boot LS
!SESSION 2026-03-30 07:32:31.775 -----------------------------------------------
eclipse.buildId=4.39.0.20260305-0817
java.version=21.0.6
java.vendor=Eclipse Adoptium
BootLoader constants: OS=linux, ARCH=x86_64, WS=gtk, NL=de_DE
Framework arguments: -product org.eclipse.epp.package.java.product
Command-line arguments: -os linux -ws gtk -arch x86_64 -clean -product org.eclipse.epp.package.java.product
!ENTRY ch.qos.logback.classic 1 0 2026-03-30 07:32:34.763
!MESSAGE Activated before the state location was initialized. Retry after the state location is initialized.
!ENTRY ch.qos.logback.classic 1 0 2026-03-30 07:33:02.086
!MESSAGE Logback config file: /home/mario/Workspaces/xxx-thegame/.metadata/.plugins/org.eclipse.m2e.logback/logback.2.7.101.20251017-1242.xml
!ENTRY org.eclipse.ui 2 0 2026-03-30 07:33:02.235
!MESSAGE Warnings while parsing the commands from the 'org.eclipse.ui.commands' and 'org.eclipse.ui.actionDefinitions' extension points.
!SUBENTRY 1 org.eclipse.ui 2 0 2026-03-30 07:33:02.235
!MESSAGE Commands should really have a category: plug-in='org.springframework.tooling.boot.ls', id='spring.initializr.addStarters', categoryId='org.eclipse.lsp4e.commandCategory'
!ENTRY org.eclipse.ui 2 0 2026-03-30 07:33:02.395
!MESSAGE Warnings while parsing the commands from the 'org.eclipse.ui.commands' and 'org.eclipse.ui.actionDefinitions' extension points.
!SUBENTRY 1 org.eclipse.ui 2 0 2026-03-30 07:33:02.395
!MESSAGE Commands should really have a category: plug-in='org.springframework.tooling.boot.ls', id='spring.initializr.addStarters', categoryId='org.eclipse.lsp4e.commandCategory'
!ENTRY org.eclipse.jface 2 0 2026-03-30 22:51:45.208
!MESSAGE Keybinding conflicts occurred. They may interfere with normal accelerator operation.
!SUBENTRY 1 org.eclipse.jface 2 0 2026-03-30 22:51:45.208
!MESSAGE A conflict occurred for CTRL+SHIFT+T:
Binding(CTRL+SHIFT+T,
ParameterizedCommand(Command(org.eclipse.jdt.ui.navigate.open.type,Open Type,
Open a type in a Java editor,
Category(org.eclipse.ui.category.navigate,Navigate,null,true),
WorkbenchHandlerServiceHandler("org.eclipse.jdt.ui.navigate.open.type"),
,,true),null),
org.eclipse.ui.defaultAcceleratorConfiguration,
org.eclipse.ui.contexts.window,,,system)
Binding(CTRL+SHIFT+T,
ParameterizedCommand(Command(org.eclipse.lsp4e.symbolInWorkspace,Go to Symbol in Workspace,
,
Category(org.eclipse.lsp4e.category,Language Servers,null,true),
WorkbenchHandlerServiceHandler("org.eclipse.lsp4e.symbolInWorkspace"),
,,true),null),
org.eclipse.ui.defaultAcceleratorConfiguration,
org.eclipse.ui.contexts.window,,,system)

File diff suppressed because one or more lines are too long

View File

@@ -15,3 +15,4 @@
2026-03-26 16:50:11,098 [Worker-7: Loading available Gradle versions] INFO o.e.b.c.i.u.g.PublishedGradleVersions - Gradle version information cache is up-to-date. Trying to read.
2026-03-27 07:46:24,300 [Worker-7: Loading available Gradle versions] INFO o.e.b.c.i.u.g.PublishedGradleVersions - Gradle version information cache is up-to-date. Trying to read.
2026-03-29 16:28:13,219 [Worker-2: Loading available Gradle versions] INFO o.e.b.c.i.u.g.PublishedGradleVersions - Gradle version information cache is out-of-date. Trying to update.
2026-03-30 07:33:05,316 [Worker-5: Loading available Gradle versions] INFO o.e.b.c.i.u.g.PublishedGradleVersions - Gradle version information cache is up-to-date. Trying to read.

View File

@@ -1,3 +1,3 @@
#Sun Mar 29 16:28:09 CEST 2026
#Mon Mar 30 07:33:01 CEST 2026
org.eclipse.core.runtime=2
org.eclipse.platform=4.39.0.v20260226-0420

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

View File

@@ -254,6 +254,40 @@ public class AdminController {
return ResponseEntity.noContent().build();
}
// ── Item verschieben ─────────────────────────────────────────────────────
@PutMapping("/aufgabengruppen/items/{kind}/{itemId}/move")
public ResponseEntity<Void> moveItem(
@PathVariable("kind") String kind,
@PathVariable("itemId") UUID itemId,
@RequestParam("targetGruppeId") UUID targetGruppeId,
Principal principal) {
requireAdmin(principal);
AufgabenGruppeEntity targetGruppe = aufgabenGruppeRepository.findById(targetGruppeId)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND, "Zielgruppe nicht gefunden"));
switch (kind) {
case "aufgabe" -> aufgabeRepository.findById(itemId).ifPresent(e -> {
e.setAufgabenGruppe(targetGruppe);
aufgabeRepository.save(e);
});
case "strafe" -> strafeRepository.findById(itemId).ifPresent(e -> {
e.setAufgabenGruppe(targetGruppe);
strafeRepository.save(e);
});
case "zeitstrafe" -> sperreRepository.findById(itemId).ifPresent(e -> {
e.setAufgabenGruppe(targetGruppe);
sperreRepository.save(e);
});
case "finisher" -> finisherRepository.findById(itemId).ifPresent(e -> {
e.setAufgabenGruppe(targetGruppe);
finisherRepository.save(e);
});
default -> { return ResponseEntity.badRequest().build(); }
}
return ResponseEntity.noContent().build();
}
// ── Toys ─────────────────────────────────────────────────────────────────
@GetMapping("/toys")

View File

@@ -49,12 +49,10 @@ public class SecurityConfig {
.requestMatchers("/games/chastity/sessionchastity.html").authenticated()
.requestMatchers("/games/chastity/neulock.html").authenticated()
.requestMatchers("/games/chastity/activelock.html").authenticated()
.requestMatchers("/sessionbdsmtasks.html").authenticated()
.requestMatchers("/sessionbdsmtoys.html").authenticated()
.requestMatchers("/sessionbdsmingame.html").authenticated()
.requestMatchers("/games/bdsm/neubdsm.html").authenticated()
.requestMatchers("/games/bdsm/bdsmingame.html").authenticated()
.requestMatchers("/games/bdsm/bdsmwarten.html").authenticated()
.requestMatchers("/community/personen-suchen.html").authenticated()
.requestMatchers("/community/freunde.html").authenticated()
.requestMatchers("/community/nachrichten.html").authenticated()
@@ -69,7 +67,7 @@ public class SecurityConfig {
.requestMatchers("/games/chastity/meine-locks.html").authenticated()
.requestMatchers("/games/chastity/entdecken-vorlagen.html").authenticated()
.requestMatchers("/games/chastity/unlock-history.html").authenticated()
.requestMatchers("/community/einladungen.html").authenticated()
.requestMatchers("/games/common/einladungen.html").authenticated()
.requestMatchers("/games/chastity/joinlock.html").authenticated()
.requestMatchers("/community/benachrichtigungen.html").authenticated()
.requestMatchers("/community/abonnements.html").authenticated()

View File

@@ -23,7 +23,6 @@ import de.oaa.xxx.games.bdsm.entity.BdsmEinladungEntity;
import de.oaa.xxx.games.bdsm.entity.BdsmEinladungEntity.Status;
import de.oaa.xxx.games.bdsm.repository.BdsmEinladungRepository;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.social.entity.MessageCause;
import de.oaa.xxx.social.repository.FriendshipRepository;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
@@ -97,13 +96,7 @@ public class BdsmEinladungController {
entity.setCreatedAt(LocalDateTime.now());
einladungRepository.save(entity);
String inviterName = userRepository.findById(inviterId).map(u -> u.getName()).orElse("Jemand");
systemMessageService.send(
inviterId, req.inviteeId(),
inviterName + " hat dich zum BDSM Game eingeladen.",
"/community/einladungen.html",
MessageCause.INVITATION
);
systemMessageService.pushInvitationUpdate(req.inviteeId());
Map<String, Object> result = new LinkedHashMap<>();
result.put("einladungId", entity.getEinladungId());
@@ -118,10 +111,7 @@ public class BdsmEinladungController {
if (e == null) return ResponseEntity.notFound().build();
if (!e.getInviterId().equals(userId)) return ResponseEntity.status(403).build();
e.setStatus(Status.CANCELLED);
String inviterName = userRepository.findById(userId).map(u -> u.getName()).orElse("Jemand");
systemMessageService.send(userId, e.getInviteeId(),
inviterName + " hat die BDSM-Spieleinladung zurückgezogen.",
"/community/einladungen.html", MessageCause.INVITATION);
systemMessageService.pushInvitationUpdate(e.getInviteeId());
return ResponseEntity.accepted().build();
}

View File

@@ -189,10 +189,7 @@ public class CardLockController {
inv.setDetailsVisible(req.lockeeDetailsVisible());
lockeeInvitationRepository.save(inv);
String lockName = req.name() != null && !req.name().isBlank() ? req.name() : "Unbenanntes Lock";
sendMessage(myId, lockee.getUserId(),
me.getName() + " hat dich als Lockee für das Lock „" + lockName + "\" eingeladen.",
"/community/einladungen.html", de.oaa.xxx.social.entity.MessageCause.INVITATION);
systemMessageService.pushInvitationUpdate(lockee.getUserId());
return ResponseEntity.ok(Map.of("lockId", lock.getLockId().toString(), "lockeeInvitationSent", true));
}
@@ -261,10 +258,7 @@ public class CardLockController {
inv.setCreatedAt(now);
invitationRepository.save(inv);
String lockName = req.name() != null && !req.name().isBlank() ? req.name() : "Unbenanntes Lock";
sendMessage(me.getUserId(), kh.getUserId(),
me.getName() + " hat dich als Keyholder*In für das Lock „" + lockName + "\" eingeladen.",
"/community/einladungen.html", de.oaa.xxx.social.entity.MessageCause.INVITATION);
systemMessageService.pushInvitationUpdate(kh.getUserId());
keyholderPending = true;
}
@@ -772,10 +766,7 @@ public class CardLockController {
if (lockOpt.isPresent()) {
var lock = lockOpt.get();
String lockName = lock.getName() != null && !lock.getName().isBlank() ? lock.getName() : "Unbenanntes Lock";
sendMessage(myId, lock.getLockee(),
me.getName() + " hat die Einladung als Keyholder*In für das Lock „" + lockName + "\" abgelehnt.",
null, de.oaa.xxx.social.entity.MessageCause.INVITATION);
systemMessageService.pushInvitationUpdate(lock.getLockee());
}
return ResponseEntity.noContent().build();
@@ -828,12 +819,7 @@ public class CardLockController {
invitationRepository.delete(inv);
String lockName = lockOpt.get().getName() != null && !lockOpt.get().getName().isBlank()
? lockOpt.get().getName()
: "Unbenanntes Lock";
sendMessage(myId, inv.getKeyholderUserId(),
me.getName() + " hat die Keyholder-Einladung für das Lock „" + lockName + "\" zurückgezogen.", null,
de.oaa.xxx.social.entity.MessageCause.INVITATION);
systemMessageService.pushInvitationUpdate(inv.getKeyholderUserId());
return ResponseEntity.noContent().build();
}

View File

@@ -37,7 +37,6 @@ import de.oaa.xxx.games.chastity.timelock.TimeLockRepository;
import de.oaa.xxx.games.chastity.timelock.TimeLockServiceFactory;
import de.oaa.xxx.games.chastity.timelock.TimeLockTemplateEntity;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.social.entity.MessageCause;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
@@ -319,7 +318,6 @@ public class KeyholderOfferController {
return ResponseEntity.status(500).build();
}
String lockName = template.getName() != null ? template.getName() : "Unbenanntes Lock";
boolean invitationSent = false;
if (!directStart) {
// Normaler Einladungsworkflow: Keyholder muss bestätigen
@@ -331,17 +329,11 @@ public class KeyholderOfferController {
inv.setCreatedAt(LocalDateTime.now());
invitationRepository.save(inv);
systemMessageService.send(myId, offerer.getUserId(),
me.getName() + " möchte dein Keyholder-Angebot annehmen und lädt dich als Keyholder für „"
+ lockName + "\" ein.",
"/community/einladungen.html", MessageCause.INVITATION);
systemMessageService.pushInvitationUpdate(offerer.getUserId());
invitationSent = true;
} else {
// Direktstart: Keyholder wird direkt gesetzt, aber trotzdem benachrichtigen
systemMessageService.send(myId, offerer.getUserId(),
me.getName() + " hat dein Keyholder-Angebot angenommen und das Lock „"
+ lockName + "\" gestartet.",
"/games/chastity/keyholder.html", MessageCause.INVITATION);
// Direktstart: Keyholder wird direkt gesetzt
systemMessageService.pushInvitationUpdate(offerer.getUserId());
}
// Annahmezähler erhöhen

View File

@@ -65,10 +65,6 @@ public class LockeeInvitationController {
this.userService = userService;
}
private void sendMessage(UUID senderId, UUID receiverId, String text, String targetUrl, de.oaa.xxx.social.entity.MessageCause cause) {
systemMessageService.send(senderId, receiverId, text, targetUrl, cause);
}
private String generateUnlockCode(int lines) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < lines; i++) sb.append(RNG.nextInt(10));
@@ -165,10 +161,7 @@ public class LockeeInvitationController {
if (lockOpt.isPresent()) {
var lock = lockOpt.get();
baseLockRepository.delete(lock);
String lockName = lock.getName() != null && !lock.getName().isBlank() ? lock.getName() : "Unbenanntes Lock";
sendMessage(myId, inv.getLockeeUserId(),
me.getName() + " hat die Lockee-Einladung für das Lock „" + lockName + "\" zurückgezogen.",
null, de.oaa.xxx.social.entity.MessageCause.INVITATION);
systemMessageService.pushInvitationUpdate(inv.getLockeeUserId());
}
return ResponseEntity.noContent().build();
@@ -245,7 +238,6 @@ public class LockeeInvitationController {
LocalDateTime now = LocalDateTime.now();
String unlockCode;
String lockName;
if (lock instanceof CardLockEntity cardLock) {
unlockCode = generateUnlockCode(codeLines);
@@ -259,7 +251,6 @@ public class LockeeInvitationController {
cardLock.setLastHygineOpening(now);
}
cardlockRepository.save(cardLock);
lockName = cardLock.getName() != null && !cardLock.getName().isBlank() ? cardLock.getName() : "Unbenanntes Lock";
} else if (lock instanceof TimeLockEntity timeLock) {
unlockCode = CodeCreator.createNumeric(codeLines);
int unlockMinutes = randomBetween(timeLock.getMinTimeInMinutes(), timeLock.getMaxTimeInMinutes());
@@ -271,16 +262,13 @@ public class LockeeInvitationController {
timeLock.setLastHygineOpening(now);
}
timeLockRepository.save(timeLock);
lockName = timeLock.getName() != null && !timeLock.getName().isBlank() ? timeLock.getName() : "Unbenanntes Lock";
} else {
return ResponseEntity.status(500).build();
}
lockeeInvitationRepository.delete(inv);
sendMessage(myId, inv.getKeyholderUserId(),
me.getName() + " hat die Einladung als Lockee für das Lock „" + lockName + "\" angenommen.",
"/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.INVITATION);
systemMessageService.pushInvitationUpdate(inv.getKeyholderUserId());
return ResponseEntity.ok(Map.of(
"lockId", lock.getLockId().toString(),
@@ -305,10 +293,7 @@ public class LockeeInvitationController {
if (lockOpt.isPresent()) {
var lock = lockOpt.get();
baseLockRepository.delete(lock);
String lockName = lock.getName() != null && !lock.getName().isBlank() ? lock.getName() : "Unbenanntes Lock";
sendMessage(myId, inv.getKeyholderUserId(),
me.getName() + " hat die Einladung als Lockee für das Lock „" + lockName + "\" abgelehnt.",
null, de.oaa.xxx.social.entity.MessageCause.INVITATION);
systemMessageService.pushInvitationUpdate(inv.getKeyholderUserId());
}
return ResponseEntity.noContent().build();

View File

@@ -140,10 +140,7 @@ public class TimeLockController {
inv.setDetailsVisible(req.lockeeDetailsVisible());
lockeeInvitationRepository.save(inv);
String lockName = template.getName() != null ? template.getName() : "Unbenanntes Lock";
systemMessageService.send(myId, lockee.getUserId(),
me.getName() + " hat dich als Lockee für das Lock „" + lockName + "\" eingeladen.",
"/community/einladungen.html", de.oaa.xxx.social.entity.MessageCause.INVITATION);
systemMessageService.pushInvitationUpdate(lockee.getUserId());
return ResponseEntity.ok(Map.of(
"lockId", lock.getLockId().toString(),
@@ -178,10 +175,7 @@ public class TimeLockController {
inv.setCreatedAt(LocalDateTime.now());
invitationRepository.save(inv);
String lockName = template.getName() != null ? template.getName() : "Unbenanntes Lock";
systemMessageService.send(myId, kh.getUserId(),
me.getName() + " hat dich als Keyholder*In für das Lock „" + lockName + "\" eingeladen.",
"/community/einladungen.html", de.oaa.xxx.social.entity.MessageCause.INVITATION);
systemMessageService.pushInvitationUpdate(kh.getUserId());
keyholderPending = true;
}

View File

@@ -3,6 +3,7 @@ package de.oaa.xxx.games.common.repository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -37,4 +38,10 @@ public interface AufgabenGruppeRepository extends JpaRepository<AufgabenGruppeEn
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.privateGruppe = false AND g.userId IS NOT NULL AND g.userId <> :userId AND g.strafen IS EMPTY AND g.sperren IS EMPTY AND (:name IS NULL OR LOWER(g.name) LIKE LOWER(:name))")
List<AufgabenGruppeEntity> findVanillaSafePublicFromOthers(@Param("userId") UUID userId, @Param("name") String name);
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId = :userId AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)")
Page<AufgabenGruppeEntity> findByUserIdWithContent(@Param("userId") UUID userId, Pageable pageable);
@Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId IS NULL AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)")
Page<AufgabenGruppeEntity> findSystemGroupsWithContent(Pageable pageable);
}

View File

@@ -1,6 +1,10 @@
package de.oaa.xxx.games.common.repository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.entity.GruppenAboEntity;
@@ -19,4 +23,8 @@ public interface GruppenAboRepository extends JpaRepository<GruppenAboEntity, UU
long countByAufgabenGruppe(AufgabenGruppeEntity gruppe);
void deleteByAufgabenGruppe(AufgabenGruppeEntity gruppe);
@Query(value = "SELECT a FROM GruppenAboEntity a JOIN a.aufgabenGruppe g WHERE a.userId = :userId AND g.privateGruppe = false AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)",
countQuery = "SELECT COUNT(a) FROM GruppenAboEntity a JOIN a.aufgabenGruppe g WHERE a.userId = :userId AND g.privateGruppe = false AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)")
Page<GruppenAboEntity> findByUserIdWithContent(@Param("userId") UUID userId, Pageable pageable);
}

View File

@@ -1,13 +1,14 @@
package de.oaa.xxx.games.vanilla.controller;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppePage;
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;
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.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;
@@ -18,13 +19,14 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.Principal;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppePage;
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;
@RestController
@RequestMapping("/vanilla/abo")
@@ -56,15 +58,17 @@ public class VanillaAboController {
Principal principal) {
UserEntity user = userService.requireUser(principal);
List<AufgabenGruppe> dtos = aboRepository.findByUserId(user.getUserId()).stream()
.map(GruppenAboEntity::getAufgabenGruppe)
.filter(g -> !g.isPrivateGruppe())
.filter(g -> g.getStrafen().isEmpty() && g.getSperren().isEmpty())
.map(g -> enrich(g, user.getUserId(), true))
.sorted(Comparator.comparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER))
Page<GruppenAboEntity> dbPage = aboRepository.findByUserIdWithContent(
user.getUserId(), PageRequest.of(page, size, Sort.by("aufgabenGruppe.name")));
List<AufgabenGruppe> dtos = dbPage.getContent().stream()
.map(a -> enrich(a.getAufgabenGruppe(), user.getUserId(), true))
.toList();
return ResponseEntity.ok(manualPage(dtos, page, size));
AufgabenGruppePage result = new AufgabenGruppePage();
result.setContent(dtos);
result.setCurrentPage(dbPage.getNumber());
result.setTotalPages(dbPage.getTotalPages());
result.setTotalElements(dbPage.getTotalElements());
return ResponseEntity.ok(result);
}
// ── Entdecken (nur vanilla-safe Gruppen von anderen) ──
@@ -82,11 +86,19 @@ public class VanillaAboController {
List<AufgabenGruppe> dtos = gruppeRepository
.findVanillaSafePublicFromOthers(user.getUserId(), namePattern).stream()
.map(g -> enrich(g, user.getUserId(), aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), g)))
.sorted(Comparator.comparingLong(AufgabenGruppe::getSubscriberCount).reversed()
.sorted(java.util.Comparator.comparingLong(AufgabenGruppe::getSubscriberCount).reversed()
.thenComparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER))
.toList();
return ResponseEntity.ok(manualPage(dtos, page, size));
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) ──
@@ -138,16 +150,5 @@ public class VanillaAboController {
return g;
}
private AufgabenGruppePage manualPage(List<AufgabenGruppe> all, int page, int size) {
int total = all.size();
int start = page * size;
List<AufgabenGruppe> content = start >= total ? List.of() : all.subList(start, Math.min(start + size, total));
AufgabenGruppePage result = new AufgabenGruppePage();
result.setContent(content);
result.setCurrentPage(page);
result.setTotalPages(total == 0 ? 1 : (int) Math.ceil((double) total / size));
result.setTotalElements(total);
return result;
}
}

View File

@@ -84,18 +84,17 @@ public class VanillaAufgabenGruppeController {
Principal principal) {
UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build();
// Only vanilla-safe user groups (no Strafen, no Sperren)
UUID userId = user.getUserId();
String searchPattern = null;
java.util.List<AufgabenGruppeEntity> all = gruppeRepository.listVanillaSafeWithUserAndSearch(
userId, searchPattern, PageRequest.of(0, 500, Sort.by("name")));
java.util.List<AufgabenGruppeEntity> ownOnly = all.stream()
.filter(g -> userId.equals(g.getUserId())).toList();
AufgabenGruppePage result = manualPage(ownOnly.stream().map(entity -> {
Page<AufgabenGruppeEntity> dbPage = gruppeRepository.findByUserIdWithContent(
user.getUserId(), PageRequest.of(page, size, Sort.by("name")));
AufgabenGruppePage result = new AufgabenGruppePage();
result.setContent(dbPage.getContent().stream().map(entity -> {
AufgabenGruppe g = entity.toAufgabenGruppe();
g.setSubscriberCount(aboRepository.countByAufgabenGruppe(entity));
return g;
}).toList(), page, DEFAULT_PAGE_SIZE);
}).toList());
result.setCurrentPage(dbPage.getNumber());
result.setTotalPages(dbPage.getTotalPages());
result.setTotalElements(dbPage.getTotalElements());
return ResponseEntity.ok(result);
}
@@ -103,16 +102,14 @@ public class VanillaAufgabenGruppeController {
public ResponseEntity<AufgabenGruppePage> listSystem(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size) {
// Only vanilla-safe system groups (userId IS NULL → no strafen/sperren anyway, but filter for safety)
Page<AufgabenGruppeEntity> result = gruppeRepository.findByUserIdIsNull(
Page<AufgabenGruppeEntity> dbPage = gruppeRepository.findSystemGroupsWithContent(
PageRequest.of(page, size, Sort.by("name")));
AufgabenGruppePage r = new AufgabenGruppePage();
r.setContent(result.getContent().stream()
.filter(g -> g.getStrafen().isEmpty() && g.getSperren().isEmpty())
r.setContent(dbPage.getContent().stream()
.map(AufgabenGruppeEntity::toAufgabenGruppe).toList());
r.setCurrentPage(result.getNumber());
r.setTotalPages(result.getTotalPages());
r.setTotalElements(result.getTotalElements());
r.setCurrentPage(dbPage.getNumber());
r.setTotalPages(dbPage.getTotalPages());
r.setTotalElements(dbPage.getTotalElements());
return ResponseEntity.ok(r);
}
@@ -270,15 +267,4 @@ public class VanillaAufgabenGruppeController {
return userService.requireUser(principal);
}
private AufgabenGruppePage manualPage(java.util.List<AufgabenGruppe> all, int page, int size) {
int total = all.size();
int start = page * size;
java.util.List<AufgabenGruppe> content = start >= total ? java.util.List.of() : all.subList(start, Math.min(start + size, total));
AufgabenGruppePage result = new AufgabenGruppePage();
result.setContent(content);
result.setCurrentPage(page);
result.setTotalPages(total == 0 ? 1 : (int) Math.ceil((double) total / size));
result.setTotalElements(total);
return result;
}
}

View File

@@ -23,7 +23,6 @@ import de.oaa.xxx.games.vanilla.entity.VanillaEinladungEntity;
import de.oaa.xxx.games.vanilla.entity.VanillaEinladungEntity.Status;
import de.oaa.xxx.games.vanilla.repository.VanillaEinladungRepository;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.social.entity.MessageCause;
import de.oaa.xxx.social.repository.FriendshipRepository;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
@@ -106,13 +105,7 @@ public class VanillaEinladungController {
entity.setCreatedAt(LocalDateTime.now());
einladungRepository.save(entity);
String inviterName = userRepository.findById(inviterId).map(u -> u.getName()).orElse("Jemand");
systemMessageService.send(
inviterId, req.inviteeId(),
inviterName + " hat dich zum Vanilla Game eingeladen.",
"/community/einladungen.html",
MessageCause.INVITATION
);
systemMessageService.pushInvitationUpdate(req.inviteeId());
Map<String, Object> result = new LinkedHashMap<>();
result.put("einladungId", entity.getEinladungId());
@@ -127,10 +120,7 @@ public class VanillaEinladungController {
if (e == null) return ResponseEntity.notFound().build();
if (!e.getInviterId().equals(userId)) return ResponseEntity.status(403).build();
e.setStatus(Status.CANCELLED);
String inviterName = userRepository.findById(userId).map(u -> u.getName()).orElse("Jemand");
systemMessageService.send(userId, e.getInviteeId(),
inviterName + " hat die Vanilla-Spieleinladung zurückgezogen.",
"/community/einladungen.html", MessageCause.INVITATION);
systemMessageService.pushInvitationUpdate(e.getInviteeId());
return ResponseEntity.accepted().build();
}

View File

@@ -57,8 +57,8 @@ public class SystemMessageService {
.findByUserIdAndCause(receiverId, cause)
.orElseGet(() -> NotificationPreferenceEntity.defaultFor(receiverId, cause));
// FRIENDREQUEST ist immer in-app, unabhängig von der Einstellung
boolean sendInApp = cause == MessageCause.FRIENDREQUEST || pref.isInApp();
// FRIENDREQUEST und INVITATION sind immer nur in-app, kein E-Mail
boolean sendInApp = cause == MessageCause.FRIENDREQUEST || cause == MessageCause.INVITATION || pref.isInApp();
if (sendInApp) {
MessageEntity msg = new MessageEntity();
@@ -76,7 +76,7 @@ public class SystemMessageService {
sseService.push(receiverId, "NOTIFICATION", Map.of("unreadCount", unread, "text", text));
}
if (pref.isEmail()) {
if (pref.isEmail() && cause != MessageCause.INVITATION) {
userRepository.findById(receiverId).ifPresent(user -> {
try {
Email email = new Email();
@@ -91,6 +91,15 @@ public class SystemMessageService {
}
}
/**
* Benachrichtigt den Empfänger per SSE, dass sich seine Einladungsliste geändert hat,
* ohne eine In-App-Nachricht oder E-Mail zu erstellen.
*/
public void pushInvitationUpdate(UUID receiverId) {
if (receiverId == null) return;
sseService.push(receiverId, "INVITATION", java.util.Map.of());
}
private String causeTitel(MessageCause cause) {
return switch (cause) {
case INVITATION -> "XXX The Game Neue Einladung";

View File

@@ -0,0 +1,588 @@
-- ============================================================
-- Testdaten: Aufgabengruppen (generiert aus DefaultFiller)
-- Toys und *Toy-Join-Tabellen werden ignoriert.
-- UUID-Speicherung: varchar(36) als plain UUID-String
-- Spaltennamen: SpringPhysicalNamingStrategy → snake_case
-- ============================================================
SET NAMES utf8mb4;
-- ── Aufgabengruppen ──────────────────────────────────────────
INSERT IGNORE INTO aufgaben_gruppe (gruppen_id, name, beschreibung, user_id, private_gruppe, bild, von) VALUES
('10000000-0000-0000-0000-000000000001', 'Keuschhaltung weiblich', 'Enthält verschiedene Aufgaben für Keuschhaltung von weiblichen Spielpartnern', NULL, 0, NULL, NULL),
('10000000-0000-0000-0000-000000000002', 'Keuschhaltung männlich', 'Enthält verschiedene Aufgaben für Keuschhaltung von männlichen Spielpartnern', NULL, 0, NULL, NULL),
('10000000-0000-0000-0000-000000000003', 'Plugs', 'Enthält verschiedene Aufgaben für das Tragen von Buttplugs über einen gewissen Zeitraum.', NULL, 0, NULL, NULL),
('10000000-0000-0000-0000-000000000004', 'Knebel', 'Enthält verschiedene Aufgaben für das Tragen von Knebeln über einen gewissen Zeitraum.', NULL, 0, NULL, NULL),
('10000000-0000-0000-0000-000000000005', 'Strafen', 'Enthält verschiedene Bestrafungen', NULL, 0, NULL, NULL),
('10000000-0000-0000-0000-000000000006', 'Aufgaben', 'Enthält verschiedene Sex-Aufgaben.', NULL, 0, NULL, NULL);
-- ── Sperren ──────────────────────────────────────────────────
-- Gruppe: Keuschhaltung weiblich
INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES
('20000000-0000-0000-0000-000000000001', 'Voll-KG',
'{PASSIV} trägt fortan einen Voll-KG, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien',
10, 30, '10000000-0000-0000-0000-000000000001'),
('20000000-0000-0000-0000-000000000002', 'Voll-KG + Vaginaldildo',
'{PASSIV} trägt fortan einen Voll-KG mit Vaginaldildo, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien',
10, 30, '10000000-0000-0000-0000-000000000001'),
('20000000-0000-0000-0000-000000000003', 'Voll-KG + Analdildo',
'{PASSIV} trägt fortan einen Voll-KG mit Analdildo, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien',
10, 30, '10000000-0000-0000-0000-000000000001'),
('20000000-0000-0000-0000-000000000004', 'Voll-KG + Doubleplugged',
'{PASSIV} trägt fortan einen Voll-KG mit Vaginal- und Analdildo, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien',
10, 30, '10000000-0000-0000-0000-000000000001');
-- Gruppe: Keuschhaltung männlich
INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES
('20000000-0000-0000-0000-000000000005', 'Peniskäfig',
'{PASSIV} trägt fortan einen Peniskäfig, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von seinem Peniskäfig zu befreien',
10, 30, '10000000-0000-0000-0000-000000000002'),
('20000000-0000-0000-0000-000000000006', 'Voll-KG',
'{PASSIV} trägt fortan einen Voll-KG, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von seinem KG zu befreien',
10, 30, '10000000-0000-0000-0000-000000000002'),
('20000000-0000-0000-0000-000000000007', 'Voll-KG + Analdildo',
'{PASSIV} trägt fortan einen Voll-KG mit Analdildo, {AKTIV} ist der Keyholder',
'{AKTIV}, es ist ab der Zeit {PASSIV} von seinem KG zu befreien',
10, 30, '10000000-0000-0000-0000-000000000002');
-- Gruppe: Plugs
INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES
('20000000-0000-0000-0000-000000000008', 'Plug klein',
'{AKTIV} führt {PASSIV} einen kleinen Buttplug in anal ein, dieser ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien',
10, 30, '10000000-0000-0000-0000-000000000003'),
('20000000-0000-0000-0000-000000000009', 'Plug mittel',
'{AKTIV} führt {PASSIV} einen mittelgroßen Buttplug anal ein, dieser ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien',
10, 30, '10000000-0000-0000-0000-000000000003'),
('20000000-0000-0000-0000-000000000010', 'Plug groß',
'{AKTIV} führt {PASSIV} einen großen Buttplug anal ein, dieser ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien',
10, 30, '10000000-0000-0000-0000-000000000003'),
('20000000-0000-0000-0000-000000000011', 'Elektro-Plug anal',
'{AKTIV} führt {PASSIV} einen Elekro-Plug anal ein, dieser ist bis auf weiteres zu tragen. {AKTIV} darf {PASSIV} leichte Stromstöße verpassen',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien',
10, 30, '10000000-0000-0000-0000-000000000003'),
('20000000-0000-0000-0000-000000000012', 'Elektro-Plug vaginal',
'{AKTIV} führt {PASSIV} einen Elekto-Plug vaginal ein, dieser ist bis auf weiteres zu tragen. {AKTIV} darf {PASSIV} leichte Stromstöße verpassen',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien',
10, 30, '10000000-0000-0000-0000-000000000003');
-- Gruppe: Knebel
INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES
('20000000-0000-0000-0000-000000000013', 'Ballknebel',
'{AKTIV}, lege {PASSIV} einen Ballknebel an, dieser ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.',
10, 30, '10000000-0000-0000-0000-000000000004'),
('20000000-0000-0000-0000-000000000014', 'Penisknebel',
'{AKTIV}, lege {PASSIV} einen Dildoknebel an, dieser ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.',
10, 30, '10000000-0000-0000-0000-000000000004'),
('20000000-0000-0000-0000-000000000015', 'Aufblasbarer Knebel',
'{AKTIV}, lege {PASSIV} einen aufblasbaren Knebel an und pumpe diesen soweit auf, dass {PASSIV} noch halbwegs gut atmen kann, dieser ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.',
5, 15, '10000000-0000-0000-0000-000000000004'),
('20000000-0000-0000-0000-000000000016', 'Isolationsmaske',
'{AKTIV}, lege {PASSIV} eine Isolationsmaske an, diese ist bis auf weiteres zu tragen.',
'{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.',
5, 15, '10000000-0000-0000-0000-000000000004');
-- sperre_sperre_fuer (war @CollectionTable name="sperre_sperreFuer" → snake_case)
INSERT IGNORE INTO sperre_sperre_fuer (sperre_id, werkzeug) VALUES
('20000000-0000-0000-0000-000000000001', 'VAGINA'),
('20000000-0000-0000-0000-000000000002', 'VAGINA'),
('20000000-0000-0000-0000-000000000003', 'VAGINA'),
('20000000-0000-0000-0000-000000000003', 'ANUS'),
('20000000-0000-0000-0000-000000000004', 'VAGINA'),
('20000000-0000-0000-0000-000000000004', 'ANUS'),
('20000000-0000-0000-0000-000000000005', 'PENIS'),
('20000000-0000-0000-0000-000000000006', 'PENIS'),
('20000000-0000-0000-0000-000000000007', 'PENIS'),
('20000000-0000-0000-0000-000000000007', 'ANUS'),
('20000000-0000-0000-0000-000000000008', 'ANUS'),
('20000000-0000-0000-0000-000000000009', 'ANUS'),
('20000000-0000-0000-0000-000000000010', 'ANUS'),
('20000000-0000-0000-0000-000000000011', 'ANUS'),
('20000000-0000-0000-0000-000000000012', 'VAGINA'),
('20000000-0000-0000-0000-000000000013', 'MUND'),
('20000000-0000-0000-0000-000000000014', 'MUND'),
('20000000-0000-0000-0000-000000000015', 'MUND'),
('20000000-0000-0000-0000-000000000016', 'MUND');
-- ── Strafen ──────────────────────────────────────────────────
INSERT IGNORE INTO strafe (strafe_id, kurz_text, text, level, sekunden_von, sekunden_bis, gruppe_id) VALUES
('30000000-0000-0000-0000-000000000001', '5 Schläge mit flachen Hand',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der flachen Hand auf das Gesäß.',
1, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000002', '15 Schläge mit flachen Hand',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der flachen Hand auf das Gesäß, {PASSIV} zählt laut mit',
3, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000003', '5 Schläge mit Gerte',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Gerte auf das Gesäß.',
2, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000004', '15 Schläge mit Gerte',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der Gerte auf das Gesäß, {PASSIV} zählt laut mit',
4, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000005', '5 Schläge mit Paddel',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit dem Paddel auf das Gesäß.',
2, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000006', '15 Schläge mit Paddel',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit dem Paddel auf das Gesäß, {PASSIV} zählt laut mit',
4, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000007', '5 Schläge mit Peitsche',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Peitsche auf das Gesäß.',
3, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000008', '15 Schläge mit Peitsche',
'{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der Peitsche auf das Gesäß, {PASSIV} zählt laut mit',
5, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000009', 'Schläge auf Klitoris mit Hand',
'{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Hand auf die Klitoris, {PASSIV} zählt laut mit',
4, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000010', 'Schläge auf Klitoris mit Peitsche',
'{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Peitsche auf die Klitoris, {PASSIV} zählt laut mit',
5, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000011', 'Schläge auf Klitoris mit Paddel',
'{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit dem Paddel auf die Klitoris, {PASSIV} zählt laut mit',
5, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000012', 'Schläge auf Klitoris mit Gerte',
'{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Gerte auf die Klitoris, {PASSIV} zählt laut mit',
5, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000013', '5 Ohrfeigen',
'{PASSIV} stellt sich mit dem Rücken zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Ohrfeigen, {PASSIV} zählt laut mit',
5, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000014', 'Elektroplug anal',
'{AKTIV} führt {PASSIV} anal einen Elektro-Plug ein. {AKTIV} erhöht ganz langsam die Intensität bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an',
5, 30, 90, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000015', 'Elektroplug vaginal',
'{AKTIV} führt {PASSIV} vaginal einen Elektro-Plug ein. {AKTIV} erhöht ganz langsam die Intensität bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an',
5, 30, 90, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000016', 'Pumpplug anal',
'{AKTIV} führt {PASSIV} anal einen Pump-Plug ein. {AKTIV} pumpt ganz langsam auf bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an',
5, 30, 90, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000017', 'Pumpplug vaginal',
'{AKTIV} führt {PASSIV} vaginal einen Pump-Plug ein. {AKTIV} pumpt ganz langsam auf bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an',
5, 30, 90, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000018', 'Facesitting (Vagina)',
'{PASSIV} liegt auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Vaginal und/oder Analbereich verwöhnen',
2, 90, 180, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000019', 'Facesitting gefesselt (Vagina)',
'{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Vaginal und/oder Analbereich verwöhnen',
4, 90, 180, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000020', 'Facesitting (Penis)',
'{PASSIV} liegt auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Penis und/oder Analbereich verwöhnen',
2, 90, 180, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000021', 'Facesitting gefesselt (Penis)',
'{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Penis und/oder Analbereich verwöhnen',
4, 90, 180, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000022', 'Facesitting Doppelpenisknebel',
'{PASSIV} liegt auf dem Rücken, {AKTIV} legt {PASSIV} einen Doppel-Penisknebel an und reitet diesen vaginal oder anal',
3, 60, 120, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000023', 'Facesitting Doppelpenisknebel gefesselt',
'{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} legt {PASSIV} einen Doppel-Penisknebel an und reitet diesen vaginal oder anal',
3, 60, 120, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000024', 'Nippelklemmen',
'{AKTIV} legt {PASSIV} Nippelklemmen an, {AKTIV} zieht an der Kette und erhöht ganz langsam die Intensität bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an',
3, 30, 90, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000025', 'Nippelbehandlung',
'{AKTIV} nimmt die Nippel von {PASSIV} zwischen die Finger und erhöht langsam den Druck bis {PASSIV} ''STOP'' sagt',
2, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000026', 'Hilflos liegen lassen',
'{AKTIV} fesselt, knebelt und verbindet die Augen von {PASSIV}. {AKTIV} lässt {PASSIV} wehrlos liegen, bei Ablauf der Zeit erlöst {AKTIV} {PASSIV} mit einem beherzten Platsch auf den Po',
4, 300, 600, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000027', 'Strapon reiten',
'{PASSIV} liegt auf dem Rücken und trägt dabei einen Umschnalldildo. {AKTIV} reitet den Umschnalldildo von {PASSIV}',
3, 60, 180, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000028', 'Strapon reiten gefesselt',
'{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo. {AKTIV} reitet den Umschnalldildo von {PASSIV}',
4, 60, 180, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000029', 'Teaseblowjob mit dem Strapon',
'{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo, KG und einen großen Buttplug. {AKTIV} gibt dem Umschnalldildo einen Blowjob in 69er Position und präsentiert {PASSIV} dabei den Intimbereich',
5, 180, 300, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000030', 'Teasereiten mit Strapon',
'{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo, KG und einen großen Buttplug. {AKTIV} reitet den Umschnalldildo von {PASSIV}.',
5, 180, 300, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000031', 'Tease mit Selbstbefriedigung (Mann KG)',
'{AKTIV} knebelt und fesselt {PASSIV} an einen Stuhl. {PASSIV} trägt dabei einen KG und einen großen Buttplug. {AKTIV} befriedigt sich dann vor den Augen von {PASSIV} selber',
4, 240, 360, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000032', 'Tease mit Selbstbefriedigung (Frau KG)',
'{AKTIV} knebelt und fesselt {PASSIV} an einen Stuhl. {PASSIV} trägt dabei einen KG und einen großen Buttplug. {AKTIV} befriedigt sich dann vor den Augen von {PASSIV} selber',
4, 240, 360, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000033', 'Blowjob auf allen vieren',
'{AKTIV}, zwinge {PASSIV} vor dir auf die Knie, führe dein Glied (oder Strap on) in den Mund von {PASSIV} ein und zeig mit einem Deepthroat, wer das sagen hat',
5, 30, 90, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000034', 'Oralsex mit kleinem Dildo in der Vagina',
'{PASSIV}, geh auf die Knie und reite vaginal einen kleinen Dildo, befriedige dabei {AKTIV} oral.',
2, 60, 120, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000035', 'Oralsex mit großen Dildo in der Vagina',
'{PASSIV}, geh auf die Knie und reite vaginal einen großen Dildo, befriedige dabei {AKTIV} oral.',
4, 60, 120, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000036', 'Oralsex mit kleinem Dildo im Anus',
'{PASSIV}, geh auf die Knie und reite anal einen kleinen Dildo, befriedige dabei {AKTIV} oral.',
3, 60, 120, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000037', 'Oralsex mit großen Dildo im Anus',
'{PASSIV}, geh auf die Knie und reite anal einen großen Dildo, befriedige dabei {AKTIV} oral.',
4, 60, 120, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000038', 'Vagina dehnen',
'{PASSIV} geht auf alle viere und streckt den Hintern schön in die Luft, {AKTIV} führe langsam nach und nach mehr Finger in die Vagina von {PASSIV} ein, bis {PASSIV} ''STOP'' sagt',
2, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000039', 'Anus dehnen',
'{PASSIV} geht auf alle viere und streckt den Hintern schön in die Luft, {AKTIV} führe langsam nach und nach mehr Finger in die Anus von {PASSIV} ein, bis {PASSIV} ''STOP'' sagt',
2, NULL, NULL, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000040', 'Vaginalsex in Missionarstellung und Breathplay',
'{AKTIV} dringt in Missionarsstellung in {PASSIV} und gibt vollgas, dabei packt {AKTIV} {PASSIV} am Hals und drückt beherzt zu',
4, 30, 60, '10000000-0000-0000-0000-000000000005'),
('30000000-0000-0000-0000-000000000041', 'Analsex in Missionarstellung und Breathplay',
'{AKTIV} dringt in Missionarsstellung anal in {PASSIV} und gibt vollgas, dabei packt {AKTIV} {PASSIV} am Hals und drückt beherzt zu',
4, 30, 60, '10000000-0000-0000-0000-000000000005');
-- strafe_benoetigt_passiv (war @CollectionTable name="strafe_benoetigtPassiv")
INSERT IGNORE INTO strafe_benoetigt_passiv (strafe_id, werkzeug) VALUES
('30000000-0000-0000-0000-000000000009', 'VAGINA'),
('30000000-0000-0000-0000-000000000010', 'VAGINA'),
('30000000-0000-0000-0000-000000000011', 'VAGINA'),
('30000000-0000-0000-0000-000000000012', 'VAGINA'),
('30000000-0000-0000-0000-000000000014', 'ANUS'),
('30000000-0000-0000-0000-000000000015', 'VAGINA'),
('30000000-0000-0000-0000-000000000016', 'ANUS'),
('30000000-0000-0000-0000-000000000017', 'VAGINA'),
('30000000-0000-0000-0000-000000000018', 'MUND'),
('30000000-0000-0000-0000-000000000019', 'MUND'),
('30000000-0000-0000-0000-000000000020', 'MUND'),
('30000000-0000-0000-0000-000000000021', 'MUND'),
('30000000-0000-0000-0000-000000000022', 'MUND'),
('30000000-0000-0000-0000-000000000023', 'MUND'),
('30000000-0000-0000-0000-000000000033', 'MUND'),
('30000000-0000-0000-0000-000000000034', 'VAGINA'),
('30000000-0000-0000-0000-000000000035', 'VAGINA'),
('30000000-0000-0000-0000-000000000036', 'ANUS'),
('30000000-0000-0000-0000-000000000037', 'ANUS'),
('30000000-0000-0000-0000-000000000038', 'VAGINA'),
('30000000-0000-0000-0000-000000000039', 'ANUS'),
('30000000-0000-0000-0000-000000000040', 'VAGINA'),
('30000000-0000-0000-0000-000000000041', 'ANUS');
-- strafe_benoetigt_aktiv (war @CollectionTable name="strafe_benoetigtAktiv")
INSERT IGNORE INTO strafe_benoetigt_aktiv (strafe_id, werkzeug) VALUES
('30000000-0000-0000-0000-000000000018', 'VAGINA'),
('30000000-0000-0000-0000-000000000018', 'ANUS'),
('30000000-0000-0000-0000-000000000019', 'VAGINA'),
('30000000-0000-0000-0000-000000000019', 'ANUS'),
('30000000-0000-0000-0000-000000000020', 'PENIS'),
('30000000-0000-0000-0000-000000000020', 'ANUS'),
('30000000-0000-0000-0000-000000000021', 'VAGINA'),
('30000000-0000-0000-0000-000000000021', 'PENIS'),
('30000000-0000-0000-0000-000000000022', 'VAGINA'),
('30000000-0000-0000-0000-000000000023', 'VAGINA'),
('30000000-0000-0000-0000-000000000027', 'VAGINA'),
('30000000-0000-0000-0000-000000000027', 'ANUS'),
('30000000-0000-0000-0000-000000000028', 'VAGINA'),
('30000000-0000-0000-0000-000000000028', 'ANUS'),
('30000000-0000-0000-0000-000000000029', 'VAGINA'),
('30000000-0000-0000-0000-000000000030', 'VAGINA'),
('30000000-0000-0000-0000-000000000031', 'VAGINA'),
('30000000-0000-0000-0000-000000000032', 'PENIS'),
('30000000-0000-0000-0000-000000000033', 'PENIS'),
('30000000-0000-0000-0000-000000000033', 'UMSCHNALLDILDO'),
('30000000-0000-0000-0000-000000000034', 'VAGINA'),
('30000000-0000-0000-0000-000000000034', 'PENIS'),
('30000000-0000-0000-0000-000000000035', 'VAGINA'),
('30000000-0000-0000-0000-000000000035', 'PENIS'),
('30000000-0000-0000-0000-000000000036', 'VAGINA'),
('30000000-0000-0000-0000-000000000036', 'PENIS'),
('30000000-0000-0000-0000-000000000037', 'VAGINA'),
('30000000-0000-0000-0000-000000000037', 'PENIS'),
('30000000-0000-0000-0000-000000000040', 'PENIS'),
('30000000-0000-0000-0000-000000000040', 'UMSCHNALLDILDO'),
('30000000-0000-0000-0000-000000000041', 'PENIS'),
('30000000-0000-0000-0000-000000000041', 'UMSCHNALLDILDO');
-- ── Aufgaben ─────────────────────────────────────────────────
INSERT IGNORE INTO aufgabe (aufgabe_id, kurz_text, text, level, sekunden_von, sekunden_bis, gruppe_id) VALUES
('40000000-0000-0000-0000-000000000001', 'Hintern präsentieren',
'{AKTIV}, zeig {PASSIV} deinen Hintern, gib dir selber dabei ein oder zwei Klappse auf den Po',
1, NULL, NULL, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000002', 'Hals küssen',
'{AKTIV}, küsse den Hals von {PASSIV} leidenschaftlich',
1, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000003', 'Bauchnabel küssen',
'{AKTIV}, zeichne mit Küssen den Bauchnabel von {PASSIV} nach',
1, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000004', 'Ohren knabbern',
'{AKTIV}, knabber leidenschaftlich an den Ohrläppchen von {PASSIV}',
1, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000005', 'Berühren ohne anfassen',
'{AKTIV}, berühre den gesamten Körper von {PASSIV} ohne die Hände zu verwenden',
2, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000006', 'Nacken küssen',
'{PASSIV} sitzt vor {AKTIV}, {AKTIV} küsste leidenschaftlich den Nacken von {PASSIV}',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000007', 'Brust küssen',
'{AKTIV}, küsse die Brust von {PASSIV} ohne die Nippel zu berühren',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000008', 'Nippel verwöhnen',
'{AKTIV}, verwöhne die Nippel von {PASSIV} mit Küssen',
2, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000009', 'Hintern küssen',
'{AKTIV}, küsse den Hintern von {PASSIV} ohne den Anus zu berühren',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000010', 'Intimkuss durch Unterwäsche',
'{AKTIV}, küsse den Intimbereich von {PASSIV} durch die Unterwäsche',
2, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000011', 'Brustmassage',
'{AKTIV}, massiere die Brust von {PASSIV} leidenschaftlich',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000012', 'Hinternmassage',
'{AKTIV}, massiere den Hintern von {PASSIV} leidenschaftlich',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000013', 'Rückenmassage',
'{AKTIV}, massiere den Rücken von {PASSIV} leidenschaftlich',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000014', 'Oberschenkelmassage',
'{AKTIV}, massiere die Oberschenkel von {PASSIV} leidenschaftlich',
1, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000015', 'Klitoris mit Vibrator verwöhnen',
'{AKTIV}, verwöhne die Klitoris von {PASSIV} mit einem Vibrator',
3, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000016', 'Cunnilingus und Finger in Vagina',
'{AKTIV}, verwöhne die Klitoris von {PASSIV} mit dem Mund, führe dabei einen bis zwei Finger in die Vagina von {PASSIV} ein',
3, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000017', 'Klitoris mit Fingern verwöhnen und Finger in Vagina',
'{AKTIV}, verwöhne die Klitoris von {PASSIV} mit der Hand, führe dabei einen bis zwei Finger in die Vagina von {PASSIV} ein',
4, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000018', 'Eichel mit Vibrator verwöhnen',
'{AKTIV}, verwöhne die Eichel von {PASSIV} mit einem Vibrator',
3, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000019', 'Felatio',
'{AKTIV}, verwöhne die Eichel von {PASSIV} mit dem Mund',
3, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000020', 'Handjob',
'{AKTIV}, verwöhne die Eichel von {PASSIV} mit der Hand',
3, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000021', 'Facesitting',
'{AKTIV} liegt auf dem Rücken, {PASSIV} sitzt auf seinem Gesicht. {AKTIV}, verwöhne die Vagina von {PASSIV} mit dem Mund',
4, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000022', '69er-Position',
'69er-Zeit: {AKTIV} liegt oben. {PASSIV}, falls du verschlossen bist, ziehe einen Strap on an, damit {AKTIV} auch was zu tun hat.',
4, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000023', 'Kleiner Dildo vaginal',
'{AKTIV}, führe {PASSIV} einen kleinen Dildo vaginal ein und verwöhne {PASSIV} durch langsame Bewegungen mit selbigem',
3, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000024', 'Großer Dildo vaginal',
'{AKTIV}, führe {PASSIV} einen großen Dildo vaginal ein und verwöhne {PASSIV} durch langsame Bewegungen mit selbigem',
4, 30, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000025', 'Großer Dildo vaginal schnell',
'{AKTIV}, führe {PASSIV} einen großen Dildo vaginal ein und bewege selbigen möglichst schnell rein und raus',
5, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000026', 'Missionarstellung langsam',
'{AKTIV} dringt in Missionarstellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen',
3, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000027', 'Missionarstellung schnell',
'{AKTIV} dringt in Missionarstellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen',
4, 30, 90, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000028', 'Missionarstellung Vollgas',
'{AKTIV} dringt in Missionarstellung in {PASSIV} ein und gibt vollgas',
5, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000029', 'Reiterstellung langsam',
'{PASSIV} setzt sich in Reiterstellung auf {AKTIV}. {PASSIV} bestimmt das Tempo',
3, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000030', 'Reiterstellung schnell',
'{PASSIV} setzt sich in Reiterstellung auf {AKTIV}. {PASSIV} versucht das Tempo hoch zu halten',
4, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000031', 'Reiterstellung vollgas',
'{PASSIV} setzt sich in Reiterstellung auf {AKTIV} und gibt vollgas',
5, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000032', 'Doggystyle langsam',
'{AKTIV} dringt in Hundestellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen',
3, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000033', 'Doggystyle schnell',
'{AKTIV} dringt in Hundestellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen',
4, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000034', 'Doggystyle vollgas',
'{AKTIV} dringt in Hundestellung in {PASSIV} ein und gibt vollgas',
5, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000035', 'Doggystyle vollgas keinen Mucks',
'{AKTIV} dringt in Hundestellung in {PASSIV} ein und gibt vollgas. {PASSIV} darf dabei keinen Laut von sich geben.',
5, 30, 60, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000036', 'Doggystyle Tempo bestimmt die ''gefickte'' Person',
'{AKTIV} dringt in Hundestellung in {PASSIV} ein. {AKTIV} hält still und {PASSIV} gibt das Tempo vor',
3, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000037', 'Löffelchen langsam',
'{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen',
3, 60, 180, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000038', 'Löffelchen schnell',
'{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen',
4, 60, 120, '10000000-0000-0000-0000-000000000006'),
('40000000-0000-0000-0000-000000000039', 'Löffelchen vollgas',
'{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und gibt vollgas',
5, 30, 60, '10000000-0000-0000-0000-000000000006');
-- aufgabe_benoetigt_aktiv (war @CollectionTable name="aufgabe_benoetigtAktiv")
INSERT IGNORE INTO aufgabe_benoetigt_aktiv (aufgabe_id, werkzeug) VALUES
('40000000-0000-0000-0000-000000000002', 'MUND'),
('40000000-0000-0000-0000-000000000003', 'MUND'),
('40000000-0000-0000-0000-000000000004', 'MUND'),
('40000000-0000-0000-0000-000000000006', 'MUND'),
('40000000-0000-0000-0000-000000000007', 'MUND'),
('40000000-0000-0000-0000-000000000008', 'MUND'),
('40000000-0000-0000-0000-000000000009', 'MUND'),
('40000000-0000-0000-0000-000000000010', 'MUND'),
('40000000-0000-0000-0000-000000000016', 'MUND'),
('40000000-0000-0000-0000-000000000019', 'MUND'),
('40000000-0000-0000-0000-000000000021', 'MUND'),
('40000000-0000-0000-0000-000000000022', 'VAGINA'),
('40000000-0000-0000-0000-000000000022', 'MUND'),
('40000000-0000-0000-0000-000000000026', 'PENIS'),
('40000000-0000-0000-0000-000000000026', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000027', 'PENIS'),
('40000000-0000-0000-0000-000000000027', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000028', 'PENIS'),
('40000000-0000-0000-0000-000000000028', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000029', 'PENIS'),
('40000000-0000-0000-0000-000000000029', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000030', 'PENIS'),
('40000000-0000-0000-0000-000000000030', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000031', 'PENIS'),
('40000000-0000-0000-0000-000000000031', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000032', 'PENIS'),
('40000000-0000-0000-0000-000000000032', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000033', 'PENIS'),
('40000000-0000-0000-0000-000000000033', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000034', 'PENIS'),
('40000000-0000-0000-0000-000000000034', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000035', 'PENIS'),
('40000000-0000-0000-0000-000000000035', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000036', 'PENIS'),
('40000000-0000-0000-0000-000000000036', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000037', 'PENIS'),
('40000000-0000-0000-0000-000000000037', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000038', 'PENIS'),
('40000000-0000-0000-0000-000000000038', 'UMSCHNALLDILDO'),
('40000000-0000-0000-0000-000000000039', 'PENIS'),
('40000000-0000-0000-0000-000000000039', 'UMSCHNALLDILDO');
-- aufgabe_benoetigt_passiv (war @CollectionTable name="aufgabe_benoetigtPassiv")
INSERT IGNORE INTO aufgabe_benoetigt_passiv (aufgabe_id, werkzeug) VALUES
('40000000-0000-0000-0000-000000000015', 'VAGINA'),
('40000000-0000-0000-0000-000000000016', 'VAGINA'),
('40000000-0000-0000-0000-000000000017', 'VAGINA'),
('40000000-0000-0000-0000-000000000018', 'PENIS'),
('40000000-0000-0000-0000-000000000019', 'PENIS'),
('40000000-0000-0000-0000-000000000020', 'PENIS'),
('40000000-0000-0000-0000-000000000021', 'VAGINA'),
('40000000-0000-0000-0000-000000000022', 'MUND'),
('40000000-0000-0000-0000-000000000023', 'VAGINA'),
('40000000-0000-0000-0000-000000000024', 'VAGINA'),
('40000000-0000-0000-0000-000000000025', 'VAGINA'),
('40000000-0000-0000-0000-000000000026', 'VAGINA'),
('40000000-0000-0000-0000-000000000027', 'VAGINA'),
('40000000-0000-0000-0000-000000000028', 'VAGINA'),
('40000000-0000-0000-0000-000000000029', 'VAGINA'),
('40000000-0000-0000-0000-000000000030', 'VAGINA'),
('40000000-0000-0000-0000-000000000031', 'VAGINA'),
('40000000-0000-0000-0000-000000000032', 'VAGINA'),
('40000000-0000-0000-0000-000000000033', 'VAGINA'),
('40000000-0000-0000-0000-000000000034', 'VAGINA'),
('40000000-0000-0000-0000-000000000035', 'VAGINA'),
('40000000-0000-0000-0000-000000000036', 'VAGINA'),
('40000000-0000-0000-0000-000000000037', 'VAGINA'),
('40000000-0000-0000-0000-000000000038', 'VAGINA'),
('40000000-0000-0000-0000-000000000039', 'VAGINA');

View File

@@ -194,6 +194,15 @@
font-size:0.78rem; color:var(--color-muted); line-height:1.6; }
.placeholder-hint code { background:rgba(233,69,96,0.12); color:var(--color-primary);
border-radius:3px; padding:0.05rem 0.3rem; font-size:0.75rem; }
#iTextAC { position:fixed; z-index:9999; background:var(--color-surface,#1e1e2e);
border:1px solid var(--color-border,#444); border-radius:6px;
box-shadow:0 4px 14px rgba(0,0,0,.5); display:none; overflow:hidden; min-width:180px; max-height:280px; overflow-y:auto; }
.ac-item { padding:0.45rem 0.9rem; cursor:pointer; font-size:0.88rem;
font-family:monospace; color:var(--color-text,#cdd6f4); user-select:none; }
.ac-item:hover, .ac-item-active { background:var(--color-primary,#cba6f7); color:#1e1e2e; }
.ac-separator { padding:0.2rem 0.7rem; font-size:0.72rem; color:var(--color-muted);
text-transform:uppercase; letter-spacing:0.05em; background:rgba(255,255,255,0.04);
pointer-events:none; border-top:1px solid rgba(136,136,136,0.2); margin-top:2px; }
.modal-two-col { display:flex; gap:0.75rem; }
.modal-two-col > * { flex:1; }
.werkzeug-checks { display:flex; flex-wrap:nowrap; gap:0.25rem; margin-top:0.5rem; }
@@ -315,6 +324,7 @@
</div>
</div>
<textarea id="iText" rows="4" maxlength="4000" placeholder="Ausführliche Beschreibung…"></textarea>
<div id="iTextAC"></div>
<div id="iGeschlechtRow">
<label>Geschlecht der Person die kommt *</label>
@@ -430,6 +440,23 @@
</div>
</div>
<!-- Verschieben-Modal -->
<div class="modal-backdrop" id="moveModal">
<div class="modal" style="max-width:400px;">
<h2>In andere Gruppe verschieben</h2>
<p id="moveModalItemName" style="font-size:0.9rem;color:var(--color-muted);margin:0 0 1rem 0;"></p>
<div class="form-group">
<label for="moveModalSelect">Zielgruppe</label>
<select id="moveModalSelect" class="form-control" style="height:2.6rem;font-size:1rem;"></select>
</div>
<p id="moveModalError" style="color:var(--color-danger);font-size:0.85rem;min-height:1.2em;margin:0.5rem 0 0 0;"></p>
<div class="modal-actions">
<button class="btn-cancel" id="moveModalCancel">Abbrechen</button>
<button class="btn-save" id="moveModalOk">Verschieben</button>
</div>
</div>
</div>
<div class="main">
<div class="content">
@@ -926,7 +953,7 @@ function renderAdminGruppen(gruppen) {
${renderSubSection('Aufgaben', sortByLevelThenName(g.aufgaben || []), 'aufgabe', renderAufgabe, g.gruppenId)}
${renderSubSection('Strafen', sortByLevelThenName(g.strafen || []), 'strafe', renderStrafe, g.gruppenId)}
${renderSubSection('Zeitstrafen', sortByName(g.sperren || []), 'zeitstrafe', renderZeitstrafe, g.gruppenId)}
${renderSubSection('Finisher', sortByName(g.finisher || []), 'finisher', renderFinisher, g.gruppenId)}
${renderSubSection('Finisher', sortByGeschlecht(g.finisher || []), 'finisher', renderFinisher, g.gruppenId)}
</div>
</div>`;
}).join('');
@@ -1010,6 +1037,7 @@ function itemCard(id, kurzText, badges, rows, kind, gruppenId) {
const actionBtns = `<div class="item-action-btns">
<button class="btn-item-edit" onclick="openEditItemModal('${esc(id)}',event)">✎ Bearbeiten</button>
<button class="btn-item-edit" onclick="duplicateItem('${kind}','${esc(id)}','${esc(gruppenId)}',event)">⧉ Duplizieren</button>
<button class="btn-item-edit" onclick="openMoveModal('${kind}','${esc(id)}','${esc(gruppenId)}',event)">↪ Verschieben</button>
<button class="btn-item-delete" onclick="deleteItem('${kind}','${esc(id)}','${esc(gruppenId)}',event)">✕ Löschen</button>
</div>`;
return `<div class="item" id="item-${esc(id)}">
@@ -1073,6 +1101,52 @@ async function duplicateItem(kind, itemId, gruppenId, event) {
else document.getElementById('gruppeActionError').textContent = 'Fehler beim Duplizieren (HTTP ' + r.status + ').';
}
let _moveState = null;
function openMoveModal(kind, itemId, currentGruppeId, event) {
event.stopPropagation();
const item = _itemData[itemId]; if (!item) return;
_moveState = { kind, itemId, currentGruppeId };
document.getElementById('moveModalItemName').textContent = item.kurzText || itemId;
document.getElementById('moveModalError').textContent = '';
const sel = document.getElementById('moveModalSelect');
sel.innerHTML = '';
Object.values(_gruppeData)
.filter(g => g.gruppenId !== currentGruppeId)
.sort((a, b) => (a.name || '').localeCompare(b.name || '', 'de'))
.forEach(g => {
const opt = document.createElement('option');
opt.value = g.gruppenId;
opt.textContent = g.name;
sel.appendChild(opt);
});
document.getElementById('moveModal').classList.add('open');
}
function closeMoveModal() {
document.getElementById('moveModal').classList.remove('open');
_moveState = null;
}
document.getElementById('moveModalCancel').addEventListener('click', closeMoveModal);
document.getElementById('moveModal').addEventListener('click', e => {
if (e.target === document.getElementById('moveModal')) closeMoveModal();
});
document.getElementById('moveModalOk').addEventListener('click', async () => {
if (!_moveState) return;
const { kind, itemId, currentGruppeId } = _moveState;
const targetGruppeId = document.getElementById('moveModalSelect').value;
if (!targetGruppeId) return;
const r = await fetch(`/admin/aufgabengruppen/items/${kind}/${itemId}/move?targetGruppeId=${targetGruppeId}`, {
method: 'PUT'
});
if (r.ok || r.status === 204) {
closeMoveModal();
openItemId = null;
pendingExpandId = currentGruppeId;
loadAdminGruppen();
} else {
document.getElementById('moveModalError').textContent = 'Fehler beim Verschieben (HTTP ' + r.status + ').';
}
});
function sortByLevelThenName(items) {
return items.slice().sort((a, b) => {
const la = a.level ?? 999, lb = b.level ?? 999;
@@ -1081,6 +1155,14 @@ function sortByLevelThenName(items) {
});
}
function sortByName(items) { return items.slice().sort((a, b) => (a.kurzText || '').localeCompare(b.kurzText || '', 'de')); }
const GESCHLECHT_ORDER = { WEIBLICH: 0, DIVERS: 1, MAENNLICH: 2 };
function sortByGeschlecht(items) {
return items.slice().sort((a, b) => {
const ga = GESCHLECHT_ORDER[a.geschlecht] ?? 99, gb = GESCHLECHT_ORDER[b.geschlecht] ?? 99;
if (ga !== gb) return ga - gb;
return (a.kurzText || '').localeCompare(b.kurzText || '', 'de');
});
}
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 '';
@@ -1308,8 +1390,116 @@ function openEditItemModal(itemId, event) {
itemModal.classList.add('open'); document.getElementById('iKurzText').focus();
}
function closeItemModal() { itemModal.classList.remove('open'); closeToySearch(); document.getElementById('iPlaceholderHint').style.display = 'none'; }
function closeItemModal() { itemModal.classList.remove('open'); closeToySearch(); document.getElementById('iPlaceholderHint').style.display = 'none'; document.getElementById('iTextAC').style.display = 'none'; }
function togglePlaceholderHint() { const el = document.getElementById('iPlaceholderHint'); el.style.display = el.style.display === 'none' ? 'block' : 'none'; }
(function() {
const STATIC = ['{AKTIV}', '{PASSIV}'];
const ta = document.getElementById('iText');
const ac = document.getElementById('iTextAC');
let _allToys = [];
let _items = [];
let _wordStart = 0;
let activeIdx = -1;
function currentWord() {
const pos = ta.selectionStart;
let start = pos;
while (start > 0 && !/\s/.test(ta.value[start - 1])) start--;
return { word: ta.value.slice(start, pos).toLowerCase(), start };
}
function buildItems(filter) {
const f = filter || '';
_items = STATIC.filter(s => !f || s.toLowerCase().includes(f)).map(s => ({ label: s, insert: s }));
const toys = _allToys.filter(t => !f || t.name.toLowerCase().includes(f));
if (toys.length) {
_items.push({ separator: true, label: 'Toys' });
toys.forEach(t => _items.push({ label: t.name, insert: t.name, toyId: t.toyId }));
}
}
function selectables() { return _items.map((it, i) => it.separator ? null : i).filter(i => i !== null); }
function renderItems() {
ac.innerHTML = '';
activeIdx = -1;
_items.forEach((item, i) => {
if (item.separator) {
const sep = document.createElement('div');
sep.className = 'ac-separator';
sep.textContent = item.label;
ac.appendChild(sep);
} else {
const div = document.createElement('div');
div.className = 'ac-item';
div.dataset.idx = String(i);
div.textContent = item.label;
div.addEventListener('mousedown', e => { e.preventDefault(); doInsert(item); });
div.addEventListener('mouseover', () => setActive(i));
ac.appendChild(div);
}
});
const s = selectables();
if (s.length) { setActive(s[0]); ac.style.display = 'block'; } else hideAC();
}
function showAC(toys) {
_allToys = toys || [];
const { word, start } = currentWord();
_wordStart = start;
buildItems(word);
const rect = ta.getBoundingClientRect();
ac.style.left = rect.left + 'px';
ac.style.top = (rect.bottom + 4) + 'px';
renderItems();
}
function hideAC() { ac.style.display = 'none'; activeIdx = -1; }
function setActive(i) {
activeIdx = i;
let activeEl = null;
ac.querySelectorAll('.ac-item').forEach(el => {
const on = parseInt(el.dataset.idx) === i;
el.classList.toggle('ac-item-active', on);
if (on) activeEl = el;
});
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
}
function doInsert(item) {
const end = ta.selectionStart;
ta.value = ta.value.slice(0, _wordStart) + item.insert + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = _wordStart + item.insert.length;
ta.focus();
if (item.toyId) {
const already = (_selectedToys || []).find(t => t.toyId === item.toyId);
if (!already) toggleToyFromSearch(item.toyId);
}
hideAC();
}
ta.addEventListener('keydown', e => {
if (e.ctrlKey && e.code === 'Space') {
e.preventDefault();
_loadAvailableToys().then(toys => showAC(toys)).catch(() => showAC([]));
return;
}
if (ac.style.display !== 'block') return;
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); hideAC(); return; }
const s = selectables();
if (!s.length) return;
const pos = s.indexOf(activeIdx);
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(s[(pos + 1) % s.length]); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(s[(pos - 1 + s.length) % s.length]); }
else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const item = _items[activeIdx];
if (item && !item.separator) doInsert(item);
}
});
ta.addEventListener('input', () => {
if (ac.style.display !== 'block') return;
const { word, start } = currentWord();
_wordStart = start;
buildItems(word);
renderItems();
});
document.addEventListener('mousedown', e => { if (!ac.contains(e.target) && e.target !== ta) hideAC(); });
})();
document.getElementById('itemCancelBtn').addEventListener('click', closeItemModal);
itemModal.addEventListener('click', e => { if (e.target === itemModal) closeItemModal(); });

View File

@@ -883,7 +883,8 @@ body.app {
}
/* Benachrichtigungen */
.topbar-notif-item--unread { background: rgba(var(--color-primary-rgb, 231,57,84), 0.04); }
.topbar-notif-item--unread { background: rgba(var(--color-primary-rgb, 231,57,84), 0.07); border-left: 3px solid var(--color-primary); }
.topbar-notif-item--unread:hover { background: rgba(var(--color-primary-rgb, 231,57,84), 0.12); }
.topbar-notif-dot {
width: 7px;
height: 7px;

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/games/bdsm/neubdsm.html">
<title>BDSM Game xXx Sphere</title>
</head>
<body>
<script>window.location.replace('/games/bdsm/neubdsm.html');</script>
</body>
</html>

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/games/bdsm/neubdsm.html">
<title>BDSM Game xXx Sphere</title>
</head>
<body>
<script>window.location.replace('/games/bdsm/neubdsm.html');</script>
</body>
</html>

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/games/bdsm/neubdsm.html">
<title>BDSM Game xXx Sphere</title>
</head>
<body>
<script>window.location.replace('/games/bdsm/neubdsm.html');</script>
</body>
</html>

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/games/bdsm/neubdsm.html">
<title>BDSM Game xXx Sphere</title>
</head>
<body>
<script>window.location.replace('/games/bdsm/neubdsm.html');</script>
</body>
</html>

View File

@@ -104,7 +104,7 @@
<div style="font-size:2.5rem;margin-bottom:1rem;">⚠️</div>
<h2 style="margin-bottom:0.5rem;">Einladung nicht gefunden</h2>
<p style="color:var(--color-muted);">Diese Einladung existiert nicht oder wurde bereits bearbeitet.</p>
<a href="/community/einladungen.html" style="display:inline-block;margin-top:1.5rem;padding:0.65rem 1.5rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Zu meinen Einladungen</a>
<a href="/games/common/einladungen.html" style="display:inline-block;margin-top:1.5rem;padding:0.65rem 1.5rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Zu meinen Einladungen</a>
</div>
<div id="stateAlready" style="text-align:center;padding:3rem 1rem;">
@@ -117,7 +117,7 @@
<div style="font-size:2.5rem;margin-bottom:1rem;"></div>
<h2 style="margin-bottom:0.5rem;">Einladung abgelehnt</h2>
<p style="color:var(--color-muted);">Du hast die Einladung abgelehnt. Der Keyholder wurde benachrichtigt.</p>
<a href="/community/einladungen.html" style="display:inline-block;margin-top:1.5rem;padding:0.65rem 1.5rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Zu meinen Einladungen</a>
<a href="/games/common/einladungen.html" style="display:inline-block;margin-top:1.5rem;padding:0.65rem 1.5rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Zu meinen Einladungen</a>
</div>
<div id="stateInvite" style="display:none;">

View File

@@ -839,7 +839,7 @@
const data = await res.json();
if (data.lockeeInvitationSent) {
window.location.href = '/community/einladungen.html?tab=gesendet';
window.location.href = '/games/common/einladungen.html?tab=gesendet';
} else if (!data.unlockCode) {
// Trust: kein Code, direkt weiter
const isTimeLock = selectedTemplate && selectedTemplate._type === 'timelock';

View File

@@ -227,6 +227,15 @@
background: rgba(233,69,96,0.12); color: var(--color-primary);
border-radius: 3px; padding: 0.05rem 0.3rem; font-size: 0.75rem;
}
#iTextAC { position: fixed; z-index: 9999; background: var(--color-surface, #1e1e2e);
border: 1px solid var(--color-border, #444); border-radius: 6px;
box-shadow: 0 4px 14px rgba(0,0,0,.5); display: none; overflow: hidden; min-width: 180px; max-height: 280px; overflow-y: auto; }
.ac-item { padding: 0.45rem 0.9rem; cursor: pointer; font-size: 0.88rem;
font-family: monospace; color: var(--color-text, #cdd6f4); user-select: none; }
.ac-item:hover, .ac-item-active { background: var(--color-primary, #cba6f7); color: #1e1e2e; }
.ac-separator { padding: 0.2rem 0.7rem; font-size: 0.72rem; color: var(--color-muted);
text-transform: uppercase; letter-spacing: 0.05em; background: rgba(255,255,255,0.04);
pointer-events: none; border-top: 1px solid rgba(136,136,136,0.2); margin-top: 2px; }
/* ── Item-Add-Modal extra ── */
.modal-two-col { display: flex; gap: 0.75rem; }
@@ -407,6 +416,7 @@
</div>
</div>
<textarea id="iText" rows="4" maxlength="4000" placeholder="Ausführliche Beschreibung…"></textarea>
<div id="iTextAC"></div>
<!-- Finisher: Geschlecht -->
<div id="iGeschlechtRow">
@@ -734,7 +744,7 @@
${renderSubSection('Aufgaben', sortByLevelThenName(g.aufgaben || []), 'aufgabe', renderAufgabe, g.gruppenId, type)}
${IS_BDSM_MODE ? renderSubSection('Strafen', sortByLevelThenName(g.strafen || []), 'strafe', renderStrafe, g.gruppenId, type) : ''}
${IS_BDSM_MODE ? renderSubSection('Zeitstrafen',sortByName(g.sperren || []), 'zeitstrafe',renderZeitstrafe, g.gruppenId, type) : ''}
${renderSubSection('Finisher', sortByName(g.finisher || []), 'finisher', renderFinisher, g.gruppenId, type)}
${renderSubSection('Finisher', sortByGeschlecht(g.finisher || []), 'finisher', renderFinisher, g.gruppenId, type)}
</div>
</div>`;
}).join('');
@@ -945,6 +955,14 @@
return (a.kurzText || '').localeCompare(b.kurzText || '', 'de');
});
}
const GESCHLECHT_ORDER = { WEIBLICH: 0, DIVERS: 1, MAENNLICH: 2 };
function sortByGeschlecht(items) {
return items.slice().sort((a, b) => {
const ga = GESCHLECHT_ORDER[a.geschlecht] ?? 99, gb = GESCHLECHT_ORDER[b.geschlecht] ?? 99;
if (ga !== gb) return ga - gb;
return (a.kurzText || '').localeCompare(b.kurzText || '', 'de');
});
}
function sortByName(items) {
return items.slice().sort((a, b) => (a.kurzText || '').localeCompare(b.kurzText || '', 'de'));
}
@@ -1429,12 +1447,120 @@
document.getElementById('iKurzText').focus();
}
function closeItemModal() { itemModal.classList.remove('open'); closeToySearch(); document.getElementById('iPlaceholderHint').style.display = 'none'; }
function closeItemModal() { itemModal.classList.remove('open'); closeToySearch(); document.getElementById('iPlaceholderHint').style.display = 'none'; document.getElementById('iTextAC').style.display = 'none'; }
function togglePlaceholderHint() {
const el = document.getElementById('iPlaceholderHint');
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
(function() {
const STATIC = ['{AKTIV}', '{PASSIV}'];
const ta = document.getElementById('iText');
const ac = document.getElementById('iTextAC');
let _allToys = [];
let _items = [];
let _wordStart = 0;
let activeIdx = -1;
function currentWord() {
const pos = ta.selectionStart;
let start = pos;
while (start > 0 && !/\s/.test(ta.value[start - 1])) start--;
return { word: ta.value.slice(start, pos).toLowerCase(), start };
}
function buildItems(filter) {
const f = filter || '';
_items = STATIC.filter(s => !f || s.toLowerCase().includes(f)).map(s => ({ label: s, insert: s }));
const toys = _allToys.filter(t => !f || t.name.toLowerCase().includes(f));
if (toys.length) {
_items.push({ separator: true, label: 'Toys' });
toys.forEach(t => _items.push({ label: t.name, insert: t.name, toyId: t.toyId }));
}
}
function selectables() { return _items.map((it, i) => it.separator ? null : i).filter(i => i !== null); }
function renderItems() {
ac.innerHTML = '';
activeIdx = -1;
_items.forEach((item, i) => {
if (item.separator) {
const sep = document.createElement('div');
sep.className = 'ac-separator';
sep.textContent = item.label;
ac.appendChild(sep);
} else {
const div = document.createElement('div');
div.className = 'ac-item';
div.dataset.idx = String(i);
div.textContent = item.label;
div.addEventListener('mousedown', e => { e.preventDefault(); doInsert(item); });
div.addEventListener('mouseover', () => setActive(i));
ac.appendChild(div);
}
});
const s = selectables();
if (s.length) { setActive(s[0]); ac.style.display = 'block'; } else hideAC();
}
function showAC(toys) {
_allToys = toys || [];
const { word, start } = currentWord();
_wordStart = start;
buildItems(word);
const rect = ta.getBoundingClientRect();
ac.style.left = rect.left + 'px';
ac.style.top = (rect.bottom + 4) + 'px';
renderItems();
}
function hideAC() { ac.style.display = 'none'; activeIdx = -1; }
function setActive(i) {
activeIdx = i;
let activeEl = null;
ac.querySelectorAll('.ac-item').forEach(el => {
const on = parseInt(el.dataset.idx) === i;
el.classList.toggle('ac-item-active', on);
if (on) activeEl = el;
});
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
}
function doInsert(item) {
const end = ta.selectionStart;
ta.value = ta.value.slice(0, _wordStart) + item.insert + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = _wordStart + item.insert.length;
ta.focus();
if (item.toyId) {
const already = (_selectedToys || []).find(t => t.toyId === item.toyId);
if (!already) toggleToyFromSearch(item.toyId);
}
hideAC();
}
ta.addEventListener('keydown', e => {
if (e.ctrlKey && e.code === 'Space') {
e.preventDefault();
_loadAvailableToys().then(toys => showAC(toys)).catch(() => showAC([]));
return;
}
if (ac.style.display !== 'block') return;
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); hideAC(); return; }
const s = selectables();
if (!s.length) return;
const pos = s.indexOf(activeIdx);
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(s[(pos + 1) % s.length]); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(s[(pos - 1 + s.length) % s.length]); }
else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const item = _items[activeIdx];
if (item && !item.separator) doInsert(item);
}
});
ta.addEventListener('input', () => {
if (ac.style.display !== 'block') return;
const { word, start } = currentWord();
_wordStart = start;
buildItems(word);
renderItems();
});
document.addEventListener('mousedown', e => { if (!ac.contains(e.target) && e.target !== ta) hideAC(); });
})();
document.getElementById('itemCancelBtn').addEventListener('click', closeItemModal);
itemModal.addEventListener('click', e => { if (e.target === itemModal) closeItemModal(); });

View File

@@ -602,6 +602,18 @@
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmText').textContent = text;
document.getElementById('confirmModal').classList.add('open');
document.querySelector('#confirmModal .confirm-modal-cancel').style.display = '';
return new Promise(resolve => {
_confirmResolve = resolve;
document.getElementById('confirmOkBtn').onclick = () => { confirmClose(true); };
});
}
function showInfo(title, text) {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmText').textContent = text;
document.getElementById('confirmModal').classList.add('open');
document.querySelector('#confirmModal .confirm-modal-cancel').style.display = 'none';
return new Promise(resolve => {
_confirmResolve = resolve;
document.getElementById('confirmOkBtn').onclick = () => { confirmClose(true); };
@@ -885,6 +897,9 @@
removeRecvItem(key);
if (mode === 'OWN_DEVICE') {
window.location.href = `/games/bdsm/neubdsm.html`;
} else if (mode === 'HOST_DEVICE') {
await showInfo('Einladung angenommen', 'Das Spiel findet am Gerät des Hosts statt. Du wirst zur Startseite weitergeleitet.');
window.location.href = '/userhome.html';
}
} catch (_) {
errEl.textContent = 'Fehler beim Speichern der Antwort.';
@@ -933,6 +948,7 @@
if (mode === 'OWN_DEVICE') {
window.location.href = '/games/vanilla/neuvanilla.html';
} else if (mode === 'HOST_DEVICE') {
await showInfo('Einladung angenommen', 'Das Spiel findet am Gerät des Hosts statt. Du wirst zur Startseite weitergeleitet.');
window.location.href = '/userhome.html';
}
} catch (_) {

View File

@@ -190,7 +190,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/bdsm/aufgaben.html" style="color:var(--color-primary);">Aufgaben-Verwaltung (Vanilla)</a>
Gruppen verwalten: <a href="/games/common/aufgaben.html" 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>
@@ -566,11 +566,14 @@
const ul = document.getElementById(containerId);
const section = ul.closest('[id^="section"]');
const selectAllWrap = section?.querySelector('.select-all-label');
if (!gruppen.length) {
const filtered = gruppen.filter(g =>
(g.aufgaben || []).length > 0 || (g.finisher || []).length > 0
);
if (!filtered.length) {
ul.innerHTML = '<li class="empty-hint">Keine Gruppen vorhanden.</li>';
if (selectAllWrap) selectAllWrap.style.visibility = 'hidden'; return;
}
ul.innerHTML = gruppen.map(g => {
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' : ''}>

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url=/games/vanilla/neuvanilla.html">
<title>Vanilla Game xXx Sphere</title>
</head>
<body>
<script>window.location.replace('/games/vanilla/neuvanilla.html');</script>
</body>
</html>

View File

@@ -10,8 +10,8 @@
{ 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/bdsm/aufgaben.html', icon: I('CHECK'), label: 'Aufgaben' },
{ href: '/games/chastity/toys.html', icon: I('TOYS'), label: 'Toys' },
{ href: '/games/common/aufgaben.html', icon: I('CHECK'), label: 'Aufgaben' },
{ href: '/games/common/toys.html', icon: I('TOYS'), label: 'Toys' },
{ href: '/games/chastity/entdecken.html', icon: I('DISCOVER'), label: 'Entdecken' },
]
},
@@ -22,8 +22,8 @@
{ 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?mode=bdsm', icon: I('CHECK'), label: 'Aufgaben' },
{ href: '/games/chastity/toys.html', icon: I('TOYS'), label: 'Toys' },
{ href: '/games/common/aufgaben.html?mode=bdsm', icon: I('CHECK'), label: 'Aufgaben' },
{ href: '/games/common/toys.html', icon: I('TOYS'), label: 'Toys' },
{ href: '/games/chastity/entdecken.html', icon: I('DISCOVER'), label: 'Entdecken' },
]
},
@@ -56,7 +56,7 @@
{ href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten', badgeId: null },
{ href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen', badgeId: null },
{ href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge'},
{ href: '/community/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen', badgeId: null },
{ href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen', badgeId: null },
];
const socialNav = socialLinks.map(({ href, icon, label, badgeId }) => {
const cls = path === href ? ' class="active"' : '';

View File

@@ -85,6 +85,12 @@
} catch(ex) {}
});
es.addEventListener('INVITATION', () => {
try {
if (typeof window.__topbarReloadInvBadge === 'function') window.__topbarReloadInvBadge();
} catch(ex) {}
});
es.onerror = () => {
es.close();
// Vor dem Reconnect prüfen ob noch eingeloggt (verhindert Endlos-Schleife bei abgelaufener Session)

View File

@@ -79,7 +79,7 @@
<button class="topbar-panel-close" onclick="window.__topbarCloseAll()">✕</button>
</div>
<div class="topbar-panel-body" id="topbarInvBody"></div>
<div class="topbar-panel-footer"><a href="/community/einladungen.html">Alle anzeigen →</a></div>
<div class="topbar-panel-footer"><a href="/games/common/einladungen.html">Alle anzeigen →</a></div>
</div>
<div class="topbar-panel" id="topbarProfilePanel">
@@ -275,17 +275,17 @@
try {
const res = await fetch('/notifications');
if (!res.ok) { body.innerHTML = '<div class="topbar-panel-hint">Keine Benachrichtigungen.</div>'; return; }
const notifs = await res.json();
if (!notifs.length) { body.innerHTML = '<div class="topbar-panel-hint">Keine neuen Benachrichtigungen.</div>'; return; }
const unread = (await res.json()).filter(n => !n.read);
if (!unread.length) { body.innerHTML = '<div class="topbar-panel-hint">Keine neuen Benachrichtigungen.</div>'; return; }
body.innerHTML = '';
notifs.forEach(n => {
unread.forEach(n => {
const el = document.createElement('div');
const tag = n.targetUrl ? 'a' : 'div';
const href = n.targetUrl ? `href="${esc(n.targetUrl)}"` : '';
const av = n.senderAvatar
? `<img src="data:image/png;base64,${esc(n.senderAvatar)}" class="topbar-item-avatar" alt="">`
: `<span class="topbar-item-avatar topbar-item-avatar--placeholder">${IC('PROFILE')}</span>`;
el.innerHTML = `<${tag} ${href} class="topbar-panel-item topbar-notif-item">
el.innerHTML = `<${tag} ${href} class="topbar-panel-item topbar-notif-item${n.read ? '' : ' topbar-notif-item--unread'}">
${av}
<div class="topbar-panel-item-body">
<div style="font-size:0.85rem;line-height:1.4;">${esc(n.text)}</div>
@@ -323,18 +323,21 @@
if (!body) return;
body.innerHTML = '<div class="topbar-panel-hint">Wird geladen…</div>';
try {
const [lr, kr, br] = await Promise.all([
const [lr, kr, br, vr] = await Promise.all([
fetch('/lockee/invitations/mine'),
fetch('/keyholder/invitations/mine'),
fetch('/bdsm/einladung/pending')
fetch('/bdsm/einladung/pending'),
fetch('/vanilla/einladung/pending')
]);
const lockee = lr.ok ? await lr.json() : [];
const kh = kr.ok ? await kr.json() : [];
const bdsm = br.ok ? await br.json() : [];
const lockee = lr.ok ? await lr.json() : [];
const kh = kr.ok ? await kr.json() : [];
const bdsm = br.ok ? await br.json() : [];
const vanilla = vr.ok ? await vr.json() : [];
const all = [
...lockee.map(i => ({ ...i, _type: 'lockee' })),
...kh.map(i => ({ ...i, _type: 'keyholder' })),
...bdsm.map(i => ({ ...i, _type: 'bdsm' }))
...bdsm.map(i => ({ ...i, _type: 'bdsm' })),
...vanilla.map(i => ({ ...i, _type: 'vanilla' }))
];
if (!all.length) { body.innerHTML = '<div class="topbar-panel-hint">Keine offenen Einladungen.</div>'; return; }
body.innerHTML = '';
@@ -343,66 +346,35 @@
}
function buildInvCard(inv) {
let typeIcon, typeName, line, declineUrl, declineMethod = 'DELETE', declineBody = null, acceptHtml;
let typeIcon, typeName, line;
if (inv._type === 'lockee') {
typeIcon = IC('LOCK'); typeName = 'Lockee-Einladung'; line = inv.lockName || 'Lock';
declineUrl = '/lockee/invitation/' + encodeURIComponent(inv.token);
acceptHtml = `<a href="/community/einladungen.html" class="topbar-inv-btn topbar-inv-btn--accept">Details</a>`;
} else if (inv._type === 'keyholder') {
typeIcon = IC('KEY'); typeName = 'Keyholder-Einladung'; line = inv.lockName || 'Lock';
declineUrl = '/keyholder/invitations/mine/' + encodeURIComponent(inv.token);
acceptHtml = `<a href="/keyholder/invitation/${esc(inv.token)}" class="topbar-inv-btn topbar-inv-btn--accept">Annehmen</a>`;
} else if (inv._type === 'vanilla') {
typeIcon = IC('INVITATIONS'); typeName = 'Vanilla Game'; line = inv.inviterName || 'Einladung';
} else {
typeIcon = IC('BDSM'); typeName = 'BDSM Game'; line = inv.senderName || 'Einladung';
const id = inv.id || inv.einladungId || '';
declineUrl = '/bdsm/einladung/' + encodeURIComponent(id) + '/antwort';
declineMethod = 'PUT';
declineBody = JSON.stringify({ annahme: false });
acceptHtml = `<a href="/games/bdsm/bdsm-einladung.html?id=${esc(id)}" class="topbar-inv-btn topbar-inv-btn--accept">Details</a>`;
}
const senderPic = inv.senderAvatar || inv.lockOwnerAvatar;
const senderPic = inv.senderAvatar || inv.lockOwnerAvatar || inv.inviterAvatar;
const av = senderPic
? `<img src="data:image/png;base64,${esc(senderPic)}" class="topbar-item-avatar" alt="">`
: `<span class="topbar-item-avatar topbar-item-avatar--placeholder">${IC('PROFILE')}</span>`;
const div = document.createElement('div');
div.className = 'topbar-panel-item topbar-inv-card';
div.style.cursor = 'pointer';
div.innerHTML = `${av}
<div class="topbar-panel-item-body">
<div class="topbar-panel-item-sub">${typeIcon} ${typeName}</div>
<div style="font-weight:600;font-size:0.88rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(line)}</div>
</div>
<div style="display:flex;gap:0.3rem;flex-shrink:0;">
<button class="topbar-inv-btn topbar-inv-btn--decline"
data-url="${esc(declineUrl)}"
data-method="${declineMethod}"
data-body="${esc(declineBody || '')}"
onclick="window.__topbarDecline(this)">✕</button>
${acceptHtml}
</div>`;
div.addEventListener('click', () => { window.location.href = '/games/common/einladungen.html'; });
return div;
}
window.__topbarDecline = async function (btn) {
btn.disabled = true;
const url = btn.dataset.url;
const method = btn.dataset.method;
const body = btn.dataset.body || null;
try {
const opts = { method };
if (body) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = body; }
const res = await fetch(url, opts);
if (res.ok || res.status === 204) {
const card = btn.closest('.topbar-inv-card');
if (card) card.remove();
const remaining = document.getElementById('topbarInvBody')?.querySelectorAll('.topbar-inv-card').length || 0;
setTopbarBadge('inv', remaining);
} else { btn.disabled = false; }
} catch (e) { btn.disabled = false; }
};
// ── Badge-Verwaltung ──
function setTopbarBadge(type, count) {
const map = { msg: 'topbarMsgBadge', notif: 'topbarNotifBadge', inv: 'topbarInvBadge' };
@@ -415,9 +387,7 @@
// Für social-sidebar.js zugänglich
window.__topbarSetBadge = setTopbarBadge;
function loadInitialBadges() {
fetch('/social/messages/unread/count').then(r => r.ok ? r.json() : 0).then(n => setTopbarBadge('msg', n)).catch(() => {});
fetch('/notifications/unread/count').then(r => r.ok ? r.json() : 0).then(n => setTopbarBadge('notif', n)).catch(() => {});
function reloadInvBadge() {
Promise.all([
fetch('/lockee/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/keyholder/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
@@ -425,4 +395,11 @@
fetch('/vanilla/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0)
]).then(([l, k, b, v]) => setTopbarBadge('inv', l + k + b + v)).catch(() => {});
}
window.__topbarReloadInvBadge = reloadInvBadge;
function loadInitialBadges() {
fetch('/social/messages/unread/count').then(r => r.ok ? r.json() : 0).then(n => setTopbarBadge('msg', n)).catch(() => {});
fetch('/notifications/unread/count').then(r => r.ok ? r.json() : 0).then(n => setTopbarBadge('notif', n)).catch(() => {});
reloadInvBadge();
}
})();