Weiter am Chastity Game gearbeitet und Interaktionen zwischen Keyholder und Lockee hinzugefügt
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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){
|
||||
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<>());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
249
xxxthegame/src/main/resources/static/communityvotes.html
Normal file
249
xxxthegame/src/main/resources/static/communityvotes.html
Normal 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>
|
||||
664
xxxthegame/src/main/resources/static/einladungen.html
Normal file
664
xxxthegame/src/main/resources/static/einladungen.html
Normal 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} ${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>
|
||||
306
xxxthegame/src/main/resources/static/joinlock.html
Normal file
306
xxxthegame/src/main/resources/static/joinlock.html
Normal 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>
|
||||
69
xxxthegame/src/main/resources/static/js/card-display.js
Normal file
69
xxxthegame/src/main/resources/static/js/card-display.js
Normal 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>';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {});
|
||||
})();
|
||||
|
||||
494
xxxthegame/src/main/resources/static/keyholder.html
Normal file
494
xxxthegame/src/main/resources/static/keyholder.html
Normal 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 <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>
|
||||
758
xxxthegame/src/main/resources/static/meine-locks.html
Normal file
758
xxxthegame/src/main/resources/static/meine-locks.html
Normal 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 "X–Y" 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>
|
||||
@@ -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); }
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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, '"') : ''}">
|
||||
<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
Reference in New Issue
Block a user