Weiter am Chastity Game gearbeitet und Interaktionen zwischen Keyholder und Lockee hinzugefügt

This commit is contained in:
2026-03-16 23:16:45 +01:00
parent 57a7c78037
commit 97c6f0a131
399 changed files with 34194 additions and 2272 deletions

View File

@@ -0,0 +1,36 @@
package de.oaa.xxx.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Component
public class SchemaMigration implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(SchemaMigration.class);
private final JdbcTemplate jdbc;
public SchemaMigration(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public void run(ApplicationArguments args) {
try {
String columnType = jdbc.queryForObject(
"SELECT DATA_TYPE FROM information_schema.COLUMNS " +
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'verification' AND COLUMN_NAME = 'image'",
String.class);
if ("blob".equalsIgnoreCase(columnType)) {
log.info("Migrating verification.image from BLOB to MEDIUMBLOB");
jdbc.execute("ALTER TABLE verification MODIFY COLUMN image MEDIUMBLOB");
log.info("Migration complete");
}
} catch (Exception e) {
log.warn("Schema migration check failed: {}", e.getMessage());
}
}
}

View File

@@ -54,6 +54,11 @@ public class SecurityConfig {
.requestMatchers(AntPathRequestMatcher.antMatcher("/gruppen.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/gruppe.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/feed.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/communityvotes.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/keyholder.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/meine-locks.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/einladungen.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/joinlock.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/gruppen/**")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/feed/**")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/*.html")).permitAll()

View File

@@ -11,8 +11,10 @@ import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import de.oaa.xxx.games.chastity.cardlock.CardLockRepository;
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.tasks.Task;
import de.oaa.xxx.games.chastity.verification.VerificationEntity;
import de.oaa.xxx.games.chastity.verification.VerificationRepository;
@@ -34,15 +36,38 @@ public class CardLockService extends ProcessLock {
this.cardLockRepository = cardLockRepository;
}
public String getNextCard() {
public CardDTO getNextCard() {
LOGGER.debug("New Card requested by user {}", lock.getLockee());
CardDTO card = null;
if (lock.isAccumulatePicks()) {
if (lock.getNextCardIn().isAfter(LocalDateTime.now())) {
lock.setOpenPicks(lock.getOpenPicks() == null ? 1 : lock.getOpenPicks() + 1);
}
if (lock.getOpenPicks() != null && lock.getOpenPicks() > 0) {
lock.setOpenPicks(lock.getOpenPicks() - 1);
card = getRandomCard();
}
} else {
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;
}
private CardDTO getRandomCard() {
var cards = lock.getAvailableCards();
if (!cards.isEmpty()) {
var card = cards.get(new Random().nextInt(cards.size()));
LOGGER.debug("Card drafted: {}", card);
card.get().processCard(this);
lock.getAvailableCards().remove(card);
return card.get().processCard(this);
}
return "";
LOGGER.error("Keine Karten mehr im Lock - generiere Notfall Grüne Karte");
return new CardDTO(CardEnum.GREEN, lock.getUnlockCode());
}
public String doubleUp() {
@@ -58,8 +83,13 @@ public class CardLockService extends ProcessLock {
lock.setAvailableCards(lock.getInitialCards());
return "";
}
public String green() {
LOGGER.debug("Green Card drafted");
return lock.getUnlockCode();
}
public String unlock() {
public void unlock(String unlockCode) {
this.lock.setUnlockTime(LocalDateTime.now());
boolean valid = true;
if (!this.lock.isTestLock()) {
@@ -69,8 +99,8 @@ public class CardLockService extends ProcessLock {
.map(verification -> verification.getVerificationTime().toLocalDate())
.collect(Collectors.toSet());
LocalDate current = this.lock.getStartTime().toLocalDate().plusDays(1);
LocalDate last = this.lock.getUnlockTime().toLocalDate();
LocalDate current = this.lock.getStartTime().toLocalDate();
LocalDate last = this.lock.getUnlockTime().toLocalDate().minusDays(1);
while (!current.isAfter(last)) {
if (!verifications.contains(current)) {
@@ -92,9 +122,16 @@ public class CardLockService extends ProcessLock {
lock.setUnlockTime(LocalDateTime.now());
LOGGER.debug("Unlocked at {}", lock.getUnlockTime());
return this.lock.getUnlockCode();
cardLockRepository.save(lock);
}
public void putBackGreen() {
LOGGER.debug("Green Card was put Back");
lock.getAvailableCards().add(CardEnum.GREEN);
cardLockRepository.save(lock);
}
private boolean isValid(VerificationEntity entity) {
int count = 0;
for (VerificationVoteEntity vote : verificationVoteRepository.findAllByVerificationId(entity.getVerficationId())) {
@@ -116,6 +153,7 @@ public class CardLockService extends ProcessLock {
private String freeze(double multiplier) {
LocalDateTime frozenTill = LocalDateTime.now().plus((long) multiplier, ChronoUnit.MINUTES);
lock.setFrozenUntill(frozenTill);
lock.setNextCardIn(frozenTill);
LOGGER.debug("Frozen until {}", lock.getFrozenUntill());
return "";
}
@@ -150,6 +188,18 @@ public class CardLockService extends ProcessLock {
}
public String yellowCard() {
Random random = new Random();
if (random.nextBoolean()) {
for (int i = 0; i < random.nextInt(1, 3); i++) {
LOGGER.debug("Adding Red card");
lock.getAvailableCards().add(CardEnum.RED);
}
} else {
for (int i = 0; i < random.nextInt(1, 3); i++) {
LOGGER.debug("Removing Red card if possible");
lock.getAvailableCards().remove(CardEnum.RED);
}
}
return "";
}
}

View File

@@ -19,6 +19,9 @@ public class KeyholderInvitationEntity {
@Column(nullable = false)
private UUID keyholderUserId;
@Column
private UUID lockeeUserId;
@Column(nullable = false, unique = true)
private String token;
@@ -33,6 +36,9 @@ public class KeyholderInvitationEntity {
public UUID getKeyholderUserId() { return keyholderUserId; }
public void setKeyholderUserId(UUID keyholderUserId) { this.keyholderUserId = keyholderUserId; }
public UUID getLockeeUserId() { return lockeeUserId; }
public void setLockeeUserId(UUID lockeeUserId) { this.lockeeUserId = lockeeUserId; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }

View File

@@ -7,6 +7,11 @@ import java.util.UUID;
public interface KeyholderInvitationRepository extends JpaRepository<KeyholderInvitationEntity, UUID> {
Optional<KeyholderInvitationEntity> findByToken(String token);
java.util.List<KeyholderInvitationEntity> findByKeyholderUserId(UUID keyholderUserId);
java.util.List<KeyholderInvitationEntity> findByLockeeUserId(UUID lockeeUserId);
java.util.List<KeyholderInvitationEntity> findByLockId(UUID lockId);
@Transactional
void deleteByLockId(UUID lockId);
@Transactional
void deleteByToken(String token);
}

View File

@@ -0,0 +1,281 @@
package de.oaa.xxx.games.chastity;
import java.security.Principal;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
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.RestController;
import de.oaa.xxx.games.chastity.cardlock.CardlockRepository;
import de.oaa.xxx.social.entity.MessageEntity;
import de.oaa.xxx.social.repository.MessageRepository;
import de.oaa.xxx.user.UserRepository;
@RestController
@RequestMapping("/lockee")
public class LockeeInvitationController {
private final LockeeInvitationRepository lockeeInvitationRepository;
private final CardlockRepository cardlockRepository;
private final UserRepository userRepository;
private final MessageRepository messageRepository;
@Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
private static final SecureRandom RNG = new SecureRandom();
public LockeeInvitationController(LockeeInvitationRepository lockeeInvitationRepository,
CardlockRepository cardlockRepository,
UserRepository userRepository,
MessageRepository messageRepository) {
this.lockeeInvitationRepository = lockeeInvitationRepository;
this.cardlockRepository = cardlockRepository;
this.userRepository = userRepository;
this.messageRepository = messageRepository;
}
private String generateUnlockCode(int lines) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < lines; i++) sb.append(RNG.nextInt(10));
return sb.toString();
}
@GetMapping("/invitations/mine")
public ResponseEntity<List<Map<String, Object>>> getMyInvitations(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var invitations = lockeeInvitationRepository.findByLockeeUserId(myId);
List<Map<String, Object>> result = new ArrayList<>();
for (var inv : invitations) {
var lockOpt = cardlockRepository.findById(inv.getLockId());
if (lockOpt.isEmpty()) continue;
var lock = lockOpt.get();
if (lock.getStartTime() != null) continue; // already accepted
var khOpt = userRepository.findById(inv.getKeyholderUserId());
if (khOpt.isEmpty()) continue;
var kh = khOpt.get();
Map<String, Object> item = new HashMap<>();
item.put("lockId", inv.getLockId().toString());
item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock");
item.put("keyholderName", kh.getName());
item.put("keyholderProfilePic", kh.getProfilePicture());
item.put("token", inv.getToken());
item.put("createdAt", inv.getCreatedAt().toString());
item.put("detailsVisible", inv.isDetailsVisible());
result.add(item);
}
return ResponseEntity.ok(result);
}
@GetMapping("/invitations/sent")
public ResponseEntity<List<Map<String, Object>>> getSentInvitations(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var invitations = lockeeInvitationRepository.findByKeyholderUserId(myId);
List<Map<String, Object>> result = new ArrayList<>();
for (var inv : invitations) {
var lockOpt = cardlockRepository.findById(inv.getLockId());
if (lockOpt.isEmpty()) continue;
var lock = lockOpt.get();
if (lock.getStartTime() != null) continue; // already accepted
var lockeeOpt = userRepository.findById(inv.getLockeeUserId());
if (lockeeOpt.isEmpty()) continue;
var lockee = lockeeOpt.get();
Map<String, Object> item = new HashMap<>();
item.put("lockId", inv.getLockId().toString());
item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock");
item.put("lockeeName", lockee.getName());
item.put("lockeeProfilePic", lockee.getProfilePicture());
item.put("token", inv.getToken());
item.put("createdAt", inv.getCreatedAt().toString());
item.put("detailsVisible", inv.isDetailsVisible());
result.add(item);
}
return ResponseEntity.ok(result);
}
@DeleteMapping("/invitations/sent/{token}")
@Transactional
public ResponseEntity<Void> cancelSentInvitation(@PathVariable String token, 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 invOpt = lockeeInvitationRepository.findByToken(token);
if (invOpt.isEmpty()) return ResponseEntity.notFound().build();
var inv = invOpt.get();
if (!inv.getKeyholderUserId().equals(myId)) return ResponseEntity.status(403).build();
var lockOpt = cardlockRepository.findById(inv.getLockId());
lockeeInvitationRepository.delete(inv);
if (lockOpt.isPresent()) {
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);
}
return ResponseEntity.noContent().build();
}
@GetMapping("/invitation/{token}")
public ResponseEntity<Map<String, Object>> getInvitation(@PathVariable String token, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var invOpt = lockeeInvitationRepository.findByToken(token);
if (invOpt.isEmpty()) return ResponseEntity.notFound().build();
var inv = invOpt.get();
if (!inv.getLockeeUserId().equals(myId)) return ResponseEntity.status(403).build();
var lockOpt = cardlockRepository.findById(inv.getLockId());
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var lock = lockOpt.get();
if (lock.getStartTime() != null) return ResponseEntity.status(409).body(Map.of("error", "already_accepted"));
var khOpt = userRepository.findById(inv.getKeyholderUserId());
String khName = khOpt.map(u -> u.getName()).orElse("Unbekannt");
Map<String, Object> result = new LinkedHashMap<>();
result.put("lockId", inv.getLockId().toString());
result.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock");
result.put("keyholderName", khName);
result.put("token", inv.getToken());
result.put("createdAt", inv.getCreatedAt().toString());
result.put("detailsVisible", inv.isDetailsVisible());
if (inv.isDetailsVisible() && lock.getInitialCards() != null) {
Map<String, Long> cardCounts = lock.getInitialCards().stream()
.collect(java.util.stream.Collectors.groupingBy(
c -> c.name(), java.util.stream.Collectors.counting()));
result.put("cardCounts", cardCounts);
result.put("pickEveryMinute", lock.getPickEveryMinute());
result.put("accumulatePicks", lock.isAccumulatePicks());
result.put("showRemainingCards", lock.isShowRemainingCards());
result.put("hygineOpeningEveryMinites", lock.getHygineOpeningEveryMinites());
result.put("hygineOpeningDurationMinutes", lock.getHygineOpeningDurationMinutes());
result.put("requiresVerification", lock.isRequiresVerification());
result.put("taskCount", lock.getTasks() != null ? lock.getTasks().size() : 0);
}
return ResponseEntity.ok(result);
}
record AcceptRequest(Integer unlockCodeLines) {}
@PostMapping("/invitation/{token}/accept")
@Transactional
public ResponseEntity<Map<String, Object>> acceptInvitation(@PathVariable String token,
@RequestBody AcceptRequest 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 invOpt = lockeeInvitationRepository.findByToken(token);
if (invOpt.isEmpty()) return ResponseEntity.notFound().build();
var inv = invOpt.get();
if (!inv.getLockeeUserId().equals(myId)) return ResponseEntity.status(403).build();
var lockOpt = cardlockRepository.findById(inv.getLockId());
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
var lock = lockOpt.get();
if (lock.getStartTime() != null) return ResponseEntity.status(409).body(Map.of("error", "already_accepted"));
if (cardlockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(myId))
return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists"));
int codeLines = (req.unlockCodeLines() != null && req.unlockCodeLines() >= 1) ? req.unlockCodeLines() : 5;
String unlockCode = generateUnlockCode(codeLines);
LocalDateTime now = LocalDateTime.now();
lock.setStartTime(now);
lock.setUnlockCode(unlockCode);
lock.setUnlockCodeLines(codeLines);
lock.setAvailableCards(new ArrayList<>(lock.getInitialCards()));
lock.setOpenPicks(0);
lock.setNextCardIn(now.plusMinutes(lock.getPickEveryMinute()));
if (lock.getHygineOpeningEveryMinites() != null) {
lock.setLastHygineOpening(now);
}
cardlockRepository.save(lock);
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);
return ResponseEntity.ok(Map.of(
"lockId", lock.getLockId().toString(),
"unlockCode", unlockCode
));
}
@DeleteMapping("/invitation/{token}")
@Transactional
public ResponseEntity<Void> declineInvitation(@PathVariable String token, 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 invOpt = lockeeInvitationRepository.findByToken(token);
if (invOpt.isEmpty()) return ResponseEntity.notFound().build();
var inv = invOpt.get();
if (!inv.getLockeeUserId().equals(myId)) return ResponseEntity.status(403).build();
var lockOpt = cardlockRepository.findById(inv.getLockId());
lockeeInvitationRepository.delete(inv);
if (lockOpt.isPresent()) {
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);
}
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,53 @@
package de.oaa.xxx.games.chastity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "lockee_invitation")
public class LockeeInvitationEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column
private UUID invitationId;
@Column(nullable = false)
private UUID lockId;
@Column(nullable = false)
private UUID lockeeUserId;
@Column(nullable = false)
private UUID keyholderUserId;
@Column(nullable = false, unique = true)
private String token;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private boolean detailsVisible = true;
public UUID getInvitationId() { return invitationId; }
public UUID getLockId() { return lockId; }
public void setLockId(UUID lockId) { this.lockId = lockId; }
public UUID getLockeeUserId() { return lockeeUserId; }
public void setLockeeUserId(UUID lockeeUserId) { this.lockeeUserId = lockeeUserId; }
public UUID getKeyholderUserId() { return keyholderUserId; }
public void setKeyholderUserId(UUID keyholderUserId) { this.keyholderUserId = keyholderUserId; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public boolean isDetailsVisible() { return detailsVisible; }
public void setDetailsVisible(boolean detailsVisible) { this.detailsVisible = detailsVisible; }
}

View File

@@ -0,0 +1,13 @@
package de.oaa.xxx.games.chastity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface LockeeInvitationRepository extends JpaRepository<LockeeInvitationEntity, UUID> {
List<LockeeInvitationEntity> findByLockeeUserId(UUID lockeeUserId);
List<LockeeInvitationEntity> findByKeyholderUserId(UUID keyholderUserId);
Optional<LockeeInvitationEntity> findByToken(String token);
}

View File

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

View File

@@ -1,5 +1,5 @@
package de.oaa.xxx.games.chastity.cardlock;
public record CardDTO (String text, String urlImage){
public record CardDTO (CardEnum card, String unlockCode){
}

View File

@@ -1,56 +1,89 @@
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.mail.Email;
import de.oaa.xxx.mail.MailService;
import de.oaa.xxx.mail.MailTemplateService;
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;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.Principal;
import java.security.SecureRandom;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/keyholder")
public class CardLockController {
private final CardlockRepository cardlockRepository;
private final CardLockRepository cardLockRepository;
private final UserRepository userRepository;
private final KeyholderInvitationRepository invitationRepository;
private final MailService mailService;
private final MailTemplateService mailTemplateService;
private final VerificationRepository verificationRepository;
private final VerificationVoteRepository verificationVoteRepository;
private final HygieneViolationRepository hygieneViolationRepository;
private final MessageRepository messageRepository;
private final LockeeInvitationRepository lockeeInvitationRepository;
@Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
public CardLockController(CardlockRepository cardlockRepository,
CardLockRepository cardLockRepository,
UserRepository userRepository,
KeyholderInvitationRepository invitationRepository,
MailService mailService,
MailTemplateService mailTemplateService) {
this.cardlockRepository = cardlockRepository;
this.userRepository = userRepository;
this.invitationRepository = invitationRepository;
this.mailService = mailService;
this.mailTemplateService = mailTemplateService;
VerificationRepository verificationRepository,
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;
}
record CreateCardLockRequest(
String name,
UUID keyholder,
UUID lockeeUserId,
boolean lockeeDetailsVisible,
List<CardEnum> initialCards,
Integer pickEveryMinute,
boolean accumulatePicks,
@@ -88,6 +121,61 @@ public class CardLockController {
|| req.pickEveryMinute() == null || req.pickEveryMinute() < 1)
return ResponseEntity.badRequest().build();
// Friend-lockee path: current user becomes keyholder, invite lockee
boolean friendLockee = req.lockeeUserId() != null && !req.lockeeUserId().equals(myId);
if (friendLockee) {
var lockeeOpt = userRepository.findById(req.lockeeUserId());
if (lockeeOpt.isEmpty()) return ResponseEntity.badRequest().build();
var lockee = lockeeOpt.get();
LocalDateTime now = LocalDateTime.now();
CardLockEntity lock = new CardLockEntity();
lock.setName(req.name());
lock.setLockee(lockee.getUserId());
lock.setKeyholder(myId);
lock.setInitialCards(req.initialCards());
lock.setPickEveryMinute(req.pickEveryMinute());
lock.setAccumulatePicks(req.accumulatePicks());
lock.setShowRemainingCards(req.showRemainingCards());
lock.setLatestOpeningtime(req.latestOpeningtime());
lock.setHygineOpeningDurationMinutes(req.hygineOpeningDurationMinutes());
lock.setHygineOpeningEveryMinites(req.hygineOpeningEveryMinites());
lock.setTasks(req.tasks() != null ? req.tasks() : List.of());
lock.setRequiresVerification(req.requiresVerification());
lock.setTestLock(false);
// startTime, unlockCode, unlockCodeLines left null until lockee accepts
cardlockRepository.save(lock);
String token = UUID.randomUUID().toString().replace("-", "");
LockeeInvitationEntity inv = new LockeeInvitationEntity();
inv.setLockId(lock.getLockId());
inv.setLockeeUserId(lockee.getUserId());
inv.setKeyholderUserId(myId);
inv.setToken(token);
inv.setCreatedAt(now);
inv.setDetailsVisible(req.lockeeDetailsVisible());
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);
return ResponseEntity.ok(Map.of(
"lockId", lock.getLockId().toString(),
"lockeeInvitationSent", true
));
}
// Self-lockee path (existing behavior)
if (cardlockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(myId))
return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists"));
int codeLines = (req.unlockCodeLines() != null && req.unlockCodeLines() >= 1)
? req.unlockCodeLines() : 5;
String unlockCode = generateUnlockCode(codeLines);
@@ -114,8 +202,10 @@ public class CardLockController {
lock.setAvailableCards(new ArrayList<>(req.initialCards()));
lock.setOpenPicks(0);
lock.setNextCardIn(now.plusMinutes(req.pickEveryMinute()));
lock.setHygineOpeningtime(now);
if (req.hygineOpeningEveryMinites() != null) {
lock.setLastHygineOpening(now);
}
cardlockRepository.save(lock);
boolean keyholderPending = false;
@@ -128,18 +218,21 @@ public class CardLockController {
KeyholderInvitationEntity inv = new KeyholderInvitationEntity();
inv.setLockId(lock.getLockId());
inv.setKeyholderUserId(kh.getUserId());
inv.setLockeeUserId(myId);
inv.setToken(token);
inv.setCreatedAt(now);
invitationRepository.save(inv);
String confirmLink = baseUrl + "/keyholder/invitation/" + token;
String lockName = req.name() != null && !req.name().isBlank() ? req.name() : "Unbenanntes Lock";
Email email = new Email();
email.setEmailAdresse(kh.getEmail());
email.setTitel("Einladung als Keyholder*In " + lockName);
email.setText(mailTemplateService.buildKeyholderInvitationMail(
kh.getName(), me.getName(), lockName, confirmLink));
mailService.send(email);
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);
keyholderPending = true;
}
@@ -152,6 +245,131 @@ public class CardLockController {
));
}
@PostMapping("/cardlock/{lockId}/draw")
@Transactional
public ResponseEntity<Map<String, Object>> drawCard(@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 (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
CardLockService service = new CardLockService(l, verificationRepository, verificationVoteRepository, cardLockRepository);
CardDTO dto = service.getNextCard();
if (dto == null) return ResponseEntity.status(409).body(Map.of("error", "Keine Karte verfügbar"));
Map<String, Object> result = new HashMap<>();
result.put("card", dto.card().name());
result.put("unlockCode", dto.unlockCode() != null ? dto.unlockCode() : "");
return ResponseEntity.ok(result);
}
@PostMapping("/cardlock/{lockId}/hygiene/start")
@Transactional
public ResponseEntity<Map<String, Object>> startHygieneOpening(@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 (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
l.setHygineOpeningtime(LocalDateTime.now());
cardlockRepository.save(l);
return ResponseEntity.ok(Map.of(
"unlockCode", l.getUnlockCode(),
"durationMinutes", l.getHygineOpeningDurationMinutes() != null ? l.getHygineOpeningDurationMinutes() : 30
));
}
@PostMapping("/cardlock/{lockId}/hygiene/end")
@Transactional
public ResponseEntity<Map<String, Object>> endHygieneOpening(@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 (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
LocalDateTime now = LocalDateTime.now();
// Overtime berechnen
if (l.getHygineOpeningtime() != null && l.getHygineOpeningDurationMinutes() != null) {
LocalDateTime dueTime = l.getHygineOpeningtime().plusMinutes(l.getHygineOpeningDurationMinutes());
if (now.isAfter(dueTime)) {
long overtimeMinutes = ChronoUnit.MINUTES.between(dueTime, now);
if (l.getKeyholder() == null) {
// Self-Lock: 4-fache Überschreitungszeit einfrieren
l.setFrozenUntill(now.plusMinutes(overtimeMinutes * 4));
} else {
// Keyholder vorhanden: Verletzung protokollieren
HygieneViolationEntity violation = new HygieneViolationEntity();
violation.setLockId(lockId);
violation.setLockeeId(myId);
violation.setKeyholderUserId(l.getKeyholder());
violation.setViolationTime(now);
violation.setOvertimeMinutes(overtimeMinutes);
hygieneViolationRepository.save(violation);
}
}
}
// Nächsten Öffnungszeitpunkt setzen
l.setLastHygineOpening(LocalDateTime.now());
l.setHygineOpeningtime(null);
// Neuen Entsperrcode generieren
int codeLines = l.getUnlockCodeLines() != null ? l.getUnlockCodeLines() : 5;
String newCode = generateUnlockCode(codeLines);
l.setUnlockCode(newCode);
cardlockRepository.save(l);
return ResponseEntity.ok(Map.of("newUnlockCode", newCode));
}
@PostMapping("/cardlock/{lockId}/task/complete")
@Transactional
public ResponseEntity<Void> completeTask(@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 (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
CardLockService service = new CardLockService(l, verificationRepository, verificationVoteRepository, cardLockRepository);
service.clearTask();
return ResponseEntity.noContent().build();
}
@PostMapping("/cardlock/{lockId}/green/keep")
@Transactional
public ResponseEntity<Void> greenKeep(@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 (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
CardLockService service = new CardLockService(l, verificationRepository, verificationVoteRepository, cardLockRepository);
service.putBackGreen();
return ResponseEntity.noContent().build();
}
@GetMapping("/mylock")
public ResponseEntity<Map<String, Object>> getMyActiveLock(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
@@ -184,15 +402,15 @@ public class CardLockController {
// Hygiene-Berechnung
boolean hygieneEnabled = l.getHygineOpeningEveryMinites() != null;
boolean hygieneOpeningDue = false;
long hygieneMinutesRemaining = 0;
long hygieneSecondsRemaining = 0;
if (hygieneEnabled) {
LocalDateTime base = l.getLastHygineOpening() != null
? l.getLastHygineOpening()
: l.getStartTime();
if (base != null) {
LocalDateTime nextHygiene = base.plusMinutes(l.getHygineOpeningEveryMinites());
hygieneMinutesRemaining = ChronoUnit.MINUTES.between(LocalDateTime.now(), nextHygiene);
hygieneOpeningDue = hygieneMinutesRemaining <= 0;
hygieneSecondsRemaining = ChronoUnit.SECONDS.between(LocalDateTime.now(), nextHygiene);
hygieneOpeningDue = hygieneSecondsRemaining <= 0;
}
}
@@ -204,9 +422,411 @@ 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("hygieneEnabled", hygieneEnabled);
result.put("hygieneOpeningDue", hygieneOpeningDue);
result.put("hygieneMinutesRemaining", hygieneMinutesRemaining);
result.put("frozenUntill", l.getFrozenUntill() != null ? l.getFrozenUntill().toString() : null);
result.put("currentTask", l.getCurrentTask() != null ? l.getCurrentTask() : null);
result.put("hygieneEnabled", hygieneEnabled);
result.put("hygieneOpeningDue", hygieneOpeningDue);
result.put("hygieneSecondsRemaining", hygieneSecondsRemaining);
result.put("hygieneOpeningActive", l.getHygineOpeningtime() != null);
result.put("hygieneOpeningStarted", l.getHygineOpeningtime() != null ? l.getHygineOpeningtime().toString() : null);
result.put("hygieneDurationMinutes", l.getHygineOpeningDurationMinutes() != null ? l.getHygineOpeningDurationMinutes() : 0);
result.put("hasKeyholder", l.getKeyholder() != null);
result.put("keyholderInvitationPending", l.getKeyholder() == null &&
!invitationRepository.findByLockId(l.getLockId()).isEmpty());
if (l.getKeyholder() != null) {
userRepository.findById(l.getKeyholder()).ifPresent(kh -> {
result.put("keyholderName", kh.getName());
result.put("keyholderUserId", kh.getUserId().toString());
result.put("keyholderProfilePic", kh.getProfilePicture());
});
}
// Verifikation
boolean verificationDue = false;
String verificationTodayId = null;
String verificationPendingId = null;
String verificationPendingCode = null;
long verificationUpvotes = 0;
long verificationDownvotes = 0;
if (l.isRequiresVerification()) {
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
var completed = verificationRepository.findByLockIdAndVerificationTimeBetweenAndImageIsNotNull(l.getLockId(), todayStart, todayEnd);
if (!completed.isEmpty()) {
var todayV = completed.get(0);
verificationTodayId = todayV.getVerficationId().toString();
var votes = verificationVoteRepository.findAllByVerificationId(todayV.getVerficationId());
verificationUpvotes = votes.stream().filter(VerificationVoteEntity::isUpvote).count();
verificationDownvotes = votes.stream().filter(v2 -> !v2.isUpvote()).count();
} else {
verificationDue = true;
var pending = verificationRepository.findByLockIdAndVerificationTimeBetweenAndImageIsNull(l.getLockId(), todayStart, todayEnd);
if (!pending.isEmpty()) {
verificationPendingId = pending.get(0).getVerficationId().toString();
verificationPendingCode = pending.get(0).getCode();
}
}
}
result.put("verificationRequired", l.isRequiresVerification());
result.put("verificationDue", verificationDue);
result.put("verificationTodayId", verificationTodayId);
result.put("verificationUpvotes", verificationUpvotes);
result.put("verificationDownvotes", verificationDownvotes);
result.put("verificationPendingId", verificationPendingId);
result.put("verificationPendingCode", verificationPendingCode);
return ResponseEntity.ok(result);
}
@PostMapping("/cardlock/{lockId}/verification/start")
@Transactional
public ResponseEntity<Map<String, Object>> startVerification(@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 (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build();
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
// Existierende Verifikation für heute zurückgeben statt neue anlegen
var existing = verificationRepository.findByLockIdAndVerificationTimeBetweenAndImageIsNull(lockId, todayStart, todayEnd);
if (!existing.isEmpty()) {
var ev = existing.get(0);
return ResponseEntity.ok(Map.of("verificationId", ev.getVerficationId().toString(), "code", ev.getCode()));
}
var completed = verificationRepository.findByLockIdAndVerificationTimeBetweenAndImageIsNotNull(lockId, todayStart, todayEnd);
if (!completed.isEmpty()) {
var cv = completed.get(0);
return ResponseEntity.ok(Map.of("verificationId", cv.getVerficationId().toString(), "code", cv.getCode()));
}
VerificationEntity v = new VerificationEntity();
v.setVerficationId(UUID.randomUUID());
v.setLockId(lockId);
v.setLockeeId(myId);
v.setCode(CodeCreator.createAlphanumericCode(6));
v.setVerificationTime(LocalDateTime.now());
if (l.getKeyholder() != null) v.setKeyholderId(l.getKeyholder());
verificationRepository.save(v);
return ResponseEntity.ok(Map.of(
"verificationId", v.getVerficationId().toString(),
"code", v.getCode()
));
}
@PostMapping(value = "/cardlock/{lockId}/verification/{verificationId}/complete", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional
public ResponseEntity<Void> completeVerification(
@PathVariable UUID lockId,
@PathVariable UUID verificationId,
@RequestParam("image") MultipartFile image,
Principal principal) throws IOException {
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();
if (!lockOpt.get().getLockee().equals(myId)) return ResponseEntity.status(403).build();
var vOpt = verificationRepository.findById(verificationId);
if (vOpt.isEmpty()) return ResponseEntity.notFound().build();
var v = vOpt.get();
if (!v.getLockId().equals(lockId)) return ResponseEntity.status(403).build();
v.setImage(scaleImage(image.getBytes(), 1024));
verificationRepository.save(v);
return ResponseEntity.noContent().build();
}
private byte[] scaleImage(byte[] input, int maxSize) throws IOException {
BufferedImage original = ImageIO.read(new ByteArrayInputStream(input));
if (original == null) return input;
int w = original.getWidth();
int h = original.getHeight();
if (w <= maxSize && h <= maxSize) return input;
double scale = (double) maxSize / Math.max(w, h);
int newW = (int) (w * scale);
int newH = (int) (h * scale);
BufferedImage scaled = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(original, 0, 0, newW, newH, null);
g.dispose();
String format = "jpeg";
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(scaled, format, out);
return out.toByteArray();
}
@DeleteMapping("/cardlock/{lockId}/verification/today")
@Transactional
public ResponseEntity<Void> renewVerification(@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();
if (!lockOpt.get().getLockee().equals(myId)) return ResponseEntity.status(403).build();
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
var completed = verificationRepository
.findByLockIdAndVerificationTimeBetweenAndImageIsNotNull(lockId, todayStart, todayEnd);
for (var v : completed) {
verificationVoteRepository.deleteAllByVerificationId(v.getVerficationId());
verificationRepository.delete(v);
}
return ResponseEntity.noContent().build();
}
// ── Keyholder-Dashboard Endpunkte ──
@GetMapping("/invitations/mine")
public ResponseEntity<List<Map<String, Object>>> getMyInvitations(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var invitations = invitationRepository.findByKeyholderUserId(myId);
List<Map<String, Object>> result = new ArrayList<>();
for (var inv : invitations) {
var lockOpt = cardlockRepository.findById(inv.getLockId());
if (lockOpt.isEmpty()) continue;
var lock = lockOpt.get();
if (lock.getKeyholder() != null) continue; // bereits akzeptiert
var lockeeOpt = userRepository.findById(lock.getLockee());
if (lockeeOpt.isEmpty()) continue;
var lockee = lockeeOpt.get();
Map<String, Object> item = new HashMap<>();
item.put("lockId", inv.getLockId().toString());
item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock");
item.put("lockeeName", lockee.getName());
item.put("lockeeId", lockee.getUserId().toString());
item.put("lockeeProfilePic", lockee.getProfilePicture());
item.put("token", inv.getToken());
item.put("createdAt", inv.getCreatedAt().toString());
result.add(item);
}
return ResponseEntity.ok(result);
}
@Transactional
@DeleteMapping("/invitations/mine/{token}")
public ResponseEntity<Void> declineInvitation(@PathVariable String token, 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 invOpt = invitationRepository.findByToken(token);
if (invOpt.isEmpty()) return ResponseEntity.notFound().build();
var inv = invOpt.get();
if (!inv.getKeyholderUserId().equals(myId)) return ResponseEntity.status(403).build();
var lockOpt = cardlockRepository.findById(inv.getLockId());
invitationRepository.delete(inv);
if (lockOpt.isPresent()) {
var lock = lockOpt.get();
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(lock.getLockee());
msg.setText(me.getName() + " hat die Einladung als Keyholder*In für das Lock „" + lockName + "\" abgelehnt.");
msg.setSentAt(LocalDateTime.now());
messageRepository.save(msg);
}
return ResponseEntity.noContent().build();
}
@GetMapping("/invitations/sent")
public ResponseEntity<List<Map<String, Object>>> getSentKeyholderInvitations(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var invitations = invitationRepository.findByLockeeUserId(myId);
List<Map<String, Object>> result = new ArrayList<>();
for (var inv : invitations) {
var lockOpt = cardlockRepository.findById(inv.getLockId());
if (lockOpt.isEmpty()) continue;
var lock = lockOpt.get();
if (lock.getKeyholder() != null) continue; // already accepted
var khOpt = userRepository.findById(inv.getKeyholderUserId());
if (khOpt.isEmpty()) continue;
var kh = khOpt.get();
Map<String, Object> item = new HashMap<>();
item.put("lockId", lock.getLockId().toString());
item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock");
item.put("keyholderName", kh.getName());
item.put("keyholderProfilePic", kh.getProfilePicture());
item.put("token", inv.getToken());
item.put("createdAt", inv.getCreatedAt().toString());
result.add(item);
}
return ResponseEntity.ok(result);
}
@Transactional
@DeleteMapping("/invitations/sent/{token}")
public ResponseEntity<Void> cancelSentKeyholderInvitation(@PathVariable String token, 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 invOpt = invitationRepository.findByToken(token);
if (invOpt.isEmpty()) return ResponseEntity.notFound().build();
var inv = invOpt.get();
// Verify the lock belongs to the current user as lockee
var lockOpt = cardlockRepository.findById(inv.getLockId());
if (lockOpt.isEmpty() || !lockOpt.get().getLockee().equals(myId))
return ResponseEntity.status(403).build();
invitationRepository.delete(inv);
String lockName = lockOpt.get().getName() != null && !lockOpt.get().getName().isBlank()
? lockOpt.get().getName() : "Unbenanntes Lock";
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(myId);
msg.setReceiverId(inv.getKeyholderUserId());
msg.setText(me.getName() + " hat die Keyholder-Einladung für das Lock „" + lockName + "\" zurückgezogen.");
msg.setSentAt(LocalDateTime.now());
messageRepository.save(msg);
return ResponseEntity.noContent().build();
}
@GetMapping("/as-keyholder")
public ResponseEntity<List<Map<String, Object>>> getLocksAsKeyholder(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var locks = cardlockRepository.findByKeyholderAndUnlockTimeIsNull(myId);
List<Map<String, Object>> result = new ArrayList<>();
for (var lock : locks) {
var lockeeOpt = userRepository.findById(lock.getLockee());
if (lockeeOpt.isEmpty()) continue;
var lockee = lockeeOpt.get();
Map<String, Object> item = new HashMap<>();
item.put("lockId", lock.getLockId().toString());
item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock");
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);
result.add(item);
}
return ResponseEntity.ok(result);
}
@GetMapping("/as-keyholder/{lockId}")
public ResponseEntity<Map<String, Object>> getLockAsKeyholder(@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();
var lockeeOpt = userRepository.findById(l.getLockee());
if (lockeeOpt.isEmpty()) return ResponseEntity.notFound().build();
var lockee = lockeeOpt.get();
Map<String, Long> cardCounts = new LinkedHashMap<>();
if (l.getAvailableCards() != null) {
l.getAvailableCards().forEach(c -> cardCounts.merge(c.name(), 1L, Long::sum));
}
boolean hygieneEnabled = l.getHygineOpeningEveryMinites() != null;
boolean hygieneOpeningDue = false;
long hygieneSecondsRemaining = 0;
if (hygieneEnabled) {
LocalDateTime base = l.getLastHygineOpening() != null ? l.getLastHygineOpening() : l.getStartTime();
if (base != null) {
LocalDateTime nextHygiene = base.plusMinutes(l.getHygineOpeningEveryMinites());
hygieneSecondsRemaining = ChronoUnit.SECONDS.between(LocalDateTime.now(), nextHygiene);
hygieneOpeningDue = hygieneSecondsRemaining <= 0;
}
}
boolean verificationDue = false;
boolean verificationDoneToday = false;
String verificationMyVote = null; // null = not voted, "upvote", "downvote"
String verificationTodayId = null;
String verificationImage = null;
long verificationUpvotes = 0, verificationDownvotes = 0;
if (l.isRequiresVerification()) {
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
var completed = verificationRepository.findByLockIdAndVerificationTimeBetweenAndImageIsNotNull(lockId, todayStart, todayEnd);
if (!completed.isEmpty()) {
verificationDoneToday = true;
var v = completed.get(0);
var votes = verificationVoteRepository.findAllByVerificationId(v.getVerficationId());
verificationUpvotes = votes.stream().filter(VerificationVoteEntity::isUpvote).count();
verificationDownvotes = votes.stream().filter(v2 -> !v2.isUpvote()).count();
verificationTodayId = v.getVerficationId().toString();
var myVoteOpt = verificationVoteRepository.findByVerificationIdAndUserId(v.getVerficationId(), myId);
if (myVoteOpt.isPresent()) {
verificationMyVote = myVoteOpt.get().isUpvote() ? "upvote" : "downvote";
} else if (v.getImage() != null) {
verificationImage = java.util.Base64.getEncoder().encodeToString(v.getImage());
}
} else {
verificationDue = true;
}
}
var recentViolations = hygieneViolationRepository.findByLockId(lockId).stream()
.sorted((a, b) -> b.getViolationTime().compareTo(a.getViolationTime()))
.limit(5)
.map(v -> Map.of("time", v.getViolationTime().toString(), "overtimeMinutes", v.getOvertimeMinutes()))
.toList();
Map<String, Object> result = new HashMap<>();
result.put("lockId", l.getLockId().toString());
result.put("lockName", l.getName() != null ? l.getName() : "Unbenanntes Lock");
result.put("lockeeName", lockee.getName());
result.put("lockeeId", lockee.getUserId().toString());
result.put("lockeeProfilePic", lockee.getProfilePicture());
result.put("totalCards", l.getAvailableCards() != null ? l.getAvailableCards().size() : 0);
result.put("cardCounts", cardCounts);
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("currentTask", l.getCurrentTask());
result.put("startTime", l.getStartTime() != null ? l.getStartTime().toString() : null);
result.put("hygieneEnabled", hygieneEnabled);
result.put("hygieneOpeningDue", hygieneOpeningDue);
result.put("hygieneSecondsRemaining", hygieneSecondsRemaining);
result.put("hygieneOpeningActive", l.getHygineOpeningtime() != null);
result.put("requiresVerification", l.isRequiresVerification());
result.put("verificationDue", verificationDue);
result.put("verificationDoneToday", verificationDoneToday);
result.put("verificationTodayId", verificationTodayId);
result.put("verificationMyVote", verificationMyVote);
result.put("verificationImage", verificationImage);
result.put("verificationUpvotes", verificationUpvotes);
result.put("verificationDownvotes", verificationDownvotes);
result.put("hygieneViolations", recentViolations);
result.put("hasTasks", l.getTasks() != null && !l.getTasks().isEmpty());
return ResponseEntity.ok(result);
}
@@ -221,11 +841,145 @@ public class CardLockController {
if (lockOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!lockOpt.get().getLockee().equals(myId)) return ResponseEntity.status(403).build();
var verifications = verificationRepository.findByLockId(lockId);
verifications.forEach(v -> verificationVoteRepository.deleteAllByVerificationId(v.getVerficationId()));
verificationRepository.deleteAll(verifications);
invitationRepository.deleteByLockId(lockId);
cardlockRepository.deleteById(lockId);
return ResponseEntity.noContent().build();
}
record ModifyCardsRequest(Map<String, Integer> cards, boolean notifyDetailed) {}
@PostMapping("/as-keyholder/{lockId}/cards/add")
@Transactional
public ResponseEntity<Void> addCards(@PathVariable UUID lockId,
@RequestBody ModifyCardsRequest 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 (req.cards() == null || req.cards().isEmpty()) return ResponseEntity.badRequest().build();
List<CardEnum> toAdd = new ArrayList<>();
for (var entry : req.cards().entrySet()) {
try {
CardEnum type = CardEnum.valueOf(entry.getKey());
int count = entry.getValue() != null ? Math.max(0, entry.getValue()) : 0;
for (int i = 0; i < count; i++) toAdd.add(type);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
if (toAdd.isEmpty()) return ResponseEntity.badRequest().build();
List<CardEnum> cards = new ArrayList<>(l.getAvailableCards() != null ? l.getAvailableCards() : List.of());
cards.addAll(toAdd);
l.setAvailableCards(cards);
cardlockRepository.save(l);
String detail = toAdd.stream()
.collect(Collectors.groupingBy(c -> c, Collectors.counting()))
.entrySet().stream()
.map(e -> e.getValue() + "x " + cardLabel(e.getKey()))
.collect(Collectors.joining(", "));
String msgText = req.notifyDetailed()
? me.getName() + " hat " + toAdd.size() + " Karte(n) zu deinem Lock hinzugefügt: " + detail + "."
: me.getName() + " hat Karten zu deinem Lock hinzugefügt.";
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(myId);
msg.setReceiverId(l.getLockee());
msg.setText(msgText);
msg.setSentAt(LocalDateTime.now());
messageRepository.save(msg);
return ResponseEntity.noContent().build();
}
@PostMapping("/as-keyholder/{lockId}/cards/remove")
@Transactional
public ResponseEntity<Map<String, String>> removeCards(@PathVariable UUID lockId,
@RequestBody ModifyCardsRequest 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 (req.cards() == null || req.cards().isEmpty()) return ResponseEntity.badRequest().build();
List<CardEnum> cards = new ArrayList<>(l.getAvailableCards() != null ? l.getAvailableCards() : List.of());
// Plausi: letzte grüne Karte darf nicht entfernt werden
long greenInDeck = cards.stream().filter(c -> c == CardEnum.GREEN).count();
int greenToRemove = req.cards().getOrDefault("GREEN", 0);
if (greenInDeck > 0 && greenToRemove >= greenInDeck) {
return ResponseEntity.badRequest().body(Map.of("error", "Die letzte grüne Karte darf nicht entfernt werden."));
}
List<CardEnum> removed = new ArrayList<>();
for (var entry : req.cards().entrySet()) {
try {
CardEnum type = CardEnum.valueOf(entry.getKey());
int count = entry.getValue() != null ? Math.max(0, entry.getValue()) : 0;
Iterator<CardEnum> it = cards.iterator();
int done = 0;
while (it.hasNext() && done < count) {
if (it.next() == type) { it.remove(); removed.add(type); done++; }
}
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
if (removed.isEmpty()) return ResponseEntity.badRequest().build();
l.setAvailableCards(cards);
cardlockRepository.save(l);
String detail = removed.stream()
.collect(Collectors.groupingBy(c -> c, Collectors.counting()))
.entrySet().stream()
.map(e -> e.getValue() + "x " + cardLabel(e.getKey()))
.collect(Collectors.joining(", "));
String msgText = req.notifyDetailed()
? me.getName() + " hat " + removed.size() + " Karte(n) aus deinem Lock entfernt: " + detail + "."
: me.getName() + " hat Karten aus deinem Lock entfernt.";
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(myId);
msg.setReceiverId(l.getLockee());
msg.setText(msgText);
msg.setSentAt(LocalDateTime.now());
messageRepository.save(msg);
return ResponseEntity.noContent().build();
}
private String cardLabel(CardEnum card) {
return switch (card) {
case RED -> "Rote Karte";
case GREEN -> "Grüne Karte";
case YELLOW -> "Gelbe Karte";
case TASK -> "Aufgabe";
case FREEZE -> "Freeze";
case RESET -> "Reset";
case DOUBLE_UP -> "Double Up";
};
}
@GetMapping("/invitation/{token}")
public void confirmInvitation(@PathVariable String token,
jakarta.servlet.http.HttpServletResponse response) throws Exception {
@@ -244,6 +998,6 @@ public class CardLockController {
lock.setKeyholder(inv.getKeyholderUserId());
cardlockRepository.save(lock);
invitationRepository.delete(inv);
response.sendRedirect("/keyholder-invitation-confirmed.html?status=ok");
response.sendRedirect("/keyholder.html");
}
}

View File

@@ -8,4 +8,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface CardlockRepository extends JpaRepository<CardLockEntity, UUID> {
List<CardLockEntity> findByLockee(UUID lockee);
List<CardLockEntity> findByKeyholderAndUnlockTimeIsNull(UUID keyholder);
boolean existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(UUID lockee);
}

View File

@@ -0,0 +1,131 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.user.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/cardlock/templates")
public class CardlockTemplateController {
private final CardlockTemplateRepository templateRepository;
private final UserRepository userRepository;
public CardlockTemplateController(CardlockTemplateRepository templateRepository,
UserRepository userRepository) {
this.templateRepository = templateRepository;
this.userRepository = userRepository;
}
record TemplateRequest(
String name,
Map<String, Integer> cardCountsMin,
Map<String, Integer> cardCountsMax,
Integer pickEveryMinute,
boolean accumulatePicks,
boolean showRemainingCards,
Integer hygineOpeningDurationMinutes,
Integer hygineOpeningEveryMinites,
List<Task> tasks,
boolean requiresVerification
) {}
private Map<String, Object> toDto(CardlockTemplateEntity t) {
Map<String, Object> dto = new LinkedHashMap<>();
dto.put("templateId", t.getTemplateId());
dto.put("name", t.getName());
dto.put("cardCountsMin", t.getCardCountsMin() != null ? t.getCardCountsMin() : Map.of());
dto.put("cardCountsMax", t.getCardCountsMax() != null ? t.getCardCountsMax() : Map.of());
dto.put("pickEveryMinute", t.getPickEveryMinute());
dto.put("accumulatePicks", t.isAccumulatePicks());
dto.put("showRemainingCards", t.isShowRemainingCards());
dto.put("hygineOpeningEveryMinites", t.getHygineOpeningEveryMinites());
dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes());
dto.put("tasks", t.getTasks() != null ? t.getTasks() : List.of());
dto.put("requiresVerification", t.isRequiresVerification());
return dto;
}
@GetMapping
public ResponseEntity<List<Map<String, Object>>> list(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 = templateRepository.findByOwner(myId)
.stream().map(this::toDto).collect(Collectors.toList());
return ResponseEntity.ok(result);
}
@PostMapping
public ResponseEntity<Map<String, Object>> create(@RequestBody TemplateRequest req, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
if (req.pickEveryMinute() == null || req.pickEveryMinute() < 1)
return ResponseEntity.badRequest().build();
if (req.cardCountsMin() == null || req.cardCountsMin().isEmpty())
return ResponseEntity.badRequest().build();
CardlockTemplateEntity t = new CardlockTemplateEntity();
t.setOwner(myId);
applyRequest(t, req);
templateRepository.save(t);
return ResponseEntity.ok(toDto(t));
}
@PutMapping("/{id}")
public ResponseEntity<Map<String, Object>> update(@PathVariable UUID id,
@RequestBody TemplateRequest req,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var opt = templateRepository.findById(id);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
CardlockTemplateEntity t = opt.get();
if (!t.getOwner().equals(myId)) return ResponseEntity.status(403).build();
if (req.pickEveryMinute() == null || req.pickEveryMinute() < 1)
return ResponseEntity.badRequest().build();
if (req.cardCountsMin() == null || req.cardCountsMin().isEmpty())
return ResponseEntity.badRequest().build();
applyRequest(t, req);
templateRepository.save(t);
return ResponseEntity.ok(toDto(t));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable UUID id, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var opt = templateRepository.findById(id);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
if (!opt.get().getOwner().equals(myId)) return ResponseEntity.status(403).build();
templateRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
private void applyRequest(CardlockTemplateEntity t, TemplateRequest req) {
t.setName(req.name());
t.setCardCountsMin(req.cardCountsMin());
t.setCardCountsMax(req.cardCountsMax() != null ? req.cardCountsMax() : req.cardCountsMin());
t.setPickEveryMinute(req.pickEveryMinute());
t.setAccumulatePicks(req.accumulatePicks());
t.setShowRemainingCards(req.showRemainingCards());
t.setHygineOpeningEveryMinites(req.hygineOpeningEveryMinites());
t.setHygineOpeningDurationMinutes(req.hygineOpeningDurationMinutes());
t.setTasks(req.tasks() != null ? req.tasks() : List.of());
t.setRequiresVerification(req.requiresVerification());
}
}

View File

@@ -0,0 +1,91 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.games.chastity.tasks.TaskListConverter;
import jakarta.persistence.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "cardlock_template")
public class CardlockTemplateEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column
private UUID templateId;
@Column(nullable = false)
private UUID owner;
@Column
private String name;
@Convert(converter = CardCountMapConverter.class)
@Column(columnDefinition = "TEXT")
private Map<String, Integer> cardCountsMin;
@Convert(converter = CardCountMapConverter.class)
@Column(columnDefinition = "TEXT")
private Map<String, Integer> cardCountsMax;
@Column
private Integer pickEveryMinute;
@Column
private boolean accumulatePicks;
@Column
private boolean showRemainingCards;
@Column
private Integer hygineOpeningDurationMinutes;
@Column
private Integer hygineOpeningEveryMinites;
@Convert(converter = TaskListConverter.class)
@Column(columnDefinition = "TEXT")
private List<Task> tasks;
@Column
private boolean requiresVerification;
public UUID getTemplateId() { return templateId; }
public void setTemplateId(UUID templateId) { this.templateId = templateId; }
public UUID getOwner() { return owner; }
public void setOwner(UUID owner) { this.owner = owner; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Map<String, Integer> getCardCountsMin() { return cardCountsMin; }
public void setCardCountsMin(Map<String, Integer> cardCountsMin) { this.cardCountsMin = cardCountsMin; }
public Map<String, Integer> getCardCountsMax() { return cardCountsMax; }
public void setCardCountsMax(Map<String, Integer> cardCountsMax) { this.cardCountsMax = cardCountsMax; }
public Integer getPickEveryMinute() { return pickEveryMinute; }
public void setPickEveryMinute(Integer pickEveryMinute) { this.pickEveryMinute = pickEveryMinute; }
public boolean isAccumulatePicks() { return accumulatePicks; }
public void setAccumulatePicks(boolean accumulatePicks) { this.accumulatePicks = accumulatePicks; }
public boolean isShowRemainingCards() { return showRemainingCards; }
public void setShowRemainingCards(boolean showRemainingCards) { this.showRemainingCards = showRemainingCards; }
public Integer getHygineOpeningDurationMinutes() { return hygineOpeningDurationMinutes; }
public void setHygineOpeningDurationMinutes(Integer hygineOpeningDurationMinutes) { this.hygineOpeningDurationMinutes = hygineOpeningDurationMinutes; }
public Integer getHygineOpeningEveryMinites() { return hygineOpeningEveryMinites; }
public void setHygineOpeningEveryMinites(Integer hygineOpeningEveryMinites) { this.hygineOpeningEveryMinites = hygineOpeningEveryMinites; }
public List<Task> getTasks() { return tasks; }
public void setTasks(List<Task> tasks) { this.tasks = tasks; }
public boolean isRequiresVerification() { return requiresVerification; }
public void setRequiresVerification(boolean requiresVerification) { this.requiresVerification = requiresVerification; }
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.cardlock;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface CardlockTemplateRepository extends JpaRepository<CardlockTemplateEntity, UUID> {
List<CardlockTemplateEntity> findByOwner(UUID owner);
}

View File

@@ -6,6 +6,6 @@ public class DoubleUpCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.doubleUp(), "img/card_red.png");
return new CardDTO(CardEnum.DOUBLE_UP, lock.doubleUp());
}
}

View File

@@ -6,7 +6,7 @@ public class FreezeCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.freeze(), "img/card_freeze.png");
return new CardDTO(CardEnum.FREEZE, lock.freeze());
}
}

View File

@@ -6,6 +6,6 @@ public class GreenCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.unlock(), "img/card_green.png");
return new CardDTO(CardEnum.GREEN, lock.green());
}
}

View File

@@ -0,0 +1,53 @@
package de.oaa.xxx.games.chastity.cardlock;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "hygiene_violation")
public class HygieneViolationEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private UUID lockId;
@Column(nullable = false)
private UUID lockeeId;
@Column
private UUID keyholderUserId;
@Column(nullable = false)
private LocalDateTime violationTime;
@Column(nullable = false)
private long overtimeMinutes;
@Column(nullable = false)
private boolean notifiedKeyholder = false;
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public UUID getLockId() { return lockId; }
public void setLockId(UUID lockId) { this.lockId = lockId; }
public UUID getLockeeId() { return lockeeId; }
public void setLockeeId(UUID lockeeId) { this.lockeeId = lockeeId; }
public UUID getKeyholderUserId() { return keyholderUserId; }
public void setKeyholderUserId(UUID keyholderUserId) { this.keyholderUserId = keyholderUserId; }
public LocalDateTime getViolationTime() { return violationTime; }
public void setViolationTime(LocalDateTime violationTime){ this.violationTime = violationTime; }
public long getOvertimeMinutes() { return overtimeMinutes; }
public void setOvertimeMinutes(long overtimeMinutes) { this.overtimeMinutes = overtimeMinutes; }
public boolean isNotifiedKeyholder() { return notifiedKeyholder; }
public void setNotifiedKeyholder(boolean notifiedKeyholder){ this.notifiedKeyholder = notifiedKeyholder; }
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.cardlock;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface HygieneViolationRepository extends JpaRepository<HygieneViolationEntity, UUID> {
List<HygieneViolationEntity> findByKeyholderUserIdAndNotifiedKeyholderFalse(UUID keyholderUserId);
List<HygieneViolationEntity> findByLockId(UUID lockId);
}

View File

@@ -6,7 +6,7 @@ public class RedCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.redCard(), "img/card_red.png");
return new CardDTO(CardEnum.RED, lock.redCard());
}
}

View File

@@ -6,7 +6,7 @@ public class ResetCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.reset(), "img/card_reset.png");
return new CardDTO(CardEnum.RESET, lock.reset());
}
}

View File

@@ -6,7 +6,7 @@ public class TaskCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.task(), "img/card_task.png");
return new CardDTO(CardEnum.TASK, lock.task());
}
}

View File

@@ -6,6 +6,6 @@ public class YellowCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.yellowCard(), "img/card_yellow.png");
return new CardDTO(CardEnum.YELLOW, lock.yellowCard());
}
}

View File

@@ -1,8 +1,12 @@
package de.oaa.xxx.games.chastity.verification;
import java.security.Principal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.data.domain.Page;
@@ -94,15 +98,54 @@ public class VerificationController {
return ResponseEntity.ok().build();
}
@GetMapping("/community")
public ResponseEntity<List<Map<String, Object>>> getCommunity(
@RequestParam(defaultValue = "0") int page,
Principal principal) {
var user = userRepository.findByEmail(principal.getName()).orElse(null);
if (user == null) return ResponseEntity.status(401).build();
UUID myId = user.getUserId();
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
var paging = PageRequest.of(page, 10, Sort.by("verificationTime").descending());
Page<VerificationEntity> result = verificationRepository
.findByKeyholderIsNullAndVerificationTimeBetweenAndImageIsNotNull(todayStart, todayEnd, paging);
List<Map<String, Object>> items = result.getContent().stream().map(v -> {
var votes = verificationVoteRepository.findAllByVerificationId(v.getVerficationId());
long upvotes = votes.stream().filter(VerificationVoteEntity::isUpvote).count();
long downvotes = votes.stream().filter(vt -> !vt.isUpvote()).count();
var myVoteOpt = votes.stream().filter(vt -> myId.equals(vt.getUserId())).findFirst();
boolean isOwn = myId.equals(v.getLockeeId());
Map<String, Object> item = new HashMap<>();
item.put("verificationId", v.getVerficationId().toString());
item.put("verificationTime", v.getVerificationTime().toString());
item.put("code", v.getCode());
item.put("image", v.getImage() != null ? Base64.getEncoder().encodeToString(v.getImage()) : null);
item.put("upvotes", upvotes);
item.put("downvotes", downvotes);
item.put("myVote", isOwn ? "own" : myVoteOpt.map(VerificationVoteEntity::isUpvote).orElse(null));
item.put("hasMore", result.hasNext());
return item;
}).toList();
return ResponseEntity.ok(items);
}
@PostMapping("/{verificationId}/vote/")
public ResponseEntity<Void> addVote(@PathVariable UUID verificationId, @RequestBody VerificationVoteDTO dto,
Principal principal) {
var user = userRepository.findByEmail(principal.getName()).orElse(null);
if (user == null) {
return ResponseEntity.status(401).build();
}
if (!verificationRepository.existsById(verificationId)) {
return ResponseEntity.notFound().build();
if (user == null) return ResponseEntity.status(401).build();
if (!verificationRepository.existsById(verificationId)) return ResponseEntity.notFound().build();
var vEntity = verificationRepository.findById(verificationId).orElse(null);
if (vEntity == null) return ResponseEntity.notFound().build();
if (user.getUserId().equals(vEntity.getLockeeId())) return ResponseEntity.status(403).build();
if (verificationVoteRepository.findByVerificationIdAndUserId(verificationId, user.getUserId()).isPresent()) {
return ResponseEntity.status(409).build();
}
var vote = new VerificationVoteEntity();
vote.setVoteId(UUID.randomUUID());

View File

@@ -16,14 +16,18 @@ public class VerificationEntity {
@Id
@Column
private UUID verficationId;
@Column
@Column(nullable = false)
private UUID lockId;
@Column(nullable = false)
private String code;
@Column(nullable = false)
private LocalDateTime verificationTime;
@Column(columnDefinition = "BLOB")
@Column(columnDefinition = "MEDIUMBLOB")
private byte[] image;
@Column
private UUID lockeeId;
@Column
private UUID keyholder;
public UUID getVerficationId() {
return verficationId;
@@ -65,6 +69,22 @@ public class VerificationEntity {
this.lockId = lockId;
}
public UUID getLockeeId() {
return lockeeId;
}
public void setLockeeId(UUID lockeeId) {
this.lockeeId = lockeeId;
}
public UUID getKeyholderId() {
return keyholder;
}
public void setKeyholderId(UUID keyholder) {
this.keyholder = keyholder;
}
public VerificationDTO toVerification() {
return new VerificationDTO(verficationId, code, verificationTime, image, new ArrayList<>());
}

View File

@@ -10,4 +10,11 @@ public interface VerificationRepository extends JpaRepository<VerificationEntity
org.springframework.data.domain.Page<VerificationEntity> findAllByImageIsNotNull(Pageable pageable);
java.util.List<VerificationEntity> findByLockId(UUID lockId);
java.util.List<VerificationEntity> findByLockIdAndVerificationTimeBetweenAndImageIsNotNull(UUID lockId, java.time.LocalDateTime from, java.time.LocalDateTime to);
java.util.List<VerificationEntity> findByLockIdAndVerificationTimeBetweenAndImageIsNull(UUID lockId, java.time.LocalDateTime from, java.time.LocalDateTime to);
org.springframework.data.domain.Page<VerificationEntity> findByKeyholderIsNullAndVerificationTimeBetweenAndImageIsNotNull(
java.time.LocalDateTime from, java.time.LocalDateTime to, org.springframework.data.domain.Pageable pageable);
}

View File

@@ -8,7 +8,9 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface VerificationVoteRepository extends JpaRepository<VerificationVoteEntity, UUID> {
List<VerificationVoteEntity> findAllByVerificationId(UUID verificationId);
java.util.Optional<VerificationVoteEntity> findByVerificationIdAndUserId(UUID verificationId, UUID userId);
void deleteAllByVerificationId(UUID verificationId);
}

View File

@@ -255,16 +255,40 @@ public class SocialController {
return ResponseEntity.ok(messageRepository.countUnread(myId));
}
private static final int MSG_PAGE_SIZE = 20;
@GetMapping("/messages/{partnerId}")
public ResponseEntity<List<MessageDto>> getConversation(@PathVariable UUID partnerId, Principal principal) {
public ResponseEntity<?> getConversation(
@PathVariable UUID partnerId,
@RequestParam(required = false) String before,
@RequestParam(required = false) String after,
Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<MessageEntity> messages = messageRepository.findConversation(myId, partnerId, PageRequest.of(0, 50));
messageRepository.markAsRead(myId, partnerId, LocalDateTime.now());
if (after != null) {
LocalDateTime afterDt = LocalDateTime.parse(after);
List<MessageEntity> newMsgs = messageRepository.findConversationAfter(myId, partnerId, afterDt);
messageRepository.markAsRead(myId, partnerId, LocalDateTime.now());
return ResponseEntity.ok(Map.of("messages", newMsgs.stream().map(this::toMessageDto).toList(), "hasMore", false));
}
return ResponseEntity.ok(messages.stream().map(this::toMessageDto).toList());
List<MessageEntity> messages;
if (before != null) {
LocalDateTime beforeDt = LocalDateTime.parse(before);
messages = new ArrayList<>(messageRepository.findConversationBefore(myId, partnerId, beforeDt, PageRequest.of(0, MSG_PAGE_SIZE + 1)));
} else {
messages = new ArrayList<>(messageRepository.findConversation(myId, partnerId, PageRequest.of(0, MSG_PAGE_SIZE + 1)));
messageRepository.markAsRead(myId, partnerId, LocalDateTime.now());
}
boolean hasMore = messages.size() > MSG_PAGE_SIZE;
if (hasMore) messages = messages.subList(0, MSG_PAGE_SIZE);
// DESC order from DB → reverse to oldest-first for client
Collections.reverse(messages);
return ResponseEntity.ok(Map.of("messages", messages.stream().map(this::toMessageDto).toList(), "hasMore", hasMore));
}
// ── Helpers ──

View File

@@ -17,6 +17,12 @@ 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")
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")
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")
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")
List<MessageEntity> findAllByUser(@Param("userId") UUID userId);

View File

@@ -49,3 +49,7 @@ logging.level.de.oaa.xxx=DEBUG
# Server
server.port=8080
server.servlet.context-path=/
# Multipart upload
spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=20MB

View File

@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Community Votes XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.page-title {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.35rem;
}
.page-subtitle {
font-size: 0.88rem;
color: var(--color-muted);
margin-bottom: 1.5rem;
}
.vote-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.vote-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
overflow: hidden;
}
.vote-card-media {
position: relative;
}
.vote-card-img {
width: 100%;
max-height: 420px;
object-fit: contain;
display: block;
background: #000;
}
.vote-card-code {
position: absolute;
top: 1rem;
left: 50%;
transform: translateX(-50%);
font-family: monospace;
font-size: 2rem;
font-weight: 700;
letter-spacing: 0.25em;
color: #fff;
background: rgba(0,0,0,0.55);
padding: 0.4rem 1.1rem;
border-radius: 8px;
pointer-events: none;
white-space: nowrap;
}
.vote-card-body {
padding: 0.85rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.vote-meta {
font-size: 0.8rem;
color: var(--color-muted);
}
.vote-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.vote-btn {
display: flex;
align-items: center;
gap: 0.35rem;
background: none;
border: 1px solid var(--color-secondary);
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.9rem;
cursor: pointer;
color: var(--color-text);
margin: 0;
width: auto;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.vote-btn:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
background: none;
}
.vote-btn.voted-up {
border-color: #2ecc71;
color: #2ecc71;
background: rgba(46,204,113,0.08);
}
.vote-btn.voted-down {
border-color: #e74c3c;
color: #e74c3c;
background: rgba(231,76,60,0.08);
}
.vote-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
pointer-events: none;
}
.vote-count {
font-weight: 600;
font-size: 0.88rem;
}
.empty-hint {
color: var(--color-muted);
font-size: 0.9rem;
text-align: center;
padding: 2rem 0;
}
.load-spinner {
text-align: center;
color: var(--color-muted);
font-size: 0.85rem;
padding: 1rem 0;
}
.sentinel { height: 1px; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div class="page-title">Community Votes</div>
<div class="page-subtitle">Verifikationen - stimme ab</div>
<div class="vote-grid" id="voteGrid"></div>
<div class="load-spinner" id="loadSpinner" style="display:none;">Lädt…</div>
<div class="empty-hint" id="emptyHint" style="display:none;">Heute gibt es noch keine Verifikationen ohne Keyholder.</div>
<div class="sentinel" id="sentinel"></div>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
let page = 0;
let loading = false;
let exhausted = false;
function fmtTime(isoStr) {
const d = new Date(isoStr);
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function buildCard(item) {
const isOwn = item.myVote === 'own';
const voted = isOwn || (item.myVote !== null && item.myVote !== undefined);
const votedUp = !isOwn && item.myVote === true;
const votedDn = !isOwn && item.myVote === false;
const card = document.createElement('div');
card.className = 'vote-card';
card.dataset.id = item.verificationId;
card.innerHTML = `
<div class="vote-card-media">
<img class="vote-card-img" src="data:image/jpeg;base64,${item.image}" alt="Verifikationsbild">
<div class="vote-card-code">${item.code}</div>
</div>
<div class="vote-card-body">
<div class="vote-meta">Erstellt um ${fmtTime(item.verificationTime)}</div>
<div class="vote-actions">
<button class="vote-btn ${votedUp ? 'voted-up' : ''}" id="up-${item.verificationId}"
${voted ? 'disabled' : ''}
onclick="castVote('${item.verificationId}', true)">
👍 <span class="vote-count" id="upcount-${item.verificationId}">${item.upvotes}</span>
</button>
<button class="vote-btn ${votedDn ? 'voted-down' : ''}" id="dn-${item.verificationId}"
${voted ? 'disabled' : ''}
onclick="castVote('${item.verificationId}', false)">
👎 <span class="vote-count" id="dncount-${item.verificationId}">${item.downvotes}</span>
</button>
</div>
</div>`;
return card;
}
async function loadPage() {
if (loading || exhausted) return;
loading = true;
document.getElementById('loadSpinner').style.display = '';
const res = await fetch('/verification/community?page=' + page);
document.getElementById('loadSpinner').style.display = 'none';
loading = false;
if (!res.ok) return;
const items = await res.json();
const grid = document.getElementById('voteGrid');
if (items.length === 0 && page === 0) {
document.getElementById('emptyHint').style.display = '';
exhausted = true;
return;
}
items.forEach(item => grid.appendChild(buildCard(item)));
if (items.length < 10 || (items.length > 0 && !items[items.length - 1].hasMore)) {
exhausted = true;
} else {
page++;
}
}
async function castVote(verificationId, upvote) {
const upBtn = document.getElementById('up-' + verificationId);
const dnBtn = document.getElementById('dn-' + verificationId);
upBtn.disabled = true;
dnBtn.disabled = true;
const res = await fetch('/verification/' + verificationId + '/vote/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upvote })
});
if (res.ok || res.status === 202) {
const countEl = document.getElementById(upvote ? 'upcount-' + verificationId : 'dncount-' + verificationId);
countEl.textContent = parseInt(countEl.textContent) + 1;
(upvote ? upBtn : dnBtn).classList.add(upvote ? 'voted-up' : 'voted-down');
} else {
// Doppelter Vote oder Fehler Buttons trotzdem disabled lassen
}
}
// Infinite Scroll via IntersectionObserver
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadPage();
}, { rootMargin: '200px' });
observer.observe(document.getElementById('sentinel'));
loadPage();
</script>
</body>
</html>

View File

@@ -0,0 +1,664 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einladungen XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* Tabs */
.tabs-bar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-secondary);
margin-bottom: 1.5rem;
}
.tab-btn {
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
padding: 0.6rem 1.25rem;
font-size: 0.92rem;
font-weight: 600;
color: var(--color-muted);
cursor: pointer;
width: auto;
border-radius: 0;
transition: color 0.15s, border-color 0.15s;
}
.tab-btn:hover { color: var(--color-text); background: none; }
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* 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;
}
.inv-list { display: flex; flex-direction: column; gap: 0.5rem; }
.inv-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
display: flex; align-items: center; gap: 0.9rem;
padding: 0.75rem 1rem;
}
.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;
border: 1px solid rgba(255,255,255,0.08);
}
.inv-avatar img { width: 100%; height: 100%; object-fit: cover; }
.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; }
/* Lockee-Einladungs-Dialog */
.lockee-dialog-bg {
display: none; position: fixed; inset: 0; z-index: 400;
align-items: center; justify-content: center;
}
.lockee-dialog-bg.open { display: flex; }
.lockee-dialog-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.55); }
.lockee-dialog-box {
position: relative; background: var(--color-card);
border: 1px solid var(--color-secondary); border-radius: 12px;
padding: 1.75rem 1.5rem 1.5rem; max-width: 420px; width: 92%; z-index: 1;
display: flex; flex-direction: column; gap: 1rem;
max-height: 90vh; overflow-y: auto;
}
.lockee-dialog-header { display: flex; align-items: center; gap: 0.75rem; }
.lockee-dialog-avatar {
width: 48px; height: 48px; border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.3rem; flex-shrink: 0; overflow: hidden;
border: 1px solid rgba(255,255,255,0.08);
}
.lockee-dialog-avatar img { width: 100%; height: 100%; object-fit: cover; }
.lockee-dialog-title { font-weight: 700; font-size: 1rem; }
.lockee-dialog-sub { font-size: 0.82rem; color: var(--color-muted); margin-top: 0.1rem; }
.lockee-dialog-detail {
background: var(--color-secondary); border-radius: 8px;
padding: 0.75rem 1rem; font-size: 0.88rem;
}
.lockee-dialog-detail dt { color: var(--color-muted); font-size: 0.75rem; margin-bottom: 0.1rem; }
.lockee-dialog-detail dd { font-weight: 600; margin: 0 0 0.5rem 0; }
.lockee-dialog-detail dd:last-child { margin-bottom: 0; }
.lockee-dialog-codelines { display: flex; align-items: center; gap: 0.6rem; }
.lockee-dialog-codelines label { font-size: 0.88rem; font-weight: 600; white-space: nowrap; }
.lockee-dialog-codelines input { width: 72px; text-align: center; }
.lockee-dialog-codelines span { font-size: 0.88rem; color: var(--color-muted); }
.lockee-dialog-actions { display: flex; gap: 0.6rem; justify-content: flex-end; }
.lockee-dialog-actions button { width: auto; padding: 0.6rem 1.3rem; font-size: 0.9rem; }
.btn-accept { background: var(--color-success, #27ae60) !important; }
.btn-accept:hover { background: #219150 !important; }
.btn-decline { background: #c0392b !important; }
.btn-decline:hover { background: #a93226 !important; }
.lockee-dialog-error { color: #e74c3c; font-size: 0.82rem; display: none; }
/* Lock-Details im Dialog */
.lock-details-section { display: flex; flex-direction: column; gap: 0.5rem; }
.lock-details-cards {
display: grid; grid-template-columns: repeat(auto-fill, minmax(68px, 1fr)); gap: 0.4rem;
}
.lock-details-card-item {
background: var(--color-secondary); border-radius: 6px;
padding: 0.4rem 0.3rem;
display: flex; flex-direction: column; align-items: center; gap: 0.2rem; text-align: center;
}
.lock-details-card-item img { width: 36px; height: auto; border-radius: 3px; }
.lock-details-card-item .ldc-count { font-weight: 700; font-size: 0.9rem; }
.lock-details-card-item .ldc-name { font-size: 0.65rem; color: var(--color-muted); line-height: 1.2; }
.lock-details-meta { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.lock-details-badge {
background: var(--color-secondary); border-radius: 20px;
padding: 0.2rem 0.6rem; font-size: 0.75rem; color: var(--color-muted);
}
.blind-hint {
background: var(--color-secondary); border-radius: 8px; padding: 0.9rem 1rem;
display: flex; gap: 0.6rem; align-items: flex-start;
font-size: 0.85rem; color: var(--color-muted); line-height: 1.5;
}
.blind-hint-icon { font-size: 1.4rem; flex-shrink: 0; }
/* Entsperrcode-Modal */
.unlock-modal-bg {
display: none; position: fixed; inset: 0; z-index: 500;
align-items: center; justify-content: center;
}
.unlock-modal-bg.open { display: flex; }
.unlock-modal-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); }
.unlock-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%; z-index: 1;
display: flex; flex-direction: column; align-items: center; gap: 0.75rem; text-align: center;
}
.unlock-code-display {
font-family: monospace; font-size: 2rem; letter-spacing: 0.3em;
background: var(--color-secondary); border-radius: 8px;
padding: 1rem 1.5rem; color: var(--color-primary);
line-height: 1.8; word-break: break-all; width: 100%; box-sizing: border-box;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom:1.25rem;">Einladungen</h1>
<div class="tabs-bar">
<button class="tab-btn active" data-tab="empfangen" onclick="switchTab('empfangen')">Empfangen</button>
<button class="tab-btn" data-tab="gesendet" onclick="switchTab('gesendet')">Gesendet</button>
</div>
<!-- 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>
<!-- 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>
</div>
</div>
<!-- Lockee-Einladungs-Dialog -->
<div class="lockee-dialog-bg" id="lockeeInviteDialog">
<div class="lockee-dialog-overlay" onclick="closeLockeeInviteDialog()"></div>
<div class="lockee-dialog-box">
<div class="lockee-dialog-header">
<div class="lockee-dialog-avatar" id="dialogAvatar">🔒</div>
<div>
<div class="lockee-dialog-title" id="dialogTitle"></div>
<div class="lockee-dialog-sub" id="dialogSub"></div>
</div>
</div>
<dl class="lockee-dialog-detail" id="dialogDetail"></dl>
<div id="dialogDetailsArea"></div>
<div>
<div class="lockee-dialog-codelines">
<label for="dialogCodeLines">Ziffern des Entsperrcodes:</label>
<input type="number" id="dialogCodeLines" min="1" max="20" value="5">
<span>Ziffern</span>
</div>
</div>
<div class="lockee-dialog-error" id="dialogError"></div>
<div class="lockee-dialog-actions">
<button class="btn-decline" onclick="declineLockeeInviteDialog()">✕ Ablehnen</button>
<button class="btn-accept" onclick="acceptLockeeInviteDialog()">✓ Annehmen</button>
</div>
</div>
</div>
<!-- Entsperrcode-Modal -->
<div class="unlock-modal-bg" id="unlockModal">
<div class="unlock-modal-overlay"></div>
<div class="unlock-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;margin:0;">
Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem.
</p>
<div class="unlock-code-display" id="unlockCodeDisplay"></div>
<div id="unlockModalCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);font-family:monospace;"></div>
<button id="unlockModalBtn" style="width:100%;margin-top:0.25rem;">Weiter</button>
</div>
</div>
<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; }
// ── Tabs ──
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
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() {
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>
<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);
});
} catch(e) { console.error(e); }
}
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; }
} 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; }
} catch(e) { btn.disabled = false; }
}
// ── Gesendet: Lockee-Einladungen ──
async function loadSentLockeeInvitations() {
try {
const res = await fetch('/lockee/invitations/sent');
if (!res.ok) return;
const invs = await res.json();
const grid = document.getElementById('sentLockeeGrid');
const empty = document.getElementById('sentLockeeEmpty');
grid.innerHTML = '';
if (invs.length === 0) { empty.style.display = ''; return; }
empty.style.display = 'none';
invs.forEach(inv => {
const av = inv.lockeeProfilePic
? `<div class="inv-avatar"><img src="data:image/jpeg;base64,${inv.lockeeProfilePic}" alt=""></div>`
: `<div class="inv-avatar">👤</div>`;
const dt = new Date(inv.createdAt);
const createdStr = dt.toLocaleDateString('de-DE') + ', ' + dt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr';
const badge = inv.detailsVisible
? '<span style="font-size:0.72rem;">👁 Details sichtbar</span>'
: '<span style="font-size:0.72rem;">🙈 Details verborgen</span>';
const card = document.createElement('div');
card.className = 'inv-card';
card.id = 'sentlockeeinv-' + inv.token;
card.innerHTML = `
${av}
<div class="inv-body">
<div class="inv-line1">${esc(inv.lockeeName)}</div>
<div class="inv-line2">${esc(inv.lockName)}</div>
<div class="inv-line3">Gesendet am ${createdStr} &nbsp;${badge}</div>
</div>
<div style="flex-shrink:0;">
<button onclick="cancelSentLockeeInvitation('${esc(inv.token)}', this)" style="margin:0;padding:0.45rem 1rem;font-size:0.85rem;width:auto;background:#c0392b;border:none;color:#fff;border-radius:6px;font-weight:600;cursor:pointer;">✕ Zurückziehen</button>
</div>`;
grid.appendChild(card);
});
} catch(e) { console.error(e); }
}
async function cancelSentLockeeInvitation(token, btn) {
if (!confirm('Einladung zurückziehen? Das Lock wird gelöscht und der Lockee wird benachrichtigt.')) return;
btn.disabled = true;
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; }
} 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' },
];
function fmtMinutes(min) {
if (!min) return '';
const d = Math.floor(min / (24 * 60));
const h = Math.floor((min % (24 * 60)) / 60);
const m = min % 60;
const parts = [];
if (d) parts.push(d + 'd');
if (h) parts.push(h + 'h');
if (m) parts.push(m + 'min');
return parts.join(' ') || '';
}
function renderLockDetails(inv) {
if (!inv.detailsVisible) {
return `<div class="blind-hint">
<span class="blind-hint-icon">🙈</span>
<span>Der Keyholder hat die Lock-Details nicht freigegeben. Du weißt nicht, worauf du dich einlässt.</span>
</div>`;
}
const cardCounts = inv.cardCounts || {};
const totalCards = Object.values(cardCounts).reduce((a, b) => a + b, 0);
const cardsHtml = CARD_DEFS_DIALOG
.filter(c => cardCounts[c.id] > 0)
.map(c => `<div class="lock-details-card-item">
<img src="${c.img}" alt="${c.name}">
<span class="ldc-count">${cardCounts[c.id]}×</span>
<span class="ldc-name">${c.name}</span>
</div>`).join('');
const badges = [];
badges.push(`🃏 ${totalCards} Karten`);
badges.push(`⏱ Ziehen alle ${fmtMinutes(inv.pickEveryMinute)}`);
if (inv.accumulatePicks) badges.push('📦 Picks akkumulieren');
if (inv.showRemainingCards) badges.push('👁 Karten sichtbar');
if (inv.hygineOpeningEveryMinites) badges.push(`🚿 Hygiene alle ${fmtMinutes(inv.hygineOpeningEveryMinites)} (${fmtMinutes(inv.hygineOpeningDurationMinutes)})`);
if (inv.taskCount > 0) badges.push(`${inv.taskCount} Aufgabe${inv.taskCount !== 1 ? 'n' : ''}`);
if (inv.requiresVerification) badges.push('🔍 Verifikation erforderlich');
return `<div class="lock-details-section">
<div class="lock-details-cards">${cardsHtml}</div>
<div class="lock-details-meta">${badges.map(b => `<span class="lock-details-badge">${b}</span>`).join('')}</div>
</div>`;
}
let activeDialogToken = null;
async function openLockeeInviteDialog(token) {
activeDialogToken = token;
document.getElementById('dialogError').style.display = 'none';
document.getElementById('dialogCodeLines').value = '5';
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 line1 = card?.querySelector('.inv-line1')?.textContent || '';
const line2 = card?.querySelector('.inv-line2')?.textContent || '';
const line3 = card?.querySelector('.inv-line3')?.textContent || '';
const imgEl = card?.querySelector('.inv-avatar img');
const avatarEl = document.getElementById('dialogAvatar');
avatarEl.innerHTML = imgEl ? `<img src="${imgEl.src}" alt="">` : '👤';
document.getElementById('dialogTitle').textContent = line2;
document.getElementById('dialogSub').textContent = line1 + ' lädt dich als Lockee ein';
document.getElementById('dialogDetail').innerHTML =
`<dt>Keyholder</dt><dd>${esc(line1)}</dd>` +
`<dt>Lock-Name</dt><dd>${esc(line2)}</dd>` +
`<dt>Datum</dt><dd>${esc(line3)}</dd>`;
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token));
if (res.ok) {
document.getElementById('dialogDetailsArea').innerHTML = renderLockDetails(await res.json());
} else {
document.getElementById('dialogDetailsArea').innerHTML = '';
}
} catch(e) { document.getElementById('dialogDetailsArea').innerHTML = ''; }
}
function closeLockeeInviteDialog() {
document.getElementById('lockeeInviteDialog').classList.remove('open');
activeDialogToken = null;
}
async function acceptLockeeInviteDialog() {
if (!activeDialogToken) return;
const lines = parseInt(document.getElementById('dialogCodeLines').value);
if (!lines || lines < 1) { showDialogError('Bitte eine Ziffernanzahl eingeben.'); return; }
const acceptBtn = document.querySelector('.btn-accept');
acceptBtn.disabled = true;
document.getElementById('dialogError').style.display = 'none';
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(activeDialogToken) + '/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ unlockCodeLines: lines })
});
if (!res.ok) {
acceptBtn.disabled = false;
if (res.status === 409) {
const data = await res.json().catch(() => ({}));
showDialogError(data.error === 'active_lock_exists'
? 'Du hast bereits ein aktives Lock als Lockee. Erst das bestehende Lock beenden, bevor ein neues angenommen werden kann.'
: 'Diese Einladung wurde bereits angenommen.');
} else {
showDialogError('Fehler beim Annehmen der Einladung.');
}
return;
}
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 = '';
showUnlockCodeModal(data.unlockCode, data.lockId);
} catch(e) {
acceptBtn.disabled = false;
showDialogError('Fehler beim Annehmen der Einladung.');
}
}
async function declineLockeeInviteDialog() {
if (!activeDialogToken) return;
if (!confirm('Bist du sicher, dass du diese Einladung ablehnen möchtest? Das Lock wird gelöscht.')) return;
const declineBtn = document.querySelector('.btn-decline');
declineBtn.disabled = true;
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 = '';
closeLockeeInviteDialog();
} else {
declineBtn.disabled = false;
showDialogError('Fehler beim Ablehnen der Einladung.');
}
} catch(e) { declineBtn.disabled = false; showDialogError('Fehler beim Ablehnen der Einladung.'); }
}
function showDialogError(msg) {
const el = document.getElementById('dialogError');
el.textContent = msg;
el.style.display = '';
}
// ── Entsperrcode-Modal ──
function showUnlockCodeModal(code, lockId) {
document.getElementById('unlockCodeDisplay').textContent = code;
const url = '/sessionchastityingame.html?lockId=' + lockId;
const btn = document.getElementById('unlockModalBtn');
btn.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;
function updateCountdown() {
const m = Math.floor(remaining / 60);
const s = remaining % 60;
countdown.textContent = `${m}:${String(s).padStart(2, '0')}`;
}
updateCountdown();
const scrambleInterval = setInterval(() => { if (!stopped) display.textContent = randomCode(); }, 1000);
const countdownInterval = setInterval(() => {
if (stopped) return;
remaining--;
updateCountdown();
if (remaining <= 0) finish();
}, 1000);
}
// ── Alles laden ──
loadLockeeInvitations();
loadKeyholderInvitations();
loadSentLockeeInvitations();
loadSentKeyholderInvitations();
</script>
</body>
</html>

