BDSM Game umgesetzt, Community Features ergänzt

This commit is contained in:
2026-03-03 23:18:35 +01:00
parent abf85f66e4
commit 21c276e96f
140 changed files with 9552 additions and 2841 deletions

View File

@@ -14,6 +14,7 @@ public class AufgabenGruppe {
private List<Aufgabe> aufgaben;
private List<Strafe> strafen;
private List<Sperre> sperren;
private List<Finisher> finisher;
private String bild;
private long subscriberCount;
private boolean subscribed;
@@ -45,6 +46,9 @@ public class AufgabenGruppe {
public List<Sperre> getSperren() { return sperren; }
public void setSperren(List<Sperre> sperren) { this.sperren = sperren; }
public List<Finisher> getFinisher() { return finisher; }
public void setFinisher(List<Finisher> finisher) { this.finisher = finisher; }
public String getBild() { return bild; }
public void setBild(String bild) { this.bild = bild; }

View File

@@ -0,0 +1,47 @@
package de.oaa.xxx.aufgaben;
import de.oaa.xxx.session.GeschlechtEnum;
import java.util.List;
import java.util.UUID;
public class Finisher {
private UUID finisherId;
private String kurzText;
private String text;
private GeschlechtEnum geschlecht;
private List<Werkzeug> benoetigtAktiv;
private List<Werkzeug> benoetigtPassiv;
private List<Toy> benoetigteToys;
private UUID gruppeId;
public UUID getFinisherId() { return finisherId; }
public void setFinisherId(UUID finisherId) { this.finisherId = finisherId; }
public String getKurzText() { return kurzText; }
public void setKurzText(String kurzText) { this.kurzText = kurzText; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public GeschlechtEnum getGeschlecht() { return geschlecht; }
public void setGeschlecht(GeschlechtEnum geschlecht) { this.geschlecht = geschlecht; }
public List<Werkzeug> getBenoetigtAktiv() { return benoetigtAktiv; }
public void setBenoetigtAktiv(List<Werkzeug> benoetigtAktiv) { this.benoetigtAktiv = benoetigtAktiv; }
public List<Werkzeug> getBenoetigtPassiv() { return benoetigtPassiv; }
public void setBenoetigtPassiv(List<Werkzeug> benoetigtPassiv) { this.benoetigtPassiv = benoetigtPassiv; }
public List<Toy> getBenoetigteToys() { return benoetigteToys; }
public void setBenoetigteToys(List<Toy> benoetigteToys) { this.benoetigteToys = benoetigteToys; }
public UUID getGruppeId() { return gruppeId; }
public void setGruppeId(UUID gruppeId) { this.gruppeId = gruppeId; }
@Override
public String toString() {
return "Finisher[id=" + finisherId + ", kurzText=" + kurzText + ", geschlecht=" + geschlecht + ", gruppeId=" + gruppeId + "]";
}
}

View File

@@ -5,11 +5,13 @@ import de.oaa.xxx.aufgaben.AufgabenGruppeList;
import de.oaa.xxx.aufgaben.AufgabenGruppePage;
import de.oaa.xxx.aufgaben.entity.AufgabeEntity;
import de.oaa.xxx.aufgaben.entity.AufgabenGruppeEntity;
import de.oaa.xxx.aufgaben.entity.FinisherEntity;
import de.oaa.xxx.aufgaben.entity.SperreEntity;
import de.oaa.xxx.aufgaben.entity.StrafeEntity;
import de.oaa.xxx.aufgaben.entity.ToyEntity;
import de.oaa.xxx.aufgaben.repository.AufgabeRepository;
import de.oaa.xxx.aufgaben.repository.AufgabenGruppeRepository;
import de.oaa.xxx.aufgaben.repository.FinisherRepository;
import de.oaa.xxx.aufgaben.repository.GruppenAboRepository;
import de.oaa.xxx.aufgaben.repository.SperreRepository;
import de.oaa.xxx.aufgaben.repository.StrafeRepository;
@@ -57,6 +59,7 @@ public class AufgabenGruppeController {
private final AufgabeRepository aufgabeRepository;
private final StrafeRepository strafeRepository;
private final SperreRepository sperreRepository;
private final FinisherRepository finisherRepository;
private final UserRepository userRepository;
private final GruppenAboRepository aboRepository;
private final ToyRepository toyRepository;
@@ -65,6 +68,7 @@ public class AufgabenGruppeController {
AufgabeRepository aufgabeRepository,
StrafeRepository strafeRepository,
SperreRepository sperreRepository,
FinisherRepository finisherRepository,
UserRepository userRepository,
GruppenAboRepository aboRepository,
ToyRepository toyRepository) {
@@ -72,6 +76,7 @@ public class AufgabenGruppeController {
this.aufgabeRepository = aufgabeRepository;
this.strafeRepository = strafeRepository;
this.sperreRepository = sperreRepository;
this.finisherRepository = finisherRepository;
this.userRepository = userRepository;
this.aboRepository = aboRepository;
this.toyRepository = toyRepository;
@@ -198,6 +203,7 @@ public class AufgabenGruppeController {
source.getAufgaben().forEach(a -> { if (a.getBenoetigteToys() != null) allSourceToys.addAll(a.getBenoetigteToys()); });
source.getStrafen().forEach(s -> { if (s.getBenoetigteToys() != null) allSourceToys.addAll(s.getBenoetigteToys()); });
source.getSperren().forEach(sp -> { if (sp.getBenoetigteToys() != null) allSourceToys.addAll(sp.getBenoetigteToys()); });
source.getFinisher().forEach(f -> { if (f.getBenoetigteToys() != null) allSourceToys.addAll(f.getBenoetigteToys()); });
Map<UUID, ToyEntity> toyMapping = new HashMap<>();
for (ToyEntity sourceToy : allSourceToys) {
@@ -274,6 +280,19 @@ public class AufgabenGruppeController {
sperreRepository.save(spc);
}
for (FinisherEntity f : source.getFinisher()) {
FinisherEntity fc = new FinisherEntity();
fc.setFinisherId(UUID.randomUUID());
fc.setAufgabenGruppe(copy);
fc.setKurzText(f.getKurzText());
fc.setText(f.getText());
fc.setGeschlecht(f.getGeschlecht());
fc.setBenoetigtAktiv(f.getBenoetigtAktiv() != null ? new ArrayList<>(f.getBenoetigtAktiv()) : null);
fc.setBenoetigtPassiv(f.getBenoetigtPassiv() != null ? new ArrayList<>(f.getBenoetigtPassiv()) : null);
fc.setBenoetigteToys(mapToys(f.getBenoetigteToys(), toyMapping));
finisherRepository.save(fc);
}
return ResponseEntity.status(201).build();
}
@@ -298,6 +317,7 @@ public class AufgabenGruppeController {
aufgabeRepository.deleteAll(entity.getAufgaben());
strafeRepository.deleteAll(entity.getStrafen());
sperreRepository.deleteAll(entity.getSperren());
finisherRepository.deleteAll(entity.getFinisher());
gruppeRepository.delete(entity);
return ResponseEntity.accepted().build();
} catch (Exception e) {

View File

@@ -0,0 +1,113 @@
package de.oaa.xxx.aufgaben.controller;
import de.oaa.xxx.aufgaben.Finisher;
import de.oaa.xxx.aufgaben.Toy;
import de.oaa.xxx.aufgaben.entity.AufgabenGruppeEntity;
import de.oaa.xxx.aufgaben.entity.FinisherEntity;
import de.oaa.xxx.aufgaben.entity.ToyEntity;
import de.oaa.xxx.aufgaben.repository.AufgabenGruppeRepository;
import de.oaa.xxx.aufgaben.repository.FinisherRepository;
import de.oaa.xxx.aufgaben.repository.ToyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/finisher")
@Transactional
public class FinisherController {
private static final Logger LOGGER = LoggerFactory.getLogger(FinisherController.class);
private final FinisherRepository finisherRepository;
private final AufgabenGruppeRepository gruppeRepository;
private final ToyRepository toyRepository;
public FinisherController(FinisherRepository finisherRepository,
AufgabenGruppeRepository gruppeRepository,
ToyRepository toyRepository) {
this.finisherRepository = finisherRepository;
this.gruppeRepository = gruppeRepository;
this.toyRepository = toyRepository;
}
@GetMapping("/{finisherId}")
public ResponseEntity<Finisher> get(@PathVariable UUID finisherId) {
return finisherRepository.findById(finisherId)
.map(entity -> ResponseEntity.ok(entity.toFinisher()))
.orElse(ResponseEntity.noContent().build());
}
@PostMapping
public ResponseEntity<Void> create(@RequestBody Finisher finisher) {
if (finisher.getKurzText() == null || finisher.getText() == null
|| finisher.getGeschlecht() == null || finisher.getGruppeId() == null) {
return ResponseEntity.badRequest().build();
}
AufgabenGruppeEntity gruppeEntity = gruppeRepository.findById(finisher.getGruppeId()).orElse(null);
if (gruppeEntity == null) {
return ResponseEntity.badRequest().build();
}
if (gruppeEntity.getFinisher().size() >= 100) {
return ResponseEntity.status(409).build();
}
List<ToyEntity> toys = resolveToys(finisher.getBenoetigteToys());
FinisherEntity entity = FinisherEntity.create(finisher, gruppeEntity, toys);
finisherRepository.save(entity);
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getFinisherId()).toUri()
).build();
}
@PutMapping("/{finisherId}")
public ResponseEntity<Void> update(@PathVariable UUID finisherId, @RequestBody Finisher finisher) {
if (finisher.getKurzText() == null || finisher.getText() == null || finisher.getGeschlecht() == null) {
return ResponseEntity.badRequest().build();
}
FinisherEntity entity = finisherRepository.findById(finisherId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
entity.setKurzText(finisher.getKurzText());
entity.setText(finisher.getText());
entity.setGeschlecht(finisher.getGeschlecht());
entity.setBenoetigtAktiv(finisher.getBenoetigtAktiv());
entity.setBenoetigtPassiv(finisher.getBenoetigtPassiv());
entity.setBenoetigteToys(resolveToys(finisher.getBenoetigteToys()));
finisherRepository.save(entity);
return ResponseEntity.ok().build();
}
@DeleteMapping
public ResponseEntity<Void> delete(@RequestBody Finisher finisher) {
try {
finisherRepository.findById(finisher.getFinisherId()).ifPresent(finisherRepository::delete);
return ResponseEntity.accepted().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
private List<ToyEntity> resolveToys(List<Toy> toys) {
if (toys == null || toys.isEmpty()) return new ArrayList<>();
List<UUID> ids = toys.stream()
.filter(t -> t.getToyId() != null)
.map(Toy::getToyId)
.toList();
if (ids.isEmpty()) return new ArrayList<>();
return toyRepository.findAllById(ids);
}
}

View File

@@ -84,6 +84,12 @@ public class AufgabeEntity {
public List<ToyEntity> getBenoetigteToys() { return benoetigteToys; }
public void setBenoetigteToys(List<ToyEntity> benoetigteToys) { this.benoetigteToys = benoetigteToys; }
@Override
public String toString() {
return "AufgabeEntity[id=" + aufgabeId + ", kurzText=" + kurzText + ", level=" + level
+ ", sekunden=" + sekundenVon + "-" + sekundenBis + "]";
}
public Aufgabe toAufgabe() {
Aufgabe aufgabe = new Aufgabe();
aufgabe.setAufgabeId(aufgabeId);

View File

@@ -39,6 +39,8 @@ public class AufgabenGruppeEntity {
private List<StrafeEntity> strafen;
@OneToMany(mappedBy = "aufgabenGruppe")
private List<SperreEntity> sperren;
@OneToMany(mappedBy = "aufgabenGruppe")
private List<FinisherEntity> finisher;
public UUID getGruppenId() { return gruppenId; }
public void setGruppenId(UUID gruppenId) { this.gruppenId = gruppenId; }
@@ -70,6 +72,15 @@ public class AufgabenGruppeEntity {
public List<SperreEntity> getSperren() { return sperren; }
public void setSperren(List<SperreEntity> sperren) { this.sperren = sperren; }
public List<FinisherEntity> getFinisher() { return finisher; }
public void setFinisher(List<FinisherEntity> finisher) { this.finisher = finisher; }
@Override
public String toString() {
return "AufgabenGruppeEntity[gruppenId=" + gruppenId + ", name=" + name + ", userId=" + userId
+ ", privat=" + privateGruppe + ", von=" + von + "]";
}
public AufgabenGruppe toAufgabenGruppe() {
AufgabenGruppe gruppe = new AufgabenGruppe();
gruppe.setGruppenId(gruppenId);
@@ -82,6 +93,7 @@ public class AufgabenGruppeEntity {
gruppe.setAufgaben(aufgaben.stream().map(AufgabeEntity::toAufgabe).toList());
gruppe.setStrafen(strafen.stream().map(StrafeEntity::toStrafe).toList());
gruppe.setSperren(sperren.stream().map(SperreEntity::toSperre).toList());
gruppe.setFinisher(finisher.stream().map(FinisherEntity::toFinisher).toList());
return gruppe;
}

View File

@@ -29,6 +29,11 @@ public class FavoritEntity {
public UUID getAufgabenGruppeId() { return aufgabenGruppeId; }
public void setAufgabenGruppeId(UUID aufgabenGruppeId) { this.aufgabenGruppeId = aufgabenGruppeId; }
@Override
public String toString() {
return "FavoritEntity[favoritId=" + favoritId + ", userId=" + userId + ", gruppeId=" + aufgabenGruppeId + "]";
}
public Favorit toFavorit() {
Favorit favorit = new Favorit();
favorit.setAufgabenGruppeId(aufgabenGruppeId);

View File

@@ -0,0 +1,109 @@
package de.oaa.xxx.aufgaben.entity;
import de.oaa.xxx.aufgaben.Finisher;
import de.oaa.xxx.aufgaben.Werkzeug;
import de.oaa.xxx.session.GeschlechtEnum;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "finisher")
public class FinisherEntity {
@Id
@Column
private UUID finisherId;
@Column
private String kurzText;
@Column(columnDefinition = "TEXT")
private String text;
@Enumerated(EnumType.STRING)
@Column
private GeschlechtEnum geschlecht;
@ManyToOne
@JoinColumn(name = "gruppeId")
private AufgabenGruppeEntity aufgabenGruppe;
@Enumerated(EnumType.STRING)
@ElementCollection(targetClass = Werkzeug.class)
@CollectionTable(name = "finisher_benoetigtAktiv", joinColumns = @JoinColumn(name = "finisherId"))
@Column(name = "werkzeug")
private List<Werkzeug> benoetigtAktiv;
@Enumerated(EnumType.STRING)
@ElementCollection(targetClass = Werkzeug.class)
@CollectionTable(name = "finisher_benoetigtPassiv", joinColumns = @JoinColumn(name = "finisherId"))
@Column(name = "werkzeug")
private List<Werkzeug> benoetigtPassiv;
@ManyToMany(cascade = CascadeType.DETACH)
@JoinTable(name = "finisherToy", joinColumns = {@JoinColumn(name = "finisherId")}, inverseJoinColumns = {@JoinColumn(name = "toyId")})
private List<ToyEntity> benoetigteToys;
public UUID getFinisherId() { return finisherId; }
public void setFinisherId(UUID finisherId) { this.finisherId = finisherId; }
public String getKurzText() { return kurzText; }
public void setKurzText(String kurzText) { this.kurzText = kurzText; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public GeschlechtEnum getGeschlecht() { return geschlecht; }
public void setGeschlecht(GeschlechtEnum geschlecht) { this.geschlecht = geschlecht; }
public AufgabenGruppeEntity getAufgabenGruppe() { return aufgabenGruppe; }
public void setAufgabenGruppe(AufgabenGruppeEntity aufgabenGruppe) { this.aufgabenGruppe = aufgabenGruppe; }
public List<Werkzeug> getBenoetigtAktiv() { return benoetigtAktiv; }
public void setBenoetigtAktiv(List<Werkzeug> benoetigtAktiv) { this.benoetigtAktiv = benoetigtAktiv; }
public List<Werkzeug> getBenoetigtPassiv() { return benoetigtPassiv; }
public void setBenoetigtPassiv(List<Werkzeug> benoetigtPassiv) { this.benoetigtPassiv = benoetigtPassiv; }
public List<ToyEntity> getBenoetigteToys() { return benoetigteToys; }
public void setBenoetigteToys(List<ToyEntity> benoetigteToys) { this.benoetigteToys = benoetigteToys; }
@Override
public String toString() {
return "FinisherEntity[id=" + finisherId + ", kurzText=" + kurzText + ", geschlecht=" + geschlecht + "]";
}
public Finisher toFinisher() {
Finisher finisher = new Finisher();
finisher.setFinisherId(finisherId);
finisher.setKurzText(kurzText);
finisher.setText(text);
finisher.setGeschlecht(geschlecht);
finisher.setBenoetigtAktiv(benoetigtAktiv);
finisher.setBenoetigtPassiv(benoetigtPassiv);
finisher.setBenoetigteToys(benoetigteToys.stream().map(ToyEntity::toToy).toList());
finisher.setGruppeId(aufgabenGruppe.getGruppenId());
return finisher;
}
public static FinisherEntity create(Finisher finisher, AufgabenGruppeEntity aufgabenGruppeEntity, List<ToyEntity> toys) {
FinisherEntity entity = new FinisherEntity();
entity.setFinisherId(UUID.randomUUID());
entity.setAufgabenGruppe(aufgabenGruppeEntity);
entity.setKurzText(finisher.getKurzText());
entity.setText(finisher.getText());
entity.setGeschlecht(finisher.getGeschlecht());
entity.setBenoetigtAktiv(finisher.getBenoetigtAktiv());
entity.setBenoetigtPassiv(finisher.getBenoetigtPassiv());
entity.setBenoetigteToys(toys != null ? toys : new ArrayList<>());
return entity;
}
}

View File

@@ -32,4 +32,10 @@ public class GruppenAboEntity {
public AufgabenGruppeEntity getAufgabenGruppe() { return aufgabenGruppe; }
public void setAufgabenGruppe(AufgabenGruppeEntity aufgabenGruppe) { this.aufgabenGruppe = aufgabenGruppe; }
@Override
public String toString() {
return "GruppenAboEntity[aboId=" + aboId + ", userId=" + userId
+ ", gruppe=" + (aufgabenGruppe != null ? aufgabenGruppe.getName() : null) + "]";
}
}

View File

@@ -76,6 +76,12 @@ public class SperreEntity {
public List<ToyEntity> getBenoetigteToys() { return benoetigteToys; }
public void setBenoetigteToys(List<ToyEntity> benoetigteToys) { this.benoetigteToys = benoetigteToys; }
@Override
public String toString() {
return "SperreEntity[id=" + sperreId + ", kurzText=" + kurzText
+ ", minuten=" + minutenVon + "-" + minutenBis + ", fuer=" + sperreFuer + "]";
}
public Sperre toSperre() {
Sperre sperre = new Sperre();
sperre.setSperreId(sperreId);

View File

@@ -84,6 +84,12 @@ public class StrafeEntity {
public List<ToyEntity> getBenoetigteToys() { return benoetigteToys; }
public void setBenoetigteToys(List<ToyEntity> benoetigteToys) { this.benoetigteToys = benoetigteToys; }
@Override
public String toString() {
return "StrafeEntity[id=" + strafeId + ", kurzText=" + kurzText + ", level=" + level
+ ", sekunden=" + sekundenVon + "-" + sekundenBis + "]";
}
public Strafe toStrafe() {
Strafe strafe = new Strafe();
strafe.setStrafeId(strafeId);

View File

@@ -42,6 +42,11 @@ public class ToyEntity {
public byte[] getBild() { return bild; }
public void setBild(byte[] bild) { this.bild = bild; }
@Override
public String toString() {
return "ToyEntity[toyId=" + toyId + ", name=" + name + ", userId=" + userId + "]";
}
public Toy toToy() {
Toy toy = new Toy();
toy.setToyId(toyId);

View File

@@ -1,9 +1,13 @@
package de.oaa.xxx.aufgaben.repository;
import de.oaa.xxx.aufgaben.entity.AufgabeEntity;
import de.oaa.xxx.aufgaben.entity.AufgabenGruppeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface AufgabeRepository extends JpaRepository<AufgabeEntity, UUID> {
List<AufgabeEntity> findByAufgabenGruppeIn(List<AufgabenGruppeEntity> gruppen);
}

View File

@@ -11,4 +11,6 @@ public interface FavoritRepository extends JpaRepository<FavoritEntity, UUID> {
List<FavoritEntity> findByUserId(UUID userId);
List<FavoritEntity> findByUserIdAndAufgabenGruppeId(UUID userId, UUID aufgabenGruppeId);
void deleteByAufgabenGruppeId(UUID aufgabenGruppeId);
}

View File

@@ -0,0 +1,9 @@
package de.oaa.xxx.aufgaben.repository;
import de.oaa.xxx.aufgaben.entity.FinisherEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface FinisherRepository extends JpaRepository<FinisherEntity, UUID> {
}

View File

@@ -1,9 +1,13 @@
package de.oaa.xxx.aufgaben.repository;
import de.oaa.xxx.aufgaben.entity.AufgabenGruppeEntity;
import de.oaa.xxx.aufgaben.entity.SperreEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface SperreRepository extends JpaRepository<SperreEntity, UUID> {
List<SperreEntity> findByAufgabenGruppeIn(List<AufgabenGruppeEntity> gruppen);
}

View File

@@ -1,9 +1,13 @@
package de.oaa.xxx.aufgaben.repository;
import de.oaa.xxx.aufgaben.entity.AufgabenGruppeEntity;
import de.oaa.xxx.aufgaben.entity.StrafeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface StrafeRepository extends JpaRepository<StrafeEntity, UUID> {
List<StrafeEntity> findByAufgabenGruppeIn(List<AufgabenGruppeEntity> gruppen);
}

View File

@@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -16,6 +17,8 @@ public interface ToyRepository extends JpaRepository<ToyEntity, UUID> {
Page<ToyEntity> findByUserId(UUID userId, Pageable pageable);
List<ToyEntity> findByUserId(UUID userId);
boolean existsByNameIgnoreCaseAndUserIdIsNull(String name);
boolean existsByNameIgnoreCaseAndUserId(String name, UUID userId);

View File

@@ -36,18 +36,40 @@ public class SecurityConfig {
.requestMatchers(AntPathRequestMatcher.antMatcher("/toys.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/aufgaben.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/entdecken.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/profile.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/infovanilla.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/infobdsm.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/infochastity.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionvanilla.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionbdsm.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionchastity.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionbdsmtasks.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionbdsmtoys.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/sessionbdsmingame.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/personen-suchen.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/freunde.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/nachrichten.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/benutzer.html")).authenticated()
.requestMatchers(AntPathRequestMatcher.antMatcher("/*.html")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/css/**")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/js/**")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/images/**")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/favicon.ico")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/*.png")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/*.jpg")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/*.svg")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher("/*.webp")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/login")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/login/publickey")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/login/logout")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/user")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/registration")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/registration")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/activation")).permitAll()
.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/activation/**")).permitAll()
.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.POST, "/filler")).permitAll()
.anyRequest().authenticated()
)

View File

@@ -0,0 +1,119 @@
package de.oaa.xxx.emailchange;
import de.oaa.xxx.mail.Email;
import de.oaa.xxx.mail.MailService;
import de.oaa.xxx.mail.MailTemplateService;
import de.oaa.xxx.registration.RegistrationRepository;
import de.oaa.xxx.user.UserRepository;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.security.Principal;
import java.util.UUID;
@RestController
@RequestMapping("/email-change")
public class EmailChangeController {
private static final Logger LOGGER = LoggerFactory.getLogger(EmailChangeController.class);
@Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
private final EmailChangeRepository emailChangeRepository;
private final UserRepository userRepository;
private final RegistrationRepository registrationRepository;
private final MailService mailService;
private final MailTemplateService mailTemplateService;
public EmailChangeController(EmailChangeRepository emailChangeRepository,
UserRepository userRepository,
RegistrationRepository registrationRepository,
MailService mailService,
MailTemplateService mailTemplateService) {
this.emailChangeRepository = emailChangeRepository;
this.userRepository = userRepository;
this.registrationRepository = registrationRepository;
this.mailService = mailService;
this.mailTemplateService = mailTemplateService;
}
record EmailChangeRequest(String newEmail) {}
@PostMapping
public ResponseEntity<Void> requestChange(@RequestBody EmailChangeRequest request, Principal principal) {
String currentEmail = principal.getName();
String newEmail = request.newEmail();
if (userRepository.findByEmail(newEmail).isPresent()
|| registrationRepository.findByEmail(newEmail).isPresent()) {
return ResponseEntity.status(409).build();
}
// Remove any pending request for this user
emailChangeRepository.findByUserEmail(currentEmail)
.ifPresent(emailChangeRepository::delete);
var user = userRepository.findByEmail(currentEmail);
if (user.isEmpty()) return ResponseEntity.status(401).build();
EmailChangeEntity entity = EmailChangeEntity.create(currentEmail, newEmail);
emailChangeRepository.save(entity);
Email email = new Email();
email.setTitel("Bitte bestätige deine neue E-Mail-Adresse");
email.setEmailAdresse(newEmail);
String confirmLink = baseUrl + "/email-change/" + entity.getTokenId().toString();
email.setText(mailTemplateService.buildEmailChangeMail(user.get().getName(), confirmLink, newEmail));
if (!mailService.send(email)) {
emailChangeRepository.delete(entity);
return ResponseEntity.internalServerError().build();
}
return ResponseEntity.status(202).build();
}
@GetMapping("/{token}")
public void confirm(@PathVariable String token, HttpServletResponse response) throws IOException {
UUID tokenId;
try {
tokenId = UUID.fromString(token);
} catch (IllegalArgumentException e) {
response.sendRedirect("/login.html");
return;
}
var entity = emailChangeRepository.findById(tokenId);
if (entity.isEmpty()) {
response.sendRedirect("/login.html");
return;
}
var user = userRepository.findByEmail(entity.get().getUserEmail());
if (user.isPresent()) {
user.get().setEmail(entity.get().getNewEmail());
userRepository.save(user.get());
LOGGER.info("E-Mail geändert von {} zu {}", entity.get().getUserEmail(), entity.get().getNewEmail());
}
emailChangeRepository.delete(entity.get());
// Clear JWT cookie so user must log in with new email
ResponseCookie cookie = ResponseCookie.from("jwt", "")
.httpOnly(true)
.sameSite("Strict")
.path("/")
.maxAge(0)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
response.sendRedirect("/login.html?emailChanged=1");
}
}

View File

@@ -0,0 +1,53 @@
package de.oaa.xxx.emailchange;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "email_change")
public class EmailChangeEntity {
@Id
@Column
private UUID tokenId;
@Column
private String userEmail;
@Column
private String newEmail;
@Column
private LocalDateTime createdAt;
public UUID getTokenId() { return tokenId; }
public void setTokenId(UUID tokenId) { this.tokenId = tokenId; }
public String getUserEmail() { return userEmail; }
public void setUserEmail(String userEmail) { this.userEmail = userEmail; }
public String getNewEmail() { return newEmail; }
public void setNewEmail(String newEmail) { this.newEmail = newEmail; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
@Override
public String toString() {
return "EmailChangeEntity[tokenId=" + tokenId + ", userEmail=" + userEmail + ", newEmail=" + newEmail + ", createdAt=" + createdAt + "]";
}
public static EmailChangeEntity create(String userEmail, String newEmail) {
EmailChangeEntity entity = new EmailChangeEntity();
entity.setTokenId(UUID.randomUUID());
entity.setUserEmail(userEmail);
entity.setNewEmail(newEmail);
entity.setCreatedAt(LocalDateTime.now());
return entity;
}
}

View File

@@ -0,0 +1,11 @@
package de.oaa.xxx.emailchange;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface EmailChangeRepository extends JpaRepository<EmailChangeEntity, UUID> {
Optional<EmailChangeEntity> findByUserEmail(String userEmail);
}

View File

@@ -32,6 +32,90 @@ public class MailTemplateService {
@Value("${app.theme.color-success:#2ecc71}")
private String colorSuccess;
public String buildEmailChangeMail(String name, String confirmLink, String newEmail) {
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;">Du hast eine Änderung deiner E-Mail-Adresse angefordert.</p>
<p style="color:%s; margin:0 0 2rem 0;">Klick auf den Button, um deine neue Adresse <strong style="color:%s;">%s</strong> zu bestätigen:</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;">
E-Mail-Adresse bestätigen
</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 diese Änderung nicht angefordert hast, kannst du diese E-Mail einfach ignorieren.
</p>
</div>
</body>
</html>
""".formatted(
colorBg, colorText,
colorCard, colorSecondary,
colorPrimary,
colorText, name,
colorText,
colorText, colorPrimary, newEmail,
confirmLink, colorPrimary,
colorSecondary,
colorMuted
);
}
public String buildPasswordResetMail(String name, String resetLink) {
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;">Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.</p>
<p style="color:%s; margin:0 0 2rem 0;">Klick auf den Button, um ein neues Passwort zu vergeben:</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;">
Passwort zurücksetzen
</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 diese Anfrage nicht gestellt hast, kannst du diese E-Mail einfach ignorieren.
</p>
</div>
</body>
</html>
""".formatted(
colorBg, colorText,
colorCard, colorSecondary,
colorPrimary,
colorText, name,
colorText,
colorText,
resetLink, colorPrimary,
colorSecondary,
colorMuted
);
}
public String buildActivationMail(String name, String activationLink, String activatePageUrl, String uuid) {
return """
<!DOCTYPE html>

View File

@@ -0,0 +1,3 @@
package de.oaa.xxx.passwordreset;
public record PasswordResetConfirm(String token, String passwordHash) {}

View File

@@ -0,0 +1,77 @@
package de.oaa.xxx.passwordreset;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/password-reset")
public class PasswordResetController {
private static final Logger LOGGER = LoggerFactory.getLogger(PasswordResetController.class);
@Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
private final PasswordResetRepository passwordResetRepository;
private final UserRepository userRepository;
private final MailService mailService;
private final MailTemplateService mailTemplateService;
public PasswordResetController(PasswordResetRepository passwordResetRepository,
UserRepository userRepository,
MailService mailService,
MailTemplateService mailTemplateService) {
this.passwordResetRepository = passwordResetRepository;
this.userRepository = userRepository;
this.mailService = mailService;
this.mailTemplateService = mailTemplateService;
}
@PostMapping("/request")
public ResponseEntity<Void> request(@RequestBody PasswordResetRequest request) {
userRepository.findByEmail(request.email()).ifPresent(user -> {
passwordResetRepository.findByEmail(request.email())
.ifPresent(passwordResetRepository::delete);
PasswordResetEntity entity = PasswordResetEntity.create(request.email());
passwordResetRepository.save(entity);
String resetLink = baseUrl + "/reset-password.html?token=" + entity.getTokenId();
Email email = new Email();
email.setTitel("Passwort zurücksetzen");
email.setEmailAdresse(request.email());
email.setText(mailTemplateService.buildPasswordResetMail(user.getName(), resetLink));
mailService.send(email);
LOGGER.info("Passwort-Reset angefordert für: {}", request.email());
});
return ResponseEntity.status(202).build();
}
@PostMapping("/confirm")
public ResponseEntity<Void> confirm(@RequestBody PasswordResetConfirm confirm) {
UUID tokenId;
try {
tokenId = UUID.fromString(confirm.token());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
var entity = passwordResetRepository.findById(tokenId);
if (entity.isEmpty()) {
return ResponseEntity.badRequest().build();
}
userRepository.findByEmail(entity.get().getEmail()).ifPresent(user -> {
user.setPassword(confirm.passwordHash());
userRepository.save(user);
LOGGER.info("Passwort zurückgesetzt für: {}", entity.get().getEmail());
});
passwordResetRepository.delete(entity.get());
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,46 @@
package de.oaa.xxx.passwordreset;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "password_reset")
public class PasswordResetEntity {
@Id
@Column
private UUID tokenId;
@Column(unique = true)
private String email;
@Column
private LocalDateTime createdAt;
public UUID getTokenId() { return tokenId; }
public void setTokenId(UUID tokenId) { this.tokenId = tokenId; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
@Override
public String toString() {
return "PasswordResetEntity[tokenId=" + tokenId + ", email=" + email + ", createdAt=" + createdAt + "]";
}
public static PasswordResetEntity create(String email) {
PasswordResetEntity entity = new PasswordResetEntity();
entity.setTokenId(UUID.randomUUID());
entity.setEmail(email);
entity.setCreatedAt(LocalDateTime.now());
return entity;
}
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.passwordreset;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface PasswordResetRepository extends JpaRepository<PasswordResetEntity, UUID> {
Optional<PasswordResetEntity> findByEmail(String email);
}

View File

@@ -0,0 +1,3 @@
package de.oaa.xxx.passwordreset;
public record PasswordResetRequest(String email) {}

View File

@@ -44,6 +44,11 @@ public class RegistrationController {
LOGGER.warn("User mit E-Mail {} bereits vorhanden", registration.getEmail());
return ResponseEntity.badRequest().build();
}
if (registrationRepository.findByName(registration.getName()).isPresent()
|| userRepository.findByName(registration.getName()).isPresent()) {
LOGGER.warn("User mit Name {} bereits vorhanden", registration.getName());
return ResponseEntity.status(409).build();
}
RegistrationEntity entity = RegistrationEntity.create(registration);
registrationRepository.save(entity);

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
public interface RegistrationRepository extends JpaRepository<RegistrationEntity, UUID> {
Optional<RegistrationEntity> findByEmail(String email);
Optional<RegistrationEntity> findByName(String name);
}

View File

@@ -34,4 +34,10 @@ public class AktiveSperre {
public String getReleaseText() { return releaseText; }
public void setReleaseText(String releaseText) { this.releaseText = releaseText; }
@Override
public String toString() {
return "AktiveSperre[id=" + aktiveSperreId + ", mitspieler=" + (mitspieler != null ? mitspieler.getName() : null)
+ ", " + minuten + "min, von=" + startzeit + ", bis=" + endzeit + ", fuer=" + fuer + "]";
}
}

View File

@@ -6,6 +6,7 @@ public class AufgabeAnzeige {
private String aufgabeText;
private Integer timer;
private Callback callback;
private Integer level;
public String getNameAktiverMitspieler() { return nameAktiverMitspieler; }
public void setNameAktiverMitspieler(String nameAktiverMitspieler) { this.nameAktiverMitspieler = nameAktiverMitspieler; }
@@ -18,4 +19,13 @@ public class AufgabeAnzeige {
public Callback getCallback() { return callback; }
public void setCallback(Callback callback) { this.callback = callback; }
public Integer getLevel() { return level; }
public void setLevel(Integer level) { this.level = level; }
@Override
public String toString() {
return "AufgabeAnzeige[mitspieler=" + nameAktiverMitspieler + ", level=" + level + ", timer=" + timer
+ ", callback=" + (callback != null ? callback.getClass().getSimpleName() : null) + "]";
}
}

View File

@@ -8,4 +8,9 @@ public abstract class Callback {
public UUID getSessionId() { return sessionId; }
public void setSessionId(UUID sessionId) { this.sessionId = sessionId; }
@Override
public String toString() {
return getClass().getSimpleName() + "[sessionId=" + sessionId + "]";
}
}

View File

@@ -34,6 +34,12 @@ public class Mitspieler {
return verfuegbareWerkzeuge.contains(werkzeug);
}
@Override
public String toString() {
return "Mitspieler[id=" + id + ", name=" + name + ", geschlecht=" + geschlecht
+ ", rollen=" + rollen + ", werkzeuge=" + verfuegbareWerkzeuge + "]";
}
public boolean isPassenderSpielpartner(Mitspieler other) {
if (!spieltMit.contains(other.getGeschlecht())) {
return false;

View File

@@ -1,5 +1,6 @@
package de.oaa.xxx.session;
import java.time.LocalDateTime;
import java.util.UUID;
public class Session {
@@ -10,6 +11,10 @@ public class Session {
private Integer wahrscheinlichkeitStrafe;
private Integer aufgabenProLevel;
private Double zeitfaktorZeitstrafen;
private Integer level;
private Integer aufgabenAufAktuellemLevel;
private LocalDateTime startZeit;
private LocalDateTime letzteAktivitaet;
public UUID getSessionId() { return sessionId; }
public void setSessionId(UUID sessionId) { this.sessionId = sessionId; }
@@ -28,4 +33,24 @@ public class Session {
public Double getZeitfaktorZeitstrafen() { return zeitfaktorZeitstrafen; }
public void setZeitfaktorZeitstrafen(Double zeitfaktorZeitstrafen) { this.zeitfaktorZeitstrafen = zeitfaktorZeitstrafen; }
public Integer getLevel() { return level; }
public void setLevel(Integer level) { this.level = level; }
public Integer getAufgabenAufAktuellemLevel() { return aufgabenAufAktuellemLevel; }
public void setAufgabenAufAktuellemLevel(Integer aufgabenAufAktuellemLevel) { this.aufgabenAufAktuellemLevel = aufgabenAufAktuellemLevel; }
public LocalDateTime getStartZeit() { return startZeit; }
public void setStartZeit(LocalDateTime startZeit) { this.startZeit = startZeit; }
public LocalDateTime getLetzteAktivitaet() { return letzteAktivitaet; }
public void setLetzteAktivitaet(LocalDateTime letzteAktivitaet) { this.letzteAktivitaet = letzteAktivitaet; }
@Override
public String toString() {
return "Session[sessionId=" + sessionId + ", userId=" + userId
+ ", level=" + level + ", aufgaben=" + aufgabenAufAktuellemLevel + "/" + aufgabenProLevel
+ ", pStrafe=" + wahrscheinlichkeitStrafe + "%, pSperre=" + wahrscheinlichkeitSperre + "%"
+ ", zeitfaktor=" + zeitfaktorZeitstrafen + "]";
}
}

View File

@@ -1,6 +1,12 @@
package de.oaa.xxx.session;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.xxx.session.aufgaben.Aufgabe;
import de.oaa.xxx.session.aufgaben.AufgabenList;
import de.oaa.xxx.session.aufgaben.Sperre;
@@ -9,11 +15,6 @@ import de.oaa.xxx.session.entity.SessionEntity;
import de.oaa.xxx.session.sperre.SperreCallback;
import de.oaa.xxx.session.sperre.SperrenVerlaengernCallback;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
public class SessionDurchfuehren {
private final AufgabenList aufgabenList;
@@ -23,12 +24,12 @@ public class SessionDurchfuehren {
private final Integer wahrscheinlichkeitSperre;
private final Integer wahrscheinlichkeitStrafe;
private final Integer aufgabenProLevel;
private Integer level;
private Integer aufgabenAufAktuellemLevel;
private int aufgabenProLevel;
private int level;
private int aufgabenAufAktuellemLevel;
public SessionDurchfuehren(SessionEntity entity) throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
ObjectMapper objectMapper = new ObjectMapper();
aufgabenList = objectMapper.readValue(entity.getAufgaben(), AufgabenList.class);
entity.getMitspieler().forEach(mitspielerEntity -> mitspieler.add(mitspielerEntity.toMitspieler()));
entity.getAktiveSperren().forEach(sperreEntity -> aktiveSperren.add(sperreEntity.toSperre(mitspieler)));
@@ -36,13 +37,16 @@ public class SessionDurchfuehren {
wahrscheinlichkeitSperre = entity.getWahrscheinlichkeitSperre();
wahrscheinlichkeitStrafe = entity.getWahrscheinlichkeitStrafe();
aufgabenProLevel = entity.getAufgabenProLevel() != null ? entity.getAufgabenProLevel() : 5;
level = entity.getLevel() != null ? entity.getLevel() : 1;
aufgabenAufAktuellemLevel = entity.getAufgabenAufAktuellemLevel() != null ? entity.getAufgabenAufAktuellemLevel() : 0;
this.aufgabenProLevel = entity.getAufgabenProLevel() != null ? entity.getAufgabenProLevel() : 5;
this.level = entity.getLevel() != null ? entity.getLevel() : 1;
this.aufgabenAufAktuellemLevel = entity.getAufgabenAufAktuellemLevel() != null ? entity.getAufgabenAufAktuellemLevel() : 0;
}
public AufgabeAnzeige getNext() {
checkLevel();
if (level == 6) {
return null;
}
AufgabeAnzeige anzeige = null;
int nextInt = new Random().nextInt(1, 100);
if (nextInt == 1) {
@@ -67,9 +71,34 @@ public class SessionDurchfuehren {
}
return anzeige;
}
public void backToLvl5() {
this.level = 5;
this.aufgabenAufAktuellemLevel = 0;
}
public List<AufgabeAnzeige> getFinisher() {
var list = new ArrayList<AufgabeAnzeige>();
List.of(GeschlechtEnum.WEIBLICH, GeschlechtEnum.DIVERS, GeschlechtEnum.MAENNLICH).forEach(geschlecht -> {
mitspieler.stream().filter(m -> geschlecht == m.getGeschlecht()).toList().forEach(cumming -> {
var partner = findeMitspielerMitRolle(RolleEnum.AUFGABE_PASSIV, cumming);
aufgabenList.getFinisher().stream()
.filter(finisher -> geschlecht == finisher.getGeschlecht())
.findAny()
.ifPresent(aufgabe -> {
AufgabeAnzeige anzeige = new AufgabeAnzeige();
anzeige.setNameAktiverMitspieler(cumming.getName());
anzeige.setAufgabeText(getAnzeigeText(aufgabe.getText(),
cumming.getName(), partner != null ? partner.getName() : ""));
list.add(anzeige);
});
});
});
return list;
}
private void checkLevel() {
if (++aufgabenAufAktuellemLevel >= aufgabenProLevel && level < 5) {
if (++aufgabenAufAktuellemLevel >= 1 + aufgabenProLevel) {
aufgabenAufAktuellemLevel = 0;
level++;
}
@@ -210,4 +239,12 @@ public class SessionDurchfuehren {
.toList();
return list.isEmpty() ? null : list.get(new Random().nextInt(list.size()));
}
public int getAufgabenAufAktuellemLevel() {
return aufgabenAufAktuellemLevel;
}
public int getLevel() {
return level;
}
}

View File

@@ -45,6 +45,12 @@ public class Aufgabe {
public List<Werkzeug> getBenoetigtPassiv() { return benoetigtPassiv; }
public void setBenoetigtPassiv(List<Werkzeug> benoetigtPassiv) { this.benoetigtPassiv = benoetigtPassiv; }
@Override
public String toString() {
return "Aufgabe[id=" + aufgabeId + ", kurzText=" + kurzText + ", level=" + level
+ ", sekunden=" + sekundenVon + "-" + sekundenBis + ", gruppeId=" + gruppeId + "]";
}
public boolean isAufgabePassend(int level, Mitspieler aktiv, Mitspieler passiv) {
if (level != this.level && level - 1 != this.level) {
return false;

View File

@@ -7,6 +7,7 @@ public class AufgabenList {
private List<Aufgabe> aufgaben;
private List<Sperre> sperren;
private List<Strafe> strafen;
private List<Finisher> finisher;
public List<Aufgabe> getAufgaben() { return aufgaben; }
public void setAufgaben(List<Aufgabe> aufgaben) { this.aufgaben = aufgaben; }
@@ -17,11 +18,15 @@ public class AufgabenList {
public List<Strafe> getStrafen() { return strafen; }
public void setStrafen(List<Strafe> strafen) { this.strafen = strafen; }
public List<Finisher> getFinisher() { return finisher; }
public void setFinisher(List<Finisher> finisher) { this.finisher = finisher; }
public int size() {
int size = 0;
if (aufgaben != null) size += aufgaben.size();
if (sperren != null) size += sperren.size();
if (strafen != null) size += strafen.size();
if (getFinisher() != null) size += getFinisher().size();
return size;
}
}

View File

@@ -0,0 +1,40 @@
package de.oaa.xxx.session.aufgaben;
import java.util.List;
import java.util.UUID;
import de.oaa.xxx.session.GeschlechtEnum;
import de.oaa.xxx.session.Werkzeug;
public class Finisher {
private UUID finisherId;
private String kurzText;
private String text;
private GeschlechtEnum geschlecht;
private List<Werkzeug> benoetigtAktiv;
private List<Werkzeug> benoetigtPassiv;
public UUID getFinisherId() { return finisherId; }
public void setFinisherId(UUID finisherId) { this.finisherId = finisherId; }
public String getKurzText() { return kurzText; }
public void setKurzText(String kurzText) { this.kurzText = kurzText; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public GeschlechtEnum getGeschlecht() { return geschlecht; }
public void setGeschlecht(GeschlechtEnum geschlecht) { this.geschlecht = geschlecht; }
public List<Werkzeug> getBenoetigtAktiv() { return benoetigtAktiv; }
public void setBenoetigtAktiv(List<Werkzeug> benoetigtAktiv) { this.benoetigtAktiv = benoetigtAktiv; }
public List<Werkzeug> getBenoetigtPassiv() { return benoetigtPassiv; }
public void setBenoetigtPassiv(List<Werkzeug> benoetigtPassiv) { this.benoetigtPassiv = benoetigtPassiv; }
@Override
public String toString() {
return "Finisher[id=" + finisherId + ", kurzText=" + kurzText + ", geschlecht=" + geschlecht + "]";
}
}

View File

@@ -41,6 +41,12 @@ public class Sperre {
public Integer getMinutenBis() { return minutenBis; }
public void setMinutenBis(Integer minutenBis) { this.minutenBis = minutenBis; }
@Override
public String toString() {
return "Sperre[id=" + sperreId + ", kurzText=" + kurzText
+ ", minuten=" + minutenVon + "-" + minutenBis + ", fuer=" + sperreFuer + ", gruppeId=" + gruppeId + "]";
}
public boolean isAufgabePassend(Mitspieler passiv) {
for (Werkzeug werkzeug : sperreFuer) {
if (!passiv.isVerfuegbar(werkzeug)) {

View File

@@ -8,8 +8,10 @@ import de.oaa.xxx.session.SessionDurchfuehren;
import de.oaa.xxx.session.aufgaben.AufgabenList;
import de.oaa.xxx.session.entity.MitspielerEntity;
import de.oaa.xxx.session.entity.SessionEntity;
import de.oaa.xxx.session.repository.AktiveSperreRepository;
import de.oaa.xxx.session.repository.MitspielerRepository;
import de.oaa.xxx.session.repository.SessionRepository;
import de.oaa.xxx.user.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
@@ -26,6 +28,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@RestController
@@ -37,12 +40,17 @@ public class SessionController {
private final SessionRepository sessionRepository;
private final MitspielerRepository mitspielerRepository;
private final AktiveSperreRepository aktiveSperreRepository;
private final UserRepository userRepository;
private final ObjectMapper objectMapper;
public SessionController(SessionRepository sessionRepository, MitspielerRepository mitspielerRepository,
AktiveSperreRepository aktiveSperreRepository, UserRepository userRepository,
ObjectMapper objectMapper) {
this.sessionRepository = sessionRepository;
this.mitspielerRepository = mitspielerRepository;
this.aktiveSperreRepository = aktiveSperreRepository;
this.userRepository = userRepository;
this.objectMapper = objectMapper;
}
@@ -62,7 +70,9 @@ public class SessionController {
@PostMapping
public ResponseEntity<Void> create(@RequestBody Session session) {
UUID userId = (UUID) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
String email = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
UUID userId = userRepository.findByEmail(email).map(u -> u.getUserId()).orElse(null);
if (userId == null) return ResponseEntity.status(401).build();
SessionEntity entity = new SessionEntity();
entity.setSessionId(UUID.randomUUID());
entity.setUserId(userId);
@@ -76,6 +86,10 @@ public class SessionController {
entity.setZeitfaktorZeitstrafen(session.getZeitfaktorZeitstrafen() != null ? session.getZeitfaktorZeitstrafen() : 1.0);
entity.setLevel(1);
sessionRepository.save(entity);
LOGGER.debug("Session gestartet [sessionId={}, userId={}, aufgabenProLevel={}, wahrscheinlichkeitStrafe={}%, wahrscheinlichkeitSperre={}%, zeitfaktorZeitstrafen={}]",
entity.getSessionId(), entity.getUserId(), entity.getAufgabenProLevel(),
entity.getWahrscheinlichkeitStrafe(), entity.getWahrscheinlichkeitSperre(),
entity.getZeitfaktorZeitstrafen());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getSessionId()).toUri()
).build();
@@ -85,6 +99,8 @@ public class SessionController {
public ResponseEntity<Void> deleteSession(@RequestBody Session session) {
return sessionRepository.findById(session.getSessionId())
.map(entity -> {
aktiveSperreRepository.deleteAll(entity.getAktiveSperren());
mitspielerRepository.deleteAll(entity.getMitspieler());
sessionRepository.delete(entity);
return ResponseEntity.accepted().<Void>build();
})
@@ -119,7 +135,22 @@ public class SessionController {
return ResponseEntity.badRequest().build();
}
session.setLetzteAktivitaet(LocalDateTime.now());
AufgabeAnzeige next = new SessionDurchfuehren(session).getNext();
SessionDurchfuehren durchfuehren = new SessionDurchfuehren(session);
AufgabeAnzeige next = durchfuehren.getNext();
session.setLevel(durchfuehren.getLevel());
session.setAufgabenAufAktuellemLevel(durchfuehren.getAufgabenAufAktuellemLevel());
if (next == null) {
return ResponseEntity.noContent().build();
}
next.setLevel(durchfuehren.getLevel());
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Neue Aufgabe [sessionId={}, level={}, aufgaben={}/{}, aktiveSperren={}]",
sessionId, session.getLevel(), session.getAufgabenAufAktuellemLevel(),
session.getAufgabenProLevel(), session.getAktiveSperren().size());
session.getAktiveSperren().forEach(s ->
LOGGER.debug(" Sperre [mitspieler={}, {}min, ende={}]",
s.getMitspieler().getName(), s.getMinuten(), s.getEndzeit()));
}
return ResponseEntity.ok(next);
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
@@ -150,14 +181,48 @@ public class SessionController {
return ResponseEntity.accepted().build();
}
@GetMapping("/{sessionId}/finisher")
public ResponseEntity<List<AufgabeAnzeige>> getFinisher(@PathVariable UUID sessionId) {
try {
SessionEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.badRequest().build();
SessionDurchfuehren durchfuehren = new SessionDurchfuehren(session);
return ResponseEntity.ok(durchfuehren.getFinisher());
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
@PostMapping("/{sessionId}/backToLevel5")
public ResponseEntity<Void> backToLevel5(@PathVariable UUID sessionId) {
try {
SessionEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.badRequest().build();
SessionDurchfuehren durchfuehren = new SessionDurchfuehren(session);
durchfuehren.backToLvl5();
session.setLevel(durchfuehren.getLevel());
session.setAufgabenAufAktuellemLevel(durchfuehren.getAufgabenAufAktuellemLevel());
sessionRepository.save(session);
return ResponseEntity.accepted().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
private Session toSession(SessionEntity entity) {
Session session = new Session();
session.setSessionId(entity.getSessionId());
session.setUserId(entity.getUserId());
session.setAufgabenProLevel(entity.getAufgabenAufAktuellemLevel());
session.setAufgabenProLevel(entity.getAufgabenProLevel());
session.setWahrscheinlichkeitSperre(entity.getWahrscheinlichkeitSperre());
session.setWahrscheinlichkeitStrafe(entity.getWahrscheinlichkeitStrafe());
session.setZeitfaktorZeitstrafen(entity.getZeitfaktorZeitstrafen());
session.setLevel(entity.getLevel());
session.setAufgabenAufAktuellemLevel(entity.getAufgabenAufAktuellemLevel());
session.setStartZeit(entity.getStartZeit());
session.setLetzteAktivitaet(entity.getLetzteAktivitaet());
return session;
}
}

View File

@@ -1,6 +1,9 @@
package de.oaa.xxx.session.controller;
import de.oaa.xxx.session.AktiveSperre;
import de.oaa.xxx.session.Mitspieler;
import de.oaa.xxx.session.entity.AktiveSperreEntity;
import de.oaa.xxx.session.entity.SessionEntity;
import de.oaa.xxx.session.repository.AktiveSperreRepository;
import de.oaa.xxx.session.repository.MitspielerRepository;
import de.oaa.xxx.session.repository.SessionRepository;
@@ -21,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@RestController("sessionSperreController")
@RequestMapping("/session/sperre")
@@ -70,6 +74,24 @@ public class SperreController {
}
}
@GetMapping("/aktive")
public ResponseEntity<List<AktiveSperre>> getAktiveSperren(@RequestParam UUID sessionId) {
try {
SessionEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.noContent().build();
List<Mitspieler> mitspielerList = session.getMitspieler().stream()
.map(m -> m.toMitspieler())
.collect(Collectors.toList());
List<AktiveSperre> sperren = session.getAktiveSperren().stream()
.map(e -> e.toSperre(mitspielerList))
.collect(Collectors.toList());
return ResponseEntity.ok(sperren);
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
@PostMapping("/verlaengern")
public ResponseEntity<Void> aktiveVerlaengern(@RequestBody SperrenVerlaengernCallback callback) {
if (callback == null || callback.getSpielerId() == null || callback.getFaktor() == null) {

View File

@@ -84,6 +84,12 @@ public class AktiveSperreEntity {
return sperre;
}
@Override
public String toString() {
return "AktiveSperreEntity[id=" + aktiveSperreId + ", mitspieler=" + (mitspieler != null ? mitspieler.getName() : null)
+ ", " + minuten + "min, von=" + startzeit + ", bis=" + endzeit + ", fuer=" + fuer + "]";
}
private Mitspieler getMitspielerFromList(List<Mitspieler> mitspielerList, UUID id) {
Optional<Mitspieler> first = mitspielerList.stream().filter(m -> m.getId().equals(id)).findFirst();
return first.orElse(null);

View File

@@ -78,6 +78,12 @@ public class MitspielerEntity {
public List<AktiveSperreEntity> getAktiveSperren() { return aktiveSperren; }
public void setAktiveSperren(List<AktiveSperreEntity> aktiveSperren) { this.aktiveSperren = aktiveSperren; }
@Override
public String toString() {
return "MitspielerEntity[mitspielerId=" + mitspielerId + ", name=" + name
+ ", geschlecht=" + geschlecht + ", rollen=" + rollen + ", werkzeuge=" + werkzeuge + "]";
}
public Mitspieler toMitspieler() {
Mitspieler mitspieler = new Mitspieler();
mitspieler.setGeschlecht(geschlecht);

View File

@@ -82,4 +82,12 @@ public class SessionEntity {
public Double getZeitfaktorZeitstrafen() { return zeitfaktorZeitstrafen; }
public void setZeitfaktorZeitstrafen(Double zeitfaktorZeitstrafen) { this.zeitfaktorZeitstrafen = zeitfaktorZeitstrafen; }
@Override
public String toString() {
return "SessionEntity[sessionId=" + sessionId + ", userId=" + userId
+ ", level=" + level + ", aufgaben=" + aufgabenAufAktuellemLevel + "/" + aufgabenProLevel
+ ", pStrafe=" + wahrscheinlichkeitStrafe + "%, pSperre=" + wahrscheinlichkeitSperre + "%"
+ ", zeitfaktor=" + zeitfaktorZeitstrafen + ", start=" + startZeit + "]";
}
}

View File

@@ -18,4 +18,9 @@ public class SperreCallback extends Callback {
public String getReleaseText() { return releaseText; }
public void setReleaseText(String releaseText) { this.releaseText = releaseText; }
@Override
public String toString() {
return "SperreCallback[sessionId=" + getSessionId() + ", sperreId=" + sperreId + ", spielerId=" + spielerId + "]";
}
}

View File

@@ -14,4 +14,9 @@ public class SperrenVerlaengernCallback extends Callback {
public Integer getFaktor() { return faktor; }
public void setFaktor(Integer faktor) { this.faktor = faktor; }
@Override
public String toString() {
return "SperrenVerlaengernCallback[sessionId=" + getSessionId() + ", spielerId=" + spielerId + ", faktor=" + faktor + "]";
}
}

View File

@@ -0,0 +1,114 @@
package de.oaa.xxx.social;
import de.oaa.xxx.social.dto.ProfileImageDto;
import de.oaa.xxx.social.entity.ProfileImageEntity;
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.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/social/profile-images")
public class ProfileImageController {
private static final int MAX_IMAGES_PER_USER = 20;
private final ProfileImageRepository profileImageRepository;
private final ProfileImageLikeRepository profileImageLikeRepository;
private final UserRepository userRepository;
public ProfileImageController(ProfileImageRepository profileImageRepository,
ProfileImageLikeRepository profileImageLikeRepository,
UserRepository userRepository) {
this.profileImageRepository = profileImageRepository;
this.profileImageLikeRepository = profileImageLikeRepository;
this.userRepository = userRepository;
}
record UploadRequest(String imageData) {}
@PostMapping
public ResponseEntity<ProfileImageDto> uploadImage(@RequestBody UploadRequest request, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
if (request.imageData() == null || request.imageData().isBlank()) {
return ResponseEntity.badRequest().build();
}
if (profileImageRepository.countByUserId(myId) >= MAX_IMAGES_PER_USER) {
return ResponseEntity.status(422).build();
}
ProfileImageEntity entity = new ProfileImageEntity();
entity.setImageId(UUID.randomUUID());
entity.setUserId(myId);
entity.setImageData(request.imageData());
entity.setUploadedAt(LocalDateTime.now());
profileImageRepository.save(entity);
return ResponseEntity.status(201).body(toDto(entity, myId));
}
@GetMapping
public ResponseEntity<List<ProfileImageDto>> getImages(@RequestParam UUID userId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<ProfileImageEntity> images = profileImageRepository.findByUserIdOrderByUploadedAtDesc(userId);
List<ProfileImageDto> dtos = images.stream().map(img -> toDto(img, myId)).toList();
return ResponseEntity.ok(dtos);
}
@DeleteMapping("/{imageId}")
public ResponseEntity<Void> deleteImage(@PathVariable UUID imageId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var imgOpt = profileImageRepository.findById(imageId);
if (imgOpt.isEmpty()) return ResponseEntity.notFound().build();
if (!imgOpt.get().getUserId().equals(myId)) return ResponseEntity.status(403).build();
profileImageLikeRepository.deleteByImageId(imageId);
profileImageRepository.delete(imgOpt.get());
return ResponseEntity.noContent().build();
}
@PostMapping("/{imageId}/like")
public ResponseEntity<Void> toggleLike(@PathVariable UUID imageId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
if (profileImageRepository.findById(imageId).isEmpty()) return ResponseEntity.notFound().build();
var existing = profileImageLikeRepository.findByImageIdAndUserId(imageId, myId);
if (existing.isPresent()) {
profileImageLikeRepository.delete(existing.get());
} else {
ProfileImageLikeEntity like = new ProfileImageLikeEntity();
like.setLikeId(UUID.randomUUID());
like.setImageId(imageId);
like.setUserId(myId);
like.setLikedAt(LocalDateTime.now());
profileImageLikeRepository.save(like);
}
return ResponseEntity.ok().build();
}
private ProfileImageDto toDto(ProfileImageEntity entity, UUID myId) {
long likeCount = profileImageLikeRepository.countByImageId(entity.getImageId());
boolean likedByMe = profileImageLikeRepository.findByImageIdAndUserId(entity.getImageId(), myId).isPresent();
return new ProfileImageDto(entity.getImageId(), entity.getUserId(), entity.getImageData(),
entity.getUploadedAt(), likeCount, likedByMe);
}
}

View File

@@ -0,0 +1,273 @@
package de.oaa.xxx.social;
import de.oaa.xxx.social.dto.ConversationSummary;
import de.oaa.xxx.social.dto.FriendshipDto;
import de.oaa.xxx.social.dto.MessageDto;
import de.oaa.xxx.social.dto.UserProfile;
import de.oaa.xxx.social.entity.FriendshipEntity;
import de.oaa.xxx.social.entity.FriendshipEntity.Status;
import de.oaa.xxx.social.entity.MessageEntity;
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.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.*;
@RestController
@RequestMapping("/social")
public class SocialController {
private final UserRepository userRepository;
private final FriendshipRepository friendshipRepository;
private final MessageRepository messageRepository;
public SocialController(UserRepository userRepository,
FriendshipRepository friendshipRepository,
MessageRepository messageRepository) {
this.userRepository = userRepository;
this.friendshipRepository = friendshipRepository;
this.messageRepository = messageRepository;
}
record FriendRequestBody(UUID receiverId) {}
record FriendshipActionBody(UUID friendshipId) {}
record SendMessageBody(UUID receiverId, String text) {}
// ── User Profile ──
@GetMapping("/users/{userId}")
public ResponseEntity<UserProfile> getUserProfile(@PathVariable UUID userId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
return userRepository.findById(userId)
.map(u -> ResponseEntity.ok(toUserProfileWithStatus(u, myId)))
.orElse(ResponseEntity.notFound().build());
}
// ── User Search ──
@GetMapping("/users/search")
public ResponseEntity<List<UserProfile>> searchUsers(@RequestParam String q, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<UserEntity> results = userRepository.findByNameContainingIgnoreCase(q);
List<UserProfile> profiles = results.stream()
.filter(u -> !u.getUserId().equals(myId))
.limit(20)
.map(u -> toUserProfileWithStatus(u, myId))
.toList();
return ResponseEntity.ok(profiles);
}
// ── Friendship ──
@PostMapping("/friends/request")
public ResponseEntity<Void> sendFriendRequest(@RequestBody FriendRequestBody body, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
if (friendshipRepository.findExisting(myId, body.receiverId()).isPresent()) {
return ResponseEntity.status(409).build();
}
FriendshipEntity f = new FriendshipEntity();
f.setFriendshipId(UUID.randomUUID());
f.setSenderId(myId);
f.setReceiverId(body.receiverId());
f.setStatus(Status.PENDING);
f.setCreatedAt(LocalDateTime.now());
friendshipRepository.save(f);
return ResponseEntity.status(201).build();
}
@PostMapping("/friends/accept")
public ResponseEntity<Void> acceptFriendRequest(@RequestBody FriendshipActionBody body, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var fOpt = friendshipRepository.findById(body.friendshipId());
if (fOpt.isEmpty()) return ResponseEntity.notFound().build();
FriendshipEntity f = fOpt.get();
if (!f.getReceiverId().equals(myId)) return ResponseEntity.status(403).build();
f.setStatus(Status.ACCEPTED);
friendshipRepository.save(f);
return ResponseEntity.ok().build();
}
@DeleteMapping("/friends/reject")
public ResponseEntity<Void> rejectOrRemoveFriend(@RequestBody FriendshipActionBody body, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
var fOpt = friendshipRepository.findById(body.friendshipId());
if (fOpt.isEmpty()) return ResponseEntity.notFound().build();
FriendshipEntity f = fOpt.get();
if (!f.getSenderId().equals(myId) && !f.getReceiverId().equals(myId)) {
return ResponseEntity.status(403).build();
}
friendshipRepository.delete(f);
return ResponseEntity.noContent().build();
}
@GetMapping("/friends")
public ResponseEntity<List<FriendshipDto>> getFriends(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<FriendshipDto> dtos = friendshipRepository.findFriends(myId, Status.ACCEPTED).stream()
.map(f -> {
UUID friendId = f.getSenderId().equals(myId) ? f.getReceiverId() : f.getSenderId();
return userRepository.findById(friendId)
.map(u -> new FriendshipDto(
f.getFriendshipId(),
new UserProfile(u.getUserId(), u.getName(), u.getProfilePicture(), u.getProfilePictureHq(), "FRIEND"),
f.getStatus().name(),
f.getCreatedAt()))
.orElse(null);
})
.filter(Objects::nonNull)
.toList();
return ResponseEntity.ok(dtos);
}
@GetMapping("/friends/pending")
public ResponseEntity<List<FriendshipDto>> getPendingRequests(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<FriendshipDto> dtos = friendshipRepository.findByReceiverIdAndStatus(myId, Status.PENDING).stream()
.map(f -> userRepository.findById(f.getSenderId())
.map(u -> new FriendshipDto(
f.getFriendshipId(),
new UserProfile(u.getUserId(), u.getName(), u.getProfilePicture(), u.getProfilePictureHq(), "PENDING_RECEIVED"),
f.getStatus().name(),
f.getCreatedAt()))
.orElse(null))
.filter(Objects::nonNull)
.toList();
return ResponseEntity.ok(dtos);
}
@GetMapping("/friends/pending/count")
public ResponseEntity<Long> getPendingCount(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
return ResponseEntity.ok(friendshipRepository.countByReceiverIdAndStatus(myId, Status.PENDING));
}
// ── Messages ──
@PostMapping("/messages")
public ResponseEntity<Void> sendMessage(@RequestBody SendMessageBody body, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
if (body.text() == null || body.text().isBlank()) return ResponseEntity.badRequest().build();
MessageEntity msg = new MessageEntity();
msg.setMessageId(UUID.randomUUID());
msg.setSenderId(myId);
msg.setReceiverId(body.receiverId());
msg.setText(body.text().trim());
msg.setSentAt(LocalDateTime.now());
messageRepository.save(msg);
return ResponseEntity.status(201).build();
}
@GetMapping("/messages")
public ResponseEntity<List<ConversationSummary>> getConversations(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<MessageEntity> allMessages = messageRepository.findAllByUser(myId);
// Group by partner, keep most recent message per partner
Map<UUID, MessageEntity> latestByPartner = new LinkedHashMap<>();
for (MessageEntity m : allMessages) {
UUID partnerId = m.getSenderId().equals(myId) ? m.getReceiverId() : m.getSenderId();
latestByPartner.putIfAbsent(partnerId, m);
}
List<ConversationSummary> summaries = new ArrayList<>();
for (Map.Entry<UUID, MessageEntity> entry : latestByPartner.entrySet()) {
UUID partnerId = entry.getKey();
MessageEntity lastMsg = entry.getValue();
var partnerOpt = userRepository.findById(partnerId);
if (partnerOpt.isEmpty()) continue;
UserProfile partnerProfile = toUserProfileWithStatus(partnerOpt.get(), myId);
MessageDto lastMsgDto = toMessageDto(lastMsg);
long unreadCount = allMessages.stream()
.filter(m -> m.getSenderId().equals(partnerId)
&& m.getReceiverId().equals(myId)
&& m.getReadAt() == null)
.count();
summaries.add(new ConversationSummary(partnerProfile, lastMsgDto, unreadCount));
}
return ResponseEntity.ok(summaries);
}
@GetMapping("/messages/unread/count")
public ResponseEntity<Long> getUnreadCount(Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
return ResponseEntity.ok(messageRepository.countUnread(myId));
}
@GetMapping("/messages/{partnerId}")
public ResponseEntity<List<MessageDto>> getConversation(@PathVariable UUID partnerId, Principal principal) {
var meOpt = userRepository.findByEmail(principal.getName());
if (meOpt.isEmpty()) return ResponseEntity.status(401).build();
UUID myId = meOpt.get().getUserId();
List<MessageEntity> messages = messageRepository.findConversation(myId, partnerId, PageRequest.of(0, 50));
messageRepository.markAsRead(myId, partnerId, LocalDateTime.now());
return ResponseEntity.ok(messages.stream().map(this::toMessageDto).toList());
}
// ── Helpers ──
private UserProfile toUserProfileWithStatus(UserEntity user, UUID myId) {
String status = "NONE";
var existing = friendshipRepository.findExisting(myId, user.getUserId());
if (existing.isPresent()) {
FriendshipEntity f = existing.get();
if (f.getStatus() == Status.ACCEPTED) {
status = "FRIEND";
} else if (f.getSenderId().equals(myId)) {
status = "PENDING_SENT";
} else {
status = "PENDING_RECEIVED";
}
}
return new UserProfile(user.getUserId(), user.getName(), user.getProfilePicture(), user.getProfilePictureHq(), status);
}
private MessageDto toMessageDto(MessageEntity m) {
String senderName = userRepository.findById(m.getSenderId())
.map(UserEntity::getName)
.orElse("Unbekannt");
return new MessageDto(
m.getMessageId(), m.getSenderId(), senderName,
m.getReceiverId(), m.getText(), m.getSentAt(), m.getReadAt() != null);
}
}

View File

@@ -0,0 +1,3 @@
package de.oaa.xxx.social.dto;
public record ConversationSummary(UserProfile partner, MessageDto lastMessage, long unreadCount) {}

View File

@@ -0,0 +1,6 @@
package de.oaa.xxx.social.dto;
import java.time.LocalDateTime;
import java.util.UUID;
public record FriendshipDto(UUID friendshipId, UserProfile user, String status, LocalDateTime createdAt) {}

View File

@@ -0,0 +1,6 @@
package de.oaa.xxx.social.dto;
import java.time.LocalDateTime;
import java.util.UUID;
public record MessageDto(UUID messageId, UUID senderId, String senderName, UUID receiverId, String text, LocalDateTime sentAt, boolean read) {}

View File

@@ -0,0 +1,7 @@
package de.oaa.xxx.social.dto;
import java.time.LocalDateTime;
import java.util.UUID;
public record ProfileImageDto(UUID imageId, UUID userId, String imageData,
LocalDateTime uploadedAt, long likeCount, boolean likedByMe) {}

View File

@@ -0,0 +1,5 @@
package de.oaa.xxx.social.dto;
import java.util.UUID;
public record UserProfile(UUID userId, String name, String profilePicture, String profilePictureHq, String friendStatus) {}

View File

@@ -0,0 +1,44 @@
package de.oaa.xxx.social.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "friendship")
public class FriendshipEntity {
public enum Status { PENDING, ACCEPTED }
@Id
@Column
private UUID friendshipId;
@Column(nullable = false)
private UUID senderId;
@Column(nullable = false)
private UUID receiverId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private Status status;
@Column(nullable = false)
private LocalDateTime createdAt;
public UUID getFriendshipId() { return friendshipId; }
public void setFriendshipId(UUID friendshipId) { this.friendshipId = friendshipId; }
public UUID getSenderId() { return senderId; }
public void setSenderId(UUID senderId) { this.senderId = senderId; }
public UUID getReceiverId() { return receiverId; }
public void setReceiverId(UUID receiverId) { this.receiverId = receiverId; }
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

View File

@@ -0,0 +1,47 @@
package de.oaa.xxx.social.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "message")
public class MessageEntity {
@Id
@Column
private UUID messageId;
@Column(nullable = false)
private UUID senderId;
@Column(nullable = false)
private UUID receiverId;
@Column(columnDefinition = "MEDIUMTEXT", nullable = false)
private String text;
@Column(nullable = false)
private LocalDateTime sentAt;
@Column
private LocalDateTime readAt;
public UUID getMessageId() { return messageId; }
public void setMessageId(UUID messageId) { this.messageId = messageId; }
public UUID getSenderId() { return senderId; }
public void setSenderId(UUID senderId) { this.senderId = senderId; }
public UUID getReceiverId() { return receiverId; }
public void setReceiverId(UUID receiverId) { this.receiverId = receiverId; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public LocalDateTime getSentAt() { return sentAt; }
public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; }
public LocalDateTime getReadAt() { return readAt; }
public void setReadAt(LocalDateTime readAt) { this.readAt = readAt; }
}

View File

@@ -0,0 +1,35 @@
package de.oaa.xxx.social.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "profile_image")
public class ProfileImageEntity {
@Id
@Column
private UUID imageId;
@Column(nullable = false)
private UUID userId;
@Column(columnDefinition = "MEDIUMTEXT", nullable = false)
private String imageData;
@Column(nullable = false)
private LocalDateTime uploadedAt;
public UUID getImageId() { return imageId; }
public void setImageId(UUID imageId) { this.imageId = imageId; }
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public String getImageData() { return imageData; }
public void setImageData(String imageData) { this.imageData = imageData; }
public LocalDateTime getUploadedAt() { return uploadedAt; }
public void setUploadedAt(LocalDateTime uploadedAt) { this.uploadedAt = uploadedAt; }
}

View File

@@ -0,0 +1,35 @@
package de.oaa.xxx.social.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "profile_image_like")
public class ProfileImageLikeEntity {
@Id
@Column
private UUID likeId;
@Column(nullable = false)
private UUID imageId;
@Column(nullable = false)
private UUID userId;
@Column(nullable = false)
private LocalDateTime likedAt;
public UUID getLikeId() { return likeId; }
public void setLikeId(UUID likeId) { this.likeId = likeId; }
public UUID getImageId() { return imageId; }
public void setImageId(UUID imageId) { this.imageId = imageId; }
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
public LocalDateTime getLikedAt() { return likedAt; }
public void setLikedAt(LocalDateTime likedAt) { this.likedAt = likedAt; }
}

View File

@@ -0,0 +1,24 @@
package de.oaa.xxx.social.repository;
import de.oaa.xxx.social.entity.FriendshipEntity;
import de.oaa.xxx.social.entity.FriendshipEntity.Status;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface FriendshipRepository extends JpaRepository<FriendshipEntity, UUID> {
List<FriendshipEntity> findByReceiverIdAndStatus(UUID receiverId, Status status);
long countByReceiverIdAndStatus(UUID receiverId, Status status);
@Query("SELECT f FROM FriendshipEntity f WHERE (f.senderId = :userId OR f.receiverId = :userId) AND f.status = :status")
List<FriendshipEntity> findFriends(@Param("userId") UUID userId, @Param("status") Status status);
@Query("SELECT f FROM FriendshipEntity f WHERE (f.senderId = :userA AND f.receiverId = :userB) OR (f.senderId = :userB AND f.receiverId = :userA)")
Optional<FriendshipEntity> findExisting(@Param("userA") UUID userA, @Param("userB") UUID userB);
}

View File

@@ -0,0 +1,30 @@
package de.oaa.xxx.social.repository;
import de.oaa.xxx.social.entity.MessageEntity;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public interface MessageRepository extends JpaRepository<MessageEntity, UUID> {
@Query("SELECT m FROM MessageEntity m WHERE (m.senderId = :userA AND m.receiverId = :userB) OR (m.senderId = :userB AND m.receiverId = :userA) ORDER BY m.sentAt DESC")
List<MessageEntity> findConversation(@Param("userA") UUID userA, @Param("userB") UUID userB, Pageable pageable);
@Query("SELECT m FROM MessageEntity m WHERE m.senderId = :userId OR m.receiverId = :userId ORDER BY m.sentAt DESC")
List<MessageEntity> findAllByUser(@Param("userId") UUID userId);
@Query("SELECT COUNT(m) FROM MessageEntity m WHERE m.receiverId = :userId AND m.readAt IS NULL")
long countUnread(@Param("userId") UUID userId);
@Modifying
@Transactional
@Query("UPDATE MessageEntity m SET m.readAt = :now WHERE m.senderId = :partnerId AND m.receiverId = :userId AND m.readAt IS NULL")
void markAsRead(@Param("userId") UUID userId, @Param("partnerId") UUID partnerId, @Param("now") LocalDateTime now);
}

View File

@@ -0,0 +1,28 @@
package de.oaa.xxx.social.repository;
import de.oaa.xxx.social.entity.ProfileImageLikeEntity;
import jakarta.transaction.Transactional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
import java.util.UUID;
public interface ProfileImageLikeRepository extends JpaRepository<ProfileImageLikeEntity, UUID> {
Optional<ProfileImageLikeEntity> findByImageIdAndUserId(UUID imageId, UUID userId);
long countByImageId(UUID imageId);
@Modifying
@Transactional
@Query("DELETE FROM ProfileImageLikeEntity l WHERE l.imageId = :imageId")
void deleteByImageId(@Param("imageId") UUID imageId);
@Modifying
@Transactional
@Query("DELETE FROM ProfileImageLikeEntity l WHERE l.userId = :userId")
void deleteByUserId(@Param("userId") UUID userId);
}

View File

@@ -0,0 +1,14 @@
package de.oaa.xxx.social.repository;
import de.oaa.xxx.social.entity.ProfileImageEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface ProfileImageRepository extends JpaRepository<ProfileImageEntity, UUID> {
List<ProfileImageEntity> findByUserIdOrderByUploadedAtDesc(UUID userId);
long countByUserId(UUID userId);
}

View File

@@ -62,6 +62,18 @@ public class LoginController {
.orElse(ResponseEntity.status(401).build());
}
@GetMapping("/logout")
public void logout(HttpServletResponse response) throws java.io.IOException {
ResponseCookie cookie = ResponseCookie.from("jwt", "")
.httpOnly(true)
.sameSite("Strict")
.path("/")
.maxAge(0)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
response.sendRedirect("/");
}
@GetMapping("/{userId}")
public ResponseEntity<User> get(@PathVariable UUID userId) {
return userRepository.findById(userId)

View File

@@ -8,6 +8,7 @@ public class User {
private String name;
private String email;
private String password;
private String profilePicture;
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
@@ -20,4 +21,12 @@ public class User {
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getProfilePicture() { return profilePicture; }
public void setProfilePicture(String profilePicture) { this.profilePicture = profilePicture; }
@Override
public String toString() {
return "User[userId=" + userId + ", name=" + name + ", email=" + email + "]";
}
}

View File

@@ -1,14 +1,39 @@
package de.oaa.xxx.user;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
import de.oaa.xxx.aufgaben.repository.AufgabeRepository;
import de.oaa.xxx.aufgaben.repository.AufgabenGruppeRepository;
import de.oaa.xxx.aufgaben.repository.FavoritRepository;
import de.oaa.xxx.aufgaben.repository.GruppenAboRepository;
import de.oaa.xxx.aufgaben.repository.SperreRepository;
import de.oaa.xxx.aufgaben.repository.StrafeRepository;
import de.oaa.xxx.aufgaben.repository.ToyRepository;
import de.oaa.xxx.emailchange.EmailChangeRepository;
import de.oaa.xxx.passwordreset.PasswordResetRepository;
import de.oaa.xxx.registration.RegistrationRepository;
import de.oaa.xxx.session.entity.AktiveSperreEntity;
import de.oaa.xxx.session.entity.MitspielerEntity;
import de.oaa.xxx.session.repository.AktiveSperreRepository;
import de.oaa.xxx.session.repository.MitspielerRepository;
import de.oaa.xxx.session.repository.SessionRepository;
import de.oaa.xxx.social.repository.ProfileImageLikeRepository;
import de.oaa.xxx.social.repository.ProfileImageRepository;
import jakarta.transaction.Transactional;
@RestController
@RequestMapping("/user")
@@ -17,9 +42,150 @@ public class UserController {
private static final Logger LOGGER = LoggerFactory.getLogger(UserController.class);
private final UserRepository userRepository;
private final RegistrationRepository registrationRepository;
private final AufgabenGruppeRepository aufgabenGruppeRepository;
private final AufgabeRepository aufgabeRepository;
private final StrafeRepository strafeRepository;
private final SperreRepository sperreRepository;
private final ToyRepository toyRepository;
private final FavoritRepository favoritRepository;
private final GruppenAboRepository gruppenAboRepository;
private final SessionRepository sessionRepository;
private final AktiveSperreRepository aktiveSperreRepository;
private final MitspielerRepository mitspielerRepository;
private final EmailChangeRepository emailChangeRepository;
private final PasswordResetRepository passwordResetRepository;
private final ProfileImageRepository profileImageRepository;
private final ProfileImageLikeRepository profileImageLikeRepository;
public UserController(UserRepository userRepository) {
public UserController(UserRepository userRepository,
RegistrationRepository registrationRepository,
AufgabenGruppeRepository aufgabenGruppeRepository,
AufgabeRepository aufgabeRepository,
StrafeRepository strafeRepository,
SperreRepository sperreRepository,
ToyRepository toyRepository,
FavoritRepository favoritRepository,
GruppenAboRepository gruppenAboRepository,
SessionRepository sessionRepository,
AktiveSperreRepository aktiveSperreRepository,
MitspielerRepository mitspielerRepository,
EmailChangeRepository emailChangeRepository,
PasswordResetRepository passwordResetRepository,
ProfileImageRepository profileImageRepository,
ProfileImageLikeRepository profileImageLikeRepository) {
this.userRepository = userRepository;
this.registrationRepository = registrationRepository;
this.aufgabenGruppeRepository = aufgabenGruppeRepository;
this.aufgabeRepository = aufgabeRepository;
this.strafeRepository = strafeRepository;
this.sperreRepository = sperreRepository;
this.toyRepository = toyRepository;
this.favoritRepository = favoritRepository;
this.gruppenAboRepository = gruppenAboRepository;
this.sessionRepository = sessionRepository;
this.aktiveSperreRepository = aktiveSperreRepository;
this.mitspielerRepository = mitspielerRepository;
this.emailChangeRepository = emailChangeRepository;
this.passwordResetRepository = passwordResetRepository;
this.profileImageRepository = profileImageRepository;
this.profileImageLikeRepository = profileImageLikeRepository;
}
record ProfilePictureRequest(String picture, String pictureHq) {}
record NameChangeRequest(String name) {}
@PutMapping("/me/picture")
public ResponseEntity<Void> updateProfilePicture(@RequestBody ProfilePictureRequest request, Principal principal) {
var user = userRepository.findByEmail(principal.getName());
if (user.isEmpty()) return ResponseEntity.status(401).build();
user.get().setProfilePicture(request.picture());
user.get().setProfilePictureHq(request.pictureHq());
userRepository.save(user.get());
return ResponseEntity.ok().build();
}
@PutMapping("/me/name")
public ResponseEntity<Void> updateName(@RequestBody NameChangeRequest request, Principal principal) {
String newName = request.name();
if (userRepository.findByName(newName).isPresent()
|| registrationRepository.findByName(newName).isPresent()) {
return ResponseEntity.status(409).build();
}
var user = userRepository.findByEmail(principal.getName());
if (user.isEmpty()) return ResponseEntity.status(401).build();
user.get().setName(newName);
userRepository.save(user.get());
return ResponseEntity.ok().build();
}
@DeleteMapping("/me")
@Transactional
public ResponseEntity<Void> deleteAccount(Principal principal) {
var userOpt = userRepository.findByEmail(principal.getName());
if (userOpt.isEmpty()) return ResponseEntity.status(401).build();
var user = userOpt.get();
UUID userId = user.getUserId();
String email = user.getEmail();
LOGGER.info("Lösche Konto für User {}", email);
// 1. Delete user's AufgabenGruppen and all their content
var gruppen = aufgabenGruppeRepository.findByUserId(userId);
if (!gruppen.isEmpty()) {
aufgabeRepository.deleteAll(aufgabeRepository.findByAufgabenGruppeIn(gruppen));
strafeRepository.deleteAll(strafeRepository.findByAufgabenGruppeIn(gruppen));
sperreRepository.deleteAll(sperreRepository.findByAufgabenGruppeIn(gruppen));
for (var gruppe : gruppen) {
gruppenAboRepository.deleteByAufgabenGruppe(gruppe);
favoritRepository.deleteByAufgabenGruppeId(gruppe.getGruppenId());
}
aufgabenGruppeRepository.deleteAll(gruppen);
}
// 2. Delete user's Toys (join table refs already cleared above)
toyRepository.deleteAll(toyRepository.findByUserId(userId));
// 3. Delete user's own Favoriten and Gruppenabos (to other groups)
favoritRepository.deleteAll(favoritRepository.findByUserId(userId));
gruppenAboRepository.deleteAll(gruppenAboRepository.findByUserId(userId));
// 4. Delete Session with Mitspieler and AktiveSperre
var sessionOpt = sessionRepository.findByUserId(userId);
if (sessionOpt.isPresent()) {
var session = sessionOpt.get();
List<AktiveSperreEntity> sperren = session.getAktiveSperren();
List<MitspielerEntity> mitspieler = session.getMitspieler();
aktiveSperreRepository.deleteAll(sperren);
mitspielerRepository.deleteAll(mitspieler);
sessionRepository.delete(session);
}
// 5. Delete pending tokens
emailChangeRepository.findByUserEmail(email).ifPresent(emailChangeRepository::delete);
passwordResetRepository.findByEmail(email).ifPresent(passwordResetRepository::delete);
// 5b. Delete profile images and likes
var profileImages = profileImageRepository.findByUserIdOrderByUploadedAtDesc(userId);
for (var img : profileImages) {
profileImageLikeRepository.deleteByImageId(img.getImageId());
}
profileImageRepository.deleteAll(profileImages);
profileImageLikeRepository.deleteByUserId(userId);
// 6. Delete user
userRepository.delete(user);
// Clear JWT cookie
ResponseCookie cookie = ResponseCookie.from("jwt", "")
.httpOnly(true)
.sameSite("Strict")
.path("/")
.maxAge(0)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.build();
}
@PostMapping

View File

@@ -21,6 +21,12 @@ public class UserEntity {
@Column
private String password;
@Column(columnDefinition = "TEXT")
private String profilePicture;
@Column(columnDefinition = "MEDIUMTEXT")
private String profilePictureHq;
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
@@ -33,11 +39,23 @@ public class UserEntity {
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getProfilePicture() { return profilePicture; }
public void setProfilePicture(String profilePicture) { this.profilePicture = profilePicture; }
public String getProfilePictureHq() { return profilePictureHq; }
public void setProfilePictureHq(String profilePictureHq) { this.profilePictureHq = profilePictureHq; }
@Override
public String toString() {
return "UserEntity[userId=" + userId + ", name=" + name + ", email=" + email + "]";
}
public User toUser() {
User user = new User();
user.setEmail(email);
user.setName(name);
user.setUserId(userId);
user.setProfilePicture(profilePicture);
return user;
}
}

View File

@@ -2,12 +2,14 @@ package de.oaa.xxx.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface UserRepository extends JpaRepository<UserEntity, UUID> {
Optional<UserEntity> findByEmailAndPassword(String email, String password);
Optional<UserEntity> findByEmail(String email);
Optional<UserEntity> findByName(String name);
List<UserEntity> findByNameContainingIgnoreCase(String name);
}

View File

@@ -43,6 +43,9 @@ app.theme.color-text=#eeeeee
app.theme.color-muted=#888888
app.theme.color-success=#2ecc71
# Logging
logging.level.de.oaa.xxx=DEBUG
# Server
server.port=8080
server.servlet.context-path=/

View File

@@ -7,70 +7,6 @@
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
body { display: block; min-height: 100vh; }
.layout { display: flex; min-height: 100vh; }
/* ── Sidebar ── */
.sidebar {
width: 240px; background: var(--color-card);
border-right: 1px solid var(--color-secondary);
display: flex; flex-direction: column;
position: fixed; top: 0; left: 0;
height: 100vh; overflow-y: auto;
z-index: 100; transition: transform 0.25s ease;
}
.sidebar-brand {
color: var(--color-primary); font-size: 1.1rem; font-weight: 700;
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid var(--color-secondary); flex-shrink: 0;
}
.sidebar ul { list-style: none; padding: 0.5rem 0; }
.sidebar ul li a {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.7rem 1.25rem; color: var(--color-text);
text-decoration: none; font-size: 0.95rem;
border-left: 3px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.sidebar ul li a:hover, .sidebar ul li a.active {
background: var(--color-secondary); color: var(--color-primary);
border-left-color: var(--color-primary);
}
.sidebar ul li a .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; }
/* ── Main ── */
.main { margin-left: 240px; flex: 1; display: flex; flex-direction: column; min-height: 100vh; }
/* ── Topbar ── */
.topbar {
display: flex; align-items: center; gap: 1rem;
padding: 0.9rem 1.5rem; background: var(--color-card);
border-bottom: 1px solid var(--color-secondary);
position: sticky; top: 0; z-index: 50;
}
.topbar h1 { font-size: 1.2rem; font-weight: 600; }
.burger {
display: none; background: none; border: none; cursor: pointer;
color: var(--color-text); padding: 0.25rem 0.4rem;
border-radius: 4px; transition: background 0.15s; flex-shrink: 0;
}
.burger:hover { background: var(--color-secondary); }
.burger-icon { display: flex; flex-direction: column; gap: 5px; width: 22px; }
.burger-icon span {
display: block; height: 2px; background: var(--color-text);
border-radius: 2px; transition: transform 0.25s, opacity 0.25s;
}
.burger.open .burger-icon span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.burger.open .burger-icon span:nth-child(2) { opacity: 0; }
.burger.open .burger-icon span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.content { padding: 2rem 1.5rem; flex: 1; }
.overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.55); z-index: 90;
}
/* ── Section ── */
.section + .section { margin-top: 2.5rem; }
.section-header {
@@ -272,6 +208,25 @@
.modal-actions .btn-save:disabled { opacity: 0.5; cursor: default; }
.modal-error { color: var(--color-primary); font-size: 0.82rem; margin-top: 0.75rem; display: none; }
/* ── Placeholder-Hint ── */
.label-with-hint { display: flex; align-items: center; gap: 0.4rem; }
.btn-hint {
background: none; border: 1px solid rgba(136,136,136,0.4); border-radius: 50%;
color: var(--color-muted); font-size: 0.7rem; font-style: italic; font-weight: 700;
width: 16px; height: 16px; line-height: 1; padding: 0;
cursor: pointer; flex-shrink: 0; transition: border-color 0.15s, color 0.15s;
}
.btn-hint:hover { border-color: var(--color-primary); color: var(--color-primary); }
.placeholder-hint {
background: rgba(255,255,255,0.04); border: 1px solid rgba(136,136,136,0.25);
border-radius: 6px; padding: 0.5rem 0.7rem; margin-bottom: 0.3rem;
font-size: 0.78rem; color: var(--color-muted); line-height: 1.6;
}
.placeholder-hint code {
background: rgba(233,69,96,0.12); color: var(--color-primary);
border-radius: 3px; padding: 0.05rem 0.3rem; font-size: 0.75rem;
}
/* ── Item-Add-Modal extra ── */
.modal-two-col { display: flex; gap: 0.75rem; }
.modal-two-col > * { flex: 1; }
@@ -326,8 +281,7 @@
}
.btn-toy-add:hover { border-color: var(--color-primary); color: var(--color-primary); }
/* ── Toy-Suchmodal ── */
#toySearchModal .modal-box { max-width: 420px; }
/* ── Toy-Suche (inline) ── */
.toy-search-input {
width: 100%; box-sizing: border-box; padding: 0.45rem 0.7rem;
background: var(--color-secondary); border: 1px solid rgba(136,136,136,0.3);
@@ -368,19 +322,9 @@
flex-shrink: 0; margin-top: 0.15rem;
}
/* ── Mobile ── */
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); }
.sidebar.open { transform: translateX(0); box-shadow: 4px 0 20px rgba(0,0,0,0.5); }
.main { margin-left: 0; }
.burger { display: flex; }
.overlay.visible { display: block; }
}
</style>
</head>
<body>
<div class="overlay" id="overlay"></div>
<body class="app">
<!-- Gruppe-Modal -->
<div class="modal-backdrop" id="gruppeModal">
@@ -439,9 +383,38 @@
<label for="iKurzText">Kurzbezeichnung *</label>
<input type="text" id="iKurzText" maxlength="200" placeholder="Kurzer Name">
<label for="iText">Beschreibung *</label>
<label class="label-with-hint">
<span>Beschreibung *</span>
<button type="button" class="btn-hint" onclick="togglePlaceholderHint()" title="Platzhalter-Hilfe">i</button>
</label>
<div id="iPlaceholderHint" style="display:none;">
<div class="placeholder-hint">
In Texten können Platzhalter verwendet werden:<br>
<code>{AKTIV}</code> Name des aktiven Parts<br>
<code>{PASSIV}</code> Name des passiven Parts
</div>
</div>
<textarea id="iText" rows="4" maxlength="4000" placeholder="Ausführliche Beschreibung…"></textarea>
<!-- Finisher: Geschlecht -->
<div id="iGeschlechtRow">
<label>Geschlecht der Person die kommt *</label>
<div style="display:flex; gap:1.5rem; margin-top:0.5rem;" id="iGeschlecht">
<label style="display:flex; align-items:center; gap:0.4rem; font-size:0.85rem; cursor:pointer;">
<input type="radio" name="iGeschlechtRadio" value="WEIBLICH" style="accent-color:var(--color-primary);">
Weiblich
</label>
<label style="display:flex; align-items:center; gap:0.4rem; font-size:0.85rem; cursor:pointer;">
<input type="radio" name="iGeschlechtRadio" value="DIVERS" style="accent-color:var(--color-primary);">
Divers
</label>
<label style="display:flex; align-items:center; gap:0.4rem; font-size:0.85rem; cursor:pointer;">
<input type="radio" name="iGeschlechtRadio" value="MAENNLICH" style="accent-color:var(--color-primary);">
Männlich
</label>
</div>
</div>
<!-- Aufgabe / Strafe: Level + Sekunden -->
<div id="iLevelRow">
<label for="iLevel">Level *</label>
@@ -481,6 +454,27 @@
</div>
</div>
<!-- Finisher: Werkzeuge (eigene Labels, kein Umschnalldildo bei aktiv) -->
<div id="iWerkzeugFinisherAktivRow">
<label>Benötigt (Person die kommt)</label>
<div class="werkzeug-checks" id="iWerkzeugFinisherAktiv">
<label class="werkzeug-check"><span>Mund</span><input type="checkbox" value="MUND"></label>
<label class="werkzeug-check"><span>Vagina</span><input type="checkbox" value="VAGINA"></label>
<label class="werkzeug-check"><span>Penis</span><input type="checkbox" value="PENIS"></label>
<label class="werkzeug-check"><span>Anus</span><input type="checkbox" value="ANUS"></label>
</div>
</div>
<div id="iWerkzeugFinisherPassivRow">
<label>Benötigt (Person die zum Kommen bringt)</label>
<div class="werkzeug-checks" id="iWerkzeugFinisherPassiv">
<label class="werkzeug-check"><span>Mund</span><input type="checkbox" value="MUND"></label>
<label class="werkzeug-check"><span>Vagina</span><input type="checkbox" value="VAGINA"></label>
<label class="werkzeug-check"><span>Penis</span><input type="checkbox" value="PENIS"></label>
<label class="werkzeug-check"><span>Anus</span><input type="checkbox" value="ANUS"></label>
<label class="werkzeug-check"><span>Umschnall-Dildo</span><input type="checkbox" value="UMSCHNALLDILDO"></label>
</div>
</div>
<!-- Zeitstrafe: Minuten + SperreFuer + releaseText -->
<div id="iMinutenRow">
<label>Dauer (Minuten) *</label>
@@ -513,6 +507,11 @@
<label>Benötigte Toys (optional)</label>
<div class="selected-toys-row" id="iSelectedToys"></div>
<button class="btn-toy-add" type="button" id="iToyAddBtn">+ Toy hinzufügen</button>
<div id="iToySearchArea" style="display:none; margin-top:0.5rem;">
<input class="toy-search-input" type="text" id="toySearchInput" placeholder="Name filtern…" autocomplete="off">
<div class="toy-search-results" id="toySearchResults"></div>
<div id="toySearchEmpty" style="font-size:0.82rem; color:var(--color-muted); display:none; margin-top:0.4rem;">Keine Toys gefunden.</div>
</div>
<div class="modal-error" id="itemModalError"></div>
<div class="modal-actions">
@@ -522,46 +521,9 @@
</div>
</div>
<!-- ── Toy-Suchmodal ── -->
<div class="modal-overlay" id="toySearchModal">
<div class="modal-box" style="max-width:420px;">
<h2 class="modal-title">Toy auswählen</h2>
<input class="toy-search-input" type="text" id="toySearchInput" placeholder="Name filtern…" autocomplete="off">
<div class="toy-search-results" id="toySearchResults"></div>
<div id="toySearchEmpty" style="font-size:0.82rem; color:var(--color-muted); display:none; margin-top:0.5rem;">Keine Toys gefunden.</div>
<div class="modal-actions" style="margin-top:1rem;">
<button class="btn-save" id="toySearchDoneBtn">Fertig</button>
</div>
</div>
</div>
<div class="layout">
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">XXX The Game</div>
<ul>
<li><a href="/userhome.html"><span class="icon"></span> Dashboard</a></li>
<li><a href="#"><span class="icon"></span> Meine Session</a></li>
<li><a href="/aufgaben.html" class="active"><span class="icon"></span> Aufgaben</a></li>
<li><a href="/entdecken.html"><span class="icon"></span> Entdecken</a></li>
<li><a href="#"><span class="icon"></span> Strafen</a></li>
<li><a href="/toys.html"><span class="icon"></span> Toys</a></li>
<li><a href="#"><span class="icon"></span> Favoriten</a></li>
<li><a href="#"><span class="icon"></span> Rangliste</a></li>
<li><a href="#"><span class="icon"></span> Nachrichten</a></li>
<li><a href="#"><span class="icon"></span> Einstellungen</a></li>
<li><a href="#" id="logoutLink"><span class="icon"></span> Abmelden</a></li>
</ul>
</aside>
<div class="main">
<header class="topbar">
<button class="burger" id="burgerBtn" aria-label="Menü öffnen">
<span class="burger-icon"><span></span><span></span><span></span></span>
</button>
<h1>Aufgaben</h1>
</header>
<div class="content">
<div class="main">
<div class="content">
<!-- Meine Aufgabengruppen -->
<div class="section">
@@ -621,7 +583,6 @@
</div>
</div>
</div>
</div>
</div>
@@ -708,13 +669,15 @@
}
list.innerHTML = gruppen.map(g => {
_gruppeData[g.gruppenId] = g;
const aufgabenCount = (g.aufgaben || []).length;
const strafeCount = (g.strafen || []).length;
const sperreCount = (g.sperren || []).length;
const aufgabenCount = (g.aufgaben || []).length;
const strafeCount = (g.strafen || []).length;
const sperreCount = (g.sperren || []).length;
const finisherCount = (g.finisher || []).length;
const counts = [
aufgabenCount ? `${aufgabenCount} Aufgabe${aufgabenCount !== 1 ? 'n' : ''}` : '',
strafeCount ? `${strafeCount} Strafe${strafeCount !== 1 ? 'n' : ''}` : '',
sperreCount ? `${sperreCount} Zeitstrafe${sperreCount !== 1 ? 'n' : ''}` : ''
aufgabenCount ? `${aufgabenCount} Aufgabe${aufgabenCount !== 1 ? 'n' : ''}` : '',
strafeCount ? `${strafeCount} Strafe${strafeCount !== 1 ? 'n' : ''}` : '',
sperreCount ? `${sperreCount} Zeitstrafe${sperreCount !== 1 ? 'n' : ''}` : '',
finisherCount ? `${finisherCount} Finisher` : ''
].filter(Boolean).join(' · ');
const badges = [];
@@ -738,9 +701,10 @@
</div>
<div class="gruppe-body" id="body-${esc(g.gruppenId)}" style="display:none;">
${g.beschreibung ? `<div class="gruppe-desc">${esc(g.beschreibung)}</div>` : ''}
${renderSubSection('Aufgaben', sortByLevelThenName(g.aufgaben || []), 'aufgabe', renderAufgabe, g.gruppenId, type)}
${renderSubSection('Strafen', sortByLevelThenName(g.strafen || []), 'strafe', renderStrafe, g.gruppenId, type)}
${renderSubSection('Zeitstrafen',sortByName(g.sperren || []), 'zeitstrafe',renderZeitstrafe, g.gruppenId, type)}
${renderSubSection('Aufgaben', sortByLevelThenName(g.aufgaben || []), 'aufgabe', renderAufgabe, g.gruppenId, type)}
${renderSubSection('Strafen', sortByLevelThenName(g.strafen || []), 'strafe', renderStrafe, g.gruppenId, type)}
${renderSubSection('Zeitstrafen',sortByName(g.sperren || []), 'zeitstrafe',renderZeitstrafe, g.gruppenId, type)}
${renderSubSection('Finisher', sortByName(g.finisher || []), 'finisher', renderFinisher, g.gruppenId, type)}
</div>
</div>`;
}).join('');
@@ -864,6 +828,33 @@
</div>`;
}
const GESCHLECHT_LABEL = { WEIBLICH: 'Weiblich', DIVERS: 'Divers', MAENNLICH: 'Männlich' };
function renderFinisher(f, type, gruppenId) {
_itemData[f.finisherId] = { ...f, _kind: 'finisher', _gruppenId: gruppenId };
const badges = [];
if (f.geschlecht) badges.push(`<span class="badge badge-neutral">${esc(GESCHLECHT_LABEL[f.geschlecht] || f.geschlecht)}</span>`);
const detailRows = [];
if (f.text) detailRows.push(`<div class="item-detail-text">${esc(f.text)}</div>`);
if (f.benoetigtAktiv && f.benoetigtAktiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Aktiv:</span>${werkzeugChips(f.benoetigtAktiv)}</div>`);
if (f.benoetigtPassiv && f.benoetigtPassiv.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Passiv:</span>${werkzeugChips(f.benoetigtPassiv)}</div>`);
if (f.benoetigteToys && f.benoetigteToys.length) detailRows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(f.benoetigteToys)}</div>`);
const actionBtns = type === 'user' ? `
<div class="item-action-btns">
<button class="btn-item-edit" onclick="openEditItemModal('${esc(f.finisherId)}',event)">✎ Bearbeiten</button>
<button class="btn-item-delete" onclick="deleteItem('finisher','${esc(f.finisherId)}','${esc(gruppenId)}',event)">✕ Löschen</button>
</div>` : '';
return `<div class="item" id="item-${esc(f.finisherId)}">
<div class="item-row" onclick="toggleItem('${esc(f.finisherId)}')">
<span class="item-text">${esc(f.kurzText)}</span>
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
</div>
${(detailRows.length || actionBtns) ? `<div class="item-detail">${detailRows.join('')}${actionBtns}</div>` : ''}
</div>`;
}
// ── Item toggle (detail panel) ──
let openItemId = null;
@@ -883,8 +874,8 @@
}
// ── Item löschen ──
const ITEM_DELETE_URL = { aufgabe: '/aufgabe', strafe: '/strafe', zeitstrafe: '/sperre' };
const ITEM_DELETE_FIELD = { aufgabe: 'aufgabeId', strafe: 'strafeId', zeitstrafe: 'sperreId' };
const ITEM_DELETE_URL = { aufgabe: '/aufgabe', strafe: '/strafe', zeitstrafe: '/sperre', finisher: '/finisher' };
const ITEM_DELETE_FIELD = { aufgabe: 'aufgabeId', strafe: 'strafeId', zeitstrafe: 'sperreId', finisher: 'finisherId' };
function deleteItem(kind, itemId, gruppenId, event) {
event.stopPropagation();
@@ -1225,29 +1216,34 @@
if (chip) chip.classList.remove('selected');
}
// Toy-Suchmodal
const toySearchModal = document.getElementById('toySearchModal');
document.getElementById('iToyAddBtn').addEventListener('click', openToySearch);
document.getElementById('toySearchDoneBtn').addEventListener('click', closeToySearch);
toySearchModal.addEventListener('click', e => { if (e.target === toySearchModal) closeToySearch(); });
// Toy-Suche (inline)
document.getElementById('iToyAddBtn').addEventListener('click', toggleToySearch);
document.getElementById('toySearchInput').addEventListener('input', renderToySearchResults);
function openToySearch() {
document.getElementById('toySearchInput').value = '';
document.getElementById('toySearchResults').innerHTML = '';
document.getElementById('toySearchEmpty').style.display = 'none';
toySearchModal.classList.add('open');
_loadAvailableToys()
.then(() => renderToySearchResults())
.catch(() => {
document.getElementById('toySearchResults').innerHTML =
'<span style="font-size:0.82rem;color:var(--color-muted)">Fehler beim Laden.</span>';
});
document.getElementById('toySearchInput').focus();
function toggleToySearch() {
const area = document.getElementById('iToySearchArea');
if (area.style.display === 'none') {
area.style.display = 'block';
document.getElementById('toySearchInput').value = '';
document.getElementById('toySearchResults').innerHTML = '';
document.getElementById('toySearchEmpty').style.display = 'none';
document.getElementById('iToyAddBtn').textContent = '▲ Suche schließen';
_loadAvailableToys()
.then(() => renderToySearchResults())
.catch(() => {
document.getElementById('toySearchResults').innerHTML =
'<span style="font-size:0.82rem;color:var(--color-muted)">Fehler beim Laden.</span>';
});
document.getElementById('toySearchInput').focus();
} else {
closeToySearch();
}
}
function closeToySearch() { toySearchModal.classList.remove('open'); }
function closeToySearch() {
document.getElementById('iToySearchArea').style.display = 'none';
document.getElementById('iToyAddBtn').textContent = '+ Toy hinzufügen';
}
function renderToySearchResults() {
const query = document.getElementById('toySearchInput').value.trim().toLowerCase();
@@ -1297,17 +1293,29 @@
let currentItemKind = null; // 'aufgabe' | 'strafe' | 'zeitstrafe'
let currentItemEditId = null; // null = neu, sonst ID des zu bearbeitenden Items
const ITEM_TITLES_NEW = { aufgabe: 'Aufgabe hinzufügen', strafe: 'Strafe hinzufügen', zeitstrafe: 'Zeitstrafe hinzufügen' };
const ITEM_TITLES_EDIT = { aufgabe: 'Aufgabe bearbeiten', strafe: 'Strafe bearbeiten', zeitstrafe: 'Zeitstrafe bearbeiten' };
const ITEM_TITLES_NEW = { aufgabe: 'Aufgabe hinzufügen', strafe: 'Strafe hinzufügen', zeitstrafe: 'Zeitstrafe hinzufügen', finisher: 'Finisher hinzufügen' };
const ITEM_TITLES_EDIT = { aufgabe: 'Aufgabe bearbeiten', strafe: 'Strafe bearbeiten', zeitstrafe: 'Zeitstrafe bearbeiten', finisher: 'Finisher bearbeiten' };
function _setupItemModal(kind) {
const isZeit = kind === 'zeitstrafe';
document.getElementById('iLevelRow').style.display = isZeit ? 'none' : 'block';
document.getElementById('iWerkzeugAktivRow').style.display = isZeit ? 'none' : 'block';
document.getElementById('iWerkzeugPassivRow').style.display = isZeit ? 'none' : 'block';
document.getElementById('iMinutenRow').style.display = isZeit ? 'block' : 'none';
document.getElementById('iSperreFuerRow').style.display = isZeit ? 'block' : 'none';
document.getElementById('iReleaseTextRow').style.display = isZeit ? 'block' : 'none';
const isZeit = kind === 'zeitstrafe';
const isFinisher = kind === 'finisher';
document.querySelector('#iPlaceholderHint .placeholder-hint').innerHTML =
isFinisher
? 'In Texten können Platzhalter verwendet werden:<br>' +
'<code>{AKTIV}</code> Name der Person die kommt<br>' +
'<code>{PASSIV}</code> Name der Person die zum Kommen bringt'
: 'In Texten können Platzhalter verwendet werden:<br>' +
'<code>{AKTIV}</code> Name des aktiven Parts<br>' +
'<code>{PASSIV}</code> Name des passiven Parts';
document.getElementById('iGeschlechtRow').style.display = isFinisher ? 'block' : 'none';
document.getElementById('iLevelRow').style.display = (!isZeit && !isFinisher) ? 'block' : 'none';
document.getElementById('iWerkzeugAktivRow').style.display = (!isZeit && !isFinisher) ? 'block' : 'none';
document.getElementById('iWerkzeugPassivRow').style.display = (!isZeit && !isFinisher) ? 'block' : 'none';
document.getElementById('iWerkzeugFinisherAktivRow').style.display = isFinisher ? 'block' : 'none';
document.getElementById('iWerkzeugFinisherPassivRow').style.display = isFinisher ? 'block' : 'none';
document.getElementById('iMinutenRow').style.display = isZeit ? 'block' : 'none';
document.getElementById('iSperreFuerRow').style.display = isZeit ? 'block' : 'none';
document.getElementById('iReleaseTextRow').style.display = isZeit ? 'block' : 'none';
}
function _resetItemFields() {
@@ -1319,9 +1327,12 @@
document.getElementById('iMinVon').value = '';
document.getElementById('iMinBis').value = '';
document.getElementById('iReleaseText').value = '';
document.querySelectorAll('#iWerkzeugAktiv input').forEach(cb => cb.checked = false);
document.querySelectorAll('#iWerkzeugPassiv input').forEach(cb => cb.checked = false);
document.querySelectorAll('#iSperreFuer input').forEach(cb => cb.checked = false);
document.querySelectorAll('#iWerkzeugAktiv input').forEach(cb => cb.checked = false);
document.querySelectorAll('#iWerkzeugPassiv input').forEach(cb => cb.checked = false);
document.querySelectorAll('#iWerkzeugFinisherAktiv input').forEach(cb => cb.checked = false);
document.querySelectorAll('#iWerkzeugFinisherPassiv input').forEach(cb => cb.checked = false);
document.querySelectorAll('#iSperreFuer input').forEach(cb => cb.checked = false);
document.querySelectorAll('#iGeschlecht input').forEach(rb => rb.checked = false);
_selectedToys = [];
renderSelectedToys();
document.getElementById('itemModalError').style.display = 'none';
@@ -1359,6 +1370,13 @@
document.getElementById('iSekBis').value = d.sekundenBis != null ? d.sekundenBis : '';
(d.benoetigtAktiv || []).forEach(w => { const cb = document.querySelector(`#iWerkzeugAktiv input[value="${w}"]`); if (cb) cb.checked = true; });
(d.benoetigtPassiv || []).forEach(w => { const cb = document.querySelector(`#iWerkzeugPassiv input[value="${w}"]`); if (cb) cb.checked = true; });
} else if (d._kind === 'finisher') {
(d.benoetigtAktiv || []).forEach(w => { const cb = document.querySelector(`#iWerkzeugFinisherAktiv input[value="${w}"]`); if (cb) cb.checked = true; });
(d.benoetigtPassiv || []).forEach(w => { const cb = document.querySelector(`#iWerkzeugFinisherPassiv input[value="${w}"]`); if (cb) cb.checked = true; });
if (d.geschlecht) {
const rb = document.querySelector(`#iGeschlecht input[value="${d.geschlecht}"]`);
if (rb) rb.checked = true;
}
} else {
document.getElementById('iMinVon').value = d.minutenVon != null ? d.minutenVon : '';
document.getElementById('iMinBis').value = d.minutenBis != null ? d.minutenBis : '';
@@ -1372,7 +1390,11 @@
document.getElementById('iKurzText').focus();
}
function closeItemModal() { itemModal.classList.remove('open'); }
function closeItemModal() { itemModal.classList.remove('open'); closeToySearch(); document.getElementById('iPlaceholderHint').style.display = 'none'; }
function togglePlaceholderHint() {
const el = document.getElementById('iPlaceholderHint');
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
document.getElementById('itemCancelBtn').addEventListener('click', closeItemModal);
itemModal.addEventListener('click', e => { if (e.target === itemModal) closeItemModal(); });
@@ -1408,6 +1430,19 @@
url = isEdit ? `${base}/${currentItemEditId}` : base;
method = isEdit ? 'PUT' : 'POST';
} else if (kind === 'finisher') {
const geschlecht = document.querySelector('#iGeschlecht input:checked')?.value;
if (!geschlecht) { showItemError('Bitte ein Geschlecht auswählen.'); return; }
payload = {
kurzText, text, geschlecht,
gruppeId: isEdit ? undefined : currentItemGruppeId,
benoetigtAktiv: checkedValues('iWerkzeugFinisherAktiv'),
benoetigtPassiv: checkedValues('iWerkzeugFinisherPassiv'),
benoetigteToys: _selectedToys.map(t => ({ toyId: t.toyId }))
};
url = isEdit ? `/finisher/${currentItemEditId}` : '/finisher';
method = isEdit ? 'PUT' : 'POST';
} else {
const minVon = document.getElementById('iMinVon').value.trim();
if (!minVon) { showItemError('Bitte eine Mindestdauer in Minuten angeben.'); return; }
@@ -1422,7 +1457,7 @@
minutenBis: minBis ? parseInt(minBis, 10) : null,
releaseText: document.getElementById('iReleaseText').value.trim() || null,
sperreFuer,
benoetigteToys: Array.from(_selectedToyIds).map(id => ({ toyId: id }))
benoetigteToys: _selectedToys.map(t => ({ toyId: t.toyId }))
};
url = isEdit ? `/sperre/${currentItemEditId}` : '/sperre';
method = isEdit ? 'PUT' : 'POST';
@@ -1558,27 +1593,12 @@
// ── ESC schließt alle Modals ──
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (toySearchModal.classList.contains('open')) { closeToySearch(); return; }
if (publishModal.classList.contains('open')) { closePublishModal(); return; }
if (gruppeModal.classList.contains('open')) { closeGruppeModal(); return; }
if (itemModal.classList.contains('open')) { closeItemModal(); return; }
});
// ── Burger menu ──
const sidebar = document.getElementById('sidebar');
const burgerBtn = document.getElementById('burgerBtn');
const overlay = document.getElementById('overlay');
function openMenu() {
sidebar.classList.add('open'); overlay.classList.add('visible');
burgerBtn.classList.add('open'); burgerBtn.setAttribute('aria-label', 'Menü schließen');
}
function closeMenu() {
sidebar.classList.remove('open'); overlay.classList.remove('visible');
burgerBtn.classList.remove('open'); burgerBtn.setAttribute('aria-label', 'Menü öffnen');
}
burgerBtn.addEventListener('click', () => sidebar.classList.contains('open') ? closeMenu() : openMenu());
overlay.addEventListener('click', closeMenu);
sidebar.querySelectorAll('a').forEach(l => l.addEventListener('click', () => { if (window.innerWidth <= 768) closeMenu(); }));
</script>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,443 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profil XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.profile-view {
max-width: 480px;
margin: 0 auto;
text-align: center;
}
.profile-pic-large {
width: 120px;
height: 120px;
border-radius: 50%;
background: var(--color-secondary);
border: 3px solid var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: var(--color-muted);
margin: 0 auto 1.25rem;
overflow: hidden;
flex-shrink: 0;
}
.profile-pic-large img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-name {
font-size: 1.6rem;
font-weight: 700;
margin-bottom: 1.75rem;
color: var(--color-text);
}
.profile-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
flex-wrap: wrap;
}
.profile-actions button,
.profile-actions a.btn {
margin-top: 0;
width: auto;
padding: 0.65rem 1.5rem;
}
.btn-outline {
background: transparent;
border: 1px solid var(--color-secondary);
color: var(--color-muted);
padding: 0.65rem 1.5rem;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-outline:hover {
background: #3d0f1a;
border-color: var(--color-primary);
color: var(--color-primary);
}
.profile-pic-large img { cursor: zoom-in; }
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 500;
align-items: center;
justify-content: center;
}
.lightbox.open { display: flex; }
.lightbox img {
max-width: 90vw;
max-height: 90vh;
border-radius: 6px;
object-fit: contain;
box-shadow: 0 8px 40px rgba(0,0,0,0.7);
}
.lightbox-close {
position: fixed !important;
top: 1rem !important;
right: 1rem !important;
background: rgba(0,0,0,0.55) !important;
border: 1px solid rgba(255,255,255,0.2) !important;
color: #fff !important;
font-size: 1.1rem !important;
width: 2.2rem !important;
height: 2.2rem !important;
border-radius: 50% !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
margin: 0 !important;
line-height: 1 !important;
z-index: 501;
}
.lightbox-close:hover { background: rgba(0,0,0,0.85) !important; }
/* ── Profile Gallery ── */
.profile-gallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.4rem;
margin-top: 1.5rem;
}
.gallery-thumb {
aspect-ratio: 1;
overflow: hidden;
border-radius: 6px;
cursor: pointer;
background: var(--color-secondary);
}
.gallery-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.2s;
}
.gallery-thumb:hover img { transform: scale(1.04); }
/* ── Lightbox gallery extensions ── */
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0,0,0,0.45);
border: 1px solid rgba(255,255,255,0.15);
color: #fff;
font-size: 2rem;
line-height: 1;
width: 2.8rem;
height: 4rem;
border-radius: 6px;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
transition: background 0.15s;
z-index: 502;
}
.lightbox-nav:hover { background: rgba(0,0,0,0.8); }
#lbPrev { left: 1rem; }
#lbNext { right: 1rem; }
.lightbox-footer {
position: absolute;
bottom: 1rem;
left: 0;
right: 0;
display: none;
align-items: center;
justify-content: center;
gap: 1.5rem;
z-index: 502;
}
#lbCounter {
color: rgba(255,255,255,0.75);
font-size: 0.9rem;
}
.lightbox-like {
background: rgba(0,0,0,0.45);
border: 1px solid rgba(255,255,255,0.2);
color: #fff;
border-radius: 20px;
padding: 0.3rem 0.9rem;
font-size: 1rem;
cursor: pointer;
margin: 0;
transition: background 0.15s;
}
.lightbox-like:hover { background: rgba(180,30,30,0.7); }
.lightbox.gallery-mode .lightbox-nav,
.lightbox.gallery-mode .lightbox-footer { display: flex; }
#lbPrev:disabled, #lbNext:disabled { opacity: 0.25; cursor: default; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<p id="loadingHint" style="color:var(--color-muted);">Wird geladen…</p>
<div class="profile-view" id="profileView" style="display:none;">
<div class="profile-pic-large" id="profilePic"></div>
<div class="profile-name" id="profileName"></div>
<div class="profile-actions" id="profileActions"></div>
<div class="profile-gallery" id="profileGallery"></div>
</div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<img id="lightboxImg" src="" alt="" onclick="event.stopPropagation()">
<button class="lightbox-close" onclick="closeLightbox()" aria-label="Schließen"></button>
<button class="lightbox-nav" id="lbPrev" onclick="event.stopPropagation(); galleryPrev()">&#8249;</button>
<button class="lightbox-nav" id="lbNext" onclick="event.stopPropagation(); galleryNext()">&#8250;</button>
<div class="lightbox-footer" id="lbFooter" onclick="event.stopPropagation()">
<span id="lbCounter"></span>
<button class="lightbox-like" id="lbLike" onclick="toggleLike()">&#9825; <span id="lbLikeCount"></span></button>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script>
const params = new URLSearchParams(window.location.search);
const targetUserId = params.get('userId');
// Kein userId-Parameter → eigenes Profil laden
if (!targetUserId) {
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
if (user) window.location.replace('/benutzer.html?userId=' + user.userId);
else window.location.href = '/login.html';
});
} else {
Promise.all([
fetch('/login/me').then(r => r.ok ? r.json() : null),
fetch('/social/users/' + targetUserId).then(r => r.ok ? r.json() : null),
fetch('/social/profile-images?userId=' + targetUserId).then(r => r.ok ? r.json() : [])
]).then(([me, profile, images]) => {
document.getElementById('loadingHint').style.display = 'none';
if (!profile) {
document.getElementById('loadingHint').textContent = 'Profil nicht gefunden.';
document.getElementById('loadingHint').style.display = '';
return;
}
// Avatar HQ bevorzugt, Fallback auf kleine Version
const picData = profile.profilePictureHq || profile.profilePicture;
if (picData) {
document.getElementById('profilePic').innerHTML =
`<img src="data:image/png;base64,${picData}" alt="Profilbild" onclick="openLightbox(this.src)">`;
}
// Name + Seitentitel
document.getElementById('profileName').textContent = profile.name;
document.title = profile.name + ' XXX The Game';
// Aktions-Buttons
const actions = document.getElementById('profileActions');
const isOwnProfile = me && me.userId === profile.userId;
if (isOwnProfile) {
actions.innerHTML = `<a href="/profile.html" class="btn">Profil bearbeiten</a>`;
} else {
let html = '';
if (profile.friendStatus === 'FRIEND') {
html += `<a href="/nachrichten.html?userId=${profile.userId}" class="btn" style="background:var(--color-secondary);color:var(--color-text);">✉ Nachricht</a>`;
html += `<button class="btn-outline" id="friendActionBtn" onclick="removeFriend()">Freundschaft beenden</button>`;
} else if (profile.friendStatus === 'PENDING_SENT') {
html += `<button disabled>Anfrage gesendet</button>`;
html += `<a href="/nachrichten.html?userId=${profile.userId}" class="btn" style="background:var(--color-secondary);color:var(--color-text);">✉ Nachricht</a>`;
} else if (profile.friendStatus === 'PENDING_RECEIVED') {
html += `<button id="friendActionBtn" onclick="acceptFriend()">✓ Anfrage annehmen</button>`;
html += `<a href="/nachrichten.html?userId=${profile.userId}" class="btn" style="background:var(--color-secondary);color:var(--color-text);">✉ Nachricht</a>`;
} else {
html += `<button id="friendActionBtn" onclick="addFriend()">+ Freund hinzufügen</button>`;
html += `<a href="/nachrichten.html?userId=${profile.userId}" class="btn" style="background:var(--color-secondary);color:var(--color-text);">✉ Nachricht</a>`;
}
actions.innerHTML = html;
}
// Gallery
galleryImages = images;
galleryIsOwn = isOwnProfile;
renderProfileGallery();
document.getElementById('profileView').style.display = '';
}).catch(() => {
document.getElementById('loadingHint').textContent = 'Fehler beim Laden.';
document.getElementById('loadingHint').style.display = '';
});
}
async function addFriend() {
const btn = document.getElementById('friendActionBtn');
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/social/friends/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId: targetUserId })
});
btn.textContent = (res.ok || res.status === 201) ? 'Anfrage gesendet' : 'Fehler';
} catch {
btn.disabled = false;
btn.textContent = '+ Freund hinzufügen';
}
}
async function acceptFriend() {
const btn = document.getElementById('friendActionBtn');
btn.disabled = true;
btn.textContent = '…';
try {
const pending = await fetch('/social/friends/pending').then(r => r.json());
const f = pending.find(p => p.user.userId === targetUserId);
if (!f) { btn.textContent = 'Fehler'; return; }
const res = await fetch('/social/friends/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId: f.friendshipId })
});
if (res.ok) location.reload();
else { btn.disabled = false; btn.textContent = '✓ Anfrage annehmen'; }
} catch {
btn.disabled = false;
btn.textContent = '✓ Anfrage annehmen';
}
}
function openLightbox(src) {
document.getElementById('lightboxImg').src = src;
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open', 'gallery-mode');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') galleryPrev();
if (e.key === 'ArrowRight') galleryNext();
});
// ── Gallery ──
let galleryImages = [];
let galleryIndex = 0;
let galleryIsOwn = false;
function renderProfileGallery() {
const grid = document.getElementById('profileGallery');
grid.innerHTML = galleryImages.map((img, i) => `
<div class="gallery-thumb" onclick="openGallery(${i})">
<img src="data:image/jpeg;base64,${img.imageData}" alt="Galerie-Bild ${i+1}">
</div>
`).join('');
}
function openGallery(index) {
galleryIndex = index;
document.getElementById('lightbox').classList.add('open', 'gallery-mode');
showGalleryItem();
}
function showGalleryItem() {
const img = galleryImages[galleryIndex];
document.getElementById('lightboxImg').src = 'data:image/jpeg;base64,' + img.imageData;
document.getElementById('lbCounter').textContent = (galleryIndex + 1) + ' / ' + galleryImages.length;
document.getElementById('lbPrev').disabled = galleryIndex === 0;
document.getElementById('lbNext').disabled = galleryIndex === galleryImages.length - 1;
const likeBtn = document.getElementById('lbLike');
if (galleryIsOwn) {
likeBtn.style.display = 'none';
} else {
likeBtn.style.display = '';
likeBtn.innerHTML = (img.likedByMe ? '&#9829;' : '&#9825;') + ' <span id="lbLikeCount">' + img.likeCount + '</span>';
}
}
function galleryPrev() {
if (!document.getElementById('lightbox').classList.contains('gallery-mode')) return;
if (galleryIndex > 0) { galleryIndex--; showGalleryItem(); }
}
function galleryNext() {
if (!document.getElementById('lightbox').classList.contains('gallery-mode')) return;
if (galleryIndex < galleryImages.length - 1) { galleryIndex++; showGalleryItem(); }
}
async function toggleLike() {
const img = galleryImages[galleryIndex];
// Optimistic update
img.likedByMe = !img.likedByMe;
img.likeCount += img.likedByMe ? 1 : -1;
showGalleryItem();
try {
await fetch('/social/profile-images/' + img.imageId + '/like', { method: 'POST' });
} catch (e) {
// Revert on error
img.likedByMe = !img.likedByMe;
img.likeCount += img.likedByMe ? 1 : -1;
showGalleryItem();
}
}
async function removeFriend() {
const btn = document.getElementById('friendActionBtn');
btn.disabled = true;
btn.textContent = '…';
try {
const friends = await fetch('/social/friends').then(r => r.json());
const f = friends.find(f => f.user.userId === targetUserId);
if (!f) { btn.textContent = 'Fehler'; return; }
await fetch('/social/friends/reject', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId: f.friendshipId })
});
location.reload();
} catch {
btn.disabled = false;
btn.textContent = 'Freundschaft beenden';
}
}
</script>
</body>
</html>

