Wetier am Cahstity game gebasterln

This commit is contained in:
2026-03-17 19:51:51 +01:00
parent 97c6f0a131
commit aafc203407
130 changed files with 9356 additions and 4222 deletions

View File

@@ -2,8 +2,10 @@ package de.oaa.xxx;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class XxxThegameApplication {
public static void main(String[] args) {

View File

@@ -1,5 +1,6 @@
package de.oaa.xxx.config;
import jakarta.servlet.DispatcherType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
@@ -30,6 +31,7 @@ public class SecurityConfig {
.authenticationEntryPoint((request, response, authException) ->
response.sendRedirect("/login.html")))
.authorizeHttpRequests(auth -> auth
.dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/error")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/userhome.html")).authenticated()
@@ -43,7 +45,8 @@ public class SecurityConfig {
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionvanilla.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionbdsm.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionchastity.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionchastityingame.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/neulock.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/activelock.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionbdsmtasks.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionbdsmtoys.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionbdsmingame.html")).authenticated()
@@ -57,15 +60,20 @@ public class SecurityConfig {
.requestMatchers(AntPathRequestMatcher.antMatcher("/communityvotes.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/keyholder.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/meine-locks.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/unlock-history.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/einladungen.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/joinlock.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/benachrichtigungen.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/gruppen/**")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/feed/**")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/notifications/**")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/events/**")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/*.html")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/css/**")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/js/**")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/images/**")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/favicon.ico")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/audio/**")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/*.png")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/*.jpg")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/*.svg")).permitAll()

View File

@@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.games.chastity.cardlock.CardlockRepository;
import de.oaa.xxx.social.SseService;
import de.oaa.xxx.social.entity.MessageEntity;
import de.oaa.xxx.social.repository.MessageRepository;
import de.oaa.xxx.user.UserRepository;
@@ -34,6 +35,7 @@ public class LockeeInvitationController {
private final CardlockRepository cardlockRepository;
private final UserRepository userRepository;
private final MessageRepository messageRepository;
private final SseService sseService;
@Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
@@ -43,11 +45,27 @@ public class LockeeInvitationController {
public LockeeInvitationController(LockeeInvitationRepository lockeeInvitationRepository,
CardlockRepository cardlockRepository,
UserRepository userRepository,
MessageRepository messageRepository) {
MessageRepository messageRepository,
SseService sseService) {
this.lockeeInvitationRepository = lockeeInvitationRepository;
this.cardlockRepository = cardlockRepository;
this.userRepository = userRepository;
this.messageRepository = messageRepository;
this.sseService = sseService;
}
private void sendMessage(UUID senderId, UUID receiverId, String text, String targetUrl) {
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(senderId);
msg.setReceiverId(receiverId);
msg.setText(text);
msg.setSentAt(LocalDateTime.now());
msg.setSystemMessage(true);
if (targetUrl != null) msg.setTargetUrl(targetUrl);
messageRepository.save(msg);
long unread = messageRepository.countByReceiverIdAndSystemMessageAndReadAtIsNull(receiverId, true);
sseService.push(receiverId, "NOTIFICATION", java.util.Map.of("unreadCount", unread, "text", text));
}
private String generateUnlockCode(int lines) {
@@ -134,13 +152,9 @@ public class LockeeInvitationController {
var lock = lockOpt.get();
cardlockRepository.delete(lock);
String lockName = lock.getName() != null && !lock.getName().isBlank() ? lock.getName() : "Unbenanntes Lock";
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(myId);
msg.setReceiverId(inv.getLockeeUserId());
msg.setText(me.getName() + " hat die Lockee-Einladung für das Lock „" + lockName + "\" zurückgezogen.");
msg.setSentAt(LocalDateTime.now());
messageRepository.save(msg);
sendMessage(myId, inv.getLockeeUserId(),
me.getName() + " hat die Lockee-Einladung für das Lock „" + lockName + "\" zurückgezogen.",
null);
}
return ResponseEntity.noContent().build();
@@ -232,14 +246,9 @@ public class LockeeInvitationController {
lockeeInvitationRepository.delete(inv);
String lockName = lock.getName() != null && !lock.getName().isBlank() ? lock.getName() : "Unbenanntes Lock";
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(myId);
msg.setReceiverId(inv.getKeyholderUserId());
msg.setText(me.getName() + " hat die Einladung als Lockee für das Lock „" + lockName + "\" angenommen.\n\n" +
"Deine Keyholder-Seite: " + baseUrl + "/keyholder.html");
msg.setSentAt(now);
messageRepository.save(msg);
sendMessage(myId, inv.getKeyholderUserId(),
me.getName() + " hat die Einladung als Lockee für das Lock „" + lockName + "\" angenommen.",
"/keyholder.html");
return ResponseEntity.ok(Map.of(
"lockId", lock.getLockId().toString(),
@@ -267,13 +276,9 @@ public class LockeeInvitationController {
var lock = lockOpt.get();
cardlockRepository.delete(lock);
String lockName = lock.getName() != null && !lock.getName().isBlank() ? lock.getName() : "Unbenanntes Lock";
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(myId);
msg.setReceiverId(inv.getKeyholderUserId());
msg.setText(me.getName() + " hat die Einladung als Lockee für das Lock „" + lockName + "\" abgelehnt.");
msg.setSentAt(LocalDateTime.now());
messageRepository.save(msg);
sendMessage(myId, inv.getKeyholderUserId(),
me.getName() + " hat die Einladung als Lockee für das Lock „" + lockName + "\" abgelehnt.",
null);
}
return ResponseEntity.noContent().build();

View File

@@ -1,7 +1,5 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.CardLockService;
public interface Card {
public CardDTO processCard(CardLockService lock);

View File

@@ -1,27 +1,5 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.CardLockService;
import de.oaa.xxx.games.chastity.KeyholderInvitationEntity;
import de.oaa.xxx.games.chastity.KeyholderInvitationRepository;
import de.oaa.xxx.games.chastity.LockeeInvitationEntity;
import de.oaa.xxx.games.chastity.LockeeInvitationRepository;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.games.chastity.verification.VerificationRepository;
import de.oaa.xxx.games.chastity.verification.VerificationVoteEntity;
import de.oaa.xxx.games.chastity.verification.VerificationVoteRepository;
import de.oaa.xxx.games.chastity.CodeCreator;
import de.oaa.xxx.games.chastity.verification.VerificationEntity;
import de.oaa.xxx.social.entity.MessageEntity;
import de.oaa.xxx.social.repository.MessageRepository;
import de.oaa.xxx.user.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
@@ -42,6 +20,39 @@ import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.imageio.ImageIO;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import de.oaa.xxx.games.chastity.CodeCreator;
import de.oaa.xxx.games.chastity.KeyholderInvitationEntity;
import de.oaa.xxx.games.chastity.KeyholderInvitationRepository;
import de.oaa.xxx.games.chastity.LockeeInvitationEntity;
import de.oaa.xxx.games.chastity.LockeeInvitationRepository;
import de.oaa.xxx.games.chastity.tasks.AssignedTaskEntity;
import de.oaa.xxx.games.chastity.tasks.AssignedTaskRepository;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.games.chastity.verification.VerificationEntity;
import de.oaa.xxx.games.chastity.verification.VerificationRepository;
import de.oaa.xxx.games.chastity.verification.VerificationVoteEntity;
import de.oaa.xxx.games.chastity.verification.VerificationVoteRepository;
import de.oaa.xxx.social.SseService;
import de.oaa.xxx.social.entity.MessageEntity;
import de.oaa.xxx.social.repository.MessageRepository;
import de.oaa.xxx.user.UserRepository;
@RestController
@RequestMapping("/keyholder")
public class CardLockController {
@@ -55,6 +66,11 @@ public class CardLockController {
private final HygieneViolationRepository hygieneViolationRepository;
private final MessageRepository messageRepository;
private final LockeeInvitationRepository lockeeInvitationRepository;
private final AssignedTaskRepository assignedTaskRepository;
private final KeyholderTaskChoiceRepository keyholderTaskChoiceRepository;
private final CommunityTaskVoteRepository communityTaskVoteRepository;
private final UnlockCodeHistoryRepository unlockCodeHistoryRepository;
private final SseService sseService;
@Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
@@ -67,16 +83,26 @@ public class CardLockController {
VerificationVoteRepository verificationVoteRepository,
HygieneViolationRepository hygieneViolationRepository,
MessageRepository messageRepository,
LockeeInvitationRepository lockeeInvitationRepository) {
this.cardlockRepository = cardlockRepository;
this.cardLockRepository = cardLockRepository;
this.userRepository = userRepository;
this.invitationRepository = invitationRepository;
this.verificationRepository = verificationRepository;
this.verificationVoteRepository = verificationVoteRepository;
this.hygieneViolationRepository = hygieneViolationRepository;
this.messageRepository = messageRepository;
this.lockeeInvitationRepository = lockeeInvitationRepository;
LockeeInvitationRepository lockeeInvitationRepository,
AssignedTaskRepository assignedTaskRepository,
KeyholderTaskChoiceRepository keyholderTaskChoiceRepository,
CommunityTaskVoteRepository communityTaskVoteRepository,
UnlockCodeHistoryRepository unlockCodeHistoryRepository,
SseService sseService) {
this.cardlockRepository = cardlockRepository;
this.cardLockRepository = cardLockRepository;
this.userRepository = userRepository;
this.invitationRepository = invitationRepository;
this.verificationRepository = verificationRepository;
this.verificationVoteRepository = verificationVoteRepository;
this.hygieneViolationRepository = hygieneViolationRepository;
this.messageRepository = messageRepository;
this.lockeeInvitationRepository = lockeeInvitationRepository;
this.assignedTaskRepository = assignedTaskRepository;
this.keyholderTaskChoiceRepository = keyholderTaskChoiceRepository;
this.communityTaskVoteRepository = communityTaskVoteRepository;
this.unlockCodeHistoryRepository = unlockCodeHistoryRepository;
this.sseService = sseService;
}
record CreateCardLockRequest(
@@ -94,7 +120,8 @@ public class CardLockController {
List<Task> tasks,
boolean requiresVerification,
boolean testLock,
Integer unlockCodeLines
Integer unlockCodeLines,
String taskCardMode
) {}
private static final SecureRandom RNG = new SecureRandom();
@@ -143,6 +170,7 @@ public class CardLockController {
lock.setTasks(req.tasks() != null ? req.tasks() : List.of());
lock.setRequiresVerification(req.requiresVerification());
lock.setTestLock(false);
lock.setTaskCardMode(req.taskCardMode() != null ? req.taskCardMode() : "RANDOM");
// startTime, unlockCode, unlockCodeLines left null until lockee accepts
cardlockRepository.save(lock);
@@ -157,14 +185,9 @@ public class CardLockController {
lockeeInvitationRepository.save(inv);
String lockName = req.name() != null && !req.name().isBlank() ? req.name() : "Unbenanntes Lock";
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(myId);
msg.setReceiverId(lockee.getUserId());
msg.setText(me.getName() + " hat dich als Lockee für das Lock „" + lockName + "\" eingeladen.\n\n" +
"Deine Einladungen findest du hier: " + baseUrl + "/einladungen.html");
msg.setSentAt(now);
messageRepository.save(msg);
sendMessage(myId, lockee.getUserId(),
me.getName() + " hat dich als Lockee für das Lock „" + lockName + "\" eingeladen.",
"/einladungen.html");
return ResponseEntity.ok(Map.of(
"lockId", lock.getLockId().toString(),
@@ -194,6 +217,7 @@ public class CardLockController {
lock.setTasks(req.tasks() != null ? req.tasks() : List.of());
lock.setRequiresVerification(req.requiresVerification());
lock.setTestLock(req.testLock());
lock.setTaskCardMode(req.taskCardMode() != null ? req.taskCardMode() : "RANDOM");
lock.setUnlockCodeLines(codeLines);
lock.setUnlockCode(unlockCode);
@@ -224,15 +248,9 @@ public class CardLockController {
invitationRepository.save(inv);
String lockName = req.name() != null && !req.name().isBlank() ? req.name() : "Unbenanntes Lock";
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(me.getUserId());
msg.setReceiverId(kh.getUserId());
msg.setText(me.getName() + " hat dich als Keyholder*In für das Lock „" + lockName + "\" eingeladen.\n\n" +
"Deine Einladungen findest du hier: " + baseUrl + "/einladungen.html");
msg.setSentAt(now);
messageRepository.save(msg);
sendMessage(me.getUserId(), kh.getUserId(),
me.getName() + " hat dich als Keyholder*In für das Lock „" + lockName + "\" eingeladen.",
"/einladungen.html");
keyholderPending = true;
}
@@ -261,9 +279,45 @@ public class CardLockController {
CardDTO dto = service.getNextCard();
if (dto == null) return ResponseEntity.status(409).body(Map.of("error", "Keine Karte verfügbar"));
// Task-Karte in nicht-zufälligem Modus → Entscheidung delegieren
String taskPending = null;
if (dto.card() == CardEnum.TASK && !"RANDOM".equals(l.getTaskCardMode())
&& l.getTasks() != null && !l.getTasks().isEmpty()) {
if ("KEYHOLDER".equals(l.getTaskCardMode()) && l.getKeyholder() != null) {
KeyholderTaskChoiceEntity choice = new KeyholderTaskChoiceEntity();
choice.setLockId(l.getLockId());
choice.setCreatedAt(LocalDateTime.now());
choice.setStatus("PENDING");
keyholderTaskChoiceRepository.save(choice);
userRepository.findById(l.getKeyholder()).ifPresent(kh ->
sendMessage(l.getLockee(), kh.getUserId(),
"Deine Lockee hat eine Aufgaben-Karte gezogen wähle eine Aufgabe aus.",
"/keyholder.html"));
taskPending = "KEYHOLDER";
} else if ("COMMUNITY".equals(l.getTaskCardMode())) {
CommunityTaskVoteEntity vote = new CommunityTaskVoteEntity();
vote.setLockId(l.getLockId());
vote.setCreatedAt(LocalDateTime.now());
vote.setExpiresAt(LocalDateTime.now().plusHours(1));
vote.setStatus("ACTIVE");
vote.setTestLock(l.isTestLock());
communityTaskVoteRepository.save(vote);
taskPending = l.isTestLock() ? "RANDOM" : "COMMUNITY";
}
}
Map<String, Object> result = new HashMap<>();
result.put("card", dto.card().name());
result.put("unlockCode", dto.unlockCode() != null ? dto.unlockCode() : "");
if (taskPending != null) result.put("taskPending", taskPending);
// Grüne Karte → Entsperrcode-Historie speichern
if (dto.unlockCode() != null && !dto.unlockCode().isBlank()) {
saveUnlockCodeHistory(myId, l.getLockId(), l.getName(), dto.unlockCode(), "GREEN_CARD");
}
return ResponseEntity.ok(result);
}
@@ -282,6 +336,8 @@ public class CardLockController {
l.setHygineOpeningtime(LocalDateTime.now());
cardlockRepository.save(l);
saveUnlockCodeHistory(myId, l.getLockId(), l.getName(), l.getUnlockCode(), "HYGIENE_OPEN");
return ResponseEntity.ok(Map.of(
"unlockCode", l.getUnlockCode(),
"durationMinutes", l.getHygineOpeningDurationMinutes() != null ? l.getHygineOpeningDurationMinutes() : 30
@@ -309,7 +365,11 @@ public class CardLockController {
long overtimeMinutes = ChronoUnit.MINUTES.between(dueTime, now);
if (l.getKeyholder() == null) {
// Self-Lock: 4-fache Überschreitungszeit einfrieren
l.setFrozenUntill(now.plusMinutes(overtimeMinutes * 4));
if (l.getFrozenUntill() != null) {
l.setFrozenUntill(l.getFrozenUntill().plusMinutes(overtimeMinutes * 4));
} else {
l.setFrozenUntill(now.plusMinutes(overtimeMinutes * 4));
}
} else {
// Keyholder vorhanden: Verletzung protokollieren
HygieneViolationEntity violation = new HygieneViolationEntity();
@@ -333,6 +393,8 @@ public class CardLockController {
l.setUnlockCode(newCode);
cardlockRepository.save(l);
saveUnlockCodeHistory(myId, l.getLockId(), l.getName(), newCode, "HYGIENE_CLOSE");
return ResponseEntity.ok(Map.of("newUnlockCode", newCode));
}
@@ -422,8 +484,10 @@ public class CardLockController {
result.put("totalCards", totalCards);
result.put("openPicks", l.getOpenPicks() != null ? l.getOpenPicks() : 0);
result.put("nextCardIn", l.getNextCardIn() != null ? l.getNextCardIn().toString() : "");
result.put("frozenUntill", l.getFrozenUntill() != null ? l.getFrozenUntill().toString() : null);
result.put("currentTask", l.getCurrentTask() != null ? l.getCurrentTask() : null);
result.put("frozenUntill", l.getFrozenUntill() != null ? l.getFrozenUntill().toString() : null);
result.put("currentTask", l.getCurrentTask() != null ? l.getCurrentTask() : null);
result.put("currentTaskDescription", l.getCurrentTaskDescription());
result.put("taskFrozenUntil", l.getTaskFrozenUntil() != null ? l.getTaskFrozenUntil().toString() : null);
result.put("hygieneEnabled", hygieneEnabled);
result.put("hygieneOpeningDue", hygieneOpeningDue);
result.put("hygieneSecondsRemaining", hygieneSecondsRemaining);
@@ -475,6 +539,79 @@ public class CardLockController {
result.put("verificationPendingId", verificationPendingId);
result.put("verificationPendingCode", verificationPendingCode);
// Abgelaufene Aufgaben prüfen und Strafe anwenden
boolean lockDirty = false;
var expiredTasks = assignedTaskRepository.findByLockIdAndStatus(l.getLockId(), "PENDING")
.stream().filter(t -> t.getAcceptDeadline().isBefore(LocalDateTime.now())).toList();
for (var t : expiredTasks) {
t.setStatus("EXPIRED");
applyAssignedTaskPenalty(l, t);
assignedTaskRepository.save(t);
lockDirty = true;
sendMessage(l.getKeyholder(), l.getLockee(),
"Die dir gestellte Aufgabe ist abgelaufen, ohne dass du reagiert hast. Die Strafe wurde automatisch angewendet.",
"/activelock.html?lockId=" + l.getLockId());
}
if (lockDirty) cardlockRepository.save(l);
// Ausstehende Keyholder-Aufgaben (ohne Aufgabentext)
var pendingAssigned = assignedTaskRepository.findByLockIdAndStatus(l.getLockId(), "PENDING")
.stream()
.filter(t -> t.getAcceptDeadline().isAfter(LocalDateTime.now()))
.map(t -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("taskId", t.getTaskId().toString());
m.put("taskTitle", t.getTaskTitle() != null ? t.getTaskTitle() : t.getTaskText());
m.put("taskDescription", t.getTaskDescription() != null ? t.getTaskDescription() : "");
m.put("taskMinutes", t.getTaskMinutes() != null ? t.getTaskMinutes() : 0);
m.put("assignedAt", t.getAssignedAt().toString());
m.put("acceptDeadline", t.getAcceptDeadline().toString());
m.put("penaltyFreezeMinutes", t.getPenaltyFreezeMinutes() != null ? t.getPenaltyFreezeMinutes() : 0);
m.put("penaltyRedCards", t.getPenaltyRedCards() != null ? t.getPenaltyRedCards() : 0);
return m;
})
.toList();
result.put("assignedTasks", pendingAssigned);
result.put("taskCardMode", l.getTaskCardMode());
// Ausstehende Keyholder-Choices
boolean pendingKeyholderChoice = !keyholderTaskChoiceRepository
.findByLockIdAndStatus(l.getLockId(), "PENDING").isEmpty();
result.put("pendingKeyholderChoice", pendingKeyholderChoice);
// Aktive Community-Vote
var activeVotes = communityTaskVoteRepository.findByStatus("ACTIVE").stream()
.filter(v -> v.getLockId().equals(l.getLockId())).findFirst();
if (activeVotes.isPresent()) {
var v = activeVotes.get();
result.put("activeCommunityVote", Map.of(
"voteSessionId", v.getVoteSessionId().toString(),
"expiresAt", v.getExpiresAt().toString()
));
}
// Notfall-Entsperrung: nach 1 Stunde automatisch öffnen
if (l.getEmergencyUnlockRequestedAt() != null
&& !l.isKeyholderRequestedUnlock()
&& l.getEmergencyUnlockRequestedAt().isBefore(LocalDateTime.now().minusHours(1))) {
l.setEmergencyAutoUnlocked(true);
l.setKeyholderRequestedUnlock(true);
cardlockRepository.save(l);
}
// Keyholder hat Unlock angefordert → Unlock-Code mitliefern
result.put("keyholderRequestedUnlock", l.isKeyholderRequestedUnlock());
if (l.isKeyholderRequestedUnlock()) {
result.put("unlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : "");
saveUnlockCodeHistory(myId, l.getLockId(), l.getName(), l.getUnlockCode(), "KEYHOLDER_UNLOCK");
}
result.put("testLock", l.isTestLock());
result.put("emergencyUnlockRequested", l.getEmergencyUnlockRequestedAt() != null);
if (l.isTestLock()) {
result.put("unlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : "");
}
return ResponseEntity.ok(result);
}
@@ -643,6 +780,7 @@ public class CardLockController {
msg.setReceiverId(lock.getLockee());
msg.setText(me.getName() + " hat die Einladung als Keyholder*In für das Lock „" + lockName + "\" abgelehnt.");
msg.setSentAt(LocalDateTime.now());
msg.setSystemMessage(true);
messageRepository.save(msg);
}
@@ -704,6 +842,7 @@ public class CardLockController {
msg.setReceiverId(inv.getKeyholderUserId());
msg.setText(me.getName() + " hat die Keyholder-Einladung für das Lock „" + lockName + "\" zurückgezogen.");
msg.setSentAt(LocalDateTime.now());
msg.setSystemMessage(true);
messageRepository.save(msg);
return ResponseEntity.noContent().build();
@@ -727,8 +866,12 @@ public class CardLockController {
item.put("lockeeName", lockee.getName());
item.put("lockeeId", lockee.getUserId().toString());
item.put("lockeeProfilePic", lockee.getProfilePicture());
item.put("totalCards", lock.getAvailableCards() != null ? lock.getAvailableCards().size() : 0);
item.put("startTime", lock.getStartTime() != null ? lock.getStartTime().toString() : null);
item.put("totalCards", lock.getAvailableCards() != null ? lock.getAvailableCards().size() : 0);
item.put("startTime", lock.getStartTime() != null ? lock.getStartTime().toString() : null);
boolean frozenByKh = lock.getFrozenUntill() != null
&& lock.getFrozenUntill().isAfter(LocalDateTime.now())
&& (lock.getCurrentTask() == null || lock.getCurrentTask().isBlank());
item.put("isFrozenByKeyholder", frozenByKh);
result.add(item);
}
return ResponseEntity.ok(result);
@@ -811,7 +954,11 @@ public class CardLockController {
result.put("openPicks", l.getOpenPicks() != null ? l.getOpenPicks() : 0);
result.put("nextCardIn", l.getNextCardIn() != null ? l.getNextCardIn().toString() : null);
result.put("frozenUntill", l.getFrozenUntill() != null ? l.getFrozenUntill().toString() : null);
result.put("taskFrozenUntil", l.getTaskFrozenUntil() != null ? l.getTaskFrozenUntil().toString() : null);
boolean isFrozenByKeyholder = l.getFrozenUntill() != null && l.getFrozenUntill().isAfter(LocalDateTime.now());
result.put("isFrozenByKeyholder", isFrozenByKeyholder);
result.put("currentTask", l.getCurrentTask());
result.put("currentTaskDescription", l.getCurrentTaskDescription());
result.put("startTime", l.getStartTime() != null ? l.getStartTime().toString() : null);
result.put("hygieneEnabled", hygieneEnabled);
result.put("hygieneOpeningDue", hygieneOpeningDue);
@@ -827,6 +974,66 @@ public class CardLockController {
result.put("verificationDownvotes", verificationDownvotes);
result.put("hygieneViolations", recentViolations);
result.put("hasTasks", l.getTasks() != null && !l.getTasks().isEmpty());
if (l.getTasks() != null) {
var taskList = l.getTasks().stream()
.map(t -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("title", t.resolveTitle());
m.put("description", t.getDescription() != null ? t.getDescription() : "");
m.put("minutes", t.getMinutes() != null ? t.getMinutes() : 0);
return m;
})
.toList();
result.put("taskList", taskList);
} else {
result.put("taskList", List.of());
}
var pendingAssigned = assignedTaskRepository.findByLockIdAndStatus(lockId, "PENDING")
.stream()
.filter(t -> t.getAcceptDeadline().isAfter(LocalDateTime.now()))
.map(t -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("taskId", t.getTaskId().toString());
m.put("taskTitle", t.getTaskTitle() != null ? t.getTaskTitle() : t.getTaskText());
m.put("taskDescription", t.getTaskDescription() != null ? t.getTaskDescription() : "");
m.put("taskMinutes", t.getTaskMinutes() != null ? t.getTaskMinutes() : 0);
m.put("assignedAt", t.getAssignedAt().toString());
m.put("acceptDeadline", t.getAcceptDeadline().toString());
m.put("penaltyFreezeMinutes", t.getPenaltyFreezeMinutes() != null ? t.getPenaltyFreezeMinutes() : 0);
m.put("penaltyRedCards", t.getPenaltyRedCards() != null ? t.getPenaltyRedCards() : 0);
return m;
})
.toList();
result.put("pendingAssignedTasks", pendingAssigned);
result.put("taskCardMode", l.getTaskCardMode());
// Ausstehende Task-Karten-Choices (KEYHOLDER-Modus)
List<Task> lockTasks = l.getTasks() != null ? l.getTasks() : List.of();
List<Map<String, Object>> taskListForChoice = new ArrayList<>();
for (int i = 0; i < lockTasks.size(); i++) {
Task t = lockTasks.get(i);
Map<String, Object> tm = new LinkedHashMap<>();
tm.put("index", i);
tm.put("title", t.resolveTitle());
tm.put("description", t.getDescription() != null ? t.getDescription() : "");
tm.put("minutes", t.getMinutes() != null ? t.getMinutes() : 0);
taskListForChoice.add(tm);
}
var pendingChoices = keyholderTaskChoiceRepository.findByLockIdAndStatus(lockId, "PENDING")
.stream()
.map(c -> {
Map<String, Object> cm = new LinkedHashMap<>();
cm.put("choiceId", c.getChoiceId().toString());
cm.put("createdAt", c.getCreatedAt().toString());
cm.put("tasks", taskListForChoice);
return cm;
})
.toList();
result.put("pendingTaskChoices", pendingChoices);
result.put("keyholderRequestedUnlock", l.isKeyholderRequestedUnlock());
result.put("emergencyUnlockRequested", l.getEmergencyUnlockRequestedAt() != null);
result.put("emergencyUnlockRequestedAt", l.getEmergencyUnlockRequestedAt() != null ? l.getEmergencyUnlockRequestedAt().toString() : null);
return ResponseEntity.ok(result);
}
@@ -899,6 +1106,7 @@ public class CardLockController {
msg.setReceiverId(l.getLockee());
msg.setText(msgText);
msg.setSentAt(LocalDateTime.now());
msg.setSystemMessage(true);
messageRepository.save(msg);
return ResponseEntity.noContent().build();
@@ -963,11 +1171,383 @@ public class CardLockController {
msg.setReceiverId(l.getLockee());
msg.setText(msgText);
msg.setSentAt(LocalDateTime.now());
msg.setSystemMessage(true);
messageRepository.save(msg);
return ResponseEntity.noContent().build();
}
// ── Hilfsmethoden ──────────────────────────────────────────────────────────
private void applyAssignedTaskPenalty(CardLockEntity l, AssignedTaskEntity task) {
if (task.getPenaltyFreezeMinutes() != null && task.getPenaltyFreezeMinutes() > 0) {
LocalDateTime until = LocalDateTime.now().plusMinutes(task.getPenaltyFreezeMinutes());
// Bestehenden Freeze nur verlängern, nie verkürzen
if (l.getFrozenUntill() == null || until.isAfter(l.getFrozenUntill())) {
l.setFrozenUntill(until);
l.setNextCardIn(until);
}
}
if (task.getPenaltyRedCards() != null && task.getPenaltyRedCards() > 0) {
List<CardEnum> cards = new ArrayList<>(l.getAvailableCards() != null ? l.getAvailableCards() : List.of());
for (int i = 0; i < task.getPenaltyRedCards(); i++) {
cards.add(CardEnum.RED);
}
l.setAvailableCards(cards);
}
}
private void sendMessage(UUID senderId, UUID receiverId, String text, String targetUrl) {
if (senderId == null || receiverId == null) return;
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(senderId);
msg.setReceiverId(receiverId);
msg.setText(text);
msg.setSentAt(LocalDateTime.now());
msg.setSystemMessage(true);
if (targetUrl != null) msg.setTargetUrl(targetUrl);
messageRepository.save(msg);
long unread = messageRepository.countByReceiverIdAndSystemMessageAndReadAtIsNull(receiverId, true);
sseService.push(receiverId, "NOTIFICATION", java.util.Map.of("unreadCount", unread, "text", text));
}
// ── Entsperrcode-Historie ──────────────────────────────────────────────────
private void saveUnlockCodeHistory(UUID userId, UUID lockId, String lockName, String unlockCode, String source) {
if (unlockCode == null || unlockCode.isBlank()) return;
// Deduplizierung: gleicher Code+Quelle+Lock bereits gespeichert → überspringen
if (unlockCodeHistoryRepository.existsByLockIdAndSourceAndUnlockCode(lockId, source, unlockCode)) return;
UnlockCodeHistoryEntity entry = new UnlockCodeHistoryEntity();
entry.setUserId(userId);
entry.setLockId(lockId);
entry.setLockName(lockName != null && !lockName.isBlank() ? lockName : "Unbenanntes Lock");
entry.setUnlockCode(unlockCode);
entry.setSource(source);
entry.setReceivedAt(LocalDateTime.now());
unlockCodeHistoryRepository.save(entry);
// Nur die letzten 10 behalten
long count = unlockCodeHistoryRepository.countByUserId(userId);
if (count > 10) {
var oldest = unlockCodeHistoryRepository.findByUserIdOrderByReceivedAtAsc(userId);
for (int i = 0; i < count - 10; i++) {
unlockCodeHistoryRepository.delete(oldest.get(i));
}
}
}
@GetMapping("/cardlock/unlock-history")
public ResponseEntity<List<Map<String, Object>>> getUnlockHistory(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var entries = unlockCodeHistoryRepository.findByUserIdOrderByReceivedAtDesc(myId,
org.springframework.data.domain.PageRequest.of(0, 10));
List<Map<String, Object>> result = new ArrayList<>();
for (var e : entries) {
Map<String, Object> item = new HashMap<>();
item.put("lockName", e.getLockName());
item.put("unlockCode", e.getUnlockCode());
item.put("source", e.getSource());
item.put("receivedAt", e.getReceivedAt().toString());
result.add(item);
}
return ResponseEntity.ok(result);
}
// ── Keyholder: Aufgabe stellen ─────────────────────────────────────────────
record AssignTaskRequest(int taskIndex, int acceptDeadlineMinutes,
Integer penaltyFreezeMinutes, Integer penaltyRedCards) {}
@Transactional
@PostMapping("/as-keyholder/{lockId}/assign-task")
public ResponseEntity<?> assignTask(@PathVariable UUID lockId,
@RequestBody AssignTaskRequest req,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
var me = meOpt.get();
var lockOpt = cardlockRepository.findById(lockId);
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!me.getUserId().equals(l.getKeyholder())) return ResponseEntity.status(403).build();
var tasks = l.getTasks();
if (tasks == null || tasks.isEmpty())
return ResponseEntity.badRequest().body(Map.of("error", "Dieses Lock hat keine Aufgaben."));
if (req.taskIndex() < 0 || req.taskIndex() >= tasks.size())
return ResponseEntity.badRequest().body(Map.of("error", "Ungültiger Aufgaben-Index."));
if (req.acceptDeadlineMinutes() < 1)
return ResponseEntity.badRequest().body(Map.of("error", "Die Annahme-Frist muss mindestens 1 Minute betragen."));
long pendingCount = assignedTaskRepository.findByLockIdAndStatus(lockId, "PENDING").stream()
.filter(t -> t.getAcceptDeadline().isAfter(LocalDateTime.now()))
.count();
if (pendingCount >= 5)
return ResponseEntity.badRequest().body(Map.of("error", "Es sind bereits 5 Aufgaben offen. Bitte warte, bis der Lockee eine davon annimmt oder ablehnt."));
Task task = tasks.get(req.taskIndex());
AssignedTaskEntity assigned = new AssignedTaskEntity();
assigned.setLockId(lockId);
assigned.setTaskTitle(task.resolveTitle());
assigned.setTaskDescription(task.getDescription());
assigned.setTaskText(task.resolveTitle()); // Compat
assigned.setTaskMinutes(task.getMinutes());
assigned.setAssignedAt(LocalDateTime.now());
assigned.setAcceptDeadline(LocalDateTime.now().plusMinutes(req.acceptDeadlineMinutes()));
assigned.setPenaltyFreezeMinutes(req.penaltyFreezeMinutes());
assigned.setPenaltyRedCards(req.penaltyRedCards());
assigned.setStatus("PENDING");
assignedTaskRepository.save(assigned);
sendMessage(me.getUserId(), l.getLockee(),
me.getName() + " hat dir eine Aufgabe gestellt. Du hast " +
req.acceptDeadlineMinutes() + " Minuten, um sie anzunehmen.",
"/activelock.html?lockId=" + lockId);
return ResponseEntity.noContent().build();
}
// ── Lockee: Aufgabe annehmen ───────────────────────────────────────────────
@Transactional
@PostMapping("/cardlock/{lockId}/assigned-tasks/{taskId}/accept")
public ResponseEntity<?> acceptAssignedTask(@PathVariable UUID lockId,
@PathVariable UUID taskId,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var lockOpt = cardlockRepository.findById(lockId);
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
var taskOpt = assignedTaskRepository.findById(taskId);
if (taskOpt.isEmpty() || !taskOpt.get().getLockId().equals(lockId))
return ResponseEntity.notFound().build();
var task = taskOpt.get();
if (!"PENDING".equals(task.getStatus()))
return ResponseEntity.status(409).body(Map.of("error", "Diese Aufgabe ist nicht mehr ausstehend."));
if (task.getAcceptDeadline().isBefore(LocalDateTime.now())) {
// Bereits abgelaufen Strafe anwenden
task.setStatus("EXPIRED");
applyAssignedTaskPenalty(l, task);
assignedTaskRepository.save(task);
cardlockRepository.save(l);
return ResponseEntity.status(409).body(Map.of("error", "Die Annahme-Frist ist abgelaufen. Die Strafe wurde angewendet."));
}
boolean hasActiveTask = (l.getCurrentTask() != null && !l.getCurrentTask().isBlank())
|| (l.getTaskFrozenUntil() != null && l.getTaskFrozenUntil().isAfter(LocalDateTime.now()));
if (hasActiveTask)
return ResponseEntity.status(409).body(Map.of("error", "Du hast bereits eine laufende Aufgabe."));
// Aufgabe aktivieren separater Task-Timer, kein Freeze
String title = task.getTaskTitle() != null ? task.getTaskTitle() : task.getTaskText();
l.setCurrentTask(title);
l.setCurrentTaskDescription(task.getTaskDescription());
if (task.getTaskMinutes() != null && task.getTaskMinutes() > 0) {
l.setTaskFrozenUntil(LocalDateTime.now().plusMinutes(task.getTaskMinutes()));
// Fälligkeit aller anderen offenen Aufgaben um die Task-Dauer verschieben
final int extraMinutes = task.getTaskMinutes();
assignedTaskRepository.findByLockIdAndStatus(lockId, "PENDING").stream()
.filter(t -> !t.getTaskId().equals(taskId))
.forEach(t -> {
t.setAcceptDeadline(t.getAcceptDeadline().plusMinutes(extraMinutes));
assignedTaskRepository.save(t);
});
}
task.setStatus("ACCEPTED");
assignedTaskRepository.save(task);
cardlockRepository.save(l);
sendMessage(myId, l.getKeyholder(), meOpt.get().getName() + " hat die gestellte Aufgabe angenommen.", "/keyholder.html");
return ResponseEntity.noContent().build();
}
// ── Lockee: Aufgabe ablehnen ───────────────────────────────────────────────
@Transactional
@PostMapping("/cardlock/{lockId}/assigned-tasks/{taskId}/decline")
public ResponseEntity<?> declineAssignedTask(@PathVariable UUID lockId,
@PathVariable UUID taskId,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var lockOpt = cardlockRepository.findById(lockId);
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
var taskOpt = assignedTaskRepository.findById(taskId);
if (taskOpt.isEmpty() || !taskOpt.get().getLockId().equals(lockId))
return ResponseEntity.notFound().build();
var task = taskOpt.get();
if (!"PENDING".equals(task.getStatus()))
return ResponseEntity.status(409).body(Map.of("error", "Diese Aufgabe ist nicht mehr ausstehend."));
task.setStatus("DECLINED");
applyAssignedTaskPenalty(l, task);
assignedTaskRepository.save(task);
cardlockRepository.save(l);
sendMessage(myId, l.getKeyholder(), meOpt.get().getName() + " hat die gestellte Aufgabe abgelehnt. Die Strafe wurde angewendet.", "/keyholder.html");
return ResponseEntity.noContent().build();
}
// ── Keyholder: Aufgabe zurückziehen ───────────────────────────────────────
@Transactional
@DeleteMapping("/as-keyholder/{lockId}/assigned-tasks/{taskId}")
public ResponseEntity<?> cancelAssignedTask(@PathVariable UUID lockId,
@PathVariable UUID taskId,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var lockOpt = cardlockRepository.findById(lockId);
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!myId.equals(l.getKeyholder())) return ResponseEntity.status(403).build();
var taskOpt = assignedTaskRepository.findById(taskId);
if (taskOpt.isEmpty() || !taskOpt.get().getLockId().equals(lockId))
return ResponseEntity.notFound().build();
var task = taskOpt.get();
if (!"PENDING".equals(task.getStatus()))
return ResponseEntity.status(409).body(Map.of("error", "Aufgabe ist nicht mehr ausstehend."));
assignedTaskRepository.delete(task);
return ResponseEntity.noContent().build();
}
record FreezeRequest(String frozenUntil) {}
@Transactional
@PostMapping("/as-keyholder/{lockId}/freeze")
public ResponseEntity<?> freezeLock(@PathVariable UUID lockId,
@RequestBody FreezeRequest req,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
var me = meOpt.get();
UUID myId = me.getUserId();
var lockOpt = cardlockRepository.findById(lockId);
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!myId.equals(l.getKeyholder())) return ResponseEntity.status(403).build();
if (l.getCurrentTask() != null && !l.getCurrentTask().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "Das Lock ist gerade durch eine Aufgabe eingefroren."));
}
LocalDateTime until;
try {
until = LocalDateTime.parse(req.frozenUntil());
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", "Ungültiges Datumsformat."));
}
if (!until.isAfter(LocalDateTime.now())) {
return ResponseEntity.badRequest().body(Map.of("error", "Zeitpunkt muss in der Zukunft liegen."));
}
l.setFrozenUntill(until);
cardlockRepository.save(l);
sendMessage(myId, l.getLockee(), me.getName() + " hat dein Lock bis " +
until.toLocalDate().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")) +
" " + until.toLocalTime().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) +
" Uhr eingefroren.",
"/activelock.html?lockId=" + lockId);
return ResponseEntity.noContent().build();
}
@Transactional
@DeleteMapping("/as-keyholder/{lockId}/freeze")
public ResponseEntity<?> unfreezeLock(@PathVariable UUID lockId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
var me = meOpt.get();
UUID myId = me.getUserId();
var lockOpt = cardlockRepository.findById(lockId);
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!myId.equals(l.getKeyholder())) return ResponseEntity.status(403).build();
if (l.getCurrentTask() != null && !l.getCurrentTask().isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "Das Lock ist durch eine Aufgabe eingefroren und kann nicht manuell entfroren werden."));
}
l.setFrozenUntill(null);
cardlockRepository.save(l);
sendMessage(myId, l.getLockee(), me.getName() + " hat dein Lock wieder entfroren.",
"/activelock.html?lockId=" + lockId);
return ResponseEntity.noContent().build();
}
@Transactional
@PostMapping("/as-keyholder/{lockId}/request-unlock")
public ResponseEntity<?> requestUnlock(@PathVariable UUID lockId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var lockOpt = cardlockRepository.findById(lockId);
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!myId.equals(l.getKeyholder())) return ResponseEntity.status(403).build();
l.setKeyholderRequestedUnlock(true);
cardlockRepository.save(l);
sendMessage(myId, l.getLockee(),
"Dein Keyholder hat das Lock freigeschaltet. Du erhältst beim nächsten Laden deinen Entsperrcode.",
"/activelock.html?lockId=" + lockId);
return ResponseEntity.noContent().build();
}
@Transactional
@PostMapping("/cardlock/{lockId}/emergency-unlock")
public ResponseEntity<?> requestEmergencyUnlock(@PathVariable UUID lockId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
var me = meOpt.get();
UUID myId = me.getUserId();
var lockOpt = cardlockRepository.findById(lockId);
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var l = lockOpt.get();
if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
if (l.isTestLock()) return ResponseEntity.badRequest().build();
if (l.getEmergencyUnlockRequestedAt() != null) return ResponseEntity.status(409).build();
l.setEmergencyUnlockRequestedAt(LocalDateTime.now());
if (l.getKeyholder() == null) {
// Self-Lock ohne Keyholderin → sofort öffnen
l.setEmergencyAutoUnlocked(true);
l.setKeyholderRequestedUnlock(true);
} else {
// Keyholderin benachrichtigen
sendMessage(myId, l.getKeyholder(),
"⚠️ NOTFALL: " + me.getName() + " bittet dringend um Freigabe des Locks. Bitte reagiere innerhalb einer Stunde, sonst öffnet sich das Lock automatisch.",
"/keyholder.html");
}
cardlockRepository.save(l);
return ResponseEntity.noContent().build();
}
private String cardLabel(CardEnum card) {
return switch (card) {
case RED -> "Rote Karte";

View File

@@ -76,12 +76,32 @@ public class CardLockEntity {
private LocalDateTime unlockTime;
@Column
private String currentTask;
@Column(columnDefinition = "TEXT")
private String currentTaskDescription;
@Column
private LocalDateTime taskFrozenUntil;
@Convert(converter = TaskListConverter.class)
@Column(columnDefinition = "TEXT")
private List<Task> tasksInQueue;
@Column
private String unlockCode;
/** Keyholder hat Unlock angefordert nächste Aktion der Lockee zeigt grüne Karte */
@Column(nullable = false)
private boolean keyholderRequestedUnlock = false;
/** Lockee hat Notfall-Entsperrung angefordert */
@Column
private java.time.LocalDateTime emergencyUnlockRequestedAt;
/** true = System hat automatisch entsperrt (Keyholderin nicht reagiert) */
@Column(nullable = false)
private boolean emergencyAutoUnlocked = false;
/** RANDOM | KEYHOLDER | COMMUNITY */
@Column(nullable = false)
private String taskCardMode = "RANDOM";
public UUID getLockId() {
return lockId;
}
@@ -242,13 +262,14 @@ public class CardLockEntity {
this.tasks = tasks;
}
public String getCurrentTask() {
return currentTask;
}
public String getCurrentTask() { return currentTask; }
public void setCurrentTask(String currentTask) { this.currentTask = currentTask; }
public void setCurrentTask(String currentTask) {
this.currentTask = currentTask;
}
public String getCurrentTaskDescription() { return currentTaskDescription; }
public void setCurrentTaskDescription(String d) { this.currentTaskDescription = d; }
public LocalDateTime getTaskFrozenUntil() { return taskFrozenUntil; }
public void setTaskFrozenUntil(LocalDateTime t) { this.taskFrozenUntil = t; }
public List<Task> getTasksInQueue() {
return tasksInQueue;
@@ -289,4 +310,16 @@ public class CardLockEntity {
public void setUnlockCodeLines(Integer unlockCodeLines) {
this.unlockCodeLines = unlockCodeLines;
}
public String getTaskCardMode() { return taskCardMode != null ? taskCardMode : "RANDOM"; }
public void setTaskCardMode(String taskCardMode) { this.taskCardMode = taskCardMode; }
public boolean isKeyholderRequestedUnlock() { return keyholderRequestedUnlock; }
public void setKeyholderRequestedUnlock(boolean v) { this.keyholderRequestedUnlock = v; }
public java.time.LocalDateTime getEmergencyUnlockRequestedAt() { return emergencyUnlockRequestedAt; }
public void setEmergencyUnlockRequestedAt(java.time.LocalDateTime t) { this.emergencyUnlockRequestedAt = t; }
public boolean isEmergencyAutoUnlocked() { return emergencyAutoUnlocked; }
public void setEmergencyAutoUnlocked(boolean v) { this.emergencyAutoUnlocked = v; }
}

View File

@@ -1,4 +1,4 @@
package de.oaa.xxx.games.chastity;
package de.oaa.xxx.games.chastity.cardlock;
import java.time.Duration;
import java.time.LocalDate;
@@ -11,10 +11,7 @@ import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import de.oaa.xxx.games.chastity.cardlock.CardDTO;
import de.oaa.xxx.games.chastity.cardlock.CardEnum;
import de.oaa.xxx.games.chastity.cardlock.CardLockEntity;
import de.oaa.xxx.games.chastity.cardlock.CardLockRepository;
import de.oaa.xxx.games.chastity.ProcessLock;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.games.chastity.verification.VerificationEntity;
import de.oaa.xxx.games.chastity.verification.VerificationRepository;
@@ -39,7 +36,9 @@ public class CardLockService extends ProcessLock {
public CardDTO getNextCard() {
LOGGER.debug("New Card requested by user {}", lock.getLockee());
CardDTO card = null;
if (lock.isAccumulatePicks()) {
if (lock.getLatestOpeningtime() != null && lock.getLatestOpeningtime().isAfter(LocalDateTime.now())) {
card = getGreenCard();
} else if (lock.isAccumulatePicks()) {
if (lock.getNextCardIn().isAfter(LocalDateTime.now())) {
lock.setOpenPicks(lock.getOpenPicks() == null ? 1 : lock.getOpenPicks() + 1);
}
@@ -51,8 +50,7 @@ public class CardLockService extends ProcessLock {
if (lock.getNextCardIn().isBefore(LocalDateTime.now())) {
lock.setNextCardIn(LocalDateTime.now().plusMinutes(lock.getPickEveryMinute()));
card = getRandomCard();
lock.getAvailableCards().remove(card.card());
}
}
}
cardLockRepository.save(lock);
return card;
@@ -67,6 +65,10 @@ public class CardLockService extends ProcessLock {
return card.get().processCard(this);
}
LOGGER.error("Keine Karten mehr im Lock - generiere Notfall Grüne Karte");
return getGreenCard();
}
private CardDTO getGreenCard() {
return new CardDTO(CardEnum.GREEN, lock.getUnlockCode());
}
@@ -91,7 +93,8 @@ public class CardLockService extends ProcessLock {
public void unlock(String unlockCode) {
this.lock.setUnlockTime(LocalDateTime.now());
boolean valid = true;
// Self-Lock oder automatische Entsperrung ohne Keyholder-Zustimmung ungültig
boolean valid = lock.getKeyholder() != null && !lock.isEmergencyAutoUnlocked();
if (!this.lock.isTestLock()) {
if (Duration.between(lock.getStartTime(), lock.getUnlockTime()).toHours() > 24) {
Set<LocalDate> verifications = verificationRepository.findByLockId(this.lock.getLockId()).stream()
@@ -159,6 +162,11 @@ public class CardLockService extends ProcessLock {
}
public String task() {
// Non-RANDOM modes are handled by the controller after the card is drawn
if (!"RANDOM".equals(lock.getTaskCardMode())) {
LOGGER.debug("Task card drawn in {} mode skipping random assignment", lock.getTaskCardMode());
return "";
}
LOGGER.debug("Apply random task");
var tasks = lock.getTasks();
if (!tasks.isEmpty()) {
@@ -169,17 +177,19 @@ public class CardLockService extends ProcessLock {
public String task(Task task) {
LOGGER.debug("Apply task {}", task);
lock.setCurrentTask(task.getText());
if (task.getMinutes() != null) {
freeze(task.getMinutes());
lock.setCurrentTask(task.resolveTitle());
lock.setCurrentTaskDescription(task.getDescription());
if (task.getMinutes() != null && task.getMinutes() > 0) {
lock.setTaskFrozenUntil(LocalDateTime.now().plusMinutes(task.getMinutes()));
}
return "";
}
public String clearTask() {
LOGGER.debug("Clear task");
lock.setFrozenUntill(null);
lock.setCurrentTask(null);
lock.setCurrentTaskDescription(null);
lock.setTaskFrozenUntil(null);
return "";
}

View File

@@ -32,7 +32,8 @@ public class CardlockTemplateController {
Integer hygineOpeningDurationMinutes,
Integer hygineOpeningEveryMinites,
List<Task> tasks,
boolean requiresVerification
boolean requiresVerification,
String taskCardMode
) {}
private Map<String, Object> toDto(CardlockTemplateEntity t) {
@@ -48,6 +49,7 @@ public class CardlockTemplateController {
dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes());
dto.put("tasks", t.getTasks() != null ? t.getTasks() : List.of());
dto.put("requiresVerification", t.isRequiresVerification());
dto.put("taskCardMode", t.getTaskCardMode());
return dto;
}
@@ -127,5 +129,6 @@ public class CardlockTemplateController {
t.setHygineOpeningDurationMinutes(req.hygineOpeningDurationMinutes());
t.setTasks(req.tasks() != null ? req.tasks() : List.of());
t.setRequiresVerification(req.requiresVerification());
t.setTaskCardMode(req.taskCardMode() != null ? req.taskCardMode() : "RANDOM");
}
}

View File

@@ -53,6 +53,9 @@ public class CardlockTemplateEntity {
@Column
private boolean requiresVerification;
@Column(nullable = false)
private String taskCardMode = "RANDOM";
public UUID getTemplateId() { return templateId; }
public void setTemplateId(UUID templateId) { this.templateId = templateId; }
@@ -88,4 +91,7 @@ public class CardlockTemplateEntity {
public boolean isRequiresVerification() { return requiresVerification; }
public void setRequiresVerification(boolean requiresVerification) { this.requiresVerification = requiresVerification; }
public String getTaskCardMode() { return taskCardMode != null ? taskCardMode : "RANDOM"; }
public void setTaskCardMode(String taskCardMode) { this.taskCardMode = taskCardMode; }
}

View File

@@ -0,0 +1,56 @@
package de.oaa.xxx.games.chastity.cardlock;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "community_task_vote")
public class CommunityTaskVoteEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID voteSessionId;
@Column(nullable = false)
private UUID lockId;
/** ACTIVE | COMPLETED */
@Column(nullable = false)
private String status = "ACTIVE";
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime expiresAt;
/** true = TestLock, nicht der Community zeigen */
@Column(nullable = false)
private boolean testLock = false;
/** null until completed */
@Column
private Integer winningTaskIndex;
public UUID getVoteSessionId() { return voteSessionId; }
public void setVoteSessionId(UUID id) { this.voteSessionId = id; }
public UUID getLockId() { return lockId; }
public void setLockId(UUID lockId) { this.lockId = lockId; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime t) { this.createdAt = t; }
public LocalDateTime getExpiresAt() { return expiresAt; }
public void setExpiresAt(LocalDateTime t) { this.expiresAt = t; }
public boolean isTestLock() { return testLock; }
public void setTestLock(boolean testLock) { this.testLock = testLock; }
public Integer getWinningTaskIndex() { return winningTaskIndex; }
public void setWinningTaskIndex(Integer i) { this.winningTaskIndex = i; }
}

View File

@@ -0,0 +1,35 @@
package de.oaa.xxx.games.chastity.cardlock;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "community_task_vote_entry",
uniqueConstraints = @UniqueConstraint(columnNames = {"voteSessionId", "voterUserId"}))
public class CommunityTaskVoteEntryEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID entryId;
@Column(nullable = false)
private UUID voteSessionId;
@Column(nullable = false)
private UUID voterUserId;
@Column(nullable = false)
private int taskIndex;
public UUID getEntryId() { return entryId; }
public void setEntryId(UUID entryId) { this.entryId = entryId; }
public UUID getVoteSessionId() { return voteSessionId; }
public void setVoteSessionId(UUID id) { this.voteSessionId = id; }
public UUID getVoterUserId() { return voterUserId; }
public void setVoterUserId(UUID id) { this.voterUserId = id; }
public int getTaskIndex() { return taskIndex; }
public void setTaskIndex(int taskIndex) { this.taskIndex = taskIndex; }
}

View File

@@ -0,0 +1,11 @@
package de.oaa.xxx.games.chastity.cardlock;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface CommunityTaskVoteEntryRepository extends JpaRepository<CommunityTaskVoteEntryEntity, UUID> {
List<CommunityTaskVoteEntryEntity> findByVoteSessionId(UUID voteSessionId);
boolean existsByVoteSessionIdAndVoterUserId(UUID voteSessionId, UUID voterUserId);
Integer countByVoteSessionIdAndTaskIndex(UUID voteSessionId, int taskIndex);
}

View File

@@ -0,0 +1,12 @@
package de.oaa.xxx.games.chastity.cardlock;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public interface CommunityTaskVoteRepository extends JpaRepository<CommunityTaskVoteEntity, UUID> {
List<CommunityTaskVoteEntity> findByStatus(String status);
List<CommunityTaskVoteEntity> findByStatusAndExpiresAtBefore(String status, LocalDateTime time);
boolean existsByLockIdAndStatus(UUID lockId, String status);
}

View File

@@ -1,7 +1,5 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.CardLockService;
public class DoubleUpCard implements Card {
@Override

View File

@@ -1,7 +1,5 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.CardLockService;
public class FreezeCard implements Card {
@Override

View File

@@ -1,7 +1,5 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.CardLockService;
public class GreenCard implements Card {
@Override

View File

@@ -0,0 +1,36 @@
package de.oaa.xxx.games.chastity.cardlock;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "keyholder_task_choice")
public class KeyholderTaskChoiceEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID choiceId;
@Column(nullable = false)
private UUID lockId;
/** PENDING | CHOSEN */
@Column(nullable = false)
private String status = "PENDING";
@Column(nullable = false)
private LocalDateTime createdAt;
public UUID getChoiceId() { return choiceId; }
public void setChoiceId(UUID choiceId) { this.choiceId = choiceId; }
public UUID getLockId() { return lockId; }
public void setLockId(UUID lockId) { this.lockId = lockId; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime t) { this.createdAt = t; }
}

View File

@@ -0,0 +1,9 @@
package de.oaa.xxx.games.chastity.cardlock;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface KeyholderTaskChoiceRepository extends JpaRepository<KeyholderTaskChoiceEntity, UUID> {
List<KeyholderTaskChoiceEntity> findByLockIdAndStatus(UUID lockId, String status);
}

View File

@@ -1,7 +1,5 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.CardLockService;
public class RedCard implements Card {
@Override

View File

@@ -1,7 +1,5 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.CardLockService;
public class ResetCard implements Card {
@Override

View File

@@ -1,7 +1,5 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.CardLockService;
public class TaskCard implements Card {
@Override

View File

@@ -0,0 +1,249 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.tasks.AssignedTaskEntity;
import de.oaa.xxx.games.chastity.tasks.AssignedTaskRepository;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.social.SseService;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.social.entity.MessageEntity;
import de.oaa.xxx.social.repository.MessageRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.*;
@RestController
@RequestMapping("/task-card")
public class TaskCardController {
private final CardlockRepository cardlockRepository;
private final UserRepository userRepository;
private final KeyholderTaskChoiceRepository keyholderTaskChoiceRepository;
private final CommunityTaskVoteRepository communityTaskVoteRepository;
private final CommunityTaskVoteEntryRepository communityTaskVoteEntryRepository;
private final AssignedTaskRepository assignedTaskRepository;
private final MessageRepository messageRepository;
private final SseService sseService;
public TaskCardController(CardlockRepository cardlockRepository,
UserRepository userRepository,
KeyholderTaskChoiceRepository keyholderTaskChoiceRepository,
CommunityTaskVoteRepository communityTaskVoteRepository,
CommunityTaskVoteEntryRepository communityTaskVoteEntryRepository,
AssignedTaskRepository assignedTaskRepository,
MessageRepository messageRepository,
SseService sseService) {
this.cardlockRepository = cardlockRepository;
this.userRepository = userRepository;
this.keyholderTaskChoiceRepository = keyholderTaskChoiceRepository;
this.communityTaskVoteRepository = communityTaskVoteRepository;
this.communityTaskVoteEntryRepository = communityTaskVoteEntryRepository;
this.assignedTaskRepository = assignedTaskRepository;
this.messageRepository = messageRepository;
this.sseService = sseService;
}
// ── Keyholder: ausstehende Aufgaben-Karten-Entscheidungen ─────────────────
@GetMapping("/keyholder/choices")
@Transactional(readOnly = true)
public ResponseEntity<List<Map<String, Object>>> getPendingChoices(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
// Alle Locks bei denen ich Keyholder bin
var locks = cardlockRepository.findByKeyholderAndUnlockTimeIsNull(myId);
List<Map<String, Object>> result = new ArrayList<>();
for (var lock : locks) {
var pending = keyholderTaskChoiceRepository.findByLockIdAndStatus(lock.getLockId(), "PENDING");
if (pending.isEmpty()) continue;
var lockee = userRepository.findById(lock.getLockee()).orElse(null);
List<Map<String, Object>> taskList = buildTaskList(lock.getTasks());
for (var choice : pending) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("choiceId", choice.getChoiceId().toString());
m.put("lockId", lock.getLockId().toString());
m.put("lockName", lock.getName() != null ? lock.getName() : "");
m.put("lockeeName", lockee != null ? lockee.getName() : "");
m.put("createdAt", choice.getCreatedAt().toString());
m.put("tasks", taskList);
result.add(m);
}
}
return ResponseEntity.ok(result);
}
record PenaltyRequest(Integer penaltyFreezeMinutes, Integer penaltyRedCards) {}
@PostMapping("/keyholder/choices/{choiceId}/choose/{taskIndex}")
@Transactional
public ResponseEntity<Void> chooseTask(@PathVariable UUID choiceId,
@PathVariable int taskIndex,
@org.springframework.web.bind.annotation.RequestBody(required = false) PenaltyRequest penalty,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var choiceOpt = keyholderTaskChoiceRepository.findById(choiceId);
if (choiceOpt.isEmpty()) return ResponseEntity.notFound().build();
var choice = choiceOpt.get();
var lockOpt = cardlockRepository.findById(choice.getLockId());
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var lock = lockOpt.get();
if (!myId.equals(lock.getKeyholder())) return ResponseEntity.status(403).build();
if (!"PENDING".equals(choice.getStatus())) return ResponseEntity.status(409).build();
List<Task> tasks = lock.getTasks();
if (tasks == null || taskIndex < 0 || taskIndex >= tasks.size())
return ResponseEntity.badRequest().build();
Task task = tasks.get(taskIndex);
AssignedTaskEntity assigned = new AssignedTaskEntity();
assigned.setLockId(lock.getLockId());
assigned.setTaskTitle(task.resolveTitle());
assigned.setTaskDescription(task.getDescription());
assigned.setTaskText(task.resolveTitle());
assigned.setTaskMinutes(task.getMinutes());
assigned.setAssignedAt(LocalDateTime.now());
assigned.setAcceptDeadline(LocalDateTime.now().plusHours(1));
assigned.setStatus("PENDING");
if (penalty != null) {
assigned.setPenaltyFreezeMinutes(penalty.penaltyFreezeMinutes());
assigned.setPenaltyRedCards(penalty.penaltyRedCards());
}
assignedTaskRepository.save(assigned);
choice.setStatus("CHOSEN");
keyholderTaskChoiceRepository.save(choice);
sendMessage(myId, lock.getLockee(),
"Dein Keyholder hat eine Aufgabe für dich ausgewählt.",
"/activelock.html?lockId=" + lock.getLockId());
return ResponseEntity.noContent().build();
}
// ── Community: aktive Abstimmungen ────────────────────────────────────────
@GetMapping("/community/votes")
@Transactional(readOnly = true)
public ResponseEntity<List<Map<String, Object>>> getActiveVotes(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var activeVotes = communityTaskVoteRepository.findByStatus("ACTIVE");
List<Map<String, Object>> result = new ArrayList<>();
for (var vote : activeVotes) {
if (vote.isTestLock()) continue;
var lockOpt = cardlockRepository.findById(vote.getLockId());
if (lockOpt.isEmpty()) continue;
var lock = lockOpt.get();
var lockee = userRepository.findById(lock.getLockee()).orElse(null);
List<Task> tasks = lock.getTasks();
if (tasks == null || tasks.isEmpty()) continue;
List<Map<String, Object>> taskList = buildTaskList(tasks);
// Stimmenanzahl pro Task
List<Integer> voteCounts = new ArrayList<>();
for (int i = 0; i < tasks.size(); i++) {
voteCounts.add(communityTaskVoteEntryRepository
.countByVoteSessionIdAndTaskIndex(vote.getVoteSessionId(), i));
}
Integer myVote = communityTaskVoteEntryRepository.findByVoteSessionId(vote.getVoteSessionId())
.stream().filter(e -> myId.equals(e.getVoterUserId()))
.map(CommunityTaskVoteEntryEntity::getTaskIndex).findFirst().orElse(null);
Map<String, Object> m = new LinkedHashMap<>();
m.put("voteSessionId", vote.getVoteSessionId().toString());
m.put("lockId", lock.getLockId().toString());
m.put("lockeeName", lockee != null ? lockee.getName() : "");
m.put("expiresAt", vote.getExpiresAt().toString());
m.put("tasks", taskList);
m.put("voteCounts", voteCounts);
m.put("myVote", myVote);
result.add(m);
}
return ResponseEntity.ok(result);
}
@PostMapping("/community/votes/{voteSessionId}/vote/{taskIndex}")
@Transactional
public ResponseEntity<Void> castVote(@PathVariable UUID voteSessionId,
@PathVariable int taskIndex,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var voteOpt = communityTaskVoteRepository.findById(voteSessionId);
if (voteOpt.isEmpty()) return ResponseEntity.notFound().build();
var vote = voteOpt.get();
if (!"ACTIVE".equals(vote.getStatus()) || vote.getExpiresAt().isBefore(LocalDateTime.now()))
return ResponseEntity.status(409).build();
var lockOpt = cardlockRepository.findById(vote.getLockId());
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var lock = lockOpt.get();
if (lock.getTasks() == null || taskIndex < 0 || taskIndex >= lock.getTasks().size())
return ResponseEntity.badRequest().build();
if (communityTaskVoteEntryRepository.existsByVoteSessionIdAndVoterUserId(voteSessionId, myId))
return ResponseEntity.status(409).build();
CommunityTaskVoteEntryEntity entry = new CommunityTaskVoteEntryEntity();
entry.setVoteSessionId(voteSessionId);
entry.setVoterUserId(myId);
entry.setTaskIndex(taskIndex);
communityTaskVoteEntryRepository.save(entry);
return ResponseEntity.noContent().build();
}
// ── Helpers ───────────────────────────────────────────────────────────────
private List<Map<String, Object>> buildTaskList(List<Task> tasks) {
if (tasks == null) return List.of();
List<Map<String, Object>> list = new ArrayList<>();
for (int i = 0; i < tasks.size(); i++) {
Task t = tasks.get(i);
Map<String, Object> m = new LinkedHashMap<>();
m.put("index", i);
m.put("title", t.resolveTitle());
m.put("description", t.getDescription() != null ? t.getDescription() : "");
m.put("minutes", t.getMinutes() != null ? t.getMinutes() : 0);
list.add(m);
}
return list;
}
private void sendMessage(UUID fromId, UUID toId, String text, String targetUrl) {
if (toId == null) return;
MessageEntity msg = new MessageEntity();
msg.setMessageId(java.util.UUID.randomUUID());
msg.setSenderId(fromId);
msg.setReceiverId(toId);
msg.setText(text);
msg.setSystemMessage(true);
msg.setTargetUrl(targetUrl);
msg.setSentAt(java.time.LocalDateTime.now());
messageRepository.save(msg);
sseService.push(toId, "notification", Map.of("text", text));
}
}

View File

@@ -0,0 +1,132 @@
package de.oaa.xxx.games.chastity.cardlock;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import de.oaa.xxx.games.chastity.tasks.AssignedTaskEntity;
import de.oaa.xxx.games.chastity.tasks.AssignedTaskRepository;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.social.SseService;
import de.oaa.xxx.social.entity.MessageEntity;
import de.oaa.xxx.social.repository.MessageRepository;
@Component
public class TaskVoteScheduler {
private static final Logger LOG = LoggerFactory.getLogger(TaskVoteScheduler.class);
private final CommunityTaskVoteRepository communityTaskVoteRepository;
private final CommunityTaskVoteEntryRepository communityTaskVoteEntryRepository;
private final CardlockRepository cardlockRepository;
private final AssignedTaskRepository assignedTaskRepository;
private final MessageRepository messageRepository;
private final SseService sseService;
public TaskVoteScheduler(CommunityTaskVoteRepository communityTaskVoteRepository,
CommunityTaskVoteEntryRepository communityTaskVoteEntryRepository,
CardlockRepository cardlockRepository,
AssignedTaskRepository assignedTaskRepository,
MessageRepository messageRepository,
SseService sseService) {
this.communityTaskVoteRepository = communityTaskVoteRepository;
this.communityTaskVoteEntryRepository = communityTaskVoteEntryRepository;
this.cardlockRepository = cardlockRepository;
this.assignedTaskRepository = assignedTaskRepository;
this.messageRepository = messageRepository;
this.sseService = sseService;
}
@Scheduled(fixedDelay = 60_000)
@Transactional
public void processExpiredVotes() {
var expired = communityTaskVoteRepository
.findByStatusAndExpiresAtBefore("ACTIVE", LocalDateTime.now());
for (var vote : expired) {
LOG.debug("Processing expired community task vote {}", vote.getVoteSessionId());
var lockOpt = cardlockRepository.findById(vote.getLockId());
if (lockOpt.isEmpty()) {
vote.setStatus("COMPLETED");
communityTaskVoteRepository.save(vote);
continue;
}
var lock = lockOpt.get();
List<Task> tasks = lock.getTasks();
if (tasks == null || tasks.isEmpty()) {
vote.setStatus("COMPLETED");
communityTaskVoteRepository.save(vote);
continue;
}
// Stimmen auszählen
var entries = communityTaskVoteEntryRepository.findByVoteSessionId(vote.getVoteSessionId());
int winnerIndex;
if (entries.isEmpty()) {
winnerIndex = new Random().nextInt(tasks.size());
LOG.debug("No votes → random task index {}", winnerIndex);
} else {
int[] counts = new int[tasks.size()];
for (var e : entries) {
if (e.getTaskIndex() >= 0 && e.getTaskIndex() < tasks.size()) {
counts[e.getTaskIndex()]++;
}
}
int max = Arrays.stream(counts).max().getAsInt();
// Alle Tasks mit Maximalstimmen sammeln → zufällige Auswahl bei Gleichstand
List<Integer> winners = new ArrayList<>();
for (int i = 0; i < counts.length; i++) {
if (counts[i] == max) winners.add(i);
}
winnerIndex = winners.get(new Random().nextInt(winners.size()));
LOG.debug("Vote winner: task index {} with {} votes", winnerIndex, max);
}
Task task = tasks.get(winnerIndex);
AssignedTaskEntity assigned = new AssignedTaskEntity();
assigned.setLockId(lock.getLockId());
assigned.setTaskTitle(task.resolveTitle());
assigned.setTaskDescription(task.getDescription());
assigned.setTaskText(task.resolveTitle());
assigned.setTaskMinutes(task.getMinutes());
assigned.setAssignedAt(LocalDateTime.now());
assigned.setAcceptDeadline(LocalDateTime.now().plusHours(1));
assigned.setStatus("PENDING");
assignedTaskRepository.save(assigned);
vote.setStatus("COMPLETED");
vote.setWinningTaskIndex(winnerIndex);
communityTaskVoteRepository.save(vote);
// Lockee benachrichtigen
sendMessage(lock.getLockee(),
"Die Community hat für deine Aufgabe abgestimmt: \"" + task.resolveTitle() + "\"",
"/activelock.html?lockId=" + lock.getLockId());
}
}
private void sendMessage(UUID toId, String text, String targetUrl) {
if (toId == null) return;
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(toId); // System-Nachricht, kein echter Sender
msg.setReceiverId(toId);
msg.setText(text);
msg.setSystemMessage(true);
msg.setTargetUrl(targetUrl);
msg.setSentAt(LocalDateTime.now());
messageRepository.save(msg);
sseService.push(toId, "notification", Map.of("text", text));
}
}

View File

@@ -0,0 +1,47 @@
package de.oaa.xxx.games.chastity.cardlock;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "unlock_code_history")
public class UnlockCodeHistoryEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private UUID userId;
@Column(nullable = false)
private UUID lockId;
@Column
private String lockName;
@Column(nullable = false)
private String unlockCode;
/** GREEN_CARD | HYGIENE_OPEN | HYGIENE_CLOSE | KEYHOLDER_UNLOCK */
@Column(nullable = false)
private String source;
@Column(nullable = false)
private LocalDateTime receivedAt;
public UUID getId() { return id; }
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public UUID getLockId() { return lockId; }
public void setLockId(UUID lockId) { this.lockId = lockId; }
public String getLockName() { return lockName; }
public void setLockName(String lockName) { this.lockName = lockName; }
public String getUnlockCode() { return unlockCode; }
public void setUnlockCode(String unlockCode) { this.unlockCode = unlockCode; }
public String getSource() { return source; }
public void setSource(String source) { this.source = source; }
public LocalDateTime getReceivedAt() { return receivedAt; }
public void setReceivedAt(LocalDateTime receivedAt) { this.receivedAt = receivedAt; }
}

View File

@@ -0,0 +1,23 @@
package de.oaa.xxx.games.chastity.cardlock;
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 java.util.List;
import java.util.UUID;
public interface UnlockCodeHistoryRepository extends JpaRepository<UnlockCodeHistoryEntity, UUID> {
List<UnlockCodeHistoryEntity> findByUserIdOrderByReceivedAtDesc(UUID userId, Pageable pageable);
/** Prüft, ob für dieses Lock und diese Quelle bereits der gleiche Code gespeichert ist. */
boolean existsByLockIdAndSourceAndUnlockCode(UUID lockId, String source, String unlockCode);
/** Alle Einträge des Users aufsteigend (für Cleanup: älteste löschen). */
@Query("SELECT e FROM UnlockCodeHistoryEntity e WHERE e.userId = :userId ORDER BY e.receivedAt ASC")
List<UnlockCodeHistoryEntity> findByUserIdOrderByReceivedAtAsc(@Param("userId") UUID userId);
long countByUserId(UUID userId);
}

View File

@@ -1,7 +1,5 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.CardLockService;
public class YellowCard implements Card {
@Override

View File

@@ -0,0 +1,80 @@
package de.oaa.xxx.games.chastity.tasks;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "assigned_task")
public class AssignedTaskEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID taskId;
@Column(nullable = false)
private UUID lockId;
@Column
private String taskTitle;
@Column(columnDefinition = "TEXT")
private String taskDescription;
@Column(columnDefinition = "TEXT", nullable = false)
private String taskText;
@Column
private Integer taskMinutes;
@Column(nullable = false)
private LocalDateTime assignedAt;
@Column(nullable = false)
private LocalDateTime acceptDeadline;
/** Wie viele Minuten einfrieren bei Ablehnung / Ablauf (null = keine Freeze-Strafe). */
@Column
private Integer penaltyFreezeMinutes;
/** Wie viele rote Karten hinzufügen bei Ablehnung / Ablauf (null = keine). */
@Column
private Integer penaltyRedCards;
/** PENDING | ACCEPTED | DECLINED | EXPIRED */
@Column(nullable = false)
private String status = "PENDING";
public UUID getTaskId() { return taskId; }
public void setTaskId(UUID taskId) { this.taskId = taskId; }
public UUID getLockId() { return lockId; }
public void setLockId(UUID lockId) { this.lockId = lockId; }
public String getTaskTitle() { return taskTitle; }
public void setTaskTitle(String taskTitle) { this.taskTitle = taskTitle; }
public String getTaskDescription() { return taskDescription; }
public void setTaskDescription(String d) { this.taskDescription = d; }
public String getTaskText() { return taskText; }
public void setTaskText(String taskText) { this.taskText = taskText; }
public Integer getTaskMinutes() { return taskMinutes; }
public void setTaskMinutes(Integer m) { this.taskMinutes = m; }
public LocalDateTime getAssignedAt() { return assignedAt; }
public void setAssignedAt(LocalDateTime t) { this.assignedAt = t; }
public LocalDateTime getAcceptDeadline() { return acceptDeadline; }
public void setAcceptDeadline(LocalDateTime t) { this.acceptDeadline = t; }
public Integer getPenaltyFreezeMinutes() { return penaltyFreezeMinutes; }
public void setPenaltyFreezeMinutes(Integer m) { this.penaltyFreezeMinutes = m; }
public Integer getPenaltyRedCards() { return penaltyRedCards; }
public void setPenaltyRedCards(Integer n) { this.penaltyRedCards = n; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.tasks;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface AssignedTaskRepository extends JpaRepository<AssignedTaskEntity, UUID> {
List<AssignedTaskEntity> findByLockIdAndStatus(UUID lockId, String status);
}

View File

@@ -2,29 +2,34 @@ package de.oaa.xxx.games.chastity.tasks;
public class Task {
private String text;
private String title;
private String description;
private Integer minutes;
public String getText() {
return text;
}
/** @deprecated Backward-Compat alte Einträge ohne title/description. Nur lesen, nicht setzen. */
private String text;
public void setText(String text) {
this.text = text;
}
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public Integer getMinutes() {
return minutes;
}
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public void setMinutes(Integer minutes) {
this.minutes = minutes;
public Integer getMinutes() { return minutes; }
public void setMinutes(Integer minutes) { this.minutes = minutes; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
/** Gibt den anzeigbaren Titel zurück fällt auf altes text-Feld zurück. */
public String resolveTitle() {
if (title != null && !title.isBlank()) return title;
if (text != null && !text.isBlank()) return text;
return "Aufgabe";
}
@Override
public String toString() {
return "Task [text=" + text + ", minutes=" + minutes + "]";
return "Task[title=" + title + ", minutes=" + minutes + "]";
}
}

View File

@@ -0,0 +1,30 @@
package de.oaa.xxx.social;
import de.oaa.xxx.user.UserRepository;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.security.Principal;
@RestController
@RequestMapping("/events")
public class EventController {
private final SseService sseService;
private final UserRepository userRepository;
public EventController(SseService sseService, UserRepository userRepository) {
this.sseService = sseService;
this.userRepository = userRepository;
}
@GetMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) throw new RuntimeException("Not authenticated");
return sseService.subscribe(meOpt.get().getUserId());
}
}

View File

@@ -0,0 +1,81 @@
package de.oaa.xxx.social;
import de.oaa.xxx.social.repository.MessageRepository;
import de.oaa.xxx.user.UserRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.*;
@RestController
@RequestMapping("/notifications")
public class NotificationController {
private final MessageRepository messageRepository;
private final UserRepository userRepository;
public NotificationController(MessageRepository messageRepository,
UserRepository userRepository) {
this.messageRepository = messageRepository;
this.userRepository = userRepository;
}
@GetMapping
public ResponseEntity<List<Map<String, Object>>> getNotifications(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<Map<String, Object>> result = messageRepository
.findNotificationsForUser(myId, PageRequest.of(0, 10))
.stream()
.map(m -> {
Map<String, Object> n = new LinkedHashMap<>();
n.put("id", m.getMessageId().toString());
n.put("text", m.getText());
n.put("sentAt", m.getSentAt().toString());
n.put("read", m.getReadAt() != null);
n.put("targetUrl", m.getTargetUrl() != null ? m.getTargetUrl() : "");
userRepository.findById(m.getSenderId()).ifPresent(sender -> {
n.put("senderName", sender.getName());
n.put("senderAvatar", sender.getProfilePicture() != null ? sender.getProfilePicture() : "");
});
return n;
})
.toList();
return ResponseEntity.ok(result);
}
@GetMapping("/unread/count")
public ResponseEntity<Long> getUnreadCount(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
return ResponseEntity.ok(
messageRepository.countByReceiverIdAndSystemMessageAndReadAtIsNull(myId, true));
}
@Transactional
@PostMapping("/{id}/read")
public ResponseEntity<Void> markOneRead(@PathVariable UUID id, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
messageRepository.markNotificationAsRead(id, myId, LocalDateTime.now());
return ResponseEntity.noContent().build();
}
@Transactional
@PostMapping("/read-all")
public ResponseEntity<Void> markAllRead(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
messageRepository.markAllNotificationsAsRead(myId, LocalDateTime.now());
return ResponseEntity.noContent().build();
}
}

View File

@@ -30,13 +30,16 @@ public class SocialController {
private final UserRepository userRepository;
private final FriendshipRepository friendshipRepository;
private final MessageRepository messageRepository;
private final SseService sseService;
public SocialController(UserRepository userRepository,
FriendshipRepository friendshipRepository,
MessageRepository messageRepository) {
MessageRepository messageRepository,
SseService sseService) {
this.userRepository = userRepository;
this.friendshipRepository = friendshipRepository;
this.messageRepository = messageRepository;
this.sseService = sseService;
}
record FriendRequestBody(UUID receiverId) {}
@@ -210,6 +213,8 @@ public class SocialController {
msg.setSentAt(LocalDateTime.now());
messageRepository.save(msg);
LOGGER.debug("User {} hat Nachricht an User {} gesendet", myId, body.receiverId());
long unread = messageRepository.countUnread(body.receiverId());
sseService.push(body.receiverId(), "DM", Map.of("unreadCount", unread, "senderId", myId.toString()));
return ResponseEntity.status(201).build();
}

View File

@@ -0,0 +1,54 @@
package de.oaa.xxx.social;
import jakarta.annotation.PreDestroy;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
@Service
public class SseService {
private final Map<UUID, List<SseEmitter>> emitters = new ConcurrentHashMap<>();
@PreDestroy
public void shutdown() {
emitters.values().forEach(list -> list.forEach(SseEmitter::complete));
emitters.clear();
}
public SseEmitter subscribe(UUID userId) {
SseEmitter emitter = new SseEmitter(30_000L); // 30 s Client reconnects automatically
emitters.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>()).add(emitter);
Runnable cleanup = () -> removeEmitter(userId, emitter);
emitter.onCompletion(cleanup);
emitter.onTimeout(() -> { emitter.complete(); cleanup.run(); });
emitter.onError(e -> cleanup.run());
return emitter;
}
/** Pushes a named SSE event to all open connections of the given user. */
public void push(UUID userId, String eventName, Object data) {
List<SseEmitter> list = emitters.get(userId);
if (list == null || list.isEmpty()) return;
list.removeIf(emitter -> {
try {
emitter.send(SseEmitter.event().name(eventName).data(data, MediaType.APPLICATION_JSON));
return false;
} catch (IOException e) {
return true;
}
});
}
private void removeEmitter(UUID userId, SseEmitter emitter) {
List<SseEmitter> list = emitters.get(userId);
if (list != null) list.remove(emitter);
}
}

View File

@@ -27,6 +27,12 @@ public class MessageEntity {
@Column
private LocalDateTime readAt;
@Column(nullable = false)
private boolean systemMessage = false;
@Column(length = 500)
private String targetUrl;
public UUID getMessageId() { return messageId; }
public void setMessageId(UUID messageId) { this.messageId = messageId; }
@@ -44,4 +50,10 @@ public class MessageEntity {
public LocalDateTime getReadAt() { return readAt; }
public void setReadAt(LocalDateTime readAt) { this.readAt = readAt; }
public boolean isSystemMessage() { return systemMessage; }
public void setSystemMessage(boolean systemMessage) { this.systemMessage = systemMessage; }
public String getTargetUrl() { return targetUrl; }
public void setTargetUrl(String targetUrl) { this.targetUrl = targetUrl; }
}

View File

@@ -14,23 +14,44 @@ import java.util.UUID;
public interface MessageRepository extends JpaRepository<MessageEntity, UUID> {
@Query("SELECT m FROM MessageEntity m WHERE (m.senderId = :userA AND m.receiverId = :userB) OR (m.senderId = :userB AND m.receiverId = :userA) ORDER BY m.sentAt DESC")
// ── DM queries (systemMessage = false) ────────────────────────────────────
@Query("SELECT m FROM MessageEntity m WHERE ((m.senderId = :userA AND m.receiverId = :userB) OR (m.senderId = :userB AND m.receiverId = :userA)) AND m.systemMessage = false ORDER BY m.sentAt DESC")
List<MessageEntity> findConversation(@Param("userA") UUID userA, @Param("userB") UUID userB, Pageable pageable);
@Query("SELECT m FROM MessageEntity m WHERE ((m.senderId = :userA AND m.receiverId = :userB) OR (m.senderId = :userB AND m.receiverId = :userA)) AND m.sentAt < :before ORDER BY m.sentAt DESC")
@Query("SELECT m FROM MessageEntity m WHERE ((m.senderId = :userA AND m.receiverId = :userB) OR (m.senderId = :userB AND m.receiverId = :userA)) AND m.systemMessage = false AND m.sentAt < :before ORDER BY m.sentAt DESC")
List<MessageEntity> findConversationBefore(@Param("userA") UUID userA, @Param("userB") UUID userB, @Param("before") LocalDateTime before, Pageable pageable);
@Query("SELECT m FROM MessageEntity m WHERE ((m.senderId = :userA AND m.receiverId = :userB) OR (m.senderId = :userB AND m.receiverId = :userA)) AND m.sentAt > :after ORDER BY m.sentAt ASC")
@Query("SELECT m FROM MessageEntity m WHERE ((m.senderId = :userA AND m.receiverId = :userB) OR (m.senderId = :userB AND m.receiverId = :userA)) AND m.systemMessage = false AND m.sentAt > :after ORDER BY m.sentAt ASC")
List<MessageEntity> findConversationAfter(@Param("userA") UUID userA, @Param("userB") UUID userB, @Param("after") LocalDateTime after);
@Query("SELECT m FROM MessageEntity m WHERE m.senderId = :userId OR m.receiverId = :userId ORDER BY m.sentAt DESC")
@Query("SELECT m FROM MessageEntity m WHERE (m.senderId = :userId OR m.receiverId = :userId) AND m.systemMessage = false ORDER BY m.sentAt DESC")
List<MessageEntity> findAllByUser(@Param("userId") UUID userId);
@Query("SELECT COUNT(m) FROM MessageEntity m WHERE m.receiverId = :userId AND m.readAt IS NULL")
@Query("SELECT COUNT(m) FROM MessageEntity m WHERE m.receiverId = :userId AND m.readAt IS NULL AND m.systemMessage = false")
long countUnread(@Param("userId") UUID userId);
@Modifying
@Transactional
@Query("UPDATE MessageEntity m SET m.readAt = :now WHERE m.senderId = :partnerId AND m.receiverId = :userId AND m.readAt IS NULL")
@Query("UPDATE MessageEntity m SET m.readAt = :now WHERE m.senderId = :partnerId AND m.receiverId = :userId AND m.readAt IS NULL AND m.systemMessage = false")
void markAsRead(@Param("userId") UUID userId, @Param("partnerId") UUID partnerId, @Param("now") LocalDateTime now);
// ── Notification queries (systemMessage = true) ───────────────────────────
/** Ungelesene zuerst, dann nach sentAt absteigend, max. 10 Einträge. */
@Query("SELECT m FROM MessageEntity m WHERE m.receiverId = :receiverId AND m.systemMessage = true " +
"ORDER BY CASE WHEN m.readAt IS NULL THEN 0 ELSE 1 END, m.sentAt DESC")
List<MessageEntity> findNotificationsForUser(@Param("receiverId") UUID receiverId, Pageable pageable);
long countByReceiverIdAndSystemMessageAndReadAtIsNull(UUID receiverId, boolean systemMessage);
@Modifying
@Transactional
@Query("UPDATE MessageEntity m SET m.readAt = :now WHERE m.receiverId = :userId AND m.systemMessage = true AND m.readAt IS NULL")
void markAllNotificationsAsRead(@Param("userId") UUID userId, @Param("now") LocalDateTime now);
@Modifying
@Transactional
@Query("UPDATE MessageEntity m SET m.readAt = :now WHERE m.messageId = :id AND m.receiverId = :userId AND m.readAt IS NULL")
void markNotificationAsRead(@Param("id") UUID id, @Param("userId") UUID userId, @Param("now") LocalDateTime now);
}

View File

@@ -7,6 +7,7 @@ spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# JPA / Hibernate
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
spring.jpa.open-in-view=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.properties.hibernate.type.preferred_uuid_jdbc_type=VARCHAR
@@ -45,10 +46,15 @@ app.theme.color-success=#2ecc71
# Logging
logging.level.de.oaa.xxx=DEBUG
# Spring 6.2.3 Bug: NPE in DisconnectedClientHelper bei AsyncRequestTimeoutException (SSE-Reconnect) harmlos
logging.level.org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver=ERROR
logging.level.org.apache.catalina.core.AsyncContextImpl=ERROR
# Server
server.port=8080
server.servlet.context-path=/
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=5s
# Multipart upload
spring.servlet.multipart.max-file-size=20MB

View File

@@ -113,37 +113,25 @@
color: #fff;
}
/* ── Frozen-Overlay (Eis) ── */
.nextcard-overlay.frozen {
/* ── Frost-Overlay (Eis) gemeinsam für frozen + task ── */
.nextcard-overlay.frozen,
.nextcard-overlay.task {
border-radius: 6px;
background:
radial-gradient(ellipse at 18% 28%, rgba(255,255,255,0.38) 0%, transparent 48%),
radial-gradient(ellipse at 82% 72%, rgba(255,255,255,0.28) 0%, transparent 42%),
radial-gradient(ellipse at 55% 12%, rgba(255,255,255,0.22) 0%, transparent 38%),
rgba(55,140,210,0.68);
background-image:
radial-gradient(ellipse at 18% 28%, rgba(255,255,255,0.38) 0%, transparent 48%),
radial-gradient(ellipse at 82% 72%, rgba(255,255,255,0.28) 0%, transparent 42%),
radial-gradient(ellipse at 55% 12%, rgba(255,255,255,0.22) 0%, transparent 38%),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48'%3E%3Cg stroke='rgba(255,255,255,0.25)' stroke-width='1' fill='none'%3E%3Cline x1='24' y1='4' x2='24' y2='44'/%3E%3Cline x1='4' y1='14' x2='44' y2='34'/%3E%3Cline x1='4' y1='34' x2='44' y2='14'/%3E%3Cline x1='24' y1='4' x2='19' y2='12'/%3E%3Cline x1='24' y1='4' x2='29' y2='12'/%3E%3Cline x1='24' y1='44' x2='19' y2='36'/%3E%3Cline x1='24' y1='44' x2='29' y2='36'/%3E%3Cline x1='4' y1='14' x2='11' y2='17'/%3E%3Cline x1='4' y1='14' x2='9' y2='22'/%3E%3Cline x1='44' y1='34' x2='37' y2='31'/%3E%3Cline x1='44' y1='34' x2='39' y2='26'/%3E%3Cline x1='4' y1='34' x2='11' y2='31'/%3E%3Cline x1='4' y1='34' x2='9' y2='26'/%3E%3Cline x1='44' y1='14' x2='37' y2='17'/%3E%3Cline x1='44' y1='14' x2='39' y2='22'/%3E%3C/g%3E%3C/svg%3E");
background-size: auto, auto, auto, 48px 48px;
}
.frozen-label {
font-size: 1.2rem;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 6px rgba(0,80,180,0.6);
letter-spacing: 0.05em;
}
/* ── Task-Overlay ── */
.nextcard-task-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
background: rgba(0,0,0,0.5);
border: 1px solid rgba(255,255,255,0.3);
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 0.9rem 1.2rem;
max-width: 85%;
@@ -151,14 +139,14 @@
}
.nextcard-task-text {
font-size: 0.92rem;
color: #fff;
color: var(--color-text);
line-height: 1.5;
}
.nextcard-task-countdown {
font-size: 1.1rem;
font-weight: 700;
font-family: monospace;
color: rgba(255,220,100,0.95);
color: var(--color-primary);
}
.btn-task-erledigt {
padding: 0.45rem 1.2rem;
@@ -557,6 +545,9 @@
<div style="font-size:0.88rem;color:var(--color-muted);">Keyholder*In: <a id="keyholderInfoName" href="#" style="font-weight:700;color:var(--color-text);text-decoration:none;"></a></div>
</div>
<!-- Zugewiesene Aufgaben vom Keyholder -->
<div id="assignedTasksArea" style="display:none;margin-bottom:1rem;"></div>
<div class="keyholder-pending-banner" id="keyholderPendingBanner" style="display:none;">
<span class="icon"></span>
<div>
@@ -586,6 +577,7 @@
<!-- Task -->
<div class="nextcard-task-box" id="nextcardTaskBox" style="display:none;">
<div class="nextcard-task-text" id="nextcardTaskText"></div>
<div id="nextcardTaskDescription" style="font-size:0.82rem;color:rgba(255,255,255,0.7);margin-top:0.25rem;line-height:1.4;display:none;"></div>
<div class="nextcard-task-countdown" id="nextcardTaskCountdown" style="display:none;"></div>
<button class="btn-task-erledigt" id="btnTaskErledigt" disabled onclick="taskErledigt()">✓ Erledigt</button>
</div>
@@ -629,9 +621,7 @@
Wird geladen…
</div>
<div style="display:flex; justify-content:flex-end; margin-top:2rem;">
<button class="btn-lock-beenden" onclick="lockBeendenFragen()">🔓 Lock beenden</button>
</div>
<div id="lockActionArea" style="display:flex; justify-content:flex-end; margin-top:2rem;"></div>
</div>
</div>
@@ -706,9 +696,13 @@
<p id="drawCardDesc"></p>
</div>
<div id="drawTaskPendingHint" style="display:none;margin-top:0.75rem;padding:0.75rem 1rem;border-radius:8px;background:rgba(233,69,96,0.10);border:1px solid rgba(233,69,96,0.3);font-size:0.88rem;color:var(--color-text);line-height:1.5;text-align:center;">
<span id="drawTaskPendingText"></span>
</div>
<!-- Grüne Karte: Entscheidung -->
<div class="draw-green-choice" id="drawGreenChoice">
<p style="text-align:center;font-size:0.88rem;color:var(--color-muted);margin:0;">
<p id="drawGreenText" style="text-align:center;font-size:0.88rem;color:var(--color-muted);margin:0;">
Du hast die grüne Karte gezogen!<br>Möchtest du den Entsperrcode erhalten und die Session beenden,<br>oder die Karte zurücklegen?
</p>
<div class="draw-unlock-code" id="drawUnlockCode"></div>
@@ -724,22 +718,48 @@
</div>
</div>
<!-- Warn-Modal -->
<!-- Warn-Modal (TestLock beenden) -->
<div class="warn-modal-backdrop" id="warnModal">
<div class="warn-modal-box">
<h3>Lock wirklich beenden?</h3>
<p>
Wenn du das Lock beendest, wird der <strong>Entsperrcode unwiderruflich gelöscht</strong>.
Der Code kann danach nicht wiederhergestellt werden.
</p>
<h3>Lock beenden?</h3>
<p>Dein Entsperrcode:</p>
<div id="warnModalUnlockCode" style="font-size:1.6rem;font-weight:700;letter-spacing:0.15em;text-align:center;margin:0.5rem 0 1rem;color:var(--color-primary);"></div>
<div class="warn-modal-actions">
<button class="btn-cancel" onclick="closeWarnModal()">Abbrechen</button>
<button class="btn-danger" onclick="lockLoeschen()">Ja, Lock beenden</button>
<button class="btn-danger" onclick="lockLoeschen()">Lock beenden</button>
</div>
</div>
</div>
<!-- Notfall-Modal -->
<div class="warn-modal-backdrop" id="emergencyModal">
<div class="warn-modal-box">
<h3>🆘 Notfall-Entsperrung</h3>
<div id="emergencyModalContent"></div>
<div class="warn-modal-actions" id="emergencyModalActions">
<button class="btn-cancel" onclick="closeEmergencyModal()">Abbrechen</button>
<button class="btn-danger" id="btnEmergencyConfirm" onclick="confirmEmergency()">Notfall bestätigen</button>
</div>
</div>
</div>
<!-- Annehmen/Ablehnen-Dialog für Keyholder-Aufgaben -->
<div id="assignedTaskModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.65);z-index:500;align-items:center;justify-content:center;">
<div style="background:var(--color-card);border:1px solid var(--color-secondary);border-radius:14px;padding:1.5rem;max-width:400px;width:92%;display:flex;flex-direction:column;gap:1rem;position:relative;">
<button onclick="closeAssignedTaskModal()" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.5rem;line-height:1;"></button>
<h3 style="margin:0;font-size:1.05rem;">✅ Aufgabe gestellt</h3>
<div id="assignedTaskModalInfo" style="font-size:0.88rem;color:var(--color-muted);line-height:1.6;"></div>
<div id="assignedTaskPenaltyInfo" style="background:rgba(231,76,60,0.08);border:1px solid rgba(231,76,60,0.25);border-radius:8px;padding:0.65rem 0.85rem;font-size:0.83rem;color:#e74c3c;line-height:1.5;"></div>
<div id="assignedTaskModalError" style="display:none;font-size:0.85rem;color:#e74c3c;"></div>
<div style="display:flex;gap:0.6rem;justify-content:flex-end;">
<button onclick="respondAssignedTask('decline')" style="background:rgba(231,76,60,0.12);border:1px solid rgba(231,76,60,0.35);color:#e74c3c;padding:0.5rem 1.1rem;border-radius:7px;cursor:pointer;font-size:0.88rem;font-weight:600;width:auto;">✕ Ablehnen</button>
<button onclick="respondAssignedTask('accept')" style="background:rgba(46,204,113,0.15);border:1px solid rgba(46,204,113,0.4);color:#2ecc71;padding:0.5rem 1.1rem;border-radius:7px;cursor:pointer;font-size:0.88rem;font-weight:600;width:auto;">✓ Annehmen</button>
</div>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/card-defs.js"></script>
<script src="/js/card-display.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
@@ -761,14 +781,114 @@
return;
}
const lock = await res.json();
_currentLock = lock;
document.getElementById('lockContent').textContent = '';
document.getElementById('keyholderPendingBanner').style.display =
lock.keyholderInvitationPending ? '' : 'none';
renderKeyholderBar(lock);
renderAssignedTasks(lock);
renderNextCardPanel(lock);
renderHygienePanel(lock);
renderVerificationPanel(lock);
renderCardsPanel(lock);
if (lock.keyholderRequestedUnlock) {
showKeyholderUnlockModal(lock.unlockCode || '');
}
renderLockActionArea(lock);
}
function showKeyholderUnlockModal(unlockCode) {
const modal = document.getElementById('drawModal');
const inner = document.getElementById('flipCardInner');
const info = document.getElementById('drawCardInfo');
const green = document.getElementById('drawGreenChoice');
const actions = document.getElementById('drawModalActions');
const hint = document.getElementById('drawTaskPendingHint');
// Direkt aufgedeckt mit grüner Karte zeigen
inner.classList.remove('flipped');
info.classList.remove('visible');
green.classList.remove('visible');
hint.style.display = 'none';
document.getElementById('drawUnlockCode').style.display = 'none';
document.getElementById('btnDrawOk').style.display = 'none';
document.getElementById('btnDrawUnlock').style.display = 'none';
document.getElementById('btnDrawKeep').style.display = 'none';
actions.style.display = 'none';
drawnUnlockCode = unlockCode;
modal.classList.add('open');
const def = CARD_LABELS['GREEN'];
setTimeout(() => {
document.getElementById('drawnCardImg').src = def.img;
document.getElementById('drawnCardImg').alt = def.name;
inner.classList.add('flipped');
setTimeout(() => {
const khName = _currentLock && _currentLock.keyholderName ? _currentLock.keyholderName : 'Deine Keyholderin';
document.getElementById('drawCardName').textContent = def.name;
document.getElementById('drawCardDesc').textContent = `🔑 ${khName} hat das Lock freigegeben.`;
document.getElementById('drawGreenText').style.display = 'none';
info.classList.add('visible');
actions.style.display = '';
green.classList.add('visible');
document.getElementById('btnDrawUnlock').style.display = '';
document.getElementById('btnDrawKeep').style.display = 'none';
}, 700);
}, 800);
}
let _currentLock = null;
function renderLockActionArea(lock) {
const area = document.getElementById('lockActionArea');
if (!area) return;
if (lock.testLock) {
area.innerHTML = `<button class="btn-lock-beenden" onclick="lockBeendenFragen()">🔓 Lock beenden</button>`;
} else if (lock.emergencyUnlockRequested) {
const khName = lock.keyholderName || 'Deine Keyholderin';
area.innerHTML = `<div style="font-size:0.85rem;color:var(--color-muted);padding:0.5rem 0.75rem;border:1px solid rgba(231,76,60,0.3);border-radius:8px;background:rgba(231,76,60,0.06);">
⏳ Notfall-Entsperrung angefordert ${khName} wurde benachrichtigt.
</div>`;
} else {
area.innerHTML = `<button onclick="openEmergencyModal()"
style="background:rgba(231,76,60,0.1);border:1px solid rgba(231,76,60,0.35);color:#e74c3c;padding:0.4rem 0.9rem;border-radius:7px;cursor:pointer;font-size:0.82rem;font-weight:600;width:auto;">
🆘 Notfall
</button>`;
}
}
function openEmergencyModal() {
const khName = _currentLock && _currentLock.keyholderName ? _currentLock.keyholderName : 'Deine Keyholderin';
document.getElementById('emergencyModalContent').innerHTML = `
<p style="font-size:0.88rem;color:var(--color-muted);line-height:1.5;margin:0 0 0.5rem;">
Im Notfall kannst du eine sofortige Freigabe anfordern.<br>
<strong style="color:var(--color-text);">${khName}</strong> wird benachrichtigt und hat <strong>1 Stunde</strong> Zeit zu reagieren.
Reagiert ${khName} nicht, öffnet sich das Lock automatisch.
</p>`;
document.getElementById('emergencyModalActions').style.display = '';
document.getElementById('emergencyModal').classList.add('open');
}
function closeEmergencyModal() {
document.getElementById('emergencyModal').classList.remove('open');
}
async function confirmEmergency() {
document.getElementById('emergencyModalActions').style.display = 'none';
try {
const res = await fetch('/keyholder/cardlock/' + lockId + '/emergency-unlock', { method: 'POST' });
if (res.ok || res.status === 204) {
const khName2 = _currentLock && _currentLock.keyholderName ? _currentLock.keyholderName : 'Deine Keyholderin';
document.getElementById('emergencyModalContent').innerHTML = `
<p style="font-size:0.88rem;color:#2ecc71;line-height:1.5;margin:0;">
✅ Notfall-Anfrage wurde gesendet. ${khName2} wurde benachrichtigt.
</p>`;
setTimeout(() => { closeEmergencyModal(); loadLock(); }, 2000);
}
} catch(e) {
document.getElementById('emergencyModalContent').innerHTML = `<p style="color:#e74c3c;">Fehler beim Senden der Anfrage.</p>`;
}
}
let tickInterval = null;
@@ -784,6 +904,118 @@
: `${m}:${String(s).padStart(2,'0')}`;
}
// ── Keyholder-Aufgaben ──────────────────────────────────────────────────────
let activeAssignedTaskId = null;
function fmtDateTime(iso) {
const dt = new Date(iso);
return dt.toLocaleDateString('de-DE') + ', ' + dt.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' }) + ' Uhr';
}
const assignedTaskCache = {};
function renderAssignedTasks(lock) {
const area = document.getElementById('assignedTasksArea');
const tasks = lock.assignedTasks || [];
// Aufgaben ausblenden wenn bereits eine Aufgabe aktiv ist (nicht bei Keyholder-Freeze)
if (tasks.length === 0 || (lock.currentTask && lock.currentTask.trim())) {
area.style.display = 'none'; area.innerHTML = ''; return;
}
area.style.display = '';
area.innerHTML = tasks.map(t => {
assignedTaskCache[t.taskId] = t;
const deadline = new Date(t.acceptDeadline);
const remaining = deadline - Date.now();
const urgent = remaining < 60 * 60 * 1000; // < 1h
const titleEsc = (t.taskTitle || '').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const descEsc = (t.taskDescription || '').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const mins = t.taskMinutes > 0 ? ` · ${t.taskMinutes} Min.` : '';
return `<div onclick="openAssignedTaskModal('${t.taskId}')"
style="background:var(--color-card);border:1px solid ${urgent ? 'rgba(231,76,60,0.5)' : 'var(--color-secondary)'};
border-radius:10px;padding:0.75rem 1rem;margin-bottom:0.5rem;cursor:pointer;display:flex;align-items:flex-start;gap:0.6rem;"
onmouseover="this.style.borderColor='var(--color-primary)'" onmouseout="this.style.borderColor='${urgent ? 'rgba(231,76,60,0.5)' : 'var(--color-secondary)'}'">
<span style="font-size:1.3rem;flex-shrink:0;line-height:1.4;"></span>
<div style="flex:1;min-width:0;">
<div style="font-weight:700;font-size:0.92rem;margin-bottom:0.15rem;">${titleEsc}${mins}</div>
${descEsc ? `<div style="font-size:0.82rem;color:var(--color-muted);margin-bottom:0.25rem;line-height:1.4;">${descEsc}</div>` : ''}
<div style="font-size:0.78rem;color:var(--color-muted);">Gestellt: ${fmtDateTime(t.assignedAt)}</div>
<div style="font-size:0.78rem;color:${urgent ? '#e74c3c' : 'var(--color-muted)'};">Fällig bis: ${fmtDateTime(t.acceptDeadline)}</div>
</div>
<span style="font-size:0.75rem;color:var(--color-primary);font-weight:600;flex-shrink:0;align-self:center;">Details →</span>
</div>`;
}).join('');
}
function openAssignedTaskModal(taskId) {
const t = assignedTaskCache[taskId];
if (!t) return;
activeAssignedTaskId = taskId;
document.getElementById('assignedTaskModalError').style.display = 'none';
const titleEsc = (t.taskTitle || '').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const descEsc = (t.taskDescription || '').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const taskMinutes = t.taskMinutes || 0;
const penaltyFreezeMinutes = t.penaltyFreezeMinutes;
const penaltyRedCards = t.penaltyRedCards;
const minsHtml = taskMinutes > 0 ? `<div>Zeit: <strong>${taskMinutes} Minute${taskMinutes !== 1 ? 'n' : ''}</strong></div>` : '';
const descHtml = descEsc ? `<div style="margin-top:0.35rem;">${descEsc}</div>` : '';
document.getElementById('assignedTaskModalInfo').innerHTML =
`<div style="font-weight:700;font-size:0.97rem;color:var(--color-text);margin-bottom:0.4rem;">${titleEsc}</div>` +
descHtml + minsHtml +
`<div style="margin-top:0.5rem;">Gestellt am: <strong>${fmtDateTime(t.assignedAt)}</strong></div>` +
`<div>Annehmen bis: <strong>${fmtDateTime(t.acceptDeadline)}</strong></div>`;
const penaltyParts = [];
if (penaltyFreezeMinutes > 0) {
const d = Math.floor(penaltyFreezeMinutes / 1440);
const h = Math.floor((penaltyFreezeMinutes % 1440) / 60);
const m = penaltyFreezeMinutes % 60;
const parts = [];
if (d) parts.push(d + 'd');
if (h) parts.push(h + 'h');
if (m) parts.push(m + 'min');
penaltyParts.push('❄️ Einfrieren für ' + parts.join(' '));
}
if (penaltyRedCards > 0) penaltyParts.push('🔴 ' + penaltyRedCards + ' rote Karte' + (penaltyRedCards !== 1 ? 'n' : '') + ' hinzufügen');
document.getElementById('assignedTaskPenaltyInfo').innerHTML =
'<strong>Strafe bei Ablehnung:</strong><br>' + (penaltyParts.join('<br>') || '');
document.getElementById('assignedTaskModal').style.display = 'flex';
}
function closeAssignedTaskModal() {
document.getElementById('assignedTaskModal').style.display = 'none';
activeAssignedTaskId = null;
}
async function respondAssignedTask(action) {
if (!activeAssignedTaskId) return;
const errEl = document.getElementById('assignedTaskModalError');
errEl.style.display = 'none';
try {
const res = await fetch(`/keyholder/cardlock/${lockId}/assigned-tasks/${activeAssignedTaskId}/${action}`, { method: 'POST' });
if (res.ok || res.status === 204) {
closeAssignedTaskModal();
loadLock();
} else {
const data = await res.json().catch(() => ({}));
errEl.textContent = data.error || 'Fehler.';
errEl.style.display = '';
}
} catch(e) {
errEl.textContent = 'Fehler bei der Verbindung.';
errEl.style.display = '';
}
}
document.getElementById('assignedTaskModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeAssignedTaskModal();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('assignedTaskModal').style.display === 'flex')
closeAssignedTaskModal();
});
// ── Keyholder-Bar ──────────────────────────────────────────────────────────
function renderKeyholderBar(lock) {
const bar = document.getElementById('keyholderInfoBar');
const avatar = document.getElementById('keyholderInfoAvatar');
@@ -827,53 +1059,57 @@
const frozenLabel = document.getElementById('nextcardFrozenLabel');
const overlayLabel = document.getElementById('nextcardOverlayLabel');
const taskText = document.getElementById('nextcardTaskText');
const taskDesc = document.getElementById('nextcardTaskDescription');
const taskCountdown = document.getElementById('nextcardTaskCountdown');
const btnErledigt = document.getElementById('btnTaskErledigt');
// Overlay-Reset
overlay.classList.remove('frozen');
overlay.classList.remove('frozen', 'task');
timerBox.style.display = 'none';
taskBox.style.display = 'none';
countdown.style.display = 'none';
frozenLabel.style.display = 'none';
const frozenUntill = lock.frozenUntill ? new Date(lock.frozenUntill) : null;
const currentTask = lock.currentTask || null;
const frozenUntill = lock.frozenUntill ? new Date(lock.frozenUntill) : null;
const taskFrozenUntil = lock.taskFrozenUntil ? new Date(lock.taskFrozenUntil) : null;
const currentTask = lock.currentTask || null;
const currentTaskDesc = lock.currentTaskDescription || null;
// ── Zustand 1: Task + Freeze-Countdown ──────────────────────────────
if (frozenUntill && currentTask) {
overlay.style.display = '';
taskBox.style.display = '';
taskText.textContent = currentTask;
taskCountdown.style.display = '';
btnErledigt.disabled = true;
function tickTask() {
const diff = frozenUntill - Date.now();
if (diff <= 0) {
taskCountdown.style.display = 'none';
btnErledigt.disabled = false;
clearInterval(tickInterval); tickInterval = null;
return;
}
taskCountdown.textContent = fmtCountdown(diff);
}
tickTask();
tickInterval = setInterval(tickTask, 1000);
return;
}
// ── Zustand 2: Nur Task (sofort erledigbar) ──────────────────────────
// ── Zustand 1: Aktive Aufgabe (mit oder ohne Task-Timer) ─────────────
if (currentTask) {
overlay.style.display = '';
overlay.classList.add('task');
taskBox.style.display = '';
taskText.textContent = currentTask;
taskCountdown.style.display = 'none';
btnErledigt.disabled = false;
if (currentTaskDesc) {
taskDesc.textContent = currentTaskDesc;
taskDesc.style.display = '';
} else {
taskDesc.style.display = 'none';
}
if (taskFrozenUntil && taskFrozenUntil > new Date()) {
btnErledigt.disabled = true;
taskCountdown.style.display = '';
function tickTask() {
const diff = taskFrozenUntil - Date.now();
if (diff <= 0) {
taskCountdown.style.display = 'none';
btnErledigt.disabled = false;
clearInterval(tickInterval); tickInterval = null;
return;
}
taskCountdown.textContent = '⏱ noch ' + fmtCountdown(diff);
}
tickTask();
tickInterval = setInterval(tickTask, 1000);
} else {
btnErledigt.disabled = false;
taskCountdown.style.display = 'none';
}
return;
}
// ── Zustand 3: Nur eingefroren ───────────────────────────────────────
// ── Zustand 2: Nur Keyholder-Freeze (keine Aufgabe) ──────────────────
if (frozenUntill && frozenUntill > new Date()) {
overlay.style.display = '';
overlay.classList.add('frozen');
@@ -1045,7 +1281,9 @@
inner.classList.remove('flipped');
info.classList.remove('visible');
green.classList.remove('visible');
document.getElementById('drawUnlockCode').style.display = 'none';
document.getElementById('drawGreenText').style.display = '';
document.getElementById('drawUnlockCode').style.display = 'none';
document.getElementById('drawTaskPendingHint').style.display = 'none';
document.getElementById('btnDrawOk').style.display = '';
document.getElementById('btnDrawUnlock').style.display = 'none';
document.getElementById('btnDrawKeep').style.display = 'none';
@@ -1073,6 +1311,16 @@
info.classList.add('visible');
actions.style.display = '';
if (dto.taskPending) {
const msgs = {
'KEYHOLDER': '🔑 Dein Keyholder wählt für dich eine Aufgabe aus. Du wirst benachrichtigt, sobald eine Aufgabe zugewiesen wurde.',
'COMMUNITY': '🗳️ Die Community stimmt ab, welche Aufgabe du erhältst. Das Ergebnis steht in einer Stunde fest.',
'RANDOM': '🎲 Da es sich um ein Test-Lock handelt, wird nach einer Stunde automatisch eine zufällige Aufgabe ausgewählt.',
};
document.getElementById('drawTaskPendingText').textContent = msgs[dto.taskPending] || '';
document.getElementById('drawTaskPendingHint').style.display = '';
}
if (dto.card === 'GREEN') {
green.classList.add('visible');
document.getElementById('btnDrawOk').style.display = 'none';
@@ -1213,6 +1461,7 @@
// ── Lock beenden ──
function lockBeendenFragen() {
document.getElementById('warnModalUnlockCode').textContent = _currentLock ? (_currentLock.unlockCode || '') : '';
document.getElementById('warnModal').classList.add('open');
}

Binary file not shown.

View File

@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Benachrichtigungen XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.notif-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 680px;
}
.notif-item {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 0.75rem 1rem;
display: flex;
gap: 0.75rem;
align-items: flex-start;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
text-decoration: none;
color: inherit;
}
.notif-item:hover {
border-color: var(--color-primary);
background: rgba(255,255,255,0.03);
}
.notif-item.unread {
border-left: 3px solid var(--color-primary);
background: rgba(var(--color-primary-rgb, 200,0,0), 0.05);
}
.notif-item.unread:hover {
background: rgba(var(--color-primary-rgb, 200,0,0), 0.09);
}
.notif-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
overflow: hidden;
}
.notif-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.notif-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
margin-top: 0.45rem;
}
.notif-dot.read { background: transparent; }
.notif-body { flex: 1; min-width: 0; }
.notif-text {
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.notif-time {
font-size: 0.75rem;
color: var(--color-muted);
margin-top: 0.2rem;
}
.notif-arrow {
font-size: 0.75rem;
color: var(--color-muted);
flex-shrink: 0;
align-self: center;
}
.notif-empty {
color: var(--color-muted);
font-size: 0.9rem;
margin-top: 0.5rem;
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
max-width: 680px;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="notif-header">
<h1 style="margin:0;">🔔 Benachrichtigungen</h1>
<button id="markAllBtn" onclick="markAllRead()"
style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);
padding:0.35rem 0.85rem;border-radius:7px;cursor:pointer;font-size:0.82rem;width:auto;display:none;">
Alle als gelesen markieren
</button>
</div>
<div class="notif-list" id="notifList">
<p class="notif-empty" id="notifEmpty" style="display:none;">Keine Benachrichtigungen vorhanden.</p>
</div>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function fmtRelTime(isoStr) {
const diff = Date.now() - new Date(isoStr).getTime();
const min = Math.floor(diff / 60000);
const h = Math.floor(min / 60);
const d = Math.floor(h / 24);
if (d > 0) return `vor ${d} Tag${d > 1 ? 'en' : ''}`;
if (h > 0) return `vor ${h} Std.`;
if (min > 0) return `vor ${min} Min.`;
return 'gerade eben';
}
function esc(s) {
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
async function handleClick(id, targetUrl) {
// Als gelesen markieren
await fetch(`/notifications/${id}/read`, { method: 'POST' }).catch(() => {});
// Dot des angeklickten Items auf "gelesen" setzen
const dot = document.querySelector(`[data-notif-id="${id}"] .notif-dot`);
if (dot) dot.classList.add('read');
const item = document.querySelector(`[data-notif-id="${id}"]`);
if (item) item.classList.remove('unread');
// Badge aktualisieren
const remaining = document.querySelectorAll('.notif-item.unread').length;
['socialNotifBadge','socialMobileNotifBadge'].forEach(bid => {
const el = document.getElementById(bid);
if (!el) return;
el.textContent = remaining;
el.style.display = remaining > 0 ? '' : 'none';
});
if (remaining === 0) document.getElementById('markAllBtn').style.display = 'none';
// Navigieren wenn Zielseite vorhanden
if (targetUrl) window.location.href = targetUrl;
}
async function markAllRead() {
await fetch('/notifications/read-all', { method: 'POST' }).catch(() => {});
document.querySelectorAll('.notif-item.unread').forEach(el => {
el.classList.remove('unread');
const dot = el.querySelector('.notif-dot');
if (dot) dot.classList.add('read');
});
document.getElementById('markAllBtn').style.display = 'none';
['socialNotifBadge','socialMobileNotifBadge'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.textContent = '0'; el.style.display = 'none'; }
});
}
async function loadNotifications() {
const list = document.getElementById('notifList');
const empty = document.getElementById('notifEmpty');
const btn = document.getElementById('markAllBtn');
// Vorhandene Einträge (außer Empty-Hint) entfernen
list.querySelectorAll('.notif-item').forEach(el => el.remove());
try {
const res = await fetch('/notifications');
if (!res.ok) return;
const items = await res.json();
if (items.length === 0) {
empty.style.display = '';
btn.style.display = 'none';
return;
}
empty.style.display = 'none';
const hasUnread = items.some(n => !n.read);
btn.style.display = hasUnread ? '' : 'none';
items.forEach(n => {
const div = document.createElement('div');
div.className = 'notif-item' + (n.read ? '' : ' unread');
div.dataset.notifId = n.id;
div.setAttribute('role', 'button');
div.setAttribute('tabindex', '0');
div.onclick = () => handleClick(n.id, n.targetUrl);
div.onkeydown = e => { if (e.key === 'Enter') handleClick(n.id, n.targetUrl); };
const arrow = n.targetUrl
? `<span class="notif-arrow"></span>`
: '';
const avatarInner = n.senderAvatar
? `<img src="data:image/png;base64,${n.senderAvatar}" alt="${esc(n.senderName || '')}">`
: `<span>🔔</span>`;
div.innerHTML = `
<div class="notif-avatar">${avatarInner}</div>
<div class="notif-dot${n.read ? ' read' : ''}"></div>
<div class="notif-body">
<div class="notif-text">${esc(n.text)}</div>
<div class="notif-time">${fmtRelTime(n.sentAt)}</div>
</div>
${arrow}`;
list.appendChild(div);
});
} catch(e) {
console.error(e);
}
}
// SSE: Neue Benachrichtigung → sofort neu laden
window.__sseOnNotification = () => loadNotifications();
loadNotifications();
</script>
</body>
</html>

View File

@@ -113,6 +113,78 @@
font-size: 0.88rem;
}
/* Task Vote Section */
.task-vote-section {
margin-bottom: 2rem;
}
.task-vote-section-title {
font-size: 1rem;
font-weight: 700;
margin-bottom: 0.75rem;
color: var(--color-primary);
}
.task-vote-card {
background: var(--color-card);
border: 1px solid rgba(52,152,219,0.35);
border-radius: 10px;
padding: 0.85rem 1rem;
margin-bottom: 0.75rem;
}
.task-vote-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.task-vote-lockee {
font-weight: 600;
font-size: 0.92rem;
}
.task-vote-expires {
font-size: 0.78rem;
color: var(--color-muted);
}
.task-vote-options {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.5rem;
}
.task-vote-btn {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(52,152,219,0.08);
border: 1px solid rgba(52,152,219,0.25);
border-radius: 7px;
padding: 0.45rem 0.7rem;
cursor: pointer;
color: var(--color-text);
text-align: left;
font-size: 0.85rem;
transition: background 0.15s, border-color 0.15s;
width: 100%;
margin: 0;
}
.task-vote-btn:hover:not(:disabled) {
background: rgba(52,152,219,0.22);
border-color: rgba(52,152,219,0.5);
}
.task-vote-btn.my-vote {
border-color: var(--color-primary);
background: rgba(52,152,219,0.18);
}
.task-vote-btn:disabled {
cursor: default;
}
.task-vote-count {
font-size: 0.78rem;
color: var(--color-muted);
white-space: nowrap;
margin-left: 0.5rem;
flex-shrink: 0;
}
.empty-hint {
color: var(--color-muted);
font-size: 0.9rem;
@@ -133,7 +205,16 @@
<div class="content">
<div class="page-title">Community Votes</div>
<div class="page-subtitle">Verifikationen - stimme ab</div>
<div class="page-subtitle">Verifikationen &amp; Aufgaben-Abstimmungen</div>
<!-- Aktive Aufgaben-Abstimmungen -->
<div class="task-vote-section" id="taskVoteSection" style="display:none;">
<div class="task-vote-section-title">🃏 Aufgaben-Abstimmungen</div>
<div id="taskVoteList"></div>
</div>
<div class="page-title" style="font-size:1rem;margin-bottom:0.25rem;">Verifikationen</div>
<div class="page-subtitle" style="margin-bottom:0.75rem;">Stimme ab, ob die Verifikation gültig ist</div>
<div class="vote-grid" id="voteGrid"></div>
<div class="load-spinner" id="loadSpinner" style="display:none;">Lädt…</div>
@@ -146,6 +227,83 @@
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
// ── Aufgaben-Abstimmungen ──────────────────────────────────────────────────
function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtDateTime(isoStr) {
return new Date(isoStr).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
}
async function loadTaskVotes() {
try {
const res = await fetch('/task-card/community/votes');
if (!res.ok) return;
const votes = await res.json();
const section = document.getElementById('taskVoteSection');
const list = document.getElementById('taskVoteList');
list.innerHTML = '';
if (votes.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = '';
votes.forEach(vote => {
const card = document.createElement('div');
card.className = 'task-vote-card';
card.dataset.voteSessionId = vote.voteSessionId;
let optionsHtml = '';
(vote.tasks || []).forEach((t, i) => {
const count = (vote.voteCounts || [])[i] || 0;
const isMyVote = vote.myVote === i;
const alreadyVoted = vote.myVote !== null && vote.myVote !== undefined;
const desc = t.description ? `<div style="font-size:0.75rem;color:var(--color-muted);margin-top:0.1rem;">${esc(t.description)}</div>` : '';
const mins = t.minutes > 0 ? ` <span style="font-size:0.75rem;color:var(--color-muted);">⏱ ${t.minutes} Min.</span>` : '';
optionsHtml += `<button class="task-vote-btn ${isMyVote ? 'my-vote' : ''}"
id="tvbtn-${vote.voteSessionId}-${i}"
${alreadyVoted ? 'disabled' : ''}
onclick="castTaskVote('${vote.voteSessionId}', ${i})">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;">${esc(t.title)}${mins}</div>
${desc}
</div>
<span class="task-vote-count" id="tvcount-${vote.voteSessionId}-${i}">${count} Stimme${count !== 1 ? 'n' : ''}</span>
</button>`;
});
card.innerHTML = `
<div class="task-vote-header">
<span class="task-vote-lockee">🔒 ${esc(vote.lockeeName)}</span>
<span class="task-vote-expires">Endet: ${fmtDateTime(vote.expiresAt)}</span>
</div>
<div class="task-vote-options">${optionsHtml}</div>`;
list.appendChild(card);
});
} catch(e) { console.error(e); }
}
async function castTaskVote(voteSessionId, taskIndex) {
// Disable all buttons for this vote immediately
document.querySelectorAll(`[id^="tvbtn-${voteSessionId}-"]`).forEach(btn => btn.disabled = true);
const res = await fetch(`/task-card/community/votes/${voteSessionId}/vote/${taskIndex}`, { method: 'POST' });
if (res.ok || res.status === 204) {
const countEl = document.getElementById(`tvcount-${voteSessionId}-${taskIndex}`);
if (countEl) {
const current = parseInt(countEl.textContent) || 0;
const next = current + 1;
countEl.textContent = `${next} Stimme${next !== 1 ? 'n' : ''}`;
}
const btn = document.getElementById(`tvbtn-${voteSessionId}-${taskIndex}`);
if (btn) btn.classList.add('my-vote');
}
}
// ── Verifikations-Votes ───────────────────────────────────────────────────
let page = 0;
let loading = false;
let exhausted = false;
@@ -243,6 +401,7 @@
}, { rootMargin: '200px' });
observer.observe(document.getElementById('sentinel'));
loadTaskVotes();
loadPage();
</script>
</body>

View File

@@ -331,7 +331,7 @@ body.app {
/* ── Social Sidebar ── */
.social-sidebar {
width: 220px;
width: 260px;
flex-shrink: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);

View File

@@ -33,13 +33,7 @@
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Sektionen */
.inv-section { margin-bottom: 2rem; }
.inv-section-title {
font-size: 0.8rem; font-weight: 700; color: var(--color-primary);
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 0.75rem;
}
/* Liste */
.inv-list { display: flex; flex-direction: column; gap: 0.5rem; }
.inv-card {
@@ -49,21 +43,60 @@
display: flex; align-items: center; gap: 0.9rem;
padding: 0.75rem 1rem;
}
/* Avatar mit Typ-Badge */
.inv-avatar-wrap {
position: relative;
flex-shrink: 0;
}
.inv-avatar {
width: 52px; height: 52px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; flex-shrink: 0; overflow: hidden;
font-size: 1.4rem; overflow: hidden;
border: 1px solid rgba(255,255,255,0.08);
}
.inv-avatar img { width: 100%; height: 100%; object-fit: cover; }
.inv-type-badge {
position: absolute;
top: -6px; left: -6px;
width: 26px; height: 26px;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 1.08rem;
z-index: 1;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.inv-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
.inv-line1 { font-size: 0.78rem; color: var(--color-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inv-line2 { font-weight: 700; font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inv-line3 { font-size: 0.78rem; color: var(--color-muted); }
.empty-hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.25rem; }
/* Paging */
.paging-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 1rem;
font-size: 0.88rem;
color: var(--color-muted);
}
.paging-bar button {
width: auto;
padding: 0.4rem 0.9rem;
font-size: 0.85rem;
}
.paging-bar button:disabled {
opacity: 0.35;
cursor: default;
}
/* Lockee-Einladungs-Dialog */
.lockee-dialog-bg {
display: none; position: fixed; inset: 0; z-index: 400;
@@ -166,30 +199,16 @@
<!-- Tab: Empfangen -->
<div id="tab-empfangen" class="tab-panel active">
<div class="inv-section">
<div class="inv-section-title">Lockee-Einladungen</div>
<div class="inv-list" id="lockeeInvGrid"></div>
<p class="empty-hint" id="lockeeInvEmpty" style="display:none;">Keine ausstehenden Lockee-Einladungen.</p>
</div>
<div class="inv-section">
<div class="inv-section-title">Keyholder-Einladungen</div>
<div class="inv-list" id="khInvGrid"></div>
<p class="empty-hint" id="khInvEmpty" style="display:none;">Keine ausstehenden Keyholder-Einladungen.</p>
</div>
<div class="inv-list" id="recvList"></div>
<p class="empty-hint" id="recvEmpty" style="display:none;">Keine ausstehenden Einladungen.</p>
<div class="paging-bar" id="recvPaging" style="display:none;"></div>
</div>
<!-- Tab: Gesendet -->
<div id="tab-gesendet" class="tab-panel">
<div class="inv-section">
<div class="inv-section-title">Gesendete Lockee-Einladungen</div>
<div class="inv-list" id="sentLockeeGrid"></div>
<p class="empty-hint" id="sentLockeeEmpty" style="display:none;">Keine ausstehenden gesendeten Lockee-Einladungen.</p>
</div>
<div class="inv-section">
<div class="inv-section-title">Gesendete Keyholder-Einladungen</div>
<div class="inv-list" id="sentKhGrid"></div>
<p class="empty-hint" id="sentKhEmpty" style="display:none;">Keine ausstehenden gesendeten Keyholder-Einladungen.</p>
</div>
<div class="inv-list" id="sentList"></div>
<p class="empty-hint" id="sentEmpty" style="display:none;">Keine ausstehenden gesendeten Einladungen.</p>
<div class="paging-bar" id="sentPaging" style="display:none;"></div>
</div>
</div>
</div>
@@ -198,6 +217,7 @@
<div class="lockee-dialog-bg" id="lockeeInviteDialog">
<div class="lockee-dialog-overlay" onclick="closeLockeeInviteDialog()"></div>
<div class="lockee-dialog-box">
<button onclick="closeLockeeInviteDialog()" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.5rem;line-height:1;" title="Schließen"></button>
<div class="lockee-dialog-header">
<div class="lockee-dialog-avatar" id="dialogAvatar">🔒</div>
<div>
@@ -237,6 +257,7 @@
</div>
</div>
<script src="/js/card-defs.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
@@ -248,219 +269,251 @@
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-' + name));
history.replaceState(null, '', '?tab=' + name);
}
// Activate tab from URL param
const urlTab = new URLSearchParams(window.location.search).get('tab');
if (urlTab === 'gesendet') switchTab('gesendet');
// ── Empfangen: Lockee-Einladungen ──
async function loadLockeeInvitations() {
// ── Konstanten ──
const PAGE_SIZE = 10;
// ── State ──
let recvItems = [];
let sentItems = [];
let recvPage = 0;
let sentPage = 0;
// ── Hilfsfunktionen ──
function fmtDate(iso) {
const dt = new Date(iso);
return dt.toLocaleDateString('de-DE') + ', ' + dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr';
}
function buildAvatarHtml(picBase64, type) {
const badge = type === 'keyholder' ? '🔑' : '🔒';
const inner = picBase64
? `<div class="inv-avatar"><img src="data:image/jpeg;base64,${picBase64}" alt=""></div>`
: `<div class="inv-avatar">👤</div>`;
return `<div class="inv-avatar-wrap"><span class="inv-type-badge">${badge}</span>${inner}</div>`;
}
function renderPaging(barId, page, total, onNav) {
const bar = document.getElementById(barId);
if (total <= 1) { bar.style.display = 'none'; return; }
bar.style.display = 'flex';
bar.innerHTML = `
<button onclick="${onNav}(${page - 1})" ${page === 0 ? 'disabled' : ''}> Zurück</button>
<span>Seite ${page + 1} von ${total}</span>
<button onclick="${onNav}(${page + 1})" ${page >= total - 1 ? 'disabled' : ''}>Weiter </button>`;
}
// ── Empfangen laden ──
async function loadReceivedInvitations() {
try {
const res = await fetch('/lockee/invitations/mine');
if (!res.ok) return;
const invs = await res.json();
const grid = document.getElementById('lockeeInvGrid');
const empty = document.getElementById('lockeeInvEmpty');
grid.innerHTML = '';
if (invs.length === 0) { empty.style.display = ''; return; }
empty.style.display = 'none';
invs.forEach(inv => {
const av = inv.keyholderProfilePic
? `<div class="inv-avatar"><img src="data:image/jpeg;base64,${inv.keyholderProfilePic}" alt=""></div>`
: `<div class="inv-avatar">👤</div>`;
const dt = new Date(inv.createdAt);
const createdStr = dt.toLocaleDateString('de-DE') + ', ' + dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr';
const card = document.createElement('div');
card.className = 'inv-card';
card.id = 'lockeeinv-' + inv.token;
card.dataset.detailsVisible = inv.detailsVisible ? '1' : '0';
card.innerHTML = `
${av}
<div class="inv-body">
<div class="inv-line1">${esc(inv.keyholderName)}</div>
<div class="inv-line2">${esc(inv.lockName)}</div>
<div class="inv-line3">Eingeladen am ${createdStr}</div>
</div>
const [lockeeRes, khRes] = await Promise.all([
fetch('/lockee/invitations/mine'),
fetch('/keyholder/invitations/mine')
]);
const lockeeInvs = lockeeRes.ok ? await lockeeRes.json() : [];
const khInvs = khRes.ok ? await khRes.json() : [];
lockeeInvs.forEach(inv => { inv._type = 'lockee'; inv._otherName = inv.keyholderName; inv._otherPic = inv.keyholderProfilePic; });
khInvs.forEach(inv => { inv._type = 'keyholder'; inv._otherName = inv.lockeeName; inv._otherPic = inv.lockeeProfilePic; });
recvItems = [...lockeeInvs, ...khInvs].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
recvPage = 0;
renderRecvPage();
} catch(e) { console.error(e); }
}
function renderRecvPage() {
const list = document.getElementById('recvList');
const empty = document.getElementById('recvEmpty');
list.innerHTML = '';
if (recvItems.length === 0) {
empty.style.display = '';
document.getElementById('recvPaging').style.display = 'none';
return;
}
empty.style.display = 'none';
const totalPages = Math.ceil(recvItems.length / PAGE_SIZE);
const start = recvPage * PAGE_SIZE;
const pageItems = recvItems.slice(start, start + PAGE_SIZE);
pageItems.forEach(inv => {
const av = buildAvatarHtml(inv._otherPic, inv._type);
const card = document.createElement('div');
card.className = 'inv-card';
card.id = 'recvinv-' + inv.token;
if (inv._type === 'lockee') card.dataset.detailsVisible = inv.detailsVisible ? '1' : '0';
const typeLabel = inv._type === 'lockee' ? 'Lockee-Einladung' : 'Keyholder-Einladung';
let actions;
if (inv._type === 'lockee') {
actions = `
<div style="display:flex;flex-direction:column;gap:0.4rem;flex-shrink:0;">
<button onclick="declineLockeeInvitation('${esc(inv.token)}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Ablehnen</button>
<button onclick="openLockeeInviteDialog('${esc(inv.token)}')" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:var(--color-success,#27ae60);border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✓ Details</button>
</div>`;
grid.appendChild(card);
});
} else {
actions = `
<div style="display:flex;flex-direction:column;gap:0.4rem;flex-shrink:0;">
<button onclick="declineKhInvitation('${esc(inv.token)}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Ablehnen</button>
<a href="/keyholder/invitation/${esc(inv.token)}" style="display:block;text-align:center;padding:0.45rem 1rem;font-size:0.85rem;background:var(--color-success);color:#fff;border-radius:6px;text-decoration:none;font-weight:600;">✓ Annehmen</a>
</div>`;
}
const rolePrefix = inv._type === 'lockee' ? 'Lockee: ' : 'Keyholder: ';
card.innerHTML = `
${av}
<div class="inv-body">
<div class="inv-line1">${esc(inv._otherName)}</div>
<div class="inv-line2">${rolePrefix}${esc(inv.lockName)}</div>
<div class="inv-line3">${typeLabel} · ${fmtDate(inv.createdAt)}</div>
</div>
${actions}`;
list.appendChild(card);
});
renderPaging('recvPaging', recvPage, totalPages, 'goRecvPage');
}
function goRecvPage(page) {
const total = Math.ceil(recvItems.length / PAGE_SIZE);
if (page < 0 || page >= total) return;
recvPage = page;
renderRecvPage();
document.getElementById('recvList').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function removeRecvItem(token) {
recvItems = recvItems.filter(i => i.token !== token);
const total = Math.ceil(recvItems.length / PAGE_SIZE);
if (recvPage >= total && recvPage > 0) recvPage = total - 1;
renderRecvPage();
}
// ── Gesendet laden ──
async function loadSentInvitations() {
try {
const [lockeeRes, khRes] = await Promise.all([
fetch('/lockee/invitations/sent'),
fetch('/keyholder/invitations/sent')
]);
const lockeeInvs = lockeeRes.ok ? await lockeeRes.json() : [];
const khInvs = khRes.ok ? await khRes.json() : [];
lockeeInvs.forEach(inv => { inv._type = 'lockee'; inv._otherName = inv.lockeeName; inv._otherPic = inv.lockeeProfilePic; });
khInvs.forEach(inv => { inv._type = 'keyholder'; inv._otherName = inv.keyholderName; inv._otherPic = inv.keyholderProfilePic; });
sentItems = [...lockeeInvs, ...khInvs].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
sentPage = 0;
renderSentPage();
} catch(e) { console.error(e); }
}
function renderSentPage() {
const list = document.getElementById('sentList');
const empty = document.getElementById('sentEmpty');
list.innerHTML = '';
if (sentItems.length === 0) {
empty.style.display = '';
document.getElementById('sentPaging').style.display = 'none';
return;
}
empty.style.display = 'none';
const totalPages = Math.ceil(sentItems.length / PAGE_SIZE);
const start = sentPage * PAGE_SIZE;
const pageItems = sentItems.slice(start, start + PAGE_SIZE);
pageItems.forEach(inv => {
const av = buildAvatarHtml(inv._otherPic, inv._type);
const card = document.createElement('div');
card.className = 'inv-card';
card.id = 'sentinv-' + inv.token;
const typeLabel = inv._type === 'lockee' ? 'Lockee-Einladung' : 'Keyholder-Einladung';
let extra = '';
if (inv._type === 'lockee') {
extra = inv.detailsVisible
? ' &nbsp;<span style="font-size:0.72rem;">👁 Details sichtbar</span>'
: ' &nbsp;<span style="font-size:0.72rem;">🙈 Details verborgen</span>';
}
const rolePrefix2 = inv._type === 'lockee' ? 'Lockee: ' : 'Keyholder: ';
card.innerHTML = `
${av}
<div class="inv-body">
<div class="inv-line1">${esc(inv._otherName)}</div>
<div class="inv-line2">${rolePrefix2}${esc(inv.lockName)}</div>
<div class="inv-line3">${typeLabel} · ${fmtDate(inv.createdAt)}${extra}</div>
</div>
<div style="flex-shrink:0;">
<button onclick="cancelSentInvitation('${esc(inv.token)}', '${inv._type}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Zurückziehen</button>
</div>`;
list.appendChild(card);
});
renderPaging('sentPaging', sentPage, totalPages, 'goSentPage');
}
function goSentPage(page) {
const total = Math.ceil(sentItems.length / PAGE_SIZE);
if (page < 0 || page >= total) return;
sentPage = page;
renderSentPage();
document.getElementById('sentList').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function removeSentItem(token) {
sentItems = sentItems.filter(i => i.token !== token);
const total = Math.ceil(sentItems.length / PAGE_SIZE);
if (sentPage >= total && sentPage > 0) sentPage = total - 1;
renderSentPage();
}
// ── Aktionen: Empfangen ──
async function declineLockeeInvitation(token, btn) {
if (!confirm('Bist du sicher, dass du diese Einladung ablehnen möchtest?')) return;
btn.disabled = true;
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token), { method: 'DELETE' });
if (res.ok || res.status === 204) {
document.getElementById('lockeeinv-' + token)?.remove();
const grid = document.getElementById('lockeeInvGrid');
if (grid.children.length === 0) document.getElementById('lockeeInvEmpty').style.display = '';
} else { btn.disabled = false; }
if (res.ok || res.status === 204) { removeRecvItem(token); }
else { btn.disabled = false; }
} catch(e) { btn.disabled = false; }
}
// ── Empfangen: Keyholder-Einladungen ──
async function loadKeyholderInvitations() {
try {
const res = await fetch('/keyholder/invitations/mine');
if (!res.ok) return;
const invs = await res.json();
const grid = document.getElementById('khInvGrid');
const empty = document.getElementById('khInvEmpty');
grid.innerHTML = '';
if (invs.length === 0) { empty.style.display = ''; return; }
empty.style.display = 'none';
invs.forEach(inv => {
const av = inv.lockeeProfilePic
? `<div class="inv-avatar"><img src="data:image/jpeg;base64,${inv.lockeeProfilePic}" alt=""></div>`
: `<div class="inv-avatar">👤</div>`;
const dt = new Date(inv.createdAt);
const createdStr = dt.toLocaleDateString('de-DE') + ', ' + dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr';
const card = document.createElement('div');
card.className = 'inv-card';
card.id = 'khinv-' + inv.token;
card.innerHTML = `
${av}
<div class="inv-body">
<div class="inv-line1">${esc(inv.lockeeName)}</div>
<div class="inv-line2">${esc(inv.lockName)}</div>
<div class="inv-line3">Eingeladen am ${createdStr}</div>
</div>
<div style="display:flex;flex-direction:column;gap:0.4rem;flex-shrink:0;">
<button onclick="declineKhInvitation('${esc(inv.token)}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Ablehnen</button>
<a href="/keyholder/invitation/${esc(inv.token)}" style="display:block;text-align:center;padding:0.45rem 1rem;font-size:0.85rem;background:var(--color-success);color:#fff;border-radius:6px;text-decoration:none;font-weight:600;">✓ Annehmen</a>
</div>`;
grid.appendChild(card);
});
} catch(e) { console.error(e); }
}
async function declineKhInvitation(token, btn) {
if (!confirm('Bist du sicher, dass du diese Einladung ablehnen möchtest?')) return;
btn.disabled = true;
try {
const res = await fetch('/keyholder/invitations/mine/' + encodeURIComponent(token), { method: 'DELETE' });
if (res.ok || res.status === 204) {
document.getElementById('khinv-' + token)?.remove();
const grid = document.getElementById('khInvGrid');
if (grid.children.length === 0) document.getElementById('khInvEmpty').style.display = '';
} else { btn.disabled = false; }
if (res.ok || res.status === 204) { removeRecvItem(token); }
else { btn.disabled = false; }
} catch(e) { btn.disabled = false; }
}
// ── Gesendet: Lockee-Einladungen ──
async function loadSentLockeeInvitations() {
try {
const res = await fetch('/lockee/invitations/sent');
if (!res.ok) return;
const invs = await res.json();
const grid = document.getElementById('sentLockeeGrid');
const empty = document.getElementById('sentLockeeEmpty');
grid.innerHTML = '';
if (invs.length === 0) { empty.style.display = ''; return; }
empty.style.display = 'none';
invs.forEach(inv => {
const av = inv.lockeeProfilePic
? `<div class="inv-avatar"><img src="data:image/jpeg;base64,${inv.lockeeProfilePic}" alt=""></div>`
: `<div class="inv-avatar">👤</div>`;
const dt = new Date(inv.createdAt);
const createdStr = dt.toLocaleDateString('de-DE') + ', ' + dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr';
const badge = inv.detailsVisible
? '<span style="font-size:0.72rem;">👁 Details sichtbar</span>'
: '<span style="font-size:0.72rem;">🙈 Details verborgen</span>';
const card = document.createElement('div');
card.className = 'inv-card';
card.id = 'sentlockeeinv-' + inv.token;
card.innerHTML = `
${av}
<div class="inv-body">
<div class="inv-line1">${esc(inv.lockeeName)}</div>
<div class="inv-line2">${esc(inv.lockName)}</div>
<div class="inv-line3">Gesendet am ${createdStr} &nbsp;${badge}</div>
</div>
<div style="flex-shrink:0;">
<button onclick="cancelSentLockeeInvitation('${esc(inv.token)}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Zurückziehen</button>
</div>`;
grid.appendChild(card);
});
} catch(e) { console.error(e); }
}
async function cancelSentLockeeInvitation(token, btn) {
if (!confirm('Einladung zurückziehen? Das Lock wird gelöscht und der Lockee wird benachrichtigt.')) return;
// ── Aktionen: Gesendet ──
async function cancelSentInvitation(token, type, btn) {
const msg = type === 'lockee'
? 'Einladung zurückziehen? Das Lock wird gelöscht und der Lockee wird benachrichtigt.'
: 'Keyholder-Einladung zurückziehen? Der Keyholder wird benachrichtigt.';
if (!confirm(msg)) return;
btn.disabled = true;
const url = type === 'lockee'
? '/lockee/invitations/sent/' + encodeURIComponent(token)
: '/keyholder/invitations/sent/' + encodeURIComponent(token);
try {
const res = await fetch('/lockee/invitations/sent/' + encodeURIComponent(token), { method: 'DELETE' });
if (res.ok || res.status === 204) {
document.getElementById('sentlockeeinv-' + token)?.remove();
const grid = document.getElementById('sentLockeeGrid');
if (grid.children.length === 0) document.getElementById('sentLockeeEmpty').style.display = '';
} else { btn.disabled = false; }
} catch(e) { btn.disabled = false; }
}
// ── Gesendet: Keyholder-Einladungen ──
async function loadSentKeyholderInvitations() {
try {
const res = await fetch('/keyholder/invitations/sent');
if (!res.ok) return;
const invs = await res.json();
const grid = document.getElementById('sentKhGrid');
const empty = document.getElementById('sentKhEmpty');
grid.innerHTML = '';
if (invs.length === 0) { empty.style.display = ''; return; }
empty.style.display = 'none';
invs.forEach(inv => {
const av = inv.keyholderProfilePic
? `<div class="inv-avatar"><img src="data:image/jpeg;base64,${inv.keyholderProfilePic}" alt=""></div>`
: `<div class="inv-avatar">👤</div>`;
const dt = new Date(inv.createdAt);
const createdStr = dt.toLocaleDateString('de-DE') + ', ' + dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr';
const card = document.createElement('div');
card.className = 'inv-card';
card.id = 'sentkhinv-' + inv.token;
card.innerHTML = `
${av}
<div class="inv-body">
<div class="inv-line1">${esc(inv.keyholderName)}</div>
<div class="inv-line2">${esc(inv.lockName)}</div>
<div class="inv-line3">Gesendet am ${createdStr}</div>
</div>
<div style="flex-shrink:0;">
<button onclick="cancelSentKhInvitation('${esc(inv.token)}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Zurückziehen</button>
</div>`;
grid.appendChild(card);
});
} catch(e) { console.error(e); }
}
async function cancelSentKhInvitation(token, btn) {
if (!confirm('Keyholder-Einladung zurückziehen? Der Keyholder wird benachrichtigt.')) return;
btn.disabled = true;
try {
const res = await fetch('/keyholder/invitations/sent/' + encodeURIComponent(token), { method: 'DELETE' });
if (res.ok || res.status === 204) {
document.getElementById('sentkhinv-' + token)?.remove();
const grid = document.getElementById('sentKhGrid');
if (grid.children.length === 0) document.getElementById('sentKhEmpty').style.display = '';
} else { btn.disabled = false; }
const res = await fetch(url, { method: 'DELETE' });
if (res.ok || res.status === 204) { removeSentItem(token); }
else { btn.disabled = false; }
} catch(e) { btn.disabled = false; }
}
// ── Lockee-Einladungs-Dialog ──
const CARD_DEFS_DIALOG = [
{ id: 'RED', img: '/img/card_red.png', name: 'Rot' },
{ id: 'GREEN', img: '/img/card_green.png', name: 'Grün' },
{ id: 'YELLOW', img: '/img/card_yellow.png', name: 'Gelb' },
{ id: 'TASK', img: '/img/card_task.png', name: 'Aufgabe' },
{ id: 'FREEZE', img: '/img/card_freeze.png', name: 'Freeze' },
{ id: 'RESET', img: '/img/card_reset.png', name: 'Reset' },
{ id: 'DOUBLE_UP', img: '/img/card_doubleup.png', name: 'Double Up' },
];
// CARD_DEFS wird von /js/card-defs.js bereitgestellt.
function fmtMinutes(min) {
if (!min) return '';
@@ -483,7 +536,7 @@
}
const cardCounts = inv.cardCounts || {};
const totalCards = Object.values(cardCounts).reduce((a, b) => a + b, 0);
const cardsHtml = CARD_DEFS_DIALOG
const cardsHtml = CARD_DEFS
.filter(c => cardCounts[c.id] > 0)
.map(c => `<div class="lock-details-card-item">
<img src="${c.img}" alt="${c.name}">
@@ -513,7 +566,7 @@
document.getElementById('dialogDetailsArea').innerHTML = '<div style="color:var(--color-muted);font-size:0.85rem;">Lade Details…</div>';
document.getElementById('lockeeInviteDialog').classList.add('open');
const card = document.getElementById('lockeeinv-' + token);
const card = document.getElementById('recvinv-' + token);
const line1 = card?.querySelector('.inv-line1')?.textContent || '';
const line2 = card?.querySelector('.inv-line2')?.textContent || '';
const line3 = card?.querySelector('.inv-line3')?.textContent || '';
@@ -570,9 +623,7 @@
}
const data = await res.json();
document.getElementById('lockeeInviteDialog').classList.remove('open');
document.getElementById('lockeeinv-' + activeDialogToken)?.remove();
const grid = document.getElementById('lockeeInvGrid');
if (grid.children.length === 0) document.getElementById('lockeeInvEmpty').style.display = '';
removeRecvItem(activeDialogToken);
showUnlockCodeModal(data.unlockCode, data.lockId);
} catch(e) {
acceptBtn.disabled = false;
@@ -588,9 +639,7 @@
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(activeDialogToken), { method: 'DELETE' });
if (res.ok || res.status === 204) {
document.getElementById('lockeeinv-' + activeDialogToken)?.remove();
const grid = document.getElementById('lockeeInvGrid');
if (grid.children.length === 0) document.getElementById('lockeeInvEmpty').style.display = '';
removeRecvItem(activeDialogToken);
closeLockeeInviteDialog();
} else {
declineBtn.disabled = false;
@@ -608,7 +657,7 @@
// ── Entsperrcode-Modal ──
function showUnlockCodeModal(code, lockId) {
document.getElementById('unlockCodeDisplay').textContent = code;
const url = '/sessionchastityingame.html?lockId=' + lockId;
const url = '/activelock.html?lockId=' + lockId;
const btn = document.getElementById('unlockModalBtn');
btn.onclick = () => startCodeScramble(code, url);
document.getElementById('unlockModal').classList.add('open');
@@ -654,11 +703,16 @@
}, 1000);
}
// ── Esc schließt Dialog ──
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('lockeeInviteDialog').classList.contains('open')) {
closeLockeeInviteDialog();
}
});
// ── Alles laden ──
loadLockeeInvitations();
loadKeyholderInvitations();
loadSentLockeeInvitations();
loadSentKeyholderInvitations();
loadReceivedInvitations();
loadSentInvitations();
</script>
</body>
</html>

View File

@@ -251,7 +251,7 @@
function showUnlockCodeModal(code, lid) {
document.getElementById('unlockCodeDisplay').textContent = code;
const url = '/sessionchastityingame.html?lockId=' + lid;
const url = '/activelock.html?lockId=' + lid;
const btn = document.getElementById('unlockModalBtn');
btn.onclick = () => startCodeScramble(code, url);
document.getElementById('unlockModal').classList.add('open');

View File

@@ -0,0 +1,70 @@
/**
* Zentrale Kartendefinitionen für das Chastity Game.
*
* Exportiert (global):
* CARD_DEFS Array mit { id, img, name, desc, defMin, defMax }
* CARD_LABELS Object { ID: { name, img, desc } } (Lookup für card-display.js u.a.)
*/
const CARD_DEFS = [
{
id: 'RED',
img: '/img/card_red.png',
name: 'Rote Karte',
desc: 'Niete - Viel Erfolg beim nächsten Zug',
defMin: 5,
defMax: 10,
},
{
id: 'GREEN',
img: '/img/card_green.png',
name: 'Grüne Karte',
desc: 'Öffnet das Lock. Kann wieder ins Deck zurück gelegt werden',
defMin: 1,
defMax: 2,
},
{
id: 'YELLOW',
img: '/img/card_yellow.png',
name: 'Gelbe Karte',
desc: 'Per Zufall werden rote Karten entfernt oder hinzugefügt',
defMin: 1,
defMax: 2,
},
{
id: 'TASK',
img: '/img/card_task.png',
name: 'Aufgabe',
desc: 'Keyholder*In, Community oder der Zufall teilt eine Aufgabe zu.',
defMin: 0,
defMax: 0,
},
{
id: 'FREEZE',
img: '/img/card_freeze.png',
name: 'Freeze',
desc: 'Friert das Lock für eine festgelegte Zeit ein in diesem Zeitraum können keine Karten gezogen werden.',
defMin: 0,
defMax: 0,
},
{
id: 'RESET',
img: '/img/card_reset.png',
name: 'Reset',
desc: 'Setzt das Kartendeck auf den Ausgangszustand zurück. Alle bisher gezogenen Karten kommen wieder rein.',
defMin: 0,
defMax: 0,
},
{
id: 'DOUBLE_UP',
img: '/img/card_doubleup.png',
name: 'Double Up',
desc: 'Verdoppelt alle noch im Deck vorhandenen Karten.',
defMin: 0,
defMax: 0,
},
];
/** Lookup-Objekt für Konsumenten, die nach ID auf Name/Bild/Beschreibung zugreifen. */
const CARD_LABELS = Object.fromEntries(
CARD_DEFS.map(c => [c.id, { name: c.name, img: c.img, desc: c.desc }])
);

View File

@@ -1,6 +1,7 @@
/**
* Gemeinsame Kartenanzeige für Chastity Game.
* Exportiert: CARD_LABELS, cardTypeGridHtml(cardCounts)
* Benötigt: /js/card-defs.js (CARD_LABELS muss bereits global verfügbar sein)
* Exportiert: cardTypeGridHtml(cardCounts)
*/
(function () {
const style = document.createElement('style');
@@ -35,16 +36,6 @@
document.head.appendChild(style);
})();
const CARD_LABELS = {
RED: { name: 'Rote Karte', img: '/img/card_red.png', desc: 'Verlängert die Sperrzeit. Je nach Konfiguration werden Minuten oder Stunden auf den Timer addiert.' },
GREEN: { name: 'Grüne Karte', img: '/img/card_green.png', desc: 'Verkürzt die Sperrzeit. Eine grüne Karte bringt dich dem Öffnen näher.' },
YELLOW: { name: 'Gelbe Karte', img: '/img/card_yellow.png', desc: 'Neutrales Ereignis keine Zeitveränderung, aber es passiert trotzdem etwas.' },
TASK: { name: 'Aufgabe', img: '/img/card_task.png', desc: 'Teilt dir eine zufällige Aufgabe aus der Aufgabenliste zu. Die Aufgabe muss erfüllt werden.' },
FREEZE: { name: 'Freeze', img: '/img/card_freeze.png', desc: 'Friert das Lock für eine festgelegte Zeit ein in diesem Zeitraum können keine Karten gezogen werden.' },
RESET: { name: 'Reset', img: '/img/card_reset.png', desc: 'Setzt das Kartendeck auf den Ausgangszustand zurück. Alle bisher gezogenen Karten kommen wieder rein.' },
DOUBLE_UP: { name: 'Double Up', img: '/img/card_doubleup.png', desc: 'Verdoppelt alle Karten im aktuellen Deck.' },
};
/**
* Gibt HTML für ein Karten-Typ-Raster zurück (ein Bild pro Typ, Anzahl-Badge).
* @param {Object} cardCounts { RED: 3, GREEN: 1, … }

View File

@@ -27,11 +27,12 @@
icon: '⊗',
items: [
{ href: '/infochastity.html', icon: '', label: 'Info' },
{ href: '/sessionchastity.html', icon: '▷', label: 'Neues Lock', id: 'navChastityNeu' },
{ href: '/neulock.html', icon: '▷', label: 'Neues Lock', id: 'navChastityNeu' },
{ href: '#', icon: '▶', label: 'Aktives Lock', id: 'navChastityAktiv' },
{ href: '/communityvotes.html', icon: '🗳️', label: 'Community Votes' },
{ href: '/meine-locks.html', icon: '🔒', label: 'Meine Locks' },
{ href: '/keyholder.html', icon: '🔑', label: 'Keyholder' },
{ href: '/unlock-history.html', icon: '🗝️', label: 'Code-Historie' },
]
},
];
@@ -125,7 +126,7 @@
const lockId = lockData.lockId;
if (navCAktiv) {
navCAktiv.style.display = '';
navCAktiv.querySelector('a').href = '/sessionchastityingame.html?lockId=' + lockId;
navCAktiv.querySelector('a').href = '/activelock.html?lockId=' + lockId;
}
}
} catch (_) { /* Menü bleibt im Standardzustand */ }

View File

@@ -5,12 +5,13 @@
const path = window.location.pathname;
const links = [
{ href: '/feed.html', icon: '📰', label: 'Feed', badgeId: null, mobileBadgeId: null },
{ href: '/personen-suchen.html', icon: '⊕', label: 'Personen suchen', badgeId: null, mobileBadgeId: null },
{ href: '/freunde.html', icon: '♡', label: 'Freunde', badgeId: 'socialFriendsBadge', mobileBadgeId: 'socialMobileFriendsBadge' },
{ href: '/nachrichten.html', icon: '✉', label: 'Nachrichten', badgeId: 'socialMsgBadge', mobileBadgeId: 'socialMobileMsgBadge' },
{ href: '/gruppen.html', icon: '👥', label: 'Gruppen', badgeId: 'socialGruppenBadge', mobileBadgeId: 'socialMobileGruppenBadge' },
{ href: '/einladungen.html', icon: '', label: 'Einladungen', badgeId: 'socialInvBadge', mobileBadgeId: 'socialMobileInvBadge' },
{ href: '/feed.html', icon: '📰', label: 'Feed', badgeId: null, mobileBadgeId: null },
{ href: '/personen-suchen.html', icon: '⊕', label: 'Personen suchen', badgeId: null, mobileBadgeId: null },
{ href: '/freunde.html', icon: '♡', label: 'Freunde', badgeId: 'socialFriendsBadge', mobileBadgeId: 'socialMobileFriendsBadge' },
{ href: '/nachrichten.html', icon: '✉', label: 'Nachrichten', badgeId: 'socialMsgBadge', mobileBadgeId: 'socialMobileMsgBadge' },
{ href: '/benachrichtigungen.html', icon: '🔔', label: 'Benachrichtigungen', badgeId: 'socialNotifBadge', mobileBadgeId: 'socialMobileNotifBadge' },
{ href: '/gruppen.html', icon: '👥', label: 'Gruppen', badgeId: 'socialGruppenBadge', mobileBadgeId: 'socialMobileGruppenBadge' },
{ href: '/einladungen.html', icon: '✉', label: 'Einladungen', badgeId: 'socialInvBadge', mobileBadgeId: 'socialMobileInvBadge' },
];
const profileActive = (path === '/benutzer.html' || path === '/profile.html') ? ' class="active"' : '';
@@ -102,6 +103,23 @@
});
}
// ── Ton abspielen ──
// Browser erlauben audio.play() sobald der Nutzer mindestens einmal interagiert hat.
let userHasInteracted = false;
document.addEventListener('click', () => { userHasInteracted = true; }, { passive: true });
document.addEventListener('keydown', () => { userHasInteracted = true; }, { passive: true });
document.addEventListener('touchstart', () => { userHasInteracted = true; }, { passive: true });
function playSound(src) {
if (!userHasInteracted) return;
try {
const audio = new Audio(src);
audio.volume = 0.6;
audio.play().catch(() => {});
} catch(e) {}
}
// ── Initiale Badge-Counts laden ──
fetch('/social/friends/pending/count')
.then(r => r.ok ? r.json() : 0)
.then(n => setBadge(['socialFriendsBadge', 'socialMobileFriendsBadge'], n))
@@ -112,6 +130,11 @@
.then(n => setBadge(['socialMsgBadge', 'socialMobileMsgBadge'], n))
.catch(() => {});
fetch('/notifications/unread/count')
.then(r => r.ok ? r.json() : 0)
.then(n => setBadge(['socialNotifBadge', 'socialMobileNotifBadge'], n))
.catch(() => {});
Promise.all([
fetch('/gruppen/requests/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0),
fetch('/gruppen/reports/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0)
@@ -124,4 +147,41 @@
]).then(([khInvs, lockeeInvs]) =>
setBadge(['socialInvBadge', 'socialMobileInvBadge'], khInvs.length + lockeeInvs.length)
).catch(() => {});
// ── SSE: Echtzeit-Push vom Server ──
function connectSse() {
const es = new EventSource('/events/stream');
es.addEventListener('DM', e => {
try {
const data = JSON.parse(e.data);
setBadge(['socialMsgBadge', 'socialMobileMsgBadge'], data.unreadCount || 0);
// Nur Ton abspielen wenn nicht gerade auf der Nachrichten-Seite
if (window.location.pathname !== '/nachrichten.html') {
playSound('/audio/message.mp3');
}
// Nachrichten-Seite: sofortiges Laden neuer Nachrichten auslösen
if (typeof window.__sseOnDm === 'function') window.__sseOnDm(data);
} catch(ex) {}
});
es.addEventListener('NOTIFICATION', e => {
try {
const data = JSON.parse(e.data);
setBadge(['socialNotifBadge', 'socialMobileNotifBadge'], data.unreadCount || 0);
if (window.location.pathname !== '/benachrichtigungen.html') {
playSound('/audio/notification.mp3');
}
if (typeof window.__sseOnNotification === 'function') window.__sseOnNotification(data);
} catch(ex) {}
});
es.onerror = () => {
es.close();
// Nach 5 Sekunden neu verbinden
setTimeout(connectSse, 5000);
};
}
connectSse();
})();

File diff suppressed because it is too large Load Diff

View File

@@ -121,15 +121,27 @@
.tp-colon { font-size:1rem; font-weight:700; color:var(--color-muted); margin-bottom:0.9rem; }
/* ── Aufgaben ── */
.task-list { display:flex; flex-direction:column; gap:0.4rem; margin-bottom:0.6rem; }
.task-list { display:flex; flex-direction:column; gap:0.5rem; margin-bottom:0.6rem; }
.task-item {
display:grid; grid-template-columns:1fr 70px auto;
gap:0.4rem; align-items:center;
background:var(--color-card); border-radius:7px; padding:0.5rem 0.6rem;
display:flex; flex-direction:column;
background:var(--color-card); border-radius:7px; padding:0.6rem 0.75rem;
}
.task-item-row {
display:grid; grid-template-columns:1fr 80px auto;
gap:0.4rem; align-items:center;
}
.task-title-label { font-size:0.73rem; color:var(--color-muted); margin-bottom:0.1rem; }
.task-item input[type="text"] { width:100%; box-sizing:border-box; }
.task-item input[type="number"] { width:100%; box-sizing:border-box; text-align:center; }
.task-remove { background:none; border:none; color:rgba(200,50,50,0.7); cursor:pointer; font-size:0.95rem; padding:0; margin:0; width:auto; }
.task-item textarea {
resize:vertical; min-height:56px; margin-top:0.4rem; width:100%; box-sizing:border-box;
padding:0.55rem 0.9rem; border:1px solid var(--color-secondary);
border-radius:6px; background:var(--color-secondary);
color:var(--color-text); font-size:0.88rem; font-family:inherit;
outline:none; transition:border-color 0.2s; line-height:1.45;
}
.task-item textarea:focus { border-color:var(--color-primary); }
.task-remove { background:none; border:none; color:rgba(200,50,50,0.7); cursor:pointer; font-size:0.95rem; padding:0; margin:0; width:auto; align-self:center; }
.task-remove:hover { color:#e74c3c; background:none; }
.btn-add {
background:none; border:1px dashed var(--color-muted); color:var(--color-muted);
@@ -318,10 +330,29 @@
</div>
</div>
<!-- 4. Aufgaben -->
<!-- 4. Aufgaben-Karten-Modus -->
<div id="modalTaskCardModeSection" style="display:none;" class="form-section">
<div class="form-section-title">Wer entscheidet über die Aufgabe?</div>
<div style="display:flex;flex-direction:row;gap:1.5rem;flex-wrap:wrap;align-items:center;">
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;font-size:0.9rem;margin:0;color:var(--color-text);">
<input type="radio" name="modalTaskCardMode" value="RANDOM" checked style="width:auto;padding:0;margin:0;">
<span>Zufall</span>
</label>
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;font-size:0.9rem;margin:0;color:var(--color-text);">
<input type="radio" name="modalTaskCardMode" value="KEYHOLDER" style="width:auto;padding:0;margin:0;">
<span>Keyholder*In</span>
</label>
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;font-size:0.9rem;margin:0;color:var(--color-text);">
<input type="radio" name="modalTaskCardMode" value="COMMUNITY" style="width:auto;padding:0;margin:0;">
<span>Community</span>
</label>
</div>
</div>
<!-- 5. Aufgaben -->
<div class="form-section">
<div class="form-section-title">Aufgaben (optional)</div>
<div class="form-hint" style="margin-bottom:0.6rem;">Aufgaben werden zufällig bei Aufgaben-Karten zugeteilt.</div>
<div class="task-list" id="modalTaskList"></div>
<button class="btn-add" onclick="addTask()">+ Aufgabe hinzufügen</button>
</div>
@@ -346,29 +377,20 @@
</div>
</div>
<script src="/js/card-defs.js"></script>
<script src="/js/card-display.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
const CARD_DEFS = [
{ id: 'RED', img: '/img/card_red.png', name: 'Rote Karte', desc: 'Verlängert die Sperrzeit. Je nach Konfiguration werden Minuten oder Stunden auf den Timer addiert.', defMin: 5, defMax: 10 },
{ id: 'GREEN', img: '/img/card_green.png', name: 'Grüne Karte', desc: 'Verkürzt die Sperrzeit. Eine grüne Karte bringt dich dem Öffnen näher.', defMin: 1, defMax: 2 },
{ id: 'YELLOW', img: '/img/card_yellow.png', name: 'Gelbe Karte', desc: 'Neutrales Ereignis keine Zeitveränderung, aber es passiert trotzdem etwas.', defMin: 1, defMax: 2 },
{ id: 'TASK', img: '/img/card_task.png', name: 'Aufgabe', desc: 'Teilt dir eine zufällige Aufgabe aus der Aufgabenliste zu. Die Aufgabe muss erfüllt werden.', defMin: 0, defMax: 0 },
{ id: 'FREEZE', img: '/img/card_freeze.png', name: 'Freeze', desc: 'Friert das Lock für eine festgelegte Zeit ein in diesem Zeitraum können keine Karten gezogen werden.', defMin: 0, defMax: 0 },
{ id: 'RESET', img: '/img/card_reset.png', name: 'Reset', desc: 'Setzt das Kartendeck auf den Ausgangszustand zurück. Alle bisher gezogenen Karten kommen wieder rein.', defMin: 0, defMax: 0 },
{ id: 'DOUBLE_UP', img: '/img/card_doubleup.png', name: 'Double Up', desc: 'Verdoppelt alle Karten im aktuellen Deck.', defMin: 0, defMax: 0 },
];
// ── Karten-Grid ──
function renderCardsGrid(cardCountsMin, cardCountsMax) {
function renderCardsGrid(cardCountsMin, cardCountsMax, isEdit = false) {
const grid = document.getElementById('modalCardsGrid');
grid.innerHTML = '';
CARD_DEFS.forEach(c => {
const minVal = cardCountsMin?.[c.id] ?? c.defMin;
const maxVal = cardCountsMax?.[c.id] ?? c.defMax;
const minVal = cardCountsMin?.[c.id] ?? (isEdit ? 0 : c.defMin);
const maxVal = cardCountsMax?.[c.id] ?? (isEdit ? 0 : c.defMax);
const item = document.createElement('div');
item.className = 'card-count-item';
item.innerHTML = `
@@ -471,24 +493,45 @@
// ── Aufgaben ──
let taskCtr = 0;
function addTask(text, mins) {
function addTask(data) {
const id = ++taskCtr;
const titleVal = (data?.title || data?.text || '').replace(/"/g, '&quot;');
const descVal = (data?.description || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const minVal = data?.minutes != null ? data.minutes : '';
const div = document.createElement('div');
div.className = 'task-item';
div.id = 'mt-' + id;
div.innerHTML = `
<input type="text" placeholder="Aufgaben-Beschreibung…" maxlength="300" id="mt-text-${id}" value="${esc(text || '')}">
<input type="number" min="1" max="9999" placeholder="Min." id="mt-min-${id}" title="Dauer in Minuten" value="${mins || ''}">
<button class="task-remove" onclick="removeTask(${id})" title="Entfernen">✕</button>`;
<div class="task-item-row">
<div>
<div class="task-title-label">Titel *</div>
<input type="text" placeholder="Aufgabentitel…" maxlength="150" id="mt-title-${id}" value="${titleVal}">
</div>
<div>
<div class="task-title-label">Minuten</div>
<input type="number" min="1" max="9999" placeholder="Min." id="mt-min-${id}" title="Dauer in Minuten (optional)" value="${minVal}">
</div>
<button class="task-remove" onclick="removeTask(${id})" title="Entfernen">✕</button>
</div>
<textarea placeholder="Beschreibung (optional)…" maxlength="600" id="mt-desc-${id}">${descVal}</textarea>`;
document.getElementById('modalTaskList').appendChild(div);
updateModalTaskCardModeVisibility();
}
function removeTask(id) {
document.getElementById('mt-' + id)?.remove();
updateModalTaskCardModeVisibility();
}
function updateModalTaskCardModeVisibility() {
const hasTasks = document.querySelectorAll('.task-item').length > 0;
document.getElementById('modalTaskCardModeSection').style.display = hasTasks ? '' : 'none';
}
function removeTask(id) { document.getElementById('mt-' + id)?.remove(); }
function collectTasks() {
return Array.from(document.querySelectorAll('.task-item')).map(item => {
const id = item.id.replace('mt-', '');
const text = document.getElementById('mt-text-' + id)?.value.trim();
const mins = parseInt(document.getElementById('mt-min-' + id)?.value);
return text ? { text, minutes: isNaN(mins) ? null : mins } : null;
const id = item.id.replace('mt-', '');
const title = document.getElementById('mt-title-' + id)?.value.trim();
const desc = document.getElementById('mt-desc-' + id)?.value.trim();
const mins = parseInt(document.getElementById('mt-min-' + id)?.value);
return title ? { title, description: desc || null, minutes: isNaN(mins) ? null : mins } : null;
}).filter(Boolean);
}
@@ -538,7 +581,7 @@
document.getElementById('fAccumulate').checked = template?.accumulatePicks || false;
document.getElementById('fShowRemaining').checked = template?.showRemainingCards || false;
renderCardsGrid(template?.cardCountsMin || {}, template?.cardCountsMax || {});
renderCardsGrid(template?.cardCountsMin || {}, template?.cardCountsMax || {}, !!template);
tpFromMinutes('pe', template?.pickEveryMinute || 60);
const hygieneOn = !!(template?.hygineOpeningEveryMinites);
@@ -549,7 +592,11 @@
tpFromMinutes('hd', template.hygineOpeningDurationMinutes || 30);
}
(template?.tasks || []).forEach(t => addTask(t.text, t.minutes));
(template?.tasks || []).forEach(t => addTask(t));
const mode = template?.taskCardMode || 'RANDOM';
document.querySelector(`input[name="modalTaskCardMode"][value="${mode}"]`).checked = true;
updateModalTaskCardModeVisibility();
alignModalToContent();
document.getElementById('modalBackdrop').classList.add('open');
@@ -661,6 +708,7 @@
hygineOpeningDurationMinutes: hygieneDur,
tasks,
requiresVerification: document.getElementById('fRequiresVerification').checked,
taskCardMode: document.querySelector('input[name="modalTaskCardMode"]:checked')?.value || 'RANDOM',
};
const btn = document.getElementById('modalSaveBtn');

View File

@@ -444,7 +444,11 @@
newestSentAt = messages[messages.length - 1].sentAt;
hasMoreOlder = hasMore;
}
container.scrollTop = container.scrollHeight;
// Zweifaches rAF stellt sicher, dass der Browser das Layout vollständig berechnet hat
// bevor gescrollt wird (wichtig bei Bild-Nachrichten)
requestAnimationFrame(() => requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
}));
loadConversations();
} catch (e) { console.error(e); }
}

View File

@@ -0,0 +1,653 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neues Lock XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.form-section {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.form-section-title {
font-size: 0.78rem;
font-weight: 700;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 1rem;
}
.form-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.9rem;
}
.form-row:last-child { margin-bottom: 0; }
.form-row label {
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text);
}
.form-hint {
font-size: 0.78rem;
color: var(--color-muted);
margin-top: 0.1rem;
}
.form-row input[type="text"],
.form-row input[type="number"],
.form-row input[type="datetime-local"] {
width: 100%;
box-sizing: border-box;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.75rem;
cursor: pointer;
}
.checkbox-row:last-child { margin-bottom: 0; }
.checkbox-row input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--color-primary);
}
.checkbox-row label {
font-size: 0.9rem;
color: var(--color-text);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.combo-wrap { position: relative; }
.combo-wrap input[type="text"] { width: 100%; box-sizing: border-box; }
.combo-dropdown {
display: none;
position: absolute;
top: calc(100% + 3px);
left: 0; right: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 8px;
max-height: 220px;
overflow-y: auto;
z-index: 200;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
.combo-dropdown.open { display: block; }
.combo-option {
padding: 0.55rem 0.85rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text);
}
.combo-option:hover, .combo-option.active { background: var(--color-secondary); }
.combo-option .combo-hint { font-size: 0.78rem; color: var(--color-muted); margin-left: 0.4rem; }
.combo-empty { padding: 0.55rem 0.85rem; font-size: 0.85rem; color: var(--color-muted); font-style: italic; }
.inline-number { display: flex; align-items: center; gap: 0.5rem; }
.inline-number input { width: 90px !important; flex-shrink: 0; }
.inline-number span { font-size: 0.9rem; color: var(--color-text); }
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
.form-actions button { width: auto; padding: 0.65rem 1.5rem; }
.error-msg { color: #e74c3c; font-size: 0.85rem; margin-top: 0.4rem; display: none; }
.required-star { color: #e74c3c; margin-left: 0.15em; }
.field-error input { border-color: #e74c3c !important; }
.field-error-msg { font-size: 0.78rem; color: #e74c3c; margin-top: 0.15rem; }
/* Zeitpicker */
.time-picker { display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; }
.tp-seg { display:flex; flex-direction:column; align-items:center; gap:0.15rem; }
.tp-seg-row { display:flex; align-items:center; gap:0.2rem; }
.tp-seg button {
width:24px; height:24px; background:var(--color-card);
border:1px solid var(--color-muted); border-radius:4px;
cursor:pointer; font-size:0.9rem; font-weight:700; color:var(--color-text);
display:flex; align-items:center; justify-content:center; padding:0; flex-shrink:0;
}
.tp-seg button:hover { background:var(--color-primary); color:#fff; border-color:var(--color-primary); }
.tp-seg input {
width:28px; text-align:center; background:var(--color-card);
border:1px solid var(--color-muted); border-radius:4px;
color:var(--color-text); font-size:0.9rem; font-weight:600;
font-family:monospace; padding:0.15rem 0; box-sizing:border-box;
}
.tp-seg .tp-label { font-size:0.62rem; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.04em; }
.tp-colon { font-size:1rem; font-weight:700; color:var(--color-muted); margin-bottom:0.9rem; }
/* Unlock-Code-Modal */
.modal-overlay {
display: none; position: fixed; inset: 0; z-index: 500;
align-items: center; justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal-bg { position: absolute; inset: 0; background: rgba(0,0,0,0.55); }
.modal-box {
position: relative; background: var(--color-card);
border: 1px solid var(--color-secondary); border-radius: 12px;
padding: 1.5rem 1.5rem 1.25rem; max-width: 380px; width: 90%;
display: flex; flex-direction: column; align-items: center; gap: 0.75rem; z-index: 1;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h2 style="margin-bottom:1.25rem;">🔒 Neues Lock</h2>
<!-- Vorlage (Pflichtfeld) -->
<div class="form-section">
<div class="form-section-title">Vorlage<span class="required-star">*</span></div>
<div class="form-row" id="rowTemplate">
<div class="combo-wrap" id="templateCombo">
<input type="text" id="templateInput" placeholder="Vorlage suchen…" autocomplete="off">
<div class="combo-dropdown" id="templateDropdown"></div>
<input type="hidden" id="templateValue">
</div>
</div>
</div>
<!-- Personen -->
<div class="form-section">
<div class="form-section-title">Personen</div>
<div class="form-row" id="rowLockee">
<label for="lockeeInput">Lockee<span class="required-star">*</span></label>
<div class="combo-wrap" id="lockeeCombo">
<input type="text" id="lockeeInput" placeholder="Suchen oder „Ich selbst"" autocomplete="off">
<div class="combo-dropdown" id="lockeeDropdown"></div>
<input type="hidden" id="lockeeValue">
</div>
<div class="form-hint">Wähle dich selbst oder einen Freund als Lockee.</div>
</div>
<div class="checkbox-row" id="rowDetailsVisible" style="display:none;">
<input type="checkbox" id="lockeeDetailsVisible" checked>
<label for="lockeeDetailsVisible">Details für Lockee sichtbar
<span class="form-hint">(Lockee sieht die Lock-Konfiguration vor dem Annehmen)</span>
</label>
</div>
<div class="form-row" id="rowKeyholder">
<label for="keyholderInput">Keyholder*In</label>
<div class="combo-wrap" id="keyholderCombo">
<input type="text" id="keyholderInput" placeholder="Freund suchen…" autocomplete="off">
<div class="combo-dropdown" id="keyholderDropdown"></div>
<input type="hidden" id="keyholderValue">
</div>
<div class="form-hint">Ohne Keyholder läuft das Lock als Self-Lock.</div>
</div>
</div>
<!-- Optionen -->
<div class="form-section">
<div class="form-section-title">Optionen</div>
<div class="form-row">
<label>Längste Dauer</label>
<div class="time-picker">
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('dur',-1,'d')"></button>
<input type="text" id="dur_d" value="0" readonly>
<button type="button" onclick="tpChange('dur',1,'d')">+</button>
</div>
<span class="tp-label">Tage</span>
</div>
<div class="tp-colon">:</div>
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('dur',-1,'h')"></button>
<input type="text" id="dur_h" value="00" readonly>
<button type="button" onclick="tpChange('dur',1,'h')">+</button>
</div>
<span class="tp-label">Std</span>
</div>
</div>
<div class="form-hint">Das Lock öffnet spätestens nach dieser Zeit automatisch. 0 : 00 = keine Begrenzung.</div>
</div>
<div class="form-row" id="rowUnlockCodeLines">
<label for="unlockCodeLines">Anzahl Ziffern des Entsperrcodes</label>
<div class="inline-number">
<input type="number" id="unlockCodeLines" min="1" max="20" value="5">
<span>Ziffern</span>
</div>
</div>
<div class="checkbox-row" id="rowTestLock">
<input type="checkbox" id="testLock">
<label for="testLock">Test-Lock <span class="form-hint">(kein echter Lock, zum Ausprobieren)</span></label>
</div>
</div>
<div class="error-msg" id="errorMsg"></div>
<div class="form-actions">
<button onclick="history.back()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);">Abbrechen</button>
<button onclick="createSession()">🔒 Lock starten</button>
</div>
</div>
</div>
<!-- Entsperrcode-Modal -->
<div class="modal-overlay" id="unlockModal">
<div class="modal-bg"></div>
<div class="modal-box">
<div style="font-size:2rem;">🔒</div>
<h3 id="unlockModalTitle" style="margin:0;">Dein Entsperrcode</h3>
<p id="unlockModalHint" style="color:var(--color-muted);font-size:0.85rem;text-align:center;margin:0;">
Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem.
</p>
<div id="unlockCodeDisplay" style="
font-family: monospace; font-size: 2rem; letter-spacing: 0.3em;
background: var(--color-secondary); border-radius: 8px;
padding: 1rem 1.5rem; text-align: center; color: var(--color-primary);
line-height: 1.8; word-break: break-all;
"></div>
<div id="unlockModalCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);text-align:center;font-family:monospace;"></div>
<div id="unlockKeyholderHint" style="display:none;background:var(--color-secondary);border-radius:8px;padding:0.75rem 1rem;font-size:0.85rem;color:var(--color-muted);text-align:center;line-height:1.5;">
⏳ Die eingetragene Keyholder*In wurde benachrichtigt und muss die Rolle noch bestätigen.
Bis zur Bestätigung läuft das Lock als Self-Lock.
</div>
<button id="unlockModalBtn" onclick="" style="width:100%;margin-top:0.25rem;">Weiter</button>
</div>
</div>
<script src="/js/card-defs.js"></script>
<script src="/js/shared.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
let myUserId = null;
let myUserName = null;
let allFriends = [];
let allTemplates = [];
let comboActiveIdx = -1;
// ── Boot ──
fetch('/login/me').then(r => r.ok ? r.json() : null).then(async user => {
if (!user) { window.location.href = '/login.html'; return; }
myUserId = user.userId;
myUserName = user.name;
// Templates laden Pflicht
try {
allTemplates = await fetch('/cardlock/templates').then(r => r.ok ? r.json() : []);
} catch { allTemplates = []; }
if (allTemplates.length === 0) {
document.querySelector('.content').innerHTML = `
<div style="text-align:center;padding:3rem 1rem;">
<div style="font-size:2.5rem;margin-bottom:1rem;">📋</div>
<h2 style="margin-bottom:0.75rem;">Keine Vorlagen vorhanden</h2>
<p style="color:var(--color-muted);margin-bottom:2rem;">
Du musst zuerst mindestens eine Lock-Vorlage erstellen,<br>
bevor du ein neues Lock starten kannst.
</p>
<a href="/meine-locks.html" style="display:inline-block;padding:0.7rem 1.8rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Vorlage erstellen</a>
</div>`;
return;
}
setupTemplateCombo();
await loadOptions(user.userId);
});
async function loadOptions(myId) {
try {
allFriends = await fetch('/social/friends/user/' + myId).then(r => r.ok ? r.json() : []);
} catch { allFriends = []; }
setupLockeeCombo();
setupKeyholderCombo();
document.getElementById('lockeeInput').value = 'Ich selbst';
document.getElementById('lockeeValue').value = myId;
}
// ── Template-Combobox ──
function setupTemplateCombo() {
const input = document.getElementById('templateInput');
const dropdown = document.getElementById('templateDropdown');
const hidden = document.getElementById('templateValue');
function renderDropdown(query) {
const q = query.toLowerCase().trim();
const filtered = q
? allTemplates.filter(t => (t.name || '').toLowerCase().includes(q))
: allTemplates;
dropdown.innerHTML = '';
if (filtered.length === 0) {
dropdown.innerHTML = `<div class="combo-empty">Keine Vorlagen gefunden.</div>`;
} else {
filtered.forEach(t => {
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = t.templateId;
div.textContent = t.name || 'Unbenannte Vorlage';
div.addEventListener('mousedown', e => {
e.preventDefault();
hidden.value = t.templateId;
input.value = t.name || 'Unbenannte Vorlage';
dropdown.classList.remove('open');
clearFieldError('rowTemplate');
});
dropdown.appendChild(div);
});
}
dropdown.classList.add('open');
}
input.addEventListener('input', () => { hidden.value = ''; renderDropdown(input.value); });
input.addEventListener('focus', () => renderDropdown(input.value));
input.addEventListener('blur', () => {
setTimeout(() => {
dropdown.classList.remove('open');
if (!hidden.value) input.value = '';
}, 150);
});
}
// ── Lockee-Combobox ──
function setupLockeeCombo() {
const input = document.getElementById('lockeeInput');
const dropdown = document.getElementById('lockeeDropdown');
const hidden = document.getElementById('lockeeValue');
function renderDropdown(query) {
const q = query.toLowerCase().trim();
const selfMatch = 'ich selbst'.includes(q);
const filtered = allFriends.filter(f => f.name.toLowerCase().includes(q));
dropdown.innerHTML = '';
comboActiveIdx = -1;
if (!selfMatch && filtered.length === 0) {
dropdown.innerHTML = `<div class="combo-empty">${q ? 'Keine Treffer.' : 'Keine Freunde vorhanden.'}</div>`;
} else {
if (selfMatch) {
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = myUserId; div.dataset.name = 'Ich selbst';
div.innerHTML = 'Ich selbst<span class="combo-hint">(Self-Lock)</span>';
div.addEventListener('mousedown', e => { e.preventDefault(); selectLockee(myUserId, 'Ich selbst'); });
dropdown.appendChild(div);
}
filtered.forEach(f => {
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = f.userId; div.dataset.name = f.name;
div.textContent = f.name;
div.addEventListener('mousedown', e => { e.preventDefault(); selectLockee(f.userId, f.name); });
dropdown.appendChild(div);
});
}
dropdown.classList.add('open');
}
function selectLockee(id, name) {
hidden.value = id;
input.value = name;
dropdown.classList.remove('open');
onLockeeChanged(id);
}
input.addEventListener('input', () => { hidden.value = ''; renderDropdown(input.value); });
input.addEventListener('focus', () => renderDropdown(input.value));
input.addEventListener('blur', () => {
setTimeout(() => {
dropdown.classList.remove('open');
if (!hidden.value) { input.value = 'Ich selbst'; hidden.value = myUserId; onLockeeChanged(myUserId); }
}, 150);
});
}
function onLockeeChanged(lockeeId) {
const isFriend = lockeeId && lockeeId !== myUserId;
const khInput = document.getElementById('keyholderInput');
const khHidden = document.getElementById('keyholderValue');
if (isFriend) {
khInput.value = myUserName || 'Ich selbst';
khHidden.value = myUserId;
khInput.readOnly = true;
khInput.style.opacity = '0.6';
document.getElementById('rowUnlockCodeLines').style.display = 'none';
document.getElementById('rowTestLock').style.display = 'none';
document.getElementById('rowDetailsVisible').style.display = '';
} else {
khInput.readOnly = false;
khInput.style.opacity = '';
if (!khHidden.value) khInput.value = '';
document.getElementById('rowUnlockCodeLines').style.display = '';
document.getElementById('rowTestLock').style.display = '';
document.getElementById('rowDetailsVisible').style.display = 'none';
}
}
// Self-Lock-Felder beim Start ausblenden (werden durch onLockeeChanged gesetzt)
document.getElementById('rowUnlockCodeLines').style.display = '';
document.getElementById('rowTestLock').style.display = '';
// ── Keyholder-Combobox ──
function setupKeyholderCombo() {
const input = document.getElementById('keyholderInput');
const dropdown = document.getElementById('keyholderDropdown');
const hidden = document.getElementById('keyholderValue');
function renderDropdown(query) {
if (input.readOnly) return;
const q = query.toLowerCase().trim();
const filtered = q ? allFriends.filter(f => f.name.toLowerCase().includes(q)) : allFriends;
dropdown.innerHTML = '';
comboActiveIdx = -1;
if (filtered.length === 0) {
dropdown.innerHTML = `<div class="combo-empty">${q ? 'Keine Freunde gefunden.' : 'Keine Freunde vorhanden.'}</div>`;
} else {
filtered.forEach(f => {
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = f.userId; div.dataset.name = f.name;
div.textContent = f.name;
div.addEventListener('mousedown', e => { e.preventDefault(); hidden.value = f.userId; input.value = f.name; dropdown.classList.remove('open'); });
dropdown.appendChild(div);
});
}
dropdown.classList.add('open');
}
input.addEventListener('input', () => { hidden.value = ''; renderDropdown(input.value); });
input.addEventListener('focus', () => renderDropdown(input.value));
input.addEventListener('blur', () => { setTimeout(() => { dropdown.classList.remove('open'); if (!hidden.value) input.value = ''; }, 150); });
}
// ── Zeitpicker ──
function tpChange(prefix, delta, seg) {
let d = parseInt(document.getElementById(prefix + '_d').value) || 0;
let h = parseInt(document.getElementById(prefix + '_h')?.value) || 0;
if (seg === 'h') h += delta;
else d += delta;
if (h >= 24) { d += Math.floor(h / 24); h %= 24; }
if (h < 0) { const b = Math.ceil(-h / 24); d -= b; h += b * 24; }
if (d < 0) d = 0;
document.getElementById(prefix + '_d').value = d;
if (document.getElementById(prefix + '_h'))
document.getElementById(prefix + '_h').value = String(h).padStart(2, '0');
}
// ── Längste Dauer → LocalDateTime ──
function durationToLatestOpening() {
const days = parseInt(document.getElementById('dur_d').value) || 0;
const hours = parseInt(document.getElementById('dur_h').value) || 0;
if (days === 0 && hours === 0) return null;
const ms = (days * 24 * 3600 + hours * 3600) * 1000;
const dt = new Date(Date.now() + ms);
const pad = n => String(n).padStart(2, '0');
return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
}
// ── Fehler ──
function showError(msg) {
const el = document.getElementById('errorMsg');
el.textContent = msg;
el.style.display = '';
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function setFieldError(rowId, msg) {
const row = document.getElementById(rowId);
if (!row) return;
row.classList.add('field-error');
let el = row.querySelector('.field-error-msg');
if (!el) { el = document.createElement('div'); el.className = 'field-error-msg'; row.appendChild(el); }
el.textContent = msg;
}
function clearFieldError(rowId) {
const row = document.getElementById(rowId);
if (!row) return;
row.classList.remove('field-error');
row.querySelector('.field-error-msg')?.remove();
}
// ── Karten aus Template aufbauen ──
function buildInitialCardsFromTemplate(t) {
const cards = [];
CARD_DEFS.forEach(c => {
const minVal = t.cardCountsMin?.[c.id] ?? 0;
const maxVal = t.cardCountsMax?.[c.id] ?? 0;
const n = minVal + Math.floor(Math.random() * (maxVal - minVal + 1));
for (let i = 0; i < n; i++) cards.push(c.id);
});
return cards;
}
// ── Absenden ──
async function createSession() {
document.getElementById('errorMsg').style.display = 'none';
const templateId = document.getElementById('templateValue').value;
if (!templateId) {
setFieldError('rowTemplate', 'Bitte eine Vorlage wählen.');
document.getElementById('rowTemplate').scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
clearFieldError('rowTemplate');
const t = allTemplates.find(x => x.templateId === templateId);
if (!t) { showError('Vorlage nicht gefunden.'); return; }
const lockeeVal = document.getElementById('lockeeValue').value;
const keyholderVal = document.getElementById('keyholderValue').value;
const isFriendLockee = lockeeVal && lockeeVal !== myUserId;
const initialCards = buildInitialCardsFromTemplate(t);
if (initialCards.length === 0) {
showError('Die gewählte Vorlage enthält keine Karten. Bitte Vorlage prüfen.');
return;
}
const body = {
name: t.name,
lockeeUserId: isFriendLockee ? lockeeVal : null,
lockeeDetailsVisible: isFriendLockee ? document.getElementById('lockeeDetailsVisible').checked : false,
keyholder: isFriendLockee ? null : (keyholderVal || null),
initialCards,
pickEveryMinute: t.pickEveryMinute,
accumulatePicks: t.accumulatePicks,
showRemainingCards: t.showRemainingCards,
latestOpeningtime: durationToLatestOpening(),
hygineOpeningEveryMinites: t.hygineOpeningEveryMinites || null,
hygineOpeningDurationMinutes: t.hygineOpeningDurationMinutes || null,
tasks: t.tasks || [],
taskCardMode: t.taskCardMode || 'RANDOM',
unlockCodeLines: isFriendLockee ? null : (parseInt(document.getElementById('unlockCodeLines').value) || 5),
requiresVerification: t.requiresVerification,
testLock: isFriendLockee ? false : document.getElementById('testLock').checked,
};
const res = await fetch('/keyholder/cardlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
if (res.status === 409) {
const data = await res.json().catch(() => ({}));
if (data.error === 'active_lock_exists')
showError('Du hast bereits ein aktives Lock als Lockee. Erst das bestehende Lock beenden, bevor ein neues gestartet werden kann.');
else
showError('Fehler beim Erstellen des Locks.');
} else {
showError(res.status === 400 ? 'Ungültige Eingabe. Bitte alle Felder prüfen.' : 'Fehler beim Erstellen des Locks.');
}
return;
}
const data = await res.json();
if (data.lockeeInvitationSent) {
window.location.href = '/einladungen.html?tab=gesendet';
} else {
showUnlockCodeModal(data.unlockCode, data.lockId, data.keyholderPending);
}
}
// ── Entsperrcode-Modal ──
function showUnlockCodeModal(code, lockId, keyholderPending) {
document.getElementById('unlockCodeDisplay').textContent = code;
if (keyholderPending) document.getElementById('unlockKeyholderHint').style.display = '';
const url = '/activelock.html?lockId=' + lockId + (keyholderPending ? '&keyholderPending=1' : '');
document.getElementById('unlockModalBtn').onclick = () => startCodeScramble(code, url);
document.getElementById('unlockModal').classList.add('open');
}
function startCodeScramble(realCode, url) {
const display = document.getElementById('unlockCodeDisplay');
const btn = document.getElementById('unlockModalBtn');
const hint = document.getElementById('unlockModalHint');
const countdown = document.getElementById('unlockModalCountdown');
const len = realCode.length;
const DURATION = 3 * 60;
let remaining = DURATION;
let stopped = false;
function randomCode() {
return Array.from({ length: len }, () => Math.floor(Math.random() * 10)).join('');
}
function finish() {
stopped = true;
clearInterval(scrambleInterval);
clearInterval(countdownInterval);
window.location.href = url;
}
if (hint) hint.style.display = 'none';
countdown.style.display = '';
document.getElementById('unlockModalTitle').textContent = 'Nun vergessen wir den Code…';
btn.textContent = 'Abbrechen';
btn.onclick = finish;
const scrambleInterval = setInterval(() => {
if (!stopped) display.textContent = randomCode();
}, 80);
const countdownInterval = setInterval(() => {
if (stopped) return;
remaining--;
const m = Math.floor(remaining / 60);
const s = remaining % 60;
countdown.textContent = `Weiterleitung in ${m}:${String(s).padStart(2,'0')}`;
if (remaining <= 0) finish();
}, 1000);
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Code-Historie XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.history-list { display:flex; flex-direction:column; gap:0.75rem; margin-top:0.5rem; }
.history-card {
background:var(--color-card); border:1px solid var(--color-secondary);
border-radius:10px; padding:1rem 1.2rem;
}
.history-header { display:flex; align-items:center; justify-content:space-between; gap:0.5rem; margin-bottom:0.4rem; }
.history-lock-name { font-weight:700; font-size:0.95rem; }
.history-source {
font-size:0.75rem; color:var(--color-muted);
background:var(--color-secondary); border-radius:6px;
padding:0.15rem 0.5rem; white-space:nowrap;
}
.history-code {
font-family: monospace; font-size:0.85rem;
background:var(--color-secondary); border-radius:6px;
padding:0.5rem 0.75rem; word-break:break-all; line-height:1.5;
margin-bottom:0.4rem;
}
.history-time { font-size:0.78rem; color:var(--color-muted); }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
.page-hint { font-size:0.82rem; color:var(--color-muted); margin-bottom:1rem; }
</style>
</head>
<body>
<div class="main">
<h1>🗝️ Entsperrcode-Historie</h1>
<p class="page-hint">Die letzten 10 Entsperrcodes, die dir angezeigt wurden.</p>
<div class="history-list" id="historyList">
<span class="empty-hint">Wird geladen…</span>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script>
const SOURCE_LABELS = {
GREEN_CARD: 'Grüne Karte',
HYGIENE_OPEN: 'Hygiene-Öffnung',
HYGIENE_CLOSE: 'Hygiene-Öffnung (neu)',
KEYHOLDER_UNLOCK: 'Freigabe durch Keyholder',
};
async function load() {
const res = await fetch('/keyholder/cardlock/unlock-history');
const list = document.getElementById('historyList');
if (!res.ok) { list.innerHTML = '<span class="empty-hint">Fehler beim Laden.</span>'; return; }
const entries = await res.json();
if (!entries.length) { list.innerHTML = '<span class="empty-hint">Noch keine Entsperrcodes erhalten.</span>'; return; }
list.innerHTML = '';
for (const e of entries) {
const dt = new Date(e.receivedAt);
const formatted = dt.toLocaleString('de-DE', {
day:'2-digit', month:'2-digit', year:'numeric',
hour:'2-digit', minute:'2-digit'
});
const sourceLabel = SOURCE_LABELS[e.source] || e.source;
const card = document.createElement('div');
card.className = 'history-card';
card.innerHTML = `
<div class="history-header">
<span class="history-lock-name">${escHtml(e.lockName)}</span>
<span class="history-source">${escHtml(sourceLabel)}</span>
</div>
<div class="history-code">${escHtml(e.unlockCode)}</div>
<div class="history-time">Erhalten am ${escHtml(formatted)}</div>
`;
list.appendChild(card);
}
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
load();
</script>
</body>
</html>