View File

@@ -0,0 +1,306 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lock-Einladung XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.invite-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem 1.5rem;
max-width: 480px;
margin: 0 auto;
text-align: center;
}
.invite-icon { font-size: 3rem; margin-bottom: 1rem; }
.invite-title { font-size: 1.3rem; font-weight: 700; margin-bottom: 0.4rem; }
.invite-sub { color: var(--color-muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
.invite-detail {
background: var(--color-secondary);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
text-align: left;
font-size: 0.9rem;
}
.invite-detail dt { color: var(--color-muted); font-size: 0.78rem; margin-bottom: 0.1rem; }
.invite-detail dd { font-weight: 600; margin: 0 0 0.5rem 0; }
.invite-detail dd:last-child { margin-bottom: 0; }
.invite-actions { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; }
.invite-actions button { width: auto; padding: 0.65rem 1.5rem; }
.btn-danger { background: #c0392b !important; }
.btn-danger:hover { background: #a93226 !important; }
.code-lines-row {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
margin-bottom: 1.25rem;
}
.code-lines-row input { width: 80px; text-align: center; }
.code-lines-row span { color: var(--color-text); font-size: 0.9rem; }
/* Unlock-Code-Modal */
.unlock-modal-bg {
display: none;
position: fixed;
inset: 0;
z-index: 400;
align-items: center;
justify-content: center;
}
.unlock-modal-bg.open { display: flex; }
.unlock-modal-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.55);
}
.unlock-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;
text-align: center;
}
.unlock-code-display {
font-family: monospace;
font-size: 2rem;
letter-spacing: 0.3em;
background: var(--color-secondary);
border-radius: 8px;
padding: 1rem 1.5rem;
color: var(--color-primary);
line-height: 1.8;
word-break: break-all;
width: 100%;
box-sizing: border-box;
}
#stateLoading { display: none; }
#stateError { display: none; }
#stateAlready { display: none; }
#stateDeclined { display: none; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div id="stateLoading" style="text-align:center;padding:3rem 1rem;color:var(--color-muted);">Lade Einladung…</div>
<div id="stateError" style="text-align:center;padding:3rem 1rem;">
<div style="font-size:2.5rem;margin-bottom:1rem;">⚠️</div>
<h2 style="margin-bottom:0.5rem;">Einladung nicht gefunden</h2>
<p style="color:var(--color-muted);">Diese Einladung existiert nicht oder wurde bereits bearbeitet.</p>
<a href="/einladungen.html" style="display:inline-block;margin-top:1.5rem;padding:0.65rem 1.5rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Zu meinen Einladungen</a>
</div>
<div id="stateAlready" style="text-align:center;padding:3rem 1rem;">
<div style="font-size:2.5rem;margin-bottom:1rem;">🔒</div>
<h2 style="margin-bottom:0.5rem;">Lock bereits aktiv</h2>
<p style="color:var(--color-muted);">Diese Einladung wurde bereits angenommen.</p>
</div>
<div id="stateDeclined" style="text-align:center;padding:3rem 1rem;">
<div style="font-size:2.5rem;margin-bottom:1rem;"></div>
<h2 style="margin-bottom:0.5rem;">Einladung abgelehnt</h2>
<p style="color:var(--color-muted);">Du hast die Einladung abgelehnt. Der Keyholder wurde benachrichtigt.</p>
<a href="/einladungen.html" style="display:inline-block;margin-top:1.5rem;padding:0.65rem 1.5rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">Zu meinen Einladungen</a>
</div>
<div id="stateInvite" style="display:none;">
<div class="invite-card">
<div class="invite-icon">🔒</div>
<div class="invite-title">Lock-Einladung</div>
<div class="invite-sub" id="invSubtitle"></div>
<dl class="invite-detail" id="invDetail"></dl>
<div id="acceptSection">
<p style="font-size:0.88rem;color:var(--color-muted);margin-bottom:0.75rem;">
Wie viele Ziffern soll dein Entsperrcode haben?
</p>
<div class="code-lines-row">
<input type="number" id="codeLines" min="1" max="20" value="5">
<span>Ziffern</span>
</div>
<div class="invite-actions">
<button class="btn-danger" onclick="declineInvitation()">✕ Ablehnen</button>
<button onclick="acceptInvitation()">✓ Annehmen</button>
</div>
</div>
<div id="errorMsg" style="color:#e74c3c;font-size:0.85rem;margin-top:0.5rem;display:none;"></div>
</div>
</div>
</div>
</div>
<!-- Unlock-Code-Modal -->
<div class="unlock-modal-bg" id="unlockModal">
<div class="unlock-modal-overlay"></div>
<div class="unlock-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;margin:0;">
Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem.
</p>
<div class="unlock-code-display" id="unlockCodeDisplay"></div>
<div id="unlockModalCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);font-family:monospace;"></div>
<button id="unlockModalBtn" style="width:100%;margin-top:0.25rem;">Weiter</button>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
let lockId = null;
document.getElementById('stateLoading').style.display = '';
async function load() {
if (!token) { showState('stateError'); return; }
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token));
if (res.status === 409) { showState('stateAlready'); return; }
if (!res.ok) { showState('stateError'); return; }
const inv = await res.json();
lockId = inv.lockId;
document.getElementById('invSubtitle').textContent =
inv.keyholderName + ' hat dich als Lockee eingeladen';
const dl = document.getElementById('invDetail');
dl.innerHTML = `
<dt>Lock-Name</dt><dd>${esc(inv.lockName)}</dd>
<dt>Keyholder</dt><dd>${esc(inv.keyholderName)}</dd>`;
showState('stateInvite');
} catch(e) {
showState('stateError');
}
}
function showState(id) {
document.getElementById('stateLoading').style.display = 'none';
['stateError','stateAlready','stateInvite','stateDeclined'].forEach(s => {
document.getElementById(s).style.display = s === id ? '' : 'none';
});
}
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
async function acceptInvitation() {
const lines = parseInt(document.getElementById('codeLines').value);
if (!lines || lines < 1) { showError('Bitte eine Ziffernanzahl eingeben.'); return; }
const btn = document.querySelector('#acceptSection button:last-child');
btn.disabled = true;
document.getElementById('errorMsg').style.display = 'none';
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token) + '/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ unlockCodeLines: lines })
});
if (!res.ok) { btn.disabled = false; showError('Fehler beim Annehmen der Einladung.'); return; }
const data = await res.json();
showUnlockCodeModal(data.unlockCode, data.lockId);
} catch(e) {
btn.disabled = false;
showError('Fehler beim Annehmen der Einladung.');
}
}
async function declineInvitation() {
if (!confirm('Bist du sicher, dass du diese Einladung ablehnen möchtest? Das Lock wird gelöscht.')) return;
const btn = document.querySelector('.btn-danger');
btn.disabled = true;
try {
const res = await fetch('/lockee/invitation/' + encodeURIComponent(token), { method: 'DELETE' });
if (res.ok || res.status === 204) {
showState('stateDeclined');
} else {
btn.disabled = false;
showError('Fehler beim Ablehnen der Einladung.');
}
} catch(e) {
btn.disabled = false;
showError('Fehler beim Ablehnen der Einladung.');
}
}
function showError(msg) {
const el = document.getElementById('errorMsg');
el.textContent = msg;
el.style.display = '';
}
function showUnlockCodeModal(code, lid) {
document.getElementById('unlockCodeDisplay').textContent = code;
const url = '/sessionchastityingame.html?lockId=' + lid;
const btn = document.getElementById('unlockModalBtn');
btn.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;
function updateCountdown() {
const m = Math.floor(remaining / 60);
const s = remaining % 60;
countdown.textContent = `${m}:${String(s).padStart(2, '0')}`;
}
updateCountdown();
const scrambleInterval = setInterval(() => { if (!stopped) display.textContent = randomCode(); }, 1000);
const countdownInterval = setInterval(() => {
if (stopped) return;
remaining--;
updateCountdown();
if (remaining <= 0) finish();
}, 1000);
}
load();
</script>
</body>
</html>