View File

@@ -132,16 +132,278 @@ button.secondary:hover {
.message.error {
background: #3d0f1a;
border: 1px solid var(--color-primary);
border: 2px solid var(--color-primary);
color: var(--color-primary);
}
.message.warning {
background: #3a2c0a;
border: 2px solid #f5c518;
color: #f5c518;
}
.message.success {
background: #0f3d1a;
border: 1px solid var(--color-success);
color: var(--color-success);
}
/* ── App layout ── */
body.app {
display: block;
min-height: 100vh;
background: var(--color-bg);
padding: 1.5rem;
}
.app-wrapper {
max-width: 1400px;
margin: 0 auto;
display: flex;
gap: 1.5rem;
align-items: stretch;
min-height: calc(100vh - 3rem);
}
.main {
flex: 1;
min-width: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.content { padding: 2rem 1.5rem; flex: 1; }
/* ── Sidebar ── */
.sidebar {
width: 240px;
flex-shrink: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-self: flex-start;
position: sticky;
top: 1.5rem;
max-height: calc(100vh - 3rem);
overflow-y: auto;
z-index: 10;
transition: transform 0.25s ease;
}
.sidebar-icon-area {
display: flex;
justify-content: center;
padding: 1.25rem 1.25rem 0.75rem;
flex-shrink: 0;
}
.sidebar-icon-area a { display: block; line-height: 0; }
.sidebar-icon-area img {
width: 64px;
height: 64px;
object-fit: contain;
transition: transform 0.2s;
}
.sidebar-icon-area img:hover { transform: scale(1.1); }
.sidebar-mobile-only { display: none; }
.sidebar ul { list-style: none; padding: 0.5rem 0; }
.sidebar ul li a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1.25rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.95rem;
border-left: 3px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.sidebar ul li a:hover,
.sidebar ul li a.active {
background: var(--color-secondary);
color: var(--color-primary);
border-left-color: var(--color-primary);
}
.sidebar ul li a .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; }
.sidebar-profile-img {
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--color-secondary);
}
/* ── Burger (mobile only) ── */
.burger {
display: none;
position: fixed;
top: 0.75rem; right: 0.75rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 6px;
cursor: pointer;
color: var(--color-text);
padding: 0.35rem 0.5rem;
z-index: 110;
transition: background 0.15s;
}
.burger:hover { background: var(--color-secondary); }
.burger-icon { display: flex; flex-direction: column; gap: 5px; width: 22px; }
.burger-icon span {
display: block;
height: 2px;
background: var(--color-text);
border-radius: 2px;
transition: transform 0.25s, opacity 0.25s;
}
.burger.open .burger-icon span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.burger.open .burger-icon span:nth-child(2) { opacity: 0; }
.burger.open .burger-icon span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
/* ── Sidebar overlay ── */
.sidebar-overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 90;
}
.sidebar-overlay.visible { display: block; }
/* ── Mobile ── */
@media (max-width: 768px) {
body.app { padding: 0; }
.app-wrapper {
flex-direction: column;
gap: 0;
min-height: 100vh;
}
.sidebar {
position: fixed;
top: 0; right: 0;
width: 240px;
height: 100vh;
max-height: 100vh;
border-radius: 0;
border: none;
border-left: 1px solid var(--color-secondary);
box-shadow: none;
transform: translateX(100%);
align-self: auto;
z-index: 100;
}
.sidebar.open { transform: translateX(0); box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5); }
.sidebar-icon-area { display: none; }
.main {
border-radius: 0;
box-shadow: none;
border: none;
min-height: 100vh;
}
.burger { display: flex; }
.sidebar-mobile-only { display: block; }
}
/* ── Social Sidebar ── */
.social-sidebar {
width: 220px;
flex-shrink: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-self: flex-start;
position: sticky;
top: 1.5rem;
max-height: calc(100vh - 3rem);
overflow-y: auto;
}
.social-sidebar ul { list-style: none; padding: 0.5rem 0; }
.social-sidebar ul li a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1.25rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.95rem;
border-left: 3px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.social-sidebar ul li a:hover,
.social-sidebar ul li a.active {
background: var(--color-secondary);
color: var(--color-primary);
border-left-color: var(--color-primary);
}
.social-sidebar ul li a .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; }
.social-sidebar-title {
padding: 1rem 1.25rem 0.25rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-muted);
text-transform: uppercase;
flex-shrink: 0;
}
.social-badge {
margin-left: auto;
background: var(--color-primary);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.1rem 0.4rem;
line-height: 1.4;
flex-shrink: 0;
}
@media (max-width: 768px) {
.social-sidebar {
position: static;
width: 100%;
max-height: none;
border-radius: 0;
border: none;
border-top: 1px solid var(--color-secondary);
box-shadow: none;
align-self: auto;
}
}
/* ── Token box ── */
.token-box {
margin-top: 1.25rem;
@@ -161,3 +423,38 @@ button.secondary:hover {
color: #666;
margin-bottom: 0.4rem;
}
/* ── Sidebar groups ── */
.sidebar-group-toggle {
cursor: pointer;
justify-content: space-between;
}
.sidebar-arrow {
margin-left: auto;
font-size: 0.7rem;
flex-shrink: 0;
transition: transform 0.2s;
}
.sidebar-group.open > a .sidebar-arrow {
transform: rotate(90deg);
}
.sidebar-sub {
list-style: none;
padding: 0;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.sidebar-group.open > .sidebar-sub {
max-height: 500px;
}
.sidebar .sidebar-sub li a {
padding: 0.55rem 1.25rem 0.55rem 2.5rem;
font-size: 0.9rem;
color: var(--color-muted);
}

View File

@@ -7,70 +7,6 @@
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
body { display: block; min-height: 100vh; }
.layout { display: flex; min-height: 100vh; }
/* ── Sidebar ── */
.sidebar {
width: 240px; background: var(--color-card);
border-right: 1px solid var(--color-secondary);
display: flex; flex-direction: column;
position: fixed; top: 0; left: 0;
height: 100vh; overflow-y: auto;
z-index: 100; transition: transform 0.25s ease;
}
.sidebar-brand {
color: var(--color-primary); font-size: 1.1rem; font-weight: 700;
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid var(--color-secondary); flex-shrink: 0;
}
.sidebar ul { list-style: none; padding: 0.5rem 0; }
.sidebar ul li a {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.7rem 1.25rem; color: var(--color-text);
text-decoration: none; font-size: 0.95rem;
border-left: 3px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.sidebar ul li a:hover, .sidebar ul li a.active {
background: var(--color-secondary); color: var(--color-primary);
border-left-color: var(--color-primary);
}
.sidebar ul li a .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; }
/* ── Main ── */
.main { margin-left: 240px; flex: 1; display: flex; flex-direction: column; min-height: 100vh; }
/* ── Topbar ── */
.topbar {
display: flex; align-items: center; gap: 1rem;
padding: 0.9rem 1.5rem; background: var(--color-card);
border-bottom: 1px solid var(--color-secondary);
position: sticky; top: 0; z-index: 50;
}
.topbar h1 { font-size: 1.2rem; font-weight: 600; }
.burger {
display: none; background: none; border: none; cursor: pointer;
color: var(--color-text); padding: 0.25rem 0.4rem;
border-radius: 4px; transition: background 0.15s; flex-shrink: 0;
}
.burger:hover { background: var(--color-secondary); }
.burger-icon { display: flex; flex-direction: column; gap: 5px; width: 22px; }
.burger-icon span {
display: block; height: 2px; background: var(--color-text);
border-radius: 2px; transition: transform 0.25s, opacity 0.25s;
}
.burger.open .burger-icon span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.burger.open .burger-icon span:nth-child(2) { opacity: 0; }
.burger.open .burger-icon span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.content { padding: 2rem 1.5rem; flex: 1; }
.overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.55); z-index: 90;
}
/* ── Search ── */
.search-bar {
display: flex; gap: 0.6rem; margin-bottom: 1.5rem; align-items: center;
@@ -208,47 +144,12 @@
.item-detail-chip-toy { background: rgba(233,69,96,0.12); color: var(--color-primary); }
.sub-empty { font-size: 0.78rem; color: var(--color-muted); padding: 0.2rem 0; }
/* ── Mobile ── */
@media (max-width: 768px) {
.sidebar { transform: translateX(-100%); }
.sidebar.open { transform: translateX(0); box-shadow: 4px 0 20px rgba(0,0,0,0.5); }
.main { margin-left: 0; }
.burger { display: flex; }
.overlay.visible { display: block; }
}
</style>
</head>
<body>
<body class="app">
<div class="overlay" id="overlay"></div>
<div class="layout">
<aside class="sidebar" id="sidebar">
<div class="sidebar-brand">XXX The Game</div>
<ul>
<li><a href="/userhome.html"><span class="icon"></span> Dashboard</a></li>
<li><a href="#"><span class="icon"></span> Meine Session</a></li>
<li><a href="/aufgaben.html"><span class="icon"></span> Aufgaben</a></li>
<li><a href="/entdecken.html" class="active"><span class="icon"></span> Entdecken</a></li>
<li><a href="#"><span class="icon"></span> Strafen</a></li>
<li><a href="/toys.html"><span class="icon"></span> Toys</a></li>
<li><a href="#"><span class="icon"></span> Favoriten</a></li>
<li><a href="#"><span class="icon"></span> Rangliste</a></li>
<li><a href="#"><span class="icon"></span> Nachrichten</a></li>
<li><a href="#"><span class="icon"></span> Einstellungen</a></li>
<li><a href="#" id="logoutLink"><span class="icon"></span> Abmelden</a></li>
</ul>
</aside>
<div class="main">
<header class="topbar">
<button class="burger" id="burgerBtn" aria-label="Menü öffnen">
<span class="burger-icon"><span></span><span></span><span></span></span>
</button>
<h1>Entdecken</h1>
</header>
<div class="content">
<div class="main">
<div class="content">
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Gruppenname suchen…" maxlength="200">
<button class="btn-search" id="searchBtn">Suchen</button>
@@ -260,10 +161,10 @@
<span class="page-info" id="pageInfo"></span>
<button id="nextBtn">Weiter </button>
</div>
</div>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script>
const PAGE_SIZE = 10;
let currentPage = 0, totalPages = 1;
@@ -281,12 +182,6 @@
.then(user => { if (!user) return; loadGroups(); })
.catch(() => { window.location.href = '/login.html'; });
document.getElementById('logoutLink').addEventListener('click', e => {
e.preventDefault();
document.cookie = 'jwt=; Max-Age=0; path=/';
window.location.href = '/login.html';
});
// ── Load ──
function loadGroups() {
document.getElementById('loading').style.display = 'block';
@@ -583,21 +478,6 @@
if (currentPage < totalPages - 1) { currentPage++; loadGroups(); }
});
// ── Burger menu ──
const sidebar = document.getElementById('sidebar');
const burgerBtn = document.getElementById('burgerBtn');
const overlay = document.getElementById('overlay');
function openMenu() {
sidebar.classList.add('open'); overlay.classList.add('visible');
burgerBtn.classList.add('open'); burgerBtn.setAttribute('aria-label', 'Menü schließen');
}
function closeMenu() {
sidebar.classList.remove('open'); overlay.classList.remove('visible');
burgerBtn.classList.remove('open'); burgerBtn.setAttribute('aria-label', 'Menü öffnen');
}
burgerBtn.addEventListener('click', () => sidebar.classList.contains('open') ? closeMenu() : openMenu());
overlay.addEventListener('click', closeMenu);
sidebar.querySelectorAll('a').forEach(l => l.addEventListener('click', () => { if (window.innerWidth <= 768) closeMenu(); }));
</script>
</body>
</html>

