Chastity game angefangen

This commit is contained in:
2026-03-15 22:51:10 +01:00
parent 3e23ae788b
commit 57a7c78037
359 changed files with 27638 additions and 1109 deletions

View File

@@ -18,6 +18,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.Principal;
import java.util.Comparator;
import java.util.List;
@@ -28,6 +31,7 @@ import java.util.UUID;
@Transactional
public class AboController {
private static final Logger LOGGER = LoggerFactory.getLogger(AboController.class);
private static final int DEFAULT_PAGE_SIZE = 5;
private static final int DISCOVER_PAGE_SIZE = 10;
@@ -105,6 +109,7 @@ public class AboController {
abo.setUserId(user.getUserId());
abo.setAufgabenGruppe(gruppe);
aboRepository.save(abo);
LOGGER.info("User {} hat Gruppe {} abonniert", user.getUserId(), gruppenId);
return ResponseEntity.status(201).build();
}
@@ -119,6 +124,7 @@ public class AboController {
if (gruppe == null) return ResponseEntity.noContent().build();
aboRepository.deleteByUserIdAndAufgabenGruppe(user.getUserId(), gruppe);
LOGGER.info("User {} hat Abo auf Gruppe {} beendet", user.getUserId(), gruppenId);
return ResponseEntity.accepted().build();
}

View File

@@ -67,6 +67,7 @@ public class AufgabeController {
List<ToyEntity> toys = resolveToys(aufgabe.getBenoetigteToys());
AufgabeEntity entity = AufgabeEntity.create(aufgabe, gruppeEntity, toys);
aufgabeRepository.save(entity);
LOGGER.debug("Aufgabe {} '{}' in Gruppe {} erstellt", entity.getAufgabeId(), entity.getKurzText(), aufgabe.getGruppeId());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getAufgabeId()).toUri()
).build();
@@ -88,6 +89,7 @@ public class AufgabeController {
entity.setBenoetigtPassiv(aufgabe.getBenoetigtPassiv());
entity.setBenoetigteToys(resolveToys(aufgabe.getBenoetigteToys()));
aufgabeRepository.save(entity);
LOGGER.debug("Aufgabe {} aktualisiert", aufgabeId);
return ResponseEntity.ok().build();
}

View File

@@ -150,6 +150,7 @@ public class AufgabenGruppeController {
entity.setUserId(user.getUserId());
entity.setPrivateGruppe(true);
gruppeRepository.save(entity);
LOGGER.debug("User {} hat AufgabenGruppe '{}' ({}) erstellt", user.getUserId(), entity.getName(), entity.getGruppenId());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getGruppenId()).toUri()
).build();
@@ -179,6 +180,7 @@ public class AufgabenGruppeController {
entity.setBild(Base64.getDecoder().decode(gruppe.getBild()));
}
gruppeRepository.save(entity);
LOGGER.debug("User {} hat AufgabenGruppe {} aktualisiert", user.getUserId(), gruppeId);
return ResponseEntity.ok().build();
}
@@ -293,6 +295,7 @@ public class AufgabenGruppeController {
finisherRepository.save(fc);
}
LOGGER.info("User {} hat AufgabenGruppe {} kopiert (Quelle: {})", user.getUserId(), copy.getGruppenId(), gruppeId);
return ResponseEntity.status(201).build();
}

View File

@@ -61,6 +61,7 @@ public class FavoritController {
if (existing.isEmpty()) {
entity = FavoritEntity.fromFavorit(favorit, userId);
favoritRepository.save(entity);
LOGGER.debug("User {} hat AufgabenGruppe {} als Favorit gespeichert", userId, favorit.getAufgabenGruppeId());
} else {
entity = existing.get(0);
}

View File

@@ -68,6 +68,7 @@ public class FinisherController {
List<ToyEntity> toys = resolveToys(finisher.getBenoetigteToys());
FinisherEntity entity = FinisherEntity.create(finisher, gruppeEntity, toys);
finisherRepository.save(entity);
LOGGER.debug("Finisher {} '{}' in Gruppe {} erstellt", entity.getFinisherId(), entity.getKurzText(), finisher.getGruppeId());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getFinisherId()).toUri()
).build();
@@ -87,6 +88,7 @@ public class FinisherController {
entity.setBenoetigtPassiv(finisher.getBenoetigtPassiv());
entity.setBenoetigteToys(resolveToys(finisher.getBenoetigteToys()));
finisherRepository.save(entity);
LOGGER.debug("Finisher {} aktualisiert", finisherId);
return ResponseEntity.ok().build();
}

View File

@@ -68,6 +68,7 @@ public class SperreController {
List<ToyEntity> toys = resolveToys(sperre.getBenoetigteToys());
SperreEntity entity = SperreEntity.create(sperre, gruppeEntity, toys);
sperreRepository.save(entity);
LOGGER.debug("Sperre {} '{}' in Gruppe {} erstellt", entity.getSperreId(), entity.getKurzText(), sperre.getGruppeId());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getSperreId()).toUri()
).build();
@@ -89,6 +90,7 @@ public class SperreController {
entity.setSperreFuer(sperre.getSperreFuer());
entity.setBenoetigteToys(resolveToys(sperre.getBenoetigteToys()));
sperreRepository.save(entity);
LOGGER.debug("Sperre {} aktualisiert", sperreId);
return ResponseEntity.ok().build();
}

View File

@@ -67,6 +67,7 @@ public class StrafeController {
List<ToyEntity> toys = resolveToys(strafe.getBenoetigteToys());
StrafeEntity entity = StrafeEntity.create(strafe, gruppeEntity, toys);
strafeRepository.save(entity);
LOGGER.debug("Strafe {} '{}' in Gruppe {} erstellt", entity.getStrafeId(), entity.getKurzText(), strafe.getGruppeId());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getStrafeId()).toUri()
).build();
@@ -88,6 +89,7 @@ public class StrafeController {
entity.setBenoetigtPassiv(strafe.getBenoetigtPassiv());
entity.setBenoetigteToys(resolveToys(strafe.getBenoetigteToys()));
strafeRepository.save(entity);
LOGGER.debug("Strafe {} aktualisiert", strafeId);
return ResponseEntity.ok().build();
}

View File

@@ -145,6 +145,7 @@ public class ToyController {
ToyEntity entity = ToyEntity.create(toy);
entity.setUserId(user.getUserId());
toyRepository.save(entity);
LOGGER.debug("User {} hat Toy {} '{}' erstellt", user.getUserId(), entity.getToyId(), entity.getName());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getToyId()).toUri()
).build();
@@ -175,6 +176,7 @@ public class ToyController {
copy.setUserId(user.getUserId());
copy.setBild(source.getBild());
toyRepository.save(copy);
LOGGER.debug("User {} hat System-Toy {} kopiert (Kopie: {})", user.getUserId(), toyId, copy.getToyId());
return ResponseEntity.status(201).build();
}
@@ -206,6 +208,7 @@ public class ToyController {
entity.setBild(Base64.getDecoder().decode(toy.getBild()));
}
toyRepository.save(entity);
LOGGER.debug("User {} hat Toy {} aktualisiert", user.getUserId(), toyId);
return ResponseEntity.ok().build();
}

View File