View File

@@ -0,0 +1,69 @@
/**
* Gemeinsame Kartenanzeige für Chastity Game.
* Exportiert: CARD_LABELS, cardTypeGridHtml(cardCounts)
*/
(function () {
const style = document.createElement('style');
style.textContent = `
.card-type-grid {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: 0.4rem;
}
.card-type-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
width: calc((100% - 6 * 0.6rem) / 14);
min-width: 28px;
}
.card-type-item img {
width: 100%;
height: auto;
border-radius: 4px;
display: block;
}
.card-type-badge {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
line-height: 1.2;
}
`;
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, … }
* @returns {string} HTML-String
*/
function cardTypeGridHtml(cardCounts) {
if (!cardCounts || Object.keys(cardCounts).length === 0) {
return '<span style="color:var(--color-muted);font-size:0.85rem;">Keine Karten mehr im Stapel.</span>';
}
const items = Object.entries(cardCounts)
.filter(([, n]) => n > 0)
.map(([type, n]) => {
const info = CARD_LABELS[type] || { img: '/img/card.png', name: type };
return `<div class="card-type-item">
<img src="${info.img}" alt="${info.name}">
<span class="card-type-badge">${n}</span>
</div>`;
}).join('');
return items
? `<div class="card-type-grid">${items}</div>`
: '<span style="color:var(--color-muted);font-size:0.85rem;">Keine Karten mehr im Stapel.</span>';
}

View File

@@ -27,8 +27,11 @@
icon: '⊗',
items: [
{ href: '/infochastity.html', icon: '', label: 'Info' },
{ href: '/sessionchastity.html', icon: '▷', label: 'Neue Session', id: 'navChastityNeu' },
{ href: '#', icon: '▶', label: 'Aktive Session', id: 'navChastityAktiv' },
{ href: '/sessionchastity.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' },
]
},
];
@@ -96,7 +99,6 @@
// "Im Spiel" standardmäßig ausblenden; wird nach Session-Check ggf. wieder eingeblendet
const navNeu = document.getElementById('navBdsmNeu');
const navImSpiel = document.getElementById('navBdsmImSpiel');
const navCNeu = document.getElementById('navChastityNeu');
const navCAktiv = document.getElementById('navChastityAktiv');
if (navImSpiel) navImSpiel.style.display = 'none';
if (navCAktiv) navCAktiv.style.display = 'none';
@@ -121,7 +123,6 @@
if (lockRes.ok) {
const lockData = await lockRes.json();
const lockId = lockData.lockId;
if (navCNeu) navCNeu.style.display = 'none';
if (navCAktiv) {
navCAktiv.style.display = '';
navCAktiv.querySelector('a').href = '/sessionchastityingame.html?lockId=' + lockId;

View File

@@ -9,7 +9,8 @@
{ 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: '/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"' : '';
@@ -116,4 +117,11 @@
fetch('/gruppen/reports/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0)
]).then(([joins, reports]) => setBadge(['socialGruppenBadge', 'socialMobileGruppenBadge'], joins + reports))
.catch(() => {});
Promise.all([
fetch('/keyholder/invitations/mine').then(r => r.ok ? r.json() : []).catch(() => []),
fetch('/lockee/invitations/mine').then(r => r.ok ? r.json() : []).catch(() => [])
]).then(([khInvs, lockeeInvs]) =>
setBadge(['socialInvBadge', 'socialMobileInvBadge'], khInvs.length + lockeeInvs.length)
).catch(() => {});
})();