View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>xXx Games Passwort vergessen</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100;
align-items: center;
justify-content: center;
}
.overlay.active {
display: flex;
}
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
max-width: 340px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
text-align: center;
}
.modal p {
color: var(--color-text);
font-size: 0.95rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="card">
<img src="icon.png" alt="Logo">
<h1>Passwort vergessen</h1>
<p class="subtitle">Gib deine E-Mail-Adresse ein. Falls sie bei uns registriert ist, erhältst du einen Link zum Zurücksetzen.</p>
<label for="email">E-Mail</label>
<input type="email" id="email" placeholder="deine@email.de" autocomplete="email" />
<button class="full-width" id="submitBtn" onclick="submit()">Link anfordern</button>
<div class="message" id="message"></div>
<p style="text-align:center; margin-top:1.25rem; font-size:0.85rem;">
<a href="/login.html" style="color:var(--color-primary);">Zurück zum Login</a>
</p>
</div>
<div class="overlay" id="overlay">
<div class="modal">
<p>Falls diese E-Mail-Adresse bei uns registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen deines Passworts.</p>
<button class="full-width" onclick="goToLogin()">Zum Login</button>
</div>
</div>
<script>
document.addEventListener('keydown', e => {
if (e.key === 'Enter') submit();
});
async function submit() {
const email = document.getElementById('email').value.trim();
const btn = document.getElementById('submitBtn');
if (!email) {
showMessage('Bitte E-Mail-Adresse eingeben.', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
hideMessage();
try {
await fetch('/password-reset/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
} catch (err) {
console.error(err);
}
document.getElementById('overlay').classList.add('active');
}
function goToLogin() {
window.location.href = '/login.html';
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Freunde XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.tabs {
display: flex;
gap: 0;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--color-secondary);
}
.tab-btn {
background: none;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
padding: 0.6rem 1.25rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-muted);
cursor: pointer;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
}
.tab-btn:hover { color: var(--color-text); background: none; }
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.user-list { list-style: none; margin: 0; padding: 0; }
.user-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-secondary);
}
.user-item:last-child { border-bottom: none; }
.user-avatar {
width: 42px; height: 42px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid var(--color-secondary);
}
.user-avatar img { width: 100%; height: 100%; object-fit: cover; }
.user-name { font-weight: 600; flex: 1; }
.user-actions { display: flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.user-actions button, .user-actions a.btn {
margin-top: 0;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
width: auto;
}
.user-profile-link {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
text-decoration: none;
color: inherit;
}
.user-profile-link:hover .user-name { color: var(--color-primary); }
.btn-reject {
background: transparent;
border: 1px solid var(--color-secondary);
color: var(--color-muted);
font-size: 0.8rem;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-reject:hover { background: #3d0f1a; border-color: var(--color-primary); color: var(--color-primary); }
.empty-hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.5rem; }
.tab-badge {
display: inline-block;
background: var(--color-primary);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.05rem 0.35rem;
margin-left: 0.35rem;
vertical-align: middle;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom: 1.25rem;">Freunde</h1>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('friends', this)">Freunde</button>
<button class="tab-btn" onclick="switchTab('pending', this)" id="pendingTabBtn">
Anfragen<span class="tab-badge" id="pendingBadge" style="display:none;"></span>
</button>
</div>
<!-- Friends tab -->
<div class="tab-panel active" id="tab-friends">
<ul class="user-list" id="friendsList"></ul>
<p class="empty-hint" id="friendsEmpty" style="display:none;">Du hast noch keine Freunde. <a href="/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></p>
</div>
<!-- Pending tab -->
<div class="tab-panel" id="tab-pending">
<ul class="user-list" id="pendingList"></ul>
<p class="empty-hint" id="pendingEmpty" style="display:none;">Keine offenen Anfragen.</p>
</div>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
function switchTab(name, btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function avatar(u) {
return u.profilePicture
? `<img src="data:image/png;base64,${u.profilePicture}" alt="">`
: '◉';
}
async function loadFriends() {
try {
const res = await fetch('/social/friends');
if (!res.ok) return;
const friends = await res.json();
const list = document.getElementById('friendsList');
list.innerHTML = '';
if (friends.length === 0) {
document.getElementById('friendsEmpty').style.display = '';
return;
}
document.getElementById('friendsEmpty').style.display = 'none';
friends.forEach(f => {
list.insertAdjacentHTML('beforeend', `
<li class="user-item" id="friend-${f.friendshipId}">
<a href="/benutzer.html?userId=${f.user.userId}" class="user-profile-link">
<div class="user-avatar">${avatar(f.user)}</div>
<div class="user-name">${esc(f.user.name)}</div>
</a>
<div class="user-actions">
<a href="/nachrichten.html?userId=${f.user.userId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>
<button class="btn-reject" onclick="removeFriend('${f.friendshipId}', this)">Entfernen</button>
</div>
</li>`);
});
} catch (e) { console.error(e); }
}
async function loadPending() {
try {
const res = await fetch('/social/friends/pending');
if (!res.ok) return;
const pending = await res.json();
const list = document.getElementById('pendingList');
list.innerHTML = '';
const badge = document.getElementById('pendingBadge');
if (pending.length > 0) {
badge.textContent = pending.length;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
if (pending.length === 0) {
document.getElementById('pendingEmpty').style.display = '';
return;
}
document.getElementById('pendingEmpty').style.display = 'none';
pending.forEach(f => {
list.insertAdjacentHTML('beforeend', `
<li class="user-item" id="pending-${f.friendshipId}">
<a href="/benutzer.html?userId=${f.user.userId}" class="user-profile-link">
<div class="user-avatar">${avatar(f.user)}</div>
<div class="user-name">${esc(f.user.name)}</div>
</a>
<div class="user-actions">
<button onclick="accept('${f.friendshipId}', this)">✓ Annehmen</button>
<button class="btn-reject" onclick="reject('${f.friendshipId}', this)">✕ Ablehnen</button>
</div>
</li>`);
});
} catch (e) { console.error(e); }
}
async function accept(friendshipId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/social/friends/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId })
});
if (res.ok) {
document.getElementById('pending-' + friendshipId)?.remove();
await loadFriends();
await loadPending();
} else {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
} catch (e) {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
}
async function reject(friendshipId, btn) {
btn.disabled = true;
try {
await fetch('/social/friends/reject', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId })
});
document.getElementById('pending-' + friendshipId)?.remove();
await loadPending();
} catch (e) {
btn.disabled = false;
}
}
async function removeFriend(friendshipId, btn) {
btn.disabled = true;
try {
await fetch('/social/friends/reject', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId })
});
document.getElementById('friend-' + friendshipId)?.remove();
const list = document.getElementById('friendsList');
if (list.children.length === 0) {
document.getElementById('friendsEmpty').style.display = '';
}
} catch (e) {
btn.disabled = false;
}
}
loadFriends();
loadPending();
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -3,14 +3,16 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XXX The Game</title>
<title>xXx Games</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<link rel="icon" type="image/png" href="icon.png">
</head>
<body>
<h1>XXX The Game</h1>
<p>Das Erwachsenenspiel für Paare und Gruppen</p>
<div style="display:flex; gap:1rem;">
<img src="logo.png" alt="Logo">
<h1>Erwachsenenspiele</h1>
<p>Alleine, als Paar oder mehr...</p>
<div id="authButtons" style="display:flex; gap:1rem;">
<a class="btn" href="/login.html">Anmelden</a>
<a class="btn" href="/registration.html" style="background:#0f3460;">Registrieren</a>
</div>
@@ -22,6 +24,18 @@
</button>
<script>
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
if (!user) return;
const btns = document.getElementById('authButtons');
btns.insertAdjacentHTML('beforebegin',
`<p style="text-align:center;">Willkommen zurück, <strong style="color:var(--color-text);">${user.name}</strong></p>`
);
btns.innerHTML = '<a class="btn" href="/userhome.html">Zu den Games</a>';
})
.catch(() => {});
async function fillDb() {
const btn = event.currentTarget;
btn.disabled = true;

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BDSM Game Info 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">
<h1>BDSM Game</h1>
<p>Informationen zum BDSM Game folgen hier.</p>
</div>
</div>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chastity Game Info 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">
<h1>Chastity Game</h1>
<p>Informationen zum Chastity Game folgen hier.</p>
</div>
</div>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vanilla Game Info 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">
<h1>Vanilla Game</h1>
<p>Informationen zum Vanilla Game folgen hier.</p>
</div>
</div>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,146 @@
(function () {
const path = window.location.pathname;
const groups = [
{
label: 'Vanilla Game',
icon: '♡',
items: [
{ href: '/infovanilla.html', icon: '', label: 'Info' },
{ href: '/sessionvanilla.html', icon: '▷', label: 'Neue Session' },
]
},
{
label: 'BDSM Game',
icon: '◆',
items: [
{ href: '/infobdsm.html', icon: '', label: 'Info' },
{ href: '/sessionbdsm.html', icon: '▷', label: 'Neue Session', id: 'navBdsmNeu' },
{ href: '/sessionbdsmingame.html', icon: '▶', label: 'Im Spiel', id: 'navBdsmImSpiel' },
{ href: '/aufgaben.html', icon: '✓', label: 'Aufgaben' },
{ href: '/toys.html', icon: '◈', label: 'Toys' },
{ href: '/entdecken.html', icon: '⊙', label: 'Entdecken' },
]
},
{
label: 'Chastity Game',
icon: '⊗',
items: [
{ href: '/infochastity.html', icon: '', label: 'Info' },
{ href: '/sessionchastity.html', icon: '▷', label: 'Neue Session' },
]
},
];
const homeCls = path === '/userhome.html' ? ' class="active"' : '';
const homeItem = `
<li class="sidebar-mobile-only">
<a href="/userhome.html"${homeCls}><span class="icon">⊞</span> Home</a>
</li>`;
const nav = groups.map(({ label, icon, items }) => {
const isOpen = items.some(item => item.href === path);
const openCls = isOpen ? ' open' : '';
const subItems = items.map(({ href, icon: iIcon, label: iLabel, id: iId }) => {
const cls = path === href ? ' class="active"' : '';
const idAt = iId ? ` id="${iId}"` : '';
return `<li${idAt}><a href="${href}"${cls}><span class="icon">${iIcon}</span> ${iLabel}</a></li>`;
}).join('');
return `
<li class="sidebar-group${openCls}">
<a class="sidebar-group-toggle"><span class="icon">${icon}</span> ${label}<span class="sidebar-arrow">▸</span></a>
<ul class="sidebar-sub">
${subItems}
</ul>
</li>`;
}).join('');
document.body.insertAdjacentHTML('afterbegin', `
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<button class="burger" id="burgerBtn" aria-label="Menü öffnen">
<span class="burger-icon"><span></span><span></span><span></span></span>
</button>
<aside class="sidebar" id="sidebar">
<div class="sidebar-icon-area">
<a href="/userhome.html"><img src="/icon.png" alt="Home"></a>
</div>
<ul>
${homeItem}
${nav}
<li><hr style="border:none; border-top:1px solid var(--color-secondary); margin:0.4rem 1rem;"></li>
<li>
<a href="/login/logout"><span class="icon">⏏</span> Abmelden</a>
</li>
</ul>
</aside>
`);
// Sidebar und .main in einen zentrierten App-Wrapper verschieben
const appWrapper = document.createElement('div');
appWrapper.className = 'app-wrapper';
const sidebarEl = document.getElementById('sidebar');
const mainEl = document.querySelector('.main');
document.body.insertBefore(appWrapper, sidebarEl);
appWrapper.appendChild(sidebarEl);
if (mainEl) appWrapper.appendChild(mainEl);
// Group toggle
document.querySelectorAll('.sidebar-group-toggle').forEach(toggle => {
toggle.addEventListener('click', e => {
e.preventDefault();
toggle.closest('.sidebar-group').classList.toggle('open');
});
});
// "Im Spiel" standardmäßig ausblenden; wird nach Session-Check ggf. wieder eingeblendet
const navNeu = document.getElementById('navBdsmNeu');
const navImSpiel = document.getElementById('navBdsmImSpiel');
if (navImSpiel) navImSpiel.style.display = 'none';
// Session-Status prüfen
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(async user => {
if (!user) return;
// Session-Status prüfen und Menü anpassen
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 */ }
})
.catch(() => {});
const sidebar = document.getElementById('sidebar');
const burgerBtn = document.getElementById('burgerBtn');
const overlay = document.getElementById('sidebarOverlay');
function openMenu() {
sidebar.classList.add('open');
overlay.classList.add('visible');
burgerBtn.classList.add('open');
burgerBtn.setAttribute('aria-label', 'Menü schließen');
}
function closeMenu() {
sidebar.classList.remove('open');
overlay.classList.remove('visible');
burgerBtn.classList.remove('open');
burgerBtn.setAttribute('aria-label', 'Menü öffnen');
}
burgerBtn.addEventListener('click', () =>
sidebar.classList.contains('open') ? closeMenu() : openMenu()
);
overlay.addEventListener('click', closeMenu);
sidebar.querySelectorAll('a:not([href="/login/logout"]):not(.sidebar-group-toggle)').forEach(l =>
l.addEventListener('click', () => { if (window.innerWidth <= 768) closeMenu(); })
);
// Social sidebar auf allen App-Seiten nachladen
const s = document.createElement('script');
s.src = '/js/social-sidebar.js';
document.head.appendChild(s);
})();

