Wetier am Cahstity game gebasterln
This commit is contained in:
@@ -18,6 +18,10 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.projectlombok:lombok")
|
||||
annotationProcessor("org.projectlombok:lombok")
|
||||
testCompileOnly("org.projectlombok:lombok")
|
||||
testAnnotationProcessor("org.projectlombok:lombok")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package de.oaa.xxx.games.chastity.cardlock;
|
||||
|
||||
import de.oaa.xxx.games.chastity.CardLockService;
|
||||
|
||||
public class DoubleUpCard implements Card {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package de.oaa.xxx.games.chastity.cardlock;
|
||||
|
||||
import de.oaa.xxx.games.chastity.CardLockService;
|
||||
|
||||
public class FreezeCard implements Card {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package de.oaa.xxx.games.chastity.cardlock;
|
||||
|
||||
import de.oaa.xxx.games.chastity.CardLockService;
|
||||
|
||||
public class GreenCard implements Card {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package de.oaa.xxx.games.chastity.cardlock;
|
||||
|
||||
import de.oaa.xxx.games.chastity.CardLockService;
|
||||
|
||||
public class RedCard implements Card {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package de.oaa.xxx.games.chastity.cardlock;
|
||||
|
||||
import de.oaa.xxx.games.chastity.CardLockService;
|
||||
|
||||
public class ResetCard implements Card {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package de.oaa.xxx.games.chastity.cardlock;
|
||||
|
||||
import de.oaa.xxx.games.chastity.CardLockService;
|
||||
|
||||
public class TaskCard implements Card {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package de.oaa.xxx.games.chastity.cardlock;
|
||||
|
||||
import de.oaa.xxx.games.chastity.CardLockService;
|
||||
|
||||
public class YellowCard implements Card {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 + "]";
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
54
xxxthegame/src/main/java/de/oaa/xxx/social/SseService.java
Normal file
54
xxxthegame/src/main/java/de/oaa/xxx/social/SseService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,'<').replace(/>/g,'>');
|
||||
const descEsc = (t.taskDescription || '').replace(/</g,'<').replace(/>/g,'>');
|
||||
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,'<').replace(/>/g,'>');
|
||||
const descEsc = (t.taskDescription || '').replace(/</g,'<').replace(/>/g,'>');
|
||||
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');
|
||||
}
|
||||
|
||||
BIN
xxxthegame/src/main/resources/static/audio/message.mp3
Normal file
BIN
xxxthegame/src/main/resources/static/audio/message.mp3
Normal file
Binary file not shown.
BIN
xxxthegame/src/main/resources/static/audio/notification.mp3
Normal file
BIN
xxxthegame/src/main/resources/static/audio/notification.mp3
Normal file
Binary file not shown.
251
xxxthegame/src/main/resources/static/benachrichtigungen.html
Normal file
251
xxxthegame/src/main/resources/static/benachrichtigungen.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
? ' <span style="font-size:0.72rem;">👁 Details sichtbar</span>'
|
||||
: ' <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} ${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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
70
xxxthegame/src/main/resources/static/js/card-defs.js
Normal file
70
xxxthegame/src/main/resources/static/js/card-defs.js
Normal 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 }])
|
||||
);
|
||||
@@ -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, … }
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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
@@ -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, '"');
|
||||
const descVal = (data?.description || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
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');
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
653
xxxthegame/src/main/resources/static/neulock.html
Normal file
653
xxxthegame/src/main/resources/static/neulock.html
Normal 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
86
xxxthegame/src/main/resources/static/unlock-history.html
Normal file
86
xxxthegame/src/main/resources/static/unlock-history.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user