View File

@@ -0,0 +1,494 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keyholder XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.lock-list { display:flex; flex-direction:column; gap:0.5rem; margin-top:0.5rem; }
/* Karte = Container */
.lock-card {
background:var(--color-card);
border:1px solid var(--color-secondary);
border-radius:10px;
overflow:hidden;
transition:border-color 0.15s;
}
.lock-card.open { border-color:var(--color-primary); }
/* Header-Zeile (klickbar) */
.lock-card-header {
display:flex; align-items:center; gap:0.9rem;
padding:0.75rem 1rem;
cursor:pointer; user-select:none;
}
.lock-card-header:hover { background:rgba(255,255,255,0.03); }
.lock-card-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;
border:1px solid rgba(255,255,255,0.08);
}
.lock-card-avatar img { width:100%; height:100%; object-fit:cover; }
.lock-card-body { flex:1; min-width:0; display:flex; flex-direction:column; gap:0.15rem; }
.lock-card-line1 { font-size:0.78rem; color:var(--color-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lock-card-line2 { font-weight:700; font-size:0.95rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lock-card-line3 { font-size:0.78rem; color:var(--color-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lock-card-actions { display:flex; gap:0.5rem; flex-shrink:0; }
.lock-card-actions button, .lock-card-actions a.btn {
margin-top:0; padding:0.3rem 0.7rem; font-size:0.8rem; width:auto;
}
.lock-toggle { font-size:0.75rem; color:var(--color-muted); flex-shrink:0; transition:transform 0.2s; }
.lock-card.open .lock-toggle { transform:rotate(90deg); }
/* Ausgeklappter Bereich */
.lock-detail-body {
display:none;
border-top:1px solid var(--color-secondary);
padding:1rem 1rem 0.85rem;
}
.lock-card.open .lock-detail-body { display:block; }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
.detail-section { margin-bottom:1.1rem; }
.detail-section-title {
font-size:0.72rem; font-weight:700; color:var(--color-primary);
text-transform:uppercase; letter-spacing:0.06em; margin-bottom:0.4rem;
}
.detail-row { display:flex; justify-content:space-between; align-items:baseline; gap:0.5rem; padding:0.2rem 0; }
.detail-label { font-size:0.85rem; color:var(--color-muted); }
.detail-value { font-size:0.9rem; font-weight:600; text-align:right; }
.detail-value.ok { color:var(--color-success); }
.detail-value.warn { color:#e67e22; }
.detail-value.danger { color:var(--color-primary); }
.violation-item {
font-size:0.82rem; color:var(--color-muted);
padding:0.25rem 0; border-bottom:1px solid var(--color-secondary);
}
.violation-item:last-child { border-bottom:none; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom:1.25rem;">Keyholder</h1>
<!-- Meine Lockees -->
<div class="lock-list" id="locksGrid"></div>
<p class="empty-hint" id="locksEmpty" style="display:none;">Du bist aktuell bei keinem Lock als Keyholder eingetragen.</p>
</div>
</div>
<!-- Verifikations-Prüfen-Modal -->
<div id="verificationVoteModal" 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:420px;width:90%;max-height:85vh;overflow-y:auto;display:flex;flex-direction:column;gap:1rem;">
<h3 style="margin:0;font-size:1.05rem;">Verifikation prüfen</h3>
<img id="verificationVoteImg" src="" alt="Verifikationsbild" style="width:100%;border-radius:8px;display:block;">
<div style="display:flex;gap:0.5rem;">
<button id="verificationVoteBtnDown" onclick="submitVerificationVote(false)"
style="flex:1;background:rgba(231,76,60,0.12);border:1px solid rgba(231,76,60,0.35);color:#e74c3c;padding:0.55rem 0;border-radius:7px;cursor:pointer;font-size:0.9rem;font-weight:600;width:auto;">
👎 Ablehnen
</button>
<button id="verificationVoteBtnUp" onclick="submitVerificationVote(true)"
style="flex:1;background:rgba(46,204,113,0.15);border:1px solid rgba(46,204,113,0.4);color:#2ecc71;padding:0.55rem 0;border-radius:7px;cursor:pointer;font-size:0.9rem;font-weight:600;width:auto;">
👍 Bestätigen
</button>
</div>
<button onclick="closeVerificationModal()"
style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);padding:0.45rem 1rem;border-radius:7px;cursor:pointer;font-size:0.88rem;width:auto;align-self:flex-end;">
Abbrechen
</button>
</div>
</div>
<!-- Karten-Bearbeiten-Modal -->
<div id="cardEditModal" 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:90%;max-height:85vh;overflow-y:auto;display:flex;flex-direction:column;gap:1rem;">
<h3 id="cardEditTitle" style="margin:0;font-size:1.05rem;"></h3>
<div id="cardEditInputs" style="display:flex;flex-direction:column;gap:0.35rem;"></div>
<label style="display:flex;align-items:flex-start;gap:0.6rem;font-size:0.85rem;color:var(--color-muted);cursor:pointer;line-height:1.5;">
<input type="checkbox" id="cardEditNotifyDetailed" checked style="margin-top:3px;width:auto;flex-shrink:0;">
<span>Detailierte Beschreibung der Änderung mitteilen</span>
</label>
<div id="cardEditError" style="display:none;font-size:0.85rem;color:#e74c3c;"></div>
<div style="display:flex;gap:0.6rem;justify-content:flex-end;margin-top:0.25rem;">
<button onclick="closeCardModal()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);padding:0.5rem 1.1rem;border-radius:7px;cursor:pointer;font-size:0.88rem;width:auto;">Abbrechen</button>
<button id="cardEditSubmit" onclick="submitCardEdit()" style="background:var(--color-primary);border:none;color:#fff;padding:0.5rem 1.1rem;border-radius:7px;cursor:pointer;font-size:0.88rem;font-weight:600;width:auto;">Bestätigen</button>
</div>
</div>
</div>
<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 lockDetailCache = {};
// ── Meine Locks als Keyholder ──
async function loadLocks() {
try {
const res = await fetch('/keyholder/as-keyholder');
if (!res.ok) return;
const locks = await res.json();
const grid = document.getElementById('locksGrid');
grid.innerHTML = '';
const empty = document.getElementById('locksEmpty');
if (locks.length === 0) { empty.style.display = ''; return; }
empty.style.display = 'none';
locks.forEach(l => {
const av = l.lockeeProfilePic
? `<div class="lock-card-avatar"><img src="data:image/jpeg;base64,${l.lockeeProfilePic}" alt=""></div>`
: `<div class="lock-card-avatar">👤</div>`;
const startDate = l.startTime ? new Date(l.startTime).toLocaleDateString('de-DE') : '';
const card = document.createElement('div');
card.className = 'lock-card';
card.dataset.lockId = l.lockId;
card.innerHTML = `
<div class="lock-card-header">
${av}
<div class="lock-card-body">
<div class="lock-card-line1">${esc(l.lockeeName)}</div>
<div class="lock-card-line2">${esc(l.lockName)}</div>
<div class="lock-card-line3">🃏 ${l.totalCards} Karten · seit ${startDate}</div>
</div>
<span class="lock-toggle">▶</span>
</div>
<div class="lock-detail-body">Wird geladen…</div>`;
card.querySelector('.lock-card-header').addEventListener('click', () => toggleLock(card, l.lockId));
grid.appendChild(card);
});
} catch(e) { console.error(e); }
}
async function toggleLock(card, lockId) {
const isOpen = card.classList.contains('open');
// Alle anderen schließen
document.querySelectorAll('#locksGrid .lock-card.open').forEach(c => c.classList.remove('open'));
if (isOpen) return;
card.classList.add('open');
const body = card.querySelector('.lock-detail-body');
if (body.dataset.loaded) return;
try {
const res = await fetch('/keyholder/as-keyholder/' + lockId);
if (!res.ok) { body.textContent = 'Fehler beim Laden.'; return; }
const d = await res.json();
lockDetailCache[lockId] = d;
body.innerHTML = buildDetailHtml(d);
body.dataset.loaded = '1';
attachDetailListeners(body, lockId);
} catch(e) { body.textContent = 'Fehler beim Laden.'; }
}
function attachDetailListeners(body, lockId) {
const pruefenLink = body.querySelector('.prufen-link');
if (pruefenLink) {
pruefenLink.addEventListener('click', e => {
e.preventDefault();
openVerificationModal(lockId);
});
}
}
function buildDetailHtml(d) {
let html = `<div style="margin-bottom:0.75rem;">
<a href="/benutzer.html?userId=${d.lockeeId}" style="font-size:0.82rem;color:var(--color-primary);">Profil ansehen →</a>
</div>`;
// Karten
html += `<div class="detail-section">
<div class="detail-section-title">Verbleibende Karten (${d.totalCards})</div>`;
html += cardTypeGridHtml(d.cardCounts || {});
html += `<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
<button onclick="openCardModal('${d.lockId}', 'add')" style="background:rgba(46,204,113,0.15);border:1px solid rgba(46,204,113,0.4);color:#2ecc71;padding:0.4rem 0.9rem;border-radius:7px;cursor:pointer;font-size:0.82rem;font-weight:600;width:auto;"> Hinzufügen</button>
<button onclick="openCardModal('${d.lockId}', 'remove')" style="background:rgba(231,76,60,0.12);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;"> Entfernen</button>
</div>`;
if (d.openPicks > 0) {
html += `<div class="detail-row" style="margin-top:0.35rem;">
<span class="detail-label">Offene Züge</span>
<span class="detail-value warn">${d.openPicks}</span>
</div>`;
}
if (d.nextCardIn) {
const secUntil = Math.max(0, Math.round((new Date(d.nextCardIn) - Date.now()) / 1000));
html += `<div class="detail-row">
<span class="detail-label">Nächste Karte in</span>
<span class="detail-value">${fmtDuration(secUntil)}</span>
</div>`;
}
html += `</div>`;
// Eingefroren
if (d.frozenUntill) {
const frozenUntil = new Date(d.frozenUntill);
if (frozenUntil > new Date()) {
html += `<div class="detail-section">
<div class="detail-section-title">Status</div>
<div class="detail-row">
<span class="detail-label">Eingefroren bis</span>
<span class="detail-value danger">${frozenUntil.toLocaleString('de-DE')}</span>
</div>
</div>`;
}
}
// Aktuelle Aufgabe
if (d.currentTask) {
html += `<div class="detail-section">
<div class="detail-section-title">Aktuelle Aufgabe</div>
<div style="font-size:0.9rem;line-height:1.5;">${esc(d.currentTask)}</div>
</div>`;
}
// Hygiene
if (d.hygieneEnabled) {
html += `<div class="detail-section">
<div class="detail-section-title">Hygiene-Öffnung</div>`;
if (d.hygieneOpeningActive) {
html += `<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value warn">Öffnung aktiv</span></div>`;
} else if (d.hygieneOpeningDue) {
html += `<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value ok">Verfügbar</span></div>`;
} else {
html += `<div class="detail-row">
<span class="detail-label">Verfügbar in</span>
<span class="detail-value">${fmtDuration(d.hygieneSecondsRemaining)}</span>
</div>`;
}
html += `</div>`;
}
// Verifikation
if (d.requiresVerification) {
html += `<div class="detail-section">
<div class="detail-section-title">Verifikation heute</div>`;
if (!d.verificationDoneToday) {
html += `<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value danger">Warte auf Verifikation</span></div>`;
} else if (!d.verificationMyVote) {
html += `<div class="detail-row">
<span class="detail-label">Status</span>
<span class="detail-value warn">Verifikation ausstehend &nbsp;<a href="#" class="prufen-link" style="font-size:0.82rem;color:var(--color-primary);font-weight:600;">Prüfen →</a></span>
</div>`;
} else if (d.verificationMyVote === 'upvote') {
html += `<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value ok">✓ Erledigt</span></div>`;
} else {
html += `<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value danger">✗ Abgelehnt</span></div>`;
}
html += `</div>`;
}
// Hygiene-Verletzungen
if (d.hygieneViolations && d.hygieneViolations.length > 0) {
html += `<div class="detail-section">
<div class="detail-section-title">Hygiene-Verletzungen (letzte ${d.hygieneViolations.length})</div>`;
d.hygieneViolations.forEach(v => {
const dt = new Date(v.time).toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
html += `<div class="violation-item">
${dt} · <span style="color:var(--color-primary);font-weight:600;">+${v.overtimeMinutes} Min. Überschreitung</span>
</div>`;
});
html += `</div>`;
}
// Gestartet am
if (d.startTime) {
html += `<div style="font-size:0.78rem;color:var(--color-muted);margin-top:0.5rem;">
Gestartet am ${new Date(d.startTime).toLocaleDateString('de-DE')}
</div>`;
}
return html;
}
// ── Hilfsfunktionen ──
function fmtDuration(seconds) {
if (seconds <= 0) return 'Jetzt';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
// ── Verifikation prüfen ──
let verificationVoteLockId = null;
function openVerificationModal(lockId) {
const d = lockDetailCache[lockId];
if (!d || !d.verificationImage) return;
verificationVoteLockId = lockId;
document.getElementById('verificationVoteImg').src = 'data:image/jpeg;base64,' + d.verificationImage;
document.getElementById('verificationVoteBtnUp').disabled = false;
document.getElementById('verificationVoteBtnDown').disabled = false;
document.getElementById('verificationVoteModal').style.display = 'flex';
}
function closeVerificationModal() {
document.getElementById('verificationVoteModal').style.display = 'none';
verificationVoteLockId = null;
}
async function submitVerificationVote(upvote) {
const lockId = verificationVoteLockId;
const d = lockDetailCache[lockId];
if (!d?.verificationTodayId) return;
const btnUp = document.getElementById('verificationVoteBtnUp');
const btnDown = document.getElementById('verificationVoteBtnDown');
btnUp.disabled = btnDown.disabled = true;
try {
const res = await fetch('/verification/' + d.verificationTodayId + '/vote/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upvote })
});
if (res.ok || res.status === 202) {
closeVerificationModal();
const cardEl = document.querySelector(`[data-lock-id="${lockId}"]`);
if (cardEl) {
const body = cardEl.querySelector('.lock-detail-body');
const detailRes = await fetch('/keyholder/as-keyholder/' + lockId);
if (detailRes.ok) {
const updated = await detailRes.json();
lockDetailCache[lockId] = updated;
body.innerHTML = buildDetailHtml(updated);
body.dataset.loaded = '1';
attachDetailListeners(body, lockId);
}
}
} else {
btnUp.disabled = btnDown.disabled = false;
}
} catch(e) { btnUp.disabled = btnDown.disabled = false; }
}
document.getElementById('verificationVoteModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeVerificationModal();
});
// ── Karten hinzufügen / entfernen ──
let cardEditLockId = null;
let cardEditMode = null;
function openCardModal(lockId, mode) {
cardEditLockId = lockId;
cardEditMode = mode;
const d = lockDetailCache[lockId] || {};
const counts = d.cardCounts || {};
document.getElementById('cardEditTitle').textContent =
mode === 'add' ? 'Karten hinzufügen' : 'Karten entfernen';
document.getElementById('cardEditError').style.display = 'none';
document.getElementById('cardEditNotifyDetailed').checked = true;
document.getElementById('cardEditSubmit').disabled = false;
const inputs = document.getElementById('cardEditInputs');
inputs.innerHTML = '';
Object.entries(CARD_LABELS).forEach(([type, info]) => {
const current = counts[type] || 0;
if (mode === 'remove' && current === 0) return;
// Aufgabenkarten beim Hinzufügen nur anzeigen, wenn Aufgaben definiert sind
if (mode === 'add' && type === 'TASK' && !d.hasTasks) return;
// Grünkarten-Plausi: letzte grüne Karte darf nicht entfernt werden
const maxRemove = type === 'GREEN' ? Math.max(0, current - 1) : current;
const max = mode === 'add' ? 20 : maxRemove;
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:0.75rem;padding:0.3rem 0;';
row.innerHTML = `
<img src="${info.img}" alt="${info.name}" style="width:28px;height:auto;border-radius:4px;flex-shrink:0;">
<span style="flex:1;font-size:0.88rem;">${info.name}</span>
${mode === 'remove'
? `<span style="font-size:0.78rem;color:var(--color-muted);">${current} im Stapel${type === 'GREEN' && current === 1 ? ' · nicht entfernbar' : ''}</span>`
: ''}
<input type="number" min="0" max="${max}" value="0" data-type="${type}" ${max === 0 ? 'disabled' : ''}
style="width:58px;padding:0.3rem 0.4rem;border-radius:6px;border:1px solid var(--color-secondary);background:var(--color-secondary);color:var(--color-text);font-size:0.9rem;text-align:center;">`;
inputs.appendChild(row);
});
document.getElementById('cardEditModal').style.display = 'flex';
}
function closeCardModal() {
document.getElementById('cardEditModal').style.display = 'none';
cardEditLockId = null;
cardEditMode = null;
}
async function submitCardEdit() {
const btn = document.getElementById('cardEditSubmit');
btn.disabled = true;
document.getElementById('cardEditError').style.display = 'none';
const cards = {};
document.querySelectorAll('#cardEditInputs input[type=number]').forEach(inp => {
const v = parseInt(inp.value) || 0;
if (v > 0) cards[inp.dataset.type] = v;
});
if (Object.keys(cards).length === 0) { btn.disabled = false; closeCardModal(); return; }
const notifyDetailed = document.getElementById('cardEditNotifyDetailed').checked;
const lockId = cardEditLockId;
const mode = cardEditMode;
try {
const res = await fetch(`/keyholder/as-keyholder/${lockId}/cards/${mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cards, notifyDetailed })
});
if (res.ok || res.status === 204) {
closeCardModal();
// Detail neu laden
const cardEl = document.querySelector(`[data-lock-id="${lockId}"]`);
if (cardEl) {
const body = cardEl.querySelector('.lock-detail-body');
const detailRes = await fetch('/keyholder/as-keyholder/' + lockId);
if (detailRes.ok) {
const d = await detailRes.json();
lockDetailCache[lockId] = d;
body.innerHTML = buildDetailHtml(d);
body.dataset.loaded = '1';
attachDetailListeners(body, lockId);
}
}
} else {
const data = await res.json().catch(() => ({}));
const errEl = document.getElementById('cardEditError');
errEl.textContent = data.error || 'Fehler beim Speichern.';
errEl.style.display = '';
btn.disabled = false;
}
} catch(e) { btn.disabled = false; }
}
// Modal bei Klick außerhalb schließen
document.getElementById('cardEditModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeCardModal();
});
// Initial laden
loadLocks();
</script>
</body>
</html>

View File

@@ -0,0 +1,758 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meine Locks XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Liste ── */
.template-list { display:flex; flex-direction:column; gap:0.75rem; margin-top:0.5rem; }
.template-card {
background:var(--color-card); border:1px solid var(--color-secondary);
border-radius:10px; padding:1rem;
}
.template-card-header { display:flex; align-items:flex-start; justify-content:space-between; gap:0.75rem; margin-bottom:0.6rem; }
.template-name { font-weight:700; font-size:1rem; }
.template-meta { font-size:0.78rem; color:var(--color-muted); margin-top:0.2rem; line-height:1.5; }
.template-actions { display:flex; gap:0.4rem; flex-shrink:0; }
.template-actions button { margin:0; padding:0.3rem 0.75rem; font-size:0.82rem; width:auto; }
.empty-hint { color:var(--color-muted); font-size:0.9rem; margin-top:0.5rem; }
/* ── Modal ── */
.modal-backdrop {
display:none; position:fixed; inset:0;
background:rgba(0,0,0,0.65); z-index:400;
align-items:flex-start; overflow-y:auto; padding:2rem 0;
}
.modal-backdrop.open { display:flex; }
.modal-box {
background:var(--color-card); border:1px solid var(--color-secondary);
border-radius:14px; padding:1.5rem; box-sizing:border-box;
display:flex; flex-direction:column; gap:0;
}
/* ── Formular ── */
.form-section {
background:var(--color-secondary); border-radius:10px;
padding:1rem 1.1rem; margin-bottom:1rem;
}
.form-section-title {
font-size:0.75rem; font-weight:700; color:var(--color-muted);
text-transform:uppercase; letter-spacing:0.07em; margin-bottom:0.85rem;
}
.form-row { display:flex; flex-direction:column; gap:0.3rem; margin-bottom:0.75rem; }
.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"] { width:100%; box-sizing:border-box; }
.checkbox-row {
display:flex; align-items:center; gap:0.6rem;
margin-bottom:0.6rem; cursor:pointer;
}
.checkbox-row:last-child { margin-bottom:0; }
.checkbox-row input[type="checkbox"] { width:1.1rem; height:1.1rem; flex-shrink:0; accent-color:var(--color-primary); }
.checkbox-row label { font-size:0.9rem; color:var(--color-text); cursor:pointer; user-select:none; }
/* ── Karten-Grid ── */
.cards-grid {
display:grid;
grid-template-columns:repeat(auto-fill, minmax(110px, 1fr));
gap:0.5rem; margin-bottom:0.5rem;
}
.card-count-item {
background:var(--color-card); border-radius:8px; padding:0.6rem 0.5rem;
display:flex; flex-direction:column; align-items:center; gap:0.3rem; text-align:center;
}
.card-count-item img {
width:42px; height:auto; border-radius:4px; display:block;
cursor:pointer; transition:transform 0.15s,opacity 0.15s;
}
.card-count-item img:hover { transform:scale(1.07); opacity:0.85; }
.card-count-item label { font-size:0.72rem; font-weight:600; color:var(--color-text); line-height:1.2; }
.card-range-row {
display:flex; align-items:center; gap:0.25rem;
width:100%; justify-content:center;
}
.range-label {
font-size:0.68rem; color:var(--color-muted);
width:22px; text-align:right; flex-shrink:0;
}
/* ── Stepper ── */
.stepper {
display:flex; align-items:center;
border:1px solid var(--color-muted); border-radius:6px; overflow:hidden; height:26px;
}
.stepper button {
width:22px; height:26px; padding:0; margin:0; border:none; border-radius:0;
background:var(--color-secondary); color:var(--color-text); font-size:0.95rem;
cursor:pointer; flex-shrink:0; display:flex; align-items:center; justify-content:center;
}
.stepper button:hover { background:var(--color-primary); color:#fff; }
.stepper input[type="text"] {
width:28px; height:26px; border:none;
border-left:1px solid var(--color-muted); border-right:1px solid var(--color-muted);
border-radius:0; text-align:center; font-size:0.82rem; padding:0;
background:var(--color-secondary); color:var(--color-text); box-sizing:border-box;
}
.stepper input[type="text"]:focus { outline:none; background:rgba(255,255,255,0.08); }
/* ── 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; }
/* ── Aufgaben ── */
.task-list { display:flex; flex-direction:column; gap:0.4rem; 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;
}
.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-remove:hover { color:#e74c3c; background:none; }
.btn-add {
background:none; border:1px dashed var(--color-muted); color:var(--color-muted);
border-radius:7px; padding:0.4rem; width:100%; cursor:pointer; font-size:0.82rem; margin:0;
}
.btn-add:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
.hygiene-fields { display:none; }
.field-error-msg { font-size:0.78rem; color:#e74c3c; margin-top:0.2rem; }
.error-msg { color:#e74c3c; font-size:0.85rem; margin-top:0.25rem; display:none; }
.required-star { color:#e74c3c; margin-left:0.15em; }
.modal-footer { display:flex; gap:0.6rem; justify-content:flex-end; margin-top:0.5rem; }
.modal-footer button { width:auto; padding:0.55rem 1.25rem; }
/* ── Karten-Info-Dialog ── */
.card-info-dialog {
display:none; position:fixed; inset:0; z-index:600;
align-items:center; justify-content:center;
}
.card-info-dialog.open { display:flex; }
.card-info-overlay { position:absolute; inset:0; background:rgba(0,0,0,0.55); }
.card-info-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:300px; width:90%;
display:flex; flex-direction:column; align-items:center; gap:0.75rem; z-index:1;
}
.card-info-box img { width:80px; height:auto; border-radius:6px; }
.card-info-box h3 { margin:0; font-size:1.05rem; }
.card-info-box p { margin:0; font-size:0.88rem; color:var(--color-muted); text-align:center; line-height:1.5; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;gap:1rem;flex-wrap:wrap;">
<h1 style="margin:0;">Meine Locks</h1>
<button onclick="openModal()" style="width:auto;padding:0.55rem 1.2rem;">+ Vorlage erstellen</button>
</div>
<div class="template-list" id="templateList"></div>
<p class="empty-hint" id="listEmpty" style="display:none;">Noch keine Lock-Vorlagen vorhanden.</p>
</div>
</div>
<!-- Erstellen/Bearbeiten-Modal -->
<div class="modal-backdrop" id="modalBackdrop">
<div class="modal-box" onclick="event.stopPropagation()">
<h2 id="modalTitle" style="margin:0 0 1.25rem;">Vorlage erstellen</h2>
<!-- 1. Grundeinstellungen -->
<div class="form-section">
<div class="form-section-title">Grundeinstellungen</div>
<div class="form-row" id="rowName">
<label for="fName">Name<span class="required-star">*</span></label>
<input type="text" id="fName" placeholder="z.B. Wochenend-Lock" maxlength="100" oninput="clearErr('rowName')">
</div>
<div class="checkbox-row">
<input type="checkbox" id="fRequiresVerification" checked>
<label for="fRequiresVerification">Verifikation erforderlich
<span class="form-hint">(tägliche Verifikation für Profildarstellung)</span>
</label>
</div>
</div>
<!-- 2. Karten -->
<div class="form-section">
<div class="form-section-title">Karten-Konfiguration</div>
<div class="cards-grid" id="modalCardsGrid"></div>
<div class="field-error-msg" id="errGreen" style="display:none;margin-bottom:0.5rem;">Grüne Karte Min muss mindestens 1 sein.</div>
<div class="form-row" style="margin-top:0.75rem;">
<label>Karte ziehen alle</label>
<div class="time-picker">
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('pe',-1,'d')"></button>
<input type="text" id="pe_d" value="0" readonly>
<button type="button" onclick="tpChange('pe',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('pe',-1,'h')"></button>
<input type="text" id="pe_h" value="01" readonly>
<button type="button" onclick="tpChange('pe',1,'h')">+</button>
</div>
<span class="tp-label">Std</span>
</div>
<div class="tp-colon">:</div>
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('pe',-1,'m')"></button>
<input type="text" id="pe_m" value="00" readonly>
<button type="button" onclick="tpChange('pe',1,'m')">+</button>
</div>
<span class="tp-label">Min</span>
</div>
</div>
</div>
<div class="checkbox-row">
<input type="checkbox" id="fAccumulate">
<label for="fAccumulate">Picks akkumulieren
<span class="form-hint">(nicht genutzte Züge bleiben erhalten)</span>
</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="fShowRemaining">
<label for="fShowRemaining">Art der verbleibenden Karten anzeigen</label>
</div>
</div>
<!-- 3. Hygiene -->
<div class="form-section">
<div class="form-section-title">Hygiene-Öffnungen (optional)</div>
<div class="checkbox-row">
<input type="checkbox" id="fHygieneToggle" onchange="toggleHygiene(this.checked)">
<label for="fHygieneToggle">Regelmäßige Hygiene-Öffnungen aktivieren</label>
</div>
<div class="hygiene-fields" id="hygieneFields">
<div class="form-row">
<label>Hygiene-Öffnung alle</label>
<div class="time-picker">
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('he',-1,'d')"></button>
<input type="text" id="he_d" value="1" readonly>
<button type="button" onclick="tpChange('he',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('he',-1,'h')"></button>
<input type="text" id="he_h" value="00" readonly>
<button type="button" onclick="tpChange('he',1,'h')">+</button>
</div>
<span class="tp-label">Std</span>
</div>
<div class="tp-colon">:</div>
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('he',-1,'m')"></button>
<input type="text" id="he_m" value="00" readonly>
<button type="button" onclick="tpChange('he',1,'m')">+</button>
</div>
<span class="tp-label">Min</span>
</div>
</div>
</div>
<div class="form-row" style="margin-bottom:0;">
<label>Dauer der Öffnung</label>
<div class="time-picker">
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('hd',-1,'d')"></button>
<input type="text" id="hd_d" value="0" readonly>
<button type="button" onclick="tpChange('hd',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('hd',-1,'h')"></button>
<input type="text" id="hd_h" value="00" readonly>
<button type="button" onclick="tpChange('hd',1,'h')">+</button>
</div>
<span class="tp-label">Std</span>
</div>
<div class="tp-colon">:</div>
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('hd',-1,'m')"></button>
<input type="text" id="hd_m" value="30" readonly>
<button type="button" onclick="tpChange('hd',1,'m')">+</button>
</div>
<span class="tp-label">Min</span>
</div>
</div>
</div>
</div>
</div>
<!-- 4. 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>
<div class="error-msg" id="modalError"></div>
<div class="modal-footer">
<button onclick="closeModal()" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);">Abbrechen</button>
<button id="modalSaveBtn" onclick="saveTemplate()">Speichern</button>
</div>
</div>
</div>
<!-- Karten-Info-Dialog -->
<div class="card-info-dialog" id="cardInfoDialog">
<div class="card-info-overlay" onclick="closeCardInfo()"></div>
<div class="card-info-box">
<img id="cardInfoImg" src="" alt="">
<h3 id="cardInfoTitle"></h3>
<p id="cardInfoDesc"></p>
<button style="width:auto;padding:0.45rem 1.4rem;" onclick="closeCardInfo()">Schließen</button>
</div>
</div>
<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) {
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 item = document.createElement('div');
item.className = 'card-count-item';
item.innerHTML = `
<img src="${c.img}" alt="${c.name}" title="Klicken für Details" onclick="openCardInfo('${c.id}')">
<label>${c.name}</label>
<div class="card-range-row">
<span class="range-label">Min</span>
${stepperHtml('min_' + c.id, minVal)}
</div>
<div class="card-range-row">
<span class="range-label">Max</span>
${stepperHtml('max_' + c.id, maxVal)}
</div>`;
grid.appendChild(item);
});
}
function stepperHtml(id, val) {
return `<div class="stepper">
<button type="button" onclick="stepChange('${id}',-1)"></button>
<input type="text" id="${id}" value="${val}" onchange="stepClamp('${id}')">
<button type="button" onclick="stepChange('${id}',1)">+</button>
</div>`;
}
function stepChange(id, delta) {
const el = document.getElementById(id);
const val = Math.max(0, (parseInt(el.value) || 0) + delta);
el.value = val;
syncMinMax(id, val);
}
function stepClamp(id) {
const el = document.getElementById(id);
const v = parseInt(el.value);
el.value = isNaN(v) || v < 0 ? 0 : v;
syncMinMax(id, parseInt(el.value));
}
function syncMinMax(id, val) {
if (id.startsWith('min_')) {
const maxEl = document.getElementById('max_' + id.slice(4));
if (maxEl && val > (parseInt(maxEl.value) || 0)) maxEl.value = val;
} else if (id.startsWith('max_')) {
const minEl = document.getElementById('min_' + id.slice(4));
if (minEl && val < (parseInt(minEl.value) || 0)) minEl.value = val;
}
}
// ── Karten-Info-Dialog ──
function openCardInfo(cardId) {
const c = CARD_DEFS.find(x => x.id === cardId);
if (!c) return;
document.getElementById('cardInfoImg').src = c.img;
document.getElementById('cardInfoImg').alt = c.name;
document.getElementById('cardInfoTitle').textContent = c.name;
document.getElementById('cardInfoDesc').textContent = c.desc;
document.getElementById('cardInfoDialog').classList.add('open');
}
function closeCardInfo() {
document.getElementById('cardInfoDialog').classList.remove('open');
}
// ── Zeitpicker ──
function tpChange(prefix, delta, seg) {
let d = parseInt(document.getElementById(prefix + '_d').value) || 0;
let h = parseInt(document.getElementById(prefix + '_h').value) || 0;
let m = parseInt(document.getElementById(prefix + '_m').value) || 0;
if (seg === 'm') m += delta;
else if (seg === 'h') h += delta;
else d += delta;
if (m >= 60) { h += Math.floor(m / 60); m %= 60; }
if (m < 0) { const b = Math.ceil(-m / 60); h -= b; m += b * 60; }
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;
document.getElementById(prefix + '_h').value = String(h).padStart(2, '0');
document.getElementById(prefix + '_m').value = String(m).padStart(2, '0');
}
function tpToMinutes(prefix) {
const d = parseInt(document.getElementById(prefix + '_d').value) || 0;
const h = parseInt(document.getElementById(prefix + '_h').value) || 0;
const m = parseInt(document.getElementById(prefix + '_m').value) || 0;
return d * 1440 + h * 60 + m;
}
function tpFromMinutes(prefix, total) {
total = total || 0;
const d = Math.floor(total / 1440);
const h = Math.floor((total % 1440) / 60);
const m = total % 60;
document.getElementById(prefix + '_d').value = d;
document.getElementById(prefix + '_h').value = String(h).padStart(2, '0');
document.getElementById(prefix + '_m').value = String(m).padStart(2, '0');
}
// ── Hygiene Toggle ──
function toggleHygiene(on) {
document.getElementById('hygieneFields').style.display = on ? 'block' : 'none';
if (!on) { tpFromMinutes('he', 1440); tpFromMinutes('hd', 30); }
}
// ── Aufgaben ──
let taskCtr = 0;
function addTask(text, mins) {
const id = ++taskCtr;
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>`;
document.getElementById('modalTaskList').appendChild(div);
}
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;
}).filter(Boolean);
}
// ── Fehler ──
function clearErr(rowId) {
const row = document.getElementById(rowId);
row?.classList.remove('field-error');
row?.querySelector('.field-error-msg')?.remove();
}
function setErr(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 showModalError(msg) {
const el = document.getElementById('modalError');
el.textContent = msg;
el.style.display = '';
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// ── Modal ──
let editId = null;
function alignModalToContent() {
const rect = document.querySelector('.content')?.getBoundingClientRect();
if (!rect) return;
const box = document.querySelector('.modal-box');
box.style.width = rect.width + 'px';
box.style.marginLeft = rect.left + 'px';
}
function openModal(template) {
editId = template?.templateId || null;
document.getElementById('modalTitle').textContent = editId ? 'Vorlage bearbeiten' : 'Vorlage erstellen';
document.getElementById('modalError').style.display = 'none';
document.getElementById('modalSaveBtn').disabled = false;
document.getElementById('modalTaskList').innerHTML = '';
document.getElementById('errGreen').style.display = 'none';
taskCtr = 0;
document.getElementById('fName').value = template?.name || '';
document.getElementById('fRequiresVerification').checked = template ? template.requiresVerification : true;
document.getElementById('fAccumulate').checked = template?.accumulatePicks || false;
document.getElementById('fShowRemaining').checked = template?.showRemainingCards || false;
renderCardsGrid(template?.cardCountsMin || {}, template?.cardCountsMax || {});
tpFromMinutes('pe', template?.pickEveryMinute || 60);
const hygieneOn = !!(template?.hygineOpeningEveryMinites);
document.getElementById('fHygieneToggle').checked = hygieneOn;
toggleHygiene(hygieneOn);
if (hygieneOn) {
tpFromMinutes('he', template.hygineOpeningEveryMinites);
tpFromMinutes('hd', template.hygineOpeningDurationMinutes || 30);
}
(template?.tasks || []).forEach(t => addTask(t.text, t.minutes));
alignModalToContent();
document.getElementById('modalBackdrop').classList.add('open');
document.getElementById('fName').focus();
}
function closeModal() {
document.getElementById('modalBackdrop').classList.remove('open');
editId = null;
}
document.getElementById('modalBackdrop').addEventListener('click', e => {
if (e.target === e.currentTarget) closeModal();
});
// ── Speichern mit Validierung ──
async function saveTemplate() {
document.getElementById('modalError').style.display = 'none';
document.getElementById('errGreen').style.display = 'none';
clearErr('rowName');
let firstError = null;
// Name
const name = document.getElementById('fName').value.trim();
if (!name) {
setErr('rowName', 'Name ist ein Pflichtfeld.');
firstError = firstError || document.getElementById('rowName');
} else {
clearErr('rowName');
}
// Min > Max Prüfung
for (const c of CARD_DEFS) {
const minVal = parseInt(document.getElementById('min_' + c.id).value) || 0;
const maxVal = parseInt(document.getElementById('max_' + c.id).value) || 0;
if (minVal > maxVal) {
showModalError(`Min darf nicht größer als Max sein (${c.name}).`);
firstError = firstError || document.getElementById('modalError');
break;
}
}
// Grüne Karte
const greenMin = parseInt(document.getElementById('min_GREEN').value) || 0;
if (greenMin < 1) {
document.getElementById('errGreen').style.display = '';
document.getElementById('min_GREEN').closest('.card-count-item').style.outline = '2px solid #e74c3c';
firstError = firstError || document.getElementById('errGreen');
} else {
document.getElementById('min_GREEN').closest('.card-count-item').style.outline = '';
}
// Intervall
const pickEvery = tpToMinutes('pe');
if (pickEvery < 1) {
showModalError('Kartenzieh-Intervall muss mindestens 1 Minute betragen.');
firstError = firstError || document.getElementById('modalError');
}
// Mind. eine Karte im Deck (Max-Summe > 0)
const totalMax = CARD_DEFS.reduce((s, c) => s + (parseInt(document.getElementById('max_' + c.id).value) || 0), 0);
if (totalMax === 0) {
showModalError('Das Deck muss mindestens eine Karte enthalten (Max > 0).');
firstError = firstError || document.getElementById('modalError');
}
// Hygiene
const hygieneOn = document.getElementById('fHygieneToggle').checked;
const hygieneEvery = hygieneOn ? tpToMinutes('he') : null;
const hygieneDur = hygieneOn ? tpToMinutes('hd') : null;
if (hygieneOn && hygieneEvery < 1) {
showModalError('Hygiene-Intervall muss mindestens 1 Minute betragen.');
firstError = firstError || document.getElementById('modalError');
}
if (hygieneOn && hygieneDur < 1) {
showModalError('Dauer der Hygiene-Öffnung muss mindestens 1 Minute betragen.');
firstError = firstError || document.getElementById('modalError');
}
// Aufgaben-Karten ohne Aufgaben
const tasks = collectTasks();
const hasTaskCards = (parseInt(document.getElementById('min_TASK').value) || 0) > 0
|| (parseInt(document.getElementById('max_TASK').value) || 0) > 0;
if (hasTaskCards && tasks.length === 0) {
showModalError('Aufgaben-Karten sind konfiguriert, aber keine Aufgaben definiert.');
firstError = firstError || document.getElementById('modalError');
}
if (firstError) { firstError.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; }
// Body bauen
const cardCountsMin = {}, cardCountsMax = {};
CARD_DEFS.forEach(c => {
const mn = parseInt(document.getElementById('min_' + c.id).value) || 0;
const mx = parseInt(document.getElementById('max_' + c.id).value) || 0;
if (mn > 0) cardCountsMin[c.id] = mn;
if (mx > 0) cardCountsMax[c.id] = mx;
});
const body = {
name,
cardCountsMin,
cardCountsMax,
pickEveryMinute: pickEvery,
accumulatePicks: document.getElementById('fAccumulate').checked,
showRemainingCards: document.getElementById('fShowRemaining').checked,
hygineOpeningEveryMinites: hygieneEvery,
hygineOpeningDurationMinutes: hygieneDur,
tasks,
requiresVerification: document.getElementById('fRequiresVerification').checked,
};
const btn = document.getElementById('modalSaveBtn');
btn.disabled = true;
try {
const url = editId ? '/cardlock/templates/' + editId : '/cardlock/templates';
const method = editId ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (res.ok) {
closeModal();
await loadTemplates();
} else {
showModalError('Fehler beim Speichern.');
btn.disabled = false;
}
} catch(e) { btn.disabled = false; }
}
// ── Löschen ──
async function deleteTemplate(id, name) {
if (!confirm(`Vorlage „${name}" wirklich löschen?`)) return;
const res = await fetch('/cardlock/templates/' + id, { method: 'DELETE' });
if (res.ok || res.status === 204) await loadTemplates();
}
// ── Liste laden ──
function fmtMinutes(min) {
if (!min) return '';
const d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
return [d && d+'d', h && h+'h', m && m+'m'].filter(Boolean).join(' ') || '0m';
}
function cardRangeSummary(min, max) {
// Summarize as "XY" per non-zero card type
const parts = CARD_DEFS
.map(c => {
const mn = min?.[c.id] || 0, mx = max?.[c.id] || 0;
if (mx === 0) return null;
return `${c.name.split(' ')[0]}: ${mn === mx ? mn : mn+''+mx}`;
})
.filter(Boolean);
return parts.join(' · ') || '';
}
async function loadTemplates() {
try {
const res = await fetch('/cardlock/templates');
if (!res.ok) return;
const templates = await res.json();
const list = document.getElementById('templateList');
const empty = document.getElementById('listEmpty');
list.innerHTML = '';
if (templates.length === 0) { empty.style.display = ''; return; }
empty.style.display = 'none';
templates.forEach(t => {
const card = document.createElement('div');
card.className = 'template-card';
const hygieneText = t.hygineOpeningEveryMinites
? `alle ${fmtMinutes(t.hygineOpeningEveryMinites)}, ${fmtMinutes(t.hygineOpeningDurationMinutes)} offen`
: 'Keine';
card.innerHTML = `
<div class="template-card-header">
<div>
<div class="template-name">${esc(t.name || 'Ohne Namen')}</div>
<div class="template-meta">
Karte alle ${fmtMinutes(t.pickEveryMinute)}
${t.accumulatePicks ? ' · Akkumuliert' : ''}
${t.showRemainingCards ? ' · Karten sichtbar' : ''}
· Hygiene: ${hygieneText}
· Verifikation: ${t.requiresVerification ? 'Ja' : 'Nein'}
${t.tasks?.length ? ' · ' + t.tasks.length + ' Aufgabe(n)' : ''}
</div>
<div class="template-meta" style="margin-top:0.3rem;">${cardRangeSummary(t.cardCountsMin, t.cardCountsMax)}</div>
</div>
<div class="template-actions">
<button onclick='editTemplate(${JSON.stringify(t)})' style="background:none;border:1px solid var(--color-secondary);color:var(--color-text);">✏ Bearbeiten</button>
<button onclick="deleteTemplate('${t.templateId}', '${esc(t.name || '')}')" style="background:rgba(231,76,60,0.12);border:1px solid rgba(231,76,60,0.35);color:#e74c3c;">✕ Löschen</button>
</div>
</div>
${cardTypeGridHtml(t.cardCountsMax || {})}`;
list.appendChild(card);
});
} catch(e) { console.error(e); }
}
function editTemplate(t) { openModal(t); }
window.addEventListener('resize', () => {
if (document.getElementById('modalBackdrop').classList.contains('open')) alignModalToContent();
});
loadTemplates();
</script>
</body>
</html>

View File

@@ -324,6 +324,12 @@
let activePartnerId = null;
let pollTimer = null;
// Pagination state
let oldestSentAt = null; // ISO string of oldest visible message
let newestSentAt = null; // ISO string of newest visible message
let hasMoreOlder = false;
let isLoadingOlder = false;
// Load current user
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
@@ -331,7 +337,6 @@
if (!user) return;
myId = user.userId;
loadConversations();
// Open thread from URL param
const urlPartnerId = new URLSearchParams(window.location.search).get('userId');
if (urlPartnerId) openThread(urlPartnerId);
})
@@ -380,13 +385,15 @@
async function openThread(partnerId, partnerName, partnerPic) {
activePartnerId = partnerId;
oldestSentAt = null;
newestSentAt = null;
hasMoreOlder = false;
isLoadingOlder = false;
// Update active state in list
document.querySelectorAll('.conv-item').forEach(li => {
li.classList.toggle('active', li.dataset.partnerId === partnerId);
});
// Update header if name not provided, fetch from conversation list or user lookup
if (!partnerName) {
const convItem = document.querySelector(`.conv-item[data-partner-id="${partnerId}"]`);
partnerName = convItem ? convItem.querySelector('.conv-name').textContent : '…';
@@ -405,52 +412,107 @@
document.getElementById('threadInputWrap').style.display = '';
document.getElementById('msgInput').focus();
// Mobile: hide list, show thread
if (window.innerWidth <= 768) showThread();
await loadThread();
// Clear and load latest messages
const container = document.getElementById('threadMessages');
container.innerHTML = '';
await loadInitialThread();
startPolling();
}
async function loadThread() {
async function loadInitialThread() {
if (!activePartnerId) return;
try {
const res = await fetch('/social/messages/' + activePartnerId);
if (!res.ok) return;
const msgs = await res.json();
renderThread(msgs);
loadConversations(); // refresh unread counts in list
const { messages, hasMore } = await res.json();
const container = document.getElementById('threadMessages');
container.innerHTML = '';
if (messages.length === 0) {
container.appendChild(Object.assign(document.createElement('div'), {
className: 'thread-placeholder',
textContent: 'Noch keine Nachrichten. Schreib als Erster!'
}));
oldestSentAt = null;
newestSentAt = null;
hasMoreOlder = false;
} else {
// messages are oldest-first from backend
messages.forEach(m => container.appendChild(buildBubble(m)));
oldestSentAt = messages[0].sentAt;
newestSentAt = messages[messages.length - 1].sentAt;
hasMoreOlder = hasMore;
}
container.scrollTop = container.scrollHeight;
loadConversations();
} catch (e) { console.error(e); }
}
function renderThread(msgs) {
const container = document.getElementById('threadMessages');
container.innerHTML = '';
if (msgs.length === 0) {
container.appendChild(Object.assign(document.createElement('div'), {
className: 'thread-placeholder',
textContent: 'Noch keine Nachrichten. Schreib als Erster!'
}));
return;
}
// msgs are newest-first; reverse to show oldest first
[...msgs].reverse().forEach(m => {
const isMe = m.senderId === myId;
const time = new Date(m.sentAt).toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
const wrap = document.createElement('div');
wrap.className = 'bubble-wrap ' + (isMe ? 'me' : 'them');
const isImg = m.text.startsWith('data:image/');
const content = isImg
? `<img src="${m.text}" class="bubble-img" onclick="openLightbox(this.src)" alt="Bild">`
: esc(m.text);
wrap.innerHTML = `
<div class="bubble">${content}</div>
<div class="bubble-time">${time}</div>`;
container.appendChild(wrap);
});
container.scrollTop = container.scrollHeight;
async function loadOlderMessages() {
if (!activePartnerId || !hasMoreOlder || isLoadingOlder || !oldestSentAt) return;
isLoadingOlder = true;
try {
const res = await fetch('/social/messages/' + activePartnerId + '?before=' + encodeURIComponent(oldestSentAt));
if (!res.ok) return;
const { messages, hasMore } = await res.json();
if (messages.length === 0) { hasMoreOlder = false; return; }
const container = document.getElementById('threadMessages');
const prevHeight = container.scrollHeight;
// Prepend older messages (oldest-first order)
const frag = document.createDocumentFragment();
messages.forEach(m => frag.appendChild(buildBubble(m)));
container.prepend(frag);
// Restore scroll position
container.scrollTop = container.scrollHeight - prevHeight;
oldestSentAt = messages[0].sentAt;
hasMoreOlder = hasMore;
} catch (e) { console.error(e); }
finally { isLoadingOlder = false; }
}
async function pollNewMessages() {
if (!activePartnerId || !newestSentAt) return;
try {
const res = await fetch('/social/messages/' + activePartnerId + '?after=' + encodeURIComponent(newestSentAt));
if (!res.ok) return;
const { messages } = await res.json();
if (messages.length === 0) return;
const container = document.getElementById('threadMessages');
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 60;
// Remove placeholder if present
const ph = container.querySelector('.thread-placeholder');
if (ph) ph.remove();
messages.forEach(m => container.appendChild(buildBubble(m)));
newestSentAt = messages[messages.length - 1].sentAt;
if (atBottom) container.scrollTop = container.scrollHeight;
loadConversations();
} catch (e) { console.error(e); }
}
function buildBubble(m) {
const isMe = m.senderId === myId;
const time = new Date(m.sentAt).toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
const wrap = document.createElement('div');
wrap.className = 'bubble-wrap ' + (isMe ? 'me' : 'them');
wrap.dataset.sentAt = m.sentAt;
const isImg = m.text.startsWith('data:image/');
const content = isImg
? `<img src="${m.text}" class="bubble-img" onclick="openLightbox(this.src)" alt="Bild">`
: linkify(m.text);
wrap.innerHTML = `
<div class="bubble">${content}</div>
<div class="bubble-time">${time}</div>`;
return wrap;
}
// Scroll-up → load older messages
document.getElementById('threadMessages').addEventListener('scroll', function () {
if (this.scrollTop < 80 && hasMoreOlder && !isLoadingOlder) {
loadOlderMessages();
}
});
async function sendMsg() {
if (!activePartnerId) return;
const input = document.getElementById('msgInput');
@@ -463,7 +525,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId: activePartnerId, text })
});
await loadThread();
await pollNewMessages();
} catch (e) { console.error(e); }
}
@@ -473,10 +535,9 @@
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(loadThread, 10000);
pollTimer = setInterval(pollNewMessages, 10000);
}
// Mobile: show/hide panes
function showThread() {
document.getElementById('convListPane').classList.add('hidden');
document.getElementById('threadPane').classList.remove('hidden');
@@ -488,7 +549,6 @@
activePartnerId = null;
}
// ── Lightbox ──
function openLightbox(src) {
document.getElementById('lightboxImg').src = src;
document.getElementById('lightbox').classList.add('open');
@@ -506,6 +566,16 @@
return d.innerHTML;
}
function linkify(text) {
// HTML-escapen, Zeilenumbrüche als <br>, URLs als klickbare Links
const escaped = esc(text)
.replace(/\n/g, '<br>');
return escaped.replace(
/(https?:\/\/[^\s<>"]+)/g,
url => `<a href="${url}" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:underline;word-break:break-all;">${url}</a>`
);
}
// ── Emoji-Picker ──
const EMOJIS = [
'😀','😂','🤣','😅','😊','😍','🥰','😘','😎','🤩',
@@ -551,7 +621,6 @@
document.getElementById('imgFile').addEventListener('change', async function () {
const file = this.files[0];
if (!file || !activePartnerId) return;
// Frisches Input-Element einsetzen, damit jedes weitere Bild (auch dasselbe) wählbar bleibt
const fresh = this.cloneNode(false);
this.replaceWith(fresh);
attachImgHandler();
@@ -562,7 +631,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId: activePartnerId, text: dataUrl })
});
await loadThread();
await pollNewMessages();
} catch (e) { console.error(e); }
});
})();

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chastity Game Neue Session XXX The Game</title>
<title>Chastity Game Neues Lock XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
@@ -273,6 +273,72 @@
.inline-number input { width: 90px !important; flex-shrink: 0; }
.inline-number span { font-size: 0.9rem; color: var(--color-text); }
/* ── Zeitpicker ── */
.time-picker { display: flex; align-items: center; gap: 0.5rem; }
.tp-seg {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
}
.tp-seg-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.tp-seg button {
width: 26px;
height: 26px;
background: var(--color-secondary);
border: 1px solid var(--color-muted);
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
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: 30px;
text-align: center;
background: var(--color-secondary);
border: 1px solid var(--color-muted);
border-radius: 4px;
color: var(--color-text);
font-size: 0.95rem;
font-weight: 600;
font-family: monospace;
padding: 0.18rem 0;
box-sizing: border-box;
}
.tp-seg .tp-label {
font-size: 0.65rem;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tp-colon {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-muted);
margin-bottom: 1rem;
}
/* Gesperrte Sections (Vorlage aktiv) */
.form-section-locked { opacity: 0.72; }
.form-section-locked .form-section-title::after {
content: ' 🔒';
font-size: 0.8em;
vertical-align: middle;
}
.form-section-locked .stepper button,
.form-section-locked .tp-seg button { cursor: not-allowed; }
/* Keyholder Combobox */
.combo-wrap {
position: relative;
@@ -321,29 +387,60 @@
<div class="main">
<div class="content">
<h2 style="margin-bottom:1.25rem;">🔒 Neue Chastity-Session</h2>
<h2 style="margin-bottom:1.25rem;">🔒 Neues Lock</h2>
<!-- Template-Auswahl -->
<div class="form-section" id="templateSection" style="display:none;">
<div class="form-section-title">Vorlage</div>
<div class="form-row">
<label for="templateInput">Aus Vorlage laden</label>
<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 class="form-hint">Füllt die Einstellungen aus einer gespeicherten Vorlage die übernommenen Felder können nicht mehr bearbeitet werden.</div>
</div>
</div>
<!-- 1. Grundeinstellungen -->
<div class="form-section">
<div class="form-section-title">Grundeinstellungen</div>
<div class="form-row" id="rowLockName">
<label for="lockName">Name der Session<span class="required-star">*</span></label>
<label for="lockName">Name des Locks<span class="required-star">*</span></label>
<input type="text" id="lockName" placeholder="z.B. Wochenend-Lock" maxlength="100" required
oninput="clearFieldError('rowLockName')">
</div>
<div class="form-row">
<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 du hast selbst jederzeit die Kontrolle.</div>
<div class="form-hint">Ohne Keyholder läuft das Lock als Self-Lock</div>
</div>
<div class="form-row">
<div class="form-row" id="rowUnlockCodeLines">
<label for="unlockCodeLines">Zeilen des Entsperrcodes</label>
<div class="inline-number">
<input type="number" id="unlockCodeLines" min="1" max="20" value="5">
@@ -352,6 +449,19 @@
<div class="form-hint">Aus wie vielen Zeilen der Entsperrcode bestehen soll.</div>
</div>
<div class="form-row">
<label for="latestOpening">Spätestes Öffnungsdatum</label>
<input type="datetime-local" id="latestOpening">
<div class="form-hint">Das Lock öffnet spätestens zu diesem Zeitpunkt automatisch.</div>
</div>
<div class="checkbox-row">
<input type="checkbox" id="requiresVerification" checked>
<label for="requiresVerification">Verifikation erforderlich
<span class="form-hint">(Für die Darstellung im Profil ist eine tägliche Verifikation erforderlich)</span>
</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="testLock">
<label for="testLock">Test-Lock <span class="form-hint">(kein echter Lock, zum Ausprobieren)</span></label>
@@ -362,19 +472,40 @@
<div class="form-section">
<div class="form-section-title">Karten-Konfiguration</div>
<div class="form-row" style="margin-bottom:0.75rem;">
<label>Startdeck Anzahl je Kartentyp</label>
<div class="form-hint">Das Deck wird aus diesen Karten zusammengestellt. Mindestens eine grüne Karte ist Pflicht<span class="required-star">*</span>.</div>
</div>
<div class="cards-grid" id="cardsGrid"></div>
<div class="field-error-msg" id="errorGreenCard" style="display:none; margin-bottom:0.5rem;">Es muss mindestens eine Grüne Karte im Deck sein (Min ≥ 1).</div>
<div class="form-row">
<label>Karte ziehen alle X Minuten</label>
<div class="inline-number">
<input type="number" id="pickEvery" min="1" max="99999" value="60">
<span>Minuten</span>
<label>Karte ziehen alle</label>
<div class="time-picker">
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('pickEvery',-1,'d')"></button>
<input type="text" id="pickEvery_d" value="0" readonly>
<button type="button" onclick="tpChange('pickEvery',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('pickEvery',-1,'h')"></button>
<input type="text" id="pickEvery_h" value="01" readonly>
<button type="button" onclick="tpChange('pickEvery',1,'h')">+</button>
</div>
<span class="tp-label">Std</span>
</div>
<div class="tp-colon">:</div>
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('pickEvery',-1,'m')"></button>
<input type="text" id="pickEvery_m" value="00" readonly>
<button type="button" onclick="tpChange('pickEvery',1,'m')">+</button>
</div>
<span class="tp-label">Min</span>
</div>
</div>
</div>
@@ -391,18 +522,7 @@
</div>
</div>
<!-- 3. Zeitlimits -->
<div class="form-section">
<div class="form-section-title">Zeitlimits (optional)</div>
<div class="form-row" style="margin-bottom:0;">
<label for="latestOpening">Spätestes Öffnungsdatum</label>
<input type="datetime-local" id="latestOpening">
<div class="form-hint">Das Lock öffnet spätestens zu diesem Zeitpunkt automatisch.</div>
</div>
</div>
<!-- 4. Hygiene -->
<!-- 3. Hygiene -->
<div class="form-section">
<div class="form-section-title">Hygiene-Öffnungen (optional)</div>
@@ -413,17 +533,65 @@
<div class="hygiene-fields" id="hygieneFields">
<div class="form-row">
<label>Hygiene-Öffnung alle X Stunden</label>
<div class="inline-number">
<input type="number" id="hygieneEveryHours" min="1" max="999" value="24">
<span>Stunden</span>
<label>Hygiene-Öffnung alle</label>
<div class="time-picker">
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('hygieneEvery',-1,'d')"></button>
<input type="text" id="hygieneEvery_d" value="1" readonly>
<button type="button" onclick="tpChange('hygieneEvery',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('hygieneEvery',-1,'h')"></button>
<input type="text" id="hygieneEvery_h" value="00" readonly>
<button type="button" onclick="tpChange('hygieneEvery',1,'h')">+</button>
</div>
<span class="tp-label">Std</span>
</div>
<div class="tp-colon">:</div>
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('hygieneEvery',-1,'m')"></button>
<input type="text" id="hygieneEvery_m" value="00" readonly>
<button type="button" onclick="tpChange('hygieneEvery',1,'m')">+</button>
</div>
<span class="tp-label">Min</span>
</div>
</div>
</div>
<div class="form-row" style="margin-bottom:0;">
<label>Dauer der Öffnung</label>
<div class="inline-number">
<input type="number" id="hygieneDuration" min="1" max="9999" value="30">
<span>Minuten</span>
<div class="time-picker">
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('hygieneDur',-1,'d')"></button>
<input type="text" id="hygieneDur_d" value="0" readonly>
<button type="button" onclick="tpChange('hygieneDur',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('hygieneDur',-1,'h')"></button>
<input type="text" id="hygieneDur_h" value="00" readonly>
<button type="button" onclick="tpChange('hygieneDur',1,'h')">+</button>
</div>
<span class="tp-label">Std</span>
</div>
<div class="tp-colon">:</div>
<div class="tp-seg">
<div class="tp-seg-row">
<button type="button" onclick="tpChange('hygieneDur',-1,'m')"></button>
<input type="text" id="hygieneDur_m" value="30" readonly>
<button type="button" onclick="tpChange('hygieneDur',1,'m')">+</button>
</div>
<span class="tp-label">Min</span>
</div>
</div>
</div>
</div>
@@ -439,22 +607,12 @@
<button class="btn-add" onclick="addTask()">+ Aufgabe hinzufügen</button>
</div>
<!-- 6. Sicherheit -->
<div class="form-section">
<div class="form-section-title">Sicherheit</div>
<div class="checkbox-row">
<input type="checkbox" id="requiresVerification">
<label for="requiresVerification">Verifikation erforderlich
<span class="form-hint">(Community muss bestätigen, dass Aufgabe erfüllt wurde)</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()">🔒 Session starten</button>
<button onclick="createSession()">🔒 Lock starten</button>
</div>
</div>
@@ -483,7 +641,7 @@
"></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 per E-Mail benachrichtigt und muss die Rolle noch bestätigen.
⏳ Die eingetragene Keyholder*In wurde per Direktnachricht 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>
@@ -505,23 +663,359 @@
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
let myUserId = null;
let myUserId = null;
let myUserName = null;
// ── Boot ──
fetch('/login/me').then(r => r.ok ? r.json() : null).then(user => {
if (!user) { window.location.href = '/login.html'; return; }
myUserId = user.userId;
loadKeyholderOptions(user.userId);
myUserId = user.userId;
myUserName = user.name;
loadOptions(user.userId);
loadTemplates();
});
let allFriends = [];
let comboActiveIdx = -1;
async function loadKeyholderOptions(myId) {
async function loadOptions(myId) {
try {
allFriends = await fetch('/social/friends/user/' + myId).then(r => r.ok ? r.json() : []);
} catch { allFriends = []; }
setupLockeeCombo();
setupKeyholderCombo();
// Pre-select "Ich selbst"
document.getElementById('lockeeInput').value = 'Ich selbst';
document.getElementById('lockeeValue').value = myId;
}
// ── Template laden ──
let allTemplates = [];
async function loadTemplates() {
try {
allTemplates = await fetch('/cardlock/templates').then(r => r.ok ? r.json() : []);
} catch { allTemplates = []; }
if (allTemplates.length > 0) {
document.getElementById('templateSection').style.display = '';
setupTemplateCombo();
}
}
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">${q ? 'Keine Vorlagen gefunden.' : 'Keine Vorlagen vorhanden.'}</div>`;
} else {
// "Keine Vorlage" option when template is currently active
if (hidden.value) {
const clear = document.createElement('div');
clear.className = 'combo-option';
clear.innerHTML = '<span style="color:var(--color-muted);font-style:italic;">— Keine Vorlage —</span>';
clear.addEventListener('mousedown', e => { e.preventDefault(); clearTemplate(); });
dropdown.appendChild(clear);
}
filtered.forEach(t => {
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = t.templateId;
div.dataset.name = t.name || 'Unbenannte Vorlage';
div.textContent = t.name || 'Unbenannte Vorlage';
div.addEventListener('mousedown', e => { e.preventDefault(); selectTemplate(t.templateId, t.name || 'Unbenannte Vorlage'); });
dropdown.appendChild(div);
});
}
dropdown.classList.add('open');
}
function selectTemplate(id, name) {
hidden.value = id;
input.value = name;
dropdown.classList.remove('open');
applyTemplate(id);
}
function clearTemplate() {
hidden.value = '';
input.value = '';
dropdown.classList.remove('open');
setTemplateLocked(false);
}
input.addEventListener('input', () => {
if (hidden.value) { hidden.value = ''; setTemplateLocked(false); }
renderDropdown(input.value);
});
input.addEventListener('focus', () => renderDropdown(input.value));
input.addEventListener('blur', () => {
setTimeout(() => {
dropdown.classList.remove('open');
if (!hidden.value) input.value = '';
}, 150);
});
input.addEventListener('keydown', e => {
const opts = dropdown.querySelectorAll('.combo-option');
if (!dropdown.classList.contains('open')) return;
if (e.key === 'Escape') { dropdown.classList.remove('open'); }
});
}
function applyTemplate(id) {
const t = allTemplates.find(x => x.templateId === id);
if (!t) return;
// Name
document.getElementById('lockName').value = t.name || '';
// Karten
CARD_DEFS.forEach(c => {
const minVal = (t.cardCountsMin && t.cardCountsMin[c.id] != null) ? t.cardCountsMin[c.id] : c.defMin;
const maxVal = (t.cardCountsMax && t.cardCountsMax[c.id] != null) ? t.cardCountsMax[c.id] : c.defMax;
const minEl = document.getElementById('min_' + c.id);
const maxEl = document.getElementById('max_' + c.id);
if (minEl) minEl.value = minVal;
if (maxEl) maxEl.value = maxVal;
});
// Kartenzieh-Intervall
if (t.pickEveryMinute) {
const total = t.pickEveryMinute;
const d = Math.floor(total / (24 * 60));
const h = Math.floor((total % (24 * 60)) / 60);
const m = total % 60;
document.getElementById('pickEvery_d').value = d;
document.getElementById('pickEvery_h').value = String(h).padStart(2, '0');
document.getElementById('pickEvery_m').value = String(m).padStart(2, '0');
}
// Checkboxen
document.getElementById('accumulatePicks').checked = !!t.accumulatePicks;
document.getElementById('showRemaining').checked = !!t.showRemainingCards;
document.getElementById('requiresVerification').checked = !!t.requiresVerification;
// Hygiene
const hyOn = t.hygineOpeningEveryMinites != null && t.hygineOpeningEveryMinites > 0;
document.getElementById('hygieneToggle').checked = hyOn;
toggleHygiene(hyOn);
if (hyOn) {
const ev = t.hygineOpeningEveryMinites;
const ed = Math.floor(ev / (24 * 60));
const eh = Math.floor((ev % (24 * 60)) / 60);
const em = ev % 60;
document.getElementById('hygieneEvery_d').value = ed;
document.getElementById('hygieneEvery_h').value = String(eh).padStart(2, '0');
document.getElementById('hygieneEvery_m').value = String(em).padStart(2, '0');
if (t.hygineOpeningDurationMinutes) {
const dur = t.hygineOpeningDurationMinutes;
const dd = Math.floor(dur / (24 * 60));
const dh = Math.floor((dur % (24 * 60)) / 60);
const dm = dur % 60;
document.getElementById('hygieneDur_d').value = dd;
document.getElementById('hygieneDur_h').value = String(dh).padStart(2, '0');
document.getElementById('hygieneDur_m').value = String(dm).padStart(2, '0');
}
}
// Aufgaben
document.getElementById('taskList').innerHTML = '';
taskCounter = 0;
if (t.tasks && t.tasks.length > 0) {
t.tasks.forEach(task => {
const id = ++taskCounter;
const div = document.createElement('div');
div.className = 'task-item';
div.id = 'task-' + id;
div.innerHTML = `
<input type="text" placeholder="Aufgaben-Beschreibung…" maxlength="300" id="task-text-${id}" value="${task.text ? task.text.replace(/"/g, '&quot;') : ''}">
<input type="number" min="1" max="9999" placeholder="Min." id="task-min-${id}" title="Dauer in Minuten" value="${task.minutes != null ? task.minutes : ''}">
<button class="task-remove" onclick="removeTask(${id})" title="Entfernen" style="display:none;">✕</button>`;
document.getElementById('taskList').appendChild(div);
});
}
setTemplateLocked(true);
}
function setTemplateLocked(locked) {
// ── Lock name ──
const lockNameEl = document.getElementById('lockName');
lockNameEl.readOnly = locked;
lockNameEl.style.cursor = locked ? 'not-allowed' : '';
lockNameEl.style.opacity = locked ? '0.7' : '';
// ── Cards section ──
const cardsSection = document.getElementById('cardsGrid')?.closest('.form-section');
if (cardsSection) cardsSection.classList.toggle('form-section-locked', locked);
CARD_DEFS.forEach(c => {
['min_', 'max_'].forEach(p => {
const el = document.getElementById(p + c.id);
if (el) {
el.readOnly = locked;
el.closest('.stepper')?.querySelectorAll('button').forEach(b => { b.disabled = locked; });
}
});
});
// Pick-every time picker (in same cards section)
['pickEvery_d','pickEvery_h','pickEvery_m'].forEach(id => {
const el = document.getElementById(id);
if (el) el.closest('.tp-seg')?.querySelectorAll('button').forEach(b => { b.disabled = locked; });
});
// Checkboxes in cards section
['accumulatePicks','showRemaining'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.disabled = locked;
const row = el.closest('.checkbox-row');
if (row) { row.style.opacity = locked ? '0.65' : ''; row.style.pointerEvents = locked ? 'none' : ''; }
});
// ── Hygiene section ──
const hySection = document.getElementById('hygieneToggle')?.closest('.form-section');
if (hySection) hySection.classList.toggle('form-section-locked', locked);
const hyToggle = document.getElementById('hygieneToggle');
if (hyToggle) {
hyToggle.disabled = locked;
const row = hyToggle.closest('.checkbox-row');
if (row) { row.style.opacity = locked ? '0.65' : ''; row.style.pointerEvents = locked ? 'none' : ''; }
}
['hygieneEvery_d','hygieneEvery_h','hygieneEvery_m','hygieneDur_d','hygieneDur_h','hygieneDur_m'].forEach(id => {
const el = document.getElementById(id);
if (el) el.closest('.tp-seg')?.querySelectorAll('button').forEach(b => { b.disabled = locked; });
});
// ── Aufgaben section ──
const taskSection = document.getElementById('taskList')?.closest('.form-section');
if (taskSection) taskSection.classList.toggle('form-section-locked', locked);
document.querySelector('.btn-add').style.display = locked ? 'none' : '';
document.querySelectorAll('.task-remove').forEach(b => b.style.display = locked ? 'none' : '');
document.querySelectorAll('.task-item input').forEach(el => { el.readOnly = locked; el.style.cursor = locked ? 'not-allowed' : ''; });
// ── requiresVerification ──
const rvEl = document.getElementById('requiresVerification');
if (rvEl) {
rvEl.disabled = locked;
const row = rvEl.closest('.checkbox-row');
if (row) { row.style.opacity = locked ? '0.65' : ''; row.style.pointerEvents = locked ? 'none' : ''; }
}
}
// ── 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);
});
input.addEventListener('keydown', e => {
const opts = dropdown.querySelectorAll('.combo-option');
if (!dropdown.classList.contains('open')) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
comboActiveIdx = Math.min(comboActiveIdx + 1, opts.length - 1);
opts.forEach((o, i) => o.classList.toggle('active', i === comboActiveIdx));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
comboActiveIdx = Math.max(comboActiveIdx - 1, 0);
opts.forEach((o, i) => o.classList.toggle('active', i === comboActiveIdx));
} else if (e.key === 'Enter') {
e.preventDefault();
const active = dropdown.querySelector('.combo-option.active');
if (active) selectLockee(active.dataset.id, active.dataset.name);
else dropdown.classList.remove('open');
} else if (e.key === 'Escape') {
dropdown.classList.remove('open');
}
});
}
function onLockeeChanged(lockeeId) {
const isFriend = lockeeId && lockeeId !== myUserId;
const khInput = document.getElementById('keyholderInput');
const khHidden = document.getElementById('keyholderValue');
const khCombo = document.getElementById('rowKeyholder');
const codeRow = document.getElementById('rowUnlockCodeLines');
const testRow = document.getElementById('testLock')?.closest('.checkbox-row');
const detailsRow = document.getElementById('rowDetailsVisible');
if (isFriend) {
// Auto-set current user as keyholder, lock the field
khInput.value = myUserName || 'Ich selbst';
khHidden.value = myUserId;
khInput.readOnly = true;
khInput.style.opacity = '0.6';
codeRow.style.display = 'none';
if (testRow) { testRow.style.display = 'none'; }
if (detailsRow) { detailsRow.style.display = ''; }
} else {
khInput.readOnly = false;
khInput.style.opacity = '';
if (!khHidden.value) khInput.value = '';
codeRow.style.display = '';
if (testRow) { testRow.style.display = ''; }
if (detailsRow) { detailsRow.style.display = 'none'; }
updateTestLock();
}
}
function setupKeyholderCombo() {
@@ -530,6 +1024,8 @@
const hidden = document.getElementById('keyholderValue');
function renderDropdown(query) {
// Don't open if lockee is a friend (keyholder is locked)
if (input.readOnly) return;
const q = query.toLowerCase().trim();
const filtered = q
? allFriends.filter(f => f.name.toLowerCase().includes(q))
@@ -611,12 +1107,48 @@
});
}
// ── Zeitpicker ──
function tpChange(prefix, delta, seg) {
let d = parseInt(document.getElementById(prefix + '_d').value) || 0;
let h = parseInt(document.getElementById(prefix + '_h').value) || 0;
let m = parseInt(document.getElementById(prefix + '_m').value) || 0;
if (seg === 'm') m += delta;
else if (seg === 'h') h += delta;
else if (seg === 'd') d += delta;
// Überlauf Minuten → Stunden
if (m >= 60) { h += Math.floor(m / 60); m = m % 60; }
if (m < 0) { const b = Math.ceil(-m / 60); h -= b; m += b * 60; }
// Überlauf Stunden → Tage
if (h >= 24) { d += Math.floor(h / 24); h = h % 24; }
if (h < 0) { const b = Math.ceil(-h / 24); d -= b; h += b * 24; }
// Tage nicht unter 0
if (d < 0) d = 0;
document.getElementById(prefix + '_d').value = d;
document.getElementById(prefix + '_h').value = String(h).padStart(2, '0');
document.getElementById(prefix + '_m').value = String(m).padStart(2, '0');
}
function tpToMinutes(prefix) {
const d = parseInt(document.getElementById(prefix + '_d').value) || 0;
const h = parseInt(document.getElementById(prefix + '_h').value) || 0;
const m = parseInt(document.getElementById(prefix + '_m').value) || 0;
return d * 24 * 60 + h * 60 + m;
}
// ── Hygiene Toggle ──
function toggleHygiene(on) {
document.getElementById('hygieneFields').style.display = on ? 'block' : 'none';
if (!on) {
document.getElementById('hygieneEveryHours').value = '24';
document.getElementById('hygieneDuration').value = '30';
// Reset auf Defaults: 1d 0h 0m / 0d 0h 30m
document.getElementById('hygieneEvery_d').value = '1';
document.getElementById('hygieneEvery_h').value = '00';
document.getElementById('hygieneEvery_m').value = '00';
document.getElementById('hygieneDur_d').value = '0';
document.getElementById('hygieneDur_h').value = '00';
document.getElementById('hygieneDur_m').value = '30';
}
}
@@ -649,13 +1181,13 @@
// ── Karten-Definitionen ──
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: 3, defMax: 5 },
{ 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: 1, defMax: 2 },
{ 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: 1 },
{ 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: 1 },
{ id: 'DOUBLE_UP', img: '/img/card_doubleup.png', name: 'Double Up', desc: 'Verdoppelt den Effekt der nächsten gezogenen Karte.', defMin: 0, defMax: 1 },
{ 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 den Effekt der nächsten gezogenen Karte.', defMin: 0, defMax: 0 },
];
renderCardsGrid();
@@ -693,12 +1225,24 @@
const el = document.getElementById(id);
const val = Math.max(0, (parseInt(el.value) || 0) + delta);
el.value = val;
syncMinMax(id, val);
}
function syncMinMax(id, val) {
if (id.startsWith('min_')) {
const maxEl = document.getElementById('max_' + id.slice(4));
if (maxEl && val > (parseInt(maxEl.value) || 0)) maxEl.value = val;
} else if (id.startsWith('max_')) {
const minEl = document.getElementById('min_' + id.slice(4));
if (minEl && val < (parseInt(minEl.value) || 0)) minEl.value = val;
}
}
function stepperClamp(id) {
const el = document.getElementById(id);
const val = parseInt(el.value);
el.value = isNaN(val) || val < 0 ? 0 : val;
syncMinMax(id, parseInt(el.value));
}
// ── Karten-Info-Dialog ──
@@ -772,7 +1316,7 @@
// Name Pflichtfeld
const nameVal = document.getElementById('lockName').value.trim();
if (!nameVal) {
setFieldError('rowLockName', 'Name der Session ist ein Pflichtfeld.');
setFieldError('rowLockName', 'Name des Locks ist ein Pflichtfeld.');
firstError = firstError || document.getElementById('rowLockName');
} else {
clearFieldError('rowLockName');
@@ -803,9 +1347,9 @@
firstError = firstError || document.getElementById('errorMsg');
}
const pickEvery = parseInt(document.getElementById('pickEvery').value);
if (isNaN(pickEvery) || pickEvery < 1) {
showError('Bitte einen gültigen Kartenzieh-Intervall eingeben.');
const pickEvery = tpToMinutes('pickEvery');
if (pickEvery < 1) {
showError('Der Kartenzieh-Intervall muss mindestens 1 Minute betragen.');
firstError = firstError || document.getElementById('errorMsg');
}
@@ -814,15 +1358,15 @@
firstError = firstError || document.getElementById('errorMsg');
}
const hygieneOn = document.getElementById('hygieneToggle').checked;
const hygieneHours = hygieneOn ? parseInt(document.getElementById('hygieneEveryHours').value) : null;
const hygieneDur = hygieneOn ? parseInt(document.getElementById('hygieneDuration').value) : null;
if (hygieneOn && (!hygieneHours || hygieneHours < 1)) {
showError('Bitte das Hygiene-Intervall in Stunden angeben.');
const hygieneOn = document.getElementById('hygieneToggle').checked;
const hygieneEveryMin = hygieneOn ? tpToMinutes('hygieneEvery') : null;
const hygieneDurMin = hygieneOn ? tpToMinutes('hygieneDur') : null;
if (hygieneOn && hygieneEveryMin < 1) {
showError('Das Hygiene-Intervall muss mindestens 1 Minute betragen.');
firstError = firstError || document.getElementById('errorMsg');
}
if (hygieneOn && (!hygieneDur || hygieneDur < 1)) {
showError('Bitte die Dauer der Hygiene-Öffnung angeben.');
if (hygieneOn && hygieneDurMin < 1) {
showError('Die Dauer der Hygiene-Öffnung muss mindestens 1 Minute betragen.');
firstError = firstError || document.getElementById('errorMsg');
}
@@ -831,22 +1375,26 @@
return;
}
const lockeeVal = document.getElementById('lockeeValue').value;
const keyholderVal = document.getElementById('keyholderValue').value;
const isFriendLockee = lockeeVal && lockeeVal !== myUserId;
const body = {
name: nameVal,
keyholder: keyholderVal || null,
lockeeUserId: isFriendLockee ? lockeeVal : null,
lockeeDetailsVisible: isFriendLockee ? document.getElementById('lockeeDetailsVisible').checked : false,
keyholder: isFriendLockee ? null : (keyholderVal || null),
initialCards,
pickEveryMinute: pickEvery,
accumulatePicks: document.getElementById('accumulatePicks').checked,
showRemainingCards: document.getElementById('showRemaining').checked,
latestOpeningtime: toLocalDateTime(document.getElementById('latestOpening').value),
hygineOpeningEveryMinites: hygieneOn ? hygieneHours * 60 : null,
hygineOpeningDurationMinutes: hygieneOn ? hygieneDur : null,
hygineOpeningEveryMinites: hygieneOn ? hygieneEveryMin : null,
hygineOpeningDurationMinutes: hygieneOn ? hygieneDurMin : null,
tasks: collectTasks(),
unlockCodeLines: parseInt(document.getElementById('unlockCodeLines').value) || 5,
unlockCodeLines: isFriendLockee ? null : (parseInt(document.getElementById('unlockCodeLines').value) || 5),
requiresVerification: document.getElementById('requiresVerification').checked,
testLock: document.getElementById('testLock').checked,
testLock: isFriendLockee ? false : document.getElementById('testLock').checked,
};
const res = await fetch('/keyholder/cardlock', {
@@ -856,12 +1404,39 @@
});
if (!res.ok) {
showError(res.status === 400 ? 'Ungültige Eingabe. Bitte alle Pflichtfelder prüfen.' : 'Fehler beim Erstellen der Session.');
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 Pflichtfelder prüfen.' : 'Fehler beim Erstellen des Locks.');
}
return;
}
const data = await res.json();
showUnlockCodeModal(data.unlockCode, data.lockId, data.keyholderPending);
if (data.lockeeInvitationSent) {
window.location.href = '/einladungen.html?tab=gesendet';
return;
} else {
showUnlockCodeModal(data.unlockCode, data.lockId, data.keyholderPending);
}
}
function showInvitationSentMessage() {
// Replace form content with confirmation
document.querySelector('.content').innerHTML = `
<div style="text-align:center;padding:3rem 1rem;">
<div style="font-size:3rem;margin-bottom:1rem;">✉️</div>
<h2 style="margin-bottom:0.75rem;">Einladung gesendet!</h2>
<p style="color:var(--color-muted);margin-bottom:2rem;">
Die eingeladene Person wurde per Direktnachricht benachrichtigt.<br>
Sobald sie die Einladung annimmt, startet das Lock automatisch.
</p>
<a href="/einladungen.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;">Einladungen anzeigen</a>
</div>`;
}
function showUnlockCodeModal(code, lockId, keyholderPending) {

File diff suppressed because it is too large Load Diff