View File

@@ -0,0 +1,111 @@
(function () {
// Verhindert doppelte Ausführung (z.B. wenn sidebar.js nachladen UND direktes <script>-Tag vorhanden)
if (document.querySelector('.social-sidebar')) return;
const path = window.location.pathname;
const links = [
{ href: '/personen-suchen.html', icon: '⊕', label: 'Personen suchen', badgeId: null, mobileBadgeId: null },
{ href: '/freunde.html', icon: '♡', label: 'Freunde', badgeId: 'socialFriendsBadge', mobileBadgeId: 'socialMobileFriendsBadge' },
{ href: '/nachrichten.html', icon: '✉', label: 'Nachrichten', badgeId: 'socialMsgBadge', mobileBadgeId: 'socialMobileMsgBadge' },
];
const profileActive = (path === '/benutzer.html' || path === '/profile.html') ? ' class="active"' : '';
// ── Rechte Desktop-Sidebar (kein Titel) ──
const desktopItems = links.map(({ href, icon, label, badgeId }) => {
const cls = path === href ? ' class="active"' : '';
const badge = badgeId ? `<span class="social-badge" id="${badgeId}" style="display:none;"></span>` : '';
return `<li><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}${badge}</a></li>`;
}).join('');
const aside = document.createElement('aside');
aside.className = 'social-sidebar';
aside.innerHTML = `
<ul>
<li id="socialProfileItem">
<a href="/benutzer.html"${profileActive}>
<span class="icon" id="socialProfileIcon">◉</span>
<span id="socialProfileName">Profil</span>
</a>
</li>
<li><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>
${desktopItems}
</ul>`;
const appWrapper = document.querySelector('.app-wrapper');
if (appWrapper) appWrapper.appendChild(aside);
// ── Mobile: Links + Profil ins Burger-Menü einhängen ──
const sidebarUl = document.querySelector('.sidebar ul');
if (sidebarUl) {
const mobileLinks = links.map(({ href, icon, label, mobileBadgeId }) => {
const cls = path === href ? ' class="active"' : '';
const badge = mobileBadgeId
? `<span class="social-badge" id="${mobileBadgeId}" style="display:none;"></span>`
: '';
return `<li class="sidebar-mobile-only"><a href="${href}"${cls}><span class="icon">${icon}</span> ${label}${badge}</a></li>`;
}).join('');
const mobileProfileActive = profileActive;
const mobileProfile = `
<li class="sidebar-mobile-only" id="socialMobileProfileItem">
<a href="/benutzer.html"${mobileProfileActive}>
<span class="icon" id="socialMobileProfileIcon">◉</span>
<span id="socialMobileProfileName">Profil</span>
</a>
</li>`;
const sep = '<li class="sidebar-mobile-only"><hr style="border:none;border-top:1px solid var(--color-secondary);margin:0.4rem 1rem;"></li>';
const logoutLi = sidebarUl.querySelector('a[href="/login/logout"]')?.closest('li');
if (logoutLi) {
logoutLi.insertAdjacentHTML('beforebegin', sep + mobileLinks + mobileProfile);
} else {
sidebarUl.insertAdjacentHTML('beforeend', sep + mobileLinks + mobileProfile);
}
}
// ── Profil-Daten nachladen (Name + Avatar) ──
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
if (!user) return;
function updateProfileEntry(nameId, iconId, itemId) {
const nameEl = document.getElementById(nameId);
if (nameEl) nameEl.textContent = user.name;
const iconEl = document.getElementById(iconId);
if (iconEl && user.profilePicture) {
iconEl.innerHTML = `<img src="data:image/png;base64,${user.profilePicture}" class="sidebar-profile-img" alt="">`;
}
const anchor = document.querySelector('#' + itemId + ' a');
if (anchor) anchor.href = '/benutzer.html?userId=' + user.userId;
}
updateProfileEntry('socialProfileName', 'socialProfileIcon', 'socialProfileItem');
updateProfileEntry('socialMobileProfileName', 'socialMobileProfileIcon', 'socialMobileProfileItem');
})
.catch(() => {});
// ── Badge-Zähler ──
function setBadge(ids, count) {
ids.forEach(id => {
if (!id) return;
const el = document.getElementById(id);
if (!el) return;
el.textContent = count;
el.style.display = count > 0 ? '' : 'none';
});
}
fetch('/social/friends/pending/count')
.then(r => r.ok ? r.json() : 0)
.then(n => setBadge(['socialFriendsBadge', 'socialMobileFriendsBadge'], n))
.catch(() => {});
fetch('/social/messages/unread/count')
.then(r => r.ok ? r.json() : 0)
.then(n => setBadge(['socialMsgBadge', 'socialMobileMsgBadge'], n))
.catch(() => {});
})();