@@ -43,6 +43,7 @@ public class SecurityConfig {
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionvanilla.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionbdsm.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionchastity.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionchastityingame.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionbdsmtasks.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionbdsmtoys.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionbdsmingame.html")).authenticated()
@@ -75,6 +76,7 @@ public class SecurityConfig {
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/password-reset/request")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/password-reset/confirm")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/email-change/**")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/keyholder/invitation/**")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/filler")).permitAll()
.anyRequest().authenticated()
)

View File

@@ -46,10 +46,15 @@ import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestController
@RequestMapping("/feed")
public class FeedController {
private static final Logger LOGGER = LoggerFactory.getLogger(FeedController.class);
private final FeedPostRepository feedPostRepository;
private final FeedPostLikeRepository feedPostLikeRepository;
private final FeedPostOptionRepository feedPostOptionRepository;
@@ -120,6 +125,7 @@ public class FeedController {
post.setPublic(req.isPublic());
post.setCreatedAt(LocalDateTime.now());
feedPostRepository.save(post);
LOGGER.info("User {} hat Feed-Post {} erstellt (Typ: {}, public: {})", myId, post.getPostId(), typ, post.isPublic());
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
for (int i = 0; i < req.optionen().size(); i++) {
@@ -250,6 +256,7 @@ public class FeedController {
var existing = feedPostLikeRepository.findByPostIdAndUserId(id, myId);
if (existing.isPresent()) {
feedPostLikeRepository.delete(existing.get());
LOGGER.debug("User {} hat Like auf Feed-Post {} entfernt", myId, id);
} else {
FeedPostLikeEntity like = new FeedPostLikeEntity();
like.setLikeId(UUID.randomUUID());
@@ -257,6 +264,7 @@ public class FeedController {
like.setUserId(myId);
like.setLikedAt(LocalDateTime.now());
feedPostLikeRepository.save(like);
LOGGER.debug("User {} hat Feed-Post {} geliked", myId, id);
}
return ResponseEntity.ok().build();
}
@@ -297,6 +305,7 @@ public class FeedController {
vote.setPostId(id);
vote.setUserId(myId);
feedPostVoteRepository.save(vote);
LOGGER.debug("User {} hat für Option {} in Feed-Post {} gestimmt", myId, req.optionId(), id);
return ResponseEntity.ok().build();
}
@@ -319,6 +328,7 @@ public class FeedController {
var kommentare = kommentarRepository.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("FEED_POST", id);
kommentarRepository.deleteAll(kommentare);
feedPostRepository.delete(post);
LOGGER.info("User {} hat Feed-Post {} gelöscht", myId, id);
return ResponseEntity.noContent().build();
}

View File

@@ -0,0 +1,155 @@
package de.oaa.xxx.games.chastity;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Random;
import java.util.Set;
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.CardLockEntity;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.games.chastity.verification.VerificationEntity;
import de.oaa.xxx.games.chastity.verification.VerificationRepository;
import de.oaa.xxx.games.chastity.verification.VerificationVoteEntity;
import de.oaa.xxx.games.chastity.verification.VerificationVoteRepository;
public class CardLockService extends ProcessLock {
private static final Logger LOGGER = LoggerFactory.getLogger(CardLockService.class);
private final CardLockEntity lock;
private VerificationRepository verificationRepository;
private VerificationVoteRepository verificationVoteRepository;
private CardLockRepository cardLockRepository;
public CardLockService(CardLockEntity lock, VerificationRepository verificationRepository, VerificationVoteRepository verificationVoteRepository, CardLockRepository cardLockRepository) {
this.lock = lock;
this.verificationRepository = verificationRepository;
this.verificationVoteRepository = verificationVoteRepository;
this.cardLockRepository = cardLockRepository;
}
public String getNextCard() {
LOGGER.debug("New Card requested by user {}", lock.getLockee());
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);
}
return "";
}
public String doubleUp() {
var cards = lock.getAvailableCards();
LOGGER.debug("Double up {} cards", cards.size());
lock.getAvailableCards().addAll(cards);
LOGGER.debug("Now {} cards", lock.getAvailableCards().size());
return "";
}
public String reset() {
LOGGER.debug("Reset to initial cards");
lock.setAvailableCards(lock.getInitialCards());
return "";
}
public String unlock() {
this.lock.setUnlockTime(LocalDateTime.now());
boolean valid = true;
if (!this.lock.isTestLock()) {
if (Duration.between(lock.getStartTime(), lock.getUnlockTime()).toHours() > 24) {
Set<LocalDate> verifications = verificationRepository.findByLockId(this.lock.getLockId()).stream()
.filter(verification -> isValid(verification))
.map(verification -> verification.getVerificationTime().toLocalDate())
.collect(Collectors.toSet());
LocalDate current = this.lock.getStartTime().toLocalDate().plusDays(1);
LocalDate last = this.lock.getUnlockTime().toLocalDate();
while (!current.isAfter(last)) {
if (!verifications.contains(current)) {
valid = false;
break;
}
current = current.plusDays(1);
}
}
}
if (valid) {
}
// XP berechnen
// Lock löschen
lock.setUnlockTime(LocalDateTime.now());
LOGGER.debug("Unlocked at {}", lock.getUnlockTime());
return this.lock.getUnlockCode();
}
private boolean isValid(VerificationEntity entity) {
int count = 0;
for (VerificationVoteEntity vote : verificationVoteRepository.findAllByVerificationId(entity.getVerficationId())) {
if (vote.isUpvote()) {
count++;
} else {
count--;
}
}
return count >= 0;
}
public String freeze() {
var multiplier = lock.getPickEveryMinute() * new Random().nextDouble(1.0, 4.0);
freeze(multiplier);
return "";
}
private String freeze(double multiplier) {
LocalDateTime frozenTill = LocalDateTime.now().plus((long) multiplier, ChronoUnit.MINUTES);
lock.setFrozenUntill(frozenTill);
LOGGER.debug("Frozen until {}", lock.getFrozenUntill());
return "";
}
public String task() {
LOGGER.debug("Apply random task");
var tasks = lock.getTasks();
if (!tasks.isEmpty()) {
task(tasks.get(new Random().nextInt(tasks.size())));
}
return "";
}
public String task(Task task) {
LOGGER.debug("Apply task {}", task);
lock.setCurrentTask(task.getText());
if (task.getMinutes() != null) {
freeze(task.getMinutes());
}
return "";
}
public String clearTask() {
LOGGER.debug("Clear task");
lock.setFrozenUntill(null);
lock.setCurrentTask(null);
return "";
}
public String redCard() {
return "";
}
public String yellowCard() {
return "";
}
}

View File

@@ -0,0 +1,25 @@
package de.oaa.xxx.games.chastity;
import java.util.Random;
public class CodeCreator {
private static final String CHARS_AN = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789";
private static final String CHARS_N = "0123456789";
public static String createNumeric(int digits) {
return create(digits, CHARS_N);
}
public static String createAlphanumericCode(int digits) {
return create(digits, CHARS_AN);
}
private static String create(int digits, String chars) {
StringBuilder sb = new StringBuilder(6);
for (int i = 0; i < digits; i++) {
sb.append(chars.charAt(new Random().nextInt(chars.length())));
}
return sb.toString();
}
}

View File

@@ -12,38 +12,34 @@ import de.oaa.xxx.games.chastity.cardlock.CardEnum;
import de.oaa.xxx.games.chastity.cardlock.CardLockEntity;
import de.oaa.xxx.games.chastity.tasks.Task;
public class KeyholdeCardLock {
private static final Logger LOGGER = LoggerFactory.getLogger(KeyholdeCardLock.class);
public class KeyholderCardLock {
private static final Logger LOGGER = LoggerFactory.getLogger(KeyholderCardLock.class);
private CardLockEntity lock;
public void addCards(List<CardEnum> cards) {
lock.getAvailableCards().addAll(cards);
}
public void freeze() {
var multiplier = lock.getPickEveryMinute() * new Random().nextDouble(1.0, 4.0);
LocalDateTime frozenTill = LocalDateTime.now().plus((long) multiplier, ChronoUnit.MINUTES);
lock.setFrozenUntill(frozenTill);
LOGGER.debug("Frozen by keyholder untill %s", lock.getFrozenUntill());
LOGGER.debug("Frozen by keyholder until {}", lock.getFrozenUntill());
}
public void freeze(LocalDateTime untill) {
lock.setFrozenUntill(untill);
LOGGER.debug("Frozen by keyholder untill %s", lock.getFrozenUntill());
public void freeze(LocalDateTime until) {
lock.setFrozenUntill(until);
LOGGER.debug("Frozen by keyholder until {}", lock.getFrozenUntill());
}
public void task() {
}
public void task(Task task) {
}
public void penalty() {
}
}

View File

@@ -1,8 +0,0 @@
package de.oaa.xxx.games.chastity;
public class KeyholderController {
}

View File

@@ -0,0 +1,41 @@
package de.oaa.xxx.games.chastity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "keyholder_invitation")
public class KeyholderInvitationEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column
private UUID id;
@Column(nullable = false)
private UUID lockId;
@Column(nullable = false)
private UUID keyholderUserId;
@Column(nullable = false, unique = true)
private String token;
@Column(nullable = false)
private LocalDateTime createdAt;
public UUID getId() { return id; }
public UUID getLockId() { return lockId; }
public void setLockId(UUID lockId) { this.lockId = lockId; }
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; }
}

View File

@@ -0,0 +1,12 @@
package de.oaa.xxx.games.chastity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import java.util.UUID;
public interface KeyholderInvitationRepository extends JpaRepository<KeyholderInvitationEntity, UUID> {
Optional<KeyholderInvitationEntity> findByToken(String token);
@Transactional
void deleteByLockId(UUID lockId);
}

View File

@@ -1,5 +0,0 @@
package de.oaa.xxx.games.chastity;
public class PrcoessTimedLock {
}

View File

@@ -1,104 +0,0 @@
package de.oaa.xxx.games.chastity;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Random;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import de.oaa.xxx.games.chastity.cardlock.CardEnum;
import de.oaa.xxx.games.chastity.cardlock.CardLockEntity;
import de.oaa.xxx.games.chastity.tasks.Task;
public class ProcessCardLock extends ProcessLock {
private static final Logger LOGGER = LoggerFactory.getLogger(ProcessCardLock.class);
private final CardLockEntity lock;
public ProcessCardLock(CardLockEntity lock) {
this.lock = lock;
}
public String getNextCard() {
LOGGER.debug("New Card requested by user %s", lock.getLockee().toString());
if (lock.getAvailableCards().size() > 0) {
var card = lock.getAvailableCards().get(new Random().nextInt(lock.getAvailableCards().size()));
LOGGER.debug("Card drafted: %s", card.toString());
card.get().processCard(this);
}
return "";
}
public String doubleUp() {
List<CardEnum> cards = lock.getAvailableCards();
LOGGER.debug("Double up %i cards", cards.size());
lock.getAvailableCards().addAll(cards);
LOGGER.debug("Now %i cards", cards.size());
return "";
}
public String reset() {
LOGGER.debug("Reset to initial cards");
lock.setAvailableCards(lock.getInitialCards());
return "";
}
public String unlock() {
// Verifications prüfen
// Historie eintragen
// XP berechnen
// Lock löschen
lock.setUnlockTime(LocalDateTime.now());
LOGGER.debug("Unlocked at " + lock.getUnlockTime());
return "";
}
public String freeze() {
var multiplier = lock.getPickEveryMinute() * new Random().nextDouble(1.0, 4.0);
freeze(multiplier);
return "";
}
private String freeze(double multiplier) {
LocalDateTime frozenTill = LocalDateTime.now().plus((long) multiplier, ChronoUnit.MINUTES);
lock.setFrozenUntill(frozenTill);
LOGGER.debug("Frozen untill %s", lock.getFrozenUntill());
return "";
}
public String task() {
LOGGER.debug("Apply random task");
if (lock.getTasks().size() > 0) {
task(lock.getTasks().get(new Random().nextInt(lock.getTasks().size())));
}
return "";
}
public String task(Task task) {
LOGGER.debug("Apply task %s", task);
lock.setCurrentTask(task.getText());
if (task.getMinutes() != null) {
freeze(task.getMinutes());
}
return "";
}
public String clearTask() {
LOGGER.debug("Clear task");
lock.setFrozenUntill(null);
lock.setCurrentTask(null);
return "";
}
public String redCard() {
return "";
}
public String yellowCard() {
return "";
}
}

View File

@@ -0,0 +1,5 @@
package de.oaa.xxx.games.chastity;
public class ProcessTimedLock extends ProcessLock {
}

View File

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

View File

@@ -2,29 +2,49 @@ package de.oaa.xxx.games.chastity.cardlock;
public enum CardEnum {
ROT {
RED {
@Override
public Card get() {
return new RedCard();
}
},
GRUEN {
GREEN {
@Override
public Card get() {
return new GreenCard();
}
},
GELB {
YELLOW {
@Override
public Card get() {
return new YellowCard();
}
},
AUFGABE {
TASK {
@Override
public Card get() {
return new TaskCard();
}
},
FREEZE {
@Override
public Card get() {
return new FreezeCard();
}
},
RESET {
@Override
public Card get() {
return new ResetCard();
}
},
DOUBLE_UP {
@Override
public Card get() {
return new DoubleUpCard();
}
};
public abstract Card get();
}

View File

@@ -0,0 +1,36 @@
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.ArrayList;
import java.util.List;
@Converter
public class CardEnumListConverter implements AttributeConverter<List<CardEnum>, String> {
private static final ObjectMapper mapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<CardEnum> list) {
if (list == null || list.isEmpty()) return null;
try {
return mapper.writeValueAsString(list.stream().map(Enum::name).toList());
} catch (Exception e) {
return null;
}
}
@Override
public List<CardEnum> convertToEntityAttribute(String json) {
if (json == null || json.isBlank()) return new ArrayList<>();
try {
List<String> names = mapper.readValue(json, new TypeReference<>() {});
return new ArrayList<>(names.stream().map(CardEnum::valueOf).toList());
} catch (Exception e) {
return new ArrayList<>();
}
}
}

View File

@@ -0,0 +1,249 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.KeyholderInvitationEntity;
import de.oaa.xxx.games.chastity.KeyholderInvitationRepository;
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.user.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/keyholder")
public class CardLockController {
private final CardlockRepository cardlockRepository;
private final UserRepository userRepository;
private final KeyholderInvitationRepository invitationRepository;
private final MailService mailService;
private final MailTemplateService mailTemplateService;
@Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
public CardLockController(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;
}
record CreateCardLockRequest(
String name,
UUID keyholder,
List<CardEnum> initialCards,
Integer pickEveryMinute,
boolean accumulatePicks,
boolean showRemainingCards,
LocalDateTime latestOpeningtime,
Integer hygineOpeningDurationMinutes,
Integer hygineOpeningEveryMinites,
List<Task> tasks,
boolean requiresVerification,
boolean testLock,
Integer unlockCodeLines
) {}
private static final SecureRandom RNG = new SecureRandom();
private String generateUnlockCode(int lines) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < lines; i++) {
sb.append(RNG.nextInt(10));
}
return sb.toString();
}
@PostMapping("/cardlock")
public ResponseEntity<Map<String, Object>> createCardLock(
@RequestBody CreateCardLockRequest 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();
if (req.initialCards() == null || req.initialCards().isEmpty()
|| req.pickEveryMinute() == null || req.pickEveryMinute() < 1)
return ResponseEntity.badRequest().build();
int codeLines = (req.unlockCodeLines() != null && req.unlockCodeLines() >= 1)
? req.unlockCodeLines() : 5;
String unlockCode = generateUnlockCode(codeLines);
CardLockEntity lock = new CardLockEntity();
lock.setName(req.name());
lock.setLockee(myId);
lock.setKeyholder(null); // set only after invitation is confirmed
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(req.testLock());
lock.setUnlockCodeLines(codeLines);
lock.setUnlockCode(unlockCode);
LocalDateTime now = LocalDateTime.now();
lock.setStartTime(now);
lock.setAvailableCards(new ArrayList<>(req.initialCards()));
lock.setOpenPicks(0);
lock.setNextCardIn(now.plusMinutes(req.pickEveryMinute()));
lock.setHygineOpeningtime(now);
cardlockRepository.save(lock);
boolean keyholderPending = false;
if (req.keyholder() != null) {
var khOpt = userRepository.findById(req.keyholder());
if (khOpt.isPresent()) {
var kh = khOpt.get();
String token = UUID.randomUUID().toString().replace("-", "");
KeyholderInvitationEntity inv = new KeyholderInvitationEntity();
inv.setLockId(lock.getLockId());
inv.setKeyholderUserId(kh.getUserId());
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);
keyholderPending = true;
}
}
return ResponseEntity.ok(Map.of(
"lockId", lock.getLockId().toString(),
"unlockCode", unlockCode,
"keyholderPending", keyholderPending
));
}
@GetMapping("/mylock")
public ResponseEntity<Map<String, Object>> getMyActiveLock(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
var locks = cardlockRepository.findByLockee(meOpt.get().getUserId());
var active = locks.stream()
.filter(l -> l.getUnlockTime() == null)
.findFirst();
if (active.isEmpty()) return ResponseEntity.noContent().build();
return ResponseEntity.ok(Map.of("lockId", active.get().getLockId().toString()));
}
@GetMapping("/cardlock/{lockId}")
public ResponseEntity<Map<String, Object>> getLock(@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();
Map<String, Long> cardCounts = new LinkedHashMap<>();
if (l.getAvailableCards() != null) {
l.getAvailableCards().forEach(c -> cardCounts.merge(c.name(), 1L, Long::sum));
}
long totalCards = l.getAvailableCards() != null ? l.getAvailableCards().size() : 0;
// Hygiene-Berechnung
boolean hygieneEnabled = l.getHygineOpeningEveryMinites() != null;
boolean hygieneOpeningDue = false;
long hygieneMinutesRemaining = 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;
}
}
Map<String, Object> result = new HashMap<>();
result.put("lockId", l.getLockId().toString());
result.put("name", l.getName() != null ? l.getName() : "");
result.put("showRemainingCards", l.isShowRemainingCards());
result.put("availableCardCounts", cardCounts);
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);
return ResponseEntity.ok(result);
}
@Transactional
@DeleteMapping("/cardlock/{lockId}")
public ResponseEntity<Void> deleteLock(@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();
invitationRepository.deleteByLockId(lockId);
cardlockRepository.deleteById(lockId);
return ResponseEntity.noContent().build();
}
@GetMapping("/invitation/{token}")
public void confirmInvitation(@PathVariable String token,
jakarta.servlet.http.HttpServletResponse response) throws Exception {
var invOpt = invitationRepository.findByToken(token);
if (invOpt.isEmpty()) {
response.sendRedirect("/keyholder-invitation-confirmed.html?status=invalid");
return;
}
var inv = invOpt.get();
var lockOpt = cardlockRepository.findById(inv.getLockId());
if (lockOpt.isEmpty()) {
response.sendRedirect("/keyholder-invitation-confirmed.html?status=invalid");
return;
}
var lock = lockOpt.get();
lock.setKeyholder(inv.getKeyholderUserId());
cardlockRepository.save(lock);
invitationRepository.delete(inv);
response.sendRedirect("/keyholder-invitation-confirmed.html?status=ok");
}
}

View File

@@ -5,36 +5,98 @@ import java.util.List;
import java.util.UUID;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.games.chastity.tasks.TaskListConverter;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "card_lock")
public class CardLockEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column
private UUID lockId;
@Column
private String name;
// Settings
@Column(nullable = false)
private UUID lockee;
@Column
private UUID keyholder;
@Convert(converter = CardEnumListConverter.class)
@Column(columnDefinition = "TEXT")
private List<CardEnum> initialCards;
@Column
private Integer pickEveryMinute;
@Column
private boolean accumulatePicks;
@Column
private boolean showRemainingCards;
@Column
private LocalDateTime latestOpeningtime;
@Column
private LocalDateTime frozenUntill;
@Column
private Integer hygineOpeningDurationMinutes;
@Column
private Integer hygineOpeningEveryMinites;
@Convert(converter = TaskListConverter.class)
@Column(columnDefinition = "TEXT")
private List<Task> tasks;
@Column
private boolean requiresVerification;
@Column
private boolean testLock;
@Column
private Integer unlockCodeLines;
// State
@Column
private LocalDateTime startTime;
@Column
private LocalDateTime nextCardIn;
@Column
private Integer openPicks;
@Convert(converter = CardEnumListConverter.class)
@Column(columnDefinition = "TEXT")
private List<CardEnum> availableCards;
@Column
private LocalDateTime lastHygineOpening;
@Column
private LocalDateTime hygineOpeningtime; // If null, not while hygine opening
@Column
private LocalDateTime unlockTime;
@Column
private String currentTask;
@Convert(converter = TaskListConverter.class)
@Column(columnDefinition = "TEXT")
private List<Task> tasksInQueue;
@Column
private String unlockCode;
public UUID getLockId() {
return lockId;
}
public void setLockId(UUID lockId) {
this.lockId = lockId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public UUID getLockee() {
return lockee;
@@ -44,6 +106,14 @@ public class CardLockEntity {
this.lockee = lockee;
}
public UUID getKeyholder() {
return keyholder;
}
public void setKeyholder(UUID keyholder) {
this.keyholder = keyholder;
}
public List<CardEnum> getInitialCards() {
return initialCards;
}
@@ -188,4 +258,35 @@ public class CardLockEntity {
this.tasksInQueue = tasksInQueue;
}
public boolean isRequiresVerification() {
return requiresVerification;
}
public void setRequiresVerification(boolean requiresVerification) {
this.requiresVerification = requiresVerification;
}
public boolean isTestLock() {
return testLock;
}
public void setTestLock(boolean testLock) {
this.testLock = testLock;
}
public String getUnlockCode() {
return unlockCode;
}
public void setUnlockCode(String unlockCode) {
this.unlockCode = unlockCode;
}
public Integer getUnlockCodeLines() {
return unlockCodeLines;
}
public void setUnlockCodeLines(Integer unlockCodeLines) {
this.unlockCodeLines = unlockCodeLines;
}
}

View File

@@ -0,0 +1,9 @@
package de.oaa.xxx.games.chastity.cardlock;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CardLockRepository extends JpaRepository<CardLockEntity, UUID> {
}

View File

@@ -1,5 +1,11 @@
package de.oaa.xxx.games.chastity.cardlock;
public class CardlockRepository {
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CardlockRepository extends JpaRepository<CardLockEntity, UUID> {
List<CardLockEntity> findByLockee(UUID lockee);
}

View File

@@ -1,11 +1,11 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.ProcessCardLock;
import de.oaa.xxx.games.chastity.CardLockService;
public class DoubleUpCard implements Card {
@Override
public CardDTO processCard(ProcessCardLock lock) {
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.doubleUp(), "img/card_red.png");
}
}

View File

@@ -1,11 +1,11 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.ProcessCardLock;
import de.oaa.xxx.games.chastity.CardLockService;
public class FreezeCard implements Card {
@Override
public CardDTO processCard(ProcessCardLock lock) {
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.freeze(), "img/card_freeze.png");
}

View File

@@ -1,11 +1,11 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.ProcessCardLock;
import de.oaa.xxx.games.chastity.CardLockService;
public class GreenCard implements Card {
@Override
public CardDTO processCard(ProcessCardLock lock) {
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.unlock(), "img/card_green.png");
}
}

View File

@@ -1,11 +1,11 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.ProcessCardLock;
import de.oaa.xxx.games.chastity.CardLockService;
public class RedCard implements Card {
@Override
public CardDTO processCard(ProcessCardLock lock) {
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.redCard(), "img/card_red.png");
}

View File

@@ -1,11 +1,11 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.ProcessCardLock;
import de.oaa.xxx.games.chastity.CardLockService;
public class ResetCard implements Card {
@Override
public CardDTO processCard(ProcessCardLock lock) {
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.reset(), "img/card_reset.png");
}

View File

@@ -1,11 +1,11 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.ProcessCardLock;
import de.oaa.xxx.games.chastity.CardLockService;
public class TaskCard implements Card {
@Override
public CardDTO processCard(ProcessCardLock lock) {
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.task(), "img/card_task.png");
}

View File

@@ -1,11 +1,11 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.ProcessCardLock;
import de.oaa.xxx.games.chastity.CardLockService;
public class YellowCard implements Card {
@Override
public CardDTO processCard(ProcessCardLock lock) {
public CardDTO processCard(CardLockService lock) {
return new CardDTO(lock.yellowCard(), "img/card_yellow.png");
}
}

View File

@@ -6,6 +6,10 @@ import java.util.UUID;
import de.oaa.xxx.games.chastity.LockType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@@ -14,6 +18,7 @@ import jakarta.persistence.Table;
public class LockHistoryEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column
private UUID historyId;
@Column(nullable = false)
@@ -22,6 +27,7 @@ public class LockHistoryEntity {
private LocalDateTime startTime;
@Column(nullable = false)
private LocalDateTime endTime;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private LockType type;
@Column

View File

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

View File

@@ -2,12 +2,8 @@ package de.oaa.xxx.games.chastity.verification;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -24,7 +20,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.aufgaben.controller.AboController;
import de.oaa.xxx.games.chastity.CodeCreator;
import de.oaa.xxx.user.UserRepository;
@RestController
@@ -32,15 +28,14 @@ import de.oaa.xxx.user.UserRepository;
@Transactional
public class VerificationController {
private static final String CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private final VerificationRepository verificationRepository;
private final VerificationVoteRepository verificationVoteRepository;
private final UserRepository userRepository;
public VerificationController(VerificationRepository verificationRepository,
VerificationVoteRepository verificationVoteRepository, UserRepository userRepository,
AboController aboController) {
VerificationVoteRepository verificationVoteRepository, UserRepository userRepository) {
this.verificationRepository = verificationRepository;
this.verificationVoteRepository = verificationVoteRepository;
this.userRepository = userRepository;
@@ -48,40 +43,36 @@ public class VerificationController {
@GetMapping("/{verificationId}")
public ResponseEntity<VerificationDTO> get(@PathVariable UUID verificationId) {
return verificationRepository.findById(verificationId).map(entity -> ResponseEntity.ok(entity.toVerification()))
.orElse(ResponseEntity.noContent().build());
}
@GetMapping("/")
public ResponseEntity<List<VerificationDTO>> getAll(@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "10") int size) {
var paging = PageRequest.of(page, size, Sort.by("verificationTime").descending());
Page<VerificationEntity> result = verificationRepository.findAllByImageIsNotNull(paging);
return ResponseEntity.ok(result.stream().map(entity -> entity.toVerification()).toList());
}
@GetMapping("/new")
public ResponseEntity<VerificationDTO> createVerification() {
VerificationEntity verification = new VerificationEntity();
verification.setVerficationId(UUID.randomUUID());
verification.setCode(createCode());
verification.setVerificationTime(LocalDateTime.now());
verificationRepository.save(verification);
return ResponseEntity.ok(verification.toVerification());
}
@GetMapping("/{verificationId}")
public ResponseEntity<VerificationDTO> getVerification(@PathVariable UUID verificationId) {
Optional<VerificationEntity> optional = verificationRepository.findById(verificationId);
var optional = verificationRepository.findById(verificationId);
if (optional.isEmpty()) {
return ResponseEntity.noContent().build();
}
var dto = optional.get().toVerification();
dto.votes().addAll(verificationVoteRepository.findAllByVerificationId(verificationId).stream()
.map(entity -> entity.toVerificationVote()).collect(Collectors.toList()));
verificationVoteRepository.findAllByVerificationId(verificationId).stream()
.map(VerificationVoteEntity::toVerificationVote)
.forEach(dto.votes()::add);
return ResponseEntity.ok(dto);
}
@GetMapping("/")
public ResponseEntity<List<VerificationDTO>> getAll(
@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "10") int size) {
var paging = PageRequest.of(page, size, Sort.by("verificationTime").descending());
Page<VerificationEntity> result = verificationRepository.findAllByImageIsNotNull(paging);
return ResponseEntity.ok(result.stream().map(VerificationEntity::toVerification).toList());
}
@GetMapping("/new")
public ResponseEntity<VerificationDTO> createVerification() {
var verification = new VerificationEntity();
verification.setVerficationId(UUID.randomUUID());
verification.setCode(CodeCreator.createAlphanumericCode(6));
verification.setVerificationTime(LocalDateTime.now());
verificationRepository.save(verification);
return ResponseEntity.ok(verification.toVerification());
}
@PutMapping("/{verificationId}")
public ResponseEntity<Void> update(@PathVariable UUID verificationId, @RequestBody VerificationDTO dto,
Principal principal) {
@@ -93,13 +84,11 @@ public class VerificationController {
if (entity == null) {
return ResponseEntity.notFound().build();
}
var codeErstelltAm = entity.getVerificationTime();
var vorEinerStunde = LocalDateTime.now().minusHours(1);
if (codeErstelltAm.isBefore(vorEinerStunde)) {
if (entity.getVerificationTime().isBefore(LocalDateTime.now().minusHours(1))) {
return ResponseEntity.status(HttpStatus.GONE).build();
}
if (dto.image() != null) {
entity.setImage(Base64.getDecoder().decode(dto.image()));
entity.setImage(dto.image());
}
verificationRepository.save(entity);
return ResponseEntity.ok().build();
@@ -112,23 +101,16 @@ public class VerificationController {
if (user == null) {
return ResponseEntity.status(401).build();
}
var verification = verificationRepository.findById(verificationId).orElse(null);
if (verification == null) {
if (!verificationRepository.existsById(verificationId)) {
return ResponseEntity.notFound().build();
}
var vote = new VerificationVoteEntity();
vote.setVoteId(UUID.randomUUID());
vote.setUserId(dto.userId());
vote.setVerificationId(verificationId);
vote.setUserId(user.getUserId());
vote.setUpvote(dto.upvote());
verificationVoteRepository.save(vote);
return ResponseEntity.accepted().build();
}
private String createCode() {
StringBuilder sb = new StringBuilder(6);
for (int i = 0; i < 6; i++) {
sb.append(CHARS.charAt(new Random().nextInt(CHARS.length())));
}
return sb.toString();
}
}

View File

@@ -1,6 +1,7 @@
package de.oaa.xxx.games.chastity.verification;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.UUID;
import jakarta.persistence.Column;
@@ -15,39 +16,56 @@ public class VerificationEntity {
@Id
@Column
private UUID verficationId;
@Column
private UUID lockId;
@Column(nullable = false)
private String code;
@Column(nullable = false)
private LocalDateTime verificationTime;
@Column(columnDefinition = "BLOB")
@Column(columnDefinition = "BLOB")
private byte[] image;
public UUID getVerficationId() {
return verficationId;
}
public void setVerficationId(UUID verficationId) {
this.verficationId = verficationId;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public LocalDateTime getVerificationTime() {
return verificationTime;
}
public void setVerificationTime(LocalDateTime verificationTime) {
this.verificationTime = verificationTime;
}
public byte[] getImage() {
return image;
}
public void setImage(byte[] image) {
this.image = image;
}
public VerificationDTO toVerification() {
return new VerificationDTO(verficationId, code, verificationTime,image, null);
public UUID getLockId() {
return lockId;
}
public void setLockId(UUID lockId) {
this.lockId = lockId;
}
public VerificationDTO toVerification() {
return new VerificationDTO(verficationId, code, verificationTime, image, new ArrayList<>());
}
}

View File

@@ -8,4 +8,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface VerificationRepository extends JpaRepository<VerificationEntity, UUID> {
org.springframework.data.domain.Page<VerificationEntity> findAllByImageIsNotNull(Pageable pageable);
java.util.List<VerificationEntity> findByLockId(UUID lockId);
}

View File

@@ -15,6 +15,8 @@ public class VerificationVoteEntity {
@Column
private UUID voteId;
@Column(nullable = false)
private UUID verificationId;
@Column(nullable = false)
private UUID userId;
@Column(nullable = false)
private boolean upvote;
@@ -25,6 +27,12 @@ public class VerificationVoteEntity {
public void setVoteId(UUID voteId) {
this.voteId = voteId;
}
public UUID getVerificationId() {
return verificationId;
}
public void setVerificationId(UUID verificationId) {
this.verificationId = verificationId;
}
public UUID getUserId() {
return userId;
}

View File

@@ -7,6 +7,8 @@ import de.oaa.xxx.social.entity.KommentarEntity;
import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -18,6 +20,8 @@ import java.util.*;
@RequestMapping("/gruppen")
public class GruppeController {
private static final Logger LOGGER = LoggerFactory.getLogger(GruppeController.class);
private final GruppeRepository gruppeRepository;
private final GruppenmitgliedRepository mitgliedRepository;
private final BeitrittsanfrageRepository anfrageRepository;
@@ -137,6 +141,7 @@ public class GruppeController {
admin.setRolle(GruppenRolle.ADMIN);
admin.setJoinedAt(LocalDateTime.now());
mitgliedRepository.save(admin);
LOGGER.info("User {} hat Gruppe '{}' ({}) erstellt", myId, gruppe.getName(), gruppe.getGruppeId());
return ResponseEntity.status(201).body(toDto(gruppe, myId));
}
@@ -161,6 +166,7 @@ public class GruppeController {
if (req.bild() != null) gruppe.setBild(req.bild());
if (req.isPrivate() != null) gruppe.setPrivate(req.isPrivate());
gruppeRepository.save(gruppe);
LOGGER.debug("User {} hat Gruppe {} aktualisiert", myId, id);
return ResponseEntity.ok(toDto(gruppe, myId));
}
@@ -195,6 +201,7 @@ public class GruppeController {
anfrageRepository.deleteByGruppeId(id);
mitgliedRepository.deleteByGruppeId(id);
gruppeRepository.deleteById(id);
LOGGER.info("User {} hat Gruppe {} gelöscht", myId, id);
return ResponseEntity.noContent().build();
}
@@ -229,6 +236,7 @@ public class GruppeController {
anfrage.setAngefragtAt(LocalDateTime.now());
anfrage.setStatus(AnfrageStatus.AUSSTEHEND);
anfrageRepository.save(anfrage);
LOGGER.info("User {} hat Beitrittsanfrage für private Gruppe {} gestellt", myId, id);
return ResponseEntity.status(201).build();
} else {
GruppenmitgliedEntity mitglied = new GruppenmitgliedEntity();
@@ -238,6 +246,7 @@ public class GruppeController {
mitglied.setRolle(GruppenRolle.MITGLIED);
mitglied.setJoinedAt(LocalDateTime.now());
mitgliedRepository.save(mitglied);
LOGGER.info("User {} ist Gruppe {} beigetreten", myId, id);
return ResponseEntity.status(201).build();
}
}
@@ -250,6 +259,7 @@ public class GruppeController {
if (myId == null) return ResponseEntity.status(401).build();
mitgliedRepository.deleteByGruppeIdAndUserId(id, myId);
LOGGER.info("User {} hat Gruppe {} verlassen", myId, id);
return ResponseEntity.noContent().build();
}
@@ -290,6 +300,7 @@ public class GruppeController {
if (!isAdmin(id, myId)) return ResponseEntity.status(403).build();
mitgliedRepository.deleteByGruppeIdAndUserId(id, userId);
LOGGER.warn("Admin {} hat User {} aus Gruppe {} entfernt", myId, userId, id);
return ResponseEntity.noContent().build();
}
@@ -307,6 +318,7 @@ public class GruppeController {
if (m.isEmpty()) return ResponseEntity.notFound().build();
m.get().setRolle(GruppenRolle.ADMIN);
mitgliedRepository.save(m.get());
LOGGER.info("Admin {} hat User {} in Gruppe {} zum Admin befördert", myId, userId, id);
return ResponseEntity.ok().build();
}
@@ -347,6 +359,7 @@ public class GruppeController {
BeitrittsanfrageEntity anfrage = anfOpt.get();
anfrage.setStatus(AnfrageStatus.GENEHMIGT);
anfrageRepository.save(anfrage);
LOGGER.info("Admin {} hat Beitrittsanfrage {} (User: {}) für Gruppe {} genehmigt", myId, reqId, anfrage.getUserId(), id);
if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, anfrage.getUserId()).isEmpty()) {
GruppenmitgliedEntity mitglied = new GruppenmitgliedEntity();
@@ -376,6 +389,7 @@ public class GruppeController {
BeitrittsanfrageEntity anfrage = anfOpt.get();
anfrage.setStatus(AnfrageStatus.ABGELEHNT);
anfrageRepository.save(anfrage);
LOGGER.debug("Admin {} hat Beitrittsanfrage {} für Gruppe {} abgelehnt", myId, reqId, id);
return ResponseEntity.noContent().build();
}
@@ -443,6 +457,7 @@ public class GruppeController {
anfrageRepository.findByGruppeIdAndUserId(id, myId).ifPresent(a -> {
if (a.getStatus() == AnfrageStatus.AUSSTEHEND) {
anfrageRepository.delete(a);
LOGGER.debug("User {} hat eigene Beitrittsanfrage für Gruppe {} zurückgezogen", myId, id);
}
});
return ResponseEntity.noContent().build();

View File

@@ -6,6 +6,8 @@ import de.oaa.xxx.gruppe.repository.*;
import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.http.ResponseEntity;
@@ -18,6 +20,8 @@ import java.util.*;
@RestController
public class GruppenbeitragController {
private static final Logger LOGGER = LoggerFactory.getLogger(GruppenbeitragController.class);
private final GruppeRepository gruppeRepository;
private final GruppenmitgliedRepository mitgliedRepository;
private final GruppenbeitragRepository beitragRepository;
@@ -118,6 +122,7 @@ public class GruppenbeitragController {
beitrag.setBilder(req.bilder() != null ? req.bilder() : List.of());
beitrag.setCreatedAt(LocalDateTime.now());
beitragRepository.save(beitrag);
LOGGER.debug("User {} hat Beitrag {} (Typ: {}) in Gruppe {} erstellt", myId, beitrag.getBeitragId(), typ, id);
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
for (int i = 0; i < req.optionen().size(); i++) {
@@ -155,6 +160,7 @@ public class GruppenbeitragController {
if (!isAuthor && !isAdmin) return ResponseEntity.status(403).build();
deleteBeitragCascade(beitrag);
LOGGER.info("User {} hat Beitrag {} aus Gruppe {} gelöscht", myId, postId, id);
return ResponseEntity.noContent().build();
}
@@ -172,6 +178,7 @@ public class GruppenbeitragController {
var existing = likeRepository.findByBeitragIdAndUserId(postId, myId);
if (existing.isPresent()) {
likeRepository.delete(existing.get());
LOGGER.debug("User {} hat Like auf Beitrag {} entfernt", myId, postId);
} else {
GruppenbeitragLikeEntity like = new GruppenbeitragLikeEntity();
like.setLikeId(UUID.randomUUID());
@@ -179,6 +186,7 @@ public class GruppenbeitragController {
like.setUserId(myId);
like.setLikedAt(LocalDateTime.now());
likeRepository.save(like);
LOGGER.debug("User {} hat Beitrag {} geliked", myId, postId);
}
return ResponseEntity.ok().build();
}
@@ -224,6 +232,7 @@ public class GruppenbeitragController {
stimme.setBeitragId(postId);
stimme.setUserId(myId);
stimmeRepository.save(stimme);
LOGGER.debug("User {} hat für Option {} in Beitrag {} gestimmt", myId, req.optionId(), postId);
return ResponseEntity.ok().build();
}
@@ -251,6 +260,7 @@ public class GruppenbeitragController {
meldung.setGrund(req.grund());
meldung.setGemeldetAt(LocalDateTime.now());
meldungRepository.save(meldung);
LOGGER.warn("User {} hat Beitrag {} in Gruppe {} gemeldet (Grund: {})", myId, postId, id, req.grund());
return ResponseEntity.status(201).build();
}
@@ -292,6 +302,7 @@ public class GruppenbeitragController {
return ResponseEntity.status(403).build();
meldungRepository.deleteById(meldungId);
LOGGER.debug("Admin {} hat Meldung {} in Gruppe {} abgewiesen", myId, meldungId, id);
return ResponseEntity.noContent().build();
}

View File

@@ -116,6 +116,55 @@ public class MailTemplateService {
);
}
public String buildKeyholderInvitationMail(String keyholderName, String lockeeName, String lockName, String confirmLink) {
return """
<!DOCTYPE html>
<html lang="de">
<body style="margin:0; padding:2rem; background:%s; font-family:'Segoe UI',Arial,sans-serif; color:%s;">
<div style="max-width:460px; margin:0 auto; background:%s; border:1px solid %s; border-radius:12px; padding:2.5rem; box-shadow:0 8px 32px rgba(0,0,0,0.5);">
<h1 style="color:%s; text-align:center; margin:0 0 1.5rem 0; font-size:1.6rem;">XXX The Game</h1>
<p style="color:%s; margin:0 0 0.75rem 0;">Moin %s,</p>
<p style="color:%s; margin:0 0 0.5rem 0;">
<strong style="color:%s;">%s</strong> hat dich als Keyholder*In für das Chastity-Lock
<strong style="color:%s;">%s</strong> eingetragen.
</p>
<p style="color:%s; margin:0 0 2rem 0;">
Wenn du die Keyholder*In-Rolle annehmen möchtest, klicke auf den Button. Das Lock startet erst nach deiner Bestätigung mit dir als Keyholder*In.
</p>
<div style="text-align:center; margin:0 0 2rem 0;">
<a href="%s"
style="display:inline-block; padding:0.75rem 2.5rem; background:%s; color:#ffffff;
border-radius:6px; text-decoration:none; font-weight:600; font-size:1rem;">
Keyholder*In-Rolle annehmen
</a>
</div>
<hr style="border:none; border-top:1px solid %s; margin:0 0 1.5rem 0;">
<p style="color:%s; font-size:0.85em; margin:0;">
Falls du die Rolle nicht annehmen möchtest, kannst du diese E-Mail einfach ignorieren.
Das Lock läuft dann als Self-Lock weiter.
</p>
</div>
</body>
</html>
""".formatted(
colorBg, colorText,
colorCard, colorSecondary,
colorPrimary,
colorText, keyholderName,
colorText, colorPrimary, lockeeName,
colorPrimary, lockName,
colorText,
confirmLink, colorPrimary,
colorSecondary,
colorMuted
);
}
public String buildActivationMail(String name, String activationLink, String activatePageUrl, String uuid) {
return """
<!DOCTYPE html>

View File

@@ -48,6 +48,7 @@ public class SperreController {
public ResponseEntity<Void> sperren(@RequestBody SperreCallback callback) {
try {
new SperreVerarbeiten().sperreAnwenden(callback, sessionRepository, mitspielerRepository, aktiveSperreRepository);
LOGGER.info("Sperre angewandt für Session {}", callback.getSessionId());
return ResponseEntity.status(201).build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
@@ -101,6 +102,7 @@ public class SperreController {
List<AktiveSperreEntity> aktiveLocks = aktiveSperreRepository.findAktiveLocks(callback.getSpielerId());
SperreVerarbeiten verarbeiten = new SperreVerarbeiten();
aktiveLocks.forEach(lock -> verarbeiten.sperreVerlaengern(lock, callback.getFaktor(), aktiveSperreRepository));
LOGGER.debug("Sperren für Spieler {} um Faktor {} verlängert ({} Sperren)", callback.getSpielerId(), callback.getFaktor(), aktiveLocks.size());
return ResponseEntity.accepted().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);

View File

@@ -7,6 +7,8 @@ import de.oaa.xxx.social.repository.KommentarLikeRepository;
import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -19,6 +21,8 @@ import java.util.UUID;
@RequestMapping("/social/kommentare")
public class KommentarController {
private static final Logger LOGGER = LoggerFactory.getLogger(KommentarController.class);
private final KommentarRepository kommentarRepository;
private final KommentarLikeRepository likeRepository;
private final UserRepository userRepository;
@@ -70,6 +74,7 @@ public class KommentarController {
entity.setText(request.text().trim());
entity.setCreatedAt(LocalDateTime.now());
kommentarRepository.save(entity);
LOGGER.debug("User {} hat Kommentar {} auf {} {} erstellt", myId, entity.getKommentarId(), request.targetType(), request.targetId());
return ResponseEntity.status(201).body(toDto(entity, myId));
}
@@ -93,6 +98,7 @@ public class KommentarController {
}
likeRepository.deleteByKommentarId(kommentarId);
kommentarRepository.delete(kOpt.get());
LOGGER.debug("User {} hat Kommentar {} gelöscht", myId, kommentarId);
return ResponseEntity.noContent().build();
}
@@ -107,6 +113,7 @@ public class KommentarController {
var existing = likeRepository.findByKommentarIdAndUserId(kommentarId, myId);
if (existing.isPresent()) {
likeRepository.delete(existing.get());
LOGGER.debug("User {} hat Like auf Kommentar {} entfernt", myId, kommentarId);
} else {
KommentarLikeEntity like = new KommentarLikeEntity();
like.setLikeId(UUID.randomUUID());
@@ -114,6 +121,7 @@ public class KommentarController {
like.setUserId(myId);
like.setLikedAt(LocalDateTime.now());
likeRepository.save(like);
LOGGER.debug("User {} hat Kommentar {} geliked", myId, kommentarId);
}
return ResponseEntity.ok().build();
}

View File

@@ -8,6 +8,8 @@ import de.oaa.xxx.social.repository.PinnwandEintragRepository;
import de.oaa.xxx.social.repository.PinnwandLikeRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -20,6 +22,8 @@ import java.util.UUID;
@RequestMapping("/social/pinnwand")
public class PinnwandController {
private static final Logger LOGGER = LoggerFactory.getLogger(PinnwandController.class);
private final PinnwandEintragRepository eintragRepository;
private final PinnwandLikeRepository likeRepository;
private final KommentarRepository kommentarRepository;
@@ -67,6 +71,7 @@ public class PinnwandController {
entity.setText(request.text().trim());
entity.setCreatedAt(LocalDateTime.now());
eintragRepository.save(entity);
LOGGER.debug("User {} hat Pinnwand-Eintrag {} auf Profil {} erstellt", myId, entity.getEintragId(), request.profilUserId());
return ResponseEntity.status(201).body(toDto(entity, myId));
}
@@ -91,6 +96,7 @@ public class PinnwandController {
kommentarRepository.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("PINNWAND", eintragId)
.forEach(k -> kommentarRepository.deleteById(k.getKommentarId()));
eintragRepository.delete(eintrag);
LOGGER.debug("User {} hat Pinnwand-Eintrag {} gelöscht", myId, eintragId);
return ResponseEntity.noContent().build();
}
@@ -105,6 +111,7 @@ public class PinnwandController {
var existing = likeRepository.findByEintragIdAndUserId(eintragId, myId);
if (existing.isPresent()) {
likeRepository.delete(existing.get());
LOGGER.debug("User {} hat Like auf Pinnwand-Eintrag {} entfernt", myId, eintragId);
} else {
PinnwandLikeEntity like = new PinnwandLikeEntity();
like.setLikeId(UUID.randomUUID());
@@ -112,6 +119,7 @@ public class PinnwandController {
like.setUserId(myId);
like.setLikedAt(LocalDateTime.now());
likeRepository.save(like);
LOGGER.debug("User {} hat Pinnwand-Eintrag {} geliked", myId, eintragId);
}
return ResponseEntity.ok().build();
}

View File

@@ -6,6 +6,8 @@ import de.oaa.xxx.social.entity.ProfileImageLikeEntity;
import de.oaa.xxx.social.repository.ProfileImageLikeRepository;
import de.oaa.xxx.social.repository.ProfileImageRepository;
import de.oaa.xxx.user.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -18,6 +20,7 @@ import java.util.UUID;
@RequestMapping("/social/profile-images")
public class ProfileImageController {
private static final Logger LOGGER = LoggerFactory.getLogger(ProfileImageController.class);
private static final int MAX_IMAGES_PER_USER = 20;
private final ProfileImageRepository profileImageRepository;
@@ -53,6 +56,7 @@ public class ProfileImageController {
entity.setImageData(request.imageData());
entity.setUploadedAt(LocalDateTime.now());
profileImageRepository.save(entity);
LOGGER.debug("User {} hat Profilbild {} hochgeladen", myId, entity.getImageId());
return ResponseEntity.status(201).body(toDto(entity, myId));
}
@@ -80,6 +84,7 @@ public class ProfileImageController {
profileImageLikeRepository.deleteByImageId(imageId);
profileImageRepository.delete(imgOpt.get());
LOGGER.info("User {} hat Profilbild {} gelöscht", myId, imageId);
return ResponseEntity.noContent().build();
}
@@ -94,6 +99,7 @@ public class ProfileImageController {
var existing = profileImageLikeRepository.findByImageIdAndUserId(imageId, myId);
if (existing.isPresent()) {
profileImageLikeRepository.delete(existing.get());
LOGGER.debug("User {} hat Like auf Profilbild {} entfernt", myId, imageId);
} else {
ProfileImageLikeEntity like = new ProfileImageLikeEntity();
like.setLikeId(UUID.randomUUID());
@@ -101,6 +107,7 @@ public class ProfileImageController {
like.setUserId(myId);
like.setLikedAt(LocalDateTime.now());
profileImageLikeRepository.save(like);
LOGGER.debug("User {} hat Profilbild {} geliked", myId, imageId);
}
return ResponseEntity.ok().build();
}

View File

@@ -11,6 +11,8 @@ import de.oaa.xxx.social.repository.FriendshipRepository;
import de.oaa.xxx.social.repository.MessageRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -23,6 +25,8 @@ import java.util.*;
@RequestMapping("/social")
public class SocialController {
private static final Logger LOGGER = LoggerFactory.getLogger(SocialController.class);
private final UserRepository userRepository;
private final FriendshipRepository friendshipRepository;
private final MessageRepository messageRepository;
@@ -86,6 +90,7 @@ public class SocialController {
f.setStatus(Status.PENDING);
f.setCreatedAt(LocalDateTime.now());
friendshipRepository.save(f);
LOGGER.info("User {} hat Freundschaftsanfrage an User {} gesendet", myId, body.receiverId());
return ResponseEntity.status(201).build();
}
@@ -102,6 +107,7 @@ public class SocialController {
f.setStatus(Status.ACCEPTED);
friendshipRepository.save(f);
LOGGER.info("User {} hat Freundschaftsanfrage {} angenommen", myId, body.friendshipId());
return ResponseEntity.ok().build();
}
@@ -118,6 +124,7 @@ public class SocialController {
return ResponseEntity.status(403).build();
}
friendshipRepository.delete(f);
LOGGER.info("User {} hat Freundschaft/Anfrage {} gelöscht", myId, body.friendshipId());
return ResponseEntity.noContent().build();
}
@@ -202,6 +209,7 @@ public class SocialController {
msg.setText(body.text().trim());
msg.setSentAt(LocalDateTime.now());
messageRepository.save(msg);
LOGGER.debug("User {} hat Nachricht an User {} gesendet", myId, body.receiverId());
return ResponseEntity.status(201).build();
}

View File

@@ -120,6 +120,7 @@ public class UserController {
user.get().setProfilePicture(request.picture());
user.get().setProfilePictureHq(request.pictureHq());
userRepository.save(user.get());
LOGGER.debug("User {} hat Profilbild aktualisiert", user.get().getUserId());
return ResponseEntity.ok().build();
}
@@ -139,6 +140,7 @@ public class UserController {
user.setBeziehungsstatus(request.beziehungsstatus());
user.setBeschreibung(request.beschreibung());
userRepository.save(user);
LOGGER.info("User {} hat Profil aktualisiert", user.getUserId());
return ResponseEntity.ok().build();
}
@@ -153,6 +155,7 @@ public class UserController {
if (user.isEmpty()) return ResponseEntity.status(401).build();
user.get().setName(newName);
userRepository.save(user.get());
LOGGER.info("User {} hat Namen zu '{}' geändert", user.get().getUserId(), newName);
return ResponseEntity.ok().build();
}

View File

@@ -223,207 +223,6 @@
white-space: pre-wrap;
}
/* ── Like button (inline) ── */
.btn-like {
background: none;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 20px;
padding: 0.2rem 0.65rem;
color: var(--color-muted);
font-size: 0.78rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin: 0;
width: auto;
transition: border-color 0.15s, color 0.15s;
}
.btn-like:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-like.liked { border-color: var(--color-primary); color: var(--color-primary); }
/* ── Lightbox ── */
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.88);
z-index: 500;
align-items: center;
justify-content: center;
padding: 2rem;
}
.lightbox.open { display: flex; }
.lightbox-close {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(0,0,0,0.55);
border: 1px solid rgba(255,255,255,0.2);
color: #fff;
font-size: 1.1rem;
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
z-index: 502;
transition: background 0.15s;
}
.lightbox-close:hover { background: rgba(180,30,30,0.8); }
/* Layout wrapper fixed size */
.lb-layout {
display: flex;
flex-direction: row;
gap: 1rem;
height: min(78vh, 660px);
max-width: calc(100vw - 4rem);
align-items: stretch;
}
/* Image side fixed preferred width so all images show at the same size */
.lb-image-side {
width: 660px;
flex-shrink: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.lb-image-box {
flex: 1;
position: relative;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
#lightboxImg {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
/* Arrow + like overlay on image bottom */
.lb-image-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.6));
border-radius: 0 0 12px 12px;
padding: 2rem 0.75rem 0.6rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.lb-nav-btn {
background: rgba(0,0,0,0.35);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 6px;
color: #fff;
padding: 0.3rem 0.75rem;
cursor: pointer;
margin: 0;
width: auto;
font-size: 1rem;
flex-shrink: 0;
transition: background 0.15s;
}
.lb-nav-btn:hover { background: rgba(0,0,0,0.65); }
.lb-nav-btn:disabled { opacity: 0.25; cursor: default; }
.lb-overlay-center {
display: flex;
align-items: center;
gap: 0.6rem;
flex: 1;
justify-content: center;
}
.lb-counter {
font-size: 0.8rem;
color: rgba(255,255,255,0.75);
}
/* Comments panel same height as image box */
.lb-comments-panel {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.lb-comments-header {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--color-secondary);
flex-shrink: 0;
}
.lb-comments-list {
flex: 1;
overflow-y: auto;
padding: 0.65rem 0.75rem;
scrollbar-width: thin;
scrollbar-color: var(--color-secondary) transparent;
}
.lb-comment-write {
display: flex;
gap: 0.4rem;
padding: 0.65rem 0.75rem;
border-top: 1px solid var(--color-secondary);
flex-shrink: 0;
}
.lb-comment-write input {
flex: 1;
padding: 0.4rem 0.65rem;
font-size: 0.85rem;
}
.lb-comment-write button {
width: auto;
padding: 0.4rem 0.7rem;
font-size: 0.82rem;
white-space: nowrap;
}
/* Narrow layout toggled by JS reading --breakpoint-mobile */
.lb-layout.narrow {
flex-direction: column;
height: auto;
max-height: 90vh;
overflow-y: auto;
width: calc(100vw - 1rem);
max-width: calc(100vw - 1rem);
}
.lb-layout.narrow .lb-image-side { width: 100%; flex-shrink: 0; }
.lb-layout.narrow .lb-image-box { height: min(45vh, 360px); flex: none; }
.lb-layout.narrow .lb-comments-panel { width: 100%; max-height: 40vh; flex-shrink: 0; }
/* ── Pinnwand ── */
.pinnwand-write {
display: flex;
@@ -504,77 +303,13 @@
flex-wrap: wrap;
}
.btn-text {
background: none;
border: none;
color: var(--color-muted);
font-size: 0.78rem;
cursor: pointer;
margin: 0;
width: auto;
padding: 0;
text-decoration: underline;
text-decoration-color: rgba(255,255,255,0.2);
}
.btn-text:hover { color: var(--color-text); }
.btn-delete-small {
background: none;
border: none;
color: rgba(200,50,50,0.6);
font-size: 0.78rem;
cursor: pointer;
margin: 0;
width: auto;
padding: 0;
}
.btn-delete-small:hover { color: var(--color-primary); }
/* ── Comments ── */
/* ── Comments (section container) ── */
.comments-section {
margin-top: 0.65rem;
padding-top: 0.65rem;
border-top: 1px solid rgba(255,255,255,0.06);
}
.comment-item {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.comment-body {
flex: 1;
background: rgba(255,255,255,0.04);
border-radius: 6px;
padding: 0.5rem 0.65rem;
}
.comment-author { font-size: 0.8rem; font-weight: 600; color: var(--color-text); }
.comment-time { font-size: 0.7rem; color: var(--color-muted); margin-left: 0.4rem; }
.comment-text { font-size: 0.85rem; color: rgba(255,255,255,0.75); margin-top: 0.2rem; line-height: 1.45; white-space: pre-wrap; }
.comment-actions {
display: flex;
gap: 0.4rem;
margin-top: 0.3rem;
align-items: center;
}
.replies-section {
margin-top: 0.5rem;
padding-left: 0.5rem;
border-left: 2px solid rgba(255,255,255,0.06);
}
.comment-write {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
.comment-write input { flex: 1; padding: 0.4rem 0.75rem; font-size: 0.85rem; }
.comment-write button { width: auto; padding: 0.4rem 0.75rem; font-size: 0.82rem; white-space: nowrap; }
/* ── Profil Tabs ── */
.profil-tabs { display:flex; gap:0; margin-bottom:1.25rem; border-bottom:1px solid var(--color-secondary); }
.profil-tab-btn { background:none; border:none; border-bottom:3px solid transparent; border-radius:0; padding:0.6rem 1.25rem; font-size:0.9rem; font-weight:600; color:var(--color-muted); cursor:pointer; margin-bottom:-1px; transition:color 0.15s,border-color 0.15s; }
@@ -591,14 +326,11 @@
.post-author { font-weight:600; font-size:0.9rem; }
.post-date { font-size:0.75rem; color:var(--color-muted); margin-left:auto; }
.post-text { font-size:0.95rem; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.post-bild { width:100%; max-height:360px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; }
.post-carousel { position:relative; margin-top:0.5rem; }
.car-slide { display:none; }
.car-slide.active { display:block; }
.car-btn { position:absolute; top:50%; transform:translateY(-50%); background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:2.2rem; width:auto; min-width:2.4rem; height:3.2rem; border-radius:8px; cursor:pointer; z-index:5; display:flex; align-items:center; justify-content:center; padding:0 0.5rem; margin:0; line-height:1; }
.car-prev { left:0.3rem; }
.car-next { right:0.3rem; }
.car-indicator { text-align:center; font-size:0.75rem; color:var(--color-muted); margin-top:0.25rem; }
.post-bild { width:100%; max-height:360px; object-fit:contain; border-radius:6px; margin-top:0.5rem; display:block; transition:opacity 0.2s; }
.post-bild-wrap { position:relative; cursor:pointer; display:block; }
.post-bild-wrap:hover .post-bild { opacity:0.82; }
.post-bild-hover-icon { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; opacity:0; transition:opacity 0.2s; pointer-events:none; font-size:1.6rem; }
.post-bild-wrap:hover .post-bild-hover-icon { opacity:1; }
.post-actions { display:flex; gap:1rem; margin-top:0.75rem; align-items:center; flex-wrap:wrap; }
.post-action-btn { background:none; border:none; color:var(--color-muted); cursor:pointer; font-size:0.85rem; padding:0; display:flex; align-items:center; gap:0.3rem; margin:0; width:auto; }
.post-action-btn:hover { color:var(--color-primary); background:none; }
@@ -612,18 +344,23 @@
.umfrage-bar-content { position:relative; display:flex; justify-content:space-between; padding:0.45rem 0.75rem; font-size:0.88rem; }
.umfrage-total { font-size:0.78rem; color:var(--color-muted); margin-top:0.3rem; }
/* ── Post Lightbox ── */
.post-lb { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:400; align-items:center; justify-content:center; }
.post-lb.open { display:flex; }
.post-lb-layout { display:flex; max-width:860px; width:95vw; max-height:88vh; background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.post-lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.post-lb-body { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.post-lb-comments { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.post-lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.post-lb-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; gap:0.5rem; }
.post-lb-compose input { flex:1; font-size:0.85rem; padding:0.35rem 0.6rem; height:auto; }
.post-lb-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media(max-width:650px) { .post-lb-layout { flex-direction:column; max-height:95vh; } .post-lb-body { border-right:none; border-bottom:1px solid var(--color-secondary); } .post-lb-comments { width:100%; } }
/* ── Post / Bild Lightbox ── */
.lightbox { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.88); z-index:400; align-items:center; justify-content:center; }
.lightbox.open { display:flex; }
.lb-layout { display:flex; max-width:920px; width:95vw; max-height:90vh; background:var(--color-card); border-radius:12px; overflow:hidden; position:relative; }
.lb-close { position:absolute; top:0.6rem; right:0.6rem; background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:1.1rem; width:2rem; height:2rem; border-radius:50%; cursor:pointer; z-index:10; display:flex; align-items:center; justify-content:center; padding:0; margin:0; }
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-header { font-size:0.78rem; font-weight:600; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.06em; padding:0.7rem 1rem; border-bottom:1px solid var(--color-secondary); flex-shrink:0; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
.lb-img-nav { display:flex; gap:0.75rem; align-items:center; justify-content:center; margin-top:0.75rem; flex-wrap:wrap; }
.compose-action-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted); border-radius:6px; padding:0.35rem 0.6rem; font-size:0.95rem; cursor:pointer; margin:0; width:auto; }
@media (max-width:650px) { .lb-layout { flex-direction:column; max-height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
</style>
</head>
<body class="app">
@@ -694,60 +431,28 @@
</div>
</div>
<!-- Lightbox -->
<div class="lightbox" id="lightbox">
<button class="lightbox-close" onclick="closeLightbox()"></button>
<!-- Post / Bild Lightbox -->
<div class="lightbox" id="postLightbox">
<div class="lb-layout">
<!-- Image side -->
<div class="lb-image-side">
<div class="lb-image-box">
<img id="lightboxImg" src="" alt="">
<div class="lb-image-overlay">
<button class="lb-nav-btn" id="lbPrev" onclick="galleryPrev()" style="display:none;">&#8592;</button>
<div class="lb-overlay-center">
<span class="lb-counter" id="lbCounter" style="display:none;"></span>
<button class="btn-like" id="lbLikeBtn" onclick="toggleGalleryLike()" style="display:none;">
<span id="lbLikeCount">0</span>
</button>
</div>
<button class="lb-nav-btn" id="lbNext" onclick="galleryNext()" style="display:none;">&#8594;</button>
<button class="lb-close" onclick="closeLb()"></button>
<div class="lb-post-side" id="lbPostBody"></div>
<div class="lb-comments-panel">
<div class="lb-comments-header">Kommentare</div>
<div class="lb-comments-list" id="lbCommentsList"></div>
<div class="lb-comment-compose">
<textarea id="lbCommentInput" placeholder="Kommentar schreiben…" maxlength="500" rows="3"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();postLbComment()}"></textarea>
<div class="lb-comment-compose-actions">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button>
<button onclick="postLbComment()">Senden</button>
</div>
</div>
</div>
<!-- Comments panel (always visible for gallery images) -->
<div class="lb-comments-panel" id="lbCommentsPanel" style="display:none;">
<div class="lb-comments-header">Kommentare</div>
<div class="lb-comments-list" id="lbComments"></div>
<div class="lb-comment-write" style="display:flex;gap:0.5rem;align-items:center;">
<input type="text" id="lbCommentInput" placeholder="Kommentar schreiben…" maxlength="500"
onkeydown="if(event.key==='Enter') postLbComment()" style="flex:1;">
<button type="button" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.35rem 0.6rem;font-size:0.95rem;cursor:pointer;margin:0;width:auto;">😊</button>
<button onclick="postLbComment()">Senden</button>
</div>
</div>
</div>
</div>
<!-- Post Lightbox -->
<div class="post-lb" id="postLightbox">
<div class="post-lb-layout">
<button class="post-lb-close" onclick="closePostLb()"></button>
<div class="post-lb-body" id="postLbBody"></div>
<div class="post-lb-comments">
<div class="lb-comments-header" style="font-size:0.78rem;font-weight:600;color:var(--color-muted);text-transform:uppercase;letter-spacing:0.06em;padding:0.7rem 1rem;border-bottom:1px solid var(--color-secondary);">Kommentare</div>
<div class="post-lb-comments-list" id="postLbComments"></div>
<div class="post-lb-compose" style="display:flex;gap:0.5rem;align-items:center;">
<input type="text" id="postLbCommentInput" placeholder="Kommentar schreiben…" maxlength="500"
onkeydown="if(event.key==='Enter') postLbFeedComment()" style="flex:1;">
<button type="button" onclick="toggleEmojiPicker(this,'postLbCommentInput')" title="Emoji" style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.35rem 0.6rem;font-size:0.95rem;cursor:pointer;margin:0;width:auto;">😊</button>
<button onclick="postLbFeedComment()">Senden</button>
</div>
</div>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/image-viewer.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
@@ -766,14 +471,13 @@
let friendsOffset = 0;
const FRIENDS_PAGE = 5;
let lbMode = null; // 'avatar' | 'gallery'
let lbImageIndex = 0;
// Posts tab state
let postsPage = 0;
let postsHasMore = true;
let postsLoading = false;
let activeLbPostId = null;
let postsPage = 0;
let postsHasMore = true;
let postsLoading = false;
let activeLbPostId = null;
let activeLbMode = null; // 'post' | 'image'
let activeLbImageIdx = null;
// ── Label maps ──
const GESCHLECHT_LABEL = { WEIBLICH: 'weiblich', DIVERS: 'divers', MAENNLICH: 'männlich' };
@@ -944,7 +648,7 @@
const strip = document.getElementById('friendsStrip');
strip.innerHTML = page.map(f => {
const pic = f.profilePicture
? `<img src="${f.profilePicture}" alt="${esc(f.name)}">`
? `<img src="data:image/png;base64,${f.profilePicture}" alt="${esc(f.name)}">`
: `<div class="friend-avatar-placeholder">👤</div>`;
return `<div class="friend-thumb" onclick="window.location='/benutzer.html?userId=${f.userId}'">
${pic}
@@ -977,124 +681,57 @@
img.likeCount += img.likedByMe ? 1 : -1;
btn.className = 'btn-like' + (img.likedByMe ? ' liked' : '');
btn.textContent = '♥ ' + img.likeCount;
if (lbMode === 'gallery' && lbImageIndex === idx) syncLbLike();
if (activeLbMode === 'image' && activeLbImageIdx === idx) renderLbImageBody();
}
// ── Lightbox ──
const lbLayout = () => document.querySelector('#lightbox .lb-layout');
const mobileBP = () => parseInt(getComputedStyle(document.documentElement).getPropertyValue('--breakpoint-mobile').trim()) || 768;
function updateLbLayout() {
const el = lbLayout();
if (el) el.classList.toggle('narrow', window.innerWidth <= mobileBP());
}
window.addEventListener('resize', updateLbLayout);
// ── Bild-Lightbox (Avatar = imageViewer, Galerie = shared lightbox) ──
function openAvatarLightbox() {
if (!avatarSrc) return;
lbMode = 'avatar';
document.getElementById('lightboxImg').src = avatarSrc;
document.getElementById('lbPrev').style.display = 'none';
document.getElementById('lbNext').style.display = 'none';
document.getElementById('lbCounter').style.display = 'none';
document.getElementById('lbLikeBtn').style.display = 'none';
document.getElementById('lbCommentsPanel').style.display = 'none';
document.getElementById('lightbox').classList.add('open');
updateLbLayout();
imageViewer.open({ images: [{ src: avatarSrc }] });
}
function renderLbImageBody() {
const img = allImages[activeLbImageIdx];
const total = allImages.length;
const prevDisabled = activeLbImageIdx === 0 ? 'disabled' : '';
const nextDisabled = activeLbImageIdx === total - 1 ? 'disabled' : '';
const likeClass = img.likedByMe ? ' active' : '';
document.getElementById('lbPostBody').innerHTML = `
<img src="data:image/jpeg;base64,${img.imageData}" alt=""
style="width:100%;max-height:360px;object-fit:contain;border-radius:8px;display:block;">
<div class="lb-img-nav">
<button class="post-action-btn" ${prevDisabled} onclick="lbGalleryNav(-1)">← Zurück</button>
${!isOwnProfile ? `<button class="post-action-btn${likeClass}" id="lbImgLikeBtn" onclick="lbToggleImageLike()">
♥ <span id="lbImgLikeCount">${img.likeCount}</span></button>` : ''}
<span style="font-size:0.8rem;color:var(--color-muted);">${activeLbImageIdx + 1} / ${total}</span>
<button class="post-action-btn" ${nextDisabled} onclick="lbGalleryNav(1)">Weiter →</button>
</div>`;
}
function openGallery(index) {
lbMode = 'gallery';
lbImageIndex = index;
document.getElementById('lbPrev').style.display = '';
document.getElementById('lbNext').style.display = '';
document.getElementById('lbCounter').style.display = '';
document.getElementById('lbCommentsPanel').style.display = '';
if (!isOwnProfile) document.getElementById('lbLikeBtn').style.display = '';
showLbImage();
document.getElementById('lightbox').classList.add('open');
updateLbLayout();
activeLbMode = 'image';
activeLbImageIdx = index;
activeLbPostId = null;
renderLbImageBody();
loadLbComments();
document.getElementById('postLightbox').classList.add('open');
}
function showLbImage() {
const img = allImages[lbImageIndex];
document.getElementById('lightboxImg').src = 'data:image/jpeg;base64,' + img.imageData;
document.getElementById('lbCounter').textContent = (lbImageIndex + 1) + ' / ' + allImages.length;
document.getElementById('lbPrev').disabled = lbImageIndex === 0;
document.getElementById('lbNext').disabled = lbImageIndex === allImages.length - 1;
syncLbLike();
function lbGalleryNav(dir) {
activeLbImageIdx = Math.max(0, Math.min(activeLbImageIdx + dir, allImages.length - 1));
renderLbImageBody();
loadLbComments();
}
function syncLbLike() {
const img = allImages[lbImageIndex];
const btn = document.getElementById('lbLikeBtn');
btn.className = 'btn-like' + (img.likedByMe ? ' liked' : '');
document.getElementById('lbLikeCount').textContent = img.likeCount;
}
function galleryPrev() {
if (lbImageIndex > 0) { lbImageIndex--; showLbImage(); }
}
function galleryNext() {
if (lbImageIndex < allImages.length - 1) { lbImageIndex++; showLbImage(); }
}
async function toggleGalleryLike() {
const img = allImages[lbImageIndex];
async function lbToggleImageLike() {
const img = allImages[activeLbImageIdx];
await fetch('/social/profile-images/' + img.imageId + '/like', { method: 'POST' });
img.likedByMe = !img.likedByMe;
img.likeCount += img.likedByMe ? 1 : -1;
syncLbLike();
renderLbImageBody();
renderGallery();
try {
await fetch('/social/profile-images/' + img.imageId + '/like', { method: 'POST' });
} catch {
img.likedByMe = !img.likedByMe;
img.likeCount += img.likedByMe ? 1 : -1;
syncLbLike();
renderGallery();
}
}
async function loadLbComments() {
const imageId = allImages[lbImageIndex].imageId;
const res = await fetch('/social/kommentare?targetType=IMAGE&targetId=' + imageId);
const comments = await res.json();
document.getElementById('lbComments').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin-bottom:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, 'IMAGE', imageId, true)).join('');
}
async function postLbComment() {
const input = document.getElementById('lbCommentInput');
const text = input.value.trim();
if (!text) return;
const imageId = allImages[lbImageIndex].imageId;
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType: 'IMAGE', targetId: imageId, text })
});
input.value = '';
await loadLbComments();
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open');
lbMode = null;
}
document.getElementById('lightbox').addEventListener('click', e => {
if (e.target === document.getElementById('lightbox')) closeLightbox();
});
document.addEventListener('keydown', e => {
if (!document.getElementById('lightbox').classList.contains('open')) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') galleryPrev();
if (e.key === 'ArrowRight') galleryNext();
});
// ── Pinnwand ──
async function loadPinnwand() {
const res = await fetch('/social/pinnwand?userId=' + targetUserId);
@@ -1166,13 +803,14 @@
}
}
// loadComments und renderKommentarHtml kommen aus shared.js
async function loadComments(targetId, targetType) {
const res = await fetch(`/social/kommentare?targetType=${targetType}&targetId=${targetId}`);
const comments = await res.json();
const section = document.getElementById('comments-' + targetId);
section.innerHTML = (comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin-bottom:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, targetType, targetId, false)).join(''))
: comments.map(k => renderKommentarHtml(k, targetType, targetId, { myUserId, showReplies: true })).join(''))
+ `<div class="comment-write">
<input type="text" id="ci-${targetId}" placeholder="Kommentar schreiben…" maxlength="500"
onkeydown="if(event.key==='Enter') postComment('${targetId}','${targetType}')">
@@ -1180,27 +818,6 @@
</div>`;
}
function renderKommentarHtml(k, targetType, targetId, inLb) {
const avatarHtml = k.authorPicture ? `<img src="data:image/png;base64,${k.authorPicture}" alt="">` : '◉';
const canDelete = k.authorId === myUserId;
const replyLabel = k.replyCount > 0 ? `Antworten (${k.replyCount})` : 'Antworten';
return `<div class="comment-item" id="kom-${k.kommentarId}">
<div class="entry-avatar" style="width:26px;height:26px;font-size:0.85rem;">${avatarHtml}</div>
<div class="comment-body">
<span class="comment-author">${esc(k.authorName)}</span>
<span class="comment-time">${fmtDate(k.createdAt)}</span>
<div class="comment-text">${esc(k.text)}</div>
<div class="comment-actions">
<button class="btn-like ${k.likedByMe ? 'liked' : ''}" id="like-k-${k.kommentarId}"
onclick="toggleKommentarLike('${k.kommentarId}')">♥ <span id="lc-k-${k.kommentarId}">${k.likeCount}</span></button>
<button class="btn-text" onclick="toggleReplies('${k.kommentarId}')">${replyLabel}</button>
${canDelete ? `<button class="btn-delete-small" onclick="deleteKommentar('${k.kommentarId}','${targetType}','${targetId}',${inLb})">✕</button>` : ''}
</div>
<div class="replies-section" id="replies-${k.kommentarId}" style="display:none;"></div>
</div>
</div>`;
}
async function postComment(targetId, targetType) {
const input = document.getElementById('ci-' + targetId);
const text = input.value.trim();
@@ -1213,81 +830,15 @@
await loadComments(targetId, targetType);
}
async function deleteKommentar(kommentarId, targetType, targetId, inLb) {
async function deleteKommentar(kommentarId, targetType, targetId) {
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
if (inLb) await loadLbComments();
else await loadComments(targetId, targetType);
}
async function toggleKommentarLike(kommentarId) {
await fetch('/social/kommentare/' + kommentarId + '/like', { method: 'POST' });
const btn = document.getElementById('like-k-' + kommentarId);
const lc = document.getElementById('lc-k-' + kommentarId);
const was = btn.classList.contains('liked');
btn.classList.toggle('liked', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
}
// ── Replies ──
async function toggleReplies(kommentarId) {
const section = document.getElementById('replies-' + kommentarId);
if (section.style.display === 'none') {
section.style.display = '';
await loadReplies(kommentarId);
if (document.getElementById('postLightbox')?.classList.contains('open')) {
await loadLbComments();
} else {
section.style.display = 'none';
await loadComments(targetId, targetType);
}
}
async function loadReplies(kommentarId) {
const res = await fetch(`/social/kommentare?targetType=KOMMENTAR&targetId=${kommentarId}`);
const replies = await res.json();
const section = document.getElementById('replies-' + kommentarId);
section.innerHTML = (replies.length === 0
? '<p style="color:var(--color-muted);font-size:0.78rem;margin-bottom:0.35rem;">Noch keine Antworten.</p>'
: replies.map(r => renderReplyHtml(r, kommentarId)).join(''))
+ `<div class="comment-write">
<input type="text" id="ri-${kommentarId}" placeholder="Antwort schreiben…" maxlength="500"
onkeydown="if(event.key==='Enter') postReply('${kommentarId}')">
<button onclick="postReply('${kommentarId}')">Senden</button>
</div>`;
}
function renderReplyHtml(r, parentId) {
const avatarHtml = r.authorPicture ? `<img src="data:image/png;base64,${r.authorPicture}" alt="">` : '◉';
const canDelete = r.authorId === myUserId;
return `<div class="comment-item" id="kom-${r.kommentarId}" style="margin-bottom:0.35rem;">
<div class="entry-avatar" style="width:22px;height:22px;font-size:0.75rem;">${avatarHtml}</div>
<div class="comment-body" style="padding:0.35rem 0.55rem;">
<span class="comment-author">${esc(r.authorName)}</span>
<span class="comment-time">${fmtDate(r.createdAt)}</span>
<div class="comment-text">${esc(r.text)}</div>
<div class="comment-actions">
<button class="btn-like ${r.likedByMe ? 'liked' : ''}" id="like-k-${r.kommentarId}"
onclick="toggleKommentarLike('${r.kommentarId}')">♥ <span id="lc-k-${r.kommentarId}">${r.likeCount}</span></button>
${canDelete ? `<button class="btn-delete-small" onclick="deleteReply('${r.kommentarId}','${parentId}')">✕</button>` : ''}
</div>
</div>
</div>`;
}
async function postReply(kommentarId) {
const input = document.getElementById('ri-' + kommentarId);
const text = input.value.trim();
if (!text) return;
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType: 'KOMMENTAR', targetId: kommentarId, text })
});
input.value = '';
await loadReplies(kommentarId);
}
async function deleteReply(replyId, parentId) {
await fetch('/social/kommentare/' + replyId, { method: 'DELETE' });
await loadReplies(parentId);
}
// ── Friend actions ──
async function addFriend() {
const btn = document.getElementById('friendActionBtn');
@@ -1339,38 +890,16 @@
}
}
function bilderCarousel(bilder) {
if (!bilder || bilder.length === 0) return '';
if (bilder.length === 1) {
return `<div style="margin-top:0.5rem;"><img class="post-bild" src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>`;
}
const slides = bilder.map((b, i) =>
`<div class="car-slide${i === 0 ? ' active' : ''}"><img class="post-bild" src="data:image/jpeg;base64,${b}" alt=""></div>`
).join('');
return `<div class="post-carousel">
${slides}
<button class="car-btn car-prev" onclick="event.stopPropagation();carNav(this,-1)">&#8249;</button>
<button class="car-btn car-next" onclick="event.stopPropagation();carNav(this,1)">&#8250;</button>
<div class="car-indicator"><span class="car-cur">1</span>/${bilder.length}</div>
</div>`;
}
function carNav(btn, dir) {
const car = btn.closest('.post-carousel');
const slides = Array.from(car.querySelectorAll('.car-slide'));
const cur = slides.findIndex(s => s.classList.contains('active'));
slides[cur].classList.remove('active');
const next = (cur + dir + slides.length) % slides.length;
slides[next].classList.add('active');
const ind = car.querySelector('.car-cur');
if (ind) ind.textContent = next + 1;
}
// bilderCarousel und carNav kommen aus shared.js
function renderProfilPostCard(p) {
const avatarHtml = p.authorPicture
? `<img src="data:image/png;base64,${p.authorPicture}" alt="">`
: '◉';
const bildHtml = bilderCarousel(p.bilder);
const bildRaw = bilderCarousel(p.bilder);
const bildHtml = bildRaw
? `<div class="post-bild-wrap" data-post-id="${p.postId}">${bildRaw}<div class="post-bild-hover-icon">💬</div></div>`
: '';
const privacyLabel = p.isPublic ? '' : '<span style="font-size:0.72rem;color:var(--color-muted);margin-left:0.4rem;">🔒 privat</span>';
let umfrageHtml = '';
@@ -1443,43 +972,74 @@
if (res.ok) document.getElementById('pp-' + postId)?.remove();
}
// ── Post Lightbox ──
// ── Lightbox (Post + Bild) ──
function openPostLb(postId) {
activeLbPostId = postId;
activeLbMode = 'post';
activeLbPostId = postId;
activeLbImageIdx = null;
const card = document.getElementById('pp-' + postId);
if (card) document.getElementById('postLbBody').innerHTML = card.innerHTML;
loadPostLbComments(postId);
if (card) {
const clone = card.cloneNode(true);
clone.querySelectorAll('.post-actions').forEach(el => el.remove());
document.getElementById('lbPostBody').innerHTML = clone.innerHTML;
}
loadLbComments();
document.getElementById('postLightbox').classList.add('open');
}
function closePostLb() {
function closeLb() {
document.getElementById('postLightbox').classList.remove('open');
activeLbPostId = null;
activeLbMode = null;
activeLbPostId = null;
activeLbImageIdx = null;
}
async function loadPostLbComments(postId) {
const res = await fetch(`/social/kommentare?targetType=FEED_POST&targetId=${postId}`);
async function loadLbComments() {
let targetType, targetId;
if (activeLbMode === 'image') {
targetType = 'IMAGE';
targetId = allImages[activeLbImageIdx].imageId;
} else {
targetType = 'FEED_POST';
targetId = activeLbPostId;
}
const res = await fetch(`/social/kommentare?targetType=${targetType}&targetId=${targetId}`);
const comments = await res.json();
document.getElementById('postLbComments').innerHTML = comments.length === 0
document.getElementById('lbCommentsList').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, 'FEED_POST', postId, true)).join('');
: comments.map(k => renderKommentarHtml(k, targetType, targetId, { myUserId, showReplies: false })).join('');
}
async function postLbFeedComment() {
if (!activeLbPostId) return;
const input = document.getElementById('postLbCommentInput');
async function postLbComment() {
const input = document.getElementById('lbCommentInput');
const text = input.value.trim();
if (!text) return;
let targetType, targetId;
if (activeLbMode === 'image') {
targetType = 'IMAGE';
targetId = allImages[activeLbImageIdx].imageId;
} else {
if (!activeLbPostId) return;
targetType = 'FEED_POST';
targetId = activeLbPostId;
}
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType: 'FEED_POST', targetId: activeLbPostId, text })
body: JSON.stringify({ targetType, targetId, text })
});
input.value = '';
await loadPostLbComments(activeLbPostId);
await loadLbComments();
}
document.getElementById('postLightbox').addEventListener('click', e => {
if (e.target === document.getElementById('postLightbox')) closePostLb();
if (e.target === document.getElementById('postLightbox')) closeLb();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('postLightbox').classList.contains('open')) closeLb();
if (activeLbMode === 'image') {
if (e.key === 'ArrowLeft') lbGalleryNav(-1);
if (e.key === 'ArrowRight') lbGalleryNav(1);
}
});
// Infinite scroll for profil posts
@@ -1487,68 +1047,14 @@
if (entries[0].isIntersecting) loadProfilPosts();
}, { threshold: 0.5 });
// ── Helpers ──
function esc(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/\n/g,'<br>');
}
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
}
// ── Emoji Picker ──
const EMOJIS = ['😊','😂','❤️','😍','🔥','👍','🥰','😎','🤔','😘','💕','🎉','✨','💋','😈','🫦','🍑','🍆','🔞','🥵','😭','😢','😤','🙄','🤦','🤷','🙏','💪','😏','🤩'];
let emojiTarget = null;
function toggleEmojiPicker(btn, targetId) {
emojiTarget = document.getElementById(targetId);
let picker = document.getElementById('emojiPicker');
if (!picker) {
picker = document.createElement('div');
picker.id = 'emojiPicker';
picker.style.cssText = 'position:fixed;z-index:1000;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:0.5rem;display:flex;flex-wrap:wrap;gap:0.2rem;max-width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.5);';
EMOJIS.forEach(em => {
const b = document.createElement('button');
b.textContent = em;
b.style.cssText = 'background:none;border:none;font-size:1.3rem;cursor:pointer;padding:0.2rem;margin:0;width:auto;line-height:1;';
b.onclick = e => { e.stopPropagation(); insertEmoji(em); };
picker.appendChild(b);
});
document.body.appendChild(picker);
}
if (picker.style.display === 'flex') { picker.style.display = 'none'; return; }
picker.style.display = 'flex';
requestAnimationFrame(() => {
const rect = btn.getBoundingClientRect();
const ph = picker.offsetHeight, pw = picker.offsetWidth;
let top = rect.top - ph - 8;
let left = rect.left;
if (top < 8) top = rect.bottom + 8;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
picker.style.top = top + 'px';
picker.style.left = left + 'px';
});
}
function insertEmoji(emoji) {
if (!emojiTarget) return;
const start = emojiTarget.selectionStart ?? emojiTarget.value.length;
const end = emojiTarget.selectionEnd ?? start;
emojiTarget.value = emojiTarget.value.slice(0, start) + emoji + emojiTarget.value.slice(end);
emojiTarget.selectionStart = emojiTarget.selectionEnd = start + emoji.length;
emojiTarget.focus();
}
document.addEventListener('click', e => {
const picker = document.getElementById('emojiPicker');
if (picker && picker.style.display === 'flex' && !picker.contains(e.target) && !e.target.closest('[onclick*="toggleEmojiPicker"]')) {
picker.style.display = 'none';
}
// Klick auf Post-Bilder → Post-Lightbox öffnen
document.getElementById('profilPostsFeed').addEventListener('click', e => {
const wrap = e.target.closest('.post-bild-wrap');
if (!wrap) return;
openPostLb(wrap.dataset.postId);
});
// esc, fmtDate, toggleEmojiPicker, insertEmoji kommen aus shared.js
</script>
</body>
</html>

View File

@@ -51,14 +51,7 @@
.post-delete { margin-left:auto; }
.post-delete:hover { color:#c0392b !important; }
/* Carousel */
.post-carousel { position:relative; margin-top:0.5rem; }
.car-slide { display:none; }
.car-slide.active { display:block; }
.car-btn { position:absolute; top:50%; transform:translateY(-50%); background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:2.2rem; width:auto; min-width:2.4rem; height:3.2rem; border-radius:8px; cursor:pointer; z-index:5; display:flex; align-items:center; justify-content:center; padding:0 0.5rem; margin:0; line-height:1; }
.car-prev { left:0.3rem; }
.car-next { right:0.3rem; }
.car-indicator { text-align:center; font-size:0.75rem; color:var(--color-muted); margin-top:0.25rem; }
/* Carousel Stile kommen aus shared.js */
.umfrage-option-bar { margin:0.3rem 0; cursor:pointer; border-radius:6px; overflow:hidden; border:1px solid var(--color-secondary); position:relative; transition:border-color 0.15s; }
.umfrage-option-bar:hover { border-color:var(--color-primary); }
@@ -83,23 +76,14 @@
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-header { font-size:0.78rem; font-weight:600; color:var(--color-muted); text-transform:uppercase; letter-spacing:0.06em; padding:0.7rem 1rem; border-bottom:1px solid var(--color-secondary); flex-shrink:0; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; gap:0.5rem; flex-shrink:0; align-items:center; }
.lb-comment-compose input { flex:1; font-size:0.85rem; padding:0.35rem 0.6rem; height:auto; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) { .lb-layout { flex-direction:column; max-height:95vh; } .lb-post-side { border-right:none; border-bottom:1px solid var(--color-secondary); max-height:55vh; } .lb-comments-panel { width:100%; } }
.comment-item { display:flex; gap:0.5rem; margin-bottom:0.5rem; }
.comment-avatar { width:28px; height:28px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.75rem; flex-shrink:0; overflow:hidden; }
.comment-avatar img { width:100%; height:100%; object-fit:cover; }
.comment-body { flex:1; background:rgba(255,255,255,0.04); border-radius:6px; padding:0.5rem 0.65rem; }
.comment-author { font-size:0.8rem; font-weight:600; }
.comment-text { font-size:0.85rem; white-space:pre-wrap; word-break:break-word; margin-top:0.2rem; }
.comment-date { font-size:0.72rem; color:var(--color-muted); margin-left:0.4rem; }
.comment-actions { display:flex; gap:0.4rem; margin-top:0.3rem; align-items:center; }
.btn-like { background:none; border:1px solid rgba(255,255,255,0.15); border-radius:20px; padding:0.2rem 0.65rem; color:var(--color-muted); font-size:0.78rem; cursor:pointer; display:inline-flex; align-items:center; gap:0.3rem; margin:0; width:auto; transition:border-color 0.15s, color 0.15s; }
.btn-like:hover, .btn-like.liked { border-color:var(--color-primary); color:var(--color-primary); }
.btn-delete-small { background:none; border:none; color:rgba(200,50,50,0.6); font-size:0.78rem; cursor:pointer; margin:0; width:auto; padding:0; }
.btn-delete-small:hover { color:var(--color-primary); }
/* Comment + Like-Stile kommen aus shared.js */
</style>
</head>
<body class="app">
@@ -166,15 +150,18 @@
<div class="lb-comments-header">Kommentare</div>
<div class="lb-comments-list" id="lbCommentsList"></div>
<div class="lb-comment-compose">
<input type="text" id="lbCommentInput" placeholder="Kommentar schreiben…" maxlength="500"
onkeydown="if(event.key==='Enter') postLbComment()">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button>
<button onclick="postLbComment()">Senden</button>
<textarea id="lbCommentInput" placeholder="Kommentar schreiben…" maxlength="500" rows="3"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();postLbComment()}"></textarea>
<div class="lb-comment-compose-actions">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button>
<button onclick="postLbComment()">Senden</button>
</div>
</div>
</div>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
@@ -241,33 +228,7 @@
observer.observe(document.getElementById('mineSentinel'));
observer.observe(document.getElementById('publicSentinel'));
// ── Carousel ──
function bilderCarousel(bilder, postId) {
if (!bilder || bilder.length === 0) return '';
if (bilder.length === 1) {
return `<div style="margin-top:0.5rem;"><img class="post-bild" src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>`;
}
const slides = bilder.map((b, i) =>
`<div class="car-slide${i === 0 ? ' active' : ''}"><img class="post-bild" src="data:image/jpeg;base64,${b}" alt=""></div>`
).join('');
return `<div class="post-carousel">
${slides}
<button class="car-btn car-prev" onclick="event.stopPropagation();carNav(this,-1)">&#8249;</button>
<button class="car-btn car-next" onclick="event.stopPropagation();carNav(this,1)">&#8250;</button>
<div class="car-indicator"><span class="car-cur">1</span>/${bilder.length}</div>
</div>`;
}
function carNav(btn, dir) {
const car = btn.closest('.post-carousel');
const slides = Array.from(car.querySelectorAll('.car-slide'));
const cur = slides.findIndex(s => s.classList.contains('active'));
slides[cur].classList.remove('active');
const next = (cur + dir + slides.length) % slides.length;
slides[next].classList.add('active');
const ind = car.querySelector('.car-cur');
if (ind) ind.textContent = next + 1;
}
// bilderCarousel und carNav kommen aus shared.js
// ── Render post card ──
function renderPostCard(p, tab) {
@@ -533,7 +494,7 @@
const comments = await res.json();
document.getElementById('lbCommentsList').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, targetType, postId)).join('');
: comments.map(k => renderKommentarHtml(k, targetType, postId, { myUserId })).join('');
}
async function postLbComment() {
@@ -552,104 +513,16 @@
if (kcEl) kcEl.textContent = parseInt(kcEl.textContent) + 1;
}
function renderKommentarHtml(k, targetType, targetId) {
const avatarHtml = k.authorPicture
? `<img src="data:image/png;base64,${k.authorPicture}" alt="">`
: '◉';
const canDelete = k.authorId === myUserId;
return `<div class="comment-item" id="kom-${k.kommentarId}">
<div class="comment-avatar">${avatarHtml}</div>
<div class="comment-body">
<span class="comment-author">${esc(k.authorName)}</span>
<span class="comment-date">${fmtDate(k.createdAt)}</span>
<div class="comment-text">${esc(k.text)}</div>
<div class="comment-actions">
<button class="btn-like${k.likedByMe ? ' liked' : ''}" onclick="likeKommentar('${k.kommentarId}')">
♥ <span id="lkk-${k.kommentarId}">${k.likeCount}</span>
</button>
${canDelete ? `<button class="btn-delete-small" onclick="deleteKommentar('${k.kommentarId}','${targetType}','${targetId}')">✕</button>` : ''}
</div>
</div>
</div>`;
}
async function likeKommentar(kommentarId) {
await fetch('/social/kommentare/' + kommentarId + '/like', { method: 'POST' });
const btn = document.getElementById('lkk-' + kommentarId)?.parentElement;
const lc = document.getElementById('lkk-' + kommentarId);
if (!btn || !lc) return;
const was = btn.classList.contains('liked');
btn.classList.toggle('liked', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
}
// renderKommentarHtml und toggleKommentarLike kommen aus shared.js
async function deleteKommentar(kommentarId, targetType, targetId) {
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments(targetId, activeLbPostType);
}
// ── Emoji Picker ──
const EMOJIS = ['😊','😂','❤️','😍','🔥','👍','🥰','😎','🤔','😘','💕','🎉','✨','💋','😈','🫦','🍑','🍆','🔞','🥵','😭','😢','😤','🙄','🤦','🤷','🙏','💪','😏','🤩'];
let emojiTarget = null;
// toggleEmojiPicker, insertEmoji kommen aus shared.js
function toggleEmojiPicker(btn, targetId) {
emojiTarget = document.getElementById(targetId);
let picker = document.getElementById('emojiPicker');
if (!picker) {
picker = document.createElement('div');
picker.id = 'emojiPicker';
picker.style.cssText = 'position:fixed;z-index:1000;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:0.5rem;display:flex;flex-wrap:wrap;gap:0.2rem;max-width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.5);';
EMOJIS.forEach(em => {
const b = document.createElement('button');
b.textContent = em;
b.style.cssText = 'background:none;border:none;font-size:1.3rem;cursor:pointer;padding:0.2rem;margin:0;width:auto;line-height:1;';
b.onclick = e => { e.stopPropagation(); insertEmoji(em); };
picker.appendChild(b);
});
document.body.appendChild(picker);
}
if (picker.style.display === 'flex') { picker.style.display = 'none'; return; }
picker.style.display = 'flex';
requestAnimationFrame(() => {
const rect = btn.getBoundingClientRect();
const ph = picker.offsetHeight, pw = picker.offsetWidth;
let top = rect.top - ph - 8;
let left = rect.left;
if (top < 8) top = rect.bottom + 8;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
picker.style.top = top + 'px';
picker.style.left = left + 'px';
});
}
function insertEmoji(emoji) {
if (!emojiTarget) return;
const start = emojiTarget.selectionStart ?? emojiTarget.value.length;
const end = emojiTarget.selectionEnd ?? start;
emojiTarget.value = emojiTarget.value.slice(0, start) + emoji + emojiTarget.value.slice(end);
emojiTarget.selectionStart = emojiTarget.selectionEnd = start + emoji.length;
emojiTarget.focus();
}
document.addEventListener('click', e => {
const picker = document.getElementById('emojiPicker');
if (picker && picker.style.display === 'flex' && !picker.contains(e.target) && !e.target.closest('[onclick*="toggleEmojiPicker"]')) {
picker.style.display = 'none';
}
});
// ── Helpers ──
function esc(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/\n/g,'<br>');
}
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
}
// esc, fmtDate kommen aus shared.js
</script>
</body>
</html>

View File

@@ -43,14 +43,6 @@
.umfrage-option-row button { width:auto; margin:0; padding:0.3rem 0.6rem; font-size:0.8rem; }
.compose-footer { display:flex; justify-content:space-between; align-items:center; margin-top:0.75rem; flex-wrap:wrap; gap:0.5rem; }
.multi-toggle { font-size:0.85rem; display:flex; align-items:center; gap:0.4rem; }
/* Carousel */
.post-carousel { position:relative; margin-top:0.5rem; }
.car-slide { display:none; }
.car-slide.active { display:block; }
.car-btn { position:absolute; top:50%; transform:translateY(-50%); background:rgba(0,0,0,0.55); border:none; color:#fff; font-size:2.2rem; width:auto; min-width:2.4rem; height:3.2rem; border-radius:8px; cursor:pointer; z-index:5; display:flex; align-items:center; justify-content:center; padding:0 0.5rem; margin:0; line-height:1; }
.car-prev { left:0.3rem; }
.car-next { right:0.3rem; }
.car-indicator { text-align:center; font-size:0.75rem; color:var(--color-muted); margin-top:0.25rem; }
.post-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; padding:1rem; margin-bottom:0.9rem; }
.post-header { display:flex; align-items:center; gap:0.7rem; margin-bottom:0.6rem; }
@@ -76,13 +68,6 @@
/* Kommentare */
.comments-section { margin-top:0.75rem; border-top:1px solid var(--color-secondary); padding-top:0.75rem; }
.comment-item { display:flex; gap:0.5rem; margin-bottom:0.5rem; }
.comment-avatar { width:28px; height:28px; border-radius:50%; background:var(--color-secondary); display:flex; align-items:center; justify-content:center; font-size:0.75rem; flex-shrink:0; overflow:hidden; }
.comment-avatar img { width:100%; height:100%; object-fit:cover; }
.comment-body { flex:1; }
.comment-author { font-weight:600; font-size:0.82rem; }
.comment-text { font-size:0.85rem; white-space:pre-wrap; word-break:break-word; }
.comment-date { font-size:0.72rem; color:var(--color-muted); }
.comment-compose { display:flex; gap:0.5rem; margin-top:0.5rem; }
.comment-compose input { flex:1; font-size:0.85rem; padding:0.35rem 0.6rem; height:auto; }
.comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@@ -133,8 +118,10 @@
.lb-post-side { flex:1; overflow-y:auto; padding:1.25rem; border-right:1px solid var(--color-secondary); min-width:0; }
.lb-comments-panel { width:300px; flex-shrink:0; display:flex; flex-direction:column; }
.lb-comments-list { flex:1; overflow-y:auto; padding:0.75rem; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; gap:0.5rem; }
.lb-comment-compose input { flex:1; font-size:0.85rem; padding:0.35rem 0.6rem; height:auto; }
.lb-comment-compose { padding:0.75rem; border-top:1px solid var(--color-secondary); display:flex; flex-direction:column; gap:0.5rem; flex-shrink:0; }
.lb-comment-compose textarea { width:100%; font-size:0.85rem; padding:0.35rem 0.6rem; resize:none; background:var(--color-secondary); border:1px solid var(--color-secondary); border-radius:6px; color:var(--color-text); font-family:inherit; outline:none; transition:border-color 0.2s; box-sizing:border-box; }
.lb-comment-compose textarea:focus { border-color:var(--color-primary); }
.lb-comment-compose-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
.lb-comment-compose button { width:auto; margin:0; padding:0.35rem 0.75rem; font-size:0.8rem; }
@media (max-width:650px) {
.lb-layout { flex-direction:column; max-height:95vh; }
@@ -294,15 +281,19 @@
<div class="lb-post-side" id="lbPostContent"></div>
<div class="lb-comments-panel">
<div class="lb-comments-list" id="lbCommentsList"></div>
<div class="lb-comment-compose" style="display:flex;gap:0.5rem;align-items:center;">
<input type="text" id="lbCommentInput" placeholder="Kommentieren…" onkeydown="if(event.key==='Enter')submitLbComment()" style="flex:1;">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button>
<button onclick="submitLbComment()">Senden</button>
<div class="lb-comment-compose">
<textarea id="lbCommentInput" placeholder="Kommentieren…" maxlength="500" rows="3"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();submitLbComment()}"></textarea>
<div class="lb-comment-compose-actions">
<button type="button" class="compose-action-btn" onclick="toggleEmojiPicker(this,'lbCommentInput')" title="Emoji">😊</button>
<button onclick="submitLbComment()">Senden</button>
</div>
</div>
</div>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
@@ -355,8 +346,7 @@
let currentPage = 0;
const PAGE_SIZE = 10;
function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }
function fmtDate(iso) { if (!iso) return ''; return new Date(iso).toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' }); }
// esc, fmtDate kommen aus shared.js
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
@@ -466,32 +456,6 @@
btn.style.display = data.hasMore ? '' : 'none';
}
function bilderCarousel(bilder) {
if (!bilder || bilder.length === 0) return '';
if (bilder.length === 1) {
return `<div style="margin-top:0.5rem;"><img class="post-bild" src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>`;
}
const slides = bilder.map((b, i) =>
`<div class="car-slide${i === 0 ? ' active' : ''}"><img class="post-bild" src="data:image/jpeg;base64,${b}" alt=""></div>`
).join('');
return `<div class="post-carousel">
${slides}
<button class="car-btn car-prev" onclick="event.stopPropagation();carNav(this,-1)">&#8249;</button>
<button class="car-btn car-next" onclick="event.stopPropagation();carNav(this,1)">&#8250;</button>
<div class="car-indicator"><span class="car-cur">1</span>/${bilder.length}</div>
</div>`;
}
function carNav(btn, dir) {
const car = btn.closest('.post-carousel');
const slides = Array.from(car.querySelectorAll('.car-slide'));
const cur = slides.findIndex(s => s.classList.contains('active'));
slides[cur].classList.remove('active');
const next = (cur + dir + slides.length) % slides.length;
slides[next].classList.add('active');
const ind = car.querySelector('.car-cur');
if (ind) ind.textContent = next + 1;
}
function renderPostCard(p) {
const canDelete = (myRole === 'ADMIN' || p.authorId === myId);
@@ -593,17 +557,6 @@
]);
}
function renderComment(k) {
const av = k.authorPicture ? `<img src="data:image/png;base64,${k.authorPicture}" alt="">` : '◉';
return `<div class="comment-item">
<div class="comment-avatar">${av}</div>
<div class="comment-body">
<span class="comment-author">${esc(k.authorName)}</span>
<span class="comment-date" style="margin-left:0.4rem;">${fmtDate(k.createdAt)}</span>
<div class="comment-text">${esc(k.text)}</div>
</div>
</div>`;
}
// ── Compose image ──
@@ -1044,6 +997,11 @@
if (post) { post.likeCount = newCount; post.likedByMe = !isActive; }
}
async function deleteKommentar(kommentarId) {
await fetch('/social/kommentare/' + kommentarId, { method: 'DELETE' });
await loadLbComments();
}
async function loadLbComments() {
if (!lbPostId) return;
const list = document.getElementById('lbCommentsList');
@@ -1051,7 +1009,7 @@
const res = await fetch('/social/kommentare?targetType=GROUP_POST&targetId=' + lbPostId);
if (!res.ok) return;
const kmts = await res.json();
kmts.forEach(k => list.insertAdjacentHTML('beforeend', renderComment(k)));
kmts.forEach(k => list.insertAdjacentHTML('beforeend', renderKommentarHtml(k, 'GROUP_POST', lbPostId, { myUserId: myId })));
list.scrollTop = list.scrollHeight;
}
@@ -1123,55 +1081,6 @@
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closePostDialog(); });
// ── Emoji Picker ──
const EMOJIS = ['😊','😂','❤️','😍','🔥','👍','🥰','😎','🤔','😘','💕','🎉','✨','💋','😈','🫦','🍑','🍆','🔞','🥵','😭','😢','😤','🙄','🤦','🤷','🙏','💪','😏','🤩'];
let emojiTarget = null;
function toggleEmojiPicker(btn, targetId) {
emojiTarget = document.getElementById(targetId);
let picker = document.getElementById('emojiPicker');
if (!picker) {
picker = document.createElement('div');
picker.id = 'emojiPicker';
picker.style.cssText = 'position:fixed;z-index:1000;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:0.5rem;display:flex;flex-wrap:wrap;gap:0.2rem;max-width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.5);';
EMOJIS.forEach(em => {
const b = document.createElement('button');
b.textContent = em;
b.style.cssText = 'background:none;border:none;font-size:1.3rem;cursor:pointer;padding:0.2rem;margin:0;width:auto;line-height:1;';
b.onclick = e => { e.stopPropagation(); insertEmoji(em); };
picker.appendChild(b);
});
document.body.appendChild(picker);
}
if (picker.style.display === 'flex') { picker.style.display = 'none'; return; }
picker.style.display = 'flex';
requestAnimationFrame(() => {
const rect = btn.getBoundingClientRect();
const ph = picker.offsetHeight, pw = picker.offsetWidth;
let top = rect.top - ph - 8;
let left = rect.left;
if (top < 8) top = rect.bottom + 8;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
picker.style.top = top + 'px';
picker.style.left = left + 'px';
});
}
function insertEmoji(emoji) {
if (!emojiTarget) return;
const start = emojiTarget.selectionStart ?? emojiTarget.value.length;
const end = emojiTarget.selectionEnd ?? start;
emojiTarget.value = emojiTarget.value.slice(0, start) + emoji + emojiTarget.value.slice(end);
emojiTarget.selectionStart = emojiTarget.selectionEnd = start + emoji.length;
emojiTarget.focus();
}
document.addEventListener('click', e => {
const picker = document.getElementById('emojiPicker');
if (picker && picker.style.display === 'flex' && !picker.contains(e.target) && !e.target.closest('[onclick*="toggleEmojiPicker"]')) {
picker.style.display = 'none';
}
});
init();
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

View File

@@ -0,0 +1,237 @@
// ─────────────────────────────────────────────────────────────────────────────
// image-viewer.js Universelle Bild-Lightbox
//
// Einbinden: <script src="/js/shared.js"></script> (vorher)
// <script src="/js/image-viewer.js"></script>
//
// Zwei Modi:
// Modus A Nur Bild (kein Like, keine Kommentare):
// imageViewer.open({ images: [{ src }] })
//
// Modus B Galerie mit Like + Kommentare:
// imageViewer.open({
// images: [{ src, id, likedByMe, likeCount }],
// index: 0,
// showLike: true,
// showComments: true,
// myUserId: '...',
// onLike: async (img) => {} // optional; sonst POST /social/profile-images/{id}/like
// })
//
// Globale Instanz: window.imageViewer
// ─────────────────────────────────────────────────────────────────────────────
class ImageViewer {
constructor() {
this._cfg = null;
this._idx = 0;
this.isOpen = false;
this._injectStyles();
this._injectHTML();
this._bindEvents();
}
// ── Öffentliche API ───────────────────────────────────────────────────────
open(cfg) {
this._cfg = cfg;
this._idx = cfg.index || 0;
this.isOpen = true;
const multi = cfg.images.length > 1;
const showLike = !!cfg.showLike;
const showCom = !!cfg.showComments;
this._q('ivPrev').style.display = multi ? '' : 'none';
this._q('ivNext').style.display = multi ? '' : 'none';
this._q('ivCounter').style.display = multi ? '' : 'none';
this._q('ivLikeBtn').style.display = showLike ? '' : 'none';
this._q('ivComments').style.display = showCom ? '' : 'none';
this._render();
this._q('imageViewer').classList.add('open');
this._updateLayout();
}
close() {
this._q('imageViewer').classList.remove('open');
this.isOpen = false;
this._cfg = null;
}
/** Kommentare im offenen Viewer neu laden (z.B. nach externem Löschen) */
reloadComments() {
if (this.isOpen && this._cfg?.showComments) this._loadComments();
}
// ── Internes Rendering ────────────────────────────────────────────────────
_q(id) { return document.getElementById(id); }
_render() {
const img = this._cfg.images[this._idx];
this._q('ivImg').src = img.src;
const total = this._cfg.images.length;
this._q('ivCounter').textContent = `${this._idx + 1} / ${total}`;
this._q('ivPrev').disabled = this._idx === 0;
this._q('ivNext').disabled = this._idx === total - 1;
if (this._cfg.showLike) this._syncLike();
if (this._cfg.showComments) this._loadComments();
}
_syncLike() {
const img = this._cfg.images[this._idx];
const btn = this._q('ivLikeBtn');
btn.className = 'btn-like' + (img.likedByMe ? ' liked' : '');
this._q('ivLikeCount').textContent = img.likeCount;
}
async _loadComments() {
const img = this._cfg.images[this._idx];
const res = await fetch(`/social/kommentare?targetType=IMAGE&targetId=${img.id}`);
const comments = await res.json();
const myUserId = this._cfg.myUserId || null;
this._q('ivCommentsList').innerHTML = comments.length === 0
? '<p style="color:var(--color-muted);font-size:0.82rem;margin-bottom:0.4rem;">Noch keine Kommentare.</p>'
: comments.map(k => renderKommentarHtml(k, 'IMAGE', img.id, { myUserId, showReplies: true })).join('');
}
async _postComment() {
const input = this._q('ivCommentInput');
const text = input.value.trim();
if (!text) return;
const img = this._cfg.images[this._idx];
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType: 'IMAGE', targetId: img.id, text })
});
input.value = '';
await this._loadComments();
}
async _toggleLike() {
const img = this._cfg.images[this._idx];
const onLike = this._cfg.onLike;
img.likedByMe = !img.likedByMe;
img.likeCount += img.likedByMe ? 1 : -1;
this._syncLike();
try {
if (onLike) await onLike(img);
else await fetch('/social/profile-images/' + img.id + '/like', { method: 'POST' });
} catch {
img.likedByMe = !img.likedByMe;
img.likeCount += img.likedByMe ? 1 : -1;
this._syncLike();
}
}
_prev() { if (this._idx > 0) { this._idx--; this._render(); } }
_next() { if (this._idx < this._cfg.images.length - 1) { this._idx++; this._render(); } }
_updateLayout() {
const el = this._q('ivLayout');
if (!el) return;
const bp = parseInt(getComputedStyle(document.documentElement)
.getPropertyValue('--breakpoint-mobile').trim()) || 768;
el.classList.toggle('iv-narrow', window.innerWidth <= bp);
}
// ── CSS + HTML Injection ──────────────────────────────────────────────────
_injectStyles() {
if (document.getElementById('iv-styles')) return;
const s = document.createElement('style');
s.id = 'iv-styles';
s.textContent = `
#imageViewer{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.88);z-index:500;align-items:center;justify-content:center;padding:2rem}
#imageViewer.open{display:flex}
#ivLayout{display:flex;flex-direction:row;gap:1rem;height:min(78vh,660px);max-width:calc(100vw - 4rem);align-items:stretch}
#ivImageSide{width:660px;flex-shrink:1;min-width:0;display:flex;flex-direction:column}
.iv-image-box{flex:1;position:relative;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;overflow:hidden;display:flex;align-items:center;justify-content:center}
#ivImg{width:100%;height:100%;object-fit:contain;display:block}
.iv-overlay{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,0.6));border-radius:0 0 12px 12px;padding:2rem 0.75rem 0.6rem;display:flex;align-items:center;justify-content:space-between;gap:0.5rem}
.iv-nav-btn{background:rgba(0,0,0,0.35);border:1px solid rgba(255,255,255,0.2);border-radius:6px;color:#fff;padding:0.3rem 0.75rem;cursor:pointer;margin:0;width:auto;font-size:1rem;flex-shrink:0;transition:background 0.15s}
.iv-nav-btn:hover{background:rgba(0,0,0,0.65)}
.iv-nav-btn:disabled{opacity:.25;cursor:default}
.iv-overlay-center{display:flex;align-items:center;gap:0.6rem;flex:1;justify-content:center}
#ivCounter{font-size:0.8rem;color:rgba(255,255,255,0.75)}
.iv-close{position:fixed;top:1rem;right:1rem;background:rgba(0,0,0,0.55);border:1px solid rgba(255,255,255,0.2);color:#fff;font-size:1.1rem;width:2.2rem;height:2.2rem;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;margin:0;z-index:502;transition:background 0.15s}
.iv-close:hover{background:rgba(180,30,30,0.8)}
#ivComments{background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;width:280px;flex-shrink:0;display:flex;flex-direction:column;overflow:hidden}
.iv-comments-header{font-size:0.78rem;font-weight:600;color:var(--color-muted);text-transform:uppercase;letter-spacing:0.06em;padding:0.7rem 1rem;border-bottom:1px solid var(--color-secondary);flex-shrink:0}
#ivCommentsList{flex:1;overflow-y:auto;padding:0.65rem 0.75rem;scrollbar-width:thin;scrollbar-color:var(--color-secondary) transparent}
.iv-comment-compose{display:flex;gap:0.4rem;padding:0.65rem 0.75rem;border-top:1px solid var(--color-secondary);flex-shrink:0;align-items:center}
.iv-comment-compose input{flex:1;padding:0.4rem 0.65rem;font-size:0.85rem}
.iv-comment-compose button{width:auto;padding:0.4rem 0.7rem;font-size:0.82rem;white-space:nowrap}
#ivLayout.iv-narrow{flex-direction:column;height:auto;max-height:90vh;overflow-y:auto;width:calc(100vw - 1rem);max-width:calc(100vw - 1rem)}
#ivLayout.iv-narrow #ivImageSide{width:100%;flex-shrink:0}
#ivLayout.iv-narrow .iv-image-box{height:min(45vh,360px);flex:none}
#ivLayout.iv-narrow #ivComments{width:100%;max-height:40vh;flex-shrink:0}
`;
document.head.appendChild(s);
}
_injectHTML() {
if (document.getElementById('imageViewer')) return;
const div = document.createElement('div');
div.id = 'imageViewer';
div.innerHTML = `
<button class="iv-close" id="ivClose">✕</button>
<div id="ivLayout">
<div id="ivImageSide">
<div class="iv-image-box">
<img id="ivImg" src="" alt="">
<div class="iv-overlay">
<button class="iv-nav-btn" id="ivPrev">&#8592;</button>
<div class="iv-overlay-center">
<span id="ivCounter"></span>
<button class="btn-like" id="ivLikeBtn">♥ <span id="ivLikeCount">0</span></button>
</div>
<button class="iv-nav-btn" id="ivNext">&#8594;</button>
</div>
</div>
</div>
<div id="ivComments">
<div class="iv-comments-header">Kommentare</div>
<div id="ivCommentsList"></div>
<div class="iv-comment-compose">
<input type="text" id="ivCommentInput" placeholder="Kommentar schreiben…" maxlength="500">
<button type="button" onclick="toggleEmojiPicker(this,'ivCommentInput')" title="Emoji"
style="background:none;border:1px solid var(--color-secondary);color:var(--color-muted);border-radius:6px;padding:0.35rem 0.6rem;font-size:0.95rem;cursor:pointer;margin:0;width:auto;">😊</button>
<button id="ivCommentSend">Senden</button>
</div>
</div>
</div>`;
document.body.appendChild(div);
}
_bindEvents() {
const init = () => {
this._q('ivClose').addEventListener('click', () => this.close());
this._q('imageViewer').addEventListener('click', e => {
if (e.target === this._q('imageViewer')) this.close();
});
this._q('ivPrev').addEventListener('click', () => this._prev());
this._q('ivNext').addEventListener('click', () => this._next());
this._q('ivLikeBtn').addEventListener('click', () => this._toggleLike());
this._q('ivCommentSend').addEventListener('click', () => this._postComment());
this._q('ivCommentInput').addEventListener('keydown', e => {
if (e.key === 'Enter') this._postComment();
});
window.addEventListener('resize', () => this._updateLayout());
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
document.addEventListener('keydown', e => {
if (!this.isOpen) return;
if (e.key === 'Escape') this.close();
if (e.key === 'ArrowLeft') this._prev();
if (e.key === 'ArrowRight') this._next();
});
}
}
window.imageViewer = new ImageViewer();

View File

@@ -0,0 +1,237 @@
// ─────────────────────────────────────────────────────────────────────────────
// shared.js Gemeinsame Helfer & Komponenten
// Einbinden: <script src="/js/shared.js"></script>
// (vor allen Seiten-Skripten, nach CSS-Links)
// ─────────────────────────────────────────────────────────────────────────────
// ── CSS-Injection (Comment + Carousel) ────────────────────────────────────────
(function injectSharedStyles() {
if (document.getElementById('shared-styles')) return;
const s = document.createElement('style');
s.id = 'shared-styles';
s.textContent = `
/* ── Karussell ── */
.post-carousel{position:relative;margin-top:0.5rem}
.car-slide{display:none}
.car-slide.active{display:block}
.car-btn{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.55);border:none;color:#fff;font-size:2.2rem;width:auto;min-width:2.4rem;height:3.2rem;border-radius:8px;cursor:pointer;z-index:5;display:flex;align-items:center;justify-content:center;padding:0 0.5rem;margin:0;line-height:1}
.car-prev{left:0.3rem}
.car-next{right:0.3rem}
.car-indicator{text-align:center;font-size:0.75rem;color:var(--color-muted);margin-top:0.25rem}
/* ── Like / Löschen-Buttons ── */
.btn-like{background:none;border:1px solid rgba(255,255,255,0.15);border-radius:20px;padding:0.2rem 0.65rem;color:var(--color-muted);font-size:0.78rem;cursor:pointer;display:inline-flex;align-items:center;gap:0.3rem;margin:0;width:auto;transition:border-color 0.15s,color 0.15s}
.btn-like:hover,.btn-like.liked{border-color:var(--color-primary);color:var(--color-primary)}
.btn-delete-small{background:none;border:none;color:rgba(200,50,50,0.6);font-size:0.78rem;cursor:pointer;margin:0;width:auto;padding:0}
.btn-delete-small:hover{color:var(--color-primary)}
.btn-text{background:none;border:none;color:var(--color-muted);font-size:0.78rem;cursor:pointer;margin:0;width:auto;padding:0;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.2)}
.btn-text:hover{color:var(--color-text)}
/* ── Kommentare ── */
.comment-item{display:flex;gap:0.5rem;margin-bottom:0.5rem}
.comment-avatar{width:28px;height:28px;border-radius:50%;background:var(--color-secondary);display:flex;align-items:center;justify-content:center;font-size:0.75rem;flex-shrink:0;overflow:hidden}
.comment-avatar img{width:100%;height:100%;object-fit:cover}
.comment-body{flex:1;background:rgba(255,255,255,0.04);border-radius:6px;padding:0.5rem 0.65rem}
.comment-author{font-size:0.8rem;font-weight:600;color:var(--color-text)}
.comment-date{font-size:0.72rem;color:var(--color-muted);margin-left:0.4rem}
.comment-text{font-size:0.85rem;color:rgba(255,255,255,0.75);margin-top:0.2rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
.comment-actions{display:flex;gap:0.4rem;margin-top:0.3rem;align-items:center}
.replies-section{margin-top:0.5rem;padding-left:0.5rem;border-left:2px solid rgba(255,255,255,0.06)}
.comment-write{display:flex;gap:0.4rem;margin-top:0.5rem}
.comment-write input{flex:1;padding:0.4rem 0.75rem;font-size:0.85rem}
.comment-write button{width:auto;padding:0.4rem 0.75rem;font-size:0.82rem;white-space:nowrap}
`;
document.head.appendChild(s);
})();
// ── HTML-Escape ────────────────────────────────────────────────────────────────
function esc(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/\n/g, '<br>');
}
// ── Datum-Format ──────────────────────────────────────────────────────────────
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
// ── Emoji-Picker ──────────────────────────────────────────────────────────────
const EMOJIS = ['😊','😂','❤️','😍','🔥','👍','🥰','😎','🤔','😘','💕','🎉','✨','💋','😈','🫦','🍑','🍆','🔞','🥵','😭','😢','😤','🙄','🤦','🤷','🙏','💪','😏','🤩'];
let _emojiTarget = null;
function toggleEmojiPicker(btn, targetId) {
_emojiTarget = document.getElementById(targetId);
let picker = document.getElementById('sharedEmojiPicker');
if (!picker) {
picker = document.createElement('div');
picker.id = 'sharedEmojiPicker';
picker.style.cssText = 'position:fixed;z-index:9000;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:0.5rem;display:flex;flex-wrap:wrap;gap:0.2rem;max-width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.5);';
EMOJIS.forEach(em => {
const b = document.createElement('button');
b.textContent = em;
b.style.cssText = 'background:none;border:none;font-size:1.3rem;cursor:pointer;padding:0.2rem;margin:0;width:auto;line-height:1;';
b.onclick = e => { e.stopPropagation(); insertEmoji(em); };
picker.appendChild(b);
});
document.body.appendChild(picker);
}
if (picker.style.display === 'flex') { picker.style.display = 'none'; return; }
picker.style.display = 'flex';
requestAnimationFrame(() => {
const rect = btn.getBoundingClientRect();
const ph = picker.offsetHeight, pw = picker.offsetWidth;
let top = rect.top - ph - 8;
let left = rect.left;
if (top < 8) top = rect.bottom + 8;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
picker.style.top = top + 'px';
picker.style.left = left + 'px';
});
}
function insertEmoji(emoji) {
if (!_emojiTarget) return;
const start = _emojiTarget.selectionStart ?? _emojiTarget.value.length;
const end = _emojiTarget.selectionEnd ?? start;
_emojiTarget.value = _emojiTarget.value.slice(0, start) + emoji + _emojiTarget.value.slice(end);
_emojiTarget.selectionStart = _emojiTarget.selectionEnd = start + emoji.length;
_emojiTarget.focus();
}
document.addEventListener('click', e => {
const picker = document.getElementById('sharedEmojiPicker');
if (picker && picker.style.display === 'flex'
&& !picker.contains(e.target)
&& !e.target.closest('[onclick*="toggleEmojiPicker"]')) {
picker.style.display = 'none';
}
});
// ── Bild-Karussell ────────────────────────────────────────────────────────────
function bilderCarousel(bilder) {
if (!bilder || bilder.length === 0) return '';
if (bilder.length === 1) {
return `<div style="margin-top:0.5rem;"><img class="post-bild" src="data:image/jpeg;base64,${bilder[0]}" alt=""></div>`;
}
const slides = bilder.map((b, i) =>
`<div class="car-slide${i === 0 ? ' active' : ''}"><img class="post-bild" src="data:image/jpeg;base64,${b}" alt=""></div>`
).join('');
return `<div class="post-carousel">
${slides}
<button class="car-btn car-prev" onclick="event.stopPropagation();carNav(this,-1)">&#8249;</button>
<button class="car-btn car-next" onclick="event.stopPropagation();carNav(this,1)">&#8250;</button>
<div class="car-indicator"><span class="car-cur">1</span>/${bilder.length}</div>
</div>`;
}
function carNav(btn, dir) {
const car = btn.closest('.post-carousel');
const slides = Array.from(car.querySelectorAll('.car-slide'));
const cur = slides.findIndex(s => s.classList.contains('active'));
slides[cur].classList.remove('active');
const next = (cur + dir + slides.length) % slides.length;
slides[next].classList.add('active');
const ind = car.querySelector('.car-cur');
if (ind) ind.textContent = next + 1;
}
// ── Kommentar-Rendering ───────────────────────────────────────────────────────
// opts: { myUserId, showReplies }
// Seite muss definieren: deleteKommentar(kommentarId, targetType, targetId)
function renderKommentarHtml(k, targetType, targetId, opts) {
const { myUserId = null, showReplies = false } = opts || {};
const avatarHtml = k.authorPicture
? `<img src="data:image/png;base64,${k.authorPicture}" alt="">`
: '◉';
const canDelete = k.authorId === myUserId;
const replyLabel = k.replyCount > 0 ? `Antworten (${k.replyCount})` : 'Antworten';
return `<div class="comment-item" id="kom-${k.kommentarId}">
<div class="comment-avatar">${avatarHtml}</div>
<div class="comment-body">
<span class="comment-author">${esc(k.authorName)}</span>
<span class="comment-date">${fmtDate(k.createdAt)}</span>
<div class="comment-text">${esc(k.text)}</div>
<div class="comment-actions">
<button class="btn-like${k.likedByMe ? ' liked' : ''}" id="lk-kom-${k.kommentarId}"
onclick="toggleKommentarLike('${k.kommentarId}')">♥ <span id="lkc-kom-${k.kommentarId}">${k.likeCount}</span></button>
${showReplies ? `<button class="btn-text" onclick="toggleReplies('${k.kommentarId}')">${replyLabel}</button>` : ''}
${canDelete ? `<button class="btn-delete-small" onclick="deleteKommentar('${k.kommentarId}','${targetType}','${targetId}')">✕</button>` : ''}
</div>
${showReplies ? `<div class="replies-section" id="replies-${k.kommentarId}" style="display:none;"></div>` : ''}
</div>
</div>`;
}
function renderReplyHtml(r, parentId) {
const avatarHtml = r.authorPicture
? `<img src="data:image/png;base64,${r.authorPicture}" alt="">`
: '◉';
const canDelete = typeof window.myUserId !== 'undefined' && r.authorId === window.myUserId;
return `<div class="comment-item" id="kom-${r.kommentarId}" style="margin-bottom:0.35rem;">
<div class="comment-avatar" style="width:22px;height:22px;font-size:0.75rem;">${avatarHtml}</div>
<div class="comment-body" style="padding:0.35rem 0.55rem;">
<span class="comment-author">${esc(r.authorName)}</span>
<span class="comment-date">${fmtDate(r.createdAt)}</span>
<div class="comment-text">${esc(r.text)}</div>
<div class="comment-actions">
<button class="btn-like${r.likedByMe ? ' liked' : ''}" id="lk-kom-${r.kommentarId}"
onclick="toggleKommentarLike('${r.kommentarId}')">♥ <span id="lkc-kom-${r.kommentarId}">${r.likeCount}</span></button>
${canDelete ? `<button class="btn-delete-small" onclick="deleteReply('${r.kommentarId}','${parentId}')">✕</button>` : ''}
</div>
</div>
</div>`;
}
async function toggleKommentarLike(kommentarId) {
await fetch('/social/kommentare/' + kommentarId + '/like', { method: 'POST' });
const btn = document.getElementById('lk-kom-' + kommentarId);
const lc = document.getElementById('lkc-kom-' + kommentarId);
if (!btn || !lc) return;
const was = btn.classList.contains('liked');
btn.classList.toggle('liked', !was);
lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1);
}
async function toggleReplies(kommentarId) {
const section = document.getElementById('replies-' + kommentarId);
if (section.style.display === 'none') {
section.style.display = '';
await loadReplies(kommentarId);
} else {
section.style.display = 'none';
}
}
async function loadReplies(kommentarId) {
const res = await fetch(`/social/kommentare?targetType=KOMMENTAR&targetId=${kommentarId}`);
const replies = await res.json();
const section = document.getElementById('replies-' + kommentarId);
section.innerHTML = (replies.length === 0
? '<p style="color:var(--color-muted);font-size:0.78rem;margin-bottom:0.35rem;">Noch keine Antworten.</p>'
: replies.map(r => renderReplyHtml(r, kommentarId)).join(''))
+ `<div class="comment-write">
<input type="text" id="ri-${kommentarId}" placeholder="Antwort schreiben…" maxlength="500"
onkeydown="if(event.key==='Enter') postReply('${kommentarId}')">
<button onclick="postReply('${kommentarId}')">Senden</button>
</div>`;
}
async function postReply(kommentarId) {
const input = document.getElementById('ri-' + kommentarId);
const text = input.value.trim();
if (!text) return;
await fetch('/social/kommentare', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetType: 'KOMMENTAR', targetId: kommentarId, text })
});
input.value = '';
await loadReplies(kommentarId);
}
async function deleteReply(replyId, parentId) {
await fetch('/social/kommentare/' + replyId, { method: 'DELETE' });
await loadReplies(parentId);
}

View File

@@ -27,7 +27,8 @@
icon: '⊗',
items: [
{ href: '/infochastity.html', icon: '', label: 'Info' },
{ href: '/sessionchastity.html', icon: '▷', label: 'Neue Session' },
{ href: '/sessionchastity.html', icon: '▷', label: 'Neue Session', id: 'navChastityNeu' },
{ href: '#', icon: '▶', label: 'Aktive Session', id: 'navChastityAktiv' },
]
},
];
@@ -93,9 +94,12 @@
});
// "Im Spiel" standardmäßig ausblenden; wird nach Session-Check ggf. wieder eingeblendet
const navNeu = document.getElementById('navBdsmNeu');
const navImSpiel = document.getElementById('navBdsmImSpiel');
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';
// Session-Status prüfen
fetch('/login/me')
@@ -103,13 +107,27 @@
.then(async user => {
if (!user) return;
// Session-Status prüfen und Menü anpassen
// BDSM Session-Status
try {
const sessionRes = await fetch(`/session?userId=${user.userId}`);
const hasSession = sessionRes.status === 200;
if (navNeu) navNeu.style.display = hasSession ? 'none' : '';
if (navImSpiel) navImSpiel.style.display = hasSession ? '' : 'none';
} catch (_) { /* Menü bleibt im Standardzustand */ }
// Chastity Lock-Status
try {
const lockRes = await fetch('/keyholder/mylock');
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;
}
}
} catch (_) { /* Menü bleibt im Standardzustand */ }
})
.catch(() => {});

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keyholder*In bestätigt XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body class="app">
<div class="main">
<div class="content" style="max-width:480px;margin:4rem auto;text-align:center;">
<div id="msgOk" style="display:none;">
<div style="font-size:3rem;margin-bottom:1rem;">🔐</div>
<h2 style="margin-bottom:0.75rem;">Keyholder*In-Rolle angenommen!</h2>
<p style="color:var(--color-muted);margin-bottom:2rem;line-height:1.6;">
Du hast die Keyholder*In-Rolle erfolgreich bestätigt.<br>
Das Lock läuft ab sofort mit dir als Keyholder*In.
</p>
<a href="/userhome.html" style="display:inline-block;padding:0.65rem 1.75rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">
Zur Startseite
</a>
</div>
<div id="msgInvalid" style="display:none;">
<div style="font-size:3rem;margin-bottom:1rem;"></div>
<h2 style="margin-bottom:0.75rem;">Link ungültig</h2>
<p style="color:var(--color-muted);margin-bottom:2rem;line-height:1.6;">
Dieser Bestätigungslink ist abgelaufen oder wurde bereits verwendet.
</p>
<a href="/userhome.html" style="display:inline-block;padding:0.65rem 1.75rem;background:var(--color-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">
Zur Startseite
</a>
</div>
</div>
</div>
<script>
const status = new URLSearchParams(window.location.search).get('status');
document.getElementById(status === 'ok' ? 'msgOk' : 'msgInvalid').style.display = '';
</script>
</body>
</html>

View File

@@ -407,11 +407,14 @@
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/image-viewer.js"></script>
<script src="/js/sidebar.js"></script>
<script>
let currentPicture = null;
let currentPictureHq = null;
let pictureDirty = false;
let allGalleryImages = [];
fetch('/login/me')
.then(r => {
@@ -637,19 +640,34 @@
}
function renderOwnGallery(images) {
allGalleryImages = images;
const grid = document.getElementById('ownGallery');
if (images.length === 0) {
grid.innerHTML = '<p style="color:var(--color-muted);font-size:0.85rem;grid-column:1/-1;">Noch keine Bilder hochgeladen.</p>';
return;
}
grid.innerHTML = images.map(img => `
<div class="own-thumb">
grid.innerHTML = images.map((img, i) => `
<div class="own-thumb" onclick="openGalleryViewer(${i})" style="cursor:pointer;">
<img src="data:image/jpeg;base64,${img.imageData}" alt="Galerie-Bild">
<button class="own-thumb-delete" onclick="deleteGalleryImage('${img.imageId}', event)" title="Bild löschen">✕</button>
</div>
`).join('');
}
function openGalleryViewer(index) {
imageViewer.open({
images: allGalleryImages.map(img => ({
src: 'data:image/jpeg;base64,' + img.imageData,
id: img.imageId,
likedByMe: false,
likeCount: 0
})),
index,
showLike: false,
showComments: false
});
}
async function deleteGalleryImage(imageId, event) {
event.stopPropagation();
const res = await fetch('/social/profile-images/' + imageId, { method: 'DELETE' });

View File

@@ -6,14 +6,941 @@
<title>Chastity Game Neue Session XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.form-section {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.form-section-title {
font-size: 0.78rem;
font-weight: 700;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 1rem;
}
.form-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.9rem;
}
.form-row:last-child { margin-bottom: 0; }
.form-row label {
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text);
}
.form-hint {
font-size: 0.78rem;
color: var(--color-muted);
margin-top: 0.1rem;
}
.form-row input[type="text"],
.form-row input[type="number"],
.form-row input[type="datetime-local"],
.form-row select {
width: 100%;
box-sizing: border-box;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.75rem;
cursor: pointer;
}
.checkbox-row:last-child { margin-bottom: 0; }
.checkbox-row input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--color-primary);
}
.checkbox-row label {
font-size: 0.9rem;
color: var(--color-text);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
/* Karten-Grid */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.6rem;
margin-bottom: 1rem;
}
.card-count-item {
background: var(--color-secondary);
border-radius: 8px;
padding: 0.7rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
text-align: center;
}
.card-count-item img {
width: 54px;
height: auto;
border-radius: 4px;
object-fit: contain;
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.78rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.2;
}
/* Stepper */
.card-range-row {
display: flex;
align-items: center;
gap: 0.25rem;
width: 100%;
justify-content: center;
}
.card-range-row .range-label {
font-size: 0.72rem;
color: var(--color-muted);
width: 24px;
text-align: right;
flex-shrink: 0;
}
.stepper {
display: flex;
align-items: center;
border: 1px solid var(--color-muted);
border-radius: 6px;
overflow: hidden;
height: 28px;
}
.stepper button {
width: 24px;
height: 28px;
padding: 0;
margin: 0;
border: none;
border-radius: 0;
background: var(--color-card);
color: var(--color-text);
font-size: 1rem;
line-height: 1;
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: 32px;
height: 28px;
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.85rem;
padding: 0;
background: var(--color-card);
color: var(--color-text);
box-sizing: border-box;
}
.stepper input[type="text"]:focus { outline: none; background: var(--color-secondary); }
/* Karten-Info-Dialog */
.card-info-dialog {
display: none;
position: fixed;
inset: 0;
z-index: 500;
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: 320px;
width: 90%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
z-index: 1;
}
.card-info-box img {
width: 90px;
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;
}
.card-info-close {
margin-top: 0.25rem;
width: auto;
padding: 0.45rem 1.4rem;
}
/* Aufgaben */
.task-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 0.75rem; }
.task-item {
display: grid;
grid-template-columns: 1fr 80px auto;
gap: 0.5rem;
align-items: center;
background: var(--color-secondary);
border-radius: 8px;
padding: 0.65rem 0.75rem;
}
.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: 1rem;
padding: 0;
margin: 0;
width: auto;
line-height: 1;
}
.task-remove:hover { color: #e74c3c; background: none; }
.btn-add {
background: none;
border: 1px dashed var(--color-secondary);
color: var(--color-muted);
border-radius: 8px;
padding: 0.5rem;
width: 100%;
cursor: pointer;
font-size: 0.85rem;
margin: 0;
transition: border-color 0.15s, color 0.15s;
}
.btn-add:hover { border-color: var(--color-primary); color: var(--color-primary); background: none; }
.hygiene-fields { display: none; }
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
.form-actions button { width: auto; padding: 0.65rem 1.5rem; }
.error-msg { color: #e74c3c; font-size: 0.85rem; margin-top: 0.4rem; display: none; }
.field-error input,
.field-error .combo-wrap input[type="text"],
.field-error .stepper {
border-color: #e74c3c !important;
}
.field-error .stepper { box-shadow: 0 0 0 1px #e74c3c; border-radius: 6px; }
.field-error-msg {
font-size: 0.78rem;
color: #e74c3c;
margin-top: 0.15rem;
}
.required-star { color: #e74c3c; margin-left: 0.15em; }
.inline-number { display: flex; align-items: center; gap: 0.5rem; }
.inline-number input { width: 90px !important; flex-shrink: 0; }
.inline-number span { font-size: 0.9rem; color: var(--color-text); }
/* Keyholder Combobox */
.combo-wrap {
position: relative;
}
.combo-wrap input[type="text"] {
width: 100%;
box-sizing: border-box;
}
.combo-dropdown {
display: none;
position: absolute;
top: calc(100% + 3px);
left: 0;
right: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 8px;
max-height: 220px;
overflow-y: auto;
z-index: 200;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
.combo-dropdown.open { display: block; }
.combo-option {
padding: 0.55rem 0.85rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text);
}
.combo-option:hover,
.combo-option.active { background: var(--color-secondary); }
.combo-option .combo-hint {
font-size: 0.78rem;
color: var(--color-muted);
margin-left: 0.4rem;
}
.combo-empty {
padding: 0.55rem 0.85rem;
font-size: 0.85rem;
color: var(--color-muted);
font-style: italic;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1>Chastity Game Neue Session</h1>
<p>Session-Setup für das Chastity Game folgt hier.</p>
<h2 style="margin-bottom:1.25rem;">🔒 Neue Chastity-Session</h2>
<!-- 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>
<input type="text" id="lockName" placeholder="z.B. Wochenend-Lock" maxlength="100" required
oninput="clearFieldError('rowLockName')">
</div>
<div class="form-row">
<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>
<div class="form-row">
<label for="unlockCodeLines">Zeilen des Entsperrcodes</label>
<div class="inline-number">
<input type="number" id="unlockCodeLines" min="1" max="20" value="5">
<span>Zeilen</span>
</div>
<div class="form-hint">Aus wie vielen Zeilen der Entsperrcode bestehen soll.</div>
</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>
</div>
</div>
<!-- 2. Karten -->
<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>
</div>
</div>
<div class="checkbox-row">
<input type="checkbox" id="accumulatePicks">
<label for="accumulatePicks">Picks akkumulieren
<span class="form-hint">(nicht genutzte Züge bleiben erhalten)</span>
</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="showRemaining">
<label for="showRemaining">Art der verbleibenden Karten anzeigen</label>
</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 -->
<div class="form-section">
<div class="form-section-title">Hygiene-Öffnungen (optional)</div>
<div class="checkbox-row">
<input type="checkbox" id="hygieneToggle" onchange="toggleHygiene(this.checked)">
<label for="hygieneToggle">Regelmäßige Hygiene-Öffnungen aktivieren</label>
</div>
<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>
</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>
</div>
</div>
</div>
<!-- 5. Aufgaben -->
<div class="form-section">
<div class="form-section-title">Aufgaben (optional)</div>
<div class="form-hint" style="margin-bottom:0.75rem;">
Aufgaben werden zufällig bei Aufgaben-Karten zugeteilt. Jede Aufgabe hat eine Beschreibung und eine Dauer in Minuten.
</div>
<div class="task-list" id="taskList"></div>
<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>
</div>
</div>
</div>
<!-- Entsperrcode-Modal -->
<div class="card-info-dialog" id="unlockModal">
<div class="card-info-overlay"></div>
<div class="card-info-box" style="max-width:380px;">
<div style="font-size:2rem;">🔒</div>
<h3 style="margin:0;">Dein Entsperrcode</h3>
<p id="unlockModalHint" style="color:var(--color-muted);font-size:0.85rem;text-align:center;margin:0;">
Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem.
</p>
<div id="unlockCodeDisplay" style="
font-family: monospace;
font-size: 2rem;
letter-spacing: 0.3em;
background: var(--color-secondary);
border-radius: 8px;
padding: 1rem 1.5rem;
text-align: center;
color: var(--color-primary);
line-height: 1.8;
word-break: break-all;
"></div>
<div id="unlockModalCountdown" style="display:none;font-size:0.82rem;color:var(--color-muted);text-align:center;font-family:monospace;"></div>
<div id="unlockKeyholderHint" style="display:none;background:var(--color-secondary);border-radius:8px;padding:0.75rem 1rem;font-size:0.85rem;color:var(--color-muted);text-align:center;line-height:1.5;">
⏳ Die eingetragene Keyholder*In wurde per E-Mail benachrichtigt und muss die Rolle noch bestätigen.
Bis zur Bestätigung läuft das Lock als Self-Lock.
</div>
<button id="unlockModalBtn" onclick="" style="width:100%;margin-top:0.25rem;">Weiter</button>
</div>
</div>
<!-- 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 class="card-info-close" onclick="closeCardInfo()">Schließen</button>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
let myUserId = 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);
});
let allFriends = [];
let comboActiveIdx = -1;
async function loadKeyholderOptions(myId) {
try {
allFriends = await fetch('/social/friends/user/' + myId).then(r => r.ok ? r.json() : []);
} catch { allFriends = []; }
setupKeyholderCombo();
}
function setupKeyholderCombo() {
const input = document.getElementById('keyholderInput');
const dropdown = document.getElementById('keyholderDropdown');
const hidden = document.getElementById('keyholderValue');
function renderDropdown(query) {
const q = query.toLowerCase().trim();
const filtered = q
? allFriends.filter(f => f.name.toLowerCase().includes(q))
: allFriends;
dropdown.innerHTML = '';
comboActiveIdx = -1;
if (filtered.length === 0) {
dropdown.innerHTML = `<div class="combo-empty">${q ? 'Keine Freunde gefunden.' : 'Keine Freunde vorhanden.'}</div>`;
} else {
filtered.forEach((f, i) => {
const div = document.createElement('div');
div.className = 'combo-option';
div.dataset.id = f.userId;
div.dataset.name = f.name;
div.dataset.idx = i;
div.textContent = f.name;
div.addEventListener('mousedown', e => {
e.preventDefault();
selectKeyholder(f.userId, f.name);
});
dropdown.appendChild(div);
});
}
dropdown.classList.add('open');
}
function selectKeyholder(id, name) {
hidden.value = id;
input.value = name;
dropdown.classList.remove('open');
updateTestLock();
}
function clearKeyholder() {
hidden.value = '';
updateTestLock();
}
input.addEventListener('input', () => {
clearKeyholder();
renderDropdown(input.value);
});
input.addEventListener('focus', () => {
renderDropdown(input.value);
});
input.addEventListener('blur', () => {
// slight delay so mousedown on option fires first
setTimeout(() => {
dropdown.classList.remove('open');
// if text doesn't match selected, clear hidden value and text
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 === '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) selectKeyholder(active.dataset.id, active.dataset.name);
else dropdown.classList.remove('open');
} else if (e.key === 'Escape') {
dropdown.classList.remove('open');
}
});
}
// ── 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';
}
}
// ── Aufgaben ──
let taskCounter = 0;
function addTask() {
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}">
<input type="number" min="1" max="9999" placeholder="Min." id="task-min-${id}" title="Dauer in Minuten">
<button class="task-remove" onclick="removeTask(${id})" title="Entfernen">✕</button>`;
document.getElementById('taskList').appendChild(div);
}
function removeTask(id) {
document.getElementById('task-' + id)?.remove();
}
function collectTasks() {
return Array.from(document.querySelectorAll('.task-item')).map(item => {
const id = item.id.replace('task-', '');
const text = document.getElementById('task-text-' + id)?.value.trim();
const mins = parseInt(document.getElementById('task-min-' + id)?.value);
return text ? { text, minutes: isNaN(mins) ? null : mins } : null;
}).filter(Boolean);
}
// ── 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 },
];
renderCardsGrid();
// ── Karten-Grid rendern ──
function renderCardsGrid() {
const grid = document.getElementById('cardsGrid');
grid.innerHTML = '';
CARD_DEFS.forEach(c => {
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, c.defMin)}
</div>
<div class="card-range-row">
<span class="range-label">Max</span>
${stepperHtml('max_' + c.id, c.defMax)}
</div>`;
grid.appendChild(item);
});
}
function stepperHtml(id, val) {
return `<div class="stepper">
<button type="button" onclick="stepperChange('${id}',-1)"></button>
<input type="text" id="${id}" value="${val}" onchange="stepperClamp('${id}')">
<button type="button" onclick="stepperChange('${id}',1)">+</button>
</div>`;
}
function stepperChange(id, delta) {
const el = document.getElementById(id);
const val = Math.max(0, (parseInt(el.value) || 0) + delta);
el.value = val;
}
function stepperClamp(id) {
const el = document.getElementById(id);
const val = parseInt(el.value);
el.value = isNaN(val) || val < 0 ? 0 : 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');
}
// ── Karten-Array aufbauen (zufällig zwischen min und max) ──
function buildInitialCards() {
// Validate first
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) {
showError(`Min-Wert darf nicht größer als Max-Wert sein (${c.name}).`);
return null;
}
}
const cards = [];
CARD_DEFS.forEach(c => {
const minVal = parseInt(document.getElementById('min_' + c.id).value) || 0;
const maxVal = parseInt(document.getElementById('max_' + c.id).value) || 0;
const n = minVal + Math.floor(Math.random() * (maxVal - minVal + 1));
for (let i = 0; i < n; i++) cards.push(c.id);
});
return cards;
}
// ── Datetime → LocalDateTime String ──
function toLocalDateTime(val) {
if (!val) return null;
return val.length === 16 ? val + ':00' : val;
}
// ── Fehler-Highlighting ──
function setFieldError(rowId, msg) {
const row = document.getElementById(rowId);
if (!row) return;
row.classList.add('field-error');
let el = row.querySelector('.field-error-msg');
if (!el) {
el = document.createElement('div');
el.className = 'field-error-msg';
row.appendChild(el);
}
el.textContent = msg;
}
function clearFieldError(rowId) {
const row = document.getElementById(rowId);
if (!row) return;
row.classList.remove('field-error');
row.querySelector('.field-error-msg')?.remove();
}
// ── Validierung & Absenden ──
async function createSession() {
document.getElementById('errorMsg').style.display = 'none';
let firstError = null;
// Name Pflichtfeld
const nameVal = document.getElementById('lockName').value.trim();
if (!nameVal) {
setFieldError('rowLockName', 'Name der Session ist ein Pflichtfeld.');
firstError = firstError || document.getElementById('rowLockName');
} else {
clearFieldError('rowLockName');
}
// Min > Max Prüfung
const initialCards = buildInitialCards();
if (initialCards === null) return; // buildInitialCards zeigt eigene Fehlermeldung
// Mindestens eine grüne Karte
const greenMin = parseInt(document.getElementById('min_GREEN').value) || 0;
const greenErr = document.getElementById('errorGreenCard');
const greenItem = document.getElementById('min_GREEN')?.closest('.card-count-item');
if (greenMin < 1) {
greenErr.style.display = '';
if (greenItem) greenItem.style.outline = '2px solid #e74c3c';
firstError = firstError || greenErr;
} else {
greenErr.style.display = 'none';
if (greenItem) greenItem.style.outline = '';
}
// Aufgaben-Karten ohne Aufgaben
const hasTaskDefs = (parseInt(document.getElementById('min_TASK').value) || 0) > 0
|| (parseInt(document.getElementById('max_TASK').value) || 0) > 0;
if (hasTaskDefs && collectTasks().length === 0) {
showError('Es sind Aufgaben-Karten im Stapel, aber keine Aufgaben definiert. Bitte mindestens eine Aufgabe hinzufügen.');
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.');
firstError = firstError || document.getElementById('errorMsg');
}
if (initialCards.length === 0) {
showError('Das Deck muss mindestens eine Karte enthalten.');
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.');
firstError = firstError || document.getElementById('errorMsg');
}
if (hygieneOn && (!hygieneDur || hygieneDur < 1)) {
showError('Bitte die Dauer der Hygiene-Öffnung angeben.');
firstError = firstError || document.getElementById('errorMsg');
}
if (firstError) {
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
const keyholderVal = document.getElementById('keyholderValue').value;
const body = {
name: nameVal,
keyholder: 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,
tasks: collectTasks(),
unlockCodeLines: parseInt(document.getElementById('unlockCodeLines').value) || 5,
requiresVerification: document.getElementById('requiresVerification').checked,
testLock: document.getElementById('testLock').checked,
};
const res = await fetch('/keyholder/cardlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
showError(res.status === 400 ? 'Ungültige Eingabe. Bitte alle Pflichtfelder prüfen.' : 'Fehler beim Erstellen der Session.');
return;
}
const data = await res.json();
showUnlockCodeModal(data.unlockCode, data.lockId, data.keyholderPending);
}
function showUnlockCodeModal(code, lockId, keyholderPending) {
const display = document.getElementById('unlockCodeDisplay');
display.textContent = code;
if (keyholderPending) {
document.getElementById('unlockKeyholderHint').style.display = '';
}
const url = '/sessionchastityingame.html?lockId=' + lockId
+ (keyholderPending ? '&keyholderPending=1' : '');
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; // seconds
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;
}
// update hint text and button
if (hint) hint.style.display = 'none';
countdown.style.display = '';
document.querySelector('#unlockModal h3').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);
}
function updateTestLock() {
const hasKeyholder = !!document.getElementById('keyholderValue').value;
const cb = document.getElementById('testLock');
cb.disabled = hasKeyholder;
if (hasKeyholder) cb.checked = false;
cb.closest('.checkbox-row').style.opacity = hasKeyholder ? '0.4' : '';
cb.closest('.checkbox-row').style.pointerEvents = hasKeyholder ? 'none' : '';
}
function showError(msg) {
const el = document.getElementById('errorMsg');
el.textContent = msg;
el.style.display = '';
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
</script>
</body>
</html>

View File

@@ -0,0 +1,447 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chastity Game XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.keyholder-pending-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
background: rgba(233,69,96,0.12);
border: 1px solid rgba(233,69,96,0.35);
border-radius: 10px;
padding: 1rem 1.1rem;
margin-bottom: 1.25rem;
font-size: 0.9rem;
color: var(--color-text);
line-height: 1.5;
}
.keyholder-pending-banner .icon { font-size: 1.4rem; flex-shrink: 0; }
/* ── Karten-Anzeige ── */
.cards-panel {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1rem 1.1rem;
margin-bottom: 1.25rem;
}
.cards-panel-title {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 0.75rem;
}
/* showRemainingCards = false: only total count */
.cards-total {
display: flex;
align-items: center;
gap: 0.75rem;
}
.cards-total img {
width: 38px;
height: auto;
}
.cards-total-count {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text);
}
.cards-total-label {
font-size: 0.85rem;
color: var(--color-muted);
}
/* showRemainingCards = true: per-type grid */
.cards-by-type {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.card-type-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
background: var(--color-secondary);
border-radius: 8px;
padding: 0.6rem 0.8rem;
min-width: 68px;
}
.card-type-item img {
width: 40px;
height: auto;
}
.card-type-count {
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
}
.card-type-name {
font-size: 0.7rem;
color: var(--color-muted);
text-align: center;
line-height: 1.2;
}
/* ── Nächste Karte Panel ── */
.nextcard-panel {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1rem 1.1rem;
margin-bottom: 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.nextcard-panel-title {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 0.25rem;
}
.nextcard-countdown {
font-size: 1.4rem;
font-weight: 700;
font-family: monospace;
color: var(--color-text);
}
/* ── Hygiene-Panel ── */
.hygiene-panel {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1rem 1.1rem;
margin-bottom: 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.hygiene-panel-title {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 0.25rem;
}
.hygiene-countdown {
font-size: 1.4rem;
font-weight: 700;
font-family: monospace;
color: var(--color-text);
}
.btn-hygiene {
white-space: nowrap;
width: auto;
padding: 0.5rem 1.1rem;
font-size: 0.9rem;
}
/* ── Lock beenden Button ── */
.btn-lock-beenden {
background: transparent;
border: 1px solid rgba(200,50,50,0.45);
color: rgba(200,50,50,0.7);
font-size: 0.82rem;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
margin-top: 1.5rem;
width: auto;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.btn-lock-beenden:hover {
background: rgba(200,50,50,0.12);
color: #e74c3c;
border-color: #e74c3c;
}
/* ── Warn-Modal ── */
.warn-modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 500;
align-items: center;
justify-content: center;
}
.warn-modal-backdrop.open { display: flex; }
.warn-modal-box {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem 1.75rem 1.5rem;
max-width: 360px;
width: 90%;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.warn-modal-box h3 { margin: 0; font-size: 1.05rem; }
.warn-modal-box p { margin: 0; font-size: 0.88rem; color: var(--color-muted); line-height: 1.55; }
.warn-modal-actions { display: flex; gap: 0.6rem; justify-content: flex-end; margin-top: 0.25rem; }
.warn-modal-actions .btn-cancel {
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;
}
.warn-modal-actions .btn-danger {
background: #c0392b;
border: none;
color: #fff;
padding: 0.5rem 1.1rem;
border-radius: 7px;
cursor: pointer;
font-size: 0.88rem;
width: auto;
}
.warn-modal-actions .btn-danger:hover { background: #e74c3c; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h2 style="margin-bottom:1.25rem;">🔒 Chastity Session</h2>
<div class="keyholder-pending-banner" id="keyholderPendingBanner" style="display:none;">
<span class="icon"></span>
<div>
<strong>Keyholder*In-Bestätigung ausstehend</strong><br>
Die eingetragene Keyholder*In wurde per E-Mail eingeladen und muss die Rolle noch bestätigen.
Bis zur Bestätigung läuft das Lock als Self-Lock du hast selbst die Kontrolle.
</div>
</div>
<!-- Nächste Karte Panel -->
<div class="nextcard-panel" id="nextcardPanel" style="display:none;">
<div>
<div class="nextcard-panel-title">Nächste Karte ziehbar in</div>
<div class="nextcard-countdown" id="nextcardCountdown"></div>
</div>
</div>
<!-- Hygiene-Panel -->
<div class="hygiene-panel" id="hygienePanel" style="display:none;">
<div>
<div class="hygiene-panel-title">Nächste Hygiene-Öffnung</div>
<div class="hygiene-countdown" id="hygieneCountdown"></div>
</div>
<button class="btn-hygiene" id="hygieneBtn" style="display:none;">🚿 Hygiene-Öffnung</button>
</div>
<!-- Karten-Panel -->
<div class="cards-panel" id="cardsPanel" style="display:none;">
<div class="cards-panel-title">Verbleibende Karten</div>
<div id="cardsDisplay"></div>
</div>
<div id="lockContent" style="color:var(--color-muted);font-size:0.95rem;">
Wird geladen…
</div>
<div style="display:flex; justify-content:flex-end; margin-top:2rem;">
<button class="btn-lock-beenden" onclick="lockBeendenFragen()">🔓 Lock beenden</button>
</div>
</div>
</div>
<!-- Warn-Modal -->
<div class="warn-modal-backdrop" id="warnModal">
<div class="warn-modal-box">
<h3>Lock wirklich beenden?</h3>
<p>
Wenn du das Lock beendest, wird der <strong>Entsperrcode unwiderruflich gelöscht</strong>.
Der Code kann danach nicht wiederhergestellt werden.
</p>
<div class="warn-modal-actions">
<button class="btn-cancel" onclick="closeWarnModal()">Abbrechen</button>
<button class="btn-danger" onclick="lockLoeschen()">Ja, Lock beenden</button>
</div>
</div>
</div>
<script src="/js/shared.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
const CARD_LABELS = {
RED: { name: 'Rot', img: '/img/card_red.png' },
GREEN: { name: 'Grün', img: '/img/card_green.png' },
YELLOW: { name: 'Gelb', img: '/img/card_yellow.png' },
TASK: { name: 'Aufgabe', img: '/img/card_task.png' },
FREEZE: { name: 'Freeze', img: '/img/card_freeze.png' },
RESET: { name: 'Reset', img: '/img/card_reset.png' },
DOUBLE_UP: { name: 'Double Up', img: '/img/card_doubleup.png' },
};
const params = new URLSearchParams(window.location.search);
const lockId = params.get('lockId');
const khPending = params.get('keyholderPending') === '1';
if (khPending) {
document.getElementById('keyholderPendingBanner').style.display = '';
}
fetch('/login/me').then(r => r.ok ? r.json() : null).then(user => {
if (!user) { window.location.href = '/login.html'; return; }
if (lockId) loadLock();
else document.getElementById('lockContent').textContent = 'Kein Lock angegeben.';
});
async function loadLock() {
const res = await fetch('/keyholder/cardlock/' + lockId);
if (!res.ok) {
document.getElementById('lockContent').textContent = 'Lock nicht gefunden.';
return;
}
const lock = await res.json();
document.getElementById('lockContent').textContent = '';
renderNextCardPanel(lock);
renderHygienePanel(lock);
renderCardsPanel(lock);
}
function renderNextCardPanel(lock) {
const panel = document.getElementById('nextcardPanel');
const countdown = document.getElementById('nextcardCountdown');
panel.style.display = '';
if (lock.openPicks > 0) {
document.querySelector('.nextcard-panel-title').textContent = 'Karten ziehbar';
countdown.textContent = lock.openPicks + '× verfügbar';
countdown.style.color = 'var(--color-primary)';
return;
}
if (!lock.nextCardIn) return;
const target = new Date(lock.nextCardIn);
function tick() {
const diffMs = target - Date.now();
if (diffMs <= 0) {
countdown.textContent = 'Jetzt ziehbar';
countdown.style.color = 'var(--color-primary)';
return;
}
const totalSec = Math.floor(diffMs / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
countdown.textContent = h > 0
? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
: `${m}:${String(s).padStart(2,'0')}`;
}
tick();
setInterval(tick, 1000);
}
function renderHygienePanel(lock) {
if (!lock.hygieneEnabled) return;
const panel = document.getElementById('hygienePanel');
const countdown = document.getElementById('hygieneCountdown');
const btn = document.getElementById('hygieneBtn');
panel.style.display = '';
if (lock.hygieneOpeningDue) {
countdown.textContent = 'Jetzt fällig';
countdown.style.color = 'var(--color-primary)';
btn.style.display = '';
} else {
btn.style.display = 'none';
// tick down from server-provided remaining minutes
let totalSeconds = lock.hygieneMinutesRemaining * 60;
function tick() {
if (totalSeconds <= 0) {
countdown.textContent = 'Jetzt fällig';
countdown.style.color = 'var(--color-primary)';
btn.style.display = '';
return;
}
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
countdown.textContent = h > 0
? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
: `${m}:${String(s).padStart(2,'0')}`;
totalSeconds--;
}
tick();
setInterval(tick, 1000);
}
}
function renderCardsPanel(lock) {
const panel = document.getElementById('cardsPanel');
const display = document.getElementById('cardsDisplay');
panel.style.display = '';
if (lock.showRemainingCards) {
// Show each card type with its count
const counts = lock.availableCardCounts || {};
const items = Object.entries(counts)
.filter(([, n]) => n > 0)
.map(([type, n]) => {
const info = CARD_LABELS[type] || { name: type, img: '/img/card.png' };
return `<div class="card-type-item">
<img src="${info.img}" alt="${info.name}">
<span class="card-type-name">${info.name}</span>
<span class="card-type-count">${n}</span>
</div>`;
}).join('');
display.innerHTML = `<div class="cards-by-type">${items || '<span style="color:var(--color-muted);font-size:0.85rem;">Keine Karten mehr im Stapel.</span>'}</div>`;
} else {
// Show only total count with generic card image
display.innerHTML = `
<div class="cards-total">
<img src="/img/card.png" alt="Karten">
<div>
<div class="cards-total-count">${lock.totalCards}</div>
<div class="cards-total-label">Karten verbleiben</div>
</div>
</div>`;
}
}
// ── Lock beenden ──
function lockBeendenFragen() {
document.getElementById('warnModal').classList.add('open');
}
function closeWarnModal() {
document.getElementById('warnModal').classList.remove('open');
}
async function lockLoeschen() {
closeWarnModal();
try {
await fetch('/keyholder/cardlock/' + lockId, { method: 'DELETE' });
} catch (_) { /* ignorieren */ }
window.location.href = '/userhome.html';
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeWarnModal();
});
</script>
</body>
</html>