View File

@@ -3,14 +3,14 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XXX The Game Login</title>
<title>xXx Games Login</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="card">
<h1>XXX The Game</h1>
<p class="subtitle">Bitte melde dich an</p>
<img src="icon.png" alt="Logo">
<h1>Bitte melde dich an</h1>
<label for="email">E-Mail</label>
<input type="email" id="email" placeholder="deine@email.de" autocomplete="username" />
@@ -21,6 +21,13 @@
<button class="full-width" id="loginBtn" onclick="login()">Anmelden</button>
<div class="message" id="message"></div>
<p style="text-align:center; margin-top:1.25rem; font-size:0.85rem;">
<a href="/forgot-password.html" style="color:var(--color-primary);">Passwort vergessen?</a>
</p>
<p style="text-align:center; margin-top:0.5rem; font-size:0.85rem;">
Noch kein Konto? <a href="/registration.html" style="color:var(--color-primary);">Registrieren</a>
</p>
</div>
<script>
@@ -31,6 +38,10 @@
document.getElementById('email').value = email;
document.getElementById('password').focus();
showMessage('E-Mail-Adresse bestätigt! Du kannst dich jetzt anmelden.', 'success');
} else if (params.get('emailChanged')) {
showMessage('E-Mail-Adresse erfolgreich geändert. Bitte melde dich mit deiner neuen Adresse an.', 'success');
} else if (params.get('accountDeleted')) {
showMessage('Dein Konto wurde gelöscht.', 'success');
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

View File

@@ -0,0 +1,593 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nachrichten XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* Override .main and .content for full-height chat layout */
.main { overflow: hidden; }
.msg-layout {
display: flex;
height: calc(100vh - 3rem);
overflow: hidden;
}
/* Left: conversation list */
.conv-list-pane {
width: 260px;
flex-shrink: 0;
border-right: 1px solid var(--color-secondary);
display: flex;
flex-direction: column;
overflow: hidden;
}
.conv-list-header {
padding: 1.25rem 1rem 0.75rem;
font-weight: 700;
font-size: 1rem;
border-bottom: 1px solid var(--color-secondary);
flex-shrink: 0;
}
.conv-list {
flex: 1;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 0.25rem 0;
}
.conv-item {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.7rem 1rem;
cursor: pointer;
border-left: 3px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.conv-item:hover { background: var(--color-secondary); }
.conv-item.active { background: var(--color-secondary); border-left-color: var(--color-primary); }
.conv-avatar {
width: 38px; height: 38px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.1);
}
.conv-avatar img { width: 100%; height: 100%; object-fit: cover; }
.conv-info { flex: 1; min-width: 0; }
.conv-name { font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.conv-preview { font-size: 0.78rem; color: var(--color-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.conv-unread {
background: var(--color-primary);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
border-radius: 9999px;
padding: 0.1rem 0.35rem;
flex-shrink: 0;
}
/* Right: thread */
.thread-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.thread-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-secondary);
font-weight: 700;
font-size: 1rem;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.thread-back {
display: none;
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
font-size: 1.1rem;
padding: 0;
margin: 0;
}
.thread-messages {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.thread-placeholder {
color: var(--color-muted);
text-align: center;
margin-top: 3rem;
font-size: 0.95rem;
}
.bubble-wrap {
display: flex;
flex-direction: column;
}
.bubble-wrap.me { align-items: flex-end; }
.bubble-wrap.them { align-items: flex-start; }
.bubble {
max-width: 70%;
padding: 0.5rem 0.9rem;
border-radius: 14px;
font-size: 0.9rem;
line-height: 1.45;
word-break: break-word;
}
.bubble-wrap.me .bubble {
background: var(--color-primary);
color: #fff;
border-bottom-right-radius: 4px;
}
.bubble-wrap.them .bubble {
background: var(--color-secondary);
color: var(--color-text);
border-bottom-left-radius: 4px;
}
.bubble-time {
font-size: 0.7rem;
color: var(--color-muted);
margin-top: 0.15rem;
padding: 0 0.25rem;
}
.thread-input-wrap {
flex-shrink: 0;
position: relative;
}
.thread-input-area {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.65rem 1rem;
border-top: 1px solid var(--color-secondary);
}
.thread-input-area input {
flex: 1;
}
.btn-icon {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 0.3rem 0.4rem;
width: auto;
margin-top: 0;
flex-shrink: 0;
opacity: 0.65;
border-radius: 6px;
transition: opacity 0.15s, background 0.15s;
line-height: 1;
}
.btn-icon:hover { opacity: 1; background: var(--color-secondary); }
.btn-send {
width: auto;
margin-top: 0;
padding: 0.55rem 1rem;
flex-shrink: 0;
}
.emoji-picker {
position: absolute;
bottom: 100%;
left: 0; right: 0;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px 10px 0 0;
padding: 0.5rem;
display: none;
flex-wrap: wrap;
gap: 2px;
max-height: 160px;
overflow-y: auto;
box-shadow: 0 -4px 16px rgba(0,0,0,0.4);
z-index: 50;
}
.emoji-picker.open { display: flex; }
.emoji-picker button {
background: none;
border: none;
font-size: 1.3rem;
cursor: pointer;
padding: 0.2rem 0.3rem;
border-radius: 4px;
width: auto;
margin-top: 0;
line-height: 1;
}
.emoji-picker button:hover { background: var(--color-secondary); }
.bubble-img {
max-width: 220px;
max-height: 220px;
border-radius: 8px;
display: block;
cursor: zoom-in;
object-fit: contain;
}
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 500;
align-items: center;
justify-content: center;
}
.lightbox.open { display: flex; }
.lightbox img {
max-width: 90vw;
max-height: 90vh;
border-radius: 6px;
object-fit: contain;
box-shadow: 0 8px 40px rgba(0,0,0,0.7);
}
.lightbox-close {
position: fixed !important;
top: 1rem !important;
right: 1rem !important;
background: rgba(0,0,0,0.55) !important;
border: 1px solid rgba(255,255,255,0.2) !important;
color: #fff !important;
font-size: 1.1rem !important;
width: 2.2rem !important;
height: 2.2rem !important;
border-radius: 50% !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
margin: 0 !important;
line-height: 1 !important;
transition: background 0.15s !important;
z-index: 501;
}
.lightbox-close:hover { background: rgba(0,0,0,0.85) !important; }
/* Mobile */
@media (max-width: 768px) {
.msg-layout { height: auto; flex-direction: column; }
.conv-list-pane { width: 100%; border-right: none; border-bottom: 1px solid var(--color-secondary); max-height: 240px; }
.conv-list-pane.hidden { display: none; }
.thread-pane.hidden { display: none; }
.thread-back { display: block; }
}
</style>
</head>
<body class="app">
<div class="main">
<div class="msg-layout">
<!-- Left pane: conversation list -->
<div class="conv-list-pane" id="convListPane">
<div class="conv-list-header">Nachrichten</div>
<ul class="conv-list" id="convList">
<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Wird geladen…</li>
</ul>
</div>
<!-- Right pane: thread -->
<div class="thread-pane" id="threadPane">
<div class="thread-header" id="threadHeader">
<button class="thread-back" id="backBtn" onclick="showList()" aria-label="Zurück"></button>
<div class="conv-avatar" id="threadPartnerAvatar" style="display:none;"></div>
<span id="threadPartnerName">Konversation auswählen</span>
</div>
<div class="thread-messages" id="threadMessages">
<div class="thread-placeholder" id="threadPlaceholder">Wähle eine Konversation aus oder schreibe jemanden direkt an.</div>
</div>
<div class="thread-input-wrap" id="threadInputWrap" style="display:none;">
<div class="emoji-picker" id="emojiPicker"></div>
<div class="thread-input-area">
<button class="btn-icon" id="emojiBtn" onclick="toggleEmoji()" title="Emoji">😊</button>
<input type="text" id="msgInput" placeholder="Nachricht eingeben…" autocomplete="off">
<input type="file" id="imgFile" accept="image/*" style="display:none;">
<button class="btn-icon" onclick="document.getElementById('imgFile').click()" title="Bild senden">📷</button>
<button class="btn-send" onclick="sendMsg()"></button>
</div>
</div>
</div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<img id="lightboxImg" src="" alt="" onclick="event.stopPropagation()">
<button class="lightbox-close" onclick="closeLightbox()" aria-label="Schließen"></button>
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
let myId = null;
let activePartnerId = null;
let pollTimer = null;
// Load current user
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
if (!user) return;
myId = user.userId;
loadConversations();
// Open thread from URL param
const urlPartnerId = new URLSearchParams(window.location.search).get('userId');
if (urlPartnerId) openThread(urlPartnerId);
})
.catch(() => {});
async function loadConversations() {
try {
const res = await fetch('/social/messages');
if (!res.ok) return;
const convs = await res.json();
renderConvList(convs);
} catch (e) { console.error(e); }
}
function renderConvList(convs) {
const list = document.getElementById('convList');
list.innerHTML = '';
if (convs.length === 0) {
list.innerHTML = '<li style="padding:1rem; color:var(--color-muted); font-size:0.9rem;">Noch keine Nachrichten. <a href="/personen-suchen.html" style="color:var(--color-primary);">Personen suchen</a></li>';
return;
}
convs.forEach(c => {
const av = c.partner.profilePicture
? `<img src="data:image/png;base64,${c.partner.profilePicture}" alt="" style="cursor:zoom-in;" onclick="event.stopPropagation();openLightbox(this.src)">`
: '◉';
const unreadHtml = c.unreadCount > 0
? `<span class="conv-unread">${c.unreadCount}</span>`
: '';
const preview = c.lastMessage
? (c.lastMessage.text.startsWith('data:image/') ? '📷 Bild' : esc(c.lastMessage.text.substring(0, 40)))
: '';
const li = document.createElement('li');
li.className = 'conv-item' + (c.partner.userId === activePartnerId ? ' active' : '');
li.dataset.partnerId = c.partner.userId;
li.innerHTML = `
<div class="conv-avatar">${av}</div>
<div class="conv-info">
<div class="conv-name">${esc(c.partner.name)}</div>
<div class="conv-preview">${preview}</div>
</div>
${unreadHtml}`;
li.addEventListener('click', () => openThread(c.partner.userId, c.partner.name, c.partner.profilePicture));
list.appendChild(li);
});
}
async function openThread(partnerId, partnerName, partnerPic) {
activePartnerId = partnerId;
// Update active state in list
document.querySelectorAll('.conv-item').forEach(li => {
li.classList.toggle('active', li.dataset.partnerId === partnerId);
});
// Update header if name not provided, fetch from conversation list or user lookup
if (!partnerName) {
const convItem = document.querySelector(`.conv-item[data-partner-id="${partnerId}"]`);
partnerName = convItem ? convItem.querySelector('.conv-name').textContent : '…';
}
document.getElementById('threadPartnerName').innerHTML =
`<a href="/benutzer.html?userId=${partnerId}" style="color:inherit;text-decoration:none;">${esc(partnerName)}</a>`;
const avatarEl = document.getElementById('threadPartnerAvatar');
if (partnerPic) {
avatarEl.innerHTML = `<img src="data:image/png;base64,${partnerPic}" alt="" style="cursor:zoom-in;" onclick="openLightbox(this.src)">`;
avatarEl.style.display = '';
} else {
avatarEl.style.display = 'none';
}
document.getElementById('threadInputWrap').style.display = '';
document.getElementById('msgInput').focus();
// Mobile: hide list, show thread
if (window.innerWidth <= 768) showThread();
await loadThread();
startPolling();
}
async function loadThread() {
if (!activePartnerId) return;
try {
const res = await fetch('/social/messages/' + activePartnerId);
if (!res.ok) return;
const msgs = await res.json();
renderThread(msgs);
loadConversations(); // refresh unread counts in list
} catch (e) { console.error(e); }
}
function renderThread(msgs) {
const container = document.getElementById('threadMessages');
container.innerHTML = '';
if (msgs.length === 0) {
container.appendChild(Object.assign(document.createElement('div'), {
className: 'thread-placeholder',
textContent: 'Noch keine Nachrichten. Schreib als Erster!'
}));
return;
}
// msgs are newest-first; reverse to show oldest first
[...msgs].reverse().forEach(m => {
const isMe = m.senderId === myId;
const time = new Date(m.sentAt).toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
const wrap = document.createElement('div');
wrap.className = 'bubble-wrap ' + (isMe ? 'me' : 'them');
const isImg = m.text.startsWith('data:image/');
const content = isImg
? `<img src="${m.text}" class="bubble-img" onclick="openLightbox(this.src)" alt="Bild">`
: esc(m.text);
wrap.innerHTML = `
<div class="bubble">${content}</div>
<div class="bubble-time">${time}</div>`;
container.appendChild(wrap);
});
container.scrollTop = container.scrollHeight;
}
async function sendMsg() {
if (!activePartnerId) return;
const input = document.getElementById('msgInput');
const text = input.value.trim();
if (!text) return;
input.value = '';
try {
await fetch('/social/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId: activePartnerId, text })
});
await loadThread();
} catch (e) { console.error(e); }
}
document.getElementById('msgInput').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMsg(); }
});
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(loadThread, 10000);
}
// Mobile: show/hide panes
function showThread() {
document.getElementById('convListPane').classList.add('hidden');
document.getElementById('threadPane').classList.remove('hidden');
}
function showList() {
document.getElementById('convListPane').classList.remove('hidden');
document.getElementById('threadPane').classList.add('hidden');
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
activePartnerId = null;
}
// ── Lightbox ──
function openLightbox(src) {
document.getElementById('lightboxImg').src = src;
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeLightbox();
});
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ── Emoji-Picker ──
const EMOJIS = [
'😀','😂','🤣','😅','😊','😍','🥰','😘','😎','🤩',
'😉','🙂','😐','😏','😒','😢','😭','😤','😡','🤬',
'🤔','🥺','😳','🤭','😈','💀','🙈','🙉','🙊','😴',
'👍','👎','👏','🙏','💪','🫶','🤗','🫠','✌️','🤞',
'❤️','🧡','💛','💚','💙','💜','🖤','💕','💖','💘',
'🔥','✨','⚡','🌟','💯','🎉','🎊','🎀','🌹','🌸',
'🍕','🍔','🍟','🌮','🍜','🍣','🍩','🍪','☕','🍷',
'🐱','🐶','🦊','🐼','🐨','🐸','🦄','🐝','🦋','🐬',
];
(function initEmojis() {
const picker = document.getElementById('emojiPicker');
EMOJIS.forEach(emoji => {
const btn = document.createElement('button');
btn.textContent = emoji;
btn.title = emoji;
btn.onclick = () => {
const input = document.getElementById('msgInput');
const pos = input.selectionStart ?? input.value.length;
const val = input.value;
input.value = val.slice(0, pos) + emoji + val.slice(pos);
input.selectionStart = input.selectionEnd = pos + [...emoji].length;
input.focus();
};
picker.appendChild(btn);
});
})();
function toggleEmoji() {
document.getElementById('emojiPicker').classList.toggle('open');
}
document.addEventListener('click', e => {
if (!e.target.closest('#emojiPicker') && !e.target.closest('#emojiBtn')) {
document.getElementById('emojiPicker').classList.remove('open');
}
});
// ── Bild-Upload ──
(function attachImgHandler() {
document.getElementById('imgFile').addEventListener('change', async function () {
const file = this.files[0];
if (!file || !activePartnerId) return;
// Frisches Input-Element einsetzen, damit jedes weitere Bild (auch dasselbe) wählbar bleibt
const fresh = this.cloneNode(false);
this.replaceWith(fresh);
attachImgHandler();
try {
const dataUrl = await resizeImage(file);
await fetch('/social/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId: activePartnerId, text: dataUrl })
});
await loadThread();
} catch (e) { console.error(e); }
});
})();
function resizeImage(file) {
const MAX = 800;
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > MAX || h > MAX) {
if (w >= h) { h = Math.max(1, Math.round(MAX * h / w)); w = MAX; }
else { w = Math.max(1, Math.round(MAX * w / h)); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', 0.72));
};
img.onerror = reject;
img.src = url;
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Personen suchen XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.search-bar input { flex: 1; }
.search-bar button { width: auto; margin-top: 0; padding: 0.65rem 1.25rem; }
.user-list { list-style: none; margin: 0; padding: 0; }
.user-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-secondary);
}
.user-item:last-child { border-bottom: none; }
.user-avatar {
width: 42px; height: 42px;
border-radius: 50%;
background: var(--color-secondary);
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
overflow: hidden;
border: 1px solid var(--color-secondary);
}
.user-avatar img { width: 100%; height: 100%; object-fit: cover; }
.user-name { font-weight: 600; flex: 1; }
.user-actions { display: flex; gap: 0.5rem; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.user-actions button, .user-actions a.btn {
margin-top: 0;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
width: auto;
}
.user-profile-link {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
text-decoration: none;
color: inherit;
}
.user-profile-link:hover .user-name { color: var(--color-primary); }
.hint { color: var(--color-muted); font-size: 0.9rem; margin-top: 0.5rem; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content">
<h1 style="margin-bottom: 1.25rem;">Personen suchen</h1>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="Name eingeben (mind. 2 Zeichen)…" autocomplete="off">
<button onclick="doSearch()">Suchen</button>
</div>
<ul class="user-list" id="resultList"></ul>
<p class="hint" id="hint">Gib mindestens 2 Zeichen ein, um zu suchen.</p>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script>
let debounceTimer;
document.getElementById('searchInput').addEventListener('input', function () {
clearTimeout(debounceTimer);
const q = this.value.trim();
if (q.length < 2) {
document.getElementById('resultList').innerHTML = '';
document.getElementById('hint').textContent = 'Gib mindestens 2 Zeichen ein, um zu suchen.';
document.getElementById('hint').style.display = '';
return;
}
document.getElementById('hint').style.display = 'none';
debounceTimer = setTimeout(doSearch, 400);
});
document.getElementById('searchInput').addEventListener('keydown', e => {
if (e.key === 'Enter') { clearTimeout(debounceTimer); doSearch(); }
});
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
if (q.length < 2) return;
try {
const res = await fetch('/social/users/search?q=' + encodeURIComponent(q));
if (!res.ok) return;
renderResults(await res.json());
} catch (e) { console.error(e); }
}
function renderResults(users) {
const list = document.getElementById('resultList');
const hint = document.getElementById('hint');
list.innerHTML = '';
if (users.length === 0) {
hint.textContent = 'Keine Ergebnisse gefunden.';
hint.style.display = '';
return;
}
hint.style.display = 'none';
users.forEach(u => {
const avatar = u.profilePicture
? `<img src="data:image/png;base64,${u.profilePicture}" alt="">`
: '◉';
list.insertAdjacentHTML('beforeend', `
<li class="user-item" data-user-id="${u.userId}">
<a href="/benutzer.html?userId=${u.userId}" class="user-profile-link">
<div class="user-avatar">${avatar}</div>
<div class="user-name">${esc(u.name)}</div>
</a>
<div class="user-actions">${buildActions(u)}</div>
</li>`);
});
}
function buildActions(u) {
if (u.friendStatus === 'FRIEND') {
return `<a href="/nachrichten.html?userId=${u.userId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>`;
}
if (u.friendStatus === 'PENDING_SENT') {
return `<button disabled>Anfrage gesendet</button>`;
}
if (u.friendStatus === 'PENDING_RECEIVED') {
return `<button onclick="acceptByUserId('${u.userId}', this)">✓ Annehmen</button>`;
}
return `<button onclick="sendRequest('${u.userId}', this)">+ Freund hinzufügen</button>`;
}
async function sendRequest(receiverId, btn) {
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
try {
const res = await fetch('/social/friends/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiverId })
});
btn.textContent = (res.ok || res.status === 201 || res.status === 409) ? 'Anfrage gesendet' : 'Fehler';
} catch (e) {
btn.disabled = false;
btn.textContent = '+ Freund hinzufügen';
}
}
async function acceptByUserId(senderId, btn) {
btn.disabled = true;
btn.textContent = '…';
try {
const pendingRes = await fetch('/social/friends/pending');
const pending = await pendingRes.json();
const f = pending.find(p => p.user.userId === senderId);
if (!f) { btn.textContent = 'Fehler'; return; }
const res = await fetch('/social/friends/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friendshipId: f.friendshipId })
});
if (res.ok) {
btn.textContent = '✓ Freund';
const item = btn.closest('.user-item');
if (item) {
item.querySelector('.user-actions').innerHTML =
`<a href="/nachrichten.html?userId=${senderId}" class="btn" style="background:var(--color-secondary); color:var(--color-text);">✉ Nachricht</a>`;
}
} else {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
} catch (e) {
btn.disabled = false;
btn.textContent = '✓ Annehmen';
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,646 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profil XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.profile-picture-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.profile-picture {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--color-secondary);
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: var(--color-muted);
flex-shrink: 0;
}
.profile-picture img {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
}
.profile-field {
margin-bottom: 1.25rem;
}
.profile-field label {
margin-top: 0;
}
.profile-field-row {
display: flex;
align-items: stretch;
gap: 0.5rem;
}
.profile-field-row p {
flex: 1;
padding: 0.65rem 0.9rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
font-size: 1rem;
color: var(--color-text);
margin: 0;
}
.profile-field-row button {
flex-shrink: 0;
width: 175px;
padding: 0.65rem 0.75rem;
margin-top: 0;
white-space: nowrap;
}
input[type="file"] {
font-size: 0.85rem;
color: var(--color-muted);
}
/* ── Gallery ── */
.gallery-section-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 1.75rem 0 0.75rem;
border-top: 1px solid var(--color-secondary);
padding-top: 1.5rem;
}
.gallery-upload-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
#ownGallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.4rem;
margin-bottom: 1.5rem;
}
.own-thumb {
aspect-ratio: 1;
overflow: hidden;
border-radius: 6px;
position: relative;
background: var(--color-secondary);
cursor: pointer;
}
.own-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.own-thumb-delete {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0,0,0,0.6);
border: none;
color: #fff;
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
font-size: 0.75rem;
line-height: 1;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
transition: background 0.15s;
}
.own-thumb:hover .own-thumb-delete { display: flex; }
.own-thumb-delete:hover { background: rgba(180,30,30,0.85); }
.btn-delete-row {
display: flex;
justify-content: flex-end;
margin-bottom: 1.25rem;
}
.btn-delete {
width: 175px;
padding: 0.65rem 0.75rem;
margin-top: 0;
background: transparent;
border: 1px solid var(--color-secondary);
border-radius: 6px;
color: var(--color-muted);
font-size: 0.85rem;
font-weight: normal;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-delete:hover {
background: #3d0f1a;
border-color: var(--color-primary);
color: var(--color-primary);
}
/* ── Modal ── */
.modal-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-backdrop.visible {
display: flex;
}
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
}
.modal h2 {
color: var(--color-primary);
font-size: 1.2rem;
margin-bottom: 1.25rem;
}
.modal-actions {
display: flex;
gap: 0.75rem;
margin-top: 1.25rem;
}
.modal-actions button {
flex: 1;
margin-top: 0;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content" style="max-width: 600px;">
<div class="profile-picture-wrap">
<div class="profile-picture" id="profilePicDisplay"></div>
<input type="file" id="picFile" accept="image/*">
</div>
<div class="profile-field">
<label>Nickname</label>
<div class="profile-field-row">
<p id="userName"></p>
<button onclick="openNameDialog()">Nickname ändern</button>
</div>
</div>
<div class="profile-field">
<label>E-Mail</label>
<div class="profile-field-row">
<p id="userEmail"></p>
<button onclick="openEmailDialog()">E-Mail ändern</button>
</div>
</div>
<div class="btn-delete-row">
<button class="btn-delete" onclick="openDeleteDialog()">Konto löschen</button>
</div>
<div class="message" id="message"></div>
<button class="full-width" id="saveBtn" onclick="saveProfile()">Profil speichern</button>
<div class="gallery-section-label">Meine Bilder</div>
<div class="gallery-upload-row">
<input type="file" id="galleryFile" accept="image/*" multiple style="display:none;" onchange="handleGalleryUpload(this.files)">
<button onclick="document.getElementById('galleryFile').click()">+ Bilder hochladen</button>
<span id="galleryUploadStatus" style="font-size:0.85rem;color:var(--color-muted);"></span>
</div>
<div id="ownGallery"></div>
</div>
</div>
<!-- Nickname modal -->
<div class="modal-backdrop" id="nameModal">
<div class="modal">
<h2>Nickname ändern</h2>
<label for="newName">Neuer Nickname</label>
<input type="text" id="newName" placeholder="Neuer Name" autocomplete="off">
<div class="message" id="nameMessage"></div>
<div class="modal-actions">
<button class="secondary" onclick="closeNameDialog()">Abbrechen</button>
<button id="nameConfirmBtn" onclick="saveName()">Speichern</button>
</div>
</div>
</div>
<!-- Konto löschen modal -->
<div class="modal-backdrop" id="deleteModal">
<div class="modal">
<h2>Konto löschen</h2>
<p style="color:var(--color-muted); font-size:0.9rem; margin-bottom:0.75rem;">
Dein Konto sowie alle gespeicherten Aufgaben und Toys werden unwiderruflich gelöscht.
</p>
<p style="color:var(--color-primary); font-size:0.85rem;">Dieser Vorgang kann nicht rückgängig gemacht werden.</p>
<div class="message" id="deleteMessage"></div>
<div class="modal-actions">
<button class="secondary" onclick="closeDeleteDialog()">Abbrechen</button>
<button id="deleteConfirmBtn" onclick="deleteAccount()" style="background:#c0392b;">Konto löschen</button>
</div>
</div>
</div>
<!-- E-Mail modal -->
<div class="modal-backdrop" id="emailModal">
<div class="modal">
<h2>E-Mail-Adresse ändern</h2>
<label for="newEmail">Neue E-Mail-Adresse</label>
<input type="email" id="newEmail" placeholder="neue@email.de" autocomplete="off">
<div class="message" id="emailMessage"></div>
<div class="modal-actions">
<button class="secondary" onclick="closeEmailDialog()">Abbrechen</button>
<button id="emailConfirmBtn" onclick="requestEmailChange()">Bestätigungsmail senden</button>
</div>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script>
let currentPicture = null;
let currentPictureHq = null;
fetch('/login/me')
.then(r => {
if (r.status === 401) { window.location.href = '/login.html'; return null; }
return r.json();
})
.then(user => {
if (!user) return;
document.getElementById('userName').textContent = user.name;
document.getElementById('userEmail').textContent = user.email;
if (user.profilePicture) {
currentPicture = user.profilePicture;
renderPicture(currentPicture);
}
myUserId = user.userId;
loadOwnGallery();
})
.catch(() => { window.location.href = '/login.html'; });
// Check if redirected back after email change
if (new URLSearchParams(window.location.search).get('emailChanged')) {
// Already handled by login.html redirect — nothing to do here
}
document.getElementById('picFile').addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
[currentPicture, currentPictureHq] = await Promise.all([toBase64(file, 96), toBase64(file, 1024)]);
renderPicture(currentPicture);
});
function renderPicture(base64) {
const el = document.getElementById('profilePicDisplay');
el.innerHTML = `<img src="data:image/png;base64,${base64}" alt="Profilbild">`;
}
async function saveProfile() {
const btn = document.getElementById('saveBtn');
btn.disabled = true;
btn.textContent = 'Wird gespeichert…';
hideMessage();
try {
const response = await fetch('/user/me/picture', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ picture: currentPicture, pictureHq: currentPictureHq })
});
if (response.ok) {
window.location.href = '/userhome.html';
} else {
showMessage(`Fehler: HTTP ${response.status}`, 'error');
btn.disabled = false;
btn.textContent = 'Profil speichern';
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
btn.disabled = false;
btn.textContent = 'Profil speichern';
console.error(err);
}
}
// ── Nickname dialog ──
function openNameDialog() {
document.getElementById('newName').value = '';
hideModalMessage('nameMessage');
document.getElementById('nameModal').classList.add('visible');
document.getElementById('newName').focus();
}
function closeNameDialog() {
document.getElementById('nameModal').classList.remove('visible');
}
async function saveName() {
const newName = document.getElementById('newName').value.trim();
if (!newName) {
showModalMessage('nameMessage', 'Bitte einen Namen eingeben.', 'error');
return;
}
const btn = document.getElementById('nameConfirmBtn');
btn.disabled = true;
btn.textContent = 'Wird gespeichert…';
hideModalMessage('nameMessage');
try {
const response = await fetch('/user/me/name', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
if (response.ok) {
document.getElementById('userName').textContent = newName;
closeNameDialog();
showMessage('Nickname geändert.', 'success');
} else if (response.status === 409) {
showModalMessage('nameMessage', 'Dieser Nickname ist bereits vergeben.', 'error');
} else {
showModalMessage('nameMessage', `Fehler: HTTP ${response.status}`, 'error');
}
} catch (err) {
showModalMessage('nameMessage', 'Server nicht erreichbar.', 'error');
console.error(err);
} finally {
btn.disabled = false;
btn.textContent = 'Speichern';
}
}
// ── E-Mail dialog ──
function openEmailDialog() {
document.getElementById('newEmail').value = '';
hideModalMessage('emailMessage');
document.getElementById('emailModal').classList.add('visible');
document.getElementById('newEmail').focus();
}
function closeEmailDialog() {
document.getElementById('emailModal').classList.remove('visible');
}
async function requestEmailChange() {
const newEmail = document.getElementById('newEmail').value.trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
showModalMessage('emailMessage', 'Bitte eine gültige E-Mail-Adresse eingeben.', 'error');
return;
}
const btn = document.getElementById('emailConfirmBtn');
btn.disabled = true;
btn.textContent = 'Wird gesendet…';
hideModalMessage('emailMessage');
try {
const response = await fetch('/email-change', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newEmail })
});
if (response.status === 202) {
showModalMessage('emailMessage',
'Bestätigungsmail wurde an die neue Adresse gesendet. Bitte bestätige die Änderung über den Link in der E-Mail.',
'success');
btn.disabled = true;
btn.textContent = 'Gesendet';
} else if (response.status === 409) {
showModalMessage('emailMessage', 'Diese E-Mail-Adresse ist bereits vergeben.', 'error');
btn.disabled = false;
btn.textContent = 'Bestätigungsmail senden';
} else {
showModalMessage('emailMessage', `Fehler: HTTP ${response.status}`, 'error');
btn.disabled = false;
btn.textContent = 'Bestätigungsmail senden';
}
} catch (err) {
showModalMessage('emailMessage', 'Server nicht erreichbar.', 'error');
btn.disabled = false;
btn.textContent = 'Bestätigungsmail senden';
console.error(err);
}
}
// ── Gallery ──
let myUserId = null;
async function loadOwnGallery() {
if (!myUserId) return;
const res = await fetch('/social/profile-images?userId=' + myUserId);
if (!res.ok) return;
const images = await res.json();
renderOwnGallery(images);
}
function renderOwnGallery(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">
<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('');
}
async function deleteGalleryImage(imageId, event) {
event.stopPropagation();
const res = await fetch('/social/profile-images/' + imageId, { method: 'DELETE' });
if (res.ok || res.status === 204) loadOwnGallery();
}
async function handleGalleryUpload(files) {
if (!files || files.length === 0) return;
const status = document.getElementById('galleryUploadStatus');
status.textContent = '0 / ' + files.length + ' hochgeladen…';
let done = 0;
for (const file of Array.from(files)) {
try {
const base64 = await toJpeg(file, 1024);
const res = await fetch('/social/profile-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageData: base64 })
});
if (res.status === 422) {
status.textContent = 'Limit von 20 Bildern erreicht.';
break;
}
} catch (e) {
console.error('Upload-Fehler:', e);
}
done++;
status.textContent = done + ' / ' + files.length + ' hochgeladen…';
}
status.textContent = done + ' Bild' + (done !== 1 ? 'er' : '') + ' hochgeladen.';
document.getElementById('galleryFile').value = '';
loadOwnGallery();
}
function toJpeg(file, max) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > max || h > max) {
if (w >= h) { h = Math.max(1, Math.round(max * h / w)); w = max; }
else { w = Math.max(1, Math.round(max * w / h)); h = max; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', 0.85).split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
function toBase64(file, max) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > max || h > max) {
if (w >= h) { h = Math.max(1, Math.round(max * h / w)); w = max; }
else { w = Math.max(1, Math.round(max * w / h)); h = max; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/png').split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
function showModalMessage(id, text, type) {
const el = document.getElementById(id);
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideModalMessage(id) {
document.getElementById(id).style.display = 'none';
}
// ── Konto löschen ──
function openDeleteDialog() {
hideModalMessage('deleteMessage');
document.getElementById('deleteConfirmBtn').disabled = false;
document.getElementById('deleteConfirmBtn').textContent = 'Konto löschen';
document.getElementById('deleteModal').classList.add('visible');
}
function closeDeleteDialog() {
document.getElementById('deleteModal').classList.remove('visible');
}
async function deleteAccount() {
const btn = document.getElementById('deleteConfirmBtn');
btn.disabled = true;
btn.textContent = 'Wird gelöscht…';
hideModalMessage('deleteMessage');
try {
const response = await fetch('/user/me', { method: 'DELETE' });
if (response.ok) {
window.location.href = '/login.html?accountDeleted=1';
} else {
showModalMessage('deleteMessage', `Fehler: HTTP ${response.status}`, 'error');
btn.disabled = false;
btn.textContent = 'Konto löschen';
}
} catch (err) {
showModalMessage('deleteMessage', 'Server nicht erreichbar.', 'error');
btn.disabled = false;
btn.textContent = 'Konto löschen';
console.error(err);
}
}
// Close modals on backdrop click
document.getElementById('deleteModal').addEventListener('click', e => {
if (e.target === document.getElementById('deleteModal')) closeDeleteDialog();
});
document.getElementById('nameModal').addEventListener('click', e => {
if (e.target === document.getElementById('nameModal')) closeNameDialog();
});
document.getElementById('emailModal').addEventListener('click', e => {
if (e.target === document.getElementById('emailModal')) closeEmailDialog();
});
// Enter key in modal inputs
document.getElementById('newName').addEventListener('keydown', e => {
if (e.key === 'Enter') saveName();
});
document.getElementById('newEmail').addEventListener('keydown', e => {
if (e.key === 'Enter') requestEmailChange();
});
</script>
</body>
</html>

View File

@@ -3,14 +3,14 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XXX The Game Registrierung</title>
<title>xXx Games Neues Konto erstellen</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="card">
<h1>XXX The Game</h1>
<p class="subtitle">Neues Konto erstellen</p>
<img src="icon.png" alt="Logo">
<h1>Neues Konto erstellen</h1>
<label for="name">Name</label>
<input type="text" id="name" placeholder="Dein Name" autocomplete="name" />
@@ -84,6 +84,10 @@
showMessage('Diese E-Mail-Adresse ist bereits registriert.', 'error');
btn.disabled = false;
btn.textContent = 'Registrieren';
} else if (response.status === 409) {
showMessage('Dieser Name ist bereits vergeben.', 'error');
btn.disabled = false;
btn.textContent = 'Registrieren';
} else {
showMessage(`Fehler: HTTP ${response.status}`, 'error');
btn.disabled = false;

View File

@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>xXx Games Neues Passwort</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100;
align-items: center;
justify-content: center;
}
.overlay.active {
display: flex;
}
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
max-width: 340px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
text-align: center;
}
.modal p {
color: var(--color-text);
font-size: 0.95rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="card">
<img src="icon.png" alt="Logo">
<h1>Neues Passwort</h1>
<p class="subtitle">Gib dein neues Passwort ein.</p>
<label for="password">Neues Passwort</label>
<input type="password" id="password" placeholder="••••••••" autocomplete="new-password" />
<label for="passwordConfirm">Passwort wiederholen</label>
<input type="password" id="passwordConfirm" placeholder="••••••••" autocomplete="new-password" />
<button class="full-width" id="submitBtn" onclick="submit()">Passwort speichern</button>
<div class="message" id="message"></div>
</div>
<div class="overlay" id="overlay">
<div class="modal">
<p>Dein Passwort wurde erfolgreich geändert. Du kannst dich jetzt anmelden.</p>
<button class="full-width" onclick="goToLogin()">Zum Login</button>
</div>
</div>
<script>
document.addEventListener('keydown', e => {
if (e.key === 'Enter') submit();
});
function getToken() {
return new URLSearchParams(window.location.search).get('token');
}
document.addEventListener('DOMContentLoaded', () => {
if (!getToken()) {
showMessage('Ungültiger oder fehlender Reset-Link.', 'error');
document.getElementById('submitBtn').disabled = true;
}
});
async function sha256(text) {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
async function submit() {
const password = document.getElementById('password').value;
const passwordConfirm = document.getElementById('passwordConfirm').value;
const btn = document.getElementById('submitBtn');
const token = getToken();
if (!password || !passwordConfirm) {
showMessage('Bitte beide Felder ausfüllen.', 'error');
return;
}
if (password !== passwordConfirm) {
showMessage('Die Passwörter stimmen nicht überein.', 'error');
return;
}
if (!token) {
showMessage('Ungültiger Reset-Link.', 'error');
return;
}
btn.disabled = true;
btn.textContent = 'Wird gespeichert…';
hideMessage();
try {
const passwordHash = await sha256(password);
const response = await fetch('/password-reset/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, passwordHash })
});
if (response.ok) {
document.getElementById('overlay').classList.add('active');
} else {
showMessage('Der Reset-Link ist ungültig oder abgelaufen.', 'error');
btn.disabled = false;
btn.textContent = 'Passwort speichern';
}
} catch (err) {
showMessage('Server nicht erreichbar.', 'error');
btn.disabled = false;
btn.textContent = 'Passwort speichern';
console.error(err);
}
}
function goToLogin() {
window.location.href = '/login.html';
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BDSM Game Neue Session XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.session-setup { max-width: 540px; }
.setup-section { margin-bottom: 2.5rem; }
.setup-section h2 {
color: var(--color-primary);
font-size: 1rem;
font-weight: 600;
margin-bottom: 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.setting-row { margin-bottom: 1.25rem; }
.setting-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.4rem;
}
.setting-header label {
font-size: 0.85rem;
color: #aaa;
margin: 0;
}
.setting-value {
font-size: 1rem;
font-weight: 600;
color: var(--color-primary);
min-width: 3.5rem;
text-align: right;
}
input[type="range"] {
width: 100%;
accent-color: var(--color-primary);
cursor: pointer;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.modal-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 14px;
padding: 2rem;
max-width: 420px;
width: 100%;
text-align: center;
}
.modal-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: 0.75rem;
}
.modal-text {
font-size: 0.9rem;
color: var(--color-muted);
line-height: 1.6;
margin-bottom: 1.5rem;
}
.modal-actions { display: flex; flex-direction: column; gap: 0.6rem; }
.modal-actions button { width: 100%; padding: 0.75rem; }
</style>
</head>
<body class="app">
<div class="modal-overlay" id="modal" style="display:none;">
<div class="modal-card">
<div class="modal-title" id="modalTitle"></div>
<div class="modal-text" id="modalText"></div>
<div class="modal-actions" id="modalActions"></div>
</div>
</div>
<div class="main">
<div class="content session-setup">
<h1>BDSM Game</h1>
<p style="margin-bottom:2rem;">Schritt 1 von 4 Session-Einstellungen</p>
<div class="setup-section">
<h2>Session-Einstellungen</h2>
<div class="setting-row">
<div class="setting-header">
<label for="sldStrafe">Wahrscheinlichkeit Strafe</label>
<span class="setting-value"><span id="valStrafe">15</span> %</span>
</div>
<input type="range" id="sldStrafe" min="0" max="100" value="15"
oninput="document.getElementById('valStrafe').textContent=this.value; updateWarnung()">
</div>
<div class="setting-row">
<div class="setting-header">
<label for="sldZeitstrafe">Wahrscheinlichkeit Zeitstrafe</label>
<span class="setting-value"><span id="valZeitstrafe">15</span> %</span>
</div>
<input type="range" id="sldZeitstrafe" min="0" max="100" value="15"
oninput="document.getElementById('valZeitstrafe').textContent=this.value; updateWarnung()">
</div>
<div class="message" id="wahrschWarnung" style="display:none; margin-top:0.75rem;"></div>
<div class="setting-row">
<div class="setting-header">
<label for="sldAufgaben">Aufgaben pro Level</label>
<span class="setting-value" id="valAufgaben">5</span>
</div>
<input type="range" id="sldAufgaben" min="4" max="20" value="5"
oninput="document.getElementById('valAufgaben').textContent=this.value">
</div>
<div class="setting-row">
<div class="setting-header">
<label for="sldZeit">Zeitfaktor Zeitstrafen</label>
<span class="setting-value" id="valZeit">1,0</span>
</div>
<input type="range" id="sldZeit" min="5" max="20" value="10"
oninput="document.getElementById('valZeit').textContent=(this.value/10).toFixed(1).replace('.',',')">
</div>
</div>
<div class="message" id="message"></div>
<button class="full-width" onclick="weiter()">Weiter</button>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script>
function updateWarnung() {
const strafe = parseInt(document.getElementById('sldStrafe').value);
const zeitstrafe = parseInt(document.getElementById('sldZeitstrafe').value);
const summe = strafe + zeitstrafe;
const el = document.getElementById('wahrschWarnung');
if (summe > 98) {
el.textContent = `Kombiniert ${summe} % Werte über 98 % sind nicht möglich.`;
el.className = 'message error';
el.style.display = 'block';
} else if (summe > 60) {
el.textContent = `Hinweis: Bei ${summe} % kombinierten Wahrscheinlichkeiten ist die Chance auf Vanilla-Aufgaben sehr gering.`;
el.className = 'message warning';
el.style.display = 'block';
} else {
el.style.display = 'none';
}
}
function weiter() {
hideMessage();
const strafe = parseInt(document.getElementById('sldStrafe').value);
const zeitstrafe = parseInt(document.getElementById('sldZeitstrafe').value);
if (strafe + zeitstrafe > 98) {
showMessage('Die kombinierten Wahrscheinlichkeiten dürfen 98 % nicht überschreiten.', 'error');
return;
}
sessionStorage.setItem('bdsm-session-settings', JSON.stringify({
wahrscheinlichkeitStrafe: strafe,
wahrscheinlichkeitSperre: zeitstrafe,
aufgabenProLevel: parseInt(document.getElementById('sldAufgaben').value),
zeitfaktorZeitstrafen: parseInt(document.getElementById('sldZeit').value) / 10,
}));
window.location.href = '/sessionbdsmplayers.html';
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
// ── Aktive-Session-Check ──
function zeigeModal(title, text, actions) {
document.getElementById('modalTitle').textContent = title;
const textEl = document.getElementById('modalText');
textEl.textContent = text;
textEl.style.display = text ? '' : 'none';
const actEl = document.getElementById('modalActions');
actEl.innerHTML = '';
actions.forEach(a => {
const btn = document.createElement('button');
btn.textContent = a.label;
btn.className = a.primary ? 'full-width' : 'full-width secondary';
btn.onclick = () => a.onClick();
actEl.appendChild(btn);
});
document.getElementById('modal').style.display = 'flex';
}
function versteckeModal() {
document.getElementById('modal').style.display = 'none';
}
const BDSM_STORAGE_KEYS = [
'bdsm-session-id', 'bdsm-session-settings', 'bdsm-session-setup',
'bdsm-session-gruppen', 'bdsm-session-toys', 'bdsm-session-game',
];
function sessionFortfahren(sid) {
BDSM_STORAGE_KEYS.forEach(k => sessionStorage.removeItem(k));
sessionStorage.setItem('bdsm-session-id', sid);
window.location.href = '/sessionbdsmingame.html';
}
function sessionBeendenFragen(sid) {
zeigeModal(
'Session wirklich beenden?',
'Die Session und alle aktiven Sperren werden gelöscht.',
[
{ label: 'Ja, beenden', primary: true, onClick: () => sessionLoeschen(sid) },
{ label: 'Nein, fortfahren', onClick: () => sessionFortfahren(sid) },
]
);
}
async function sessionLoeschen(sid) {
versteckeModal();
try {
await fetch('/session', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: sid }),
});
} catch (_) { /* ignorieren */ }
BDSM_STORAGE_KEYS.forEach(k => sessionStorage.removeItem(k));
}
(async function checkAktiveSession() {
try {
const meRes = await fetch('/login/me');
if (!meRes.ok) return;
const user = await meRes.json();
const sessionRes = await fetch(`/session?userId=${user.userId}`);
if (sessionRes.status === 204) return;
if (!sessionRes.ok) return;
const session = await sessionRes.json();
zeigeModal(
'Aktive Session vorhanden',
'Du hast noch eine laufende Session. Möchtest du fortfahren?',
[
{ label: 'Ja, fortfahren', primary: true, onClick: () => sessionFortfahren(session.sessionId) },
{ label: 'Nein', onClick: () => sessionBeendenFragen(session.sessionId) },
]
);
} catch (_) { /* ignorieren */ }
})();
// Gespeicherte Einstellungen wiederherstellen
(function restore() {
const saved = sessionStorage.getItem('bdsm-session-settings');
if (!saved) return;
const s = JSON.parse(saved);
function setSlider(id, displayId, value, transform) {
const el = document.getElementById(id);
if (!el) return;
el.value = value;
document.getElementById(displayId).textContent = transform ? transform(value) : value;
}
setSlider('sldStrafe', 'valStrafe', s.wahrscheinlichkeitStrafe);
setSlider('sldZeitstrafe', 'valZeitstrafe', s.wahrscheinlichkeitSperre);
setSlider('sldAufgaben', 'valAufgaben', s.aufgabenProLevel);
setSlider('sldZeit', 'valZeit', Math.round(s.zeitfaktorZeitstrafen * 10),
v => (v / 10).toFixed(1).replace('.', ','));
updateWarnung();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,588 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BDSM Game Im Spiel XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.game-overview { max-width: 700px; }
.overview-section { margin-bottom: 2rem; }
.overview-section h2 {
color: var(--color-primary);
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kv-table { width: 100%; border-collapse: collapse; }
.kv-table td { padding: 0.35rem 0; font-size: 0.9rem; vertical-align: top; }
.kv-table td:first-child { color: var(--color-muted); width: 200px; }
.player-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
}
.player-name { font-weight: 600; margin-bottom: 0.4rem; }
.player-sub { font-size: 0.8rem; color: var(--color-muted); }
.tag-list { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.25rem; }
.tag {
background: var(--color-secondary);
border-radius: 6px;
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
color: var(--color-text);
}
.count-row { display: flex; gap: 2.5rem; }
.count-item { text-align: center; }
.count-num { font-size: 2rem; font-weight: 700; color: var(--color-primary); }
.count-label { font-size: 0.75rem; color: var(--color-muted); margin-top: 0.1rem; }
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.modal-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 14px;
padding: 2rem 2rem 1.75rem;
max-width: 460px;
width: 100%;
text-align: center;
}
.modal-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: 0.75rem;
line-height: 1.4;
}
.modal-text {
font-size: 0.95rem;
color: var(--color-muted);
line-height: 1.65;
margin-bottom: 1.5rem;
white-space: pre-wrap;
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
}
.modal-actions button { min-width: 110px; }
/* ── Aufgaben-Karte ── */
.task-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 1.5rem;
height: 260px;
display: flex;
flex-direction: column;
}
.task-card.loading {
align-items: center;
justify-content: center;
color: var(--color-muted);
font-style: italic;
}
.task-player-badge {
display: inline-block;
background: var(--color-primary);
color: #fff;
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.7rem;
border-radius: 10px;
margin-bottom: 0.85rem;
flex-shrink: 0;
}
.task-text {
flex: 1;
font-size: 1.05rem;
line-height: 1.7;
color: var(--color-text);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
cursor: default;
}
.task-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-secondary);
flex-shrink: 0;
}
.task-btns {
display: flex;
gap: 0.75rem;
align-items: center;
}
.task-timer-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.timer-big {
font-size: 2rem;
font-weight: 700;
color: var(--color-primary);
font-variant-numeric: tabular-nums;
line-height: 1;
min-width: 3.5rem;
}
.timer-big.expired { color: var(--color-muted); }
.btn-sm-cancel {
background: transparent;
border: 1px solid var(--color-secondary);
color: var(--color-muted);
font-size: 0.75rem;
padding: 0.25rem 0.6rem;
font-weight: normal;
border-radius: 4px;
}
.btn-sm-cancel:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: transparent;
}
.btn-session-beenden {
background: transparent;
border: none;
color: var(--color-muted);
font-size: 0.78rem;
padding: 0;
font-weight: normal;
cursor: pointer;
text-decoration: underline;
white-space: nowrap;
flex-shrink: 0;
}
.btn-session-beenden:hover {
color: var(--color-text);
background: transparent;
}
/* ── Level-Anzeige ── */
.level-display {
display: flex;
justify-content: center;
margin-bottom: 1.25rem;
}
.level-display img {
width: 72px;
height: 72px;
object-fit: contain;
}
</style>
</head>
<body class="app">
<!-- ── Modal ── -->
<div class="modal-overlay" id="modal" style="display:none;">
<div class="modal-card">
<div class="modal-title" id="modalTitle"></div>
<div class="modal-text" id="modalText"></div>
<div class="modal-actions" id="modalActions"></div>
</div>
</div>
<div class="main">
<div class="content game-overview">
<!-- ── Level ── -->
<div class="level-display" id="levelDisplay" style="display:none;">
<img id="levelImg" src="" alt="Level">
</div>
<!-- ── Aufgabe ── -->
<div style="margin-bottom:2rem;">
<div class="task-card loading" id="taskCard">Aufgabe wird geladen…</div>
<div class="message" id="taskMessage" style="display:none; margin-top:0.75rem;"></div>
</div>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script>
const game = JSON.parse(sessionStorage.getItem('bdsm-session-game') || 'null');
const setup = JSON.parse(sessionStorage.getItem('bdsm-session-setup') || 'null');
const toys = JSON.parse(sessionStorage.getItem('bdsm-session-toys') || '[]');
const sessionId = sessionStorage.getItem('bdsm-session-id');
if (!sessionId) window.location.replace('/sessionbdsm.html');
// ── Modal ──
function zeigeModal(title, text, actions) {
document.getElementById('modalTitle').textContent = title;
const textEl = document.getElementById('modalText');
textEl.textContent = text;
textEl.style.display = text ? '' : 'none';
const actEl = document.getElementById('modalActions');
actEl.innerHTML = '';
actions.forEach(a => {
const btn = document.createElement('button');
btn.textContent = a.label;
if (!a.primary) btn.classList.add('secondary');
btn.onclick = () => { versteckeModal(); a.onClick(); };
actEl.appendChild(btn);
});
document.getElementById('modal').style.display = 'flex';
}
function versteckeModal() {
document.getElementById('modal').style.display = 'none';
}
// ── Hilfsfunktionen ──
function escapeAttr(str) {
return (str || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function badgeHtml(name) {
return name ? `<div class="task-player-badge">${name} ist dran</div>` : '';
}
const SESSION_BEENDEN_BTN = `<button class="btn-session-beenden" onclick="sessionBeendenFragen()">Session beenden</button>`;
// ── Aufgaben-Logik ──
let currentTask = null;
let timerInterval = null;
function formatTime(sec) {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function clearTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
}
function zeigeTaskFehler(text) {
const el = document.getElementById('taskMessage');
el.textContent = text;
el.className = 'message error';
el.style.display = '';
const card = document.getElementById('taskCard');
card.className = 'task-card';
card.innerHTML = `
<div style="flex:1;"></div>
<div class="task-footer">
<div class="task-btns">
<button onclick="ladeAufgabe()">Erneut versuchen</button>
</div>
${SESSION_BEENDEN_BTN}
</div>`;
}
async function ladeAufgabe() {
clearTimer();
document.getElementById('taskMessage').style.display = 'none';
const card = document.getElementById('taskCard');
card.className = 'task-card loading';
card.innerHTML = 'Aufgabe wird geladen…';
try {
const res = await fetch(`/session/${sessionId}/aufgaben/next`);
if (res.status === 204) { zeigeFinaleDialog(); return; }
if (!res.ok) throw new Error(`HTTP ${res.status}`);
currentTask = await res.json();
if (currentTask.level) {
document.getElementById('levelImg').src = `/img/lvl${currentTask.level}.png`;
document.getElementById('levelDisplay').style.display = '';
}
const name = currentTask.nameAktiverMitspieler || '';
const title = name ? `${name}, du bist an der Reihe` : 'Du bist an der Reihe';
zeigeModal(title, '', [{ label: 'OK', primary: true, onClick: zeigeAufgabe }]);
} catch (e) {
zeigeTaskFehler('Aufgabe konnte nicht geladen werden: ' + e.message);
}
}
function zeigeAufgabe() {
const task = currentTask;
const cb = task.callback;
if (cb && cb.sperreId != null) zeigeSperreAufgabe(task);
else if (cb && cb.faktor != null) zeigeVerlaengernAufgabe(task);
else if (task.timer != null) zeigeTimerAufgabe(task);
else zeigeEinfacheAufgabe(task);
}
function renderCard(task, footerInner) {
const card = document.getElementById('taskCard');
card.className = 'task-card';
card.innerHTML = `
${badgeHtml(task.nameAktiverMitspieler)}
<div class="task-text" title="${escapeAttr(task.aufgabeText)}">${task.aufgabeText}</div>
<div class="task-footer">
<div class="task-btns" id="taskActions">${footerInner}</div>
${SESSION_BEENDEN_BTN}
</div>`;
}
function zeigeSperreAufgabe(task) {
renderCard(task, `
<button onclick="sperreAnwenden()">Zeitstrafe anwenden</button>
<button class="secondary" onclick="gnadeErweisen()">Gnade erweisen</button>`);
}
function gnadeErweisen() {
const name = currentTask?.nameAktiverMitspieler;
const title = name ? `${name}, erweist du wirklich Gnade?` : 'Wirklich Gnade erweisen?';
zeigeModal(title, 'Die Zeitstrafe wird nicht eingetragen und das Spiel geht weiter.', [
{ label: 'Ja, Gnade erweisen', primary: true, onClick: aufgabeAbgeschlossen },
{ label: 'Nein', onClick: versteckeModal },
]);
}
function zeigeVerlaengernAufgabe(task) {
renderCard(task, `
<button onclick="sperrenVerlaengern()">Ja, verlängern</button>
<button class="secondary" onclick="aufgabeAbgeschlossen()">Nein</button>`);
}
function zeigeTimerAufgabe(task) {
renderCard(task, `<button onclick="timerStarten()">Starten</button>`);
}
function timerStarten() {
const task = currentTask;
const actions = document.getElementById('taskActions');
let remaining = task.timer;
actions.className = 'task-timer-row';
actions.innerHTML = `
<div class="timer-big" id="timerValue">${formatTime(remaining)}</div>
<button class="btn-sm-cancel" onclick="timerAbbrechen()">✕ Abbrechen</button>`;
timerInterval = setInterval(() => {
remaining--;
const el = document.getElementById('timerValue');
if (remaining <= 0) {
clearTimer();
if (el) { el.textContent = formatTime(0); el.classList.add('expired'); }
aufgabeAbgeschlossen();
} else {
if (el) el.textContent = formatTime(remaining);
}
}, 1000);
}
function timerAbbrechen() {
clearTimer();
aufgabeAbgeschlossen();
}
function zeigeEinfacheAufgabe(task) {
renderCard(task, `<button onclick="aufgabeAbgeschlossen()">Erledigt</button>`);
}
async function aufgabeAbgeschlossen() {
clearTimer();
try {
const res = await fetch(`/session/sperre/abgelaufene?sessionId=${sessionId}`);
if (res.ok) {
const text = await res.text();
const texte = text.split(';').map(t => t.trim()).filter(t => t.length > 0);
if (texte.length > 0) { zeigeAbgelaufeneSperre(texte, 0); return; }
}
} catch (_) { /* ignorieren */ }
ladeAufgabe();
}
function zeigeAbgelaufeneSperre(texte, index) {
if (index >= texte.length) { ladeAufgabe(); return; }
zeigeModal(
'Zeitstrafe abgelaufen',
texte[index],
[{ label: 'OK', primary: true, onClick: () => zeigeAbgelaufeneSperre(texte, index + 1) }]
);
}
async function sperreAnwenden() {
const cb = currentTask?.callback;
if (!cb) return;
try {
const res = await fetch('/session/sperre', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...cb, sessionId }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
aufgabeAbgeschlossen();
} catch (e) {
zeigeTaskFehler('Zeitstrafe konnte nicht verhängt werden: ' + e.message);
}
}
async function sperrenVerlaengern() {
const cb = currentTask?.callback;
if (!cb) return;
try {
const res = await fetch('/session/sperre/verlaengern', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...cb, sessionId }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
aufgabeAbgeschlossen();
} catch (e) {
zeigeTaskFehler('Sperren verlängern fehlgeschlagen: ' + e.message);
}
}
// ── Finale ──
let _finisherListe = [];
let _finisherIndex = 0;
function zeigeFinaleDialog() {
clearTimer();
const card = document.getElementById('taskCard');
card.className = 'task-card loading';
card.innerHTML = 'Level 5 abgeschlossen…';
zeigeModal(
'Level 5 abgeschlossen!',
'Seid ihr bereit für das große Finale?',
[
{ label: 'Ja, Finale!', primary: true, onClick: starteFinale },
{ label: 'Nein, weiter spielen', onClick: zurueckZuLevel5 },
]
);
}
async function zurueckZuLevel5() {
try {
await fetch(`/session/${sessionId}/backToLevel5`, { method: 'POST' });
} catch (_) {}
ladeAufgabe();
}
async function starteFinale() {
try {
const res = await fetch(`/session/sperre/aktive?sessionId=${sessionId}`);
if (res.ok) {
const sperren = await res.json();
const texte = (sperren || []).map(s => s.releaseText).filter(t => t);
if (texte.length > 0) { zeigeFinaleSperre(texte, 0); return; }
}
} catch (_) {}
ladeFinisher();
}
function zeigeFinaleSperre(texte, index) {
if (index >= texte.length) { ladeFinisher(); return; }
zeigeModal(
'Zeitstrafe aufgelöst',
texte[index],
[{ label: 'OK', primary: true, onClick: () => zeigeFinaleSperre(texte, index + 1) }]
);
}
async function ladeFinisher() {
try {
const res = await fetch(`/session/${sessionId}/finisher`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const liste = await res.json();
naechsterFinisher(liste, 0);
} catch (e) {
zeigeTaskFehler('Finisher konnten nicht geladen werden: ' + e.message);
}
}
function naechsterFinisher(liste, index) {
_finisherListe = liste;
_finisherIndex = index;
if (index >= liste.length) {
const card = document.getElementById('taskCard');
card.className = 'task-card loading';
card.innerHTML = '';
zeigeModal(
'Das Finale ist abgeschlossen!',
'Wir hoffen, ihr hattet viel Spaß! 🎉',
[{ label: 'Session beenden', primary: true, onClick: sessionLoeschen }]
);
return;
}
const finisher = liste[index];
const name = finisher.nameAktiverMitspieler || '';
zeigeModal(
name ? `${name} ist dran` : 'Finale',
'',
[{ label: 'OK', primary: true, onClick: zeigeFinisherAufgabe }]
);
}
function zeigeFinisherAufgabe() {
const finisher = _finisherListe[_finisherIndex];
const card = document.getElementById('taskCard');
card.className = 'task-card';
card.innerHTML = `
${badgeHtml(finisher.nameAktiverMitspieler)}
<div class="task-text" title="${escapeAttr(finisher.aufgabeText)}">${finisher.aufgabeText}</div>
<div class="task-footer">
<div class="task-btns">
<button onclick="naechsterFinisher(_finisherListe, _finisherIndex + 1)">Erledigt</button>
</div>
</div>`;
}
// ── Session beenden ──
const BDSM_STORAGE_KEYS = [
'bdsm-session-id', 'bdsm-session-settings', 'bdsm-session-setup',
'bdsm-session-gruppen', 'bdsm-session-toys', 'bdsm-session-game',
];
function sessionBeendenFragen() {
zeigeModal(
'Wirklich beenden?',
'Möchtest du die aktive Session wirklich beenden?',
[
{ label: 'Ja, beenden', primary: true, onClick: sessionLoeschen },
{ label: 'Nein', onClick: versteckeModal },
]
);
}
async function sessionLoeschen() {
versteckeModal();
try {
await fetch('/session', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
} catch (_) { /* ignorieren */ }
BDSM_STORAGE_KEYS.forEach(k => sessionStorage.removeItem(k));
window.location.href = '/userhome.html';
}
// ── Start ──
ladeAufgabe();
</script>
</body>
</html>

View File

@@ -0,0 +1,438 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BDSM Game Mitspieler XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.session-setup { max-width: 700px; }
.setup-section { margin-bottom: 2.5rem; }
.setup-section h2 {
color: var(--color-primary);
font-size: 1rem;
font-weight: 600;
margin-bottom: 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Player cards ── */
.player-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1rem;
}
.player-card-header {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.25rem;
}
.player-title {
font-weight: 600;
color: var(--color-text);
font-size: 1rem;
}
.player-badge {
background: var(--color-primary);
color: #fff;
font-size: 0.7rem;
padding: 0.1rem 0.5rem;
border-radius: 10px;
font-weight: 600;
}
.player-remove {
margin-left: auto;
background: transparent;
border: 1px solid var(--color-secondary);
color: var(--color-muted);
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
font-weight: normal;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.player-remove:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: transparent;
}
.card-field { margin-bottom: 1rem; }
.card-field > label {
font-size: 0.8rem;
color: #aaa;
margin: 0 0 0.5rem 0;
display: block;
}
/* ── Check/radio items ── */
.check-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.check-group--two-col {
display: grid;
grid-template-columns: 1fr 1fr;
}
.check-item {
display: inline-flex;
align-items: flex-start;
gap: 0.45rem;
background: var(--color-secondary);
border: 1px solid transparent;
border-radius: 6px;
padding: 0.4rem 0.7rem;
cursor: pointer;
transition: border-color 0.15s;
user-select: none;
}
.check-item.is-checked { border-color: var(--color-primary); }
.check-item input {
accent-color: var(--color-primary);
width: auto;
margin-top: 0.15rem;
cursor: pointer;
flex-shrink: 0;
}
.check-item-label {
font-size: 0.88rem;
color: var(--color-text);
line-height: 1.3;
}
.check-item-desc {
display: block;
font-size: 0.72rem;
color: var(--color-muted);
margin-top: 0.1rem;
}
.add-player-btn {
width: 100%;
background: transparent;
border: 1px dashed var(--color-secondary);
color: var(--color-muted);
padding: 0.75rem;
border-radius: 10px;
font-size: 0.9rem;
font-weight: normal;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
margin-bottom: 2rem;
}
.add-player-btn:hover {
border-color: var(--color-primary);
color: var(--color-text);
background: transparent;
}
.field-error {
font-size: 0.78rem;
color: var(--color-primary);
margin-top: 0.3rem;
display: none;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content session-setup">
<h1>BDSM Game</h1>
<p style="margin-bottom:2rem;">Schritt 2 von 4 Mitspieler</p>
<div class="setup-section">
<h2>Mitspieler</h2>
<div id="playersContainer"></div>
<button class="add-player-btn" onclick="addPlayer()">+ Spieler hinzufügen</button>
</div>
<div class="message" id="message"></div>
<div style="display:flex; gap:1rem;">
<button style="flex:1;" class="secondary" onclick="window.location.href='/sessionbdsm.html'">← Zurück</button>
<button style="flex:2;" onclick="weiter()">Weiter</button>
</div>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script>
// Redirect back if settings are missing
if (!sessionStorage.getItem('bdsm-session-settings')) {
window.location.replace('/sessionbdsm.html');
}
const GESCHLECHTER = [
{ value: 'MAENNLICH', label: 'Männlich' },
{ value: 'WEIBLICH', label: 'Weiblich' },
{ value: 'DIVERS', label: 'Divers' },
];
const ROLLEN = [
{ value: 'AUFGABE_AKTIV', label: 'Aufgabe Aktiv' },
{ value: 'AUFGABE_PASSIV', label: 'Aufgabe Passiv' },
{ value: 'BESTRAFUNG_AKTIV', label: 'Bestrafung Aktiv' },
{ value: 'BESTRAFUNG_PASSIV', label: 'Bestrafung Passiv' },
];
const WERKZEUGE_DEFAULTS = {
MAENNLICH: ['MUND', 'PENIS', 'ANUS', 'UMSCHNALLDILDO'],
WEIBLICH: ['MUND', 'VAGINA', 'ANUS', 'UMSCHNALLDILDO'],
DIVERS: ['MUND', 'ANUS', 'UMSCHNALLDILDO'],
};
const WERKZEUGE = [
{ value: 'MUND', label: 'Mund', desc: 'Gewillt den Mund einzusetzen' },
{ value: 'VAGINA', label: 'Vagina', desc: 'Verfügt über eine Vagina und setzt sie ein' },
{ value: 'PENIS', label: 'Penis', desc: 'Verfügt über einen Penis und setzt ihn ein' },
{ value: 'ANUS', label: 'Anus', desc: 'Gewillt den Anus einzusetzen' },
{ value: 'UMSCHNALLDILDO', label: 'Umschnall-Dildo', desc: 'Verfügt über einen Umschnall-Dildo' },
];
let playerSeq = 0;
let playerIds = [];
function buildCheckItems(name, items, type) {
return items.map(({ value, label, desc }) => `
<label class="check-item">
<input type="${type}" name="${name}" value="${value}">
<span>
<span class="check-item-label">${label}</span>
${desc ? `<span class="check-item-desc">${desc}</span>` : ''}
</span>
</label>`).join('');
}
function createCardHtml(id, prefillName, isSelf) {
const badge = isSelf ? '<span class="player-badge">Du</span>' : '';
const num = playerIds.indexOf(id) + 1;
return `
<div class="player-card" id="player-${id}">
<div class="player-card-header">
<span class="player-title">Spieler ${num}</span>
${badge}
<button class="player-remove" onclick="removePlayer(${id})">✕ Entfernen</button>
</div>
<div class="card-field">
<label>Name</label>
<input type="text" id="p${id}-name" value="${prefillName}" placeholder="Name" autocomplete="off">
<div class="field-error" id="p${id}-name-err">Bitte Namen eingeben.</div>
</div>
<div class="card-field">
<label>Geschlecht</label>
<div class="check-group">${buildCheckItems('p' + id + '-geschlecht', GESCHLECHTER, 'radio')}</div>
<div class="field-error" id="p${id}-geschlecht-err">Bitte Geschlecht auswählen.</div>
</div>
<div class="card-field">
<label>Spielt mit</label>
<div class="check-group">${buildCheckItems('p' + id + '-spieltmit', GESCHLECHTER, 'checkbox')}</div>
<div class="field-error" id="p${id}-spieltmit-err">Bitte mindestens eine Option wählen.</div>
<div class="field-error" id="p${id}-partner-err">Kein Mitspieler mit passendem Geschlecht vorhanden.</div>
</div>
<div class="card-field">
<label>Rollen</label>
<div class="check-group">${buildCheckItems('p' + id + '-rollen', ROLLEN, 'checkbox')}</div>
<div class="field-error" id="p${id}-rollen-err">Bitte mindestens eine Rolle wählen.</div>
</div>
<div class="card-field">
<label>Verfügbar</label>
<div class="check-group check-group--two-col">${buildCheckItems('p' + id + '-werkzeuge', WERKZEUGE, 'checkbox')}</div>
<div class="field-error" id="p${id}-werkzeuge-err">Bitte mindestens ein Werkzeug wählen.</div>
</div>
</div>`;
}
function addPlayer(prefillName = '', isSelf = false) {
playerSeq++;
const id = playerSeq;
playerIds.push(id);
document.getElementById('playersContainer')
.insertAdjacentHTML('beforeend', createCardHtml(id, prefillName, isSelf));
refreshRemoveButtons();
}
function removePlayer(id) {
document.getElementById('player-' + id)?.remove();
playerIds = playerIds.filter(x => x !== id);
refreshPlayerTitles();
refreshRemoveButtons();
}
function refreshPlayerTitles() {
playerIds.forEach((id, idx) => {
const el = document.querySelector(`#player-${id} .player-title`);
if (el) el.textContent = 'Spieler ' + (idx + 1);
});
}
function refreshRemoveButtons() {
const canRemove = playerIds.length > 2;
playerIds.forEach(id => {
const btn = document.querySelector(`#player-${id} .player-remove`);
if (btn) btn.style.display = canRemove ? '' : 'none';
});
}
document.addEventListener('change', e => {
const input = e.target;
if (input.type !== 'checkbox' && input.type !== 'radio') return;
if (input.type === 'radio') {
document.querySelectorAll(`input[name="${input.name}"]`).forEach(r => {
r.closest('.check-item')?.classList.toggle('is-checked', r.checked);
});
// Werkzeuge vorauswählen wenn Geschlecht gewählt wird
if (input.checked && input.name.endsWith('-geschlecht')) {
const prefix = input.name.slice(0, -'-geschlecht'.length);
const defaults = WERKZEUGE_DEFAULTS[input.value] || [];
document.querySelectorAll(`input[name="${prefix}-werkzeuge"]`).forEach(cb => {
cb.checked = defaults.includes(cb.value);
cb.closest('.check-item')?.classList.toggle('is-checked', cb.checked);
});
}
} else {
input.closest('.check-item')?.classList.toggle('is-checked', input.checked);
}
});
function getChecked(name) {
return [...document.querySelectorAll(`input[name="${name}"]:checked`)].map(el => el.value);
}
function setFieldError(id, show) {
const el = document.getElementById(id);
if (el) el.style.display = show ? 'block' : 'none';
}
const ROLE_LABELS = {
AUFGABE_AKTIV: 'Aufgabe Aktiv',
AUFGABE_PASSIV: 'Aufgabe Passiv',
BESTRAFUNG_AKTIV: 'Bestrafung Aktiv',
BESTRAFUNG_PASSIV: 'Bestrafung Passiv',
};
function weiter() {
hideMessage();
let valid = true;
// Reset cross-player errors
playerIds.forEach(id => setFieldError(`p${id}-partner-err`, false));
const mitspieler = playerIds.map(id => {
const name = document.getElementById(`p${id}-name`).value.trim();
const geschlecht = getChecked(`p${id}-geschlecht`);
const spieltMit = getChecked(`p${id}-spieltmit`);
const rollen = getChecked(`p${id}-rollen`);
const werkzeuge = getChecked(`p${id}-werkzeuge`);
setFieldError(`p${id}-name-err`, !name);
setFieldError(`p${id}-geschlecht-err`, geschlecht.length === 0);
setFieldError(`p${id}-spieltmit-err`, spieltMit.length === 0);
setFieldError(`p${id}-rollen-err`, rollen.length === 0);
setFieldError(`p${id}-werkzeuge-err`, werkzeuge.length === 0);
if (!name || geschlecht.length === 0 || spieltMit.length === 0 || rollen.length === 0 || werkzeuge.length === 0) {
valid = false;
}
return { name, geschlecht: geschlecht[0] || null, spieltMit, rollen, werkzeuge };
});
if (!valid) {
showMessage('Bitte alle Felder für jeden Spieler ausfüllen.', 'error');
return;
}
// Jede Rolle muss mindestens einmal vergeben sein
const allRoles = new Set(mitspieler.flatMap(p => p.rollen));
const missingRoles = Object.keys(ROLE_LABELS).filter(r => !allRoles.has(r));
if (missingRoles.length > 0) {
showMessage('Folgende Rollen müssen mindestens einmal vergeben sein: ' +
missingRoles.map(r => ROLE_LABELS[r]).join(', '), 'error');
return;
}
// Jeder Spieler braucht mindestens einen Mitspieler mit passendem Geschlecht
let partnerFehler = false;
mitspieler.forEach((player, i) => {
const andereGeschlechter = mitspieler
.filter((_, j) => j !== i)
.map(p => p.geschlecht);
const hatPartner = player.spieltMit.some(g => andereGeschlechter.includes(g));
if (!hatPartner) {
setFieldError(`p${playerIds[i]}-partner-err`, true);
partnerFehler = true;
}
});
if (partnerFehler) {
showMessage('Mindestens ein Spieler hat keinen kompatiblen Mitspieler.', 'error');
return;
}
const settings = JSON.parse(sessionStorage.getItem('bdsm-session-settings'));
sessionStorage.setItem('bdsm-session-setup', JSON.stringify({ settings, mitspieler }));
window.location.href = '/sessionbdsmtasks.html';
}
function showMessage(text, type) {
const el = document.getElementById('message');
el.textContent = text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
function restorePlayer(id, data) {
if (data.geschlecht) {
const radio = document.querySelector(`input[name="p${id}-geschlecht"][value="${data.geschlecht}"]`);
if (radio) { radio.checked = true; radio.closest('.check-item')?.classList.add('is-checked'); }
}
(data.spieltMit || []).forEach(val => {
const cb = document.querySelector(`input[name="p${id}-spieltmit"][value="${val}"]`);
if (cb) { cb.checked = true; cb.closest('.check-item')?.classList.add('is-checked'); }
});
(data.rollen || []).forEach(val => {
const cb = document.querySelector(`input[name="p${id}-rollen"][value="${val}"]`);
if (cb) { cb.checked = true; cb.closest('.check-item')?.classList.add('is-checked'); }
});
(data.werkzeuge || []).forEach(val => {
const cb = document.querySelector(`input[name="p${id}-werkzeuge"][value="${val}"]`);
if (cb) { cb.checked = true; cb.closest('.check-item')?.classList.add('is-checked'); }
});
}
// Init: gespeicherte Spieler wiederherstellen oder neu anlegen
const savedSetup = sessionStorage.getItem('bdsm-session-setup');
if (savedSetup) {
const { mitspieler } = JSON.parse(savedSetup);
mitspieler.forEach((p, i) => {
addPlayer(p.name, i === 0);
restorePlayer(playerIds[playerIds.length - 1], p);
});
} else {
fetch('/login/me')
.then(r => r.ok ? r.json() : null)
.then(user => {
addPlayer(user ? user.name : '', true);
addPlayer();
})
.catch(() => {
addPlayer('', true);
addPlayer();
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BDSM Game Aufgaben-Gruppen XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.session-setup { max-width: 700px; }
.setup-section { margin-bottom: 2.5rem; }
.setup-section h2 {
color: var(--color-primary);
font-size: 1rem;
font-weight: 600;
margin-bottom: 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.select-all-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.select-all-label input {
accent-color: var(--color-primary);
width: 14px;
height: 14px;
cursor: pointer;
flex-shrink: 0;
}
.gruppe-list { list-style: none; padding: 0; margin: 0; }
.gruppe-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.85rem;
border-radius: 8px;
background: var(--color-card);
border: 1px solid var(--color-secondary);
margin-bottom: 0.5rem;
cursor: pointer;
transition: border-color 0.15s;
user-select: none;
}
.gruppe-item.is-checked { border-color: var(--color-primary); }
.gruppe-item input {
accent-color: var(--color-primary);
flex-shrink: 0;
width: 14px;
height: 14px;
cursor: pointer;
}
.gruppe-item span { flex: 1; min-width: 0; }
.item-img {
width: 38px;
height: 38px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.gruppe-item-name {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
}
.gruppe-item-desc {
display: block;
font-size: 0.8rem;
color: var(--color-muted);
margin-top: 0.15rem;
}
.empty-hint {
color: var(--color-muted);
font-size: 0.875rem;
font-style: italic;
padding: 0.5rem 0;
}
</style>
</head>
<body class="app">
<div class="main">
<div class="content session-setup">
<h1>BDSM Game</h1>
<p style="margin-bottom:2rem;">Schritt 3 von 4 Aufgaben</p>
<div class="setup-section" id="sectionOwn">
<h2><label class="select-all-label">
<input type="checkbox" class="select-all-cb" data-list="listOwn">
Eigene Gruppen
</label></h2>
<ul class="gruppe-list" id="listOwn"></ul>
</div>
<div class="setup-section" id="sectionSubscribed">
<h2><label class="select-all-label">
<input type="checkbox" class="select-all-cb" data-list="listSubscribed">
Abonnierte Gruppen
</label></h2>
<ul class="gruppe-list" id="listSubscribed"></ul>
</div>
<div class="setup-section" id="sectionSystem">
<h2><label class="select-all-label">
<input type="checkbox" class="select-all-cb" data-list="listSystem">
System-Gruppen
</label></h2>
<ul class="gruppe-list" id="listSystem"></ul>
</div>
<div style="position:relative; margin-top:2rem;">
<div class="message" id="message" style="position:absolute; bottom:calc(100% + 0.5rem); left:0; right:0; margin:0;"></div>
<div style="display:flex; gap:1rem;">
<button style="flex:1;" class="secondary" onclick="window.location.href='/sessionbdsmplayers.html'">← Zurück</button>
<button style="flex:2;" onclick="weiter()">Weiter</button>
</div>
</div>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script>
if (!sessionStorage.getItem('bdsm-session-setup')) {
window.location.replace('/sessionbdsm.html');
}
const savedGruppen = new Set(JSON.parse(sessionStorage.getItem('bdsm-session-gruppen') || '[]'));
let warnungsAkzeptiert = false;
document.addEventListener('change', e => {
const cb = e.target;
if (cb.type !== 'checkbox') return;
if (cb.classList.contains('select-all-cb')) {
// Alle Gruppen in dieser Sektion (de-)selektieren
const list = document.getElementById(cb.dataset.list);
list.querySelectorAll('input[type="checkbox"]').forEach(itemCb => {
itemCb.checked = cb.checked;
itemCb.closest('.gruppe-item')?.classList.toggle('is-checked', cb.checked);
});
} else {
// Einzelne Gruppe: is-checked-Klasse anpassen und Alles-Haken aktualisieren
cb.closest('.gruppe-item')?.classList.toggle('is-checked', cb.checked);
updateSelectAll(cb.closest('.gruppe-list'));
}
warnungsAkzeptiert = false;
hideMessage();
});
function updateSelectAll(list) {
if (!list) return;
const itemCbs = [...list.querySelectorAll('input[type="checkbox"]')];
if (!itemCbs.length) return;
const section = list.closest('.setup-section');
const selectAllCb = section?.querySelector('.select-all-cb');
if (!selectAllCb) return;
const checkedCount = itemCbs.filter(cb => cb.checked).length;
selectAllCb.checked = checkedCount === itemCbs.length;
selectAllCb.indeterminate = checkedCount > 0 && checkedCount < itemCbs.length;
}
function renderList(containerId, gruppen) {
const ul = document.getElementById(containerId);
const section = ul.closest('.setup-section');
const selectAllWrap = section?.querySelector('.select-all-label');
if (!gruppen.length) {
ul.innerHTML = '<li class="empty-hint">Keine Gruppen vorhanden.</li>';
if (selectAllWrap) selectAllWrap.style.visibility = 'hidden';
return;
}
ul.innerHTML = gruppen.map(g => {
const checked = savedGruppen.has(g.gruppenId);
return `
<li>
<label class="gruppe-item${checked ? ' is-checked' : ''}">
<input type="checkbox" value="${g.gruppenId}"${checked ? ' checked' : ''}>
<span>
<span class="gruppe-item-name">${g.name}</span>
${g.beschreibung ? `<span class="gruppe-item-desc">${g.beschreibung}</span>` : ''}
</span>
${g.bild ? `<img class="item-img" src="data:image/png;base64,${g.bild}" alt="">` : ''}
</label>
</li>`;
}).join('');
updateSelectAll(ul);
}
const GESCHLECHT_LABEL = { WEIBLICH: 'Weiblich', DIVERS: 'Divers', MAENNLICH: 'Männlich' };
function validateContent(content, settings, mitspieler) {
const errors = [], warnings = [];
const aufgabenByLevel = {};
content.aufgaben.forEach(a => {
const l = a.level ?? 0;
aufgabenByLevel[l] = (aufgabenByLevel[l] || 0) + 1;
});
for (const [level, count] of Object.entries(aufgabenByLevel)) {
if (count < 5) errors.push(`Level ${level}: Nur ${count} Aufgabe(n) Minimum 5 erforderlich`);
else if (count < 10) warnings.push(`Level ${level}: Nur ${count} Aufgaben empfohlen ≥ 10`);
}
if (settings.wahrscheinlichkeitStrafe > 1) {
const strafenByLevel = {};
content.strafen.forEach(s => {
const l = s.level ?? 0;
strafenByLevel[l] = (strafenByLevel[l] || 0) + 1;
});
for (const level of Object.keys(aufgabenByLevel)) {
const count = strafenByLevel[level] || 0;
if (count < 1) errors.push(`Level ${level}: Keine Strafe vorhanden`);
else if (count < 2) warnings.push(`Level ${level}: Nur ${count} Strafe(n) empfohlen ≥ 2`);
}
}
if (settings.wahrscheinlichkeitSperre > 1) {
const count = content.sperren.length;
if (count < 1) errors.push('Keine Zeitstrafen vorhanden');
else if (count < 5) warnings.push(`Nur ${count} Zeitstrafe(n) empfohlen ≥ 5`);
}
const beteiligtGeschlecht = [...new Set((mitspieler || []).map(p => p.geschlecht).filter(Boolean))];
for (const g of beteiligtGeschlecht) {
const count = (content.finisher || []).filter(f => f.geschlecht === g).length;
if (count < 1) errors.push(`Kein Finisher für ${GESCHLECHT_LABEL[g] || g} vorhanden`);
}
return { errors, warnings };
}
function showValidation(errors, warnings, mitHinweis) {
const el = document.getElementById('message');
el.innerHTML = [
...errors.map(e => `<div>✕ ${e}</div>`),
...warnings.map(w => `<div>⚠ ${w}</div>`),
...(mitHinweis ? ['<div style="margin-top:0.5rem;font-style:italic;">Nochmals auf Weiter klicken um fortzufahren.</div>'] : []),
].join('');
el.className = `message ${errors.length ? 'error' : 'warning'}`;
el.style.display = 'block';
}
function showMessage(text, type) {
const el = document.getElementById('message');
const icon = type === 'error' ? '✕ ' : type === 'warning' ? '⚠ ' : '';
el.textContent = icon + text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
async function weiter() {
hideMessage();
const selected = [...document.querySelectorAll('.gruppe-list input[type="checkbox"]:checked')]
.map(cb => cb.value);
if (selected.length === 0) {
showMessage('Bitte mindestens eine Aufgaben-Gruppe auswählen.', 'error');
warnungsAkzeptiert = false;
return;
}
const btn = document.querySelector('button[onclick="weiter()"]');
btn.disabled = true;
const gruppen = await Promise.all(
selected.map(id => fetch(`/gruppe/${id}`).then(r => r.ok ? r.json() : null))
);
btn.disabled = false;
const content = { aufgaben: [], strafen: [], sperren: [], finisher: [] };
gruppen.filter(Boolean).forEach(g => {
content.aufgaben.push(...(g.aufgaben || []));
content.strafen.push(...(g.strafen || []));
content.sperren.push(...(g.sperren || []));
content.finisher.push(...(g.finisher || []));
});
const settings = JSON.parse(sessionStorage.getItem('bdsm-session-settings'));
const setup = JSON.parse(sessionStorage.getItem('bdsm-session-setup'));
const { errors, warnings } = validateContent(content, settings, setup?.mitspieler || []);
if (errors.length > 0) {
showValidation(errors, warnings, false);
warnungsAkzeptiert = false;
return;
}
if (warnings.length > 0 && !warnungsAkzeptiert) {
showValidation([], warnings, true);
warnungsAkzeptiert = true;
return;
}
sessionStorage.setItem('bdsm-session-gruppen', JSON.stringify(selected));
window.location.href = '/sessionbdsmtoys.html';
}
Promise.all([
fetch('/gruppe/list/user?page=0&size=500').then(r => r.ok ? r.json() : { content: [] }),
fetch('/abo/list?page=0&size=500').then(r => r.ok ? r.json() : { content: [] }),
fetch('/gruppe/list/system?page=0&size=500').then(r => r.ok ? r.json() : { content: [] }),
]).then(([own, abo, system]) => {
renderList('listOwn', own.content || []);
renderList('listSubscribed', abo.content || []);
renderList('listSystem', system.content || []);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BDSM Game Toys XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.session-setup { max-width: 700px; }
.setup-section { margin-bottom: 2.5rem; }
.setup-section h2 {
color: var(--color-primary);
font-size: 1rem;
font-weight: 600;
margin-bottom: 1.25rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.toy-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.85rem;
border-radius: 8px;
background: var(--color-card);
border: 1px solid var(--color-secondary);
margin-bottom: 0.5rem;
cursor: pointer;
transition: border-color 0.15s;
user-select: none;
}
.toy-item.is-checked { border-color: var(--color-primary); }
.toy-item input {
accent-color: var(--color-primary);
flex-shrink: 0;
width: 14px;
height: 14px;
cursor: pointer;
}
.toy-item span { flex: 1; min-width: 0; }
.item-img {
width: 38px;
height: 38px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.toy-item-name { font-size: 0.95rem; font-weight: 600; color: var(--color-text); }
.toy-item-desc { display: block; font-size: 0.8rem; color: var(--color-muted); margin-top: 0.15rem; }
.no-toys { color: var(--color-muted); font-size: 0.875rem; font-style: italic; }
</style>
</head>
<body class="app">
<div class="main">
<div class="content session-setup">
<h1>BDSM Game</h1>
<p style="margin-bottom:2rem;">Schritt 4 von 4 Toys</p>
<div class="setup-section">
<h2>Benötigte Toys</h2>
<p style="font-size:0.85rem; color:var(--color-muted); margin-bottom:1.25rem;">
Deaktiviere Toys, die nicht zur Verfügung stehen. Aufgaben, die diese benötigen, werden nicht gespielt.
</p>
<div id="toyList"></div>
</div>
<div style="position:relative; margin-top:2rem;">
<div class="message" id="message" style="position:absolute; bottom:calc(100% + 0.5rem); left:0; right:0; margin:0;"></div>
<div style="display:flex; gap:1rem;">
<button style="flex:1;" class="secondary" onclick="window.location.href='/sessionbdsmtasks.html'">← Zurück</button>
<button style="flex:2;" onclick="spielStarten()">Spiel starten</button>
</div>
</div>
</div>
</div>
<script src="/js/sidebar.js"></script>
<script>
const savedGruppen = JSON.parse(sessionStorage.getItem('bdsm-session-gruppen') || 'null');
if (!savedGruppen) window.location.replace('/sessionbdsm.html');
// Previously saved toy selection (when navigating back from game page)
const savedToysRaw = sessionStorage.getItem('bdsm-session-toys');
const savedToyIds = savedToysRaw
? new Set(JSON.parse(savedToysRaw).map(t => t.toyId))
: null; // null = first visit → default all checked
// All content collected from selected groups
const allContent = { aufgaben: [], strafen: [], sperren: [], finisher: [] };
let warnungsAkzeptiert = false;
document.addEventListener('change', e => {
const cb = e.target;
if (cb.type !== 'checkbox') return;
cb.closest('.toy-item')?.classList.toggle('is-checked', cb.checked);
warnungsAkzeptiert = false;
hideMessage();
});
const GESCHLECHT_LABEL = { WEIBLICH: 'Weiblich', DIVERS: 'Divers', MAENNLICH: 'Männlich' };
function validateContent(content, settings, mitspieler) {
const errors = [], warnings = [];
const aufgabenByLevel = {};
content.aufgaben.forEach(a => {
const l = a.level ?? 0;
aufgabenByLevel[l] = (aufgabenByLevel[l] || 0) + 1;
});
for (const [level, count] of Object.entries(aufgabenByLevel)) {
if (count < 5) errors.push(`Level ${level}: Nur ${count} Aufgabe(n) Minimum 5 erforderlich`);
else if (count < 10) warnings.push(`Level ${level}: Nur ${count} Aufgaben empfohlen ≥ 10`);
}
if (settings.wahrscheinlichkeitStrafe > 1) {
const strafenByLevel = {};
content.strafen.forEach(s => {
const l = s.level ?? 0;
strafenByLevel[l] = (strafenByLevel[l] || 0) + 1;
});
for (const level of Object.keys(aufgabenByLevel)) {
const count = strafenByLevel[level] || 0;
if (count < 1) errors.push(`Level ${level}: Keine Strafe vorhanden`);
else if (count < 2) warnings.push(`Level ${level}: Nur ${count} Strafe(n) empfohlen ≥ 2`);
}
}
if (settings.wahrscheinlichkeitSperre > 1) {
const count = content.sperren.length;
if (count < 1) errors.push('Keine Zeitstrafen vorhanden');
else if (count < 5) warnings.push(`Nur ${count} Zeitstrafe(n) empfohlen ≥ 5`);
}
const beteiligtGeschlecht = [...new Set((mitspieler || []).map(p => p.geschlecht).filter(Boolean))];
for (const g of beteiligtGeschlecht) {
const count = (content.finisher || []).filter(f => f.geschlecht === g).length;
if (count < 1) errors.push(`Kein Finisher für ${GESCHLECHT_LABEL[g] || g} vorhanden`);
}
return { errors, warnings };
}
function showValidation(errors, warnings, mitHinweis) {
const el = document.getElementById('message');
el.innerHTML = [
...errors.map(e => `<div>✕ ${e}</div>`),
...warnings.map(w => `<div>⚠ ${w}</div>`),
...(mitHinweis ? ['<div style="margin-top:0.5rem;font-style:italic;">Nochmals auf Spiel starten klicken um fortzufahren.</div>'] : []),
].join('');
el.className = `message ${errors.length ? 'error' : 'warning'}`;
el.style.display = 'block';
}
function showMessage(text, type) {
const el = document.getElementById('message');
const icon = type === 'error' ? '✕ ' : type === 'warning' ? '⚠ ' : '';
el.textContent = icon + text;
el.className = `message ${type}`;
el.style.display = 'block';
}
function hideMessage() {
document.getElementById('message').style.display = 'none';
}
function renderToys(toys) {
const container = document.getElementById('toyList');
if (!toys.length) {
container.innerHTML = '<p class="no-toys">Keine Toys erforderlich alle Aufgaben können gespielt werden.</p>';
return;
}
container.innerHTML = toys.map(toy => {
const checked = savedToyIds === null || savedToyIds.has(toy.toyId);
return `
<label class="toy-item${checked ? ' is-checked' : ''}">
<input type="checkbox" value="${toy.toyId}"${checked ? ' checked' : ''}>
<span>
<span class="toy-item-name">${toy.name}</span>
${toy.beschreibung ? `<span class="toy-item-desc">${toy.beschreibung}</span>` : ''}
</span>
${toy.bild ? `<img class="item-img" src="data:image/png;base64,${toy.bild}" alt="">` : ''}
</label>`;
}).join('');
}
async function spielStarten() {
const checkedToyIds = new Set(
[...document.querySelectorAll('#toyList input[type="checkbox"]:checked')].map(cb => cb.value)
);
// Collect full toy objects for the checked ones (for name display on overview)
const toyMap = new Map();
[...allContent.aufgaben, ...allContent.strafen, ...allContent.sperren, ...allContent.finisher].forEach(item => {
(item.benoetigteToys || []).forEach(t => toyMap.set(t.toyId, t));
});
const checkedToys = [...checkedToyIds].map(id => toyMap.get(id)).filter(Boolean);
sessionStorage.setItem('bdsm-session-toys', JSON.stringify(checkedToys));
function toyOk(item) {
const toys = item.benoetigteToys || [];
return toys.length === 0 || toys.every(t => checkedToyIds.has(t.toyId));
}
const gameContent = {
aufgaben: allContent.aufgaben.filter(toyOk),
strafen: allContent.strafen.filter(toyOk),
sperren: allContent.sperren.filter(toyOk),
finisher: allContent.finisher.filter(toyOk),
};
const settings = JSON.parse(sessionStorage.getItem('bdsm-session-settings'));
const setup = JSON.parse(sessionStorage.getItem('bdsm-session-setup'));
const { errors, warnings } = validateContent(gameContent, settings, setup?.mitspieler || []);
if (errors.length > 0) {
showValidation(errors, warnings, false);
warnungsAkzeptiert = false;
return;
}
if (warnings.length > 0 && !warnungsAkzeptiert) {
showValidation([], warnings, true);
warnungsAkzeptiert = true;
return;
}
sessionStorage.setItem('bdsm-session-game', JSON.stringify(gameContent));
const btn = document.querySelector('button[onclick="spielStarten()"]');
btn.disabled = true;
try {
// 1. Session anlegen
const sessionRes = await fetch('/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wahrscheinlichkeitStrafe: settings.wahrscheinlichkeitStrafe,
wahrscheinlichkeitSperre: settings.wahrscheinlichkeitSperre,
aufgabenProLevel: settings.aufgabenProLevel,
zeitfaktorZeitstrafen: settings.zeitfaktorZeitstrafen,
}),
});
if (!sessionRes.ok) throw new Error('Session konnte nicht angelegt werden.');
const location = sessionRes.headers.get('Location');
const sessionId = location.split('/').pop();
// 2. Mitspieler hinzufügen
const setup = JSON.parse(sessionStorage.getItem('bdsm-session-setup'));
for (const p of setup.mitspieler) {
const res = await fetch(`/session/${sessionId}/mitspieler`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: p.name,
geschlecht: p.geschlecht,
spieltMit: p.spieltMit,
rollen: p.rollen,
verfuegbareWerkzeuge: p.werkzeuge,
}),
});
if (!res.ok) throw new Error(`Mitspieler "${p.name}" konnte nicht hinzugefügt werden.`);
}
// 3. Aufgaben setzen
const aufgabenRes = await fetch(`/session/${sessionId}/aufgaben`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(gameContent),
});
if (!aufgabenRes.ok) throw new Error('Aufgaben konnten nicht gespeichert werden.');
sessionStorage.setItem('bdsm-session-id', sessionId);
window.location.href = '/sessionbdsmingame.html';
} catch (e) {
showMessage(e.message, 'error');
btn.disabled = false;
}
}
// Load all selected groups, collect content and unique toys
Promise.all(
savedGruppen.map(id => fetch(`/gruppe/${id}`).then(r => r.ok ? r.json() : null))
).then(gruppen => {
gruppen.filter(Boolean).forEach(g => {
allContent.aufgaben.push(...(g.aufgaben || []));
allContent.strafen.push(...(g.strafen || []));
allContent.sperren.push(...(g.sperren || []));
allContent.finisher.push(...(g.finisher || []));
});
const toyMap = new Map();
[...allContent.aufgaben, ...allContent.strafen, ...allContent.sperren, ...allContent.finisher].forEach(item => {
(item.benoetigteToys || []).forEach(t => {
if (!toyMap.has(t.toyId)) toyMap.set(t.toyId, t);
});
});
renderToys([...toyMap.values()].sort((a, b) => a.name.localeCompare(b.name)));
});
</script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chastity Game Neue Session XXX The Game</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
</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>
</div>
</div>
<script src="/js/sidebar.js"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vanilla Game Neue Session 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">
<h1>Vanilla Game Neue Session</h1>
<p>Session-Setup für das Vanilla Game folgt hier.</p>
</div>
</div>
<script src="/js/sidebar.js"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More